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.
@@ -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
+ }