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/cli.py ADDED
@@ -0,0 +1,393 @@
1
+ import argparse
2
+ import json
3
+ import logging
4
+ import shutil
5
+ import sys
6
+ import textwrap
7
+ from importlib import resources
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ import yaml
12
+
13
+ from patchsim import __version__
14
+ from patchsim.calibration import get_calibration_plan, run_calibration
15
+ from patchsim.core.simulation import (
16
+ get_available_template_names,
17
+ get_config_schema,
18
+ get_init_template_config,
19
+ get_model_catalog,
20
+ load_config,
21
+ run_simulation,
22
+ setup_simulation,
23
+ )
24
+ from patchsim.sensitivity import get_sensitivity_plan, run_sensitivity
25
+ from patchsim.utils.geo import generate_contacts
26
+
27
+
28
+ def _configure_logging(*, json_output: bool = False) -> None:
29
+ """Configure CLI logging with rich handler when available."""
30
+ if json_output:
31
+ logging.basicConfig(level=logging.WARNING)
32
+ return
33
+
34
+ try:
35
+ from rich.logging import RichHandler
36
+
37
+ logging.basicConfig(
38
+ level=logging.INFO,
39
+ format="%(message)s",
40
+ datefmt="[%X]",
41
+ handlers=[RichHandler(rich_tracebacks=True)],
42
+ )
43
+ except Exception:
44
+ logging.basicConfig(level=logging.INFO, format="%(levelname)s:%(name)s:%(message)s")
45
+
46
+
47
+ def _emit_json(payload: dict[str, Any]) -> None:
48
+ print(json.dumps(payload, indent=2, sort_keys=True))
49
+
50
+
51
+ def _cmd_run(config_path: str, *, json_output: bool = False) -> dict[str, Any]:
52
+ if not json_output:
53
+ print("Starting PatchSim simulation...")
54
+ config = load_config(config_path)
55
+ net, y0, patches, num_patches = setup_simulation(config)
56
+ summary = run_simulation(config, config["ModelName"], net, y0, patches, num_patches)
57
+ if not json_output:
58
+ print("Simulation completed successfully.")
59
+ return {"ok": True, "config": config_path, **summary} if json_output else summary
60
+
61
+
62
+ def _cmd_validate(config_path: str, *, json_output: bool = False, schema: bool = False) -> dict[str, Any] | None:
63
+ if schema:
64
+ return get_config_schema()
65
+
66
+ config = load_config(config_path)
67
+ net, y0, patches, num_patches = setup_simulation(config)
68
+ sensitivity = get_sensitivity_plan(config, list(net.all_compartments), required=False)
69
+ calibration = get_calibration_plan(config, net, y0, required=False)
70
+ if not json_output:
71
+ print(f"Configuration is valid: {config_path}")
72
+ if sensitivity:
73
+ print(f"Planned sensitivity evaluations: {sensitivity.evaluation_count}")
74
+ if calibration:
75
+ print(
76
+ f"Calibration observations: {calibration.n}; fitted variables: {calibration.p}; "
77
+ f"starts: {calibration.start_count}"
78
+ )
79
+ print(f"Maximum forward simulations: {calibration.max_forward_simulations}")
80
+ for warning in calibration.warnings:
81
+ print(f"Warning: {warning}", file=sys.stderr)
82
+ if net.groups:
83
+ diagnostics = net.interaction_diagnostics
84
+ print(
85
+ "Interaction diagnostics: "
86
+ f"units={diagnostics['units']}, "
87
+ f"max reciprocity residual={diagnostics['max_local_reciprocity_residual']:.6g}"
88
+ )
89
+ if json_output:
90
+ result = {
91
+ "ok": True,
92
+ "config": config_path,
93
+ "model_name": config.get("ModelName"),
94
+ "num_patches": num_patches,
95
+ "patches": patches,
96
+ "solver": config["Solver"],
97
+ "time_step": config["TimeStep"],
98
+ }
99
+ if net.groups:
100
+ result.update(
101
+ {
102
+ "num_groups": net.num_groups,
103
+ "groups": net.groups,
104
+ "interaction": net.interaction_diagnostics,
105
+ }
106
+ )
107
+ if sensitivity:
108
+ result["sensitivity"] = {
109
+ "name": sensitivity.name,
110
+ "method": "sobol",
111
+ "num_parameters": len(sensitivity.parameters),
112
+ "evaluation_count": sensitivity.evaluation_count,
113
+ }
114
+ if calibration:
115
+ result["calibration"] = {
116
+ "name": calibration.name,
117
+ "method": "least_squares",
118
+ "n": calibration.n,
119
+ "p": calibration.p,
120
+ "start_count": calibration.start_count,
121
+ "max_forward_simulations": calibration.max_forward_simulations,
122
+ "warnings": list(calibration.warnings),
123
+ }
124
+ return result
125
+ return None
126
+
127
+
128
+ def _cmd_sensitivity(config_path: str, *, json_output: bool = False) -> dict[str, Any]:
129
+ summary = run_sensitivity(config_path, progress=lambda message: print(message, file=sys.stderr))
130
+ result = {"ok": True, "config": config_path, **summary}
131
+ if not json_output:
132
+ action = "Reused" if summary["reused"] else "Completed"
133
+ print(f"{action} sensitivity study: {summary['output_dir']}")
134
+ print(f"Samples: {summary['samples_path']}")
135
+ print(f"Responses: {summary['responses_path']}")
136
+ print(f"Indices: {summary['indices_path']}")
137
+ print(f"Manifest: {summary['manifest_path']}")
138
+ return result
139
+
140
+
141
+ def _cmd_calibrate(config_path: str, *, json_output: bool = False) -> dict[str, Any]:
142
+ summary = run_calibration(config_path, progress=lambda message: print(message, file=sys.stderr))
143
+ result = {"ok": True, "config": config_path, **summary}
144
+ if not json_output:
145
+ action = "Reused" if summary["reused"] else "Completed"
146
+ print(f"{action} calibration study: {summary['output_dir']}")
147
+ print(f"Estimates: {summary['estimates_path']}")
148
+ print(f"Fitted seeds: {summary['fitted_seeds_path']}")
149
+ print(f"Attempts: {summary['attempts_path']}")
150
+ print(f"Residuals: {summary['residuals_path']}")
151
+ print(f"Manifest: {summary['manifest_path']}")
152
+ return result
153
+
154
+
155
+ def _copy_template_tree(template_node, target_path: Path) -> None:
156
+ if template_node.is_dir():
157
+ target_path.mkdir(parents=True, exist_ok=True)
158
+ for child in template_node.iterdir():
159
+ _copy_template_tree(child, target_path / child.name)
160
+ return
161
+
162
+ target_path.parent.mkdir(parents=True, exist_ok=True)
163
+ target_path.write_bytes(template_node.read_bytes())
164
+
165
+
166
+ def _write_seed_for_template(project_dir: Path, config: dict[str, Any]) -> None:
167
+ """Write a seed CSV whose columns match the template's compartments.
168
+
169
+ The scaffold ships a single patch-population file; seed every patch fully
170
+ susceptible and place one infectious individual in the first patch.
171
+ """
172
+ import pandas as pd
173
+
174
+ compartments = list(config.get("compartments") or ["S", "I", "R"])
175
+ patch_df = pd.read_csv(project_dir / config["PatchFile"])
176
+ patch_col = next(c for c in patch_df.columns if c.lower() == "patch")
177
+ pop_col = next(c for c in patch_df.columns if c.lower() == "population")
178
+
179
+ susceptible = "S" if "S" in compartments else compartments[0]
180
+ infectious = "I" if "I" in compartments else compartments[-1]
181
+
182
+ rows = []
183
+ for idx, record in patch_df.iterrows():
184
+ seeded = 1 if idx == 0 else 0
185
+ row = {"patch": record[patch_col], **{c: 0 for c in compartments}}
186
+ row[infectious] = seeded
187
+ row[susceptible] = int(record[pop_col]) - seeded
188
+ rows.append(row)
189
+
190
+ seed_path = project_dir / config["SeedFile"]
191
+ seed_path.parent.mkdir(parents=True, exist_ok=True)
192
+ pd.DataFrame(rows)[["patch", *compartments]].to_csv(seed_path, index=False)
193
+
194
+
195
+ def _cmd_init(name: str, force: bool = False, template: str = "sir") -> None:
196
+ project_dir = Path(name)
197
+
198
+ # Safety checks: prevent deleting cwd, parent dirs, root, or non-directories
199
+ resolved = project_dir.resolve()
200
+ cwd = Path.cwd().resolve()
201
+
202
+ if project_dir.exists() and not project_dir.is_dir():
203
+ raise NotADirectoryError(f"Target exists and is not a directory: {project_dir}")
204
+
205
+ # Block deletion of root, cwd, or any ancestor of cwd
206
+ if force and (
207
+ resolved == Path(resolved.anchor) # Filesystem root (/ or C:\)
208
+ or resolved == cwd # Current working directory
209
+ or cwd in resolved.parents # resolved is ancestor of cwd (e.g., ..)
210
+ ):
211
+ raise ValueError(f"Refusing to overwrite unsafe target: {resolved}")
212
+
213
+ if project_dir.exists() and any(project_dir.iterdir()) and not force:
214
+ raise FileExistsError(f"Refusing to overwrite existing directory: {project_dir}. Use --force to overwrite.")
215
+
216
+ if project_dir.exists() and force:
217
+ shutil.rmtree(project_dir)
218
+
219
+ template_root = resources.files("patchsim").joinpath("templates", "project")
220
+ _copy_template_tree(template_root, project_dir)
221
+
222
+ template_config = get_init_template_config(template, project_dir.name)
223
+ config_path = project_dir / "config.yaml"
224
+ config_path.write_text(yaml.safe_dump(template_config, sort_keys=False), encoding="utf-8")
225
+ _write_seed_for_template(project_dir, template_config)
226
+
227
+ print(f"Created project scaffold at: {project_dir}")
228
+
229
+
230
+ def _list_builtin_models() -> list[dict[str, str]]:
231
+ return get_model_catalog()
232
+
233
+
234
+ def _cmd_list_models(*, json_output: bool = False) -> list[dict[str, str]]:
235
+ models = _list_builtin_models()
236
+ if not models:
237
+ if not json_output:
238
+ print("No built-in models found.")
239
+ return []
240
+
241
+ if json_output:
242
+ return models
243
+
244
+ print("Built-in models and templates:")
245
+ for model in models:
246
+ print(f"- {model['name']} ({model['kind']})")
247
+ return models
248
+
249
+
250
+ def _cmd_generate_contacts(args: argparse.Namespace) -> None:
251
+ output_path, report_path, _report = generate_contacts(
252
+ args.source,
253
+ args.output,
254
+ id_column=args.id_column,
255
+ population_column=args.population_column,
256
+ kernel=args.kernel,
257
+ decay=args.decay,
258
+ scale=args.scale,
259
+ min_distance_km=args.min_distance_km,
260
+ normalize=args.normalize,
261
+ self_weight=args.self_weight,
262
+ self_share=args.self_share,
263
+ centroid_crs=args.centroid_crs,
264
+ force=args.force,
265
+ )
266
+ print(f"Wrote contacts to: {output_path}")
267
+ print(f"Wrote validation report to: {report_path}")
268
+ if args.normalize == "none":
269
+ unit = "scale * population_i * population_j / km**decay" if args.kernel == "gravity" else "scale / km**decay"
270
+ print(f"Warning: unnormalized weights use raw kernel units ({unit}); they are not probabilities.")
271
+
272
+
273
+ def main() -> None:
274
+ """Command-line interface for running the PatchSim simulation."""
275
+ parser = argparse.ArgumentParser(
276
+ description=(
277
+ "PatchSim: A modular metapopulation simulation framework for multi-disease epidemiological modelling."
278
+ ),
279
+ formatter_class=argparse.RawDescriptionHelpFormatter,
280
+ epilog=textwrap.dedent(
281
+ """
282
+ Examples:
283
+ uv run patchsim init my-project
284
+ uv run patchsim init my-project --template seir
285
+ uv run patchsim run -c my-project/config.yaml
286
+ uv run patchsim validate -c my-project/config.yaml
287
+ uv run patchsim sensitivity -c my-project/config.yaml
288
+ uv run patchsim calibrate -c my-project/config.yaml
289
+ uv run patchsim list-models
290
+ uv run patchsim generate-contacts centroids.csv contacts.csv --id-column id \
291
+ --kernel distance --decay 2 --min-distance-km 0.001 --normalize row --self-share 0.9
292
+ """
293
+ ),
294
+ )
295
+ parser.add_argument("--version", action="version", version=f"patchsim {__version__}")
296
+
297
+ subparsers = parser.add_subparsers(dest="command", required=True)
298
+
299
+ init_p = subparsers.add_parser("init", help="Scaffold a new PatchSim project")
300
+ init_p.add_argument("name", help="Directory name for the new project")
301
+ init_p.add_argument("--force", action="store_true", help="Overwrite target directory if it already exists")
302
+ init_p.add_argument(
303
+ "--template",
304
+ choices=get_available_template_names(),
305
+ default="sir",
306
+ help="Starter template to use for config.yaml",
307
+ )
308
+
309
+ run_p = subparsers.add_parser("run", help="Run a simulation")
310
+ run_p.add_argument("-c", "--config", required=True, help="Path to simulation config YAML")
311
+ run_p.add_argument("--json", action="store_true", help="Emit machine-readable JSON summary")
312
+
313
+ sensitivity_p = subparsers.add_parser("sensitivity", help="Run or reuse a Sobol sensitivity study")
314
+ sensitivity_p.add_argument("-c", "--config", required=True, help="Path to simulation config YAML")
315
+ sensitivity_p.add_argument("--json", action="store_true", help="Emit machine-readable JSON summary")
316
+
317
+ calibration_p = subparsers.add_parser("calibrate", help="Run or reuse a bounded calibration study")
318
+ calibration_p.add_argument("-c", "--config", required=True, help="Path to simulation config YAML")
319
+ calibration_p.add_argument("--json", action="store_true", help="Emit machine-readable JSON summary")
320
+
321
+ validate_p = subparsers.add_parser("validate", help="Validate config and inputs")
322
+ validate_p.add_argument("-c", "--config", help="Path to simulation config YAML")
323
+ validate_p.add_argument("--schema", action="store_true", help="Print the configuration JSON Schema")
324
+ validate_p.add_argument("--json", action="store_true", help="Emit machine-readable JSON summary")
325
+
326
+ list_p = subparsers.add_parser("list-models", help="List available built-in models")
327
+ list_p.add_argument("--json", action="store_true", help="Emit machine-readable JSON list")
328
+
329
+ contacts_p = subparsers.add_parser(
330
+ "generate-contacts",
331
+ help="Generate a validated spatial contact network",
332
+ )
333
+ contacts_p.add_argument("source", help="Centroid CSV, GeoJSON, JSON, or Shapefile input")
334
+ contacts_p.add_argument("output", help="Output edge-list CSV")
335
+ contacts_p.add_argument("--id-column", required=True, help="Identifier column in the source")
336
+ contacts_p.add_argument("--population-column", help="Positive population column required by gravity")
337
+ contacts_p.add_argument("--kernel", required=True, choices=["distance", "gravity"])
338
+ contacts_p.add_argument("--decay", required=True, type=float, help="Dimensionless distance-decay exponent")
339
+ contacts_p.add_argument(
340
+ "--min-distance-km",
341
+ required=True,
342
+ type=float,
343
+ help="Positive distance floor in kilometres",
344
+ )
345
+ contacts_p.add_argument("--normalize", required=True, choices=["none", "row"])
346
+ contacts_p.add_argument("--scale", type=float, help="Positive raw-kernel scale; only for normalization none")
347
+ diagonal = contacts_p.add_mutually_exclusive_group()
348
+ diagonal.add_argument("--self-weight", type=float, help="Raw diagonal weight for normalization none")
349
+ diagonal.add_argument("--self-share", type=float, help="Diagonal share in [0, 1) for row normalization")
350
+ contacts_p.add_argument("--centroid-crs", help="Projected CRS used to centroid polygon vector input")
351
+ contacts_p.add_argument("--force", action="store_true", help="Replace both output artifacts")
352
+
353
+ args = parser.parse_args()
354
+ json_mode = bool(getattr(args, "json", False) or getattr(args, "schema", False))
355
+ _configure_logging(json_output=json_mode)
356
+
357
+ try:
358
+ if args.command == "init":
359
+ _cmd_init(args.name, force=args.force, template=args.template)
360
+ elif args.command == "run":
361
+ result = _cmd_run(args.config, json_output=args.json)
362
+ if args.json:
363
+ _emit_json(result)
364
+ elif args.command == "sensitivity":
365
+ result = _cmd_sensitivity(args.config, json_output=args.json)
366
+ if args.json:
367
+ _emit_json(result)
368
+ elif args.command == "calibrate":
369
+ result = _cmd_calibrate(args.config, json_output=args.json)
370
+ if args.json:
371
+ _emit_json(result)
372
+ elif args.command == "validate":
373
+ if not args.schema and not args.config:
374
+ parser.error("the following arguments are required: -c/--config")
375
+ result = _cmd_validate(args.config, json_output=args.json, schema=args.schema)
376
+ if result is not None:
377
+ _emit_json(result)
378
+ elif args.command == "list-models":
379
+ result = _cmd_list_models(json_output=args.json)
380
+ if args.json:
381
+ _emit_json({"models": result})
382
+ elif args.command == "generate-contacts":
383
+ _cmd_generate_contacts(args)
384
+ else:
385
+ parser.print_help()
386
+ raise SystemExit(2)
387
+ except Exception as e:
388
+ logging.error(f"Command failed: {e}")
389
+ raise
390
+
391
+
392
+ if __name__ == "__main__":
393
+ main()
File without changes
@@ -0,0 +1,144 @@
1
+ """Evaluate transition rate expressions from user config files.
2
+
3
+ Evaluation is restricted to arithmetic over named values so that a config file cannot
4
+ execute arbitrary code. Any other construct is rejected.
5
+ """
6
+
7
+ import ast
8
+ import math
9
+ import numbers
10
+ import operator
11
+ from functools import lru_cache
12
+ from typing import Any, Mapping
13
+
14
+ _MAX_DEPTH = 64 # maximum expression nesting depth
15
+ _MAX_LENGTH = 1000 # maximum expression length in characters
16
+
17
+ _BINARY_OPS = {
18
+ ast.Add: operator.add,
19
+ ast.Sub: operator.sub,
20
+ ast.Mult: operator.mul,
21
+ ast.Div: operator.truediv,
22
+ ast.Pow: operator.pow,
23
+ }
24
+
25
+ _UNARY_OPS = {
26
+ ast.UAdd: operator.pos,
27
+ ast.USub: operator.neg,
28
+ }
29
+
30
+
31
+ @lru_cache(maxsize=256)
32
+ def _parse(expression: str) -> ast.AST:
33
+ if len(expression) > _MAX_LENGTH:
34
+ raise ValueError(f"is too long to evaluate ({len(expression)} characters, limit {_MAX_LENGTH})")
35
+
36
+ try:
37
+ tree = ast.parse(expression, mode="eval")
38
+ except SyntaxError as exc:
39
+ raise ValueError(f"could not be parsed: {exc.msg}") from exc
40
+ except (RecursionError, MemoryError) as exc:
41
+ raise ValueError("is too deeply nested to parse") from exc
42
+
43
+ return tree.body
44
+
45
+
46
+ def evaluate(expression: str, scope: Mapping[str, Any]) -> float:
47
+ """Evaluate an arithmetic rate expression against a scope of named values.
48
+
49
+ Args:
50
+ expression: Arithmetic expression over parameter and compartment names.
51
+ scope: Mapping of names available to the expression.
52
+
53
+ Returns:
54
+ The numeric value of the expression.
55
+
56
+ Raises:
57
+ ValueError: If the expression uses an unsupported construct, references an
58
+ unknown name, or cannot be evaluated numerically.
59
+ """
60
+ return _evaluate_node(_parse(expression), scope, depth=0)
61
+
62
+
63
+ def _evaluate_node(node: ast.AST, scope: Mapping[str, Any], depth: int) -> float:
64
+ if depth > _MAX_DEPTH:
65
+ raise ValueError(f"is too deeply nested to evaluate (limit {_MAX_DEPTH} levels)")
66
+
67
+ if isinstance(node, ast.Constant):
68
+ if isinstance(node.value, bool) or not isinstance(node.value, numbers.Real):
69
+ raise ValueError(f"{type(node.value).__name__} literals are not allowed in rate expressions")
70
+ return _coerce(node.value, "a numeric literal")
71
+
72
+ if isinstance(node, ast.Name):
73
+ if node.id not in scope:
74
+ raise ValueError(f"unknown name '{node.id}'; expected a parameter or compartment")
75
+ return _coerce(scope[node.id], f"name '{node.id}'")
76
+
77
+ if isinstance(node, ast.BinOp) and type(node.op) in _BINARY_OPS:
78
+ left = _evaluate_node(node.left, scope, depth + 1)
79
+ right = _evaluate_node(node.right, scope, depth + 1)
80
+ return _apply(_BINARY_OPS[type(node.op)], left, right)
81
+
82
+ if isinstance(node, ast.UnaryOp) and type(node.op) in _UNARY_OPS:
83
+ return _apply(_UNARY_OPS[type(node.op)], _evaluate_node(node.operand, scope, depth + 1))
84
+
85
+ raise ValueError(
86
+ f"{_describe(node)} is not allowed in rate expressions, "
87
+ "which may only combine parameters and compartments arithmetically"
88
+ )
89
+
90
+
91
+ def _coerce(value: Any, description: str) -> float:
92
+ """Convert a value to a finite real float, or raise ValueError.
93
+
94
+ Accepts ``numbers.Real`` rather than ``float`` because solver state can be a numpy
95
+ scalar and only ``numpy.float64`` subclasses ``float``. Complex results (from a
96
+ negative base to a fractional power) and non-finite results (from overflow) are
97
+ rejected.
98
+ """
99
+ if isinstance(value, complex):
100
+ raise ValueError(f"{description} is a complex number; rates must be real")
101
+ if isinstance(value, bool) or not isinstance(value, numbers.Real):
102
+ raise ValueError(f"{description} is not a real number")
103
+ try:
104
+ result = float(value)
105
+ except (OverflowError, ValueError) as exc:
106
+ raise ValueError(f"{description} is too large to represent as a floating point number") from exc
107
+ if not math.isfinite(result):
108
+ raise ValueError(f"{description} is {result}; rates must be finite")
109
+ return result
110
+
111
+
112
+ def _apply(op, *operands: float) -> float:
113
+ """Apply an arithmetic operator and validate its result."""
114
+ try:
115
+ value = op(*operands)
116
+ except ZeroDivisionError as exc:
117
+ raise ValueError("attempted division by zero") from exc
118
+ except OverflowError as exc:
119
+ raise ValueError("overflowed to a value that is not finite") from exc
120
+ except ValueError as exc:
121
+ raise ValueError(f"could not be evaluated: {exc}") from exc
122
+
123
+ return _coerce(value, "the expression")
124
+
125
+
126
+ def _describe(node: ast.AST) -> str:
127
+ """Return a readable name for a disallowed construct, for the error message."""
128
+ descriptions = {
129
+ ast.Attribute: "attribute access",
130
+ ast.Call: "function calls",
131
+ ast.Subscript: "indexing",
132
+ ast.ListComp: "comprehensions",
133
+ ast.GeneratorExp: "comprehensions",
134
+ ast.DictComp: "comprehensions",
135
+ ast.SetComp: "comprehensions",
136
+ ast.Lambda: "lambdas",
137
+ ast.List: "list literals",
138
+ ast.Tuple: "tuple literals",
139
+ ast.Dict: "dict literals",
140
+ }
141
+ for node_type, description in descriptions.items():
142
+ if isinstance(node, node_type):
143
+ return description
144
+ return f"{type(node).__name__} expressions"