From 5637450fa1777ab8272168fef6659248f685472c Mon Sep 17 00:00:00 2001 From: Eduard Kerkhoven Date: Thu, 15 Oct 2026 13:26:02 +0200 Subject: [PATCH] Share model_adapter.toml with GECKO GECKO (MATLAB) now reads model_adapter.toml as well. Two sections make that possible without code in an adapter class: - [spontaneous]: name_contains and/or file set which reactions are spontaneous; the default get_spontaneous_reactions reads them. - [matlab]: settings that only GECKO reads (function names for anaerobic conditions and protein content, and [matlab.params]); geckopy accepts and ignores them. adapterParameters.tsv, the table GECKO uses to map keys to its MATLAB field names and defaults, is shipped here too, and a test checks it against the schema. --- src/geckopy/adapter/adapter.py | 29 +++++- src/geckopy/adapter/adapterParameters.tsv | 36 +++++++ src/geckopy/adapter/params.py | 48 ++++++++- tests/test_adapter.py | 117 ++++++++++++++++++++++ 4 files changed, 225 insertions(+), 5 deletions(-) create mode 100644 src/geckopy/adapter/adapterParameters.tsv diff --git a/src/geckopy/adapter/adapter.py b/src/geckopy/adapter/adapter.py index d9042d5..ac6b90d 100644 --- a/src/geckopy/adapter/adapter.py +++ b/src/geckopy/adapter/adapter.py @@ -87,9 +87,11 @@ def from_folder(cls, folder: str | Path) -> "ModelAdapter": data["path"] = folder params = ModelParameters.model_validate(data) - # Resolve conv_gem relative to the folder if it is not absolute. + # Resolve project paths relative to the folder if they are not absolute. if not params.conv_gem.is_absolute(): params.conv_gem = folder / params.conv_gem + if params.spontaneous.file is not None and not params.spontaneous.file.is_absolute(): + params.spontaneous.file = folder / params.spontaneous.file # Note: a subclass in the project's `adapter.py` is not auto-discovered; # import and instantiate your ModelAdapter subclass explicitly. @@ -101,10 +103,29 @@ def from_folder(cls, folder: str | Path) -> "ModelAdapter": def get_spontaneous_reactions(self, model: "cobra.Model") -> list[str]: """Return IDs of reactions that proceed without enzyme catalysis. - The default returns an empty list. Override in a subclass to - identify spontaneous reactions for your organism. + The default reads the ``[spontaneous]`` section of the adapter: + reactions whose name contains ``name_contains``, and those listed + in ``file``. Without that section it returns an empty list. + Override in a subclass for other rules. """ - return [] + rules = self.params.spontaneous + if rules.name_contains is None and rules.file is None: + return [] + listed: set[str] = set() + if rules.file is not None: + with open(rules.file, "r", encoding="utf-8") as f: + next(f) # header + for line in f: + parts = line.rstrip("\n").split("\t") + if not parts[0]: + continue + if len(parts) == 1 or parts[1].strip().lower() in ("1", "true"): + listed.add(parts[0].strip('"')) + return [ + r.id for r in model.reactions + if r.id in listed + or (rules.name_contains is not None and rules.name_contains in (r.name or "")) + ] def get_uniprot_compatible_genes(self, in_genes: list[str]) -> list[str]: """Map model gene IDs to IDs usable in UniProt queries. diff --git a/src/geckopy/adapter/adapterParameters.tsv b/src/geckopy/adapter/adapterParameters.tsv new file mode 100644 index 0000000..c4c45f8 --- /dev/null +++ b/src/geckopy/adapter/adapterParameters.tsv @@ -0,0 +1,36 @@ +toml_key matlab_field default description +conv_gem convGEM Path to the conventional GEM, relative to the project folder +org_name org_name Scientific name of the organism +sigma sigma 0.5 Average enzyme saturation factor +p_tot Ptot 0.5 Total protein content [g protein/gDW] +f f 0.5 Fraction of enzymes in the model [g enzyme/g protein] +gr_exp gR_exp 0.41 Growth rate the model should reach when not limited by nutrient uptake [1/h] +c_source c_source "" Exchange reaction of the main carbon source +bio_rxn bioRxn "" Biomass pseudoreaction +enzyme_comp enzyme_comp "cytoplasm" Compartment of the protein pseudometabolites +kcat_aggregate_brenda kcatAggregation "max" BRENDA aggregate read by fuzzyKcatMatching: max or median +kcat_aggregate_candidates "max" Aggregation of several candidate kcats for one reaction (geckopy) +kcat_aggregate_isozymes "mean" Aggregation of isozyme kcats when filling a missing value (geckopy) +kegg.id kegg.ID "sce" KEGG organism code +kegg.gene_id kegg.geneID "kegg" Field in the KEGG entry that matches model gene identifiers +uniprot.type uniprot.type "taxonomy" Whether uniprot.id is a taxonomy or a proteome identifier +uniprot.id uniprot.ID "559292" Taxonomy or proteome identifier +uniprot.gene_id_field uniprot.geneIDfield "gene_oln" UniProt field that matches model gene identifiers +uniprot.reviewed uniprot.reviewed false Use only reviewed (Swiss-Prot) entries +complex.taxonomic_id complex.taxonomicID Taxonomy identifier for Complex Portal +spontaneous.name_contains spontaneous.name_contains Reactions whose name contains this text are spontaneous +spontaneous.file spontaneous.file TSV of spontaneous reactions, relative to the project folder: reaction identifiers, optionally a 0/1 column +evotune.sigma0_log_default evotune.sigma0logDefault 0.5 Prior standard deviation (log) for kcats whose source is in no group +evotune.source_groups evotune.kcatSources Trust tiers: group name to the ec.source values it covers +evotune.sigma0_log_source evotune.sigma0logSource Prior standard deviation (log) per group +evotune.rmse_threshold evotune.rmseThreshold 0.2 Stop when the best RMSE reaches this +evotune.max_generations evotune.maxGenerations 150 Maximum number of CMA-ES generations +evotune.max_growth_weight evotune.maxGrowthWeight 1.0 Weight of the maximum-growth RMSE against the flux RMSE +evotune.prior_penalty_weight evotune.priorPenaltyWeight 0.03 Weight of the prior term in the search objective +evotune.tie_isozymes evotune.tieIsozymes true Give isozyme copies of one reaction a single kcat +okp.method okp.method "CataPro" OpenKineticsPredictor method +okp.handle_long_sequences okp.handleLongSequences "truncate" How OpenKineticsPredictor handles sequences that are too long +okp.include_similarity_columns okp.includeSimilarityColumns true Return similarity to the method's training data +okp.canonicalize_substrates okp.canonicalizeSubstrates true Canonicalize substrate SMILES before prediction +matlab.make_model_anaerobic matlab.makeModelAnaerobic MATLAB function that constrains the ecModel to anaerobic growth +matlab.change_protein_biomass matlab.changeProteinBiomass MATLAB function that changes the protein content of the biomass diff --git a/src/geckopy/adapter/params.py b/src/geckopy/adapter/params.py index b709593..a6009b0 100644 --- a/src/geckopy/adapter/params.py +++ b/src/geckopy/adapter/params.py @@ -8,7 +8,7 @@ Ported from GECKO MATLAB: src/geckomat/model_adapter/adapterTemplate.m. """ from pathlib import Path -from typing import Literal, Optional +from typing import Any, Literal, Optional from pydantic import BaseModel, ConfigDict, Field, model_validator @@ -56,6 +56,50 @@ class ComplexParams(BaseModel): ) +class SpontaneousParams(BaseModel): + """Which reactions proceed without an enzyme. + + Both rules may be given; a reaction is spontaneous if either matches. + GECKO (MATLAB) reads the same keys, so a project needs no code for this. + """ + model_config = ConfigDict(extra="forbid") + + name_contains: Optional[str] = Field( + default=None, + description="Reactions whose name contains this text are spontaneous.", + ) + file: Optional[Path] = Field( + default=None, + description=( + "TSV of spontaneous reactions, relative to the project folder: " + "a header row, reaction identifiers in the first column and, " + "optionally, a second column where 1 or true marks the " + "spontaneous ones." + ), + ) + + +class MatlabParams(BaseModel): + """Settings that only GECKO (MATLAB) reads; geckopy ignores them. + + They let one model_adapter.toml configure both tools. + """ + model_config = ConfigDict(extra="forbid") + + make_model_anaerobic: Optional[str] = Field( + default=None, + description="MATLAB function that constrains the ecModel to anaerobic growth.", + ) + change_protein_biomass: Optional[str] = Field( + default=None, + description="MATLAB function that changes the protein content of the biomass.", + ) + params: dict[str, Any] = Field( + default_factory=dict, + description="Further GECKO parameters, kept under their MATLAB names.", + ) + + class SourceGroupRule(BaseModel): """One trust-tier entry in ``EvotuneParams.source_groups``. @@ -277,3 +321,5 @@ class ModelParameters(BaseModel): complex: ComplexParams = Field(default_factory=ComplexParams) evotune: EvotuneParams = Field(default_factory=EvotuneParams) okp: OkpParams = Field(default_factory=OkpParams) + spontaneous: SpontaneousParams = Field(default_factory=SpontaneousParams) + matlab: MatlabParams = Field(default_factory=MatlabParams) diff --git a/tests/test_adapter.py b/tests/test_adapter.py index 5bbb093..f7244e2 100644 --- a/tests/test_adapter.py +++ b/tests/test_adapter.py @@ -134,3 +134,120 @@ def test_shipped_example_adapter_toml_is_valid(example_dir): """Verify every folder under examples/ has a model_adapter.toml that validates against the current ModelParameters schema.""" ModelAdapter.from_folder(example_dir) + + +SPONTANEOUS_TOML = MINIMAL_TOML + """ +[spontaneous] +name_contains = "spontaneous" +file = "data/spontaneousReactions.tsv" +""" + + +def test_spontaneous_reactions_from_toml(tmp_path): + import cobra + + folder = _write_adapter_folder(tmp_path, SPONTANEOUS_TOML) + (folder / "data").mkdir() + (folder / "data" / "spontaneousReactions.tsv").write_text( + "rxns\tspontaneous\nR1\t0\nR2\t1\nR3\t1\n" + ) + model = cobra.Model("m") + for rid, name in [("R1", "a"), ("R2", "b"), ("R4", "spontaneous c"), ("R5", "d")]: + model.add_reactions([cobra.Reaction(rid, name=name)]) + adapter = ModelAdapter.from_folder(folder) + assert adapter.params.spontaneous.file == folder / "data" / "spontaneousReactions.tsv" + assert adapter.get_spontaneous_reactions(model) == ["R2", "R4"] + + +# A file as GECKO's convertAdapterToToml writes it, MATLAB-only settings included. +CONVERTED_TOML = """\ +# Model adapter for testus testus, converted from TestGEMAdapter. +conv_gem = "models/testModel.xml" +org_name = "testus testus" +sigma = 0.5 +p_tot = 0.5 +f = 4 +gr_exp = 0.41 +c_source = "E1" +bio_rxn = "R4" +enzyme_comp = "c" + +[kegg] +id = "tst" +gene_id = "kegg" + +[uniprot] +type = "taxonomy" +id = "TE00000000" +gene_id_field = "gene_tst" +reviewed = true + +[complex] +taxonomic_id = 123456 + +[evotune.source_groups.dlkcat] +sources = ["dlkcat"] + +[evotune.source_groups.brenda] +sources = ["brenda"] + +[evotune.sigma0_log_source] +dlkcat = 0.4 +brenda = 0.2 + +[matlab] +make_model_anaerobic = "anaerobicModel_GECKO" + +[matlab.params] +c_UptakeExp = 0.641339301 +""" + + +def test_toml_converted_from_matlab_adapter_loads(tmp_path): + folder = _write_adapter_folder(tmp_path, CONVERTED_TOML) + adapter = ModelAdapter.from_folder(folder) + assert adapter.params.f == 4 + assert adapter.params.matlab.make_model_anaerobic == "anaerobicModel_GECKO" + assert adapter.params.matlab.params == {"c_UptakeExp": 0.641339301} + assert set(adapter.params.evotune.source_groups) == {"dlkcat", "brenda"} + + +def _schema_leaves(model_cls, prefix=""): + """Dotted keys of every leaf field of a pydantic model, with defaults.""" + from pydantic import BaseModel + + leaves = {} + instance = model_cls.model_construct() + for name, field in model_cls.model_fields.items(): + key = f"{prefix}{name}" + annotation = field.annotation + if isinstance(annotation, type) and issubclass(annotation, BaseModel): + leaves.update(_schema_leaves(annotation, key + ".")) + else: + leaves[key] = field + return leaves + + +def test_adapter_parameter_table_matches_schema(): + """adapterParameters.tsv, shared with GECKO, lists every key and its default.""" + import csv + import tomllib + + from geckopy.adapter.params import ModelParameters + + table = Path(__file__).resolve().parent.parent / "src" / "geckopy" / "adapter" / "adapterParameters.tsv" + with open(table, newline="", encoding="utf-8") as f: + rows = {r["toml_key"]: r for r in csv.DictReader(f, delimiter="\t", quoting=csv.QUOTE_NONE)} + + leaves = _schema_leaves(ModelParameters) + leaves.pop("path") # set from the project folder, never written + leaves.pop("matlab.params") # free-form + assert set(rows) == set(leaves) + + for key, field in leaves.items(): + default_text = rows[key]["default"] + if field.is_required() or not default_text: + continue + expected = tomllib.loads(f"x = {default_text}")["x"] + default = field.get_default(call_default_factory=True) + assert default == expected, key