From 4c28b01268ef3c9cee4296a9125c6c27d02790c8 Mon Sep 17 00:00:00 2001 From: Ronan Fleming Date: Mon, 29 Apr 2024 12:44:29 +0100 Subject: [PATCH 1/8] extremePools updated lrs interface --- docs/source/installation/compatMatrix.rst | 8 +- external/visualization/MatGPT | 2 +- initCobraToolbox.m | 29 +- .../topology/extremeRays/lrs/extremePools.m | 239 ++++++++---- .../extremeRays/lrs/lrsInterface/README.txt | 8 +- .../extremeRays/lrs/lrsInterface/lrsReadRay.m | 7 + .../lrs/lrsInterface/lrsWriteHalfspace.m | 23 +- src/analysis/topology/integerizeS.m | 121 ++++-- .../entropicFBA/entropicFluxBalanceAnalysis.m | 46 ++- .../entropicFBA/processConcConstraints.m | 356 +++++++++++------- .../entropicFBA/processFluxConstraints.m | 130 ++++--- src/base/solvers/entropicFBA/solveCobraEP.m | 53 ++- .../solvers/getSetSolver/changeCobraSolver.m | 11 +- .../getSetSolver/getCobraSolverVersion.m | 3 + src/base/solvers/isCompatible.m | 15 +- src/base/solvers/msk/parseMskResult.m | 119 +++--- src/base/solvers/param/getCobraSolverParams.m | 23 +- .../getCobraSolverParamsOptionsForType.m | 13 +- .../solvers/param/parseSolverParameters.m | 55 +-- src/base/solvers/solveCobraLP.m | 11 +- .../checkStoichiometricConsistency.m | 24 +- .../analysis/testTopology/model.ext | 16 + .../analysis/testTopology/model.ine | 12 + .../analysis/testTopology/testLrsInterface.m | 3 + tutorials | 2 +- 25 files changed, 848 insertions(+), 481 deletions(-) create mode 100644 test/verifiedTests/analysis/testTopology/model.ext create mode 100644 test/verifiedTests/analysis/testTopology/model.ine diff --git a/docs/source/installation/compatMatrix.rst b/docs/source/installation/compatMatrix.rst index afb11a9c5b..0282cc2ee9 100644 --- a/docs/source/installation/compatMatrix.rst +++ b/docs/source/installation/compatMatrix.rst @@ -5,7 +5,7 @@ Linux Ubuntu ~~~~~~~~~~~~ +-------------------+--------------------+--------------------+--------------------+--------------------+ -| SolverName | R2021b | R2021a | R2020b | R2020a | +| SolverName | R2023b | R2021b | R2020b | R2020a | +===================+====================+====================+====================+====================+ | IBM CPLEX 20.10 | |x| | |x| | |x| | |x| | +-------------------+--------------------+--------------------+--------------------+--------------------+ @@ -17,7 +17,7 @@ Linux Ubuntu +-------------------+--------------------+--------------------+--------------------+--------------------+ | TOMLAB CPLEX 8.6 | |white_check_mark| | |white_check_mark| | |white_check_mark| | |white_check_mark| | +-------------------+--------------------+--------------------+--------------------+--------------------+ -| MOSEK 9.2 | |white_check_mark| | |white_check_mark| | |white_check_mark| | |white_check_mark| | +| MOSEK 10.1 | |white_check_mark| | |white_check_mark| | |white_check_mark| | |white_check_mark| | +-------------------+--------------------+--------------------+--------------------+--------------------+ | GLPK | |white_check_mark| | |white_check_mark| | |white_check_mark| | |white_check_mark| | +-------------------+--------------------+--------------------+--------------------+--------------------+ @@ -42,7 +42,7 @@ macOS 10.13+ +-------------------+--------------------+--------------------+--------------------+--------------------+ | TOMLAB CPLEX 8.6 | |white_check_mark| | |white_check_mark| | |white_check_mark| | |white_check_mark| | +-------------------+--------------------+--------------------+--------------------+--------------------+ -| MOSEK 9.2 | |white_check_mark| | |white_check_mark| | |white_check_mark| | |white_check_mark| | +| MOSEK 10.1 | |white_check_mark| | |white_check_mark| | |white_check_mark| | |white_check_mark| | +-------------------+--------------------+--------------------+--------------------+--------------------+ | GLPK | |white_check_mark| | |white_check_mark| | |white_check_mark| | |white_check_mark| | +-------------------+--------------------+--------------------+--------------------+--------------------+ @@ -68,7 +68,7 @@ Windows 10 +-------------------+--------------------+--------------------+--------------------+--------------------+ | TOMLAB CPLEX 8.6 | |white_check_mark| | |white_check_mark| | |white_check_mark| | |white_check_mark| | +-------------------+--------------------+--------------------+--------------------+--------------------+ -| MOSEK 9.2 | |white_check_mark| | |white_check_mark| | |white_check_mark| | |white_check_mark| | +| MOSEK 10.1 | |white_check_mark| | |white_check_mark| | |white_check_mark| | |white_check_mark| | +-------------------+--------------------+--------------------+--------------------+--------------------+ | GLPK | |white_check_mark| | |white_check_mark| | |white_check_mark| | |white_check_mark| | +-------------------+--------------------+--------------------+--------------------+--------------------+ diff --git a/external/visualization/MatGPT b/external/visualization/MatGPT index 61dea6bdba..dde437afc8 160000 --- a/external/visualization/MatGPT +++ b/external/visualization/MatGPT @@ -1 +1 @@ -Subproject commit 61dea6bdba984d663422e8a25977193d7a07ebd8 +Subproject commit dde437afc8995494e65a3e3cdddb2212820dd59e diff --git a/initCobraToolbox.m b/initCobraToolbox.m index ed562608e2..5d57b669d5 100644 --- a/initCobraToolbox.m +++ b/initCobraToolbox.m @@ -31,6 +31,7 @@ function initCobraToolbox(updateToolbox) global CBT_LP_SOLVER; global CBT_QP_SOLVER; global CBT_EP_SOLVER; +global CBT_CLP_SOLVER; global CBT_MILP_SOLVER; global CBT_MIQP_SOLVER; global CBT_NLP_SOLVER; @@ -358,6 +359,9 @@ function initCobraToolbox(updateToolbox) % save the userpath originalUserPath = path; +% requires the solver compatibility to be re-read at each initialisation +clear isCompatible + %These default tolerances are based on the default values for the Gurobi LP %solver. Do not change them without first consulting with other developers. %https://www.gurobi.com/documentation/9.0/refman/parameters.html @@ -400,7 +404,7 @@ function initCobraToolbox(updateToolbox) end % define categories of solvers: LP, MILP, QP, MIQP, NLP -OPT_PROB_TYPES = {'LP', 'MILP', 'QP', 'MIQP', 'NLP','EP'}; +OPT_PROB_TYPES = {'LP', 'MILP', 'QP', 'MIQP', 'NLP','EP','CLP'}; %Define a set of "use first" solvers, other supported solvers will also be added to the struct. %This allows to assign them in any order but keep the most commonly used ones on top of the struct. @@ -413,7 +417,7 @@ function initCobraToolbox(updateToolbox) % active support - supported solvers SOLVERS.gurobi.type = {'LP', 'MILP', 'QP', 'MIQP'}; -SOLVERS.mosek.type = {'LP', 'QP','EP'}; +SOLVERS.mosek.type = {'LP', 'QP','EP','CLP'}; SOLVERS.glpk.type = {'LP', 'MILP'}; SOLVERS.pdco.type = {'LP', 'QP','EP'}; SOLVERS.quadMinos.type = {'LP'}; @@ -470,6 +474,7 @@ function initCobraToolbox(updateToolbox) catSolverNames.MIQP = {}; catSolverNames.NLP = {}; catSolverNames.EP = {}; +catSolverNames.CLP = {}; for i = 1:length(supportedSolversNames) SOLVERS.(supportedSolversNames{i}).installed = false; SOLVERS.(supportedSolversNames{i}).working = false; @@ -511,6 +516,7 @@ function initCobraToolbox(updateToolbox) changeCobraSolver('glpk', 'LP', 0); changeCobraSolver('pdco', 'QP', 0); changeCobraSolver('mosek', 'EP', 0); + changeCobraSolver('mosek', 'CLP', 0); changeCobraSolver('matlab', 'NLP', 0); for k = 1:length(OPT_PROB_TYPES) varName = horzcat(['CBT_', OPT_PROB_TYPES{k}, '_SOLVER']); @@ -526,8 +532,8 @@ function initCobraToolbox(updateToolbox) types = SOLVERS.(supportedSolversNames{i}).type; catList{i} = SOLVERS.(supportedSolversNames{i}).categ; for j = 1:length(types) - if 0 %set to 1 to debug a new solver - if strcmp(supportedSolversNames{i},'mosek') && strcmp(types{j},'EP') + if 1 %set to 1 to debug a new solver + if strcmp(supportedSolversNames{i},'mosek') && strcmp(types{j},'CLP') pause(0.1) end end @@ -546,6 +552,9 @@ function initCobraToolbox(updateToolbox) if strcmp(supportedSolversNames{i},'mosek') && strcmp(types{j},'EP') changeCobraSolver(supportedSolversNames{i}, types{j}, 0); end + if strcmp(supportedSolversNames{i},'mosek') && strcmp(types{j},'CLP') + changeCobraSolver(supportedSolversNames{i}, types{j}, 0); + end else solverStatus(i, k + 1) = 0; end @@ -605,21 +614,21 @@ function initCobraToolbox(updateToolbox) % print out a summary table if ENV_VARS.printLevel - colFormat = '\t%-12s \t%-13s \t%5s \t%5s \t%5s \t%5s \t%5s \t%5s\n'; + colFormat = '\t%-12s \t%-13s \t%5s \t%5s \t%5s \t%5s \t%5s \t%5s \t%5s\n'; sep = '\t------------------------------------------------------------------------------\n'; fprintf('\n > Summary of available solvers and solver interfaces\n\n'); if ispc - topLineFormat = '\t\t\t\t\tSupport %5s \t%5s \t%5s \t%5s \t%5s \t%5s\n'; + topLineFormat = '\t\t\t\t\tSupport %5s \t%5s \t%5s \t%5s \t%5s \t%5s \t%5s\n'; else - topLineFormat = '\t\t\tSupport \t%5s \t%5s \t%5s \t%5s \t%5s \t%5s\n'; + topLineFormat = '\t\t\tSupport \t%5s \t%5s \t%5s \t%5s \t%5s \t%5s \t%5s\n'; end - fprintf(topLineFormat, OPT_PROB_TYPES{1}, OPT_PROB_TYPES{2}, OPT_PROB_TYPES{3}, OPT_PROB_TYPES{4}, OPT_PROB_TYPES{5}, OPT_PROB_TYPES{6}) + fprintf(topLineFormat, OPT_PROB_TYPES{1}, OPT_PROB_TYPES{2}, OPT_PROB_TYPES{3}, OPT_PROB_TYPES{4}, OPT_PROB_TYPES{5}, OPT_PROB_TYPES{6}, OPT_PROB_TYPES{7}) fprintf(sep); for i = 1:length(catList)-2 - fprintf(colFormat, rowNames{i}, catList{i}, statusTable{1}{i}, statusTable{2}{i}, statusTable{3}{i}, statusTable{4}{i}, statusTable{5}{i}, statusTable{6}{i}) + fprintf(colFormat, rowNames{i}, catList{i}, statusTable{1}{i}, statusTable{2}{i}, statusTable{3}{i}, statusTable{4}{i}, statusTable{5}{i}, statusTable{6}{i}, statusTable{7}{i}) end fprintf(sep); - fprintf(colFormat, rowNames{end}, catList{end}, statusTable{1}{end}, statusTable{2}{end}, statusTable{3}{end}, statusTable{4}{end}, statusTable{5}{end}, statusTable{6}{end}) + fprintf(colFormat, rowNames{end}, catList{end}, statusTable{1}{end}, statusTable{2}{end}, statusTable{3}{end}, statusTable{4}{end}, statusTable{5}{end}, statusTable{6}{end}, statusTable{7}{end}) fprintf('\n + Legend: - = not applicable, 0 = solver not compatible or not installed, 1 = solver installed.\n\n\n') end diff --git a/src/analysis/topology/extremeRays/lrs/extremePools.m b/src/analysis/topology/extremeRays/lrs/extremePools.m index d14bbacb6c..12ad72f9c5 100644 --- a/src/analysis/topology/extremeRays/lrs/extremePools.m +++ b/src/analysis/topology/extremeRays/lrs/extremePools.m @@ -1,102 +1,195 @@ -function [P, V, A] = extremePools(model, positivity, inequality) +function [P, vertexBool, N] = extremePools(model, param) % Calculates the extreme pools of a stoichiometric model using the vertex / facet enumeration package +% such that % % INPUT: -% model: structure with: +% model.S - `m x (n + k)` Stoichiometric matrix +% OPTIONAL INPUTS: +% model.SConsistentRxnBool: n x 1 boolean indicating stoichiometrically consistent metabolites +% model.SIntRxnBool - Boolean of reactions heuristically though to be mass balanced. +% model.SIntMetBool - Boolean of metabolites heuristically though to be involved in mass balanced reactions. % -% * .S - `m x n` Stoichiometric matrix with integer coefficients. If no -% other inputs are specified it is assumed that all reactions are -% reversible and `S.v = 0` -% * .SIntRxnBool - `n x 1` boolean vector with 1 for internal reactions -% * .description - description -% -% OPTIONAL INPUT: % positivity: {0, (1)} if `positivity == 1`, then positive orthant base % inequality: {(0), 1} if `inequality == 1`, then use two inequalities rather than a single equaltiy % -% .. Author: - lrs by David Avis, McGill University +% OUTPUT: +% P: p x m matrix of non-negative entries such that P*N = 0. +% vertexBool n x 1 Boolean vector indicating which columns of P are vertices +% N: m x n stoichiometric matrix used such that P*N. +% +% Author(s) Ronan Fleming + [nMet, nRxn] = size(model.S); -if isfield(model, 'SIntRxnBool') - A = model.S(:, model.SIntRxnBool)'; +if isfield(model,'SConsistentRxnBool') + N = model.S(:, model.SConsistentRxnBool)'; else - A = model.S'; + %heuristically identify exchange reactions and metabolites exclusively + %involved in exchange reactions + if ~isfield(model,'SIntRxnBool') || ~isfield(model,'SIntMetBool') + if isfield(model,'mets') + %attempts to finds the reactions in the model which export/import from the model + %boundary i.e. mass unbalanced reactions + %e.g. Exchange reactions + % Demand reactions + % Sink reactions + model = findSExRxnInd(model,[],printLevel-1); + else + model.SIntMetBool=true(size(model.S,1),1); + model.SIntRxnBool=true(size(model.S,2),1); + end + else + if length(model.SIntMetBool)~=size(model.S,1) || length(model.SIntRxnBool)~=size(model.S,2) + model = findSExRxnInd(model,[],printLevel-1); + end + end + if isfield(model, 'SIntRxnBool') + N = model.S(:, model.SIntRxnBool)'; + else + N = model.S'; + end end -if nnz(A - round(A)) +if nnz(N - round(N)) figure - spy(A - round(A)) + spy(N - round(N)) title('S-round(S)') error('Stoichiometric coefficients must be all integers') end -a = zeros(size(A, 1),1); - -if isfield(model, 'description') - filename = model.description; -else - filename = 'model'; +try + [rankA, p, q] = getRankLUSOL(N, 1); + N=N(:,q(1:rankA)); + disp('extremePools: row reduction with getRankLUSOL worked.') +catch + disp('extremePools: row reduction with getRankLUSOL did not work, check installation of LUSOL. Proceeding without it.') end -if ~exist('positivity', 'var') - positivity = 1; -end -if ~exist('inequality', 'var') - inequality = 0; -end +if 0 + a = zeros(size(N, 1),1); -suffix = ''; -if positivity - suffix = [suffix 'pos_']; -else - suffix = [suffix 'neg_']; -end -if inequality - suffix = [suffix 'ineq']; -else - suffix = [suffix 'eq']; -end + if isfield(model, 'description') + filename = model.description; + else + filename = 'model'; + end -% no inequalities -D = []; -d = []; + if exist('positivity', 'var') + positivity = 1; + end + if ~exist('inequality', 'var') + inequality = 0; + end + suffix = ''; + if positivity + suffix = [suffix 'pos_']; + else + suffix = [suffix 'neg_']; + end + if inequality + suffix = [suffix 'ineq']; + else + suffix = [suffix 'eq']; + end -% no linear objective -f = []; + % no inequalities + D = []; + d = []; -% no shell script -sh = 0; + % no linear objective + f = []; + + % no shell script + sh = 0; + + % INPUT + % A matrix of linear equalities A*x=(a) + % D matrix of linear inequalities D*x>=(d) + % filename base name of output file + % + % OPTIONAL INPUT + % positivity {0,(1)} if positivity==1, then positive orthant base + % inequality {0,(1)} if inequality==1, then use two inequalities rather than a single equaltiy + % a boundry values for matrix of linear equalities A*x=a + % d boundry values for matrix of linear inequalities D*x>=d + % f linear objective for a linear optimization problem in rational arithmetic + % minimise f'*x + % subject to A*x=(a) + % D*x>=(d) + lrsInputHalfspace(N, D, filename, positivity, inequality, a, d, f, sh); + + % pause(eps) + [status, result] = system('which lrs'); + if ~isempty(result) + % call lrs and wait until extreme pathways have been calculated + systemCallText = ['lrs ' pwd filesep filename '_' suffix '.ine > ' pwd filesep filename '_' suffix '.ext']; + [status, result] = system(systemCallText); + else + error('lrs not installed or not in path') + end + + %old interface + [P, V] = lrsOutputReadRay([filename '_' suffix '.ext']); + P = P'; + N = N'; + if any(any(P * N ~= 0)) + warning('extreme pool not in nullspace of stoichiometric matrix') + end + + % Q = [P, V]; + % vertexBool = false(size(Q,2),1); + % vertexBool(size(P,2)+1:end,1)=1; -% INPUT -% A matrix of linear equalities A*x=(a) -% D matrix of linear inequalities D*x>=(d) -% filename base name of output file -% -% OPTIONAL INPUT -% positivity {0,(1)} if positivity==1, then positive orthant base -% inequality {0,(1)} if inequality==1, then use two inequalities rather than a single equaltiy -% a boundry values for matrix of linear equalities A*x=a -% d boundry values for matrix of linear inequalities D*x>=d -% f linear objective for a linear optimization problem in rational arithmetic -% minimise f'*x -% subject to A*x=(a) -% D*x>=(d) -lrsInputHalfspace(A, D, filename, positivity, inequality, a, d, f, sh); - -% pause(eps) -[status, result] = system('which lrs'); -if ~isempty(result) - % call lrs and wait until extreme pathways have been calculated - systemCallText = ['lrs ' pwd filesep filename '_' suffix '.ine > ' pwd filesep filename '_' suffix '.ext']; - [status, result] = system(systemCallText); else - error('lrs not installed or not in path') + if ~exist('param','var') + param = struct(); + end + if ~isfield(param,'positivity') + param.positivity = 1; + end + if ~isfield(param,'inequality') + param.inequality = 0; + end + if ~isfield(param,'debug') + param.debug = 0; + end + if isfield(model, 'description') + modelName = model.description; + else + modelName = 'model'; + end + + b = zeros(size(N, 2),1); + + csense(1:size(N, 2),1)='E'; + + % Output a file for lrs to convert an H-representation (half-space) of a + % polyhedron to a V-representation (vertex / ray) via vertex enumeration + fileNameOut = lrsWriteHalfspace(N', b, csense, modelName, param); + + %run lrs + param.facetEnumeration = 0;%vertex enumeration + fileNameOut = lrsRun(modelName, param); + + %read in vertex representation + [Q, vertexBool, fileNameOut] = lrsReadRay(modelName,param); + + %first vertices then rays + V = Q(:,vertexBool); + P = Q(:,~vertexBool)';%extreme rays + + if any(any(P*N ~= 0)) + warning('extreme pool not in left nullspace of stoichiometric matrix') + end end -[P, V] = lrsOutputReadRay([filename '_' suffix '.ext']); -P = P'; -A = A'; -if any(any(P * A ~= 0)) - warning('extreme pool not in nullspace of stoichiometric matrix') +if ~param.debug + % delete generated files + delete('*.ine'); + delete('*.ext'); + delete('*.sh'); + delete('*.time'); end + + diff --git a/src/analysis/topology/extremeRays/lrs/lrsInterface/README.txt b/src/analysis/topology/extremeRays/lrs/lrsInterface/README.txt index bcd903e2f5..80811155f7 100644 --- a/src/analysis/topology/extremeRays/lrs/lrsInterface/README.txt +++ b/src/analysis/topology/extremeRays/lrs/lrsInterface/README.txt @@ -1,4 +1,10 @@ Must have lrs installed to compute extreme pathways http://cgm.cs.mcgill.ca/~avis/C/lrs.html -lrslib Ver 4.2 is a self-contained ANSI C implementation as a callable library of the reverse search algorithm for vertex enumeration/convex hull problems and comes with a choice of three arithmetic packages. Input file formats are compatible with Komei Fukuda's cdd package. All computations are done exactly in either multiple precision or fixed integer arithmetic. Output is not stored in memory, so even problems with very large output sizes can sometimes be solved. The program is intended for Unix/Linux platforms, but will compile using gcc/cygwin on Windows. +lrslib Ver 4.2 is a self-contained ANSI C implementation as a callable library of the reverse search algorithm for +vertex enumeration/convex hull problems and comes with a choice of three arithmetic packages. Input file formats are + compatible with Komei Fukuda's cdd package. All computations are done exactly in either multiple precision or fixed + integer arithmetic. Output is not stored in memory, so even problems with very large output sizes can sometimes be solved. +The program is intended for Unix/Linux platforms, but will compile using gcc/cygwin on Windows. + +See testLrsInterface.m in the cobra toolbox to see how to use lrs from within the cobra toolbox. diff --git a/src/analysis/topology/extremeRays/lrs/lrsInterface/lrsReadRay.m b/src/analysis/topology/extremeRays/lrs/lrsInterface/lrsReadRay.m index 4796338195..944a666a21 100644 --- a/src/analysis/topology/extremeRays/lrs/lrsInterface/lrsReadRay.m +++ b/src/analysis/topology/extremeRays/lrs/lrsInterface/lrsReadRay.m @@ -10,6 +10,13 @@ % modelName string giving the prefix of the *.ext file that will contain the vertex representation % It is assumed the file is pwd/*.ine, otherwise provide the full path. % +% OPTIONAL INPUT: +% param: parameter structure with the following fields: +% *.positivity: if equals to 1, then positive orthant base +% *.inequality: if equals to 1, then represent as two inequalities rather than a single equality +% *.shellScript: if equals to 1, then lrs is run through a bash script +% *.redund if equals to 0, then remove redundant linear equalities +% % OUTPUT: % Q m x n integer matrix where each row is a variable and each column is a vertex or ray % vertexBool n x 1 Boolean vector indicating which columns of Q are vertices diff --git a/src/analysis/topology/extremeRays/lrs/lrsInterface/lrsWriteHalfspace.m b/src/analysis/topology/extremeRays/lrs/lrsInterface/lrsWriteHalfspace.m index b3f54ace5d..8104164b1d 100644 --- a/src/analysis/topology/extremeRays/lrs/lrsInterface/lrsWriteHalfspace.m +++ b/src/analysis/topology/extremeRays/lrs/lrsInterface/lrsWriteHalfspace.m @@ -4,18 +4,18 @@ % % USAGE: % -% lrsInputHalfspace(A, D, filename, positivity, inequality, a, d, f, sh) +% lrsWriteHalfspace(A, b, csense, modelName, param) % % INPUTS: -% A: matrix of linear equalities :math:`A x =(a)` -% D: matrix of linear inequalities :math:`D x \geq (d)` -% filename: base name of output file +% A: m x n matrix of linear constraints :math:`A x (csense) b` % % OPTIONAL INPUTS: -% positivity: {0, (1)} if positivity == 1, then positive orthant base -% inequality: {0, (1)} if inequality == 1, then use two inequalities rather than a single equaltiy -% a: boundary values for matrix of linear equalities :math:`A x = a` -% d: boundary values for matrix of linear inequalities :math:`D x \geq d` +% b: m x 1 rhs of linear constraints +% csense: m x 1 char with ('E'),'G' or 'L' +% modelName: name of the model to be used when generating filenames, 'model' by default +% param.positivity: {0, (1)} if positivity == 1, then positive orthant base +% param.inequality: {0, (1)} if inequality == 1, then use two inequalities rather than a single equaltiy + % f: linear objective for a linear optimization problem in rational arithmetic % % minimise :math:`f^T x`, @@ -46,10 +46,15 @@ param.sh = 0; end -if exist('f') ~= 1 +if exist('f','var') ~= 1 f = []; end +if length(csense)~=size(A,1) + error('csense must equal the number of rows of A') +end + + eqBool = csense == 'E'; leBool = csense == 'L'; geBool = csense == 'G'; diff --git a/src/analysis/topology/integerizeS.m b/src/analysis/topology/integerizeS.m index dd36656644..9d44d69158 100644 --- a/src/analysis/topology/integerizeS.m +++ b/src/analysis/topology/integerizeS.m @@ -1,4 +1,4 @@ -function [SInteger,G]=integerizeS(S,SIntRxnBool) +function [modelOut,g]=integerizeS(model,printLevel,internalRxnsOnly) %convert an S matrix with some non integer coefficients into an S matrix %with all integer coefficients %assumes that there are a maximum of six significant digits in the biomass @@ -8,48 +8,111 @@ % S stoichiometric matrix % %OPTIONAL INPUT -% SIntRxnBool Boolean of internal (mass balanced) reactions. -% If provided, only these reactions are integerised +% internalRxnsOnly (1),0. 1 = integerise internal reactions only % %OUTPUT -% SInteger stoichiometric matrix of integers -% G scaling matrix, SInteger=S*G; -% +% S stoichiometric matrix with internal reactions integers, unless internalRxnsOnly =0, whence all reactions will be integerised +% g scaling vector such that modelIn.S*diag(g)=modelOut.S; + +if ~exist('printLevel','var') + printLevel=1; +end -if ~exist('SIntRxnBool') - SIntRxnBool=true(size(S,2),1); +if ~exist('internalRxnsOnly','var') + internalRxnsOnly=1; end -Sabs=abs(S); +[nMet,nRxn]=size(model.S); + +if internalRxnsOnly + if isfield(model,'SConsistentRxnBool') + rxnToIntegerize=model.SConsistentRxnBool; + else + %heuristically identify exchange reactions and metabolites exclusively + %involved in exchange reactions + if isfield(model,'mets') + %attempts to finds the reactions in the model which export/import from the model + %boundary i.e. mass unbalanced reactionsisfield + %e.g. Exchange reactions + % Demand reactions + % Sink reactions + model = findSExRxnInd(model,[],printLevel-1); + else + model.SIntMetBool=true(size(model.S,1),1); + model.SIntRxnBool=true(size(model.S,2),1); + end + rxnToIntegerize=model.SIntRxnBool; + end +else + rxnToIntegerize=true(nRxn,1); +end + +Sabs=abs(model.S); Srem=Sabs-floor(Sabs); -[nMet,nRxn]=size(S); -G=speye(nRxn); +g=ones(nRxn,1); for n=1:nRxn - if SIntRxnBool(n) + if rxnToIntegerize(n) if max(Srem(:,n))~=0 - fprintf('%s\t','Reaction '); - fprintf('%s\t',int2str(n)); - if length(find(Srem(:,n)~=0))>6 - fprintf('%s\n',' a biomass reaction multiplied by 1e6'); - G(n,n)=1e6; - else - sigDigit=1; - while sigDigit>0 - Srem2=Srem(:,n)*10*sigDigit; - Srem2=Srem2-floor(Srem2); - if max(Srem2)~=0 - sigDigit=sigDigit+1; - else - G(n,n)=10*sigDigit; - fprintf('%s\n',['multiplied by ' int2str(10*sigDigit)]); - break; + if 0 + %old approach + fprintf('%s\t','Reaction '); + fprintf('%s\t',model.rxns{n}); + if length(find(Srem(:,n)~=0))>6 + fprintf('%s\n',' a biomass reaction multiplied by 1e6'); + g(n)=1e6; + else + sigDigit=1; + while sigDigit>0 + Srem2=Srem(:,n)*10*sigDigit; + Srem2=Srem2-floor(Srem2); + if max(Srem2)~=0 + sigDigit=sigDigit+1; + else + g(n)=10*sigDigit; + fprintf('%s\n',['multiplied by ' int2str(10*sigDigit)]); + break; + end end end + else + %new approach + coefficients=Sabs(Sabs(:,n)~=0,n); + % Initialize a variable to store denominators + denominators = zeros(size(coefficients)); + + % Loop through each number and get its denominator + for i = 1:length(coefficients) + [~, d] = rat(coefficients(i)); + denominators(i) = d; + end + + % Calculate the least common multiple of all denominators + k = lcm(denominators(1), denominators(2)); + for i = 3:length(denominators) + k = lcm(k, denominators(i)); + end + g(n)=k; end end end end -SInteger=fix(S*G); + +if printLevel>1 && any(g~=1) + fprintf('%s\n','Before reactions integerised: '); + printRxnFormula(model,model.rxns(g~=1)); + fprintf('\n') +end + +% fix(X) rounds the elements of X to the nearest integers +modelOut=model; +modelOut.S=fix(model.S*diag(g)); + +if printLevel && any(g~=1) + fprintf('%s\n','Reactions integerised: '); + printRxnFormula(modelOut,modelOut.rxns(g~=1)); +end + + \ No newline at end of file diff --git a/src/base/solvers/entropicFBA/entropicFluxBalanceAnalysis.m b/src/base/solvers/entropicFBA/entropicFluxBalanceAnalysis.m index 80d65dd02f..1d8da27b3a 100644 --- a/src/base/solvers/entropicFBA/entropicFluxBalanceAnalysis.m +++ b/src/base/solvers/entropicFBA/entropicFluxBalanceAnalysis.m @@ -101,7 +101,7 @@ % model.SConsistentRxnBool: n x 1 boolean indicating stoichiometrically consistent metabolites % % param.solver: {('pdco'),'mosek'} -% param.method: {('fluxes'),'fluxesConcentrations','fluxTracer')} maximise entropy of fluxes or also concentrations +% param.method: {('fluxes'),'fluxConc')} maximise entropy of fluxes or also concentrations % param.printLevel: {(0),1} % % @@ -113,7 +113,9 @@ % % Parameters related with concentration optimisation: % param.maxConc: scalar maximum permitted metabolite concentration -% param.externalNetFluxBounds: +% param.externalNetFluxBounds: ('original') = use bounds on external reactions from model.lb and model.ub +% 'dxReplacement' = replace model.lb and model.ub with bounds on change in concentration +% 'none' = set exchange reactions to be unbounded % % model.gasConstant: scalar gas constant (default 8.31446261815324 J K^-1 mol^-1) % model.T: scalar temperature (default 310.15 Kelvin) @@ -160,6 +162,9 @@ if ~isfield(param,'method') param.method='fluxes'; end +if ~isfield(param,'externalNetFluxBounds') + param.externalNetFluxBounds='original'; +end if ~isfield(model,'osenseStr') || isempty(model.osenseStr) %default linear objective sense is maximisation @@ -211,7 +216,10 @@ [vl,vu,vel,veu,vfl,vfu,vrl,vru,ci,ce,cf,cr,g] = processFluxConstraints(model,param); %% optionally processing for concentrations -processConcConstraints +%processConcConstraints +if contains(lower(param.method),'conc') + [f,u0,x0l,x0u,xl,xu,dxl,dxu,vel,veu,B] = processConcConstraints(model,param); +end %matrices for padding Omn = sparse(m,n); @@ -299,8 +307,8 @@ EPproblem.osense = 1; %minimise %bounds - EPproblem.lb = [vfl;vrl;vl;model.xl;model.x0l;model.dxl]; - EPproblem.ub = [vfu;vru;vu;model.xu;model.x0u;model.dxu]; + EPproblem.lb = [vfl;vrl;vl;xl;x0l;dxl]; + EPproblem.ub = [vfu;vru;vu;xu;x0u;dxu]; if any(EPproblem.lb > EPproblem.ub) if any(vfl>vfu) @@ -312,17 +320,17 @@ if any(vl>vu) error('vl>vu, i.e. lower bound on dx cannot be greater than upper bound') end - if any(model.xl>model.xu) + if any(xl>xu) error('model.xl>model.xu i.e. lower bound on dx cannot be greater than upper bound') end - if any(model.x0l>model.x0u) + if any(x0l>x0u) error('model.x0l>model.x0u i.e. lower bound on dx cannot be greater than upper bound') end - if any(model.dxl>model.dxu) - bool = (model.dxl~=0 | model.dxu~=0) & model.dxl>model.dxu; - T=table(model.dxl(bool),model.dxu(bool)); + if any(dxl>dxu) + bool = (dxl~=0 | dxu~=0) & dxl>dxu; + T=table(dxl(bool),dxu(bool)); disp(T) - error('model.dxl>model.dxu i.e. lower bound on dx cannot be greater than upper bound') + error('dxl>dxu i.e. lower bound on dx cannot be greater than upper bound') end end %variables for entropy maximisation @@ -475,8 +483,8 @@ Omn, Omn, Omk, Im, -Im; C, -C, D, Ocm, Ocm]; % vf, vr, w, x, x0 - EPproblem.blc = [model.b;vl;model.dxl;model.d]; - EPproblem.buc = [model.b;vu;model.dxu;model.d]; + EPproblem.blc = [model.b;vl;dxl;model.d]; + EPproblem.buc = [model.b;vu;dxu;model.d]; csense(1:size(EPproblem.A,1),1)='E'; csense(1:m,1)=model.csense; csense(2*m+n+1:2*m+n+nConstr,1) = model.dsense; @@ -486,8 +494,8 @@ In, -In, Onk, Onm, Onm; Omn, Omn, Omk, Im, -Im]; % vf, vr, w, x, x0 - EPproblem.blc = [model.b;vl;model.dxl]; - EPproblem.buc = [model.b;vu;model.dxu]; + EPproblem.blc = [model.b;vl;dxl]; + EPproblem.buc = [model.b;vu;dxu]; csense(1:size(EPproblem.A,1),1)='E'; csense(1:m,1)=model.csense; end @@ -513,8 +521,8 @@ EPproblem.osense = 1; %minimise %bounds - EPproblem.lb = [vfl;vrl;vel;model.xl;model.x0l]; - EPproblem.ub = [vfu;vru;veu;model.xu;model.x0u]; + EPproblem.lb = [vfl;vrl;vel;xl;x0l]; + EPproblem.ub = [vfu;vru;veu;xu;x0u]; %variables for entropy maximisation % vf, vr, w, x, x0 @@ -638,6 +646,8 @@ z_vf = solution.rcost(1:n,1); % duals to bounds on reverse unidirectional fluxes z_vr = solution.rcost(n+1:2*n,1); + %dual to bounds on net exchange fluxes + z_ve = solution.rcost(2*n+1:2*n+k,1); %duals to bounds on final concentration z_x = solution.rcost(2*n+k+1:2*n+k+m,1); %duals to bounds on initial concentration @@ -1528,7 +1538,7 @@ end case 'fluxTracing' otherwise - error('Incorrect method choice'); + error('entropicFluxBalanceAnalysis: Incorrect method choice'); end if 0 diff --git a/src/base/solvers/entropicFBA/processConcConstraints.m b/src/base/solvers/entropicFBA/processConcConstraints.m index afedfa504c..5152b59fe8 100644 --- a/src/base/solvers/entropicFBA/processConcConstraints.m +++ b/src/base/solvers/entropicFBA/processConcConstraints.m @@ -1,159 +1,229 @@ +function [f,u0,x0l,x0u,xl,xu,dxl,dxu,vel,veu,B] = processConcConstraints(model,param) +% +% USAGE: +% [] = processConcConstraints(model,param) +% +% INPUTS: +% model: (the following fields are required - others can be supplied) +% +% * S - `m x (n + k)` Stoichiometric matrix +% * c - `(n + k) x 1` Linear objective coefficients +% * lb - `(n + k) x 1` Lower bounds on net flux +% * ub - `(n + k) x 1` Upper bounds on net flux +% +% +% OPTIONAL INPUTS +% model.SConsistentMetBool: m x 1 boolean indicating stoichiometrically consistent metabolites +% model.SConsistentRxnBool: n x 1 boolean indicating stoichiometrically consistent metabolites +% model.rxns: +% +% model.f: m x 1 strictly positive weight on concentration entropy maximisation (default 1) +% model.u0: m x 1 real valued linear objective coefficients on concentrations (default 0) +% model.x0l: m x 1 non-negative lower bound on initial molecular concentrations +% model.x0u: m x 1 non-negative upper bound on initial molecular concentrations +% model.xl: m x 1 non-negative lower bound on final molecular concentrations +% model.xu: m x 1 non-negative lower bound on final molecular concentrations +% model.dxl: m x 1 real valued lower bound on difference between final and initial molecular concentrations (default -inf) +% model.dxu: m x 1 real valued upper bound on difference between final and initial initial molecular concentrations (default inf) +% model.gasConstant: scalar gas constant (default 8.31446261815324 J K^-1 mol^-1) +% model.T: scalar temperature (default 310.15 Kelvin) +% +% param.method: 'fluxConc' +% param.maxConc: (1e4) maximim micromolar concentration allowed +% param.externalNetFluxBounds: ('original') = +% 'dxReplacement' = when model.dxl or model.dxu is provided then they set the upper and lower bounds on metabolite exchange +% param.printLevel: +% +% OUTPUTS: +% f +% u0 +% x0l +% x0u +% xl +% xu +% dxl +% dxu +% vel +% veu +% B +% +% EXAMPLE: +% +% NOTE: +% +% Author(s): Ronan Fleming + +N=model.S(:,model.SConsistentRxnBool); % internal stoichiometric matrix +B=model.S(:,~model.SConsistentRxnBool); % external stoichiometric matrix +[m,n]=size(N); +k=nnz(~model.SConsistentRxnBool); + %% processing for concentrations -if contains(lower(param.method),'conc') - if ~isfield(param,'maxConc') - param.maxConc=1e4; - end - if ~isfield(param,'externalNetFluxBounds') - if isfield(model,'dxl') || isfield(model,'dxu') - param.externalNetFluxBounds='dxReplacement'; - else - param.externalNetFluxBounds='original'; - end - end - - nMetabolitesPerRxn = sum(model.S~=0,1)'; - bool = nMetabolitesPerRxn~=1 & ~model.SConsistentRxnBool; - if any(bool) - warning('Exchange reactions involving more than one metabolite, check bounds on x - x0') - disp(model.rxns(bool)) +if ~isfield(param,'maxConc') + param.maxConc=1e4; +end +if ~isfield(param,'externalNetFluxBounds') + if isfield(model,'dxl') || isfield(model,'dxu') + param.externalNetFluxBounds='dxReplacement'; + else + param.externalNetFluxBounds='original'; end - - if any(~model.SConsistentRxnBool) +end + +nMetabolitesPerRxn = sum(model.S~=0,1)'; +bool = nMetabolitesPerRxn>1 & ~model.SConsistentRxnBool; +if any(bool) + warning('Exchange reactions involving more than one metabolite, check bounds on x - x0') + disp(model.rxns(bool)) +end + +if any(~model.SConsistentRxnBool) - switch param.externalNetFluxBounds - case 'original' - if param.printLevel>0 - fprintf('%s\n','Using existing external net flux bounds without modification.') - end - if (isfield(model,'dxl') && any(model.dxl~=0)) || (isfield(model,'dxu') && any(model.dxu~=0)) - error('Option clash between param.externalNetFluxBounds=''original'' and (isfield(model,''dxl'') && any(model.dxl~=0)) || (isfield(model,''dxu'') && any(model.dxu~=0))') - end - % - vel = lb(~model.SConsistentRxnBool); - veu = ub(~model.SConsistentRxnBool); - %force initial and final concentration to be equal + switch param.externalNetFluxBounds + case 'original' + if param.printLevel>0 + fprintf('%s\n','Using existing external net flux bounds without modification.') + end + if (isfield(model,'dxl') && any(model.dxl~=0)) || (isfield(model,'dxu') && any(model.dxu~=0)) + error('Option clash between param.externalNetFluxBounds=''original'' and (isfield(model,''dxl'') && any(model.dxl~=0)) || (isfield(model,''dxu'') && any(model.dxu~=0))') + end + % + vel = model.lb(~model.SConsistentRxnBool); + veu = model.ub(~model.SConsistentRxnBool); + %force initial and final concentration to be equal + dxl = zeros(m,1); + dxu = zeros(m,1); + case 'dxReplacement' + %TODO + error('revise how net to initial and final conc bounds are dealt with') + if ~isfield(model,'dxl') + %close bounds by default model.dxl = zeros(m,1); - model.dxu = zeros(m,1); - case 'dxReplacement' - %TODO - error('revise how net to initial and final conc bounds are dealt with') - if ~isfield(model,'dxl') - %close bounds by default - model.dxl = zeros(m,1); - if exist('B','var') - dxlB = -B*model.lb(~model.SConsistentRxnBool); - model.dxl(dxlB~=0)=dxlB(dxlB~=0); - end - end - if ~isfield(model,'dxu') - %close bounds by default - model.dxu = zeros(m,1); - if exist('B','var') - dxuB = -B*model.ub(~model.SConsistentRxnBool); - model.dxu(dxuB~=0)=dxuB(dxuB~=0); - end - end - %eliminate all exchange reactions - B = B*0; - vel = lb(~model.SConsistentRxnBool)*0; - veu = ub(~model.SConsistentRxnBool)*0; - otherwise - error(['param.externalNetFluxBounds = ' param.externalNetFluxBounds ' is an unrecognised input']) - end - else - model.dxl = -inf*ones(m,1); - model.dxu = inf*ones(m,1); - end - - clear lb ub - - - if ~isfield(model,'x0l') - model.x0l = zeros(m,1); - end - if ~isfield(model,'x0u') - model.x0u = param.maxConc*ones(m,1); - end - if ~isfield(model,'xl') - model.xl = zeros(m,1); - end - if ~isfield(model,'xu') - model.xu = param.maxConc*ones(m,1); + dxlB = -B*model.lb(~model.SConsistentRxnBool); + dxl(dxlB~=0)=dxlB(dxlB~=0); + end + if ~isfield(model,'dxu') + %close bounds by default + dxu = zeros(m,1); + dxuB = -B*model.ub(~model.SConsistentRxnBool); + dxu(dxuB~=0)=dxuB(dxuB~=0); + end + %eliminate all exchange reactions + B = B*0; + vel = model.lb(~model.SConsistentRxnBool)*0; + veu = model.ub(~model.SConsistentRxnBool)*0; + otherwise + error(['param.externalNetFluxBounds = ' param.externalNetFluxBounds ' is an unrecognised input']) end - - if ~isfield(model,'u0') || isempty(model.u0) - model.u0='zero'; +else + dxl = -inf*ones(m,1); + dxu = inf*ones(m,1); +end + +clear lb ub + +if isfield(model,'x0l') + x0l = model.x0l; +else + x0l = zeros(m,1); +end +if isfield(model,'x0u') + x0u = model.x0u; +else + x0u = param.maxConc*ones(m,1); +end +if isfield(model,'xl') + xl = model.xl; +else + xl = zeros(m,1); +end +if isfield(model,'xu') + xu = model.xu; +else + xu = param.maxConc*ones(m,1); +end + +if ~isfield(model,'u0') || isempty(model.u0) + model.u0='zero'; +end +if ischar(model.u0) + switch model.u0 + case 'rand' + u0=rand(m,1); + case 'one' + u0=ones(m,1); + case 'zero' + u0=zeros(m,1); + otherwise + error('unrecognised option for model.u0') end - if ischar(model.u0) - switch model.u0 - case 'rand' - u0=rand(m,1); - case 'one' - u0=ones(m,1); - case 'zero' - u0=zeros(m,1); - otherwise - error('unrecognised option for model.u0') - end +else + if length(model.u0)==size(model.S,1) + u0 = columnVector(model.u0); else - if length(model.u0)==size(model.S,1) - u0 = columnVector(model.u0); + if length(model.u0)==1 + u0=ones(m,1)*model.u0; else - if length(model.u0)==1 - u0=ones(m,1)*model.u0; - else - error('model.u0 is of incorrect dimension') - end - end - if any(~isfinite(u0)) - error('u0 must be finite') + error('model.u0 is of incorrect dimension') end end - - %assume concentrations are in uMol - if ~isfield(model,'concUnit') - concUnit = 10-6; + if any(~isfinite(u0)) + error('u0 must be finite') end - - % Define constants - if isfield(model,'gasConstant') && isfield(model,'T') - if isfield(model,'gasConstant') - gasConstant = model.gasConstant; - else - %8.31446261815324 J K^-1 mol^-1 - gasConstant = 8.3144621e-3; % Gas constant in kJ K^-1 mol^-1 - end - if isfield(model,'T') - temperature = model.T; - else - temperature = 310.15; - end - %dimensionless - u0 = u0/(gasConstant*temperature); +end + +%assume concentrations are in uMol +if ~isfield(model,'concUnit') + concUnit = 10-6; +end + +% Define constants +if isfield(model,'gasConstant') && isfield(model,'T') + if isfield(model,'gasConstant') + gasConstant = model.gasConstant; + else + %8.31446261815324 J K^-1 mol^-1 + gasConstant = 8.3144621e-3; % Gas constant in kJ K^-1 mol^-1 end - - if ~isfield(model,'f') || isempty(model.f) - model.f='one'; + if isfield(model,'T') + temperature = model.T; + else + temperature = 310.15; end - if ischar(model.f) - switch model.f - case 'rand' - f=N'*rand(m,1); - case 'one' - f=ones(m,1); - case 'two' - f=ones(m,1)*2; - end + %dimensionless + u0 = u0/(gasConstant*temperature); +end + +if ~isfield(model,'f') || isempty(model.f) + model.f='one'; +end +if ischar(model.f) + switch model.f + case 'rand' + f=N'*rand(m,1); + case 'one' + f=ones(m,1); + case 'two' + f=ones(m,1)*2; + end +else + if length(model.f)==size(model.S,1) + f = columnVector(model.f); else - if length(model.f)==size(model.S,1) - f = columnVector(model.f); - else - if length(model.f)==1 - f=ones(m,1)*model.f; - end - end - if any(~isfinite(f)) - error('f must all be finite') + if length(model.f)==1 + f=ones(m,1)*model.f; end end -end \ No newline at end of file + if any(~isfinite(f)) + error('f must all be finite') + end +end + +% %lower and upper bounds on logarithmic concentration +% pl = -log(param.maxConc*ones(m2,1)); +% if 1 +% pu = log(param.maxConc*ones(m2,1)); +% else +% %All potentials negative +% pu = zeros(m2,1); +% end \ No newline at end of file diff --git a/src/base/solvers/entropicFBA/processFluxConstraints.m b/src/base/solvers/entropicFBA/processFluxConstraints.m index a06f1f3d79..582eba04da 100644 --- a/src/base/solvers/entropicFBA/processFluxConstraints.m +++ b/src/base/solvers/entropicFBA/processFluxConstraints.m @@ -1,38 +1,52 @@ function [vl,vu,vel,veu,vfl,vfu,vrl,vru,ci,ce,cf,cr,g] = processFluxConstraints(model,param) % % USAGE: +% processFluxConstraints(model) % processFluxConstraints(model,param) % % INPUTS: -% model.osenseStr: -% model.S: -% model.SConsistentRxnBool: -% model.lb: -% model.ub: -% model.c: -% model.cf: -% model.cr: -% model.g: +% model: (the following fields are required - others can be supplied) % -% param.printLevel: -% param.maxUnidirectionalFlux: -% param.solver: -% param.minUnidirectionalFlux: -% param.internalNetFluxBounds: -% param.debug: -% param.method: +% * S - `m x (n + k)` Stoichiometric matrix +% * c - `(n + k) x 1` Linear objective coefficients +% * lb - `(n + k) x 1` Lower bounds on net flux +% * ub - `(n + k) x 1` Upper bounds on net flux % % OPTIONAL INPUTS -% model.vfl: -% model.vfu: -% model.vrl: -% model.vru: - +% model.osenseStr: ('max') or 'min'. The default linear objective sense is maximisation, i.e. 'max' +% model.cf: n x 1 real valued linear objective coefficients on internal forward flux (default 0) +% model.cr: n x 1 real valued linear objective coefficients on internal reverse flux (default 0) +% model.g n x 1 strictly positive weight on internal flux entropy maximisation (default 2) +% model.SConsistentRxnBool: n x 1 boolean indicating stoichiometrically consistent metabolites +% model.vfl: n x 1 non-negative lower bound on internal forward flux (default 0) +% model.vfu: n x 1 non-negative upper bound on internal forward flux (default inf) +% model.vrl: n x 1 non-negative lower bound on internal reverse flux (default 0) +% model.vru: n x 1 non-negative upper bound on internal reverse flux (default 0) +% +% param.printLevel: +% param.solver: {'pdco',('mosek')} +% param.debug: {(0),1} 1 = run in debug mode +% param.method: {('fluxes'),'fluxesConcentrations'} maximise entropy of fluxes (default) or also concentrations +% param.maxUnidirectionalFlux: maximum unidirectional flux (inf by default) +% param.minUnidirectionalFlux: minimum unidirectional flux (zero by default) +% param.internalNetFluxBounds: ('original') = use model.lb and model.ub to set the direction and magnitude of internal net flux bounds +% 'directional' = use model.lb and model.ub to set the direction of net flux bounds (ignoring magnitude) +% 'none' = ignore model.lb and model.ub and allow all net flues to be reversible +% % OUTPUTS: -% vfl: -% vfu: -% vrl: -% vru: +% vl: n x 1 lower bound on internal net flux +% vu: n x 1 upper bound on internal net flux +% vel: k x 1 lower bound on external net flux +% veu: k x 1 upper bound on external net flux +% vfl: n x 1 non-negative lower bound on internal forward flux +% vfu: n x 1 non-negative upper bound on internal forward flux +% vrl: n x 1 non-negative lower bound on internal reverse flux +% vru: n x 1 non-negative upper bound on internal reverse flux +% ci: n x 1 linear objective coefficients corresponding to internal net fluxes +% vel: k x 1 linear objective coefficients corresponding to external net fluxes +% cf: n x 1 real valued linear objective coefficients on internal forward flux +% cr: n x 1 real valued linear objective coefficients on internal reverse flux +% g n x 1 strictly positive weight on internal flux entropy maximisation % % % EXAMPLE: @@ -61,26 +75,26 @@ if ~isfield(param,'maxUnidirectionalFlux') %try to set the maximum unidirectional flux based on the magnitude of the largest bound but dont have it greater than 1e5 - %param.maxUnidirectionalFlux=min(1e5,max(abs(model.ub))); - param.maxUnidirectionalFlux=inf; + param.maxUnidirectionalFlux=min(1e5,max(abs(model.ub))); + %param.maxUnidirectionalFlux=inf; end if ~isfield(param,'minUnidirectionalFlux') - if isequal(param.solver,'mosek') - %try to set the minimum unidirectional flux - param.minUnidirectionalFlux=0; - else - param.minUnidirectionalFlux = 0; - end + param.minUnidirectionalFlux = 0; end if ~isfield(param,'internalNetFluxBounds') param.internalNetFluxBounds='original'; end + +if ~isfield(param,'externalNetFluxBounds') + param.externalNetFluxBounds='original'; +end + if isfield(param,'internalBounds') error('internalBounds replaced by other parameter options') end -if param.debug +if isfield(param,'debug') && param.debug solution_optimizeCbModel = optimizeCbModel(model); switch solution_optimizeCbModel.stat case 0 @@ -144,7 +158,7 @@ ub(model.SConsistentRxnBool)= ones(n,1)*param.maxUnidirectionalFlux; case 'none' - if param.printLevel>0 + if isfield(param,'printLevel') && param.printLevel>0 fprintf('%s\n','Using no internal net flux bounds.') end lb(model.SConsistentRxnBool,1)=-ones(n,1)*inf; @@ -202,6 +216,10 @@ else vfl = max(param.minUnidirectionalFlux,vl); end + +if any(vfl<0) + error('lower bound on forward flux cannot be less than zero') +end %upper bounds on forward fluxes if isfield(model,'vfu') vfu = model.vfu; @@ -210,15 +228,44 @@ vfu = ones(n,1)*param.maxUnidirectionalFlux; else vfu = vu; - vfu(vfu<=0) = param.maxUnidirectionalFlux; + bool = vfu<=0; + vfu(bool) = param.maxUnidirectionalFlux; end end +if any(vfl>0) + if param.printLevel + fprintf('%s\n','Lower bound on forward flux is positive for the following internal reactions:') + vfl2= zeros(n+k,1); + vfl2(model.SConsistentRxnBool,1)=vfl; + %print the non-zero lower bounds + printFluxVector(model,vfl2,1); + end +end +if any(vfl>vfu) + bool = vfl>vfu; + %print the problematic bounds + fprintf('%s%s%s\n','Reaction','vfl','vul') + vfl2 = vfl; + vfl2(vfl<=vfu)=0; + vfl3 = zeros(n+k,1); + vfl3(model.SConsistentRxnBool,1)=vfl; + + vfu2 = vfu; + vfu2(vfl<=vfu)=0; + vfu3 = zeros(n+k,1); + vfu3(model.SConsistentRxnBool,1)=vfu; + printFluxVector(model,[vfl3,vfu3],1); + error('lower bound on forward flux is greater than upper bound') +end %lower bounds on reverse fluxes if isfield(model,'vrl') vrl = model.vrl; else vrl = max(param.minUnidirectionalFlux,-vu); end +if any(vrl<0) + error('lower bound on reverse flux cannot be less than zero') +end %upper bounds on reverse fluxes if isfield(model,'vru') vru = model.vru; @@ -230,17 +277,8 @@ vru(vru<=0) = param.maxUnidirectionalFlux; end end -if any(vfl<0) - error('lower bound on forward flux cannot be less than zero') -end -if any(vrl<0) - error('lower bound on reverse flux cannot be less than zero') -end -if any(vfl>vfu) - error('lower bound on forward flux greater than upper bound') -end if any(vrl>vru) - error('lower bound on reverse flux greater than upper bound') + error('lower bound on reverse flux is greater than upper bound') end if any(vl>0 | vu<0) && ~strcmp(param.internalNetFluxBounds,'original') diff --git a/src/base/solvers/entropicFBA/solveCobraEP.m b/src/base/solvers/entropicFBA/solveCobraEP.m index bc04131b38..f2c61e697f 100644 --- a/src/base/solvers/entropicFBA/solveCobraEP.m +++ b/src/base/solvers/entropicFBA/solveCobraEP.m @@ -192,16 +192,7 @@ [res] = msklpopt(EPproblem.c,EPproblem.A,EPproblem.blc,EPproblem.buc,EPproblem.lb,EPproblem.ub,solverParams,'minimize'); %parse mosek result structure - [solutionLP2.stat,solutionLP2.origStat,x,y,z,zl,zu,k,doty,bas,pobjval,dobjval] = parseMskResult(res);%,A,blc,buc,printLevel,param) - %[solutionLP2.stat,solutionLP2.origStat,x,y,w] = parseMskResult(res); -% if stat ==1 -% f=c'*x; -% % slack for blc <= A*x <= buc -% s = b - A * x; % output the slack variables -% else -% f = NaN; -% s = NaN*ones(size(A,1),1); -% end + [solutionLP2.stat,solutionLP2.origStat,x,y,yl,yu,z,zl,zu,s,k,bas,pobjval,dobjval] = parseMskResult(res,EPproblem,solverParams,problemTypeParams.printLevel); switch solutionLP2.stat case 0 @@ -679,7 +670,7 @@ [~, res] = mosekopt('symbcon echo(0)'); end % https://docs.mosek.com/9.2/toolbox/data-types.html#cones -% For affine conic constraints Fx+g \in K, where K = K_1 * K_2 * ... * K_s, cones is a list consisting of s oncatenated cone descriptions. +% For affine conic constraints Fx+g \in K, where K = K_1 * K_2 * ... * K_s, cones is a list consisting of concatenated cone descriptions. % If a cone requires no additional parameters (quadratic, rotated quadratic, exponential, zero) then its description is [type,len] % where type is the type (conetype) and len is the length (dimension). The length must be present. @@ -876,9 +867,9 @@ solution.time = toc; %parse mosek result structure - %[stat,origStat,x,y,z,zl,zu,k,doty,bas,pobjval,dobjval] = parseMskResult(res,A,blc,buc,printLevel,param) - [stat,origStat,x,y,z,zl,zu,s,doty] = parseMskResult(res,prob.a,prob.blc,prob.buc,problemTypeParams.printLevel,paramMosek); - + %[stat,origStat,x,y,yl,yu,z,zl,zu,k,basis,pobjval,dobjval] = parseMskResult(res,solverParams,printLevel) + [stat,origStat,x,y,yl,yu,z,zl,zu,k,bas,pobjval,dobjval] = parseMskResult(res,solverParams,problemTypeParams.printLevel); + solution.stat = stat; solution.origStat = origStat; switch stat @@ -893,30 +884,39 @@ fprintf('%8.4g %8.4g %8.4g\n',prob.blx(ind(i)),x(ind(i)),prob.bux(ind(i))); end end + + %slacks + sbl = prob.a*x - prob.blc; + sbu = prob.buc - prob.a*x; + s = sbu - sbl; %TODO -double check this + if problemTypeParams.printLevel>1 + fprintf('%8.2g %s\n',min(sbl), ' min(sbl) = min(A*x - bl), (should be positive)'); + fprintf('%8.2g %s\n',min(sbu), ' min(sbu) = min(bu - A*x), (should be positive)'); + end if problemTypeParams.printLevel > 1 % Problem definition here: https://docs.mosek.com/9.2/toolbox/prob-def-affine-conic.html fprintf('%s\n','Optimality conditions (numerical)') % Guide to interpreting the solution summary: https://docs.mosek.com/9.2/toolbox/debugging-log.html#continuous-problem fprintf('%8.2g %s\n',norm(prob.a(prob.blc==prob.buc,:)*x - prob.blc(prob.blc==prob.buc),inf), '|| A*x - b ||_inf'); - val = norm(prob.c - prob.a'*y - z - prob.f'*doty,inf); - fprintf('%8.2g %s\n',val, '|| c - A''*y - z - F''*doty ||_inf'); + val = norm(prob.c - prob.a'*y - z - prob.f'*k,inf); + fprintf('%8.2g %s\n',val, '|| c - A''*y - z - F''*k ||_inf'); if val>1e-6 || problemTypeParams.debug - solution.T0 = table(prob.c - prob.a'*y - z - prob.f'*doty,prob.c, prob.a'*y, z,prob.f'*doty,'VariableNames',{'tot','c','Aty','z','Ftdoty'}); + solution.T0 = table(prob.c - prob.a'*y - z - prob.f'*k,prob.c, prob.a'*y, z,prob.f'*k,'VariableNames',{'tot','c','Aty','z','Ftdoty'}); end - %fprintf('%8.2g %s\n',norm(prob.c - prob.f'*s,inf), '|| c - F''s ||_inf'); + %fprintf('%8.2g %s\n',norm(prob.c - prob.f'*s,inf), '|| c - F''k ||_inf'); fprintf('%8.2g %s\n',norm(-y + res.sol.itr.slc - res.sol.itr.suc,inf), '|| -y + res.sol.itr.slc - res.sol.itr.suc ||_inf'); %fprintf('%8.2g %s\n',prob.c'*x - prob.b'*y, ' c''*x -b''*y'); - fprintf('%8.2g %s\n',(prob.f*x + prob.g)'*doty, '(F*x + g)''*s >= 0'); + fprintf('%8.2g %s\n',(prob.f*x + prob.g)'*k, '(F*x + g)''*k >= 0'); end %%% Reorder % Dual variables to affine conic constraints, based on original order of rows in F matrix - y_K = zeros(length(doty),1); - y_K(1:nCone,1) = doty(1:3:3*nCone); - y_K(nCone+1:2*nCone,1) = doty(2:3:3*nCone); - y_K(2*nCone+1:3*nCone,1) = doty(3:3:3*nCone); + y_K = zeros(length(k),1); + y_K(1:nCone,1) = k(1:3:3*nCone); + y_K(nCone+1:2*nCone,1) = k(2:3:3*nCone); + y_K(2*nCone+1:3*nCone,1) = k(3:3:3*nCone); %check with the original order of the affine cone constraints val = norm(prob.c - prob.a'*y - z - F'*y_K,inf); @@ -924,7 +924,7 @@ fprintf('%8.2g %s\n',val, '|| c - A''*y - z - F''*y_K ||_inf'); end if val>1e-6 || problemTypeParams.debug - solution.T = table(prob.c - prob.a'*y - z - F'*y_K,prob.c, prob.a'*y, z,prob.f'*doty,F'*y_K,'VariableNames',{'tot','c','Aty','z','Ftdoty','Fty_K'}); + solution.T = table(prob.c - prob.a'*y - z - F'*y_K,prob.c, prob.a'*y, z,prob.f'*k,F'*y_K,'VariableNames',{'tot','c','Aty','z','Ftdoty','Fty_K'}); end if problemTypeParams.printLevel > 1 @@ -1012,7 +1012,7 @@ otherwise - doty = NaN*ones(size(prob.f,1),1); + k = NaN*ones(size(prob.f,1),1); end otherwise error([problemTypeParams.solver ' is an unrecognised solver']) @@ -1065,8 +1065,7 @@ end [res] = msklpopt(EPproblem.c,EPproblem.A,EPproblem.blc,EPproblem.buc,EPproblem.lb,EPproblem.ub,solverParams,cmd); - %[stat,origStat,x,y,z,zl,zu,k,doty,bas,pobjval,dobjval] = parseMskResult(res,A,blc,buc,printLevel,param) - [statLP,origStat,x,y,z,zl,zu,s,doty] = parseMskResult(res,EPproblem.A,EPproblem.blc,EPproblem.buc,problemTypeParams.printLevel); + [stat,origStat,x,y,yl,yu,z,zl,zu,s,k,bas,pobjval,dobjval] = parseMskResult(res,prob,solverParams,printLevel); end diff --git a/src/base/solvers/getSetSolver/changeCobraSolver.m b/src/base/solvers/getSetSolver/changeCobraSolver.m index 52a7bfb2c5..19ae78f308 100644 --- a/src/base/solvers/getSetSolver/changeCobraSolver.m +++ b/src/base/solvers/getSetSolver/changeCobraSolver.m @@ -7,7 +7,7 @@ % % INPUTS: % solverName: Solver name -% solverType: Solver type, `LP`, `MILP`, `QP`, `MIQP` (opt, default +% solverType: Solver type, `LP`, `MILP`, `QP`, `MIQP` 'EP', 'CLP' (opt, default % `LP`, `all`). 'all' attempts to change all applicable % solvers to solverName. This is purely a shorthand % convenience. @@ -179,6 +179,7 @@ global CBT_MILP_SOLVER; global CBT_QP_SOLVER; global CBT_EP_SOLVER; +global CBT_CLP_SOLVER; global CBT_MIQP_SOLVER; global CBT_NLP_SOLVER; global ENV_VARS; @@ -217,6 +218,8 @@ CBT_MIQP_SOLVER = solverName; case 'EP' CBT_EP_SOLVER = solverName; + case 'CLP' + CBT_CLP_SOLVER = solverName; end solverOK = NaN; return @@ -244,7 +247,7 @@ % Print out all solvers defined in global variables CBT_*_SOLVER if nargin < 1 - definedSolvers = [CBT_LP_SOLVER, CBT_MILP_SOLVER, CBT_QP_SOLVER, CBT_MIQP_SOLVER, CBT_NLP_SOLVER]; + definedSolvers = [CBT_LP_SOLVER, CBT_MILP_SOLVER, CBT_QP_SOLVER, CBT_MIQP_SOLVER, CBT_NLP_SOLVER, CBT_CLP_SOLVER, CBT_EP_SOLVER]; if isempty(definedSolvers) fprintf('No solvers are defined!\n'); else @@ -532,8 +535,12 @@ eval(['solveCobra' solverType '(problem,''printLevel'',0);']); end catch ME + %This is the code that describes what went wrong if a call to a solver does not work if printLevel > 0 + fprintf(2,'The identifier was:\n%s',ME.identifier); + fprintf(2,'There was an error! The message was:\n%s',ME.message); disp(ME.message); + rethrow(ME) end solverOK = false; eval(['CBT_', solverType, '_SOLVER = oldval;']); diff --git a/src/base/solvers/getSetSolver/getCobraSolverVersion.m b/src/base/solvers/getSetSolver/getCobraSolverVersion.m index dc6e064860..9b5baa0d98 100644 --- a/src/base/solvers/getSetSolver/getCobraSolverVersion.m +++ b/src/base/solvers/getSetSolver/getCobraSolverVersion.m @@ -115,6 +115,9 @@ function [solverVersion, rootPathSolver] = extractVersionNumber(globalVar, solverNamePattern) % extract the version number based on the path of the solver + if strcmpi('mosek',solverNamePattern) && contains(globalVar,'mosektoolslinux64x86') + solverNamePattern= ['mosektoolslinux64x86' filesep 'mosek' filesep]; + end index = regexp(lower(globalVar), lower(solverNamePattern)); rootPathSolver = globalVar(1:index-1); beginIndex = index + length(solverNamePattern); diff --git a/src/base/solvers/isCompatible.m b/src/base/solvers/isCompatible.m index d5f76a245a..80b28167fb 100644 --- a/src/base/solvers/isCompatible.m +++ b/src/base/solvers/isCompatible.m @@ -1,5 +1,9 @@ function compatibleStatus = isCompatible(solverName, printLevel, specificSolverVersion) % determine the compatibility status of a solver based on the file compatMatrix.rst +% stored here cobratoolbox/docs/source/installation/compatMatrix.rst +% +% clear isCompatible when the compatMatrix has been changed as the data from compatMatrix +% is stored in a persistent variable % % USAGE: % compatibleStatus = isCompatible(solverName, printLevel, specificSolverVersion) @@ -51,7 +55,7 @@ compatMatrixFile = [CBTDIR filesep 'docs' filesep 'source' filesep 'installation' filesep 'compatMatrix.rst']; % read in the file with the compatibility matrix - persistent C; + %persistent C; persistent compatMatrix; persistent testedOS; @@ -104,7 +108,8 @@ % convert untested flag Cpart = strrep(Cpart, ':warning:', '2'); - C{end+1} = strtrim(Cpart); + %C(end+1,:) = [testedOS{end} strtrim(Cpart)]; + C(end+1,:) = strtrim(Cpart); else if ~isempty(C) compatMatrix{end+1} = C; @@ -174,7 +179,7 @@ % determine the version of MATLAB and the corresponding column versionMatlab = ['R' version('-release')]; if ~isempty(cMatrix) - compatMatlabVersions = cMatrix{1}(2:end); + compatMatlabVersions = cMatrix(1,2:end); colIndexVersion = strmatch(versionMatlab, compatMatlabVersions); else colIndexVersion = []; @@ -198,9 +203,9 @@ end % check compatibility of solver - for i = 1:length(cMatrix) + for i = 2:length(cMatrix) % save the row of the compatibilitx matrix - row = cMatrix{i}; + row = cMatrix(i,:); % determine the name of the solver solverNameRow = row{1}; diff --git a/src/base/solvers/msk/parseMskResult.m b/src/base/solvers/msk/parseMskResult.m index 8a08269921..d4fb902d4a 100644 --- a/src/base/solvers/msk/parseMskResult.m +++ b/src/base/solvers/msk/parseMskResult.m @@ -1,5 +1,38 @@ -function [stat,origStat,x,y,z,zl,zu,k,doty,bas,pobjval,dobjval] = parseMskResult(res,A,blc,buc,printLevel,param) +function [stat,origStat,x,y,yl,yu,z,zl,zu,k,basis,pobjval,dobjval] = parseMskResult(res,solverOnlyParams,printLevel) %parse the res structure returned from mosek +% INPUTS: +% res: mosek results structure returned by mosekopt +% +% OPTIONAL INPUTS +% prob: mosek problem structure passed to mosekopt +% solverOnlyParams: Additional parameters provided which are not part +% of the COBRA parameters and are assumed to be part +% of direct solver input structs. For some solvers, it +% is essential to not include any extraneous fields that are +% outside the solver interface specification. +% printLevel: +% +% OUTPUTS: +% stat: cobra toolbox status +% origStat: solver status +% x: primal variable vector +% y: dual variable vector to linear constraints (yl - yu) +% yl: dual variable vector to lower bound on linear constraints +% yu: dual variable vector to upper bound on linear constraints +% z: dual variable vector to box constraints (zl - zu) +% zl: dual variable vector to lower bounds +% zu: dual variable vector to upper bounds +% k: dual variable vector to affine conic constraints +% basis basis returned by mosekopt +% pobjval: primal objective value returned by modekopt +% dobjval: dual objective value returned by modekopt +% +% EXAMPLE: +% [~,res]=mosekopt('minimize',prob); +% +% NOTE: +% +% Author(s): Ronan Fleming % initialise variables stat =[]; @@ -10,19 +43,17 @@ zl = []; zu = []; k = []; -doty = []; -bas = []; +basis = []; pobjval =[]; dobjval =[]; if ~exist('printLevel','var') printLevel = 0; end -if ~exist('param','var') - param = struct(); +if ~exist('solverOnlyParams','var') + solverOnlyParams = struct(); end - % https://docs.mosek.com/8.1/toolbox/data-types.html?highlight=res%20sol%20itr#data-types-and-structures if isfield(res, 'sol') if isfield(res.sol, 'itr') @@ -33,20 +64,22 @@ stat = 1; % optimal solution found x=res.sol.itr.xx; % primal solution. y=res.sol.itr.y; % dual variable to blc <= A*x <= buc + yl = res.sol.itr.slc; + yu = res.sol.itr.suc; z=res.sol.itr.slx-res.sol.itr.sux; %dual to blx <= x <= bux zl=res.sol.itr.slx; %dual to blx <= x zu=res.sol.itr.sux; %dual to x <= bux if isfield(res.sol.itr,'doty') % Dual variables to affine conic constraints - doty = res.sol.itr.doty; + k = res.sol.itr.doty; end pobjval = res.sol.itr.pobjval; dobjval = res.sol.itr.dobjval; % % TODO -work this out with Erling % % override if specific solver selected -% if isfield(param,'MSK_IPAR_OPTIMIZER') -% switch param.MSK_IPAR_OPTIMIZER +% if isfield(solverOnlyParams,'MSK_IPAR_OPTIMIZER') +% switch solverOnlyParams.MSK_IPAR_OPTIMIZER % case {'MSK_OPTIMIZER_PRIMAL_SIMPLEX','MSK_OPTIMIZER_DUAL_SIMPLEX'} % stat = 1; % optimal solution found % x=res.sol.bas.xx; % primal solution. @@ -54,7 +87,7 @@ % z=res.sol.bas.slx-res.sol.bas.sux; %dual to blx <= x <= bux % if isfield(res.sol.itr,'doty') % % Dual variables to affine conic constraints -% doty = res.sol.itr.doty; +% s = res.sol.itr.doty; % end % case 'MSK_OPTIMIZER_INTPNT' % stat = 1; % optimal solution found @@ -63,7 +96,7 @@ % z=res.sol.itr.slx-res.sol.itr.sux; %dual to blx <= x <= bux % if isfield(res.sol.itr,'doty') % % Dual variables to affine conic constraints -% doty = res.sol.itr.doty; +% s = res.sol.itr.doty; % end % end % end @@ -94,12 +127,14 @@ stat = 1; % optimal solution found x=res.sol.bas.xx; % primal solution. y=res.sol.bas.y; % dual variable to blc <= A*x <= buc + yl = res.sol.bas.slc; %assuming this exists + yu = res.sol.bas.suc; z=res.sol.bas.slx-res.sol.bas.sux; %dual to blx <= x <= bux zl=res.sol.bas.slx; %dual to blx <= x zu=res.sol.bas.sux; %dual to x <= bux - if isfield(res.sol.bas,'doty') + if isfield(res.sol.bas,'s') % Dual variables to affine conic constraints - doty = res.sol.bas.doty; + k = res.sol.bas.s; end %https://docs.mosek.com/10.0/toolbox/advanced-hotstart.html bas.skc = res.sol.bas.skc; @@ -121,63 +156,37 @@ if stat==1 % override if specific solver selected - if isfield(param,'MSK_IPAR_OPTIMIZER') - switch param.MSK_IPAR_OPTIMIZER + if isfield(solverOnlyParams,'MSK_IPAR_OPTIMIZER') + switch solverOnlyParams.MSK_IPAR_OPTIMIZER case {'MSK_OPTIMIZER_PRIMAL_SIMPLEX','MSK_OPTIMIZER_DUAL_SIMPLEX'} stat = 1; % optimal solution found x=res.sol.bas.xx; % primal solution. y=res.sol.bas.y; % dual variable to blc <= A*x <= buc + yl = res.sol.bas.slc; %assuming this exists + yu = res.sol.bas.suc; z=res.sol.bas.slx-res.sol.bas.sux; %dual to blx <= x <= bux zl=res.sol.bas.slx; %dual to blx <= x zu=res.sol.bas.sux; %dual to x <= bux - if isfield(res.sol.bas,'doty') + if isfield(res.sol.bas,'s') % Dual variables to affine conic constraints - doty = res.sol.bas.doty; + k = res.sol.bas.s; end case 'MSK_OPTIMIZER_INTPNT' stat = 1; % optimal solution found x=res.sol.itr.xx; % primal solution. y=res.sol.itr.y; % dual variable to blc <= A*x <= buc + yl = res.sol.itr.slc; %assuming this exists + yu = res.sol.itr.suc; z=res.sol.itr.slx-res.sol.itr.sux; %dual to blx <= x <= bux - zl=res.sol.bas.slx; %dual to blx <= x - zu=res.sol.bas.sux; %dual to x <= bux + zl=res.sol.itr.slx; %dual to blx <= x + zu=res.sol.itr.sux; %dual to x <= bux if isfield(res.sol.itr,'doty') % Dual variables to affine conic constraints - doty = res.sol.itr.doty; + k = res.sol.itr.doty; end end end - end - - if stat ==1 && exist('A','var') - %slack for blc <= A*x <= buc - k = zeros(size(A,1),1); - %slack for blc = A*x = buc - k(blc==buc) = abs(A(blc==buc,:)*x - blc(blc==buc)); - %slack for blc <= A*x - k(~isfinite(blc)) = A(~isfinite(blc),:)*x - blc(~isfinite(blc)); - %slack for A*x <= buc - k(~isfinite(buc)) = buc(~isfinite(buc)) - A(~isfinite(buc),:)*x; - - %debugging - % if printLevel>2 - % res1=A*x + s -b; - % norm(res1(csense == 'G'),inf) - % norm(s(csense == 'G'),inf) - % norm(res1(csense == 'L'),inf) - % norm(s(csense == 'L'),inf) - % norm(res1(csense == 'E'),inf) - % norm(s(csense == 'E'),inf) - % res1(~isfinite(res1))=0; - % norm(res1,inf) - - % norm(osense*c -A'*y -z,inf) - % y2=res.sol.itr.slc-res.sol.itr.suc; - % norm(osense*c -A'*y2 -z,inf) - % end - end - - + end else if printLevel>0 fprintf('%s\n',res.rcode) @@ -218,7 +227,7 @@ % end % % else -% %try to solve with default parameters +% %try to solve with default solverParamseters % [res] = msklpopt(EPproblem.c,EPproblem.A,EPproblem.blc,EPproblem.buc,EPproblem.lb,EPproblem.ub); % if isfield(res,'sol') % if isfield(res.sol, 'itr') @@ -248,7 +257,7 @@ % end % end % if solutionLP2.stat ==0 -% if problemTypeParams.printLevel>2 +% if problemTypesolverParamss.printLevel>2 % disp(res); % end % end @@ -291,7 +300,7 @@ % end % end % else -% %try to solve with default parameters +% %try to solve with default solverParamseters % [res] = msklpopt(EPproblem.c,EPproblem.A,EPproblem.blc,EPproblem.buc,EPproblem.lb,EPproblem.ub); % if isfield(res,'sol') % if isfield(res.sol, 'itr') @@ -321,7 +330,7 @@ % end % end % if solutionLP.stat ==0 -% if problemTypeParams.printLevel>2 +% if problemTypesolverParamss.printLevel>2 % disp(res); % end % end diff --git a/src/base/solvers/param/getCobraSolverParams.m b/src/base/solvers/param/getCobraSolverParams.m index 532c1e0a1a..96f94931bf 100644 --- a/src/base/solvers/param/getCobraSolverParams.m +++ b/src/base/solvers/param/getCobraSolverParams.m @@ -1,4 +1,4 @@ -function varargout = getCobraSolverParams(solverType, paramNames, paramStructure) +function varargout = getCobraSolverParams(problemType, paramNames, paramStructure) % This function gets the specified paramStructure in `paramNames` from % paramStructure, the global cobra paramters variable or default values set within % this script. @@ -11,10 +11,10 @@ % % USAGE: % -% varargout = getCobraSolverParams(solverType, paramNames, paramStructure) +% varargout = getCobraSolverParams(problemType, paramNames, paramStructure) % % INPUTS: -% solverType: Type of solver used: 'LP', 'MILP', 'QP', 'MIQP' +% problemType: Type of problem solved: 'LP', 'MILP', 'QP', 'MIQP', 'EP', 'CLP' % paramNames: Cell array of strings containing parameter names OR one % parameter name as string % @@ -76,10 +76,10 @@ valDef.timeLimit = 1e36; valDef.iterationLimit = 1000; -%valDef.logFile = ['Cobra' solverType 'Solver.log']; +%valDef.logFile = ['Cobra' problemType 'Solver.log']; valDef.logFile = []; %log file should be empty to avoid creating it by default valDef.saveInput = []; -valDef.PbName = [solverType 'problem']; +valDef.PbName = [problemType 'problem']; valDef.debug = 0; valDef.lifting = 0; @@ -101,7 +101,7 @@ paramNames = {paramNames}; end -switch solverType +switch problemType case 'LP' global CBT_LP_PARAMS parametersGlobal = CBT_LP_PARAMS; @@ -116,14 +116,13 @@ parametersGlobal = CBT_EP_PARAMS; valDef.feasTol = 1e-6; % (primal) feasibility tolerance valDef.optTol = 1e-6; % (dual) optimality tolerance - case 'KP' - global CBT_EP_PARAMS - parametersGlobal = CBT_EP_PARAMS; + valDef.solver='mosek'; + case 'CLP' + global CBT_CLP_PARAMS + parametersGlobal = CBT_CLP_PARAMS; valDef.feasTol = 1e-6; % (primal) feasibility tolerance valDef.optTol = 1e-6; % (dual) optimality tolerance - valDef.maxUnidirectionalFlux=10000; - valDef.internalNetFluxBounds='original'; - valDef.externalNetFluxBounds='original'; + valDef.solver='mosek'; case 'MIQP' global CBT_MIQP_PARAMS parametersGlobal = CBT_MIQP_PARAMS; diff --git a/src/base/solvers/param/getCobraSolverParamsOptionsForType.m b/src/base/solvers/param/getCobraSolverParamsOptionsForType.m index c0af92b38e..d228910f21 100644 --- a/src/base/solvers/param/getCobraSolverParamsOptionsForType.m +++ b/src/base/solvers/param/getCobraSolverParamsOptionsForType.m @@ -48,24 +48,21 @@ 'solver'}; % the solver to use case 'EP' - paramNames = {'verify',... % verify that it is a suitable QP problem + paramNames = {'verify',... % verify that it is a suitable EP problem 'method', ... % solver method: -1 = automatic, 0 = primal simplex, 1 = dual simplex, 2 = barrier, 3 = concurrent, 4 = deterministic concurrent, 5 = Network Solver(if supported by the solver) 'printLevel', ... % print level 'debug', ... % run debgugging code 'feasTol',... % feasibility tolerance 'optTol',... % optimality tolerance 'solver'}; % the solver to use - case 'KP' - paramNames = {'verify',... % verify that it is a suitable QP problem - 'method', ... % solver method: -1 = automatic, 0 = primal simplex, 1 = dual simplex, 2 = barrier, 3 = concurrent, 4 = deterministic concurrent, 5 = Network Solver(if supported by the solver) + case 'CLP' + paramNames = {'verify',... % verify that it is a suitable CLP problem 'printLevel', ... % print level 'debug', ... % run debgugging code 'feasTol',... % feasibility tolerance 'optTol',... % optimality tolerance 'solver',... % the solver to use - 'maxUnidirectionalFlux',... %todo remove these later - 'internalNetFluxBounds',... - 'externalNetFluxBounds'}; + }; case 'MILP' paramNames = {'intTol', ... % integer tolerance (accepted derivation from integer numbers) @@ -104,7 +101,7 @@ 'saveInput', ... % save the input to a file (specified) 'solver'}; % the solver to use otherwise - error('Solver type %s is not supported by the Toolbox'); + error(['Solver type ' problemType ' is not supported by the COBRA Toolbox']); end diff --git a/src/base/solvers/param/parseSolverParameters.m b/src/base/solvers/param/parseSolverParameters.m index a8b22c21d9..44a94b6907 100644 --- a/src/base/solvers/param/parseSolverParameters.m +++ b/src/base/solvers/param/parseSolverParameters.m @@ -1,14 +1,14 @@ -function [problemTypeParams, solverParams] = parseSolverParameters(problemType, varargin) +function [params, solverOnlyParams] = parseSolverParameters(problemType, varargin) % Gets default cobra solver parameters for a problem of type problemType, unless % overridden by cobra solver parameters provided by varagin either as parameter % struct, or as parameter/value pairs. % % USAGE: -% [problemTypeParams, solverParams] = parseSolverParameters(problemType,varargin) +% [params, solverOnlyParams] = parseSolverParameters(problemType,varargin) % % INPUT: % problemType: The type of the problem to get parameters for -% ('LP','MILP','QP','MIQP','NLP','EP') +% ('LP','MILP','QP','MIQP','NLP','EP','CLP') % % OPTIONAL INPUTS: % varargin: Additional parameters either as parameter struct, or as @@ -16,19 +16,16 @@ % the parameter struct is either at the beginning or the % end of the optional input. % All fields of the struct which are not COBRA parameters -% (see `getCobraSolverParamsOptionsForType`) for this +% (see `getCobraParamsOptionsForType`) for this % problem type will be passed on to the solver in a % solver specific manner. % % OUTPUTS: -% problemTypeParams: The COBRA Toolbox specific parameters for this -% problem type given the provided parameters +% params: The COBRA Toolbox specific parameters for this problem type given the provided parameters, plus any additional parameters % -% solverParams: Additional parameters provided which are not part -% of the COBRA parameters and are assumed to be part -% of direct solver input structs. For some solvers, it -% is essential to not include any extraneous fields that are -% outside the solver interface specification. +% solverOnlyParams: Structure of parameters that only contains fields that can be passed to a specific solver, e.g., gurobi or mosek. +% For some solvers, it is essential to NOT include any extraneous fields that are outside the solver interface specification, +% otherwise an error will result. cobraSolverParameters = getCobraSolverParamsOptionsForType(problemType); % build the default Parameter Structure @@ -48,13 +45,13 @@ if nVarargin > 0 % we should have a struct at the end if mod(nVarargin,2) == 1 - optParamStruct = varargin{end}; - if ~isstruct(optParamStruct) + solverOnlyParams = varargin{end}; + if ~isstruct(solverOnlyParams) % but it could also be at the first position, so test that as well. - optParamStruct = varargin{1}; + solverOnlyParams = varargin{1}; varargin(1) = []; nVarargin = numel(varargin); %added this in case varagin{1} is the parameter structure - if ~isstruct(optParamStruct) + if ~isstruct(solverOnlyParams) error(['Invalid Parameters supplied.\n',... 'Parameters have to be supplied either as parameter/Value pairs, or as struct.\n',... 'A combination is possible, if the last or first input argument is a struct, and all other arguments are parameter/value pairs']) @@ -62,9 +59,10 @@ else varargin(end) = []; end + else % no parameter struct. so initialize an empty one. - optParamStruct = struct(); + solverOnlyParams = struct(); end nVarargin = numel(varargin); %added this in case varagin{1} is the parameter structure % now, loop through all parameter/value pairs. @@ -75,9 +73,9 @@ end % the param struct overrides the only use the parameter if it is % not a field of the parameter struct. - if ~isfield(optParamStruct,cparam) + if ~isfield(solverOnlyParams,cparam) try - optParamStruct.(cparam) = varargin{i+1}; + solverOnlyParams.(cparam) = varargin{i+1}; catch error('All parameters have to be valid matlab field names. %s is not a valid field name',cparam); end @@ -87,23 +85,28 @@ end else % no optional parameters. - optParamStruct = struct(); + solverOnlyParams = struct(); end % set up the cobra parameters -problemTypeParams = struct(); +params = struct(); for i = 1:numel(defaultParams(:,1)) % if the field is part of the optional parameters (i.e. explicitly provided) use it. - if isfield(optParamStruct,defaultParams{i,1}) - problemTypeParams.(defaultParams{i,1}) = optParamStruct.(defaultParams{i,1}); + if isfield(solverOnlyParams,defaultParams{i,1}) + params.(defaultParams{i,1}) = solverOnlyParams.(defaultParams{i,1}); % and remove the field from the struct for the solver specific parameters. - optParamStruct = rmfield(optParamStruct,defaultParams{i,1}); + solverOnlyParams = rmfield(solverOnlyParams,defaultParams{i,1}); else % otherwise use the default parameter - problemTypeParams.(defaultParams{i,1}) = defaultParams{i,2}; + params.(defaultParams{i,1}) = defaultParams{i,2}; end end -% assign all remaining parameters to the solver parameter struct. -solverParams = optParamStruct; \ No newline at end of file +% %duplicate this parameter in both structures +% if isfield(params,'printLevel') && ~isfield(solverOnlyParams,'printLevel') +% solverOnlyParams.printLevel = params.printLevel; +% end +% if isfield(params,'debug') && ~isfield(solverOnlyParams,'debug') +% solverOnlyParams.debug = params.debug; +% end \ No newline at end of file diff --git a/src/base/solvers/solveCobraLP.m b/src/base/solvers/solveCobraLP.m index 7955243043..dcb2e7df51 100644 --- a/src/base/solvers/solveCobraLP.m +++ b/src/base/solvers/solveCobraLP.m @@ -739,9 +739,18 @@ end %parse mosek result structure - [stat,origStat,x,y,w, wl, wu ,s,~,basis] = parseMskResult(res,A,blc,buc,problemTypeParams.printLevel,param); + %[stat,origStat,x,y,w, wl, wu ,s,~,basis] = parseMskResult(res,A,blc,buc,problemTypeParams.printLevel,param); + [stat,origStat,x,y,yl,yu,z,zl,zu,k,basis,pobjval,dobjval] = parseMskResult(res,solverParams,problemTypeParams.printLevel); if stat ==1 f=c'*x; + %slacks + sbl = prob.a*x - prob.blc; + sbu = prob.buc - prob.a*x; + s = sbu - sbl; %TODO -double check this + if problemTypeParams.printLevel>1 + fprintf('%8.2g %s\n',min(sbl), ' min(sbl) = min(A*x - bl), (should be positive)'); + fprintf('%8.2g %s\n',min(sbu), ' min(sbu) = min(bu - A*x), (should be positive)'); + end else f = NaN; s = NaN*ones(size(A,1),1); diff --git a/src/reconstruction/modelGeneration/stoichConsistency/checkStoichiometricConsistency.m b/src/reconstruction/modelGeneration/stoichConsistency/checkStoichiometricConsistency.m index db769cbc28..a053243dcb 100644 --- a/src/reconstruction/modelGeneration/stoichConsistency/checkStoichiometricConsistency.m +++ b/src/reconstruction/modelGeneration/stoichConsistency/checkStoichiometricConsistency.m @@ -99,21 +99,25 @@ epsilon = feasTol*10; [nMet,nRxn]=size(model.S); -if ~isfield(model,'mets') - %assume all reactions are internal - model.SIntRxnBool=true(nRxn,1); - intR=''; + +if isfield(model,'SConsistentRxnBool') + model.SIntRxnBool=model.SConsistentRxnBool; else - if ~isfield(model,'SIntRxnBool') || ~isfield(model,'SIntMetBool') - %Requires the openCOBRA toolbox - model=findSExRxnInd(model); - intR='internal reaction '; - else + if ~isfield(model,'mets') + %assume all reactions are internal + model.SIntRxnBool=true(nRxn,1); intR=''; + else + if ~isfield(model,'SIntRxnBool') || ~isfield(model,'SIntMetBool') + %Requires the openCOBRA toolbox + model=findSExRxnInd(model); + intR='internal reaction '; + else + intR=''; + end end end - checkConsistency = 0; if checkConsistency diff --git a/test/verifiedTests/analysis/testTopology/model.ext b/test/verifiedTests/analysis/testTopology/model.ext new file mode 100644 index 0000000000..dbbfc13f6e --- /dev/null +++ b/test/verifiedTests/analysis/testTopology/model.ext @@ -0,0 +1,16 @@ + +*lrs:lrslib v.7.1 2021.6.2(64bit,lrslong.h,hybrid arithmetic) +*Input taken from /home/rfleming/drive/sbgCloud/code/fork-cobratoolbox/test/verifiedTests/analysis/testTopology/model.ine +/home/rfleming/drive/sbgCloud/code/fork-cobratoolbox/test/verifiedTests/analysis/testTopology/model +*Input linearity in row 3 is redundant--converted to inequality +V-representation +begin +***** 4 rational + 1 0 0 0 + 0 1 1 1 +end +*Totals: vertices=1 rays=1 bases=1 integer_vertices=1 vertices+rays=2 +*Dictionary Cache: max size= 1 misses= 0/0 Tree Depth= 0 +*Overflow checking on lrslong arithmetic +*lrs:lrslib v.7.1 2021.6.2(64bit,lrslong.h,hybrid arithmetic) +*0.001u 0.000s 1920Kb 0 flts 0 swaps 0 blks-in 8 blks-out diff --git a/test/verifiedTests/analysis/testTopology/model.ine b/test/verifiedTests/analysis/testTopology/model.ine new file mode 100644 index 0000000000..1fc4dfcd2f --- /dev/null +++ b/test/verifiedTests/analysis/testTopology/model.ine @@ -0,0 +1,12 @@ +/home/rfleming/drive/sbgCloud/code/fork-cobratoolbox/test/verifiedTests/analysis/testTopology/model +H-representation +linearity 3 1 2 3 +begin +6 4 integer +0 -1 1 0 +0 0 -1 1 +0 1 0 -1 +0 1 0 0 +0 0 1 0 +0 0 0 1 +end diff --git a/test/verifiedTests/analysis/testTopology/testLrsInterface.m b/test/verifiedTests/analysis/testTopology/testLrsInterface.m index b48faa79a5..cda0567ac7 100644 --- a/test/verifiedTests/analysis/testTopology/testLrsInterface.m +++ b/test/verifiedTests/analysis/testTopology/testLrsInterface.m @@ -45,6 +45,8 @@ %read in vertex representation [Q, vertexBool, fileNameOut] = lrsReadRay(modelName,param); system(['cp ' fileNameOut ' ' strrep(fileNameOut,'test.ext','test_lrs.ext')]) + + %write out vertex representation [fileNameOut, extension] = lrsWriteRay(Q,modelName,vertexBool,param); system(['cp ' fileNameOut ' ' strrep(fileNameOut,'test.ext','test_manual.ext')]) @@ -62,6 +64,7 @@ %implicit representation of a polytope is not uniquely defined from vertex representation assert(all((S - A)==0,'all')) end + %write out halfspace representation fileNameOut = lrsWriteHalfspace(A, b, csense, [modelName '2'], param); %run lrs diff --git a/tutorials b/tutorials index f87dc50aae..641bf645c8 160000 --- a/tutorials +++ b/tutorials @@ -1 +1 @@ -Subproject commit f87dc50aaeb4d58b84d1070c42b1ebd8f4ec3b56 +Subproject commit 641bf645c84712868d3f600d6c259aa1035cd1c9 From 491d155eb3b920c051a3fd3daeb5ef02b0c8f0cd Mon Sep 17 00:00:00 2001 From: Farid Zare Date: Thu, 29 Aug 2024 00:06:05 +0100 Subject: [PATCH 2/8] update submodules --- external/visualization/MatGPT | 2 +- papers | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/external/visualization/MatGPT b/external/visualization/MatGPT index dde437afc8..d755ac9a9d 160000 --- a/external/visualization/MatGPT +++ b/external/visualization/MatGPT @@ -1 +1 @@ -Subproject commit dde437afc8995494e65a3e3cdddb2212820dd59e +Subproject commit d755ac9a9d77887c203d46924f705da5f37603a9 diff --git a/papers b/papers index dac18f7107..2cecd22a95 160000 --- a/papers +++ b/papers @@ -1 +1 @@ -Subproject commit dac18f7107ad09358c0df704da9a05bb2d347a67 +Subproject commit 2cecd22a955fa60e7405cffb99a691707c686d1d From aaf9040a151a33413abd4aaa0c1eab8b6d6d82a0 Mon Sep 17 00:00:00 2001 From: yixing Date: Thu, 29 Aug 2024 13:52:49 +0100 Subject: [PATCH 3/8] add initCobrarrow bug fix matlab version compatibility doc update --- src/cobrarrow/COBRArrow.m | 14 ++++----- src/cobrarrow/README.md | 2 -- src/cobrarrow/initCobrarrow.m | 55 +++++++++++++++++++++++++++++++++++ 3 files changed, 62 insertions(+), 9 deletions(-) create mode 100644 src/cobrarrow/initCobrarrow.m diff --git a/src/cobrarrow/COBRArrow.m b/src/cobrarrow/COBRArrow.m index ffb09e4551..deddb81636 100644 --- a/src/cobrarrow/COBRArrow.m +++ b/src/cobrarrow/COBRArrow.m @@ -500,9 +500,9 @@ function sendField(obj, schemaName, fieldName, fieldData, toOverwrite) % Convert specific fields in the struct to appropriate MATLAB types resultStruct.rxns = cellfun(@char, cell(tempStruct.rxns), 'UniformOutput', false)'; - resultStruct.flux = double(tempStruct.flux)'; + resultStruct.flux = cellfun(@double, cell(tempStruct.flux))'; resultStruct.status = char(tempStruct.status); - resultStruct.objective_value = double(tempStruct.objective_value); + resultStruct.objective_value = cellfun(@double, cell(tempStruct.objective_value)); end function persistModel(obj, schemaName, toOverwrite) @@ -1065,7 +1065,7 @@ function loadFromDuckDB(obj,schemaName) columnData = pyArrowTable.columns{1}.to_pylist(); % Convert the Python list to a MATLAB double array - data = double(columnData); + data = cellfun(@double, cell(columnData)); % Transpose the double array to match the expected MATLAB format fieldData = data'; @@ -1139,9 +1139,9 @@ function loadFromDuckDB(obj,schemaName) ncols = dimensionsArray(2); % Extract row, column, and value data from the PyArrow table - row = double(pyArrowTable.columns{1}.to_pylist()); - col = double(pyArrowTable.columns{2}.to_pylist()); - val = double(pyArrowTable.columns{3}.to_pylist()); + row = cellfun(@double, cell(pyArrowTable.columns{1}.to_pylist())); + col = cellfun(@double, cell(pyArrowTable.columns{2}.to_pylist())); + val = cellfun(@double, cell(pyArrowTable.columns{3}.to_pylist())); % Construct a sparse matrix from the row, column, and value data fieldData = sparse(row, col, val, nrows, ncols); @@ -1236,7 +1236,7 @@ function loadFromDuckDB(obj,schemaName) columnData = pyArrowTable.columns{1}.to_pylist(); % Convert the Python list to a MATLAB int64 array - data = int64(columnData); + data = cellfun(@int64, cell(columnData)); % Transpose the integer array to match the expected MATLAB format fieldData = data'; end diff --git a/src/cobrarrow/README.md b/src/cobrarrow/README.md index a40f7508b5..575ffd5042 100644 --- a/src/cobrarrow/README.md +++ b/src/cobrarrow/README.md @@ -80,8 +80,6 @@ To ensure MATLAB uses this Python environment every time it starts: ### Example Use of Calling MATLAB APIs -Refer to MATLAB script `testMatlab.m` that uses the MATLAB API to perform some operations. - ```matlab % Example MATLAB script to use the MATLAB API diff --git a/src/cobrarrow/initCobrarrow.m b/src/cobrarrow/initCobrarrow.m new file mode 100644 index 0000000000..afb5e6d76e --- /dev/null +++ b/src/cobrarrow/initCobrarrow.m @@ -0,0 +1,55 @@ +function initCobrarrow() +% Display MATLAB version +ver = version; +fprintf('MATLAB Version: %s\n', ver); + +% Check if Python is installed and display details +pyEnvInfo = pyenv; + +if isempty(pyEnvInfo.Executable) + showInstruction(); +else + fprintf('Python is installed. Version: '); + disp(pyEnvInfo.Version); + fprintf('Python Executable: \n'); + disp(pyEnvInfo.Executable); + + try + py.math.sqrt(16); % test Python version compatibility + + % Get the current directory where the MATLAB script is located + currentFile = mfilename('fullpath'); + currentDir = fileparts(currentFile); + + % Construct the full path to the requirements.txt file + requirementsPath = fullfile(currentDir, 'requirements.txt'); + + % Use python -m pip install to ensure pip is invoked correctly + cmd = sprintf('"%s" -m pip install -r "%s"', pyEnvInfo.Executable, requirementsPath); + status = system(cmd); + + if status == 0 + disp('Packages installed successfully.'); + else + disp('Failed to install packages.'); + end + + fprintf("\nNow you can use the COBRArrow API in MATLAB.\n\n"); + catch ME + showInstruction(); + error('Failed to use Python. Please check the Python installation.\n'); + end +end +end + +function showInstruction() + versionLink = 'https://uk.mathworks.com/support/requirements/python-compatibility.html?s_tid=srchtitle_site_search_1_python%20compatibility'; + versionLabel = 'Versions of Python Compatible with MATLAB'; + configLink = 'https://uk.mathworks.com/help/matlab/matlab_external/install-supported-python-implementation.html'; + configLabel = 'Configure Your System to Use Python'; + + fprintf('Python is not installed or not set up in MATLAB.\n'); + fprintf('Please install the correct version of Python according to '); + fprintf('%s. \n', versionLink, versionLabel); + fprintf('Please refer to %s to config Python in MATLAB. \n', configLink,configLabel); +end \ No newline at end of file From 084795a90d397e3958c5783db43916a90ed7802f Mon Sep 17 00:00:00 2001 From: yixing Date: Thu, 29 Aug 2024 14:18:46 +0100 Subject: [PATCH 4/8] doc update --- src/cobrarrow/README.md | 135 +++++++++------------------------------- 1 file changed, 30 insertions(+), 105 deletions(-) diff --git a/src/cobrarrow/README.md b/src/cobrarrow/README.md index 575ffd5042..cf2b504021 100644 --- a/src/cobrarrow/README.md +++ b/src/cobrarrow/README.md @@ -1,152 +1,77 @@ # Instructions for Using MATLAB API -This guide will walk you through setting up and running the MATLAB API. +## Step 1: Run `initCobrarrow` -## Step 1: Install Python and Create a Virtual Environment +1. In the MATLAB command window, run the following command: -If Python is not installed on your system, download and install a compatible version as specified by [MathWorks](https://www.mathworks.com/content/dam/mathworks/mathworks-dot-com/support/sysreq/files/python-support.pdf). + ```matlab + initCobrarrow() + ``` +2. If you encounter an error while running `initCobrarrow`, it may be because Python is not installed on your system or the installed version is not compatible with MATLAB. -### 1.1 Create a Virtual Environment + - **Solution**: Download and install a compatible version of Python as specified by [MathWorks Python Compatibility](https://www.mathworks.com/content/dam/mathworks/mathworks-dot-com/support/sysreq/files/python-support.pdf). + - For detailed instructions on installing and configuring Python, refer to the following MathWorks documentation: + - [Configure Your System to Use Python](https://uk.mathworks.com/help/matlab/matlab_external/install-supported-python-implementation.html) -Ensure you use a Python version compatible with your MATLAB installation. For this example, we'll use Python 3.9. -```sh -# Specify Python version when you create the virtual environment -python3.9 -m venv python_env -``` - -### 1.2 Activate the Virtual Environment - -Activate the virtual environment you just created: - -- **On Windows:** - - ```sh - .\python_env\Scripts\activate - ``` - -- **On macOS and Linux:** - - ```sh - source python_env/bin/activate - ``` - -## Step 2: Install Dependencies - -Once the virtual environment is activated, install the required dependencies using the `requirements.txt` file provided with the MATLAB API: - -```sh -pip install -r requirements.txt -``` - -## Step 3: Set MATLAB to Use the Python Environment - -To ensure MATLAB uses this Python environment every time it starts: - -1. **Locate or Create the MATLAB Startup File**: - - - On Windows: The `startup.m` file is usually located in the `Documents\MATLAB` folder. - - On macOS or Linux: The file is also named `startup.m` and is located in the MATLAB user folder, typically `~/Documents/MATLAB`. - If the file doesn't exist, create a new `startup.m` file in the appropriate directory. - -2. **Edit the Startup File**: - - Add the following lines to the `startup.m` file, replacing the path with the full path to your virtual environment's Python executable: - - ```matlab - % Set the Python environment for MATLAB - pyenv('Version', fullfile('path_to_your_virtual_env', 'python')); - ``` - - For example: - - ```matlab - pyenv('Version', fullfile('C:\COBRArrow\client\MATLAB_API\python_env\Scripts\python.exe')); - ``` - - Or on macOS/Linux: - - ```matlab - pyenv('Version', fullfile('~/Document/COBRArrow/client/MATLAB_API/python_env/bin/python')); - ``` - -3. **Save and Restart MATLAB**: - - Save the `startup.m` file. When you restart MATLAB, it will automatically use the specified Python environment. - - -## Step 4: MATLAB API is ready to use. +## Step 2: MATLAB API is ready to use. ### Example Use of Calling MATLAB APIs ```matlab % Example MATLAB script to use the MATLAB API -% load the model -recon3D_model = load('../models/Recon3D_301.mat').Recon3D; -model = recon3D_model; -schemaName = 'Recon3D'; +% Load the model +file = load('path/to/your_model.mat'); +model = file.yourModel; +schemaName = 'yourModelName'; % Create an instance of the COBRArrow class -host = 'localhost'; -port = 443; +host = 'cobrarrowServerHost'; +port = cobrarrowServerPort; % port is optional if you use a domain name for host client = COBRArrow(host, port); % Login -client = client.login('johndoe', '123456'); +client = client.login('username', 'password'); % Send the model to the server client.sendModel(model, schemaName); +% Persist the model in DuckDB +client.persistModel(schemaName, true); + % Read the model back for general purposes fetchedModel = client.fetchModel(schemaName); -disp('Fetched Model:'); -disp(fetchedModel); % For example, fetchedModel can be used to do simulations using COBRA Toolbox initCobraToolbox; solution = optimizeCbModel(fetchedModel); -disp('solution:'); -disp(solution); % Read the model back for FBA analysis FBAmodel = client.fetchModelForFBAAnalysis(schemaName); -disp('FBA Model:'); -disp(FBAmodel); % Set solver for using remote optimization service arrowSolver = COBRArrowSolver('GLPK'); -arrowSolver.setParameter('tol', 1e-9); -% Perform optimization -resultStruct = client.optimizeModel(schemaName,arrowSolver); -% Display results -disp('Optimization Results:'); -disp(resultStruct); +% Perform optimization +resultStruct = client.optimizeModel(schemaName, arrowSolver); -% Example of sending an individual field -fieldName = 'rxns'; -fieldData = model.rxns; +% Example of sending flux value(an individual field) for animation +fieldName = 'flux'; +fieldData = resultStruct.flux; client.sendField(schemaName, fieldName, fieldData); % Read the field back -readData = client.readField(schemaName, fieldName); -disp('Read Field Data:'); -disp(readData); +readData = client.fetchField(schemaName, fieldName); -% List all flight information in a schema on server +% List all flight information in a schema on the server flightsList = client.listAllFlights(schemaName); -disp('Flights List in schema:'); -disp(flightsList); -% List all flight information on server +% List all flight information on the server flightsList = client.listAllFlights(); -disp('All Flights List:'); -disp(flightsList); -% Get all unique identifier of the flights in a schema +% Get all unique identifiers of the flights in a schema descriptors = client.getAllDescriptors(schemaName); -disp('Descriptors:'); -disp(descriptors); + ``` From 67f6a17d6836255ceaed19abfea937b0f74eb3ed Mon Sep 17 00:00:00 2001 From: yixing Date: Thu, 29 Aug 2024 16:18:57 +0100 Subject: [PATCH 5/8] add test to make sure all dependencies work in initCobrarrow --- src/cobrarrow/initCobrarrow.m | 46 +++++++++++++++++++---------------- 1 file changed, 25 insertions(+), 21 deletions(-) diff --git a/src/cobrarrow/initCobrarrow.m b/src/cobrarrow/initCobrarrow.m index afb5e6d76e..a903ab2173 100644 --- a/src/cobrarrow/initCobrarrow.m +++ b/src/cobrarrow/initCobrarrow.m @@ -14,31 +14,35 @@ function initCobrarrow() fprintf('Python Executable: \n'); disp(pyEnvInfo.Executable); + % test Python version compatibility try - py.math.sqrt(16); % test Python version compatibility - - % Get the current directory where the MATLAB script is located - currentFile = mfilename('fullpath'); - currentDir = fileparts(currentFile); - - % Construct the full path to the requirements.txt file - requirementsPath = fullfile(currentDir, 'requirements.txt'); - - % Use python -m pip install to ensure pip is invoked correctly - cmd = sprintf('"%s" -m pip install -r "%s"', pyEnvInfo.Executable, requirementsPath); - status = system(cmd); - - if status == 0 - disp('Packages installed successfully.'); - else - disp('Failed to install packages.'); - end - - fprintf("\nNow you can use the COBRArrow API in MATLAB.\n\n"); + py.math.sqrt(16); catch ME showInstruction(); - error('Failed to use Python. Please check the Python installation.\n'); + rethrow(ME); end + + % Get the current directory where the MATLAB script is located + currentFile = mfilename('fullpath'); + currentDir = fileparts(currentFile); + + % Construct the full path to the requirements.txt file + requirementsPath = fullfile(currentDir, 'requirements.txt'); + + % Use python -m pip install to ensure pip is invoked correctly + cmd = sprintf('"%s" -m pip install -r "%s"', pyEnvInfo.Executable, requirementsPath); + status = system(cmd); + + if status == 0 + % test if pyarrow package works + py.pyarrow.table(py.dict(pyargs('column1', {1, 2, 3, 4}))); + disp('Python package pyarrow installed successfully.'); + else + error('Failed to install packages.'); + end + + fprintf("\nNow you can use the COBRArrow API in MATLAB.\n\n"); + end end From a28d280ccef20bc3e3186f3ea6ec1f1c03839538 Mon Sep 17 00:00:00 2001 From: Farid Zare Date: Fri, 30 Aug 2024 17:07:03 +0100 Subject: [PATCH 6/8] Re-added MatGPT submodule after cleanup --- .gitmodules | 3 +++ external/visualization/MatGPT | 1 + 2 files changed, 4 insertions(+) create mode 160000 external/visualization/MatGPT diff --git a/.gitmodules b/.gitmodules index 5ec9a14a33..1a865cc517 100644 --- a/.gitmodules +++ b/.gitmodules @@ -78,3 +78,6 @@ url = https://github.com/tpfau/xlread ignore = dirty branch = master +[submodule "external/visualization/MatGPT"] + path = external/visualization/MatGPT + url = git@github.com:toshiakit/MatGPT.git diff --git a/external/visualization/MatGPT b/external/visualization/MatGPT new file mode 160000 index 0000000000..d755ac9a9d --- /dev/null +++ b/external/visualization/MatGPT @@ -0,0 +1 @@ +Subproject commit d755ac9a9d77887c203d46924f705da5f37603a9 From 8bf37c06a67099df1c6ce613d64b3d3d152eec74 Mon Sep 17 00:00:00 2001 From: Farid Zare Date: Fri, 30 Aug 2024 17:08:40 +0100 Subject: [PATCH 7/8] update .gitmodules --- .gitmodules | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitmodules b/.gitmodules index 1a865cc517..31f8babc6f 100644 --- a/.gitmodules +++ b/.gitmodules @@ -80,4 +80,6 @@ branch = master [submodule "external/visualization/MatGPT"] path = external/visualization/MatGPT - url = git@github.com:toshiakit/MatGPT.git + url = https://github.com/toshiakit/MatGPT.git + ignore = dirty + branch = main From 9b83c79a88c2d52828137be41f7ccb63e643f0a4 Mon Sep 17 00:00:00 2001 From: Farid Zare Date: Fri, 30 Aug 2024 17:17:31 +0100 Subject: [PATCH 8/8] Remove the out of date non submodule version of MatGPT --- src/chatGPT/MatGPT/.gitattributes | 28 --- src/chatGPT/MatGPT/.gitignore | 4 - src/chatGPT/MatGPT/CONTRIBUTING.md | 25 -- src/chatGPT/MatGPT/LICENSE.txt | 21 -- src/chatGPT/MatGPT/MatGPT.mlapp | Bin 96274 -> 0 bytes src/chatGPT/MatGPT/README.md | 123 ---------- src/chatGPT/MatGPT/SECURITY.md | 3 - src/chatGPT/MatGPT/contents/index.html | 50 ---- src/chatGPT/MatGPT/contents/presets.csv | 288 ----------------------- src/chatGPT/MatGPT/contents/styles.css | 116 --------- src/chatGPT/MatGPT/helpers/CodeChecker.m | 268 --------------------- src/chatGPT/MatGPT/helpers/TextHelper.m | 174 -------------- src/chatGPT/MatGPT/helpers/chatGPT.m | 160 ------------- src/chatGPT/MatGPT/helpers/chatter.m | 114 --------- 14 files changed, 1374 deletions(-) delete mode 100644 src/chatGPT/MatGPT/.gitattributes delete mode 100644 src/chatGPT/MatGPT/.gitignore delete mode 100644 src/chatGPT/MatGPT/CONTRIBUTING.md delete mode 100644 src/chatGPT/MatGPT/LICENSE.txt delete mode 100644 src/chatGPT/MatGPT/MatGPT.mlapp delete mode 100644 src/chatGPT/MatGPT/README.md delete mode 100644 src/chatGPT/MatGPT/SECURITY.md delete mode 100644 src/chatGPT/MatGPT/contents/index.html delete mode 100644 src/chatGPT/MatGPT/contents/presets.csv delete mode 100644 src/chatGPT/MatGPT/contents/styles.css delete mode 100644 src/chatGPT/MatGPT/helpers/CodeChecker.m delete mode 100644 src/chatGPT/MatGPT/helpers/TextHelper.m delete mode 100644 src/chatGPT/MatGPT/helpers/chatGPT.m delete mode 100644 src/chatGPT/MatGPT/helpers/chatter.m diff --git a/src/chatGPT/MatGPT/.gitattributes b/src/chatGPT/MatGPT/.gitattributes deleted file mode 100644 index d2bd0c966f..0000000000 --- a/src/chatGPT/MatGPT/.gitattributes +++ /dev/null @@ -1,28 +0,0 @@ -*.fig binary -*.mat binary -*.mdl binary diff merge=mlAutoMerge -*.mdlp binary -*.mexa64 binary -*.mexw64 binary -*.mexmaci64 binary -*.mlapp binary -*.mldatx binary -*.mlproj binary -*.mlx binary -*.p binary -*.sfx binary -*.sldd binary -*.slreqx binary merge=mlAutoMerge -*.slmx binary merge=mlAutoMerge -*.sltx binary -*.slxc binary -*.slx binary merge=mlAutoMerge -*.slxp binary - -## Other common binary file types -*.docx binary -*.exe binary -*.jpg binary -*.pdf binary -*.png binary -*.xlsx binary diff --git a/src/chatGPT/MatGPT/.gitignore b/src/chatGPT/MatGPT/.gitignore deleted file mode 100644 index 7371b76ea9..0000000000 --- a/src/chatGPT/MatGPT/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -*.mlx -contents/ChatData.mat -contents/GeneratedCode/ -helpers/*.asv \ No newline at end of file diff --git a/src/chatGPT/MatGPT/CONTRIBUTING.md b/src/chatGPT/MatGPT/CONTRIBUTING.md deleted file mode 100644 index e53523faa3..0000000000 --- a/src/chatGPT/MatGPT/CONTRIBUTING.md +++ /dev/null @@ -1,25 +0,0 @@ -# Contributing - ->_If you believe you have discovered a security vulnerability, please **do not** open an issue or make a pull request. Follow the instructions in the [SECURITY.MD](SECURITY.md) file in this repository._ - -Thank you for your interest in contributing to this repo. - -**Contributions do not have to be code!** If you see a way to explain things more clearly or a great example of how to use something, please contribute it (or a link to your content). We welcome issues even if you don't code the solution. We also welcome pull requests to resolve issues that we haven't gotten to yet! - -## How to contribute - -* **Open an issue:** Start by [creating an issue](https://docs.github.com/en/issues/tracking-your-work-with-issues/creating-an-issue) in the repository that you're interested in. That will start a conversation with the maintainer. When you are creating a bug report, please include as many details as possible. Please remember that other people do not have your background or understanding of the issue; make sure you are clear and complete in your description. -* **Work in your own public fork:** If you choose to make a contribution, you should [fork the repository](https://docs.github.com/en/get-started/quickstart/fork-a-repo). This creates an editable copy on GitHub where you can write, test, and refine your changes. We suggest that you keep your changes small and focused on the issue you submitted. -* **Sign a Contributor License Agreement (CLA):** We require that all outside contributors sign a [CLA](https://en.wikipedia.org/wiki/Contributor_License_Agreement) before we can accept your contribution. When you create a pull request (see below), we'll reach out to you if you do not already have one on file. Essentially, the CLA gives us permission to publish your contribution as part of the repository. -* **Make a pull request:** "[Pull Request](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/about-pull-requests)" is a confusing term, but it means exactly what it says: You're requesting that the maintainers of the repository pull your changes in. If you don't have a CLA on file, we'll reach out to you. Your contribution will be reviewed, and we may ask you to revise your pull request based on our feedback. Once everyone is satisfied, we'll merge your pull request into the repository. - -## Guidelines - -We don't have best practices for writing MATLAB® code, but we do have some recommendations: - -* You should not have any warnings or errors in the [code analyzer report](http://www.mathworks.com/help/matlab/matlab_prog/matlab-code-analyzer-report.html) -* [Loren Shure's blog](https://blogs.mathworks.com/loren) has [great advice on improving your MATLAB code](https://blogs.mathworks.com/loren/category/best-practice/) -* Examples should be written as [live scripts](https://www.mathworks.com/help/matlab/matlab_prog/what-is-a-live-script-or-function.html) and then [exported as HTML](https://www.mathworks.com/help/matlab/matlab_prog/share-live-scripts.html). -* We adhere to the [CommonMark](https://commonmark.org/) specification where it does not conflict with GitHub rendering. If you edit your Markdown in Visual Studio Code or a similar editor, it uses [markdownlint](https://github.com/DavidAnson/markdownlint) to highlight issues in your Markdown. - -**Again, thanks for contributing, and we look forward to your issues and pull requests!** diff --git a/src/chatGPT/MatGPT/LICENSE.txt b/src/chatGPT/MatGPT/LICENSE.txt deleted file mode 100644 index 2969c2a68e..0000000000 --- a/src/chatGPT/MatGPT/LICENSE.txt +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2023 Toshiaki Takeuchi - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/src/chatGPT/MatGPT/MatGPT.mlapp b/src/chatGPT/MatGPT/MatGPT.mlapp deleted file mode 100644 index f832dbb18497b0bfda503ef16d6e33afba77eba4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 96274 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⪚v^VxO8zpD#@n90-(!kp5L-MCUUbE!8Rc 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

