raven-toolbox 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. raven_toolbox/__init__.py +15 -0
  2. raven_toolbox/analysis/__init__.py +23 -0
  3. raven_toolbox/analysis/fseof.py +161 -0
  4. raven_toolbox/analysis/reporter.py +119 -0
  5. raven_toolbox/analysis/sampling.py +218 -0
  6. raven_toolbox/annotation/__init__.py +24 -0
  7. raven_toolbox/annotation/delta_g.py +115 -0
  8. raven_toolbox/annotation/sbo.py +180 -0
  9. raven_toolbox/binaries.py +185 -0
  10. raven_toolbox/biomass/__init__.py +50 -0
  11. raven_toolbox/biomass/config.py +95 -0
  12. raven_toolbox/biomass/gam.py +67 -0
  13. raven_toolbox/biomass/scale.py +207 -0
  14. raven_toolbox/comparison/__init__.py +23 -0
  15. raven_toolbox/comparison/compare.py +150 -0
  16. raven_toolbox/comparison/diff.py +285 -0
  17. raven_toolbox/conditions/__init__.py +25 -0
  18. raven_toolbox/conditions/apply.py +192 -0
  19. raven_toolbox/curation/__init__.py +54 -0
  20. raven_toolbox/curation/batch.py +460 -0
  21. raven_toolbox/data.py +215 -0
  22. raven_toolbox/gapfilling/__init__.py +9 -0
  23. raven_toolbox/gapfilling/fill.py +188 -0
  24. raven_toolbox/init/__init__.py +48 -0
  25. raven_toolbox/init/build.py +113 -0
  26. raven_toolbox/init/ftinit.py +330 -0
  27. raven_toolbox/init/genes.py +81 -0
  28. raven_toolbox/init/init.py +256 -0
  29. raven_toolbox/init/merge.py +226 -0
  30. raven_toolbox/init/prep.py +241 -0
  31. raven_toolbox/init/score.py +81 -0
  32. raven_toolbox/init/steps.py +62 -0
  33. raven_toolbox/init/taskfill.py +186 -0
  34. raven_toolbox/io/__init__.py +18 -0
  35. raven_toolbox/io/ec_data.py +392 -0
  36. raven_toolbox/io/excel.py +136 -0
  37. raven_toolbox/io/git.py +106 -0
  38. raven_toolbox/io/sif.py +96 -0
  39. raven_toolbox/io/yaml.py +494 -0
  40. raven_toolbox/localization/__init__.py +27 -0
  41. raven_toolbox/localization/predict.py +382 -0
  42. raven_toolbox/localization/scores.py +117 -0
  43. raven_toolbox/manifest.py +123 -0
  44. raven_toolbox/manipulation/__init__.py +39 -0
  45. raven_toolbox/manipulation/add.py +365 -0
  46. raven_toolbox/manipulation/change.py +128 -0
  47. raven_toolbox/manipulation/compartments.py +196 -0
  48. raven_toolbox/manipulation/expand.py +126 -0
  49. raven_toolbox/manipulation/irreversible.py +72 -0
  50. raven_toolbox/manipulation/merge.py +147 -0
  51. raven_toolbox/manipulation/parameters.py +78 -0
  52. raven_toolbox/manipulation/remove.py +120 -0
  53. raven_toolbox/manipulation/simplify.py +277 -0
  54. raven_toolbox/manipulation/transfer.py +145 -0
  55. raven_toolbox/manipulation/transport.py +162 -0
  56. raven_toolbox/omics/__init__.py +23 -0
  57. raven_toolbox/omics/hpa.py +190 -0
  58. raven_toolbox/py.typed +0 -0
  59. raven_toolbox/reconstruction/__init__.py +1 -0
  60. raven_toolbox/reconstruction/homology/__init__.py +23 -0
  61. raven_toolbox/reconstruction/homology/blast.py +146 -0
  62. raven_toolbox/reconstruction/homology/hits.py +64 -0
  63. raven_toolbox/reconstruction/homology/homology.py +287 -0
  64. raven_toolbox/reconstruction/kegg/__init__.py +83 -0
  65. raven_toolbox/reconstruction/kegg/assemble.py +82 -0
  66. raven_toolbox/reconstruction/kegg/download.py +269 -0
  67. raven_toolbox/reconstruction/kegg/hmm.py +451 -0
  68. raven_toolbox/reconstruction/kegg/organism.py +168 -0
  69. raven_toolbox/reconstruction/kegg/parse.py +618 -0
  70. raven_toolbox/reconstruction/kegg/query.py +250 -0
  71. raven_toolbox/reconstruction/kegg/taxonomy.py +176 -0
  72. raven_toolbox/tasks/__init__.py +23 -0
  73. raven_toolbox/tasks/check.py +336 -0
  74. raven_toolbox/tasks/tasklist.py +141 -0
  75. raven_toolbox/utils/__init__.py +16 -0
  76. raven_toolbox/utils/balance.py +90 -0
  77. raven_toolbox/utils/gpr.py +148 -0
  78. raven_toolbox/utils/parse.py +59 -0
  79. raven_toolbox/utils/sort.py +21 -0
  80. raven_toolbox/utils/validate.py +86 -0
  81. raven_toolbox-0.2.0.dist-info/METADATA +135 -0
  82. raven_toolbox-0.2.0.dist-info/RECORD +84 -0
  83. raven_toolbox-0.2.0.dist-info/WHEEL +4 -0
  84. raven_toolbox-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,15 @@
