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,878 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This module is reserved for reusable simulation utilities or wrappers.
|
|
3
|
+
ODE and discrete simulation logic is implemented in core/model.py.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import hashlib
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
from copy import deepcopy
|
|
10
|
+
from numbers import Real
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
import pandas as pd
|
|
16
|
+
import yaml
|
|
17
|
+
|
|
18
|
+
from patchsim.core.model import CompartmentalModel, NetworkModel, _validate_expression_names
|
|
19
|
+
from patchsim.utils.logger import setup_logger
|
|
20
|
+
from patchsim.utils.viz import plot_patch_subplots
|
|
21
|
+
|
|
22
|
+
EPSILON = 1e-6
|
|
23
|
+
DEFAULT_SOLVER = "ode"
|
|
24
|
+
DEFAULT_TIME_STEP = 1.0
|
|
25
|
+
SOLVERS = ("ode", "discrete")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
MODEL_TEMPLATE_CONFIGS: dict[str, dict[str, Any]] = {
|
|
29
|
+
"sir": {
|
|
30
|
+
"compartments": ["S", "I", "R"],
|
|
31
|
+
"Parameters": {"beta": 0.08, "gamma": 0.1},
|
|
32
|
+
"Transitions": {"S -> I": "beta", "I -> R": "gamma * I"},
|
|
33
|
+
},
|
|
34
|
+
"seir": {
|
|
35
|
+
"compartments": ["S", "E", "I", "R"],
|
|
36
|
+
"Parameters": {"beta": 0.08, "sigma": 0.2, "gamma": 0.1},
|
|
37
|
+
"Transitions": {"S -> E": "beta", "E -> I": "sigma * E", "I -> R": "gamma * I"},
|
|
38
|
+
},
|
|
39
|
+
"sirs": {
|
|
40
|
+
"compartments": ["S", "I", "R"],
|
|
41
|
+
"Parameters": {"beta": 0.08, "gamma": 0.1, "waning": 0.02},
|
|
42
|
+
"Transitions": {"S -> I": "beta", "I -> R": "gamma * I", "R -> S": "waning * R"},
|
|
43
|
+
},
|
|
44
|
+
"sis": {
|
|
45
|
+
"compartments": ["S", "I"],
|
|
46
|
+
"Parameters": {"beta": 0.08, "gamma": 0.1},
|
|
47
|
+
"Transitions": {"S -> I": "beta", "I -> S": "gamma * I"},
|
|
48
|
+
},
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def get_config_schema() -> dict[str, Any]:
|
|
53
|
+
"""Return the JSON Schema for PatchSim configuration files."""
|
|
54
|
+
return {
|
|
55
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
56
|
+
"$id": "https://dsih-artpark.github.io/patchsim/config.schema.json",
|
|
57
|
+
"title": "PatchSim configuration",
|
|
58
|
+
"type": "object",
|
|
59
|
+
"required": ["PatchFile", "SeedFile", "OutputDir", "Transitions", "TMax"],
|
|
60
|
+
"properties": {
|
|
61
|
+
"PatchFile": {"type": "string"},
|
|
62
|
+
"SeedFile": {"type": "string"},
|
|
63
|
+
"NetworkFile": {"type": ["string", "null"]},
|
|
64
|
+
"GroupFile": {"type": "string"},
|
|
65
|
+
"InteractionFile": {"type": "string"},
|
|
66
|
+
"InteractionUnits": {"type": "string", "minLength": 1},
|
|
67
|
+
"OutputDir": {"type": "string"},
|
|
68
|
+
"ModelName": {"type": "string"},
|
|
69
|
+
"TMax": {"type": "integer", "minimum": 1},
|
|
70
|
+
"Solver": {"type": "string", "enum": list(SOLVERS), "default": DEFAULT_SOLVER},
|
|
71
|
+
"TimeStep": {
|
|
72
|
+
"type": "number",
|
|
73
|
+
"exclusiveMinimum": 0,
|
|
74
|
+
"default": DEFAULT_TIME_STEP,
|
|
75
|
+
},
|
|
76
|
+
"Tolerance": {"type": ["number", "string"]},
|
|
77
|
+
"MaxIter": {"type": "integer", "minimum": 1},
|
|
78
|
+
"StartDate": {"type": ["string", "null"]},
|
|
79
|
+
"EndDate": {"type": ["string", "null"]},
|
|
80
|
+
"Logging": {"type": ["boolean", "string"]},
|
|
81
|
+
"compartments": {
|
|
82
|
+
"type": "array",
|
|
83
|
+
"items": {"type": "string"},
|
|
84
|
+
"minItems": 1,
|
|
85
|
+
},
|
|
86
|
+
"Compartments": {
|
|
87
|
+
"type": "array",
|
|
88
|
+
"items": {"type": "string"},
|
|
89
|
+
"minItems": 1,
|
|
90
|
+
},
|
|
91
|
+
"Parameters": {
|
|
92
|
+
"type": "object",
|
|
93
|
+
"additionalProperties": {"type": ["number", "integer", "string", "boolean"]},
|
|
94
|
+
},
|
|
95
|
+
"PatchParameters": {
|
|
96
|
+
"type": "array",
|
|
97
|
+
"items": {
|
|
98
|
+
"type": "object",
|
|
99
|
+
"required": ["patch"],
|
|
100
|
+
"properties": {
|
|
101
|
+
"patch": {"type": "string"},
|
|
102
|
+
"parameters": {"type": "object"},
|
|
103
|
+
},
|
|
104
|
+
"additionalProperties": True,
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
"Sensitivity": {
|
|
108
|
+
"type": "object",
|
|
109
|
+
"required": ["Name", "Method", "BaseSamples", "Seed", "Parameters", "Metrics"],
|
|
110
|
+
"properties": {
|
|
111
|
+
"Name": {
|
|
112
|
+
"type": "string",
|
|
113
|
+
"pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$",
|
|
114
|
+
},
|
|
115
|
+
"Method": {"const": "sobol"},
|
|
116
|
+
"BaseSamples": {
|
|
117
|
+
"type": "integer",
|
|
118
|
+
"minimum": 2,
|
|
119
|
+
"description": "Power of two; minimum 2.",
|
|
120
|
+
},
|
|
121
|
+
"Seed": {"type": "integer", "minimum": 0},
|
|
122
|
+
"Parameters": {
|
|
123
|
+
"type": "object",
|
|
124
|
+
"minProperties": 1,
|
|
125
|
+
"additionalProperties": {
|
|
126
|
+
"type": "array",
|
|
127
|
+
"prefixItems": [{"type": "number"}, {"type": "number"}],
|
|
128
|
+
"minItems": 2,
|
|
129
|
+
"maxItems": 2,
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
"Metrics": {
|
|
133
|
+
"type": "object",
|
|
134
|
+
"minProperties": 1,
|
|
135
|
+
"additionalProperties": {
|
|
136
|
+
"type": "object",
|
|
137
|
+
"required": ["Columns", "Reduce"],
|
|
138
|
+
"properties": {
|
|
139
|
+
"Columns": {
|
|
140
|
+
"type": "array",
|
|
141
|
+
"items": {"type": "string"},
|
|
142
|
+
"minItems": 1,
|
|
143
|
+
"uniqueItems": True,
|
|
144
|
+
},
|
|
145
|
+
"Reduce": {"type": "string", "enum": ["max", "final"]},
|
|
146
|
+
},
|
|
147
|
+
"additionalProperties": False,
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
"additionalProperties": False,
|
|
152
|
+
},
|
|
153
|
+
"Calibration": {
|
|
154
|
+
"type": "object",
|
|
155
|
+
"required": ["Name", "Method", "Observations", "MaxEvaluations", "Observables"],
|
|
156
|
+
"properties": {
|
|
157
|
+
"Name": {
|
|
158
|
+
"type": "string",
|
|
159
|
+
"pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$",
|
|
160
|
+
},
|
|
161
|
+
"Method": {"const": "least_squares"},
|
|
162
|
+
"Observations": {"type": "string", "minLength": 1},
|
|
163
|
+
"MaxEvaluations": {"type": "integer", "minimum": 1},
|
|
164
|
+
"Observables": {
|
|
165
|
+
"type": "object",
|
|
166
|
+
"minProperties": 1,
|
|
167
|
+
"additionalProperties": {
|
|
168
|
+
"type": "object",
|
|
169
|
+
"required": ["Columns", "Scale"],
|
|
170
|
+
"properties": {
|
|
171
|
+
"Columns": {
|
|
172
|
+
"type": "array",
|
|
173
|
+
"items": {"type": "string"},
|
|
174
|
+
"minItems": 1,
|
|
175
|
+
"uniqueItems": True,
|
|
176
|
+
},
|
|
177
|
+
"Scale": {"type": "number", "exclusiveMinimum": 0},
|
|
178
|
+
},
|
|
179
|
+
"additionalProperties": False,
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
"Parameters": {
|
|
183
|
+
"type": "object",
|
|
184
|
+
"minProperties": 1,
|
|
185
|
+
"additionalProperties": {
|
|
186
|
+
"type": "array",
|
|
187
|
+
"prefixItems": [{"type": "number"}, {"type": "number"}],
|
|
188
|
+
"minItems": 2,
|
|
189
|
+
"maxItems": 2,
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
"InitialConditions": {
|
|
193
|
+
"type": "array",
|
|
194
|
+
"minItems": 1,
|
|
195
|
+
"items": {
|
|
196
|
+
"type": "object",
|
|
197
|
+
"required": ["Patch", "Remainder", "Fit"],
|
|
198
|
+
"properties": {
|
|
199
|
+
"Patch": {"type": "string"},
|
|
200
|
+
"Group": {"type": "string"},
|
|
201
|
+
"Remainder": {"type": "string"},
|
|
202
|
+
"Fit": {
|
|
203
|
+
"type": "object",
|
|
204
|
+
"minProperties": 1,
|
|
205
|
+
"additionalProperties": {
|
|
206
|
+
"type": "array",
|
|
207
|
+
"prefixItems": [{"type": "number"}, {"type": "number"}],
|
|
208
|
+
"minItems": 2,
|
|
209
|
+
"maxItems": 2,
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
},
|
|
213
|
+
"additionalProperties": False,
|
|
214
|
+
},
|
|
215
|
+
},
|
|
216
|
+
"Starts": {
|
|
217
|
+
"type": "array",
|
|
218
|
+
"items": {
|
|
219
|
+
"type": "object",
|
|
220
|
+
"properties": {
|
|
221
|
+
"Parameters": {"type": "object"},
|
|
222
|
+
"InitialConditions": {"type": "array"},
|
|
223
|
+
},
|
|
224
|
+
"additionalProperties": False,
|
|
225
|
+
},
|
|
226
|
+
},
|
|
227
|
+
},
|
|
228
|
+
"anyOf": [{"required": ["Parameters"]}, {"required": ["InitialConditions"]}],
|
|
229
|
+
"additionalProperties": False,
|
|
230
|
+
},
|
|
231
|
+
"Transitions": {
|
|
232
|
+
"type": "object",
|
|
233
|
+
"minProperties": 1,
|
|
234
|
+
"additionalProperties": {"type": "string"},
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
"dependentRequired": {
|
|
238
|
+
"GroupFile": ["InteractionFile", "InteractionUnits"],
|
|
239
|
+
"InteractionFile": ["GroupFile", "InteractionUnits"],
|
|
240
|
+
"InteractionUnits": ["GroupFile", "InteractionFile"],
|
|
241
|
+
},
|
|
242
|
+
"additionalProperties": True,
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def get_model_catalog() -> list[dict[str, str]]:
|
|
247
|
+
"""Return built-in model references and YAML templates for the CLI."""
|
|
248
|
+
return [{"name": name, "kind": "yaml-template"} for name in sorted(MODEL_TEMPLATE_CONFIGS)]
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def get_available_template_names() -> list[str]:
|
|
252
|
+
"""Return the built-in starter template names."""
|
|
253
|
+
return sorted(MODEL_TEMPLATE_CONFIGS)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def get_init_template_config(template_name: str, project_name: str) -> dict[str, Any]:
|
|
257
|
+
"""Return a starter project config for the requested template."""
|
|
258
|
+
try:
|
|
259
|
+
template = MODEL_TEMPLATE_CONFIGS[template_name]
|
|
260
|
+
except KeyError as e:
|
|
261
|
+
raise ValueError(
|
|
262
|
+
f"Unknown template '{template_name}'. Available templates: {sorted(MODEL_TEMPLATE_CONFIGS)}"
|
|
263
|
+
) from e
|
|
264
|
+
|
|
265
|
+
config: dict[str, Any] = {
|
|
266
|
+
"PatchFile": "data/patch/patch-population.csv",
|
|
267
|
+
"NetworkFile": "data/networks/network-static.csv",
|
|
268
|
+
"SeedFile": "data/seeds/seed-initial.csv",
|
|
269
|
+
"Logging": False,
|
|
270
|
+
"ModelName": project_name,
|
|
271
|
+
"TMax": 60,
|
|
272
|
+
"Solver": DEFAULT_SOLVER,
|
|
273
|
+
"TimeStep": DEFAULT_TIME_STEP,
|
|
274
|
+
"Tolerance": 1e-8,
|
|
275
|
+
"MaxIter": 10000,
|
|
276
|
+
"StartDate": "2020-01-01",
|
|
277
|
+
"EndDate": "2022-12-31",
|
|
278
|
+
"OutputDir": f"output/{project_name}",
|
|
279
|
+
}
|
|
280
|
+
config.update(template)
|
|
281
|
+
return config
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def load_config(config_path: str) -> dict[str, Any]:
|
|
285
|
+
"""Load and validate configuration file."""
|
|
286
|
+
cfg_path = Path(config_path).expanduser().resolve()
|
|
287
|
+
with open(cfg_path, encoding="utf-8") as f:
|
|
288
|
+
config = yaml.safe_load(f)
|
|
289
|
+
|
|
290
|
+
if not isinstance(config, dict):
|
|
291
|
+
raise ValueError(
|
|
292
|
+
f"Configuration error in {cfg_path}: expected YAML mapping (key-value pairs), "
|
|
293
|
+
"but got a list or scalar value instead. \n"
|
|
294
|
+
"Ensure the config file has the format: key1: value1\\n key2: value2"
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
# Validate required fields
|
|
298
|
+
required_fields = ["PatchFile", "SeedFile", "OutputDir", "Transitions", "TMax"]
|
|
299
|
+
for field in required_fields:
|
|
300
|
+
if field not in config:
|
|
301
|
+
available = ", ".join(sorted(config.keys()))
|
|
302
|
+
raise ValueError(
|
|
303
|
+
f"Configuration error: missing required field '{field}'.\n"
|
|
304
|
+
f"Available fields in config: {available}\n"
|
|
305
|
+
f"Please add '{field}' to your config file."
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
solver, _t_max, time_step = get_run_settings(config)
|
|
309
|
+
config["Solver"] = solver
|
|
310
|
+
config["TimeStep"] = time_step
|
|
311
|
+
|
|
312
|
+
# Resolve relative paths against the config file directory.
|
|
313
|
+
cfg_dir = cfg_path.parent
|
|
314
|
+
for key in ["PatchFile", "SeedFile", "NetworkFile", "GroupFile", "InteractionFile", "OutputDir"]:
|
|
315
|
+
val = config.get(key)
|
|
316
|
+
if isinstance(val, str) and val.strip():
|
|
317
|
+
p = Path(val).expanduser()
|
|
318
|
+
if not p.is_absolute():
|
|
319
|
+
config[key] = str((cfg_dir / p).resolve())
|
|
320
|
+
|
|
321
|
+
calibration = config.get("Calibration")
|
|
322
|
+
if isinstance(calibration, dict):
|
|
323
|
+
observations = calibration.get("Observations")
|
|
324
|
+
if isinstance(observations, str) and observations.strip():
|
|
325
|
+
path = Path(observations).expanduser()
|
|
326
|
+
if not path.is_absolute():
|
|
327
|
+
calibration["Observations"] = str((cfg_dir / path).resolve())
|
|
328
|
+
|
|
329
|
+
return config
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def get_run_settings(config: dict[str, Any]) -> tuple[str, int, float]:
|
|
333
|
+
"""Validate and return the solver, reporting-point count, and grid interval."""
|
|
334
|
+
solver = config.get("Solver", DEFAULT_SOLVER)
|
|
335
|
+
if not isinstance(solver, str) or solver not in SOLVERS:
|
|
336
|
+
raise ValueError(f"'Solver' must be one of {list(SOLVERS)}; received {solver!r}")
|
|
337
|
+
|
|
338
|
+
t_max = config.get("TMax")
|
|
339
|
+
if isinstance(t_max, bool) or not isinstance(t_max, int) or t_max <= 0:
|
|
340
|
+
raise ValueError(f"'TMax' must be a positive integer count of reporting points; received {t_max!r}")
|
|
341
|
+
|
|
342
|
+
time_step = config.get("TimeStep", DEFAULT_TIME_STEP)
|
|
343
|
+
if isinstance(time_step, bool) or not isinstance(time_step, Real):
|
|
344
|
+
raise ValueError(f"'TimeStep' must be a finite positive number; received {time_step!r}")
|
|
345
|
+
time_step = float(time_step)
|
|
346
|
+
if not np.isfinite(time_step) or time_step <= 0:
|
|
347
|
+
raise ValueError(f"'TimeStep' must be a finite positive number; received {time_step!r}")
|
|
348
|
+
return solver, t_max, time_step
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _numeric_values(frame: pd.DataFrame, columns: list[str], source: str) -> None:
|
|
352
|
+
"""Convert selected columns to finite numbers in place."""
|
|
353
|
+
for column in columns:
|
|
354
|
+
try:
|
|
355
|
+
frame[column] = pd.to_numeric(frame[column], errors="raise")
|
|
356
|
+
except (TypeError, ValueError) as e:
|
|
357
|
+
raise ValueError(f"{source} column '{column}' must contain only numbers.") from e
|
|
358
|
+
if not np.all(np.isfinite(frame[column].to_numpy(dtype=float))):
|
|
359
|
+
raise ValueError(f"{source} column '{column}' must contain only finite numbers.")
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _load_groups(
|
|
363
|
+
config: dict[str, Any], patches: list[str], populations: dict[str, float]
|
|
364
|
+
) -> tuple[list[str], dict[tuple[str, str], float]]:
|
|
365
|
+
"""Load the optional patch-by-group population table."""
|
|
366
|
+
fields = ("GroupFile", "InteractionFile", "InteractionUnits")
|
|
367
|
+
present = [field for field in fields if config.get(field) is not None]
|
|
368
|
+
if present and len(present) != len(fields):
|
|
369
|
+
missing = [field for field in fields if config.get(field) is None]
|
|
370
|
+
raise ValueError(f"Grouped simulations require {list(fields)} together; missing {missing}.")
|
|
371
|
+
if not present:
|
|
372
|
+
return [], {}
|
|
373
|
+
|
|
374
|
+
units = config["InteractionUnits"]
|
|
375
|
+
if not isinstance(units, str) or not units.strip():
|
|
376
|
+
raise ValueError("'InteractionUnits' must be a non-empty unit description.")
|
|
377
|
+
|
|
378
|
+
path = config["GroupFile"]
|
|
379
|
+
header = pd.read_csv(path, nrows=0).columns
|
|
380
|
+
patch_col = next((c for c in header if c.lower() == "patch"), None)
|
|
381
|
+
group_col = next((c for c in header if c.lower() == "group"), None)
|
|
382
|
+
pop_col = next((c for c in header if c.lower() == "population"), None)
|
|
383
|
+
if patch_col is None or group_col is None or pop_col is None:
|
|
384
|
+
raise ValueError(f"GroupFile ({path}) must have 'patch', 'group', and 'population' columns.")
|
|
385
|
+
|
|
386
|
+
frame = pd.read_csv(
|
|
387
|
+
path,
|
|
388
|
+
dtype={patch_col: "string", group_col: "string"},
|
|
389
|
+
keep_default_na=False,
|
|
390
|
+
).rename(columns={patch_col: "patch", group_col: "group", pop_col: "population"})
|
|
391
|
+
_numeric_values(frame, ["population"], f"GroupFile ({path})")
|
|
392
|
+
for column in ("patch", "group"):
|
|
393
|
+
values = frame[column].astype(str)
|
|
394
|
+
if any(not value or value != value.strip() for value in values):
|
|
395
|
+
raise ValueError(f"GroupFile ({path}) contains an empty or whitespace-padded {column} identifier.")
|
|
396
|
+
frame[column] = values
|
|
397
|
+
if (frame["population"] < 0).any():
|
|
398
|
+
raise ValueError(f"GroupFile ({path}) populations must be non-negative.")
|
|
399
|
+
if frame.duplicated(["patch", "group"]).any():
|
|
400
|
+
raise ValueError(f"GroupFile ({path}) contains duplicate patch/group pairs.")
|
|
401
|
+
|
|
402
|
+
patch_set = set(frame["patch"])
|
|
403
|
+
expected_patch_set = set(patches)
|
|
404
|
+
if patch_set != expected_patch_set:
|
|
405
|
+
raise ValueError(
|
|
406
|
+
f"GroupFile ({path}) patch identifiers do not match PatchFile "
|
|
407
|
+
f"(missing={sorted(expected_patch_set - patch_set)}, extra={sorted(patch_set - expected_patch_set)})."
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
first_patch_groups = frame.loc[frame["patch"] == patches[0], "group"].tolist()
|
|
411
|
+
if not first_patch_groups:
|
|
412
|
+
raise ValueError(f"GroupFile ({path}) has no groups for first patch '{patches[0]}'.")
|
|
413
|
+
groups = list(first_patch_groups)
|
|
414
|
+
expected_groups = set(groups)
|
|
415
|
+
rows_by_patch = {patch: rows for patch, rows in frame.groupby("patch", sort=False)}
|
|
416
|
+
for patch in patches:
|
|
417
|
+
patch_rows = rows_by_patch[patch]
|
|
418
|
+
actual = set(patch_rows["group"])
|
|
419
|
+
if actual != expected_groups:
|
|
420
|
+
raise ValueError(
|
|
421
|
+
f"GroupFile ({path}) groups for patch '{patch}' do not match the first patch "
|
|
422
|
+
f"(missing={sorted(expected_groups - actual)}, extra={sorted(actual - expected_groups)})."
|
|
423
|
+
)
|
|
424
|
+
total = float(patch_rows["population"].sum())
|
|
425
|
+
if abs(total - populations[patch]) >= EPSILON:
|
|
426
|
+
raise ValueError(
|
|
427
|
+
f"Group populations for patch '{patch}' sum to {total}, not PatchFile population {populations[patch]}."
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
group_populations = {
|
|
431
|
+
(row.patch, row.group): float(row.population)
|
|
432
|
+
for row in frame[["patch", "group", "population"]].itertuples(index=False)
|
|
433
|
+
}
|
|
434
|
+
return groups, group_populations
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _load_interactions(
|
|
438
|
+
config: dict[str, Any],
|
|
439
|
+
groups: list[str],
|
|
440
|
+
patches: list[str],
|
|
441
|
+
group_populations: dict[tuple[str, str], float],
|
|
442
|
+
network_matrix: np.ndarray,
|
|
443
|
+
) -> tuple[np.ndarray | None, dict[str, Any] | None]:
|
|
444
|
+
"""Load a shared group interaction matrix and return validation diagnostics."""
|
|
445
|
+
if not groups:
|
|
446
|
+
return None, None
|
|
447
|
+
|
|
448
|
+
path = config["InteractionFile"]
|
|
449
|
+
required = ["focal_group", "contributor_group", "weight"]
|
|
450
|
+
header = pd.read_csv(path, nrows=0).columns.tolist()
|
|
451
|
+
missing = [column for column in required if column not in header]
|
|
452
|
+
if missing:
|
|
453
|
+
raise ValueError(f"InteractionFile ({path}) is missing columns: {missing}.")
|
|
454
|
+
|
|
455
|
+
frame = pd.read_csv(
|
|
456
|
+
path,
|
|
457
|
+
dtype={"focal_group": "string", "contributor_group": "string"},
|
|
458
|
+
keep_default_na=False,
|
|
459
|
+
)
|
|
460
|
+
_numeric_values(frame, ["weight"], f"InteractionFile ({path})")
|
|
461
|
+
for column in ("focal_group", "contributor_group"):
|
|
462
|
+
values = frame[column].astype(str)
|
|
463
|
+
if any(not value or value != value.strip() for value in values):
|
|
464
|
+
raise ValueError(f"InteractionFile ({path}) contains an empty or whitespace-padded {column}.")
|
|
465
|
+
frame[column] = values
|
|
466
|
+
if (frame["weight"] < 0).any():
|
|
467
|
+
raise ValueError(f"InteractionFile ({path}) weights must be non-negative.")
|
|
468
|
+
if frame.duplicated(["focal_group", "contributor_group"]).any():
|
|
469
|
+
raise ValueError(f"InteractionFile ({path}) contains duplicate group pairs.")
|
|
470
|
+
|
|
471
|
+
expected_groups = set(groups)
|
|
472
|
+
for column in ("focal_group", "contributor_group"):
|
|
473
|
+
actual = set(frame[column])
|
|
474
|
+
unknown = actual - expected_groups
|
|
475
|
+
if unknown:
|
|
476
|
+
raise ValueError(f"InteractionFile ({path}) contains unknown groups in {column}: {sorted(unknown)}.")
|
|
477
|
+
absent = expected_groups - actual
|
|
478
|
+
if absent:
|
|
479
|
+
raise ValueError(
|
|
480
|
+
f"InteractionFile ({path}) must mention every group in {column}; missing {sorted(absent)}."
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
group_idx = {group: idx for idx, group in enumerate(groups)}
|
|
484
|
+
matrix = np.zeros((len(groups), len(groups)), dtype=float)
|
|
485
|
+
for row in frame.itertuples(index=False):
|
|
486
|
+
matrix[group_idx[row.focal_group], group_idx[row.contributor_group]] = float(row.weight)
|
|
487
|
+
|
|
488
|
+
max_reciprocity_residual = 0.0
|
|
489
|
+
for patch in patches:
|
|
490
|
+
for focal in groups:
|
|
491
|
+
for contributor in groups:
|
|
492
|
+
i = group_idx[focal]
|
|
493
|
+
j = group_idx[contributor]
|
|
494
|
+
forward = group_populations[(patch, focal)] * matrix[i, j]
|
|
495
|
+
reverse = group_populations[(patch, contributor)] * matrix[j, i]
|
|
496
|
+
denominator = max(forward, reverse)
|
|
497
|
+
residual = abs(forward - reverse) / denominator if denominator > 0 else 0.0
|
|
498
|
+
max_reciprocity_residual = max(max_reciprocity_residual, residual)
|
|
499
|
+
|
|
500
|
+
interaction_row_sums = matrix.sum(axis=1)
|
|
501
|
+
effective_spatial_matrix = np.ones((1, 1), dtype=float) if len(patches) == 1 else network_matrix
|
|
502
|
+
spatial_row_sums = effective_spatial_matrix.sum(axis=1)
|
|
503
|
+
diagnostics = {
|
|
504
|
+
"units": config["InteractionUnits"].strip(),
|
|
505
|
+
"sha256": hashlib.sha256(Path(path).read_bytes()).hexdigest(),
|
|
506
|
+
"interaction_row_sum": {
|
|
507
|
+
"min": float(interaction_row_sums.min()),
|
|
508
|
+
"max": float(interaction_row_sums.max()),
|
|
509
|
+
},
|
|
510
|
+
"spatial_row_sum": {
|
|
511
|
+
"min": float(spatial_row_sums.min()),
|
|
512
|
+
"max": float(spatial_row_sums.max()),
|
|
513
|
+
},
|
|
514
|
+
"max_local_reciprocity_residual": float(max_reciprocity_residual),
|
|
515
|
+
"reciprocity": "diagnostic_only",
|
|
516
|
+
}
|
|
517
|
+
return matrix, diagnostics
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def setup_simulation(config: dict[str, Any]) -> tuple[NetworkModel, dict[str, float], list, int]:
|
|
521
|
+
"""Set up the simulation model and initial conditions."""
|
|
522
|
+
# Load patch data (accept either case for the 'patch'/'population' columns)
|
|
523
|
+
patch_columns = pd.read_csv(config["PatchFile"], nrows=0).columns
|
|
524
|
+
patch_col = next((c for c in patch_columns if c.lower() == "patch"), None)
|
|
525
|
+
pop_col = next((c for c in patch_columns if c.lower() == "population"), None)
|
|
526
|
+
if patch_col is None or pop_col is None:
|
|
527
|
+
raise ValueError(
|
|
528
|
+
f"PatchFile ({config['PatchFile']}) must have 'patch' and 'population' columns.\n"
|
|
529
|
+
f"Found columns: {list(patch_columns)}"
|
|
530
|
+
)
|
|
531
|
+
patch_df = pd.read_csv(config["PatchFile"], converters={patch_col: str})
|
|
532
|
+
patches = patch_df[patch_col].tolist()
|
|
533
|
+
if not patches:
|
|
534
|
+
raise ValueError(f"PatchFile ({config['PatchFile']}) must contain at least one patch.")
|
|
535
|
+
if any(not patch.strip() for patch in patches):
|
|
536
|
+
raise ValueError(f"PatchFile ({config['PatchFile']}) contains an empty patch identifier.")
|
|
537
|
+
if len(set(patches)) != len(patches):
|
|
538
|
+
raise ValueError(f"PatchFile ({config['PatchFile']}) contains duplicate patch identifiers.")
|
|
539
|
+
populations = patch_df.set_index(patch_col)[pop_col].to_dict()
|
|
540
|
+
|
|
541
|
+
for p, pop in populations.items():
|
|
542
|
+
if not isinstance(pop, Real) or not np.isfinite(pop) or pop <= 0:
|
|
543
|
+
raise ValueError(
|
|
544
|
+
f"Invalid population in {config['PatchFile']}: patch '{p}' has population {pop}.\n"
|
|
545
|
+
"Population must be a positive number (> 0).\n"
|
|
546
|
+
"Please correct the population value in your patch file."
|
|
547
|
+
)
|
|
548
|
+
|
|
549
|
+
groups, group_populations = _load_groups(config, patches, populations)
|
|
550
|
+
|
|
551
|
+
# Load seed data
|
|
552
|
+
seed_converters = {"patch": str}
|
|
553
|
+
if groups:
|
|
554
|
+
seed_converters["group"] = str
|
|
555
|
+
seed_df = pd.read_csv(config["SeedFile"], converters=seed_converters, keep_default_na=False)
|
|
556
|
+
if groups and "group" not in seed_df.columns:
|
|
557
|
+
raise ValueError(f"SeedFile ({config['SeedFile']}) must include a 'group' column for grouped simulations.")
|
|
558
|
+
if not groups and "group" in seed_df.columns:
|
|
559
|
+
raise ValueError("SeedFile includes 'group', but GroupFile and InteractionFile are not configured.")
|
|
560
|
+
identifier_columns = {"patch", "group"} if groups else {"patch"}
|
|
561
|
+
seed_compartments = [col for col in seed_df.columns if col not in identifier_columns]
|
|
562
|
+
_numeric_values(seed_df, seed_compartments, f"SeedFile ({config['SeedFile']})")
|
|
563
|
+
|
|
564
|
+
configured_compartments = config.get("compartments", config.get("Compartments"))
|
|
565
|
+
if configured_compartments is None:
|
|
566
|
+
compartments = seed_compartments
|
|
567
|
+
else:
|
|
568
|
+
if not isinstance(configured_compartments, list) or not configured_compartments:
|
|
569
|
+
raise ValueError("'compartments' must be a non-empty list when provided")
|
|
570
|
+
compartments = [str(c) for c in configured_compartments]
|
|
571
|
+
|
|
572
|
+
missing_in_seed = sorted(set(compartments) - set(seed_compartments))
|
|
573
|
+
extra_in_seed = sorted(set(seed_compartments) - set(compartments))
|
|
574
|
+
if missing_in_seed or extra_in_seed:
|
|
575
|
+
raise ValueError(
|
|
576
|
+
f"Compartment mismatch between config and SeedFile ({config['SeedFile']}).\n"
|
|
577
|
+
f"Config compartments: {sorted(compartments)}\n"
|
|
578
|
+
f"SeedFile columns: {seed_compartments}\n"
|
|
579
|
+
f"Missing in SeedFile: {missing_in_seed}\n"
|
|
580
|
+
f"Extra in SeedFile: {extra_in_seed}\n"
|
|
581
|
+
"Please ensure the SeedFile has columns matching your config compartments."
|
|
582
|
+
)
|
|
583
|
+
|
|
584
|
+
if groups:
|
|
585
|
+
if seed_df.duplicated(["patch", "group"]).any():
|
|
586
|
+
raise ValueError(f"SeedFile ({config['SeedFile']}) contains duplicate patch/group pairs.")
|
|
587
|
+
expected_pairs = set(group_populations)
|
|
588
|
+
actual_pairs = set(zip(seed_df["patch"], seed_df["group"], strict=True))
|
|
589
|
+
if actual_pairs != expected_pairs:
|
|
590
|
+
raise ValueError(
|
|
591
|
+
f"SeedFile ({config['SeedFile']}) patch/group coverage is incomplete "
|
|
592
|
+
f"(missing={sorted(expected_pairs - actual_pairs)}, extra={sorted(actual_pairs - expected_pairs)})."
|
|
593
|
+
)
|
|
594
|
+
|
|
595
|
+
for _, row in seed_df.iterrows():
|
|
596
|
+
patch = row["patch"]
|
|
597
|
+
if patch not in populations:
|
|
598
|
+
raise ValueError(
|
|
599
|
+
f"Unknown patch '{patch}' in SeedFile ({config['SeedFile']}).\n"
|
|
600
|
+
f"Known patches from PatchFile: {sorted(populations.keys())}\n"
|
|
601
|
+
"Please ensure all patches in SeedFile match PatchFile."
|
|
602
|
+
)
|
|
603
|
+
group = row["group"] if groups else None
|
|
604
|
+
if groups and group not in groups:
|
|
605
|
+
raise ValueError(f"Unknown group '{group}' in SeedFile ({config['SeedFile']}).")
|
|
606
|
+
total = sum(row[c] for c in compartments)
|
|
607
|
+
if not all(row[c] >= 0 for c in compartments):
|
|
608
|
+
neg_comps = [c for c in compartments if row[c] < 0]
|
|
609
|
+
raise ValueError(
|
|
610
|
+
f"Invalid seed data for patch '{patch}': negative values found.\n"
|
|
611
|
+
f"Compartments with negative values: {neg_comps}\n"
|
|
612
|
+
"All seed values must be non-negative."
|
|
613
|
+
)
|
|
614
|
+
expected_population = group_populations[(patch, group)] if groups else populations[patch]
|
|
615
|
+
if abs(total - expected_population) >= EPSILON:
|
|
616
|
+
stratum = f", group '{group}'" if groups else ""
|
|
617
|
+
raise ValueError(
|
|
618
|
+
f"Seed mismatch for patch '{patch}'{stratum}: "
|
|
619
|
+
f"seed sum ({total}) != population ({expected_population}).\n"
|
|
620
|
+
f"Seed compartments: {dict((c, row[c]) for c in compartments)}\n"
|
|
621
|
+
"Ensure seed values sum exactly to the patch population."
|
|
622
|
+
)
|
|
623
|
+
|
|
624
|
+
# Set up network
|
|
625
|
+
num_patches = len(patches)
|
|
626
|
+
if "NetworkFile" not in config or config["NetworkFile"] is None:
|
|
627
|
+
# Multi-patch model with no network: use zero matrix
|
|
628
|
+
network_matrix = np.zeros((num_patches, num_patches))
|
|
629
|
+
else:
|
|
630
|
+
# Multi-patch model
|
|
631
|
+
net_df = pd.read_csv(
|
|
632
|
+
config["NetworkFile"],
|
|
633
|
+
converters={"source": str, "target": str},
|
|
634
|
+
)
|
|
635
|
+
net_df = net_df[net_df["day"] == 0]
|
|
636
|
+
patch_idx = {p: i for i, p in enumerate(patches)}
|
|
637
|
+
network_matrix = np.zeros((num_patches, num_patches))
|
|
638
|
+
|
|
639
|
+
for _, row in net_df.iterrows():
|
|
640
|
+
source = row["source"].strip('"')
|
|
641
|
+
target = row["target"].strip('"')
|
|
642
|
+
if source not in patch_idx:
|
|
643
|
+
raise ValueError(
|
|
644
|
+
f"Unknown source patch '{source}' in NetworkFile ({config['NetworkFile']}).\n"
|
|
645
|
+
f"Known patches: {sorted(patch_idx.keys())}\n"
|
|
646
|
+
"Please ensure all patches in NetworkFile match PatchFile."
|
|
647
|
+
)
|
|
648
|
+
if target not in patch_idx:
|
|
649
|
+
raise ValueError(
|
|
650
|
+
f"Unknown target patch '{target}' in NetworkFile ({config['NetworkFile']}).\n"
|
|
651
|
+
f"Known patches: {sorted(patch_idx.keys())}\n"
|
|
652
|
+
"Please ensure all patches in NetworkFile match PatchFile."
|
|
653
|
+
)
|
|
654
|
+
i = patch_idx[source]
|
|
655
|
+
j = patch_idx[target]
|
|
656
|
+
if row["weight"] < 0:
|
|
657
|
+
raise ValueError(
|
|
658
|
+
f"Invalid network weight in NetworkFile ({config['NetworkFile']}): "
|
|
659
|
+
f"weight={row['weight']} from '{source}' to '{target}'.\n"
|
|
660
|
+
"Network weights must be non-negative. Please correct your network file."
|
|
661
|
+
)
|
|
662
|
+
network_matrix[i, j] = row["weight"]
|
|
663
|
+
|
|
664
|
+
interaction_matrix, interaction_diagnostics = _load_interactions(
|
|
665
|
+
config,
|
|
666
|
+
groups,
|
|
667
|
+
patches,
|
|
668
|
+
group_populations,
|
|
669
|
+
network_matrix,
|
|
670
|
+
)
|
|
671
|
+
|
|
672
|
+
# Set up model
|
|
673
|
+
global_params = config.get("Parameters", {})
|
|
674
|
+
|
|
675
|
+
# Collect per-patch parameters if provided (needed for transition-name validation too)
|
|
676
|
+
patch_params: dict[str, dict[str, Any]] = {}
|
|
677
|
+
if "PatchParameters" in config:
|
|
678
|
+
for entry in config["PatchParameters"]:
|
|
679
|
+
patch_name = entry["patch"]
|
|
680
|
+
patch_params[patch_name] = entry.get("parameters", {})
|
|
681
|
+
|
|
682
|
+
transitions_cfg = config.get("Transitions", {})
|
|
683
|
+
# Transitions must be provided as arrow-map syntax in config, e.g.:
|
|
684
|
+
# Transitions: {S -> I: beta, I -> R: gamma * I}
|
|
685
|
+
if not isinstance(transitions_cfg, dict) or not transitions_cfg:
|
|
686
|
+
raise ValueError("'Transitions' must be a non-empty mapping in arrow syntax, e.g. {S -> I: 'beta'}.")
|
|
687
|
+
|
|
688
|
+
transitions: list[dict[str, Any]] = []
|
|
689
|
+
patch_param_names = set()
|
|
690
|
+
for per_patch in patch_params.values():
|
|
691
|
+
patch_param_names |= set(per_patch.keys())
|
|
692
|
+
_validate_expression_names(compartments, set(global_params) | patch_param_names)
|
|
693
|
+
allowed_names = set(compartments) | set(global_params.keys()) | patch_param_names
|
|
694
|
+
for k, v in transitions_cfg.items():
|
|
695
|
+
parts = [p.strip() for p in str(k).split("->")]
|
|
696
|
+
if len(parts) != 2 or not all(parts):
|
|
697
|
+
raise ValueError(
|
|
698
|
+
f"Invalid transition key '{k}'.\n"
|
|
699
|
+
"Use arrow format: 'source -> target' (e.g., 'S -> I')\n"
|
|
700
|
+
"Available compartments: {}".format(sorted(compartments))
|
|
701
|
+
)
|
|
702
|
+
source, target = parts[0], parts[1]
|
|
703
|
+
if source not in compartments or target not in compartments:
|
|
704
|
+
bad_comps = [p for p in [source, target] if p not in compartments]
|
|
705
|
+
raise ValueError(
|
|
706
|
+
f"Transition '{k}' uses unknown compartments: {bad_comps}\n"
|
|
707
|
+
f"Known compartments: {sorted(compartments)}\n"
|
|
708
|
+
"Please correct the transition definition."
|
|
709
|
+
)
|
|
710
|
+
|
|
711
|
+
if isinstance(v, str):
|
|
712
|
+
identifiers = set(re.findall(r"[A-Za-z_]\w*", v))
|
|
713
|
+
python_keywords = {"and", "or", "not", "True", "False", "None"}
|
|
714
|
+
unknown_identifiers = sorted(identifiers - allowed_names - python_keywords)
|
|
715
|
+
if unknown_identifiers:
|
|
716
|
+
raise ValueError(
|
|
717
|
+
f"Transition '{k}' uses undefined names: {unknown_identifiers}\n"
|
|
718
|
+
f"Expression: '{v}'\n"
|
|
719
|
+
f"Defined names (compartments + parameters): {sorted(allowed_names)}\n"
|
|
720
|
+
"Please check your transition expression for typos or add missing parameters."
|
|
721
|
+
)
|
|
722
|
+
|
|
723
|
+
transitions.append({"transition": f"{source}->{target}", "rate": v})
|
|
724
|
+
|
|
725
|
+
# Validate that all configured patches exist in PatchFile
|
|
726
|
+
unknown_patches = set(patch_params) - set(patches)
|
|
727
|
+
if unknown_patches:
|
|
728
|
+
raise ValueError(f"PatchParameters contains unknown patches: {sorted(unknown_patches)}")
|
|
729
|
+
|
|
730
|
+
# Ensure every patch has a full parameter set: global + per-patch override
|
|
731
|
+
for p in patches:
|
|
732
|
+
patch_params[p] = {**global_params, **patch_params.get(p, {})}
|
|
733
|
+
|
|
734
|
+
# Initialize the base model (will hold default/global transitions)
|
|
735
|
+
base_model = CompartmentalModel(compartments=compartments, parameters=global_params, transitions=transitions)
|
|
736
|
+
|
|
737
|
+
# Create network model
|
|
738
|
+
net = NetworkModel(
|
|
739
|
+
base_model=base_model,
|
|
740
|
+
num_patches=num_patches,
|
|
741
|
+
network_matrix=network_matrix,
|
|
742
|
+
groups=groups,
|
|
743
|
+
interaction_matrix=interaction_matrix,
|
|
744
|
+
)
|
|
745
|
+
|
|
746
|
+
# Prepare initial conditions
|
|
747
|
+
patch_idx = {p: i for i, p in enumerate(patches)}
|
|
748
|
+
group_idx = {group: i for i, group in enumerate(groups)}
|
|
749
|
+
y0 = {}
|
|
750
|
+
for _, row in seed_df.iterrows():
|
|
751
|
+
for c in compartments:
|
|
752
|
+
group_position = group_idx[row["group"]] if groups else 0
|
|
753
|
+
y0[net.state_key(c, patch_idx[row["patch"]], group_position)] = row[c]
|
|
754
|
+
|
|
755
|
+
# Attach per-patch parameters to the network model
|
|
756
|
+
net.patch_parameters = patch_params
|
|
757
|
+
net.patch_names = patches
|
|
758
|
+
net.interaction_diagnostics = interaction_diagnostics
|
|
759
|
+
|
|
760
|
+
return net, y0, patches, num_patches
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
def _simulate_prepared(
|
|
764
|
+
config: dict[str, Any],
|
|
765
|
+
net: NetworkModel,
|
|
766
|
+
y0: dict[str, float],
|
|
767
|
+
) -> pd.DataFrame:
|
|
768
|
+
"""Evaluate a prepared model on its configured reporting grid."""
|
|
769
|
+
solver, t_max, time_step = get_run_settings(config)
|
|
770
|
+
t_range = np.arange(t_max, dtype=float) * time_step
|
|
771
|
+
if solver == "ode":
|
|
772
|
+
_times, results = net.simulate_ode(y0, t_range)
|
|
773
|
+
else:
|
|
774
|
+
results = net.simulate_discrete(y0, t_range)
|
|
775
|
+
|
|
776
|
+
frame = pd.DataFrame(results)
|
|
777
|
+
frame.insert(0, "time", t_range)
|
|
778
|
+
return frame
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
def simulate(
|
|
782
|
+
config: dict[str, Any] | str | Path,
|
|
783
|
+
*,
|
|
784
|
+
parameter_overrides: dict[str, Real] | None = None,
|
|
785
|
+
) -> pd.DataFrame:
|
|
786
|
+
"""Run a simulation in memory without mutating the config or writing files.
|
|
787
|
+
|
|
788
|
+
A mapping must already contain resolved input paths, as returned by
|
|
789
|
+
:func:`load_config`. Passing a path loads and resolves the configuration.
|
|
790
|
+
"""
|
|
791
|
+
prepared = load_config(str(config)) if isinstance(config, (str, Path)) else deepcopy(config)
|
|
792
|
+
if not isinstance(prepared, dict):
|
|
793
|
+
raise TypeError("config must be a loaded configuration mapping or a path")
|
|
794
|
+
|
|
795
|
+
overrides = parameter_overrides or {}
|
|
796
|
+
if not isinstance(overrides, dict):
|
|
797
|
+
raise TypeError("parameter_overrides must be a mapping")
|
|
798
|
+
global_parameters = prepared.get("Parameters", {})
|
|
799
|
+
if not isinstance(global_parameters, dict):
|
|
800
|
+
raise ValueError("'Parameters' must be a mapping")
|
|
801
|
+
|
|
802
|
+
patch_parameter_names: set[str] = set()
|
|
803
|
+
for entry in prepared.get("PatchParameters", []):
|
|
804
|
+
if isinstance(entry, dict) and isinstance(entry.get("parameters", {}), dict):
|
|
805
|
+
patch_parameter_names.update(entry.get("parameters", {}))
|
|
806
|
+
|
|
807
|
+
for name, value in overrides.items():
|
|
808
|
+
if name not in global_parameters:
|
|
809
|
+
raise ValueError(f"Unknown global parameter override: {name!r}")
|
|
810
|
+
if name in patch_parameter_names:
|
|
811
|
+
raise ValueError(f"Cannot globally override {name!r}; it is also set in PatchParameters")
|
|
812
|
+
if isinstance(value, bool) or not isinstance(value, Real) or not np.isfinite(value):
|
|
813
|
+
raise ValueError(f"Parameter override {name!r} must be a finite real number")
|
|
814
|
+
|
|
815
|
+
prepared["Parameters"] = {**global_parameters, **overrides}
|
|
816
|
+
net, y0, _patches, _num_patches = setup_simulation(prepared)
|
|
817
|
+
return _simulate_prepared(prepared, net, y0)
|
|
818
|
+
|
|
819
|
+
|
|
820
|
+
def run_simulation(
|
|
821
|
+
config: dict[str, Any], model_name: str, net: NetworkModel, y0: dict[str, float], patches: list, num_patches: int
|
|
822
|
+
) -> dict[str, Any]:
|
|
823
|
+
"""Run the simulation and save results.
|
|
824
|
+
|
|
825
|
+
Returns:
|
|
826
|
+
A summary dictionary describing the generated artifacts.
|
|
827
|
+
"""
|
|
828
|
+
solver, t_max, time_step = get_run_settings(config)
|
|
829
|
+
config["Solver"] = solver
|
|
830
|
+
config["TimeStep"] = time_step
|
|
831
|
+
|
|
832
|
+
# Create output directories
|
|
833
|
+
for subdir in ["plots", "runs"]:
|
|
834
|
+
dir_path = os.path.join(config["OutputDir"], subdir)
|
|
835
|
+
os.makedirs(dir_path, exist_ok=True)
|
|
836
|
+
|
|
837
|
+
plots_dir = os.path.join(config["OutputDir"], "plots")
|
|
838
|
+
runs_dir = os.path.join(config["OutputDir"], "runs")
|
|
839
|
+
|
|
840
|
+
# Set up logger
|
|
841
|
+
logger = setup_logger(model_name, config, num_patches, patches, net.base_model)
|
|
842
|
+
|
|
843
|
+
out_df = _simulate_prepared(config, net, y0)
|
|
844
|
+
t_range = out_df["time"].to_numpy()
|
|
845
|
+
results = {column: out_df[column].to_numpy() for column in out_df.columns if column != "time"}
|
|
846
|
+
|
|
847
|
+
csv_path = os.path.join(runs_dir, f"all_patches_{model_name}_{solver}.csv")
|
|
848
|
+
out_df.to_csv(csv_path, index=False)
|
|
849
|
+
logger.info(f"Saved simulation output to {csv_path}")
|
|
850
|
+
|
|
851
|
+
plot_patch_subplots(
|
|
852
|
+
t_range,
|
|
853
|
+
results,
|
|
854
|
+
patches,
|
|
855
|
+
plots_dir,
|
|
856
|
+
model_name,
|
|
857
|
+
compartments=list(net.base_model.compartments),
|
|
858
|
+
groups=net.groups,
|
|
859
|
+
solver=solver,
|
|
860
|
+
)
|
|
861
|
+
|
|
862
|
+
plot_path = os.path.join(plots_dir, f"patch_timeseries_{model_name}_{solver}.png")
|
|
863
|
+
logger.info(f"Saved all patch subplots to {plot_path}")
|
|
864
|
+
|
|
865
|
+
summary = {
|
|
866
|
+
"model_name": model_name,
|
|
867
|
+
"output_dir": config["OutputDir"],
|
|
868
|
+
"csv_path": csv_path,
|
|
869
|
+
"plot_path": plot_path,
|
|
870
|
+
"num_patches": num_patches,
|
|
871
|
+
"patches": patches,
|
|
872
|
+
"t_max": t_max,
|
|
873
|
+
"solver": solver,
|
|
874
|
+
"time_step": time_step,
|
|
875
|
+
}
|
|
876
|
+
if net.groups:
|
|
877
|
+
summary.update({"num_groups": net.num_groups, "groups": net.groups})
|
|
878
|
+
return summary
|