From 64861e1fed7ed3fd086b613117ba478a56c9a33e Mon Sep 17 00:00:00 2001 From: Eduard Kerkhoven Date: Fri, 25 Sep 2026 17:16:27 +0200 Subject: [PATCH] feat: let enzymes with an unusable measurement be treated as unmeasured --unmeasured leaves the named enzymes out of the measured abundances so they draw on the shared pool. RKI1 and SOL3 are measured so low that they cap the pentose phosphate pathway at zero; released, it carries flux again. --- README.md | 12 +++++++++++- src/overflow/build.py | 18 +++++++++++++++--- src/overflow/proteomics.py | 15 ++++++++++++++- tests/test_proteomics.py | 24 ++++++++++++++++++++++++ 4 files changed, 64 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 9887df0..6b5c8ee 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,8 @@ Options change what the models are asked to account for: pool (`protein`, the default), or the smallest total flux at the dilution rate over every reaction (`flux`) or over the metabolic reactions only (`metabolic-flux`). The flux objectives need `--fit-rates`. +- `--unmeasured UNIPROT ...` treats those enzymes as unmeasured: they are not capped at + their measured abundance and draw on the shared protein pool. Default: none. - `--scale-protein` rescales biomass to the measured protein content. Off by default: the model has no carbon to spare at the measured glucose uptake, and CN4 then falls short of its dilution rate. @@ -147,8 +149,16 @@ the enzyme-constrained solution disposed of surplus carbon through unmeasured exits instead of respiring it, so the flux distribution did not describe the measured physiology. `--uptake-flex 1.08` gives CN4 the glucose it needs. +`--unmeasured Q12189 P38858` releases RKI1 and SOL3, the enzymes that cap the +pentose phosphate pathway. RKI1 is measured at about 0.01 mg/gDW at CN38, and the +model already needs all of it for nucleotide precursors, so the pathway carries no +flux however it is asked to be used; SOL3 is the next cap. Released, they draw on the +shared pool, the protein pool does not grow, and the pathway carries flux at every +condition. Drop the option to build strictly from the measured abundances. + ```bash -python -m overflow.build --fit-rates --rate-tolerance 0.08 --uptake-flex 1.08 +python -m overflow.build --fit-rates --rate-tolerance 0.08 --uptake-flex 1.08 \ + --unmeasured Q12189 P38858 python -m overflow.build_ribosome python -m overflow.analyze_usage python -m overflow.run_sampling --procs 12 diff --git a/src/overflow/build.py b/src/overflow/build.py index 07fb8c5..69de0f0 100644 --- a/src/overflow/build.py +++ b/src/overflow/build.py @@ -13,7 +13,7 @@ import time from dataclasses import dataclass, field from pathlib import Path -from typing import Optional +from typing import Optional, Sequence import numpy as np import pandas as pd @@ -101,6 +101,7 @@ def build_condition( rate_tolerance: float = 0.05, uptake_flex: float = 1.05, objective: str = "protein", + unmeasured: Sequence[str] = (), verbose: bool = True, ) -> BuildResult: """Build one proteome-constrained ecModel. @@ -122,6 +123,9 @@ def build_condition( protein pool (``"protein"``), or the smallest total flux at the dilution rate over every reaction (``"flux"``) or over the metabolic reactions only (``"metabolic-flux"``); both need ``fit_rates``. + + ``unmeasured`` lists UniProt IDs whose measurements are not used as + caps, so those enzymes draw on the shared protein pool. """ if objective not in OBJECTIVES: raise ValueError(f"objective must be one of {OBJECTIVES}, not {objective!r}") @@ -150,7 +154,7 @@ def build_condition( proteomics = condition_prot_data( condition, model, table=table, masses=masses, - fix_complex_subunits=fix_complex_subunits, + fix_complex_subunits=fix_complex_subunits, unmeasured=unmeasured, ) result.f_factor = proteomics.f_factor result.n_kept = proteomics.filtered.n_kept @@ -357,6 +361,14 @@ def main(argv: Optional[list[str]] = None) -> int: "raise abundances by the least that makes that feasible", ) parser.add_argument("--rate-tolerance", type=float, default=0.05) + parser.add_argument( + "--unmeasured", + nargs="+", + default=[], + metavar="UNIPROT", + help="treat these enzymes as unmeasured: they are not capped at their " + "measured abundance and draw on the shared protein pool", + ) parser.add_argument( "--objective", choices=OBJECTIVES, @@ -405,7 +417,7 @@ def main(argv: Optional[list[str]] = None) -> int: fit_rates=args.fit_rates, rate_tolerance=args.rate_tolerance, uptake_flex=args.uptake_flex, - objective=args.objective, + objective=args.objective, unmeasured=args.unmeasured, ) write_outputs(result, args.models_dir, args.results_dir) results.append(result) diff --git a/src/overflow/proteomics.py b/src/overflow/proteomics.py index 94ff8d6..1e487cc 100644 --- a/src/overflow/proteomics.py +++ b/src/overflow/proteomics.py @@ -8,7 +8,7 @@ from dataclasses import dataclass from pathlib import Path -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING, Optional, Sequence import numpy as np import pandas as pd @@ -229,6 +229,13 @@ def rescaled_p_tot( return condition.p_tot * (kept / total) +def drop_proteins(ids, values, drop: Sequence[str]): + """``ids`` and ``values`` without the proteins in ``drop``.""" + drop = set(drop) + keep = [i for i, protein in enumerate(ids) if protein not in drop] + return [ids[i] for i in keep], np.asarray(values)[keep] + + @dataclass(frozen=True) class ConditionProteomics: """Everything the model build needs from one condition's proteomics.""" @@ -247,6 +254,7 @@ def condition_prot_data( masses: Optional[dict[str, float]] = None, flex_factor: float = 1.96, fix_complex_subunits: bool = True, + unmeasured: Sequence[str] = (), ) -> ConditionProteomics: """Assemble the proteomics for one condition. @@ -254,6 +262,10 @@ def condition_prot_data( unfiltered replicate means, as they describe the proteome as a whole. The abundances that constrain enzymes come from the filtered set, optionally after levelling the respiratory complexes. + + Proteins in ``unmeasured`` are left out of the abundances, so the model + treats them as unmeasured: they draw on the shared protein pool instead + of being capped at a measurement that is too low to carry their flux. """ from geckopy.databases import ProtData @@ -277,6 +289,7 @@ def condition_prot_data( if fix_complex_subunits: complex_fix = fix_complexes(model, OXPHOS_RXNS, kept_ids, kept_values) kept_ids, kept_values = complex_fix.uniprot_ids, complex_fix.abundances + kept_ids, kept_values = drop_proteins(kept_ids, kept_values, unmeasured) return ConditionProteomics( prot_data=ProtData( diff --git a/tests/test_proteomics.py b/tests/test_proteomics.py index 3b5c6a4..ad7a44f 100644 --- a/tests/test_proteomics.py +++ b/tests/test_proteomics.py @@ -11,6 +11,7 @@ from overflow.config import CONDITION_ORDER, ROOT, load_conditions from overflow.proteomics import ( condition_prot_data, + drop_proteins, f_factor, filter_prot_data, mean_abundances, @@ -210,3 +211,26 @@ def test_f_factor_agrees_with_geckopy(ec_model, table, masses): ours = f_factor(ec_model.ec.enzymes, ids, mass) theirs = calculate_f_factor(ec_model, ProtData(uniprot_ids=ids, abundances=mass)) assert ours == pytest.approx(theirs, rel=1e-12) + + +def test_dropping_proteins_keeps_the_others_and_their_values(): + ids, values = drop_proteins(["A", "B", "C"], np.array([1.0, 2.0, 3.0]), ["B", "Z"]) + assert ids == ["A", "C"] + assert list(values) == [1.0, 3.0] + + +def test_nothing_is_dropped_by_default(): + ids, values = drop_proteins(["A", "B"], np.array([1.0, 2.0]), ()) + assert ids == ["A", "B"] and list(values) == [1.0, 2.0] + + +@pytest.mark.slow +def test_an_unmeasured_enzyme_is_left_out_of_the_abundances(ec_model, table, masses): + condition = load_conditions()["CN38"] + kwargs = dict(table=table, masses=masses, fix_complex_subunits=False) + with_rki1 = condition_prot_data(condition, ec_model, **kwargs) + without = condition_prot_data(condition, ec_model, unmeasured=["Q12189"], **kwargs) + assert "Q12189" in with_rki1.prot_data.uniprot_ids + assert "Q12189" not in without.prot_data.uniprot_ids + assert len(without.prot_data.uniprot_ids) == len(with_rki1.prot_data.uniprot_ids) - 1 + assert without.f_factor == with_rki1.f_factor and without.p_tot == with_rki1.p_tot