RadCluster
Graph-based cluster dynamics for irradiated materials
Loading...
Searching...
No Matches
simulation.py
Go to the documentation of this file.
1"""
2simulation.py — RadCluster_2_1 simulation orchestrator.
3
4Responsibilities
5----------------
61. Load inputs and initialise physics modules.
72. Dispatch to the appropriate solver mode and physics option.
83. Write timestamped output directory with provenance, CSV, and plots.
9
10Solver modes
11------------
12full_system → C++ SUNDIALS CVODE BDF, full system, via cpp_bridge.run_cpp_solver
13active_window → C++ SUNDIALS CVODE BDF with two independent sliding windows
14 (SIA + VAC) and OpenMP-parallel RHS. Thread count is auto-set
15 from N_eq; falls back to single-threaded sliding when OpenMP
16 is unavailable or only one thread is selected.
17
18Legacy aliases (still accepted): cpp_full → full_system,
19 sliding_OpenMP → active_window.
20
21Physics options (two orthogonal axes)
22-------------------------------------
23equations ∈ {'discrete', 'bin_moment'} : per-size ODEs vs. bin-moment grouping
24cascade ∈ {'fission', 'fusion'} : He coupling case + cascade spectrum
25
26Combinations map to the canonical physics_option string:
27 discrete + fission → 'full_CD_fission' (he_mode='case2', Eq. 175)
28 discrete + fusion → 'full_CD_fusion' (he_mode='case1', Eq. 174)
29 bin_moment + fission → 'bin_moment_CD_fission' (he_mode='case2')
30 bin_moment + fusion → 'bin_moment_CD_fusion' (he_mode='case1')
31
32Either form is accepted by RadClusterSimulation. Legacy equations='full_CD'
33is silently aliased to 'discrete'.
34"""
35
36import time as _time
37import os
38import platform
39import re
40import signal as _signal
41import sys
42from pathlib import Path
43from datetime import datetime
44
45import numpy as np
46
47from .input_data import InputData
48from .reaction_rates import ReactionRates
49from .rate_equations import RateEquations
50from .bin_moment_rates import BinMomentRateEquations
51from . import post_process
52
53
54BASE_DIR = Path(__file__).parent.parent
55
56
57def _remap_bin_moments(mu0_old, mu1_old, bins_old, bins_new):
58 """Move (μ₀, μ₁) from one bin partition to another, conserving both sums.
59
60 Used when a domain doubling re-derives the geometric bin edges and the
61 moments have to be expressed on the new partition.
62
63 WHY NOT reconstruct-then-reproject. The obvious route — expand the moments
64 to a per-size c_n with the active shape function, then re-take moments on
65 the new bins — is NOT conservative. ``distribution_from_moments_hat``
66 clamps its negative lobe with ``np.maximum(vals, 0.0)``, and that clip adds
67 mass: for a bin whose mean sits away from the centre the reconstructed sum
68 exceeds the bin's own μ₀, without bound. Measured on the ⟨100⟩ block of the
69 I=40000 run (2026-08-15): Σ(reconstructed) / Σμ₀ = 176.9, which is precisely
70 the ×177 jump in N_100 across that run's doubling, and it took δ_FP from
71 0.20 to 0.96. The ½⟨111⟩ and vacancy blocks survive the same round trip
72 only because they are broad and well-centred; the ⟨100⟩ population is
73 sessile, one-way and sharply peaked, so it sits in the worst case
74 permanently.
75
76 THIS route never builds a per-size array. Each old bin is treated as
77 uniformly filled and split across the new bins it overlaps, by overlap
78 fraction w (Σ_K w = 1 because the new bins tile the old bin's span). Both
79 moments ride the same weights, so Σμ₀ and Σμ₁ are conserved to round-off
80 by construction — the clip cannot enter because no reconstruction happens.
81
82 The one approximation is the within-bin placement of content: splitting μ₁
83 by width smears a peak across the pieces of a straddled bin. A final clamp
84 puts each new bin's mean back inside its own range, mirroring the
85 consistency rule in ``post_process._floor_bin_moments`` (an empty bin
86 carries no content; μ₁/μ₀ lies in the bin). Both are second-order next to
87 a factor of 177.
88
89 Returns (mu0_new, mu1_new), each length len(bins_new).
90 """
91 mu0_old = np.asarray(mu0_old, dtype=float)
92 K_new = len(bins_new)
93 mu0_new = np.zeros(K_new)
94 mu1_new = np.zeros(K_new)
95 has_m1 = mu1_old is not None
96 mu1_old = (np.asarray(mu1_old, dtype=float) if has_m1
97 else mu0_old * np.array([0.5 * (lo + hi - 1)
98 for lo, hi in bins_old]))
99
100 for k, (lo, hi) in enumerate(bins_old):
101 width = float(hi - lo)
102 if width <= 0.0 or mu0_old[k] <= 0.0:
103 continue
104 for K, (Lo, Hi) in enumerate(bins_new):
105 ov = min(hi, Hi) - max(lo, Lo)
106 if ov <= 0:
107 continue
108 w = ov / width
109 mu0_new[K] += w * mu0_old[k]
110 mu1_new[K] += w * mu1_old[k]
111
112 # Consistency: empty bin carries no content; mean size lies in the bin.
113 for K, (Lo, Hi) in enumerate(bins_new):
114 if mu0_new[K] <= 0.0:
115 mu0_new[K] = 0.0
116 mu1_new[K] = 0.0
117 continue
118 nb = mu1_new[K] / mu0_new[K]
119 if nb < Lo:
120 mu1_new[K] = mu0_new[K] * float(Lo)
121 elif nb > Hi - 1:
122 mu1_new[K] = mu0_new[K] * float(Hi - 1)
123 return mu0_new, mu1_new
124
125
127 """Defer SIGINT through a critical save block.
128
129 First Ctrl+C inside the block prints a notice and continues — the save
130 finishes flushing partial output, then KeyboardInterrupt is re-raised
131 on exit. A second Ctrl+C inside the same block restores the default
132 handler so the user can force-abort if a save step is hung.
133
134 Only used in main-thread Python contexts; signal.signal raises
135 ValueError off the main thread, so we silently fall back to a no-op.
136 """
137 __slots__ = ('_msg', '_caught', '_prev', '_active')
138
139 def __init__(self, msg=('Ctrl+C — finishing pending writes before exit. '
140 'Press Ctrl+C again to force-abort.')):
141 self._msg = msg
142 self._caught = False
143 self._prev = None
144 self._active = False
145
146 def __enter__(self):
147 try:
148 self._prev = _signal.getsignal(_signal.SIGINT)
149 _signal.signal(_signal.SIGINT, self._handler_handler)
150 self._active = True
151 except (ValueError, OSError):
152 # Off main thread, or signal not available — no-op.
153 self._active = False
154 return self
155
156 def _handler(self, signum, frame):
157 if not self._caught:
158 self._caught = True
159 try:
160 print(f"\n*** {self._msg}", flush=True)
161 except Exception:
162 pass
163 else:
164 # User insisted — restore previous handler and re-raise now.
165 try:
166 _signal.signal(_signal.SIGINT, self._prev)
167 except Exception:
168 pass
169 self._active = False
170 raise KeyboardInterrupt
171
172 def __exit__(self, exc_type, exc, tb):
173 if self._active:
174 try:
175 _signal.signal(_signal.SIGINT, self._prev)
176 except Exception:
177 pass
178 self._active = False
179 # If a Ctrl+C arrived during the block and no other exception is
180 # already propagating, raise it now so the caller still sees the
181 # interrupt — just after the partial output has been written.
182 if self._caught and exc_type is None:
183 raise KeyboardInterrupt
184 return False
185
186
188 """
189 Orchestrates the RadCluster_2_1 simulation workflow.
190
191 Parameters
192 ----------
193 I : int, optional — override max SIA cluster size
194 V : int, optional — override max vacancy cluster size
195 solver_mode : str, optional — 'full_system' | 'active_window'
196 physics_option : str, optional — 'full_CD_fission' | 'full_CD_fusion' |
197 'bin_moment_CD_fission' | 'bin_moment_CD_fusion'.
198 Mutually exclusive with the new (equations,
199 cascade) pair below.
200 equations : str, optional — 'discrete' | 'bin_moment'. Use together with
201 `cascade` instead of `physics_option`.
202 Legacy 'full_CD' is silently aliased to
203 'discrete'.
204 cascade : str, optional — 'fission' | 'fusion'. Use together with
205 `equations`.
206 excel_file : path-like, optional
207 C_floor : float, optional — concentration floor (default 1e-15).
208 Any state variable below this is clamped
209 before evaluating rate terms.
210 he_kinetics : str, optional — 'dynamic' (default) integrates free He as a
211 full ODE (Eq. 157); 'quasi_steady_state'
212 eliminates it via dc_h/dt = 0 (valid because
213 E_m_h = 0.06 eV gives rapid equilibration).
214 i_mobile : int, optional — max mobile SIA cluster size (default 1).
215 1 = mono-defect form; set > 1 to enable 1D
216 glide and SIA–SIA coalescence.
217 v_mobile : int, optional — max mobile vacancy cluster size (default 1).
218 1 = mono-defect form; set > 1 to enable
219 vacancy–vacancy coalescence.
220
221 Notes
222 -----
223 Two-layer RAG description. Alongside the legacy RateEquations /
224 BinMomentRateEquations objects that drive the C++ solver, every
225 RadClusterSimulation also carries the abstract-core + EUROFER-97
226 instantiation as ``self.material_rag`` (the reaction admissibility
227 graph G_Eur, paper Section 4) and ``self.state_layout`` (its
228 contiguous ODE-index map). This makes the graph-based formulation
229 the live structural description of every run. The RAG build is
230 advisory: it is wrapped in try/except so a RAG-build issue can never
231 break a run, and the C++ drive path is byte-identical with or
232 without it. ``material_rag`` / ``state_layout`` are ``None`` if the
233 build failed.
234 """
235
236 def __init__(self, I=None, V=None, solver_mode=None,
237 physics_option=None, equations=None, cascade=None,
238 excel_file=None,
239 C_floor=None, he_kinetics=None,
240 i_mobile=None, v_mobile=None,
241 **legacy_kw):
242 # Backward compatibility: accept old N/M and n_max_i/m_max_v kwargs
243 if I is None and 'N' in legacy_kw:
244 I = legacy_kw.pop('N')
245 if V is None and 'M' in legacy_kw:
246 V = legacy_kw.pop('M')
247 if i_mobile is None and 'n_max_i' in legacy_kw:
248 i_mobile = legacy_kw.pop('n_max_i')
249 if v_mobile is None and 'm_max_v' in legacy_kw:
250 v_mobile = legacy_kw.pop('m_max_v')
251
252 # Anything still in legacy_kw is a name this constructor does not
253 # know. Silently dropping it is dangerous: on 2026-08-02 a nine-case
254 # bin-moment refinement study passed i_discrete=/I_bin=/shape_function=
255 # here, had all nine silently ignored, produced nine bit-identical
256 # runs, and was very nearly reported as a structural failure of the
257 # bin-moment closure. Fail loudly instead.
258 if legacy_kw:
259 _BIN_CFG = {'i_discrete', 'v_discrete', 'I_bin', 'V_bin',
260 'shape_function', 'bin_ratio'}
261 bad = sorted(legacy_kw)
262 msg = (f"RadClusterSimulation() got unexpected keyword argument(s): "
263 f"{', '.join(bad)}")
264 if _BIN_CFG.intersection(bad):
265 msg += (
266 "\n Bin-moment configuration is NOT a constructor argument. "
267 "Set it on the workbook dict and rebuild:\n"
268 " sim.input_data.reactions['i_discrete'] = ...\n"
269 " sim.input_data._calculate_derived(); sim.rebuild_rates()\n"
270 " Read the values back from sim.rate_equations (NOT "
271 "sim.reaction_rates) to confirm they took effect.")
272 raise TypeError(msg)
273
274 # Resolve (equations, cascade) → physics_option. Either form is
275 # accepted, but not both at once.
276 if equations is not None or cascade is not None:
277 if physics_option is not None:
278 raise ValueError(
279 "Pass either physics_option= OR (equations=, cascade=), "
280 "not both.")
281 if equations is None or cascade is None:
282 raise ValueError(
283 "Both equations= and cascade= must be provided together "
284 "(or use physics_option= instead).")
285 from .input_data import make_physics_option
286 physics_option = make_physics_option(equations, cascade)
287
288 print("Initializing RadCluster_2_1 simulation…")
289 kwargs = {}
290 if excel_file is not None:
291 kwargs['excel_file'] = excel_file
292
294 I=I, V=V,
295 solver_mode=solver_mode,
296 physics_option=physics_option,
297 **kwargs
298 )
299
300 # Inject optional overrides before building rate equations
301 if i_mobile is not None:
302 self.input_data.diffusion['i_mobile'] = int(i_mobile)
303 self.input_data.derived['i_mobile'] = int(i_mobile)
304 if v_mobile is not None:
305 self.input_data.diffusion['v_mobile'] = int(v_mobile)
306 self.input_data.derived['v_mobile'] = int(v_mobile)
307 if C_floor is not None:
308 self.input_data.reactions['C_floor'] = float(C_floor)
309 if he_kinetics is not None:
310 validated = str(he_kinetics).lower()
311 if validated not in ('dynamic', 'quasi_steady_state'):
312 import warnings
313 warnings.warn(f"Unknown he_kinetics='{he_kinetics}'. Using 'dynamic'.")
314 validated = 'dynamic'
315 self.input_data.reactions['he_kinetics'] = validated
316
318
319 po = self.input_data.physics_option
320 if 'bin_moment' in po:
322 self.input_data, self.reaction_rates
323 )
324 else:
326 self.input_data, self.reaction_rates
327 )
328
329 # ── Two-layer RAG description (abstract core + EUROFER-97) ───────────
330 # ADDITIVE: build the EUROFER-97 reaction admissibility graph and its
331 # StateLayout alongside the legacy RateEquations object above. This
332 # does NOT touch the C++ drive path — it only attaches the graph-based
333 # structural description of the run to the simulation object. Wrapped
334 # in try/except so a RAG-build issue can never break a run.
335 self.material_rag = None
336 self.state_layout = None
337 try:
338 from .materials.eurofer97 import build_eurofer_rag
339 self.material_rag, self.state_layout = build_eurofer_rag(
340 self.input_data, self.reaction_rates,
341 equations=self.input_data.equations,
342 cascade=self.input_data.cascade,
343 )
344 print(f"Material RAG built: {self.material_rag.summary()}")
345 except Exception as exc: # noqa: BLE001 — advisory build, never fatal
346 import warnings
347 warnings.warn(
348 f"EUROFER-97 RAG build failed ({exc!r}); continuing without "
349 "the two-layer structural description. The run is unaffected.")
350
351 # Always defined so notebook fallbacks like
352 # `sim._accumulated_results` don't AttributeError when run_adaptive
353 # is interrupted before it can initialize this attribute itself.
355
356 print(f"Simulation initialized: solver_mode='{self.input_data.solver_mode}'"
357 f" equations='{self.input_data.equations}'"
358 f" cascade='{self.input_data.cascade}'"
359 f" (physics_option='{po}')")
360
361 def rebuild_rates(self):
362 """Rebuild reaction rates and rate equations from current input_data.
363
364 Call this after modifying input_data parameter dicts and calling
365 input_data._calculate_derived() to ensure the solver uses the
366 updated rate constants.
367 """
369 po = self.input_data.physics_option
370 if 'bin_moment' in po:
372 self.input_data, self.reaction_rates
373 )
374 else:
376 self.input_data, self.reaction_rates
377 )
378
379 def _reconstruct_sia_per_size(self, sia_block):
380 """Reconstruct the per-size ½⟨111⟩ distribution c_n (n = 1..I) from a
381 SIA state block.
382
383 In discrete mode the block is already per-size and is returned (clipped)
384 as-is. In bin-moment mode the block is ``i_discrete`` discrete sizes
385 followed by ``I_bin`` bins of ``P`` moments each; the binned part is
386 expanded through the active intra-bin shape function, exactly as
387 cpp_bridge reconstructs the appended ⟨100⟩ block.
388 """
389 re_obj = self.rate_equations
390 I = self.input_data.I
391 col = np.maximum(np.asarray(sia_block, dtype=float), 0.0)
392 if 'bin_moment' not in self.input_data.physics_option:
393 c = np.zeros(I)
394 c[:min(I, col.size)] = col[:I]
395 return c
396 from .bin_moment_rates import reconstruct_distribution
397 i_d = int(getattr(re_obj, 'i_discrete', 0))
398 P = int(getattr(re_obj, 'n_mom', 2))
399 bins = getattr(re_obj, 'bins', [])
400 I_bin = len(bins)
401 c = np.zeros(I)
402 c[:i_d] = col[:i_d]
403 if I_bin > 0:
404 mom = col[i_d:i_d + P * I_bin]
405 mu0 = mom[0::P][:I_bin]
406 mu1 = mom[1::P][:I_bin] if P >= 2 else None
407 mu2 = mom[2::P][:I_bin] if P >= 3 else None
408 rec = reconstruct_distribution(
409 getattr(re_obj, 'shape_function', 'linear'),
410 mu0, mu1, mu2, bins, I)
411 c[i_d:] = rec[i_d:]
412 return c
413
414 def _loop_network_update(self, results, dt):
415 """Operator-split advance of the dynamic network density ρ_net between
416 integration segments (loop_network_loss.tex §Implementation).
417
418 ρ_net is held piecewise-constant *within* a segment (so the Jacobian /
419 Woodbury structure is untouched) and advanced *between* segments by an
420 explicit Euler step of Eq. (rho_net_balance):
421
422 ρ_net ← ρ_net + dt · [ (π/Ω) Σ d_n Λ_n c_n − K_rec ρ_net^{3/2} ]
423
424 The segment-end SIA/vacancy monomers are frozen into ``ci1_seg`` /
425 ``cv1_seg`` (they set the network climb velocity v_net for the next
426 segment), and ``rebuild_rates`` refreshes ReactionRates so the P4 sink
427 and Λ_n^net pick up the new ρ_net. No-op unless the channel is on.
428
429 The per-size loop distributions that drive the growth term come from the
430 post-processed segment and so work in BOTH discrete and bin-moment modes:
431 * ½⟨111⟩ — the SIA block of ``results['y']`` (per-size in discrete
432 mode; reconstructed from the SIA bin moments via the active shape
433 function in bin-moment mode);
434 * ⟨100⟩ — ``results['y_sia100']``, which cpp_bridge has already
435 reconstructed to per-size [I, n_t] in both modes. (Note ``results['y']``
436 no longer carries the appended ⟨100⟩ block — cpp_bridge splits it
437 off — so it must be read from this dedicated key, not the y tail.)
438 """
439 rr = self.reaction_rates
440 if not getattr(rr, 'loop_network_loss', False) or dt <= 0.0:
441 return
442 re_obj = self.rate_equations
443 I = self.input_data.I
444 y_end = np.asarray(results['y'][:, -1], dtype=float)
445 i_vac = int(getattr(re_obj, 'i_VAC', I))
446 ci1 = float(y_end[0]) if y_end.size > 0 else 0.0
447 cv1 = float(y_end[i_vac]) if y_end.size > i_vac else 0.0
448
449 # Per-size ½⟨111⟩ loop distribution (the SIA block precedes the VAC block).
450 c_111 = self._reconstruct_sia_per_size(y_end[:i_vac])
451
452 # Per-size ⟨100⟩ loop distribution — already reconstructed by cpp_bridge
453 # (per-size [I, n_t] in both discrete and bin-moment modes).
454 y100 = results.get('y_sia100')
455 c_100 = None
456 if y100 is not None and np.asarray(y100).size:
457 c_100 = np.asarray(y100, dtype=float)[:, -1]
458
459 drho = rr.loop_network_drho_dt(c_111, c_100)
460 rho_floor = float(self.input_data.reactions.get('rho_d', 1.0e14))
461 # Ceiling guards against ρ_net runaway when K_rec is small/uncalibrated:
462 # the gain term grows ∝ ρ_net (Λ ∝ ρ_net), so without the mandatory
463 # recovery sink ρ_net would diverge and drive the sinks numerically
464 # stiff. Default ceiling = a physically large network density.
465 from .reaction_rates import _num
466 rho_max = _num(self.input_data.reactions.get('loop_net_rho_max', 1.0e16),
467 1.0e16)
468 rho_new = min(max(float(rr.rho_net) + dt * drho, rho_floor), rho_max)
469
470 re = self.input_data.reactions
471 re['rho_net'] = rho_new
472 re['ci1_seg'] = ci1
473 re['cv1_seg'] = cv1
474 self.rebuild_rates()
475
476 def _resize_domain(self, I_new, V_new):
477 """Resize the cluster domain and rebuild all rate objects."""
478 self.input_data.reactions['I'] = int(I_new)
479 self.input_data.reactions['V'] = int(V_new)
480 # InputData caches I, V as properties from reactions dict
481 self.rebuild_rates()
482
483 def _boundary_fraction_at(self, results, idx):
484 """
485 Compute tail fractions at a specific time index.
486
487 Returns (frac_I, frac_V) — fraction of the total SIA/vacancy content
488 in the last 10% of sizes. Values > ~0.01 indicate boundary pileup.
489 """
490 y = results.get('y')
491 if y is None:
492 return 0.0, 0.0
493
494 re = self.rate_equations
495 is_bin = hasattr(re, 'bins')
496 I, V = self.input_data.I, self.input_data.V
497 ns = np.arange(1, I + 1, dtype=float)
498 ms = np.arange(1, V + 1, dtype=float)
499
500 yj = np.maximum(y[:, idx], 0.0)
501
502 if is_bin:
503 from .bin_moment_rates import reconstruct_distribution
504 I_bin = getattr(re, 'I_bin', getattr(re, 'K', 0))
505 V_bin = getattr(re, 'V_bin', getattr(re, 'K_v', 0))
506 P = getattr(re, 'n_mom', 2)
507 sf = getattr(re, 'shape_function', 'linear')
508 i_d = getattr(re, 'i_discrete', 0)
509 v_d = getattr(re, 'v_discrete', 0)
510 iv = getattr(re, 'i_VAC', i_d + P * I_bin)
511
512 # SIA: discrete + binned reconstruction
513 if I_bin > 0:
514 mom = yj[i_d:i_d + P * I_bin]
515 mu0 = mom[0::P][:I_bin]
516 mu1 = mom[1::P][:I_bin] if P >= 2 else None
517 mu2 = mom[2::P][:I_bin] if P >= 3 else None
518 c_n = reconstruct_distribution(sf, mu0, mu1, mu2, re.bins, I)
519 c_n[:i_d] = yj[:i_d]
520 else:
521 c_n = np.zeros(I)
522 c_n[:i_d] = yj[:i_d]
523
524 # VAC: discrete + binned reconstruction
525 if V_bin > 0:
526 vac_start = iv + v_d
527 vmom = yj[vac_start:vac_start + P * V_bin]
528 vmu0 = vmom[0::P][:V_bin]
529 vmu1 = vmom[1::P][:V_bin] if P >= 2 else None
530 vmu2 = vmom[2::P][:V_bin] if P >= 3 else None
531 c_v = reconstruct_distribution(sf, vmu0, vmu1, vmu2,
532 re.vac_bins, V)
533 c_v[:v_d] = yj[iv:iv + v_d]
534 else:
535 # All discrete vacancies: only v_discrete entries stored
536 c_v = np.zeros(V)
537 c_v[:v_d] = yj[iv:iv + v_d]
538 else:
539 c_n = yj[:I]
540 c_v = yj[I:I + V]
541
542 # Fraction of content in last 10% of sizes
543 tail_n = int(max(I * 0.1, 1))
544 tail_m = int(max(V * 0.1, 1))
545 total_sia = np.dot(ns, np.maximum(c_n, 0.0))
546 total_vac = np.dot(ms, np.maximum(c_v, 0.0))
547 tail_sia = np.dot(ns[-tail_n:], np.maximum(c_n[-tail_n:], 0.0))
548 tail_vac = np.dot(ms[-tail_m:], np.maximum(c_v[-tail_m:], 0.0))
549
550 # Guard: if total content is at floor level (all concentrations ≈ C_floor),
551 # the tail fraction is a floor artifact, not real boundary pileup.
552 # Require total content to be above I * C_floor * I (roughly I^2 * C_floor)
553 # to distinguish real content from uniform-floor noise.
554 C_floor = float(self.input_data.reactions.get('C_floor', 1e-15))
555 sia_threshold = I * I * C_floor * 10.0
556 vac_threshold = V * V * C_floor * 10.0
557 frac_I = tail_sia / max(total_sia, 1e-300) if total_sia > sia_threshold else 0.0
558 frac_V = tail_vac / max(total_vac, 1e-300) if total_vac > vac_threshold else 0.0
559 return frac_I, frac_V
560
561 def _boundary_fraction(self, results):
562 """Check tail fractions at the last time step (backward compat)."""
563 y = results.get('y')
564 if y is None:
565 return 0.0, 0.0
566 return self._boundary_fraction_at(results, -1)
567
568 def _find_first_exceedance(self, results, threshold):
569 """
570 Scan all output time points and return the index of the first one
571 where either the SIA or vacancy tail fraction exceeds *threshold*.
572
573 Returns None if no exceedance is found.
574 """
575 y = results.get('y')
576 if y is None:
577 return None
578 n_t = y.shape[1]
579 for j in range(n_t):
580 frac_I, frac_V = self._boundary_fraction_at(results, j)
581 if frac_I > threshold or frac_V > threshold:
582 return j
583 return None
584
585 @staticmethod
586 def _sia100_column(results, idx):
587 """The ⟨100⟩ block AS SOLVED at time index *idx*, or None if absent.
588
589 Reads ``y_sia100_raw`` (the raw appended block) and never ``y_sia100``,
590 which is a per-size closure reconstruction with the C_floor IC already
591 subtracted — fine for plots, wrong as an initial condition.
592 """
593 blk = results.get('y_sia100_raw')
594 if blk is None:
595 return None
596 blk = np.asarray(blk, dtype=float)
597 if blk.ndim != 2 or blk.size == 0:
598 return None
599 return blk[:, idx]
600
601 def _resume_state(self, results, idx=-1):
602 """Full C++ state vector at time index *idx*, ready to resume from.
603
604 ``results['y']`` has the appended ⟨100⟩ block split off by cpp_bridge,
605 so resuming from it alone hands the solver a SHORT y0. The C++ reads
606 missing ``y0_k`` keys as 1e-100 (parameters.h), so this did not fail —
607 it silently erased the entire ⟨100⟩ population at every segment
608 boundary, which the population then had to regrow from nothing. That
609 produced the once-per-segment sawtooth in the ⟨100⟩ number density and
610 a violent restart transient for CVODE. Re-attach the block here.
611 """
612 y = np.asarray(results['y'], dtype=float)[:, idx]
613 blk = self._sia100_column(results, idx)
614 return y if blk is None else np.concatenate([y, blk])
615
616 def _expand_state(self, y_old, I_new, V_new, y100_old=None):
617 """
618 Map ODE state from the current domain to a larger (I_new, V_new) domain.
619
620 1. Reconstruct per-size distributions from old state.
621 2. Pad with C_floor for new sizes beyond old I, V.
622 3. Resize domain (rebuilds bins and rate equations).
623 4. Project padded distributions onto new bins.
624
625 Returns y0_new for the enlarged domain.
626
627 Parameters
628 ----------
629 y100_old : ndarray or None
630 The appended ⟨100⟩ block AS SOLVED (``results['y_sia100_raw']``),
631 i.e. per-size in discrete modes and discrete-prefix + bin moments in
632 bin-moment modes. When given it is remapped by the SAME
633 reconstruct → pad → project path as the ½⟨111⟩ block (the two share
634 a layout) and appended to the returned state, so the C++ receives a
635 y0 of the full width. Omitting it returns the ⟨100⟩-free state,
636 which the solver zero-fills to 1e-100 — that erases the population
637 at the doubling, so pass it whenever loop conversion is on.
638 """
639 re_old = self.rate_equations
640 is_bin = hasattr(re_old, 'bins')
641 I_old = self.input_data.I
642 V_old = self.input_data.V
643 C_floor = float(self.input_data.reactions.get('C_floor', 1e-15))
644
645 yj = np.maximum(y_old, 0.0)
646
647 if is_bin:
648 from .bin_moment_rates import (reconstruct_distribution,
649 moments_from_distribution)
650 K_i_old = re_old.K_i
651 K_v_old = re_old.K_v
652 iv_old = re_old.i_VAC
653 P_old = getattr(re_old, 'n_mom', 2)
654 sf_old = getattr(re_old, 'shape_function', 'linear')
655 i_d_old = getattr(re_old, 'i_discrete', 0)
656 v_d_old = getattr(re_old, 'v_discrete', 0)
657
658 # Reconstruct SIA per-size from moments
659 sia_mom = yj[i_d_old:i_d_old + P_old * K_i_old]
660 sia_mu0 = sia_mom[0::P_old][:K_i_old]
661 sia_mu1 = sia_mom[1::P_old][:K_i_old] if P_old >= 2 else None
662 sia_mu2 = sia_mom[2::P_old][:K_i_old] if P_old >= 3 else None
663 c_n_old = reconstruct_distribution(sf_old, sia_mu0, sia_mu1,
664 sia_mu2, re_old.bins, I_old)
665 c_n_old[:i_d_old] = yj[:i_d_old]
666
667 # ⟨100⟩ block — layout matches the ½⟨111⟩ block, but it must NOT go
668 # through the reconstruct → project path used above. That path
669 # round-trips the moments through `reconstruct_distribution`, whose
670 # hat closure clips its negative lobe to zero; the clip is what
671 # breaks μ₀ conservation. For the ½⟨111⟩ population the effect is
672 # mild (broad distribution, n̄ near bin centres). The ⟨100⟩
673 # population is the opposite — sessile, one-way, sharply peaked and
674 # advecting in size space — so n̄ sits far from the bin centre and
675 # the clip is catastrophic: measured ×176.9 inflation of μ₀ on the
676 # real state at 1.9 dpa of the I=40000 run (2026-08-15), which is
677 # exactly the ×177 jump in N_100 seen across that run's doubling.
678 # Remap its moments conservatively instead — no per-size detour.
679 mu0_100 = mu1_100 = None
680 y100j = None
681 if y100_old is not None:
682 y100j = np.maximum(np.asarray(y100_old, dtype=float), 0.0)
683 m100 = y100j[i_d_old:i_d_old + P_old * K_i_old]
684 mu0_100 = m100[0::P_old][:K_i_old].astype(float)
685 mu1_100 = (m100[1::P_old][:K_i_old].astype(float)
686 if P_old >= 2 else None)
687
688 # Reconstruct vacancy per-size from moments
689 vac_mom = yj[iv_old + v_d_old:iv_old + v_d_old + P_old * K_v_old]
690 vmu0 = vac_mom[0::P_old][:K_v_old]
691 vmu1 = vac_mom[1::P_old][:K_v_old] if P_old >= 2 else None
692 vmu2 = vac_mom[2::P_old][:K_v_old] if P_old >= 3 else None
693 c_v_old = reconstruct_distribution(sf_old, vmu0, vmu1, vmu2,
694 re_old.vac_bins, V_old)
695 c_v_old[:v_d_old] = yj[iv_old:iv_old + v_d_old]
696
697 # Extract He state
698 if re_old.he_mode == 'case2':
699 Q_tot = yj[re_old.i_Qtot]
700 Q_k_old = None
701 q_n_old = None
702 else:
703 Q_k_old = yj[re_old.i_Q:re_old.i_Q + K_v_old]
704 Q_tot = np.sum(Q_k_old)
705 # Case 1 tracks per-bin mean He loading. Reconstruct the
706 # per-size He distribution q_n = ell_bin_k * c_v_n from the
707 # OLD bins (ell_bin_k = Q_k / mu0_k), so the per-bin mean He
708 # loading is preserved rather than smeared across the new
709 # domain proportionally to mu0. Same reconstruct->project
710 # pattern as the SIA/VAC moment blocks above.
711 q_n_old = np.zeros(V_old)
712 for k, (mlo, mhi) in enumerate(re_old.vac_bins):
713 mu0_k = vmu0[k]
714 if mu0_k <= 0.0:
715 continue
716 ell_bin = Q_k_old[k] / mu0_k
717 ns = np.arange(mlo, mhi)
718 idx = ns - 1
719 valid = (idx >= 0) & (idx < V_old)
720 q_n_old[idx[valid]] = ell_bin * c_v_old[idx[valid]]
721
722 c_h = None if re_old.qss_He else yj[re_old.i_He]
723
724 # Extend distributions to new domain (pad with C_floor)
725 c_n_new = np.full(I_new, C_floor)
726 c_n_new[:I_old] = c_n_old
727 c_v_new = np.full(V_new, C_floor)
728 c_v_new[:V_old] = c_v_old
729 # Per-size He distribution padded with 0 (new sizes hold no He)
730 if q_n_old is not None:
731 q_n_new = np.zeros(V_new)
732 q_n_new[:V_old] = q_n_old
733 else:
734 q_n_new = None
735
736 # Resize domain — rebuilds bins and rate equations
737 self._resize_domain(I_new, V_new)
738 re_new = self.rate_equations
739 P_new = re_new.n_mom
740
741 # Project onto new bins
742 sia_mu0_new, sia_mu1_new, sia_mu2_new = moments_from_distribution(
743 c_n_new, re_new.bins, n_mom=P_new)
744 vac_mu0_new, vac_mu1_new, vac_mu2_new = moments_from_distribution(
745 c_v_new, re_new.vac_bins, n_mom=P_new)
746
747 # Build new state vector
748 y0 = np.full(re_new.N_eq, C_floor)
749 # Discrete SIA
750 i_d_new = re_new.i_discrete
751 y0[:i_d_new] = c_n_new[:i_d_new]
752 # Binned SIA moments
753 for k in range(re_new.K_i):
754 y0[i_d_new + P_new * k] = sia_mu0_new[k]
755 if P_new >= 2 and sia_mu1_new is not None:
756 y0[i_d_new + P_new * k + 1] = sia_mu1_new[k]
757 if P_new >= 3 and sia_mu2_new is not None:
758 y0[i_d_new + P_new * k + 2] = sia_mu2_new[k]
759 # Discrete VAC
760 iv_new = re_new.i_VAC
761 v_d_new = re_new.v_discrete
762 y0[iv_new:iv_new + v_d_new] = c_v_new[:v_d_new]
763 # Binned VAC moments
764 vac_start_new = iv_new + v_d_new
765 for k in range(re_new.K_v):
766 y0[vac_start_new + P_new * k] = vac_mu0_new[k]
767 if P_new >= 2 and vac_mu1_new is not None:
768 y0[vac_start_new + P_new * k + 1] = vac_mu1_new[k]
769 if P_new >= 3 and vac_mu2_new is not None:
770 y0[vac_start_new + P_new * k + 2] = vac_mu2_new[k]
771
772 # He state
773 if re_new.he_mode == 'case2':
774 y0[re_new.i_Qtot] = Q_tot
775 else:
776 # Case 1: re-project the reconstructed per-size He
777 # distribution q_n onto the NEW vacancy bins. Q_k for a
778 # bin is exactly Σ_{n∈bin} q_n, i.e. the zeroth moment of
779 # q_n over that bin — this preserves the per-bin mean He
780 # loading instead of smearing Q_tot across bins by mu0.
781 if q_n_new is not None:
782 Q_k_new, _, _ = moments_from_distribution(
783 q_n_new, re_new.vac_bins, n_mom=1)
784 for k in range(re_new.K_v):
785 y0[re_new.i_Q + k] = Q_k_new[k]
786 if not re_new.qss_He and c_h is not None:
787 y0[re_new.i_He] = c_h
788
789 # Carry the 5 conservation accounting integrals (J_SIA_fixed,
790 # J_SIA_mutual, J_VAC_fixed, J_VAC_mutual, J_He_sink — last 5
791 # state entries) across the doubling; resetting them to C_floor
792 # corrupts delta_FP / delta_He for the remainder of the run.
793 y0[-5:] = yj[-5:]
794
795 if mu0_100 is None:
796 return y0
797 # Conservative moment remap (see the note where mu0_100 is read).
798 mu0_n, mu1_n = _remap_bin_moments(mu0_100, mu1_100,
799 re_old.bins, re_new.bins)
800 blk = np.full(i_d_new + P_new * re_new.K_i, C_floor)
801 # i_discrete does not change on a doubling, so the discrete prefix
802 # carries over one-for-one; guard the general case anyway.
803 n_pre = min(i_d_new, i_d_old, y100j.size)
804 blk[:n_pre] = y100j[:n_pre]
805 for k in range(re_new.K_i):
806 blk[i_d_new + P_new * k] = mu0_n[k]
807 if P_new >= 2:
808 blk[i_d_new + P_new * k + 1] = mu1_n[k]
809 if P_new >= 3:
810 # No independent μ₂ transport in the remap: place it
811 # consistently with the bin's own mean so the lognormal
812 # closure sees a self-consistent triple rather than a
813 # μ₂ inherited from a different binning.
814 nb = (mu1_n[k] / mu0_n[k]) if mu0_n[k] > 0 else 0.0
815 blk[i_d_new + P_new * k + 2] = mu0_n[k] * nb * nb
816 return np.concatenate([y0, blk])
817
818 else:
819 # Full per-size mode — pad state vector directly
820 c_n_old = yj[:I_old]
821 c_v_old = yj[I_old:I_old + V_old]
822
823 if re_old.he_mode == 'case2':
824 Q_tot = yj[re_old.i_Qtot]
825 Q_m_old = None
826 else:
827 Q_m_old = yj[re_old.i_Q:re_old.i_Q + V_old]
828 Q_tot = np.sum(Q_m_old)
829 c_h = None if re_old.qss_He else yj[re_old.i_He]
830
831 # Resize domain
832 self._resize_domain(I_new, V_new)
833 re_new = self.rate_equations
834
835 y0 = np.full(re_new.N_eq, C_floor)
836 y0[:I_old] = c_n_old
837 y0[I_new:I_new + V_old] = c_v_old
838
839 if re_new.he_mode == 'case2':
840 y0[re_new.i_Qtot] = Q_tot
841 else:
842 y0[re_new.i_Q:re_new.i_Q + V_old] = Q_m_old
843 if not re_new.qss_He and c_h is not None:
844 y0[re_new.i_He] = c_h
845
846 # Carry the 5 conservation accounting integrals across the
847 # doubling (see bin-moment branch above).
848 y0[-5:] = yj[-5:]
849
850 if y100_old is None:
851 return y0
852 # Discrete mode: the ⟨100⟩ block is already per-size, so widening it
853 # is a pad, exactly as for the ½⟨111⟩ block.
854 y100j = np.maximum(np.asarray(y100_old, dtype=float), 0.0)
855 blk = np.full(I_new, C_floor)
856 blk[:min(I_old, y100j.size)] = y100j[:min(I_old, y100j.size)]
857 return np.concatenate([y0, blk])
858
859 # ── Time-series keys that should be concatenated when merging segments ────
860
861 _TS_KEYS = {'t', 'dose', 'rho_net', 'C_SIA_tot', 'C_VAC_tot', 'C_He_tot',
862 'C_He_free', 'mean_n_i', 'mean_n_v', 'N_loops', 'N_voids',
863 'swelling', 'C_i1', 'C_v1', 'delta_FP', 'delta_He',
864 'J_SIA_fixed', 'J_SIA_mutual', 'J_VAC_fixed', 'J_VAC_mutual',
865 'J_He_sink',
866 # loop-conversion series (⟨100⟩ block); omitting these left
867 # them holding only the LAST segment, so their length no
868 # longer matched len(t) and every loop-character plot broke.
869 'N_loops_111', 'N_loops_100', 'mean_n_111', 'mean_n_100',
870 'f_111_loop',
871 # the separated conservation arms — same defect
872 'delta_FP_sia', 'delta_FP_vac'}
873
874 # 2-D [size, time] arrays: sliced by column, merged with row zero-padding
875 # so a domain doubling (which grows the size axis) still concatenates.
876 _TS_KEYS_2D = {'y_sia100', 'y_sia100_raw'}
877
878 def _slice_results(self, results, start, end):
879 """Slice a results dict to time indices [start:end)."""
880 sliced = {}
881 n_t = len(results['t'])
882 for key, val in results.items():
883 if key in self._TS_KEYS and isinstance(val, np.ndarray):
884 sliced[key] = val[start:end]
885 elif (key == 'y' or key in self._TS_KEYS_2D) and \
886 isinstance(val, np.ndarray) and val.ndim == 2:
887 sliced[key] = val[:, start:end]
888 else:
889 sliced[key] = val
890 return sliced
891
892 def _merge_results(self, accumulated, new_segment):
893 """
894 Merge two results dicts by concatenating time-series arrays.
895
896 The first time point of *new_segment* is skipped if it duplicates
897 the last time point of *accumulated* (continuation overlap).
898 The raw state ``y`` is decomposed into SIA / VAC / He blocks,
899 each zero-padded independently to the larger layout, then
900 reassembled and concatenated so that plots cover the full dose
901 range even after domain-doubling.
902 """
903 if accumulated is None:
904 return new_segment
905 if new_segment is None:
906 return accumulated
907
908 # Detect overlap: first point of new matches last of accumulated
909 skip = 0
910 if (len(accumulated['t']) > 0 and len(new_segment['t']) > 0 and
911 abs(new_segment['t'][0] - accumulated['t'][-1]) /
912 max(abs(accumulated['t'][-1]), 1e-30) < 1e-8):
913 skip = 1
914
915 merged = {}
916 for key in new_segment:
917 old_val = accumulated.get(key)
918 new_val = new_segment[key]
919 if key in self._TS_KEYS and isinstance(new_val, np.ndarray):
920 if old_val is not None and isinstance(old_val, np.ndarray):
921 merged[key] = np.concatenate([old_val, new_val[skip:]])
922 else:
923 merged[key] = new_val
924 elif key == 'y':
925 if old_val is not None and isinstance(old_val, np.ndarray):
926 merged[key] = self._merge_y_blocks(
927 accumulated, new_segment, skip)
928 else:
929 merged[key] = new_val
930 elif key in self._TS_KEYS_2D and isinstance(new_val, np.ndarray) \
931 and new_val.ndim == 2:
932 if old_val is not None and isinstance(old_val, np.ndarray) \
933 and old_val.ndim == 2:
934 merged[key] = self._concat_2d_padded(
935 old_val, new_val[:, skip:])
936 else:
937 merged[key] = new_val
938 else:
939 merged[key] = new_val
940 return merged
941
942 @staticmethod
943 def _concat_2d_padded(old, new):
944 """Concatenate two [size, time] arrays along time, zero-padding the
945 shorter size axis so a domain doubling does not break the merge."""
946 n_old, n_new = old.shape[0], new.shape[0]
947 if n_old < n_new:
948 old = np.vstack([old, np.zeros((n_new - n_old, old.shape[1]))])
949 elif n_new < n_old:
950 new = np.vstack([new, np.zeros((n_old - n_new, new.shape[1]))])
951 return np.concatenate([old, new], axis=1)
952
953 @staticmethod
954 def _merge_y_blocks(accumulated, new_segment, skip):
955 """
956 Remap old y to the new layout and concatenate.
957
958 Uses ``_y_i_VAC`` and ``_y_i_He`` stored in each results dict to
959 decompose y into SIA / VAC / He blocks, zero-pad each to the
960 larger size, then reassemble into a single array with the new
961 layout.
962 """
963 old_y = accumulated['y']
964 new_y = new_segment['y']
965
966 iv_old = accumulated.get('_y_i_VAC', old_y.shape[0])
967 ih_old = accumulated.get('_y_i_He', old_y.shape[0])
968 iv_new = new_segment.get('_y_i_VAC', new_y.shape[0])
969 ih_new = new_segment.get('_y_i_He', new_y.shape[0])
970
971 # The SIA/VAC/He layout indices are required to remap old y into
972 # the new layout. Silently returning only the latest segment here
973 # discards accumulated history and yields a `y` whose column count
974 # no longer matches len(t) — fail loudly instead.
975 if '_y_i_VAC' not in accumulated or '_y_i_VAC' not in new_segment:
976 missing = ('accumulated' if '_y_i_VAC' not in accumulated
977 else 'new_segment')
978 raise ValueError(
979 "_merge_y_blocks: '_y_i_VAC' missing from "
980 f"{missing} results dict — cannot remap y blocks without "
981 "the SIA/VAC/He layout indices. The merged y would have a "
982 "column count inconsistent with len(t).")
983
984 # Decompose into blocks
985 sia_old = old_y[:iv_old, :]
986 vac_old = old_y[iv_old:ih_old, :]
987 he_old = old_y[ih_old:, :]
988
989 sia_new = new_y[:iv_new, :]
990 vac_new = new_y[iv_new:ih_new, :]
991 he_new = new_y[ih_new:, :]
992
993 # Zero-pad each block of old to match new sizes
994 def _pad_rows(arr, target_rows):
995 if arr.shape[0] >= target_rows:
996 return arr[:target_rows, :]
997 pad = np.zeros((target_rows - arr.shape[0], arr.shape[1]))
998 return np.vstack([arr, pad])
999
1000 sia_old = _pad_rows(sia_old, iv_new)
1001 vac_old = _pad_rows(vac_old, ih_new - iv_new)
1002 he_n = he_new.shape[0]
1003 he_old = _pad_rows(he_old, he_n)
1004
1005 # Reassemble old y in new layout
1006 old_remapped = np.vstack([sia_old, vac_old, he_old])
1007 return np.concatenate([old_remapped, new_y[:, skip:]], axis=1)
1008
1009 def run_adaptive(self, solver_config=None, save_output=True,
1010 progress_callback=None, timeout_s=None,
1011 boundary_threshold=0.01, max_doublings=6,
1012 points_per_segment=10):
1013 """
1014 Truly adaptive domain doubling via short time-segments.
1015
1016 The total time span is divided into short segments of
1017 *points_per_segment* output points each. After every segment
1018 the tail fraction is checked. If it exceeds *boundary_threshold*:
1019
1020 1. The first exceedance point within the segment is located.
1021 2. Results up to that point are kept.
1022 3. I and/or V are doubled; the state vector is mapped to the
1023 larger domain via ``_expand_state``.
1024 4. Integration resumes from the exceedance time.
1025
1026 This allows starting with a small domain (e.g. I = V = 1000) and
1027 growing it on demand as the distribution spreads, without ever
1028 restarting from t = 0.
1029
1030 Parameters
1031 ----------
1032 boundary_threshold : float
1033 Adapt if tail fraction > this (default 0.01 = 1 %).
1034 max_doublings : int
1035 Maximum number of doublings (default 6; 1000 → 64 000).
1036 points_per_segment : int
1037 Output points per solver call (default 10). Smaller values
1038 detect exceedance earlier but add subprocess overhead.
1039
1040 Returns
1041 -------
1042 results : dict
1043 """
1044 if solver_config is None:
1045 solver_config = self._default_solver_config()
1046
1047 t_begin, t_end = solver_config['t_span']
1048 n_points_total = solver_config.get('n_points', 100)
1049 log_time = solver_config.get('log_time', True)
1050
1051 # Precompute checkpoint times (segment boundaries)
1052 n_segments = max(1, n_points_total // points_per_segment)
1053 if log_time and t_begin > 0:
1054 checkpoints = np.geomspace(t_begin, t_end, n_segments + 1)
1055 else:
1056 checkpoints = np.linspace(t_begin, t_end, n_segments + 1)
1057
1058 # REQUIRED TIMES (added 2026-09-06). The output grid is logarithmic in
1059 # TIME, so the doses a study reports are not generally on it. Measured:
1060 # a 40 dpa row with n_points=37 and points_per_segment=10 saves 28
1061 # points, of which only four lie above 1 dpa -- 0.954, 3.315, 11.51, 40.
1062 # A request to score at 15.72 dpa then silently took 11.51 dpa, 26.8%
1063 # low, because the state at 15.72 was never written. Nothing downstream
1064 # could repair that: it is missing data, not a lookup error.
1065 #
1066 # Making each required time a SEGMENT BOUNDARY puts it in the output
1067 # exactly, because every segment is integrated to its own endpoint.
1068 # That is why this belongs here and not in a post-hoc resample: the
1069 # solver has to stop there. Everything else is unchanged -- the log
1070 # spacing, points_per_segment, and the segment machinery all still
1071 # apply, with a few extra boundaries in the range that matters.
1072 req = solver_config.get('required_times') or ()
1073 req = [float(x) for x in req
1074 if t_begin < float(x) < t_end * (1 - 1e-12)]
1075 if req:
1076 checkpoints = np.unique(np.concatenate([checkpoints, req]))
1077 # n_segments is now len-1, and max_iters below is derived from it;
1078 # leaving the old value would cap the loop before the last segment.
1079 n_segments = len(checkpoints) - 1
1080
1081 # ROW-LEVEL DEADLINE, not a per-segment one. timeout_s used to be handed
1082 # to EVERY segment unchanged, so a run could legally take
1083 # n_segments x timeout_s: measured 2026-08-06, rows of 16108-20503 s
1084 # completed under a 12000 s setting, and the "time limit" the campaign
1085 # was designed around never bit. Budget it across the whole run instead
1086 # and let the last segment be cut gracefully -- the solver flushes what
1087 # it has, so the row still contributes every dose-ladder rung it reached
1088 # (plan S12(s)).
1089 import time as _time
1090 _deadline = (_time.monotonic() + timeout_s) if timeout_s else None
1091
1092 accumulated = None
1093 self._accumulated_results = None # expose for graceful interrupt
1094 current_t = t_begin
1095 y0_override = None
1096 n_doublings_I = 0 # independent doubling counters
1097 n_doublings_V = 0
1098 seg_count = 0
1099 interrupted = False
1100 _deferred_exc = None # a crash is re-raised AFTER the save block
1101 # Allow extra iterations for expansion restarts within segments
1102 max_iters = n_segments + 2 * max_doublings + 1
1103
1104 try:
1105 while current_t < t_end * (1 - 1e-10) and seg_count < max_iters:
1106 seg_count += 1
1107 I_cur = self.input_data.I
1108 V_cur = self.input_data.V
1109
1110 # Next checkpoint after current_t
1111 future = checkpoints[checkpoints > current_t * (1 + 1e-10)]
1112 seg_t_end = float(future[0]) if len(future) > 0 else t_end
1113
1114 print(f"\n[Segment {seg_count}] I={I_cur} V={V_cur}"
1115 f" t=[{current_t:.2e}, {seg_t_end:.2e}]")
1116
1117 seg_config = dict(solver_config)
1118 seg_config['t_span'] = (current_t, seg_t_end)
1119 seg_config['n_points'] = points_per_segment
1120
1121 # Whatever is LEFT of the row's budget, not the full budget again.
1122 if _deadline is not None:
1123 _left = _deadline - _time.monotonic()
1124 if _left <= 0:
1125 print(f" Row budget of {timeout_s:.0f}s exhausted after "
1126 f"{seg_count-1} segment(s) — stopping with what is "
1127 f"integrated so far.")
1128 interrupted = True
1129 break
1130 else:
1131 _left = None
1132
1133 results = self.run(
1134 solver_config=seg_config,
1135 save_output=False,
1136 progress_callback=progress_callback,
1137 timeout_s=_left,
1138 y0_override=y0_override,
1139 )
1140
1141 if results is None:
1142 print(" Solver failed — returning accumulated results.")
1143 if accumulated is not None and save_output:
1144 self._diag_text = self.reaction_rates.format_diagnostic(
1145 mean_n_i=accumulated['mean_n_i'][-1]
1146 if 'mean_n_i' in accumulated else None)
1147 self._save_output(accumulated, solver_config)
1148 return accumulated
1149
1150 # If this segment came back partial (Ctrl+C / timeout / signal),
1151 # honor the interrupt: merge what we have and stop the adaptive
1152 # loop instead of launching another segment.
1153 seg_partial = results.get('metadata', {}).get(
1154 'solver_stats', {}).get('partial', False)
1155 if seg_partial:
1156 print(" Segment ended early (interrupt or timeout) — "
1157 "stopping adaptive run.")
1158 accumulated = self._merge_results(accumulated, results)
1159 self._accumulated_results = accumulated
1160 interrupted = True
1161 break
1162
1163 # Operator-split advance of the dynamic network density ρ_net, once
1164 # per segment regardless of whether the domain doubles below (the
1165 # doubling branch does not reach the else-clause). No-op unless
1166 # LOOP_NETWORK_LOSS is on. Freezes the segment-end monomers and
1167 # rebuilds the rates so the next segment picks up the new ρ_net.
1169 results,
1170 float(results['t'][-1] - results['t'][0]))
1171
1172 # Check tail at the last point of this segment
1173 frac_I, frac_V = self._boundary_fraction_at(results, -1)
1174 can_double_I = frac_I > boundary_threshold and n_doublings_I < max_doublings
1175 can_double_V = frac_V > boundary_threshold and n_doublings_V < max_doublings
1176
1177 if can_double_I or can_double_V:
1178 # Find the first exceedance point within this segment
1179 exceed_idx = self._find_first_exceedance(
1180 results, boundary_threshold)
1181 if exceed_idx is None:
1182 exceed_idx = results['y'].shape[1] - 1
1183
1184 frac_I_ex, frac_V_ex = self._boundary_fraction_at(
1185 results, exceed_idx)
1186 t_exceed = results['t'][exceed_idx]
1187
1188 # Keep results up to and including exceedance
1189 partial = self._slice_results(results, 0, exceed_idx + 1)
1190 accumulated = self._merge_results(accumulated, partial)
1191 self._accumulated_results = accumulated
1192
1193 # Double each dimension independently if it exceeds AND
1194 # still has budget remaining
1195 do_I = frac_I_ex > boundary_threshold and n_doublings_I < max_doublings
1196 do_V = frac_V_ex > boundary_threshold and n_doublings_V < max_doublings
1197 I_new = I_cur * 2 if do_I else I_cur
1198 V_new = V_cur * 2 if do_V else V_cur
1199 which = []
1200 if do_I:
1201 which.append(f'I: {I_cur}→{I_new} [{n_doublings_I+1}/{max_doublings}]')
1202 n_doublings_I += 1
1203 if do_V:
1204 which.append(f'V: {V_cur}→{V_new} [{n_doublings_V+1}/{max_doublings}]')
1205 n_doublings_V += 1
1206 print(f" Exceedance at t={t_exceed:.3e}: "
1207 f"SIA={frac_I_ex:.3f} VAC={frac_V_ex:.3f}")
1208 print(f" Doubling: {', '.join(which)}")
1209
1210 # Map state to enlarged domain. The ⟨100⟩ block rides along —
1211 # see _resume_state below for why it must.
1212 y_at = results['y'][:, exceed_idx]
1213 y100_at = self._sia100_column(results, exceed_idx)
1214 y0_override = self._expand_state(y_at, I_new, V_new, y100_at)
1215 current_t = t_exceed
1216 else:
1217 # Segment OK (or both dimensions maxed out) — accumulate
1218 exceeded_but_maxed = []
1219 if frac_I > boundary_threshold and n_doublings_I >= max_doublings:
1220 exceeded_but_maxed.append(f'SIA={frac_I:.3f}')
1221 if frac_V > boundary_threshold and n_doublings_V >= max_doublings:
1222 exceeded_but_maxed.append(f'VAC={frac_V:.3f}')
1223 if exceeded_but_maxed:
1224 print(f" Tail: {', '.join(exceeded_but_maxed)} "
1225 f"(max doublings reached)")
1226 else:
1227 print(f" Tail OK: SIA={frac_I:.3f} VAC={frac_V:.3f}")
1228
1229 accumulated = self._merge_results(accumulated, results)
1230 self._accumulated_results = accumulated
1231 current_t = results['t'][-1]
1232 y0_override = self._resume_state(results, -1)
1233
1234 except KeyboardInterrupt:
1235 interrupted = True
1236 print("\n\n*** KeyboardInterrupt — stopping gracefully. ***")
1237 # Merge the last completed segment if it wasn't merged yet
1238 if results is not None and results is not accumulated:
1239 try:
1240 accumulated = self._merge_results(accumulated, results)
1241 self._accumulated_results = accumulated
1242 except Exception:
1243 pass # keep whatever was accumulated before
1244 except BaseException as _exc:
1245 # Same reasoning as the orchestrator: a crash in segment k must not
1246 # discard segments 1..k-1. A long adaptive run is exactly where
1247 # this costs most -- the accumulated trajectory can represent many
1248 # hours. Merge whatever the failing segment returned, fall through
1249 # to the normal save block below, and re-raise afterwards so the
1250 # error is still reported.
1251 interrupted = True
1252 _deferred_exc = _exc
1253 print(f"\n\n*** {type(_exc).__name__} in segment {seg_count} — "
1254 f"saving the {seg_count - 1} completed segment(s) before "
1255 f"re-raising. ***")
1256 if results is not None and results is not accumulated:
1257 try:
1258 accumulated = self._merge_results(accumulated, results)
1259 self._accumulated_results = accumulated
1260 except Exception:
1261 pass
1262
1263 # ── Finished (or interrupted) — save and return ───────────────────
1264 if accumulated is not None:
1265 I_final = self.input_data.I
1266 V_final = self.input_data.V
1267 tot = n_doublings_I + n_doublings_V
1268 status = "INTERRUPTED" if interrupted else "complete"
1269 n_pts = len(accumulated['t'])
1270 print(f"\nAdaptive run {status}: {seg_count} segments, "
1271 f"{tot} doublings (I×{n_doublings_I}, V×{n_doublings_V}), "
1272 f"final domain I={I_final} V={V_final}, "
1273 f"{n_pts} time points saved")
1274 if save_output:
1275 self._diag_text = self.reaction_rates.format_diagnostic(
1276 mean_n_i=accumulated['mean_n_i'][-1]
1277 if 'mean_n_i' in accumulated else None)
1278 self._save_output(accumulated, solver_config)
1279 elif interrupted:
1280 print("\nNo completed segments to save.")
1281
1282 # Re-raise a crash only now, so the traceback still reaches the caller
1283 # but the completed segments are already on disk. A KeyboardInterrupt
1284 # deliberately does NOT come through here: stopping early is a normal
1285 # outcome for this code and returning the partial trajectory is the
1286 # documented behaviour.
1287 if _deferred_exc is not None:
1288 raise _deferred_exc
1289
1290 return accumulated
1291
1292 # ── Main entry point ──────────────────────────────────────────────────────
1293
1294 def run(self, solver_config=None, save_output=True, progress_callback=None,
1295 timeout_s=None, y0_override=None):
1296 """
1297 Run the simulation using the configured solver mode and physics option.
1298
1299 Parameters
1300 ----------
1301 solver_config : dict, optional
1302 Keys: t_span, n_points, rtol, atol, log_time,
1303 solver_method (dict with linsol, window_*, etc.)
1304 save_output : bool
1305 Write timestamped output/ directory.
1306 progress_callback : callable or None
1307 Called once per output time step during the C++ solver run with a
1308 dict of concentrations and rate-breakdown values (atom fraction).
1309 Enables verbose mode in the C++ solver automatically.
1310 timeout_s : float or None
1311 Maximum wall-clock seconds for the C++ solver.
1312 y0_override : ndarray or None
1313 Custom initial conditions (adaptive continuation).
1314
1315 Returns
1316 -------
1317 results : dict (see post_process.calculate_derived_quantities)
1318 """
1319 if solver_config is None:
1320 solver_config = self._default_solver_config()
1321
1322 # Explicit kwarg wins; otherwise fall back to the config-level default
1323 # (added by _default_solver_config: 1 hour wall-clock cap).
1324 if timeout_s is None:
1325 timeout_s = solver_config.get('timeout_s')
1326
1327 sm = self.input_data.solver_mode
1328 print(f"\nLaunching solver_mode='{sm}' …")
1329
1330 _t0 = _time.perf_counter()
1331 results = None
1332 # Pre-clear any stash from a previous run so we don't accidentally
1333 # save stale data from an earlier interrupted run.
1335 try:
1336 if sm in ('full_system', 'active_window'):
1337 results = self._run_cpp(solver_config, progress_callback,
1338 timeout_s=timeout_s,
1339 y0_override=y0_override)
1340 else:
1341 raise ValueError(f"Unknown solver_mode='{sm}'. "
1342 "Use full_system or active_window.")
1343 except KeyboardInterrupt:
1344 # Defense-in-depth: cpp_bridge already catches Ctrl+C and returns
1345 # whatever it could parse, but a *second* Ctrl+C during its own
1346 # graceful-shutdown wait can propagate up to here. Recover from
1347 # the stash that cpp_bridge sets as soon as it has any data, so
1348 # the partial output still gets saved.
1349 print("\n*** KeyboardInterrupt at orchestrator — "
1350 "recovering partial output. ***")
1351 results = getattr(self, '_partial_results', None)
1352 except BaseException as _exc:
1353 # A CRASH MUST NOT COST THE TRAJECTORY. Ctrl+C was already handled
1354 # above, but any other failure -- solver segfault surfacing as an
1355 # exception, MemoryError, a parse error on a truncated stdout --
1356 # previously propagated with nothing written, discarding every hour
1357 # already spent. Recover cpp_bridge's stash, flush it to a
1358 # timestamped directory exactly as a normal run would, and only then
1359 # re-raise so the traceback still reaches the user.
1360 _partial = getattr(self, '_partial_results', None)
1361 print(f"\n*** {type(_exc).__name__} during solve — "
1362 f"{'flushing partial output' if _partial is not None else 'no partial output to save'}"
1363 f" before re-raising. ***")
1364 if _partial is not None:
1365 self._accumulated_results = _partial
1366 self._wall_clock_s = _time.perf_counter() - _t0
1367 try:
1368 self._diag_text = self.reaction_rates.format_diagnostic(
1369 mean_n_i=_partial['mean_n_i'][-1]
1370 if 'mean_n_i' in _partial else None)
1371 except Exception:
1372 self._diag_text = ''
1373 if save_output:
1374 try:
1375 self._save_output(_partial, solver_config)
1376 except Exception as _se: # never mask the real error
1377 print(f" (save failed: {type(_se).__name__}: {_se})")
1378 raise
1379 self._wall_clock_s = _time.perf_counter() - _t0
1380
1381 # Mirror the cpp_bridge stash to the run_adaptive-style attribute so
1382 # the notebook fallback `if results is None: results = sim._accumulated_results`
1383 # works for the non-adaptive path too.
1384 if results is not None:
1385 self._accumulated_results = results
1386 # Store diagnostic text for file output (no inline printing)
1387 try:
1388 self._diag_text = self.reaction_rates.format_diagnostic(
1389 mean_n_i=results['mean_n_i'][-1] if 'mean_n_i' in results else None
1390 )
1391 except Exception:
1392 self._diag_text = ''
1393 if save_output:
1394 self._save_output(results, solver_config)
1395
1396 return results
1397
1398 # ── C++ solver dispatch ───────────────────────────────────────────────────
1399
1400 def _run_cpp(self, solver_config, progress_callback=None, timeout_s=None,
1401 y0_override=None):
1402 """Invoke the C++ solver via cpp_bridge."""
1403 from . import cpp_bridge
1404 results = cpp_bridge.run_cpp_solver(
1405 self, solver_config, base_dir=BASE_DIR,
1406 progress_callback=progress_callback,
1407 timeout_s=timeout_s,
1408 y0_override=y0_override,
1409 )
1410 return results
1411
1412 # ── Default solver config ─────────────────────────────────────────────────
1413
1415 re = self.input_data.reactions
1416 sm = self.input_data.solver_mode
1417 po = self.input_data.physics_option
1418
1419 # Linear solver and window parameters from reactions sheet
1420 linsol = str(re.get('linsol', 'dense')).lower()
1421 w_w = int(float(re.get('window_width', 100)))
1422 C_exp = float(re.get('window_C_exp', 1e-18))
1423
1424 # Map solver mode to window_mode integer
1425 window_mode_map = {
1426 'full_system': 0,
1427 'active_window': 4,
1428 }
1429 win_mode = window_mode_map.get(sm, 0)
1430
1431 # For bin_moment, force gmres: dense/band scale poorly at large N_eq
1432 # and klu's sparse direct path is only wired up for full_CD.
1433 if 'bin_moment' in po:
1434 linsol = 'gmres'
1435
1436 t_begin = float(re.get('t_begin', 1e-8))
1437 t_end = float(re.get('t_end', 1e7))
1438
1439 return {
1440 't_span': (t_begin, t_end),
1441 'n_points': int(float(re.get('n_points', 200))),
1442 'log_time': bool(int(float(re.get('log_time', 1)))),
1443 'rtol': float(re.get('rtol', 1e-8)),
1444 'atol': float(re.get('atol', 1e-20)),
1445 # Wall-clock cap for the C++ solver; partial output is saved on
1446 # timeout. Override per-call via run(timeout_s=…) or by editing
1447 # this entry in solver_config.
1448 'timeout_s': float(re.get('timeout_s', 3600.0)),
1449 'solver_method': {
1450 'linsol': linsol,
1451 'window_mode': win_mode,
1452 'window_width': w_w,
1453 'concentration_threshold': C_exp,
1454 'window_pad': 10,
1455 # CVODE max internal step. Capping at t_end/10 prevents BDF
1456 # from speculating across many simulated decades in one step
1457 # (which leads to Newton failures at near-steady states).
1458 # 0 means no limit; override via solver_method['hmax'].
1459 'hmax': float(re.get('hmax', t_end / 10.0)),
1460 },
1461 }
1462
1463 # ── Output writing ────────────────────────────────────────────────────────
1464
1465 @staticmethod
1467 """Format a scalar for the provenance file."""
1468 if callable(v):
1469 return '<callable>'
1470 if isinstance(v, float):
1471 return f"{v:.6g}"
1472 if isinstance(v, (tuple, list)):
1473 return '[' + ', '.join(RadClusterSimulation._fmt_value(x) for x in v) + ']'
1474 return str(v)
1475
1476 @classmethod
1477 def _write_dict_block(cls, f, title, d):
1478 f.write(f"### {title}\n\n")
1479 if not d:
1480 f.write("_(empty)_\n\n")
1481 return
1482 for k in sorted(d.keys(), key=str):
1483 f.write(f"- {k}: {cls._fmt_value(d[k])}\n")
1484 f.write("\n")
1485
1486 def _machine_info(self):
1487 info = {
1488 'hostname': platform.node(),
1489 'platform': platform.platform(),
1490 'processor': platform.processor() or platform.machine(),
1491 'python': sys.version.split()[0],
1492 'cpu_count': os.cpu_count(),
1493 'omp_num_threads': os.environ.get('OMP_NUM_THREADS', '<unset>'),
1494 }
1495 try:
1496 import psutil
1497 vm = psutil.virtual_memory()
1498 info['ram_total_GB'] = round(vm.total / 1024**3, 2)
1499 info['ram_available_GB'] = round(vm.available / 1024**3, 2)
1500 proc = psutil.Process(os.getpid())
1501 info['process_rss_GB'] = round(proc.memory_info().rss / 1024**3, 3)
1502 except Exception:
1503 info['ram_total_GB'] = '<psutil unavailable>'
1504 return info
1505
1506 def _save_output(self, results, solver_config):
1507 """Write timestamped output directory.
1508
1509 Resilient to partial/interrupted runs: each artifact (provenance,
1510 summary, diagnostics, .npy, plots) is written under its own try/except
1511 so a failure in one does not abort the others.
1512
1513 The whole body runs under an `_InterruptDeferred` shield so that a
1514 Ctrl+C arriving mid-save does not abandon partially-written files —
1515 the current write completes, the rest of the artifacts are flushed,
1516 then the interrupt is re-raised for the caller. A second Ctrl+C in
1517 the same block restores the default handler so the user can still
1518 force-abort if a save step hangs.
1519 """
1520 with _InterruptDeferred():
1521 return self._save_output_impl(results, solver_config)
1522
1523 def _save_output_impl(self, results, solver_config):
1524 sm = self.input_data.solver_mode
1525 po = self.input_data.physics_option
1526 I = self.input_data.I
1527 V = self.input_data.V
1528 im = self.input_data.i_mobile
1529 vm = self.input_data.v_mobile
1530 ts = datetime.now().strftime('%Y%m%d_%H%M%S')
1531 domain = f"I{I}V{V}"
1532 mobility = f"im{im}vm{vm}"
1533 # OPTIONAL CALLER TAG. The label below is built from solver mode,
1534 # physics option, domain and mobility -- none of which distinguish the
1535 # rungs of a closure-refinement ladder, which differ only in
1536 # i_discrete/I_bin/v_discrete/V_bin. Without a tag the four Table-1
1537 # rungs of the verification study produce four directory names that
1538 # differ only by timestamp. Set `sim.run_tag = 'T1_B3'` before the run.
1539 tag = ''
1540 raw_tag = getattr(self, 'run_tag', None)
1541 if raw_tag:
1542 # Anything that could create a path level or confuse the label
1543 # parser is collapsed; the tag is a directory-name component.
1544 tag = '_' + re.sub(r'[^A-Za-z0-9._+-]+', '-', str(raw_tag)).strip('-_')[:48]
1545 # Tag interrupted runs in the directory name for easy identification.
1546 partial_tag = ''
1547 try:
1548 if results.get('metadata', {}).get('solver_stats', {}).get('partial'):
1549 partial_tag = '_PARTIAL'
1550 except Exception:
1551 pass
1552 # Tag sits directly after the timestamp: name order still equals time
1553 # order, and the rung is the first thing the eye reaches in a listing
1554 # of two dozen otherwise-identical run directories.
1555 label = f"{ts}{tag}_{sm}_{po}_{domain}_{mobility}{partial_tag}"
1556
1557 out_dir = BASE_DIR / 'output' / label
1558 plot_dir = out_dir / 'plots'
1559 plot_dir.mkdir(parents=True, exist_ok=True)
1560 print(f"Saving output to: {out_dir}")
1561 n_pts_have = len(results.get('t', [])) if isinstance(results, dict) else 0
1562 print(f" {n_pts_have} time points to save")
1563
1564 # ── Provenance (4-section layout) ─────────────────────────────────
1565 idata = self.input_data
1566 derived = {k: v for k, v in idata.derived.items() if not callable(v)}
1567
1568 # User selections — what the caller chose (post-override)
1569 user_selections = {
1570 'solver_mode': sm,
1571 'physics_option': po,
1572 'I': I,
1573 'V': V,
1574 'i_mobile': im,
1575 'v_mobile': vm,
1576 'i_discrete': idata.i_discrete,
1577 'v_discrete': idata.v_discrete,
1578 'I_bin': idata.I_bin,
1579 'V_bin': idata.V_bin,
1580 'shape_function': idata.shape_function,
1581 'L_He_max': idata.L_He_max,
1582 'alpha_He': idata.alpha_He,
1583 'he_kinetics': idata.reactions.get('he_kinetics', 'dynamic'),
1584 'C_floor': idata.reactions.get('C_floor', 1e-15),
1585 'boundary_flux': derived.get('boundary_flux'),
1586 'spectrum': derived.get('spectrum'),
1587 'T': derived.get('T'),
1588 'G': derived.get('G'),
1589 'excel_file': str(idata.excel_file.resolve()),
1590 }
1591
1592 # Solver configuration — flatten nested solver_method
1593 sc_flat = {k: v for k, v in solver_config.items() if k != 'solver_method'}
1594 for k, v in solver_config.get('solver_method', {}).items():
1595 sc_flat[f"solver_method.{k}"] = v
1596
1597 # Run statistics
1598 n_pts = results.get('metadata', {}).get('solver_stats', {}).get(
1599 'n_time_points', len(results.get('t', [])))
1600 run_stats = {
1601 'wall_clock_s': getattr(self, '_wall_clock_s', None),
1602 'n_time_points': n_pts,
1603 'timestamp': ts,
1604 }
1605 run_stats.update(self._machine_info())
1606 omp_used = results.get('metadata', {}).get('omp_threads_used')
1607 if omp_used is not None:
1608 run_stats['omp_threads_used'] = omp_used
1609 ssf = results.get('metadata', {}).get('solver_stats_final')
1610 if isinstance(ssf, dict):
1611 for k in ('steps', 'nfe', 'nni', 'nli', 'nli_per_nni',
1612 'npe', 'nps', 'ncfn', 'netf', 'nlsetup'):
1613 if k in ssf:
1614 run_stats[f'solver.{k}'] = ssf[k]
1615 # Mark interrupted runs explicitly so partial output is identifiable
1616 # in the provenance without having to compare time-point counts.
1617 partial = results.get('metadata', {}).get('solver_stats', {}).get('partial')
1618 if partial is not None:
1619 run_stats['partial'] = bool(partial)
1620 run_stats['run_status'] = 'interrupted' if partial else 'completed'
1621
1622 # ── 1. Provenance (always safe — pulls from self/input_data only) ────
1623 try:
1624 with open(out_dir / 'provenance.md', 'w', encoding='utf-8') as f:
1625 f.write(f"# RadCluster_2_1 run — {label}\n\n")
1626
1627 f.write("## (1) Material Data\n\n")
1628 f.write("_All input tables from the Excel workbook with parameter "
1629 "overrides applied._\n\n")
1630 self._write_dict_block(f, 'Production (Fission)', idata.production_fission)
1631 self._write_dict_block(f, 'Production (Fusion)', idata.production_fusion)
1632 self._write_dict_block(f, 'Energetics', idata.energetics)
1633 self._write_dict_block(f, 'Diffusion', idata.diffusion)
1634 self._write_dict_block(f, 'Dissociation', idata.dissociation)
1635 self._write_dict_block(f, 'Reactions', idata.reactions)
1636 self._write_dict_block(f, 'Derived (computed)', derived)
1637
1638 f.write("## (2) User Selections\n\n")
1639 self._write_dict_block(f, 'Run configuration', user_selections)
1640
1641 f.write("## (3) Solver Configuration\n\n")
1642 self._write_dict_block(f, 'Solver settings', sc_flat)
1643
1644 f.write("## (4) Run Statistics\n\n")
1645 self._write_dict_block(f, 'Runtime and machine', run_stats)
1646 print(f" ✓ provenance.md")
1647 except Exception as exc:
1648 print(f" ✗ provenance.md: {type(exc).__name__}: {exc}")
1649
1650 # ── 2. Binary results (.npy) — write FIRST so the raw data is safe ───
1651 # even if everything else fails. These are always present in `results`
1652 # for any non-empty run.
1653 try:
1654 t_arr = results.get('t')
1655 y_arr = results.get('y')
1656 if t_arr is not None and len(t_arr) > 0:
1657 np.save(str(out_dir / 'results_t.npy'), t_arr)
1658 if y_arr is not None:
1659 np.save(str(out_dir / 'results_y.npy'), y_arr)
1660 print(f" ✓ results_t.npy / results_y.npy")
1661 else:
1662 print(f" ✗ results_t.npy: no time points in results")
1663 except Exception as exc:
1664 print(f" ✗ results_*.npy: {type(exc).__name__}: {exc}")
1665
1666 # ── 3. Summary CSV ───────────────────────────────────────────────────
1667 try:
1668 import csv
1669 row = post_process.summary_csv_row(results, self.input_data,
1670 solver_label=f"{sm}/{po}")
1671 with open(out_dir / 'summary.csv', 'w', newline='', encoding='utf-8') as f:
1672 writer = csv.DictWriter(f, fieldnames=list(row.keys()))
1673 writer.writeheader()
1674 writer.writerow(row)
1675 print(f" ✓ summary.csv")
1676 except Exception as exc:
1677 print(f" ✗ summary.csv: {type(exc).__name__}: {exc}")
1678
1679 # ── 4. Diagnostics — guard each field access individually ────────────
1680 def _last(key, fmt='{:.6e}', mul=1.0):
1681 arr = results.get(key)
1682 if arr is None or len(arr) == 0:
1683 return '<unavailable>'
1684 try:
1685 return fmt.format(float(arr[-1]) * mul)
1686 except Exception:
1687 return '<unavailable>'
1688
1689 try:
1690 diag_path = out_dir / 'diagnostics.txt'
1691 with open(diag_path, 'w', encoding='utf-8') as f:
1692 f.write(f"# Diagnostics for {label}\n\n")
1693 f.write("## Rate constants\n")
1694 f.write(getattr(self, '_diag_text', '') + '\n\n')
1695 f.write("## Conservation diagnostics (final time step)\n")
1696 f.write(f"delta_FP = {_last('delta_FP')} (Frenkel pair)\n")
1697 f.write(f"delta_He = {_last('delta_He')} (He balance)\n\n")
1698 f.write("## Key results\n")
1699 f.write(f"Final dose: {_last('dose', '{:.4e}')} dpa\n")
1700 f.write(f"Swelling (final): {_last('swelling', '{:.6f}', 100.0)} %\n")
1701 f.write(f"C_He_tot (final): {_last('C_He_tot', '{:.3e}')} m^-3\n")
1702 f.write(f"mean_n_i (final): {_last('mean_n_i', '{:.2f}')}\n")
1703 f.write(f"mean_n_v (final): {_last('mean_n_v', '{:.2f}')}\n\n")
1704 prog = getattr(self, '_progress_rows', None)
1705 if prog:
1706 f.write(f"## Time-step diagnostics ({len(prog)} rows)\n")
1707 keys = list(prog[0].keys())
1708 f.write('\t'.join(keys) + '\n')
1709 for row in prog:
1710 f.write('\t'.join(f"{row.get(k, 0.0):.6e}" for k in keys) + '\n')
1711 print(f" ✓ diagnostics.txt")
1712 except Exception as exc:
1713 print(f" ✗ diagnostics.txt: {type(exc).__name__}: {exc}")
1714
1715 # ── 5. Plots — most likely to fail on degenerate partial data ────────
1716 try:
1717 from . import visualization
1718 visualization.save_all_plots(results, self.input_data,
1719 str(plot_dir), label=f"{sm}/{po}",
1720 rate_eq_obj=self.rate_equations)
1721 print(f" ✓ plots/")
1722 except Exception as exc:
1723 print(f" ✗ plots/: {type(exc).__name__}: {exc} "
1724 f"(numerical artifacts above are still safe)")
1725
1726 print(f"Output written to: {out_dir}")
1727 # Remembered so a caller that did not choose the name can still find
1728 # the directory (the verification runner attaches provenance.md and
1729 # summary.csv from here to the record it pushes to the campaign board).
1730 self._last_output_dir = out_dir
1731 return out_dir
__init__(self, msg=('Ctrl+C — finishing pending writes before exit. ' 'Press Ctrl+C again to force-abort.'))
__exit__(self, exc_type, exc, tb)
_merge_y_blocks(accumulated, new_segment, skip)
_merge_results(self, accumulated, new_segment)
_save_output(self, results, solver_config)
run(self, solver_config=None, save_output=True, progress_callback=None, timeout_s=None, y0_override=None)
_slice_results(self, results, start, end)
_save_output_impl(self, results, solver_config)
_find_first_exceedance(self, results, threshold)
_expand_state(self, y_old, I_new, V_new, y100_old=None)
_run_cpp(self, solver_config, progress_callback=None, timeout_s=None, y0_override=None)
__init__(self, I=None, V=None, solver_mode=None, physics_option=None, equations=None, cascade=None, excel_file=None, C_floor=None, he_kinetics=None, i_mobile=None, v_mobile=None, **legacy_kw)
run_adaptive(self, solver_config=None, save_output=True, progress_callback=None, timeout_s=None, boundary_threshold=0.01, max_doublings=6, points_per_segment=10)
_remap_bin_moments(mu0_old, mu1_old, bins_old, bins_new)
Definition simulation.py:57