patchsim 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
patchsim/core/model.py ADDED
@@ -0,0 +1,362 @@
1
+ """
2
+ Core model implementation for compartmental models.
3
+ """
4
+
5
+ import logging
6
+ import re
7
+ from typing import Any, Callable, Dict
8
+
9
+ import numpy as np
10
+ from scipy.integrate import odeint
11
+
12
+ from patchsim.core.expressions import evaluate as evaluate_expression
13
+
14
+ _NEGATIVITY_ROUNDOFF_FACTOR = 64
15
+ _MAX_EXPRESSION_IN_ERROR = 80 # max expression length shown in error messages
16
+
17
+
18
+ def _validate_expression_names(compartments, parameter_names) -> None:
19
+ overlap = sorted(set(compartments) & set(parameter_names))
20
+ if overlap:
21
+ raise ValueError(f"Parameter and compartment names must be distinct; shared names: {overlap}")
22
+
23
+
24
+ def _validated_time_grid(t_range: list[float]) -> "np.ndarray":
25
+ """Return ``t_range`` as a finite, strictly increasing 1-D array of time points."""
26
+ times = np.asarray(t_range, dtype=float)
27
+ if times.ndim != 1:
28
+ raise ValueError(f"Time grid must be one-dimensional; received {times.ndim} dimensions")
29
+ if not np.all(np.isfinite(times)):
30
+ raise ValueError("Time grid must contain only finite values")
31
+ steps = np.diff(times)
32
+ if steps.size and np.any(steps <= 0):
33
+ raise ValueError(
34
+ "Discrete simulation requires strictly increasing time points; "
35
+ f"received a step of {steps.min()}. A zero step never advances and a "
36
+ "negative step integrates backwards."
37
+ )
38
+ return times
39
+
40
+
41
+ def _validated_initial_total(y0_dict: dict[str, float]) -> float:
42
+ """Return the total initial population, rejecting non-finite or negative values.
43
+
44
+ Also rejects a non-finite total, which can occur when finite compartments sum past
45
+ the float range and would make population-based rates invalid.
46
+ """
47
+ for compartment, value in y0_dict.items():
48
+ if not np.isfinite(value):
49
+ raise ValueError(f"Initial value for '{compartment}' must be finite; received {value}")
50
+ if value < 0:
51
+ raise ValueError(f"Initial value for '{compartment}' must be non-negative; received {value}")
52
+ total = float(sum(y0_dict.values()))
53
+ if not np.isfinite(total):
54
+ raise ValueError("Total initial population is not finite")
55
+ return total
56
+
57
+
58
+ def _validated_population(
59
+ value: float, compartment: str, time: float, step_index: int, dt: float, tolerance: float
60
+ ) -> float:
61
+ """Check one compartment value after a step, tolerating rounding residue near zero."""
62
+ if not np.isfinite(value):
63
+ raise ValueError(
64
+ f"Discrete simulation diverged: '{compartment}' became {value} at "
65
+ f"t={time} (step {step_index}). Reduce TimeStep or the supplied interval width."
66
+ )
67
+ if value < -tolerance:
68
+ raise ValueError(
69
+ f"Discrete simulation produced a negative population: '{compartment}' "
70
+ f"became {value} at t={time} (step {step_index}). "
71
+ f"TimeStep or the supplied interval width ({dt}) is too large for these rates."
72
+ )
73
+ # Returned unchanged rather than clipped to zero, so total population is conserved.
74
+ return value
75
+
76
+
77
+ class CompartmentalModel:
78
+ """Base class for compartmental models."""
79
+
80
+ def __init__(self, compartments: list[str], parameters: dict[str, float], transitions: list[dict[str, Any]]):
81
+ """Initialize the model with compartments, parameters, and transitions."""
82
+ _validate_expression_names(compartments, parameters)
83
+ self.compartments = compartments
84
+ self.parameters = parameters
85
+ self.transitions = transitions
86
+
87
+ def compute_rates(self, state: dict[str, float], parameters: dict[str, float] | None = None) -> dict[str, float]:
88
+ """Compute transition rates for each compartment.
89
+
90
+ Args:
91
+ state: Current compartment state
92
+ parameters: Optional parameter override (defaults to self.parameters)
93
+ """
94
+ params = parameters if parameters is not None else self.parameters
95
+ rates = {}
96
+ for transition in self.transitions:
97
+ transition_label = transition["transition"]
98
+ source, target = [p.strip() for p in transition_label.split("->")]
99
+ rate = transition["rate"]
100
+ rate_expr = rate
101
+ # Handle rate expressions
102
+ if isinstance(rate_expr, str):
103
+ scope = {**params, **state}
104
+ try:
105
+ rate_val = evaluate_expression(rate_expr, scope)
106
+ except ValueError as e:
107
+ shown = (
108
+ rate_expr
109
+ if len(rate_expr) <= _MAX_EXPRESSION_IN_ERROR
110
+ else f"{rate_expr[: _MAX_EXPRESSION_IN_ERROR - 3]}..."
111
+ )
112
+ msg = f"Invalid rate expression '{shown}' in transition '{transition_label}': {e}"
113
+ raise ValueError(msg) from e
114
+ else:
115
+ rate_val = rate_expr
116
+
117
+ # If expression already includes the source compartment, don't multiply again.
118
+ if isinstance(rate, str) and re.search(rf"\b{re.escape(source)}\b", rate):
119
+ flow = rate_val
120
+ else:
121
+ flow = rate_val * state[source]
122
+
123
+ rates[transition_label] = flow
124
+ return rates
125
+
126
+
127
+ class NetworkModel:
128
+ """Network model for multi-patch simulations."""
129
+
130
+ def __init__(
131
+ self,
132
+ base_model: CompartmentalModel,
133
+ num_patches: int,
134
+ network_matrix: list[list[float]],
135
+ groups: list[str] | None = None,
136
+ interaction_matrix: list[list[float]] | None = None,
137
+ ):
138
+ """Initialize the network model."""
139
+ self.base_model = base_model
140
+ self.num_patches = num_patches
141
+ self.network = network_matrix
142
+ self.groups = list(groups or [])
143
+ self.num_groups = len(self.groups) if self.groups else 1
144
+ if len(set(self.groups)) != len(self.groups):
145
+ raise ValueError("Group labels must be unique.")
146
+ if interaction_matrix is not None and not self.groups:
147
+ raise ValueError("An interaction matrix requires group labels.")
148
+ self.interaction = np.asarray(
149
+ interaction_matrix if interaction_matrix is not None else [[1.0]],
150
+ dtype=float,
151
+ )
152
+ self._patch_names_warning_emitted = False
153
+ if self.interaction.shape != (self.num_groups, self.num_groups):
154
+ raise ValueError(
155
+ f"Interaction matrix must have shape {(self.num_groups, self.num_groups)}; "
156
+ f"received {self.interaction.shape}."
157
+ )
158
+ self.all_compartments = [
159
+ self.state_key(c, patch_idx, group_idx)
160
+ for patch_idx in range(num_patches)
161
+ for group_idx in range(self.num_groups)
162
+ for c in base_model.compartments
163
+ ]
164
+
165
+ def state_key(self, compartment: str, patch_idx: int, group_idx: int = 0) -> str:
166
+ """Return the internal state key for one compartment stratum."""
167
+ if self.groups:
168
+ return f"{compartment}_{patch_idx}_{group_idx}"
169
+ return f"{compartment}_{patch_idx}"
170
+
171
+ def get_patch_state(self, full_state: Dict[str, float], patch_idx: int, group_idx: int = 0) -> Dict[str, float]:
172
+ """Get compartment state for a patch and optional group."""
173
+ return {c: full_state[self.state_key(c, patch_idx, group_idx)] for c in self.base_model.compartments}
174
+
175
+ def get_patch_population(self, state: Dict[str, float]) -> float:
176
+ """Get total population for a patch."""
177
+ return sum(state[c] for c in self.base_model.compartments)
178
+
179
+ def compute_force_of_infection(
180
+ self, full_state: dict[str, float], infected_compartment: str = "I"
181
+ ) -> list[float] | list[list[float]]:
182
+ """Compute force of infection for each patch (per-capita rate, before beta scaling).
183
+
184
+ Args:
185
+ full_state: Current state of all compartments
186
+ infected_compartment: Name of the compartment representing infected individuals
187
+
188
+ Returns:
189
+ Per-capita forces by patch, or by patch and group for grouped models.
190
+ """
191
+ prevalence = np.zeros((self.num_patches, self.num_groups), dtype=float)
192
+ for patch_idx in range(self.num_patches):
193
+ for group_idx in range(self.num_groups):
194
+ contributor_state = self.get_patch_state(full_state, patch_idx, group_idx)
195
+ population = self.get_patch_population(contributor_state)
196
+ if population > 0:
197
+ prevalence[patch_idx, group_idx] = contributor_state[infected_compartment] / population
198
+
199
+ spatial = np.ones((1, 1), dtype=float) if self.num_patches == 1 else np.asarray(self.network, dtype=float)
200
+ forces = spatial @ prevalence @ self.interaction.T
201
+ if self.groups:
202
+ return forces.tolist()
203
+ return forces[:, 0].tolist()
204
+
205
+ def _adjust_infection_rate(
206
+ self,
207
+ patch_params: dict[str, float],
208
+ original_rate_expr: Any,
209
+ rate: float,
210
+ patch_state: dict[str, float],
211
+ force_of_infection: float,
212
+ is_infection_transition: bool,
213
+ has_mixing: bool,
214
+ ) -> float:
215
+ """Adjust infection rate for network-mediated FOI.
216
+
217
+ Args:
218
+ patch_params: Parameters for the current patch
219
+ original_rate_expr: Original rate expression from transition definition
220
+ rate: Computed rate from base model
221
+ patch_state: Current state for the patch
222
+ force_of_infection: Force of infection for the current stratum
223
+ is_infection_transition: Whether this is an infection transition
224
+ has_mixing: Whether spatial or group mixing is active
225
+
226
+ Returns:
227
+ Adjusted rate incorporating network FOI if applicable
228
+ """
229
+ if is_infection_transition and has_mixing:
230
+ # Network case: Apply network FOI (lambdas already computed)
231
+ # Check if original expression includes beta term
232
+ beta = patch_params.get("beta", 1.0)
233
+ if isinstance(original_rate_expr, str) and re.search(r"\bbeta\b", original_rate_expr):
234
+ # Rate expression includes beta; apply network FOI correction
235
+ adjusted_rate = beta * patch_state["S"] * force_of_infection
236
+ else:
237
+ # Rate is already computed; apply FOI scaling
238
+ adjusted_rate = rate * force_of_infection if patch_state["S"] > 0 else 0
239
+ else:
240
+ # Single patch or non-infection transition: use rate as-is
241
+ adjusted_rate = rate
242
+ return adjusted_rate
243
+
244
+ def compute_derivatives(self, state: dict[str, float]) -> dict[str, float]:
245
+ """Compute derivatives for all compartments based on transitions, incorporating network-mediated FOI."""
246
+ derivatives = {c: 0.0 for c in self.all_compartments}
247
+
248
+ # Compute network-mediated force of infection for each patch
249
+ lambdas = self.compute_force_of_infection(state)
250
+ infection_compartments = set(getattr(self, "infection_compartments", {"I", "E"}))
251
+ has_mixing = self.num_patches > 1 or bool(self.groups)
252
+
253
+ # Process each patch and optional group.
254
+ for i in range(self.num_patches):
255
+ for group_idx in range(self.num_groups):
256
+ patch_state = self.get_patch_state(state, i, group_idx)
257
+
258
+ # Resolve patch parameters using canonical patch ordering when available.
259
+ if hasattr(self, "patch_parameters"):
260
+ patch_name = None
261
+ if hasattr(self, "patch_names") and i < len(self.patch_names):
262
+ patch_name = self.patch_names[i]
263
+ elif self.patch_parameters and not self._patch_names_warning_emitted:
264
+ self._patch_names_warning_emitted = True
265
+ logging.getLogger(__name__).warning(
266
+ "patch_parameters defined but patch_names not set; "
267
+ "patch-specific parameters will be ignored for patch %d",
268
+ i,
269
+ )
270
+ patch_params = {**self.base_model.parameters, **self.patch_parameters.get(patch_name, {})}
271
+ else:
272
+ patch_params = self.base_model.parameters
273
+
274
+ rates = self.base_model.compute_rates(patch_state, parameters=patch_params)
275
+ force = lambdas[i][group_idx] if self.groups else lambdas[i]
276
+
277
+ for transition in self.base_model.transitions:
278
+ transition_label = transition["transition"]
279
+ source, target = [p.strip() for p in transition_label.split("->")]
280
+ rate = rates[transition_label]
281
+ original_rate_expr = transition.get("rate", "")
282
+
283
+ is_infection_transition = source == "S" and target in infection_compartments
284
+ adjusted_rate = self._adjust_infection_rate(
285
+ patch_params,
286
+ original_rate_expr,
287
+ rate,
288
+ patch_state,
289
+ force,
290
+ is_infection_transition,
291
+ has_mixing,
292
+ )
293
+
294
+ derivatives[self.state_key(source, i, group_idx)] -= adjusted_rate
295
+ derivatives[self.state_key(target, i, group_idx)] += adjusted_rate
296
+
297
+ return derivatives
298
+
299
+ def simulate_discrete(self, y0_dict: dict[str, float], t_range: list[float]) -> dict[str, list[float]]:
300
+ """Run a discrete-time forward simulation.
301
+
302
+ Takes one explicit Euler step per interval in ``t_range``, using that interval's
303
+ own width. The grid names the points to simulate and need not be evenly spaced.
304
+ The method has no stability control: a step that drives a compartment to a
305
+ non-finite value, or significantly below zero, raises. Small negative residue from
306
+ floating-point error is returned unchanged so that total population is conserved.
307
+
308
+ Args:
309
+ y0_dict: Initial state mapping for all compartment variables.
310
+ t_range: Increasing sequence of time points to simulate.
311
+
312
+ Returns:
313
+ History of each compartment variable over time.
314
+
315
+ Raises:
316
+ ValueError: If the initial state or time grid is invalid, or if a step produces
317
+ a non-finite or significantly negative compartment value.
318
+ """
319
+ state = y0_dict.copy()
320
+ expected_compartments = set(self.all_compartments)
321
+ missing = expected_compartments - state.keys()
322
+ extra = state.keys() - expected_compartments
323
+ if missing or extra:
324
+ raise ValueError(
325
+ f"Initial state keys do not match model compartments (missing={sorted(missing)}, extra={sorted(extra)})"
326
+ )
327
+ history = {c: [state[c]] for c in self.all_compartments}
328
+
329
+ times = _validated_time_grid(t_range)
330
+ # Validate before the early return, not only when steps are taken.
331
+ _validated_initial_total(y0_dict)
332
+
333
+ if times.size < 2:
334
+ return history
335
+
336
+ for step_index, (dt, time) in enumerate(zip(np.diff(times), times[1:], strict=True), start=1):
337
+ derivatives = self.compute_derivatives(state)
338
+ next_state = {}
339
+ for c in self.all_compartments:
340
+ delta = derivatives[c] * float(dt)
341
+ value = state[c] + delta
342
+ tolerance = _NEGATIVITY_ROUNDOFF_FACTOR * np.finfo(float).eps * max(abs(state[c]), abs(delta), 1.0)
343
+ next_state[c] = _validated_population(value, c, time, step_index, float(dt), tolerance)
344
+ history[c].append(next_state[c])
345
+ state = next_state
346
+
347
+ return history
348
+
349
+ def simulate_ode(
350
+ self, y0_dict: dict[str, float], t_range: list[float], integrator: Callable = odeint
351
+ ) -> tuple[list[float], dict[str, list[float]]]:
352
+ """Run ODE simulation."""
353
+ y0 = [y0_dict[c] for c in self.all_compartments]
354
+
355
+ def rhs(y, t):
356
+ state = {c: y[i] for i, c in enumerate(self.all_compartments)}
357
+ derivatives = self.compute_derivatives(state)
358
+ return [derivatives[c] for c in self.all_compartments]
359
+
360
+ sol = integrator(rhs, y0, t_range)
361
+ out = {c: sol[:, i] for i, c in enumerate(self.all_compartments)}
362
+ return t_range, out
@@ -0,0 +1,44 @@
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 = self.network.compute_derivatives(state)
19
+ return [dydt[v] for v in self.all_vars]
20
+
21
+ return rhs
22
+
23
+ def solve(self, y0, t_range):
24
+ rhs = self.construct_ode()
25
+ # Validate all required variables are present in y0
26
+ missing = [v for v in self.all_vars if v not in y0]
27
+ if missing:
28
+ raise ValueError(f"Missing initial values for: {missing}")
29
+ y0_vec = [y0[v] for v in self.all_vars]
30
+ sol = odeint(rhs, y0_vec, t_range)
31
+ return {v: sol[:, i] for i, v in enumerate(self.all_vars)}
32
+
33
+ def visualize(self, t, results, patches, outdir, model_name):
34
+ from patchsim.utils.viz import plot_patch_subplots
35
+
36
+ plot_patch_subplots(
37
+ t,
38
+ results,
39
+ patches,
40
+ outdir,
41
+ model_name,
42
+ compartments=self.compartments,
43
+ groups=self.network.groups,
44
+ )