RadCluster
Graph-based cluster dynamics for irradiated materials
Loading...
Searching...
No Matches
score_T4.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2"""Score T4 rows against the experimental bands in targets_T4.json.
3
4WHY THIS IS NOT A ONE-LINER. The database reports what TEM measures: ONE loop
5density and ONE mean diameter, over BOTH Burgers characters, above a detection
6limit. The model reports the two populations separately and unfiltered. So the
7comparison has to be built, not read off:
8
9 N_loop_total <- N_100_vis_1 + N_111_vis_1 (TEM-visible, d_min = 1 nm)
10 d_loop_mean <- number-weighted mean of d_100_nm and d_111_nm using those
11 SAME visible densities as weights
12 dominant BV <- f_100_tem_1 > 0.5 vs < 0.5
13
14Using the raw N_loops_100 / N_loops_111 instead of the _vis_1 fields would
15compare an unfiltered model against a filtered measurement, and the model's
161/2<111> population sits at ~0.8 nm -- entirely below the detection limit -- so
17that error is worth a factor of several, not a few percent.
18
19The diameter weighting is an APPROXIMATION: it weights each character's mean by
20its visible count, which is right only if the within-character distributions do
21not overlap the cutoff too strongly. It is the best available from the row
22fields; the exact quantity would need the size distribution.
23
24Usage:
25 python score_T4.py --rows <file.jsonl> [--targets targets_T4.json] [--top N]
26"""
27from __future__ import annotations
28
29import argparse
30import json
31import math
32from pathlib import Path
33
34HERE = Path(__file__).resolve().parent
35
36
37def visible(rec: dict) -> tuple[float, float]:
38 """TEM-visible <100> and 1/2<111> number densities at the 1.0 nm cutoff."""
39 n100 = rec.get("N_100_vis_1")
40 n111 = rec.get("N_111_vis_1")
41 if n100 is None:
42 n100 = rec.get("N_100_visible", rec.get("N_loops_100", 0.0)) or 0.0
43 if n111 is None:
44 n111 = rec.get("N_111_visible", rec.get("N_loops_111", 0.0)) or 0.0
45 return float(n100), float(n111)
46
47
48def derived(rec: dict) -> dict:
49 n100, n111 = visible(rec)
50 tot = n100 + n111
51 d100 = rec.get("d_100_nm") or 0.0
52 d111 = rec.get("d_111_nm") or 0.0
53 dmean = ((n100 * d100 + n111 * d111) / tot) if tot > 0 else float("nan")
54 return {
55 "N_loop_total_m3": tot,
56 "d_loop_mean_nm": dmean,
57 "f_100_tem_1": rec.get("f_100_tem_1"),
58 "N_cavity_m3": rec.get("N_voids"),
59 "d_cavity_nm": rec.get("d_cavity_nm"),
60 }
61
62
63def misfit(model: dict, target: dict) -> tuple[float, list[str]]:
64 """Sum of |log10(model/target)| over the fields the target actually pins.
65
66 Log ratio, not relative error: densities span decades, and a factor-3 miss
67 should cost the same whether it is high or low. Diameters use the same
68 measure so one channel cannot dominate purely by units.
69 """
70 terms, notes = [], []
71
72 # DENSITY IS DIAGNOSTIC ONLY, NOT SCORED (2026-08-12). The model carries
73 # two independent <100> number densities that disagree: N_100_vis_1 (from
74 # the per-size y_sia100 array) exceeds N_loops_100 (from post_process, same
75 # m^-3 units after post_process.py:471) in 95.3 % of 1173 T3 rows, median
76 # 626x, max 65540x. A detection-limit filter can only REMOVE loops, so one
77 # of the two is wrong by ~3 decades and it is not yet established which.
78 #
79 # Character and mean size are only PARTLY insulated, and the distinction
80 # matters. Both are built from the same two per-size sums a0 (<100>) and a1
81 # (1/2<111>):
82 # f_100 = a0 / (a0 + a1)
83 # d_mean = (a0 d100 + a1 d111) / (a0 + a1) = f_100 d100 + (1-f_100) d111
84 # so a factor that scales a0 and a1 TOGETHER -- e.g. the 1/Omega
85 # normalisation -- cancels identically and cannot move either number.
86 #
87 # But the defect diagnosed on 2026-08-12 is NOT of that kind. It is a
88 # spurious large-n tail in the <100> array ALONE (a per-size C_floor
89 # subtraction applied after the closure had already smeared the floor across
90 # a near-empty bin), so it inflates a0 without touching a1 and BOTH f_100 and
91 # d_mean move with it. An earlier revision of this comment claimed the
92 # cancellation covered this case; the algebra was right but the premise was
93 # not. Scoring on character and size is therefore the better-conditioned
94 # choice, not an immune one -- it drops the absolute scale, which is the
95 # worst-affected part, and keeps the ratio, which is merely affected.
96 #
97 # The tail itself is fixed at source in cpp_bridge (moment-level flooring
98 # before reconstruction); rows produced before that fix carry it.
99 for key, tkey in (("d_loop_mean_nm", "d_loop_mean_nm"),):
100 t = target.get(tkey)
101 m = model.get(key)
102 if t is None or m is None or not (m > 0) or not (t > 0):
103 continue
104 r = math.log10(m / t)
105 terms.append(abs(r))
106 notes.append(f"{tkey}: model {m:.4g} vs {t:.4g} ({10**r:.2f}x)")
107
108 tN, mN = target.get("N_loop_total_m3"), model.get("N_loop_total_m3")
109 if tN and mN and mN > 0:
110 notes.append(f"[diagnostic, NOT scored] N_loop_total_m3: model {mN:.4g} "
111 f"vs {tN:.4g} ({mN/tN:.2f}x)")
112 # character is a direction, not a number: score it as a hit/miss penalty
113 exp_f = target.get("f_100_tem_1_expected")
114 f = model.get("f_100_tem_1")
115 if exp_f and f is not None:
116 want_hi = exp_f.strip().startswith(">")
117 ok = (f > 0.5) if want_hi else (f < 0.5)
118 if not ok:
119 terms.append(0.5)
120 notes.append(f"character: f_100_tem_1 {f:.3f}, data says {exp_f} "
121 f"-> {'OK' if ok else 'INVERTED'}")
122 return (sum(terms) if terms else float("nan")), notes
123
124
125def main(argv=None) -> int:
126 ap = argparse.ArgumentParser()
127 ap.add_argument("--rows", type=Path, required=True)
128 ap.add_argument("--targets", type=Path, default=HERE / "targets_T4.json")
129 ap.add_argument("--top", type=int, default=10)
130 a = ap.parse_args(argv)
131
132 tg = json.loads(a.targets.read_text(encoding="utf-8"))
133 by_id = {}
134 for fam in ("neutron", "ion"):
135 for p in tg[fam]["points"]:
136 by_id[p["id"]] = p
137 conds = json.loads((HERE / "conditions_T4.json").read_text(encoding="utf-8"))
138
139 rows = [json.loads(l) for l in a.rows.read_text(encoding="utf-8").splitlines()
140 if l.strip()]
141 if not rows:
142 print("no rows yet")
143 return 0
144
145 scored = []
146 for r in rows:
147 c = conds.get(r.get("condition"), {})
148 t = by_id.get(c.get("_target_id"))
149 if t is None:
150 continue
151 m = derived(r)
152 s, notes = misfit(m, t)
153 scored.append((s, r, m, t, notes))
154 scored.sort(key=lambda x: (math.isnan(x[0]), x[0]))
155
156 print(f"scored {len(scored)} rows against targets_T4.json\n")
157 for s, r, m, t, notes in scored[:a.top]:
158 chan = (f"chi={r.get('loop_net_chi')} w_c={r.get('loop_net_w_c')} "
159 f"K_rec={r.get('loop_net_K_rec')}")
160 print(f"row {r['row_id']:>4} {r.get('condition')} misfit={s:.3f} {chan}")
161 print(f" dose_reached={r.get('dose_reached')} starved={r.get('starved')} "
162 f"topbin_100={r.get('topbin_100')} pile_100={r.get('pile_100')}")
163 for n in notes:
164 print(f" {n}")
165 print()
166 return 0
167
168
169if __name__ == "__main__":
170 raise SystemExit(main())
tuple[float, list[str]] misfit(dict model, dict target)
Definition score_T4.py:63
int main(argv=None)
Definition score_T4.py:125
tuple[float, float] visible(dict rec)
Definition score_T4.py:37
dict derived(dict rec)
Definition score_T4.py:48