RadCluster
Graph-based cluster dynamics for irradiated materials
Loading...
Searching...
No Matches
cpp_bridge.py
Go to the documentation of this file.
1"""
2cpp_bridge.py -- Python -> C++ solver bridge for RadCluster_2_1.
3
4Responsibilities
5----------------
61. Collect pre-computed rate constants and physics parameters from
7 InputData / ReactionRates and write them to a temporary parameter file.
82. Invoke solver.exe with --param_file=<path>.
93. Parse the binary output and reconstruct the standard results dict.
10
11Parameter file format
12---------------------
13One "key=value" entry per line. Arrays use indexed entries:
14 KVV_0=<val>, KVV_1=<val>, ...
15
16New fields vs. Eurofer_CD
17--------------------------
18- solver_mode: integer (0=full_system, 4=active_window)
19- physics_option: integer (0=full_CD_fission, 1=full_CD_fusion,
20 2=bin_moment_fission, 3=bin_moment_fusion)
21- A_sph, A_loop, A_1D, B_rot: geometric prefactors
22- trap_SIA, trap_VAC, trap_loop: solute trapping sums
23- K_1D_pref_k: 1D glide prefactor for SIA cluster k
24- Bin-moment parameters: I_bin, V_bin, i_discrete, v_discrete, r_ratio (computed)
25- i_mobile, v_mobile: mobility cutoffs
26"""
27
28import logging
29import os
30import signal as _signal
31import subprocess
32import sys
33import tempfile
34import threading
35import time
36from pathlib import Path
37
38import numpy as np
39
40log = logging.getLogger(__name__)
41
42# How long to wait for the C++ solver to finish its current step and emit
43# its final [stats] line after we ask it to stop. The solver checks the
44# interrupt flag once per output time-point, so the upper bound is roughly
45# one CVode(t_out) call -- typically seconds, but can be tens of seconds
46# when a single step has been struggling to converge.
47GRACEFUL_SHUTDOWN_TIMEOUT_S = 60.0
48
49
51 """Keep a solver.exe on a specific Windows processor group.
52
53 A SINGLE PIN AT SPAWN DOES NOT HOLD, which is the whole reason this is a
54 retry loop and not one call. Measured 2026-08-18: pinning 5 already-running
55 solvers moved the load immediately and permanently (group 1: 3.8 % ->
56 14.2 %), but the identical call issued straight after Popen left all 18
57 solvers of a fresh stage homed to group 0. The child's own startup -- CRT
58 and the OpenMP runtime, which is group-aware -- re-homes the thread after
59 CreateProcess returns, so a pin placed before that is simply overwritten.
60
61 So pin, then re-pin over the first seconds of the row, from a daemon thread
62 that never blocks the row. Rows run for hours; a few seconds on the wrong
63 socket costs nothing measurable.
64 """
65 group = os.environ.get("RADCLUSTER_CPU_GROUP", "").strip()
66 if not group or sys.platform != "win32":
67 return
68 def _keep():
69 for delay in (0.0, 0.5, 1.0, 2.0, 5.0, 10.0, 20.0):
70 if delay:
71 time.sleep(delay)
72 try:
73 if _pin_once(pid, int(group)) == 0:
74 return # process gone -- row finished
75 except Exception:
76 return
77 t = threading.Thread(target=_keep, daemon=True)
78 t.start()
79
80
81def _pin_once(pid, group):
82 """One pinning pass. Returns the number of threads moved/confirmed, 0 if
83 the process no longer exists.
84
85 NO-OP unless RADCLUSTER_CPU_GROUP is set, so every host that does not opt in
86 behaves exactly as before. Never raises: failing to pin must cost a little
87 throughput, never a row.
88
89 WHY IT EXISTS. A Windows host with more than 64 logical CPUs is split into
90 processor GROUPS, and the scheduler homes threads to the group their process
91 was assigned at creation. On MATRIX-PC2 (2x Xeon Gold 6230, two groups of 40
92 logical) that is group 0 for every solver, whatever the parent did: measured
93 2026-08-18 under a live 20-row stage, each solver.exe had exactly ONE thread,
94 all 20 homed to group 0, socket 0 saturated and socket 1 at 3 %. The run_
95 ensemble pool workers themselves DO spread across both groups -- it is only
96 the solver, where all the compute is, that piles up.
97
98 The fix is one call per row. Measured immediately after moving 5 of 20 live
99 solver threads to group 1: group 0 50.7 % -> 37.4 %, group 1 3.8 % ->
100 14.2 %.
101
102 NOT a numerical knob: affinity changes where a thread runs, never what it
103 computes. Rows produced with and without it are directly comparable.
104 """
105 try:
106 import ctypes
107 from ctypes import wintypes
108
109 class _THREADENTRY32(ctypes.Structure):
110 _fields_ = [("dwSize", wintypes.DWORD), ("cntUsage", wintypes.DWORD),
111 ("th32ThreadID", wintypes.DWORD),
112 ("th32OwnerProcessID", wintypes.DWORD),
113 ("tpBasePri", ctypes.c_long),
114 ("tpDeltaPri", ctypes.c_long),
115 ("dwFlags", wintypes.DWORD)]
116
117 class _GROUP_AFFINITY(ctypes.Structure):
118 _fields_ = [("Mask", ctypes.c_size_t), ("Group", ctypes.c_ushort),
119 ("Reserved", ctypes.c_ushort * 3)]
120
121 k = ctypes.WinDLL("kernel32", use_last_error=True)
122 k.OpenThread.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD]
123 k.OpenThread.restype = wintypes.HANDLE
124 k.SetThreadGroupAffinity.argtypes = [wintypes.HANDLE,
125 ctypes.POINTER(_GROUP_AFFINITY),
126 ctypes.POINTER(_GROUP_AFFINITY)]
127 k.GetActiveProcessorCount.argtypes = [ctypes.c_ushort]
128 k.GetActiveProcessorCount.restype = wintypes.DWORD
129
130 g = int(group)
131 n_cpu = int(k.GetActiveProcessorCount(ctypes.c_ushort(g)))
132 if n_cpu <= 0:
133 return 0
134 mask = (1 << n_cpu) - 1
135
136 snap = k.CreateToolhelp32Snapshot(0x00000004, 0) # TH32CS_SNAPTHREAD
137 te = _THREADENTRY32()
138 te.dwSize = ctypes.sizeof(_THREADENTRY32)
139 moved = 0
140 ok = k.Thread32First(snap, ctypes.byref(te))
141 while ok:
142 if te.th32OwnerProcessID == pid:
143 # THREAD_SET_INFORMATION | THREAD_QUERY_LIMITED_INFORMATION
144 h = k.OpenThread(0x0020 | 0x0800, False, te.th32ThreadID)
145 if h:
146 ga = _GROUP_AFFINITY(Mask=mask, Group=g)
147 if k.SetThreadGroupAffinity(h, ctypes.byref(ga), None):
148 moved += 1
149 k.CloseHandle(h)
150 ok = k.Thread32Next(snap, ctypes.byref(te))
151 k.CloseHandle(snap)
152 if moved:
153 logging.debug("pinned solver pid %s (%d thread(s)) to CPU group %d",
154 pid, moved, g)
155 return moved
156 except Exception as exc: # never lose a row over this
157 logging.debug("CPU-group pin skipped for pid %s: %s", pid, exc)
158 return 0
159
160
162 """Ask the C++ subprocess to finish its current integration step and
163 exit cleanly via its own signal handler. On Windows this requires the
164 process to have been launched with CREATE_NEW_PROCESS_GROUP."""
165 if proc is None or proc.poll() is not None:
166 return
167 try:
168 if sys.platform == 'win32':
169 proc.send_signal(_signal.CTRL_BREAK_EVENT)
170 else:
171 proc.send_signal(_signal.SIGTERM)
172 except Exception:
173 try:
174 proc.terminate()
175 except Exception:
176 pass
177
178#: Poll interval while waiting on the solver. Short enough that an abort is
179#: acted on promptly, long enough to be free next to a multi-hour integration.
180_ABORT_POLL_S = 5.0
181
182
183def _wait_with_abort_file(proc, timeout_s):
184 """proc.wait(timeout=timeout_s), but also abortable from OUTSIDE the process.
185
186 WHY. A detached campaign worker has no console, so Ctrl+C cannot reach it,
187 and on Windows Stop-Process is TerminateProcess -- uncatchable. Before this,
188 the only way to stop such a run was to kill it, which threw away every
189 in-flight row: the solver never got its interrupt, so no partial trajectory
190 was finalized and nothing was written. That is how a 12-row batch was lost
191 on 2026-08-11 after ~7 h of integration.
192
193 With this, dropping the abort file named by RADCLUSTER_ABORT_FILE asks the
194 solver to finish its CURRENT integration step and flush -- the same graceful
195 path the wall-clock budget already used, and the reason budget-cut rows keep
196 their dose ladder instead of vanishing. The row comes back marked partial,
197 with every rung it reached intact.
198
199 Raises subprocess.TimeoutExpired on the wall-clock budget, exactly as
200 proc.wait did, so the caller's existing handling is unchanged. With no
201 abort file configured this is proc.wait plus a 5 s poll, i.e. a no-op.
202 """
203 abort = os.environ.get('RADCLUSTER_ABORT_FILE')
204 if not abort:
205 return proc.wait(timeout=timeout_s)
206 deadline = None if not timeout_s else (time.monotonic() + float(timeout_s))
207 while True:
208 remaining = None if deadline is None else deadline - time.monotonic()
209 if remaining is not None and remaining <= 0:
210 raise subprocess.TimeoutExpired(proc.args, timeout_s)
211 try:
212 return proc.wait(timeout=min(_ABORT_POLL_S,
213 remaining if remaining is not None
214 else _ABORT_POLL_S))
215 except subprocess.TimeoutExpired:
216 pass
217 if os.path.exists(abort):
218 print(f" abort file present ({abort}) -- asking the solver to "
219 f"finalize at the dose reached so far and flush.")
220 raise subprocess.TimeoutExpired(proc.args, timeout_s)
221
222
223_SOLVER_MODE_MAP = {
224 'full_system': 0,
225 'active_window': 4,
226 # Backward-compat aliases (legacy names)
227 'cpp_full': 0,
228 'sliding_OpenMP': 4,
229}
230_PHYSICS_OPTION_MAP = {
231 'full_CD_fission': 0,
232 'full_CD_fusion': 1,
233 'bin_moment_CD_fission': 2,
234 'bin_moment_CD_fusion': 3,
235}
236
237
238def sia100_block_length(sim, solver_config):
239 """Length of the appended ⟨100⟩ SIA block in the C++ state vector.
240
241 The ⟨100⟩ block carries the SAME size reduction as the ½⟨111⟩ population:
242 full per-size (length I) in discrete modes, and the discrete-prefix +
243 bin-moment vector (i_discrete + n_mom·I_bin) in bin_moment modes. Zero when
244 loop conversion is off.
245
246 Shared by ``write_param_file`` (which must emit a y0 of the full width) and
247 ``run_cpp_solver`` (which splits the block back off). Keeping one rule in
248 one place is deliberate: when the two disagreed, the writer emitted a short
249 y0 and the reader still found a block, so the mismatch was invisible.
250 """
251 if not int(solver_config.get(
252 'loop_conversion', sim.input_data.reactions.get('loop_conversion', 0))):
253 return 0
254 re_obj = sim.rate_equations
255 if hasattr(re_obj, 'bins'): # BinMomentRateEquations
256 return (int(getattr(re_obj, 'i_discrete', 0))
257 + int(getattr(re_obj, 'n_mom', 2))
258 * int(getattr(re_obj, 'I_bin', len(getattr(re_obj, 'bins', [])))))
259 return int(sim.input_data.I)
260
261
262def write_param_file(sim, solver_config, path, y0_override=None):
263 """
264 Write all solver parameters to a text file (key=value, one per line).
265
266 Parameters
267 ----------
268 sim : RadClusterSimulation -- fully initialised
269 solver_config : dict -- t_span, rtol, atol, solver_method, etc.
270 path : str or Path
271 y0_override : ndarray or None -- if provided, use these initial conditions
272 instead of re_obj.get_initial_conditions(). Used by
273 adaptive continuation to resume from a mid-run state.
274 """
275 inp = sim.input_data
276 rr = sim.reaction_rates
277 re_obj = sim.rate_equations
278
279 d = inp.derived
280 method = solver_config.get('solver_method', {})
281
282 I = inp.I
283 V = inp.V
284
285 lines = []
286
287 # ── Cluster size limits ───────────────────────────────────────────────────
288 lines.append(f"I={I}")
289 lines.append(f"V={V}")
290 # Legacy keys for backward compat with older C++ builds
291 lines.append(f"N={I}")
292 lines.append(f"M={V}")
293 lines.append(f"Ni={I}")
294 lines.append(f"Nv={V}")
295 lines.append(f"Ni_max={I}")
296
297 # ── Solver mode and physics option ────────────────────────────────────────
298 sm_int = _SOLVER_MODE_MAP.get(inp.solver_mode, 0)
299 po_int = _PHYSICS_OPTION_MAP.get(inp.physics_option, 0)
300 lines.append(f"physics_option_int={po_int}")
301 lines.append(f"window_mode={sm_int}") # solver mode selector (0/4)
302
303 # ── Geometric rate constant prefactors (Eq. 128) ─────────────────────────
304 lines.append(f"A_sph={d['A_sph']:.17e}")
305 lines.append(f"A_loop={d['A_loop']:.17e}")
306 lines.append(f"B_rot={d['B_rot']:.17e}")
307 lines.append(f"L_hat={d['L_hat']:.17e}")
308
309 # ── Mobility cutoffs ──────────────────────────────────────────────────────
310 lines.append(f"i_mobile={d['i_mobile']}")
311 lines.append(f"v_mobile={d['v_mobile']}")
312 # Legacy keys
313 lines.append(f"n_max_i={d['i_mobile']}")
314 lines.append(f"m_max_v={d['v_mobile']}")
315
316 # ── Boundary flux option ─────────────────────────────────────────────────
317 # 0 = absorption (open boundary, default), 1 = reflection (closed boundary)
318 bf = d.get('boundary_flux', 'absorption')
319 lines.append(f"boundary_flux={1 if bf == 'reflection' else 0}")
320
321 # ── Vacancy cluster rate arrays (0-indexed, size V) ───────────────────────
322 for k, v in enumerate(rr.K_VAC_grow):
323 lines.append(f"KVV_{k}={v:.17e}")
324 for k, v in enumerate(rr.K_VAC_shrink):
325 lines.append(f"KVI_{k}={v:.17e}")
326 for k, v in enumerate(rr.G_VAC):
327 lines.append(f"GVV_{k}={v:.17e}")
328 for k, v in enumerate(rr.K_HeV):
329 lines.append(f"KHeV_{k}={v:.17e}")
330 for k, v in enumerate(re_obj.Pr_VAC):
331 lines.append(f"Pr_VAC_{k}={v:.17e}")
332 # m^{1/3} factors
333 for k in range(V):
334 m = k + 1
335 lines.append(f"m13_{k}={m**(1.0/3.0):.17e}")
336
337 # ── SIA cluster rate arrays (0-indexed, size I) ───────────────────────────
338 for k, v in enumerate(rr.K_SIA_grow):
339 lines.append(f"KII_{k}={v:.17e}")
340 for k, v in enumerate(rr.K_SIA_shrink):
341 lines.append(f"KIV_{k}={v:.17e}")
342 # Per-size SIA loop bias Z_i^loop(n). K_ii_coal recomputes the SIA capture
343 # rate inside the C++ from a SCALAR P.Z_i_loop, so a size-dependent bias
344 # written only into K_SIA_grow (KII) would be silently ignored -- KII is
345 # consumed only for the top-bin term. Shipping the array is what makes the
346 # Wolfer bias reach the growth path. Absent -> C++ falls back to the scalar.
347 _zarr = getattr(rr, "Z_i_loop_arr", None)
348 if _zarr is not None:
349 for k, v in enumerate(_zarr):
350 lines.append(f"Z_i_loop_arr_{k}={v:.17e}")
351 for k, v in enumerate(rr.G_SIA):
352 lines.append(f"GII_{k}={v:.17e}")
353 for k, v in enumerate(rr.k2_SIA):
354 lines.append(f"k2_SIA_{k}={v:.17e}")
355 for k, v in enumerate(re_obj.Pr_SIA):
356 lines.append(f"Pr_SIA_{k}={v:.17e}")
357
358 # ── 1D glide prefactors K_1D_pref[n-1] (Eq. 141) ─────────────────────────
359 for k, v in enumerate(rr.K_1D_pref):
360 lines.append(f"K_1D_pref_{k}={v:.17e}")
361
362 # ── Mobile cluster effective 3D diffusivities (for coalescence) ─────────
363 for k, v in enumerate(rr.D_SIA_eff):
364 lines.append(f"D_SIA_eff_{k}={v:.17e}")
365 for k, v in enumerate(rr.D_VAC_eff):
366 lines.append(f"D_VAC_eff_{k}={v:.17e}")
367 # ½⟨111⟩ loop coarsening: glide law continued past i_mobile, used ONLY by
368 # the loop–loop coalescence edge (see ReactionRates.D_loop_coal).
369 # SIA thermal emission returns its monomer to I_1 ("returned", the default since v2.1.0) or drops it
370 # ("dropped", the arithmetic before v2.1.0, which made delta_FP 1.2e-2 on im5vm2).
371 _emission = sim.input_data.reactions.get('sia_emission_monomer', 'returned')
372 if _emission not in ('returned', 'dropped'):
373 raise ValueError(f"sia_emission_monomer must be 'returned' or 'dropped', not {_emission!r}")
374 lines.append(f"sia_emission_monomer={1 if _emission == 'returned' else 0}")
375 _lc = int(getattr(rr, 'loop_coal', 0))
376 lines.append(f"loop_coal={_lc}")
377 if _lc:
378 for k, v in enumerate(rr.D_loop_coal):
379 lines.append(f"D_loop_coal_{k}={v:.17e}")
380 lines.append(f"A_sph_inv_O23={rr.A_sph_inv_O23:.17e}")
381 lines.append(f"A_loop_inv_O23={rr.A_loop_inv_O23:.17e}")
382 # Loop SIA bias — own key, falling back to the network bias Z_i when the
383 # workbook has no row (keeps pre-existing workbooks bit-identical).
384 _Z_i = float(inp.reactions.get('Z_i', 1.05))
385 Z_i_loop = float(inp.reactions.get('Z_i_loop', _Z_i) or _Z_i)
386 lines.append(f"Z_i_loop={Z_i_loop:.17e}")
387 Z_ii = float(inp.reactions.get('Z_ii', 1.0))
388 lines.append(f"Z_ii={Z_ii:.17e}")
389
390 # ── ½⟨111⟩ → ⟨100⟩ loop conversion (optional; appended SIA100 block) ──────
391 # Enabled via solver_config['loop_conversion'] or reactions['loop_conversion'].
392 # The C++ appends a c_i100 block of length I at the end of the state vector
393 # (N_eq += I) and computes the 2-D junction/absorption kernels on the fly.
394 loop_conv = int(solver_config.get(
395 'loop_conversion', inp.reactions.get('loop_conversion', 0)))
396 lines.append(f"loop_conversion={loop_conv}")
397 if loop_conv:
398 lines.append(f"n_loop_min={int(getattr(rr, 'n_loop_min', 4))}")
399 # Effective junction threshold, tied to the mobility cutoff so the
400 # channel cannot be silently dead when i_mobile < n_j_min_junc. Must
401 # use the SAME rule as the Python kernel or the two RHS disagree.
402 from .reaction_rates import effective_n_j_min
403 lines.append("n_j_min_junc=%d" % int(effective_n_j_min(
404 inp.reactions.get('n_j_min_junc', 30),
405 d['i_mobile'],
406 inp.reactions.get('n_j_min_frac', 0.6))))
407 lines.append(f"phi_max_junc={float(inp.reactions.get('phi_max_junc', 0.5)):.17e}")
408 lines.append(f"sigma_s_junc={float(inp.reactions.get('sigma_s_junc', 0.35)):.17e}")
409 # Marian two-step success probability P_success(T) — gates junction +
410 # absorption (½⟨111⟩→½⟨110⟩→⟨100⟩, Fig. 3); computed by ReactionRates.
411 lines.append(f"loop_conv_psuccess={float(getattr(rr, 'conv_psuccess', 1.0)):.17e}")
412 # Absorption-only success gate P_success^abs(T) = A_abs·[1 +
413 # exp((ΔH₂^abs − ΔH_rev)/k_BT)]^{-1}. Decoupled from the junction gate
414 # because a mobile ≤ i_mobile cluster joining a several-hundred-atom
415 # ⟨100⟩ loop is templated by the host character and should not pay the
416 # junction's reversion penalty. Falls back to conv_psuccess so an older
417 # ReactionRates object (no such attribute) is byte-identical.
418 lines.append("loop_conv_psuccess_abs=%.17e" % float(
419 getattr(rr, 'conv_psuccess_abs',
420 getattr(rr, 'conv_psuccess', 1.0))))
421 # Absorption-only scale (1-D-glide enhanced capture of ½⟨111⟩ by
422 # sessile ⟨100⟩). Separate from conv_psuccess, which multiplies the
423 # junction yield too and so cannot isolate growth from nucleation.
424 lines.append("conv_absorb_boost=%.17e" % float(
425 inp.reactions.get('absorb_boost_100', 1.0) or 1.0))
426 for k, v in enumerate(rr.Gamma_uni):
427 lines.append(f"Gamma_uni_{k}={v:.17e}")
428 for k, v in enumerate(rr.K_100_grow):
429 lines.append(f"K_100_grow_{k}={v:.17e}")
430 for k, v in enumerate(rr.K_100_shrink):
431 lines.append(f"K_100_shrink_{k}={v:.17e}")
432 for k, v in enumerate(rr.G_100):
433 lines.append(f"G_100_{k}={v:.17e}")
434 # Loop → network-dislocation loss on the sessile ⟨100⟩ block
435 # (loop_network_loss.tex). Zeros when LOOP_NETWORK_LOSS is off, so the
436 # OFF path is byte-identical. The ½⟨111⟩ side needs no extra key: its
437 # Λ_n^net is already folded into k2_SIA (additive-sink-strength rule).
438 lam100 = getattr(rr, 'Lambda_net_100', None)
439 if lam100 is not None:
440 for k, v in enumerate(lam100):
441 lines.append(f"lambda_net_100_{k}={v:.17e}")
442
443 # Cavity → network-dislocation sweeping (VOID_NETWORK_LOSS). Emitted only
444 # when the channel is on: an all-zero array would otherwise add V lines to
445 # every param file for no effect. Outside the loop_conversion block above
446 # because the cavity edge does not depend on the ⟨100⟩ split.
447 lam_void = getattr(rr, 'Lambda_net_void', None)
448 if lam_void is not None and float(np.max(lam_void)) > 0.0:
449 for k, v in enumerate(lam_void):
450 lines.append(f"lambda_net_void_{k}={v:.17e}")
451
452 # ── Scalar physics ────────────────────────────────────────────────────────
453 kBT = float(d['kBT'])
454 nu_h = float(inp.energetics.get('nu_h', 3.0e12))
455 E_m_h = float(inp.energetics.get('E_m_h', 0.06))
456 E_b_hV1 = float(inp.energetics.get('E_b_hV_1', 2.30))
457 beta_He = nu_h * np.exp(-(E_b_hV1 + E_m_h) / kBT)
458
459 # He-pressure correction to vacancy emission (GVV_eff in rate_kernels.cpp).
460 # The kernel multiplies GVV by exp(−δ·β·(ℓ/m)^β / kBT), i.e. the binding
461 # energy gains ΔE_b = δ·β·(ℓ/m)^β. Fit (δ, β) so this power law matches
462 # the virial-EOS pressure work P_He·Ω (the +P·Ω term of E_b_bubble in
463 # binding_energies.py) at the run temperature, over the physical loading
464 # range ℓ/m ∈ [0.05, 2]. δ > 0 ⟹ He suppresses vacancy emission and
465 # stabilizes bubbles, consistent with the Python kernels.
466 from .binding_energies import _B2, _B3
467 Omega = float(d['Omega'])
468 _x = np.geomspace(0.05, 2.0, 40) # ℓ/m grid
469 _PdV = _x * kBT * (1.0 + _B2 * _x / Omega + _B3 * (_x / Omega) ** 2) # eV
470 _slope, _icept = np.polyfit(np.log(_x), np.log(_PdV), 1)
471 beta_He_exp = float(_slope)
472 delta_He = float(np.exp(_icept) / beta_He_exp)
473
474 lines.extend([
475 f"G_He={re_obj.G_He:.17e}",
476 f"k2_disl_v={rr.k2_vac_scalar:.17e}",
477 f"k2_disl_i={rr.k2_SIA_scalar:.17e}",
478 f"k2_disl_He={rr.k2_He_scalar:.17e}",
479 f"Cv_eq={d['Cv_eq']:.17e}",
480 f"beta_He={beta_He:.17e}",
481 f"delta_He={delta_He:.17e}", # He pressure coeff [eV], fit to virial P·Ω
482 f"beta_He_exp={beta_He_exp:.17e}", # He pressure power-law exponent
483 f"kBT={kBT:.17e}",
484 f"K_iv={rr.K_iv:.17e}",
485 f"K_3D_cav_pref={rr.K_3D_cav_pref:.17e}",
486 ])
487
488 # ── Bin-moment parameters ─────────────────────────────────────────────────
489 I_bin = getattr(re_obj, 'I_bin', getattr(re_obj, 'K', 0))
490 V_bin = getattr(re_obj, 'V_bin', getattr(re_obj, 'K_v', 0))
491 i_discrete = getattr(re_obj, 'i_discrete', getattr(re_obj, 'n1', 1))
492 v_discrete = getattr(re_obj, 'v_discrete', 1)
493 r_ratio = getattr(re_obj, 'r', 2.0)
494
495 lines.append(f"I_bin={I_bin}")
496 lines.append(f"V_bin={V_bin}")
497 lines.append(f"i_discrete={i_discrete}")
498 lines.append(f"v_discrete={v_discrete}")
499 lines.append(f"r_ratio={r_ratio:.17e}")
500 # Legacy keys for backward compat
501 lines.append(f"K_bins={I_bin}")
502 lines.append(f"K_v_bins={V_bin}")
503 lines.append(f"n1_bin={i_discrete}")
504
505 # ── Explicit integer bin edges ────────────────────────────────────────────
506 # Transmit the per-bin lower/upper edges directly so the C++ consumes the
507 # exact partition that Python computed. Re-deriving edges in C++ from
508 # r_ratio via std::floor can diverge from numpy.floor over many bins
509 # (different SIA/VAC ratios, FP rounding), silently corrupting the last
510 # bins. Each bin k covers integer sizes [bin_lo, bin_hi-1].
511 sia_bins = list(getattr(re_obj, 'bins', []) or [])
512 vac_bins = list(getattr(re_obj, 'vac_bins', []) or [])
513 for k, (nlo, nhi) in enumerate(sia_bins):
514 lines.append(f"sia_bin_lo_{k}={int(nlo)}")
515 lines.append(f"sia_bin_hi_{k}={int(nhi)}")
516 for k, (mlo, mhi) in enumerate(vac_bins):
517 lines.append(f"vac_bin_lo_{k}={int(mlo)}")
518 lines.append(f"vac_bin_hi_{k}={int(mhi)}")
519
520 # Shape function: constant=0, linear=1, lognormal=2
521 _sf_map = {'constant': 0, 'linear': 1, 'lognormal': 2}
522 sf = getattr(re_obj, 'shape_function', 'linear')
523 lines.append(f"shape_function={_sf_map.get(sf, 1)}")
524
525 # ── He mode ───────────────────────────────────────────────────────────────
526 he_mode_int = 0 if getattr(re_obj, 'he_mode', 'case2') == 'case2' else 1
527 lines.append(f"he_mode={he_mode_int}")
528
529 # ── Initial conditions ────────────────────────────────────────────────────
530 # The C++ reads y0_k for k < N_eq and falls back to 1e-100 for every key it
531 # does not find (parameters.h: `optional_param(p, "y0_"+k, 1e-100)`). A
532 # short y0 therefore does not fail — it silently zeroes the tail of the
533 # state. That is exactly how adaptive continuation erased the whole ⟨100⟩
534 # block at every segment restart (resume handed back results['y'][:, -1],
535 # from which cpp_bridge had already split the block off).
536 n_sia100 = sia100_block_length(sim, solver_config)
537 n_expected = re_obj.N_eq + n_sia100
538 if y0_override is None:
539 # Fresh start: get_initial_conditions() covers N_eq only, so the ⟨100⟩
540 # block has to be built here. Give it the SAME shaped IC that
541 # get_initial_conditions builds for the ½⟨111⟩ bins — μ₀ = C_floor and
542 # μ₁ = C_floor·midpoint — for the reason stated in its docstring: all
543 # moments at C_floor gives μ₁/μ₀ = 1, a mean size outside every bin,
544 # which the closure cannot reconstruct sensibly. Leaving the block flat
545 # put all 21 ⟨100⟩ bins in that state for the whole early history (the
546 # C++ clamps every entry up to C_floor at t_begin and after each output
547 # step, so a flat block does not simply grow out of it).
548 C_floor_ic = float(inp.reactions.get('C_floor', 1e-15))
549 blk = np.full(n_sia100, C_floor_ic)
550 if n_sia100 and hasattr(re_obj, 'bins'):
551 i_d, Pm = int(re_obj.i_discrete), int(re_obj.n_mom)
552 for k, (nlo, nhi) in enumerate(re_obj.bins):
553 mid = 0.5 * (nlo + nhi - 1)
554 idx = i_d + Pm * k
555 if Pm >= 2:
556 blk[idx + 1] = C_floor_ic * mid
557 if Pm >= 3:
558 blk[idx + 2] = C_floor_ic * mid * mid
559 y0 = np.concatenate([re_obj.get_initial_conditions(), blk])
560 else:
561 y0 = np.asarray(y0_override, dtype=float)
562 # A resume MUST carry the full width; silence here is what hid the bug.
563 if y0.size != n_expected:
564 raise ValueError(
565 f"write_param_file: y0_override has {y0.size} entries but the "
566 f"C++ state vector is {n_expected} wide (N_eq={re_obj.N_eq} + "
567 f"{n_sia100} for the appended ⟨100⟩ block). A short y0 would "
568 "be silently filled with 1e-100, erasing the ⟨100⟩ population. "
569 "Resume via RadClusterSimulation._resume_state / _expand_state, "
570 "which re-attach the block.")
571 for k, v in enumerate(y0):
572 lines.append(f"y0_{k}={v:.17e}")
573
574 C_floor = float(inp.reactions.get('C_floor', 1e-15))
575 lines.append(f"C_floor={C_floor:.17e}")
576
577 # Free He mode: 'dynamic'=0 integrates Eq.157; 'quasi_steady_state'=1 uses QSS
578 he_kinetics_str = str(inp.reactions.get('he_kinetics', 'dynamic')).lower()
579 qss_He_int = 1 if he_kinetics_str == 'quasi_steady_state' else 0
580 lines.append(f"qss_He={qss_He_int}")
581
582 # ── Solver settings ────────────────────────────────────────────────────────
583 t_span = solver_config.get('t_span', (1e-8, 1e7))
584 lines.append(f"t_begin={t_span[0]:.17e}")
585 lines.append(f"t_end={t_span[1]:.17e}")
586 lines.append(f"n_points={int(solver_config.get('n_points', 200))}")
587 lines.append(f"log_time={1.0 if solver_config.get('log_time', True) else 0.0}")
588 lines.append(f"rtol={solver_config.get('rtol', 1e-8):.17e}")
589 lines.append(f"atol={solver_config.get('atol', 1e-50):.17e}")
590
591 # ── Integration method ────────────────────────────────────────────────────
592 # Backend is fixed (CVODE BDF); only the linear solver is selectable.
593 _linsol_map = {'dense': 0, 'band': 1, 'banded': 1, 'gmres': 2, 'klu': 3}
594
595 N_tot = I + V + 1
596 lines.append(f"linsol={_linsol_map.get(str(method.get('linsol','dense')).lower(), 0)}")
597 lines.append(f"mu={int(method.get('mu', N_tot - 1))}")
598 lines.append(f"ml={int(method.get('ml', N_tot - 1))}")
599 lines.append(f"max_order={int(method.get('max_order', 0))}")
600 lines.append(f"hmin={float(method.get('hmin', 0.0)):.17e}")
601 lines.append(f"hmax={float(method.get('hmax', 0.0)):.17e}")
602
603 # ── Dynamic window parameters ──────────────────────────────────────────────
604 # window_width is shared by SIA and VAC; defaults to max(I, V) (full domain).
605 lines.append(f"window_width={int(method.get('window_width', max(I, V)))}")
606 lines.append(f"concentration_threshold={float(method.get('concentration_threshold', 1e-18)):.17e}")
607 lines.append(f"window_pad={int(method.get('window_pad', 10))}")
608 # VAC expansion pad defaults to the SIA pad if not explicitly set.
609 lines.append(f"window_pad_v={int(method.get('window_pad_v', method.get('window_pad', 10)))}")
610 lines.append(f"window_check_every={int(method.get('window_check_every', 1))}")
611
612 # ── Woodbury preconditioner parameters ─────────────────────────────────────
613 # prec_type: 0=Jacobi (legacy), 1=Woodbury (bordered-banded, default for GMRES)
614 linsol_int = _linsol_map.get(str(method.get('linsol','dense')).lower(), 0)
615 window_mode_int = int(method.get('window_mode', 0))
616 # Woodbury only for full solver (window_mode==0) with GMRES -- the sliding
617 # window already keeps the active system small enough for Jacobi+GMRES.
618 prec_type_default = 1 if (linsol_int == 2 and window_mode_int == 0) else 0
619 # User-facing name takes priority over the legacy integer. Accept a few
620 # case/spelling variants so the notebook config stays forgiving.
621 _prec_name_map = {
622 'jacobi': 0,
623 'woodbury': 1,
624 'woodburry': 1, # common typo
625 }
626 if 'preconditioner' in method:
627 key = str(method['preconditioner']).strip().lower()
628 if key not in _prec_name_map:
629 raise ValueError(
630 f"Unknown preconditioner='{method['preconditioner']}'. "
631 f"Use 'Jacobi' or 'Woodbury'.")
632 prec_type_value = _prec_name_map[key]
633 else:
634 prec_type_value = int(method.get('prec_type', prec_type_default))
635 lines.append(f"prec_type={prec_type_value}")
636 # prec_bw: half-bandwidth (auto from mobility cutoffs)
637 prec_bw_default = max(2 * d['i_mobile'], 2 * d['v_mobile']) + 1
638 lines.append(f"prec_bw={int(method.get('prec_bw', prec_bw_default))}")
639 # prec_rank: number of mobile species forming the dense border
640 prec_rank_default = d['i_mobile'] + d['v_mobile']
641 lines.append(f"prec_rank={int(method.get('prec_rank', prec_rank_default))}")
642
643 verbose = 1 if solver_config.get('_verbose', False) else 0
644 lines.append(f"verbose={verbose}")
645
646 with open(path, 'w') as f:
647 f.write('\n'.join(lines) + '\n')
648
649
650# ── Output parsing ─────────────────────────────────────────────────────────────
651
652def _parse_stdout(text, N_eq):
653 rows = []
654 for line in text.strip().splitlines():
655 parts = line.split()
656 if len(parts) == 1 + N_eq:
657 try:
658 rows.append([float(x) for x in parts])
659 except ValueError:
660 pass
661 return np.array(rows) if rows else np.empty((0, 1 + N_eq))
662
663
664# ── Diagnostic line parser ────────────────────────────────────────────────────
665
667 """Parse a C++ diagnostic stderr line of the form key=value key=value ...
668 Returns a dict of {str: float} for every key=value token found."""
669 out = {}
670 for token in line.split():
671 if '=' in token:
672 k, _, v = token.partition('=')
673 try:
674 out[k] = float(v)
675 except ValueError:
676 pass
677 return out
678
679
680def _make_stderr_handler(progress_callback, info_out=None):
681 """
682 Return a callable suitable for use as a daemon-thread target that reads
683 proc.stderr line by line.
684
685 When progress_callback is None -> forward each line to sys.stderr verbatim.
686 When progress_callback is given -> also parse [diag] / [ci5_rates] /
687 [cv5_rates] lines and call progress_callback(row_dict) once all three
688 lines for a given time step have been received.
689
690 When info_out is a dict, the handler stores any solver-emitted metadata
691 lines (currently `[OpenMP_threads] N`) into it under a stable key.
692
693 The row_dict passed to the callback contains atom-fraction concentrations
694 and atom-fraction/s rates exactly as the C++ solver computed them:
695 t, c_i1, c_v1, c_i2, c_v2, c_i5, c_v5, Q_tot, SIA_tot, VAC_tot
696 ci5_prod, ci5_emit_in, ci5_emit_out, ci5_grow_in, ci5_grow_out,
697 ci5_shrink_in, ci5_shrink_out, ci5_1D_loss, ci5_sink
698 cv5_prod, cv5_emit_in, cv5_emit_out, cv5_grow_in, cv5_grow_out,
699 cv5_shrink_in, cv5_shrink_out, cv5_1D_loss, cv5_sink
700 """
701 pending = {} # accumulates fields for the current time step
702 lock = threading.Lock()
703
704 def _flush():
705 if pending:
706 try:
707 progress_callback(dict(pending))
708 except Exception:
709 pass
710 pending.clear()
711
712 _diag_prefixes = ('[diag]', '[ci5_rates]', '[cv5_rates]')
713
714 def _thread(proc_stderr):
715 for raw in proc_stderr:
716 line = raw.decode('utf-8', errors='replace')
717 stripped = line.strip()
718
719 if info_out is not None and stripped.startswith('[OpenMP_threads]'):
720 try:
721 info_out['omp_threads_used'] = int(stripped.split()[-1])
722 except (ValueError, IndexError):
723 pass
724
725 if info_out is not None and stripped.startswith('[stats]'):
726 try:
727 kv = _parse_kv_line(stripped[len('[stats]'):])
728 info_out['solver_stats_final'] = {
729 k: (int(v) if isinstance(v, float) and v.is_integer() else v)
730 for k, v in kv.items()
731 }
732 except Exception:
733 pass
734
735 # Only echo non-diagnostic lines to stderr; diagnostic lines
736 # are consumed silently by the progress_callback parser below.
737 if not any(stripped.startswith(p) for p in _diag_prefixes):
738 sys.stderr.write(line)
739 sys.stderr.flush()
740
741 if progress_callback is None:
742 continue
743 with lock:
744 if stripped.startswith('[diag]'):
745 _flush() # emit previous time step before starting new one
746 kv = _parse_kv_line(stripped[len('[diag]'):])
747 pending.update(kv)
748 elif stripped.startswith('[ci5_rates]'):
749 kv = _parse_kv_line(stripped[len('[ci5_rates]'):])
750 pending.update({f'ci5_{k}': v for k, v in kv.items()})
751 elif stripped.startswith('[cv5_rates]'):
752 kv = _parse_kv_line(stripped[len('[cv5_rates]'):])
753 pending.update({f'cv5_{k}': v for k, v in kv.items()})
754 elif stripped.startswith('Done:'):
755 _flush() # flush the last time step
756
757 return _thread
758
759
760# ── Main entry point ──────────────────────────────────────────────────────────
761
762def run_cpp_solver(sim, solver_config, base_dir=None, progress_callback=None,
763 timeout_s=None, y0_override=None):
764 """
765 Run the RadCluster_2_1 C++ solver and return the standard results dict.
766
767 Parameters
768 ----------
769 sim : RadClusterSimulation
770 solver_config : dict
771 base_dir : Path or None
772 progress_callback : callable or None
773 If provided, called once per output time step with a dict of
774 concentrations and rate-breakdown values (all in atom fraction / s).
775 The solver's verbose mode is automatically enabled.
776 timeout_s : float or None
777 Maximum wall-clock seconds to allow for the C++ solver. If exceeded,
778 the process is killed and None is returned.
779 y0_override : ndarray or None
780 Custom initial conditions for adaptive continuation runs.
781
782 Returns
783 -------
784 dict or None
785 """
786 from .post_process import calculate_derived_quantities
787
788 if base_dir is None:
789 base_dir = Path(__file__).resolve().parent.parent
790
791 # If a callback is requested, enable C++ verbose output automatically
792 if progress_callback is not None:
793 solver_config = dict(solver_config)
794 solver_config['_verbose'] = True
795
796 exe_name = 'solver.exe' if sys.platform == 'win32' else 'solver'
797 build_dir = Path(base_dir) / 'build'
798 exe_path = build_dir / 'Release' / exe_name
799 if not exe_path.exists():
800 exe_path = build_dir / 'Debug' / exe_name
801 if not exe_path.exists():
802 exe_path = build_dir / exe_name
803
804 if not exe_path.exists():
805 print(f"C++ solver not found at {build_dir}")
806 print(" Build with:")
807 print(f" cd {Path(base_dir) / 'cpp_utils'}")
808 print( " cmake -S . -B ../build -DCMAKE_BUILD_TYPE=Release")
809 print( " cmake --build ../build --config Release")
810 return None
811
812 with tempfile.NamedTemporaryFile(mode='w', suffix='.txt',
813 delete=False, prefix='expanded_cd_') as tf:
814 param_path = tf.name
815
816 bin_path = param_path[:-4] + '.bin'
817 re_obj = sim.rate_equations
818 N_tot = re_obj.N_eq
819 # Loop conversion appends a ⟨100⟩ SIA block at the end of the C++ state
820 # vector, so the solver emits N_eq + len(⟨100⟩) columns. The ⟨100⟩ block
821 # carries the SAME size reduction as the ½⟨111⟩ population: full per-size
822 # (length I) in discrete modes, and the discrete-prefix + bin-moment vector
823 # (i_discrete + n_mom·I_bin) in bin_moment modes. Parse the wider rows and
824 # split the appended block off before post-processing (which expects the
825 # original [SIA | VAC | He | conservation] layout).
826 _is_bin = hasattr(re_obj, 'bins') # BinMomentRateEquations
827 _n_sia100 = sia100_block_length(sim, solver_config)
828 N_tot += _n_sia100
829
830 proc = None
831 try:
832 write_param_file(sim, solver_config, param_path, y0_override=y0_override)
833 print(f"C++ solver: {exe_path.name} N_eq={N_tot}"
834 f" solver_mode='{sim.input_data.solver_mode}'"
835 f" physics='{sim.input_data.physics_option}'")
836
837 popen_kwargs = dict(
838 stdout=subprocess.PIPE,
839 stderr=subprocess.PIPE,
840 )
841 # On Windows, give the child its own process group so we can deliver
842 # CTRL_BREAK_EVENT to it without also signaling our own console.
843 if sys.platform == 'win32':
844 popen_kwargs['creationflags'] = subprocess.CREATE_NEW_PROCESS_GROUP
845
846 proc = subprocess.Popen(
847 [str(exe_path), f'--param_file={param_path}'],
848 **popen_kwargs,
849 )
851
852 solver_info = {}
853 stderr_fn = _make_stderr_handler(progress_callback, info_out=solver_info)
854 t_fwd = threading.Thread(target=stderr_fn, args=(proc.stderr,), daemon=True)
855 t_fwd.start()
856 partial = False
857 # Drain stdout in a thread so proc.wait(timeout=...) is actually
858 # reachable while the child is still running. If we instead call
859 # proc.stdout.read() inline it blocks until the child closes stdout
860 # (i.e. exits), defeating the wall-clock cap entirely.
861 stdout_buf = []
862 def _drain_stdout(stream, buf):
863 try:
864 buf.append(stream.read())
865 except Exception:
866 pass
867 t_out = threading.Thread(target=_drain_stdout,
868 args=(proc.stdout, stdout_buf), daemon=True)
869 t_out.start()
870 stdout_data = b''
871 try:
872 _wait_with_abort_file(proc, timeout_s)
873 except subprocess.TimeoutExpired:
874 print(f"C++ solver hit {timeout_s}s timeout -- asking it to finalize gracefully...")
875 partial = True
877 try:
878 proc.wait(timeout=GRACEFUL_SHUTDOWN_TIMEOUT_S)
879 except subprocess.TimeoutExpired:
880 print(f" Solver did not finalize within "
881 f"{GRACEFUL_SHUTDOWN_TIMEOUT_S:.0f}s -- forcing kill.")
882 proc.kill()
883 proc.wait()
884 t_fwd.join(timeout=5)
885 t_out.join(timeout=5)
886 stdout_data = b''.join(stdout_buf) if stdout_buf else b''
887 except KeyboardInterrupt:
888 print("\n*** Ctrl+C -- asking C++ solver to flush and exit gracefully ***")
889 partial = True
891 if proc is not None:
892 try:
893 # Wait for the child to finish its current output step and
894 # emit the final [stats] line; the stdout drain thread keeps
895 # collecting whatever lands. A second Ctrl+C escalates.
896 proc.wait(timeout=GRACEFUL_SHUTDOWN_TIMEOUT_S)
897 except (subprocess.TimeoutExpired, KeyboardInterrupt):
898 print(f" Solver did not exit within "
899 f"{GRACEFUL_SHUTDOWN_TIMEOUT_S:.0f}s -- forcing kill.")
900 try:
901 proc.kill()
902 proc.wait(timeout=5)
903 except Exception:
904 pass
905 try:
906 t_fwd.join(timeout=5)
907 t_out.join(timeout=5)
908 stdout_data = b''.join(stdout_buf) if stdout_buf else b''
909 except Exception:
910 pass
911 finally:
912 try:
913 os.unlink(param_path)
914 except OSError:
915 pass
916
917 if proc is not None and not partial and proc.returncode != 0:
918 print(f"C++ solver failed (exit code {proc.returncode})")
919
920 # ── Parse + post-process under a KeyboardInterrupt shield ────────────────
921 # The C++ child flushes each row to the .bin file as it's computed, so
922 # there is real data to rescue even after Ctrl+C. We must NOT lose it to
923 # a second interrupt during numpy parsing or post-processing -- stash
924 # partial results onto `sim` as soon as they exist so the orchestrator
925 # (and the notebook fallback) can recover them.
926 sim._partial_results = None
927 try:
928 # Parse binary output (works for both complete and partial runs)
929 sol_arr = None
930 try:
931 try:
932 bin_size = os.path.getsize(bin_path)
933 except OSError:
934 bin_size = -1
935 print(f"Reading bin file: {bin_path} ({bin_size} bytes)")
936 raw = np.fromfile(bin_path, dtype=np.float64)
937 n_cols = 1 + N_tot
938 n_rows = raw.size // n_cols
939 # Reshape FIRST -- losing partial data to a downstream print/encode
940 # error would be far worse than a missing log line.
941 if n_rows > 0:
942 sol_arr = raw[:n_rows * n_cols].reshape(n_rows, n_cols)
943 try:
944 print(f" parsed {raw.size} doubles -> {n_rows} rows x {n_cols} cols")
945 except Exception:
946 pass
947 except Exception as exc:
948 try:
949 print(f" bin parse failed: {type(exc).__name__}: {exc}")
950 except Exception:
951 pass
952 finally:
953 try:
954 os.unlink(bin_path)
955 except OSError:
956 pass
957
958 if sol_arr is None or sol_arr.shape[0] == 0:
959 if stdout_data:
960 text = stdout_data.decode('utf-8', errors='replace')
961 sol_arr = _parse_stdout(text, N_tot)
962
963 if sol_arr is None or sol_arr.shape[0] == 0:
964 print("C++ solver produced no parseable output")
965 return None
966
967 # The C++ solver also stamps interrupted=1 in its [stats] line when its
968 # own signal handler tripped -- pick that up too so we don't miss the
969 # case where the timeout/Ctrl+C path wasn't exercised but the OS killed
970 # the child via console close.
971 final_stats = solver_info.get('solver_stats_final') or {}
972 if final_stats.get('interrupted'):
973 partial = True
974
975 status = "partial" if partial else "completed"
976 n_pts = sol_arr.shape[0]
977 print(f"C++ solver {status} -- {n_pts} time points")
978
979 t = sol_arr[:, 0]
980 y = sol_arr[:, 1:].T # (N_tot, n_pts)
981
982 # Split off the appended ⟨100⟩ SIA block so downstream post-processing
983 # sees the original [SIA | VAC | He | conservation] layout unchanged.
984 # Two views of the block are kept:
985 # y_sia100_raw — the block AS SOLVED (discrete: per-size; bin_moment:
986 # discrete-prefix + bin-moment vector). This goes to
987 # calculate_derived_quantities, which forms the ⟨100⟩
988 # mean size / density at the MOMENT level (subtracting
989 # the C_floor IC per bin), exactly as the main ½⟨111⟩
990 # population is handled. Reconstructing to per-size
991 # first over-floors near-empty bins at large n and
992 # inflates the size-weighted mean during the transient.
993 # y_sia100_full — the per-size [I, n_pts] reconstruction, kept only for
994 # the size-distribution plots (visualization.py).
995 y_sia100 = None # raw block → moment-level scalars
996 y_sia100_full = None # per-size reconstruction → plots/results
997 if _n_sia100:
998 y_sia100 = y[-_n_sia100:, :]
999 y = y[:-_n_sia100, :]
1000 if _is_bin:
1001 from .bin_moment_rates import reconstruct_distribution
1002 from .post_process import _floor_bin_moments
1003 _i_d = int(getattr(re_obj, 'i_discrete', 0))
1004 _Pm = int(getattr(re_obj, 'n_mom', 2))
1005 _sf = getattr(re_obj, 'shape_function', 'linear')
1006 _bins = re_obj.bins
1007 _Ibin = len(_bins)
1008 _Ifull = int(sim.input_data.I)
1009 _npts = y_sia100.shape[1]
1010 _Cf = float(sim.input_data.reactions.get('C_floor', 1e-15))
1011 _c100 = np.zeros((_Ifull, _npts))
1012 # THE C_floor IC IS REMOVED AT THE MOMENT LEVEL, BEFORE the
1013 # closure reconstructs per-size values -- the same rule
1014 # post_process._floor_bin_moments enforces, and for the same
1015 # reason (its docstring: an empty bin that still carries content
1016 # is how mean_n_100 reached 2853 on a grid of I = 1000).
1017 #
1018 # Reconstructing from RAW moments and subtracting C_floor per
1019 # size afterwards is NOT equivalent: the linear closure spreads a
1020 # near-empty bin's floor residual across its sizes, so the clamp
1021 # leaves a positive size-weighted tail at large n. The <100>
1022 # population sits near-empty for most of a run -- it exists only
1023 # by conversion -- so it is in that regime continuously, and the
1024 # tail is not a small correction. Measured on the T3 campaign
1025 # (2026-08-12): the resulting N_100_vis_1 exceeded the
1026 # moment-level N_loops_100 in 95.3 % of 1173 rows, median 626x,
1027 # max 65540x, and produced the d_100 = 22.87 nm ceiling pile-up
1028 # and f_100 = 0.9999 character inversion.
1029 #
1030 # This view was documented as plot-only, but run_ensemble.observe
1031 # reads it for N_100_vis_* / f_100_tem_*, i.e. for the numbers the
1032 # calibration is scored on. Flooring here fixes every consumer at
1033 # once rather than in each caller.
1034 for _j in range(_npts):
1035 _col = np.maximum(y_sia100[:, _j], 0.0)
1036 _c = np.zeros(_Ifull)
1037 _c[:_i_d] = np.maximum(_col[:_i_d] - _Cf, 0.0)
1038 if _Ibin > 0:
1039 _mom = _col[_i_d:_i_d + _Pm * _Ibin]
1040 _mu0 = _mom[0::_Pm][:_Ibin].astype(float).copy()
1041 _mu1 = (_mom[1::_Pm][:_Ibin].astype(float).copy()
1042 if _Pm >= 2 else None)
1043 _mu2 = _mom[2::_Pm][:_Ibin] if _Pm >= 3 else None
1044 for _kb, (_nlo, _nhi) in enumerate(_bins):
1045 _m0, _m1 = _floor_bin_moments(
1046 _mu0[_kb], _mu1[_kb] if _Pm >= 2 else 0.0,
1047 _nlo, _nhi, _Cf, _Pm)
1048 _mu0[_kb] = _m0
1049 if _Pm >= 2:
1050 _mu1[_kb] = _m1
1051 _rec = reconstruct_distribution(_sf, _mu0, _mu1, _mu2,
1052 _bins, _Ifull)
1053 _c[_i_d:] = _rec[_i_d:]
1054 _c100[:, _j] = _c
1055 y_sia100_full = _c100
1056 else:
1057 # Discrete mode: the block is already per-size, so the raw and
1058 # reconstructed views coincide.
1059 y_sia100_full = y_sia100
1060
1061 results = calculate_derived_quantities(t, y, sim.input_data, re_obj,
1062 y_sia100=y_sia100)
1063 results['y'] = y # raw ODE state [N_eq, n_pts] in atom fraction
1064 if y_sia100_full is not None:
1065 results['y_sia100'] = y_sia100_full # ⟨100⟩ per-size [I, n_pts]
1066 if y_sia100 is not None:
1067 # The ⟨100⟩ block AS SOLVED — the only view that can serve as an
1068 # initial condition for a continuation segment. y_sia100_full is a
1069 # closure reconstruction with the C_floor IC already removed, so
1070 # round-tripping it through the solver is lossy; and results['y']
1071 # has the block split off entirely, so a caller that resumes from
1072 # results['y'][:, -1] alone hands the solver a SHORT y0 and every
1073 # ⟨100⟩ component silently falls back to the parameters.h default
1074 # of 1e-100 (i.e. the population is erased once per segment).
1075 results['y_sia100_raw'] = y_sia100 # [n_sia100, n_pts] as solved
1076
1077 # ── Window-bounds sidecar (active_window mode tracks expansion) ───
1078 # The C++ solver writes <bin_path>.window.csv with one row per output
1079 # point: t,x_hi_i,x_hi_v. We trust this over any heuristic derived
1080 # from y, because indices outside the window stay at C_floor (the
1081 # initial value), making y-based detection unreliable.
1082 win_path = bin_path + '.window.csv'
1083 try:
1084 if os.path.exists(win_path):
1085 wdata = np.genfromtxt(win_path, delimiter=',', skip_header=1)
1086 if wdata.ndim == 1:
1087 wdata = wdata.reshape(1, -1)
1088 n_w = min(wdata.shape[0], len(t))
1089 # x_hi_i/x_hi_v are 0-indexed inclusive upper bounds; the
1090 # active window covers sizes 1..(x_hi+1).
1091 results['n_active_sia'] = (wdata[:n_w, 1].astype(int) + 1)
1092 results['n_active_vac'] = (wdata[:n_w, 2].astype(int) + 1)
1093 results['n_active'] = (results['n_active_sia']
1094 + results['n_active_vac'])
1095 except Exception as exc:
1096 print(f" window sidecar parse failed: {type(exc).__name__}: {exc}")
1097 finally:
1098 try:
1099 os.unlink(win_path)
1100 except OSError:
1101 pass
1102
1103 # Stash immediately so a Ctrl+C during the metadata stamping below
1104 # still leaves something for the orchestrator/notebook to save.
1105 sim._partial_results = results
1106 except KeyboardInterrupt:
1107 print("\n*** Ctrl+C during result post-processing -- "
1108 "returning whatever was rescued. ***")
1109 return sim._partial_results
1110
1111 try:
1112 sm = sim.input_data.solver_mode
1113 po = sim.input_data.physics_option
1114 linsol = str(solver_config.get('solver_method', {}).get('linsol', 'dense')).upper()
1115 msg = f'C++ CVODE BDF {sm}/{po} / {linsol}'
1116 if partial:
1117 msg += ' (partial -- interrupted)'
1118 results['metadata'] = {
1119 'solver_stats': {
1120 'success': True,
1121 'message': msg,
1122 'n_time_points': n_pts,
1123 'partial': bool(partial),
1124 },
1125 'omp_threads_used': solver_info.get('omp_threads_used'),
1126 'solver_stats_final': final_stats or None,
1127 }
1128 print("Results processing complete.")
1129 except KeyboardInterrupt:
1130 print("\n*** Ctrl+C during metadata stamping -- "
1131 "returning rescued results without metadata. ***")
1132 return results
sia100_block_length(sim, solver_config)
_parse_stdout(text, N_eq)
_wait_with_abort_file(proc, timeout_s)
write_param_file(sim, solver_config, path, y0_override=None)
run_cpp_solver(sim, solver_config, base_dir=None, progress_callback=None, timeout_s=None, y0_override=None)
_pin_once(pid, group)
Definition cpp_bridge.py:81
_send_graceful_interrupt(proc)
_make_stderr_handler(progress_callback, info_out=None)