phaseprobe 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.
- phaseprobe/__init__.py +30 -0
- phaseprobe/__main__.py +5 -0
- phaseprobe/adapters/__init__.py +4 -0
- phaseprobe/adapters/loader.py +51 -0
- phaseprobe/adapters/scipy.py +539 -0
- phaseprobe/api.py +64 -0
- phaseprobe/artifacts.py +124 -0
- phaseprobe/cli.py +242 -0
- phaseprobe/config.py +132 -0
- phaseprobe/data/__init__.py +1 -0
- phaseprobe/data/examples/__init__.py +1 -0
- phaseprobe/data/examples/logistic-negative.json +27 -0
- phaseprobe/data/examples/logistic-scan.json +27 -0
- phaseprobe/data/examples/lorenz-negative.json +30 -0
- phaseprobe/data/examples/lorenz-perturb.json +30 -0
- phaseprobe/data/examples/predator-prey-check.json +25 -0
- phaseprobe/data/examples/predator-prey-negative.json +25 -0
- phaseprobe/data/examples/toggle-negative.json +30 -0
- phaseprobe/data/examples/toggle-perturb.json +31 -0
- phaseprobe/engine.py +810 -0
- phaseprobe/errors.py +31 -0
- phaseprobe/examples/__init__.py +1 -0
- phaseprobe/examples/scipy_models.py +151 -0
- phaseprobe/generate.py +72 -0
- phaseprobe/models/__init__.py +36 -0
- phaseprobe/models/_common.py +56 -0
- phaseprobe/models/logistic.py +63 -0
- phaseprobe/models/lorenz.py +64 -0
- phaseprobe/models/predator_prey.py +86 -0
- phaseprobe/models/toggle.py +73 -0
- phaseprobe/replay.py +496 -0
- phaseprobe/reporting.py +173 -0
- phaseprobe/types.py +131 -0
- phaseprobe-0.2.0.dist-info/METADATA +275 -0
- phaseprobe-0.2.0.dist-info/RECORD +38 -0
- phaseprobe-0.2.0.dist-info/WHEEL +4 -0
- phaseprobe-0.2.0.dist-info/entry_points.txt +2 -0
- phaseprobe-0.2.0.dist-info/licenses/LICENSE +201 -0
phaseprobe/__init__.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""PhaseProbe: qualitative simulation evidence and executable regressions."""
|
|
2
|
+
|
|
3
|
+
from phaseprobe.api import (
|
|
4
|
+
run_invariant_check,
|
|
5
|
+
run_parameter_scan,
|
|
6
|
+
run_perturbation,
|
|
7
|
+
run_simulation,
|
|
8
|
+
)
|
|
9
|
+
from phaseprobe.types import (
|
|
10
|
+
InvariantResult,
|
|
11
|
+
ModelAdapter,
|
|
12
|
+
SimulationTrace,
|
|
13
|
+
TracePoint,
|
|
14
|
+
TrajectoryAdapter,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"InvariantResult",
|
|
19
|
+
"ModelAdapter",
|
|
20
|
+
"SimulationTrace",
|
|
21
|
+
"TracePoint",
|
|
22
|
+
"TrajectoryAdapter",
|
|
23
|
+
"__version__",
|
|
24
|
+
"run_invariant_check",
|
|
25
|
+
"run_parameter_scan",
|
|
26
|
+
"run_perturbation",
|
|
27
|
+
"run_simulation",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
__version__ = "0.2.0"
|
phaseprobe/__main__.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Explicit loading of user-selected Python adapter factories."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib
|
|
6
|
+
|
|
7
|
+
from phaseprobe.config import ProbeConfig
|
|
8
|
+
from phaseprobe.errors import ConfigurationError
|
|
9
|
+
from phaseprobe.types import ModelAdapter, TrajectoryAdapter
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def load_configured_adapter(config: ProbeConfig) -> ModelAdapter | TrajectoryAdapter:
|
|
13
|
+
"""Import and call the explicitly configured adapter factory.
|
|
14
|
+
|
|
15
|
+
Configuration parsing validates names but does not import anything. Calling this function
|
|
16
|
+
executes the selected module and factory, so callers must trust that Python code.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
reference = config.section("adapter")
|
|
20
|
+
module_name = reference.get("module")
|
|
21
|
+
factory_name = reference.get("factory")
|
|
22
|
+
if not isinstance(module_name, str) or not isinstance(factory_name, str):
|
|
23
|
+
raise ConfigurationError("adapter.module and adapter.factory must be strings")
|
|
24
|
+
try:
|
|
25
|
+
module = importlib.import_module(module_name)
|
|
26
|
+
except (ImportError, ValueError) as exc:
|
|
27
|
+
raise ConfigurationError(
|
|
28
|
+
f"cannot import configured adapter module {module_name!r}: {exc}"
|
|
29
|
+
) from exc
|
|
30
|
+
factory = getattr(module, factory_name, None)
|
|
31
|
+
if not callable(factory):
|
|
32
|
+
raise ConfigurationError(
|
|
33
|
+
f"configured adapter factory {module_name}.{factory_name} is not callable"
|
|
34
|
+
)
|
|
35
|
+
try:
|
|
36
|
+
candidate = factory(reference)
|
|
37
|
+
except ConfigurationError:
|
|
38
|
+
raise
|
|
39
|
+
except Exception as exc:
|
|
40
|
+
raise ConfigurationError(
|
|
41
|
+
f"configured adapter factory {module_name}.{factory_name} failed: {exc}"
|
|
42
|
+
) from exc
|
|
43
|
+
if not isinstance(candidate, ModelAdapter | TrajectoryAdapter):
|
|
44
|
+
raise ConfigurationError(
|
|
45
|
+
f"configured factory {module_name}.{factory_name} did not return a PhaseProbe adapter"
|
|
46
|
+
)
|
|
47
|
+
if candidate.name != config.model:
|
|
48
|
+
raise ConfigurationError(
|
|
49
|
+
f"configured adapter name {candidate.name!r} does not match model {config.model!r}"
|
|
50
|
+
)
|
|
51
|
+
return candidate
|
|
@@ -0,0 +1,539 @@
|
|
|
1
|
+
"""Trajectory-level adapter for the public :func:`scipy.integrate.solve_ivp` API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import math
|
|
7
|
+
import platform
|
|
8
|
+
import sys
|
|
9
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from itertools import pairwise
|
|
12
|
+
from typing import Any, Literal, TypeAlias, cast
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
import numpy as np
|
|
16
|
+
import numpy.typing as npt
|
|
17
|
+
import scipy
|
|
18
|
+
from scipy.integrate import solve_ivp
|
|
19
|
+
except ModuleNotFoundError as exc: # pragma: no cover - exercised in an isolated interpreter
|
|
20
|
+
raise ImportError(
|
|
21
|
+
"PhaseProbe SciPy support is optional; install it with "
|
|
22
|
+
'`python -m pip install "phaseprobe[scipy]"`.'
|
|
23
|
+
) from exc
|
|
24
|
+
|
|
25
|
+
from phaseprobe.config import canonical_json
|
|
26
|
+
from phaseprobe.errors import ConfigurationError, NumericalFailure
|
|
27
|
+
from phaseprobe.types import (
|
|
28
|
+
InvariantResult,
|
|
29
|
+
ModelConfig,
|
|
30
|
+
Parameters,
|
|
31
|
+
Scalar,
|
|
32
|
+
SimulationTrace,
|
|
33
|
+
State,
|
|
34
|
+
Tolerances,
|
|
35
|
+
TracePoint,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
FloatArray: TypeAlias = npt.NDArray[np.float64]
|
|
39
|
+
RHSCallback: TypeAlias = Callable[[float, FloatArray, Parameters], object]
|
|
40
|
+
EventCallback: TypeAlias = Callable[[float, FloatArray, Parameters], float]
|
|
41
|
+
ObservableCallback: TypeAlias = Callable[[float, State, Parameters], Mapping[str, Scalar]]
|
|
42
|
+
ClassifierCallback: TypeAlias = Callable[[SimulationTrace, Tolerances], str]
|
|
43
|
+
InvariantCallback: TypeAlias = Callable[
|
|
44
|
+
[SimulationTrace, Parameters, Tolerances], list[InvariantResult]
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
SUPPORTED_METHODS = ("RK23", "RK45", "DOP853", "Radau", "BDF", "LSODA")
|
|
48
|
+
MAX_EVALUATION_POINTS = 100_000
|
|
49
|
+
MAX_EVENT_POINTS = 10_000
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _finite_number(value: object, context: str) -> float:
|
|
53
|
+
if not isinstance(value, int | float) or isinstance(value, bool):
|
|
54
|
+
raise ConfigurationError(f"{context} must be a real number")
|
|
55
|
+
result = float(value)
|
|
56
|
+
if not math.isfinite(result):
|
|
57
|
+
raise ConfigurationError(f"{context} must be finite")
|
|
58
|
+
return result
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _finite_sequence(value: object, context: str) -> tuple[float, ...]:
|
|
62
|
+
if not isinstance(value, list | tuple):
|
|
63
|
+
raise ConfigurationError(f"{context} must be an array of real numbers")
|
|
64
|
+
return tuple(_finite_number(item, f"{context}[{index}]") for index, item in enumerate(value))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _string(value: object, context: str) -> str:
|
|
68
|
+
if not isinstance(value, str) or not value:
|
|
69
|
+
raise ConfigurationError(f"{context} must be a non-empty string")
|
|
70
|
+
return value
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _mapping(value: object, context: str) -> Mapping[str, object]:
|
|
74
|
+
if not isinstance(value, dict):
|
|
75
|
+
raise ConfigurationError(f"{context} must be an object")
|
|
76
|
+
return cast(Mapping[str, object], value)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(frozen=True, slots=True)
|
|
80
|
+
class EventSpec:
|
|
81
|
+
"""Named, explicitly configured solve_ivp event callback."""
|
|
82
|
+
|
|
83
|
+
name: str
|
|
84
|
+
function: EventCallback
|
|
85
|
+
terminal: bool | int = False
|
|
86
|
+
direction: float = 0.0
|
|
87
|
+
|
|
88
|
+
def __post_init__(self) -> None:
|
|
89
|
+
if not self.name:
|
|
90
|
+
raise ConfigurationError("event name must be non-empty")
|
|
91
|
+
terminal = self.terminal
|
|
92
|
+
if not isinstance(terminal, bool) and (not isinstance(terminal, int) or terminal <= 0):
|
|
93
|
+
raise ConfigurationError("event terminal must be a boolean or positive integer")
|
|
94
|
+
if not math.isfinite(float(self.direction)):
|
|
95
|
+
raise ConfigurationError("event direction must be finite")
|
|
96
|
+
|
|
97
|
+
def as_dict(self) -> dict[str, object]:
|
|
98
|
+
"""Serialize event policy without serializing executable code."""
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
"name": self.name,
|
|
102
|
+
"terminal": self.terminal,
|
|
103
|
+
"direction": float(self.direction),
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class _EventWrapper:
|
|
108
|
+
def __init__(self, spec: EventSpec, parameters: Parameters) -> None:
|
|
109
|
+
self._spec = spec
|
|
110
|
+
self._parameters = parameters
|
|
111
|
+
self.terminal = spec.terminal
|
|
112
|
+
self.direction = float(spec.direction)
|
|
113
|
+
|
|
114
|
+
def __call__(self, time: float, state: FloatArray) -> float:
|
|
115
|
+
value = float(self._spec.function(time, state, self._parameters))
|
|
116
|
+
if not math.isfinite(value):
|
|
117
|
+
raise NumericalFailure(
|
|
118
|
+
f"invalid event value from {self._spec.name!r}: expected a finite scalar"
|
|
119
|
+
)
|
|
120
|
+
return value
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class SolveIVPAdapter:
|
|
124
|
+
"""Typed real-valued trajectory adapter backed by SciPy ``solve_ivp``.
|
|
125
|
+
|
|
126
|
+
``identity`` is an explicit user-controlled identifier. PhaseProbe combines it with a digest
|
|
127
|
+
of serialized numerical configuration; callable source is never inspected or stored.
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
replay_mode: Literal["tolerance"] = "tolerance"
|
|
131
|
+
|
|
132
|
+
def __init__(
|
|
133
|
+
self,
|
|
134
|
+
*,
|
|
135
|
+
name: str,
|
|
136
|
+
identity: str,
|
|
137
|
+
rhs: RHSCallback,
|
|
138
|
+
state_names: Sequence[str],
|
|
139
|
+
initial_state: Sequence[float],
|
|
140
|
+
t_span: tuple[float, float],
|
|
141
|
+
t_eval: Sequence[float] | int = 501,
|
|
142
|
+
method: str = "RK45",
|
|
143
|
+
rtol: float = 1e-3,
|
|
144
|
+
atol: float | Sequence[float] = 1e-6,
|
|
145
|
+
max_step: float | None = None,
|
|
146
|
+
events: Sequence[EventSpec] = (),
|
|
147
|
+
observable: ObservableCallback | None = None,
|
|
148
|
+
classifier: ClassifierCallback | None = None,
|
|
149
|
+
invariant: InvariantCallback | None = None,
|
|
150
|
+
vectorized: bool = False,
|
|
151
|
+
dense_output: bool = False,
|
|
152
|
+
) -> None:
|
|
153
|
+
self.name = _string(name, "name")
|
|
154
|
+
self.explicit_identity = _string(identity, "identity")
|
|
155
|
+
self.dimensions = tuple(_string(item, "state_names item") for item in state_names)
|
|
156
|
+
if not self.dimensions or len(set(self.dimensions)) != len(self.dimensions):
|
|
157
|
+
raise ConfigurationError("state_names must be non-empty and unique")
|
|
158
|
+
self._initial_state = _finite_sequence(tuple(initial_state), "initial_state")
|
|
159
|
+
if len(self._initial_state) != len(self.dimensions):
|
|
160
|
+
raise ConfigurationError("initial_state length must match state_names")
|
|
161
|
+
self._t_span = (
|
|
162
|
+
_finite_number(t_span[0], "t_span[0]"),
|
|
163
|
+
_finite_number(t_span[1], "t_span[1]"),
|
|
164
|
+
)
|
|
165
|
+
if self._t_span[0] == self._t_span[1]:
|
|
166
|
+
raise ConfigurationError("t_span endpoints must differ")
|
|
167
|
+
self._t_eval, self._grid_configuration = self._evaluation_grid(t_eval)
|
|
168
|
+
if method not in SUPPORTED_METHODS:
|
|
169
|
+
raise ConfigurationError(f"method must be one of {', '.join(SUPPORTED_METHODS)}")
|
|
170
|
+
self.method = method
|
|
171
|
+
self.rtol = _finite_number(rtol, "rtol")
|
|
172
|
+
if self.rtol <= 0.0:
|
|
173
|
+
raise ConfigurationError("rtol must be positive")
|
|
174
|
+
if isinstance(atol, int | float) and not isinstance(atol, bool):
|
|
175
|
+
scalar_atol = _finite_number(atol, "atol")
|
|
176
|
+
if scalar_atol <= 0.0:
|
|
177
|
+
raise ConfigurationError("atol must be positive")
|
|
178
|
+
self.atol: float | tuple[float, ...] = scalar_atol
|
|
179
|
+
else:
|
|
180
|
+
vector_atol = _finite_sequence(atol, "atol")
|
|
181
|
+
if len(vector_atol) != len(self.dimensions) or any(x <= 0.0 for x in vector_atol):
|
|
182
|
+
raise ConfigurationError("vector atol must contain one positive value per state")
|
|
183
|
+
self.atol = vector_atol
|
|
184
|
+
if max_step is None:
|
|
185
|
+
self.max_step = None
|
|
186
|
+
else:
|
|
187
|
+
parsed_max_step = _finite_number(max_step, "max_step")
|
|
188
|
+
if parsed_max_step <= 0.0:
|
|
189
|
+
raise ConfigurationError("max_step must be positive")
|
|
190
|
+
self.max_step = parsed_max_step
|
|
191
|
+
self.events = tuple(events)
|
|
192
|
+
if len({event.name for event in self.events}) != len(self.events):
|
|
193
|
+
raise ConfigurationError("event names must be unique")
|
|
194
|
+
if not isinstance(vectorized, bool) or not isinstance(dense_output, bool):
|
|
195
|
+
raise ConfigurationError("vectorized and dense_output must be booleans")
|
|
196
|
+
self.vectorized = vectorized
|
|
197
|
+
self.dense_output = dense_output
|
|
198
|
+
self._rhs = rhs
|
|
199
|
+
self._observable = observable or self._default_observable
|
|
200
|
+
self._classifier = classifier or self._default_classifier
|
|
201
|
+
self._invariant = invariant or self._default_invariants
|
|
202
|
+
configuration = self.configuration()
|
|
203
|
+
digest = hashlib.sha256(canonical_json(configuration).encode("utf-8")).hexdigest()
|
|
204
|
+
self.identity = f"{self.explicit_identity}:sha256:{digest[:16]}"
|
|
205
|
+
|
|
206
|
+
@classmethod
|
|
207
|
+
def from_config(
|
|
208
|
+
cls,
|
|
209
|
+
*,
|
|
210
|
+
name: str,
|
|
211
|
+
rhs: RHSCallback,
|
|
212
|
+
values: Mapping[str, object],
|
|
213
|
+
observable: ObservableCallback | None = None,
|
|
214
|
+
classifier: ClassifierCallback | None = None,
|
|
215
|
+
invariant: InvariantCallback | None = None,
|
|
216
|
+
events: Sequence[EventSpec] = (),
|
|
217
|
+
) -> SolveIVPAdapter:
|
|
218
|
+
"""Construct from a JSON-compatible ``adapter.options`` mapping."""
|
|
219
|
+
|
|
220
|
+
options = _mapping(values.get("options"), "adapter.options")
|
|
221
|
+
state_names_raw = options.get("state_names")
|
|
222
|
+
if not isinstance(state_names_raw, list) or not all(
|
|
223
|
+
isinstance(item, str) for item in state_names_raw
|
|
224
|
+
):
|
|
225
|
+
raise ConfigurationError("adapter.options.state_names must be an array of strings")
|
|
226
|
+
t_span_values = _finite_sequence(options.get("t_span"), "adapter.options.t_span")
|
|
227
|
+
if len(t_span_values) != 2:
|
|
228
|
+
raise ConfigurationError("adapter.options.t_span must contain two values")
|
|
229
|
+
atol_raw = options.get("atol", 1e-6)
|
|
230
|
+
if isinstance(atol_raw, list):
|
|
231
|
+
atol: float | Sequence[float] = _finite_sequence(atol_raw, "adapter.options.atol")
|
|
232
|
+
else:
|
|
233
|
+
atol = _finite_number(atol_raw, "adapter.options.atol")
|
|
234
|
+
t_eval_raw = options.get("t_eval", 501)
|
|
235
|
+
if isinstance(t_eval_raw, dict):
|
|
236
|
+
grid = _mapping(t_eval_raw, "adapter.options.t_eval")
|
|
237
|
+
if grid.get("kind") != "linspace":
|
|
238
|
+
raise ConfigurationError("adapter.options.t_eval.kind must be 'linspace'")
|
|
239
|
+
points_raw = grid.get("points")
|
|
240
|
+
if not isinstance(points_raw, int) or isinstance(points_raw, bool):
|
|
241
|
+
raise ConfigurationError("adapter.options.t_eval.points must be an integer")
|
|
242
|
+
t_eval: Sequence[float] | int = points_raw
|
|
243
|
+
elif isinstance(t_eval_raw, list):
|
|
244
|
+
t_eval = _finite_sequence(t_eval_raw, "adapter.options.t_eval")
|
|
245
|
+
else:
|
|
246
|
+
raise ConfigurationError("adapter.options.t_eval must be an array or linspace object")
|
|
247
|
+
max_step_raw = options.get("max_step")
|
|
248
|
+
max_step = (
|
|
249
|
+
None
|
|
250
|
+
if max_step_raw is None
|
|
251
|
+
else _finite_number(max_step_raw, "adapter.options.max_step")
|
|
252
|
+
)
|
|
253
|
+
vectorized = options.get("vectorized", False)
|
|
254
|
+
dense_output = options.get("dense_output", False)
|
|
255
|
+
if not isinstance(vectorized, bool) or not isinstance(dense_output, bool):
|
|
256
|
+
raise ConfigurationError("adapter.options.vectorized and dense_output must be booleans")
|
|
257
|
+
return cls(
|
|
258
|
+
name=name,
|
|
259
|
+
identity=_string(options.get("identity"), "adapter.options.identity"),
|
|
260
|
+
rhs=rhs,
|
|
261
|
+
state_names=cast(list[str], state_names_raw),
|
|
262
|
+
initial_state=_finite_sequence(
|
|
263
|
+
options.get("initial_state"), "adapter.options.initial_state"
|
|
264
|
+
),
|
|
265
|
+
t_span=(t_span_values[0], t_span_values[1]),
|
|
266
|
+
t_eval=t_eval,
|
|
267
|
+
method=_string(options.get("method", "RK45"), "adapter.options.method"),
|
|
268
|
+
rtol=_finite_number(options.get("rtol", 1e-3), "adapter.options.rtol"),
|
|
269
|
+
atol=atol,
|
|
270
|
+
max_step=max_step,
|
|
271
|
+
events=events,
|
|
272
|
+
observable=observable,
|
|
273
|
+
classifier=classifier,
|
|
274
|
+
invariant=invariant,
|
|
275
|
+
vectorized=vectorized,
|
|
276
|
+
dense_output=dense_output,
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
def _evaluation_grid(
|
|
280
|
+
self, requested: Sequence[float] | int
|
|
281
|
+
) -> tuple[tuple[float, ...], dict[str, object]]:
|
|
282
|
+
start, stop = self._t_span
|
|
283
|
+
if isinstance(requested, bool):
|
|
284
|
+
raise ConfigurationError("t_eval must be an integer point count or numeric sequence")
|
|
285
|
+
if isinstance(requested, int):
|
|
286
|
+
if requested < 2 or requested > MAX_EVALUATION_POINTS:
|
|
287
|
+
raise ConfigurationError(
|
|
288
|
+
f"t_eval point count must be between 2 and {MAX_EVALUATION_POINTS}"
|
|
289
|
+
)
|
|
290
|
+
values = tuple(
|
|
291
|
+
start + (stop - start) * index / (requested - 1) for index in range(requested)
|
|
292
|
+
)
|
|
293
|
+
configuration: dict[str, object] = {
|
|
294
|
+
"kind": "linspace",
|
|
295
|
+
"start": start,
|
|
296
|
+
"stop": stop,
|
|
297
|
+
"points": requested,
|
|
298
|
+
}
|
|
299
|
+
else:
|
|
300
|
+
values = _finite_sequence(tuple(requested), "t_eval")
|
|
301
|
+
if len(values) < 2 or len(values) > MAX_EVALUATION_POINTS:
|
|
302
|
+
raise ConfigurationError(
|
|
303
|
+
f"t_eval must contain between 2 and {MAX_EVALUATION_POINTS} values"
|
|
304
|
+
)
|
|
305
|
+
configuration = {"kind": "explicit", "values": list(values)}
|
|
306
|
+
direction = 1.0 if stop > start else -1.0
|
|
307
|
+
if values[0] != start or values[-1] != stop:
|
|
308
|
+
raise ConfigurationError("t_eval must include both t_span endpoints")
|
|
309
|
+
if any(direction * (right - left) <= 0.0 for left, right in pairwise(values)):
|
|
310
|
+
raise ConfigurationError("t_eval must be strictly ordered in the t_span direction")
|
|
311
|
+
return values, configuration
|
|
312
|
+
|
|
313
|
+
def configuration(self) -> Mapping[str, object]:
|
|
314
|
+
"""Return stable JSON metadata; callables are represented only by declared labels."""
|
|
315
|
+
|
|
316
|
+
return {
|
|
317
|
+
"adapter": "scipy.solve_ivp",
|
|
318
|
+
"identity": self.explicit_identity,
|
|
319
|
+
"state_names": list(self.dimensions),
|
|
320
|
+
"initial_state": list(self._initial_state),
|
|
321
|
+
"t_span": list(self._t_span),
|
|
322
|
+
"t_eval": self._grid_configuration,
|
|
323
|
+
"method": self.method,
|
|
324
|
+
"rtol": self.rtol,
|
|
325
|
+
"atol": list(self.atol) if isinstance(self.atol, tuple) else self.atol,
|
|
326
|
+
"max_step": self.max_step,
|
|
327
|
+
"events": [event.as_dict() for event in self.events],
|
|
328
|
+
"vectorized": self.vectorized,
|
|
329
|
+
"dense_output": self.dense_output,
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
def initial_state(self, config: ModelConfig, seed: int) -> State:
|
|
333
|
+
"""Return the serialized initial state; config and seed remain replay evidence."""
|
|
334
|
+
|
|
335
|
+
return self._initial_state
|
|
336
|
+
|
|
337
|
+
def _default_observable(
|
|
338
|
+
self, time: float, state: State, parameters: Parameters
|
|
339
|
+
) -> Mapping[str, Scalar]:
|
|
340
|
+
return dict(zip(self.dimensions, state, strict=True))
|
|
341
|
+
|
|
342
|
+
@staticmethod
|
|
343
|
+
def _default_classifier(trace: SimulationTrace, tolerances: Tolerances) -> str:
|
|
344
|
+
return "completed"
|
|
345
|
+
|
|
346
|
+
@staticmethod
|
|
347
|
+
def _default_invariants(
|
|
348
|
+
trace: SimulationTrace, parameters: Parameters, tolerances: Tolerances
|
|
349
|
+
) -> list[InvariantResult]:
|
|
350
|
+
return []
|
|
351
|
+
|
|
352
|
+
def _checked_rhs(self, parameters: Parameters) -> Callable[[float, FloatArray], FloatArray]:
|
|
353
|
+
def evaluate(time: float, state: FloatArray) -> FloatArray:
|
|
354
|
+
raw: Any = self._rhs(time, state, parameters)
|
|
355
|
+
if np.iscomplexobj(raw):
|
|
356
|
+
raise NumericalFailure(
|
|
357
|
+
"SolveIVPAdapter supports real-valued states only; split complex states into "
|
|
358
|
+
"real and imaginary components"
|
|
359
|
+
)
|
|
360
|
+
result = np.asarray(raw, dtype=float)
|
|
361
|
+
if result.shape != state.shape:
|
|
362
|
+
raise NumericalFailure(f"RHS returned shape {result.shape}; expected {state.shape}")
|
|
363
|
+
if not np.isfinite(result).all():
|
|
364
|
+
raise NumericalFailure("RHS returned NaN or infinite derivative values")
|
|
365
|
+
return cast(FloatArray, result)
|
|
366
|
+
|
|
367
|
+
return evaluate
|
|
368
|
+
|
|
369
|
+
def _point(self, index: int, time: float, state: object, parameters: Parameters) -> TracePoint:
|
|
370
|
+
values_array = np.asarray(state)
|
|
371
|
+
if np.iscomplexobj(values_array):
|
|
372
|
+
raise NumericalFailure("solve_ivp returned a complex state to a real-valued adapter")
|
|
373
|
+
values = tuple(float(value) for value in values_array)
|
|
374
|
+
if len(values) != len(self.dimensions) or not all(math.isfinite(x) for x in values):
|
|
375
|
+
raise NumericalFailure("solve_ivp returned an invalid state shape or NaN/Inf value")
|
|
376
|
+
observations = dict(self._observable(time, values, parameters))
|
|
377
|
+
for key, value in observations.items():
|
|
378
|
+
if not isinstance(key, str) or not key:
|
|
379
|
+
raise NumericalFailure("observable names must be non-empty strings")
|
|
380
|
+
if isinstance(value, int | float) and not isinstance(value, bool):
|
|
381
|
+
if not math.isfinite(float(value)):
|
|
382
|
+
raise NumericalFailure(f"observable {key!r} is NaN or infinite")
|
|
383
|
+
elif not isinstance(value, str):
|
|
384
|
+
raise NumericalFailure(f"observable {key!r} must be a finite number or string")
|
|
385
|
+
return TracePoint(step=index, time=float(time), state=values, observations=observations)
|
|
386
|
+
|
|
387
|
+
def simulate(
|
|
388
|
+
self,
|
|
389
|
+
initial_state: State,
|
|
390
|
+
parameters: Parameters,
|
|
391
|
+
config: ModelConfig,
|
|
392
|
+
seed: int,
|
|
393
|
+
) -> SimulationTrace:
|
|
394
|
+
"""Run one public ``solve_ivp`` call and retain its declared evaluation grid."""
|
|
395
|
+
|
|
396
|
+
if len(initial_state) != len(self.dimensions) or not all(
|
|
397
|
+
math.isfinite(value) for value in initial_state
|
|
398
|
+
):
|
|
399
|
+
raise NumericalFailure("initial state must match state_names and contain finite values")
|
|
400
|
+
parameter_snapshot = dict(parameters)
|
|
401
|
+
wrappers = tuple(_EventWrapper(event, parameter_snapshot) for event in self.events)
|
|
402
|
+
try:
|
|
403
|
+
solution: Any = solve_ivp(
|
|
404
|
+
self._checked_rhs(parameter_snapshot),
|
|
405
|
+
self._t_span,
|
|
406
|
+
np.asarray(initial_state, dtype=float),
|
|
407
|
+
method=self.method,
|
|
408
|
+
t_eval=np.asarray(self._t_eval, dtype=float),
|
|
409
|
+
dense_output=self.dense_output,
|
|
410
|
+
events=wrappers or None,
|
|
411
|
+
vectorized=self.vectorized,
|
|
412
|
+
rtol=self.rtol,
|
|
413
|
+
atol=np.asarray(self.atol, dtype=float)
|
|
414
|
+
if isinstance(self.atol, tuple)
|
|
415
|
+
else self.atol,
|
|
416
|
+
max_step=np.inf if self.max_step is None else self.max_step,
|
|
417
|
+
)
|
|
418
|
+
except NumericalFailure:
|
|
419
|
+
raise
|
|
420
|
+
except (ArithmeticError, RuntimeError, TypeError, ValueError) as exc:
|
|
421
|
+
raise NumericalFailure(f"solve_ivp failed before returning a result: {exc}") from exc
|
|
422
|
+
|
|
423
|
+
times = np.asarray(solution.t)
|
|
424
|
+
states = np.asarray(solution.y)
|
|
425
|
+
if times.ndim != 1 or states.shape != (len(self.dimensions), len(times)):
|
|
426
|
+
raise NumericalFailure("solve_ivp returned an invalid trajectory shape")
|
|
427
|
+
if not np.isfinite(times).all() or not np.isfinite(states).all():
|
|
428
|
+
raise NumericalFailure("solve_ivp returned NaN or infinite trajectory values")
|
|
429
|
+
points = [
|
|
430
|
+
self._point(index, float(time), states[:, index], parameter_snapshot)
|
|
431
|
+
for index, time in enumerate(times)
|
|
432
|
+
]
|
|
433
|
+
event_evidence: list[dict[str, object]] = []
|
|
434
|
+
terminal_candidates: list[tuple[float, State]] = []
|
|
435
|
+
t_events = solution.t_events or []
|
|
436
|
+
y_events = solution.y_events or []
|
|
437
|
+
for index, spec in enumerate(self.events):
|
|
438
|
+
event_times = np.asarray(t_events[index])
|
|
439
|
+
event_states = np.asarray(y_events[index])
|
|
440
|
+
if len(event_times) > MAX_EVENT_POINTS:
|
|
441
|
+
raise NumericalFailure(
|
|
442
|
+
f"event {spec.name!r} exceeded the bounded event retention limit"
|
|
443
|
+
)
|
|
444
|
+
serialized_states: list[list[float]] = []
|
|
445
|
+
serialized_times: list[float] = []
|
|
446
|
+
for event_time, event_state in zip(event_times, event_states, strict=True):
|
|
447
|
+
point = self._point(len(points), float(event_time), event_state, parameter_snapshot)
|
|
448
|
+
serialized_times.append(point.time)
|
|
449
|
+
serialized_states.append(list(point.state))
|
|
450
|
+
terminal_candidates.append((point.time, point.state))
|
|
451
|
+
event_evidence.append(
|
|
452
|
+
{
|
|
453
|
+
"name": spec.name,
|
|
454
|
+
"terminal": spec.terminal,
|
|
455
|
+
"direction": float(spec.direction),
|
|
456
|
+
"times": serialized_times,
|
|
457
|
+
"states": serialized_states,
|
|
458
|
+
}
|
|
459
|
+
)
|
|
460
|
+
if not points:
|
|
461
|
+
raise NumericalFailure("solve_ivp returned no retained evaluation points")
|
|
462
|
+
final_state = points[-1].state
|
|
463
|
+
termination_time = points[-1].time
|
|
464
|
+
if int(solution.status) == 1 and terminal_candidates:
|
|
465
|
+
direction = 1.0 if self._t_span[1] > self._t_span[0] else -1.0
|
|
466
|
+
termination_time, final_state = max(
|
|
467
|
+
terminal_candidates, key=lambda item: direction * item[0]
|
|
468
|
+
)
|
|
469
|
+
if not math.isclose(points[-1].time, termination_time, rel_tol=0.0, abs_tol=1e-15):
|
|
470
|
+
points.append(
|
|
471
|
+
self._point(len(points), termination_time, final_state, parameter_snapshot)
|
|
472
|
+
)
|
|
473
|
+
metadata: dict[str, object] = {
|
|
474
|
+
"python_version": platform.python_version(),
|
|
475
|
+
"python_implementation": platform.python_implementation(),
|
|
476
|
+
"numpy_version": np.__version__,
|
|
477
|
+
"scipy_version": scipy.__version__,
|
|
478
|
+
"platform": {
|
|
479
|
+
"system": platform.system(),
|
|
480
|
+
"release": platform.release(),
|
|
481
|
+
"machine": platform.machine(),
|
|
482
|
+
"architecture_bits": 64 if sys.maxsize > 2**32 else 32,
|
|
483
|
+
"byteorder": sys.byteorder,
|
|
484
|
+
},
|
|
485
|
+
"solver_method": self.method,
|
|
486
|
+
"rtol": self.rtol,
|
|
487
|
+
"atol": list(self.atol) if isinstance(self.atol, tuple) else self.atol,
|
|
488
|
+
"evaluation_grid": self._grid_configuration,
|
|
489
|
+
"maximum_step": self.max_step,
|
|
490
|
+
"t_span": list(self._t_span),
|
|
491
|
+
"initial_state": list(initial_state),
|
|
492
|
+
"parameters": parameter_snapshot,
|
|
493
|
+
"event_configuration": [event.as_dict() for event in self.events],
|
|
494
|
+
"events": event_evidence,
|
|
495
|
+
"solver_success": bool(solution.success),
|
|
496
|
+
"solver_status": int(solution.status),
|
|
497
|
+
"solver_message": str(solution.message),
|
|
498
|
+
"termination_time": termination_time,
|
|
499
|
+
"nfev": int(solution.nfev),
|
|
500
|
+
"njev": int(solution.njev),
|
|
501
|
+
"nlu": int(solution.nlu),
|
|
502
|
+
"vectorized": self.vectorized,
|
|
503
|
+
"dense_output": self.dense_output,
|
|
504
|
+
"seed": seed,
|
|
505
|
+
}
|
|
506
|
+
return SimulationTrace(
|
|
507
|
+
points=tuple(points),
|
|
508
|
+
final_state=final_state,
|
|
509
|
+
success=bool(solution.success),
|
|
510
|
+
status=int(solution.status),
|
|
511
|
+
message=str(solution.message),
|
|
512
|
+
metadata=metadata,
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
def observe(self, trace: SimulationTrace) -> Mapping[str, Scalar]:
|
|
516
|
+
"""Return the final retained point's observables."""
|
|
517
|
+
|
|
518
|
+
return dict(trace.points[-1].observations)
|
|
519
|
+
|
|
520
|
+
def classify(self, trace: SimulationTrace, tolerances: Tolerances) -> str:
|
|
521
|
+
"""Delegate qualitative interpretation to the declared callback."""
|
|
522
|
+
|
|
523
|
+
result = self._classifier(trace, tolerances)
|
|
524
|
+
if not isinstance(result, str) or not result:
|
|
525
|
+
raise NumericalFailure("classifier must return a non-empty string")
|
|
526
|
+
return result
|
|
527
|
+
|
|
528
|
+
def invariants(
|
|
529
|
+
self,
|
|
530
|
+
trace: SimulationTrace,
|
|
531
|
+
parameters: Parameters,
|
|
532
|
+
tolerances: Tolerances,
|
|
533
|
+
) -> list[InvariantResult]:
|
|
534
|
+
"""Delegate invariant evaluation to the declared callback."""
|
|
535
|
+
|
|
536
|
+
return list(self._invariant(trace, parameters, tolerances))
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
__all__ = ["SUPPORTED_METHODS", "EventSpec", "SolveIVPAdapter"]
|