perfusio 0.1.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 (63) hide show
  1. perfusio/__init__.py +38 -0
  2. perfusio/_typing.py +152 -0
  3. perfusio/bed/__init__.py +37 -0
  4. perfusio/bed/acquisitions.py +219 -0
  5. perfusio/bed/objectives.py +238 -0
  6. perfusio/bed/pareto.py +119 -0
  7. perfusio/bed/policies.py +178 -0
  8. perfusio/bed/search.py +101 -0
  9. perfusio/chemistry/__init__.py +20 -0
  10. perfusio/chemistry/balances.py +430 -0
  11. perfusio/chemistry/species.py +312 -0
  12. perfusio/chemistry/volumes.py +178 -0
  13. perfusio/cli.py +407 -0
  14. perfusio/config.py +453 -0
  15. perfusio/connectors/__init__.py +27 -0
  16. perfusio/connectors/ambr250_emulator.py +117 -0
  17. perfusio/connectors/base.py +59 -0
  18. perfusio/connectors/filesystem.py +157 -0
  19. perfusio/connectors/opcua_client.py +203 -0
  20. perfusio/connectors/sql_store.py +296 -0
  21. perfusio/embedding/__init__.py +27 -0
  22. perfusio/embedding/clones.py +168 -0
  23. perfusio/embedding/transfer.py +199 -0
  24. perfusio/gp/__init__.py +25 -0
  25. perfusio/gp/ensemble.py +202 -0
  26. perfusio/gp/exact_gp.py +137 -0
  27. perfusio/gp/kernels.py +131 -0
  28. perfusio/gp/means.py +167 -0
  29. perfusio/gp/stepwise.py +242 -0
  30. perfusio/hybrid/__init__.py +26 -0
  31. perfusio/hybrid/forecast.py +85 -0
  32. perfusio/hybrid/model.py +183 -0
  33. perfusio/hybrid/train.py +160 -0
  34. perfusio/mechanistic/__init__.py +24 -0
  35. perfusio/mechanistic/integrators.py +140 -0
  36. perfusio/mechanistic/kinetics.py +460 -0
  37. perfusio/mechanistic/models.py +164 -0
  38. perfusio/metrics/__init__.py +26 -0
  39. perfusio/metrics/coverage.py +122 -0
  40. perfusio/metrics/multiobjective.py +112 -0
  41. perfusio/metrics/rrmse.py +100 -0
  42. perfusio/simulator/__init__.py +25 -0
  43. perfusio/simulator/cho_perfusion.py +256 -0
  44. perfusio/simulator/doe.py +179 -0
  45. perfusio/simulator/noise.py +107 -0
  46. perfusio/states.py +445 -0
  47. perfusio/twin/__init__.py +24 -0
  48. perfusio/twin/audit.py +134 -0
  49. perfusio/twin/digital_twin.py +302 -0
  50. perfusio/twin/notifications.py +205 -0
  51. perfusio/twin/scheduler.py +94 -0
  52. perfusio/viz/__init__.py +14 -0
  53. perfusio/viz/dashboard/__init__.py +1 -0
  54. perfusio/viz/dashboard/app.py +191 -0
  55. perfusio/viz/interactive.py +278 -0
  56. perfusio/viz/pareto_explorer.py +8 -0
  57. perfusio/viz/static.py +337 -0
  58. perfusio/viz/theme.py +101 -0
  59. perfusio-0.1.0.dist-info/METADATA +221 -0
  60. perfusio-0.1.0.dist-info/RECORD +63 -0
  61. perfusio-0.1.0.dist-info/WHEEL +4 -0
  62. perfusio-0.1.0.dist-info/entry_points.txt +2 -0
  63. perfusio-0.1.0.dist-info/licenses/LICENSE +170 -0
