patchsim 0.1.0b1__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 +24 -0
- patchsim/cli.py +246 -0
- patchsim/core/__init__.py +0 -0
- patchsim/core/model.py +237 -0
- patchsim/core/model_runner.py +62 -0
- patchsim/core/simulation.py +417 -0
- patchsim/models/__init__.py +1 -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 +31 -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/loader.py +6 -0
- patchsim/utils/logger.py +70 -0
- patchsim/utils/viz.py +39 -0
- patchsim-0.1.0b1.dist-info/METADATA +920 -0
- patchsim-0.1.0b1.dist-info/RECORD +25 -0
- patchsim-0.1.0b1.dist-info/WHEEL +4 -0
- patchsim-0.1.0b1.dist-info/entry_points.txt +2 -0
- patchsim-0.1.0b1.dist-info/licenses/LICENSE +674 -0
|
@@ -0,0 +1,417 @@
|
|
|
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 os
|
|
7
|
+
import re
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
import pandas as pd
|
|
13
|
+
import yaml
|
|
14
|
+
|
|
15
|
+
from patchsim.core.model import CompartmentalModel, NetworkModel
|
|
16
|
+
from patchsim.core.model_runner import Model
|
|
17
|
+
from patchsim.utils.logger import setup_logger
|
|
18
|
+
|
|
19
|
+
EPSILON = 1e-6
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
MODEL_TEMPLATE_CONFIGS: dict[str, dict[str, Any]] = {
|
|
23
|
+
"sir": {
|
|
24
|
+
"compartments": ["S", "I", "R"],
|
|
25
|
+
"Parameters": {"beta": 0.08, "gamma": 0.1},
|
|
26
|
+
"Transitions": {"S -> I": "beta", "I -> R": "gamma * I"},
|
|
27
|
+
},
|
|
28
|
+
"seir": {
|
|
29
|
+
"compartments": ["S", "E", "I", "R"],
|
|
30
|
+
"Parameters": {"beta": 0.08, "sigma": 0.2, "gamma": 0.1},
|
|
31
|
+
"Transitions": {"S -> E": "beta", "E -> I": "sigma * E", "I -> R": "gamma * I"},
|
|
32
|
+
},
|
|
33
|
+
"sirs": {
|
|
34
|
+
"compartments": ["S", "I", "R"],
|
|
35
|
+
"Parameters": {"beta": 0.08, "gamma": 0.1, "waning": 0.02},
|
|
36
|
+
"Transitions": {"S -> I": "beta", "I -> R": "gamma * I", "R -> S": "waning * R"},
|
|
37
|
+
},
|
|
38
|
+
"sis": {
|
|
39
|
+
"compartments": ["S", "I"],
|
|
40
|
+
"Parameters": {"beta": 0.08, "gamma": 0.1},
|
|
41
|
+
"Transitions": {"S -> I": "beta", "I -> S": "gamma * I"},
|
|
42
|
+
},
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def get_config_schema() -> dict[str, Any]:
|
|
47
|
+
"""Return the JSON Schema for PatchSim configuration files."""
|
|
48
|
+
return {
|
|
49
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
50
|
+
"$id": "https://dsih-artpark.github.io/patchsim/config.schema.json",
|
|
51
|
+
"title": "PatchSim configuration",
|
|
52
|
+
"type": "object",
|
|
53
|
+
"required": ["PatchFile", "SeedFile", "OutputDir", "Transitions", "TMax"],
|
|
54
|
+
"properties": {
|
|
55
|
+
"PatchFile": {"type": "string"},
|
|
56
|
+
"SeedFile": {"type": "string"},
|
|
57
|
+
"NetworkFile": {"type": ["string", "null"]},
|
|
58
|
+
"OutputDir": {"type": "string"},
|
|
59
|
+
"ModelName": {"type": "string"},
|
|
60
|
+
"TMax": {"type": "integer", "minimum": 1},
|
|
61
|
+
"Tolerance": {"type": ["number", "string"]},
|
|
62
|
+
"MaxIter": {"type": "integer", "minimum": 1},
|
|
63
|
+
"StartDate": {"type": ["string", "null"]},
|
|
64
|
+
"EndDate": {"type": ["string", "null"]},
|
|
65
|
+
"Logging": {"type": ["boolean", "string"]},
|
|
66
|
+
"compartments": {
|
|
67
|
+
"type": "array",
|
|
68
|
+
"items": {"type": "string"},
|
|
69
|
+
"minItems": 1,
|
|
70
|
+
},
|
|
71
|
+
"Compartments": {
|
|
72
|
+
"type": "array",
|
|
73
|
+
"items": {"type": "string"},
|
|
74
|
+
"minItems": 1,
|
|
75
|
+
},
|
|
76
|
+
"Parameters": {
|
|
77
|
+
"type": "object",
|
|
78
|
+
"additionalProperties": {"type": ["number", "integer", "string", "boolean"]},
|
|
79
|
+
},
|
|
80
|
+
"PatchParameters": {
|
|
81
|
+
"type": "array",
|
|
82
|
+
"items": {
|
|
83
|
+
"type": "object",
|
|
84
|
+
"required": ["patch"],
|
|
85
|
+
"properties": {
|
|
86
|
+
"patch": {"type": "string"},
|
|
87
|
+
"parameters": {"type": "object"},
|
|
88
|
+
},
|
|
89
|
+
"additionalProperties": True,
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
"Transitions": {
|
|
93
|
+
"type": "object",
|
|
94
|
+
"minProperties": 1,
|
|
95
|
+
"additionalProperties": {"type": "string"},
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
"additionalProperties": True,
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def get_model_catalog() -> list[dict[str, str]]:
|
|
103
|
+
"""Return built-in model references and YAML templates for the CLI."""
|
|
104
|
+
return [{"name": name, "kind": "yaml-template"} for name in sorted(MODEL_TEMPLATE_CONFIGS)]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def get_available_template_names() -> list[str]:
|
|
108
|
+
"""Return the built-in starter template names."""
|
|
109
|
+
return sorted(MODEL_TEMPLATE_CONFIGS)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def get_init_template_config(template_name: str, project_name: str) -> dict[str, Any]:
|
|
113
|
+
"""Return a starter project config for the requested template."""
|
|
114
|
+
try:
|
|
115
|
+
template = MODEL_TEMPLATE_CONFIGS[template_name]
|
|
116
|
+
except KeyError as e:
|
|
117
|
+
raise ValueError(
|
|
118
|
+
f"Unknown template '{template_name}'. Available templates: {sorted(MODEL_TEMPLATE_CONFIGS)}"
|
|
119
|
+
) from e
|
|
120
|
+
|
|
121
|
+
config: dict[str, Any] = {
|
|
122
|
+
"PatchFile": "data/patch/patch-population.csv",
|
|
123
|
+
"NetworkFile": "data/networks/network-static.csv",
|
|
124
|
+
"SeedFile": "data/seeds/seed-initial.csv",
|
|
125
|
+
"Logging": False,
|
|
126
|
+
"ModelName": project_name,
|
|
127
|
+
"TMax": 60,
|
|
128
|
+
"Tolerance": 1e-8,
|
|
129
|
+
"MaxIter": 10000,
|
|
130
|
+
"StartDate": "2020-01-01",
|
|
131
|
+
"EndDate": "2022-12-31",
|
|
132
|
+
"OutputDir": f"output/{project_name}",
|
|
133
|
+
}
|
|
134
|
+
config.update(template)
|
|
135
|
+
return config
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def load_config(config_path: str) -> dict[str, Any]:
|
|
139
|
+
"""Load and validate configuration file."""
|
|
140
|
+
cfg_path = Path(config_path).expanduser().resolve()
|
|
141
|
+
with open(cfg_path, encoding="utf-8") as f:
|
|
142
|
+
config = yaml.safe_load(f)
|
|
143
|
+
|
|
144
|
+
if not isinstance(config, dict):
|
|
145
|
+
raise ValueError(
|
|
146
|
+
f"Configuration error in {cfg_path}: expected YAML mapping (key-value pairs), "
|
|
147
|
+
"but got a list or scalar value instead. \n"
|
|
148
|
+
"Ensure the config file has the format: key1: value1\\n key2: value2"
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
# Validate required fields
|
|
152
|
+
required_fields = ["PatchFile", "SeedFile", "OutputDir", "Transitions", "TMax"]
|
|
153
|
+
for field in required_fields:
|
|
154
|
+
if field not in config:
|
|
155
|
+
available = ", ".join(sorted(config.keys()))
|
|
156
|
+
raise ValueError(
|
|
157
|
+
f"Configuration error: missing required field '{field}'.\n"
|
|
158
|
+
f"Available fields in config: {available}\n"
|
|
159
|
+
f"Please add '{field}' to your config file."
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
# Resolve relative paths against the config file directory.
|
|
163
|
+
cfg_dir = cfg_path.parent
|
|
164
|
+
for key in ["PatchFile", "SeedFile", "NetworkFile", "OutputDir"]:
|
|
165
|
+
val = config.get(key)
|
|
166
|
+
if isinstance(val, str) and val.strip():
|
|
167
|
+
p = Path(val).expanduser()
|
|
168
|
+
if not p.is_absolute():
|
|
169
|
+
config[key] = str((cfg_dir / p).resolve())
|
|
170
|
+
|
|
171
|
+
return config
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def setup_simulation(config: dict[str, Any]) -> tuple[NetworkModel, dict[str, float], list, int]:
|
|
175
|
+
"""Set up the simulation model and initial conditions."""
|
|
176
|
+
# Load patch data (accept either case for the 'patch'/'population' columns)
|
|
177
|
+
patch_df = pd.read_csv(config["PatchFile"])
|
|
178
|
+
patch_col = next((c for c in patch_df.columns if c.lower() == "patch"), None)
|
|
179
|
+
pop_col = next((c for c in patch_df.columns if c.lower() == "population"), None)
|
|
180
|
+
if patch_col is None or pop_col is None:
|
|
181
|
+
raise ValueError(
|
|
182
|
+
f"PatchFile ({config['PatchFile']}) must have 'patch' and 'population' columns.\n"
|
|
183
|
+
f"Found columns: {list(patch_df.columns)}"
|
|
184
|
+
)
|
|
185
|
+
patches = patch_df[patch_col].tolist()
|
|
186
|
+
populations = patch_df.set_index(patch_col)[pop_col].to_dict()
|
|
187
|
+
|
|
188
|
+
for p, pop in populations.items():
|
|
189
|
+
if pop <= 0:
|
|
190
|
+
raise ValueError(
|
|
191
|
+
f"Invalid population in {config['PatchFile']}: patch '{p}' has population {pop}.\n"
|
|
192
|
+
"Population must be a positive number (> 0).\n"
|
|
193
|
+
"Please correct the population value in your patch file."
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
# Load seed data
|
|
197
|
+
seed_df = pd.read_csv(config["SeedFile"])
|
|
198
|
+
seed_compartments = [col for col in seed_df.columns if col != "patch"]
|
|
199
|
+
|
|
200
|
+
configured_compartments = config.get("compartments", config.get("Compartments"))
|
|
201
|
+
if configured_compartments is None:
|
|
202
|
+
compartments = seed_compartments
|
|
203
|
+
else:
|
|
204
|
+
if not isinstance(configured_compartments, list) or not configured_compartments:
|
|
205
|
+
raise ValueError("'compartments' must be a non-empty list when provided")
|
|
206
|
+
compartments = [str(c) for c in configured_compartments]
|
|
207
|
+
|
|
208
|
+
missing_in_seed = sorted(set(compartments) - set(seed_compartments))
|
|
209
|
+
extra_in_seed = sorted(set(seed_compartments) - set(compartments))
|
|
210
|
+
if missing_in_seed or extra_in_seed:
|
|
211
|
+
raise ValueError(
|
|
212
|
+
f"Compartment mismatch between config and SeedFile ({config['SeedFile']}).\n"
|
|
213
|
+
f"Config compartments: {sorted(compartments)}\n"
|
|
214
|
+
f"SeedFile columns: {seed_compartments}\n"
|
|
215
|
+
f"Missing in SeedFile: {missing_in_seed}\n"
|
|
216
|
+
f"Extra in SeedFile: {extra_in_seed}\n"
|
|
217
|
+
"Please ensure the SeedFile has columns matching your config compartments."
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
for _, row in seed_df.iterrows():
|
|
221
|
+
patch = row["patch"]
|
|
222
|
+
if patch not in populations:
|
|
223
|
+
raise ValueError(
|
|
224
|
+
f"Unknown patch '{patch}' in SeedFile ({config['SeedFile']}).\n"
|
|
225
|
+
f"Known patches from PatchFile: {sorted(populations.keys())}\n"
|
|
226
|
+
"Please ensure all patches in SeedFile match PatchFile."
|
|
227
|
+
)
|
|
228
|
+
total = sum(row[c] for c in compartments)
|
|
229
|
+
if not all(row[c] >= 0 for c in compartments):
|
|
230
|
+
neg_comps = [c for c in compartments if row[c] < 0]
|
|
231
|
+
raise ValueError(
|
|
232
|
+
f"Invalid seed data for patch '{patch}': negative values found.\n"
|
|
233
|
+
f"Compartments with negative values: {neg_comps}\n"
|
|
234
|
+
"All seed values must be non-negative."
|
|
235
|
+
)
|
|
236
|
+
if abs(total - populations[patch]) >= EPSILON:
|
|
237
|
+
raise ValueError(
|
|
238
|
+
f"Seed mismatch for patch '{patch}': seed sum ({total}) != population ({populations[patch]}).\n"
|
|
239
|
+
f"Seed compartments: {dict((c, row[c]) for c in compartments)}\n"
|
|
240
|
+
"Ensure seed values sum exactly to the patch population."
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
# Set up network
|
|
244
|
+
num_patches = len(patches)
|
|
245
|
+
if "NetworkFile" not in config or config["NetworkFile"] is None:
|
|
246
|
+
# Multi-patch model with no network: use zero matrix
|
|
247
|
+
network_matrix = np.zeros((num_patches, num_patches))
|
|
248
|
+
else:
|
|
249
|
+
# Multi-patch model
|
|
250
|
+
net_df = pd.read_csv(config["NetworkFile"])
|
|
251
|
+
net_df = net_df[net_df["day"] == 0]
|
|
252
|
+
patch_idx = {p: i for i, p in enumerate(patches)}
|
|
253
|
+
network_matrix = np.zeros((num_patches, num_patches))
|
|
254
|
+
|
|
255
|
+
for _, row in net_df.iterrows():
|
|
256
|
+
source = row["source"].strip('"')
|
|
257
|
+
target = row["target"].strip('"')
|
|
258
|
+
if source not in patch_idx:
|
|
259
|
+
raise ValueError(
|
|
260
|
+
f"Unknown source patch '{source}' in NetworkFile ({config['NetworkFile']}).\n"
|
|
261
|
+
f"Known patches: {sorted(patch_idx.keys())}\n"
|
|
262
|
+
"Please ensure all patches in NetworkFile match PatchFile."
|
|
263
|
+
)
|
|
264
|
+
if target not in patch_idx:
|
|
265
|
+
raise ValueError(
|
|
266
|
+
f"Unknown target patch '{target}' in NetworkFile ({config['NetworkFile']}).\n"
|
|
267
|
+
f"Known patches: {sorted(patch_idx.keys())}\n"
|
|
268
|
+
"Please ensure all patches in NetworkFile match PatchFile."
|
|
269
|
+
)
|
|
270
|
+
i = patch_idx[source]
|
|
271
|
+
j = patch_idx[target]
|
|
272
|
+
if row["weight"] < 0:
|
|
273
|
+
raise ValueError(
|
|
274
|
+
f"Invalid network weight in NetworkFile ({config['NetworkFile']}): "
|
|
275
|
+
f"weight={row['weight']} from '{source}' to '{target}'.\n"
|
|
276
|
+
"Network weights must be non-negative. Please correct your network file."
|
|
277
|
+
)
|
|
278
|
+
network_matrix[i, j] = row["weight"]
|
|
279
|
+
|
|
280
|
+
# Set up model
|
|
281
|
+
global_params = config.get("Parameters", {})
|
|
282
|
+
|
|
283
|
+
# Collect per-patch parameters if provided (needed for transition-name validation too)
|
|
284
|
+
patch_params: dict[str, dict[str, Any]] = {}
|
|
285
|
+
if "PatchParameters" in config:
|
|
286
|
+
for entry in config["PatchParameters"]:
|
|
287
|
+
patch_name = entry["patch"]
|
|
288
|
+
patch_params[patch_name] = entry.get("parameters", {})
|
|
289
|
+
|
|
290
|
+
transitions_cfg = config.get("Transitions", {})
|
|
291
|
+
# Transitions must be provided as arrow-map syntax in config, e.g.:
|
|
292
|
+
# Transitions: {S -> I: beta, I -> R: gamma * I}
|
|
293
|
+
if not isinstance(transitions_cfg, dict) or not transitions_cfg:
|
|
294
|
+
raise ValueError("'Transitions' must be a non-empty mapping in arrow syntax, e.g. {S -> I: 'beta'}.")
|
|
295
|
+
|
|
296
|
+
transitions: list[dict[str, Any]] = []
|
|
297
|
+
patch_param_names = set()
|
|
298
|
+
for per_patch in patch_params.values():
|
|
299
|
+
patch_param_names |= set(per_patch.keys())
|
|
300
|
+
allowed_names = set(compartments) | set(global_params.keys()) | patch_param_names
|
|
301
|
+
for k, v in transitions_cfg.items():
|
|
302
|
+
parts = [p.strip() for p in str(k).split("->")]
|
|
303
|
+
if len(parts) != 2 or not all(parts):
|
|
304
|
+
raise ValueError(
|
|
305
|
+
f"Invalid transition key '{k}'.\n"
|
|
306
|
+
"Use arrow format: 'source -> target' (e.g., 'S -> I')\n"
|
|
307
|
+
"Available compartments: {}".format(sorted(compartments))
|
|
308
|
+
)
|
|
309
|
+
source, target = parts[0], parts[1]
|
|
310
|
+
if source not in compartments or target not in compartments:
|
|
311
|
+
bad_comps = [p for p in [source, target] if p not in compartments]
|
|
312
|
+
raise ValueError(
|
|
313
|
+
f"Transition '{k}' uses unknown compartments: {bad_comps}\n"
|
|
314
|
+
f"Known compartments: {sorted(compartments)}\n"
|
|
315
|
+
"Please correct the transition definition."
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
if isinstance(v, str):
|
|
319
|
+
identifiers = set(re.findall(r"[A-Za-z_]\w*", v))
|
|
320
|
+
python_keywords = {"and", "or", "not", "True", "False", "None"}
|
|
321
|
+
unknown_identifiers = sorted(identifiers - allowed_names - python_keywords)
|
|
322
|
+
if unknown_identifiers:
|
|
323
|
+
raise ValueError(
|
|
324
|
+
f"Transition '{k}' uses undefined names: {unknown_identifiers}\n"
|
|
325
|
+
f"Expression: '{v}'\n"
|
|
326
|
+
f"Defined names (compartments + parameters): {sorted(allowed_names)}\n"
|
|
327
|
+
"Please check your transition expression for typos or add missing parameters."
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
transitions.append({"transition": f"{source}->{target}", "rate": v})
|
|
331
|
+
|
|
332
|
+
# Validate that all configured patches exist in PatchFile
|
|
333
|
+
unknown_patches = set(patch_params) - set(patches)
|
|
334
|
+
if unknown_patches:
|
|
335
|
+
raise ValueError(f"PatchParameters contains unknown patches: {sorted(unknown_patches)}")
|
|
336
|
+
|
|
337
|
+
# Ensure every patch has a full parameter set: global + per-patch override
|
|
338
|
+
for p in patches:
|
|
339
|
+
patch_params[p] = {**global_params, **patch_params.get(p, {})}
|
|
340
|
+
|
|
341
|
+
# Initialize the base model (will hold default/global transitions)
|
|
342
|
+
base_model = CompartmentalModel(compartments=compartments, parameters=global_params, transitions=transitions)
|
|
343
|
+
|
|
344
|
+
# Prepare initial conditions
|
|
345
|
+
patch_idx = {p: i for i, p in enumerate(patches)}
|
|
346
|
+
y0 = {}
|
|
347
|
+
for _, row in seed_df.iterrows():
|
|
348
|
+
for c in compartments:
|
|
349
|
+
y0[f"{c}_{patch_idx[row['patch']]}"] = row[c]
|
|
350
|
+
|
|
351
|
+
# Create network model
|
|
352
|
+
net = NetworkModel(base_model=base_model, num_patches=num_patches, network_matrix=network_matrix)
|
|
353
|
+
|
|
354
|
+
# Attach per-patch parameters to the network model
|
|
355
|
+
net.patch_parameters = patch_params
|
|
356
|
+
net.patch_names = patches
|
|
357
|
+
|
|
358
|
+
return net, y0, patches, num_patches
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def run_simulation(
|
|
362
|
+
config: dict[str, Any], model_name: str, net: NetworkModel, y0: dict[str, float], patches: list, num_patches: int
|
|
363
|
+
) -> dict[str, Any]:
|
|
364
|
+
"""Run the simulation and save results.
|
|
365
|
+
|
|
366
|
+
Returns:
|
|
367
|
+
A summary dictionary describing the generated artifacts.
|
|
368
|
+
"""
|
|
369
|
+
# Create output directories
|
|
370
|
+
for subdir in ["plots", "runs"]:
|
|
371
|
+
dir_path = os.path.join(config["OutputDir"], subdir)
|
|
372
|
+
os.makedirs(dir_path, exist_ok=True)
|
|
373
|
+
|
|
374
|
+
plots_dir = os.path.join(config["OutputDir"], "plots")
|
|
375
|
+
runs_dir = os.path.join(config["OutputDir"], "runs")
|
|
376
|
+
|
|
377
|
+
# Set up logger
|
|
378
|
+
logger = setup_logger(model_name, config, num_patches, patches, net.base_model)
|
|
379
|
+
|
|
380
|
+
# Validate and construct time range
|
|
381
|
+
t_max = config.get("TMax")
|
|
382
|
+
if not isinstance(t_max, int) or t_max <= 0:
|
|
383
|
+
raise ValueError(
|
|
384
|
+
f"Invalid 'TMax' value: {t_max}\n"
|
|
385
|
+
"'TMax' must be a positive integer (number of time steps).\n"
|
|
386
|
+
"Example: TMax: 100"
|
|
387
|
+
)
|
|
388
|
+
t_range = np.arange(t_max, dtype=float)
|
|
389
|
+
|
|
390
|
+
# Run simulation
|
|
391
|
+
model = Model(net, compartments=list(net.base_model.compartments))
|
|
392
|
+
out_ode = model.solve(y0, t_range)
|
|
393
|
+
|
|
394
|
+
# Save results
|
|
395
|
+
out_df = pd.DataFrame(out_ode)
|
|
396
|
+
out_df["time"] = t_range
|
|
397
|
+
cols = ["time"] + [c for c in out_df.columns if c != "time"]
|
|
398
|
+
out_df = out_df[cols]
|
|
399
|
+
|
|
400
|
+
csv_path = os.path.join(runs_dir, f"all_patches_{model_name}_ode.csv")
|
|
401
|
+
out_df.to_csv(csv_path, index=False)
|
|
402
|
+
logger.info(f"Saved simulation output to {csv_path}")
|
|
403
|
+
|
|
404
|
+
model.visualize(t_range, out_ode, patches, plots_dir, model_name)
|
|
405
|
+
|
|
406
|
+
plot_path = os.path.join(plots_dir, f"patch_timeseries_{model_name}_ode.png")
|
|
407
|
+
logger.info(f"Saved all patch subplots to {plot_path}")
|
|
408
|
+
|
|
409
|
+
return {
|
|
410
|
+
"model_name": model_name,
|
|
411
|
+
"output_dir": config["OutputDir"],
|
|
412
|
+
"csv_path": csv_path,
|
|
413
|
+
"plot_path": plot_path,
|
|
414
|
+
"num_patches": num_patches,
|
|
415
|
+
"patches": patches,
|
|
416
|
+
"t_max": t_max,
|
|
417
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# This file marks the `models` directory as a Python package.
|
|
@@ -0,0 +1,31 @@
|
|
|
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
|
+
Tolerance: 1e-8
|
|
18
|
+
MaxIter: 10000
|
|
19
|
+
StartDate: 2020-01-01
|
|
20
|
+
EndDate: 2022-12-31
|
|
21
|
+
OutputDir: output/{{PROJECT_NAME}}
|
|
22
|
+
compartments: ["S", "I", "R"]
|
|
23
|
+
|
|
24
|
+
# Global model parameters
|
|
25
|
+
Parameters:
|
|
26
|
+
beta: 0.08
|
|
27
|
+
gamma: 0.10
|
|
28
|
+
|
|
29
|
+
# Transition map (required format)
|
|
30
|
+
# Use arrow notation only: X -> Y
|
|
31
|
+
Transitions: {S -> I: "beta", I -> R: "gamma * I"}
|
|
File without changes
|
|
File without changes
|
patchsim/utils/loader.py
ADDED
patchsim/utils/logger.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
import platform
|
|
4
|
+
import sys
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def setup_logger(model_name, config, num_patches, patches, base_model):
|
|
9
|
+
"""
|
|
10
|
+
Set up a logger to log messages to a file and the console, and log system/run details.
|
|
11
|
+
Args:
|
|
12
|
+
model_name (str): Name of the model.
|
|
13
|
+
config (dict): Configuration dictionary.
|
|
14
|
+
num_patches (int): Number of patches.
|
|
15
|
+
patches (list): List of patch names.
|
|
16
|
+
base_model (CompartmentalModel): Base model object.
|
|
17
|
+
Returns:
|
|
18
|
+
logging.Logger: Configured logger.
|
|
19
|
+
"""
|
|
20
|
+
log_dir = os.path.join(config["OutputDir"], "logs")
|
|
21
|
+
os.makedirs(log_dir, exist_ok=True)
|
|
22
|
+
log_file = os.path.join(log_dir, f"{model_name}_run_{datetime.now().strftime('%Y%m%d_%H%M%S')}.log")
|
|
23
|
+
logger = logging.getLogger("PatchSimLogger")
|
|
24
|
+
logger.setLevel(logging.INFO)
|
|
25
|
+
formatter = logging.Formatter("%(asctime)s %(levelname)s %(message)s")
|
|
26
|
+
fh = logging.FileHandler(log_file)
|
|
27
|
+
fh.setFormatter(formatter)
|
|
28
|
+
logger.handlers = []
|
|
29
|
+
logger.addHandler(fh)
|
|
30
|
+
# Log system and run details
|
|
31
|
+
logger.info(f"Model: {model_name}")
|
|
32
|
+
logger.info(f"Python version: {sys.version}")
|
|
33
|
+
logger.info(f"Platform: {platform.platform()}")
|
|
34
|
+
logger.info(f"Parameters: {base_model.parameters}")
|
|
35
|
+
# log per-patch parameters if defined
|
|
36
|
+
if "PatchParameters" in config:
|
|
37
|
+
logger.info("Per-patch parameter overrides detected:")
|
|
38
|
+
for idx, entry in enumerate(config["PatchParameters"]):
|
|
39
|
+
if not isinstance(entry, dict):
|
|
40
|
+
logger.warning(f" PatchParameters[{idx}] is not a mapping: {entry!r}")
|
|
41
|
+
continue
|
|
42
|
+
patch = entry.get("patch", f"<missing-patch-{idx}>")
|
|
43
|
+
params = entry.get("parameters", {})
|
|
44
|
+
logger.info(f" {patch}: {params}")
|
|
45
|
+
else:
|
|
46
|
+
logger.info("No per-patch parameter overrides provided.")
|
|
47
|
+
# Parameter agnostic positivity check
|
|
48
|
+
for param, value in base_model.parameters.items():
|
|
49
|
+
try:
|
|
50
|
+
if float(value) <= 0:
|
|
51
|
+
logger.warning(f"Parameter '{param}' has non-positive value: {value}")
|
|
52
|
+
except Exception:
|
|
53
|
+
logger.warning(f"Parameter '{param}' could not be checked for positivity (value: {value})")
|
|
54
|
+
logger.info(f"PatchFile: {config['PatchFile']}")
|
|
55
|
+
logger.info(f"SeedFile: {config['SeedFile']}")
|
|
56
|
+
logger.info(f"NetworkFile: {config['NetworkFile']}")
|
|
57
|
+
logger.info(f"OutputDir: {config['OutputDir']}")
|
|
58
|
+
logger.info(f"TMax: {config['TMax']}")
|
|
59
|
+
logger.info(f"Num patches: {num_patches}")
|
|
60
|
+
logger.info(f"Patch list: {patches}")
|
|
61
|
+
logger.info(
|
|
62
|
+
f"Base model: compartments={base_model.compartments}, "
|
|
63
|
+
f"transitions={base_model.transitions}, "
|
|
64
|
+
f"parameters={base_model.parameters}"
|
|
65
|
+
)
|
|
66
|
+
logger.info(
|
|
67
|
+
f"Simulation started: model={model_name}, num_patches={num_patches}, patches={patches}, "
|
|
68
|
+
f"transitions={base_model.transitions}, parameters={base_model.parameters}"
|
|
69
|
+
)
|
|
70
|
+
return logger
|
patchsim/utils/viz.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import math
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
import matplotlib.pyplot as plt
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def plot_patch_subplots(t_range, out_ode, patches, output_dir, model_name, patch_parameters=None, compartments=None):
|
|
8
|
+
"""
|
|
9
|
+
Plots all patches as subplots in a single figure and saves the figure.
|
|
10
|
+
"""
|
|
11
|
+
n = len(patches)
|
|
12
|
+
if n == 0:
|
|
13
|
+
raise ValueError("`patches` must contain at least one patch")
|
|
14
|
+
ncols = math.ceil(math.sqrt(n))
|
|
15
|
+
nrows = math.ceil(n / ncols)
|
|
16
|
+
fig, axes = plt.subplots(nrows, ncols, figsize=(5 * ncols, 4 * nrows))
|
|
17
|
+
axes = axes.flatten() if n > 1 else [axes]
|
|
18
|
+
for i, patch in enumerate(patches):
|
|
19
|
+
ax = axes[i]
|
|
20
|
+
# Plot the model's actual compartments; fall back to those present for this patch.
|
|
21
|
+
comps = compartments or [k[: -len(f"_{i}")] for k in out_ode if k.endswith(f"_{i}")]
|
|
22
|
+
for c in comps:
|
|
23
|
+
ax.plot(t_range, out_ode[f"{c}_{i}"], label=c)
|
|
24
|
+
title = f"Patch {patch} (ODE)"
|
|
25
|
+
if patch_parameters and patch in patch_parameters:
|
|
26
|
+
params = patch_parameters[patch]
|
|
27
|
+
param_str = ", ".join(f"{k}={v}" for k, v in params.items())
|
|
28
|
+
title += f"\n({param_str})"
|
|
29
|
+
ax.set_title(title)
|
|
30
|
+
ax.set_xlabel("Time")
|
|
31
|
+
ax.set_ylabel("Count")
|
|
32
|
+
ax.legend()
|
|
33
|
+
# Hide unused subplots (use n instead of loop index i)
|
|
34
|
+
for j in range(n, len(axes)):
|
|
35
|
+
fig.delaxes(axes[j])
|
|
36
|
+
plt.tight_layout()
|
|
37
|
+
os.makedirs(output_dir, exist_ok=True)
|
|
38
|
+
plt.savefig(os.path.join(output_dir, f"patch_timeseries_{model_name}_ode.png"))
|
|
39
|
+
plt.close()
|