RadCluster
Graph-based cluster dynamics for irradiated materials
Loading...
Searching...
No Matches
score_crossover.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2"""Score T9 on the EUROFER97 character CROSSOVER, per parameter vector.
3
4WHY NOT score_T4. score_T4 compares ONE row to ONE datum. The character target
5is not a value at a temperature -- it is a trend across two:
6
7 300 C / 15 dpa -> f_100_tem_1 < 0.5 (1/2<111> dominant)
8 350 C / 16.3 dpa -> f_100_tem_1 > 0.5 (<100> dominant)
9
10Neither end alone is sufficient. A theta that converts uniformly too little
11passes the cold end and fails the hot one; a theta that converts uniformly too
12much does the reverse. Only the PAIR pins the crossover temperature, which is
13the quantity E_a0_conv actually sets. So rows are grouped by theta_id (both
14conditions carry the same parameter vector) and scored jointly.
15
16The 330 C datum is deliberately NOT used for character -- its database cell is a
17crossover statement, not an assignment (see targets_T4.json
18_character_correction). It still pins size and density and is scored separately
19when available.
20
21REPORTED, NOT SCORED: whether d_111 clears the 1.0 nm TEM cutoff. That is the
22diagnostic the whole widened box exists to move -- if d_111 stays below 1 nm
23everywhere, the 1/2<111> population is invisible by construction and no amount
24of parameter search can make the character come out right, which is a structural
25finding rather than a bad fit.
26
27Usage:
28 python score_crossover.py --rows t9.jsonl [--design design/T9_crossover.csv]
29"""
30from __future__ import annotations
31
32import argparse
33import csv
34import json
35import math
36from pathlib import Path
37
38HERE = Path(__file__).resolve().parent
39
40COLD, HOT = "C_300_G7", "C_350_G7"
41D_MIN_TEM = 1.0 # nm; the cutoff the model's 1/2<111> has to clear
42# hot end: 350 C / 16.3 dpa, d = 10-15 nm, N = 0.8e22
43HOT_D, HOT_D_RANGE, HOT_N = 12.5, (10.0, 15.0), 8.0e21
44# cold end: 300 C / 15 dpa, d < 5 nm, N > 1.4e22 (reported as a lower bound)
45COLD_D_MAX, COLD_N_MIN = 5.0, 1.4e22
46
47
48def d_mean(r: dict):
49 f, a, b = r.get("f_100_tem_1"), r.get("d_100_nm"), r.get("d_111_nm")
50 if f is None or not a or not b:
51 return None
52 return f * a + (1.0 - f) * b
53
54
55def main(argv=None) -> int:
56 ap = argparse.ArgumentParser()
57 ap.add_argument("--rows", type=Path, required=True)
58 ap.add_argument("--design", type=Path, default=HERE / "design/T9_crossover.csv")
59 ap.add_argument("--top", type=int, default=15)
60 a = ap.parse_args(argv)
61
62 theta_of, param_of = {}, {}
63 for d in csv.DictReader(a.design.open()):
64 theta_of[int(d["row_id"])] = int(d["theta_id"])
65 param_of[int(d["theta_id"])] = d
66
67 rows = [json.loads(l) for l in a.rows.read_text().splitlines() if l.strip()]
68 by_theta: dict[int, dict] = {}
69 for r in rows:
70 t = theta_of.get(r.get("row_id"))
71 if t is None:
72 continue
73 by_theta.setdefault(t, {})[r.get("condition")] = r
74
75 print(f"{len(rows)} rows -> {len(by_theta)} theta vectors "
76 f"({sum(1 for v in by_theta.values() if len(v) == 2)} with BOTH ends)\n")
77
78 # --- the structural question, asked of every row we have ----------------
79 d111 = [r["d_111_nm"] for r in rows if isinstance(r.get("d_111_nm"), (int, float))]
80 if d111:
81 over = [x for x in d111 if x >= D_MIN_TEM]
82 print(f"d_111 vs the {D_MIN_TEM} nm TEM cutoff over {len(d111)} rows: "
83 f"min={min(d111):.2f} median={sorted(d111)[len(d111)//2]:.2f} "
84 f"max={max(d111):.2f} nm")
85 print(f" rows clearing the cutoff: {len(over)}/{len(d111)} "
86 f"({100*len(over)/len(d111):.0f} %)"
87 + (" <-- the widened box DOES move it" if over else
88 " <-- STRUCTURAL: 1/2<111> invisible everywhere, character"
89 " cannot be fitted"))
90 print()
91
92 scored = []
93 for t, ends in by_theta.items():
94 c, h = ends.get(COLD), ends.get(HOT)
95 if not c or not h:
96 continue
97 fc, fh = c.get("f_100_tem_1"), h.get("f_100_tem_1")
98 if fc is None or fh is None:
99 continue
100 cold_ok, hot_ok = fc < 0.5, fh > 0.5
101 # crossover margin: how decisively both ends land on the right side.
102 # Negative if either end is wrong; the pair is what is being scored.
103 margin = min(0.5 - fc, fh - 0.5)
104 terms = []
105 dh = d_mean(h)
106 if dh and dh > 0:
107 terms.append(abs(math.log10(dh / HOT_D)))
108 nh = (h.get("N_100_vis_1") or 0) + (h.get("N_111_vis_1") or 0)
109 if nh > 0:
110 terms.append(abs(math.log10(nh / HOT_N)))
111 dc = d_mean(c)
112 if dc and dc > COLD_D_MAX: # only a bound: penalise excess
113 terms.append(abs(math.log10(dc / COLD_D_MAX)))
114 nc = (c.get("N_100_vis_1") or 0) + (c.get("N_111_vis_1") or 0)
115 if 0 < nc < COLD_N_MIN: # lower bound, penalise shortfall
116 terms.append(abs(math.log10(nc / COLD_N_MIN)))
117 size_misfit = sum(terms) if terms else float("nan")
118 penalty = 0.0 if (cold_ok and hot_ok) else 1.0
119 scored.append((penalty + size_misfit, cold_ok, hot_ok, margin,
120 size_misfit, t, c, h))
121
122 scored.sort(key=lambda x: (math.isnan(x[0]), x[0]))
123 both = [s for s in scored if s[1] and s[2]]
124 print(f"theta vectors reproducing the CROSSOVER (cold<0.5 AND hot>0.5): "
125 f"{len(both)}/{len(scored)}\n")
126
127 print(f"{'theta':>6} {'score':>7} {'cold f100':>10} {'hot f100':>9} "
128 f"{'d_mean C':>9} {'d_mean H':>9} {'N_hot':>10} {'cross?':>7}")
129 for s in scored[:a.top]:
130 tot, ck, hk, mg, sm, t, c, h = s
131 nh = (h.get("N_100_vis_1") or 0) + (h.get("N_111_vis_1") or 0)
132 dc, dh = d_mean(c), d_mean(h)
133 print(f"{t:6d} {tot:7.3f} {c['f_100_tem_1']:10.4f} {h['f_100_tem_1']:9.4f} "
134 f"{(dc or float('nan')):9.2f} {(dh or float('nan')):9.2f} {nh:10.2e} "
135 f"{'YES' if (ck and hk) else ('cold' if ck else ('hot' if hk else 'no')):>7}")
136
137 if both:
138 print("\nCROSSOVER-CORRECT vectors, parameter values "
139 "(* = outside the original elicited prior):")
140 meta = json.loads(a.design.with_suffix(".meta.json").read_text())
141 samp = meta["sampled"]
142 for s in both[:5]:
143 t = s[5]
144 d = param_of[t]
145 bits = []
146 for k, v in samp.items():
147 val = float(d[k])
148 op = v.get("original_prior")
149 star = "*" if (op and not (op[0] <= val <= op[1])) else ""
150 bits.append(f"{k}={val:.4g}{star}")
151 print(f" theta {t}: " + " ".join(bits))
152 return 0
153
154
155if __name__ == "__main__":
156 raise SystemExit(main())
int main(argv=None)