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/utils/geo.py
ADDED
|
@@ -0,0 +1,527 @@
|
|
|
1
|
+
"""Spatial contact-matrix generation with explicit units and validation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import tempfile
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Literal
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
import pandas as pd
|
|
14
|
+
|
|
15
|
+
EARTH_RADIUS_KM = 6371.0088
|
|
16
|
+
REPORT_SCHEMA_VERSION = 1
|
|
17
|
+
_SOURCE_SUFFIXES = {".csv", ".geojson", ".json", ".shp"}
|
|
18
|
+
_SHAPEFILE_SUFFIXES = {".shp", ".shx", ".dbf", ".prj", ".cpg", ".qix", ".sbn", ".sbx"}
|
|
19
|
+
|
|
20
|
+
KernelName = Literal["distance", "gravity"]
|
|
21
|
+
Normalization = Literal["none", "row"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _as_finite_float(name: str, value: Any, *, positive: bool = False, non_negative: bool = False) -> float:
|
|
25
|
+
if isinstance(value, bool):
|
|
26
|
+
raise ValueError(f"{name} must be a finite number")
|
|
27
|
+
try:
|
|
28
|
+
number = float(value)
|
|
29
|
+
except (TypeError, ValueError) as exc:
|
|
30
|
+
raise ValueError(f"{name} must be a finite number") from exc
|
|
31
|
+
if not np.isfinite(number):
|
|
32
|
+
raise ValueError(f"{name} must be finite")
|
|
33
|
+
if positive and number <= 0:
|
|
34
|
+
raise ValueError(f"{name} must be greater than zero")
|
|
35
|
+
if non_negative and number < 0:
|
|
36
|
+
raise ValueError(f"{name} must be non-negative")
|
|
37
|
+
return number
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _normalized_identifiers(values: pd.Series, column: str) -> pd.Series:
|
|
41
|
+
null_rows = values.index[values.isna()].tolist()
|
|
42
|
+
if null_rows:
|
|
43
|
+
raise ValueError(f"Identifier column '{column}' contains null values at rows {null_rows}")
|
|
44
|
+
|
|
45
|
+
identifiers = values.astype(str).str.strip()
|
|
46
|
+
empty_rows = identifiers.index[identifiers.eq("")].tolist()
|
|
47
|
+
if empty_rows:
|
|
48
|
+
raise ValueError(f"Identifier column '{column}' contains empty values at rows {empty_rows}")
|
|
49
|
+
|
|
50
|
+
duplicated = sorted(identifiers[identifiers.duplicated(keep=False)].unique())
|
|
51
|
+
if duplicated:
|
|
52
|
+
raise ValueError(f"Identifier column '{column}' contains duplicate values after normalization: {duplicated}")
|
|
53
|
+
return identifiers
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _numeric_series(values: pd.Series, name: str, *, positive: bool = False) -> pd.Series:
|
|
57
|
+
numbers = pd.to_numeric(values, errors="coerce").astype(float)
|
|
58
|
+
bad_rows = numbers.index[~np.isfinite(numbers)].tolist()
|
|
59
|
+
if bad_rows:
|
|
60
|
+
raise ValueError(f"Column '{name}' must contain finite numeric values; invalid rows: {bad_rows}")
|
|
61
|
+
if positive:
|
|
62
|
+
bad_rows = numbers.index[numbers.le(0)].tolist()
|
|
63
|
+
if bad_rows:
|
|
64
|
+
raise ValueError(f"Column '{name}' must contain positive values; invalid rows: {bad_rows}")
|
|
65
|
+
return numbers
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _validated_regions(
|
|
69
|
+
identifiers: pd.Series,
|
|
70
|
+
latitudes: pd.Series,
|
|
71
|
+
longitudes: pd.Series,
|
|
72
|
+
*,
|
|
73
|
+
id_column: str,
|
|
74
|
+
population: pd.Series | None = None,
|
|
75
|
+
population_column: str | None = None,
|
|
76
|
+
) -> pd.DataFrame:
|
|
77
|
+
ids = _normalized_identifiers(identifiers, id_column)
|
|
78
|
+
lat = _numeric_series(latitudes, "lat")
|
|
79
|
+
lon = _numeric_series(longitudes, "lon")
|
|
80
|
+
|
|
81
|
+
bad_lat = lat.index[~lat.between(-90, 90)].tolist()
|
|
82
|
+
bad_lon = lon.index[~lon.between(-180, 180)].tolist()
|
|
83
|
+
if bad_lat:
|
|
84
|
+
raise ValueError(f"Latitude must be within [-90, 90]; invalid rows: {bad_lat}")
|
|
85
|
+
if bad_lon:
|
|
86
|
+
raise ValueError(f"Longitude must be within [-180, 180]; invalid rows: {bad_lon}")
|
|
87
|
+
if len(ids) < 2:
|
|
88
|
+
raise ValueError("Contact generation requires at least two regions")
|
|
89
|
+
|
|
90
|
+
result = pd.DataFrame({"id": ids.to_numpy(), "lat": lat.to_numpy(), "lon": lon.to_numpy()})
|
|
91
|
+
if population is not None:
|
|
92
|
+
if population_column is None:
|
|
93
|
+
raise ValueError("A population column name is required when population values are provided")
|
|
94
|
+
result["population"] = _numeric_series(population, population_column, positive=True).to_numpy()
|
|
95
|
+
return result
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _required_column(frame: pd.DataFrame, column: str) -> pd.Series:
|
|
99
|
+
if column not in frame.columns:
|
|
100
|
+
raise ValueError(f"Required column '{column}' not found")
|
|
101
|
+
return frame[column]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _load_centroid_csv(
|
|
105
|
+
source: Path,
|
|
106
|
+
*,
|
|
107
|
+
id_column: str,
|
|
108
|
+
population_column: str | None,
|
|
109
|
+
) -> tuple[pd.DataFrame, dict[str, Any]]:
|
|
110
|
+
frame = pd.read_csv(source, dtype={id_column: "string"}, keep_default_na=False)
|
|
111
|
+
population = _required_column(frame, population_column) if population_column else None
|
|
112
|
+
regions = _validated_regions(
|
|
113
|
+
_required_column(frame, id_column),
|
|
114
|
+
_required_column(frame, "lat"),
|
|
115
|
+
_required_column(frame, "lon"),
|
|
116
|
+
id_column=id_column,
|
|
117
|
+
population=population,
|
|
118
|
+
population_column=population_column,
|
|
119
|
+
)
|
|
120
|
+
return regions, {"source_crs": None, "centroid_crs": None}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _load_vector(
|
|
124
|
+
source: Path,
|
|
125
|
+
*,
|
|
126
|
+
id_column: str,
|
|
127
|
+
population_column: str | None,
|
|
128
|
+
centroid_crs: str | None,
|
|
129
|
+
) -> tuple[pd.DataFrame, dict[str, Any]]:
|
|
130
|
+
try:
|
|
131
|
+
import geopandas as gpd
|
|
132
|
+
from pyproj import CRS
|
|
133
|
+
except ImportError as exc: # pragma: no cover - exercised in an isolated environment
|
|
134
|
+
raise RuntimeError("Vector input requires the optional geo dependencies: install 'patchsim[geo]'") from exc
|
|
135
|
+
|
|
136
|
+
frame = gpd.read_file(source)
|
|
137
|
+
identifiers = _required_column(frame, id_column)
|
|
138
|
+
normalized_ids = _normalized_identifiers(identifiers, id_column)
|
|
139
|
+
population = _required_column(frame, population_column) if population_column else None
|
|
140
|
+
|
|
141
|
+
if frame.crs is None:
|
|
142
|
+
raise ValueError("Vector input must declare a coordinate reference system (CRS)")
|
|
143
|
+
invalid_geometry = frame.geometry.isna() | frame.geometry.is_empty | ~frame.geometry.is_valid
|
|
144
|
+
if invalid_geometry.any():
|
|
145
|
+
bad_ids = normalized_ids[invalid_geometry].tolist()
|
|
146
|
+
raise ValueError(f"Vector input contains null, empty, or invalid geometries for identifiers: {bad_ids}")
|
|
147
|
+
|
|
148
|
+
geometry_types = set(frame.geometry.geom_type)
|
|
149
|
+
if geometry_types == {"Point"}:
|
|
150
|
+
if centroid_crs is not None:
|
|
151
|
+
raise ValueError("--centroid-crs is only valid for Polygon or MultiPolygon input")
|
|
152
|
+
points = frame.to_crs("EPSG:4326").geometry
|
|
153
|
+
resolved_centroid_crs = None
|
|
154
|
+
elif geometry_types.issubset({"Polygon", "MultiPolygon"}):
|
|
155
|
+
if centroid_crs is None:
|
|
156
|
+
raise ValueError("Polygon input requires --centroid-crs with a suitable projected CRS")
|
|
157
|
+
target_crs = CRS.from_user_input(centroid_crs)
|
|
158
|
+
if not target_crs.is_projected:
|
|
159
|
+
raise ValueError("--centroid-crs must be a projected CRS")
|
|
160
|
+
projected = frame.to_crs(target_crs)
|
|
161
|
+
centroids = gpd.GeoSeries(projected.geometry.centroid, index=frame.index, crs=target_crs)
|
|
162
|
+
points = centroids.to_crs("EPSG:4326")
|
|
163
|
+
resolved_centroid_crs = target_crs.to_string()
|
|
164
|
+
else:
|
|
165
|
+
raise ValueError(
|
|
166
|
+
"Vector input must contain only Point geometries or only Polygon/MultiPolygon geometries; "
|
|
167
|
+
f"received {sorted(geometry_types)}"
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
regions = _validated_regions(
|
|
171
|
+
normalized_ids,
|
|
172
|
+
pd.Series(points.y, index=frame.index),
|
|
173
|
+
pd.Series(points.x, index=frame.index),
|
|
174
|
+
id_column=id_column,
|
|
175
|
+
population=population,
|
|
176
|
+
population_column=population_column,
|
|
177
|
+
)
|
|
178
|
+
return regions, {
|
|
179
|
+
"source_crs": frame.crs.to_string(),
|
|
180
|
+
"centroid_crs": resolved_centroid_crs,
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def load_contact_regions(
|
|
185
|
+
source: str | Path,
|
|
186
|
+
*,
|
|
187
|
+
id_column: str,
|
|
188
|
+
population_column: str | None = None,
|
|
189
|
+
centroid_crs: str | None = None,
|
|
190
|
+
) -> tuple[pd.DataFrame, dict[str, Any]]:
|
|
191
|
+
"""Load validated contact regions from a centroid CSV or vector file."""
|
|
192
|
+
source_path = Path(source)
|
|
193
|
+
suffix = source_path.suffix.lower()
|
|
194
|
+
if suffix not in _SOURCE_SUFFIXES:
|
|
195
|
+
raise ValueError(f"Unsupported source format '{suffix}'; use CSV, GeoJSON, JSON, or Shapefile")
|
|
196
|
+
if suffix == ".csv":
|
|
197
|
+
if centroid_crs is not None:
|
|
198
|
+
raise ValueError("--centroid-crs is only valid for polygon vector input")
|
|
199
|
+
return _load_centroid_csv(
|
|
200
|
+
source_path,
|
|
201
|
+
id_column=id_column,
|
|
202
|
+
population_column=population_column,
|
|
203
|
+
)
|
|
204
|
+
return _load_vector(
|
|
205
|
+
source_path,
|
|
206
|
+
id_column=id_column,
|
|
207
|
+
population_column=population_column,
|
|
208
|
+
centroid_crs=centroid_crs,
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def distance_matrix_km(regions: pd.DataFrame) -> np.ndarray:
|
|
213
|
+
"""Return pairwise haversine distances in kilometres."""
|
|
214
|
+
lat = np.radians(regions["lat"].to_numpy(dtype=float))
|
|
215
|
+
lon = np.radians(regions["lon"].to_numpy(dtype=float))
|
|
216
|
+
dlat = lat[:, None] - lat[None, :]
|
|
217
|
+
dlon = lon[:, None] - lon[None, :]
|
|
218
|
+
haversine = np.sin(dlat / 2) ** 2 + np.cos(lat[:, None]) * np.cos(lat[None, :]) * np.sin(dlon / 2) ** 2
|
|
219
|
+
angular_distance = 2 * np.arcsin(np.sqrt(np.clip(haversine, 0.0, 1.0)))
|
|
220
|
+
distances = EARTH_RADIUS_KM * angular_distance
|
|
221
|
+
np.fill_diagonal(distances, 0.0)
|
|
222
|
+
return distances
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _summary(values: np.ndarray) -> dict[str, float]:
|
|
226
|
+
return {
|
|
227
|
+
"min": float(np.min(values)),
|
|
228
|
+
"max": float(np.max(values)),
|
|
229
|
+
"median": float(np.median(values)),
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def generate_contact_matrix(
|
|
234
|
+
regions: pd.DataFrame,
|
|
235
|
+
*,
|
|
236
|
+
kernel: KernelName,
|
|
237
|
+
decay: float,
|
|
238
|
+
min_distance_km: float,
|
|
239
|
+
normalize: Normalization,
|
|
240
|
+
scale: float | None = None,
|
|
241
|
+
self_weight: float | None = None,
|
|
242
|
+
self_share: float | None = None,
|
|
243
|
+
) -> tuple[np.ndarray, dict[str, Any]]:
|
|
244
|
+
"""Generate and validate a spatial contact matrix."""
|
|
245
|
+
if kernel not in {"distance", "gravity"}:
|
|
246
|
+
raise ValueError(f"Unknown kernel '{kernel}'")
|
|
247
|
+
if normalize not in {"none", "row"}:
|
|
248
|
+
raise ValueError(f"Unknown normalization mode '{normalize}'")
|
|
249
|
+
|
|
250
|
+
population = _required_column(regions, "population") if kernel == "gravity" else None
|
|
251
|
+
regions = _validated_regions(
|
|
252
|
+
_required_column(regions, "id"),
|
|
253
|
+
_required_column(regions, "lat"),
|
|
254
|
+
_required_column(regions, "lon"),
|
|
255
|
+
id_column="id",
|
|
256
|
+
population=population,
|
|
257
|
+
population_column="population" if population is not None else None,
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
decay_value = _as_finite_float("decay", decay, positive=True)
|
|
261
|
+
distance_floor = _as_finite_float("min-distance-km", min_distance_km, positive=True)
|
|
262
|
+
|
|
263
|
+
if normalize == "none":
|
|
264
|
+
if scale is None or self_weight is None:
|
|
265
|
+
raise ValueError("--normalize none requires --scale and --self-weight")
|
|
266
|
+
if self_share is not None:
|
|
267
|
+
raise ValueError("--self-share is only valid with --normalize row")
|
|
268
|
+
scale_value = _as_finite_float("scale", scale, positive=True)
|
|
269
|
+
diagonal_value = _as_finite_float("self-weight", self_weight, non_negative=True)
|
|
270
|
+
else:
|
|
271
|
+
if self_share is None:
|
|
272
|
+
raise ValueError("--normalize row requires --self-share")
|
|
273
|
+
if scale is not None or self_weight is not None:
|
|
274
|
+
raise ValueError("--scale and --self-weight are only valid with --normalize none")
|
|
275
|
+
share = _as_finite_float("self-share", self_share, non_negative=True)
|
|
276
|
+
if share >= 1:
|
|
277
|
+
raise ValueError("self-share must be less than one so the selected kernel contributes")
|
|
278
|
+
scale_value = 1.0
|
|
279
|
+
diagonal_value = share
|
|
280
|
+
|
|
281
|
+
if kernel == "gravity":
|
|
282
|
+
populations = regions["population"].to_numpy(dtype=float)
|
|
283
|
+
if not np.all(np.isfinite(populations)) or np.any(populations <= 0):
|
|
284
|
+
raise ValueError("Gravity kernel populations must be finite and positive")
|
|
285
|
+
else:
|
|
286
|
+
populations = None
|
|
287
|
+
|
|
288
|
+
distances = distance_matrix_km(regions)
|
|
289
|
+
diagonal = np.eye(len(regions), dtype=bool)
|
|
290
|
+
safe_distances = np.maximum(distances, distance_floor)
|
|
291
|
+
safe_distances[diagonal] = 1.0
|
|
292
|
+
|
|
293
|
+
try:
|
|
294
|
+
with np.errstate(over="raise", invalid="raise", divide="raise", under="ignore"):
|
|
295
|
+
denominator = np.power(safe_distances, decay_value)
|
|
296
|
+
if populations is None:
|
|
297
|
+
raw = scale_value / denominator
|
|
298
|
+
else:
|
|
299
|
+
raw = scale_value * np.outer(populations, populations) / denominator
|
|
300
|
+
except FloatingPointError as exc:
|
|
301
|
+
raise ValueError("Kernel arithmetic overflowed; revise decay, scale, population, or distance floor") from exc
|
|
302
|
+
|
|
303
|
+
np.fill_diagonal(raw, 0.0)
|
|
304
|
+
raw_off_diagonal = raw[~diagonal]
|
|
305
|
+
if np.any(~np.isfinite(raw_off_diagonal)):
|
|
306
|
+
raise ValueError("Kernel produced non-finite off-diagonal weights")
|
|
307
|
+
if np.any(raw_off_diagonal <= 0):
|
|
308
|
+
bad_pair = np.argwhere((raw <= 0) & ~diagonal)[0]
|
|
309
|
+
source_id = regions.iloc[int(bad_pair[0])]["id"]
|
|
310
|
+
target_id = regions.iloc[int(bad_pair[1])]["id"]
|
|
311
|
+
raise ValueError(
|
|
312
|
+
f"Kernel weight underflowed for '{source_id}' -> '{target_id}'; revise decay or distance floor"
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
if normalize == "row":
|
|
316
|
+
row_sums = raw.sum(axis=1)
|
|
317
|
+
if np.any(~np.isfinite(row_sums)) or np.any(row_sums <= 0):
|
|
318
|
+
raise ValueError("Every raw kernel row must have a positive finite off-diagonal sum")
|
|
319
|
+
matrix = raw / row_sums[:, None] * (1.0 - diagonal_value)
|
|
320
|
+
else:
|
|
321
|
+
matrix = raw.copy()
|
|
322
|
+
np.fill_diagonal(matrix, diagonal_value)
|
|
323
|
+
|
|
324
|
+
if matrix.shape != (len(regions), len(regions)):
|
|
325
|
+
raise ValueError("Kernel output shape does not match the region count")
|
|
326
|
+
if np.any(~np.isfinite(matrix)) or np.any(matrix < 0):
|
|
327
|
+
raise ValueError("Final contact matrix must contain only finite, non-negative weights")
|
|
328
|
+
|
|
329
|
+
pair_distances = distances[np.triu_indices(len(regions), k=1)]
|
|
330
|
+
pair_weights = raw[np.triu_indices(len(regions), k=1)]
|
|
331
|
+
dynamic_range = float(np.max(pair_weights) / np.min(pair_weights))
|
|
332
|
+
if not np.isfinite(dynamic_range):
|
|
333
|
+
raise ValueError("Kernel weight dynamic range exceeds floating-point capacity; revise parameters")
|
|
334
|
+
diagnostics = {
|
|
335
|
+
"distance": _summary(pair_distances),
|
|
336
|
+
"distance_floor_pair_count": int(np.count_nonzero(pair_distances < distance_floor)),
|
|
337
|
+
"off_diagonal_weight": {
|
|
338
|
+
**_summary(pair_weights),
|
|
339
|
+
"dynamic_range": dynamic_range,
|
|
340
|
+
},
|
|
341
|
+
"row_sum": {
|
|
342
|
+
"min": float(np.min(matrix.sum(axis=1))),
|
|
343
|
+
"max": float(np.max(matrix.sum(axis=1))),
|
|
344
|
+
},
|
|
345
|
+
"diagonal": {
|
|
346
|
+
"min": float(np.min(np.diag(matrix))),
|
|
347
|
+
"max": float(np.max(np.diag(matrix))),
|
|
348
|
+
},
|
|
349
|
+
"raw_symmetric": bool(np.allclose(raw, raw.T, rtol=1e-12, atol=0.0)),
|
|
350
|
+
"final_symmetric": bool(np.allclose(matrix, matrix.T, rtol=1e-12, atol=0.0)),
|
|
351
|
+
}
|
|
352
|
+
return matrix, diagnostics
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _path_aliases(first: Path, second: Path) -> bool:
|
|
356
|
+
if first == second:
|
|
357
|
+
return True
|
|
358
|
+
if first.exists() and second.exists():
|
|
359
|
+
return os.path.samefile(first, second)
|
|
360
|
+
return False
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _source_components(source: Path) -> list[Path]:
|
|
364
|
+
if source.suffix.lower() != ".shp":
|
|
365
|
+
return [source]
|
|
366
|
+
return sorted(
|
|
367
|
+
path.resolve()
|
|
368
|
+
for path in source.parent.iterdir()
|
|
369
|
+
if path.stem == source.stem and path.suffix.lower() in _SHAPEFILE_SUFFIXES
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _write_temp(parent: Path, prefix: str, content: bytes) -> Path:
|
|
374
|
+
descriptor, name = tempfile.mkstemp(prefix=prefix, dir=parent)
|
|
375
|
+
temp_path = Path(name)
|
|
376
|
+
try:
|
|
377
|
+
with os.fdopen(descriptor, "wb") as handle:
|
|
378
|
+
handle.write(content)
|
|
379
|
+
handle.flush()
|
|
380
|
+
os.fsync(handle.fileno())
|
|
381
|
+
except Exception:
|
|
382
|
+
temp_path.unlink(missing_ok=True)
|
|
383
|
+
raise
|
|
384
|
+
return temp_path
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _write_output_pair(
|
|
388
|
+
output: Path,
|
|
389
|
+
report_path: Path,
|
|
390
|
+
csv_bytes: bytes,
|
|
391
|
+
report_bytes: bytes,
|
|
392
|
+
) -> None:
|
|
393
|
+
csv_temp = _write_temp(output.parent, f".{output.name}.", csv_bytes)
|
|
394
|
+
report_temp: Path | None = None
|
|
395
|
+
try:
|
|
396
|
+
report_temp = _write_temp(report_path.parent, f".{report_path.name}.", report_bytes)
|
|
397
|
+
os.replace(csv_temp, output)
|
|
398
|
+
os.replace(report_temp, report_path)
|
|
399
|
+
finally:
|
|
400
|
+
csv_temp.unlink(missing_ok=True)
|
|
401
|
+
if report_temp is not None:
|
|
402
|
+
report_temp.unlink(missing_ok=True)
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def generate_contacts(
|
|
406
|
+
source: str | Path,
|
|
407
|
+
output: str | Path,
|
|
408
|
+
*,
|
|
409
|
+
id_column: str,
|
|
410
|
+
kernel: KernelName,
|
|
411
|
+
decay: float,
|
|
412
|
+
min_distance_km: float,
|
|
413
|
+
normalize: Normalization,
|
|
414
|
+
population_column: str | None = None,
|
|
415
|
+
scale: float | None = None,
|
|
416
|
+
self_weight: float | None = None,
|
|
417
|
+
self_share: float | None = None,
|
|
418
|
+
centroid_crs: str | None = None,
|
|
419
|
+
force: bool = False,
|
|
420
|
+
) -> tuple[Path, Path, dict[str, Any]]:
|
|
421
|
+
"""Generate a runtime-compatible network CSV and its validation report."""
|
|
422
|
+
source_path = Path(source).resolve(strict=True)
|
|
423
|
+
output_path = Path(output).resolve()
|
|
424
|
+
if output_path.suffix.lower() != ".csv":
|
|
425
|
+
raise ValueError("Output path must end in .csv")
|
|
426
|
+
report_path = Path(f"{output_path}.validation.json")
|
|
427
|
+
|
|
428
|
+
for source_component in _source_components(source_path):
|
|
429
|
+
if _path_aliases(source_component, output_path) or _path_aliases(source_component, report_path):
|
|
430
|
+
raise ValueError(f"Output paths must not overwrite source data: {source_component}")
|
|
431
|
+
if not force and (output_path.exists() or report_path.exists()):
|
|
432
|
+
raise FileExistsError(
|
|
433
|
+
f"Refusing to overwrite existing output pair: {output_path}, {report_path}. Use --force to overwrite."
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
if kernel == "gravity" and population_column is None:
|
|
437
|
+
raise ValueError("Gravity kernel requires --population-column")
|
|
438
|
+
|
|
439
|
+
regions, source_metadata = load_contact_regions(
|
|
440
|
+
source_path,
|
|
441
|
+
id_column=id_column,
|
|
442
|
+
population_column=population_column,
|
|
443
|
+
centroid_crs=centroid_crs,
|
|
444
|
+
)
|
|
445
|
+
matrix, diagnostics = generate_contact_matrix(
|
|
446
|
+
regions,
|
|
447
|
+
kernel=kernel,
|
|
448
|
+
decay=decay,
|
|
449
|
+
min_distance_km=min_distance_km,
|
|
450
|
+
normalize=normalize,
|
|
451
|
+
scale=scale,
|
|
452
|
+
self_weight=self_weight,
|
|
453
|
+
self_share=self_share,
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
identifiers = regions["id"].tolist()
|
|
457
|
+
rows = [
|
|
458
|
+
{
|
|
459
|
+
"day": 0,
|
|
460
|
+
"source": identifiers[source_index],
|
|
461
|
+
"target": identifiers[target_index],
|
|
462
|
+
"weight": float(matrix[source_index, target_index]),
|
|
463
|
+
}
|
|
464
|
+
for source_index in range(len(identifiers))
|
|
465
|
+
for target_index in range(len(identifiers))
|
|
466
|
+
if matrix[source_index, target_index] > 0
|
|
467
|
+
]
|
|
468
|
+
csv_bytes = (
|
|
469
|
+
pd.DataFrame(rows, columns=["day", "source", "target", "weight"])
|
|
470
|
+
.to_csv(index=False, lineterminator="\n")
|
|
471
|
+
.encode("utf-8")
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
report: dict[str, Any] = {
|
|
475
|
+
"schema_version": REPORT_SCHEMA_VERSION,
|
|
476
|
+
"source": str(source_path),
|
|
477
|
+
"id_column": id_column,
|
|
478
|
+
"population_column": population_column,
|
|
479
|
+
"region_count": len(identifiers),
|
|
480
|
+
"identifiers": identifiers,
|
|
481
|
+
**source_metadata,
|
|
482
|
+
"coordinate_crs": "EPSG:4326",
|
|
483
|
+
"distance": {
|
|
484
|
+
"algorithm": "haversine",
|
|
485
|
+
"earth_radius_km": EARTH_RADIUS_KM,
|
|
486
|
+
"unit": "km",
|
|
487
|
+
**diagnostics["distance"],
|
|
488
|
+
"floor_pair_count": diagnostics["distance_floor_pair_count"],
|
|
489
|
+
},
|
|
490
|
+
"kernel": {
|
|
491
|
+
"name": kernel,
|
|
492
|
+
"decay": float(decay),
|
|
493
|
+
"scale": float(scale) if scale is not None else 1.0,
|
|
494
|
+
"min_distance_km": float(min_distance_km),
|
|
495
|
+
"raw_weight_unit": (
|
|
496
|
+
"scale * population_i * population_j / km**decay" if kernel == "gravity" else "scale / km**decay"
|
|
497
|
+
),
|
|
498
|
+
},
|
|
499
|
+
"normalization": {
|
|
500
|
+
"mode": normalize,
|
|
501
|
+
"self_weight": float(self_weight) if self_weight is not None else None,
|
|
502
|
+
"self_share": float(self_share) if self_share is not None else None,
|
|
503
|
+
"final_weight_unit": "dimensionless" if normalize == "row" else "raw kernel unit",
|
|
504
|
+
},
|
|
505
|
+
"matrix": {
|
|
506
|
+
"off_diagonal_weight": diagnostics["off_diagonal_weight"],
|
|
507
|
+
"row_sum": diagnostics["row_sum"],
|
|
508
|
+
"diagonal": diagnostics["diagonal"],
|
|
509
|
+
"raw_symmetric": diagnostics["raw_symmetric"],
|
|
510
|
+
"final_symmetric": diagnostics["final_symmetric"],
|
|
511
|
+
},
|
|
512
|
+
"csv_sha256": hashlib.sha256(csv_bytes).hexdigest(),
|
|
513
|
+
}
|
|
514
|
+
report_bytes = (json.dumps(report, indent=2, sort_keys=True, allow_nan=False) + "\n").encode("utf-8")
|
|
515
|
+
|
|
516
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
517
|
+
_write_output_pair(output_path, report_path, csv_bytes, report_bytes)
|
|
518
|
+
return output_path, report_path, report
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
__all__ = [
|
|
522
|
+
"EARTH_RADIUS_KM",
|
|
523
|
+
"distance_matrix_km",
|
|
524
|
+
"generate_contact_matrix",
|
|
525
|
+
"generate_contacts",
|
|
526
|
+
"load_contact_regions",
|
|
527
|
+
]
|
patchsim/utils/loader.py
ADDED
patchsim/utils/logger.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
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
|
+
if "GroupFile" in config:
|
|
58
|
+
logger.info(f"GroupFile: {config['GroupFile']}")
|
|
59
|
+
logger.info(f"InteractionFile: {config['InteractionFile']}")
|
|
60
|
+
logger.info(f"InteractionUnits: {config['InteractionUnits']}")
|
|
61
|
+
logger.info(f"OutputDir: {config['OutputDir']}")
|
|
62
|
+
logger.info(f"Solver: {config['Solver']}")
|
|
63
|
+
logger.info(f"TimeStep: {config['TimeStep']}")
|
|
64
|
+
logger.info(f"TMax: {config['TMax']}")
|
|
65
|
+
logger.info(f"Num patches: {num_patches}")
|
|
66
|
+
logger.info(f"Patch list: {patches}")
|
|
67
|
+
logger.info(
|
|
68
|
+
f"Base model: compartments={base_model.compartments}, "
|
|
69
|
+
f"transitions={base_model.transitions}, "
|
|
70
|
+
f"parameters={base_model.parameters}"
|
|
71
|
+
)
|
|
72
|
+
logger.info(
|
|
73
|
+
f"Simulation started: model={model_name}, num_patches={num_patches}, patches={patches}, "
|
|
74
|
+
f"transitions={base_model.transitions}, parameters={base_model.parameters}"
|
|
75
|
+
)
|
|
76
|
+
return logger
|
patchsim/utils/viz.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import math
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
import matplotlib.pyplot as plt
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def plot_patch_subplots(
|
|
8
|
+
t_range,
|
|
9
|
+
out_ode,
|
|
10
|
+
patches,
|
|
11
|
+
output_dir,
|
|
12
|
+
model_name,
|
|
13
|
+
patch_parameters=None,
|
|
14
|
+
compartments=None,
|
|
15
|
+
groups=None,
|
|
16
|
+
solver="ode",
|
|
17
|
+
):
|
|
18
|
+
"""
|
|
19
|
+
Plots all patches as subplots in a single figure and saves the figure.
|
|
20
|
+
"""
|
|
21
|
+
n = len(patches)
|
|
22
|
+
if n == 0:
|
|
23
|
+
raise ValueError("`patches` must contain at least one patch")
|
|
24
|
+
ncols = math.ceil(math.sqrt(n))
|
|
25
|
+
nrows = math.ceil(n / ncols)
|
|
26
|
+
fig, axes = plt.subplots(nrows, ncols, figsize=(5 * ncols, 4 * nrows))
|
|
27
|
+
axes = axes.flatten() if n > 1 else [axes]
|
|
28
|
+
for i, patch in enumerate(patches):
|
|
29
|
+
ax = axes[i]
|
|
30
|
+
if compartments is not None:
|
|
31
|
+
comps = compartments
|
|
32
|
+
elif groups:
|
|
33
|
+
suffix = f"_{i}_0"
|
|
34
|
+
comps = [key[: -len(suffix)] for key in out_ode if key.endswith(suffix)]
|
|
35
|
+
else:
|
|
36
|
+
suffix = f"_{i}"
|
|
37
|
+
comps = [key[: -len(suffix)] for key in out_ode if key.endswith(suffix)]
|
|
38
|
+
if groups:
|
|
39
|
+
for group_idx, group in enumerate(groups):
|
|
40
|
+
for compartment in comps:
|
|
41
|
+
ax.plot(
|
|
42
|
+
t_range,
|
|
43
|
+
out_ode[f"{compartment}_{i}_{group_idx}"],
|
|
44
|
+
label=f"{compartment} ({group})",
|
|
45
|
+
)
|
|
46
|
+
else:
|
|
47
|
+
for compartment in comps:
|
|
48
|
+
ax.plot(t_range, out_ode[f"{compartment}_{i}"], label=compartment)
|
|
49
|
+
solver_label = "ODE" if solver == "ode" else "Discrete"
|
|
50
|
+
title = f"Patch {patch} ({solver_label})"
|
|
51
|
+
if patch_parameters and patch in patch_parameters:
|
|
52
|
+
params = patch_parameters[patch]
|
|
53
|
+
param_str = ", ".join(f"{k}={v}" for k, v in params.items())
|
|
54
|
+
title += f"\n({param_str})"
|
|
55
|
+
ax.set_title(title)
|
|
56
|
+
ax.set_xlabel("Time")
|
|
57
|
+
ax.set_ylabel("Count")
|
|
58
|
+
ax.legend()
|
|
59
|
+
# Hide unused subplots (use n instead of loop index i)
|
|
60
|
+
for j in range(n, len(axes)):
|
|
61
|
+
fig.delaxes(axes[j])
|
|
62
|
+
plt.tight_layout()
|
|
63
|
+
os.makedirs(output_dir, exist_ok=True)
|
|
64
|
+
plt.savefig(os.path.join(output_dir, f"patch_timeseries_{model_name}_{solver}.png"))
|
|
65
|
+
plt.close()
|