496 row, spec, cond, cfg = args
498 rec = {
"row_id": int(row[
"row_id"]),
"cond_row_id": int(row[
"cond_row_id"]),
499 "matrix": row[
"matrix"],
"base_idx": int(row[
"base_idx"]),
500 "param_j": int(row[
"param_j"]),
"condition": row[
"condition"]}
502 theta_keys = sorted(k
for k
in row
503 if k
not in (
"row_id",
"cond_row_id",
"matrix",
504 "base_idx",
"param_j",
"condition"))
505 rec[
"theta_hash"] = hashlib.sha256(
506 json.dumps({k: row[k]
for k
in theta_keys}, sort_keys=
True).encode()
510 from radcluster_code.py_utils.simulation
import RadClusterSimulation
511 i_mob = int(row.get(
"i_mobile", cfg[
"i_mobile_default"]))
512 v_mob = int(row.get(
"v_mobile", cfg[
"v_mobile_default"]))
513 _s = sys.stdout, sys.stderr
516 sys.stdout = sys.stderr = buf
517 sim = RadClusterSimulation(
518 I=cfg[
"I"], V=cfg[
"V"], solver_mode=cfg[
"solver_mode"],
519 equations=cfg[
"equations"], cascade=cond.get(
"cascade",
"fission"),
520 C_floor=cfg[
"C_floor"], he_kinetics=
"quasi_steady_state",
521 i_mobile=i_mob, v_mobile=v_mob)
524 sim.input_data._calculate_derived()
527 G = float(sim.input_data.reactions[
"G"])
539 dose_target = float(cond.get(
"dose_dpa", cfg[
"dose"]))
540 rec[
"dose_target"] = dose_target
541 scfg = {
"t_span": (1e-6, dose_target / G),
"n_points": cfg[
"n_points"],
542 "log_time":
True,
"rtol": cfg[
"rtol"],
"atol": cfg[
"atol"],
543 "timeout_s": cfg[
"timeout_s"],
544 "solver_method": {
"linsol":
"gmres",
545 "preconditioner": cfg[
"preconditioner"],
546 "concentration_threshold": 1e-22},
547 "loop_conversion": 1}
567 _lnl = int(float(sim.input_data.reactions.get(
568 "LOOP_NETWORK_LOSS", 0)
or 0))
569 except (TypeError, ValueError):
571 if cfg.get(
"lnl")
is not None:
572 _lnl = int(cfg[
"lnl"])
573 sim.input_data.reactions[
"LOOP_NETWORK_LOSS"] = _lnl
577 res = sim.run_adaptive(solver_config=scfg, save_output=
False,
578 timeout_s=cfg[
"timeout_s"],
581 res = sim.run(solver_config=scfg, save_output=
False)
583 sys.stdout, sys.stderr = _s
585 rec.update(
observe(res, sim, cfg, float(row.get(
"d_min_tem", 1.0))))
598 _rr = sim.reaction_rates
599 _L = _np.asarray(getattr(_rr,
"Lambda_net_111", []), dtype=float)
600 _d = _np.asarray(getattr(_rr,
"d_loop_111", []), dtype=float)
601 rec[
"rho_net_end"] = float(getattr(_rr,
"rho_net", float(
"nan")))
602 rec[
"rho_net_floor"] = float(sim.input_data.reactions.get(
"rho_d", 0.0))
603 rec[
"Lambda_net_max"] = float(_L.max())
if _L.size
else 0.0
604 _nz = _np.nonzero(_L)[0]
605 rec[
"d_lam_on_nm"] = (float(_d[_nz[0]] * 1e9)
606 if _nz.size
and _d.size > _nz[0]
else None)
607 except Exception
as _e:
608 rec[
"lnl_diag_error"] = f
"{type(_e).__name__}: {_e}"[:120]
609 rec[
"n_written"] = len(written)
610 except Exception
as exc:
612 rec[
"error"] = f
"{type(exc).__name__}: {exc}"[:300]
613 rec[
"traceback_tail"] = traceback.format_exc()[-500:]
614 rec[
"admissible"] =
False
615 rec[
"wall_s"] = round(time.time() - t0, 1)
620 """Return (c111, c_v, binned, topbin) at time index `idx`, per size.
622 `idx` defaults to -1 (the final time), which is every legacy call site. It
623 is a parameter because the CHARACTER has to be readable at a dose-ladder
624 checkpoint, not only at whatever dose a row happened to stop at: f_100 moves
625 strongly with dose (<100> is sessile and one-way, so it accumulates), and
626 two rows of the SAME theta that timed out at different doses cannot be
627 compared on it. Measured 2026-08-13 on T9 batch 1: theta 4's two ends
628 stopped at 15.000 and 0.106 dpa, theta 0's at 2.813 and 0.020 -- a 140x dose
629 ratio masquerading as a temperature effect.
631 One extraction that works in both modes, so every downstream gate is
632 computed the same way and is comparable across the discrete/bin_moment
633 bridge (plan S11(e)-2).
635 discrete : `y` is (I + V + extras, nt) -- plain slices.
636 bin_moment : `y` is (N_eq, nt) with the layout
637 [0 : i_d] discrete SIA
638 [i_d : i_d + P*I_bin] SIA bin moments, P per bin
639 [i_VAC : i_VAC + v_d] discrete vacancies
640 [i_VAC+v_d : ... + P*V_bin] vacancy bin moments
641 mirroring RadClusterSimulation._size_distributions.
643 `topbin` maps axis -> content fraction in the top bin, computed from the RAW
644 mu1 moments (not the reconstruction). Empty dict in discrete mode.
646 Returns c111 / c_v as None when an axis genuinely cannot be extracted; the
647 caller treats None as FAILING the gate, never as passing.
649 I, V = int(cfg[
"I"]), int(cfg[
"V"])
653 return None,
None,
False, topbin
656 return None,
None,
False, topbin
657 yj = np.maximum(y[:, idx], 0.0)
659 re_obj = getattr(sim,
"rate_equations",
None)
660 binned = cfg.get(
"equations") ==
"bin_moment" and hasattr(re_obj,
"bins")
663 c111 = yj[:I]
if yj.shape[0] >= I
else None
664 c_v = yj[I:I + V]
if yj.shape[0] >= I + V
else None
665 return c111, c_v,
False, topbin
667 from radcluster_code.py_utils.bin_moment_rates
import reconstruct_distribution
668 P = int(getattr(re_obj,
"n_mom", 2))
669 sf = str(getattr(re_obj,
"shape_function",
"linear"))
670 i_d = int(getattr(re_obj,
"i_discrete", 0))
671 v_d = int(getattr(re_obj,
"v_discrete", 0))
672 I_bin = int(getattr(re_obj,
"I_bin", 0))
673 V_bin = int(getattr(re_obj,
"V_bin", 0))
674 i_vac = int(getattr(re_obj,
"i_VAC", i_d + P * I_bin))
676 def _axis(start, n_bins, n_disc, bins, n_max, disc_start):
677 """Reconstruct one axis and its top-bin content fraction."""
680 c[:n_disc] = yj[disc_start:disc_start + n_disc]
682 mom = yj[start:start + P * n_bins]
683 if mom.size < P * n_bins:
685 mu0 = mom[0::P][:n_bins]
686 mu1 = mom[1::P][:n_bins]
if P >= 2
else None
687 mu2 = mom[2::P][:n_bins]
if P >= 3
else None
688 c = reconstruct_distribution(sf, mu0, mu1, mu2, bins, n_max)
689 c[:n_disc] = yj[disc_start:disc_start + n_disc]
694 tot = float(np.sum(np.maximum(mu1, 0.0)))
695 frac = float(max(mu1[-1], 0.0)) / tot
if tot > 0
else None
697 mid = np.array([(lo + hi - 1) / 2.0
for lo, hi
in bins])
698 w = mid * np.maximum(mu0, 0.0)
700 frac = float(w[-1]) / tot
if tot > 0
else None
703 c111, topbin[
"111"] = _axis(i_d, I_bin, i_d, getattr(re_obj,
"bins", []),
705 c_v, topbin[
"v"] = _axis(i_vac + v_d, V_bin, v_d,
706 getattr(re_obj,
"vac_bins", []), V, i_vac)
710 y100 = res.get(
"y_sia100")
712 y100 = np.asarray(y100)
713 if y100.ndim == 2
and y100.shape[0] >= I
and I_bin > 0:
714 c100 = np.maximum(y100[:I, -1], 0.0)
715 n_ax = np.arange(1, I + 1)
717 tot = float(k100.sum())
719 lo, hi = getattr(re_obj,
"bins", [(I, I + 1)])[-1]
720 topbin[
"100"] = float(k100[lo - 1:min(hi - 1, I)].sum()) / tot
721 return c111, c_v,
True, topbin
725 """Observation operator + the admissibility block."""
729 Om = float(d.derived[
"Omega"])
730 b100 = float(d.energetics.get(
"b_100", 0.2867)) * 1e-9
732 t = np.asarray(res[
"t"], float)
736 return np.zeros_like(t)
737 v = np.asarray(res[k], float).ravel()
738 return v
if v.size == t.size
else np.zeros_like(t)
740 G = float(d.reactions[
"G"])
741 out[
"dose_reached"] = float(t[-1] * G)
742 out[
"starved"] = bool(out[
"dose_reached"] < 0.95 * cfg[
"dose"])
743 out[
"delta_FP"] = float(ser(
"delta_FP")[-1])
744 out[
"delta_He"] = float(ser(
"delta_He")[-1])
746 N111, N100 = float(ser(
"N_loops_111")[-1]), float(ser(
"N_loops_100")[-1])
747 n111, n100 = float(ser(
"mean_n_111")[-1]), float(ser(
"mean_n_100")[-1])
748 out[
"N_loops_111"], out[
"N_loops_100"] = N111, N100
749 out[
"mean_n_111"], out[
"mean_n_100"] = n111, n100
750 out[
"d_111_nm"] = float(2*np.sqrt(max(n111, 0)*Om/(np.pi*b111))*1e9)
751 out[
"d_100_nm"] = float(2*np.sqrt(max(n100, 0)*Om/(np.pi*b100))*1e9)
752 out[
"N_voids"] = float(ser(
"N_voids")[-1])
758 nv = float(ser(
"mean_n_v")[-1])
760 out[
"d_cavity_nm"] = float(
761 2.0 * (3.0 * max(nv, 0.0) * Om / (4.0 * np.pi)) ** (1.0 / 3.0) * 1e9)
767 out[
"S_inventory"] = float(ser(
"swelling")[-1])
810 for dck
in DOSE_CHECKPOINTS:
811 if out[
"dose_reached"] < dck * (1.0 - 1e-9):
813 j = int(np.searchsorted(dose_t, dck * (1.0 + DOSE_SNAP),
817 rel = (dck - float(dose_t[j])) / dck
if dck > 0
else 0.0
818 n111c = float(ser(
"mean_n_111")[j])
819 n100c = float(ser(
"mean_n_100")[j])
820 nvc = float(ser(
"mean_n_v")[j])
821 ladder[f
"{dck:g}"] = {
822 "dose": float(dose_t[j]),
823 "dose_rel_err": float(rel),
824 "off_grid": bool(rel > DOSE_OFF_GRID),
825 "N_loops_111": float(ser(
"N_loops_111")[j]),
826 "N_loops_100": float(ser(
"N_loops_100")[j]),
827 "d_111_nm": float(2*np.sqrt(max(n111c, 0)*Om/(np.pi*b111))*1e9),
828 "d_100_nm": float(2*np.sqrt(max(n100c, 0)*Om/(np.pi*b100))*1e9),
829 "N_voids": float(ser(
"N_voids")[j]),
831 "d_cavity_nm": float(
832 2.0*(3.0*max(nvc, 0.0)*Om/(4.0*np.pi))**(1.0/3.0)*1e9),
833 "delta_FP": float(ser(
"delta_FP")[j]),
852 _y100 = res.get(
"y_sia100")
853 if _c111j
is not None and _y100
is not None:
854 _y100 = np.asarray(_y100)
855 if _y100.ndim == 2
and _y100.shape[0] >= I
and _y100.shape[1] > j:
856 _c111j = np.maximum(_c111j[:I] - cfg[
"C_floor"], 0.0)
857 _c100j = np.maximum(_y100[:I, j], 0.0)
858 _nax = np.arange(1, I + 1)
859 _d100 = 2*np.sqrt(_nax*Om/(np.pi*b100))*1e9
860 _d111 = 2*np.sqrt(_nax*Om/(np.pi*b111))*1e9
861 _a0 = float(_c100j[_d100 >= d_min_nm].sum())
862 _a1 = float(_c111j[_d111 >= d_min_nm].sum())
863 ladder[f
"{dck:g}"][
"N_100_vis"] = _a0 / Om
864 ladder[f
"{dck:g}"][
"N_111_vis"] = _a1 / Om
865 ladder[f
"{dck:g}"][
"f_100_tem"] = (
866 _a0 / (_a0 + _a1))
if (_a0 + _a1) > 0
else None
867 except Exception
as _e:
868 ladder[f
"{dck:g}"][
"f_100_err"] = f
"{type(_e).__name__}"[:40]
869 out[
"at_dose"] = ladder
or None
872 out[
"occ_111"] = n111 / I
873 out[
"occ_100"] = n100 / I
874 d_ceil = float(2*np.sqrt(I*Om/(np.pi*b100))*1e9)
875 out[
"d_ceiling_100_nm"] = d_ceil
876 out[
"d_over_ceiling_100"] = out[
"d_100_nm"] / d_ceil
if d_ceil > 0
else None
885 y100 = res.get(
"y_sia100")
886 pile111 = pile100 =
None
887 f_num = f_cont = f_tem =
None
889 n_ax = np.arange(1, I + 1)
890 top = int((1 - PILE_TOP_FRAC) * I)
891 c111 = np.maximum(c111 - cfg[
"C_floor"], 0.0)
894 pile111 = float(k111[top:].sum() / k111.sum())
896 y100 = np.asarray(y100)
900 if y100.ndim == 2
and y100.shape[0] >= I:
908 c100 = np.maximum(y100[:I, -1], 0.0)
911 pile100 = float(k100[top:].sum() / k100.sum())
913 s0, s1 = float(c100.sum()), float(c111.sum())
914 f_num = s0 / (s0 + s1)
if (s0 + s1) > 0
else None
915 q0, q1 = float(k100.sum()), float(k111.sum())
916 f_cont = q0 / (q0 + q1)
if (q0 + q1) > 0
else None
919 dd100 = 2*np.sqrt(n_ax*Om/(np.pi*b100))*1e9
920 dd111 = 2*np.sqrt(n_ax*Om/(np.pi*b111))*1e9
948 for dm
in cfg.get(
"d_min_sweep", [0.8, 1.0, 1.25, 1.5]):
949 s0m, s1m = dd100 >= dm, dd111 >= dm
950 a0 = float(c100[s0m].sum())
951 a1 = float(c111[s1m].sum())
952 tag = f
"{dm:g}".replace(
".",
"p")
953 out[f
"f_100_tem_{tag}"] = ((a0 / (a0 + a1))
954 if (a0 + a1) > 0
else None)
955 out[f
"N_100_vis_{tag}"] = a0 / Om
956 out[f
"N_111_vis_{tag}"] = a1 / Om
958 out[f
"d_100_vis_{tag}"] = (
959 float((dd100[s0m] * c100[s0m]).sum() / a0)
961 out[f
"d_111_vis_{tag}"] = (
962 float((dd111[s1m] * c111[s1m]).sum() / a1)
964 m0, m1 = dd100 >= d_min_nm, dd111 >= d_min_nm
965 v0 = float(c100[m0].sum())
966 v1 = float(c111[m1].sum())
967 f_tem = v0 / (v0 + v1)
if (v0 + v1) > 0
else None
968 out[
"N_100_visible"] = v0 / Om
969 out[
"N_111_visible"] = v1 / Om
970 out[
"d_100_visible"] = (float((dd100[m0]*c100[m0]).sum()/v0)
972 out[
"d_111_visible"] = (float((dd111[m1]*c111[m1]).sum()/v1)
974 out[
"pile_111"], out[
"pile_100"] = pile111, pile100
975 out[
"f_100_number"], out[
"f_100_content"] = f_num, f_cont
976 out[
"f_100_tem"], out[
"d_min_tem_nm"] = f_tem, d_min_nm
995 m_ax = np.arange(1, V + 1)
996 c_v = np.maximum(c_v - cfg[
"C_floor"], 0.0)
999 pile_v = float(k_v[int((1 - PILE_TOP_FRAC) * V):].sum() / k_v.sum())
1000 out[
"pile_v"] = pile_v
1020 if c_v
is not None and V > 0:
1021 m_ax = np.arange(1, V + 1)
1022 d_m = 2.0 * (3.0 * m_ax * Om / (4.0 * np.pi)) ** (1.0 / 3.0) * 1e9
1023 cv = np.maximum(c_v, 0.0)
1024 tot_n = float(cv.sum())
1025 tot_k = float((m_ax * cv).sum())
1035 for lo, hi, tag
in ((1.0, np.inf,
"tem"),
1037 (1.0, 10.0,
"1_10"),
1039 (4.0, np.inf,
"void")):
1040 sel = (d_m >= lo) & (d_m < hi)
1041 n_sel = float(cv[sel].sum())
1042 out[f
"N_cav_{tag}"] = n_sel / Om
if n_sel > 0
else 0.0
1043 out[f
"d_cav_{tag}"] = (float((d_m[sel] * cv[sel]).sum() / n_sel)
1044 if n_sel > 0
else None)
1045 out[f
"kfrac_cav_{tag}"] = (float((m_ax[sel] * cv[sel]).sum() / tot_k)
1046 if tot_k > 0
else None)
1052 out[
"d_cav_mode"] = float(d_m[int(np.argmax(kk))])
if kk.sum() > 0
else None
1054 out[
"d_cav_mode"] =
None
1056 out[
"occ_v"] = (out[
"mean_n_v"] / V)
if V > 0
else None
1070 bad_pile = (pile100
is not None and pile100 > PILE_TOL)
or \
1071 (pile111
is not None and pile111 > PILE_TOL)
or \
1072 (pile_v
is not None and pile_v > PILE_TOL)
1079 for axis
in (
"111",
"100",
"v"):
1080 val = topbin.get(axis)
1081 out[f
"topbin_{axis}"] = val
1082 if val
is not None and val > TOPBIN_TOL:
1089 unmeasured = [nm
for nm, val
in ((
"pile_111", pile111), (
"pile_100", pile100),
1090 (
"pile_v", pile_v))
if val
is None]
1092 unmeasured += [f
"topbin_{ax}" for ax
in (
"111",
"100",
"v")
1093 if topbin.get(ax)
is None]
1094 out[
"unmeasured_gates"] = unmeasured
or None
1099 out[
"grid_limited"] = bool(bad_pile
or bad_topbin
or unmeasured)
1100 out[
"grid_converged"] =
not out[
"grid_limited"]
1134 out[
"conserving"] = bool(abs(out[
"delta_FP"]) < DFP_TOL)
1136 if TRUNCATION_GATES:
1137 out[
"admissible"] = bool((
not out[
"starved"])
and (
not out[
"grid_limited"])
1138 and out[
"conserving"])
1140 out[
"admissible"] =
not out[
"starved"]
1146 out[
"admissible"] =
True
1304 ap = argparse.ArgumentParser(description=__doc__,
1305 formatter_class=argparse.RawDescriptionHelpFormatter)
1306 ap.add_argument(
"--design", type=Path, required=
True)
1311 ap.add_argument(
"--machine", default=
"auto",
1312 help=
"this machine's index k, or 'auto' (default) to detect "
1313 "it from machines.json")
1314 ap.add_argument(
"--of", type=int, default=0,
1315 help=
"total machines M; taken from machines.json under --machine auto")
1316 ap.add_argument(
"--registry", type=Path, default=MACHINES_FILE,
1317 help=
"the machine registry (default machines.json)")
1318 ap.add_argument(
"--register", type=int, default=
None, metavar=
"INDEX",
1319 help=
"pin THIS host to a registry index and exit. Use once "
1320 "per new participant, then commit machines.json.")
1325 ap.add_argument(
"--subtask", type=int, default=0,
1326 help=
"this array task's 0-based id within the machine")
1327 ap.add_argument(
"--of-subtasks", type=int, default=1,
1328 help=
"number of array tasks sharing this machine index")
1332 ap.add_argument(
"--workers", type=int, default=
None)
1333 ap.add_argument(
"--weights", default=
None,
1334 help=
"relative capacity of EACH machine, either a comma list "
1335 "('6,14,22,22', normally each machine's --workers) or "
1336 "'@path' to a campaign_layout.json written by "
1337 "campaign_layout.py. Splits the design in proportion to "
1338 "capacity instead of evenly, so heterogeneous machines "
1339 "finish together. MUST be identical on every machine -- "
1340 "prefer '@' form: a 66-entry list retyped on six hosts is "
1341 "a silent row collision waiting to happen, whereas the "
1342 "file is committed once and its hash is recorded per row. "
1343 "Omit for the default even split (row_id %% M).")
1344 ap.add_argument(
"--out", type=Path, default=
None)
1345 ap.add_argument(
"--conditions", type=Path, default=HERE /
"conditions.json")
1351 ap.add_argument(
"--spec", type=Path, default=HERE /
"parameters.json",
1352 help=
"parameter spec JSON (default parameters.json)")
1359 ap.add_argument(
"--lnl", type=int, choices=(0, 1), default=
None,
1360 help=
"force LOOP_NETWORK_LOSS on(1)/off(0); default: workbook")
1370 ap.add_argument(
"--I", type=int, default=10000)
1371 ap.add_argument(
"--V", type=int, default=5000)
1372 ap.add_argument(
"--dose", type=float, default=1.0)
1373 ap.add_argument(
"--equations", default=
"discrete",
1374 choices=[
"discrete",
"bin_moment"])
1381 ap.add_argument(
"--i-discrete", type=int, default=40,
1382 help=
"max individually-tracked SIA size; must be >= i_mobile")
1383 ap.add_argument(
"--v-discrete", type=int, default=5,
1384 help=
"max individually-tracked vacancy size; must be >= v_mobile")
1385 ap.add_argument(
"--i-bin", type=int, default=20,
1386 help=
"target SIA bin count; r=(I/i_discrete)^(1/I_bin) is DERIVED")
1387 ap.add_argument(
"--v-bin", type=int, default=20,
1388 help=
"target vacancy bin count; r derived the same way")
1389 ap.add_argument(
"--shape-function", default=
"linear",
1390 choices=[
"constant",
"linear",
"lognormal"],
1391 help=
"intra-bin closure: P=1/2/3 moments per bin")
1401 ap.add_argument(
"--i-mobile-default", type=int, default=40)
1402 ap.add_argument(
"--v-mobile-default", type=int, default=5)
1403 ap.add_argument(
"--solver-mode", default=
"active_window",
1404 choices=[
"active_window",
"full_system"],
1405 help=
"active_window (default) keeps the ACTIVE system at "
1406 "50-200 unknowns regardless of I. Verified 2026-08-02 to "
1407 "reproduce full_system to printed precision at I=1600 "
1408 "(d100/occ/pile/N100 all identical) at 2.4x lower cost "
1409 "and 3 orders better delta_FP. Preconditioner follows "
1410 "this choice automatically.")
1411 ap.add_argument(
"--rtol", type=float, default=1e-6)
1424 ap.add_argument(
"--timeout-s", type=float, default=
None)
1428 ap.add_argument(
"--omp-threads", type=int, default=
None,
1429 help=
"OpenMP threads INSIDE each row's solver (active_window "
1430 "mode only). Trades rows-in-flight for per-row speed; "
1431 "cannot change results (no reductions in the kernels).")
1432 ap.add_argument(
"--limit", type=int, default=0, help=
"run only the first n rows (smoke test)")
1433 ap.add_argument(
"--stop-after-s", type=float, default=0.0,
1434 help=
"stop SUBMITTING new rows after this many seconds and exit "
1435 "cleanly (0 = no limit). For batch schedulers: set it to "
1436 "the job's walltime minus one row's p99, so the worker "
1437 "parks itself before the scheduler SIGKILLs it mid-row. "
1438 "Rows already running are allowed to finish and are "
1439 "written; a resubmission resumes by skipping them.")
1440 ap.add_argument(
"--allow-mixed", action=
"store_true",
1441 help=
"resume even though git/solver/workbook/design changed "
1442 "since the existing rows (see RESTART SAFETY)")
1443 a = ap.parse_args(argv)
1446 if a.register
is not None:
1450 if str(a.machine).lower() ==
"auto":
1453 a.machine = int(detected[
"index"])
1454 a.of = a.of
or int(reg[
"of"])
1455 if a.weights
is None:
1456 a.weights =
"@" + str(a.registry)
1457 if a.of_subtasks == 1
and detected.get(
"subtasks"):
1458 a.of_subtasks = int(detected[
"subtasks"])
1462 if a.workers
is None and detected.get(
"slots")
is not None:
1463 a.workers = int(detected[
"slots"])
1464 if a.of_subtasks > 1:
1465 a.workers = max(1, a.workers // a.of_subtasks)
1474 if a.timeout_s
is None:
1475 _to = detected.get(
"timeout_s", reg.get(
"timeout_s"))
1477 a.timeout_s = float(_to)
1481 if a.omp_threads
is None:
1482 a.omp_threads = int(detected.get(
"omp_threads", 1)
or 1)
1483 print(f
" detected machine {a.machine} = {detected['name']} "
1484 f
"({detected.get('speed_source','?')} speed "
1485 f
"{detected.get('speed')}), {a.workers} worker(s)"
1486 + (f
", subtask {a.subtask}/{a.of_subtasks}" if a.of_subtasks > 1
else "")
1487 + f
", {a.omp_threads} OMP thread(s)/row")
1489 a.machine = int(a.machine)
1491 raise SystemExit(
"--of is required when --machine is given explicitly")
1496 if a.timeout_s
is None:
1497 a.timeout_s = 3600.0
1498 if a.omp_threads
is None:
1500 if a.workers
is None:
1501 a.workers = max(1, (os.cpu_count()
or 4) - 2)
1504 os.environ[
"RADCLUSTER_OMP_THREADS"] = str(a.omp_threads)
1505 os.environ[
"OMP_NUM_THREADS"] = str(a.omp_threads)
1513 os.environ[
"RADCLUSTER_ABORT_FILE"] = str(STOP_FILE)
1514 print(f
" row budget {a.timeout_s:.0f} s, "
1515 f
"OMP_NUM_THREADS={os.environ['OMP_NUM_THREADS']}")
1516 if not 0 <= a.subtask < a.of_subtasks:
1517 raise SystemExit(f
"--subtask {a.subtask} outside 0..{a.of_subtasks-1}")
1519 spec = json.loads(a.spec.read_text(encoding=
"utf-8"))
1535 _box = {p[
"key"]: (p[
"lo"], p[
"hi"])
for p
in spec.get(
"parameters", [])
1536 if p.get(
"lo")
is not None and p.get(
"hi")
is not None}
1539 for _k, (_lo, _hi)
in _box.items():
1541 if _v
is None or isinstance(_v, str):
1543 if not (_lo <= float(_v) <= _hi):
1544 _viol.setdefault(_k, {
"box": (_lo, _hi),
"vals": set(),
1546 _viol[_k][
"vals"].add(float(_v))
1547 _viol[_k][
"rows"].append(_r.get(
"row_id"))
1549 print(f
" !! PRIOR-BOX VIOLATIONS in {a.design.name}: "
1550 f
"{len(_viol)} parameter(s)")
1551 for _k, _d
in sorted(_viol.items()):
1552 _vs =
", ".join(f
"{v:g}" for v
in sorted(_d[
"vals"]))
1553 print(f
" {_k}: {_vs} outside [{_d['box'][0]:g}, "
1554 f
"{_d['box'][1]:g}] ({len(_d['rows'])} row(s))")
1555 print(
" These rows are RUN, but a result that depends on them is "
1556 "not a calibration inside the declared prior.")
1557 _prior_viol = {k: {
"box": list(d[
"box"]),
"values": sorted(d[
"vals"]),
1558 "rows": d[
"rows"]}
for k, d
in _viol.items()}
1580 _fixed_vals = {f[
"key"]: f[
"value"]
for f
in spec.get(
"fixed", [])}
1581 _sampled = {p[
"key"]
for p
in spec.get(
"parameters", [])}
1582 _cols = set().union(*(set(r)
for r
in rows))
if rows
else set()
1584 for _k
in sorted(_sampled & set(_fixed_vals)):
1585 _in_design = _k
in _cols
1586 _dual[_k] = {
"fixed_value": _fixed_vals[_k],
1587 "in_design": _in_design,
1588 "in_force":
"design" if _in_design
else "fixed"}
1589 _dual_silent = {k: v
for k, v
in _dual.items()
if not v[
"in_design"]}
1591 print(f
" !! DUAL-DECLARED (sampled AND fixed), design carries neither "
1592 f
"column: {len(_dual_silent)} parameter(s)")
1593 for _k, _d
in sorted(_dual_silent.items()):
1594 _b = next((f
"[{p['lo']:g}, {p['hi']:g}]"
1595 for p
in spec.get(
"parameters", [])
1596 if p[
"key"] == _k
and p.get(
"lo")
is not None),
"?")
1597 print(f
" {_k}: held at FIXED {_d['fixed_value']!r}, "
1598 f
"declared prior {_b} -- NOT calibrated in this run.")
1599 conds = (json.loads(a.conditions.read_text(encoding=
"utf-8"))
1600 if a.conditions.exists()
else
1601 {
"N2": {
"T_K": 573.15},
"N5": {
"T_K": 623.15},
1602 "I1": {
"T_K": 623.15,
"cascade":
"fission"}})
1607 if a.weights.startswith(
"@"):
1608 lay_p = Path(a.weights[1:])
1609 if not lay_p.exists():
1610 raise SystemExit(f
"--weights {a.weights}: {lay_p} does not exist. "
1611 f
"Generate it with campaign_layout.py and commit "
1612 f
"it, so every machine reads the same file.")
1613 lay = json.loads(lay_p.read_text(encoding=
"utf-8"))
1614 a.weights = lay[
"weights"]
1616 if int(lay.get(
"of", 0)) != a.of:
1618 f
"--of {a.of} but {lay_p.name} was built for "
1619 f
"of={lay['of']}. They must agree, or the machines disagree "
1620 f
"about how many participants exist and rows are both "
1621 f
"duplicated and dropped. Re-run campaign_layout.py.")
1622 n_part = len(lay.get(
"participants")
or lay.get(
"machines")
or [])
1623 print(f
" weights from {lay_p.name} (sha {weights_sha}): "
1624 f
"{n_part} participants")
1625 weights = [float(w)
for w
in a.weights.split(
",")]
1626 if len(weights) != a.of:
1627 raise SystemExit(f
"--weights has {len(weights)} entries but --of is "
1628 f
"{a.of}; they must match, and the SAME list must be "
1629 f
"passed on every machine.")
1630 if min(weights) <= 0:
1631 raise SystemExit(
"--weights entries must all be > 0.")
1632 mine = [r
for r
in rows
if assign_machine(r[
"row_id"], a.of, weights) == a.machine]
1638 if a.of_subtasks > 1:
1639 n_before = len(mine)
1640 mine = mine[a.subtask::a.of_subtasks]
1641 print(f
" subtask {a.subtask}/{a.of_subtasks}: {len(mine)} of this "
1642 f
"machine's {n_before} rows")
1644 mine = mine[:a.limit]
1645 _sfx = f
"_machine{a.machine}" + (f
"_t{a.subtask}" if a.of_subtasks > 1
else "")
1646 out = a.out
or (HERE /
"results" / f
"{a.design.stem}{_sfx}.jsonl")
1647 out.parent.mkdir(parents=
True, exist_ok=
True)
1653 for ln
in out.read_text(encoding=
"utf-8").splitlines():
1658 done.add(r[
"row_id"])
1660 todo = [r
for r
in mine
if r[
"row_id"]
not in done]
1666 _build = REPO /
"radcluster_code" /
"build"
1667 for _cand
in (_build /
"Release" /
"solver.exe", _build /
"solver.exe",
1673 solver = _build /
"solver"
1685 precond =
"Woodbury" if a.solver_mode ==
"full_system" else "Jacobi"
1686 run_cfg = {
"I": a.I,
"V": a.V,
"dose": a.dose,
"equations": a.equations,
1687 "rtol": a.rtol,
"atol": 1e-20,
"C_floor": 1e-25,
1688 "solver_mode": a.solver_mode,
"linsol":
"gmres",
1689 "preconditioner": precond,
"loop_conversion": 1,
1690 "i_mobile_default": a.i_mobile_default,
1691 "v_mobile_default": a.v_mobile_default,
1692 "n_points": 40,
"timeout_s": a.timeout_s,
1702 SHA_EXCLUDE = {
"timeout_s"}
1706 if a.equations ==
"bin_moment":
1707 run_cfg.update({
"i_discrete": a.i_discrete,
"v_discrete": a.v_discrete,
1708 "I_bin": a.i_bin,
"V_bin": a.v_bin,
1709 "shape_function": a.shape_function})
1710 if a.i_discrete >= a.I
or a.v_discrete >= a.V:
1712 f
"bin_moment needs i_discrete < I and v_discrete < V; got "
1713 f
"i_discrete={a.i_discrete} I={a.I}, v_discrete={a.v_discrete} "
1714 f
"V={a.V}. Otherwise I_bin collapses to 0 and the run is "
1715 f
"silently fully discrete.")
1719 if a.i_discrete < a.i_mobile_default
or a.v_discrete < a.v_mobile_default:
1721 f
"bin_moment needs i_discrete >= i_mobile and v_discrete >= "
1722 f
"v_mobile; got i_discrete={a.i_discrete} vs i_mobile="
1723 f
"{a.i_mobile_default}, v_discrete={a.v_discrete} vs v_mobile="
1724 f
"{a.v_mobile_default}.")
1730 sampled = sorted({k
for k
in (
"i_mobile",
"v_mobile")
1731 if any(k
in r
for r
in rows)})
1732 if sampled
and not a.allow_mixed:
1734 f
"design carries sampled {'/'.join(sampled)} but --equations "
1735 f
"bin_moment fixes the bin layout from --i-discrete/--v-discrete. "
1736 f
"Rows would not share a discretisation and their indices would "
1737 f
"not be comparable (plan S11(f)).\n"
1738 f
" Use a design with {'/'.join(sampled)} withdrawn from theta, "
1739 f
"or pass --allow-mixed if you have a specific reason.")
1740 run_cfg_sha = hashlib.sha256(json.dumps(
1741 {k: v
for k, v
in run_cfg.items()
if k
not in SHA_EXCLUDE},
1742 sort_keys=
True).encode()).hexdigest()[:16]
1743 prov = {
"git_sha":
git_sha(),
"machine_id": platform.node(),
1746 REPO /
"radcluster_code" /
"input" /
"input_parameters.xlsx")[:16],
1747 "design_sha256": meta.get(
"design_sha256",
"")[:16],
1748 "run_cfg_sha": run_cfg_sha,
1750 "timeout_s": a.timeout_s,
"stop_after_s": a.stop_after_s,
1751 "workers": a.workers,
1756 "omp_threads": a.omp_threads,
1762 "weights_sha": weights_sha,
1772 "weights_map_sha": hashlib.sha256(json.dumps(
1773 {
"of": a.of,
"weights": weights}, sort_keys=
True
1774 ).encode()).hexdigest()[:16],
1779 "equations": a.equations,
1782 "prior_violations": _prior_viol
or None,
1785 "dual_declared": _dual
or None,
1786 "python": platform.python_version()}
1787 print(f
"machine {a.machine}/{a.of} rows {len(mine)} "
1788 f
"({len(done)} done, {len(todo)} to run) workers {a.workers}")
1789 print(f
" provenance {json.dumps(prov)}")
1797 for f
in (
"git_sha",
"solver_sha256",
"workbook_sha256",
"design_sha256",
1799 old = {r.get(f)
for r
in prior
if r.get(f)}
1800 if old
and prov[f]
not in old:
1801 drift[f] = (sorted(old), prov[f])
1802 if drift
and not a.allow_mixed:
1803 print(
"\n *** REFUSING TO RESUME - the environment changed since the "
1804 "existing rows were written:")
1805 for f, (old, new)
in drift.items():
1806 print(f
" {f}: was {old} -> now {new}")
1807 print(
"\n Those rows are not comparable to the ones this process "
1809 print(
" Choose one:")
1810 print(
" - restore the previous state (git checkout / rebuild), or")
1811 print(f
" - archive the old results and start clean:")
1812 print(f
" mv {out.name} {out.stem}_pre-change.jsonl")
1813 print(
" - or, only if you are certain the change cannot affect "
1814 "results\n (e.g. a comment or README edit), re-run with "
1818 print(f
" WARNING: resuming across an environment change "
1819 f
"({', '.join(drift)}) because --allow-mixed was given.")
1820 if meta.get(
"revision_pending"):
1821 print(f
" *** design carries REVISION_PENDING parameters: "
1822 f
"{meta['revision_pending']}")
1840 from concurrent.futures
import ProcessPoolExecutor, FIRST_COMPLETED, wait
1851 started_p = out.with_suffix(
".started.json")
1852 started_p.write_text(json.dumps({
1853 "unix": t0,
"at": time.strftime(
"%Y-%m-%d %H:%M:%S"),
1854 "machine": a.machine,
"workers": a.workers,
1855 "rows_assigned": len(mine),
"rows_already_done": len(mine) - len(todo),
1856 "rows_to_run": len(todo),
"machine_id": platform.node(),
1857 }, indent=2), encoding=
"utf-8")
1878 _sig_stop: dict = {}
1880 def _on_signal(signum, _frame):
1881 nm = signal.Signals(signum).name
1883 print(f
"\n second {nm} -- aborting NOW; in-flight rows are lost.\n",
1885 signal.signal(signum, signal.SIG_DFL)
1886 os.kill(os.getpid(), signum)
1888 _sig_stop[
"reason"] = f
"{nm} (graceful; send again to abort)"
1889 print(f
"\n {nm} received -- not submitting further rows; in-flight "
1890 f
"rows will finish and be written. Re-run to resume. Send {nm} "
1891 f
"again to abort immediately.\n", flush=
True)
1901 for _sig
in (getattr(signal, n,
None)
for n
in (
"SIGINT",
"SIGTERM",
"SIGHUP")):
1909 if signal.getsignal(_sig)
is signal.SIG_IGN:
1911 signal.signal(_sig, _on_signal)
1913 n_ok = n_bad = n_inadm = 0
1915 pending = list(todo)
1931 def append_row(rec):
1932 with out.open(
"a", encoding=
"utf-8")
as fh:
1933 fh.write(json.dumps(rec) +
"\n")
1935 os.fsync(fh.fileno())
1937 with ProcessPoolExecutor(max_workers=a.workers)
as ex:
1940 return ex.submit(evaluate,
1941 (r, spec, conds.get(r[
"condition"], {
"T_K": 623.15}),
1945 while pending
and len(inflight) < a.workers:
1946 inflight.add(submit(pending.pop(0)))
1949 finished, inflight = wait(inflight, return_when=FIRST_COMPLETED)
1950 for fut
in finished:
1954 if rec.get(
"solver_rc"):
1956 elif not rec.get(
"admissible"):
1960 print(f
" row {rec['row_id']:6d} {rec['condition']:>4s} "
1961 f
"{rec['matrix']:>2s} "
1962 f
"{'FAIL' if rec.get('solver_rc') else ('INADM' if not rec.get('admissible') else ' ok ')} "
1963 f
"d100={rec.get('d_100_nm', float('nan')):6.2f} "
1964 f
"N100={rec.get('N_loops_100', float('nan')):9.3e} "
1965 f
"pile={rec.get('pile_100')} "
1966 f
"dFP={rec.get('delta_FP', float('nan')):8.1e} "
1967 f
"{rec['wall_s']:.0f}s", flush=
True)
1968 if stopped
is None and _sig_stop:
1969 stopped = dict(_sig_stop)
1970 print(f
"\n STOP on signal ({stopped.get('reason')}) -- "
1971 f
"{len(inflight)} in flight will finish and be written.\n",
1973 if stopped
is None and STOP_FILE.exists():
1975 stopped = json.loads(STOP_FILE.read_text(encoding=
"utf-8"))
1977 stopped = {
"reason":
"unreadable STOP file"}
1978 print(f
"\n STOP requested ({stopped.get('reason')}) -- not "
1979 f
"submitting further rows; {len(inflight)} in flight will "
1980 f
"finish and be written.\n", flush=
True)
1987 if stopped
is None and a.stop_after_s > 0:
1988 el = time.time() - t0
1989 if el >= a.stop_after_s:
1990 stopped = {
"reason": f
"--stop-after-s {a.stop_after_s:.0f} "
1991 f
"reached at {el:.0f}s (scheduler "
1993 print(f
"\n DEADLINE reached ({el:.0f}s of "
1994 f
"{a.stop_after_s:.0f}s) -- not submitting further "
1995 f
"rows; {len(inflight)} in flight will finish and be "
1996 f
"written. Resubmit to resume.\n", flush=
True)
1998 while pending
and len(inflight) < a.workers:
1999 inflight.add(submit(pending.pop(0)))
2009 for ln
in out.read_text(encoding=
"utf-8").splitlines():
2011 all_recs.append(json.loads(ln))
2014 ws = [r[
"wall_s"]
for r
in all_recs
if r.get(
"wall_s")]
2016 "schema_version":
"1",
2017 "written_at_unix": time.time(),
2018 "written_at": time.strftime(
"%Y-%m-%d %H:%M:%S"),
2019 "campaign": {
"design_file": str(a.design),
"design_sha256":
2020 meta.get(
"design_sha256"),
"tier": meta.get(
"tier"),
2021 "N": meta.get(
"N"),
"p": meta.get(
"p"),
2022 "conditions": meta.get(
"conditions"),
2023 "parameters": meta.get(
"parameters"),
2024 "parameters_version": meta.get(
"parameters_version"),
2025 "revision_pending": meta.get(
"revision_pending"),
2026 "rows_total": meta.get(
"rows_total")},
2027 "machine": {
"index": a.machine,
"of": a.of,
2028 "weights": weights,
"rows_assigned": len(mine),
2029 "machine_id": platform.node(),
"platform": platform.platform(),
2030 "python": platform.python_version(),
2031 "cpu_count": os.cpu_count(),
"workers": a.workers,
2032 "omp_num_threads": os.environ.get(
"OMP_NUM_THREADS")},
2033 "code": {
"git_sha": prov[
"git_sha"],
"solver_sha256": prov[
"solver_sha256"],
2034 "solver_path": str(solver),
2035 "workbook_sha256": prov[
"workbook_sha256"]},
2036 "run_config": run_cfg,
2037 "run_cfg_sha": run_cfg_sha,
2038 "conditions_file": str(a.conditions),
2039 "conditions": conds,
2040 "rows": {
"assigned": len(mine),
"completed": len(all_recs),
2041 "admissible": sum(1
for r
in all_recs
2042 if not r.get(
"solver_rc")
and r.get(
"admissible")),
2043 "inadmissible": sum(1
for r
in all_recs
2044 if not r.get(
"solver_rc")
and not r.get(
"admissible")),
2045 "failed": sum(1
for r
in all_recs
if r.get(
"solver_rc")),
2046 "not_started": len(pending)},
2047 "timing": {
"session_wall_s": round(time.time() - t0, 1),
2048 "row_wall_mean_s": (float(np.mean(ws))
if ws
else None),
2049 "row_wall_median_s": (float(np.median(ws))
if ws
else None),
2050 "row_wall_p90_s": (float(np.percentile(ws, 90))
if ws
else None),
2051 "core_hours": (sum(ws) / 3600.0
if ws
else 0.0)},
2053 "admissibility_rule": {
2054 "pile_tol": PILE_TOL,
"occ_tol": OCC_TOL,
2055 "delta_FP_tol": DFP_TOL,
2056 "pile_top_frac": PILE_TOP_FRAC,
2057 "axes": [
"sia_111",
"sia_100",
"vacancy"],
2058 "note":
"delta_FP is blind to SIA-axis truncation (the <100> pile-up "
2059 "held delta_FP at 1e-8 while 96% of content sat at n=I), so "
2060 "grid adequacy is judged by pile/occupancy. It is NOT blind "
2061 "to the VACANCY axis -- delta_FP rests on the swelling "
2062 "identity S = S_I + Delta J^d and S is the vacancy "
2063 "inventory. The vacancy axis was unchecked until 2026-08-02; "
2064 "at the check_machine probe (V=120) mean_n_v moved 18.7->136 "
2065 "and swelling 58x on going to V=240."},
2067 man_p = out.with_suffix(
".manifest.json")
2068 man_p.write_text(json.dumps(man, indent=2), encoding=
"utf-8")
2070 tag =
"STOPPED" if stopped
else "done"
2071 print(f
"\n{tag} in {time.time()-t0:.0f}s: {n_ok} admissible, "
2072 f
"{n_inadm} inadmissible, {n_bad} failed -> {out}")
2073 print(f
" manifest -> {man_p.name}")
2075 print(f
" {len(pending)} row(s) were never started and remain assigned "
2076 f
"to this machine.")
2077 reason = str(stopped.get(
"reason",
""))
2078 if "walltime guard" in reason
or "graceful; send again" in reason:
2082 what = (
"a signal" if "graceful; send again" in reason
2083 else "the --stop-after-s deadline")
2084 print(f
" No STOP flag was set: this was {what}. "
2085 "Resubmit the SAME command to resume; completed rows are "
2086 "skipped automatically.")
2088 print(
" Clear the flag (campaign_ops.clear_stop()) and re-run the same "
2089 "command to resume; completed rows are skipped automatically.")