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
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# This file marks the `models` directory as a Python package.
|
patchsim/sensitivity.py
ADDED
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
"""Sobol sensitivity studies for PatchSim configurations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
import shutil
|
|
9
|
+
import tempfile
|
|
10
|
+
import time
|
|
11
|
+
from copy import deepcopy
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
14
|
+
from numbers import Real
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Callable
|
|
17
|
+
|
|
18
|
+
import numpy as np
|
|
19
|
+
import pandas as pd
|
|
20
|
+
|
|
21
|
+
from patchsim.core.simulation import _simulate_prepared, load_config, setup_simulation
|
|
22
|
+
|
|
23
|
+
_NAME_PATTERN = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]*\Z")
|
|
24
|
+
_INPUT_FIELDS = ("PatchFile", "SeedFile", "NetworkFile", "GroupFile", "InteractionFile")
|
|
25
|
+
_ARTIFACT_NAMES = ("samples.csv", "responses.csv", "indices.csv")
|
|
26
|
+
_MIN_SALIB_VERSION = (1, 5, 2)
|
|
27
|
+
_NUM_RESAMPLES = 100
|
|
28
|
+
_CONF_LEVEL = 0.95
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class Metric:
|
|
33
|
+
name: str
|
|
34
|
+
columns: tuple[str, ...]
|
|
35
|
+
reducer: str
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class SensitivityPlan:
|
|
40
|
+
name: str
|
|
41
|
+
base_samples: int
|
|
42
|
+
seed: int
|
|
43
|
+
parameters: tuple[tuple[str, float, float], ...]
|
|
44
|
+
metrics: tuple[Metric, ...]
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def evaluation_count(self) -> int:
|
|
48
|
+
return self.base_samples * (len(self.parameters) + 2)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _parse_version(value: str) -> tuple[int, int, int]:
|
|
52
|
+
match = re.match(r"(\d+)\.(\d+)\.(\d+)", value)
|
|
53
|
+
return tuple(map(int, match.groups())) if match else (0, 0, 0)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _load_salib():
|
|
57
|
+
try:
|
|
58
|
+
salib_version = version("SALib")
|
|
59
|
+
from SALib.analyze import sobol as sobol_analyze
|
|
60
|
+
from SALib.sample import sobol as sobol_sample
|
|
61
|
+
except (ImportError, PackageNotFoundError) as exc:
|
|
62
|
+
raise RuntimeError(
|
|
63
|
+
"Sensitivity analysis requires SALib 1.5.2 or newer. "
|
|
64
|
+
'Install it with `python -m pip install "patchsim[analysis]"`.'
|
|
65
|
+
) from exc
|
|
66
|
+
if _parse_version(salib_version) < _MIN_SALIB_VERSION:
|
|
67
|
+
raise RuntimeError(
|
|
68
|
+
f"Sensitivity analysis requires SALib 1.5.2 or newer; found {salib_version}. "
|
|
69
|
+
'Upgrade with `python -m pip install --upgrade "patchsim[analysis]"`.'
|
|
70
|
+
)
|
|
71
|
+
return sobol_sample, sobol_analyze, salib_version
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _patch_parameter_names(config: dict[str, Any]) -> set[str]:
|
|
75
|
+
names: set[str] = set()
|
|
76
|
+
for entry in config.get("PatchParameters", []):
|
|
77
|
+
if isinstance(entry, dict) and isinstance(entry.get("parameters", {}), dict):
|
|
78
|
+
names.update(entry.get("parameters", {}))
|
|
79
|
+
return names
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def get_sensitivity_plan(
|
|
83
|
+
config: dict[str, Any],
|
|
84
|
+
output_columns: list[str],
|
|
85
|
+
*,
|
|
86
|
+
required: bool = True,
|
|
87
|
+
) -> SensitivityPlan | None:
|
|
88
|
+
"""Validate the optional Sensitivity block against a prepared model."""
|
|
89
|
+
raw = config.get("Sensitivity")
|
|
90
|
+
if raw is None:
|
|
91
|
+
if required:
|
|
92
|
+
raise ValueError("The configuration has no 'Sensitivity' block")
|
|
93
|
+
return None
|
|
94
|
+
if not isinstance(raw, dict):
|
|
95
|
+
raise ValueError("'Sensitivity' must be a mapping")
|
|
96
|
+
|
|
97
|
+
name = raw.get("Name")
|
|
98
|
+
if not isinstance(name, str) or not _NAME_PATTERN.fullmatch(name) or name in {".", ".."}:
|
|
99
|
+
raise ValueError("'Sensitivity.Name' must be one safe path component using letters, numbers, '.', '_', or '-'")
|
|
100
|
+
if raw.get("Method") != "sobol":
|
|
101
|
+
raise ValueError("'Sensitivity.Method' must be 'sobol'")
|
|
102
|
+
|
|
103
|
+
base_samples = raw.get("BaseSamples")
|
|
104
|
+
if (
|
|
105
|
+
isinstance(base_samples, bool)
|
|
106
|
+
or not isinstance(base_samples, int)
|
|
107
|
+
or base_samples < 2
|
|
108
|
+
or base_samples & (base_samples - 1)
|
|
109
|
+
):
|
|
110
|
+
raise ValueError("'Sensitivity.BaseSamples' must be a power of two of at least 2")
|
|
111
|
+
seed = raw.get("Seed")
|
|
112
|
+
if isinstance(seed, bool) or not isinstance(seed, int) or seed < 0:
|
|
113
|
+
raise ValueError("'Sensitivity.Seed' must be a non-negative integer")
|
|
114
|
+
|
|
115
|
+
global_parameters = config.get("Parameters", {})
|
|
116
|
+
if not isinstance(global_parameters, dict):
|
|
117
|
+
raise ValueError("'Parameters' must be a mapping")
|
|
118
|
+
patch_parameters = _patch_parameter_names(config)
|
|
119
|
+
raw_parameters = raw.get("Parameters")
|
|
120
|
+
if not isinstance(raw_parameters, dict) or not raw_parameters:
|
|
121
|
+
raise ValueError("'Sensitivity.Parameters' must contain at least one parameter")
|
|
122
|
+
|
|
123
|
+
parameters = []
|
|
124
|
+
for parameter_name, bounds in raw_parameters.items():
|
|
125
|
+
if not isinstance(parameter_name, str) or not parameter_name or parameter_name == "sample_id":
|
|
126
|
+
raise ValueError("Sensitivity parameter names must be non-empty and may not be 'sample_id'")
|
|
127
|
+
if parameter_name not in global_parameters:
|
|
128
|
+
raise ValueError(f"Unknown global sensitivity parameter: {parameter_name!r}")
|
|
129
|
+
if parameter_name in patch_parameters:
|
|
130
|
+
raise ValueError(f"Cannot sample {parameter_name!r}; it is also set in PatchParameters")
|
|
131
|
+
if not isinstance(bounds, (list, tuple)) or len(bounds) != 2:
|
|
132
|
+
raise ValueError(f"Bounds for {parameter_name!r} must be [lower, upper]")
|
|
133
|
+
lower, upper = bounds
|
|
134
|
+
if any(isinstance(value, bool) or not isinstance(value, Real) for value in bounds):
|
|
135
|
+
raise ValueError(f"Bounds for {parameter_name!r} must be finite real numbers")
|
|
136
|
+
lower, upper = float(lower), float(upper)
|
|
137
|
+
if not np.isfinite(lower) or not np.isfinite(upper) or lower >= upper:
|
|
138
|
+
raise ValueError(f"Bounds for {parameter_name!r} must satisfy finite lower < upper")
|
|
139
|
+
parameters.append((parameter_name, lower, upper))
|
|
140
|
+
|
|
141
|
+
raw_metrics = raw.get("Metrics")
|
|
142
|
+
if not isinstance(raw_metrics, dict) or not raw_metrics:
|
|
143
|
+
raise ValueError("'Sensitivity.Metrics' must contain at least one metric")
|
|
144
|
+
parameter_names = {name for name, _lower, _upper in parameters}
|
|
145
|
+
available_columns = set(output_columns)
|
|
146
|
+
metrics = []
|
|
147
|
+
for metric_name, metric_config in raw_metrics.items():
|
|
148
|
+
if (
|
|
149
|
+
not isinstance(metric_name, str)
|
|
150
|
+
or not metric_name
|
|
151
|
+
or metric_name == "sample_id"
|
|
152
|
+
or metric_name in parameter_names
|
|
153
|
+
):
|
|
154
|
+
raise ValueError(
|
|
155
|
+
"Sensitivity metric names must be non-empty and distinct from parameter columns and 'sample_id'"
|
|
156
|
+
)
|
|
157
|
+
if not isinstance(metric_config, dict):
|
|
158
|
+
raise ValueError(f"Metric {metric_name!r} must be a mapping")
|
|
159
|
+
columns = metric_config.get("Columns")
|
|
160
|
+
if (
|
|
161
|
+
not isinstance(columns, list)
|
|
162
|
+
or not columns
|
|
163
|
+
or not all(isinstance(column, str) for column in columns)
|
|
164
|
+
or len(columns) != len(set(columns))
|
|
165
|
+
):
|
|
166
|
+
raise ValueError(f"Metric {metric_name!r} Columns must be a non-empty list of unique names")
|
|
167
|
+
unknown = sorted(set(columns) - available_columns)
|
|
168
|
+
if unknown:
|
|
169
|
+
raise ValueError(f"Metric {metric_name!r} references unknown output columns: {unknown}")
|
|
170
|
+
reducer = metric_config.get("Reduce")
|
|
171
|
+
if reducer not in {"max", "final"}:
|
|
172
|
+
raise ValueError(f"Metric {metric_name!r} Reduce must be 'max' or 'final'")
|
|
173
|
+
metrics.append(Metric(metric_name, tuple(columns), reducer))
|
|
174
|
+
|
|
175
|
+
return SensitivityPlan(name, base_samples, seed, tuple(parameters), tuple(metrics))
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _sha256(path: Path) -> str:
|
|
179
|
+
return hashlib.sha256(path.read_bytes()).hexdigest()
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _canonical_bytes(value: Any) -> bytes:
|
|
183
|
+
return json.dumps(value, sort_keys=True, separators=(",", ":"), allow_nan=False, default=str).encode()
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _versions(salib_version: str) -> dict[str, str]:
|
|
187
|
+
result = {"SALib": salib_version}
|
|
188
|
+
for package in ("patchsim", "numpy", "pandas", "scipy"):
|
|
189
|
+
try:
|
|
190
|
+
result[package] = version(package)
|
|
191
|
+
except PackageNotFoundError:
|
|
192
|
+
result[package] = "unknown"
|
|
193
|
+
return result
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _request_record(
|
|
197
|
+
config: dict[str, Any],
|
|
198
|
+
salib_version: str,
|
|
199
|
+
source_config_sha256: str,
|
|
200
|
+
) -> dict[str, Any]:
|
|
201
|
+
inputs = {
|
|
202
|
+
field: {"path": str(config[field]), "sha256": _sha256(Path(config[field]))}
|
|
203
|
+
for field in _INPUT_FIELDS
|
|
204
|
+
if config.get(field)
|
|
205
|
+
}
|
|
206
|
+
method = {
|
|
207
|
+
"name": "sobol",
|
|
208
|
+
"calc_second_order": False,
|
|
209
|
+
"scramble": True,
|
|
210
|
+
"skip_values": 0,
|
|
211
|
+
"num_resamples": _NUM_RESAMPLES,
|
|
212
|
+
"conf_level": _CONF_LEVEL,
|
|
213
|
+
}
|
|
214
|
+
return {
|
|
215
|
+
"normalized_config": json.loads(json.dumps(config, default=str)),
|
|
216
|
+
"source_config_sha256": source_config_sha256,
|
|
217
|
+
"inputs": inputs,
|
|
218
|
+
"method": method,
|
|
219
|
+
"versions": _versions(salib_version),
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _artifact_paths(target: Path) -> dict[str, str]:
|
|
224
|
+
return {
|
|
225
|
+
"output_dir": str(target),
|
|
226
|
+
"samples_path": str(target / "samples.csv"),
|
|
227
|
+
"responses_path": str(target / "responses.csv"),
|
|
228
|
+
"indices_path": str(target / "indices.csv"),
|
|
229
|
+
"manifest_path": str(target / "manifest.json"),
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _reuse_existing(
|
|
234
|
+
target: Path,
|
|
235
|
+
fingerprint: str,
|
|
236
|
+
evaluation_count: int,
|
|
237
|
+
) -> dict[str, Any]:
|
|
238
|
+
manifest_path = target / "manifest.json"
|
|
239
|
+
try:
|
|
240
|
+
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
241
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
242
|
+
raise FileExistsError(f"Existing sensitivity target is incomplete: {target}") from exc
|
|
243
|
+
request = manifest.get("request")
|
|
244
|
+
if (
|
|
245
|
+
manifest.get("schema_version") != 1
|
|
246
|
+
or not isinstance(request, dict)
|
|
247
|
+
or hashlib.sha256(_canonical_bytes(request)).hexdigest() != fingerprint
|
|
248
|
+
or manifest.get("study_fingerprint") != fingerprint
|
|
249
|
+
or manifest.get("evaluation_count") != evaluation_count
|
|
250
|
+
or manifest.get("source_config", {}).get("sha256") != request.get("source_config_sha256")
|
|
251
|
+
):
|
|
252
|
+
raise FileExistsError(f"Existing sensitivity target was produced by a different study: {target}")
|
|
253
|
+
|
|
254
|
+
recorded = manifest.get("artifacts", {})
|
|
255
|
+
for filename in _ARTIFACT_NAMES:
|
|
256
|
+
path = target / filename
|
|
257
|
+
expected = recorded.get(filename, {}).get("sha256")
|
|
258
|
+
if not path.is_file() or expected != _sha256(path):
|
|
259
|
+
raise FileExistsError(f"Existing sensitivity artifact is missing or modified: {path}")
|
|
260
|
+
|
|
261
|
+
return {
|
|
262
|
+
**_artifact_paths(target),
|
|
263
|
+
"reused": True,
|
|
264
|
+
"planned_evaluations": evaluation_count,
|
|
265
|
+
"completed_evaluations": 0,
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _metric_value(frame: pd.DataFrame, metric: Metric) -> float:
|
|
270
|
+
series = frame.loc[:, metric.columns].sum(axis=1, skipna=False)
|
|
271
|
+
return float(series.max() if metric.reducer == "max" else series.iloc[-1])
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def run_sensitivity(
|
|
275
|
+
config_path: str | Path,
|
|
276
|
+
*,
|
|
277
|
+
progress: Callable[[str], None] | None = None,
|
|
278
|
+
) -> dict[str, Any]:
|
|
279
|
+
"""Run or reuse one configured Sobol sensitivity study."""
|
|
280
|
+
started = time.monotonic()
|
|
281
|
+
source_path = Path(config_path).expanduser().resolve()
|
|
282
|
+
config = load_config(str(source_path))
|
|
283
|
+
request_config = deepcopy(config)
|
|
284
|
+
net, y0, _patches, _num_patches = setup_simulation(config)
|
|
285
|
+
plan = get_sensitivity_plan(config, list(net.all_compartments))
|
|
286
|
+
assert plan is not None
|
|
287
|
+
if progress:
|
|
288
|
+
progress(f"Planned model evaluations: {plan.evaluation_count}")
|
|
289
|
+
|
|
290
|
+
sobol_sample, sobol_analyze, salib_version = _load_salib()
|
|
291
|
+
source_config_sha256 = _sha256(source_path)
|
|
292
|
+
request = _request_record(request_config, salib_version, source_config_sha256)
|
|
293
|
+
fingerprint = hashlib.sha256(_canonical_bytes(request)).hexdigest()
|
|
294
|
+
output_root = Path(config["OutputDir"]).resolve()
|
|
295
|
+
sensitivity_root = output_root / "sensitivity"
|
|
296
|
+
resolved_sensitivity_root = sensitivity_root.resolve()
|
|
297
|
+
if not resolved_sensitivity_root.is_relative_to(output_root):
|
|
298
|
+
raise ValueError(f"Sensitivity output path escapes OutputDir: {sensitivity_root}")
|
|
299
|
+
target = sensitivity_root / plan.name
|
|
300
|
+
if target.is_symlink() or not target.resolve().is_relative_to(resolved_sensitivity_root):
|
|
301
|
+
raise ValueError(f"Sensitivity output path escapes its study directory: {target}")
|
|
302
|
+
if target.exists():
|
|
303
|
+
summary = _reuse_existing(target, fingerprint, plan.evaluation_count)
|
|
304
|
+
summary["elapsed_seconds"] = time.monotonic() - started
|
|
305
|
+
return summary
|
|
306
|
+
|
|
307
|
+
names = [name for name, _lower, _upper in plan.parameters]
|
|
308
|
+
problem = {
|
|
309
|
+
"num_vars": len(names),
|
|
310
|
+
"names": names,
|
|
311
|
+
"bounds": [[lower, upper] for _name, lower, upper in plan.parameters],
|
|
312
|
+
}
|
|
313
|
+
samples = sobol_sample.sample(
|
|
314
|
+
problem,
|
|
315
|
+
plan.base_samples,
|
|
316
|
+
calc_second_order=False,
|
|
317
|
+
scramble=True,
|
|
318
|
+
skip_values=0,
|
|
319
|
+
seed=plan.seed,
|
|
320
|
+
)
|
|
321
|
+
responses = {metric.name: [] for metric in plan.metrics}
|
|
322
|
+
|
|
323
|
+
for sample_id, sample in enumerate(samples):
|
|
324
|
+
values = dict(zip(names, map(float, sample), strict=True))
|
|
325
|
+
net.base_model.parameters.update(values)
|
|
326
|
+
for patch_parameters in net.patch_parameters.values():
|
|
327
|
+
patch_parameters.update(values)
|
|
328
|
+
try:
|
|
329
|
+
frame = _simulate_prepared(config, net, y0)
|
|
330
|
+
metric_values = {metric.name: _metric_value(frame, metric) for metric in plan.metrics}
|
|
331
|
+
except Exception as exc:
|
|
332
|
+
raise RuntimeError(f"Sensitivity evaluation {sample_id} failed for parameters {values}: {exc}") from exc
|
|
333
|
+
non_finite = [name for name, value in metric_values.items() if not np.isfinite(value)]
|
|
334
|
+
if non_finite:
|
|
335
|
+
raise RuntimeError(
|
|
336
|
+
f"Sensitivity evaluation {sample_id} produced non-finite metrics {non_finite} for parameters {values}"
|
|
337
|
+
)
|
|
338
|
+
for name, value in metric_values.items():
|
|
339
|
+
responses[name].append(value)
|
|
340
|
+
|
|
341
|
+
index_rows = []
|
|
342
|
+
for metric in plan.metrics:
|
|
343
|
+
values = np.asarray(responses[metric.name], dtype=float)
|
|
344
|
+
if np.ptp(values) == 0:
|
|
345
|
+
raise ValueError(f"Sensitivity metric {metric.name!r} is constant at {values[0]}")
|
|
346
|
+
indices = sobol_analyze.analyze(
|
|
347
|
+
problem,
|
|
348
|
+
values,
|
|
349
|
+
calc_second_order=False,
|
|
350
|
+
num_resamples=_NUM_RESAMPLES,
|
|
351
|
+
conf_level=_CONF_LEVEL,
|
|
352
|
+
print_to_console=False,
|
|
353
|
+
parallel=False,
|
|
354
|
+
seed=plan.seed,
|
|
355
|
+
)
|
|
356
|
+
for index, parameter_name in enumerate(names):
|
|
357
|
+
estimates = {
|
|
358
|
+
"S1": float(indices["S1"][index]),
|
|
359
|
+
"S1_conf": float(indices["S1_conf"][index]),
|
|
360
|
+
"ST": float(indices["ST"][index]),
|
|
361
|
+
"ST_conf": float(indices["ST_conf"][index]),
|
|
362
|
+
}
|
|
363
|
+
if not all(np.isfinite(value) for value in estimates.values()):
|
|
364
|
+
raise ValueError(
|
|
365
|
+
f"Sobol analysis produced non-finite indices for metric "
|
|
366
|
+
f"{metric.name!r}, parameter {parameter_name!r}"
|
|
367
|
+
)
|
|
368
|
+
index_rows.append(
|
|
369
|
+
{
|
|
370
|
+
"metric": metric.name,
|
|
371
|
+
"parameter": parameter_name,
|
|
372
|
+
**estimates,
|
|
373
|
+
}
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
samples_frame = pd.DataFrame(samples, columns=names)
|
|
377
|
+
samples_frame.insert(0, "sample_id", np.arange(len(samples), dtype=int))
|
|
378
|
+
responses_frame = pd.DataFrame(responses)
|
|
379
|
+
responses_frame.insert(0, "sample_id", np.arange(len(samples), dtype=int))
|
|
380
|
+
indices_frame = pd.DataFrame(index_rows)
|
|
381
|
+
|
|
382
|
+
parent = target.parent
|
|
383
|
+
parent.mkdir(parents=True, exist_ok=True)
|
|
384
|
+
temporary = Path(tempfile.mkdtemp(prefix=f".{plan.name}.", dir=parent))
|
|
385
|
+
try:
|
|
386
|
+
samples_frame.to_csv(temporary / "samples.csv", index=False, lineterminator="\n")
|
|
387
|
+
responses_frame.to_csv(temporary / "responses.csv", index=False, lineterminator="\n")
|
|
388
|
+
indices_frame.to_csv(temporary / "indices.csv", index=False, lineterminator="\n")
|
|
389
|
+
artifacts = {filename: {"sha256": _sha256(temporary / filename)} for filename in _ARTIFACT_NAMES}
|
|
390
|
+
manifest = {
|
|
391
|
+
"schema_version": 1,
|
|
392
|
+
"study_fingerprint": fingerprint,
|
|
393
|
+
"evaluation_count": plan.evaluation_count,
|
|
394
|
+
"request": request,
|
|
395
|
+
"source_config": {"path": str(source_path), "sha256": source_config_sha256},
|
|
396
|
+
"artifacts": artifacts,
|
|
397
|
+
}
|
|
398
|
+
(temporary / "manifest.json").write_text(
|
|
399
|
+
json.dumps(manifest, indent=2, sort_keys=True, allow_nan=False) + "\n",
|
|
400
|
+
encoding="utf-8",
|
|
401
|
+
)
|
|
402
|
+
try:
|
|
403
|
+
temporary.rename(target)
|
|
404
|
+
except OSError:
|
|
405
|
+
if not target.exists():
|
|
406
|
+
raise
|
|
407
|
+
summary = _reuse_existing(target, fingerprint, plan.evaluation_count)
|
|
408
|
+
summary["elapsed_seconds"] = time.monotonic() - started
|
|
409
|
+
return summary
|
|
410
|
+
finally:
|
|
411
|
+
if temporary.exists():
|
|
412
|
+
shutil.rmtree(temporary)
|
|
413
|
+
|
|
414
|
+
return {
|
|
415
|
+
**_artifact_paths(target),
|
|
416
|
+
"reused": False,
|
|
417
|
+
"planned_evaluations": plan.evaluation_count,
|
|
418
|
+
"completed_evaluations": plan.evaluation_count,
|
|
419
|
+
"elapsed_seconds": time.monotonic() - started,
|
|
420
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# -----------------------------------------------------------------------------
|
|
2
|
+
# PatchSim project configuration
|
|
3
|
+
# -----------------------------------------------------------------------------
|
|
4
|
+
# This project was created by: patchsim init {{PROJECT_NAME}}
|
|
5
|
+
|
|
6
|
+
# Input files (relative to this config file)
|
|
7
|
+
PatchFile: data/patch/patch-population.csv
|
|
8
|
+
NetworkFile: data/networks/network-static.csv
|
|
9
|
+
SeedFile: data/seeds/seed-initial.csv
|
|
10
|
+
Logging: False
|
|
11
|
+
|
|
12
|
+
# Model configuration
|
|
13
|
+
ModelName: {{PROJECT_NAME}}
|
|
14
|
+
|
|
15
|
+
# Simulation parameters
|
|
16
|
+
TMax: 60
|
|
17
|
+
Solver: ode
|
|
18
|
+
TimeStep: 1.0
|
|
19
|
+
Tolerance: 1e-8
|
|
20
|
+
MaxIter: 10000
|
|
21
|
+
StartDate: 2020-01-01
|
|
22
|
+
EndDate: 2022-12-31
|
|
23
|
+
OutputDir: output/{{PROJECT_NAME}}
|
|
24
|
+
compartments: ["S", "I", "R"]
|
|
25
|
+
|
|
26
|
+
# Global model parameters
|
|
27
|
+
Parameters:
|
|
28
|
+
beta: 0.08
|
|
29
|
+
gamma: 0.10
|
|
30
|
+
|
|
31
|
+
# Transition map (required format)
|
|
32
|
+
# Use arrow notation only: X -> Y
|
|
33
|
+
Transitions: {S -> I: "beta", I -> R: "gamma * I"}
|
|
File without changes
|
|
File without changes
|