RadCluster
Graph-based cluster dynamics for irradiated materials
Loading...
Searching...
No Matches
campaign.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2"""
3campaign.py — run the verification study, one claimed run at a time.
4
5 # take runs until none are left, syncing after each
6 python campaign.py --loop
7
8 # a single named run
9 python campaign.py --run T4_C4
10
11 # what is the board doing
12 python campaign.py --status
13
14Each run produces the SAME artifact set as the tracked reference run --
15`output/<stamp>_<tag>_<mode>_<physics>_I..V.._im..vm../` with provenance.md,
16summary.csv, diagnostics.txt, results_*.npy and plots/ -- because it goes
17through `RadClusterSimulation._save_output`, the same code path the reference
18came from. The `<tag>` is the run_id (T1_B3, T4_D, ...); without it the four
19Table-1 rungs differ only in `i_discrete` and would produce four directory
20names distinguishable only by timestamp.
21
22LIVE PROGRESS. The C++ solver emits a `[diag]` line per output step; the
23progress callback turns that into two local files that update DURING a run:
24
25 verification/logs/<machine>.jsonl append-only event log
26 verification/logs/<machine>.status a rendered snapshot, rewritten per step
27
28so `cat`/`tail -f` answers "where is this run" without waiting for it to end.
29The six observables are NOT available mid-run -- they need the full state, and
30the callback carries only the solver's own diagnostic scalars -- so the live
31view reports position (dose reached of target, elapsed, step) and the final
32record carries the physics. Saying otherwise would be inventing numbers.
33
34MULTI-MACHINE. Runs are claimed through `gitsync.CampaignSync`: the git remote
35is the lock, one file per run, push races resolved by retry. After every run
36the result is committed and pushed, so any machine (or the user, anywhere) sees
37it immediately. Local output directories stay local -- `output/` is gitignored
38apart from the pinned reference -- so what travels is the compact record:
39provenance.md, summary.csv, diagnostics.txt and the observables JSON.
40"""
41from __future__ import annotations
42
43import argparse
44import io
45import json
46import os
47import sys
48import threading
49import time
50import traceback
51from pathlib import Path
52
53HERE = Path(__file__).resolve().parent
54DT = HERE.parent # digital_twin/
55MOD = DT.parent # radcluster_code/
56REPO = MOD.parent
57for p in (str(REPO), str(DT)):
58 if p not in sys.path:
59 sys.path.insert(0, p)
60
61import runs as manifest_mod # noqa: E402
62from gitsync import CampaignSync, machine_id # noqa: E402
63
64LOG_DIR = HERE / "logs"
65
66
67# ── live progress log ────────────────────────────────────────────────────────
69 """Append-only event log plus a rendered status snapshot.
70
71 Both are local and gitignored: they update per solver output step, which is
72 far too often to commit. What reaches the remote is the per-run record,
73 pushed once the run ends.
74 """
75
76 def __init__(self, machine: str, run_id: str, entry: dict):
77 LOG_DIR.mkdir(parents=True, exist_ok=True)
78 self.jsonl = LOG_DIR / f"{machine}.jsonl"
79 # PER-RUN status file, not per-machine. A single <machine>.status is
80 # overwritten by whatever ran most recently -- a smoke test clobbered a
81 # live T4_D status the moment it was introduced, and two concurrent runs
82 # on one machine would fight over it continuously. `tail -f *.status`
83 # and the --status view both glob, so nothing is lost by splitting.
84 self.status = LOG_DIR / f"{machine}.{run_id}.status"
85 self.machine, self.run_id, self.entry = machine, run_id, entry
86 self.t0 = time.time()
87 self.G = None
88 self.last = {}
89 self.n_steps = 0
90 self._t_step = time.time()
91 self._stop = threading.Event()
92 self._beat = None
93
94 # HEARTBEAT. render() otherwise fires only on a solver output step, and the
95 # steps are log-spaced: T4_D reached step 18 in 4 s and then spent a long
96 # time on step 19. A status file frozen at "elapsed 0.1 min" is
97 # indistinguishable from a dead process at exactly the moment the user most
98 # wants to know the difference -- which defeats the point of a live log.
99 # A daemon thread re-renders on a timer, so `elapsed` and `updated` keep
100 # moving while `steps` stands still: working-but-slow now looks different
101 # from stopped.
102 def start_heartbeat(self, period_s=30.0, sim=None, ckpt_dir=None):
103 self.sim, self.ckpt_dir, self._ckpt_n = sim, ckpt_dir, 0
104 def _loop():
105 while not self._stop.wait(period_s):
106 try:
107 self.render(state="running")
108 except Exception:
109 pass # a status file must never kill a run
110 try:
111 self.checkpoint()
112 except Exception as exc:
113 self.event("checkpoint_error", error=f"{type(exc).__name__}: {exc}"[:200])
114 self._beat = threading.Thread(target=_loop, daemon=True)
115 self._beat.start()
116
117 # CHECKPOINTING. _save_output runs only when a run ENDS, so an 11 h run
118 # holds nothing on disk until it finishes and a hard kill loses all of it
119 # (measured: a 24 min discrete arm, killed, left no artifact at all).
120 #
121 # run_adaptive republishes `sim._accumulated_results` after every segment
122 # merge, and that rebind is atomic -- this thread sees either the previous
123 # dict or the new one, never a half-built one -- so a concurrent reader can
124 # snapshot it safely without any change to simulation.py.
125 #
126 # Granularity is the segment, not the output step: points_per_segment stays
127 # at its default because it also sets the operator-splitting cadence that
128 # refreshes rho_net for LOOP_NETWORK_LOSS. Lowering it for finer
129 # checkpoints would change the physics and make the run incomparable with
130 # the rest of the table -- a checkpoint must never do that.
131 def checkpoint(self):
132 if self.sim is None or self.ckpt_dir is None:
133 return
134 acc = getattr(self.sim, "_accumulated_results", None)
135 if not acc or "t" not in acc:
136 return
137 n = len(acc["t"])
138 if n <= self._ckpt_n:
139 return # no new segment since last time
140 import numpy as np
141 d = Path(self.ckpt_dir)
142 d.mkdir(parents=True, exist_ok=True)
143 G = self.G or 0.0
144 for name, key in (("results_t.npy", "t"), ("results_y.npy", "y"),
145 ("y_sia100_raw.npy", "y_sia100_raw")):
146 v = acc.get(key)
147 if v is None:
148 continue
149 # NOTE the file handle: np.save(path) APPENDS .npy when the name
150 # does not already end in it, so a "<name>.npy.tmp" target silently
151 # became "<name>.npy.tmp.npy" and the rename below raised
152 # FileNotFoundError -- every checkpoint of every run would have
153 # thrown. Writing through an open handle bypasses the mangling.
154 tmp = d / (name + ".tmp")
155 with open(tmp, "wb") as fh:
156 np.save(fh, np.asarray(v))
157 os.replace(tmp, d / name) # atomic: never a torn .npy
158 meta = {"run_id": self.run_id, "machine": self.machine,
159 "n_points": n, "steps_seen": self.n_steps,
160 "t_s": float(acc["t"][-1]), "dose": float(acc["t"][-1] * G),
161 "elapsed_s": round(time.time() - self.t0, 1),
162 "written_at": time.strftime("%Y-%m-%dT%H:%M:%S"),
163 "note": "PARTIAL trajectory from a run still in progress; "
164 "post-process with post_process.calculate_derived_"
165 "quantities. Superseded by the run's output/ directory "
166 "once it completes."}
167 tmp = d / "checkpoint.json.tmp"
168 tmp.write_text(json.dumps(meta, indent=2) + "\n", encoding="utf-8")
169 os.replace(tmp, d / "checkpoint.json")
170 self._ckpt_n = n
171 self.event("checkpoint", n_points=n, dose=meta["dose"])
172
173 def stop_heartbeat(self):
174 self._stop.set()
175 if self._beat is not None:
176 self._beat.join(timeout=2.0)
177
178 def event(self, kind, **kw):
179 rec = {"ts": time.strftime("%Y-%m-%dT%H:%M:%S"), "machine": self.machine,
180 "run_id": self.run_id, "event": kind,
181 "elapsed_s": round(time.time() - self.t0, 1), **kw}
182 # Append by PATH and fsync, never through a held handle: the campaign
183 # runs for days and anything that replaces the file underneath would
184 # otherwise leave us writing into an unlinked inode (the failure
185 # run_ensemble.py documents at length).
186 with self.jsonl.open("a", encoding="utf-8") as fh:
187 fh.write(json.dumps(rec, default=str) + "\n")
188 fh.flush()
189 os.fsync(fh.fileno())
190 return rec
191
192 def tick(self, row: dict):
193 """One C++ solver output step."""
194 self.n_steps += 1
195 t = float(row.get("t", 0.0) or 0.0)
196 dose = t * self.G if self.G else None
197 self._t_step = time.time()
198 self.last = {"t_s": t, "dose": dose, "step": self.n_steps}
199 self.render(state="running")
200 # The event log gets every step; the rendered status is what a human
201 # tails. 37 steps per run makes this cheap.
202 self.event("tick", t_s=t, dose=dose, step=self.n_steps)
203
204 def render(self, state, extra=""):
205 e = self.entry
206 el = time.time() - self.t0
207 tgt = float(e.get("dose", 0) or 0)
208 dose = self.last.get("dose")
209 frac = (dose / tgt * 100.0) if (dose and tgt) else 0.0
210 bar_w = 34
211 filled = max(0, min(bar_w, int(bar_w * frac / 100.0)))
212 bar = "#" * filled + "." * (bar_w - filled)
213 lines = [
214 f"machine : {self.machine}",
215 f"run : {self.run_id} ({e.get('table')} / {e.get('label')})",
216 f"state : {state}",
217 f"grid : {e.get('equations')} I={e.get('I')} V={e.get('V')} "
218 f"i_d={e.get('i_discrete', '-')} I_bin={e.get('I_bin', '-')} "
219 f"v_d={e.get('v_discrete', '-')} V_bin={e.get('V_bin', '-')} "
220 f"shape={e.get('shape_function')}",
221 f"dose : [{bar}] "
222 + (f"{dose:.4g} / {tgt:g} dpa ({frac:.1f}%)" if dose else
223 f"-- / {tgt:g} dpa"),
224 # Not "of n_points": run_adaptive drives the solver in segments and
225 # the callback fires per output step of EACH, so the running total
226 # legitimately exceeds the grid size (it read "9 of 8" the first
227 # time). Dose is the honest progress measure; this is a liveness
228 # indicator -- if it stops advancing, the solver is stuck.
229 f"steps : {self.n_steps} solver output steps"
230 f" (grid: {e.get('n_points')} points)",
231 f"step age : {time.time() - self._t_step:.0f} s since the last step"
232 f"{' <-- long step, still working' if time.time() - self._t_step > 300 else ''}",
233 f"elapsed : {el/60:.1f} min",
234 f"updated : {time.strftime('%Y-%m-%d %H:%M:%S')}",
235 ]
236 if extra:
237 lines.append("")
238 lines.append(extra)
239 tmp = self.status.with_suffix(".tmp")
240 tmp.write_text("\n".join(lines) + "\n", encoding="utf-8")
241 os.replace(tmp, self.status) # atomic: a tailer never sees a partial
242
243
244# ── building and running one manifest entry ──────────────────────────────────
245def build_sim(e: dict):
246 """A RadClusterSimulation configured from one manifest entry."""
247 from radcluster_code.py_utils.simulation import RadClusterSimulation
248 sim = RadClusterSimulation(
249 I=e["I"], V=e["V"], solver_mode=e["solver_mode"],
250 equations=e["equations"], cascade=e["cascade"],
251 he_kinetics=e["he_kinetics"],
252 i_mobile=e["i_mobile"], v_mobile=e["v_mobile"])
253 # Bin layout through run_ensemble's own helper, so the workbook key names
254 # live in exactly one place. It MUST run after the constructor and before
255 # _calculate_derived()/rebuild_rates(): passing i_discrete= to the
256 # constructor raises, and before 2026-08-02 was silently dropped, which is
257 # how nine bit-identical "refinement" runs got produced.
258 import run_ensemble as _re
259 _re.apply_bin_config(sim, {
260 "equations": e["equations"],
261 "i_discrete": e.get("i_discrete", 0), "v_discrete": e.get("v_discrete", 0),
262 "I_bin": e.get("I_bin", 0), "V_bin": e.get("V_bin", 0),
263 "shape_function": e["shape_function"]})
264 sim.input_data.reactions["LOOP_NETWORK_LOSS"] = int(e.get("lnl", 1))
265 # LOOP_COAL is per-run so a rung can be scored against a discrete arm that
266 # lacks the channel. Default 1 keeps every existing entry unchanged.
267 sim.input_data.reactions["LOOP_COAL"] = int(e.get("loop_coal", 1))
268 sim.input_data._calculate_derived()
269 sim.rebuild_rates()
270 return sim
271
272
273def solver_config(e: dict, sim) -> dict:
274 G = float(sim.input_data.reactions["G"])
275 # The doses this row will be SCORED at must exist in the output, or the
276 # ladder falls back to the nearest point below and the table reports a dose
277 # the run was never evaluated at (plan S3.4.1). Passing them as required
278 # times makes each one a segment boundary. Every column of a table shares
279 # `dose_read`, so every column still gets an identical grid (plan S3.2).
280 reads = e.get("dose_read") or ()
281 reads = reads if isinstance(reads, (list, tuple)) else [reads]
282 return {
283 "t_span": (1e-6, float(e["dose"]) / G),
284 "n_points": int(e["n_points"]),
285 "log_time": True,
286 "required_times": tuple(float(r) / G for r in reads),
287 "rtol": float(e["rtol"]), "atol": float(e["atol"]),
288 "timeout_s": float(e.get("timeout_s", 86400)),
289 "solver_method": _solver_method(e),
290 "loop_conversion": 1,
291 }
292
293
294def _solver_method(e: dict) -> dict:
295 """The linear-solver block, with an OPTIONAL preconditioner bandwidth.
296
297 cpp_bridge derives prec_bw as max(2*i_mobile, 2*v_mobile) + 1, which is the
298 right structural width when the mobile species set the Jacobian band. At
299 i_mobile = v_mobile = 1 that gives 3, and measured 2026-09-09 on the
300 monomer-mobility campaign (I = V = 1000, discrete, to 0.05 dpa):
301
302 prec_bw 3 (auto) 100 s
303 prec_bw 11 65 s
304 prec_bw 51 139 s
305
306 -- so the auto value is too narrow there and 51 is past the point where the
307 band factorisation costs more than it saves. Carried as a per-entry key
308 rather than a change to the default, because the default is correct for
309 every run in Tables 1-5 and this is a 1.5x effect, not a defect.
310 """
311 m = {"linsol": "gmres",
312 "preconditioner": e.get("preconditioner", "woodbury"),
313 "concentration_threshold": 1e-22}
314 if e.get("prec_bw"):
315 m["prec_bw"] = int(e["prec_bw"])
316 return m
317
318
319def execute(e: dict, log: ProgressLog, save_plots=True) -> dict:
320 """Run one entry to completion. Returns the record to publish."""
321 import run_ensemble as re_mod
322
323 rec = {"run_id": e["run_id"], "table": e["table"], "label": e["label"],
324 "machine": log.machine}
325 t0 = time.time()
326 if e.get("omp_threads"):
327 os.environ["OMP_NUM_THREADS"] = str(e["omp_threads"])
328
329 sim = build_sim(e)
330 sim.run_tag = e["run_id"] # -> output/<stamp>_<run_id>_<...>/
331 G = float(sim.input_data.reactions["G"])
332 log.G = G
333 scfg = solver_config(e, sim)
334
335 cfg = {"I": e["I"], "V": e["V"], "dose": float(e["dose"]),
336 "C_floor": float(sim.input_data.reactions.get("C_floor", 1e-15)),
337 "equations": e["equations"],
338 "i_discrete": e.get("i_discrete", 0), "I_bin": e.get("I_bin", 0),
339 "v_discrete": e.get("v_discrete", 0), "V_bin": e.get("V_bin", 0),
340 "shape_function": e["shape_function"]}
341
342 # Realised bin layout, read back off rate_equations -- plan S3.3 requires
343 # the tables to report this, not the requested values. It also RAISES on a
344 # silent fallback to the coarse default, which is the failure this study
345 # cannot afford to discover after the fact.
346 if e["equations"] == "bin_moment":
347 rec.update(re_mod.bin_layout(sim, cfg))
348 else:
349 # bin_layout() returns {} in discrete mode, so N_eq was never recorded
350 # and every discrete row carried a blank in the size column -- which is
351 # how the exact arm's 8006 got reported as 28006, the value from the
352 # abandoned I=8000/V=20000 domain. A results table with a hole in it
353 # invites exactly that. Record it from the rate equations directly.
354 _re_obj = getattr(sim, "rate_equations", None)
355 rec["N_eq"] = int(getattr(_re_obj, "N_eq", -1)) if _re_obj else -1
356 rec["bin_shape"] = "discrete"
357
358 log.render(state="starting")
359 ckpt = MOD / "output" / f"CKPT_{e['run_id']}"
360 log.start_heartbeat(sim=sim, ckpt_dir=ckpt)
361 log.event("start", **{k: e.get(k) for k in
362 ("table", "label", "equations", "I", "V", "dose",
363 "i_discrete", "I_bin", "v_discrete", "V_bin",
364 "shape_function", "rtol", "preconditioner",
365 "he_kinetics", "cascade", "omp_threads")})
366
367 # The solver's own chatter goes to a buffer; the progress callback is what
368 # reaches the log. Without this the terminal is unusable for a long loop.
369 buf = io.StringIO()
370 saved = sys.stdout, sys.stderr
371 try:
372 sys.stdout = sys.stderr = buf
373 if int(e.get("lnl", 1)):
374 # LOOP_NETWORK_LOSS is DEAD under plain run(): v_net is built from
375 # the segment-frozen monomers, which only run_adaptive writes.
376 # max_doublings=0 takes the operator splitting and refuses the
377 # domain growth, which would otherwise leave rungs of one table on
378 # different grids.
379 res = sim.run_adaptive(solver_config=scfg, save_output=save_plots,
380 progress_callback=log.tick,
381 timeout_s=scfg["timeout_s"], max_doublings=0)
382 else:
383 res = sim.run(solver_config=scfg, save_output=save_plots,
384 progress_callback=log.tick)
385 finally:
386 sys.stdout, sys.stderr = saved
387 log.stop_heartbeat()
388
389 if res is None:
390 rec.update({"status": "failed", "error": "solver returned None"})
391 rec["wall_s"] = round(time.time() - t0, 1)
392 return rec
393
394 # Six observables through the campaign's own operator, so a verification
395 # table and a ledger row for the same configuration agree by construction.
396 rec.update(re_mod.observe(res, sim, cfg, 1.0))
397 rec["loop_coal"] = int(e.get("loop_coal", 1))
398 rec["status"] = "done"
399 rec["wall_s"] = round(time.time() - t0, 1)
400 rec["dose_target"] = float(e["dose"])
401
402 # S3.4.1: a run that missed its comparison dose is reported as "did not
403 # reach", never as a metric. Recorded on the row so the reporting step can
404 # enforce it mechanically rather than by discipline.
405 reads = e["dose_read"]
406 reads = reads if isinstance(reads, (list, tuple)) else [reads]
407 lad = rec.get("at_dose") or {}
408 rec["scored"] = {}
409 for target in reads:
410 key = f"{float(target):g}"
411 got = lad.get(key)
412 rec["scored"][key] = got if got else {"missing": True,
413 "dose_reached": rec.get("dose_reached")}
414 # A checkpoint taken more than DOSE_OFF_GRID below the requested dose is
415 # marked off_grid by the observation operator. It must fail this gate for
416 # the same reason a missing one does: plan S3.4.1 forbids reporting a dose
417 # the run did not actually reach as a metric, and "0.643 dpa reported in
418 # the 2 dpa column" is that failure with a number attached rather than a
419 # blank.
420 rec["reached_all_scoring_doses"] = all(
421 not v.get("missing") and not v.get("off_grid")
422 for v in rec["scored"].values())
423
424 out_dir = getattr(sim, "_last_output_dir", None)
425 if out_dir:
426 rec["output_dir"] = str(out_dir)
427 return rec
428
429
430def artifacts_for(rec: dict) -> dict:
431 """The compact record that travels to the remote (not the PNGs, not the
432 38 MB plot_data.pkl -- .gitignore excludes the latter for good reasons)."""
433 art = {"observables.json": json.dumps(rec, indent=2, sort_keys=True,
434 default=str) + "\n"}
435 od = rec.get("output_dir")
436 if od:
437 d = Path(od)
438 for name in ("provenance.md", "summary.csv", "diagnostics.txt"):
439 f = d / name
440 if f.exists():
441 try:
442 art[name] = f.read_text(encoding="utf-8", errors="replace")
443 except Exception:
444 pass
445 return art
446
447
448# ── driver ───────────────────────────────────────────────────────────────────
449def main(argv=None):
450 ap = argparse.ArgumentParser(description=__doc__,
451 formatter_class=argparse.RawDescriptionHelpFormatter)
452 ap.add_argument("--run", help="run this run_id and stop")
453 ap.add_argument("--loop", action="store_true",
454 help="keep claiming runs until the board is empty")
455 ap.add_argument("--status", action="store_true", help="print the board")
456 ap.add_argument("--list", action="store_true", help="print the manifest")
457 ap.add_argument("--release", help="drop a claim so another machine can take it")
458 ap.add_argument("--branch", default="campaign-verification")
459 ap.add_argument("--only", default=None,
460 help="restrict to one or more tables, comma separated, "
461 "e.g. --only T4 or --only T4,T1,T2,T3")
462 ap.add_argument("--offline", action="store_true",
463 help="no fetch/push; claims stay local (smoke tests)")
464 ap.add_argument("--no-plots", action="store_true",
465 help="skip the output directory entirely (diagnostics only)")
466 ap.add_argument("--omp-threads", type=int, default=0,
467 help="override the manifest omp_threads to match this "
468 "machine (0 = use the manifest value). Never applied "
469 "to T5, where thread count is the measured variable.")
470 ap.add_argument("--max-runs", type=int, default=0,
471 help="stop after N runs (0 = no limit)")
472 ap.add_argument("--smoke", action="store_true",
473 help="tiny end-to-end run (seconds) exercising the real "
474 "code path: output directory, live log, claim, sync")
475 a = ap.parse_args(argv)
476
477 entries = {e["run_id"]: e for e in manifest_mod.manifest()}
478 _tables = {t.strip() for t in (a.only or "").split(",") if t.strip()}
479 order = [e["run_id"] for e in manifest_mod.runnable(tables=_tables)]
480
481 if a.smoke:
482 # Deliberately NOT in the manifest: it is a pipeline test, and a study
483 # run_id on the shared board would have to be explained forever after.
484 smoke = dict(manifest_mod.BASE)
485 smoke.update({
486 "run_id": "SMOKE", "table": "--", "label": "pipeline smoke test",
487 "I": 200, "V": 100, "i_discrete": 10, "I_bin": 6,
488 "v_discrete": 5, "V_bin": 6,
489 # 0.01 dpa clears DOSE_CHECKPOINTS[0] = 0.005, so the ladder has a
490 # rung and the six observables are actually exercised. At 1e-3 the
491 # scoring block returned {"missing": True} and every observable
492 # printed nan -- which is correct behaviour, but tests nothing.
493 "dose": 1e-2, "n_points": 10, "dose_read": 0.005,
494 "timeout_s": 600, "omp_threads": 2,
495 })
496 entries["SMOKE"] = smoke
497 order = ["SMOKE"]
498 a.run = "SMOKE"
499 if a.only:
500 want = {t.strip() for t in a.only.split(",") if t.strip()}
501 unknown = want - {e["table"] for e in entries.values()}
502 if unknown:
503 ap.error(f"--only: no such table(s): {','.join(sorted(unknown))}")
504 order = [r for r in order if entries[r]["table"] in want]
505
506 # Thread count is hardware, not physics: it moves wall-clock and nothing
507 # else, so a 16-core manifest can be run on 8 cores without touching the
508 # numbers. T5 is the one exception -- there the thread count IS the
509 # measured variable -- so the manifest value stands regardless of the flag.
510 if a.omp_threads:
511 for e in entries.values():
512 if e["table"] != "T5":
513 e["omp_threads"] = a.omp_threads
514
515 if a.list:
516 manifest_mod.__dict__["__name__"] = "__main__"
517 os.system(f"{sys.executable} {HERE/'runs.py'}")
518 return 0
519
520 sync = CampaignSync(REPO, branch=a.branch, offline=a.offline)
521 sync.ensure()
522
523 if a.release:
524 ok = sync.release(a.release)
525 print(f"release {a.release}: {'ok' if ok else 'nothing to release'}")
526 return 0
527
528 if a.status:
529 print_status(sync, entries, order)
530 return 0
531
532 machine = machine_id()
533 done_count = 0
534 while True:
535 rid = a.run if a.run else sync.next_unclaimed(order)
536 if rid is None:
537 print("board empty — every run is claimed or finished.")
538 break
539 if rid not in entries:
540 print(f"unknown run_id {rid!r}", file=sys.stderr)
541 return 2
542 e = entries[rid]
543 # The smoke test never touches the shared board. It is a pipeline
544 # check, and a "SMOKE done" row sitting among the study runs would have
545 # to be explained to every later reader of the board.
546 if a.smoke:
547 print(f"\n=== SMOKE (pipeline test, not published) on {machine} ===",
548 flush=True)
549 log = ProgressLog(machine, "SMOKE", e)
550 rec = execute(e, log, save_plots=not a.no_plots)
551 log.render(state=rec.get("status", "?").upper(), extra=summary_line(rec))
552 print(f" {summary_line(rec)}", flush=True)
553 print(" board not touched (smoke)", flush=True)
554 return 0
555 if not sync.claim(rid, meta={"table": e["table"], "label": e["label"]}):
556 print(f" {rid}: claimed by another machine, skipping")
557 if a.run:
558 return 0
559 continue
560
561 print(f"\n=== {rid} ({e['table']} / {e['label']}) on {machine} ===",
562 flush=True)
563 log = ProgressLog(machine, rid, e)
564 try:
565 rec = execute(e, log, save_plots=not a.no_plots)
566 except BaseException as exc: # a failed run must still be published
567 rec = {"run_id": rid, "table": e["table"], "label": e["label"],
568 "status": "failed", "machine": machine,
569 "error": f"{type(exc).__name__}: {exc}"[:300],
570 "traceback_tail": traceback.format_exc()[-800:]}
571 log.event("error", error=rec["error"])
572 log.render(state="FAILED", extra=rec["error"])
573 if isinstance(exc, KeyboardInterrupt):
574 sync.publish(rid, rec, artifacts_for(rec))
575 raise
576
577 log.event("end", status=rec.get("status"),
578 wall_s=rec.get("wall_s"),
579 dose_reached=rec.get("dose_reached"))
580 log.render(state=rec.get("status", "?").upper(), extra=summary_line(rec))
581 print(f" {summary_line(rec)}", flush=True)
582
583 # AUTOMATIC SYNC AFTER EVERY RUN, success or failure.
584 pushed = sync.publish(rid, rec, artifacts_for(rec))
585 print(f" synced to {a.branch}: {'yes' if pushed else 'NO (will retry next run)'}",
586 flush=True)
587
588 done_count += 1
589 if a.run or (a.max_runs and done_count >= a.max_runs) or not a.loop:
590 break
591 return 0
592
593
594def summary_line(rec: dict) -> str:
595 if rec.get("status") != "done":
596 return f"{rec.get('status', '?')}: {rec.get('error', '')}"[:200]
597 # NAME THE DOSE. This used to take the first non-missing entry in dict
598 # order and print its d_100 unlabelled -- so the same run showed
599 # d100 = 5.89 in a worker log and 5.31 on the board, because the dicts were
600 # built in different orders and those are the 2 dpa and 20 dpa values of one
601 # trajectory. Nothing was wrong with the data; the line simply did not say
602 # what it was showing. Sort, take the HIGHEST dose reached, and label it.
603 sc = (rec.get("scored") or {})
604 ok = sorted(((float(k), v) for k, v in sc.items() if not v.get("missing")),
605 key=lambda kv: kv[0])
606 at, first = (ok[-1] if ok else (float("nan"), {}))
607 return (f"dose {rec.get('dose_reached', 0):.3g}/{rec.get('dose_target', 0):g} dpa "
608 f"N_eq={rec.get('N_eq', '-')} @{at:g}dpa "
609 f"d100={first.get('d_100_nm', float('nan')):.3g} "
610 f"d111={first.get('d_111_nm', float('nan')):.3g} "
611 f"dcav={first.get('d_cavity_nm', float('nan')):.3g} nm "
612 f"dFP={rec.get('delta_FP', float('nan')):.2g} "
613 f"{rec.get('wall_s', 0)/60:.1f} min")
614
615
616def print_status(sync, entries, order):
617 held = sync.claims()
618 print(f"\ncampaign board — branch {sync.branch} "
619 f"(this machine: {sync.machine})\n")
620 hdr = f"{'run_id':16s} {'tbl':4s} {'state':9s} {'machine':12s} {'result':52s}"
621 print(hdr); print("-" * len(hdr))
622 n_done = n_run = n_free = 0
623 for rid in order:
624 c = held.get(rid)
625 if not c:
626 n_free += 1
627 print(f"{rid:16s} {entries[rid]['table']:4s} {'free':9s} {'-':12s}")
628 continue
629 st = c.get("status", "?")
630 n_done += st == "done"
631 n_run += st == "running"
632 print(f"{rid:16s} {entries[rid]['table']:4s} {st:9s} "
633 f"{str(c.get('machine', '-'))[:12]:12s} {summary_line(c)[:52]}")
634 print(f"\n{n_done} done, {n_run} running, {n_free} free, "
635 f"of {len(order)} runnable\n")
636 for f in sorted(LOG_DIR.glob("*.status")):
637 print(f"--- live: {f.name} " + "-" * 30)
638 print(f.read_text(encoding="utf-8"))
639
640
641if __name__ == "__main__":
642 sys.exit(main())
__init__(self, str machine, str run_id, dict entry)
Definition campaign.py:76
event(self, kind, **kw)
Definition campaign.py:178
start_heartbeat(self, period_s=30.0, sim=None, ckpt_dir=None)
Definition campaign.py:102
tick(self, dict row)
Definition campaign.py:192
render(self, state, extra="")
Definition campaign.py:204
dict artifacts_for(dict rec)
Definition campaign.py:430
dict execute(dict e, ProgressLog log, save_plots=True)
Definition campaign.py:319
main(argv=None)
Definition campaign.py:449
build_sim(dict e)
Definition campaign.py:245
dict _solver_method(dict e)
Definition campaign.py:294
str summary_line(dict rec)
Definition campaign.py:594
print_status(sync, entries, order)
Definition campaign.py:616
dict solver_config(dict e, sim)
Definition campaign.py:273