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.
- aei_workflow/__init__.py +4 -0
- aei_workflow/__main__.py +5 -0
- aei_workflow/bounds.py +46 -0
- aei_workflow/cli.py +412 -0
- aei_workflow/compare.py +152 -0
- aei_workflow/csvsafe.py +46 -0
- aei_workflow/engine.py +105 -0
- aei_workflow/inputs.py +107 -0
- aei_workflow/locking.py +115 -0
- aei_workflow/logs.py +36 -0
- aei_workflow/report.py +221 -0
- aei_workflow/runner.py +161 -0
- aei_workflow/schedule.py +80 -0
- aei_workflow/schema.py +80 -0
- aei_workflow/service.py +398 -0
- aei_workflow/store.py +296 -0
- aei_workflow/workflow.py +178 -0
- aei_workflow_runner-0.1.0.dist-info/METADATA +133 -0
- aei_workflow_runner-0.1.0.dist-info/RECORD +23 -0
- aei_workflow_runner-0.1.0.dist-info/WHEEL +5 -0
- aei_workflow_runner-0.1.0.dist-info/entry_points.txt +2 -0
- aei_workflow_runner-0.1.0.dist-info/licenses/LICENSE +204 -0
- aei_workflow_runner-0.1.0.dist-info/top_level.txt +1 -0
aei_workflow/__init__.py
ADDED
aei_workflow/__main__.py
ADDED
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())
|
aei_workflow/compare.py
ADDED
|
@@ -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.")
|