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/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"
|