1
+ """raven_toolbox — Python counterpart of the RAVEN Toolbox, built on cobrapy.
2
+
3
+ raven_toolbox reuses cobrapy for simulation, standard analyses, SBML I/O, and model
4
+ manipulation, and provides the RAVEN-specific functionality on top: de novo
5
+ reconstruction (KEGG / homology), context-specific modeling (tINIT / ftINIT),
6
+ metabolic task validation, connectivity gap-filling, omics integration (HPA),
7
+ sub-cellular localisation, N-model comparison, and the RAVEN-style I/O formats.
8
+ """
9
+
10
+ from importlib.metadata import PackageNotFoundError, version
11
+
12
+ try:
13
+ __version__ = version("raven-toolbox")
14
+ except PackageNotFoundError: # running from a source tree without an install
15
+ __version__ = "0.0.0+unknown"
@@ -0,0 +1,23 @@
1
+ """Analyses not in cobrapy's core.
2
+
3
+ * :func:`reporter_metabolites` — Reporter Metabolites (around-metabolite gene-score test).
4
+ * :func:`fseof` — Flux Scanning based on Enforced Objective Flux.
5
+ * :func:`random_sampling` — random-objective flux sampling.
6
+ """
7
+ from raven_toolbox.analysis.fseof import FSEOFResult, fseof
8
+ from raven_toolbox.analysis.reporter import ReporterResult, reporter_metabolites
9
+ from raven_toolbox.analysis.sampling import (
10
+ RandomSamplingResult,
11
+ find_good_reactions,
12
+ random_sampling,
13
+ )
14
+
15
+ __all__ = [
16
+ "FSEOFResult",
17
+ "RandomSamplingResult",
18
+ "ReporterResult",
19
+ "find_good_reactions",
20
+ "fseof",
21
+ "random_sampling",
22
+ "reporter_metabolites",
23
+ ]
@@ -0,0 +1,161 @@
1
+ """Flux Scanning based on Enforced Objective Flux — FSEOF (port + redesign).
2
+
3
+ FSEOF (Choi et al., Appl Environ Microbiol 2010) finds metabolic-engineering targets
4
+ for over-producing a metabolite: enforce an increasing flux toward the target product
5
+ while optimising growth, and watch how each reaction's flux responds. This is a port
6
+ of RAVEN's ``FSEOF`` with a substantially richer, more robust output (RAVEN's
7
+ weaknesses are noted in IMPROVEMENTS, FS1–FS4):
8
+
9
+ * **Robust trend, not strict monotonicity.** Each reaction's flux is regressed against
10
+ the enforced product flux across the scan; the **slope** is the response and the
11
+ **correlation** (|r|) is a quality score. A reaction is a target if it tracks the
12
+ product cleanly (|r| ≥ ``correlation_threshold``) — one noisy step from LP
13
+ alternative optima no longer discards it (and pFBA per step keeps the scan stable).
14
+ * **Direction classification RAVEN lacks.** Targets are labelled ``amplify`` (|flux|
15
+ rises with the product → over-express), ``knockdown`` (|flux| falls), or ``knockout``
16
+ (|flux| → ~0 → delete). RAVEN only ever reports the amplification targets.
17
+ * **Gene-level view** via :attr:`FSEOFResult.gene_targets`, and the full flux scan is
18
+ retained in :attr:`FSEOFResult.scan` — all as DataFrames, not a printed TSV.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ from dataclasses import dataclass
23
+
24
+ import cobra
25
+ import numpy as np
26
+ import pandas as pd
27
+ from cobra.exceptions import OptimizationError
28
+ from cobra.flux_analysis import pfba
29
+ from scipy.stats import linregress
30
+
31
+
32
+ @dataclass
33
+ class FSEOFResult:
34
+ """FSEOF output.
35
+
36
+ ``scan`` is reactions × enforced-flux-levels (the full flux scan); ``enforced`` are
37
+ the enforced target fluxes; ``targets`` is the classified per-reaction table
38
+ (sorted by score). :attr:`gene_targets` aggregates targets to genes.
39
+ """
40
+
41
+ scan: pd.DataFrame
42
+ enforced: list[float]
43
+ targets: pd.DataFrame
44
+
45
+ @property
46
+ def amplification(self) -> pd.DataFrame:
47
+ return self.targets[self.targets["target_type"] == "amplify"].reset_index(drop=True)
48
+
49
+ @property
50
+ def knockout(self) -> pd.DataFrame:
51
+ mask = self.targets["target_type"].isin(["knockout", "knockdown"])
52
+ return self.targets[mask].reset_index(drop=True)
53
+
54
+ @property
55
+ def gene_targets(self) -> pd.DataFrame:
56
+ """Per-gene aggregation: the target reactions each gene is associated with."""
57
+ rows = []
58
+ for _, t in self.targets.iterrows():
59
+ for gene in t["genes"]:
60
+ rows.append({"gene": gene, "reaction": t["reaction"],
61
+ "target_type": t["target_type"], "slope": t["slope"]})
62
+ if not rows:
63
+ return pd.DataFrame(columns=["gene", "target_type", "reactions", "max_abs_slope"])
64
+ df = pd.DataFrame(rows)
65
+ agg = df.groupby("gene").agg(
66
+ target_type=("target_type", lambda s: ";".join(sorted(set(s)))),
67
+ reactions=("reaction", lambda s: ";".join(sorted(set(s)))),
68
+ max_abs_slope=("slope", lambda s: float(np.max(np.abs(s)))),
69
+ ).reset_index()
70
+ return agg.sort_values("max_abs_slope", ascending=False, ignore_index=True)
71
+
72
+
73
+ def fseof(
74
+ model: cobra.Model,
75
+ target_rxn: str,
76
+ *,
77
+ biomass_rxn: str | None = None,
78
+ n_steps: int = 10,
79
+ max_fraction: float = 0.9,
80
+ correlation_threshold: float = 0.9,
81
+ flux_eps: float = 1e-6,
82
+ ) -> FSEOFResult:
83
+ """Run FSEOF for over-production of ``target_rxn``'s product.
84
+
85
+ Enforces target flux from ``max_fraction/n_steps`` up to ``max_fraction`` of the
86
+ theoretical maximum in ``n_steps`` steps, maximising growth (``biomass_rxn`` or the
87
+ model's current objective) with pFBA at each step. Returns an :class:`FSEOFResult`.
88
+ """
89
+ with model: # find the theoretical maximum target flux
90
+ model.objective = target_rxn
91
+ target_opt = model.slim_optimize()
92
+ # slim_optimize returns NaN on an infeasible model; np.isfinite catches that too.
93
+ if target_opt is None or not np.isfinite(target_opt) or target_opt <= flux_eps:
94
+ raise ValueError(f"{target_rxn!r} cannot carry positive flux; nothing to scan.")
95
+ target_max = target_opt * max_fraction
96
+ levels = [target_max * (i + 1) / n_steps for i in range(n_steps)]
97
+
98
+ columns: dict[float, pd.Series] = {}
99
+ enforced: list[float] = []
100
+ for level in levels:
101
+ with model:
102
+ if biomass_rxn is not None:
103
+ model.objective = biomass_rxn
104
+ model.reactions.get_by_id(target_rxn).lower_bound = level
105
+ try:
106
+ columns[level] = pfba(model).fluxes
107
+ except OptimizationError:
108
+ break # enforced flux became infeasible — stop scanning
109
+ enforced.append(level)
110
+ if len(enforced) < 2:
111
+ raise RuntimeError("FSEOF needs at least two feasible enforced-flux levels.")
112
+
113
+ scan = pd.DataFrame(columns)
114
+ targets = _classify(model, scan, np.asarray(enforced), correlation_threshold, flux_eps)
115
+ return FSEOFResult(scan=scan, enforced=enforced, targets=targets)
116
+
117
+
118
+ def _classify(model, scan, enforced, corr_threshold, flux_eps) -> pd.DataFrame:
119
+ rows = []
120
+ for rxn in model.reactions:
121
+ flux = scan.loc[rxn.id, enforced.tolist() if hasattr(enforced, "tolist") else enforced]
122
+ flux = flux.to_numpy(dtype=float)
123
+ initial, final = flux[0], flux[-1]
124
+ if flux.std() < flux_eps: # flat -> no response
125
+ continue
126
+ fit = linregress(enforced, flux)
127
+ slope, corr = float(fit.slope), float(fit.rvalue)
128
+ if abs(corr) < corr_threshold or abs(slope) < flux_eps:
129
+ continue
130
+ # Classify on the slope of |flux| vs the enforced product flux — the
131
+ # criterion the docstring states (|flux| rises = amplify, etc.). The
132
+ # old endpoint-only check (``abs(final) vs abs(initial)``) could
133
+ # mislabel a track whose first/last values straddled a peak/trough but
134
+ # whose overall trend was the opposite. Keep ``knockout`` for tracks
135
+ # the regression drives essentially to zero.
136
+ abs_fit = linregress(enforced, np.abs(flux))
137
+ abs_slope = float(abs_fit.slope)
138
+ if abs(final) < flux_eps and abs_slope < 0:
139
+ ttype = "knockout"
140
+ elif abs_slope > 0:
141
+ ttype = "amplify"
142
+ else:
143
+ ttype = "knockdown"
144
+ rows.append({
145
+ "reaction": rxn.id,
146
+ "name": rxn.name,
147
+ "subsystem": rxn.subsystem,
148
+ "gene_reaction_rule": rxn.gene_reaction_rule,
149
+ "genes": sorted(g.id for g in rxn.genes),
150
+ "target_type": ttype,
151
+ "slope": slope,
152
+ "correlation": corr,
153
+ "initial_flux": initial,
154
+ "final_flux": final,
155
+ "score": abs(slope) * abs(corr),
156
+ })
157
+ table = pd.DataFrame(rows, columns=[
158
+ "reaction", "name", "subsystem", "gene_reaction_rule", "genes",
159
+ "target_type", "slope", "correlation", "initial_flux", "final_flux", "score",
160
+ ])
161
+ return table.sort_values("score", ascending=False, ignore_index=True)
@@ -0,0 +1,119 @@
1
+ """Reporter Metabolites — metabolites around which transcriptional change concentrates.
2
+
3
+ Patil & Nielsen, PNAS 2005. Each gene's differential-expression p-value becomes a
4
+ Z-score ``z = -Φ⁻¹(p)``; for every metabolite the Z-scores of the genes on its
5
+ neighbouring reactions are aggregated (``Σz / √n``), background-corrected, and turned
6
+ back into a p-value.
7
+
8
+ The background correction has an exact closed form (sampling with replacement from the
9
+ scored-gene pool: a random ``Σz/√n`` has mean ``√n·μ`` and standard deviation ``σ``
10
+ with μ, σ the mean/std of the scored Z-scores), so the corrected score is just
11
+ ``(metZ − √n·μ) / σ`` — no Monte-Carlo sampling needed.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import math
16
+ from collections.abc import Mapping
17
+ from dataclasses import dataclass
18
+
19
+ import cobra
20
+ import numpy as np
21
+ import pandas as pd
22
+ from scipy.stats import norm
23
+
24
+ _CLAMP = 15.0 # |Z| cap for p-values of exactly 0 or 1 (RAVEN's ±15)
25
+
26
+
27
+ @dataclass
28
+ class ReporterResult:
29
+ """Reporter-metabolite scores for one gene set.
30
+
31
+ ``test`` is ``"all"``, ``"up"`` or ``"down"``; ``table`` is a DataFrame with
32
+ columns ``metabolite, name, z_score, p_value, n_genes, mean_z, std_z`` sorted by
33
+ descending ``z_score``.
34
+ """
35
+
36
+ test: str
37
+ table: pd.DataFrame
38
+
39
+
40
+ def _gene_z(pvalues: dict[str, float]) -> dict[str, float]:
41
+ genes = list(pvalues)
42
+ z = -norm.ppf([pvalues[g] for g in genes])
43
+ z = np.where(np.isposinf(z), _CLAMP, z)
44
+ z = np.where(np.isneginf(z), -_CLAMP, z)
45
+ return dict(zip(genes, z, strict=True))
46
+
47
+
48
+ def _reporter_one(model: cobra.Model, gene_z: dict[str, float], test: str) -> ReporterResult:
49
+ z_values = np.fromiter(gene_z.values(), dtype=float)
50
+ mu = float(z_values.mean()) if z_values.size else 0.0
51
+ sigma = float(z_values.std(ddof=0)) if z_values.size else 0.0
52
+
53
+ rows = []
54
+ for met in model.metabolites:
55
+ neighbours = {g.id for rxn in met.reactions for g in rxn.genes if g.id in gene_z}
56
+ if not neighbours:
57
+ continue
58
+ zs = np.array([gene_z[g] for g in neighbours])
59
+ n = zs.size
60
+ raw = zs.sum() / math.sqrt(n)
61
+ # Exact background correction for sampling-with-replacement (see module doc).
62
+ corrected = (raw - math.sqrt(n) * mu) / sigma if sigma > 0 else 0.0
63
+ if not math.isfinite(corrected): # never on clamped z-scores; guards the p-value
64
+ corrected = 0.0
65
+ rows.append(
66
+ {
67
+ "metabolite": met.id,
68
+ "name": met.name or met.id,
69
+ "z_score": corrected,
70
+ "p_value": float(1.0 - norm.cdf(corrected)),
71
+ "n_genes": n,
72
+ "mean_z": float(zs.mean()),
73
+ "std_z": float(zs.std(ddof=1)) if n > 1 else float("nan"),
74
+ }
75
+ )
76
+ table = pd.DataFrame(rows, columns=["metabolite", "name", "z_score", "p_value", "n_genes", "mean_z", "std_z"])
77
+ table = table.sort_values("z_score", ascending=False, ignore_index=True)
78
+ return ReporterResult(test, table)
79
+
80
+
81
+ def reporter_metabolites(
82
+ model: cobra.Model,
83
+ gene_pvalues: Mapping[str, float],
84
+ *,
85
+ gene_fold_changes: Mapping[str, float] | None = None,
86
+ ) -> list[ReporterResult]:
87
+ """Compute Reporter Metabolites from per-gene differential-expression p-values.
88
+
89
+ ``gene_pvalues`` maps gene id → p-value (genes not in the model, or with a NaN or
90
+ out-of-``[0, 1]`` p-value, are dropped — a stray invalid p-value would otherwise
91
+ turn the whole result NaN). If ``gene_fold_changes`` (gene id → log fold change)
92
+ is given, two extra results are returned for the up- (fc ≥ 0) and down- (fc < 0)
93
+ regulated gene subsets, in addition to ``"all"``.
94
+
95
+ Parity with RAVEN's ``reporterMetabolites``: the ``z_score`` and underlying
96
+ background correction match exactly (exact closed-form instead of RAVEN's
97
+ Monte-Carlo, see IMPROVEMENTS RM1). The reported ``p_value`` is the
98
+ *one-sided* (``"up"``) enrichment ``1 - Φ(z)`` and the result is sorted by
99
+ ``z_score`` descending. RAVEN sorts by p-value and reports both tails
100
+ (``allPValues``, ``allUpPValues``, ``allDownPValues``); the up/down splits
101
+ here come from the ``gene_fold_changes`` subset partition instead, so the
102
+ same information is available via the three returned ``ReporterResult``
103
+ rows.
104
+ """
105
+ model_genes = {g.id for g in model.genes}
106
+ scored = {
107
+ g: float(p)
108
+ for g, p in gene_pvalues.items()
109
+ if g in model_genes and p is not None and not math.isnan(p) and 0.0 <= p <= 1.0
110
+ }
111
+ gene_z = _gene_z(scored)
112
+ results = [_reporter_one(model, gene_z, "all")]
113
+
114
+ if gene_fold_changes is not None:
115
+ up = {g: z for g, z in gene_z.items() if gene_fold_changes.get(g, 0.0) >= 0}
116
+ down = {g: z for g, z in gene_z.items() if gene_fold_changes.get(g, 0.0) < 0}
117
+ results.append(_reporter_one(model, up, "up"))
118
+ results.append(_reporter_one(model, down, "down"))
119
+ return results
@@ -0,0 +1,218 @@
1
+ """Random-objective flux sampling — RAVEN's ``randomSampling`` (port + improvements).
2
+
3
+ Samples the flux solution space by the **random-objective** method of Bordel et al.
4
+ (2010, PLoS Comput Biol, doi:10.1371/journal.pcbi.1000859), as ported from RAVEN's
5
+ ``randomSampling``: each sample maximises a small random linear combination of
6
+ reactions, so every sample is an *extreme point* (vertex) of the flux polytope.
7
+
8
+ This is a different statistical object from cobrapy's ``cobra.sampling`` (OptGP /
9
+ ACHR), which draw a (near-)uniform Markov-chain sample of the polytope *interior*.
10
+ Use cobra's samplers when you need the uniform flux distribution; use this when you
11
+ want a fast, robust spread of diverse optimal states — the workflow RAVEN uses to
12
+ compare conditions, and one that stays well-behaved on large or tightly-constrained
13
+ models where MCMC mixing is poor. cobrapy has no equivalent, so this is a genuine
14
+ addition, not a wrapper.
15
+
16
+ Improvements over RAVEN (see IMPROVEMENTS SAMP1):
17
+
18
+ * **`good_reactions` via one FVA pass**, not a hand-rolled per-reaction ``parfor``
19
+ loop. A reaction is usable as a random objective if it can carry flux and is not
20
+ stuck in a stoichiometrically-infeasible loop (its range blows past the arbitrary
21
+ large bound). ``cobra``'s FVA computes exactly that, faster and in far less code,
22
+ and can optionally be made ``loopless``.
23
+ * **Reproducible** via ``seed`` (RAVEN has no seed control).
24
+ * **`n_objectives` is a parameter** (RAVEN hard-codes 2, though its docstring claims
25
+ 3).
26
+ * **Tidy output**: a ``samples`` DataFrame shaped samples × reactions (matching
27
+ ``cobra.sampling``), plus the reusable ``good_reactions`` list — instead of a
28
+ reactions × samples matrix and a parallel index vector.
29
+ """
30
+ from __future__ import annotations
31
+
32
+ import logging
33
+ from collections.abc import Iterable
34
+ from dataclasses import dataclass
35
+
36
+ import cobra
37
+ import numpy as np
38
+ import pandas as pd
39
+ from cobra.exceptions import OptimizationError
40
+ from cobra.flux_analysis import flux_variability_analysis, pfba
41
+ from optlang.symbolics import add
42
+
43
+ logger = logging.getLogger(__name__)
44
+
45
+
46
+ @dataclass
47
+ class RandomSamplingResult:
48
+ """Output of :func:`random_sampling`.
49
+
50
+ ``samples`` is a DataFrame of flux vectors shaped *n_samples × n_reactions*
51
+ (one sample per row, reaction ids as columns — the ``cobra.sampling`` layout).
52
+ ``good_reactions`` is the list of reaction ids that were eligible as random
53
+ objectives; pass it back in to skip the (one-off) FVA on a repeat run.
54
+ """
55
+
56
+ samples: pd.DataFrame
57
+ good_reactions: list[str]
58
+
59
+
60
+ def find_good_reactions(
61
+ model: cobra.Model,
62
+ *,
63
+ flux_tol: float = 1e-9,
64
+ loopless: bool = True,
65
+ exclude_reactions: Iterable[str] | None = None,
66
+ ) -> list[str]:
67
+ """Reactions usable as random objectives: carry real (non-loop) flux.
68
+
69
+ A reaction is kept if its FVA range spans more than ``flux_tol``. With
70
+ ``loopless`` (default) the FVA is loopless (``cycleFreeFlux``), so reactions
71
+ that can carry flux *only* through a thermodynamically-infeasible cycle have a
72
+ ~0 loopless range and are dropped — the right test for "loopy", unlike a fixed
73
+ bound threshold which wrongly drops legitimate reactions that simply reach the
74
+ model's default (e.g. 1000) bound. Pass ``loopless=False`` for a faster, looser
75
+ pass that keeps any flux-carrying reaction (loops included).
76
+ """
77
+ fva = flux_variability_analysis(
78
+ model, fraction_of_optimum=0.0,
79
+ loopless="cycleFreeFlux" if loopless else None,
80
+ )
81
+ excluded = set(exclude_reactions or ())
82
+ return [
83
+ rxn_id
84
+ for rxn_id, lo, hi in zip(fva.index, fva["minimum"], fva["maximum"], strict=True)
85
+ if rxn_id not in excluded and max(abs(lo), abs(hi)) > flux_tol
86
+ ]
87
+
88
+
89
+ def random_sampling(
90
+ model: cobra.Model,
91
+ n_samples: int = 1000,
92
+ *,
93
+ n_objectives: int = 2,
94
+ good_reactions: Iterable[str] | None = None,
95
+ replace_max_bound: bool = False,
96
+ min_flux: bool = False,
97
+ loopless_good_reactions: bool = True,
98
+ exclude_reactions: Iterable[str] | None = None,
99
+ max_attempts: int = 100,
100
+ suppress_errors: bool = False,
101
+ seed: int | None = None,
102
+ ) -> RandomSamplingResult:
103
+ """Random-objective sampling of ``model``'s flux space (Bordel et al. 2010).
104
+
105
+ Each sample maximises ``sum(w_i * v_i)`` over ``n_objectives`` reactions drawn at
106
+ random from ``good_reactions``, with weights ``w_i = U(0,1) * (±1)`` (a random
107
+ sign per reaction, as in RAVEN). The resulting flux vector is one sample.
108
+
109
+ Parameters
110
+ ----------
111
+ n_samples
112
+ Number of flux vectors to return.
113
+ n_objectives
114
+ Reactions combined into each random objective (RAVEN's fixed 2).
115
+ good_reactions
116
+ Reaction ids eligible as objectives. If ``None`` they are computed once with
117
+ :func:`find_good_reactions` and returned for reuse.
118
+ replace_max_bound
119
+ RAVEN's ``replaceBoundsWithInf``: replace the largest upper bound with
120
+ ``+inf`` (and the smallest negative lower bound with ``-inf``) before
121
+ sampling, so a reaction whose biological maximum exceeds the model's
122
+ arbitrary cap is not pinned at it. **Off by default** — unlike RAVEN. It
123
+ applies only to the sampling phase (``good_reactions`` is always found on
124
+ the finite bounds), and it can open unbounded directions through loops
125
+ that show up as large fluxes in non-objective reactions; pair it with
126
+ ``min_flux`` if you enable it.
127
+ min_flux
128
+ After maximising the random objective, re-solve parsimoniously
129
+ (:func:`cobra.flux_analysis.pfba`) to minimise total flux at that optimum —
130
+ squeezes residual loops out of each individual sample.
131
+ loopless_good_reactions, exclude_reactions
132
+ Forwarded to :func:`find_good_reactions` when it is invoked (loopless loop
133
+ detection is on by default).
134
+ max_attempts, suppress_errors
135
+ A sample is retried if the random objective is degenerate (zero flux). After
136
+ ``max_attempts`` failures this raises, unless ``suppress_errors`` (then the
137
+ degenerate solution is kept with a warning).
138
+ seed
139
+ Seed for reproducible objective draws.
140
+
141
+ Returns
142
+ -------
143
+ RandomSamplingResult
144
+ """
145
+ if n_samples <= 0:
146
+ raise ValueError("n_samples must be positive.")
147
+ rng = np.random.default_rng(seed)
148
+ model = model.copy()
149
+
150
+ if model.slim_optimize(error_value=None) is None:
151
+ raise ValueError(
152
+ "The model has no feasible solution, likely due to incompatible constraints."
153
+ )
154
+
155
+ # good_reactions must be found on the finite bounds (FVA cannot handle inf),
156
+ # before any bound replacement.
157
+ if good_reactions is None:
158
+ good_reactions = find_good_reactions(
159
+ model, loopless=loopless_good_reactions,
160
+ exclude_reactions=exclude_reactions,
161
+ )
162
+ good_reactions = list(good_reactions)
163
+
164
+ if replace_max_bound:
165
+ max_ub = max(r.upper_bound for r in model.reactions)
166
+ min_lb = min(r.lower_bound for r in model.reactions)
167
+ for r in model.reactions:
168
+ if r.upper_bound == max_ub:
169
+ r.upper_bound = float("inf")
170
+ if min_lb < 0 and r.lower_bound == min_lb:
171
+ r.lower_bound = float("-inf")
172
+
173
+ if len(good_reactions) < n_objectives:
174
+ raise ValueError(
175
+ f"Only {len(good_reactions)} usable reactions found, need at least "
176
+ f"n_objectives={n_objectives}. Check the model's constraints."
177
+ )
178
+
179
+ good_rxn_objs = [model.reactions.get_by_id(r) for r in good_reactions]
180
+ reaction_ids = [r.id for r in model.reactions]
181
+ samples = np.zeros((n_samples, len(reaction_ids)))
182
+
183
+ for i in range(n_samples):
184
+ for attempt in range(1, max_attempts + 1):
185
+ chosen = rng.choice(len(good_rxn_objs), size=n_objectives, replace=False)
186
+ signs = rng.choice((-1.0, 1.0), size=n_objectives)
187
+ weights = rng.random(n_objectives) * signs
188
+ terms = [w * good_rxn_objs[j].flux_expression
189
+ for j, w in zip(chosen, weights, strict=True)]
190
+ # add() (not sum()) builds the symbolic objective in one pass; sum()
191
+ # re-canonicalises the optlang expression on every term (O(n^2)).
192
+ model.objective = model.problem.Objective(add(terms), direction="max")
193
+ sol = model.optimize()
194
+ if sol.status == "optimal" and abs(sol.objective_value) > 1e-8:
195
+ fluxes = (pfba(model) if min_flux else sol).fluxes.reindex(reaction_ids)
196
+ if fluxes.isna().any(): # solver returned an unexpected reaction set
197
+ missing = fluxes.index[fluxes.isna()].tolist()
198
+ raise OptimizationError(
199
+ "solver returned fluxes missing reaction(s) "
200
+ f"{missing[:5]}; cannot assemble a NaN-free sample matrix."
201
+ )
202
+ samples[i, :] = fluxes.to_numpy()
203
+ break
204
+ if attempt == max_attempts:
205
+ if not suppress_errors:
206
+ raise OptimizationError(
207
+ "Could not find a non-zero, loop-free solution after "
208
+ f"{max_attempts} attempts for sample {i}. Review the model's "
209
+ "constraints, or set suppress_errors=True."
210
+ )
211
+ logger.warning("Sample %d: kept a degenerate solution after %d attempts.",
212
+ i, max_attempts)
213
+ samples[i, :] = sol.fluxes.reindex(reaction_ids).to_numpy()
214
+
215
+ return RandomSamplingResult(
216
+ samples=pd.DataFrame(samples, columns=reaction_ids),
217
+ good_reactions=good_reactions,
218
+ )
@@ -0,0 +1,24 @@
1
+ """Annotation helpers — SBO term assignment, ΔG CSV persistence.
2
+
3
+ These are the pieces of yeast-GEM's ``missingFields`` module that are
4
+ organism-agnostic enough to live upstream. Default parameter values
5
+ match the RAVEN/yeast convention so the functions are immediately
6
+ useful on the standard layout; consumers with different naming pass
7
+ overrides.
8
+ """
9
+ from raven_toolbox.annotation.delta_g import load_delta_g_csv, save_delta_g_csv
10
+ from raven_toolbox.annotation.sbo import (
11
+ DEFAULT_BIOMASS_MET_NAMES,
12
+ DEFAULT_BIOMASS_RXN_NAME,
13
+ DEFAULT_NGAM_RXN_NAME,
14
+ add_sbo_terms,
15
+ )
16
+
17
+ __all__ = [
18
+ "DEFAULT_BIOMASS_MET_NAMES",
19
+ "DEFAULT_BIOMASS_RXN_NAME",
20
+ "DEFAULT_NGAM_RXN_NAME",
21
+ "add_sbo_terms",
22
+ "load_delta_g_csv",
23
+ "save_delta_g_csv",
24
+ ]
@@ -0,0 +1,115 @@
1
+ """Persist scalar annotations (Gibbs free energy, etc.) as a side-car CSV.
2
+
3
+ The committed SBML standard has no slot for thermodynamic ΔG values, so
4
+ projects like yeast-GEM keep them in a paired CSV instead. This module
5
+ provides the generic load / save round-trip — the file format is a two
6
+ column table ``<id>, <value>`` — and exposes the per-call schema so
7
+ existing projects can configure their conventions.
8
+
9
+ The functions operate on cobra ``.notes`` (string round-tripped through
10
+ SBML), keeping any other notes untouched.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import math
15
+ from collections.abc import Iterable
16
+ from pathlib import Path
17
+
18
+ import cobra
19
+ import pandas as pd
20
+
21
+
22
+ def load_delta_g_csv(
23
+ entities: Iterable,
24
+ path: str | Path,
25
+ *,
26
+ id_column: str = "Var1",
27
+ value_column: str = "Var2",
28
+ note_key: str = "deltaG",
29
+ verbose: bool = False,
30
+ ) -> int:
31
+ """Stamp ``note_key`` on each entity from a CSV of ``id → value``.
32
+
33
+ Parameters
34
+ ----------
35
+ entities
36
+ Cobra entities (``model.metabolites`` or ``model.reactions``).
37
+ path
38
+ Path to the CSV.
39
+ id_column, value_column
40
+ Column labels in the CSV. Defaults match the yeast-GEM
41
+ convention (``Var1`` / ``Var2`` from MATLAB's ``array2table``).
42
+ note_key
43
+ Key under which the value is stored on ``entity.notes``.
44
+ Default ``"deltaG"``.
45
+ verbose
46
+ Print a summary of unmatched entity ids.
47
+
48
+ Returns
49
+ -------
50
+ The number of entities that were stamped (i.e. matched the CSV).
51
+ """
52
+ df = pd.read_csv(path)
53
+ if id_column not in df.columns or value_column not in df.columns:
54
+ raise ValueError(
55
+ f"{path} columns {list(df.columns)} do not contain "
56
+ f"both {id_column!r} and {value_column!r}"
57
+ )
58
+ lookup = dict(zip(df[id_column], df[value_column], strict=True))
59
+
60
+ stamped = 0
61
+ missing: list[str] = []
62
+ for entity in entities:
63
+ value = lookup.get(entity.id)
64
+ if value is None or (isinstance(value, float) and math.isnan(value)):
65
+ missing.append(entity.id)
66
+ continue
67
+ entity.notes[note_key] = str(value)
68
+ stamped += 1
69
+
70
+ if verbose and missing:
71
+ print(
72
+ f"{len(missing)} entity ids were not in {path} "
73
+ f"(e.g. {missing[:3]}); left untouched."
74
+ )
75
+ return stamped
76
+
77
+
78
+ def save_delta_g_csv(
79
+ entities: Iterable,
80
+ path: str | Path,
81
+ *,
82
+ id_column: str = "Var1",
83
+ value_column: str = "Var2",
84
+ note_key: str = "deltaG",
85
+ ) -> int:
86
+ """Dump ``entity.notes[note_key]`` for each entity to a CSV.
87
+
88
+ Entities without ``note_key`` set get ``NaN`` written, preserving
89
+ one-row-per-entity ordering (mirrors MATLAB's ``array2table([ids,
90
+ values])`` behaviour).
91
+
92
+ Returns
93
+ -------
94
+ The number of rows written (= number of entities).
95
+ """
96
+ rows: list[tuple[str, float]] = []
97
+ for entity in entities:
98
+ raw = entity.notes.get(note_key)
99
+ if raw is None:
100
+ value: float = math.nan
101
+ else:
102
+ try:
103
+ value = float(raw)
104
+ except ValueError:
105
+ value = math.nan
106
+ rows.append((entity.id, value))
107
+ pd.DataFrame(rows, columns=[id_column, value_column]).to_csv(path, index=False)
108
+ return len(rows)
109
+
110
+
111
+ # Re-export the cobra Model type for type-checker friendliness; helps
112
+ # IDEs surface the right hints to callers that hand us model.metabolites
113
+ # / model.reactions directly.
114
+ __all__ = ["load_delta_g_csv", "save_delta_g_csv"]
115
+ _ = cobra # silence "imported but unused" — used for typing context above