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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ Milestones in the raven-toolbox port. For function-level status see

## Unreleased

* `write_yaml_model` writes an ec reaction without enzymes with no `enzymes` entry (was: `enzymes: !!omap []`). `read_yaml_model` reads a missing `enzymes` entry, a content-less `enzymes: !!omap` and `enzymes: !!omap []` as an empty enzyme map, so files written by earlier versions of GECKO, RAVEN and raven-toolbox still load.
* `init.fill_tasks` with `resolve_ties=True` on Gurobi restores `MIPGap` and `MIPGapAbs` of `reference_model` after each gap-fill (was: 0.0 and 0.4 stayed set).
* `tasks.find_task_essential_reactions` writes its `cache_path` as JSON (was: pickle) and ignores, with a warning, an existing file that is not a JSON cache of the current version, such as a pickle. `checkpoint_every` below 1 raises `ValueError` (was: a write after every task).
* `init.hpa_gene_scores` uses the default level scores also for an empty `level_scores` mapping, and `init.rna_gene_scores` compares `tissue` case-insensitively and takes the highest TPM over tissue names that differ in case, as RAVEN's `scoreModel` does.
Expand Down
9 changes: 6 additions & 3 deletions src/raven_toolbox/io/ec_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@
source: brenda # optional, omitted if empty
notes: free-text # optional, omitted if empty
eccodes: "1.1.1.1" # optional; scalar OR a list when multiple
enzymes: # column -> stoichiometric subunit count
enzymes: # optional, omitted if the reaction has no enzymes;
# column -> stoichiometric subunit count
P12345: 1.0
P67890: 2.0 # heteromeric complex with 2 copies of P67890
ec-enzymes:
Expand Down Expand Up @@ -306,7 +307,8 @@ def _build_ec_rxns_list(ec: EcData) -> list[dict[str, Any]]:
"""Translate the per-reaction ec fields and the ``rxn_enz_mat`` rows to the
list-of-mappings YAML form.

Empty ``source`` / ``notes`` / ``eccodes`` strings are omitted.
Empty ``source`` / ``notes`` / ``eccodes`` strings are omitted, and so is
``enzymes`` for a reaction whose ``rxn_enz_mat`` row has no entry.
``kcat`` is always written: a real turnover number when set,
otherwise ``0`` (0 marks "no kcat assigned").
"""
Expand All @@ -330,7 +332,8 @@ def _build_ec_rxns_list(ec: EcData) -> list[dict[str, Any]]:
entry["notes"] = ec.notes[i]
if ec.eccodes[i]:
entry["eccodes"] = _eccodes_to_yaml(ec.eccodes[i])
entry["enzymes"] = per_row_enzymes[i]
if per_row_enzymes[i]:
entry["enzymes"] = per_row_enzymes[i]
out.append(entry)
return out

