Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 25 additions & 4 deletions src/geckopy/adapter/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down
36 changes: 36 additions & 0 deletions src/geckopy/adapter/adapterParameters.tsv
Original file line number Diff line number Diff line change
@@ -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
48 changes: 47 additions & 1 deletion src/geckopy/adapter/params.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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``.

Expand Down Expand Up @@ -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)
117 changes: 117 additions & 0 deletions tests/test_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading