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,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