Skip to content

Commit 39f341a

Browse files
orchestration
1 parent 20be360 commit 39f341a

3 files changed

Lines changed: 266 additions & 0 deletions

File tree

Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
#!/usr/bin/env python3
2+
"""Orchestrate collect → publish → index → git for CY-Bench walk-forward dashboards.
3+
4+
Designed to run entirely on the WUR lustre HPC (anunna): baselines, collect output,
5+
and the GitHub Pages clone can all live on lustre or $HOME.
6+
7+
Example (on anunna, from the repo root)::
8+
9+
poetry run python cybench/runs/analysis/orchestrate_dashboard_publish.py \\
10+
--config cybench/runs/analysis/dashboard_targets.yaml \\
11+
--mode ready --dry-run
12+
13+
poetry run python cybench/runs/analysis/orchestrate_dashboard_publish.py \\
14+
--mode ready --commit
15+
16+
poetry run python cybench/runs/analysis/orchestrate_dashboard_publish.py \\
17+
--country DE --horizon eos --force publish,index,commit
18+
"""
19+
20+
from __future__ import annotations
21+
22+
import argparse
23+
import subprocess
24+
import sys
25+
from pathlib import Path
26+
27+
from cybench.runs.analysis.publish_pipeline_lib import (
28+
PipelineDefaults,
29+
StageName,
30+
assess_readiness,
31+
filter_ready_targets,
32+
load_pipeline_defaults,
33+
resolve_targets,
34+
run_collect_stage,
35+
run_commit_stage,
36+
run_index_stage,
37+
run_publish_stage,
38+
)
39+
40+
_DEFAULT_CONFIG = Path(__file__).resolve().parent / "dashboard_targets.yaml"
41+
42+
43+
def _parse_stages(raw: str | None) -> set[StageName]:
44+
if not raw:
45+
return {"collect", "publish", "index"}
46+
stages: set[StageName] = set()
47+
for part in raw.split(","):
48+
key = part.strip().lower()
49+
if key not in {"collect", "publish", "index", "commit"}:
50+
raise ValueError(f"Unknown stage: {part!r}")
51+
stages.add(key) # type: ignore[arg-type]
52+
return stages
53+
54+
55+
def main(argv: list[str] | None = None) -> int:
56+
parser = argparse.ArgumentParser(description=__doc__)
57+
parser.add_argument(
58+
"--config",
59+
type=Path,
60+
default=_DEFAULT_CONFIG,
61+
help=f"YAML target list (default: {_DEFAULT_CONFIG.name})",
62+
)
63+
parser.add_argument(
64+
"--mode",
65+
choices=["planned", "ready", "all-available"],
66+
default="ready",
67+
help=(
68+
"ready: lustre baselines_* batches with complete walk-forward (default); "
69+
"all-available: all discovered batches; "
70+
"planned: only explicit include: list in config"
71+
),
72+
)
73+
parser.add_argument(
74+
"--output-root",
75+
type=Path,
76+
help="Override defaults.output_root (Hydra ../output on lustre)",
77+
)
78+
parser.add_argument(
79+
"--repo-root",
80+
type=Path,
81+
help="Override defaults.repo_root (cybench clone used for poetry collect)",
82+
)
83+
parser.add_argument(
84+
"--publish-root",
85+
type=Path,
86+
help="Override defaults.publish_root (git clone for GitHub Pages)",
87+
)
88+
parser.add_argument(
89+
"--country",
90+
action="append",
91+
dest="countries",
92+
metavar="CC",
93+
help="Limit to country code(s), e.g. DE or FR (repeatable)",
94+
)
95+
parser.add_argument(
96+
"--horizon",
97+
action="append",
98+
dest="horizons",
99+
help="Limit to horizon(s): eos, mid, middle-of-season, ...",
100+
)
101+
parser.add_argument(
102+
"--stages",
103+
help="Comma-separated stages (default: collect,publish,index). Add commit via --commit",
104+
)
105+
parser.add_argument(
106+
"--force",
107+
help="Comma-separated stages to force even if up to date",
108+
)
109+
parser.add_argument(
110+
"--no-plot",
111+
action="store_true",
112+
help="Skip per-model plots during collect (faster; dashboard panels may be sparse)",
113+
)
114+
parser.add_argument(
115+
"--commit",
116+
action="store_true",
117+
help="Git commit publish-root changes for each processed target",
118+
)
119+
parser.add_argument(
120+
"--push",
121+
action="store_true",
122+
help="Git push after commit (implies network access on the login/submit node)",
123+
)
124+
parser.add_argument(
125+
"--dry-run",
126+
action="store_true",
127+
help="Print actions without running collect/publish/commit",
128+
)
129+
parser.add_argument(
130+
"--list",
131+
action="store_true",
132+
help="List candidate targets and readiness, then exit",
133+
)
134+
args = parser.parse_args(argv)
135+
136+
config_path = args.config if args.config.is_file() else None
137+
defaults = PipelineDefaults(
138+
output_root=args.output_root or PipelineDefaults().output_root,
139+
repo_root=args.repo_root or PipelineDefaults().repo_root,
140+
publish_root=(args.publish_root or PipelineDefaults().publish_root).expanduser(),
141+
)
142+
defaults = load_pipeline_defaults(config_path, overrides=defaults)
143+
try:
144+
targets = resolve_targets(
145+
mode="all-available" if args.list else args.mode,
146+
config_path=config_path,
147+
defaults=defaults,
148+
countries=args.countries,
149+
horizons=args.horizons,
150+
)
151+
except ValueError as exc:
152+
print(f"[ERROR] {exc}", file=sys.stderr)
153+
return 2
154+
155+
if not targets:
156+
print("[WARN] No targets matched filters")
157+
return 1
158+
159+
readiness = {t.batch_name: assess_readiness(t) for t in targets}
160+
161+
if args.list:
162+
print(f"{'batch':<28} {'ready':<5} complete reason")
163+
print("-" * 72)
164+
for target in targets:
165+
report = readiness[target.batch_name]
166+
mark = "yes" if report.ready else "no"
167+
exp = report.expected_runs or "?"
168+
print(
169+
f"{target.batch_name:<28} {mark:<5} {report.complete_runs:>3}/{exp:<3} {report.reason}"
170+
)
171+
return 0
172+
173+
if args.mode == "ready":
174+
skipped = [t for t in targets if not readiness[t.batch_name].ready]
175+
for target in skipped:
176+
print(f"[SKIP] {target.batch_name}: {readiness[target.batch_name].reason}")
177+
targets = [t for t, _ in filter_ready_targets(targets)]
178+
179+
if not targets:
180+
print("[DONE] Nothing ready to publish")
181+
return 0
182+
183+
stages = _parse_stages(args.stages)
184+
if args.commit:
185+
stages.add("commit")
186+
force = _parse_stages(args.force)
187+
exit_code = 0
188+
189+
for target in targets:
190+
report = readiness.get(target.batch_name) or assess_readiness(target)
191+
print(f"\n=== {target.batch_name} | {report.complete_runs} runs | slug={target.publish_slug} ===")
192+
try:
193+
if "collect" in stages:
194+
status = run_collect_stage(
195+
target,
196+
plot=not args.no_plot,
197+
force="collect" in force,
198+
dry_run=args.dry_run,
199+
)
200+
print(f"[{'SKIP' if status.skipped else 'OK'}] collect: {status.message}")
201+
202+
if "publish" in stages:
203+
status = run_publish_stage(
204+
target,
205+
force="publish" in force,
206+
dry_run=args.dry_run,
207+
)
208+
print(f"[{'SKIP' if status.skipped else 'OK'}] publish: {status.message}")
209+
210+
if "commit" in stages:
211+
status = run_commit_stage(
212+
target,
213+
dry_run=args.dry_run,
214+
push=args.push,
215+
)
216+
print(f"[{'SKIP' if status.skipped else 'OK'}] commit: {status.message}")
217+
except (RuntimeError, subprocess.CalledProcessError, FileNotFoundError) as exc:
218+
print(f"[ERROR] {target.batch_name}: {exc}", file=sys.stderr)
219+
exit_code = 1
220+
221+
if "index" in stages:
222+
if args.dry_run:
223+
print(f"\n[DRY-RUN] would rebuild index under {targets[0].publish_root}")
224+
else:
225+
status = run_index_stage(targets[0], dry_run=False)
226+
print(f"\n[OK] index: {status.message}")
227+
228+
return exit_code
229+
230+
231+
if __name__ == "__main__":
232+
raise SystemExit(main())

