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/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
+ ]
@@ -0,0 +1,6 @@
1
+ """Deprecated placeholder module.
2
+
3
+ Legacy loader helpers were removed because they were unused and did not reflect
4
+ the active simulation pipeline. Use `patchsim.core.simulation` and
5
+ `patchsim.utils.logger` / `patchsim.utils.viz` instead.
6
+ """
@@ -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()