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/__init__.py +27 -0
- patchsim/calibration.py +834 -0
- patchsim/cli.py +393 -0
- patchsim/core/__init__.py +0 -0
- patchsim/core/expressions.py +144 -0
- patchsim/core/model.py +362 -0
- patchsim/core/model_runner.py +44 -0
- patchsim/core/simulation.py +878 -0
- patchsim/models/__init__.py +1 -0
- patchsim/sensitivity.py +420 -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 +33 -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/geo.py +527 -0
- patchsim/utils/loader.py +6 -0
- patchsim/utils/logger.py +76 -0
- patchsim/utils/viz.py +65 -0
- patchsim-0.1.0.dist-info/METADATA +917 -0
- patchsim-0.1.0.dist-info/RECORD +29 -0
- patchsim-0.1.0.dist-info/WHEEL +4 -0
- patchsim-0.1.0.dist-info/entry_points.txt +2 -0
- patchsim-0.1.0.dist-info/licenses/LICENSE +674 -0
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
|
+
)
|