cybench/runs/slurm/generate_job_manifest.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ def generate(
4949
cn_list = countries if countries else DATASETS[crop]
5050
for country in cn_list:
5151
if country not in DATASETS[crop]:
52+
if countries is not None:
53+
continue
5254
raise ValueError(f"Country {country!r} not in DATASETS[{crop!r}]")
5355
if not _data_exists(crop, country):
5456
continue
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
"""Tests for generate_job_manifest.py."""
2+
3+
from __future__ import annotations
4+
5+
from pathlib import Path
6+
7+
from cybench.runs.slurm.generate_job_manifest import generate
8+
9+
10+
def test_countries_filter_skips_crop_without_country(
11+
tmp_path: Path, monkeypatch
12+
):
13+
data = tmp_path / "data"
14+
(data / "maize" / "AO").mkdir(parents=True)
15+
models = tmp_path / "models.txt"
16+
models.write_text("ridge pandas yes yes no\n", encoding="utf-8")
17+
out = tmp_path / "jobs.txt"
18+
19+
import cybench.config as cfg
20+
21+
monkeypatch.setattr(cfg, "PATH_DATA_DIR", str(data))
22+
23+
n = generate(
24+
crops=["maize", "wheat"],
25+
countries=["AO"],
26+
models_path=models,
27+
output=out,
28+
)
29+
assert n == 1
30+
text = out.read_text(encoding="utf-8")
31+
assert "maize AO ridge" in text
32+
assert "wheat" not in text

0 commit comments

Comments
 (0)