RadCluster
Graph-based cluster dynamics for irradiated materials
Loading...
Searching...
No Matches
loop_energetics.py
Go to the documentation of this file.
1"""
2loop_energetics.py — ½⟨111⟩ vs ⟨100⟩ interstitial-loop free energies and the
3thermodynamic driving force for the *unary* (Dudarev) ½⟨111⟩ → ⟨100⟩ conversion.
4
5Part of the ½⟨111⟩ → ⟨100⟩ loop-conversion work
6(see ``docs/design_notes/loop_111_to_100_conversion.md``).
7
8Physics
9-------
10Prismatic-loop free energy (Dudarev, Bullough & Derlet, PRL 100, 135503 (2008),
11Eq. 5; equivalently Marian, Wirth & Perlado, PRL 88, 255507 (2002), Eq. 3):
12
13 E_l^X(n,T) = P_X(n) · [ F̂_X(T)·ln(4 R*_X(n) / (e·δ)) + F_δ^X + F_c^X ]
14
15with X ∈ {111, 100}, P_X the loop perimeter, R*_X the equivalent-circle radius
16of a platelet of n self-interstitials (area A = n·Ω/b_X), and δ ≈ 0.4 nm the
17core cutoff. ½⟨111⟩ loops are hexagonal {110} (6 sides, b = (√3/2)a); ⟨100⟩
18loops are square {100} (4 sides, b = a).
19
20Temperature enters only through the ⟨100⟩ prelogarithmic factor, which softens
21toward the α–γ transition as the analytic ⟨100⟩[100] solution
22F̂_001([100]) ∝ √(c₁₁−c₁₂) ∝ (1 − T/T_c)^{1/4} (Dudarev Eq. 4); the
23½⟨111⟩ factor is treated as ~T-independent:
24
25 F̂_100(T) = F̂_100^0 · (1 − T/T_c)^{1/4}, T_c = 1185 K.
26
27Driving force for ½⟨111⟩ → ⟨100⟩ (favourable where positive):
28
29 ΔF(n,T) = E_l^111(n,T) − E_l^100(n,T).
30
31Calibration
32-----------
33Rather than trust digitised absolute values of the elastic constants, the
34overall ⟨100⟩ prelog magnitude F̂_100^0 is *derived* from a single calibration
35target — the crossover temperature T* at a reference size n_ref where
36ΔF(n_ref, T*) = 0 — via :meth:`LoopEnergetics.calibrate`. This guarantees, by
37construction, that at the reference size ½⟨111⟩ is favoured below T* and ⟨100⟩
38above it, matching the Dudarev Fig. 4 stability regions (sign change ≈ 350 °C,
39strongly ⟨100⟩-favoured by ≈ 550 °C). The remaining size dependence of the
40crossover then *emerges* from the loop geometry.
41
42.. note::
43 The default core/prelog constants below are approximate (digitised from
44 Dudarev Figs. 2–3 + best-fit table). The *direction and magnitude* of the
45 size dependence of the crossover is sensitive to them and is a physics
46 calibration decision (Phase 6, against ``loop_burgers_fraction.py``); only
47 the qualitative T behaviour at the reference size is guaranteed by the
48 calibration. All constants are overridable on the dataclass.
49"""
50from __future__ import annotations
51
52from dataclasses import dataclass
53
54import numpy as np
55
56_E = float(np.e)
57_C_TO_K = 273.15
58
59
60@dataclass
62 """½⟨111⟩ / ⟨100⟩ loop energies and the unary-conversion driving force.
63
64 Lengths are in ångström and energies in eV. Defaults are bcc-Fe values
65 consistent with :mod:`binding_energies`.
66 """
67
68 # ── lattice / geometry ───────────────────────────────────────────────────
69 a: float = 2.8665 # bcc-Fe lattice parameter [Å]
70 Omega: float = 11.78 # atomic volume [ų] (= a³/2)
71 delta: float = 4.0 # dislocation core cutoff [Å] (0.4 nm)
72 Tc: float = 1185.0 # α–γ / spin-fluctuation softening temperature [K]
73
74 # ── prelogarithmic energy factors [eV/Å] ─────────────────────────────────
75 Fhat_111: float = 0.28 # ½⟨111⟩, treated ~T-independent (Dudarev Fig. 2)
76 Fhat_100_0: float | None = None # ⟨100⟩ at T=0; set by calibrate() if None
77
78 # ── core-traction + nonlinear-core terms [eV/Å] (Dudarev best fit) ────────
79 Fdelta_111: float = 0.345
80 Fc_111: float = 0.46
81 Fdelta_100: float = 0.387
82 Fc_100: float = 0.33
83
84 # ── default calibration target ───────────────────────────────────────────
85 T_star_C: float = 450.0 # crossover temperature [°C] at n_ref
86 # 30, not the original 50: the calibrated value in force since S5 row 8700
87 # became the workbook default (18ba4d6, 2026-09-02) and the value the
88 # reference run uses. Built standalone with these defaults the class
89 # gives Fhat_100_0 = 0.5183 eV/A; through ReactionRates, with the
90 # workbook's a = 2.867 A and Omega = 11.8 A^3, it gives 0.5182.
91 n_ref: float = 30.0 # reference loop size [SIAs]
92
93 def __post_init__(self) -> None:
94 self.b_111 = (np.sqrt(3.0) / 2.0) * self.a
95 self.b_100 = self.a
96 if self.Fhat_100_0Fhat_100_0 is None:
98
99 # ── geometry ─────────────────────────────────────────────────────────────
100 def _b(self, char: int) -> float:
101 return self.b_111 if char == 111 else self.b_100
102
103 def area(self, n, char: int):
104 """Platelet area A = n·Ω/b_X [Ų]."""
105 return np.asarray(n, dtype=float) * self.Omega / self._b(char)
106
107 def R_star(self, n, char: int):
108 """Equivalent-circle radius R*_X = √(A/π) [Å]."""
109 return np.sqrt(self.area(n, char) / np.pi)
110
111 def perimeter(self, n, char: int):
112 """Loop perimeter: hexagon (½⟨111⟩) or square (⟨100⟩) [Å]."""
113 A = self.area(n, char)
114 if char == 111:
115 return 6.0 * np.sqrt(2.0 * A / (3.0 * np.sqrt(3.0)))
116 return 4.0 * np.sqrt(A)
117
118 def _Lln(self, n, char: int):
119 return np.log(4.0 * self.R_star(n, char) / (_E * self.deltadelta))
120
121 # ── per-character loop energies ──────────────────────────────────────────
122 def E_l_111(self, n):
123 """½⟨111⟩ loop free energy E_l^111(n) [eV] (T-independent here)."""
124 P = self.perimeter(n, 111)
125 return P * (self.Fhat_111 * self._Lln(n, 111)
127
128 def E_l_100_core(self, n):
129 """T-independent core part of the ⟨100⟩ loop energy [eV]."""
130 return self.perimeter(n, 100) * (self.Fdelta_100 + self.Fc_100Fc_100)
131
132 def E_l_100_pre(self, n):
133 """⟨100⟩ prelog part *without* the F̂_100(T) factor [eV·Å/(eV/Å)]."""
134 return self.perimeter(n, 100) * self._Lln(n, 100)
135
136 def _softening(self, T_K):
137 """(1 − T/T_c)^{1/4}, clipped to ≥ 0 above T_c (Dudarev Eq. 4)."""
138 return np.maximum(1.0 - np.asarray(T_K, dtype=float) / self.Tc, 0.0) ** 0.25
139
140 def Fhat_100(self, T_K):
141 """⟨100⟩ prelogarithmic factor F̂_100(T) [eV/Å]."""
142 return self.Fhat_100_0Fhat_100_0 * self._softening(T_K)
143
144 def E_l_100(self, n, T_K):
145 """⟨100⟩ loop free energy E_l^100(n,T) [eV]."""
146 return self.E_l_100_core(n) + self.Fhat_100(T_K) * self.E_l_100_pre(n)
147
148 # ── driving force & calibration ──────────────────────────────────────────
149 def delta_F(self, n, T_K):
150 """Unary-conversion driving force ΔF(n,T) [eV]; > 0 favours ⟨100⟩."""
151 return self.E_l_111(n) - self.E_l_100(n, T_K)
152
153 def calibrate(self, T_star_C: float | None = None,
154 n_ref: float | None = None) -> float:
155 """Set F̂_100^0 so that ΔF(n_ref, T*) = 0; return F̂_100^0.
156
157 Guarantees ΔF(n_ref, T) < 0 for T < T* and > 0 for T > T* (the
158 softening is monotonic), i.e. ½⟨111⟩ stable below the crossover and
159 ⟨100⟩ above it. Requires E_l^111(n_ref) > E_l^100_core(n_ref) (the
160 ⟨100⟩ core alone lies below the ½⟨111⟩ energy), which holds for the
161 defaults.
162 """
163 if T_star_C is not None:
164 self.T_star_CT_star_C = float(T_star_C)
165 if n_ref is not None:
166 self.n_refn_ref = float(n_ref)
167 T_star = self.T_star_CT_star_C + _C_TO_K
168 soft = float(self._softening(T_star))
169 num = float(self.E_l_111(self.n_refn_ref) - self.E_l_100_core(self.n_refn_ref))
170 den = float(self.E_l_100_pre(self.n_refn_ref)) * soft
171 if not (num > 0.0 and den > 0.0):
172 raise ValueError(
173 "calibration ill-posed: need E_l^111(n_ref) > E_l^100_core(n_ref) "
174 f"and 0 < T* < T_c (got num={num:.3g}, soft={soft:.3g}); "
175 "adjust the core/prelog constants or the target.")
176 self.Fhat_100_0Fhat_100_0 = num / den
177 return self.Fhat_100_0Fhat_100_0
178
180 """Temperature [°C] where ΔF(n,T)=0, or NaN if none in (0, T_c).
181
182 Solves F̂_100^0·(1−T/T_c)^{1/4}·E_pre = E_l^111 − E_l^100_core.
183 """
184 n = np.asarray(n, dtype=float)
185 ratio = ((self.E_l_111(n) - self.E_l_100_core(n))
186 / (self.Fhat_100_0Fhat_100_0 * self.E_l_100_pre(n)))
187 with np.errstate(invalid="ignore"):
188 T_K = np.where((ratio > 0.0) & (ratio < 1.0),
189 self.Tc * (1.0 - ratio ** 4), np.nan)
190 out = T_K - _C_TO_K
191 return float(out) if out.ndim == 0 else out
192
193 # ── interface for the Γ_uni kernel (Phase 3) ─────────────────────────────
194 def conversion_mask(self, T_K, n_max: int) -> np.ndarray:
195 """Boolean array over sizes 1..n_max where ΔF(n,T) > 0 (⟨100⟩ favoured).
196
197 The unary-conversion kernel Γ_uni(n,T) is nonzero only on this support
198 (and is further gated by the [1 − exp(−ΔF/k_BT)] factor in
199 ``reaction_rates``). Independent of the ⟨100⟩ population's admissible
200 size floor, which is the loop-onset size, not this thermodynamic set.
201 """
202 n = np.arange(1, int(n_max) + 1)
203 return self.delta_F(n, T_K) > 0.0
204
205 def driving_force_array(self, T_K, n_max: int) -> np.ndarray:
206 """ΔF(n,T) over sizes n = 1..n_max [eV] (0-indexed by size−1)."""
207 n = np.arange(1, int(n_max) + 1)
208 return self.delta_F(n, T_K)
209
210
211# ── self-test ─────────────────────────────────────────────────────────────────
212def _selftest() -> int:
213 """Verify the construction-guaranteed properties (not absolute eV values)."""
214 le = LoopEnergetics()
215 Tc = le.Tc
216 Tstar = le.T_star_C + _C_TO_K
217 nref = le.n_ref
218 fails = 0
219
220 def check(name, cond, *info):
221 nonlocal fails
222 if cond:
223 print(f" PASS {name} {info if info else ''}")
224 else:
225 fails += 1
226 print(f" FAIL {name} {info}")
227
228 # 1. Calibration zeroes dF at (n_ref, T*).
229 check("dF(n_ref, T*) ~ 0",
230 abs(le.delta_F(nref, Tstar)) < 1e-9, le.delta_F(nref, Tstar))
231 # 2. <111> favoured below T*, <100> favoured above (at n_ref).
232 check("dF(n_ref, T*-100K) < 0", le.delta_F(nref, Tstar - 100.0) < 0.0,
233 le.delta_F(nref, Tstar - 100.0))
234 check("dF(n_ref, T*+100K) > 0", le.delta_F(nref, Tstar + 100.0) > 0.0,
235 le.delta_F(nref, Tstar + 100.0))
236 # 3. Monotone in T at n_ref (softening monotone => dF increasing in T).
237 Ts = np.linspace(300.0, Tc - 1.0, 50)
238 dF = le.delta_F(nref, Ts)
239 check("dF monotone increasing in T", np.all(np.diff(dF) > -1e-12))
240 # 4. Crossover temperature round-trips.
241 Tc_ref = le.crossover_temperature(nref)
242 check("crossover_temperature(n_ref) ~ T*", abs(Tc_ref - le.T_star_C) < 1e-6,
243 Tc_ref)
244 # 5. Geometry sanity: both perimeters positive, R* grows with n.
245 check("perimeters > 0",
246 le.perimeter(10, 111) > 0 and le.perimeter(10, 100) > 0)
247 check("R* increasing with n", le.R_star(100, 111) > le.R_star(10, 111))
248 # 6. Fhat_100^0 calibrated to a physical magnitude (0.1-1 eV/A).
249 check("Fhat_100_0 in (0.1, 1.0) eV/A", 0.1 < le.Fhat_100_0 < 1.0,
250 le.Fhat_100_0)
251
252 # Report the emergent size-dependence direction for the user (not asserted).
253 T_op = 723.15 # 450 °C operating point
254 mask = le.conversion_mask(T_op, 300)
255 sizes = np.arange(1, 301)[mask]
256 if sizes.size:
257 print(f"\n [info] at {T_op-273.15:.0f} C, dF>0 (<100> favoured) for "
258 f"sizes n in [{sizes.min()}, {sizes.max()}] "
259 f"({'small-loop' if sizes.min()==1 else 'large-loop'} biased)")
260 else:
261 print(f"\n [info] at {T_op-273.15:.0f} C, no size has dF>0")
262 print(f" [info] Fhat_100_0 = {le.Fhat_100_0:.4f} eV/A "
263 f"(calibrated to T*={le.T_star_C:.0f} C at n_ref={nref:.0f})")
264
265 print(f"\n{7 - fails}/7 checks passed")
266 return 1 if fails else 0
267
268
269if __name__ == "__main__":
270 import sys
271 sys.exit(_selftest())
np.ndarray driving_force_array(self, T_K, int n_max)
float calibrate(self, float|None T_star_C=None, float|None n_ref=None)
np.ndarray conversion_mask(self, T_K, int n_max)