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 ADDED
@@ -0,0 +1,24 @@
1
+ """PatchSim public SDK interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from importlib.metadata import PackageNotFoundError, version
6
+
7
+ from patchsim.core.model import CompartmentalModel, NetworkModel
8
+ from patchsim.core.simulation import load_config, run_simulation, setup_simulation
9
+ from patchsim.utils.viz import plot_patch_subplots
10
+
11
+ try:
12
+ __version__ = version("patchsim")
13
+ except PackageNotFoundError: # pragma: no cover - local editable fallback
14
+ __version__ = "0.1.0b1"
15
+
16
+ __all__ = [
17
+ "CompartmentalModel",
18
+ "NetworkModel",
19
+ "__version__",
20
+ "load_config",
21
+ "plot_patch_subplots",
22
+ "run_simulation",
23
+ "setup_simulation",
24
+ ]
patchsim/cli.py ADDED
@@ -0,0 +1,246 @@
1
+ import argparse
2
+ import json
3
+ import logging
4
+ import shutil
5
+ import textwrap
6
+ from importlib import resources
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import yaml
11
+
12
+ from patchsim import __version__
13
+ from patchsim.core.simulation import (
14
+ get_available_template_names,
15
+ get_config_schema,
16
+ get_init_template_config,
17
+ get_model_catalog,
18
+ load_config,
19
+ run_simulation,
20
+ setup_simulation,
21
+ )
22
+
23
+
24
+ def _configure_logging(*, json_output: bool = False) -> None:
25
+ """Configure CLI logging with rich handler when available."""
26
+ if json_output:
27
+ logging.basicConfig(level=logging.WARNING)
28
+ return
29
+
30
+ try:
31
+ from rich.logging import RichHandler
32
+
33
+ logging.basicConfig(
34
+ level=logging.INFO,
35
+ format="%(message)s",
36
+ datefmt="[%X]",
37
+ handlers=[RichHandler(rich_tracebacks=True)],
38
+ )
39
+ except Exception:
40
+ logging.basicConfig(level=logging.INFO, format="%(levelname)s:%(name)s:%(message)s")
41
+
42
+
43
+ def _emit_json(payload: dict[str, Any]) -> None:
44
+ print(json.dumps(payload, indent=2, sort_keys=True))
45
+
46
+
47
+ def _cmd_run(config_path: str, *, json_output: bool = False) -> dict[str, Any]:
48
+ if not json_output:
49
+ print("Starting PatchSim simulation...")
50
+ config = load_config(config_path)
51
+ net, y0, patches, num_patches = setup_simulation(config)
52
+ summary = run_simulation(config, config["ModelName"], net, y0, patches, num_patches)
53
+ if not json_output:
54
+ print("Simulation completed successfully.")
55
+ return {"ok": True, "config": config_path, **summary} if json_output else summary
56
+
57
+
58
+ def _cmd_validate(config_path: str, *, json_output: bool = False, schema: bool = False) -> dict[str, Any] | None:
59
+ if schema:
60
+ return get_config_schema()
61
+
62
+ config = load_config(config_path)
63
+ _net, _y0, patches, num_patches = setup_simulation(config)
64
+ if not json_output:
65
+ print(f"Configuration is valid: {config_path}")
66
+ if json_output:
67
+ return {
68
+ "ok": True,
69
+ "config": config_path,
70
+ "model_name": config.get("ModelName"),
71
+ "num_patches": num_patches,
72
+ "patches": patches,
73
+ }
74
+ return None
75
+
76
+
77
+ def _copy_template_tree(template_node, target_path: Path) -> None:
78
+ if template_node.is_dir():
79
+ target_path.mkdir(parents=True, exist_ok=True)
80
+ for child in template_node.iterdir():
81
+ _copy_template_tree(child, target_path / child.name)
82
+ return
83
+
84
+ target_path.parent.mkdir(parents=True, exist_ok=True)
85
+ target_path.write_bytes(template_node.read_bytes())
86
+
87
+
88
+ def _write_seed_for_template(project_dir: Path, config: dict[str, Any]) -> None:
89
+ """Write a seed CSV whose columns match the template's compartments.
90
+
91
+ The scaffold ships a single patch-population file; seed every patch fully
92
+ susceptible and place one infectious individual in the first patch.
93
+ """
94
+ import pandas as pd
95
+
96
+ compartments = list(config.get("compartments") or ["S", "I", "R"])
97
+ patch_df = pd.read_csv(project_dir / config["PatchFile"])
98
+ patch_col = next(c for c in patch_df.columns if c.lower() == "patch")
99
+ pop_col = next(c for c in patch_df.columns if c.lower() == "population")
100
+
101
+ susceptible = "S" if "S" in compartments else compartments[0]
102
+ infectious = "I" if "I" in compartments else compartments[-1]
103
+
104
+ rows = []
105
+ for idx, record in patch_df.iterrows():
106
+ seeded = 1 if idx == 0 else 0
107
+ row = {"patch": record[patch_col], **{c: 0 for c in compartments}}
108
+ row[infectious] = seeded
109
+ row[susceptible] = int(record[pop_col]) - seeded
110
+ rows.append(row)
111
+
112
+ seed_path = project_dir / config["SeedFile"]
113
+ seed_path.parent.mkdir(parents=True, exist_ok=True)
114
+ pd.DataFrame(rows)[["patch", *compartments]].to_csv(seed_path, index=False)
115
+
116
+
117
+ def _cmd_init(name: str, force: bool = False, template: str = "sir") -> None:
118
+ project_dir = Path(name)
119
+
120
+ # Safety checks: prevent deleting cwd, parent dirs, root, or non-directories
121
+ resolved = project_dir.resolve()
122
+ cwd = Path.cwd().resolve()
123
+
124
+ if project_dir.exists() and not project_dir.is_dir():
125
+ raise NotADirectoryError(f"Target exists and is not a directory: {project_dir}")
126
+
127
+ # Block deletion of root, cwd, or any ancestor of cwd
128
+ if force and (
129
+ resolved == Path(resolved.anchor) # Filesystem root (/ or C:\)
130
+ or resolved == cwd # Current working directory
131
+ or cwd in resolved.parents # resolved is ancestor of cwd (e.g., ..)
132
+ ):
133
+ raise ValueError(f"Refusing to overwrite unsafe target: {resolved}")
134
+
135
+ if project_dir.exists() and any(project_dir.iterdir()) and not force:
136
+ raise FileExistsError(f"Refusing to overwrite existing directory: {project_dir}. Use --force to overwrite.")
137
+
138
+ if project_dir.exists() and force:
139
+ shutil.rmtree(project_dir)
140
+
141
+ template_root = resources.files("patchsim").joinpath("templates", "project")
142
+ _copy_template_tree(template_root, project_dir)
143
+
144
+ template_config = get_init_template_config(template, project_dir.name)
145
+ config_path = project_dir / "config.yaml"
146
+ config_path.write_text(yaml.safe_dump(template_config, sort_keys=False), encoding="utf-8")
147
+ _write_seed_for_template(project_dir, template_config)
148
+
149
+ print(f"Created project scaffold at: {project_dir}")
150
+
151
+
152
+ def _list_builtin_models() -> list[dict[str, str]]:
153
+ return get_model_catalog()
154
+
155
+
156
+ def _cmd_list_models(*, json_output: bool = False) -> list[dict[str, str]]:
157
+ models = _list_builtin_models()
158
+ if not models:
159
+ if not json_output:
160
+ print("No built-in models found.")
161
+ return []
162
+
163
+ if json_output:
164
+ return models
165
+
166
+ print("Built-in models and templates:")
167
+ for model in models:
168
+ print(f"- {model['name']} ({model['kind']})")
169
+ return models
170
+
171
+
172
+ def main() -> None:
173
+ """Command-line interface for running the PatchSim simulation."""
174
+ parser = argparse.ArgumentParser(
175
+ description=(
176
+ "PatchSim: A modular metapopulation simulation framework for multi-disease epidemiological modelling."
177
+ ),
178
+ formatter_class=argparse.RawDescriptionHelpFormatter,
179
+ epilog=textwrap.dedent(
180
+ """
181
+ Examples:
182
+ uv run patchsim init my-project
183
+ uv run patchsim init my-project --template seir
184
+ uv run patchsim run -c my-project/config.yaml
185
+ uv run patchsim validate -c my-project/config.yaml
186
+ uv run patchsim list-models
187
+ """
188
+ ),
189
+ )
190
+ parser.add_argument("--version", action="version", version=f"patchsim {__version__}")
191
+
192
+ subparsers = parser.add_subparsers(dest="command", required=True)
193
+
194
+ init_p = subparsers.add_parser("init", help="Scaffold a new PatchSim project")
195
+ init_p.add_argument("name", help="Directory name for the new project")
196
+ init_p.add_argument("--force", action="store_true", help="Overwrite target directory if it already exists")
197
+ init_p.add_argument(
198
+ "--template",
199
+ choices=get_available_template_names(),
200
+ default="sir",
201
+ help="Starter template to use for config.yaml",
202
+ )
203
+
204
+ run_p = subparsers.add_parser("run", help="Run a simulation")
205
+ run_p.add_argument("-c", "--config", required=True, help="Path to simulation config YAML")
206
+ run_p.add_argument("--json", action="store_true", help="Emit machine-readable JSON summary")
207
+
208
+ validate_p = subparsers.add_parser("validate", help="Validate config and inputs")
209
+ validate_p.add_argument("-c", "--config", help="Path to simulation config YAML")
210
+ validate_p.add_argument("--schema", action="store_true", help="Print the configuration JSON Schema")
211
+ validate_p.add_argument("--json", action="store_true", help="Emit machine-readable JSON summary")
212
+
213
+ list_p = subparsers.add_parser("list-models", help="List available built-in models")
214
+ list_p.add_argument("--json", action="store_true", help="Emit machine-readable JSON list")
215
+
216
+ args = parser.parse_args()
217
+ json_mode = bool(getattr(args, "json", False) or getattr(args, "schema", False))
218
+ _configure_logging(json_output=json_mode)
219
+
220
+ try:
221
+ if args.command == "init":
222
+ _cmd_init(args.name, force=args.force, template=args.template)
223
+ elif args.command == "run":
224
+ result = _cmd_run(args.config, json_output=args.json)
225
+ if args.json:
226
+ _emit_json(result)
227
+ elif args.command == "validate":
228
+ if not args.schema and not args.config:
229
+ parser.error("the following arguments are required: -c/--config")
230
+ result = _cmd_validate(args.config, json_output=args.json, schema=args.schema)
231
+ if result is not None:
232
+ _emit_json(result)
233
+ elif args.command == "list-models":
234
+ result = _cmd_list_models(json_output=args.json)
235
+ if args.json:
236
+ _emit_json({"models": result})
237
+ else:
238
+ parser.print_help()
239
+ raise SystemExit(2)
240
+ except Exception as e:
241
+ logging.error(f"Simulation failed: {e}")
242
+ raise
243
+
244
+
245
+ if __name__ == "__main__":
246
+ main()
File without changes
patchsim/core/model.py ADDED
@@ -0,0 +1,237 @@
1
+ """
2
+ Core model implementation for compartmental models.
3
+ """
4
+
5
+ import re
6
+ from typing import Any, Callable, Dict
7
+
8
+ from scipy.integrate import odeint
9
+
10
+
11
+ class CompartmentalModel:
12
+ """Base class for compartmental models."""
13
+
14
+ def __init__(self, compartments: list[str], parameters: dict[str, float], transitions: list[dict[str, Any]]):
15
+ """Initialize the model with compartments, parameters, and transitions."""
16
+ self.compartments = compartments
17
+ self.parameters = parameters
18
+ self.transitions = transitions
19
+
20
+ def compute_rates(self, state: dict[str, float], parameters: dict[str, float] | None = None) -> dict[str, float]:
21
+ """Compute transition rates for each compartment.
22
+
23
+ Args:
24
+ state: Current compartment state
25
+ parameters: Optional parameter override (defaults to self.parameters)
26
+ """
27
+ params = parameters if parameters is not None else self.parameters
28
+ rates = {}
29
+ for transition in self.transitions:
30
+ transition_label = transition["transition"]
31
+ source, target = [p.strip() for p in transition_label.split("->")]
32
+ rate = transition["rate"]
33
+ rate_expr = rate
34
+ # Handle rate expressions
35
+ if isinstance(rate_expr, str):
36
+ # Safe evaluation: build scope from parameters and state, disable builtins
37
+ scope = {**params, **state}
38
+ try:
39
+ rate_val = eval(rate_expr, {"__builtins__": {}}, scope)
40
+ except (KeyError, NameError, ValueError, SyntaxError, TypeError, ZeroDivisionError) as e:
41
+ msg = f"Invalid rate expression '{rate_expr}' in transition '{transition_label}': {e}"
42
+ raise ValueError(msg) from e
43
+ else:
44
+ rate_val = rate_expr
45
+
46
+ # If expression already includes the source compartment, don't multiply again.
47
+ if isinstance(rate, str) and re.search(rf"\b{re.escape(source)}\b", rate):
48
+ flow = rate_val
49
+ else:
50
+ flow = rate_val * state[source]
51
+
52
+ rates[transition_label] = flow
53
+ return rates
54
+
55
+
56
+ class NetworkModel:
57
+ """Network model for multi-patch simulations."""
58
+
59
+ def __init__(self, base_model: CompartmentalModel, num_patches: int, network_matrix: list[list[float]]):
60
+ """Initialize the network model."""
61
+ self.base_model = base_model
62
+ self.num_patches = num_patches
63
+ self.network = network_matrix
64
+ self.all_compartments = [f"{c}_{i}" for i in range(num_patches) for c in base_model.compartments]
65
+
66
+ def get_patch_state(self, full_state: Dict[str, float], patch_idx: int) -> Dict[str, float]:
67
+ """Get state for a specific patch."""
68
+ return {c: full_state[f"{c}_{patch_idx}"] for c in self.base_model.compartments}
69
+
70
+ def get_patch_population(self, state: Dict[str, float]) -> float:
71
+ """Get total population for a patch."""
72
+ return sum(state[c] for c in self.base_model.compartments)
73
+
74
+ def compute_force_of_infection(self, full_state: dict[str, float], infected_compartment: str = "I") -> list[float]:
75
+ """Compute force of infection for each patch (per-capita rate, before beta scaling).
76
+
77
+ Args:
78
+ full_state: Current state of all compartments
79
+ infected_compartment: Name of the compartment representing infected individuals
80
+
81
+ Returns:
82
+ List of per-capita forces of infection (model_runner applies beta * FOI * S)
83
+ """
84
+ lambdas = []
85
+ for i in range(self.num_patches):
86
+ if self.num_patches == 1:
87
+ # Single patch case: infected proportion
88
+ patch_state = self.get_patch_state(full_state, 0)
89
+ infected = patch_state[infected_compartment]
90
+ total_pop = self.get_patch_population(patch_state)
91
+ force = infected / total_pop if total_pop > 0 else 0
92
+ else:
93
+ # Multi-patch case: network-weighted infected proportion
94
+ force = 0
95
+ for j in range(self.num_patches):
96
+ patch_state_j = self.get_patch_state(full_state, j)
97
+ infected_j = patch_state_j[infected_compartment]
98
+ pop_j = self.get_patch_population(patch_state_j)
99
+ force += self.network[i][j] * (infected_j / pop_j if pop_j > 0 else 0)
100
+ lambdas.append(force)
101
+ return lambdas
102
+
103
+ def _adjust_infection_rate(
104
+ self,
105
+ patch_params: dict[str, float],
106
+ original_rate_expr: Any,
107
+ rate: float,
108
+ patch_state: dict[str, float],
109
+ lambdas: list[float],
110
+ patch_idx: int,
111
+ is_infection_transition: bool,
112
+ has_network: bool,
113
+ ) -> float:
114
+ """Adjust infection rate for network-mediated FOI.
115
+
116
+ Args:
117
+ patch_params: Parameters for the current patch
118
+ original_rate_expr: Original rate expression from transition definition
119
+ rate: Computed rate from base model
120
+ patch_state: Current state for the patch
121
+ lambdas: Force of infection for each patch
122
+ patch_idx: Current patch index
123
+ is_infection_transition: Whether this is an infection transition
124
+ has_network: Whether multi-patch network exists
125
+
126
+ Returns:
127
+ Adjusted rate incorporating network FOI if applicable
128
+ """
129
+ if is_infection_transition and has_network:
130
+ # Network case: Apply network FOI (lambdas already computed)
131
+ # Check if original expression includes beta term
132
+ beta = patch_params.get("beta", 1.0)
133
+ if isinstance(original_rate_expr, str) and re.search(r"\bbeta\b", original_rate_expr):
134
+ # Rate expression includes beta; apply network FOI correction
135
+ adjusted_rate = beta * patch_state["S"] * lambdas[patch_idx]
136
+ else:
137
+ # Rate is already computed; apply FOI scaling
138
+ adjusted_rate = rate * lambdas[patch_idx] if patch_state["S"] > 0 else 0
139
+ else:
140
+ # Single patch or non-infection transition: use rate as-is
141
+ adjusted_rate = rate
142
+ return adjusted_rate
143
+
144
+ def compute_derivatives(self, state: dict[str, float]) -> dict[str, float]:
145
+ """Compute derivatives for all compartments based on transitions, incorporating network-mediated FOI."""
146
+ derivatives = {c: 0.0 for c in self.all_compartments}
147
+
148
+ # Compute network-mediated force of infection for each patch
149
+ lambdas = self.compute_force_of_infection(state)
150
+
151
+ # Process each patch
152
+ for i in range(self.num_patches):
153
+ # Get state for this patch
154
+ patch_state = self.get_patch_state(state, i)
155
+
156
+ # Resolve patch parameters using canonical patch ordering when available.
157
+ if hasattr(self, "patch_parameters"):
158
+ patch_name = None
159
+ if hasattr(self, "patch_names") and i < len(self.patch_names):
160
+ patch_name = self.patch_names[i]
161
+ elif self.patch_parameters:
162
+ import logging
163
+
164
+ logger = logging.getLogger(__name__)
165
+ logger.warning(
166
+ "patch_parameters defined but patch_names not set; "
167
+ "patch-specific parameters will be ignored for patch %d",
168
+ i,
169
+ )
170
+ patch_params = {**self.base_model.parameters, **self.patch_parameters.get(patch_name, {})}
171
+ else:
172
+ patch_params = self.base_model.parameters
173
+
174
+ # Compute rates with patch-specific parameters without mutating shared state
175
+ rates = self.base_model.compute_rates(patch_state, parameters=patch_params)
176
+
177
+ # Update derivatives based on rates, applying network-mediated FOI to infection transitions
178
+ for transition in self.base_model.transitions:
179
+ transition_label = transition["transition"]
180
+ source, target = [p.strip() for p in transition_label.split("->")]
181
+ rate = rates[transition_label]
182
+ original_rate_expr = transition.get("rate", "")
183
+
184
+ # Apply network-mediated FOI to susceptible-to-infection transitions.
185
+ # Allow model-level override via `infection_compartments` attribute.
186
+ infection_compartments = set(getattr(self, "infection_compartments", {"I", "E"}))
187
+ is_infection_transition = source == "S" and target in infection_compartments
188
+
189
+ has_network = self.num_patches > 1 and self.network is not None
190
+
191
+ # Use shared helper to adjust infection rate for network FOI
192
+ adjusted_rate = self._adjust_infection_rate(
193
+ patch_params,
194
+ original_rate_expr,
195
+ rate,
196
+ patch_state,
197
+ lambdas,
198
+ i,
199
+ is_infection_transition,
200
+ has_network,
201
+ )
202
+
203
+ # Decrease source compartment
204
+ derivatives[f"{source}_{i}"] -= adjusted_rate
205
+ # Increase target compartment
206
+ derivatives[f"{target}_{i}"] += adjusted_rate
207
+
208
+ return derivatives
209
+
210
+ def simulate_discrete(self, y0_dict: dict[str, float], t_range: list[float]) -> dict[str, list[float]]:
211
+ """Run discrete-time simulation."""
212
+ state = y0_dict.copy()
213
+ history = {c: [state[c]] for c in self.all_compartments}
214
+
215
+ for _ in t_range[1:]:
216
+ derivatives = self.compute_derivatives(state)
217
+ new_state = {c: state[c] + derivatives[c] for c in self.all_compartments}
218
+ state = new_state
219
+ for c in self.all_compartments:
220
+ history[c].append(state[c])
221
+
222
+ return history
223
+
224
+ def simulate_ode(
225
+ self, y0_dict: dict[str, float], t_range: list[float], integrator: Callable = odeint
226
+ ) -> tuple[list[float], dict[str, list[float]]]:
227
+ """Run ODE simulation."""
228
+ y0 = [y0_dict[c] for c in self.all_compartments]
229
+
230
+ def rhs(y, t):
231
+ state = {c: y[i] for i, c in enumerate(self.all_compartments)}
232
+ derivatives = self.compute_derivatives(state)
233
+ return [derivatives[c] for c in self.all_compartments]
234
+
235
+ sol = integrator(rhs, y0, t_range)
236
+ out = {c: sol[:, i] for i, c in enumerate(self.all_compartments)}
237
+ return t_range, out
@@ -0,0 +1,62 @@
1
+ from scipy.integrate import odeint
2
+
3
+
4
+ class Model:
5
+ """
6
+ High-level simulation model.
7
+ Owns the Network and builds/solves the ODE.
8
+ """
9
+
10
+ def __init__(self, network_model, compartments):
11
+ self.network = network_model
12
+ self.compartments = compartments
13
+ self.all_vars = self.network.all_compartments
14
+
15
+ def construct_ode(self):
16
+ def rhs(y, t):
17
+ state = {v: y[i] for i, v in enumerate(self.all_vars)}
18
+ dydt = {v: 0.0 for v in self.all_vars}
19
+
20
+ # Compute network-based force of infection (per-capita, without beta)
21
+ lambdas = self.network.compute_force_of_infection(state)
22
+
23
+ # Apply transitions for all patches
24
+ for i in range(self.network.num_patches):
25
+ patch_state = {c: state[f"{c}_{i}"] for c in self.compartments}
26
+ rates = self.network.base_model.compute_rates(patch_state)
27
+ patch_params = self.network.base_model.parameters
28
+
29
+ for key, rate in rates.items():
30
+ src, tgt = [p.strip() for p in key.split("->")]
31
+
32
+ # Determine if this is an infection transition
33
+ infection_compartments = set(getattr(self.network, "infection_compartments", {"I", "E"}))
34
+ is_infection_transition = src == "S" and tgt in infection_compartments
35
+
36
+ # Use network helper to apply FOI adjustment consistently
37
+ has_network = self.network.num_patches > 1 and self.network.network is not None
38
+ adjusted_rate = self.network._adjust_infection_rate(
39
+ patch_params, key, rate, patch_state, lambdas, i, is_infection_transition, has_network
40
+ )
41
+
42
+ dydt[f"{src}_{i}"] -= adjusted_rate
43
+ dydt[f"{tgt}_{i}"] += adjusted_rate
44
+
45
+ return [dydt[v] for v in self.all_vars]
46
+
47
+ return rhs
48
+
49
+ def solve(self, y0, t_range):
50
+ rhs = self.construct_ode()
51
+ # Validate all required variables are present in y0
52
+ missing = [v for v in self.all_vars if v not in y0]
53
+ if missing:
54
+ raise ValueError(f"Missing initial values for: {missing}")
55
+ y0_vec = [y0[v] for v in self.all_vars]
56
+ sol = odeint(rhs, y0_vec, t_range)
57
+ return {v: sol[:, i] for i, v in enumerate(self.all_vars)}
58
+
59
+ def visualize(self, t, results, patches, outdir, model_name):
60
+ from patchsim.utils.viz import plot_patch_subplots
61
+
62
+ plot_patch_subplots(t, results, patches, outdir, model_name, compartments=self.compartments)