18bL*u9uS% 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 kzg7Rew*&qU)&GMjD=!5O@vrAW11^(*! - - - - - - - -

-
-
Role
-
Content
-
-
- - - \ No newline at end of file diff --git a/src/chatGPT/MatGPT/contents/presets.csv b/src/chatGPT/MatGPT/contents/presets.csv deleted file mode 100644 index 6ee4b4ec3b..0000000000 --- a/src/chatGPT/MatGPT/contents/presets.csv +++ /dev/null @@ -1,288 +0,0 @@ -name,content,prompt,model,max_tokens,temperature,test_code -AI Assistant,You are a helpful assistant. Answer as concisely as possible. ,Where is the capital of France?,gpt-3.5-turbo,1000,1,0 -English to MATLAB Code,You are a helpful assistant that generates MATLAB code using the latest features from R2021a or later. ,"Define two random vectors x and y, fit a linear model to the data, and plot both the data and fitted line.",gpt-3.5-turbo,1000,0,1 -English to Simulink Model,"You are a helpful assistant that creates Simulink models. -You create the models by generating MATLAB code that adds all the necessary blocks and set their parameters. -Automatically arrange the blocks by adding this line of code: -Simulink.BlockDiagram.arrangeSystem(model) -Save the model and run it.","Model name: 'sine_multiplied' -Add Sine Wave block with amplification = 1. -Multiply the sine wave signal by 3. -Add Scope block with 2 input ports. -Visualize both signals by connecting them to the same Scope block. ",gpt-3.5-turbo,1000,0,1 -Summarize Code,You are a friendly and helpful teaching assistant for MATLAB programmers. Analyze MATLAB code and provide concise summary of what the code does. ,"Summarize what the following code by Cecelya https://www.mathworks.com/matlabcentral/communitycontests/contests/5/entries/12418 does. - -```matlab -m = 0:.01:1; -T = -4:.01:25; -x=1-(5/4*(1-mod(3.6*T, 2)).^2-1/4).^2/2; -P=exp(-T/5)/2; -s=sinpi(P); -c=cospi(P); -y=2*m'.^5.*(1.6*m'-1).^2.*s; -S=x.*(m'.*s+y.*c); -X=S.*sinpi(T); -Y=S.*cospi(T); -Z=m'.*c+y.*s; -surf(X,Y,x.*Z,X.^2+Y.^2+Z.^4); -axis( 'equal','off') -view([0 33]) -colormap(flip(hot)) -shading('interp') -```",gpt-3.5-turbo,1000,0,0 -Explain Code Step by Step,You are a friendly and helpful teaching assistant for MATLAB programmers. Analyze MATLAB code and explain the code step by step.,"Explain what the following code by Cecelya https://www.mathworks.com/matlabcentral/communitycontests/contests/5/entries/12418 does. - -```matlab -m = 0:.01:1; -T = -4:.01:25; -x=1-(5/4*(1-mod(3.6*T, 2)).^2-1/4).^2/2; -P=exp(-T/5)/2; -s=sinpi(P); -c=cospi(P); -y=2*m'.^5.*(1.6*m'-1).^2.*s; -S=x.*(m'.*s+y.*c); -X=S.*sinpi(T); -Y=S.*cospi(T); -Z=m'.*c+y.*s; -surf(X,Y,x.*Z,X.^2+Y.^2+Z.^4); -axis( 'equal','off') -view([0 33]) -colormap(flip(hot)) -shading('interp') -```",gpt-3.5-turbo,1000,0,0 -Script to function,You are a friendly and helpful teaching assistant for MATLAB programmers. Convert MATLAB scripts to a local function and call the function using sample inputs. ,"Convert the following script by Cecelya https://www.mathworks.com/matlabcentral/communitycontests/contests/5/entries/12418 to a function, using m and T as input variables and set default values to those variables. - -```matlab -m = 0:.01:1; -T = -4:.01:25; -x=1-(5/4*(1-mod(3.6*T, 2)).^2-1/4).^2/2; -P=exp(-T/5)/2; -s=sinpi(P); -c=cospi(P); -y=2*m'.^5.*(1.6*m'-1).^2.*s; -S=x.*(m'.*s+y.*c); -X=S.*sinpi(T); -Y=S.*cospi(T); -Z=m'.*c+y.*s; -surf(X,Y,x.*Z,X.^2+Y.^2+Z.^4); -axis( 'equal','off') -view([0 33]) -colormap(flip(hot)) -shading('interp') -```",gpt-3.5-turbo,1000,0,1 -Write doc to function,"You are a friendly and helpful teaching assistant for MATLAB programmers. Analyze a MATLAB function and add an elaborate, high quality docstring to the function.","Write useful docstring to this function derived from the code by Cecelya https://www.mathworks.com/matlabcentral/communitycontests/contests/5/entries/12418, using comment format %PLOTBLOOMINGLIGHTS does blah blah blah. - -```matlab -function plotBloomingLight(m,T) -arguments - m double {mustBeVector} = 0:.01:1; - T double {mustBeVector} = -4:.01:25; -end -x=1-(5/4*(1-mod(3.6*T, 2)).^2-1/4).^2/2; -P=exp(-T/5)/2; -s=sinpi(P); -c=cospi(P); -y=2*m'.^5.*(1.6*m'-1).^2.*s; -S=x.*(m'.*s+y.*c); -X=S.*sinpi(T); -Y=S.*cospi(T); -Z=m'.*c+y.*s; -surf(X,Y,x.*Z,X.^2+Y.^2+Z.^4); -axis( 'equal','off') -view([0 33]) -colormap(flip(hot)) -shading('interp') -end -```",gpt-3.5-turbo,1000,0,0 -Fix Bug,You are a friendly and helpful teaching assistant for MATLAB programmers. Analyze buggy MATLAB code and provide error free code using latest features from R2021a or later. ,"Fix bug in the following MATLAB code - -```matlab -% Buggy MATLAB code - -a = 1; b = 2; -function y = add(a, b) -y = ""a"" + b -end -``` - -% Fixed MATLAB code",gpt-3.5-turbo,1000,0,0 -Write Unit Tests,You are a friendly and helpful teaching assistant for MATLAB programmers. Analyze MATLAB functions and write appropriate unit tests.,"Write unit tests for this function, - -```matlab -function y = add(a, b) -y = a + b -end -```",gpt-3.5-turbo,1000,0,0 -Vectorize Code,You are a friendly and helpful teaching assistant for MATLAB programmers. Analyze MATLAB code and rewrite it to improve efficiency using vectorization rather than for loops.,"Vectorize the following code without using for loop. - -```matlab -i = 0; -for t = 0:.01:10 - i = i + 1; - y(i) = sin(t); -end -```",gpt-3.5-turbo,1000,0,1 -Act as MATLAB Command Window,"You are a MATLAB interpreter. I will give you MATLAB code, you'll reply with what the Command Window should show. Do not provide any explanations. Do not respond with anything except the output of the code. ","```matlab -x = (1:10)*2 -``` - -",gpt-3.5-turbo,1000,0,0 -Sentiment Analysis,You are a helpful assistant that helps data scientists by analyzing sentiment expressed in a given text.,"Decide whether sentiment is positive, neutral, or negative for the following text - -1. ""I can't stand homework"" -2. ""This sucks. I'm bored 😠"" -3. ""I can't wait for Halloween!!!"" -4. ""My cat is adorable ❤️❤️"" -5. ""I hate chocolate"" - -Text sentiment ratings:",gpt-3.5-turbo,1000,0,0 -Contact Extraction,You are a helpful assistant that helps data scientists by analyzing unstructured data and extract personally identifiable information.,"Extract the name and mailing address from this text: - -Dear Kelly, - -It was great to talk to you at the seminar. I thought Jane's talk was quite good. -Thank you for the book. Here's my address - -2111 Ash Lane, Crestview CA 92002 - -Best, - -Maya",gpt-3.5-turbo,1000,0,0 -Extract table from PDF,You are a helpful assistant that helps data scientists by analyzing unstructured data and recreate a table from the text extracted from a PDF file.,"format the following PDF data into a table. Start with a title, then the table and end with a footer citing the source. - -A(Percentage)verage annual export growth rates of creative goods, 2006−2020 - - - Region 2006−2010 2011−2015 - - Developing economies 9.8 4.9 - - Developed economies 1.5 0.2 - - Least developed countries 19.5 4.2 - - Small island developing States 12.8 -0.6 - - World – creative goods 5.1 2.7 - - World – all goods 3.6 -1.8 - - Source: UNCTAD based on UN COMTRADE Database. - - - 2016−2020 - - 2.0 - - 0.3 - - 38.3 - - -29.8 - - 1.2 - - 2.6 - - - 2006−2020 - - 5.9 - - 1.3 - - 10.1 - - -3.9 - - 3.5 - - 2.4 - - - 2020 - - -10.6 - - -14.9 - - -2.2 - - -48.5 - - -12.5 - - -7.2 - - - ",gpt-3.5-turbo,1000,0,0 -Top 10 SciFi movies,You are a helpful assistant that helps data scientists extract useful public data for analysis.,"Create a table of top 10 science fiction movies, their year of release, and box office gross. Name columns 'Title', 'Year', and 'Gross'.",gpt-3.5-turbo,1000,0,0 -San Francisco Weather,You are a helpful assistant that helps data scientists extract useful public data for analysis.,"Create a table of weather temperatures for San Francisco. -Name columns 'Month', 'High', and 'Low'. -Use abbreviated month names. Use numbers only for temperature using Fahrenheit.",gpt-3.5-turbo,1000,0,0 -Boston Housing Prices,You are a helpful assistant that helps data scientists extract useful public data for analysis.,"Housing prices are correlated with size of the house. -Create a table of housing prices in Boston based on square footage as size -with columns named 'Sqft' and 'Price' with 10 rows. Randomize the values in 'Sqft', -while maintaining the correlation to 'Price' with some variation.",gpt-3.5-turbo,1000,0,0 -Boston Landmarks,You are a helpful assistant that helps data scientists extract useful public data for analysis.,"Create a markdown table of famous landmarks in Boston - with latitude and longitude in decimal degrees, i.e. 42.361145, -71.057083 - Generate MATLAB Code that stores the data in variables landmarks, lat and lon. - Use geoscatter function to plot the data points. Annotate the data points with the names of the landmarks using text function. - Avoid using for loop.",gpt-3.5-turbo,1000,0,0 -Remove duplicate names,You are a helpful assistant that helps data scientists extract useful public data for analysis.,"Here is a list of names. - -John Smith -William Davis -Sarah Brown -Thomas Miller -Julia Johnson -Adam White -David Taylor -Smith Paul -Davis William -Brown Jennifer - -Create a table with two columns from this list -that split the first name and last name. -If first name appears as last name, correct the order. -remove any duplicates. Show the table in markdown format.",gpt-3.5-turbo,1000,0,0 -Email Domains to Company Names,You are a helpful assistant that helps data scientists extract useful public data for analysis.,"Identify legal company names based on the following domains - -1. @apple.com -2. @walmart.com -3. @exxonmobil.com -4. @berkshirehathaway.com -5. @amazon.com -6. @unitedhealthgroup.com -7. @cvs.com -8. @costco.com -9. @jpmorganchase.com -10. @home-depot.com -11. @mcdonalds.com -12. @chevron.com1 -13. @unitedparcelservice.com -14. @dell.com",gpt-3.5-turbo,1000,0,0 -Normalize University Names,You are a helpful assistant that helps data scientists extract useful public data for analysis.,"Normalize the list of university names below. If a university has multiple names, apply the most common full name, not an abbreviation. - -CMU -Caltech -Carnegie Mellon UNiversity -Carnegie Mellon University -Cambrige -Cambridge University -Columbia -Columbia University -Cornell -Cornell University -Duke -Duke University -Duke Univiersity -Georgia Institute of Technolog -Georgia Institute of Technology -Georgia Tech -Georgia Tech. -Georgiatech -MIT -Massachusetts Institute of Technology - -",gpt-3.5-turbo,1000,0,0 diff --git a/src/chatGPT/MatGPT/contents/styles.css b/src/chatGPT/MatGPT/contents/styles.css deleted file mode 100644 index 4eab99705c..0000000000 --- a/src/chatGPT/MatGPT/contents/styles.css +++ /dev/null @@ -1,116 +0,0 @@ -.table { - display: table; - border-collapse: collapse; - width: 100%; - margin: 0px 0px 0px 0px; - font-size: 0.9em; - font-family: sans-serif; - min-width: 400px; -} - -.table-row { - display: table-row; - border-bottom: 1px solid #dddddd; -} - -.table-row:nth-of-type(even) { - background-color: #f3f3f3; -} - -.table-row:last-of-type { - border-bottom: 2px solid #104871; -} - -.table-cell, -.table-header { - display: table-cell; - padding: 12px 15px; - white-space: pre-wrap; -} - -.table-row.active-row { - font-weight: bold; - color: #104871; -} - -.table-header { - background-color: #104871; - color: #ffffff; - text-align: left; -} - -.code-block { - display: block; - font-size: 1.15em; - background-color: #dcebf3; - border: 1px solid #ccc; - padding: 10px; - margin: 0px 0px 0px 0px; -} - -.test-block-green { - display: block; - font-size: 1.15em; - background-color: #dcf3dc; - border: 1px solid #ccc; - padding: 10px; - margin: 0px 0px 0px 0px; -} - -.test-block-red { - display: block; - font-size: 1.15em; - background-color: #f3dcdc; - border: 1px solid #ccc; - padding: 10px; - margin: 0px 0px 0px 0px; -} - -.code-inline { - font-size: 1.25em; - font-weight: normal; -} - -.ml-figure { - max-width: 100%; - max-height: 100%; -} - -.test-report div { - margin-bottom: 10px; -} - -.test, -.figures, -.artifacts { - border: 1px solid #7d9dd6; - border-radius: 10px; - padding: 10px; -} - -table.resp { - width: 100%; - background-color: #ffffff; - border-collapse: collapse; - border-width: 2px; - border-color: #404040; - border-style: solid; - color: #000000; -} - -table.resp td, -table.resp th { - border-width: 2px; - border-color: #404040; - border-style: solid; - padding: 3px; -} - -table.resp thead { - background-color: #404040; - color: #ffffff; -} - -tr:nth-child(even) { - background-color: #f2f2f2; -} diff --git a/src/chatGPT/MatGPT/helpers/CodeChecker.m b/src/chatGPT/MatGPT/helpers/CodeChecker.m deleted file mode 100644 index 1949890699..0000000000 --- a/src/chatGPT/MatGPT/helpers/CodeChecker.m +++ /dev/null @@ -1,268 +0,0 @@ -classdef CodeChecker < handle - % CODECHECKER - Class for evaluating code from ChatGPT - % CodeChecker is a class that can extract code from ChatGPT - % responses, run the code with a unit testing framework, and return - % the test results in a format for display in a chat window. Each - % input message from ChatGPT gets its own test folder to hold the - % generated code and artifacts like test results and images - % - % Example: - % - % % Get a message from ChatGPT - % myBot = chatGPT(); - % response = send(myBot,"Create a bar plot in MATLAB."); - % - % % Create object - % checker = CodeChecker(response); - % - % % Check code for validity and display test report - % runChecks(checker); - % report = checker.Report; - - properties (SetAccess=private) - ChatResponse % ChatGPT response that may have code - OutputFolder % Full path with test results - Results % Table with code check results - Timestamp % Unique string with current time to use for names - Artifacts % List of files generated by the checks - ErrorMessages % Cell str of any error messages from results - Report % String with the nicely formatted results - end - - methods - %% Constructor - function obj = CodeChecker(inputStr,pathStr) - %CODECHECKER Constructs an instance of this class - % checker = CodeChecker(message) creates an instance - % of the CodeChecker class given a response from - % ChatGPT in the string "message". - arguments - inputStr string {mustBeTextScalar} - pathStr string {mustBeTextScalar} = ""; - end - obj.ChatResponse = inputStr; - if pathStr == "" - s = dir; - pathStr = s(1).folder; - end - % Construct a unique output folder name using a timstamp - obj.Timestamp = string(datetime('now','Format','yyyy-MM-dd''T''HH-mm-SS')); - obj.OutputFolder = fullfile(pathStr,"contents","GeneratedCode","Test-" + obj.Timestamp); - - % Empty Results table - obj.Results = []; - end - end - methods (Access=public) - function runChecks(obj) - % RUNCHECKS - Run generated code and check for errors - % runChecks(obj) will run each piece of generated code. NOTE: - % This function does not check generated code for - % correctness, just validity - - % make the output folder - mkdir(obj.OutputFolder) - - % Get list of current files so we know which artifacts to move - % to the output folder - filesBefore = dir(); - - % save code files and get their names for testing; - saveCodeFiles(obj); - - % Run all test files - runCodeFiles(obj); - - % Move all newly generated files - filesAfter = dir(); - obj.Artifacts = string(setdiff({filesAfter.name},{filesBefore.name})); - for i = 1:length(obj.Artifacts) - movefile(obj.Artifacts(i),obj.OutputFolder) - end - - % Fill the results properties like Report and ErrorMessages - processResults(obj) - end - end - - methods(Access=private) - function saveCodeFiles(obj) - % SAVECODEFILES Saves M-files for testing - % saveCodeFiles(obj) will parse the ChatResponse propety for - % code blocks and save them to separate M-files with unique - % names in the OutputFolder property location - - % Extract code blocks - [startTag,endTag] = TextHelper.codeBlockPattern(); - codeBlocks = extractBetween(obj.ChatResponse,startTag,endTag); - - % Create Results table - obj.Results = table('Size',[length(codeBlocks) 4],'VariableTypes',["string","string","string","logical"], ... - 'VariableNames',["ScriptName","ScriptContents","ScriptOutput","IsError"]); - obj.Results.ScriptContents = codeBlocks; - - % Save code blocks to M-files - for i = 1:height(obj.Results) - % Open file with the function name or a generic test name. - obj.Results.ScriptName(i) = "Test" + i + "_" + replace(obj.Timestamp,"-","_"); - fid = fopen(obj.Results.ScriptName(i) + ".m","w"); - - % Add the code to the file - fprintf(fid,"%s",obj.Results.ScriptContents(i)); - fclose(fid); - end - end - - function runCodeFiles(obj) - % RUNCODEFILES - Tries to run all the generated scripts and - % captures outputs/figures - - % Before tests, make existing figure handles invisible - figsBefore = findobj('Type','figure'); - for i = 1:length(figsBefore) - figsBefore(i).HandleVisibility = "off"; - end - - % Also check for open and closed Simulink files - addOns = matlab.addons.installedAddons; - addOns = addOns(addOns.Name=="Simulink",:); - hasSimulink = ~isdeployed && ~isempty(addOns) && addOns.Enabled; - if hasSimulink - BDsBefore = find_system('SearchDepth',0); - SLXsBefore = dir("*.slx"); - SLXsBefore = {SLXsBefore.name}'; - end - - % Iterate through scripts. Run the code and capture any output - for i = 1:height(obj.Results) - try - output = evalc(obj.Results.ScriptName(i)); - if isempty(output) - output = "This code did not produce any output"; - end - obj.Results.ScriptOutput(i) = output; - catch ME - obj.Results.IsError(i) = true; - obj.Results.ScriptOutput(i) = TextHelper.shortErrorReport(ME); - end - end - - % Find newly Simulink models - if hasSimulink - BDsNew = setdiff(find_system("SearchDepth",0),BDsBefore); - BDsNew(BDsNew=="simulink") = []; - SLXsfiles = dir("*.slx"); - SLXsNew = setdiff({SLXsfiles.name}',SLXsBefore); - SLXsNew = extractBefore(SLXsNew,".slx"); - - % Load the new SLX-files. Add to list of new BDs - for i = 1:length(SLXsNew) - load_system(SLXsNew{i}); - end - BDsNew = union(BDsNew,SLXsNew); - - % Print screenshots for the new BDs. Then save and close them - for i = 1:length(BDsNew) - print("-s" + BDsNew{i},"-dpng",BDsNew{i} + ".png"); - save_system(BDsNew{i}); - close_system(BDsNew{i}); - end - end - - % Save new figures as png and reset the old figures as visible - figsAfter = findobj("Type","figure"); - for i = 1:length(figsAfter) - saveas(figsAfter(i),"Figure" + i + ".png"); - end - for i = 1:length(figsBefore) - figsBefore(i).HandleVisibility = "on"; - end - end - - function processResults(obj) - % PROCESSRESULTS - Process the test results - % - % processResults(obj) will assign values to the properties - % Report and ErrorMessages. obj.Report is a string with a - % nicely formatted report, and obj.ErrorMessages is a cellstr - % with any error messages - % - - % Get the error messages from the Results table - obj.ErrorMessages = obj.Results.ScriptOutput(obj.Results.IsError); - - % Set up the report header - numBlocks = height(obj.Results); - numErrors = length(obj.ErrorMessages); - reportHeader = sprintf(['

Here are the test results. ' ... - 'There were %d code blocks tested and %d errors.

'], ... - numBlocks,numErrors); - - % Handle plurals - if numBlocks == 1 - reportHeader = replace(reportHeader,'were','was'); - reportHeader = replace(reportHeader,'blocks','block'); - end - if numErrors == 1 - reportHeader = replace(reportHeader,'errors','error'); - end - - % Loop through results table to make the report - testReport = reportHeader; - for i = 1:height(obj.Results) - - % Start the report with the script name - testReport = [testReport sprintf('

Test: %s

', ... - obj.Results.ScriptName(i))]; %#ok - - % Use error style if code produced an error - codeBlockClass = "test-block-green"; - if obj.Results.IsError(i) - codeBlockClass = "test-block-red"; - end - - % Add code - testReport = [testReport sprintf('%%%% Code: \n\n%s\n\n', ... - codeBlockClass,obj.Results.ScriptContents(i))]; %#ok - - % Add output - testReport = [testReport sprintf('%%%% Command Window Output: \n\n%s', ... - obj.Results.ScriptOutput(i))]; %#ok - - % Add the closing tags - testReport = [testReport '
']; %#ok - end - - % Show any image artifacts - imageFiles = obj.Artifacts(contains(obj.Artifacts,".png")); - folders = split(obj.OutputFolder,filesep); - if ~isempty(imageFiles) - testReport = [testReport '

Figures

']; - for i = 1:length(imageFiles) - % Get the relative path of the image. Assumes that the HTML - % file is at the same level as the "GeneratedCode" folder - relativePath = fullfile(folders(end-1),folders(end),imageFiles(i)); - relativePath = replace(relativePath,'\','/'); - - % Assemble the html code for displaying the image - testReport = [testReport sprintf('
',relativePath)]; %#ok - end - testReport = [testReport '
']; - end - - % List the artifacts - testReport = [testReport '

Artifacts

']; - testReport = [testReport sprintf('The following artifacts were saved to: %s\n\n',obj.OutputFolder)]; - for i = 1:length(obj.Artifacts) - testReport = [testReport sprintf(' %s\n',obj.Artifacts(i))]; %#ok - end - testReport = [testReport '
']; - - % Close the initial div for the overall report - testReport = [testReport '
']; - - % Assign testReport to Report property - obj.Report = testReport; - end - end -end \ No newline at end of file diff --git a/src/chatGPT/MatGPT/helpers/TextHelper.m b/src/chatGPT/MatGPT/helpers/TextHelper.m deleted file mode 100644 index dff8ac7720..0000000000 --- a/src/chatGPT/MatGPT/helpers/TextHelper.m +++ /dev/null @@ -1,174 +0,0 @@ -classdef TextHelper - %TEXTHELPER Collection of static methods for text processing - % TextHelper has multiple static methods that can be used for the - % weird text processing used by MatGPT - - methods (Access=public,Static) - %% codeBlockPattern - function [startPat,endPat] = codeBlockPattern() - % CODEBLOCKPATTERN - text matching pattern for the code blocks in backticks - % - % [startPat,endPat] = codeBlockPattern returns some start and end - % patterns for the backticks so you can use for - - % Code blocks start and end with 3 backticks - backticks = "```"; - startPat = backticks + wildcardPattern + newline; - endPat = newline + backticks + (newline|textBoundary); - end - - %% exceptionReport - function report = shortErrorReport(ME) - % shortErrorReport - get a shortened report from MException - % - % report = shortErrorReport(ME) will use the getReport method - % from MException input and shorten it to show the error - % message and first stack entry - - % Get full report from MException object and remove HTML tags - report = getReport(ME); - report = TextHelper.removeHTMLtags(report); - - % Count occurrences of "Error" (how big is the Stack) - errorPattern = "Error" + wildcardPattern(Except="Error"); - stackCount = count(report,errorPattern); - - % Construct pattern to match all but the first stack entry - allButFirstError = asManyOfPattern(errorPattern,1,stackCount-1) + textBoundary; - - % Extract before the constructed pattern. - report = extractBefore(report,allButFirstError); - end - - function str = removeHTMLtags(str) - % removeHTMLtags - removes all HTML tags from a string. This is - % usefull because MATLAB's error messages often come with - % hyperlinks. This function removes them. - htmlTags = "<" + wildcardPattern + ">"; - str = erase(str,htmlTags); - end - - function newStr = replaceCodeMarkdown(str,options) - % replaceCodeMarkdown - Replaces ``` markdown with tags - % - % newStr = replaceCodeMarkdown(str) will parse the input str - % and replace any code blocks enclosed in backticks ``` with - % tags using the style class "code-block" - % - % newStr = replaceCodeBlocks(___,type=T) specifies the type - % of markdown to replace or the specific tag used: - % "block" - (default) code enclosed in "```" - % "inline" - code enclosed in "`" - % T - code enclosed in the string stored in T - % - % newStr = replaceCodeBlocks(___,className=C) specifies the - % class name used in the code tag in a string. Default is - % "code-block" - % - % Example input: - % - % ```matlab - % x = 1; - % ``` - % - % Example output: - % - % x=1; - % - arguments - str string {mustBeTextScalar} - options.type string {mustBeTextScalar} = "block" - options.className string {mustBeTextScalar} = "code-block"; - end - - switch options.type - case "block" - [startTag,endTag] = TextHelper.codeBlockPattern(); - case "inline" - startTag = "`"; - endTag = "`"; - otherwise - startTag = options.type; - endTag = options.type; - end - - % Extract text with and without the tgs - textWithoutTags = extractBetween(str,startTag,endTag); - textWithTags = extractBetween(str,startTag,endTag, ... - Boundaries="inclusive"); - - % Replace the blocks with backticks with alternae versions - textWithCodeTags = "" + ... - textWithoutTags + ""; - newStr = replace(str,textWithTags,textWithCodeTags); - end - - function newStr = replaceTableMarkdown(str) - %REPLACETABLE replaces Markdown table with HTML table - % Accepts a scalar string array as input - % table must have the border for the header i.e. | --- | - - arguments - str string {mustBeTextScalar} - end - - % define table pattern - tblpat = lineBoundary + "|" + (" "|"-") + wildcardPattern(1,Inf) + (" "|"-") + "|" + lineBoundary; - % extract table - tblstr = extract(str,tblpat); - % table is not found, exit - if isempty(tblstr) - newStr = str; - return - end - try - % remove "|" at the beginning and end of a line - tblBordersPat = [lineBoundary+"|","|"+lineBoundary]; - trimmedTbl = strtrim(erase(strtrim(tblstr), tblBordersPat)); - % split each line by "|" - splittedTbl = arrayfun(@(x) (strtrim(split(x,"|")))', trimmedTbl, UniformOutput=false); - % get the number of columns in each line - numCols = cellfun(@numel, splittedTbl); - % find the header separator - separatorPat = optionalPattern(":") + asManyOfPattern(characterListPattern("-"),2) + optionalPattern(":"); - % the header is the 1 row up - headerRowIdx = find(cellfun(@(x) all(contains(x,separatorPat)), splittedTbl),1) - 1; - % lines with the same number of cols are in the table - rowIdx = numCols == numCols(headerRowIdx); - % extract table block and merge lines into a string - tblBlock = splittedTbl(rowIdx); - mergedTbl = vertcat(tblBlock{:}); - % extract header - theader = string(mergedTbl(1,:)); - % extract body - tbody = mergedTbl(3:end,:); - % generate table header - htmlTbl = "" + newline; - htmlTbl = htmlTbl + "" + newline; - htmlTbl = htmlTbl + "" + newline; - for ii = 1:numel(theader) - htmlTbl = htmlTbl + "" + newline; - end - htmlTbl = htmlTbl + "" + newline; - htmlTbl = htmlTbl + "" + newline; - % generate table body - htmlTbl = htmlTbl + "" + newline; - for ii = 1:size(tbody,1) - htmlTbl = htmlTbl + "" + newline; - for jj = 1:numel(theader) - htmlTbl = htmlTbl + "" + newline; - end - htmlTbl = htmlTbl + "" + newline; - end - htmlTbl = htmlTbl + "" + newline; - htmlTbl = htmlTbl + "
" + theader(ii) + "
" + tbody(ii,jj) + "
"; - % replace markdown table with html table - newStr = replace(str,join(tblstr,newline),htmlTbl); - catch - % if error, return the original string - newStr = str; - return - end - end - end -end \ No newline at end of file diff --git a/src/chatGPT/MatGPT/helpers/chatGPT.m b/src/chatGPT/MatGPT/helpers/chatGPT.m deleted file mode 100644 index 67087f8d91..0000000000 --- a/src/chatGPT/MatGPT/helpers/chatGPT.m +++ /dev/null @@ -1,160 +0,0 @@ -classdef chatGPT < handle - %CHATGPT defines a class to access ChatGPT API - % Create an instance using your own API key, and optionally - % max_tokens that determine the length of the response - % - % chat method lets you send a prompt text to the API as HTTP request - % and parses the response. - % - % Before using, set an environment variable with your OpenAI API key - % named OPENAI_API_KEY - % - % setenv("OPENAI_API_KEY","your key here") - - properties (Access = public) - % the API endpoint - api_endpoint = "https://api.openai.com/v1/chat/completions"; - % ChatGPT model to use - gpt-3.5-turbo, etc. - model; - % what role the bot should play - role; - % the max length of response - max_tokens; - % temperature = 0 precise, = 1 balanced, = 2 creative - temperature; - % store chat log in messages object - messages; - % store usage - completion_tokens = 0; - prompt_tokens = 0; - total_tokens = 0; - end - - methods - function obj = chatGPT(options) - %CHATGPT Constructor method to create an instance - % Set up an instance with optional parameters - - arguments - options.model string {mustBeTextScalar, ... - mustBeMember(options.model, ... - ["gpt-3.5-turbo","gpt-3.5-turbo-0613", ... - "gpt-4","gpt-4-0613", ... - "gpt-4-32k","gpt-4-32k-0613"])} = "gpt-3.5-turbo"; - options.role string {mustBeTextScalar} = ... - "You are a helpful assistant."; - options.max_tokens (1,1) double {mustBeNumeric, ... - mustBeLessThan(options.max_tokens,4096)} = 1000; - options.temperature (1,1) double {mustBeNumeric, ... - mustBeInRange(options.temperature,0,2)} = 1; - end - - obj.model = options.model; - obj.role = options.role; - obj.max_tokens = options.max_tokens; - obj.temperature = options.temperature; - obj.messages = struct('role',"system",'content',obj.role); - end - - function responseText = chat(obj,prompt) - %CHAT This send http requests to the api - % Pass the prompt as input argument to send the request - - arguments - obj - prompt string {mustBeTextScalar} - end - - % retrieve API key from the environment - api_key = getenv("OPENAI_API_KEY"); - if isempty(api_key) - id = "chatGPT:missingKey"; - msg = "No API key found in the enviroment variable" + newline; - msg = msg + "Before using, set an environment variable "; - msg = msg + "with your OpenAI API key as 'MY_OPENAI_KEY'"; - msg = msg + newline + newline + "setenv('OPENAI_API_KEY','your key here')"; - ME = MException(id,msg); - throw(ME) - end - - % constructing messages object that retains the chat history - % send user prompt with 'user' role - if ~isempty(obj.messages) - obj.messages = [obj.messages, ... - struct('role',"user",'content',prompt)]; - else - obj.messages = struct('role',"user",'content',prompt); - end - - % shorten calls to MATLAB HTTP interfaces - import matlab.net.* - import matlab.net.http.* - % construct http message content - query = struct('model',obj.model,'messages',obj.messages,'max_tokens',obj.max_tokens,'temperature',obj.temperature); - % the headers for the API request - headers = HeaderField('Content-Type', 'application/json'); - headers(2) = HeaderField('Authorization', "Bearer " + api_key); - % the request message - request = RequestMessage('post',headers,query); - % send the request and store the response - response = send(request, URI(obj.api_endpoint)); - % extract the response text - if response.StatusCode == "OK" - % extract text from the response - responseText = response.Body.Data.choices(1).message; - responseText = string(responseText.content); - responseText = strtrim(responseText); - % add the text to the messages with 'assistant' role - obj.messages = [obj.messages, ... - struct('role',"assistant",'content',responseText)]; - % add the tokens used - obj.completion_tokens = obj.completion_tokens + ... - response.Body.Data.usage.completion_tokens; - obj.prompt_tokens = obj.prompt_tokens + ... - response.Body.Data.usage.prompt_tokens; - obj.total_tokens = obj.total_tokens + ... - response.Body.Data.usage.total_tokens; - else - responseText = "Error "; - responseText = responseText + response.StatusCode + newline; - responseText = responseText + response.StatusLine.ReasonPhrase; - end - end - - function [completion_tokensd, prompt_tokens, total_tokens] = usage(obj) - %USAGE retunrs the number of tokens used - completion_tokensd = obj.completion_tokens; - prompt_tokens = obj.prompt_tokens; - total_tokens = obj.total_tokens; - end - - function saveChat(obj,options) - %SAVECHAT saves the chat history in a file - % Specify the format using .mat, .xlsx or .json - arguments - obj - options.format string {mustBeTextScalar, ... - mustBeMember(options.format, ... - [".mat",".xlsx",".json"])} = ".mat"; - end - if isempty(obj.messages) - warning("No chat history.") - else - if options.format == ".mat" - s = obj.messages; - save("chathistory.mat","s") - elseif options.format == ".xlsx" - tbl = struct2table(obj.messages); - writetable(tbl,"chathistory.xlsx") - elseif options.format == ".json" - json = jsonencode(obj.messages); - writelines(json,"chathistory.json") - else - error("Unknown format.") - end - end - - end - - end -end \ No newline at end of file diff --git a/src/chatGPT/MatGPT/helpers/chatter.m b/src/chatGPT/MatGPT/helpers/chatter.m deleted file mode 100644 index c129962fdc..0000000000 --- a/src/chatGPT/MatGPT/helpers/chatter.m +++ /dev/null @@ -1,114 +0,0 @@ -classdef chatter < chatGPT - %CHATTER extends chatGPT superclass - % This subclass adds a new method 'injectChatLog' - - methods - function responseText = chat(obj,prompt,options) - %CHAT This send http requests to the api - % Pass the prompt as input argument to send the request - % Only messages with valid roles are sent in messages object - arguments - obj - prompt string {mustBeTextScalar} - options.timeout double {mustBeScalarOrEmpty} - options.stop string {mustBeText,mustBeNonzeroLengthText} - end - - % retrieve API key from the environment - api_key = getenv("OPENAI_API_KEY"); - if isempty(api_key) - id = "chatter:missingKey"; - msg = "No API key found in the enviroment variable" + newline; - msg = msg + "Before using, set an environment variable "; - msg = msg + "with your OpenAI API key as 'MY_OPENAI_KEY'"; - msg = msg + newline + newline + "setenv('MY_OPENAI_KEY','your key here')"; - ME = MException(id,msg); - throw(ME) - end - - % constructing messages object that retains the chat history - % send user prompt with 'user' role - if ~isempty(obj.messages) - obj.messages = [obj.messages, ... - struct('role',"user",'content',prompt)]; - else - obj.messages = struct('role',"user",'content',prompt); - end - - % extract messages with valid roles - m = obj.messages; - roles = arrayfun(@(x) string(x.role),m); - m(~ismember(roles,["system","user","assistant"])) = []; - % shorten calls to MATLAB HTTP interfaces - import matlab.net.* - import matlab.net.http.* - % construct http message content - % - if isfield(options,'stop') - query = struct('model',obj.model,'messages',m,'max_tokens',obj.max_tokens,'temperature',obj.temperature,'stop',options.stop); - else - query = struct('model',obj.model,'messages',m,'max_tokens',obj.max_tokens,'temperature',obj.temperature); - end - % the headers for the API request - headers = HeaderField('Content-Type', 'application/json'); - headers(2) = HeaderField('Authorization', "Bearer " + api_key); - % the request message - request = RequestMessage('post',headers,query); - - % Create a HTTPOptions object; set proxy in MATLAB Web Preferences if needed - httpOpts = matlab.net.http.HTTPOptions; - % Set the ConnectTimeout option to 30 seconds - if isfield(options,'timeout') && options.timeout > 0 - httpOpts.ConnectTimeout = options.timeout; - end - % send the request and store the response - response = send(request, URI(obj.api_endpoint),httpOpts); - - % extract the response text - if response.StatusCode == "OK" - % extract text from the response - responseText = response.Body.Data.choices(1).message; - responseText = string(responseText.content); - responseText = strtrim(responseText); - % add the text to the messages with 'assistant' role - obj.messages = [obj.messages, ... - struct('role',"assistant",'content',responseText)]; - % add the numbers of tokens used - obj.completion_tokens = obj.completion_tokens + ... - response.Body.Data.usage.completion_tokens; - obj.prompt_tokens = obj.prompt_tokens + ... - response.Body.Data.usage.prompt_tokens; - obj.total_tokens = obj.total_tokens + ... - response.Body.Data.usage.total_tokens; - else - responseText = "Error "; - responseText = responseText + response.StatusCode + newline; - responseText = responseText + response.StatusLine.ReasonPhrase; - if string(response.StatusCode) == "401" - responseText = responseText + newline + "Check your API key."; - responseText = responseText + newline + "You may have an invalid API key."; - elseif string(response.StatusCode) == "404" - responseText = responseText + newline + "You may not have access to the model: " + ... - obj.model + ". Consider using another model."; - elseif string(response.StatusCode) == "429" - responseText = responseText + newline + "You exceeded the API limit. Your free trial for OpenAI API may have expired."; - end - id = "chatter:invalidKey"; - ME = MException(id,responseText); - throw(ME) - end - end - - function injectChatLog(obj,role,content) - %INJECTCHATLOG injects a message into messages object - % This method add a new message externally that hold - % information for display or record keeping and not to be - % sent to ChatGPT API. - % role must be something other than system, user or - % assistant. - if ~ismember(role, ["system","user","assistant"]) - obj.messages = [obj.messages,struct('role',role,'content',content)]; - end - end - end -end \ No newline at end of file