2"""Run this on EVERY machine before it joins the campaign.
4 python check_machine.py # compare against the committed reference
5 python check_machine.py --write # (re)generate the reference, once, on one machine
9The C++ solver is NOT in git -- `build/` is ignored, so every machine compiles
10its own from cpp_utils/. Four machines with different compiler or SUNDIALS
11versions produce numerically different results while every hash in the
12provenance block still looks plausible, and merge_and_sobol would then mix them
13into one Sobol estimate. That is the same class of error as the grid
14saturation this project already hit: a believable number with no validity.
16So: pin one fixed configuration, run it, and compare against a reference
17committed from a known-good machine. It takes ~30 s and it is the difference
18between "the four machines agree" and "the four machines were assumed to
21The check deliberately uses a SMALL, FAST configuration. It is verifying that
22the toolchain agrees, not that the physics is right -- the run is expected to be
23inadmissible (the grid is far too coarse), and that is fine.
25Tolerance: 1e-6 relative -- the campaign's own integration rtol. CVODE with
26fixed tolerances is bit-deterministic for a FIXED BINARY, so an earlier 1e-9
27bar was right as long as every machine ran the same build. It is unreachable
28across ARCHITECTURES: Windows/x86 (MSVC + MKL, /arch:AVX2) and macOS/arm64
29(AppleClang + Accelerate, -march=native) reassociate and vectorise the same
30reductions differently, which shows up as ~1e-7 drift on the CVODE-integrated
31fields while the closed-form fields (Di_eff, Dv_eff, conv_*) still match to
320.0 exactly. Holding the gate tighter than the integrator's own accuracy
33rejects machines whose physics is identical, so the bar is set AT the
34integration tolerance: anything above it is a real discrepancy, anything below
35it is beneath the resolution the solver claims in the first place.
36OMP_NUM_THREADS is pinned to 1 so reduction order cannot vary within a build.
38from __future__
import annotations
48from pathlib
import Path
50os.environ[
"OMP_NUM_THREADS"] =
"1"
52HERE = Path(__file__).resolve().parent
53REPO = HERE.parent.parent
54sys.path.insert(0, str(REPO))
55REF = HERE /
"machine_reference.json"
74PROBE = dict(I=300, V=480, i_mobile=10, v_mobile=5, T_K=623.15, dose=0.02,
75 rho_d=1.0e14, E_a0_conv=2.2, dH2_abs_conv=0.30, psucc_abs_pref=2.0,
76 gamma_a_conv=0.02, phi_max_junc=0.0, f_cl_i=0.25, f_cl_v=0.05,
77 rtol=1e-6, solver_mode=
"active_window")
80PRECOND = {
"full_system":
"Woodbury",
"active_window":
"Jacobi"}
85FIELDS = [
"Di_eff",
"Dv_eff",
"conv_psuccess",
"conv_psuccess_abs",
86 "N_loops_100",
"N_loops_111",
"mean_n_100",
"mean_n_111",
87 "C_SIA_tot",
"C_VAC_tot",
"swelling",
"delta_FP"]
113ATOL = {
"delta_FP": 1e-9,
"conv_psuccess": 1e-12}
118 return hashlib.sha256(Path(p).read_bytes()).hexdigest()
124 from radcluster_code.py_utils.simulation
import RadClusterSimulation
126 _s = sys.stdout, sys.stderr
129 sys.stdout = sys.stderr = buf
130 sim = RadClusterSimulation(I=PROBE[
"I"], V=PROBE[
"V"],
131 solver_mode=PROBE[
"solver_mode"],
132 equations=
"discrete", cascade=
"fission",
134 he_kinetics=
"quasi_steady_state",
135 i_mobile=PROBE[
"i_mobile"],
136 v_mobile=PROBE[
"v_mobile"])
138 for k
in (
"T",
"rho_d",
"E_a0_conv",
"dH2_abs_conv",
"psucc_abs_pref",
139 "gamma_a_conv",
"phi_max_junc"):
140 key =
"T" if k ==
"T" else k
141 d.reactions[key] = PROBE[
"T_K"]
if k ==
"T" else PROBE[k]
142 d.production_fission[
"f_cl_i"] = PROBE[
"f_cl_i"]
143 d.production_fission[
"f_cl_v"] = PROBE[
"f_cl_v"]
144 d._calculate_derived()
146 rr = sim.reaction_rates
147 G = float(d.reactions[
"G"])
148 res = sim.run(solver_config={
149 "t_span": (1e-6, PROBE[
"dose"] / G),
"n_points": 12,
150 "log_time":
True,
"rtol": PROBE[
"rtol"],
"atol": 1e-20,
152 "solver_method": {
"linsol":
"gmres",
153 "preconditioner": PRECOND[PROBE[
"solver_mode"]],
154 "concentration_threshold": 1e-22},
155 "loop_conversion": 1}, save_output=
False)
157 sys.stdout, sys.stderr = _s
159 out = {
"Di_eff": float(d.derived[
"Di_eff"]),
160 "Dv_eff": float(d.derived[
"Dv_eff"]),
161 "conv_psuccess": float(rr.conv_psuccess),
162 "conv_psuccess_abs": float(rr.conv_psuccess_abs)}
167 out[k] = float(np.asarray(v, float).ravel()[-1])
if v
is not None else None
172 solver = REPO /
"radcluster_code" /
"build" /
"Release" /
"solver.exe"
173 if not solver.exists():
174 solver = REPO /
"radcluster_code" /
"build" /
"solver"
176 gs = subprocess.check_output([
"git",
"rev-parse",
"HEAD"], cwd=str(REPO),
177 stderr=subprocess.DEVNULL, text=
True).strip()
180 return {
"machine_id": platform.node(),
"platform": platform.platform(),
181 "python": platform.python_version(),
"git_sha": gs,
183 "solver_exists": solver.exists(),
185 REPO /
"radcluster_code" /
"input" /
"input_parameters.xlsx")}
189 ap = argparse.ArgumentParser(description=__doc__,
190 formatter_class=argparse.RawDescriptionHelpFormatter)
191 ap.add_argument(
"--write", action=
"store_true",
192 help=
"generate the reference (run once, on a known-good machine)")
193 a = ap.parse_args(argv)
196 print(f
"machine {e['machine_id']} ({e['platform']})")
197 print(f
"python {e['python']}")
198 print(f
"git {e['git_sha'][:12]}")
199 print(f
"solver {e['solver_sha256'][:16]} exists={e['solver_exists']}")
200 print(f
"workbook {e['workbook_sha256'][:16]}")
201 if not e[
"solver_exists"]:
202 print(
"\n solver binary NOT FOUND. Build it first:")
203 print(
" cd radcluster_code && cmake -S cpp_utils -B build && "
204 "cmake --build build --config Release")
207 print(
"\nrunning probe (I=150, 0.02 dpa, ~30 s) ...")
211 REF.write_text(json.dumps({
"probe": PROBE,
"fields": got,
212 "generated_on": e}, indent=2),
214 print(f
"\n wrote {REF.name} from this machine. Commit it, and have every")
215 print(
" other machine run `python check_machine.py` before joining.")
217 print(f
" {k:20s} {got[k]!r}")
221 print(f
"\n no {REF.name} committed yet. Generate it on the machine you")
222 print(
" trust with: python check_machine.py --write")
225 ref = json.loads(REF.read_text(encoding=
"utf-8"))
226 if ref[
"probe"] != PROBE:
227 print(
"\n *** the probe configuration has changed since the reference "
228 "was written; regenerate it.")
231 print(f
"\n reference generated on {ref['generated_on']['machine_id']} "
232 f
"(git {ref['generated_on']['git_sha'][:12]}, "
233 f
"solver {ref['generated_on']['solver_sha256'][:16]})")
234 if ref[
"generated_on"][
"git_sha"] != e[
"git_sha"]:
235 print(
" note: git SHA differs from the reference machine - pull first "
236 "if that is not intentional.")
239 print(f
"\n {'field':20s} {'this machine':>16s} {'reference':>16s} {'rel diff':>11s}")
241 g, r = got.get(k), ref[
"fields"].get(k)
242 if g
is None or r
is None:
243 print(f
" {k:20s} {'None' if g is None else g:>16} "
244 f
"{'None' if r is None else r:>16} (skipped)")
246 den = max(abs(r), 1e-300)
247 rel = abs(g - r) / den
248 atol = ATOL.get(k, 0.0)
249 ok = abs(g - r) <= RTOL * abs(r) + atol
254 if ok
and rel > RTOL:
255 flag = f
" (within atol {atol:g})"
259 flag =
" <-- MISMATCH"
260 bad.append((k, g, r, rel))
261 print(f
" {k:20s} {g:16.9e} {r:16.9e} {rel:11.2e}{flag}")
264 print(f
"\n *** {len(bad)} field(s) exceed rtol={RTOL:g}.")
265 print(
" This machine does NOT reproduce the reference. Its results")
266 print(
" cannot be pooled with the others in one Sobol estimate.")
267 print(
" Most likely a different SUNDIALS/compiler version - compare")
268 print(
" solver_sha256 above, rebuild from the same commit, re-check.")
270 print(f
"\n all {len(FIELDS)} fields agree within {RTOL:g}.")
271 print(
" This machine may join the campaign.")
275if __name__ ==
"__main__":
276 raise SystemExit(
main())