mptool 1.0.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.
mptool/__init__.py ADDED
@@ -0,0 +1,107 @@
1
+ #!/usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+ # (c) 2019-2026 ETH Zurich, Ove Øyås
4
+ # Licensed under the MIT License. See LICENSE file for details.
5
+ """
6
+ mptool — enumeration and sampling of minimal pathways in metabolic (sub)networks.
7
+
8
+ Public functions::
9
+
10
+ enumerate_mps Exhaustively enumerate minimal pathways (MPs) and
11
+ minimal cut sets (MCSs) in a reaction subset using
12
+ direct or iterative methods.
13
+ sample_mps Stochastically sample minimal pathways from the MP
14
+ space of a reaction subset (always iterative;
15
+ supports weighted and adaptive sampling). Returns
16
+ (mps, acceptance), the unique/total acceptance rate.
17
+ preprocess_model Preprocess a model and subset for MP search:
18
+ fastercc removal, optional FVA tightening or
19
+ compression, MFI, and a preprocessed-to-original
20
+ reaction-ID mapping.
21
+ rerun Reconstruct and re-invoke a recorded find-MP run
22
+ from an exported run bundle (see the ``export``
23
+ option); pure record-and-replay, so the replayed
24
+ call behaves identically to the original.
25
+ validate_mps Validate that each MP is feasible and support-
26
+ minimal against a prepared model (returns one
27
+ bool per MP).
28
+ validate_solutions Validate that every passed flux vector is feasible
29
+ in a model (bounds + constraints); raises on the
30
+ first infeasible solution.
31
+ translate_mps Translate enumerated/sampled MPs from the
32
+ preprocessed namespace back to the original
33
+ (union expansion per member).
34
+ translate_mcs Translate enumerated MCSs from the
35
+ preprocessed namespace back to the original
36
+ (Cartesian product + antichain filter).
37
+ create_lp Build a Gurobi LP from a COBRApy model.
38
+ copy_model Copy a Gurobi model preserving solver parameters.
39
+ set_bounds Apply ``{rxn_id: (lb, ub)}`` bounds to a Gurobi LP
40
+ in place (e.g. FVA-tight bounds from ``fva``).
41
+ load_cobra_model Load a metabolic model (SBML/JSON/MATLAB).
42
+ load_cobra_models Load one or more models from files or a directory.
43
+ load_set, save_set Read/write a set of reaction IDs from/to text.
44
+ load_sets, save_sets Read/write a collection of sets of IDs from/to CSV.
45
+ load_bounds, Read/write per-reaction flux bounds from/to CSV.
46
+ save_bounds
47
+ load_mapping, Read/write a preprocessed-to-original reaction
48
+ save_mapping mapping (``{new_id: {orig_id: coef}}``) from/to CSV.
49
+ load_weights Read per-reaction sampling weights from a
50
+ ``reaction_id,weight`` CSV.
51
+ make_forward_ Split reversible reactions into forward and reverse
52
+ irreversible half-reactions with non-negative bounds; returns
53
+ the updated subset and a {new_id: {orig_id: coef}}
54
+ map.
55
+ fva Flux variability analysis on a Gurobi LP model.
56
+ compress Lossless FVA-based network compression: merge
57
+ flux-coupled reactions into single variables.
58
+ fastcc Identify flux-consistent reactions (fast version).
59
+ fastercc Identify flux-consistent reactions (faster).
60
+
61
+ To apply FVA-tight bounds without compressing, run :func:`mptool.fva` and pass
62
+ the result to :func:`mptool.set_bounds`.
63
+
64
+ .. note::
65
+ The name ``mptool.fva`` is the *function* above, not the ``mptool.fva``
66
+ submodule it is defined in — this module rebinds the attribute on import.
67
+ So ``mptool.fva.compress`` does not resolve; use :func:`mptool.compress`,
68
+ or ``from mptool.fva import compress`` to reach it through the submodule.
69
+ """
70
+
71
+ from importlib.metadata import PackageNotFoundError, version as _pkg_version
72
+
73
+ from .io import (load_cobra_model, load_cobra_models,
74
+ load_set, save_set, load_sets, save_sets,
75
+ load_bounds, save_bounds, load_mapping, save_mapping,
76
+ load_weights)
77
+ from .mapping import translate_mcs, translate_mps
78
+ from .model import create_lp, copy_model, make_forward_irreversible, set_bounds
79
+ from .fva import compress, fva
80
+ from .consistency import fastcc, fastercc
81
+ from .preprocess import preprocess_model
82
+ from .mps import enumerate_mps, sample_mps
83
+ from .reproduce import rerun
84
+ from .validate import validate_mps, validate_solutions
85
+
86
+ # Single source of truth is ``version`` in setup.cfg, read here from the
87
+ # installed distribution metadata.
88
+ try:
89
+ __version__ = _pkg_version('mptool')
90
+ except PackageNotFoundError: # pragma: no cover - package always installed
91
+ __version__ = 'unknown' # source tree that was never installed
92
+
93
+ __all__ = [
94
+ 'enumerate_mps', 'sample_mps', 'preprocess_model',
95
+ 'rerun',
96
+ 'create_lp', 'copy_model', 'set_bounds',
97
+ 'load_cobra_model', 'load_cobra_models',
98
+ 'load_set', 'save_set', 'load_sets', 'save_sets',
99
+ 'load_bounds', 'save_bounds',
100
+ 'load_mapping', 'save_mapping',
101
+ 'load_weights',
102
+ 'make_forward_irreversible', 'fva', 'compress',
103
+ 'fastcc', 'fastercc',
104
+ 'validate_mps', 'validate_solutions',
105
+ 'translate_mps', 'translate_mcs',
106
+ '__version__',
107
+ ]
mptool/__main__.py ADDED
@@ -0,0 +1,430 @@
1
+ """Command-line interface for ``python -m mptool``.
2
+
3
+ Exposes two subcommands:
4
+
5
+ * ``python -m mptool enumerate <model> [options]`` —
6
+ :func:`mptool.enumerate_mps` (exhaustive enumeration of MPs and MCSs).
7
+ * ``python -m mptool sample <model> [options]`` —
8
+ :func:`mptool.sample_mps` (stochastic sampling of MPs).
9
+
10
+ Options common to both live on a shared parent parser so they cannot drift.
11
+ The Python-only parameters ``requirements`` (a callback) and ``preprocess`` /
12
+ ``return_mapping`` are deliberately not exposed; with compression active the
13
+ preprocessed→original reaction mapping reaches CLI users as ``mapping.csv`` in
14
+ the ``--export`` bundle.
15
+ """
16
+
17
+ import argparse
18
+ import os
19
+ import sys
20
+ from importlib.metadata import version as _pkg_version
21
+
22
+ from mptool import (enumerate_mps, load_bounds, load_cobra_model, load_set,
23
+ load_weights, sample_mps)
24
+
25
+
26
+ def _int_at_least(minimum):
27
+ """Return an argparse ``type`` parsing an int that must be >= ``minimum``."""
28
+ def parse(value):
29
+ ivalue = int(value)
30
+ if ivalue < minimum:
31
+ raise argparse.ArgumentTypeError(
32
+ f'must be an integer >= {minimum}; got {ivalue}')
33
+ return ivalue
34
+ return parse
35
+
36
+
37
+ def _float_at_least(minimum):
38
+ """Return an argparse ``type`` parsing a float that must be >= ``minimum``.
39
+
40
+ Used where 0 is a meaningful value (``--max-t 0`` and ``--conv-tol 0``
41
+ both mean "off"), so the bound is inclusive.
42
+ """
43
+ def parse(value):
44
+ fvalue = float(value)
45
+ if not fvalue >= minimum: # also rejects nan
46
+ raise argparse.ArgumentTypeError(
47
+ f'must be a number >= {minimum}; got {value}')
48
+ return fvalue
49
+ return parse
50
+
51
+
52
+ def _float_above(bound):
53
+ """Return an argparse ``type`` parsing a float that must be > ``bound``.
54
+
55
+ Used for tolerances and magnitudes that are inoperative at zero, so the
56
+ CLI rejects them at parse time rather than surfacing a traceback from
57
+ deep inside the search.
58
+ """
59
+ def parse(value):
60
+ fvalue = float(value)
61
+ if not fvalue > bound: # also rejects nan
62
+ raise argparse.ArgumentTypeError(
63
+ f'must be a number > {bound}; got {value}')
64
+ return fvalue
65
+ return parse
66
+
67
+
68
+ def _build_common_parser():
69
+ """Build the shared parent parser for options common to both subcommands."""
70
+ common = argparse.ArgumentParser(add_help=False)
71
+
72
+ common.add_argument(
73
+ 'model',
74
+ help='Path to model file (SBML, JSON, or MATLAB format).',
75
+ )
76
+
77
+ common.add_argument(
78
+ '--subset',
79
+ metavar='FILE',
80
+ default=None,
81
+ help=(
82
+ 'Path to a text file with one reaction ID per line. '
83
+ 'Reactions in this file are the candidates for MPs. '
84
+ 'If omitted, all model reactions are used.'
85
+ ),
86
+ )
87
+ common.add_argument(
88
+ '--bounds',
89
+ metavar='FILE',
90
+ default=None,
91
+ help=(
92
+ 'Path to a CSV bounds file (reaction_id,lb,ub per line). '
93
+ 'Overrides model bounds for the listed reactions.'
94
+ ),
95
+ )
96
+
97
+ common.add_argument(
98
+ '--chunks',
99
+ type=_int_at_least(2),
100
+ default=None,
101
+ dest='chunks',
102
+ metavar='N',
103
+ help=(
104
+ 'Per-iteration deletion strategy. Omit for single-reaction '
105
+ 'deletion (default); pass an integer >= 2 for chunked deletion '
106
+ 'with that many splits per failed chunk.'
107
+ ),
108
+ )
109
+
110
+ common.add_argument(
111
+ '--tighten', action=argparse.BooleanOptionalAction, default=True,
112
+ help='Tighten bounds via flux variability analysis.',
113
+ )
114
+ common.add_argument(
115
+ '--compress', action=argparse.BooleanOptionalAction, default=True,
116
+ help=(
117
+ 'Losslessly compress the model via FVA-based flux coupling '
118
+ 'before the search. Requires --tighten.'
119
+ ),
120
+ )
121
+ common.add_argument(
122
+ '--verbose', action=argparse.BooleanOptionalAction, default=True,
123
+ help='Print per-MP progress output.',
124
+ )
125
+
126
+ common.add_argument(
127
+ '--export', action='store_true', default=False,
128
+ help='Write a run bundle: log, model, solver settings, bounds, '
129
+ 'manifest, plus MPs, MCSs and mapping when the run produces '
130
+ 'them. The subset is recorded in the manifest, not as a '
131
+ 'separate file.',
132
+ )
133
+
134
+ common.add_argument(
135
+ '--flux-tol',
136
+ type=_float_above(0.0),
137
+ default=1e-9,
138
+ metavar='FLUX_TOL',
139
+ help='Threshold below which a flux value or bound is considered '
140
+ 'zero; also sets the Gurobi solver tolerances.',
141
+ )
142
+ common.add_argument(
143
+ '--merge-tol',
144
+ type=_float_above(0.0),
145
+ default=1e-6,
146
+ metavar='MERGE_TOL',
147
+ help='Numerical-zero threshold on cluster-cancellation residuals '
148
+ 'in compression (only used with --compress). Unlike '
149
+ '--flux-tol, the risky direction is tighter: below the 1e-6 '
150
+ 'default the residuals can make the compressed model '
151
+ 'infeasible, and a warning is emitted.',
152
+ )
153
+ common.add_argument(
154
+ '--inf',
155
+ type=_float_above(0.0),
156
+ default=1000.0,
157
+ metavar='INF',
158
+ help='Largest allowed flux bound (absolute value).',
159
+ )
160
+ common.add_argument(
161
+ '--threads',
162
+ type=_int_at_least(0),
163
+ default=0,
164
+ metavar='N',
165
+ help='Number of Gurobi solver threads (0 = all available cores).',
166
+ )
167
+ common.add_argument(
168
+ '--max-t',
169
+ dest='max_t',
170
+ type=_float_at_least(0.0),
171
+ default=0.0,
172
+ metavar='SECONDS',
173
+ help='Time limit in seconds for the MP search (0 = no limit). '
174
+ 'Preprocessing runs before the clock starts, so total runtime '
175
+ 'is preprocessing plus this budget.',
176
+ )
177
+ common.add_argument(
178
+ '--output-dir',
179
+ dest='output_dir',
180
+ default='',
181
+ metavar='DIR',
182
+ help='Directory for exported files. Defaults to the current directory.',
183
+ )
184
+ common.add_argument(
185
+ '--seed',
186
+ type=_int_at_least(0),
187
+ default=None,
188
+ metavar='N',
189
+ help=(
190
+ 'Seed for the random number generator. Two runs with the same '
191
+ 'seed and identical other parameters produce identical results.'
192
+ ),
193
+ )
194
+
195
+ return common
196
+
197
+
198
+ def _build_parser():
199
+ """Build and return the argument parser for the mptool CLI."""
200
+ parser = argparse.ArgumentParser(
201
+ prog='python -m mptool',
202
+ description=(
203
+ 'Enumerate or sample minimal pathways (MPs) in a metabolic model.'
204
+ ),
205
+ )
206
+ parser.add_argument(
207
+ '--version', action='version',
208
+ version=f'%(prog)s {_pkg_version("mptool")}',
209
+ )
210
+
211
+ common = _build_common_parser()
212
+ subparsers = parser.add_subparsers(dest='command', required=True)
213
+
214
+ # ── enumerate ────────────────────────────────────────────────────────────
215
+ p_enum = subparsers.add_parser(
216
+ 'enumerate', parents=[common],
217
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
218
+ help='Exhaustively enumerate MPs (and MCSs) in the model.',
219
+ description=(
220
+ 'Exhaustively enumerate minimal pathways (MPs), and minimal cut '
221
+ 'sets (MCSs) with the iterative method, in a metabolic model.'
222
+ ),
223
+ )
224
+ p_enum.add_argument(
225
+ '--method',
226
+ default='iterative',
227
+ choices=('iterative', 'direct'),
228
+ help=(
229
+ 'Enumeration method. '
230
+ "'direct' requires --no-graph and is not compatible with "
231
+ '--chunks; it also finds no MCSs.'
232
+ ),
233
+ )
234
+ p_enum.add_argument(
235
+ '--mcs-method',
236
+ dest='mcs_method',
237
+ default='bip',
238
+ choices=('bip', 'berge'),
239
+ help=(
240
+ "Algorithm for minimal cut candidates (iterative only). "
241
+ "'bip' uses a Gurobi BIP; 'berge' uses an alternative incremental "
242
+ "combinatorial enumerator."
243
+ ),
244
+ )
245
+ p_enum.add_argument(
246
+ '--graph', action=argparse.BooleanOptionalAction, default=True,
247
+ help=(
248
+ 'Reaction co-occurrence graph acceleration (iterative method '
249
+ 'only; pass --no-graph with --method direct).'
250
+ ),
251
+ )
252
+ p_enum.add_argument(
253
+ '--max-mps',
254
+ dest='max_mps',
255
+ type=_int_at_least(0),
256
+ default=0,
257
+ metavar='N',
258
+ help='Maximum number of MPs to find (0 = no limit).',
259
+ )
260
+
261
+ # ── sample ───────────────────────────────────────────────────────────────
262
+ p_sample = subparsers.add_parser(
263
+ 'sample', parents=[common],
264
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
265
+ help='Stochastically sample MPs from the model.',
266
+ description=(
267
+ 'Stochastically sample minimal pathways (MPs) from a metabolic '
268
+ 'model. Requires at least one of --n-samples or --max-t.'
269
+ ),
270
+ )
271
+ p_sample.add_argument(
272
+ '--n-samples',
273
+ dest='n_samples',
274
+ type=_int_at_least(0),
275
+ default=0,
276
+ metavar='N',
277
+ help='Target number of MPs to sample (0 = no MP limit; then --max-t '
278
+ 'is required).',
279
+ )
280
+ p_sample.add_argument(
281
+ '--weights',
282
+ metavar='FILE',
283
+ default=None,
284
+ help=(
285
+ 'Path to a CSV weights file (reaction_id,weight per line) biasing '
286
+ 'the random ordering. Higher weight -> reaction more likely to be '
287
+ 'retained in sampled MPs.'
288
+ ),
289
+ )
290
+ p_sample.add_argument(
291
+ '--adaptive', action='store_true', default=False,
292
+ help='Update weights adaptively after each sample to reduce length '
293
+ 'bias.',
294
+ )
295
+ p_sample.add_argument(
296
+ '--conv-tol',
297
+ dest='conv_tol',
298
+ type=_float_at_least(0.0),
299
+ default=0.0,
300
+ metavar='CONV_TOL',
301
+ help='Early-stopping threshold for adaptive sampling (0 = disabled).',
302
+ )
303
+ p_sample.add_argument(
304
+ '--conv-window',
305
+ dest='conv_window',
306
+ type=_int_at_least(1),
307
+ default=10,
308
+ metavar='N',
309
+ help='Rolling window for the adaptive convergence check.',
310
+ )
311
+
312
+ return parser
313
+
314
+
315
+ def main(argv=None):
316
+ """Entry point for ``python -m mptool``.
317
+
318
+ Parses ``argv`` (or ``sys.argv[1:]`` when None) and dispatches to
319
+ :func:`enumerate_mps` or :func:`sample_mps` according to the chosen
320
+ subcommand.
321
+ """
322
+ parser = _build_parser()
323
+ args = parser.parse_args(argv)
324
+
325
+ if args.subset is not None and not os.path.isfile(args.subset):
326
+ parser.error(
327
+ f'--subset: file not found or not a regular file: {args.subset}'
328
+ )
329
+
330
+ if args.bounds is not None and not os.path.isfile(args.bounds):
331
+ parser.error(
332
+ f'--bounds: file not found or not a regular file: {args.bounds}'
333
+ )
334
+
335
+ # Compression always tightens bounds; preprocess_model rejects the
336
+ # combination. Catch it here as a clean CLI error before any model work.
337
+ if args.compress and not args.tighten:
338
+ parser.error(
339
+ '--compress requires --tighten (compression always tightens bounds)'
340
+ )
341
+
342
+ # enumerate-specific validation, fail-fast before any model work (mirrors
343
+ # the checks above and enumerate_mps' own guards). --graph defaults to
344
+ # True, so `--method direct` on its own would otherwise reach
345
+ # enumerate_mps and die with an unhandled ValueError traceback; --chunks
346
+ # is the same incompatibility, reached only when the user passes it.
347
+ if args.command == 'enumerate' and args.method == 'direct':
348
+ if args.graph:
349
+ parser.error(
350
+ '--method direct is not compatible with --graph: the '
351
+ 'co-occurrence graph accelerates the iterative method only. '
352
+ 'Add --no-graph, e.g. `python -m mptool enumerate MODEL '
353
+ '--method direct --no-graph`'
354
+ )
355
+ if args.chunks is not None:
356
+ parser.error(
357
+ '--method direct is not compatible with --chunks: chunked '
358
+ 'deletion is an iterative-method strategy. Drop --chunks, or '
359
+ 'use --method iterative'
360
+ )
361
+
362
+ # sample-specific validation, fail-fast before any model work (mirrors the
363
+ # subset/bounds existence checks above and sample_mps' own guard).
364
+ if args.command == 'sample':
365
+ if not (args.n_samples or args.max_t):
366
+ parser.error(
367
+ 'sample requires at least one of --n-samples or --max-t '
368
+ 'to terminate sampling'
369
+ )
370
+ if args.weights is not None and not os.path.isfile(args.weights):
371
+ parser.error(
372
+ f'--weights: file not found or not a regular file: '
373
+ f'{args.weights}'
374
+ )
375
+
376
+ cobra_model = load_cobra_model(args.model)
377
+ bounds = load_bounds(args.bounds) if args.bounds is not None else {}
378
+ subset = load_set(args.subset) if args.subset is not None else None
379
+
380
+ preprocess_kwargs = {
381
+ 'bounds': bounds,
382
+ 'tighten': args.tighten,
383
+ 'compress': args.compress,
384
+ 'merge_tol': args.merge_tol,
385
+ 'inf': args.inf,
386
+ }
387
+
388
+ if args.command == 'enumerate':
389
+ enumerate_mps(
390
+ cobra_model,
391
+ subset=subset,
392
+ method=args.method,
393
+ chunks=args.chunks,
394
+ graph=args.graph,
395
+ mcs_method=args.mcs_method,
396
+ threads=args.threads,
397
+ max_mps=args.max_mps,
398
+ max_t=args.max_t,
399
+ seed=args.seed,
400
+ flux_tol=args.flux_tol,
401
+ verbose=args.verbose,
402
+ export=args.export,
403
+ output_dir=args.output_dir,
404
+ preprocess_kwargs=preprocess_kwargs,
405
+ )
406
+ else:
407
+ weights = load_weights(args.weights) if args.weights is not None \
408
+ else None
409
+ sample_mps(
410
+ cobra_model,
411
+ subset=subset,
412
+ n_samples=args.n_samples,
413
+ weights=weights,
414
+ adaptive=args.adaptive,
415
+ conv_tol=args.conv_tol,
416
+ conv_window=args.conv_window,
417
+ chunks=args.chunks,
418
+ threads=args.threads,
419
+ max_t=args.max_t,
420
+ seed=args.seed,
421
+ flux_tol=args.flux_tol,
422
+ verbose=args.verbose,
423
+ export=args.export,
424
+ output_dir=args.output_dir,
425
+ preprocess_kwargs=preprocess_kwargs,
426
+ )
427
+
428
+
429
+ if __name__ == '__main__':
430
+ sys.exit(main())