diff --git a/setup.py b/setup.py index b4799408e7640c880eb3a5142ca9d18ba81acd97..4837556d61afef394108028c8f7e3ffde32869b3 100755 --- a/setup.py +++ b/setup.py @@ -39,7 +39,7 @@ setup( 'numpy >= 1.20.0', 'sympy >= 1.9.0', 'scipy >= 1.7.0', - 'swiglpk >= 5.0.3', + 'swiglpk >= 5.0.5', 'sbmlxdf >= 0.2.7'], python_requires=">=3.7", keywords=['modeling', 'standardization', 'SBML'], diff --git a/xbanalysis/model/xba_model.py b/xbanalysis/model/xba_model.py index 29bcbccbd4b531cd1374efb9c81b6753d9f595eb..056a5c828e081ff12f1950a91b2135d93d3c4939 100644 --- a/xbanalysis/model/xba_model.py +++ b/xbanalysis/model/xba_model.py @@ -7,6 +7,7 @@ import os import time import numpy as np import sbmlxdf +from scipy.sparse import coo_matrix from .xba_compartment import XbaCompartment from .xba_function import XbaFunction @@ -23,7 +24,8 @@ class XbaModel: def __init__(self, sbml_file): if os.path.exists(sbml_file) is False: print(f'{sbml_file} does not exist') - return + raise FileNotFoundError + print(f'loading: {sbml_file} (last modified: {time.ctime(os.path.getmtime(sbml_file))})') sbml_model = sbmlxdf.Model(sbml_file) if sbml_model.isModel is False: @@ -76,3 +78,28 @@ class XbaModel: if sid in map_metab_row: stoic_mat[map_metab_row[sid], col] = stoic return stoic_mat + + def get_stoic_matrix_coo(self, sids, rids): + """retrieve stoichiometric sub-matrix [sids x rids]. + + :param sids: species ids - rows of sub-matrix + :type sids: list of strings + :param rids: reaction ids - columns of sub-matrix + :type rids: list of strings + :returns: stoichmetric sub-matrix in sparse format + :rtype: scipy.sparse.coo_matrix + """ + # TODO: sparce matrix manipulation + sid2idx = {sid: idx for idx, sid in enumerate(sids)} + stoic_data = [] + for col_idx, rid in enumerate(rids): + for sid, stoic in self.reactions[rid].reactants.items(): + if sid in sids: + stoic_data.append([sid2idx[sid], col_idx, -stoic]) + for sid, stoic in self.reactions[rid].products.items(): + if sid in sids: + stoic_data.append([sid2idx[sid], col_idx, stoic]) + coo_data = np.array(stoic_data).copy() # ??? + stoic_mat_coo = coo_matrix((coo_data[:, 2], (coo_data[:, 0], coo_data[:, 1])), + shape=(len(sids), len(rids))) + return stoic_mat_coo diff --git a/xbanalysis/problems/__init__.py b/xbanalysis/problems/__init__.py index 3adc55401bc3b973d4b49b99d8f153dcef000be2..a7326aaac46b6415ea4bd89bc8c8e210d9b8256c 100644 --- a/xbanalysis/problems/__init__.py +++ b/xbanalysis/problems/__init__.py @@ -3,6 +3,5 @@ from .gba_problem import GbaProblem from .rba_problem import RbaProblem from .fba_problem import FbaProblem -from .lp_problem import LpProblem -__all__ = ['GbaProblem', 'FbaProblem', 'LpProblem', 'RbaProblem'] +__all__ = ['GbaProblem', 'FbaProblem', 'RbaProblem'] diff --git a/xbanalysis/problems/fba_problem.py b/xbanalysis/problems/fba_problem.py index 539a47a580e295352d6523897ae79884404c0538..7cd2a55034302bad5d15f7d8839feddadb2a45e0 100644 --- a/xbanalysis/problems/fba_problem.py +++ b/xbanalysis/problems/fba_problem.py @@ -1,11 +1,7 @@ -"""Implementation of FBA Problem class. - -Peter Schubert, HHU Duesseldorf, June 2022 -""" - import numpy as np +from scipy import sparse -from xbanalysis.problems.lp_problem import LpProblem +from xbanalysis.solvers.glpk_linear_problem import GlpkLinearProblem class FbaProblem: @@ -38,37 +34,47 @@ class FbaProblem: self.xba_model = xba_model # identify reaction excluding protein synthesis and protein degradation - self.mr_rids = [r.id for r in xba_model.reactions.values() - if r.sboterm.is_in_branch('SBO:0000205') is False and - r.sboterm.is_in_branch('SBO:0000179') is False] - self.mrid2idx = {mrid: idx for idx, mrid in enumerate(self.mr_rids)} - self.reversible = np.array([self.xba_model.reactions[rid].reversible for rid in self.mr_rids]) - - sids = set() - for rid in self.mr_rids: + self.rids = np.array([r.id for r in xba_model.reactions.values() + if r.sboterm.is_in_branch('SBO:0000205') is False and + r.sboterm.is_in_branch('SBO:0000179') is False]) + self.rid2idx = {mrid: idx for idx, mrid in enumerate(self.rids)} + self.flux_bounds = np.array([[xba_model.reactions[rid].fbc_lb for rid in self.rids], + [xba_model.reactions[rid].fbc_ub for rid in self.rids]]) + self.reversible = np.array([self.xba_model.reactions[rid].reversible for rid in self.rids]) + for idx in range(len(self.reversible)): + if self.flux_bounds[0, idx] < 0 and np.logical_not(self.reversible[idx]): + self.reversible[idx] = True + print(f'reaction {idx} set to reversible') + self.rids_rev = self.rids[self.reversible] + self.rid2idx_rev = {rid: idx + len(self.rids) for idx, rid in enumerate(self.rids_rev)} + + used_sids = set() + for rid in self.rids: for sid in xba_model.reactions[rid].reactants: - sids.add(sid) + used_sids.add(sid) for sid in xba_model.reactions[rid].products: - sids.add(sid) - self.sids = list(sids) + used_sids.add(sid) + self.sids = np.array(list(used_sids)) + self.sid2idx = {sid: idx for idx, sid in enumerate(self.sids)} - self.s_mat = xba_model.get_stoic_matrix(self.sids, self.mr_rids) + # sparse matrices faster and more efficient to handle + self.s_mat_coo = xba_model.get_stoic_matrix_coo(self.sids, self.rids) self.is_fba_model = self.check_fba_model(xba_model) if self.is_fba_model is False: print('Error: not a FBA model - missing parameters') return - self.flux_bounds = np.array([[xba_model.reactions[rid].fbc_lb for rid in self.mr_rids], - [xba_model.reactions[rid].fbc_ub for rid in self.mr_rids]]) - self.obj_coefs = None for oid, fbc_obj in xba_model.fbc_objectives.items(): if fbc_obj.active is True: - self.obj_id = fbc_obj.id + # self.obj_id = fbc_obj.id self.obj_dir = fbc_obj.direction - self.set_obj_coefs(fbc_obj.coefficiants) + self.objective = fbc_obj.coefficiants break + # lp problem will only be created once required. + self.lp = None + def check_fba_model(self, xba_model): """Check if fba related data available in the XBA model @@ -81,7 +87,7 @@ class FbaProblem: r_check = {'flux lower bound': 'fbc_lb', 'flux upper bound': 'fbc_ub'} for check, param in r_check.items(): - missing = [rid for rid in self.mr_rids if hasattr(xba_model.reactions[rid], param) is False] + missing = [rid for rid in self.rids if hasattr(xba_model.reactions[rid], param) is False] if len(missing) > 0: print(f'missing {check} in:', missing) is_fba_model = False @@ -97,7 +103,11 @@ class FbaProblem: return is_fba_model - def combine_fwd_bwd(self, vector): + def update_reversible_rids(self): + self.rids_rev = self.rids[self.reversible] + self.rid2idx_rev = {rid: idx + len(self.rids) for idx, rid in enumerate(self.rids_rev)} + + def combine_fwd_rev(self, vector): """combine forward and backward flux information. :param vector: contains data on all reactions + backward information for reversible reactions @@ -105,179 +115,206 @@ class FbaProblem: :return: combined flux information :rtype: 1D ndarray of shape (len(mr_rids)) """ - n_cols = len(self.mr_rids) + n_cols = len(self.rids) combined = vector[:n_cols] combined[self.reversible] -= vector[n_cols:] return combined - def create_fba_lp(self): - """create Linear Programming Problem for FBA with split reactions + def get_model_status(self): + """ Return status on model + + :returns: status information in a dictionary + :rtype: dict + """ + status = {'dof': self.s_mat_coo.shape[1] - np.linalg.matrix_rank(self.s_mat_coo.todense()), + 'n_rids': len(self.rids), 'n_sids': len(self.sids)} + return status + + def create_core_fba_lp(self): + """create a core Linear Programming Problem for FBA with split reactions + + Core Problem contains coefficient matrix, variable and row bounds + Objective function yet needs to be added prior to any optimization Splitting reactions in forward/backward directions reduces high values during FBA + configured bounds are >= 0 Splitting required for pFBA (minimize absolute flux values) :return: LpProblem configured for FBA :rtype: LpProblem """ - if self.is_fba_model is False: - return None - - lp = LpProblem() - # self.reversible = np.array([self.xba_model.reactions[rid].reversible for rid in self.mr_rids]) - fwd_bwd_s_mat = np.hstack((self.s_mat, -self.s_mat[:, self.reversible])) - fwd_bwd_obj_coefs = np.hstack((self.obj_coefs, -self.obj_coefs[self.reversible])) - fwd_bwd_bnds = np.hstack((np.fmax(self.flux_bounds, 0), - np.vstack((np.fmax(-self.flux_bounds[1, self.reversible], 0), - np.fmax(-self.flux_bounds[0, self.reversible], 0))))) - row_bnds = np.zeros((fwd_bwd_s_mat.shape[0], 2)) - lp.configure(fwd_bwd_obj_coefs, fwd_bwd_s_mat, row_bnds, fwd_bwd_bnds, self.obj_dir) + fwd_rev_s_mat_coo = sparse.hstack([self.s_mat_coo, + -self.s_mat_coo.tocsc()[:, self.reversible]]) + fwd_rev_col_bnds = np.hstack((np.fmax(self.flux_bounds[:], 0), + np.fmax(np.flip(-self.flux_bounds[:, self.reversible], axis=0), 0))) + row_bnds = np.zeros((fwd_rev_s_mat_coo.shape[0], 2)) + lp = GlpkLinearProblem(fwd_rev_s_mat_coo, row_bnds, fwd_rev_col_bnds) return lp - def fba_optimize(self): + def delete_fba_lp(self): + """Delete the corresponding linear problem, when required""" + if self.lp is not None: + del self.lp + self.lp = None + + def get_coefficients(self, objective): + """get coefficients for objective or constraint function + + :param objective: objective/constraint coefficients + :type objective: dict with key: reaction id, value: stoichiometric coefficient + :return: coefficients aligned with the linear problem + :rtype: 1D ndarray with float + """ + coefs = np.zeros(len(self.rids) + len(self.rids_rev)) + for rid, coef in objective.items(): + coefs[self.rid2idx[rid]] = coef + if rid in self.rids_rev: + coefs[self.rid2idx_rev[rid]] = -coef + return coefs + + def set_lp_objective(self, objective, direction='maximize'): + """Set objective coefficients and direction in linear problem + + :param objective: objective coefficients + :type objective: dict with key: reaction id, value: stoichiometric coefficient + :param direction: direction of optimization + :type direction: str (optional, default: 'maximize') + """ + assert self.lp is not None + self.lp.set_obj_coefs(self.get_coefficients(objective)) + self.lp.set_obj_dir(direction) + + def remove_lp_objective(self, objective): + """remove objective coefficients + + :param objective: objective coefficients + :type objective: dict with key: reaction id, value: stoichiometric coefficient + """ + self.lp.remove_obj_coefs(self.get_coefficients(objective)) + + def fba_optimize(self, short_result=False): """FBA optimization of current problem + :param short_result: short result (only objective value and status) or long result + :type short_result: bool (optional, default: False) :return: results information from the optimization :rtype: dict """ - lp = self.create_fba_lp() - if lp is None: - return {'success': False, 'message': 'not an FBA model'} - res = lp.solve() - res['x'] = self.combine_fwd_bwd(res['x']) - res['reduced_costs'] = self.combine_fwd_bwd(res['reduced_costs']) + if self.lp is None: + self.lp = self.create_core_fba_lp() + # print('Core LP Problem created') + assert self.lp is not None + + self.set_lp_objective(self.objective, self.obj_dir) + res = self.lp.solve(short_result) + # print(res['success'], res['fun']) + self.remove_lp_objective(self.objective) + + if short_result is False: + res['x'] = self.combine_fwd_rev(res['x']) + res['reduced_costs'] = self.combine_fwd_rev(res['reduced_costs']) return res - def pfba_optimize(self, fract_of_optimum=1.0): - """ pfba analysis + def pfba_optimize(self, frac_of_opt=1.0): + """pFBA parsimoneous flux balance analysis. - Reactions need to be split in forward/backard (to minimize absolute flux value) + 0 ≤ frac_of_opt ≤ 1.0 (for minimization problems. frac_of_opt > 0) + for maximizing objective, this sets lower bound to (frac_of_opt * optimum) + for minimization objective, this sets upper bound to (optimum / frac_of_opt) - :param fract_of_optimum: value between 0.0 and 1.0. - :type fract_of_optimum: float (default 1.0) + :param frac_of_opt: objective value within + :type frac_of_opt: float [0 ... 1] , optional (default: 1.0)` :return: + :rtype: dict """ - lp = self.create_fba_lp() - if lp is None: - return {'success': False, 'message': 'not an FBA model'} - res = lp.solve(short_result=False) - if res['success']: - # fix FBA objective as constraint - fwd_bwd_obj_coefs = np.hstack((self.obj_coefs, -self.obj_coefs[self.reversible])) - if self.obj_dir == 'maximize': - row_bds = np.array([res['fun'] * fract_of_optimum, np.nan]) - else: - row_bds = np.array([np.nan, res['fun'] / fract_of_optimum]) - lp.add_constraint(fwd_bwd_obj_coefs, row_bds) - # new LP objective is to minimize sum of fluxes - n_cols = len(self.mr_rids) - lp.set_obj_coefs(np.ones(n_cols + sum(self.reversible))) - lp.set_obj_dir('minimize') - res = lp.solve() - res['x'] = self.combine_fwd_bwd(res['x']) - res['reduced_costs'] = self.combine_fwd_bwd(res['reduced_costs']) + # get optimum value for model objective function + res = self.fba_optimize(short_result=True) + if res['success'] is False: + return res + + # implement model main objective as a constraint + if self.obj_dir == 'maximize': + row_bnds = np.array([res['fun'] * frac_of_opt, np.nan]) + else: + row_bnds = np.array([np.nan, res['fun'] / frac_of_opt]) + row_idx0 = self.lp.add_constraint(self.get_coefficients(self.objective), row_bnds) + + # create pFBA objective (all ones) + pfba_obj_coefs = np.ones(len(self.rids) + len(self.rids_rev)) + self.lp.set_obj_coefs(pfba_obj_coefs) + self.lp.set_obj_dir('minimize') + res = self.lp.solve(short_result=False) + # remove pFBA objective + self.lp.remove_obj_coefs(pfba_obj_coefs) + # remove main model objective from constraints matrix + self.lp.del_constraint(row_idx0) + res['x'] = self.combine_fwd_rev(res['x']) + res['reduced_costs'] = self.combine_fwd_rev(res['reduced_costs']) return res - def fva_optimize(self, rids=None, fract_of_optimum=1.0): - """FVA - flux variability analysis for all or selected reactions. + def fva_optimize(self, rids=None, frac_of_opt=1.0): + """Flux variability analyzis on reactions within fraction of optimum + + 0 ≤ frac_of_opt ≤ 1.0 (for minimization problems. frac_of_opt > 0) + for maximizing objective, this sets lower bound to (frac_of_opt * optimum) + for minimization objective, this sets upper bound to (optimum / frac_of_opt) - :param rids: selected reaction ids to run FVA (alternatively, all) - :type rids: list of strings (default: None - all reactions) - :param fract_of_optimum: value between 0.0 and 1.0. - :type fract_of_optimum: float (default 1.0) - :return: Flux ranges and objective values - :rtype: pandas DataFrame + :param rids: reaction ids to determine flux ranges + :type rids: list-like of str, optional (default: None, all reactions) + :param frac_of_opt: objective value within + :type frac_of_opt: float [0 ... 1] , optional (default: 1.0) + :return: rids, min and max flux values + :rtype: dict """ - lp = self.create_fba_lp() - if lp is None: - return {'success': False, 'message': 'not an FBA model'} - res = lp.solve(short_result=True) + # get optimum value for model objective function + res = self.fba_optimize(short_result=True) + if res['success'] is False: + return res + + # implement model main objective as a constraint + if self.obj_dir == 'maximize': + row_bnds = np.array([res['fun'] * frac_of_opt, np.nan]) + else: + row_bnds = np.array([np.nan, res['fun'] / frac_of_opt]) + row_idx0 = self.lp.add_constraint(self.get_coefficients(self.objective), row_bnds) if rids is None: - rids = self.mr_rids + rids = self.rids flux_ranges = np.zeros((4, len(rids))) - if res['success']: - # fix FBA objective as constraint - fwd_bwd_obj_coefs = np.hstack((self.obj_coefs, -self.obj_coefs[self.reversible])) - if self.obj_dir == 'maximize': - row_bds = np.array([res['fun'] * fract_of_optimum, np.nan]) - else: - row_bds = np.array([np.nan, res['fun'] / fract_of_optimum]) - fba_constr_row = lp.add_constraint(fwd_bwd_obj_coefs, row_bds) - - n_cols = len(self.mr_rids) - for idx, rid in enumerate(rids): - # select reaction maximize/minimize flux - new_obj_coefs = np.zeros(n_cols) - new_obj_coefs[self.mrid2idx[rid]] = 1 - new_fwd_bwd_obj_coefs = np.hstack((new_obj_coefs, -new_obj_coefs[self.reversible])) - lp.set_obj_coefs(new_fwd_bwd_obj_coefs) - - lp.set_obj_dir('minimize') - res = lp.solve(short_result=True) - if res['success']: - flux_ranges[0, idx] = res['fun'] - flux_ranges[2, idx] = lp.get_row_value(fba_constr_row) - else: - print(f"FVA min failed for {rid}") - flux_ranges[0, idx] = np.nan - - lp.set_obj_dir('maximize') - res = lp.solve(short_result=True) - if res['success']: - flux_ranges[1, idx] = res['fun'] - flux_ranges[3, idx] = lp.get_row_value(fba_constr_row) - else: - print(f"FVA max failed for {rid}") - flux_ranges[1, idx] = np.nan + for idx, rid in enumerate(rids): + # determine minimal flux for reaction rid + self.set_lp_objective({rid: 1}, 'minimize') + res = self.lp.solve(short_result=True) + if res['success']: + flux_ranges[0, idx] = res['fun'] + flux_ranges[2, idx] = self.lp.get_row_value(row_idx0) + # determine maximal flux for reaction rid + self.lp.set_obj_dir('maximize') + res = self.lp.solve(short_result=True) + if res['success']: + flux_ranges[1, idx] = res['fun'] + flux_ranges[3, idx] = self.lp.get_row_value(row_idx0) + # remove objective for reaction rid + self.remove_lp_objective({rid: 1}) + # remove main model objective from constraints matrix + self.lp.del_constraint(row_idx0) res = {'rids': rids, 'min': flux_ranges[0], 'max': flux_ranges[1], 'obj_min': flux_ranges[2], 'obj_max': flux_ranges[3]} return res - @property - def obj_dir(self): - return self.__obj_dir - - @obj_dir.setter - def obj_dir(self, dir_string): - if 'min' in dir_string: - self.__obj_dir = 'minimize' - else: - self.__obj_dir = 'maximize' - - def get_flux_bounds(self): - """retrieve flux bounds for all reactions in a dict. - - :returns: flux bounds configured in the FBA problem - :rtype: dict (key: reaction id, value: list with two float values for lower/upper bound) - """ - return {mrid: [bds[0], bds[1]] for mrid, bds in zip(self.mr_rids, self.flux_bounds.T)} - - def set_flux_bounds(self, flux_bounds): - """selectivly set lower and upper flux bounds for given reactions. - - :param flux_bounds: new lower and upper flux bounds selected reactions - :type flux_bounds: dict (key: reaction id, value: list with two float values for lower/upper bound) - """ - for rid, (lb, ub) in flux_bounds.items(): - self.flux_bounds[0, self.mrid2idx[rid]] = lb - self.flux_bounds[1, self.mrid2idx[rid]] = ub - - def get_obj_coefs(self): - """retrieve the coefficients of the FBA objective function. - - :returns: objective coefficients - :rtype: dict (key: reaction id, value: float) - """ - return {rid: self.obj_coefs[idx] for rid, idx in self.mrid2idx.items() - if self.obj_coefs[idx] != 0.0} - - def set_obj_coefs(self, coefficiants): - """set the coefficients of the FBA objective function. + def copy(self): + """Copy the FbaModel instance (while removing linear problem). - :param coefficiants: objective coefficients of selected reactions - :type coefficiants: dict (key: reaction id, value: float) + A shallow copy, to support multiprocessing of FVA, + which do not impact FBA model attributes, except for linear problem """ - self.obj_coefs = np.zeros(len(self.mr_rids)) - for rid, coef in coefficiants.items(): - self.obj_coefs[self.mrid2idx[rid]] = coef + cls = self.__class__ + result = cls.__new__(cls) + result.__dict__.update(self.__dict__) + result.__dict__['lp'] = None + return result + + def __del__(self): + print('deletion of fba_model') + del self.lp diff --git a/xbanalysis/problems/rba_problem.py b/xbanalysis/problems/rba_problem.py index 11c6dd7ecfd7686a1cc95e736fcbbb5d522e5b0b..071369ae4f8ac2f90f4127901068ef9eab486bcf 100644 --- a/xbanalysis/problems/rba_problem.py +++ b/xbanalysis/problems/rba_problem.py @@ -14,7 +14,7 @@ Peter Schubert, HHU Duesseldorf, June 2022 import numpy as np -from xbanalysis.problems.lp_problem import LpProblem +from xbanalysis.solvers.lp_problem import LpProblem class RbaProblem: @@ -138,7 +138,6 @@ class RbaProblem: res = {'success': False, 'fun': 0.0, 'message': 'not an RBA model'} return res - res = {} # Preanalysis: find range of growth rates gr = {'lb': 0.0, 'ub': np.nan, 'try': 1.0} for i in range(self.max_iter): @@ -147,6 +146,7 @@ class RbaProblem: lp = LpProblem() lp.configure(self.obj_coefs, constr_coefs, self.fix_row_bnds, self.col_bnds, self.obj_dir) res = lp.solve(scaling=self.scaling) + del lp if res['success'] and res['fun'] > 1e-10: gr['lb'] = gr['try'] gr['try'] *= 10 @@ -157,7 +157,6 @@ class RbaProblem: gr['try'] /= 10 if gr['lb'] > 0.0 and np.isfinite(gr['ub']): break - if gr['lb'] == 0.0 or np.isnan(gr['ub']): res = {'success': False, 'fun': 0.0, 'message': 'no solution found'} return res @@ -170,6 +169,7 @@ class RbaProblem: lp = LpProblem() lp.configure(self.obj_coefs, constr_coefs, self.fix_row_bnds, self.col_bnds, self.obj_dir) res = lp.solve(scaling=self.scaling) + del lp if res['success'] and res['fun'] > 1e-10: gr['lb'] = gr['try'] else: @@ -183,6 +183,7 @@ class RbaProblem: lp = LpProblem() lp.configure(self.obj_coefs, constr_coefs, self.fix_row_bnds, self.col_bnds, self.obj_dir) res = lp.solve(scaling=self.scaling) + del lp res['fun'] = gr['try'] return res diff --git a/xbanalysis/problems/rba_problem_coo.py b/xbanalysis/problems/rba_problem_coo.py new file mode 100644 index 0000000000000000000000000000000000000000..071369ae4f8ac2f90f4127901068ef9eab486bcf --- /dev/null +++ b/xbanalysis/problems/rba_problem_coo.py @@ -0,0 +1,286 @@ +"""Implementation of GBA Problem class. + + - several process machines supported (yet to be validated) + - non-enzymatic metabolic reactions supported (yet to be validated) + + Yet to implement: + - enzyme degradation + - enzyme transitions + - several compartments constraints + - target concentrations and target fluxes + +Peter Schubert, HHU Duesseldorf, June 2022 +""" + +import numpy as np + +from xbanalysis.solvers.lp_problem import LpProblem + + +class RbaProblem: + + def __init__(self, xba_model): + """ + + :param xba_model: + """ + self.xba_model = xba_model + + # metabolic reactions and enzyme transitions (no FBA reactions, no protein synthesis, no degradation) + self.mr_rids = [r.id for r in xba_model.reactions.values() if r.sboterm.is_in_branch('SBO:0000167') + and r.sboterm.is_in_branch('SBO:0000179') is False] + self.mrid2enz = {rid: xba_model.reactions[rid].enzyme for rid in self.mr_rids} + self.enz_sids = [enz_sid for enz_sid in self.mrid2enz.values() if enz_sid is not None] + self.rev_enz_sids = [enz_sid for mr_rid, enz_sid in self.mrid2enz.items() + if enz_sid is not None and xba_model.reactions[mr_rid].reversible] + self.processes = {s.rba_process_machine: sid for sid, s in xba_model.species.items() + if hasattr(s, 'rba_process_machine')} + self.psenz_rids = [xba_model.species[enz_sid].ps_rid for enz_sid in self.enz_sids] + self.pspm_rids = [xba_model.species[enz_sid].ps_rid for enz_sid in self.processes.values()] + self.densities = {cid: c.rba_frac_gcdw for cid, c in xba_model.compartments.items() + if hasattr(c, 'rba_frac_gcdw')} + + # metabolites and enzymes used in metabolic reactions and enzyme transitions + sids = set() + for rid in self.mr_rids: + for sid in xba_model.reactions[rid].reactants: + sids.add(sid) + for sid in xba_model.reactions[rid].products: + sids.add(sid) + used_sids = list(sids) + self.sids = [sid for sid in used_sids if xba_model.species[sid].constant is False] + self.ext_conc = {sid: xba_model.species[sid].initial_conc + for sid in used_sids if xba_model.species[sid].constant is True} + + self.rba_cols = self.mr_rids + self.enz_sids + list(self.processes) + self.rba_rows = (self.sids + list(self.processes) + + [sid + '_feff' for sid in self.enz_sids] + + [sid + '_reff' for sid in self.rev_enz_sids] + + list(self.densities)) + + self.is_rba_model = self.check_rba_model(xba_model) + if self.is_rba_model is False: + print('Error: not a GBA model - missing parameters') + return + + fix_m, var_m, row_bnds_m = self.construct_mass_balance(xba_model) + fix_pc, var_pc, row_bnds_pc = self.construct_process_capacities(xba_model) + fix_eff, var_eff, row_bnds_eff = self.construct_efficencies(xba_model) + fix_cd, var_cd, row_bnds_cd = self.construct_densities(xba_model) + self.fix_cd = fix_cd + self.fix_mat = np.vstack((fix_m, fix_pc, fix_eff, fix_cd)) + self.var_mat = np.vstack((var_m, var_pc, var_eff, var_cd)) + self.fix_row_bnds = np.vstack((np.zeros_like(row_bnds_m), np.zeros_like(row_bnds_pc), + row_bnds_eff, row_bnds_cd)) + self.var_row_bnds = np.vstack((row_bnds_m, row_bnds_pc, + np.zeros_like(row_bnds_eff), np.zeros_like(row_bnds_cd))) + + # in RBA we only check feasibility. + # Here we maximize enzyme amount and check that objective value e.g. > 1e-10 mmol/gcdw + self.obj_dir = 'maximize' + self.obj_coefs = np.hstack((np.zeros(len(self.mr_rids)), + np.ones(len(self.enz_sids) + len(self.processes)))) + col_lbs = np.hstack(([xba_model.reactions[rid].fbc_lb for rid in self.mr_rids], + np.zeros(len(self.enz_sids) + len(self.processes)))) + col_ubs = np.hstack(([xba_model.reactions[rid].fbc_ub for rid in self.mr_rids], + np.nan * np.ones(len(self.enz_sids) + len(self.processes)))) + self.col_bnds = np.vstack((col_lbs, col_ubs)) + self.scaling = '2N' + self.max_iter = 20 + + def check_rba_model(self, xba_model): + """Check if rba related data available in the XBA model + + :param xba_model: Xba model with data extracted from SBML file + :type xba_model: XbaModel + :return: indicator if model contains data required for RBA problem formulation + :rtype: bool + """ + is_rba_model = True + + r_check = {'flux lower bound': 'fbc_lb', 'flux upper bound': 'fbc_ub', 'kapp forward': 'rba_kapp_f'} + for check, param in r_check.items(): + missing = [rid for rid in self.mr_rids if hasattr(xba_model.reactions[rid], param) is False] + if len(missing) > 0: + print(f'missing {check} in:', missing) + is_rba_model = False + + missing = [rid for rid in self.mr_rids if xba_model.reactions[rid].reversible + and hasattr(xba_model.reactions[rid], 'rba_kapp_r') is False] + if len(missing) > 0: + print('missing kapp reverse in:', missing) + is_rba_model = False + + missing = [sid for sid in self.sids + self.enz_sids + list(self.processes.values()) + if hasattr(xba_model.species[sid], 'mw') is False] + if len(missing) > 0: + print('missing molecular weight in:', missing) + is_rba_model = False + + missing = [sid for sid in list(self.processes.values()) + if hasattr(xba_model.species[sid], 'rba_process_capacity') is False] + if len(missing) > 0: + print('missing process capacity in:', missing) + is_rba_model = False + + if len(self.densities) == 0: + print('missing a density constraint') + is_rba_model = False + return is_rba_model + + def rba_optimize(self): + """ + + :return: + """ + if self.is_rba_model is False: + print('Error: not a RBA model - missing parameters') + res = {'success': False, 'fun': 0.0, 'message': 'not an RBA model'} + return res + + # Preanalysis: find range of growth rates + gr = {'lb': 0.0, 'ub': np.nan, 'try': 1.0} + for i in range(self.max_iter): + constr_coefs = self.fix_mat + gr['try'] * self.var_mat + # row_bnds = self.fix_row_bnds + gr['try'] * self.var_row_bnds + lp = LpProblem() + lp.configure(self.obj_coefs, constr_coefs, self.fix_row_bnds, self.col_bnds, self.obj_dir) + res = lp.solve(scaling=self.scaling) + del lp + if res['success'] and res['fun'] > 1e-10: + gr['lb'] = gr['try'] + gr['try'] *= 10 + else: + if gr['try'] <= 1e-4: + break + gr['ub'] = gr['try'] + gr['try'] /= 10 + if gr['lb'] > 0.0 and np.isfinite(gr['ub']): + break + if gr['lb'] == 0.0 or np.isnan(gr['ub']): + res = {'success': False, 'fun': 0.0, 'message': 'no solution found'} + return res + + # find exact growth rate + for i in range(self.max_iter): + gr['try'] = 0.5 * (gr['lb'] + gr['ub']) + constr_coefs = self.fix_mat + gr['try'] * self.var_mat + # row_bnds = self.fix_row_bnds + gr['try'] * self.var_row_bnds + lp = LpProblem() + lp.configure(self.obj_coefs, constr_coefs, self.fix_row_bnds, self.col_bnds, self.obj_dir) + res = lp.solve(scaling=self.scaling) + del lp + if res['success'] and res['fun'] > 1e-10: + gr['lb'] = gr['try'] + else: + gr['ub'] = gr['try'] + if (gr['ub'] - gr['lb']) < gr['try'] * 1e-5: + break + + # get optimum values for the final solution + gr['try'] = gr['lb'] + constr_coefs = self.fix_mat + gr['try'] * self.var_mat + lp = LpProblem() + lp.configure(self.obj_coefs, constr_coefs, self.fix_row_bnds, self.col_bnds, self.obj_dir) + res = lp.solve(scaling=self.scaling) + del lp + res['fun'] = gr['try'] + return res + + def construct_mass_balance(self, xba_model): + + fix_m_x_mr = xba_model.get_stoic_matrix(self.sids, self.mr_rids) + var_m_x_enz = xba_model.get_stoic_matrix(self.sids, self.psenz_rids) + var_m_x_pm = xba_model.get_stoic_matrix(self.sids, self.pspm_rids) + fix_m = np.hstack((fix_m_x_mr, + np.zeros((len(self.sids), len(self.enz_sids)+len(self.processes))))) + var_m = np.hstack((np.zeros((len(self.sids), len(self.mr_rids))), + var_m_x_enz, + var_m_x_pm)) + targets = np.array([getattr(xba_model.species[sid], 'rba_target', 0.0) for sid in self.sids]) + row_bnds_m = np.vstack((targets, targets)).T + return fix_m, var_m, row_bnds_m + + def construct_process_capacities(self, xba_model): + + var_pc_x_enz = np.zeros((len(self.processes), len(self.psenz_rids))) + for idx, pm in enumerate(self.processes): + var_pc_x_enz[idx] = [xba_model.species[enz_sid].rba_process_costs.get(pm, 0.0) + for enz_sid in self.enz_sids] + var_pc_x_pm = np.zeros((len(self.processes), len(self.processes))) + for idx, pm in enumerate(self.processes): + var_pc_x_pm[idx] = [xba_model.species[enz_sid].rba_process_costs.get(pm, 0.0) + for enz_sid in self.processes.values()] + fix_pc_x_pm = -np.diag([xba_model.species[enz_sid].rba_process_capacity + for enz_sid in self.processes.values()]) * 3600 + fix_pc = np.hstack((np.zeros((len(self.processes), len(self.mr_rids) + len(self.enz_sids))), + fix_pc_x_pm)) + var_pc = np.hstack((np.zeros((len(self.processes), len(self.mr_rids))), + var_pc_x_enz, var_pc_x_pm)) + row_bnds_pc = np.zeros((len(self.processes), 2)) + return fix_pc, var_pc, row_bnds_pc + + def get_efficiencies(self, xba_model): + + efficiencies = np.zeros((2, len(self.enz_sids))) + idx = 0 + for mr_rid, enz_sid in self.mrid2enz.items(): + if enz_sid is not None: + r = xba_model.reactions[mr_rid] + saturation = 1.0 + if hasattr(r, 'rba_km_ext'): + for sid in r.reactants: + if sid in self.ext_conc: + saturation *= self.ext_conc[sid] / (self.ext_conc[sid] + r.rba_km_ext) + efficiencies[0, idx] = r.rba_kapp_f * 3600 * saturation + saturation = 1.0 + if r.reversible: + if hasattr(r, 'rba_km_ext'): + for sid in r.products: + if sid in self.ext_conc: + saturation *= self.ext_conc[sid] / (self.ext_conc[sid] + r.rba_km_ext) + efficiencies[1, idx] = r.rba_kapp_r * 3600 * saturation + idx += 1 + return efficiencies + + def construct_efficencies(self, xba_model): + + efficiencies = self.get_efficiencies(xba_model) + mask = np.array([enz_sid is not None for enz_sid in self.mrid2enz.values()]) + fix_feff_x_mr = np.diag(np.ones(len(self.mrid2enz)))[mask] + fix_feff_x_enz = np.diag(-efficiencies[0]) + fix_feff_x_pm = np.zeros((len(self.enz_sids), len(self.processes))) + fix_feff = np.hstack((fix_feff_x_mr, fix_feff_x_enz, fix_feff_x_pm)) + row_bnds_feff = np.vstack((np.ones(len(self.enz_sids)) * np.nan, np.zeros(len(self.enz_sids)))).T + + mask = [enz_sid is not None and xba_model.reactions[mr_rid].reversible + for mr_rid, enz_sid in self.mrid2enz.items()] + fix_reff_x_mr = np.diag(-np.ones(len(self.mrid2enz)))[mask] + mask = [enz_sid in self.rev_enz_sids for enz_sid in self.enz_sids] + fix_reff_x_enz = np.diag(-efficiencies[1])[mask] + fix_reff_x_pm = np.zeros((len(self.rev_enz_sids), len(self.processes))) + fix_reff = np.hstack((fix_reff_x_mr, fix_reff_x_enz, fix_reff_x_pm)) + row_bnds_reff = np.vstack((np.ones(len(self.rev_enz_sids)) * np.nan, np.zeros(len(self.rev_enz_sids)))).T + fix_eff = np.vstack((fix_feff, fix_reff)) + var_eff = np.zeros_like(fix_eff) + row_bnds_eff = np.vstack((row_bnds_feff, row_bnds_reff)) + return fix_eff, var_eff, row_bnds_eff + + def construct_densities(self, xba_model): + fix_cd_x_mr = np.zeros((len(self.densities), len(self.mr_rids))) + fix_cd_x_enz = np.zeros((len(self.densities), len(self.enz_sids))) + for row_idx, cid in enumerate(self.densities): + for col_idx, sid in enumerate(self.enz_sids): + if xba_model.species[sid].compartment == cid: + fix_cd_x_enz[row_idx, col_idx] = xba_model.species[sid].mw / 1000 + fix_cd_x_pm = np.zeros((len(self.densities), len(self.processes))) + for row_idx, cid in enumerate(self.densities): + for col_idx, sid in enumerate(self.processes.values()): + if xba_model.species[sid].compartment == cid: + fix_cd_x_pm[row_idx, col_idx] = xba_model.species[sid].mw / 1000 + fix_cd = np.hstack((fix_cd_x_mr, fix_cd_x_enz, fix_cd_x_pm)) + + var_cd = np.zeros_like(fix_cd) + row_bnds_cd = np.vstack((np.zeros(len(self.densities)), + [val for val in self.densities.values()])).T + return fix_cd, var_cd, row_bnds_cd diff --git a/xbanalysis/solvers/__init__.py b/xbanalysis/solvers/__init__.py index 37b8472d758df8fde32209a4a5daa2c4def29a96..bee7a56080a80855f0258e4ef44072aa3f7a4f14 100644 --- a/xbanalysis/solvers/__init__.py +++ b/xbanalysis/solvers/__init__.py @@ -1,5 +1,7 @@ """Subpackage with XBA model classes """ +from .glpk_linear_problem import GlpkLinearProblem +from .lp_problem import LpProblem from .gba_ipopt_problem import GbaIpoptProblem -__all__ = ['GbaIpoptProblem'] +__all__ = ['GlpkLinearProblem', 'GbaIpoptProblem', 'LpProblem'] diff --git a/xbanalysis/solvers/glpk_linear_problem.py b/xbanalysis/solvers/glpk_linear_problem.py new file mode 100644 index 0000000000000000000000000000000000000000..a1c83b8f2e02f475ea14a19e445b404a15441d54 --- /dev/null +++ b/xbanalysis/solvers/glpk_linear_problem.py @@ -0,0 +1,315 @@ +"""Implementation of LpProblem (linear programming problem) class. + +built upon swiglpk + +Peter Schubert, HHU Duesseldorf, June 2022 +""" + +import numpy as np +import swiglpk as glpk + + +# status reported +simplex_status = { + 0: 'LP problem instance successfully solved', + glpk.GLP_EBADB: 'Unable to start search, initial basis specified in the ' + 'problem object is invalid. Number basic variables is not same as the number rows.', + glpk.GLP_ESING: 'Unable to start search, basis matrix corresponding to initial basis is singular.', + glpk.GLP_ECOND: 'Unable to start search, basis matrix corresponding to initial basis is ill-conditioned.', + glpk.GLP_EBOUND: 'Unable to start search, some double-bounded variables have incorrect bounds.', + glpk.GLP_EFAIL: 'Search prematurely terminated: solver failure.', + glpk.GLP_EOBJLL: 'Search prematurely terminated: objective function being ' + 'maximized reached its lower limit and continues decreasing.', + glpk.GLP_EOBJUL: 'Search prematurely terminated: objective function being ' + 'minimized reached its upper limit and continues increasing.', + glpk.GLP_EITLIM: 'Search prematurely terminated: simplex iteration limit exceeded.', + glpk.GLP_ETMLIM: 'Search prematurely terminated: time limit exceeded.', + glpk.GLP_ENOPFS: 'LP problem instance has no primal feasible solution.', + glpk.GLP_ENODFS: 'LP problem instance has no dual feasible solution.'} + +generic_status = {glpk.GLP_UNDEF: 'solution is undefined', + glpk.GLP_FEAS: 'solution is feasible', + glpk.GLP_INFEAS: 'solution is infeasible', + glpk.GLP_NOFEAS: 'problem has no feasible solution', + glpk.GLP_OPT: 'solution is optimal', + glpk.GLP_UNBND: 'problem has unbounded solution'} + +dual_status = {glpk.GLP_UNDEF: 'dual solution is undefined', + glpk.GLP_FEAS: 'dual solution is feasible', + glpk.GLP_INFEAS: 'dual solution is infeasible', + glpk.GLP_NOFEAS: 'no dual feasible solution exists'} + +prim_status = {glpk.GLP_UNDEF: 'primal solution is undefined', + glpk.GLP_FEAS: 'primal solution is feasible', + glpk.GLP_INFEAS: 'primal solution is infeasible', + glpk.GLP_NOFEAS: 'no primal feasible solution exists'} + +var_status = {glpk.GLP_BS: 'basic variable', + glpk.GLP_NL: 'non-basic variable on its lower bound', + glpk.GLP_NU: 'non-basic variable on its upper bound', + glpk.GLP_NF: 'non-basic free (unbounded) variable', + glpk.GLP_NS: 'non-basic fixed variable'} + +scaling_options = {'GM': glpk.GLP_SF_GM, + 'EQ': glpk.GLP_SF_EQ, + '2N': glpk.GLP_SF_2N, + 'SKIP': glpk.GLP_SF_SKIP, + 'AUTO': glpk.GLP_SF_AUTO} + + +class GlpkLinearProblem: + + def __init__(self, constr_coefs_coo, row_bnds, col_bnds): + """create core linear problem (constraints, variable and row bounds) + + Problem needs to be properly deleted usin del(LpProblem) + Note: glpk related indices start at 1 (not zero) + :param constr_coefs_coo: matrix with constraint coefficients + :type constr_coefs_coo: scipy.sparce.coo_matrix with shape(nrows, ncols) + :param row_bnds: lower and upper bounds of auxhiliary (row) variables + :type row_bnds: 2D ndarray of shape(nrows, 2) (1st col: lower bounds, 2nd col: upper bounds) + :param col_bnds: lower and upper bounds of structural (col) variables + :type col_bnds: 2D ndarray of shape(2, ncols) (1st row: lower bounds, 2nd row: upper bounds) + """ + glp_msg_lev = glpk.GLP_MSG_OFF # GLP_MSG_OFF, GLP_MSG_ERR, GLP_MSG_ON, GLP_MSG_ALL + glp_tm_lim = 10 * 1000 + glp_presolve = glpk.GLP_OFF # or glpk.GLP_OFF (default) or GLP_ON + self.res = {} + + self.nrows, self.ncols = constr_coefs_coo.shape + assert col_bnds.shape == (2, self.ncols) + assert row_bnds.shape == (self.nrows, 2) + + self.lp = glpk.glp_create_prob() + + # configure variables/columns and related bounds + glpk.glp_add_cols(self.lp, self.ncols) + for i, lb_ub in enumerate(col_bnds.T): + var_type, lb, ub = self._get_variable_type(lb_ub) + glpk.glp_set_col_bnds(self.lp, 1 + i, var_type, lb, ub) + + # configure rows/constraints + glpk.glp_add_rows(self.lp, self.nrows) + for i, lb_ub in enumerate(row_bnds): + var_type, lb, ub = self._get_variable_type(lb_ub) + glpk.glp_set_row_bnds(self.lp, 1 + i, var_type, lb, ub) + n_coefs = len(constr_coefs_coo.row) + ia = glpk.intArray(1 + n_coefs) + ja = glpk.intArray(1 + n_coefs) + ar = glpk.doubleArray(1 + n_coefs) + for i in range(n_coefs): + ia[1 + i] = int(1 + constr_coefs_coo.row[i]) + ja[1 + i] = int(1 + constr_coefs_coo.col[i]) + ar[1 + i] = constr_coefs_coo.data[i] + glpk.glp_load_matrix(self.lp, n_coefs, ia, ja, ar) + + # configure options + self._smcp = glpk.glp_smcp() + glpk.glp_init_smcp(self._smcp) + self._smcp.msg_lev = glp_msg_lev + self._smcp.tm_lim = glp_tm_lim + self._smcp.presolve = glp_presolve + + @staticmethod + def _get_variable_type(bounds): + """Determine variable type for upper/lower bounds. + + :param bounds: upper and lower bounds + :type bounds: 1D ndarray of length 2 + :return var_type: glpk variable type to configure + :rtype: integer constant + :return lb: value of lower bound + :rtype float + :return ub: value of upper bound + :rtype float + """ + lb, ub = bounds + if np.isfinite(lb) and np.isfinite(ub): + if lb < ub: + var_type = glpk.GLP_DB + else: + ub = 0.0 + var_type = glpk.GLP_FX + elif not np.isfinite(lb) and not np.isfinite(ub): + lb = 0.0 + ub = 0.0 + var_type = glpk.GLP_FR + elif np.isfinite(ub): + lb = 0.0 + var_type = glpk.GLP_UP + else: + ub = 0.0 + var_type = glpk.GLP_LO + return var_type, lb, ub + + def set_obj_coefs(self, obj_coefs): + """set coefficients for objective function + + configure coefficients that are not zero + + Note: glpk related indices start at 1 (not zero) + :param obj_coefs: coefficients of objective function + :type obj_coefs: 1D ndarray of shape (1, ncols) + """ + assert len(obj_coefs) == self.ncols + for i in range(self.ncols): + if obj_coefs[i] != 0.0: + glpk.glp_set_obj_coef(self.lp, int(1 + i), obj_coefs[i]) + + def remove_obj_coefs(self, obj_coefs): + """remove coefficients from objective function + + set coefficients that are not zero to zero in the liner problem + + Note: glpk related indices start at 1 (not zero) + :param obj_coefs: coefficients of objective function + :type obj_coefs: list-like of floats + """ + assert len(obj_coefs) == self.ncols + for i in range(self.ncols): + if obj_coefs[i] != 0.0: + glpk.glp_set_obj_coef(self.lp, int(1 + i), 0.0) + + def set_obj_dir(self, direction): + """Configure the optimization direction + + :param direction: direction for the optimization ('maximize' or 'minimize') + :type direction: string + """ + obj_dir = glpk.GLP_MAX if 'max' in direction else glpk.GLP_MIN + glpk.glp_set_obj_dir(self.lp, obj_dir) + + def add_constraint(self, constr_coefs, row_bnds): + """add a single constraint to the lp problem + + :param constr_coefs: constraint coefficients for the new row + :type constr_coefs: 1d ndarray of length ncols + :param row_bnds: upper and lower bounds of the auxiliary (row) variable + :type row_bnds: 1D ndarray of length 2 + :return: row index (start from 0) + :rtype: int + """ + row_idx1 = glpk.glp_add_rows(self.lp, 1) + glpk.glp_set_row_bnds(self.lp, row_idx1, *self._get_variable_type(row_bnds)) + + n_coef = len(np.nonzero(constr_coefs)[0]) + ind = glpk.intArray(n_coef + 1) + val = glpk.doubleArray(n_coef + 1) + for num_idx0, col_idx0 in enumerate(np.nonzero(constr_coefs)[0]): + ind[num_idx0 + 1] = int(col_idx0 + 1) + val[num_idx0 + 1] = constr_coefs[col_idx0] + glpk.glp_set_mat_row(self.lp, row_idx1, n_coef, ind, val) + self.nrows += 1 + row_idx0 = row_idx1 - 1 + return row_idx0 + + def del_constraint(self, row_idx0): + """remove single constraint from the lp problem + + :param row_idx0: row index (start from 0) to be removed + :type row_idx0: int + """ + assert row_idx0 < self.nrows + num = glpk.intArray(1 + 1) + num[1] = row_idx0 + 1 + glpk.glp_del_rows(self.lp, 1, num) + self.nrows -= 1 + + def set_col_bnd(self, col_idx0, bounds): + """Set a single column (variable) bound + :param col_idx0: column index (start at 0) of variable + :type col_idx0: int + :param bounds: lower and upper bound to set + :type bounds: list with two float values + """ + assert col_idx0 < self.ncols + glpk.glp_set_col_bnds(self.lp, col_idx0 + 1, *self._get_variable_type(bounds)) + + def remove_cols(self, col_idxs0): + """ remove variables (columns) from the linear problem + + :param col_idxs0: col indices (start at 0) to be removed + :type col_idxs0: list or 1D ndarray of col indices + """ + num = glpk.intArray(len(col_idxs0) + 1) + for num_idx0, col_idx0 in enumerate(col_idxs0): + assert col_idx0 < self.ncols + num[num_idx0 + 1] = col_idx0 + 1 + glpk.glp_del_cols(self.lp, len(col_idxs0), num) + self.ncols -= len(col_idxs0) + + def remove_rows(self, row_idxs0): + """ remove constraints (rows) from the linear problem. + + :param row_idxs0: row indices (start at 0) to be removed + :type row_idxs0: list or 1D ndarray of row indices + """ + num = glpk.intArray(len(row_idxs0) + 1) + for num_idx0, row_idx0 in enumerate(row_idxs0): + assert row_idx0 < self.nrows + num[num_idx0 + 1] = row_idx0 + 1 + glpk.glp_del_rows(self.lp, len(row_idxs0), num) + self.nrows -= len(row_idxs0) + + def construct_inital_basis(self): + """Re-construct an initial basis, e.g. after problem update + """ + glpk.glp_adv_basis(self.lp, 0) + + def solve(self, short_result=True, scaling=None): + """Solve the Linear Programming Problem. + + :param short_result: short result (only objective value and status) or long result + :type short_result: bool (optional, default: True) + :param scaling: problem scaling options ('GM', 'EQ', '2N', 'SKIP', 'AUTO' + :type scaling: string (optional, default: None) + :return: result from linear optimization + :rtype: dict + """ + if scaling is not None: + glpk.glp_scale_prob(self.lp, scaling_options.get(scaling, glpk.GLP_SF_AUTO)) + + simplex_result = glpk.glp_simplex(self.lp, self._smcp) + + # in case of unsuccessful result, set basis and redo optimization + if glpk.glp_get_status(self.lp) != glpk.GLP_OPT: + glpk.glp_adv_basis(self.lp, 0) + # print('GLP ADV BASIS') + # glpk.glp_cpx_basis(self.lp) + simplex_result = glpk.glp_simplex(self.lp, self._smcp) + + self.res = { + 'fun': glpk.glp_get_obj_val(self.lp), + 'success': glpk.glp_get_status(self.lp) == glpk.GLP_OPT, + 'generic_status': generic_status[glpk.glp_get_status(self.lp)], + } + if short_result is False: + self.res['x'] = np.array([glpk.glp_get_col_prim(self.lp, 1 + i) for i in range(self.ncols)]) + self.res['reduced_costs'] = np.array([glpk.glp_get_col_dual(self.lp, 1 + i) + for i in range(self.ncols)]) + self.res['shadow_prices'] = np.array([glpk.glp_get_row_dual(self.lp, 1 + i) + for i in range(self.nrows)]) + self.res['simplex_result'] = simplex_result + self.res['simplex_message'] = simplex_status[simplex_result], + self.res['primal_status'] = prim_status[glpk.glp_get_prim_stat(self.lp)] + self.res['dual_status'] = dual_status[glpk.glp_get_dual_stat(self.lp)] + return self.res + + def get_row_value(self, row_idx0): + """Retrieve right-hand side of a constraint (auxiliary variable) + + :param row_idx0: row index into constraints matrix (starting with 0) + :type row_idx0: int + :return: value of auxiliary varibale (value of rhs) + :rtype: float + """ + assert row_idx0 < glpk.glp_get_num_rows(self.lp) + return glpk.glp_get_row_prim(self.lp, row_idx0+1) + + def __del__(self): + """Explicitly delete the LpProblem. (can we do without?) + """ + # print('deletion of LpProblem') + assert self.lp is not None + if getattr(self, 'lp'): + glpk.glp_delete_prob(self.lp) diff --git a/xbanalysis/problems/lp_problem.py b/xbanalysis/solvers/lp_problem.py similarity index 81% rename from xbanalysis/problems/lp_problem.py rename to xbanalysis/solvers/lp_problem.py index b52d80344ed2597b5fc11393fe0a2d9db43281ad..2e1479c67d33e82d09cf79858f26984e193243d5 100644 --- a/xbanalysis/problems/lp_problem.py +++ b/xbanalysis/solvers/lp_problem.py @@ -14,43 +14,43 @@ import swiglpk as glpk # status reported simplex_status = { 0: 'LP problem instance successfully solved', - 1: 'Unable to start search, initial basis specified in the ' + glpk.GLP_EBADB: 'Unable to start search, initial basis specified in the ' 'problem object is invalid. Number basic variables is not same as the number rows.', - 2: 'Unable to start search, basis matrix corresponding to initial basis is singular.', - 3: 'Unable to start search, basis matrix corresponding to initial basis is ill-conditioned.', - 4: 'Unable to start search, some double-bounded variables have incorrect bounds.', - 5: 'Search prematurely terminated: solver failure.', - 6: 'Search prematurely terminated: objective function being ' + glpk.GLP_ESING: 'Unable to start search, basis matrix corresponding to initial basis is singular.', + glpk.GLP_ECOND: 'Unable to start search, basis matrix corresponding to initial basis is ill-conditioned.', + glpk.GLP_EBOUND: 'Unable to start search, some double-bounded variables have incorrect bounds.', + glpk.GLP_EFAIL: 'Search prematurely terminated: solver failure.', + glpk.GLP_EOBJLL: 'Search prematurely terminated: objective function being ' 'maximized reached its lower limit and continues decreasing.', - 7: 'Search prematurely terminated: objective function being ' + glpk.GLP_EOBJUL: 'Search prematurely terminated: objective function being ' 'minimized reached its upper limit and continues increasing.', - 8: 'Search prematurely terminated: simplex iteration limit exceeded.', - 9: 'Search prematurely terminated: time limit exceeded.', - 10: 'LP problem instance has no primal feasible solution.', - 11: 'LP problem instance has no dual feasible solution.'} - -generic_status = {1: 'solution is undefined', - 2: 'solution is feasible', - 3: 'solution is infeasible', - 4: 'problem has no feasible solution', - 5: 'solution is optimal', - 6: 'problem has unbounded solution'} - -dual_status = {1: 'dual solution is undefined', - 2: 'dual solution is feasible', - 3: 'dual solution is infeasible', - 4: 'no dual feasible solution exists'} - -prim_status = {1: 'primal solution is undefined', - 2: 'primal solution is feasible', - 3: 'primal solution is infeasible', - 4: 'no primal feasible solution exists'} - -var_status = {1: 'basic variable', - 2: 'non-basic variable on its lower bound', - 3: 'non-basic variable on its upper bound', - 4: 'non-basic free (unbounded) variable', - 5: 'non-basic fixed variable'} + glpk.GLP_EITLIM: 'Search prematurely terminated: simplex iteration limit exceeded.', + glpk.GLP_ETMLIM: 'Search prematurely terminated: time limit exceeded.', + glpk.GLP_ENOPFS: 'LP problem instance has no primal feasible solution.', + glpk.GLP_ENODFS: 'LP problem instance has no dual feasible solution.'} + +generic_status = {glpk.GLP_UNDEF: 'solution is undefined', + glpk.GLP_FEAS: 'solution is feasible', + glpk.GLP_INFEAS: 'solution is infeasible', + glpk.GLP_NOFEAS: 'problem has no feasible solution', + glpk.GLP_OPT: 'solution is optimal', + glpk.GLP_UNBND: 'problem has unbounded solution'} + +dual_status = {glpk.GLP_UNDEF: 'dual solution is undefined', + glpk.GLP_FEAS: 'dual solution is feasible', + glpk.GLP_INFEAS: 'dual solution is infeasible', + glpk.GLP_NOFEAS: 'no dual feasible solution exists'} + +prim_status = {glpk.GLP_UNDEF: 'primal solution is undefined', + glpk.GLP_FEAS: 'primal solution is feasible', + glpk.GLP_INFEAS: 'primal solution is infeasible', + glpk.GLP_NOFEAS: 'no primal feasible solution exists'} + +var_status = {glpk.GLP_BS: 'basic variable', + glpk.GLP_NL: 'non-basic variable on its lower bound', + glpk.GLP_NU: 'non-basic variable on its upper bound', + glpk.GLP_NF: 'non-basic free (unbounded) variable', + glpk.GLP_NS: 'non-basic fixed variable'} scaling_options = {'GM': glpk.GLP_SF_GM, 'EQ': glpk.GLP_SF_EQ, @@ -69,6 +69,8 @@ class LpProblem: self.lp = glpk.glp_create_prob() self.ncols = 0 self.nrows = 0 + self.msg_lev = glpk.GLP_MSG_ERR + self.tm_lim = 10 * 1000 self.res = {} @staticmethod @@ -245,12 +247,15 @@ class LpProblem: :return: result information from the optimization stored in a dictionary :rtype: dict """ + smcp = glpk.glp_smcp() + glpk.glp_init_smcp(smcp) + smcp.msg_lev = self.msg_lev + smcp.tm_lim = self.tm_lim + if scaling is not None: opt = scaling_options.get(scaling, glpk.GLP_SF_AUTO) glpk.glp_scale_prob(self.lp, opt) - # sjj = [glpk.glp_get_sjj(self.lp, 1+i) for i in range(glpk.glp_get_num_cols(self.lp))] - # rii = [glpk.glp_get_rii(self.lp, 1+i) for i in range(glpk.glp_get_num_rows(self.lp))] - simplex_result = glpk.glp_simplex(self.lp, None) + simplex_result = glpk.glp_simplex(self.lp, smcp) return self.results(simplex_result, short_result) def get_row_value(self, row):