gpugem 0.1.2__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.
gpugem/__init__.py ADDED
@@ -0,0 +1,32 @@
1
+ """gpuGEM — GPU-accelerated Flux Balance Analysis for genome-scale metabolic models."""
2
+
3
+ from gpugem._deps import DependencyError, require as _require
4
+
5
+ # numpy/scipy are hard dependencies; fail here with guidance, not with a bare ImportError below.
6
+ # cuOpt is deliberately NOT checked at import: it is required only when a solve is requested.
7
+ _require("numpy")
8
+ _require("scipy")
9
+
10
+ from gpugem.solver import FBASolver, solve # noqa: E402
11
+ from gpugem.solve_cobra import solve_cobra # noqa: E402
12
+ from gpugem.result import FBAResult # noqa: E402
13
+ from gpugem.scaling import CoefficientScalingMapping, remap_fluxes, scale_model # noqa: E402
14
+ from gpugem.lifting import LiftingMapping, lift_coupling, lift_mass_balance, map_back # noqa: E402
15
+ from gpugem import loaders # noqa: E402
16
+
17
+ __version__ = "0.1.2"
18
+ __all__ = [
19
+ "DependencyError",
20
+ "FBASolver",
21
+ "solve",
22
+ "solve_cobra",
23
+ "FBAResult",
24
+ "CoefficientScalingMapping",
25
+ "scale_model",
26
+ "remap_fluxes",
27
+ "LiftingMapping",
28
+ "lift_mass_balance",
29
+ "lift_coupling",
30
+ "map_back",
31
+ "loaders",
32
+ ]
gpugem/_defaults.py ADDED
@@ -0,0 +1,80 @@
1
+ """
2
+ Default cuOpt solver settings derived from systematic benchmarking on whole-body
3
+ metabolic models across two scale classes.
4
+
5
+ cuOpt's precision enum (verified against cpp/include/.../constants.h):
6
+ -1 = Default, 0 = Single, 1 = Double, 2 = Mixed.
7
+ Earlier revisions of this module named these modes wrongly -- pdlp_precision=1
8
+ was described as "mixed FP32/FP64" when the enum defines it as Double. The
9
+ measurements below are unchanged and were always correct; only the labels were
10
+ wrong. gpuGEM therefore uses DOUBLE precision at both scales: explicitly below
11
+ the threshold, and via cuOpt's Default above it (Default resolves to Double,
12
+ confirmed bit-identical on e_coli_core and again at S85 scale -- identical
13
+ iteration count and max residual with and without the explicit setting).
14
+
15
+ Small models (≤ 100K reactions, e.g. Harvey whole-body model):
16
+ - Single precision (pdlp_precision=0) does not converge at tolerances
17
+ tighter than 1e-4 on these models; double precision is required.
18
+ - Mixed precision (pdlp_precision=2) hits the FP32 precision wall.
19
+ - tol=1e-8 + double precision → zero stoichiometric violations at max residual 1e-6.
20
+ - per_constraint_residual=1 (max-norm convergence) further reduces violations
21
+ vs tightening tolerances alone.
22
+ - PaPILO presolve adds postsolve reconstruction error on well-conditioned models;
23
+ the default PSLP presolve is correct at this scale.
24
+
25
+ Large models (> 100K reactions, e.g. personalised microbiome whole-body models):
26
+ - cuOpt's default PSLP presolve incorrectly reports Infeasible on models with
27
+ extreme stoichiometric coefficient ranges ([1e-6, 2e5]); PaPILO is required.
28
+ - per_constraint_residual=1 reduces violations by ~44% and max residual by ~100×.
29
+ - Tight tolerances do not improve accuracy: the bottleneck is a hardcoded feastol
30
+ in cuOpt's PaPILO integration, not PDLP convergence.
31
+ """
32
+
33
+ # Models at or below this reaction count use the Harvey-tuned settings.
34
+ _SMALL_MODEL_THRESHOLD = 100_000
35
+
36
+
37
+ def default_settings(n_vars: int, time_limit: float = 60.0) -> dict:
38
+ """
39
+ Return the recommended cuOpt parameter dict for a model of the given size.
40
+
41
+ Parameters
42
+ ----------
43
+ n_vars:
44
+ Number of variables (reactions) in the LP.
45
+ time_limit:
46
+ Wall-clock time limit in seconds passed to cuOpt.
47
+
48
+ Returns
49
+ -------
50
+ dict
51
+ Parameter names and values ready to pass to ``SolverSettings.set_parameter``.
52
+ """
53
+ if n_vars <= _SMALL_MODEL_THRESHOLD:
54
+ # Harvey-scale: double precision + tight tolerances + max-norm convergence.
55
+ # Single precision (pdlp_precision=0) does not converge on these models at
56
+ # tolerances tighter than 1e-4, and Mixed (2) hits the FP32 wall. PaPILO is
57
+ # not used — PSLP is correct on well-conditioned models and PaPILO postsolve
58
+ # adds reconstruction error.
59
+ return {
60
+ "method": 1, # PDLP
61
+ "pdlp_precision": 1, # Double (cuOpt enum 1) — required
62
+ "absolute_primal_tolerance": 1e-8,
63
+ "relative_primal_tolerance": 1e-8,
64
+ "absolute_dual_tolerance": 1e-8,
65
+ "relative_dual_tolerance": 1e-8,
66
+ "per_constraint_residual": 1, # max-norm → better postsolve quality
67
+ "time_limit": time_limit,
68
+ }
69
+ else:
70
+ # Microbiome-scale: PaPILO presolve required + max-norm convergence.
71
+ # Tight tolerances are NOT set — the accuracy floor is PaPILO's hardcoded
72
+ # feastol=1e-5, independent of PDLP tolerance.
73
+ # pdlp_precision is deliberately left unset here: cuOpt's Default (-1)
74
+ # resolves to Double, so this is double precision too, not mixed.
75
+ return {
76
+ "method": 1, # PDLP
77
+ "presolve": 1, # PaPILO — avoids PSLP false-infeasible bug
78
+ "per_constraint_residual": 1, # max-norm convergence
79
+ "time_limit": time_limit,
80
+ }
gpugem/_deps.py ADDED
@@ -0,0 +1,271 @@
1
+ """External-dependency registry and guided errors.
2
+
3
+ Every external package or executable gpuGEM, its benchmarks and its examples rely on is listed in
4
+ :data:`REGISTRY`. :func:`require` / :func:`require_tool` turn "missing", "fails to load", "too
5
+ old" and "no usable GPU" into one catchable :class:`DependencyError` that says what is wrong, why
6
+ it is needed and how to fix it (specs/016-missing-dependency-errors/contracts/).
7
+
8
+ This module is standard-library only so it can be imported when numpy/scipy/cuOpt are absent.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import glob
14
+ import importlib
15
+ import importlib.metadata
16
+ import re
17
+ import shutil
18
+ import subprocess
19
+ from dataclasses import dataclass
20
+ from typing import Optional
21
+
22
+ KINDS = ("not_installed", "broken", "version", "no_gpu", "license", "tool_missing", "network")
23
+
24
+ _CUOPT_DOCS = "https://docs.nvidia.com/cuopt/user-guide/latest/introduction.html"
25
+
26
+ _STATE_TEXT = {
27
+ "not_installed": "is not installed",
28
+ "broken": "is installed but fails to load",
29
+ "version": "is not a supported version",
30
+ "no_gpu": "cannot be used: a GPU or driver problem is suspected",
31
+ "license": "is installed but its licence is missing or invalid",
32
+ "tool_missing": "was not found on PATH",
33
+ "network": "could not be reached",
34
+ }
35
+
36
+
37
+ class DependencyError(RuntimeError):
38
+ """A required dependency is missing, broken, too old, or has no usable GPU/licence/network.
39
+
40
+ Attributes
41
+ ----------
42
+ dependency : str
43
+ Registry name, e.g. ``"cuopt"``.
44
+ kind : str
45
+ One of ``not_installed, broken, version, no_gpu, license, tool_missing, network``.
46
+ detected, required : str or None
47
+ Installed and minimum versions (``kind == "version"``).
48
+ remedy : str
49
+ What the user should do.
50
+
51
+ The original exception, when there is one, is available as ``__cause__``.
52
+ """
53
+
54
+ def __init__(self, dependency: str, kind: str, remedy: str, *, purpose: Optional[str] = None,
55
+ detected: Optional[str] = None, required: Optional[str] = None,
56
+ detail: Optional[str] = None, problem: Optional[str] = None):
57
+ if kind not in KINDS:
58
+ raise ValueError("unknown DependencyError kind %r" % (kind,))
59
+ self.dependency = dependency
60
+ self.kind = kind
61
+ self.remedy = remedy
62
+ self.purpose = purpose
63
+ self.detected = detected
64
+ self.required = required
65
+ self.detail = detail
66
+ self.problem = problem
67
+ state = problem or _STATE_TEXT[kind]
68
+ if kind == "version" and not problem:
69
+ state = "is too old (installed %s, required >= %s)" % (detected, required)
70
+ lines = ["gpuGEM cannot run: %s %s." % (dependency, state)]
71
+ if purpose:
72
+ lines.append(" Needed for: %s" % purpose)
73
+ lines.append(" Fix: %s" % remedy)
74
+ if detail:
75
+ lines.append(" (details: %s)" % detail)
76
+ super().__init__("\n".join(lines))
77
+
78
+ def __reduce__(self):
79
+ return (_rebuild, (self.dependency, self.kind, self.remedy, self.purpose,
80
+ self.detected, self.required, self.detail, self.problem))
81
+
82
+
83
+ def _rebuild(dependency, kind, remedy, purpose, detected, required, detail, problem):
84
+ return DependencyError(dependency, kind, remedy, purpose=purpose, detected=detected,
85
+ required=required, detail=detail, problem=problem)
86
+
87
+
88
+ @dataclass(frozen=True)
89
+ class DependencySpec:
90
+ name: str # import name, or executable name for tools
91
+ role: str # required | optional | benchmark | tool | dev
92
+ purpose: str
93
+ install: str
94
+ distributions: tuple = () # pip distribution names to read the version from
95
+ min_version: Optional[str] = None
96
+ extra: Optional[str] = None
97
+ tool: bool = False
98
+
99
+
100
+ # Minimum versions are mirrored in pyproject.toml; tests/test_dependency_inventory.py fails if
101
+ # the two drift apart.
102
+ MIN_VERSIONS = {
103
+ "numpy": "1.24",
104
+ "scipy": "1.10",
105
+ "cuopt": "26.6.0",
106
+ "cobra": "0.29",
107
+ }
108
+
109
+ _SPECS = [
110
+ DependencySpec(
111
+ "numpy", "required", "array maths used by every gpuGEM function",
112
+ 'pip install "numpy>=%s"' % MIN_VERSIONS["numpy"], ("numpy",), MIN_VERSIONS["numpy"]),
113
+ DependencySpec(
114
+ "scipy", "required", "sparse matrices and .mat file reading",
115
+ 'pip install "scipy>=%s"' % MIN_VERSIONS["scipy"], ("scipy",), MIN_VERSIONS["scipy"]),
116
+ DependencySpec(
117
+ "cuopt", "required", "the GPU linear-programming solver that gpuGEM wraps",
118
+ 'pip install "cuopt-cu12>=%s" --extra-index-url https://pypi.nvidia.com '
119
+ '(other CUDA versions: see %s)' % (MIN_VERSIONS["cuopt"], _CUOPT_DOCS),
120
+ ("cuopt-cu12", "cuopt-cu13", "cuopt"), MIN_VERSIONS["cuopt"]),
121
+ DependencySpec(
122
+ "cobra", "required", "reading COBRApy models (gpugem.solve_cobra, loaders.from_cobra)",
123
+ 'pip install "cobra>=%s"' % MIN_VERSIONS["cobra"],
124
+ ("cobra",), MIN_VERSIONS["cobra"]),
125
+ DependencySpec(
126
+ "gurobipy", "benchmark", "the Gurobi baseline in the benchmarks (needs a Gurobi licence)",
127
+ "pip install gurobipy (a licence is required to solve large models: "
128
+ "https://www.gurobi.com/academia/academic-program-and-licenses/)", ("gurobipy",)),
129
+ DependencySpec(
130
+ "highspy", "benchmark", "the HiGHS CPU baseline in the benchmarks",
131
+ "pip install highspy", ("highspy",)),
132
+ DependencySpec(
133
+ "pandas", "benchmark", "result tables in the benchmark aggregation and figure scripts",
134
+ "pip install pandas", ("pandas",)),
135
+ DependencySpec(
136
+ "matplotlib", "benchmark", "drawing the benchmark figures",
137
+ "pip install matplotlib", ("matplotlib",)),
138
+ DependencySpec(
139
+ "pytest", "dev", "running the test suite",
140
+ 'pip install -e ".[dev]"', ("pytest",)),
141
+ DependencySpec(
142
+ "nvidia-smi", "tool", "reading the GPU name for benchmark metadata and diagnosing "
143
+ "GPU problems", "install the NVIDIA driver (https://www.nvidia.com/drivers); "
144
+ "nvidia-smi ships with it", tool=True),
145
+ ]
146
+
147
+ REGISTRY = {s.name: s for s in _SPECS}
148
+
149
+ _version_ok = set()
150
+
151
+
152
+ def _spec(name):
153
+ try:
154
+ return REGISTRY[name]
155
+ except KeyError:
156
+ raise KeyError("%r is not in gpugem._deps.REGISTRY; add it there" % (name,)) from None
157
+
158
+
159
+ def parse_version(text):
160
+ """Leading integer components of a version string: ``"26.06.00rc1"`` -> ``(26, 6, 0)``."""
161
+ parts = []
162
+ for piece in str(text).split("."):
163
+ m = re.match(r"\d+", piece)
164
+ if not m:
165
+ break
166
+ parts.append(int(m.group()))
167
+ if m.end() != len(piece):
168
+ break
169
+ return tuple(parts)
170
+
171
+
172
+ def _installed_version(spec, module):
173
+ for dist in spec.distributions:
174
+ try:
175
+ return importlib.metadata.version(dist)
176
+ except importlib.metadata.PackageNotFoundError:
177
+ continue
178
+ v = getattr(module, "__version__", None)
179
+ return str(v) if v is not None else None
180
+
181
+
182
+ def gpu_evidence():
183
+ """Return ``(problem, detail)``: whether there is evidence the NVIDIA GPU/driver is unusable.
184
+
185
+ ``problem`` is True only on positive evidence (``nvidia-smi`` fails or lists no GPU, or it is
186
+ absent and no ``/dev/nvidia<N>`` device exists). An absent ``nvidia-smi`` alone is not proof
187
+ (containers can have a working GPU without it) so with a device node present this returns False.
188
+ """
189
+ exe = shutil.which("nvidia-smi")
190
+ if exe is None:
191
+ if glob.glob("/dev/nvidia[0-9]*"):
192
+ return False, "nvidia-smi not found, but /dev/nvidia* exists"
193
+ return True, "nvidia-smi not found and no /dev/nvidia* device exists"
194
+ try:
195
+ out = subprocess.run([exe, "-L"], capture_output=True, text=True, timeout=30)
196
+ except (OSError, subprocess.SubprocessError) as exc:
197
+ return True, "nvidia-smi could not be run: %s" % exc
198
+ if out.returncode != 0:
199
+ msg = (out.stderr or out.stdout).strip().splitlines()
200
+ return True, "nvidia-smi exited %d: %s" % (out.returncode, msg[0] if msg else "")
201
+ if "GPU" not in out.stdout:
202
+ return True, "nvidia-smi lists no GPU"
203
+ return False, out.stdout.strip().splitlines()[0]
204
+
205
+
206
+ def _no_gpu_error(cause_text, evidence_detail):
207
+ spec = REGISTRY["cuopt"]
208
+ return DependencyError(
209
+ "cuopt", "no_gpu",
210
+ "check that an NVIDIA GPU is visible (run `nvidia-smi`), that the driver supports the CUDA "
211
+ "version of your cuopt build, and that you are not inside a container without GPU access",
212
+ purpose=spec.purpose,
213
+ detail="%s; %s" % (evidence_detail, cause_text))
214
+
215
+
216
+ def require(name):
217
+ """Import and return the registered module ``name``, or raise :class:`DependencyError`."""
218
+ spec = _spec(name)
219
+ if spec.tool:
220
+ return require_tool(name)
221
+ try:
222
+ module = importlib.import_module(name)
223
+ except ModuleNotFoundError as exc:
224
+ if (exc.name or "").split(".")[0] != name:
225
+ # the package is there but one of ITS imports is not
226
+ raise _broken(spec, exc) from exc
227
+ raise DependencyError(name, "not_installed", spec.install, purpose=spec.purpose) from exc
228
+ except Exception as exc: # ImportError, OSError (shared library), RuntimeError, ...
229
+ raise _broken(spec, exc) from exc
230
+
231
+ if spec.min_version and name not in _version_ok:
232
+ detected = _installed_version(spec, module)
233
+ if detected is not None and parse_version(detected) < parse_version(spec.min_version):
234
+ raise DependencyError(
235
+ name, "version", spec.install.replace("pip install", "pip install --upgrade", 1),
236
+ purpose=spec.purpose, detected=detected, required=spec.min_version)
237
+ _version_ok.add(name)
238
+ return module
239
+
240
+
241
+ def _broken(spec, exc):
242
+ detail = "%s: %s" % (type(exc).__name__, exc)
243
+ if spec.name == "cuopt":
244
+ problem, evidence = gpu_evidence()
245
+ if problem:
246
+ return _no_gpu_error(detail, evidence)
247
+ return DependencyError(
248
+ spec.name, "broken",
249
+ "reinstall it (%s); if the problem persists the installed build does not match this "
250
+ "machine (Python, CUDA runtime or driver version)" % spec.install.split(" (")[0],
251
+ purpose=spec.purpose, detail=detail)
252
+
253
+
254
+ def require_tool(name):
255
+ """Return the path of registered executable ``name``, or raise :class:`DependencyError`."""
256
+ spec = _spec(name)
257
+ path = shutil.which(name)
258
+ if path is None:
259
+ raise DependencyError(name, "tool_missing", spec.install, purpose=spec.purpose)
260
+ return path
261
+
262
+
263
+ def diagnose_solver_failure(exc):
264
+ """Re-raise ``exc`` as a ``no_gpu`` :class:`DependencyError` if the GPU is demonstrably unusable.
265
+
266
+ Called when cuOpt raises while building or solving a model. If there is no positive evidence
267
+ of a GPU/driver problem this returns, and the caller re-raises the original exception.
268
+ """
269
+ problem, evidence = gpu_evidence()
270
+ if problem:
271
+ raise _no_gpu_error("%s: %s" % (type(exc).__name__, exc), evidence) from exc