mntools 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
mntools-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,27 @@
1
+ Metadata-Version: 2.4
2
+ Name: mntools
3
+ Version: 0.1.0
4
+ Summary: Metabolic Network Tools.
5
+ Keywords: bioinformatics,metabolic networks,metabolic network analysis,cheminformatics
6
+ Author: Casper Asbjørn Eriksen
7
+ Author-email: Casper Asbjørn Eriksen <casbjorn@imada.sdu.dk>
8
+ License-Expression: GPL-3.0-only
9
+ Classifier: Programming Language :: Python :: 3.12
10
+ Classifier: License :: OSI Approved :: GNU General Public License v3 (GPLv3)
11
+ Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
12
+ Classifier: Topic :: Scientific/Engineering :: Chemistry
13
+ Requires-Dist: chemrecon==0.1.5
14
+ Requires-Dist: python-libsbml==5.21.1
15
+ Requires-Dist: ruff>=0.15.10
16
+ Requires-Dist: ty>=0.0.31
17
+ Maintainer: Casper Asbjørn Eriksen
18
+ Maintainer-email: Casper Asbjørn Eriksen <casbjorn@imada.sdu.dk>
19
+ Requires-Python: >=3.12
20
+ Description-Content-Type: text/markdown
21
+
22
+ # Metabolic Network Tools
23
+
24
+ ---
25
+
26
+ MNTools is a library for loading and representing metabolic networks.
27
+ It uses [ChemRecon](gitlab.com/casbjorn/chemrecon) as its backend for handling database information.
@@ -0,0 +1,6 @@
1
+ # Metabolic Network Tools
2
+
3
+ ---
4
+
5
+ MNTools is a library for loading and representing metabolic networks.
6
+ It uses [ChemRecon](gitlab.com/casbjorn/chemrecon) as its backend for handling database information.
@@ -0,0 +1,55 @@
1
+ [project]
2
+ name = "mntools"
3
+ version = "0.1.0"
4
+
5
+ # Metadata
6
+ description = "Metabolic Network Tools."
7
+ authors = [
8
+ { name = 'Casper Asbjørn Eriksen', email = 'casbjorn@imada.sdu.dk' }
9
+ ]
10
+ maintainers = [
11
+ { name = 'Casper Asbjørn Eriksen', email = 'casbjorn@imada.sdu.dk' }
12
+ ]
13
+ readme = { file = "README.md", content-type = "text/markdown" }
14
+ license = 'GPL-3.0-only'
15
+ keywords = [
16
+ 'bioinformatics',
17
+ 'metabolic networks',
18
+ 'metabolic network analysis',
19
+ 'cheminformatics'
20
+ ]
21
+ classifiers = [
22
+ 'Programming Language :: Python :: 3.12',
23
+ 'License :: OSI Approved :: GNU General Public License v3 (GPLv3)',
24
+ 'Topic :: Scientific/Engineering :: Bio-Informatics',
25
+ 'Topic :: Scientific/Engineering :: Chemistry'
26
+ ]
27
+
28
+ # Dependencies
29
+ requires-python = ">=3.12"
30
+ dependencies = [
31
+ "chemrecon == 0.1.5", # Database backend
32
+ "python-libsbml==5.21.1", # For parsing SBML files
33
+ "ruff>=0.15.10",
34
+ "ty>=0.0.31",
35
+ ]
36
+
37
+ # Build
38
+ [build-system]
39
+ requires = ["uv_build>=0.11.7,<0.12"]
40
+ build-backend = "uv_build"
41
+
42
+ [tool.uv]
43
+ package = true
44
+
45
+
46
+ # Development tools
47
+ [dependency-groups]
48
+ dev = [
49
+ "pytest",
50
+ "ruff",
51
+ "ty",
52
+ ]
53
+
54
+ [tool.ruff.format]
55
+ line-ending = "lf"
@@ -0,0 +1,14 @@
1
+ """Metabolic Network Tools."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import Enum
6
+
7
+ from mntools.compartment import Compartment
8
+ from mntools.metabolicnetwork import (
9
+ MetabolicNetwork,
10
+ )
11
+ from mntools.metabolite import Metabolite
12
+ from mntools.reaction import Reaction
13
+ from mntools.species import Species
14
+ from mntools.types import Side, StoichCoeff
@@ -0,0 +1,38 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Optional
4
+
5
+ if TYPE_CHECKING:
6
+ from mntools.species import Species
7
+
8
+
9
+ class Compartment:
10
+ """todo."""
11
+
12
+ id: str #: Primary identifier
13
+ name: str #: Name, if present
14
+
15
+ # Network
16
+ _species: list[Species]
17
+
18
+ # Getters
19
+ def species(self) -> list[Species]:
20
+ return self._species
21
+
22
+ # Setters
23
+ def __init__(self, id: str, name: Optional[str]):
24
+ self.id = id
25
+ self.name = name if name is not None else id
26
+
27
+ self._species = list()
28
+
29
+ def add_species(self, species: Species):
30
+ if species in self._species:
31
+ raise ValueError(f"Species {species} already in compartment {self.id}.")
32
+ self._species.append(species)
33
+
34
+ def __str__(self):
35
+ return self.name
36
+
37
+ def __repr__(self):
38
+ return self.id
@@ -0,0 +1,19 @@
1
+ """Various tables."""
2
+
3
+ # Names and ids to recognize the extracellular compartment.
4
+ extracellular_names: set[str] = {
5
+ "external",
6
+ "extracellular",
7
+ "external_species",
8
+ "c_e",
9
+ "e",
10
+ "External_Species",
11
+ "C_e",
12
+ "extracellular space",
13
+ }
14
+
15
+ biomass_names: set[str] = {
16
+ "biomass",
17
+ "growth",
18
+ "biomass reaction",
19
+ }
@@ -0,0 +1,3 @@
1
+ """Loaders for various formats are defined here."""
2
+
3
+ from mntools.load.loader_sbml import load_from_sbml
@@ -0,0 +1,208 @@
1
+ from math import isnan
2
+ from typing import Optional
3
+
4
+ import chemrecon
5
+ import libsbml
6
+
7
+ from mntools import Compartment, MetabolicNetwork, Reaction, Species, StoichCoeff
8
+ from mntools.constants import biomass_names
9
+
10
+
11
+ def load_from_sbml(
12
+ filename: str,
13
+ metabolite_annotation_type: Optional[str] = "autodetect",
14
+ reaction_annotation_type: Optional[str] = "autodetect",
15
+ ) -> MetabolicNetwork:
16
+
17
+ model = MetabolicNetwork(name="test", filename=filename)
18
+
19
+ # Read the SBML
20
+ reader = libsbml.SBMLReader()
21
+ sbml_file = reader.readSBML(filename)
22
+ sbml_model: libsbml.Model = sbml_file.getModel()
23
+
24
+ # Annotation parser
25
+ annotation_parser = libsbml.RDFAnnotationParser()
26
+
27
+ # Determine the name of the model
28
+ # TODO
29
+ # Fall back to filename if name not found
30
+
31
+ # Determine type of id annotations
32
+ metabolite_annotation: Optional[chemrecon.IdentifierTypeCompound]
33
+ reaction_annotation: Optional[chemrecon.IdentifierTypeReaction]
34
+ match metabolite_annotation_type:
35
+ case "autodetect":
36
+ # Autodetect from the annotations
37
+ metabolite_annotation = parse_metabolite_id_type(sbml_model)
38
+ case None:
39
+ # No annotation type set
40
+ metabolite_annotation = None
41
+ case _:
42
+ # Try to parse the string
43
+ metabolite_annotation = chemrecon.recognize_id_type_compound(
44
+ metabolite_annotation_type
45
+ )
46
+
47
+ match reaction_annotation_type:
48
+ case "autodetect":
49
+ # Autodetect from the annotations
50
+ reaction_annotation = parse_reaction_id_type(sbml_model)
51
+ case None:
52
+ # No annotation type set
53
+ reaction_annotation = None
54
+ case _:
55
+ # Try to parse the string
56
+ reaction_annotation = chemrecon.recognize_id_type_reaction(
57
+ reaction_annotation_type
58
+ )
59
+
60
+ model.set_metabolite_annotation_type(metabolite_annotation)
61
+ model.set_reaction_annotation_type(reaction_annotation)
62
+
63
+ # Load compartments
64
+ sbml_compartment_list = sbml_model.getListOfCompartments()
65
+ for sbml_compartment in sbml_compartment_list:
66
+ id = sbml_compartment.getId()
67
+ name = sbml_compartment.getName()
68
+
69
+ compartment = Compartment(id=id, name=name)
70
+ model.add_compartment(compartment)
71
+
72
+ # Load species
73
+ sbml_species_list = sbml_model.getListOfSpecies()
74
+ for sbml_species in sbml_species_list:
75
+ id = sbml_species.getId()
76
+ name = sbml_species.getName()
77
+ compartment_id = sbml_species.getCompartment()
78
+ compartment = model.get_or_create_compartment(compartment_id)
79
+
80
+ # Annotations
81
+ species_annotation_strs: set[str] = set()
82
+ for cvterm in sbml_species.getCVTerms():
83
+ for i in range(cvterm.getNumResources()):
84
+ res = cvterm.getResourceURI(i)
85
+ if res.startswith("http://identifiers.org"):
86
+ species_annotation_strs.add(res)
87
+ elif res.startswith("https://identifiers.org"):
88
+ species_annotation_strs.add(res)
89
+
90
+ species = Species(id=id, name=name, compartment=compartment)
91
+ species.annotation_strs = species_annotation_strs
92
+ model.add_species(species)
93
+
94
+ # Load reactions
95
+ sbml_reaction_list = sbml_model.getListOfReactions()
96
+ for sbml_reaction in sbml_reaction_list:
97
+ id = sbml_reaction.getId()
98
+ name = sbml_reaction.getName()
99
+
100
+ # Get species which take part
101
+ lhs_float: dict[Species, float] = dict()
102
+ rhs_float: dict[Species, float] = dict()
103
+ lhs: dict[Species, StoichCoeff] = dict()
104
+ rhs: dict[Species, StoichCoeff] = dict()
105
+
106
+ for stoich_mult, listOfSpeciesRefs in [
107
+ (-1, sbml_reaction.getListOfReactants()),
108
+ (1, sbml_reaction.getListOfProducts()),
109
+ ]:
110
+ float_stoich_dict: dict[Species, float] = dict()
111
+ int_stoich_dict: dict[Species, int] = dict()
112
+
113
+ for sbml_species_ref in listOfSpeciesRefs:
114
+ stoich_float = sbml_species_ref.getStoichiometry()
115
+ species_id = sbml_species_ref.getSpecies()
116
+ species = model.get_species(species_id)
117
+ if species is None:
118
+ raise ValueError(
119
+ f"Reaction {id} refers to species {species_id} not found in model."
120
+ )
121
+
122
+ if species in float_stoich_dict:
123
+ # In this case, assume multiple entries correspond to stoichiometry
124
+ float_stoich_dict[species] += 1
125
+ int_stoich_dict[species] += 1
126
+
127
+ if isnan(stoich_float):
128
+ # If no stoichiometry is provided, assume 1
129
+ stoich_float = 1
130
+
131
+ float_stoich_dict[species] = stoich_float * stoich_mult
132
+ int_stoich_dict[species] = int(stoich_float * stoich_mult)
133
+
134
+ # Set the dicts
135
+ match stoich_mult:
136
+ case -1:
137
+ lhs_float = float_stoich_dict
138
+ lhs = int_stoich_dict
139
+ case 1:
140
+ rhs_float = float_stoich_dict
141
+ rhs = int_stoich_dict
142
+
143
+ # Annotations
144
+ reaction_annotation_strs: set[str] = set()
145
+ for cvterm in sbml_reaction.getCVTerms():
146
+ for i in range(cvterm.getNumResources()):
147
+ res = cvterm.getResourceURI(i)
148
+ if res.startswith("http://identifiers.org"):
149
+ reaction_annotation_strs.add(res)
150
+ elif res.startswith("https://identifiers.org"):
151
+ reaction_annotation_strs.add(res)
152
+
153
+ # Create reaction and add
154
+ reaction = Reaction(id=id, name=name, lhs=lhs, rhs=rhs)
155
+ reaction.annotation_strs = reaction_annotation_strs
156
+ model.add_reaction(reaction)
157
+
158
+ # Finalize and validate
159
+ model.process()
160
+ return model
161
+
162
+
163
+ def is_id_special(id: str) -> bool:
164
+ """Whether a reaction/metabolite id is 'nonstandard', or in some sense apart from the majority of compounds.
165
+ For example, the biomass species is 'special'.
166
+ """
167
+ if id.lower() in biomass_names:
168
+ return True
169
+
170
+ return False
171
+
172
+
173
+ def parse_metabolite_id_type(
174
+ sbml_model: libsbml.Model,
175
+ ) -> Optional[chemrecon.IdentifierTypeCompound]:
176
+ id_strings: list[str] = list()
177
+ sbml_species_list = sbml_model.getListOfSpecies()
178
+ for sbml_species in sbml_species_list:
179
+ species_id = sbml_species.getId()
180
+ id_strings.append(species_id)
181
+
182
+ # Check if ChEbI
183
+ if all(s.startswith("M_chebi") for s in id_strings if not is_id_special(s)):
184
+ return chemrecon.C_CHEBI
185
+
186
+ # Check if BiGG
187
+ if all(s.startswith("M_") for s in id_strings if not is_id_special(s)):
188
+ return chemrecon.C_BIGG
189
+
190
+ # If none found, return none
191
+ return None
192
+
193
+
194
+ def parse_reaction_id_type(
195
+ sbml_model: libsbml.Model,
196
+ ) -> Optional[chemrecon.IdentifierTypeReaction]:
197
+ id_strings: list[str] = list()
198
+ sbml_reaction_list = sbml_model.getListOfReactions()
199
+ for sbml_reaction in sbml_reaction_list:
200
+ reaction_id = sbml_reaction.getId()
201
+ id_strings.append(reaction_id)
202
+
203
+ # Check if BiGG
204
+ if all(s.startswith("R_") for s in id_strings if not is_id_special(s)):
205
+ return chemrecon.R_BIGG
206
+
207
+ # If none found, return none
208
+ return None
@@ -0,0 +1,308 @@
1
+ """Main class for defining a metabolic network."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import defaultdict
6
+ from typing import TYPE_CHECKING, Any, Optional
7
+
8
+ import chemrecon
9
+ from chemrecon.utils.get_id_type import get_id_from_identifiers_org
10
+
11
+ from mntools.compartment import Compartment
12
+ from mntools.metabolite import Metabolite
13
+ from mntools.reaction import Reaction
14
+ from mntools.species import Species
15
+ from mntools.types import Side, StoichCoeff
16
+ from mntools.warning import ModelWarning
17
+
18
+ # Notes:
19
+ # SBML: id and metaid (see: https://sbml.org/documents/elaborations/metaid_syntax/)
20
+ # - id: Must conform to XML id standard
21
+ # - metaid: Legacy - does not need to conform to XML id standard
22
+ # Only use ID - metaid is always just id, but with meta_ prefix or similar.
23
+
24
+
25
+ class MetabolicNetwork:
26
+ """A chemical reaection network."""
27
+
28
+ # Basics
29
+ name: str
30
+ filename: str #: Filename from which this was created.
31
+ model_text: Optional[str] #: The text file defining the model, if available.
32
+ native_repr: Any #:
33
+
34
+ # Annotations
35
+ _metabolite_annotation_type: Optional[chemrecon.IdentifierTypeCompound]
36
+ _reaction_annotation_type: Optional[chemrecon.IdentifierTypeReaction]
37
+
38
+ # Network
39
+ _species: list[Species]
40
+ _metabolites: list[Metabolite]
41
+ _reactions: list[Reaction]
42
+ _compartments: list[Compartment]
43
+
44
+ # Network graph
45
+ # TODO
46
+
47
+ # Lookup tables
48
+ _compartment_lookup: dict[str, Compartment] # Lookup for compartments by suffix
49
+ _species_lookup: dict[str, Species]
50
+ _metabolite_lookup: dict[str, Metabolite]
51
+ _reaction_lookup: dict[str, Reaction]
52
+
53
+ # Meta
54
+ loader: Optional[
55
+ Any # TODO
56
+ ] #: The loader, which created this network, if any. Can be used to access load warnings (parser warnings).
57
+ warnings: list[
58
+ ModelWarning
59
+ ] #: Any semantic uncertainties encountered when loading the network.
60
+
61
+ # Getters
62
+ # ------------------------------------------------------------------------------------------------------------------
63
+ def all_species(self) -> list[Species]:
64
+ return self._species
65
+
66
+ def all_metabolites(self) -> list[Metabolite]:
67
+ return self._metabolites
68
+
69
+ def all_reactions(self) -> list[Reaction]:
70
+ return self._reactions
71
+
72
+ def all_compartments(self) -> list[Compartment]:
73
+ return self._compartments
74
+
75
+ def get_species(self, identifier: str) -> Optional[Species]:
76
+ return self._species_lookup.get(identifier, None)
77
+
78
+ def get_metabolite(self, identifier: str) -> Optional[Metabolite]:
79
+ return self._metabolite_lookup.get(identifier, None)
80
+
81
+ def get_reaction(self, identifier: str) -> Optional[Reaction]:
82
+ return self._reaction_lookup.get(identifier, None)
83
+
84
+ def get_compartment(self, identifier: str) -> Optional[Compartment]:
85
+ return self._compartment_lookup.get(identifier, None)
86
+
87
+ def get_or_create_compartment(self, identifier: str) -> Compartment:
88
+ if identifier in self._compartment_lookup:
89
+ return self._compartment_lookup[identifier]
90
+ else:
91
+ compartment = Compartment(id=identifier, name=None)
92
+ self.add_compartment(compartment)
93
+ return compartment
94
+
95
+ # Analysis
96
+ # ----------------------------------------------------------------------------------------------------------
97
+ # TODO get graph
98
+
99
+ # TODO get stoich matrix
100
+
101
+ # Warnings
102
+ # ----------------------------------------------------------------------------------------------------------
103
+ def get_warnings_and_errors(self) -> list[ModelWarning]:
104
+ """List all the errors and warnings encountered when loading this model."""
105
+ # TODO
106
+ raise NotImplementedError()
107
+
108
+ # Metabolic network creation
109
+ # ----------------------------------------------------------------------------------------------------------
110
+ def __init__(
111
+ self,
112
+ name: str,
113
+ filename: str,
114
+ model_text: Optional[str] = None,
115
+ ):
116
+ """ """
117
+ # Model information
118
+ self.name = name
119
+ self.filename = filename.split("/")[-1]
120
+ self.model_text = model_text
121
+
122
+ # Annotations
123
+ self._metabolite_annotation_type = None
124
+ self._reaction_annotation_type = None
125
+
126
+ # Reaction network
127
+ self._species = list()
128
+ self._metabolites = list()
129
+ self._reactions = list()
130
+ self._compartments = list()
131
+
132
+ self._species_lookup = dict()
133
+ self._metabolite_lookup = dict()
134
+ self._reaction_lookup = dict()
135
+ self._compartment_lookup = dict()
136
+
137
+ # Meta
138
+ self.warnings = list()
139
+
140
+ # Set annotations
141
+ def set_metabolite_annotation_type(
142
+ self, annotation_type: Optional[chemrecon.IdentifierTypeCompound]
143
+ ):
144
+ """If the 'id' field of the model correspond to specific database annotations, MNTools can extract database
145
+ entries from the ids. Enter a string like 'bigg' or 'chebi' to associate a database entry with the compound ids.
146
+ """
147
+ self._metabolite_annotation_type = annotation_type
148
+
149
+ def set_reaction_annotation_type(
150
+ self, annotation_type: Optional[chemrecon.IdentifierTypeReaction]
151
+ ):
152
+ """If the 'id' field of the model correspond to specific database annotations, MNTools can extract database
153
+ entries from the ids. Enter a string like 'bigg' or 'chebi' to associate a database entry with the reaction ids.
154
+ """
155
+ self._reaction_annotation_type = annotation_type
156
+
157
+ # Adding network elements
158
+ def add_species(self, species: Species):
159
+ self._species.append(species)
160
+ self._species_lookup[species.id] = species
161
+
162
+ # Add to compartment
163
+ species.compartment.add_species(species)
164
+
165
+ def add_metabolite(self, metabolite: Metabolite):
166
+ self._metabolites.append(metabolite)
167
+ self._metabolite_lookup[metabolite.id] = metabolite
168
+ for species in metabolite.species():
169
+ if species.associated_metabolite is not None:
170
+ raise ValueError(
171
+ f"Attempting to assign metabolite to {species} already associated with metabolite {metabolite}."
172
+ )
173
+ species.associated_metabolite = metabolite
174
+
175
+ def add_reaction(self, reaction: Reaction):
176
+ self._reactions.append(reaction)
177
+ self._reaction_lookup[reaction.id] = reaction
178
+ for species, coeff in reaction.get_species_with_sum_stoichiometry().items():
179
+ species.add_reaction(reaction, coeff)
180
+
181
+ def add_compartment(self, compartment: Compartment):
182
+ if compartment.id in self._compartment_lookup:
183
+ raise ValueError(f"Compartment with id {compartment.id} already exists.")
184
+ self._compartments.append(compartment)
185
+ self._compartment_lookup[compartment.id] = compartment
186
+
187
+ def process(self):
188
+ """To be called after adding all elements to the model. Will perform sanity checks and
189
+ post-processing."""
190
+
191
+ # Identify and create metabolites (identify species across compartments).
192
+ # - by name
193
+ name_lookup_dict: dict[str, set[Species]] = defaultdict(set)
194
+ for s in self._species:
195
+ if s.name is not None:
196
+ name_lookup_dict[s.name].add(s)
197
+ for common_name, species_set in name_lookup_dict.items():
198
+ metabolite = Metabolite(
199
+ species_set, unified_id=common_name, unified_by="name"
200
+ )
201
+ self.add_metabolite(metabolite)
202
+
203
+ # TODO also try to identify compounds based on annotation
204
+ # - If BiGG/ChEBI: Can parse based on common prefix
205
+
206
+ # When metabolites are created, we can process the metabolites/compartments information for all
207
+ # reactions
208
+ for r in self._reactions:
209
+ r.process()
210
+
211
+ # Remove empty compartments
212
+ compartments_to_remove: list[Compartment] = list()
213
+ for comp in self._compartments:
214
+ if len(comp.species()) == 0:
215
+ compartments_to_remove.append(comp)
216
+
217
+ compartment_keys_to_remove: list[str] = list()
218
+ for comp in compartments_to_remove:
219
+ self._compartments.remove(comp)
220
+ compartment_keys_to_remove.append(comp.id)
221
+
222
+ for key in compartment_keys_to_remove:
223
+ del self._compartment_lookup[key]
224
+
225
+ # Validate
226
+ # - All species are in a compartment
227
+ for s in self._species:
228
+ if s.compartment is None or s.compartment not in self._compartments:
229
+ raise ValueError(f"Species {s} is not in a compartment.")
230
+
231
+ # - Verify that all species have associated metabolite
232
+ for s in self._species:
233
+ if s.associated_metabolite is None:
234
+ raise ValueError(f"Species {s} does not have metabolite assigned.")
235
+
236
+ # - Verify reactions
237
+ # TODO
238
+
239
+ # Finalize
240
+ pass
241
+
242
+ def create_annotations(self):
243
+ """Adds the annotations as ChemRecon proto-entries (with no looked-up recon id).
244
+ So the entry annotations may or may not exist in ChemRecon.
245
+ TODO add a method which also looks up the entries.
246
+ """
247
+ # Load all identifiers
248
+ compound_annotation_str_map: dict[str, Optional[chemrecon.Compound]] = dict()
249
+ reaction_annotation_str_map: dict[str, Optional[chemrecon.Reaction]] = dict()
250
+ for s in self.all_species():
251
+ for annotation_str in s.annotation_strs:
252
+ compound_annotation_str_map[annotation_str] = None
253
+ for r in self.all_reactions():
254
+ for annotation_str in r.annotation_strs:
255
+ reaction_annotation_str_map[annotation_str] = None
256
+
257
+ # Create 'prototype' entries from the identifiers.org strings
258
+ prototype_entries_compound: dict[str, chemrecon.Compound] = dict()
259
+ for compound_str in compound_annotation_str_map.keys():
260
+ id_org_result = get_id_from_identifiers_org(
261
+ compound_str, chemrecon.IdentifierTypeCompound
262
+ )
263
+ if id_org_result is not None:
264
+ prototype_entries_compound[compound_str] = chemrecon.Compound(
265
+ id_type=id_org_result[0].enum_type,
266
+ source_id=id_org_result[0].std_identifier(id_org_result[1]),
267
+ )
268
+ prototype_entries_reaction: dict[str, chemrecon.Reaction] = dict()
269
+ for reaction_str in reaction_annotation_str_map.keys():
270
+ id_org_result = get_id_from_identifiers_org(
271
+ reaction_str, chemrecon.IdentifierTypeCompound
272
+ )
273
+ if id_org_result is not None:
274
+ prototype_entries_reaction[reaction_str] = chemrecon.Reaction(
275
+ id_type=id_org_result[0].enum_type,
276
+ source_id=id_org_result[0].std_identifier(id_org_result[1]),
277
+ )
278
+
279
+ # Assign the prototype entries
280
+ for s in self.all_species():
281
+ s.entries = set()
282
+ for annotation_str in s.annotation_strs:
283
+ try:
284
+ s.entries.add(prototype_entries_compound[annotation_str])
285
+ except KeyError:
286
+ # Not found. TODO log warning?
287
+ pass
288
+
289
+ for r in self.all_reactions():
290
+ r.entries = set()
291
+ for annotation_str in r.annotation_strs:
292
+ try:
293
+ r.entries.add(prototype_entries_reaction[annotation_str])
294
+ except KeyError:
295
+ # Not found. TODO log warning?
296
+ pass
297
+
298
+ # Add entries to metabolites (union of entries of associated species)
299
+ for m in self.all_metabolites():
300
+ m.entries = set.union(*[s.entries for s in m.species()])
301
+ pass
302
+
303
+ # Done.
304
+ pass
305
+
306
+ def lookup_annotations(self):
307
+ # TODO method for getting additional data from the looked up annotations in the ChemRecon db.
308
+ pass
@@ -0,0 +1,74 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any, Iterable, Optional
4
+
5
+ import chemrecon
6
+
7
+ from mntools.warning import ModelWarning
8
+
9
+ if TYPE_CHECKING:
10
+ from mntools.compartment import Compartment
11
+ from mntools.species import Species
12
+
13
+
14
+ class Metabolite:
15
+ """
16
+ In this model, a `Metabolite` is a set of species that are in different compartments.
17
+ """
18
+
19
+ id: str #: Primary identifier
20
+ name: str #:
21
+
22
+ # Annotations
23
+ primary_entry: Optional[chemrecon.Compound]
24
+ entries: set[chemrecon.Compound]
25
+ unified_by: str
26
+
27
+ # Network
28
+ _species: set[Species]
29
+ _species_by_compartment: dict[Compartment, Species] = dict()
30
+
31
+ # Meta
32
+ warnings: list[ModelWarning]
33
+
34
+ def species(self) -> set[Species]:
35
+ return self._species
36
+
37
+ def get_species_in_compartment(self, compartment: Compartment) -> Optional[Species]:
38
+ return self._species_by_compartment.get(compartment, None)
39
+
40
+ def __init__(self, species: Iterable[Species], unified_id: str, unified_by: str):
41
+ """The 'unified_by' field determines the common field unifying the species that make up the compound."""
42
+ self._species = set()
43
+ self.id = unified_id
44
+ self.unified_by = unified_by
45
+ self._species_by_compartment = dict()
46
+ for s in species:
47
+ if s in self._species:
48
+ raise ValueError(f"Species {s} already in metabolite {self.id}.")
49
+ self._species.add(s)
50
+ self._species_by_compartment[s.compartment] = s
51
+
52
+ # Derive name/id from the species
53
+ if self.unified_by == "name":
54
+ self.name = unified_id
55
+
56
+ # Derive entries
57
+ self.entries = next(iter(species)).entries
58
+ for s in species:
59
+ if s.entries != self.entries:
60
+ # TODO raise warning
61
+ self.entries.update(s.entries)
62
+
63
+ # The primary entry of the metabolite is the primary entry of the species only if all agree.
64
+ primary_entry = next(iter(species)).primary_entry
65
+ if all(s.primary_entry == primary_entry for s in species):
66
+ self.primary_entry = primary_entry
67
+ else:
68
+ self.primary_entry = None
69
+
70
+ def __str__(self):
71
+ return self.name
72
+
73
+ def __repr__(self):
74
+ return self.id
@@ -0,0 +1,182 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any, Optional
4
+
5
+ import chemrecon
6
+
7
+ from mntools import compartment
8
+ from mntools.types import Side
9
+ from mntools.warning import ModelWarning
10
+
11
+ if TYPE_CHECKING:
12
+ from mntools.compartment import Compartment
13
+ from mntools.metabolite import Metabolite
14
+ from mntools.species import Species
15
+ from mntools.types import StoichCoeff
16
+
17
+
18
+ class Reaction:
19
+ """A reaction."""
20
+
21
+ id: str #: Primary identifier
22
+ name: str #:
23
+
24
+ # Annotations
25
+ primary_entry: Optional[chemrecon.Reaction]
26
+ entries: set[chemrecon.Reaction]
27
+ annotation_strs: set[str]
28
+
29
+ # Network
30
+ _species: dict[Side, dict[Species, StoichCoeff]]
31
+ _metabolites: dict[Side, set[Metabolite]] = dict()
32
+ _compartments: dict[Side, set[Compartment]] = dict()
33
+
34
+ # Meta
35
+ warnings: list[ModelWarning]
36
+
37
+ # Properties
38
+ is_biomass_reaction: bool
39
+ # - Reversibility
40
+ # - TODO reversible?
41
+ # - Transport reactions
42
+ # - TODO types (simple, antiporter, synporter ...)?
43
+ is_transport_reaction: bool #: We consider a reaction a transport reaction if it spans more than 1 compartment
44
+ is_transport_reaction_1to1: (
45
+ bool #: A transport reaction involving only the transported metabolites.
46
+ )
47
+
48
+ # Getters
49
+ def get_lhs(self) -> dict[Species, StoichCoeff]:
50
+ return self._species[Side.L]
51
+
52
+ def get_rhs(self) -> dict[Species, StoichCoeff]:
53
+ return self._species[Side.R]
54
+
55
+ def get_lhs_metabolites(self) -> dict[Metabolite, StoichCoeff]:
56
+ return {
57
+ s.associated_metabolite: coeff
58
+ for s, coeff in self._species[Side.L].items()
59
+ if s.associated_metabolite is not None
60
+ }
61
+
62
+ def get_rhs_metabolites(self) -> dict[Metabolite, StoichCoeff]:
63
+ return {
64
+ s.associated_metabolite: coeff
65
+ for s, coeff in self._species[Side.R].items()
66
+ if s.associated_metabolite is not None
67
+ }
68
+
69
+ def get_species(self) -> set[Species]:
70
+ """Get the species which appear on both the LHS and RHS, if any."""
71
+ return set(self._species[Side.L].keys()) | set(self._species[Side.R].keys())
72
+
73
+ def get_metabolites(self) -> set[Metabolite]:
74
+ """Get the metabolites involved in the reaction (species independent of compartment)."""
75
+ return set(self._metabolites[Side.L]) | set(self._metabolites[Side.R])
76
+
77
+ def get_species_with_sum_stoichiometry(self) -> dict[Species, StoichCoeff]:
78
+ """Get all species involved and their stoichiometric coefficient.
79
+ If a species appears on both sides, the sum stoichiometric coefficient is listed.
80
+ """
81
+ return {
82
+ s: self._species[Side.L].get(s, 0) + self._species[Side.R].get(s, 0)
83
+ for s in self._species[Side.L].keys() | self._species[Side.R].keys()
84
+ }
85
+
86
+ def get_metabolites_with_sum_stoichiometry(self) -> dict[Metabolite, StoichCoeff]:
87
+ """Get all metabolites involved and their stoichiometric coefficient.
88
+ If a metabolite appears on both sides, the sum stoichiometric coefficient is listed.
89
+ This lists _metabolites_, not species.
90
+ So if this is a transport reaction, the sum stoichiometry should be 0.
91
+ """
92
+ # TODO
93
+ raise NotImplementedError()
94
+
95
+ def get_compartment(self) -> Optional[Compartment]:
96
+ """If not a transport reaction, return the compartment in which the reaction takes place."""
97
+ if self.is_transport_reaction:
98
+ return None
99
+ else:
100
+ assert (
101
+ len(self._compartments[Side.L]) == 1
102
+ and len(self._compartments[Side.R]) == 1
103
+ ), "Reaction should only involve one compartment"
104
+ return next(iter(self._compartments[Side.L]))
105
+
106
+ def get_compartments(self) -> set[Compartment]:
107
+ """Get the compartments of hte species involved in the reaction."""
108
+ return set(self._compartments[Side.L]) | set(self._compartments[Side.R])
109
+
110
+ def __init__(
111
+ self,
112
+ id: str,
113
+ name: Optional[str],
114
+ lhs: dict[Species, StoichCoeff],
115
+ rhs: dict[Species, StoichCoeff],
116
+ ):
117
+ # Annotation
118
+ self.id = id
119
+ self.name = name if name is not None else id
120
+ self.primary_entry = None
121
+ self.entries = set()
122
+ self.annotation_strs = set()
123
+
124
+ # Network
125
+ self._species = {Side.L: lhs, Side.R: rhs}
126
+ self._metabolites = dict()
127
+ self._compartments = dict()
128
+
129
+ # Properties
130
+ # TODO determine whether this represents biomass
131
+ self.is_biomass_reaction = False
132
+
133
+ def process(self):
134
+ # Assign metabolites and compartments
135
+ self._metabolites[Side.L] = set()
136
+ self._metabolites[Side.R] = set()
137
+ self._compartments[Side.L] = set()
138
+ self._compartments[Side.R] = set()
139
+ for side in [Side.L, Side.R]:
140
+ for s in self._species[side]:
141
+ self._compartments[side].add(s.compartment)
142
+ if s.associated_metabolite is not None:
143
+ self._metabolites[side].add(s.associated_metabolite)
144
+ else:
145
+ # TODO raise a warning?
146
+ pass
147
+
148
+ # Identify whether this is a transport reaction
149
+ match len(self.get_compartments()):
150
+ # TODO other types of transport reactions (synporter, antiporter, etc.)?
151
+ case 1:
152
+ self.is_transport_reaction = False
153
+ self.is_transport_reaction_1to1 = False
154
+ case 2:
155
+ # Direct transport
156
+ self.is_transport_reaction = True
157
+
158
+ # Check whether 1-to-1 (same metabolites in both compartments)
159
+ if self._metabolites[Side.L] == self._metabolites[Side.R]:
160
+ self.is_transport_reaction_1to1 = True
161
+ else:
162
+ self.is_transport_reaction_1to1 = False
163
+
164
+ case _:
165
+ # Multiple compartments involved.
166
+ # TODO improve support for this case
167
+ self.is_transport_reaction = True
168
+ self.is_transport_reaction_1to1 = False
169
+
170
+ # Reversibility
171
+ # TODO:
172
+ # - Check if set in SBML?
173
+ # - Check if has an inverse reaction somewhere?
174
+
175
+ # Done processing this reaction
176
+ pass
177
+
178
+ def __str__(self):
179
+ return self.name
180
+
181
+ def __repr__(self):
182
+ return self.id
@@ -0,0 +1,76 @@
1
+ from __future__ import annotations
2
+
3
+ from collections import defaultdict
4
+ from typing import TYPE_CHECKING, Any, Optional
5
+
6
+ import chemrecon
7
+
8
+ from mntools.warning import ModelWarning
9
+
10
+ if TYPE_CHECKING:
11
+ from mntools.compartment import Compartment
12
+ from mntools.metabolite import Metabolite
13
+ from mntools.reaction import Reaction
14
+ from mntools.types import StoichCoeff
15
+
16
+
17
+ class Species:
18
+ """
19
+ A species is a type of entity that can participate in reactions.
20
+ In this model, a species is a `Metabolite` in a particular `Compartment`.
21
+ """
22
+
23
+ id: str #: Primary identifier
24
+ name: str #:
25
+
26
+ # Annotations
27
+ primary_entry: Optional[chemrecon.Compound]
28
+ entries: set[chemrecon.Compound]
29
+ annotation_strs: set[str]
30
+ is_biomass: bool
31
+
32
+ # Network
33
+ compartment: Compartment
34
+ associated_metabolite: Optional[Metabolite]
35
+ _in_reactions: dict[Reaction, StoichCoeff]
36
+
37
+ # Meta
38
+ warnings: list[ModelWarning]
39
+
40
+ # Getters
41
+ def get_reactions(self) -> dict[Reaction, StoichCoeff]:
42
+ """Get a list of reactions that this species participates in.
43
+ If the species appears on both sides of the reaction, the sum stoichiometric coefficient is listed.
44
+ """
45
+ return {r: stoich for r, stoich in self._in_reactions.items() if stoich != 0}
46
+
47
+ # Creation
48
+ def add_reaction(self, reaction: Reaction, stoich: StoichCoeff):
49
+ if reaction in self._in_reactions:
50
+ raise ValueError(f"Species {self.id} already in reaction {reaction.id}.")
51
+ self._in_reactions[reaction] = stoich
52
+
53
+ def __str__(self):
54
+ return self.name
55
+
56
+ def __repr__(self):
57
+ return self.id
58
+
59
+ def __init__(
60
+ self,
61
+ id: str,
62
+ name: Optional[str],
63
+ compartment: Compartment,
64
+ ):
65
+ self.id = id
66
+ self.name = name if name is not None else id
67
+ self.primary_entry = None
68
+ self.entries = set()
69
+ self.annotation_strs = set()
70
+ self.compartment = compartment
71
+ self._in_reactions = defaultdict(int)
72
+ self.associated_metabolite = None
73
+ self.warnings = list()
74
+
75
+ # TODO determine whether this represents biomass
76
+ self.is_biomass = False
@@ -0,0 +1,9 @@
1
+ from enum import Enum
2
+
3
+
4
+ class Side(Enum):
5
+ L = -1
6
+ R = 1
7
+
8
+
9
+ type StoichCoeff = int
@@ -0,0 +1,9 @@
1
+ import math
2
+ from fractions import Fraction
3
+
4
+
5
+ def scale_to_integer(values: list[float]) -> list[int]:
6
+ fracs = [Fraction(v) for v in values]
7
+ denominator_lcm = math.lcm(*[f.denominator for f in fracs])
8
+ ints = [int(f * denominator_lcm) for f in fracs]
9
+ return ints
@@ -0,0 +1,16 @@
1
+ class ModelWarning(Exception):
2
+ """A warning emitted by the model loading procedure."""
3
+
4
+ message: str
5
+
6
+
7
+ # Types of warnings
8
+ # --------------------------------------------------------------------------------------------------------------
9
+ class SyntaxWarning(ModelWarning):
10
+ """Something wrong with the syntax of the SBML file."""
11
+
12
+ # TODO
13
+ pass
14
+
15
+
16
+ # TODO warn when different annotations between species with the same name(compound)