perfusio/__init__.py ADDED
@@ -0,0 +1,38 @@
1
+ """perfusio — open reference implementation of self-driving perfusion bioprocess development.
2
+
3
+ ``perfusio`` implements the methodology of Gadiyar et al. (2026) and Hutter et al. (2021),
4
+ providing step-wise Gaussian-process hybrid models, entity-embedding transfer learning,
5
+ Bayesian Experimental Design, and an online-retraining digital twin for CHO perfusion
6
+ bioreactors. It is intended as an open, citable complement to commercial platforms.
7
+
8
+ References
9
+ ----------
10
+ .. [Gadiyar2026] Gadiyar, C. J., Müller, C., Vuillemin, T., Bielser, J.-M., Souquet, J.,
11
+ Fagnani, A., Sokolov, M., von Stosch, M., Feidl, F., Butté, A., &
12
+ Cruz Bournazou, M. N. (2026). Self-Driving Development of Perfusion Processes
13
+ for Monoclonal Antibody Production. *Biotechnology and Bioengineering*, 123(2),
14
+ 391–405. https://doi.org/10.1002/bit.70093
15
+
16
+ .. [Hutter2021] Hutter, S., von Stosch, M., Cruz Bournazou, M. N., & Butté, A. (2021).
17
+ Knowledge transfer across cell lines using hybrid Gaussian process models with
18
+ entity embedding vectors. *Biotechnology and Bioengineering*, 118(12), 4710–4725.
19
+ https://doi.org/10.1002/bit.27907
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ __version__ = "0.1.0"
25
+ __author__ = "perfusio Contributors"
26
+ __license__ = "Apache-2.0"
27
+
28
+ from perfusio.config import DesignSpace, RunConfig
29
+ from perfusio.states import State, StateBatch, Trajectory
30
+
31
+ __all__ = [
32
+ "DesignSpace",
33
+ "RunConfig",
34
+ "State",
35
+ "StateBatch",
36
+ "Trajectory",
37
+ "__version__",
38
+ ]
perfusio/_typing.py ADDED
@@ -0,0 +1,152 @@
1
+ """Custom types, TypeAliases, and Protocols for ``perfusio``.
2
+
3
+ This module is the single place where ``Any`` may appear (in Protocol stubs
4
+ and adapter contracts). All other modules must import from here rather than
5
+ using ``typing.Any`` directly.
6
+
7
+ Notes
8
+ -----
9
+ Only import from this module using ``TYPE_CHECKING`` guards in runtime code
10
+ to avoid circular imports and keep start-up cost low.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from collections.abc import Callable, Mapping
16
+ from pathlib import Path
17
+ from typing import Any, Protocol, TypeAlias, runtime_checkable
18
+
19
+ import numpy as np
20
+ import torch
21
+ from torch import Tensor
22
+
23
+ # ---------------------------------------------------------------------------
24
+ # Scalar types
25
+ # ---------------------------------------------------------------------------
26
+
27
+ #: A Python float, int, or zero-dimensional torch tensor.
28
+ Numeric: TypeAlias = float | int | Tensor
29
+
30
+ #: A 1-D array of float32/float64 (numpy or torch).
31
+ Vector: TypeAlias = Tensor | np.ndarray[Any, np.dtype[np.floating[Any]]]
32
+
33
+ #: A 2-D array of float64.
34
+ Matrix: TypeAlias = Tensor | np.ndarray[Any, np.dtype[np.floating[Any]]]
35
+
36
+ #: A path-like object or string.
37
+ PathLike: TypeAlias = Path | str
38
+
39
+ # ---------------------------------------------------------------------------
40
+ # Shape annotations (documentation only — not enforced at runtime)
41
+ # ---------------------------------------------------------------------------
42
+ #: Batch dimension over reactors.
43
+ _B = int
44
+ #: Number of species / tasks.
45
+ _K = int
46
+ #: Time steps.
47
+ _T = int
48
+ #: State dimension.
49
+ _D = int
50
+ #: GP input dimension.
51
+ _Din = int
52
+
53
+ # ---------------------------------------------------------------------------
54
+ # Acquisition function type
55
+ # ---------------------------------------------------------------------------
56
+
57
+ #: A callable that maps (candidate_points: Tensor) -> Tensor (acquisition values).
58
+ AcquisitionFn: TypeAlias = Callable[[Tensor], Tensor]
59
+
60
+ # ---------------------------------------------------------------------------
61
+ # Protocols
62
+ # ---------------------------------------------------------------------------
63
+
64
+
65
+ @runtime_checkable
66
+ class BioreactorConnector(Protocol):
67
+ """Protocol that all bioreactor data connectors must satisfy.
68
+
69
+ Both the real OPC UA client and the ambr®250 emulator implement this
70
+ protocol so that the :class:`~perfusio.twin.digital_twin.DigitalTwin`
71
+ can be tested offline.
72
+ """
73
+
74
+ async def read_sample(self, day: int) -> Mapping[str, float]:
75
+ """Return the latest at-line sample for *day*.
76
+
77
+ Parameters
78
+ ----------
79
+ day:
80
+ Culture day index (1-based).
81
+
82
+ Returns
83
+ -------
84
+ Mapping[str, float]
85
+ Species name → measured value, e.g.
86
+ ``{"VCD": 12.3, "Via": 97.1, "Glc": 1.8}``.
87
+
88
+ Raises
89
+ ------
90
+ ConnectionError
91
+ If the connector is offline and cannot recover.
92
+ """
93
+ ...
94
+
95
+ async def write_setpoints(self, setpoints: Mapping[str, float]) -> None:
96
+ """Write control setpoints to the reactor controller.
97
+
98
+ Parameters
99
+ ----------
100
+ setpoints:
101
+ Control variable name → desired value.
102
+
103
+ Raises
104
+ ------
105
+ PermissionError
106
+ If the connector is in read-only mode
107
+ (``--allow-write`` flag not provided).
108
+ ConnectionError
109
+ If the write fails.
110
+ """
111
+ ...
112
+
113
+ async def is_alive(self) -> bool:
114
+ """Return ``True`` if the connection is healthy."""
115
+ ...
116
+
117
+
118
+ @runtime_checkable
119
+ class Fittable(Protocol):
120
+ """Any object with a ``fit`` method."""
121
+
122
+ def fit(self, *args: Any, **kwargs: Any) -> None:
123
+ """Fit the model in-place."""
124
+ ...
125
+
126
+
127
+ @runtime_checkable
128
+ class Predictable(Protocol):
129
+ """Any object with a ``predict`` method returning a Tensor."""
130
+
131
+ def predict(self, x: Tensor) -> Tensor:
132
+ """Return point predictions at *x*."""
133
+ ...
134
+
135
+
136
+ # ---------------------------------------------------------------------------
137
+ # Callback / hook types
138
+ # ---------------------------------------------------------------------------
139
+
140
+ #: A hook called after every digital-twin step with the step result.
141
+ StepCallback: TypeAlias = Callable[..., None]
142
+
143
+ #: Factory producing a fresh model instance (used by ensemble).
144
+ ModelFactory: TypeAlias = Callable[[], Any]
145
+
146
+ # ---------------------------------------------------------------------------
147
+ # Device / dtype helpers
148
+ # ---------------------------------------------------------------------------
149
+
150
+ #: Default torch dtype — double precision everywhere.
151
+ DEFAULT_DTYPE: torch.dtype = torch.float64
152
+ DEFAULT_DEVICE: torch.device = torch.device("cpu")
@@ -0,0 +1,37 @@
1
+ """Bayesian Experimental Design (BED) sub-package for ``perfusio``.
2
+
3
+ Implements the full BED decision loop described in Gadiyar et al. (2026) §3.2,
4
+ including:
5
+
6
+ - Objective function values (OFV) — target-tracking and multi-objective.
7
+ - All 11 BoTorch acquisition functions (PI, EI, LogEI, UCB, qEI, qLogEI,
8
+ qUCB, qEHVI, qNEHVI, qNParEGO) plus constrained variants.
9
+ - Acquisition optimisation over the design space.
10
+ - Pareto front computation and hypervolume indicator.
11
+ - High-level :class:`BEDPolicy` daily decision loop.
12
+
13
+ Public API
14
+ ----------
15
+ - :class:`~perfusio.bed.objectives.TargetTrackingOFV`
16
+ - :class:`~perfusio.bed.objectives.MultiObjectiveOFV`
17
+ - :func:`~perfusio.bed.acquisitions.build_acquisition`
18
+ - :func:`~perfusio.bed.search.optimise_acquisition`
19
+ - :func:`~perfusio.bed.pareto.compute_pareto_front`
20
+ - :class:`~perfusio.bed.policies.BEDPolicy`
21
+ """
22
+
23
+ from perfusio.bed.acquisitions import build_acquisition
24
+ from perfusio.bed.objectives import MultiObjectiveOFV, TargetTrackingOFV
25
+ from perfusio.bed.pareto import compute_pareto_front, hypervolume
26
+ from perfusio.bed.policies import BEDPolicy
27
+ from perfusio.bed.search import optimise_acquisition
28
+
29
+ __all__ = [
30
+ "BEDPolicy",
31
+ "MultiObjectiveOFV",
32
+ "TargetTrackingOFV",
33
+ "build_acquisition",
34
+ "compute_pareto_front",
35
+ "hypervolume",
36
+ "optimise_acquisition",
37
+ ]
@@ -0,0 +1,219 @@
1
+ """BoTorch acquisition function factory for Bayesian Experimental Design.
2
+
3
+ Provides :func:`build_acquisition`, a single entry-point that constructs any of
4
+ the 11 acquisition functions supported by ``perfusio``:
5
+
6
+ Single-objective (analytic):
7
+ ``PI``, ``EI``, ``LogEI``, ``UCB``
8
+
9
+ Single-objective (Monte Carlo):
10
+ ``qEI``, ``qLogEI``, ``qUCB``
11
+
12
+ Multi-objective (Monte Carlo):
13
+ ``qEHVI``, ``qNEHVI``, ``qNParEGO``
14
+
15
+ Constrained variants are automatically applied when ``constraints`` are passed.
16
+
17
+ References
18
+ ----------
19
+ .. [Balandat2020] Balandat, M., et al. (2020). BoTorch: A Framework for
20
+ Efficient Monte-Carlo Bayesian Optimization. NeurIPS.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from collections.abc import Callable
26
+ from typing import Any
27
+
28
+ import botorch.acquisition as ba
29
+ import botorch.acquisition.multi_objective as bamo
30
+ import torch
31
+ from botorch.acquisition.analytic import (
32
+ ExpectedImprovement,
33
+ LogExpectedImprovement,
34
+ ProbabilityOfImprovement,
35
+ UpperConfidenceBound,
36
+ )
37
+ from botorch.acquisition.monte_carlo import (
38
+ qExpectedImprovement,
39
+ qUpperConfidenceBound,
40
+ )
41
+ from botorch.acquisition.multi_objective.parego import qLogNParEGO
42
+ from botorch.models.model import Model
43
+ from torch import Tensor
44
+
45
+ # Supported acquisition names
46
+ _SINGLE_OBJ_ANALYTIC = {"PI", "EI", "LogEI", "UCB"}
47
+ _SINGLE_OBJ_MC = {"qEI", "qLogEI", "qUCB"}
48
+ _MULTI_OBJ_MC = {"qEHVI", "qNEHVI", "qNParEGO"}
49
+ ALL_ACQUISITIONS = _SINGLE_OBJ_ANALYTIC | _SINGLE_OBJ_MC | _MULTI_OBJ_MC
50
+
51
+
52
+ def build_acquisition(
53
+ name: str,
54
+ model: Model,
55
+ best_f: float | None = None,
56
+ beta: float = 0.2,
57
+ ref_point: list[float] | None = None,
58
+ partitioning: Any = None,
59
+ sampler: Any = None,
60
+ constraints: list[Callable[[Tensor], Tensor]] | None = None,
61
+ **kwargs: Any,
62
+ ) -> ba.AcquisitionFunction:
63
+ """Construct a BoTorch acquisition function by name.
64
+
65
+ Parameters
66
+ ----------
67
+ name:
68
+ One of: ``PI``, ``EI``, ``LogEI``, ``UCB``,
69
+ ``qEI``, ``qLogEI``, ``qUCB``, ``qEHVI``, ``qNEHVI``, ``qNParEGO``.
70
+ model:
71
+ Fitted BoTorch / GPyTorch surrogate model.
72
+ best_f:
73
+ Incumbent best observed value. Required for ``PI``, ``EI``,
74
+ ``LogEI``, ``qEI``, ``qLogEI``.
75
+ beta:
76
+ UCB exploration parameter. Required for ``UCB``, ``qUCB``.
77
+ ref_point:
78
+ Reference point for hypervolume-based acquisitions (``qEHVI``,
79
+ ``qNEHVI``). Should be strictly dominated by all feasible points.
80
+ partitioning:
81
+ Pre-computed box decomposition (``NondominatedPartitioning``).
82
+ Required for ``qEHVI``.
83
+ sampler:
84
+ MC sampler (``IIDNormalSampler`` or ``SobolQMCNormalSampler``).
85
+ Defaults to ``SobolQMCNormalSampler(512)`` for MC acquisitions.
86
+ constraints:
87
+ Optional list of constraint callables ``c(X) <= 0`` to enforce.
88
+ Applied as soft-constraint penalties where available.
89
+ **kwargs:
90
+ Additional keyword arguments forwarded to the acquisition constructor.
91
+
92
+ Returns
93
+ -------
94
+ botorch.acquisition.AcquisitionFunction
95
+
96
+ Raises
97
+ ------
98
+ ValueError
99
+ If *name* is not a recognised acquisition type.
100
+ """
101
+ if name not in ALL_ACQUISITIONS:
102
+ msg = f"Unknown acquisition '{name}'. Choose one of: {sorted(ALL_ACQUISITIONS)}"
103
+ raise ValueError(msg)
104
+
105
+ if sampler is None and name in (_SINGLE_OBJ_MC | _MULTI_OBJ_MC):
106
+ from botorch.sampling.normal import SobolQMCNormalSampler
107
+
108
+ sampler = SobolQMCNormalSampler(sample_shape=torch.Size([512]))
109
+
110
+ # ── Analytic single-objective ──────────────────────────────────────────
111
+ if name == "PI":
112
+ _require(best_f, "PI", "best_f")
113
+ assert best_f is not None
114
+ return ProbabilityOfImprovement(model=model, best_f=best_f, **kwargs)
115
+
116
+ if name == "EI":
117
+ _require(best_f, "EI", "best_f")
118
+ assert best_f is not None
119
+ return ExpectedImprovement(model=model, best_f=best_f, **kwargs)
120
+
121
+ if name == "LogEI":
122
+ _require(best_f, "LogEI", "best_f")
123
+ assert best_f is not None
124
+ return LogExpectedImprovement(model=model, best_f=best_f, **kwargs)
125
+
126
+ if name == "UCB":
127
+ return UpperConfidenceBound(model=model, beta=beta, **kwargs)
128
+
129
+ # ── MC single-objective ────────────────────────────────────────────────
130
+ if name == "qEI":
131
+ _require(best_f, "qEI", "best_f")
132
+ assert best_f is not None
133
+ if constraints:
134
+ return qExpectedImprovement(
135
+ model=model, best_f=best_f, sampler=sampler, constraints=constraints, **kwargs
136
+ )
137
+ return qExpectedImprovement(model=model, best_f=best_f, sampler=sampler, **kwargs)
138
+
139
+ if name == "qLogEI":
140
+ _require(best_f, "qLogEI", "best_f")
141
+ assert best_f is not None
142
+ from botorch.acquisition.logei import qLogExpectedImprovement
143
+
144
+ return qLogExpectedImprovement(model=model, best_f=best_f, sampler=sampler, **kwargs)
145
+
146
+ if name == "qUCB":
147
+ return qUpperConfidenceBound(model=model, beta=beta, sampler=sampler, **kwargs)
148
+
149
+ # ── Multi-objective MC ─────────────────────────────────────────────────
150
+ if name == "qEHVI":
151
+ _require(ref_point, "qEHVI", "ref_point")
152
+ _require(partitioning, "qEHVI", "partitioning")
153
+ assert ref_point is not None
154
+ return bamo.qExpectedHypervolumeImprovement(
155
+ model=model,
156
+ ref_point=ref_point,
157
+ partitioning=partitioning,
158
+ sampler=sampler,
159
+ constraints=constraints,
160
+ **kwargs,
161
+ )
162
+
163
+ if name == "qNEHVI":
164
+ _require(ref_point, "qNEHVI", "ref_point")
165
+ assert ref_point is not None
166
+ # X_baseline defaults to the model's training inputs when not supplied
167
+ _x_bl = kwargs.pop(
168
+ "X_baseline",
169
+ model.train_inputs[0] # pyright: ignore[reportIndexIssue]
170
+ if hasattr(model, "train_inputs") and model.train_inputs
171
+ else None, # type: ignore[attr-defined]
172
+ )
173
+ if _x_bl is None:
174
+ msg = "Acquisition 'qNEHVI' requires 'X_baseline' or a fitted model with train_inputs."
175
+ raise ValueError(msg)
176
+ # Strip leading batch dim produced by batched multi-output GPs
177
+ if isinstance(_x_bl, Tensor) and _x_bl.dim() == 3:
178
+ _x_bl = _x_bl[0]
179
+ return bamo.qNoisyExpectedHypervolumeImprovement(
180
+ model=model,
181
+ ref_point=ref_point,
182
+ X_baseline=_x_bl,
183
+ sampler=sampler,
184
+ constraints=constraints,
185
+ **kwargs,
186
+ )
187
+
188
+ if name == "qNParEGO":
189
+ _x_bl = kwargs.pop(
190
+ "X_baseline",
191
+ model.train_inputs[0] # pyright: ignore[reportIndexIssue]
192
+ if hasattr(model, "train_inputs") and model.train_inputs
193
+ else None, # type: ignore[attr-defined]
194
+ )
195
+ if _x_bl is None:
196
+ msg = (
197
+ "Acquisition 'qNParEGO' requires 'X_baseline' or a fitted model with train_inputs."
198
+ )
199
+ raise ValueError(msg)
200
+ # Strip leading batch dim produced by batched multi-output GPs
201
+ if isinstance(_x_bl, Tensor) and _x_bl.dim() == 3:
202
+ _x_bl = _x_bl[0]
203
+ return qLogNParEGO(
204
+ model=model,
205
+ X_baseline=_x_bl,
206
+ sampler=sampler,
207
+ constraints=constraints,
208
+ **kwargs,
209
+ )
210
+
211
+ # Should never reach here — kept for static analysis
212
+ msg = f"Unhandled acquisition name: {name}"
213
+ raise AssertionError(msg)
214
+
215
+
216
+ def _require(value: object | None, acq_name: str, param_name: str) -> None:
217
+ if value is None:
218
+ msg = f"Acquisition '{acq_name}' requires '{param_name}' to be provided."
219
+ raise ValueError(msg)
@@ -0,0 +1,238 @@
1
+ """Objective function values (OFVs) for Bayesian Experimental Design.
2
+
3
+ Implements Gadiyar et al. (2026) Eq. 7:
4
+
5
+ .. math::
6
+ \\text{OFV}(\\mathbf{u}) =
7
+ \\sum_{j=1}^{3}
8
+ \\left( \\hat{y}_{t+j}(\\mathbf{u}) - y_{\\text{target}} \\right)^2
9
+
10
+ where :math:`\\hat{y}_{t+j}` is the *j*-step-ahead hybrid model prediction
11
+ (50th-percentile / mean) for the chosen objective species (e.g. VCD, Titer,
12
+ viability).
13
+
14
+ The OFV is used to evaluate candidate control setpoints :math:`\\mathbf{u}`
15
+ proposed by the acquisition function.
16
+
17
+ References
18
+ ----------
19
+ .. [Gadiyar2026] Gadiyar et al. (2026), §3.2, Eq. (7).
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from dataclasses import dataclass
25
+ from typing import TYPE_CHECKING
26
+
27
+ import torch
28
+ from torch import Tensor
29
+
30
+ if TYPE_CHECKING:
31
+ from perfusio.hybrid.model import HybridStateSpaceModel
32
+
33
+
34
+ @dataclass
35
+ class TargetSpec:
36
+ """Specification for a single tracked species target."""
37
+
38
+ species_index: int # column index in the state tensor
39
+ target: float # target value
40
+ weight: float = 1.0 # relative weight in the OFV sum
41
+
42
+
43
+ class TargetTrackingOFV:
44
+ """3-step-ahead squared-error OFV from Gadiyar et al. (2026) Eq. 7.
45
+
46
+ Parameters
47
+ ----------
48
+ hybrid:
49
+ Fitted hybrid model for forecasting.
50
+ targets:
51
+ List of :class:`TargetSpec` objects — one per tracked species.
52
+ horizon:
53
+ Prediction horizon in days. Default 3 (Gadiyar §3.2).
54
+ n_samples:
55
+ MC rollout samples. Default 100 (fast for BED inner loop).
56
+ seed:
57
+ Fixed random seed for reproducible OFV evaluations.
58
+
59
+ Examples
60
+ --------
61
+ >>> from perfusio.bed.objectives import TargetTrackingOFV, TargetSpec
62
+ >>> # assume `hybrid` is a fitted HybridStateSpaceModel
63
+ >>> ofv = TargetTrackingOFV(
64
+ ... hybrid=hybrid,
65
+ ... targets=[TargetSpec(0, target=30.0, weight=1.0)], # VCD → 30e6 cells/mL
66
+ ... )
67
+ >>> # ofv.evaluate(c0, u_candidate) # returns scalar Tensor
68
+ """
69
+
70
+ def __init__(
71
+ self,
72
+ targets: list[TargetSpec],
73
+ hybrid: HybridStateSpaceModel | None = None,
74
+ horizon: int = 3,
75
+ n_samples: int = 100,
76
+ seed: int = 42,
77
+ ) -> None:
78
+ self.hybrid = hybrid
79
+ self.targets = targets
80
+ self.horizon = horizon
81
+ self.n_samples = n_samples
82
+ self.seed = seed
83
+
84
+ def evaluate(self, c0: Tensor, u: Tensor) -> Tensor:
85
+ """Evaluate the OFV for a candidate control vector.
86
+
87
+ Parameters
88
+ ----------
89
+ c0:
90
+ Current state, shape ``(n_species,)``.
91
+ u:
92
+ Candidate control vector, shape ``(n_controls,)``.
93
+ Applied *constantly* over the horizon.
94
+
95
+ Returns
96
+ -------
97
+ Tensor
98
+ Scalar OFV value (lower = better).
99
+ """
100
+ from perfusio.hybrid.forecast import forecast_run
101
+
102
+ if self.hybrid is None:
103
+ msg = "TargetTrackingOFV.evaluate() requires a hybrid model; pass hybrid= at construction."
104
+ raise RuntimeError(msg)
105
+
106
+ u_seq = u.unsqueeze(0).expand(self.horizon, -1)
107
+ preds = forecast_run(
108
+ self.hybrid,
109
+ c0,
110
+ u_seq,
111
+ horizon=self.horizon,
112
+ n_samples=self.n_samples,
113
+ seed=self.seed,
114
+ )
115
+ # Use posterior mean for OFV (Gadiyar Eq. 7)
116
+ mean_traj = preds["mean"] # (horizon, n_species)
117
+
118
+ ofv = torch.tensor(0.0, dtype=mean_traj.dtype)
119
+ for spec in self.targets:
120
+ k = spec.species_index
121
+ for j in range(self.horizon):
122
+ ofv = ofv + spec.weight * (mean_traj[j, k] - spec.target) ** 2
123
+ return ofv
124
+
125
+ def score_trajectories(self, Y: Tensor) -> Tensor:
126
+ """Score a batch of pre-computed trajectories against targets.
127
+
128
+ Useful for evaluating acquisition function surrogates directly on GP
129
+ prediction samples without running the full hybrid model.
130
+
131
+ Parameters
132
+ ----------
133
+ Y:
134
+ Pre-computed trajectories, shape ``(B, horizon, n_species)``.
135
+
136
+ Returns
137
+ -------
138
+ Tensor
139
+ OFV values, shape ``(B,)`` — higher is better (negative SSE).
140
+ """
141
+ total = torch.zeros(Y.shape[0], dtype=Y.dtype, device=Y.device)
142
+ for spec in self.targets:
143
+ vals = Y[:, :, spec.species_index] # (B, horizon)
144
+ sse = ((vals - spec.target) ** 2).mean(dim=-1) # (B,)
145
+ total = total - spec.weight * sse
146
+ return total
147
+
148
+ def evaluate_batch(self, c0: Tensor, u_batch: Tensor) -> Tensor:
149
+ """Evaluate OFV for a batch of candidate controls.
150
+
151
+ Parameters
152
+ ----------
153
+ c0:
154
+ Current state, shape ``(n_species,)``.
155
+ u_batch:
156
+ Candidate controls, shape ``(B, n_controls)``.
157
+
158
+ Returns
159
+ -------
160
+ Tensor
161
+ OFV values, shape ``(B,)``.
162
+ """
163
+ return torch.stack([self.evaluate(c0, u_batch[i]) for i in range(u_batch.shape[0])])
164
+
165
+
166
+ class MultiObjectiveOFV:
167
+ """Vector-valued OFV for multi-objective BED.
168
+
169
+ Returns one OFV per objective (e.g. maximise titer AND minimise ammonium).
170
+ Used with multi-objective acquisition functions (qEHVI, qNEHVI, qNParEGO).
171
+
172
+ Parameters
173
+ ----------
174
+ hybrid:
175
+ Fitted hybrid model.
176
+ objectives:
177
+ List of ``(species_index, target, direction)`` tuples where
178
+ ``direction`` is ``+1`` for maximisation and ``-1`` for minimisation
179
+ (the sign is applied to the OFV before passing to botorch acqf).
180
+ horizon:
181
+ Prediction horizon.
182
+ n_samples:
183
+ MC rollout samples.
184
+ seed:
185
+ Random seed.
186
+ """
187
+
188
+ def __init__(
189
+ self,
190
+ hybrid: HybridStateSpaceModel,
191
+ objectives: list[tuple[int, float, float]],
192
+ horizon: int = 3,
193
+ n_samples: int = 100,
194
+ seed: int = 42,
195
+ ) -> None:
196
+ self.hybrid = hybrid
197
+ self.objectives = objectives
198
+ self.horizon = horizon
199
+ self.n_samples = n_samples
200
+ self.seed = seed
201
+
202
+ def evaluate(self, c0: Tensor, u: Tensor) -> Tensor:
203
+ """Return the multi-objective OFV vector for one candidate control.
204
+
205
+ Parameters
206
+ ----------
207
+ c0:
208
+ Current state, shape ``(n_species,)``.
209
+ u:
210
+ Candidate control, shape ``(n_controls,)``.
211
+
212
+ Returns
213
+ -------
214
+ Tensor
215
+ Shape ``(n_objectives,)``. Positive values = better for botorch
216
+ (all objectives are negated and then passed as maximisation).
217
+ """
218
+ from perfusio.hybrid.forecast import forecast_run
219
+
220
+ u_seq = u.unsqueeze(0).expand(self.horizon, -1)
221
+ preds = forecast_run(
222
+ self.hybrid,
223
+ c0,
224
+ u_seq,
225
+ horizon=self.horizon,
226
+ n_samples=self.n_samples,
227
+ seed=self.seed,
228
+ )
229
+ mean_traj = preds["mean"] # (horizon, n_species)
230
+
231
+ vals = []
232
+ for k, target, direction in self.objectives:
233
+ # Sum of squared deviations (lower = better)
234
+ sse = sum((mean_traj[j, k] - target) ** 2 for j in range(self.horizon))
235
+ # Negate so that botorch maximises (smaller SSE → larger objective)
236
+ vals.append(-float(direction) * sse) # type: ignore[arg-type]
237
+
238
+ return torch.tensor(vals, dtype=mean_traj.dtype)