patchsim 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.
- patchsim/__init__.py +27 -0
- patchsim/calibration.py +834 -0
- patchsim/cli.py +393 -0
- patchsim/core/__init__.py +0 -0
- patchsim/core/expressions.py +144 -0
- patchsim/core/model.py +362 -0
- patchsim/core/model_runner.py +44 -0
- patchsim/core/simulation.py +878 -0
- patchsim/models/__init__.py +1 -0
- patchsim/sensitivity.py +420 -0
- patchsim/templates/models/seir.yaml +10 -0
- patchsim/templates/models/sir.yaml +8 -0
- patchsim/templates/models/sirs.yaml +10 -0
- patchsim/templates/models/sis.yaml +8 -0
- patchsim/templates/project/config.yaml +33 -0
- patchsim/templates/project/data/networks/network-static.csv +5 -0
- patchsim/templates/project/data/patch/patch-population.csv +3 -0
- patchsim/templates/project/data/seeds/seed-initial.csv +3 -0
- patchsim/templates/project/output/.gitkeep +0 -0
- patchsim/utils/__init__.py +0 -0
- patchsim/utils/geo.py +527 -0
- patchsim/utils/loader.py +6 -0
- patchsim/utils/logger.py +76 -0
- patchsim/utils/viz.py +65 -0
- patchsim-0.1.0.dist-info/METADATA +917 -0
- patchsim-0.1.0.dist-info/RECORD +29 -0
- patchsim-0.1.0.dist-info/WHEEL +4 -0
- patchsim-0.1.0.dist-info/entry_points.txt +2 -0
- patchsim-0.1.0.dist-info/licenses/LICENSE +674 -0
patchsim/calibration.py
ADDED
|
@@ -0,0 +1,834 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import platform
|
|
6
|
+
import re
|
|
7
|
+
import tempfile
|
|
8
|
+
import time
|
|
9
|
+
from copy import deepcopy
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
12
|
+
from numbers import Real
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Callable
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
import pandas as pd
|
|
18
|
+
import scipy
|
|
19
|
+
from scipy.optimize import least_squares
|
|
20
|
+
|
|
21
|
+
from patchsim.core.model import NetworkModel
|
|
22
|
+
from patchsim.core.simulation import _simulate_prepared, get_run_settings, load_config, setup_simulation
|
|
23
|
+
|
|
24
|
+
_NAME_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
|
|
25
|
+
_ARTIFACT_NAMES = ("estimates.csv", "fitted-seeds.csv", "attempts.csv", "residuals.csv")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class Observable:
|
|
30
|
+
name: str
|
|
31
|
+
columns: tuple[str, ...]
|
|
32
|
+
scale: float
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class FitVariable:
|
|
37
|
+
kind: str
|
|
38
|
+
name: str
|
|
39
|
+
lower: float
|
|
40
|
+
upper: float
|
|
41
|
+
baseline: float
|
|
42
|
+
patch: str | None = None
|
|
43
|
+
group: str | None = None
|
|
44
|
+
compartment: str | None = None
|
|
45
|
+
state_key: str | None = None
|
|
46
|
+
remainder_key: str | None = None
|
|
47
|
+
patch_index: int | None = None
|
|
48
|
+
group_index: int | None = None
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def identity(self) -> tuple[str, str | None, str | None, str]:
|
|
52
|
+
return (self.kind, self.patch, self.group, self.name)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class CalibrationPlan:
|
|
57
|
+
name: str
|
|
58
|
+
observations_path: Path
|
|
59
|
+
observations: pd.DataFrame
|
|
60
|
+
observables: tuple[Observable, ...]
|
|
61
|
+
variables: tuple[FitVariable, ...]
|
|
62
|
+
starts: tuple[tuple[float, ...], ...]
|
|
63
|
+
max_evaluations: int
|
|
64
|
+
warnings: tuple[str, ...]
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def n(self) -> int:
|
|
68
|
+
return len(self.observations)
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def p(self) -> int:
|
|
72
|
+
return len(self.variables)
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def start_count(self) -> int:
|
|
76
|
+
return len(self.starts)
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def max_forward_simulations(self) -> int:
|
|
80
|
+
return self.start_count * self.max_evaluations
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class _BudgetExceeded(RuntimeError):
|
|
84
|
+
pass
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _finite_float(value: Any, label: str) -> float:
|
|
88
|
+
if isinstance(value, bool) or not isinstance(value, Real):
|
|
89
|
+
raise ValueError(f"{label} must be a finite real number")
|
|
90
|
+
result = float(value)
|
|
91
|
+
if not np.isfinite(result):
|
|
92
|
+
raise ValueError(f"{label} must be a finite real number")
|
|
93
|
+
return result
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _bounds(value: Any, label: str, *, non_negative: bool = False) -> tuple[float, float]:
|
|
97
|
+
if not isinstance(value, list) or len(value) != 2:
|
|
98
|
+
raise ValueError(f"{label} bounds must be a two-element list")
|
|
99
|
+
lower = _finite_float(value[0], f"{label} lower bound")
|
|
100
|
+
upper = _finite_float(value[1], f"{label} upper bound")
|
|
101
|
+
if lower >= upper:
|
|
102
|
+
raise ValueError(f"{label} bounds must satisfy lower < upper")
|
|
103
|
+
if non_negative and lower < 0:
|
|
104
|
+
raise ValueError(f"{label} lower bound must be non-negative")
|
|
105
|
+
return lower, upper
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _safe_name(value: Any) -> str:
|
|
109
|
+
if not isinstance(value, str) or not _NAME_PATTERN.fullmatch(value) or value in {".", ".."}:
|
|
110
|
+
raise ValueError("Calibration Name must be a safe path component")
|
|
111
|
+
return value
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _patch_parameter_names(config: dict[str, Any]) -> set[str]:
|
|
115
|
+
names: set[str] = set()
|
|
116
|
+
for entry in config.get("PatchParameters", []):
|
|
117
|
+
if isinstance(entry, dict) and isinstance(entry.get("parameters"), dict):
|
|
118
|
+
names.update(entry["parameters"])
|
|
119
|
+
return names
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _parse_observables(block: Any, output_columns: set[str]) -> tuple[Observable, ...]:
|
|
123
|
+
if not isinstance(block, dict) or not block:
|
|
124
|
+
raise ValueError("Calibration Observables must be a non-empty mapping")
|
|
125
|
+
observables = []
|
|
126
|
+
for name, definition in block.items():
|
|
127
|
+
if not isinstance(name, str) or not name.strip():
|
|
128
|
+
raise ValueError("Calibration observable names must be non-empty strings")
|
|
129
|
+
if not isinstance(definition, dict):
|
|
130
|
+
raise ValueError(f"Calibration observable {name!r} must be a mapping")
|
|
131
|
+
columns = definition.get("Columns")
|
|
132
|
+
if not isinstance(columns, list) or not columns or not all(isinstance(column, str) for column in columns):
|
|
133
|
+
raise ValueError(f"Calibration observable {name!r} Columns must be a non-empty string list")
|
|
134
|
+
if len(columns) != len(set(columns)):
|
|
135
|
+
raise ValueError(f"Calibration observable {name!r} contains duplicate output columns")
|
|
136
|
+
unknown = sorted(set(columns) - output_columns)
|
|
137
|
+
if unknown:
|
|
138
|
+
raise ValueError(f"Calibration observable {name!r} uses unknown output columns: {unknown}")
|
|
139
|
+
scale = _finite_float(definition.get("Scale"), f"Calibration observable {name!r} Scale")
|
|
140
|
+
if scale <= 0:
|
|
141
|
+
raise ValueError(f"Calibration observable {name!r} Scale must be positive")
|
|
142
|
+
observables.append(Observable(name=name, columns=tuple(columns), scale=scale))
|
|
143
|
+
return tuple(observables)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _load_observations(
|
|
147
|
+
path: Path,
|
|
148
|
+
observables: tuple[Observable, ...],
|
|
149
|
+
config: dict[str, Any],
|
|
150
|
+
) -> tuple[pd.DataFrame, list[str]]:
|
|
151
|
+
frame = pd.read_csv(path, keep_default_na=False)
|
|
152
|
+
required = ["time", "observable", "value"]
|
|
153
|
+
if frame.columns.tolist() != required:
|
|
154
|
+
raise ValueError(f"Calibration observations must have exactly these columns in order: {required}")
|
|
155
|
+
if frame.empty:
|
|
156
|
+
raise ValueError("Calibration observations must contain at least one row")
|
|
157
|
+
|
|
158
|
+
for column in ("time", "value"):
|
|
159
|
+
try:
|
|
160
|
+
frame[column] = pd.to_numeric(frame[column], errors="raise")
|
|
161
|
+
except (TypeError, ValueError) as exc:
|
|
162
|
+
raise ValueError(f"Calibration observations column {column!r} must contain finite numbers") from exc
|
|
163
|
+
values = frame[column].to_numpy(dtype=float)
|
|
164
|
+
if not np.isfinite(values).all():
|
|
165
|
+
rows = (np.flatnonzero(~np.isfinite(values)) + 2).tolist()
|
|
166
|
+
raise ValueError(f"Calibration observations column {column!r} contains non-finite values at rows {rows}")
|
|
167
|
+
|
|
168
|
+
frame["observable"] = frame["observable"].astype(str)
|
|
169
|
+
empty_names = frame.index[frame["observable"].str.strip().eq("")].tolist()
|
|
170
|
+
if empty_names:
|
|
171
|
+
rows = [row + 2 for row in empty_names]
|
|
172
|
+
raise ValueError(f"Calibration observations contain empty observable names at rows {rows}")
|
|
173
|
+
known = {observable.name for observable in observables}
|
|
174
|
+
unknown = sorted(set(frame["observable"]) - known)
|
|
175
|
+
if unknown:
|
|
176
|
+
raise ValueError(f"Calibration observations use unknown observable names: {unknown}")
|
|
177
|
+
unused = sorted(known - set(frame["observable"]))
|
|
178
|
+
if unused:
|
|
179
|
+
raise ValueError(f"Calibration observables have no observation rows: {unused}")
|
|
180
|
+
duplicate = frame.duplicated(["time", "observable"], keep=False)
|
|
181
|
+
if duplicate.any():
|
|
182
|
+
rows = (frame.index[duplicate] + 2).tolist()
|
|
183
|
+
raise ValueError(f"Calibration observations contain duplicate (time, observable) rows: {rows}")
|
|
184
|
+
|
|
185
|
+
_solver, t_max, time_step = get_run_settings(config)
|
|
186
|
+
grid = np.arange(t_max, dtype=np.float64) * time_step
|
|
187
|
+
if not np.isfinite(grid).all() or (len(grid) > 1 and not np.all(np.diff(grid) > 0)):
|
|
188
|
+
raise ValueError("Calibration reporting grid must be finite, strictly increasing, and unique in float64")
|
|
189
|
+
min_spacing = float(np.min(np.diff(grid))) if len(grid) > 1 else time_step
|
|
190
|
+
epsilon = np.finfo(np.float64).eps
|
|
191
|
+
indices: list[int] = []
|
|
192
|
+
unmatched: list[tuple[int, float]] = []
|
|
193
|
+
for row_index, observation_time in enumerate(frame["time"].to_numpy(dtype=float), start=2):
|
|
194
|
+
tolerance = min(
|
|
195
|
+
min_spacing / 4.0,
|
|
196
|
+
max(8.0 * epsilon * max(1.0, abs(observation_time)), 1e-9 * time_step),
|
|
197
|
+
)
|
|
198
|
+
matches = np.flatnonzero(np.abs(grid - observation_time) <= tolerance)
|
|
199
|
+
if len(matches) == 1:
|
|
200
|
+
indices.append(int(matches[0]))
|
|
201
|
+
else:
|
|
202
|
+
indices.append(-1)
|
|
203
|
+
unmatched.append((row_index, float(observation_time)))
|
|
204
|
+
frame["grid_index"] = indices
|
|
205
|
+
warnings = []
|
|
206
|
+
if unmatched:
|
|
207
|
+
examples = ", ".join(f"row {row}: {value:g}" for row, value in unmatched[:10])
|
|
208
|
+
warnings.append(f"{len(unmatched)} unmatched observation times ({examples})")
|
|
209
|
+
return frame, warnings
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _global_variables(config: dict[str, Any], block: Any) -> list[FitVariable]:
|
|
213
|
+
if block is None:
|
|
214
|
+
return []
|
|
215
|
+
if not isinstance(block, dict):
|
|
216
|
+
raise ValueError("Calibration Parameters must be a mapping")
|
|
217
|
+
global_parameters = config.get("Parameters", {})
|
|
218
|
+
if not isinstance(global_parameters, dict):
|
|
219
|
+
raise ValueError("Top-level Parameters must be a mapping")
|
|
220
|
+
patch_names = _patch_parameter_names(config)
|
|
221
|
+
variables = []
|
|
222
|
+
for name, bounds_value in block.items():
|
|
223
|
+
if name not in global_parameters:
|
|
224
|
+
raise ValueError(f"Unknown global calibration parameter: {name!r}")
|
|
225
|
+
if name in patch_names:
|
|
226
|
+
raise ValueError(f"Cannot fit global parameter {name!r}; it is also set in PatchParameters")
|
|
227
|
+
lower, upper = _bounds(bounds_value, f"Calibration parameter {name!r}")
|
|
228
|
+
baseline = _finite_float(global_parameters[name], f"Configured parameter {name!r}")
|
|
229
|
+
if not lower <= baseline <= upper:
|
|
230
|
+
raise ValueError(f"Configured parameter {name!r} must lie inside its calibration bounds")
|
|
231
|
+
variables.append(FitVariable("parameter", name, lower, upper, baseline))
|
|
232
|
+
return variables
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _initial_variables(
|
|
236
|
+
net: NetworkModel,
|
|
237
|
+
y0: dict[str, float],
|
|
238
|
+
block: Any,
|
|
239
|
+
) -> list[FitVariable]:
|
|
240
|
+
if block is None:
|
|
241
|
+
return []
|
|
242
|
+
if not isinstance(block, list):
|
|
243
|
+
raise ValueError("Calibration InitialConditions must be a list")
|
|
244
|
+
patch_indices = {name: index for index, name in enumerate(net.patch_names)}
|
|
245
|
+
group_indices = {name: index for index, name in enumerate(net.groups)}
|
|
246
|
+
compartments = list(net.base_model.compartments)
|
|
247
|
+
seen_cells: set[tuple[int, int]] = set()
|
|
248
|
+
variables = []
|
|
249
|
+
|
|
250
|
+
for entry in block:
|
|
251
|
+
if not isinstance(entry, dict):
|
|
252
|
+
raise ValueError("Each Calibration InitialConditions entry must be a mapping")
|
|
253
|
+
patch = entry.get("Patch")
|
|
254
|
+
if patch not in patch_indices:
|
|
255
|
+
raise ValueError(f"Calibration InitialConditions uses unknown patch: {patch!r}")
|
|
256
|
+
if net.groups:
|
|
257
|
+
group = entry.get("Group")
|
|
258
|
+
if group not in group_indices:
|
|
259
|
+
raise ValueError(f"Calibration InitialConditions uses unknown group: {group!r}")
|
|
260
|
+
group_index = group_indices[group]
|
|
261
|
+
else:
|
|
262
|
+
if "Group" in entry:
|
|
263
|
+
raise ValueError("Calibration InitialConditions Group is forbidden for an ungrouped model")
|
|
264
|
+
group = None
|
|
265
|
+
group_index = 0
|
|
266
|
+
patch_index = patch_indices[patch]
|
|
267
|
+
cell = (patch_index, group_index)
|
|
268
|
+
if cell in seen_cells:
|
|
269
|
+
raise ValueError(f"Calibration InitialConditions repeats patch/group cell: {patch!r}, {group!r}")
|
|
270
|
+
seen_cells.add(cell)
|
|
271
|
+
|
|
272
|
+
remainder = entry.get("Remainder")
|
|
273
|
+
if remainder not in compartments:
|
|
274
|
+
raise ValueError(f"Calibration InitialConditions uses unknown remainder compartment: {remainder!r}")
|
|
275
|
+
fit = entry.get("Fit")
|
|
276
|
+
if not isinstance(fit, dict) or not fit:
|
|
277
|
+
raise ValueError("Calibration InitialConditions Fit must be a non-empty mapping")
|
|
278
|
+
if remainder in fit:
|
|
279
|
+
raise ValueError("Calibration initial-condition remainder cannot also be fitted")
|
|
280
|
+
unknown = sorted(set(fit) - set(compartments))
|
|
281
|
+
if unknown:
|
|
282
|
+
raise ValueError(f"Calibration InitialConditions fits unknown compartments: {unknown}")
|
|
283
|
+
|
|
284
|
+
remainder_key = net.state_key(remainder, patch_index, group_index)
|
|
285
|
+
fitted_upper = 0.0
|
|
286
|
+
fitted = []
|
|
287
|
+
for compartment, bounds_value in fit.items():
|
|
288
|
+
lower, upper = _bounds(
|
|
289
|
+
bounds_value,
|
|
290
|
+
f"Calibration initial condition {patch!r}/{group!r}/{compartment!r}",
|
|
291
|
+
non_negative=True,
|
|
292
|
+
)
|
|
293
|
+
state_key = net.state_key(compartment, patch_index, group_index)
|
|
294
|
+
baseline = _finite_float(y0[state_key], f"Seed value {patch!r}/{group!r}/{compartment!r}")
|
|
295
|
+
if not lower <= baseline <= upper:
|
|
296
|
+
raise ValueError(
|
|
297
|
+
f"Seed value {patch!r}/{group!r}/{compartment!r} must lie inside its calibration bounds"
|
|
298
|
+
)
|
|
299
|
+
fitted_upper += upper
|
|
300
|
+
fitted.append(
|
|
301
|
+
FitVariable(
|
|
302
|
+
"initial",
|
|
303
|
+
compartment,
|
|
304
|
+
lower,
|
|
305
|
+
upper,
|
|
306
|
+
baseline,
|
|
307
|
+
patch=str(patch),
|
|
308
|
+
group=str(group) if group is not None else None,
|
|
309
|
+
compartment=compartment,
|
|
310
|
+
state_key=state_key,
|
|
311
|
+
remainder_key=remainder_key,
|
|
312
|
+
patch_index=patch_index,
|
|
313
|
+
group_index=group_index,
|
|
314
|
+
)
|
|
315
|
+
)
|
|
316
|
+
fixed_sum = sum(
|
|
317
|
+
y0[net.state_key(compartment, patch_index, group_index)]
|
|
318
|
+
for compartment in compartments
|
|
319
|
+
if compartment not in fit and compartment != remainder
|
|
320
|
+
)
|
|
321
|
+
population = sum(y0[net.state_key(compartment, patch_index, group_index)] for compartment in compartments)
|
|
322
|
+
if fitted_upper + fixed_sum > population:
|
|
323
|
+
raise ValueError(
|
|
324
|
+
f"Calibration initial-condition bounds for patch {patch!r}, group {group!r} "
|
|
325
|
+
"cannot guarantee a non-negative remainder"
|
|
326
|
+
)
|
|
327
|
+
variables.extend(fitted)
|
|
328
|
+
return variables
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _parse_starts(calibration: dict[str, Any], variables: tuple[FitVariable, ...]) -> tuple[tuple[float, ...], ...]:
|
|
332
|
+
baseline = tuple(variable.baseline for variable in variables)
|
|
333
|
+
starts = [baseline]
|
|
334
|
+
additional = calibration.get("Starts", [])
|
|
335
|
+
if not isinstance(additional, list):
|
|
336
|
+
raise ValueError("Calibration Starts must be a list")
|
|
337
|
+
|
|
338
|
+
parameter_variables = [variable for variable in variables if variable.kind == "parameter"]
|
|
339
|
+
initial_variables = [variable for variable in variables if variable.kind == "initial"]
|
|
340
|
+
initial_by_cell: dict[tuple[str, str | None], list[FitVariable]] = {}
|
|
341
|
+
for variable in initial_variables:
|
|
342
|
+
initial_by_cell.setdefault((variable.patch or "", variable.group), []).append(variable)
|
|
343
|
+
|
|
344
|
+
for start_index, entry in enumerate(additional, start=1):
|
|
345
|
+
if not isinstance(entry, dict):
|
|
346
|
+
raise ValueError(f"Calibration start {start_index} must be a mapping")
|
|
347
|
+
parameter_values = entry.get("Parameters", {})
|
|
348
|
+
if not isinstance(parameter_values, dict) or set(parameter_values) != {v.name for v in parameter_variables}:
|
|
349
|
+
raise ValueError(f"Calibration start {start_index} must provide every fitted global parameter")
|
|
350
|
+
|
|
351
|
+
values: dict[tuple[str, str | None, str | None, str], float] = {}
|
|
352
|
+
for variable in parameter_variables:
|
|
353
|
+
values[variable.identity] = _finite_float(
|
|
354
|
+
parameter_values[variable.name], f"Calibration start {start_index} parameter {variable.name!r}"
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
initial_entries = entry.get("InitialConditions", [])
|
|
358
|
+
if not isinstance(initial_entries, list):
|
|
359
|
+
raise ValueError(f"Calibration start {start_index} InitialConditions must be a list")
|
|
360
|
+
seen_cells: set[tuple[str, str | None]] = set()
|
|
361
|
+
for initial_entry in initial_entries:
|
|
362
|
+
if not isinstance(initial_entry, dict):
|
|
363
|
+
raise ValueError(f"Calibration start {start_index} InitialConditions entries must be mappings")
|
|
364
|
+
cell = (str(initial_entry.get("Patch")), initial_entry.get("Group"))
|
|
365
|
+
if cell not in initial_by_cell or cell in seen_cells:
|
|
366
|
+
raise ValueError(f"Calibration start {start_index} has an unknown or duplicate initial-condition cell")
|
|
367
|
+
seen_cells.add(cell)
|
|
368
|
+
cell_values = initial_entry.get("Values")
|
|
369
|
+
cell_variables = initial_by_cell[cell]
|
|
370
|
+
if not isinstance(cell_values, dict) or set(cell_values) != {v.name for v in cell_variables}:
|
|
371
|
+
raise ValueError(f"Calibration start {start_index} must provide every fitted initial condition")
|
|
372
|
+
for variable in cell_variables:
|
|
373
|
+
values[variable.identity] = _finite_float(
|
|
374
|
+
cell_values[variable.name],
|
|
375
|
+
f"Calibration start {start_index} initial condition {variable.name!r}",
|
|
376
|
+
)
|
|
377
|
+
if seen_cells != set(initial_by_cell):
|
|
378
|
+
raise ValueError(f"Calibration start {start_index} must provide every fitted initial condition")
|
|
379
|
+
|
|
380
|
+
vector = tuple(values[variable.identity] for variable in variables)
|
|
381
|
+
for variable, value in zip(variables, vector, strict=True):
|
|
382
|
+
if not variable.lower <= value <= variable.upper:
|
|
383
|
+
raise ValueError(f"Calibration start {start_index} value for {variable.name!r} is outside bounds")
|
|
384
|
+
if vector in starts:
|
|
385
|
+
raise ValueError(f"Calibration start {start_index} duplicates an existing start")
|
|
386
|
+
starts.append(vector)
|
|
387
|
+
return tuple(starts)
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def get_calibration_plan(
|
|
391
|
+
config: dict[str, Any],
|
|
392
|
+
net: NetworkModel,
|
|
393
|
+
y0: dict[str, float],
|
|
394
|
+
*,
|
|
395
|
+
required: bool = False,
|
|
396
|
+
) -> CalibrationPlan | None:
|
|
397
|
+
calibration = config.get("Calibration")
|
|
398
|
+
if calibration is None:
|
|
399
|
+
if required:
|
|
400
|
+
raise ValueError("Calibration configuration is required")
|
|
401
|
+
return None
|
|
402
|
+
if not isinstance(calibration, dict):
|
|
403
|
+
raise ValueError("Calibration must be a mapping")
|
|
404
|
+
name = _safe_name(calibration.get("Name"))
|
|
405
|
+
if calibration.get("Method") != "least_squares":
|
|
406
|
+
raise ValueError("Calibration Method must be 'least_squares'")
|
|
407
|
+
max_evaluations = calibration.get("MaxEvaluations")
|
|
408
|
+
if isinstance(max_evaluations, bool) or not isinstance(max_evaluations, int) or max_evaluations <= 0:
|
|
409
|
+
raise ValueError("Calibration MaxEvaluations must be a positive integer")
|
|
410
|
+
|
|
411
|
+
observables = _parse_observables(calibration.get("Observables"), set(net.all_compartments))
|
|
412
|
+
observations_value = calibration.get("Observations")
|
|
413
|
+
if not isinstance(observations_value, str) or not observations_value:
|
|
414
|
+
raise ValueError("Calibration Observations must be a file path")
|
|
415
|
+
observations_path = Path(observations_value)
|
|
416
|
+
observations, warnings = _load_observations(observations_path, observables, config)
|
|
417
|
+
|
|
418
|
+
variables = tuple(
|
|
419
|
+
[
|
|
420
|
+
*_global_variables(config, calibration.get("Parameters")),
|
|
421
|
+
*_initial_variables(net, y0, calibration.get("InitialConditions")),
|
|
422
|
+
]
|
|
423
|
+
)
|
|
424
|
+
if not variables:
|
|
425
|
+
raise ValueError("Calibration must fit at least one global parameter or initial condition")
|
|
426
|
+
if max_evaluations < len(variables) + 1:
|
|
427
|
+
raise ValueError("Calibration MaxEvaluations must be at least p + 1")
|
|
428
|
+
if len(observations) < len(variables):
|
|
429
|
+
raise ValueError(
|
|
430
|
+
f"Calibration is underdetermined: n={len(observations)} usable observations, p={len(variables)} variables"
|
|
431
|
+
)
|
|
432
|
+
if len(observations) == len(variables):
|
|
433
|
+
warnings.append("Calibration has n == p; there is no residual redundancy")
|
|
434
|
+
starts = _parse_starts(calibration, variables)
|
|
435
|
+
if len(starts) == 1:
|
|
436
|
+
warnings.append("Calibration uses one starting point for a bounded local method")
|
|
437
|
+
return CalibrationPlan(
|
|
438
|
+
name=name,
|
|
439
|
+
observations_path=observations_path,
|
|
440
|
+
observations=observations,
|
|
441
|
+
observables=observables,
|
|
442
|
+
variables=variables,
|
|
443
|
+
starts=starts,
|
|
444
|
+
max_evaluations=max_evaluations,
|
|
445
|
+
warnings=tuple(warnings),
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _sha256(path: Path) -> str:
|
|
450
|
+
return hashlib.sha256(path.read_bytes()).hexdigest()
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _canonical_bytes(value: Any) -> bytes:
|
|
454
|
+
return json.dumps(value, sort_keys=True, separators=(",", ":"), allow_nan=False, default=str).encode("utf-8")
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _software_versions() -> dict[str, str]:
|
|
458
|
+
try:
|
|
459
|
+
patchsim_version = version("patchsim")
|
|
460
|
+
except PackageNotFoundError:
|
|
461
|
+
patchsim_version = "0.1.0"
|
|
462
|
+
return {
|
|
463
|
+
"patchsim": patchsim_version,
|
|
464
|
+
"python": platform.python_version(),
|
|
465
|
+
"numpy": np.__version__,
|
|
466
|
+
"pandas": pd.__version__,
|
|
467
|
+
"scipy": scipy.__version__,
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _request_record(config: dict[str, Any], config_path: Path, plan: CalibrationPlan) -> dict[str, Any]:
|
|
472
|
+
input_paths = {
|
|
473
|
+
key: Path(config[key])
|
|
474
|
+
for key in ("PatchFile", "SeedFile", "NetworkFile", "GroupFile", "InteractionFile")
|
|
475
|
+
if isinstance(config.get(key), str)
|
|
476
|
+
}
|
|
477
|
+
return {
|
|
478
|
+
"normalized_config": deepcopy(config),
|
|
479
|
+
"source_config_sha256": _sha256(config_path),
|
|
480
|
+
"observation_sha256": _sha256(plan.observations_path),
|
|
481
|
+
"input_sha256": {key: _sha256(path) for key, path in input_paths.items()},
|
|
482
|
+
"variable_order": [list(variable.identity) for variable in plan.variables],
|
|
483
|
+
"starts": [list(start) for start in plan.starts],
|
|
484
|
+
"method": {
|
|
485
|
+
"name": "least_squares",
|
|
486
|
+
"method": "trf",
|
|
487
|
+
"jac": "2-point",
|
|
488
|
+
"loss": "linear",
|
|
489
|
+
"ftol": 1e-8,
|
|
490
|
+
"xtol": 1e-8,
|
|
491
|
+
"gtol": 1e-8,
|
|
492
|
+
"max_forward_simulations_per_start": plan.max_evaluations,
|
|
493
|
+
},
|
|
494
|
+
"versions": _software_versions(),
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def _artifact_paths(target: Path) -> dict[str, str]:
|
|
499
|
+
return {
|
|
500
|
+
"output_dir": str(target),
|
|
501
|
+
"estimates_path": str(target / "estimates.csv"),
|
|
502
|
+
"fitted_seeds_path": str(target / "fitted-seeds.csv"),
|
|
503
|
+
"attempts_path": str(target / "attempts.csv"),
|
|
504
|
+
"residuals_path": str(target / "residuals.csv"),
|
|
505
|
+
"manifest_path": str(target / "manifest.json"),
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def _reuse_existing(target: Path, fingerprint: str, plan: CalibrationPlan) -> dict[str, Any]:
|
|
510
|
+
manifest_path = target / "manifest.json"
|
|
511
|
+
try:
|
|
512
|
+
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
513
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
514
|
+
raise FileExistsError(f"Calibration output {target} exists but is missing or modified") from exc
|
|
515
|
+
request = manifest.get("request")
|
|
516
|
+
if (
|
|
517
|
+
manifest.get("schema_version") != 1
|
|
518
|
+
or not isinstance(request, dict)
|
|
519
|
+
or hashlib.sha256(_canonical_bytes(request)).hexdigest() != fingerprint
|
|
520
|
+
or manifest.get("fingerprint") != fingerprint
|
|
521
|
+
or manifest.get("n") != plan.n
|
|
522
|
+
or manifest.get("p") != plan.p
|
|
523
|
+
or manifest.get("start_count") != plan.start_count
|
|
524
|
+
):
|
|
525
|
+
raise FileExistsError(f"Calibration output {target} belongs to a different study")
|
|
526
|
+
artifacts = manifest.get("artifacts", {})
|
|
527
|
+
for filename in _ARTIFACT_NAMES:
|
|
528
|
+
path = target / filename
|
|
529
|
+
if not path.is_file() or artifacts.get(filename, {}).get("sha256") != _sha256(path):
|
|
530
|
+
raise FileExistsError(f"Calibration output {target} has a missing or modified artifact")
|
|
531
|
+
return {
|
|
532
|
+
**_artifact_paths(target),
|
|
533
|
+
"reused": True,
|
|
534
|
+
"n": manifest["n"],
|
|
535
|
+
"p": manifest["p"],
|
|
536
|
+
"start_count": manifest["start_count"],
|
|
537
|
+
"selected_start": manifest["selected_start"],
|
|
538
|
+
"forward_simulations": 0,
|
|
539
|
+
"warnings": manifest.get("warnings", []),
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def _apply_vector(
|
|
544
|
+
net: NetworkModel,
|
|
545
|
+
baseline_y0: dict[str, float],
|
|
546
|
+
variables: tuple[FitVariable, ...],
|
|
547
|
+
vector: np.ndarray,
|
|
548
|
+
) -> dict[str, float]:
|
|
549
|
+
y0 = baseline_y0.copy()
|
|
550
|
+
remainder_variables: dict[str, FitVariable] = {}
|
|
551
|
+
for variable, value in zip(variables, vector, strict=True):
|
|
552
|
+
value = float(value)
|
|
553
|
+
if variable.kind == "parameter":
|
|
554
|
+
net.base_model.parameters[variable.name] = value
|
|
555
|
+
for patch_parameters in net.patch_parameters.values():
|
|
556
|
+
patch_parameters[variable.name] = value
|
|
557
|
+
else:
|
|
558
|
+
assert variable.state_key is not None and variable.remainder_key is not None
|
|
559
|
+
y0[variable.state_key] = value
|
|
560
|
+
remainder_variables.setdefault(variable.remainder_key, variable)
|
|
561
|
+
for remainder_key, variable in remainder_variables.items():
|
|
562
|
+
assert variable.patch_index is not None and variable.group_index is not None
|
|
563
|
+
baseline_total = sum(
|
|
564
|
+
baseline_y0[net.state_key(compartment, variable.patch_index, variable.group_index)]
|
|
565
|
+
for compartment in net.base_model.compartments
|
|
566
|
+
)
|
|
567
|
+
non_remainder_sum = sum(
|
|
568
|
+
y0[net.state_key(compartment, variable.patch_index, variable.group_index)]
|
|
569
|
+
for compartment in net.base_model.compartments
|
|
570
|
+
if net.state_key(compartment, variable.patch_index, variable.group_index) != remainder_key
|
|
571
|
+
)
|
|
572
|
+
remainder = baseline_total - non_remainder_sum
|
|
573
|
+
if not np.isfinite(remainder) or remainder < 0:
|
|
574
|
+
raise ValueError(
|
|
575
|
+
f"Fitted initial state produced an invalid remainder for "
|
|
576
|
+
f"patch {variable.patch!r}, group {variable.group!r}"
|
|
577
|
+
)
|
|
578
|
+
y0[remainder_key] = remainder
|
|
579
|
+
return y0
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def _prediction_arrays(frame: pd.DataFrame, plan: CalibrationPlan) -> tuple[np.ndarray, np.ndarray]:
|
|
583
|
+
observable_map = {observable.name: observable for observable in plan.observables}
|
|
584
|
+
predicted = []
|
|
585
|
+
scales = []
|
|
586
|
+
for row in plan.observations.itertuples(index=False):
|
|
587
|
+
observable = observable_map[row.observable]
|
|
588
|
+
predicted.append(float(frame.loc[row.grid_index, list(observable.columns)].sum(skipna=False)))
|
|
589
|
+
scales.append(observable.scale)
|
|
590
|
+
predictions = np.asarray(predicted, dtype=float)
|
|
591
|
+
scale_values = np.asarray(scales, dtype=float)
|
|
592
|
+
if not np.isfinite(predictions).all():
|
|
593
|
+
raise ValueError("Calibration simulation produced non-finite predictions")
|
|
594
|
+
return predictions, scale_values
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def _seed_frame(net: NetworkModel, y0: dict[str, float]) -> pd.DataFrame:
|
|
598
|
+
rows = []
|
|
599
|
+
for patch_index, patch in enumerate(net.patch_names):
|
|
600
|
+
for group_index, group in enumerate(net.groups or [None]):
|
|
601
|
+
row: dict[str, Any] = {"patch": patch}
|
|
602
|
+
if net.groups:
|
|
603
|
+
row["group"] = group
|
|
604
|
+
row.update(
|
|
605
|
+
{
|
|
606
|
+
compartment: y0[net.state_key(compartment, patch_index, group_index)]
|
|
607
|
+
for compartment in net.base_model.compartments
|
|
608
|
+
}
|
|
609
|
+
)
|
|
610
|
+
rows.append(row)
|
|
611
|
+
return pd.DataFrame(rows)
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def run_calibration(
|
|
615
|
+
config_path: str | Path,
|
|
616
|
+
*,
|
|
617
|
+
progress: Callable[[str], None] | None = None,
|
|
618
|
+
) -> dict[str, Any]:
|
|
619
|
+
started = time.monotonic()
|
|
620
|
+
source_path = Path(config_path).expanduser().resolve()
|
|
621
|
+
config = load_config(str(source_path))
|
|
622
|
+
net, baseline_y0, _patches, _num_patches = setup_simulation(config)
|
|
623
|
+
plan = get_calibration_plan(config, net, baseline_y0, required=True)
|
|
624
|
+
assert plan is not None
|
|
625
|
+
if progress:
|
|
626
|
+
progress(f"Observations: {plan.n}; fitted variables: {plan.p}; starts: {plan.start_count}")
|
|
627
|
+
progress(f"Maximum forward simulations: {plan.max_forward_simulations}")
|
|
628
|
+
for warning in plan.warnings:
|
|
629
|
+
progress(f"Warning: {warning}")
|
|
630
|
+
if (plan.observations["grid_index"] < 0).any():
|
|
631
|
+
raise ValueError("Calibration cannot start with unmatched observation times")
|
|
632
|
+
|
|
633
|
+
request = _request_record(config, source_path, plan)
|
|
634
|
+
fingerprint = hashlib.sha256(_canonical_bytes(request)).hexdigest()
|
|
635
|
+
output_root = Path(config["OutputDir"]).resolve()
|
|
636
|
+
calibration_root = (output_root / "calibration").resolve()
|
|
637
|
+
if not calibration_root.is_relative_to(output_root):
|
|
638
|
+
raise ValueError("Calibration output path escapes OutputDir")
|
|
639
|
+
target = calibration_root / plan.name
|
|
640
|
+
if target.is_symlink() or not target.resolve().is_relative_to(calibration_root):
|
|
641
|
+
raise ValueError("Calibration output path escapes its study directory")
|
|
642
|
+
if target.exists():
|
|
643
|
+
result = _reuse_existing(target, fingerprint, plan)
|
|
644
|
+
result["elapsed_seconds"] = time.monotonic() - started
|
|
645
|
+
return result
|
|
646
|
+
|
|
647
|
+
lower = np.asarray([variable.lower for variable in plan.variables], dtype=float)
|
|
648
|
+
upper = np.asarray([variable.upper for variable in plan.variables], dtype=float)
|
|
649
|
+
observed = plan.observations["value"].to_numpy(dtype=float)
|
|
650
|
+
attempts: list[dict[str, Any]] = []
|
|
651
|
+
successes: list[tuple[int, Any, int]] = []
|
|
652
|
+
total_forward_simulations = 0
|
|
653
|
+
|
|
654
|
+
for start_index, start_vector in enumerate(plan.starts):
|
|
655
|
+
calls = 0
|
|
656
|
+
|
|
657
|
+
def residual(vector):
|
|
658
|
+
nonlocal calls, total_forward_simulations
|
|
659
|
+
if calls >= plan.max_evaluations:
|
|
660
|
+
raise _BudgetExceeded(f"forward-simulation budget {plan.max_evaluations} exhausted")
|
|
661
|
+
calls += 1
|
|
662
|
+
total_forward_simulations += 1
|
|
663
|
+
y0 = _apply_vector(net, baseline_y0, plan.variables, np.asarray(vector, dtype=float))
|
|
664
|
+
frame = _simulate_prepared(config, net, y0)
|
|
665
|
+
predictions, scales = _prediction_arrays(frame, plan)
|
|
666
|
+
values = (predictions - observed) / scales
|
|
667
|
+
if not np.isfinite(values).all():
|
|
668
|
+
raise ValueError("Calibration produced non-finite residuals")
|
|
669
|
+
return values
|
|
670
|
+
|
|
671
|
+
try:
|
|
672
|
+
result = least_squares(
|
|
673
|
+
residual,
|
|
674
|
+
np.asarray(start_vector, dtype=float),
|
|
675
|
+
jac="2-point",
|
|
676
|
+
bounds=(lower, upper),
|
|
677
|
+
method="trf",
|
|
678
|
+
ftol=1e-8,
|
|
679
|
+
xtol=1e-8,
|
|
680
|
+
gtol=1e-8,
|
|
681
|
+
x_scale=upper - lower,
|
|
682
|
+
loss="linear",
|
|
683
|
+
max_nfev=plan.max_evaluations,
|
|
684
|
+
)
|
|
685
|
+
finite = all(
|
|
686
|
+
np.isfinite(value).all()
|
|
687
|
+
for value in (np.asarray(result.x), np.asarray(result.fun), np.asarray(result.jac))
|
|
688
|
+
) and np.isfinite(result.cost)
|
|
689
|
+
success = bool(result.success and finite)
|
|
690
|
+
message = str(result.message).replace("\n", " ")[:500]
|
|
691
|
+
attempts.append(
|
|
692
|
+
{
|
|
693
|
+
"start": start_index,
|
|
694
|
+
"success": success,
|
|
695
|
+
"status": int(result.status),
|
|
696
|
+
"cost": float(result.cost) if np.isfinite(result.cost) else None,
|
|
697
|
+
"optimality": float(result.optimality) if np.isfinite(result.optimality) else None,
|
|
698
|
+
"nfev": int(result.nfev),
|
|
699
|
+
"njev": int(result.njev) if result.njev is not None else None,
|
|
700
|
+
"forward_simulations": calls,
|
|
701
|
+
"message": message,
|
|
702
|
+
}
|
|
703
|
+
)
|
|
704
|
+
if success:
|
|
705
|
+
successes.append((start_index, result, calls))
|
|
706
|
+
except Exception as exc:
|
|
707
|
+
attempts.append(
|
|
708
|
+
{
|
|
709
|
+
"start": start_index,
|
|
710
|
+
"success": False,
|
|
711
|
+
"status": None,
|
|
712
|
+
"cost": None,
|
|
713
|
+
"optimality": None,
|
|
714
|
+
"nfev": None,
|
|
715
|
+
"njev": None,
|
|
716
|
+
"forward_simulations": calls,
|
|
717
|
+
"message": str(exc).replace("\n", " ")[:500],
|
|
718
|
+
}
|
|
719
|
+
)
|
|
720
|
+
|
|
721
|
+
if not successes:
|
|
722
|
+
details = "; ".join(f"start {attempt['start']}: {attempt['message']}" for attempt in attempts)
|
|
723
|
+
raise RuntimeError(f"Calibration failed: no starting point terminated successfully ({details})")
|
|
724
|
+
selected_start, selected, _selected_calls = min(successes, key=lambda item: (float(item[1].cost), item[0]))
|
|
725
|
+
selected_vector = np.asarray(selected.x, dtype=float)
|
|
726
|
+
selected_y0 = _apply_vector(net, baseline_y0, plan.variables, selected_vector)
|
|
727
|
+
selected_residuals = np.asarray(selected.fun, dtype=float)
|
|
728
|
+
observable_scales = {observable.name: observable.scale for observable in plan.observables}
|
|
729
|
+
scales = plan.observations["observable"].map(observable_scales).to_numpy(dtype=float)
|
|
730
|
+
predictions = observed + selected_residuals * scales
|
|
731
|
+
|
|
732
|
+
singular_values = np.linalg.svd(np.asarray(selected.jac, dtype=float), compute_uv=False)
|
|
733
|
+
rank_tolerance = (
|
|
734
|
+
float(singular_values[0]) * max(plan.n, plan.p) * np.finfo(np.float64).eps if len(singular_values) else 0.0
|
|
735
|
+
)
|
|
736
|
+
rank = int(np.sum(singular_values > rank_tolerance))
|
|
737
|
+
condition = float(singular_values[0] / singular_values[-1]) if rank == plan.p and singular_values[-1] > 0 else None
|
|
738
|
+
|
|
739
|
+
estimate_rows = []
|
|
740
|
+
for index, (variable, value) in enumerate(zip(plan.variables, selected_vector, strict=True)):
|
|
741
|
+
estimate_rows.append(
|
|
742
|
+
{
|
|
743
|
+
"kind": variable.kind,
|
|
744
|
+
"name": variable.name,
|
|
745
|
+
"patch": variable.patch,
|
|
746
|
+
"group": variable.group,
|
|
747
|
+
"value": float(value),
|
|
748
|
+
"lower": variable.lower,
|
|
749
|
+
"upper": variable.upper,
|
|
750
|
+
"active_bound": int(selected.active_mask[index]),
|
|
751
|
+
}
|
|
752
|
+
)
|
|
753
|
+
estimates = pd.DataFrame(estimate_rows)
|
|
754
|
+
fitted_seeds = _seed_frame(net, selected_y0)
|
|
755
|
+
attempts_frame = pd.DataFrame(attempts)
|
|
756
|
+
residuals = pd.DataFrame(
|
|
757
|
+
{
|
|
758
|
+
"time": plan.observations["time"].to_numpy(dtype=float),
|
|
759
|
+
"observable": plan.observations["observable"].tolist(),
|
|
760
|
+
"observed": observed,
|
|
761
|
+
"prediction": predictions,
|
|
762
|
+
"residual": predictions - observed,
|
|
763
|
+
"scale": scales,
|
|
764
|
+
"standardized_residual": selected_residuals,
|
|
765
|
+
}
|
|
766
|
+
)
|
|
767
|
+
residual_summary = {
|
|
768
|
+
"rmse": float(np.sqrt(np.mean(np.square(residuals["residual"].to_numpy(dtype=float))))),
|
|
769
|
+
"standardized_rmse": float(np.sqrt(np.mean(np.square(selected_residuals)))),
|
|
770
|
+
"by_observable": {},
|
|
771
|
+
}
|
|
772
|
+
for observable in plan.observables:
|
|
773
|
+
subset = residuals.loc[residuals["observable"] == observable.name]
|
|
774
|
+
raw = subset["residual"].to_numpy(dtype=float)
|
|
775
|
+
standardized = subset["standardized_residual"].to_numpy(dtype=float)
|
|
776
|
+
residual_summary["by_observable"][observable.name] = {
|
|
777
|
+
"n": len(subset),
|
|
778
|
+
"rmse": float(np.sqrt(np.mean(np.square(raw)))),
|
|
779
|
+
"standardized_rmse": float(np.sqrt(np.mean(np.square(standardized)))),
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
calibration_root.mkdir(parents=True, exist_ok=True)
|
|
783
|
+
temporary = Path(tempfile.mkdtemp(prefix=f".{plan.name}.", dir=calibration_root))
|
|
784
|
+
try:
|
|
785
|
+
frames = {
|
|
786
|
+
"estimates.csv": estimates,
|
|
787
|
+
"fitted-seeds.csv": fitted_seeds,
|
|
788
|
+
"attempts.csv": attempts_frame,
|
|
789
|
+
"residuals.csv": residuals,
|
|
790
|
+
}
|
|
791
|
+
for filename, frame in frames.items():
|
|
792
|
+
frame.to_csv(temporary / filename, index=False, lineterminator="\n")
|
|
793
|
+
artifacts = {filename: {"sha256": _sha256(temporary / filename)} for filename in _ARTIFACT_NAMES}
|
|
794
|
+
manifest = {
|
|
795
|
+
"schema_version": 1,
|
|
796
|
+
"fingerprint": fingerprint,
|
|
797
|
+
"request": request,
|
|
798
|
+
"n": plan.n,
|
|
799
|
+
"p": plan.p,
|
|
800
|
+
"start_count": plan.start_count,
|
|
801
|
+
"max_forward_simulations": plan.max_forward_simulations,
|
|
802
|
+
"selected_start": selected_start,
|
|
803
|
+
"selected_attempt": attempts[selected_start],
|
|
804
|
+
"forward_simulations": total_forward_simulations,
|
|
805
|
+
"warnings": list(plan.warnings),
|
|
806
|
+
"residual_summary": residual_summary,
|
|
807
|
+
"jacobian": {
|
|
808
|
+
"singular_values": singular_values.tolist(),
|
|
809
|
+
"rank_tolerance": rank_tolerance,
|
|
810
|
+
"rank": rank,
|
|
811
|
+
"rank_deficient": rank < plan.p,
|
|
812
|
+
"condition_number": condition,
|
|
813
|
+
},
|
|
814
|
+
"artifacts": artifacts,
|
|
815
|
+
}
|
|
816
|
+
(temporary / "manifest.json").write_bytes(_canonical_bytes(manifest) + b"\n")
|
|
817
|
+
temporary.replace(target)
|
|
818
|
+
except BaseException:
|
|
819
|
+
for child in temporary.iterdir():
|
|
820
|
+
child.unlink()
|
|
821
|
+
temporary.rmdir()
|
|
822
|
+
raise
|
|
823
|
+
|
|
824
|
+
return {
|
|
825
|
+
**_artifact_paths(target),
|
|
826
|
+
"reused": False,
|
|
827
|
+
"n": plan.n,
|
|
828
|
+
"p": plan.p,
|
|
829
|
+
"start_count": plan.start_count,
|
|
830
|
+
"selected_start": selected_start,
|
|
831
|
+
"forward_simulations": total_forward_simulations,
|
|
832
|
+
"warnings": list(plan.warnings),
|
|
833
|
+
"elapsed_seconds": time.monotonic() - started,
|
|
834
|
+
}
|