diff --git a/release-notes/next-release.md b/release-notes/next-release.md index 790c94af5..d2586332a 100644 --- a/release-notes/next-release.md +++ b/release-notes/next-release.md @@ -10,6 +10,7 @@ of SBO:0000633 (see https://sourceforge.net/p/sbo/term-request/113/) * Correct reading and writing of subsystem in mat. * General cleanup of code in mat.py +* Fixed issue 673 (problem when copying reaction from a different model) ## Other diff --git a/src/cobra/core/gene.py b/src/cobra/core/gene.py index 0d1f86f29..5f4212dee 100644 --- a/src/cobra/core/gene.py +++ b/src/cobra/core/gene.py @@ -18,7 +18,7 @@ from ast import parse as ast_parse from copy import deepcopy from keyword import kwlist -from typing import FrozenSet, Iterable, Optional, Set, Tuple, Union +from typing import TYPE_CHECKING, FrozenSet, Iterable, Optional, Set, Tuple, Union from warnings import warn import sympy.logic.boolalg as spl @@ -30,6 +30,10 @@ from cobra.util.util import format_long_string +if TYPE_CHECKING: + from cobra.core import Reaction + + # TODO - When https://github.com/symengine/symengine.py/issues/334 is resolved, # change sympy.Symbol (above in imports) to optlang.symbolics.Symbol @@ -246,10 +250,28 @@ def knock_out(self) -> None: context. """ self.functional = False - for reaction in self.reactions: + for reaction in self.reactions: # type: ignore if not reaction.functional: reaction.bounds = (0, 0) + @property + def reactions(self) -> FrozenSet["Reaction"]: + """Return a frozenset of reactions. + + If the gene is present in a model, calculates this set by querying + the model reactions for this gene. + If not, returns the _reaction field, see cobra.Species. + + Returns + ------- + FrozenSet + A frozenset that includes the reactions of the gene. + """ + if self.model: + return frozenset(self.model.reactions.query(lambda x: self in x, "genes")) + else: + return frozenset(self._reaction) + def _repr_html_(self): return f""" diff --git a/src/cobra/core/metabolite.py b/src/cobra/core/metabolite.py index 6b3d31dc9..69be504c2 100644 --- a/src/cobra/core/metabolite.py +++ b/src/cobra/core/metabolite.py @@ -1,7 +1,7 @@ """Define the Metabolite class.""" import re -from typing import TYPE_CHECKING, Dict, Optional, Union +from typing import TYPE_CHECKING, Dict, FrozenSet, Optional, Union from warnings import warn from ..exceptions import OptimizationError @@ -15,7 +15,7 @@ from optlang.interface import Container from pandas import DataFrame - from cobra.core import Solution + from cobra.core import Reaction, Solution from cobra.summary.metabolite_summary import MetaboliteSummary # Numbers are not required because of the |(?=[A-Z])? block. See the @@ -276,6 +276,26 @@ def remove_from_model(self, destructive: bool = False) -> None: """ self._model.remove_metabolites(self, destructive) + @property + def reactions(self) -> FrozenSet["Reaction"]: + """Return a frozenset of reactions. + + If the metabolite is present in a model, calculates this set by querying + the model reactions for this metabolite. + If not, returns the _reaction field, see cobra.Species. + + Returns + ------- + FrozenSet + A frozenset that includes the reactions of the metabolite. + """ + if self.model: + return frozenset( + self.model.reactions.query(lambda x: self in x, "metabolites") + ) + else: + return frozenset(self._reaction) + def summary( self, solution: Optional["Solution"] = None, diff --git a/src/cobra/core/model.py b/src/cobra/core/model.py index 597c3802b..22851606f 100644 --- a/src/cobra/core/model.py +++ b/src/cobra/core/model.py @@ -428,10 +428,11 @@ def copy(self) -> "Model": new_reaction._model = new new.reactions.append(new_reaction) # update awareness - for metabolite, stoic in reaction._metabolites.items(): - new_met = new.metabolites.get_by_id(metabolite.id) - new_reaction._metabolites[new_met] = stoic - new_met._reaction.add(new_reaction) + new_metabolites = { + new.metabolites.get_by_id(metabolite.id): stoic + for metabolite, stoic in reaction.metabolites.items() + } + new_reaction.metabolites = new_metabolites new_reaction.update_genes_from_gpr() new.groups = DictList() @@ -500,6 +501,9 @@ def add_metabolites(self, metabolite_list: Union[List, Metabolite]) -> None: # First check whether the metabolites exist in the model metabolite_list = [x for x in metabolite_list if x.id not in self.metabolites] + metabolite_list = [ + x.copy() if x.model and x.model != self else x for x in metabolite_list + ] bad_ids = [ m for m in metabolite_list if not isinstance(m.id, str) or len(m.id) < 1 @@ -558,12 +562,12 @@ def remove_metabolites( group.remove_members(x) if not destructive: - for the_reaction in list(x._reaction): # noqa W0212 + for the_reaction in x.reactions: the_coefficient = the_reaction._metabolites[x] # noqa W0212 the_reaction.subtract_metabolites({x: the_coefficient}) else: - for x2 in list(x._reaction): # noqa W0212 + for x2 in x.reactions: x2.remove_from_model() self.metabolites -= metabolite_list @@ -718,8 +722,29 @@ def existing_filter(rxn: Reaction) -> bool: return False return True - # First check whether the reactions exist in the model. - pruned = DictList(filter(existing_filter, reaction_list)) + # First check whether the reactions exist in the model. Copy reactions that + # belong to another model, in order not to remove them from the original model. + exist_but_different = set() + other_model = "" + for reaction in filter(lambda x: not existing_filter(x), reaction_list): + if reaction != self.reactions.get_by_id(reaction.id): + exist_but_different.add(reaction.id) + other_model = other_model or reaction.model + if len(exist_but_different): + logger.warning( + f"The reactions {', '.join(exist_but_different)} exist" + f" in both {self} and {other_model}, but have different " + f"properties in the two mdoels. Please check to see " + f"which properties are the correct ones you'd like to add." + ) + pruned = DictList( + [ + reaction + if not reaction.model or reaction.model == self + else reaction.copy() + for reaction in filter(existing_filter, reaction_list) + ] + ) context = get_context(self) @@ -728,23 +753,17 @@ def existing_filter(rxn: Reaction) -> bool: reaction._model = self if context: context(partial(setattr, reaction, "_model", None)) - # Build a `list()` because the dict will be modified in the loop. - for metabolite in list(reaction.metabolites): - # TODO: Maybe this can happen with - # Reaction.add_metabolites(combine=False) - # TODO: Should we add a copy of the metabolite instead? - if metabolite not in self.metabolites: - self.add_metabolites(metabolite) - # A copy of the metabolite exists in the model, the reaction - # needs to point to the metabolite in the model. - else: - # FIXME: Modifying 'private' attributes is horrible. - stoichiometry = reaction._metabolites.pop(metabolite) - model_metabolite = self.metabolites.get_by_id(metabolite.id) - reaction._metabolites[model_metabolite] = stoichiometry - model_metabolite._reaction.add(reaction) - if context: - context(partial(model_metabolite._reaction.remove, reaction)) + mets_to_add = [ + met + for met in reaction.metabolites + if met.id not in self.metabolites.list_attr("id") + ] + self.add_metabolites(mets_to_add) + new_stoich = { + self.metabolites.get_by_id(met.id): stoich + for met, stoich in reaction.metabolites.items() + } + reaction.metabolites = new_stoich reaction.update_genes_from_gpr() self.reactions += pruned @@ -814,19 +833,15 @@ def remove_reactions( for met in reaction._metabolites: if reaction in met._reaction: - met._reaction.remove(reaction) - if context: - context(partial(met._reaction.add, reaction)) - if remove_orphans and len(met._reaction) == 0: + met.remove_reaction(reaction) + if remove_orphans and len(met.reactions) == 0: self.remove_metabolites(met) for gene in reaction._genes: if reaction in gene._reaction: - gene._reaction.remove(reaction) - if context: - context(partial(gene._reaction.add, reaction)) + gene.remove_reaction(reaction) - if remove_orphans and len(gene._reaction) == 0: + if remove_orphans and len(gene.reactions) == 0: self.genes.remove(gene) if context: context(partial(self.genes.add, gene)) @@ -869,7 +884,7 @@ def existing_filter(new_group: Group) -> bool: """ if new_group.id in self.groups: logger.warning( - f"Ignoring group '{new_group.id}'" f" since it already exists." + f"Ignoring group '{new_group.id}' since it already exists." ) return False return True @@ -1257,13 +1272,13 @@ def repair( self.groups._generate_index() if rebuild_relationships: for met in self.metabolites: - met._reaction.clear() + met.clear_reaction() for gene in self.genes: - gene._reaction.clear() + gene.clear_reaction() for rxn in self.reactions: rxn.update_genes_from_gpr() - for met in rxn._metabolites: - met._reaction.add(rxn) + for met in rxn.metabolites: + met.add_reaction(rxn) # point _model to self for dict_list in (self.reactions, self.genes, self.metabolites, self.groups): diff --git a/src/cobra/core/reaction.py b/src/cobra/core/reaction.py index 0623ae170..1b463bae7 100644 --- a/src/cobra/core/reaction.py +++ b/src/cobra/core/reaction.py @@ -1,6 +1,7 @@ """Define the Reaction class.""" import hashlib +import math import re from collections import defaultdict from copy import copy, deepcopy @@ -579,6 +580,23 @@ def metabolites(self) -> Dict[Metabolite, float]: """ return self._metabolites.copy() + @metabolites.setter + @resettable + def metabolites(self, value: Dict[Metabolite, float]) -> None: + """Set metabolites to a dictionary of metabolites and coefficients. + + Parameters + ---------- + value: Dict[Metabolite, float] + A dictionary of cobra.Metabolite for keys and floats for coeffecieints. + Positive coefficient means the reaction produces this metabolite, while + negative coefficient means the reaction consumes this metabolite. + """ + self._metabolites = value + context = get_context(self) + for met in value.keys(): + met.add_reaction(self) + @property def genes(self) -> FrozenSet: """Return the genes of the reaction. @@ -837,11 +855,13 @@ def _update_awareness(self) -> None: Make sure all metabolites and genes that are associated with this reaction are aware of it. + .. deprecated:: 0.26 + This function is deprecated and shouldn't be used. """ for x in self._metabolites: - x._reaction.add(self) + x.add_reaction(self) for x in self._genes: - x._reaction.add(self) + x.add_reaction(self) def remove_from_model(self, remove_orphans: bool = False) -> None: """Remove the reaction from a model. @@ -930,12 +950,9 @@ def __setstate__(self, state: Dict) -> None: state["_gpr"] = GPR.from_string(state["_gpr"]) self.__dict__.update(state) - for x in state["_metabolites"]: + for x in set(state["_metabolites"]).union(state["_genes"]): x._model = self._model - x._reaction.add(self) - for x in state["_genes"]: - x._model = self._model - x._reaction.add(self) + x.add_reaction(self) def copy(self) -> "Reaction": """Copy a reaction. @@ -985,7 +1002,7 @@ def __add__(self, other: "Reaction") -> "Reaction": """ new_reaction = self.copy() - if other == 0: + if isinstance(other, int) and other == 0: return new_reaction else: new_reaction += other @@ -1018,7 +1035,7 @@ def __iadd__(self, other: "Reaction") -> "Reaction": rule2 = other.gene_reaction_rule.strip() if rule1 != "" and rule2 != "": self.gene_reaction_rule = ( - f"({self.gene_reaction_rule}) and " f"({other.gene_reaction_rule})" + f"({self.gene_reaction_rule}) and ({other.gene_reaction_rule})" ) elif rule1 != "" and rule2 == "": self.gene_reaction_rule = rule1 @@ -1231,11 +1248,12 @@ def add_metabolites( # Make sure metabolites being added belong to the same model, or # else copy them. - if isinstance(metabolite, Metabolite): - if (metabolite.model is not None) and ( - metabolite.model is not self._model - ): - metabolite = metabolite.copy() + if isinstance(metabolite, Metabolite) and ( + (metabolite.model is not None) + and self.model + and (metabolite.model is not self._model) + ): + metabolite = metabolite.copy() met_id = str(metabolite) # If a metabolite already exists in the reaction then @@ -1268,7 +1286,7 @@ def add_metabolites( self._metabolites[metabolite] = coefficient # make the metabolite aware that it is involved in this # reaction - metabolite._reaction.add(self) + metabolite.add_reaction(self) # from cameo ... model = self.model @@ -1287,7 +1305,7 @@ def add_metabolites( if the_coefficient == 0: # make the metabolite aware that it no longer participates # in this reaction - metabolite._reaction.remove(self) + metabolite.remove_reaction(self) self._metabolites.pop(metabolite) context = get_context(self) @@ -1487,7 +1505,7 @@ def _associate_gene(self, cobra_gene: Gene) -> None: """ self._genes.add(cobra_gene) - cobra_gene._reaction.add(self) + cobra_gene.add_reaction(self) cobra_gene._model = self._model def _dissociate_gene(self, cobra_gene: Gene) -> None: @@ -1499,7 +1517,7 @@ def _dissociate_gene(self, cobra_gene: Gene) -> None: """ self._genes.discard(cobra_gene) - cobra_gene._reaction.discard(self) + cobra_gene.remove_reaction(self) def knock_out(self) -> None: """Knockout reaction by setting its bounds to zero.""" @@ -1656,6 +1674,51 @@ def summary( fva=fva, ) + def __eq__(self, other) -> bool: + self_state = self.__getstate__() + self_state["_metabolites"] = { + met.id: stoic for met, stoic in self.metabolites.items() + } + self_state["_genes"] = {gene.id for gene in self.genes} + self_state.pop("_model") + other_state = other.__getstate__() + other_state["_metabolites"] = { + met.id: stoic for met, stoic in other.metabolites.items() + } + other_state["_genes"] = {gene.id for gene in other.genes} + other_state.pop("_model") + + self_state.pop("_lower_bound") + self_state.pop("_upper_bound") + other_state.pop("_lower_bound") + other_state.pop("_upper_bound") + + _tolerance = 1e-7 + if self.model: + _tolerance = self.model.tolerance + elif other.model: + _tolerance = other.model.tolerance + elif self.model and other.model: + _tolerance = min(self.model.tolerance, other.model.tolerance) + if not math.isclose(self.lower_bound, other.lower_bound, abs_tol=_tolerance): + return False + elif not math.isclose(self.upper_bound, other.upper_bound, abs_tol=_tolerance): + return False + else: + return self_state == other_state + + def __hash__(self) -> int: + """Define __hash__ explicitly to allow usage of reaction in sets. + + Python objects that define __eq__ MUST define __hash__ explicitly, or they + are unhashable. __hash__ is set to the __hash__ function of cobra.Object. + + Returns + ------- + int + """ + return Object.__hash__(self) + def __str__(self) -> str: """Return reaction id and reaction as str. diff --git a/src/cobra/core/species.py b/src/cobra/core/species.py index 39749fcd0..162e0361c 100644 --- a/src/cobra/core/species.py +++ b/src/cobra/core/species.py @@ -8,7 +8,7 @@ if TYPE_CHECKING: - from .. import Model + from .. import Model, Reaction class Species(Object): @@ -57,6 +57,32 @@ def reactions(self) -> FrozenSet: """ return frozenset(self._reaction) + def add_reaction(self, reaction: "Reaction") -> None: + """Add reaction to .reaction field, with context. + + If this is called within a context, will be reversed when exiting the context. + + Parmeters + --------- + reaction: cobra.Reaction + """ + self._reaction.add(reaction) + + def remove_reaction(self, reaction: "Reaction") -> None: + """Remove reaction from .reaction field, with context. + + If this is called within a context, will be reversed when exiting the context. + + Parmeters + --------- + reaction: cobra.Reaction + """ + self._reaction.remove(reaction) + + def clear_reaction(self) -> None: + """Clear the reaction field.""" + self._reaction.clear() + def __getstate__(self) -> dict: """Return the state of the species. diff --git a/tests/test_core/test_model.py b/tests/test_core/test_model.py index e7e606ae1..3cf981bf4 100644 --- a/tests/test_core/test_model.py +++ b/tests/test_core/test_model.py @@ -4,6 +4,7 @@ import warnings from copy import copy, deepcopy from math import isnan +from pathlib import Path from typing import List, Tuple import numpy as np @@ -14,6 +15,7 @@ from cobra import Solution from cobra.core import Group, Metabolite, Model, Reaction from cobra.exceptions import OptimizationError +from cobra.io import read_sbml_model from cobra.manipulation.delete import remove_genes from cobra.util import solver as su from cobra.util.solver import SolverNotFound, set_objective, solvers @@ -982,6 +984,61 @@ def test_add_reactions(model: Model) -> None: assert coefficients_dict[model.reactions.r2.reverse_variable] == -3.0 +def test_add_reactions_with_context(model: Model) -> None: + """Test add_reactions() function to add reactions to model in a context. + + Parameters + ---------- + model: cobra.Model + """ + r1 = Reaction("r1") + r1.add_metabolites({Metabolite("A"): -1, Metabolite("B"): 1}) + r1.lower_bound, r1.upper_bound = -999999.0, 999999.0 + r2 = Reaction("r2") + r2.add_metabolites({Metabolite("A"): -1, Metabolite("C"): 1, Metabolite("D"): 1}) + r2.lower_bound, r2.upper_bound = 0.0, 999999.0 + with model: + model.add_reactions([r1, r2]) + r2.objective_coefficient = 3.0 + assert r2.objective_coefficient == 3.0 + assert model.reactions[-2] == r1 + assert model.reactions[-1] == r2 + assert isinstance(model.reactions[-2].reverse_variable, model.problem.Variable) + coefficients_dict = model.objective.expression.as_coefficients_dict() + biomass_r = model.reactions.get_by_id("Biomass_Ecoli_core") + assert coefficients_dict[biomass_r.forward_variable] == 1.0 + assert coefficients_dict[biomass_r.reverse_variable] == -1.0 + assert coefficients_dict[model.reactions.r2.forward_variable] == 3.0 + assert coefficients_dict[model.reactions.r2.reverse_variable] == -3.0 + assert model.reactions[-2] != r1 + assert model.reactions[-1] != r2 + assert isinstance(model.reactions[-2].reverse_variable, model.problem.Variable) + coefficients_dict = model.objective.expression.as_coefficients_dict() + biomass_r = model.reactions.get_by_id("Biomass_Ecoli_core") + assert coefficients_dict[biomass_r.forward_variable] == 1.0 + assert coefficients_dict[biomass_r.reverse_variable] == -1.0 + assert r2.forward_variable not in coefficients_dict + assert r2.reverse_variable not in coefficients_dict + + +def test_add_reactions_from_another_model(model: Model, data_directory: Path) -> None: + """Test adding reaction from an existing model. + + This will test that adding a reaction with an existing model does not add + reactions of the metabolites, and does not remove the reaction from the existing + model. + + Parameters + ---------- + model: corbra.Model + data_directory: Path + Directory where mini_cobra.xml is found. + """ + mini = read_sbml_model(data_directory / "mini_cobra.xml") + mini.add_reactions([model.reactions.get_by_id("ACALD")]) + assert len(mini.metabolites.get_by_id("acald_c").reactions) == 1 + + def test_add_reactions_single_existing(model: Model) -> None: """Test adding a reaction already present to a model.