aei-workflow-runner 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.
@@ -0,0 +1,4 @@
1
+ """aei_workflow: the product-neutral core shared by Velorona Web (as a documented contract) and the Velorona QGIS plugin
2
+ (as a library), plus the headless `velorona-run` CLI. No UI, no QGIS, no browser code lives here."""
3
+
4
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
aei_workflow/bounds.py ADDED
@@ -0,0 +1,46 @@
1
+ """Terrestrial Path Clearance input bounds and their stated basis -- the single source of truth.
2
+
3
+ Product-neutral: no QGIS, no browser. The QGIS plugin builds its parameter dialog from this list, the runner
4
+ validates every input row against it, and the Web Map's evidence.js TERRESTRIAL_BOUNDS is held equal to it by
5
+ tests/test_bounds_contract.py (against contract/terrestrial_bounds.json) and by the Map repo's own copy of that file.
6
+
7
+ An out-of-range value is refused, never clamped.
8
+ """
9
+
10
+ # Bounds carry their own basis, because an out-of-range value is accepted,
11
+ # computed and exported typed "Calculated" -- a physically meaningless number
12
+ # wearing the same authority as a real one. Each "basis" below says where the
13
+ # limit comes from; where no citable source exists it says so explicitly
14
+ # rather than presenting an engineering judgment as derived.
15
+ TERRESTRIAL_PARAM_SPEC = [
16
+ {"key": "site_a_height_m", "label": "Site A antenna height", "type": "float",
17
+ "default": 30.0, "suffix": " m", "min": 0.1, "max": 1000.0,
18
+ "basis": "Conservative engineering limit, not a derived bound: no library or "
19
+ "standard constrains antenna height. 1000 m clears the CN Tower (553 m) "
20
+ "and the tallest guyed mast (~628 m) with margin."},
21
+ {"key": "site_b_height_m", "label": "Site B antenna height", "type": "float",
22
+ "default": 30.0, "suffix": " m", "min": 0.1, "max": 1000.0,
23
+ "basis": "Conservative engineering limit, not a derived bound: no library or "
24
+ "standard constrains antenna height. 1000 m clears the CN Tower (553 m) "
25
+ "and the tallest guyed mast (~628 m) with margin."},
26
+ {"key": "frequency_ghz", "label": "Frequency", "type": "float",
27
+ "default": 7.0, "suffix": " GHz", "min": 0.1, "max": 100.0,
28
+ "basis": "Lower bound derived: aei_link_clearance.fresnel.fresnel_radius_m and "
29
+ "terrain.py both raise ValueError for a non-positive frequency. The "
30
+ "0.1-100 GHz envelope is a conservative engineering limit, not a model "
31
+ "range -- Fresnel geometry has no frequency ceiling. It brackets the "
32
+ "loaded ISED Fixed Service data (915.1 MHz to 85.75 GHz) so it cannot "
33
+ "exclude a real record."},
34
+ ]
35
+
36
+
37
+ def bounds() -> dict:
38
+ """{key: (min, max)} for every bounded parameter."""
39
+ return {p["key"]: (p["min"], p["max"]) for p in TERRESTRIAL_PARAM_SPEC if "min" in p}
40
+
41
+
42
+ def contract() -> dict:
43
+ """The machine-readable form written to contract/terrestrial_bounds.json (labels and units, no prose)."""
44
+ return {"schema": "velorona.terrestrial-bounds", "version": 1,
45
+ "bounds": {p["key"]: {"min": p["min"], "max": p["max"], "label": p["label"], "suffix": p.get("suffix", "").strip()}
46
+ for p in TERRESTRIAL_PARAM_SPEC if "min" in p}}
aei_workflow/cli.py ADDED
@@ -0,0 +1,412 @@
1
+ """velorona-run: validate, run, schedule-tick, inspect and export Velorona workflows without any GUI.
2
+
3
+ velorona-run init --name N --links FILE_OR_DIR --out workflow.json [--daily 02:00 --timezone America/Toronto ...]
4
+ velorona-run validate workflow.json [--links FILE_OR_DIR]
5
+ velorona-run install workflow.json --store DIR [--replace]
6
+ velorona-run run --store DIR (--workflow-id ID | --workflow FILE) [--links FILE_OR_DIR]
7
+ velorona-run tick --store DIR (what the OS scheduler runs, every few minutes)
8
+ velorona-run status --store DIR [--json]
9
+ velorona-run runs | export | compare | backup | restore | import-doc | unlock | schedule-template
10
+
11
+ Exit codes (also what `tick` returns, worst first): 0 ok/nothing due; 10 partial; 11 failed; 12 canceled; 13 interrupted;
12
+ 14 a scheduled occurrence was missed; 2 usage error; 3 input/workflow file problem; 75 workflow already running; 1 unexpected.
13
+
14
+ The store folder is chosen by --store or the VELORONA_STORE environment variable; there is deliberately no default location.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ import json
21
+ import os
22
+ import signal
23
+ import sys
24
+ import threading
25
+ from datetime import datetime, timezone
26
+
27
+ from . import __version__, report
28
+ from .compare import compare_runs
29
+ from .inputs import InputFileError, read_links_file, validate_links_csv
30
+ from .locking import force_unlock
31
+ from .service import (
32
+ EXIT_ERROR, EXIT_INPUT, EXIT_OK, EXIT_USAGE, ServiceError, absolutize_input, install_workflow, iso, load_workflow_file, open_store,
33
+ parse_time, resolve_input, run_workflow, status as status_report, tick,
34
+ )
35
+ from .schema import SchemaError
36
+ from .store import StoreError
37
+ from .workflow import new_workflow
38
+
39
+
40
+ def _out(args, obj, text: str) -> None:
41
+ print(json.dumps(obj, indent=2) if getattr(args, "json", False) else text)
42
+
43
+
44
+ def _store(args, create=True):
45
+ return open_store(args.store or os.environ.get("VELORONA_STORE"), create=create)
46
+
47
+
48
+ def _load_workflow(args, store):
49
+ if getattr(args, "workflow", None):
50
+ wf = load_workflow_file(args.workflow)
51
+ return absolutize_input(wf, os.path.dirname(os.path.abspath(args.workflow)))
52
+ if getattr(args, "workflow_id", None):
53
+ try:
54
+ return store.load_workflow(args.workflow_id)
55
+ except (StoreError, SchemaError) as exc:
56
+ raise ServiceError(str(exc), EXIT_USAGE) from exc
57
+ raise ServiceError("give --workflow FILE or --workflow-id ID", EXIT_USAGE)
58
+
59
+
60
+ # -- commands ----------------------------------------------------------------------------------------------------------------
61
+
62
+ def cmd_init(args):
63
+ schedule = {"kind": "manual"}
64
+ chosen = [x for x in (args.every_minutes, args.daily, args.weekly) if x]
65
+ if len(chosen) > 1:
66
+ raise ServiceError("choose only one of --every-minutes, --daily, --weekly", EXIT_USAGE)
67
+ grace = {"grace_minutes": args.grace_minutes} if args.grace_minutes is not None else {}
68
+ if args.every_minutes:
69
+ schedule = {"kind": "interval", "every_minutes": args.every_minutes, **grace}
70
+ elif args.daily:
71
+ schedule = {"kind": "daily", "at": args.daily, **grace}
72
+ elif args.weekly:
73
+ days, _, at = args.weekly.partition("@")
74
+ schedule = {"kind": "weekly", "days": [d.strip().lower() for d in days.split(",") if d.strip()], "at": at, **grace}
75
+ if os.path.exists(args.out):
76
+ raise ServiceError(f"{args.out} already exists; nothing was overwritten", EXIT_USAGE)
77
+ try:
78
+ wf = new_workflow(args.name, input_path=os.path.abspath(args.links), k_factor=args.k_factor, workflow_version=args.workflow_version,
79
+ schedule=schedule, tz=args.timezone, retain_runs=args.retain_runs, retain_hours=args.retain_hours,
80
+ write_evidence=not args.no_evidence, execution={"max_attempts": args.attempts, "pause_between_links_s": args.pause_seconds})
81
+ except SchemaError as exc:
82
+ raise ServiceError(f"invalid setting: {exc}", EXIT_USAGE) from exc
83
+ with open(args.out, "x", encoding="utf-8") as f:
84
+ json.dump(wf, f, indent=2)
85
+ _out(args, wf, f"workflow written to {args.out}\n id: {wf['workflow_id']} version: {wf['workflow_version']} schedule: {schedule} time zone: {wf['timezone']}")
86
+ return EXIT_OK
87
+
88
+
89
+ def cmd_validate(args):
90
+ wf = load_workflow_file(args.workflow)
91
+ wf = absolutize_input(wf, os.path.dirname(os.path.abspath(args.workflow)))
92
+ info = {"workflow_id": wf["workflow_id"], "name": wf["name"], "workflow_version": wf.get("workflow_version", 1), "schedule": wf.get("schedule"),
93
+ "timezone": wf.get("timezone", "UTC"), "workflow_valid": True}
94
+ target = args.links or wf["input"].get("path")
95
+ if target:
96
+ try:
97
+ text, sha, name = resolve_input(target)
98
+ v = validate_links_csv(text)
99
+ info.update(input_file=name, input_sha256=sha, links_ready=len(v["accepted"]), rows_rejected=len(v["rejected"]),
100
+ rejected=[r["reason"] for r in v["rejected"][:20]])
101
+ except InputFileError as exc:
102
+ info.update(input_error=str(exc))
103
+ _out(args, info, f"workflow OK; input problem: {exc}")
104
+ return EXIT_INPUT
105
+ lines = [f"workflow OK: {info['name']} (id {info['workflow_id']}, version {info['workflow_version']})"]
106
+ if "links_ready" in info:
107
+ lines.append(f"input {info['input_file']}: {info['links_ready']} link(s) ready, {info['rows_rejected']} row(s) rejected")
108
+ lines += [f" {r}" for r in info["rejected"]]
109
+ else:
110
+ lines.append("no input file to check (none in the workflow and none given with --links)")
111
+ _out(args, info, "\n".join(lines))
112
+ return EXIT_INPUT if info.get("links_ready") == 0 else EXIT_OK
113
+
114
+
115
+ def cmd_install(args):
116
+ store = _store(args)
117
+ wf = absolutize_input(load_workflow_file(args.workflow), os.path.dirname(os.path.abspath(args.workflow)))
118
+ res = install_workflow(store, wf, replace=args.replace)
119
+ _out(args, {**res, "workflow_id": wf["workflow_id"]}, f"workflow {wf['workflow_id']} installed in {store.root}" +
120
+ (f" (replaced version {res['replaced_version']}; the old definition is kept in workflows/_history)" if res["replaced_version"] else ""))
121
+ return EXIT_OK
122
+
123
+
124
+ def cmd_run(args):
125
+ store = _store(args)
126
+ wf = _load_workflow(args, store)
127
+ cancel = threading.Event()
128
+ previous = {}
129
+ for sig in (signal.SIGINT, signal.SIGTERM):
130
+ previous[sig] = signal.signal(sig, lambda *_: cancel.set())
131
+ try:
132
+ run = run_workflow(store, wf, links=args.links, is_canceled=cancel.is_set)
133
+ finally:
134
+ for sig, handler in previous.items():
135
+ signal.signal(sig, handler)
136
+ return _print_run(args, run)
137
+
138
+
139
+ def _print_run(args, run):
140
+ from .service import EXIT_BY_STATUS
141
+ c = run["counts"]
142
+ _out(args, {"run_id": run["run_id"], "status": run["status"], "counts": c, "error": run.get("error")},
143
+ f"run {run['run_id']}: {run['status'].upper()} -- {c['ok']} analysed, {c['failed']} failed, {c['rejected']} rejected, {c['not_run']} not run"
144
+ + (f"\n {run['error']}" if run.get("error") else ""))
145
+ return EXIT_BY_STATUS.get(run["status"], EXIT_ERROR)
146
+
147
+
148
+ def cmd_tick(args):
149
+ store = _store(args)
150
+ now = parse_time(args.now) if args.now else datetime.now(timezone.utc)
151
+ cancel = threading.Event()
152
+ for sig in (signal.SIGINT, signal.SIGTERM):
153
+ signal.signal(sig, lambda *_: cancel.set())
154
+ results = tick(store, now, workflow_ids=set(args.workflow_id) if args.workflow_id else None, is_canceled=cancel.is_set)
155
+ lines = []
156
+ for r in results:
157
+ lines.append(f"{r.get('workflow_id') or '-'}: {r['outcome']}" + (f" ({r['status']})" if r.get("status") else "") + (f", {r['missed']} missed" if r.get("missed") else "")
158
+ + (f", next due {r['next_due']}" if r.get("next_due") else "") + (f" -- {r['detail']}" if r.get("detail") else ""))
159
+ _out(args, results, "\n".join(lines) or "no scheduled workflows installed")
160
+ return max([r["exit_code"] for r in results] or [EXIT_OK])
161
+
162
+
163
+ def cmd_status(args):
164
+ store = _store(args, create=False)
165
+ rep = status_report(store, args.workflow_id, parse_time(args.now) if args.now else None)
166
+ lines = [f"store: {rep['store']} as of {rep['as_of']}"]
167
+ for w in rep["workflows"]:
168
+ lines.append(f"\n{w['name']} (id {w['workflow_id']}, v{w['workflow_version']}) schedule: {w['schedule']['kind']}"
169
+ + (f" {json.dumps({k: v for k, v in w['schedule'].items() if k != 'kind'})}" if len(w["schedule"]) > 1 else "") + f" tz: {w['timezone']}")
170
+ if w["schedule"]["kind"] != "manual":
171
+ lines.append(" scheduler has not seen this workflow yet (waiting for the first `tick`)" if not w["scheduling_started"] else f" next due: {w['next_due']}")
172
+ if w["running"]:
173
+ lines.append(f" RUNNING now or lock held: {w['running']}")
174
+ lr = w["last_run"]
175
+ if lr:
176
+ lines.append(f" last run {lr['run_id']}: {lr['status'].upper()} ({lr['trigger']}, started {lr['started_at']}, finished {lr['finished_at']})")
177
+ lines.append(f" links: {lr['counts']['ok']} ok, {lr['counts']['failed']} failed, {lr['counts']['rejected']} rejected, {lr['counts']['not_run']} not run")
178
+ if lr["error"]:
179
+ lines.append(f" error: {lr['error']}")
180
+ else:
181
+ lines.append(" no runs yet")
182
+ if w["last_completed_run"]:
183
+ lines.append(f" last COMPLETED run: {w['last_completed_run']['run_id']} at {w['last_completed_run']['finished_at']}")
184
+ if w["missed_in_history"]:
185
+ lines.append(f" missed occurrences in stored history: {w['missed_in_history']}")
186
+ for p in rep["problems"]:
187
+ lines.append(f"\nUNREADABLE (left untouched): {p['file']}: {p['problem']}")
188
+ _out(args, rep, "\n".join(lines))
189
+ return EXIT_OK
190
+
191
+
192
+ def cmd_runs(args):
193
+ store = _store(args, create=False)
194
+ runs, problems = store.list_runs(args.workflow_id)
195
+ lines = [f"{r['run_id']} {r['status']:<11} {r['started_at']} {r['workflow_name']}" for r in runs]
196
+ lines += [f"UNREADABLE (left untouched): {p['file']}: {p['problem']}" for p in problems]
197
+ _out(args, {"runs": runs, "problems": problems}, "\n".join(lines) or "no runs")
198
+ return EXIT_OK
199
+
200
+
201
+ def cmd_export(args):
202
+ store = _store(args, create=False)
203
+ try:
204
+ folder = report.export_package(store.load_run(args.run), args.out)
205
+ except (StoreError, SchemaError, FileExistsError, ValueError) as exc:
206
+ raise ServiceError(str(exc), EXIT_USAGE) from exc
207
+ _out(args, {"folder": folder}, f"result package written to {folder}")
208
+ return EXIT_OK
209
+
210
+
211
+ def cmd_compare(args):
212
+ store = _store(args, create=False)
213
+ try:
214
+ c = compare_runs(store.load_run(args.a), store.load_run(args.b))
215
+ except (StoreError, SchemaError) as exc:
216
+ raise ServiceError(str(exc), EXIT_USAGE) from exc
217
+ s = c["summary"]
218
+ text = [("LIKE-FOR-LIKE" if c["comparable"] else "NOT A VALID BEFORE/AFTER COMPARISON") + f": {s['compared']} compared, {s['results_changed']} changed, "
219
+ f"{s['los_status_changed']} status changes, {s['only_in_first']} only in first, {s['only_in_second']} only in second"] + [f" {r}" for r in c["reasons"]]
220
+ text += [f" {l['link_id']}: {l['note']}" for l in c["links"] if l.get("note") and l["note"] != "Unchanged."] + [c["note"]]
221
+ _out(args, c, "\n".join(text))
222
+ return EXIT_OK
223
+
224
+
225
+ def cmd_backup(args):
226
+ store = _store(args, create=False)
227
+ try:
228
+ info = store.export_backup(args.out)
229
+ except StoreError as exc:
230
+ raise ServiceError(str(exc), EXIT_USAGE) from exc
231
+ _out(args, info, f"backed up {info['files']} file(s) to {info['path']}")
232
+ return EXIT_OK
233
+
234
+
235
+ def cmd_restore(args):
236
+ store = _store(args)
237
+ try:
238
+ rep = store.import_backup(args.file)
239
+ except StoreError as exc:
240
+ raise ServiceError(str(exc), EXIT_USAGE) from exc
241
+ _out(args, rep, f"restored {len(rep['added'])}; {len(rep['skipped_existing'])} already present (kept as they were); {len(rep['rejected'])} rejected"
242
+ + "".join(f"\n {r['file']}: {r['problem']}" for r in rep["rejected"]))
243
+ return EXIT_OK if not rep["rejected"] else EXIT_INPUT
244
+
245
+
246
+ def cmd_import_doc(args):
247
+ store = _store(args)
248
+ try:
249
+ with open(args.file, "r", encoding="utf-8") as f:
250
+ res = store.import_document(f.read())
251
+ except (OSError, StoreError, SchemaError) as exc:
252
+ raise ServiceError(str(exc), EXIT_INPUT) from exc
253
+ _out(args, res, f"{res['kind']} {res['id']}: " + ("imported" if res["added"] else "already present, left as it was"))
254
+ return EXIT_OK
255
+
256
+
257
+ def cmd_unlock(args):
258
+ store = _store(args, create=False)
259
+ if not args.force:
260
+ raise ServiceError("unlock removes a workflow's run lock even if a run is live; re-run with --force if you are sure no run is active", EXIT_USAGE)
261
+ info = force_unlock(store.root, args.workflow_id)
262
+ _out(args, {"removed": info}, f"lock removed ({info})" if info else "no lock was present")
263
+ return EXIT_OK
264
+
265
+
266
+ def cmd_schedule_template(args):
267
+ print(schedule_template(args.kind, os.path.abspath(args.store), args.python or sys.executable, args.every_minutes, args.label))
268
+ return EXIT_OK
269
+
270
+
271
+ def schedule_template(kind: str, store: str, python: str, every_minutes: int, label: str) -> str:
272
+ """Text for the customer's own OS scheduler. Nothing is installed by this program."""
273
+ cmd = f'"{python}" -m aei_workflow tick --store "{store}"'
274
+ log = os.path.join(store, "logs", "scheduler.out")
275
+ if kind == "cron":
276
+ return f"# crontab -e (Linux, macOS). Runs `tick` every {every_minutes} minutes; tick decides what is due.\n*/{every_minutes} * * * * {cmd} >> \"{log}\" 2>&1"
277
+ if kind == "launchd":
278
+ return f"""<?xml version="1.0" encoding="UTF-8"?>
279
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
280
+ <plist version="1.0"><dict>
281
+ <key>Label</key><string>{label}</string>
282
+ <key>ProgramArguments</key><array>
283
+ <string>{python}</string><string>-m</string><string>aei_workflow</string><string>tick</string><string>--store</string><string>{store}</string>
284
+ </array>
285
+ <key>StartInterval</key><integer>{every_minutes * 60}</integer>
286
+ <key>StandardOutPath</key><string>{log}</string><key>StandardErrorPath</key><string>{log}</string>
287
+ </dict></plist>
288
+ <!-- save as ~/Library/LaunchAgents/{label}.plist, then: launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/{label}.plist -->"""
289
+ if kind == "systemd":
290
+ return f"""# ~/.config/systemd/user/{label}.service
291
+ [Unit]
292
+ Description=Velorona scheduled workflows (tick)
293
+ [Service]
294
+ Type=oneshot
295
+ ExecStart={python} -m aei_workflow tick --store {store}
296
+
297
+ # ~/.config/systemd/user/{label}.timer
298
+ [Unit]
299
+ Description=Run Velorona tick every {every_minutes} minutes
300
+ [Timer]
301
+ OnBootSec=2min
302
+ OnUnitActiveSec={every_minutes}min
303
+ Persistent=true
304
+ [Install]
305
+ WantedBy=timers.target
306
+ # then: systemctl --user daemon-reload && systemctl --user enable --now {label}.timer"""
307
+ raise ServiceError("--kind must be cron, launchd or systemd", EXIT_USAGE)
308
+
309
+
310
+ # -- parser ------------------------------------------------------------------------------------------------------------------------------
311
+
312
+ def build_parser() -> argparse.ArgumentParser:
313
+ p = argparse.ArgumentParser(prog="velorona-run", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
314
+ p.add_argument("--version", action="version", version=f"velorona-run (aei-workflow-runner) {__version__}")
315
+ sub = p.add_subparsers(dest="command", required=True)
316
+
317
+ def add(name, fn, help_, store=True, json_=True):
318
+ sp = sub.add_parser(name, help=help_)
319
+ if store:
320
+ sp.add_argument("--store", help="customer-owned folder for workflows, runs, evidence and logs (or set VELORONA_STORE)")
321
+ if json_:
322
+ sp.add_argument("--json", action="store_true", help="machine-readable output")
323
+ sp.set_defaults(fn=fn)
324
+ return sp
325
+
326
+ sp = add("init", cmd_init, "write a new workflow file", store=False)
327
+ sp.add_argument("--name", required=True)
328
+ sp.add_argument("--links", required=True, help="customer-owned CSV file, or a folder (its newest .csv is used each run)")
329
+ sp.add_argument("--out", required=True, help="workflow file to create (never overwritten)")
330
+ sp.add_argument("--k-factor", type=float, default=None)
331
+ sp.add_argument("--workflow-version", type=int, default=1)
332
+ sp.add_argument("--every-minutes", type=int, help="run every N minutes (5 to 10080)")
333
+ sp.add_argument("--daily", metavar="HH:MM", help="run every day at this local time")
334
+ sp.add_argument("--weekly", metavar="mon,thu@HH:MM", help="run on these weekdays at this local time")
335
+ sp.add_argument("--timezone", default="UTC", help="IANA zone for schedule times, e.g. America/Toronto (default UTC)")
336
+ sp.add_argument("--grace-minutes", type=int, default=None, help="how late an occurrence may still run (default 15)")
337
+ sp.add_argument("--retain-runs", type=int, default=None, help="keep the newest N runs; older ones are MOVED to _pruned (default: keep all)")
338
+ sp.add_argument("--retain-hours", type=float, default=None, help="keep runs finished within N hours; older ones are MOVED to _pruned (default: keep all)")
339
+ sp.add_argument("--no-evidence", action="store_true", help="do not write evidence files next to each run.json")
340
+ sp.add_argument("--attempts", type=int, default=2, help="attempts per link when the elevation service fails")
341
+ sp.add_argument("--pause-seconds", type=float, default=0.0, help="wait between links (to stay under a service's rate limit)")
342
+
343
+ sp = add("validate", cmd_validate, "check a workflow file and its input without running", store=False)
344
+ sp.add_argument("workflow")
345
+ sp.add_argument("--links")
346
+
347
+ sp = add("install", cmd_install, "put a workflow into a store so `tick` can schedule it")
348
+ sp.add_argument("workflow")
349
+ sp.add_argument("--replace", action="store_true", help="replace an installed workflow with a higher workflow_version (old copy is kept)")
350
+
351
+ sp = add("run", cmd_run, "run a workflow now (manual run)")
352
+ sp.add_argument("--workflow")
353
+ sp.add_argument("--workflow-id")
354
+ sp.add_argument("--links", help="use this CSV file or folder instead of the workflow's input")
355
+
356
+ sp = add("tick", cmd_tick, "evaluate schedules and run what is due (run this from cron/launchd/systemd)")
357
+ sp.add_argument("--workflow-id", action="append")
358
+ sp.add_argument("--now", help="ISO time with offset, for testing or auditing only")
359
+
360
+ sp = add("status", cmd_status, "last run, errors, next due, lock state")
361
+ sp.add_argument("--workflow-id")
362
+ sp.add_argument("--now", help=argparse.SUPPRESS)
363
+
364
+ sp = add("runs", cmd_runs, "list stored runs")
365
+ sp.add_argument("--workflow-id")
366
+
367
+ sp = add("export", cmd_export, "write a run's evidence package to a folder")
368
+ sp.add_argument("--run", required=True)
369
+ sp.add_argument("--out", required=True, help="existing folder to write velorona-run-<id>/ into")
370
+
371
+ sp = add("compare", cmd_compare, "compare two runs")
372
+ sp.add_argument("a")
373
+ sp.add_argument("b")
374
+
375
+ sp = add("backup", cmd_backup, "write every workflow and run to one zip (never overwrites)")
376
+ sp.add_argument("--out", required=True)
377
+
378
+ sp = add("restore", cmd_restore, "merge a backup zip into the store (never overwrites)")
379
+ sp.add_argument("file")
380
+
381
+ sp = add("import-doc", cmd_import_doc, "import one run.json or workflow file from Velorona Web or QGIS")
382
+ sp.add_argument("file")
383
+
384
+ sp = add("unlock", cmd_unlock, "remove a workflow's run lock (only if you are sure no run is active)")
385
+ sp.add_argument("--workflow-id", required=True)
386
+ sp.add_argument("--force", action="store_true")
387
+
388
+ sp = add("schedule-template", cmd_schedule_template, "print cron / launchd / systemd text for the OS scheduler (installs nothing)", json_=False)
389
+ sp.add_argument("--kind", required=True, choices=["cron", "launchd", "systemd"])
390
+ sp.add_argument("--every-minutes", type=int, default=5)
391
+ sp.add_argument("--python", help="python executable that has aei-workflow-runner installed (default: the current one)")
392
+ sp.add_argument("--label", default="ai.aidedge.velorona.tick")
393
+ return p
394
+
395
+
396
+ def main(argv=None) -> int:
397
+ args = build_parser().parse_args(argv)
398
+ try:
399
+ return args.fn(args)
400
+ except ServiceError as exc:
401
+ print(f"error: {exc}", file=sys.stderr)
402
+ return exc.exit_code
403
+ except (SchemaError, StoreError, InputFileError) as exc:
404
+ print(f"error: {exc}", file=sys.stderr)
405
+ return EXIT_INPUT
406
+ except OSError as exc:
407
+ print(f"error: {exc.strerror or exc}", file=sys.stderr)
408
+ return EXIT_ERROR
409
+
410
+
411
+ if __name__ == "__main__": # pragma: no cover
412
+ sys.exit(main())
@@ -0,0 +1,152 @@
1
+ """Compare two runs without overstating what the comparison means.
2
+
3
+ Every difference is filed under one of four headings, because they mean different things:
4
+
5
+ parameters / input file what the user asked for changed
6
+ data source where the terrain data came from changed
7
+ versions the software or engine that calculated it changed
8
+ link results the numbers changed
9
+
10
+ A comparison is 'like for like' only when the method and data source are the same. Otherwise
11
+ comparable=False, the reasons are listed, and any per-link differences are shown but labelled as
12
+ not a valid before/after. Even a like-for-like comparison is two stored analyses, not a replay of
13
+ conditions: the terrain source is a static model with no observation time, so a result that moved
14
+ with nothing else changed is reported as unexplained rather than attributed to anything.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from .schema import LINK_OK
20
+
21
+ RESULT_FIELDS = ("los_status", "near_threshold", "distance_km", "first_fresnel_radius_m", "required_clearance_m",
22
+ "terrain_clearance_m", "clearance_ratio", "percent_fresnel_clear", "obstruction_distance_km")
23
+ TOLERANCE = 1e-9
24
+
25
+ NOTE = ("These are two stored analyses. Neither is a reconstruction of past conditions: the terrain source "
26
+ "is a static model and records no observation time.")
27
+
28
+
29
+ def _same(a, b) -> bool:
30
+ if isinstance(a, (int, float)) and isinstance(b, (int, float)) and not isinstance(a, bool) and not isinstance(b, bool):
31
+ return abs(a - b) <= TOLERANCE
32
+ return a == b
33
+
34
+
35
+ def _diff(a: dict, b: dict, keys) -> list:
36
+ out = []
37
+ for k in keys:
38
+ if not _same(a.get(k), b.get(k)):
39
+ out.append({"field": k, "a": a.get(k), "b": b.get(k)})
40
+ return out
41
+
42
+
43
+ def _flatten(d, prefix=""):
44
+ flat = {}
45
+ for k, v in (d or {}).items():
46
+ key = f"{prefix}{k}"
47
+ if isinstance(v, dict):
48
+ flat.update(_flatten(v, key + "."))
49
+ else:
50
+ flat[key] = v
51
+ return flat
52
+
53
+
54
+ def _sources(run: dict) -> str:
55
+ return "; ".join(sorted(s.get("name", "") for s in run.get("provenance", {}).get("data_sources", [])))
56
+
57
+
58
+ def compare_runs(a: dict, b: dict) -> dict:
59
+ reasons = []
60
+ wa, wb = a.get("workflow_snapshot", {}), b.get("workflow_snapshot", {})
61
+ if wa.get("engine") != wb.get("engine"):
62
+ reasons.append(f"Different analysis engines ({wa.get('engine')} vs {wb.get('engine')}).")
63
+ param_changes = _diff(wa.get("params", {}), wb.get("params", {}), sorted(set(wa.get("params", {})) | set(wb.get("params", {}))))
64
+ for change in param_changes:
65
+ reasons.append(f"Analysis parameter '{change['field']}' differs ({change['a']} vs {change['b']}), "
66
+ "so the results were calculated under different assumptions.")
67
+ source_changes = []
68
+ if _sources(a) != _sources(b):
69
+ source_changes.append({"field": "terrain data source", "a": _sources(a), "b": _sources(b)})
70
+ reasons.append("The terrain data source differs between the runs.")
71
+ for r, label in ((a, "first"), (b, "second")):
72
+ if r.get("status") == "running":
73
+ reasons.append(f"The {label} run is still in progress.")
74
+ elif r.get("status") == "failed":
75
+ reasons.append(f"The {label} run failed and has no results to compare.")
76
+ elif r.get("status") == "missed":
77
+ reasons.append(f"The {label} run is a missed scheduled occurrence: it did not run and has no results to compare.")
78
+
79
+ version_changes = [{"field": k, "a": va, "b": vb}
80
+ for k, (va, vb) in sorted(_paired(_flatten(a.get("versions")), _flatten(b.get("versions"))).items())
81
+ if va != vb]
82
+ engine_versions_changed = any(c["field"].startswith("analysis_engine.") for c in version_changes)
83
+
84
+ ia, ib = a.get("input", {}), b.get("input", {})
85
+ input_file = {"same_workflow": a.get("workflow_id") == b.get("workflow_id"),
86
+ "file_changed": ia.get("sha256") != ib.get("sha256"),
87
+ "a": {"name": ia.get("source_name"), "sha256": ia.get("sha256")},
88
+ "b": {"name": ib.get("source_name"), "sha256": ib.get("sha256")}}
89
+
90
+ la = {l["link_id"]: l for l in a.get("links", [])}
91
+ lb = {l["link_id"]: l for l in b.get("links", [])}
92
+ links = []
93
+ for link_id in sorted(set(la) | set(lb)):
94
+ entry = {"link_id": link_id}
95
+ if link_id not in lb:
96
+ entry.update(presence="only_a", note="Present only in the first run.")
97
+ elif link_id not in la:
98
+ entry.update(presence="only_b", note="Present only in the second run.")
99
+ else:
100
+ x, y = la[link_id], lb[link_id]
101
+ entry.update(presence="both", status_a=x["status"], status_b=y["status"])
102
+ if x["status"] != LINK_OK or y["status"] != LINK_OK:
103
+ entry["note"] = "At least one run has no result for this link (failed or not run); nothing to compare."
104
+ else:
105
+ entry["input_changes"] = _diff(x["input"], y["input"], sorted(x["input"]))
106
+ entry["result_changes"] = [{**c, "delta": _delta(c["a"], c["b"])}
107
+ for c in _diff(x["result"], y["result"], RESULT_FIELDS)]
108
+ entry["note"] = _attribute(entry, engine_versions_changed)
109
+ links.append(entry)
110
+
111
+ both = [l for l in links if l["presence"] == "both" and "result_changes" in l]
112
+ summary = {
113
+ "in_both": sum(1 for l in links if l["presence"] == "both"),
114
+ "only_in_first": sum(1 for l in links if l["presence"] == "only_a"),
115
+ "only_in_second": sum(1 for l in links if l["presence"] == "only_b"),
116
+ "compared": len(both),
117
+ "results_changed": sum(1 for l in both if l["result_changes"]),
118
+ "los_status_changed": sum(1 for l in both if any(c["field"] == "los_status" for c in l["result_changes"])),
119
+ }
120
+ return {
121
+ "run_a": _ident(a), "run_b": _ident(b),
122
+ "comparable": not reasons, "reasons": reasons, "note": NOTE,
123
+ "parameter_changes": param_changes, "input_file": input_file,
124
+ "data_source_changes": source_changes, "version_changes": version_changes,
125
+ "links": links, "summary": summary,
126
+ }
127
+
128
+
129
+ def _paired(fa: dict, fb: dict) -> dict:
130
+ return {k: (fa.get(k), fb.get(k)) for k in set(fa) | set(fb)}
131
+
132
+
133
+ def _ident(run: dict) -> dict:
134
+ return {k: run.get(k) for k in ("run_id", "workflow_id", "workflow_name", "started_at", "status")}
135
+
136
+
137
+ def _delta(a, b):
138
+ if isinstance(a, (int, float)) and isinstance(b, (int, float)) and not isinstance(a, bool):
139
+ return b - a
140
+ return None
141
+
142
+
143
+ def _attribute(entry: dict, engine_versions_changed: bool) -> str:
144
+ if entry["input_changes"]:
145
+ fields = ", ".join(c["field"] for c in entry["input_changes"])
146
+ return f"This link's inputs changed ({fields}); any result difference follows from that."
147
+ if not entry["result_changes"]:
148
+ return "Unchanged."
149
+ if engine_versions_changed:
150
+ return "Inputs are identical but the analysis engine version changed; the difference may come from that."
151
+ return ("Inputs, parameters and engine versions are identical, yet the result differs. Nothing recorded "
152
+ "explains it; the elevation service's data may have changed, which this product cannot observe.")