Expand Down
5 changes: 3 additions & 2 deletions src/raven_toolbox/io/yaml.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,9 @@
class _OmapConstructor(RoundTripConstructor):
"""Round-trip constructor that reads a content-less ``!!omap`` as an empty map.

GECKO and RAVEN write an ec reaction without enzymes as ``- enzymes: !!omap``
with nothing under the tag. YAML parses that as a null scalar carrying the omap
Files written by earlier GECKO and RAVEN versions have an ec reaction without
enzymes as ``- enzymes: !!omap`` with nothing under the tag (current writers
omit the ``enzymes`` entry). YAML parses that as a null scalar carrying the omap
tag, which ruamel's own constructor rejects; RAVEN's ``readYAMLmodel.m`` reads
it as an empty enzyme list.
"""
Expand Down
77 changes: 63 additions & 14 deletions tests/test_io_yaml_ec_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"""
from __future__ import annotations

import re
from pathlib import Path

import cobra
Expand Down Expand Up @@ -204,6 +205,50 @@ def test_save_round_trip_preserves_all_ec_fields(tmp_path):
assert reloaded.ec.rxn_enz_mat[0, 0] == 1.0


def _make_ec_model_with_enzymeless_rxn() -> cobra.Model:
"""`_make_ec_model` plus a second ec reaction whose rxn_enz_mat row is empty."""
model = _make_ec_model()
ec = model.ec
mat = sparse.lil_matrix((2, 1), dtype=float)
mat[0, 0] = 1.0
model.ec = EcData(
gecko_light=False,
rxns=["R1", "R2"],
kcat=np.array([42.0, 7.0]),
source=["manual", "standard"],
notes=["", ""],
eccodes=["1.1.1.1", ""],
genes=ec.genes,
enzymes=ec.enzymes,
mw=ec.mw,
sequence=ec.sequence,
concs=ec.concs,
rxn_enz_mat=mat.tocsr(),
)
return model


def test_save_omits_enzymes_of_rxn_without_enzymes(tmp_path):
out = tmp_path / "out.yml"
write_yaml_model(_make_ec_model_with_enzymeless_rxn(), out)
text = out.read_text()
assert text.count("- enzymes:") == 2 # R1 (ec-rxns) and P1 (ec-enzymes)
assert "!!omap []" not in text.split("ec-rxns:")[1]
block = re.split(r'- id: "?R2"?\n', text)[-1].split("ec-enzymes:")[0]
assert "enzymes" not in block
assert "standard" in block


def test_save_round_trip_rxn_without_enzymes(tmp_path):
out = tmp_path / "out.yml"
write_yaml_model(_make_ec_model_with_enzymeless_rxn(), out)
reloaded = read_yaml_model(out)
assert reloaded.ec.rxns == ["R1", "R2"]
assert reloaded.ec.kcat.tolist() == [42.0, 7.0]
assert reloaded.ec.source == ["manual", "standard"]
assert reloaded.ec.rxn_enz_mat.toarray().tolist() == [[1.0], [0.0]]


def test_save_skips_nan_and_empty_optional_fields(tmp_path):
"""NaN mw/concs and empty source/notes/eccodes/sequence get omitted
from the YAML to keep files compact."""
Expand Down Expand Up @@ -462,8 +507,7 @@ def test_empty_gecko_light_flag_propagates():
- id: "std"
- kcat: 10
- source: "custom"
- notes: {note}
- enzymes: !!omap{empty}
- notes: {note}{enzymes_line}
- !!omap
- id: "enz"
- kcat: 5
Expand All @@ -478,12 +522,17 @@ def test_empty_gecko_light_flag_propagates():
"""


@pytest.mark.parametrize("empty", ["", " []"])
def test_load_reaction_with_empty_enzymes_omap(tmp_path, empty):
# GECKO and RAVEN write `- enzymes: !!omap` with nothing under it for an ec
# reaction without enzymes; raven-toolbox writes `!!omap []`. Both read as no enzymes.
@pytest.mark.parametrize(
"enzymes_line",
["", "\n - enzymes: !!omap", "\n - enzymes: !!omap []"],
ids=["missing", "omap", "omap-empty-flow"],
)
def test_load_reaction_with_empty_enzymes_omap(tmp_path, enzymes_line):
# An ec reaction without enzymes has no `enzymes` entry. Files written by earlier
# GECKO and RAVEN versions carry `- enzymes: !!omap` with nothing under it, and
# earlier raven-toolbox versions wrote `!!omap []`. All three read as no enzymes.
path = tmp_path / "m.yml"
path.write_text(_GECKO_FILE.format(note='"ok"', empty=empty), encoding="utf-8")
path.write_text(_GECKO_FILE.format(note='"ok"', enzymes_line=enzymes_line), encoding="utf-8")
model = read_yaml_model(path)
assert model.ec.rxns == ["std", "enz"]
assert model.ec.enzymes == ["P1"]
Expand All @@ -494,7 +543,7 @@ def test_load_notes_in_doubled_quotes_keep_one_pair(tmp_path):
# An older writer put quotes around a note that carried its own, giving `""text""`,
# which is not valid YAML. readYAMLmodel.m strips one pair and keeps the other.
path = tmp_path / "m.yml"
path.write_text(_GECKO_FILE.format(note='""Putative: lowest kcat.""', empty=""), encoding="utf-8")
path.write_text(_GECKO_FILE.format(note='""Putative: lowest kcat.""', enzymes_line=""), encoding="utf-8")
model = read_yaml_model(path)
assert model.ec.notes[0] == '"Putative: lowest kcat."'
assert model.ec.rxns == ["std", "enz"]
Expand All @@ -503,7 +552,7 @@ def test_load_notes_in_doubled_quotes_keep_one_pair(tmp_path):
def test_doubled_quotes_unescape_like_matlab_and_keep_apostrophes(tmp_path):
path = tmp_path / "m.yml"
note = '""it\'s a \\\\ and \\"inner\\" one""'
path.write_text(_GECKO_FILE.format(note=note, empty=""), encoding="utf-8")
path.write_text(_GECKO_FILE.format(note=note, enzymes_line=""), encoding="utf-8")
model = read_yaml_model(path)
assert model.ec.notes[0] == '"it\'s a \\ and "inner" one"'

