diff --git a/CHANGELOG.md b/CHANGELOG.md index 069bb6f..abfd2b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/raven_toolbox/io/ec_data.py b/src/raven_toolbox/io/ec_data.py index 90a4505..ec2e00c 100644 --- a/src/raven_toolbox/io/ec_data.py +++ b/src/raven_toolbox/io/ec_data.py @@ -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: @@ -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"). """ @@ -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 diff --git a/src/raven_toolbox/io/yaml.py b/src/raven_toolbox/io/yaml.py index 1ff8453..f2d9312 100644 --- a/src/raven_toolbox/io/yaml.py +++ b/src/raven_toolbox/io/yaml.py @@ -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. """ diff --git a/tests/test_io_yaml_ec_data.py b/tests/test_io_yaml_ec_data.py index 5c4feef..786c296 100644 --- a/tests/test_io_yaml_ec_data.py +++ b/tests/test_io_yaml_ec_data.py @@ -12,6 +12,7 @@ """ from __future__ import annotations +import re from pathlib import Path import cobra @@ -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.""" @@ -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 @@ -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"] @@ -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"] @@ -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"' @@ -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"] @@ -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 @@ -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" @@ -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") @@ -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)