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