Expand All @@ -521,7 +570,7 @@ def test_doubled_quotes_unescape_like_matlab_and_keep_apostrophes(tmp_path):
def test_interior_unescaped_quote_is_kept_like_matlab(tmp_path, note, expected):
# readYAMLmodel.m strips the outer pair of quotes and keeps every quote inside.
path = tmp_path / "m.yml"
path.write_text(_GECKO_FILE.format(note=note, empty=""), encoding="utf-8")
path.write_text(_GECKO_FILE.format(note=note, enzymes_line=""), encoding="utf-8")
model = read_yaml_model(path)
assert model.ec.notes[0] == expected
assert model.ec.rxns == ["std", "enz"]
Expand All @@ -539,7 +588,7 @@ def test_interior_unescaped_quote_is_kept_like_matlab(tmp_path, note, expected):
def test_interior_apostrophe_in_single_quoted_value_is_kept_like_matlab(tmp_path, value, expected):
# readYAMLmodel.m strips the outer pair of quotes and turns each '' into ', keeping a lone '.
path = tmp_path / "m.yml"
text = _GECKO_FILE.format(note='"ok"', empty="").replace('- name: "A"', f"- name: {value}")
text = _GECKO_FILE.format(note='"ok"', enzymes_line="").replace('- name: "A"', f"- name: {value}")
path.write_text(text, encoding="utf-8")
model = read_yaml_model(path)
assert model.metabolites.get_by_id("a").name == expected
Expand All @@ -548,7 +597,7 @@ def test_interior_apostrophe_in_single_quoted_value_is_kept_like_matlab(tmp_path

def test_valid_single_quoted_values_are_read_unchanged(tmp_path):
path = tmp_path / "m.yml"
text = _GECKO_FILE.format(note="'a ''b'' c'", empty="").replace('- name: "A"', "- name: 'it''s'")
text = _GECKO_FILE.format(note="'a ''b'' c'", enzymes_line="").replace('- name: "A"', "- name: 'it''s'")
path.write_text(text, encoding="utf-8")
model = read_yaml_model(path)
assert model.metabolites.get_by_id("a").name == "it's"
Expand All @@ -557,7 +606,7 @@ def test_valid_single_quoted_values_are_read_unchanged(tmp_path):

def test_valid_quoted_scalars_are_read_unchanged_next_to_a_repaired_one(tmp_path):
path = tmp_path / "m.yml"
text = _GECKO_FILE.format(note='"a "b" c"', empty="").replace(
text = _GECKO_FILE.format(note='"a "b" c"', enzymes_line="").replace(
'- source: "custom"', '- source: "cu\\\\st\\"om"'
).replace("- id: \"enz\"", "- id: \"enz\"\n - notes: \"fine\\n\"")
path.write_text(text, encoding="utf-8")
Expand All @@ -568,6 +617,6 @@ def test_valid_quoted_scalars_are_read_unchanged_next_to_a_repaired_one(tmp_path

def test_invalid_yaml_other_than_doubled_quotes_still_raises(tmp_path):
path = tmp_path / "m.yml"
path.write_text(_GECKO_FILE.format(note='"ok"', empty="").replace("kcat: 10", "kcat: [10"), encoding="utf-8")
path.write_text(_GECKO_FILE.format(note='"ok"', enzymes_line="").replace("kcat: 10", "kcat: [10"), encoding="utf-8")
with pytest.raises(YAMLError):
read_yaml_model(path)
Loading