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.
@@ -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,10 @@
1
+ # Built-in SEIR template
2
+ compartments: ["S", "E", "I", "R"]
3
+ Parameters:
4
+ beta: 0.25
5
+ sigma: 0.2
6
+ gamma: 0.1
7
+ Transitions:
8
+ "S -> E": "beta"
9
+ "E -> I": "sigma * E"
10
+ "I -> R": "gamma * I"
@@ -0,0 +1,8 @@
1
+ # Built-in SIR template
2
+ compartments: ["S", "I", "R"]
3
+ Parameters:
4
+ beta: 0.2
5
+ gamma: 0.1
6
+ Transitions:
7
+ "S -> I": "beta"
8
+ "I -> R": "gamma * I"
@@ -0,0 +1,10 @@
1
+ # Built-in SIRS template
2
+ compartments: ["S", "I", "R"]
3
+ Parameters:
4
+ beta: 0.2
5
+ gamma: 0.1
6
+ waning: 0.02
7
+ Transitions:
8
+ "S -> I": "beta"
9
+ "I -> R": "gamma * I"
10
+ "R -> S": "waning * R"
@@ -0,0 +1,8 @@
1
+ # Built-in SIS template
2
+ compartments: ["S", "I"]
3
+ Parameters:
4
+ beta: 0.08
5
+ gamma: 0.1
6
+ Transitions:
7
+ "S -> I": "beta"
8
+ "I -> S": "gamma * I"
@@ -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"}
@@ -0,0 +1,5 @@
1
+ day,source,target,weight
2
+ 0,A,A,0.9
3
+ 0,A,B,0.1
4
+ 0,B,A,0.1
5
+ 0,B,B,0.9
@@ -0,0 +1,3 @@
1
+ patch,Population
2
+ A,1000
3
+ B,500
@@ -0,0 +1,3 @@
1
+ patch,S,I,R
2
+ A,999,1,0
3
+ B,500,0,0
File without changes
File without changes
@@ -0,0 +1,6 @@
1
+ """Deprecated placeholder module.
2
+
3
+ Legacy loader helpers were removed because they were unused and did not reflect
4
+ the active simulation pipeline. Use `patchsim.core.simulation` and
5
+ `patchsim.utils.logger` / `patchsim.utils.viz` instead.
6
+ """
@@ -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()