485 Compute tail fractions at a specific time index.
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.
495 is_bin = hasattr(re,
'bins')
497 ns = np.arange(1, I + 1, dtype=float)
498 ms = np.arange(1, V + 1, dtype=float)
500 yj = np.maximum(y[:, idx], 0.0)
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)
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)
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,
533 c_v[:v_d] = yj[iv:iv + v_d]
537 c_v[:v_d] = yj[iv:iv + v_d]
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))
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
618 Map ODE state from the current domain to a larger (I_new, V_new) domain.
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.
625 Returns y0_new for the enlarged domain.
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.
640 is_bin = hasattr(re_old,
'bins')
643 C_floor = float(self.
input_data.reactions.get(
'C_floor', 1e-15))
645 yj = np.maximum(y_old, 0.0)
648 from .bin_moment_rates
import (reconstruct_distribution,
649 moments_from_distribution)
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)
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]
679 mu0_100 = mu1_100 =
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)
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]
698 if re_old.he_mode ==
'case2':
699 Q_tot = yj[re_old.i_Qtot]
703 Q_k_old = yj[re_old.i_Q:re_old.i_Q + K_v_old]
704 Q_tot = np.sum(Q_k_old)
711 q_n_old = np.zeros(V_old)
712 for k, (mlo, mhi)
in enumerate(re_old.vac_bins):
716 ell_bin = Q_k_old[k] / mu0_k
717 ns = np.arange(mlo, mhi)
719 valid = (idx >= 0) & (idx < V_old)
720 q_n_old[idx[valid]] = ell_bin * c_v_old[idx[valid]]
722 c_h =
None if re_old.qss_He
else yj[re_old.i_He]
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
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
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)
748 y0 = np.full(re_new.N_eq, C_floor)
750 i_d_new = re_new.i_discrete
751 y0[:i_d_new] = c_n_new[:i_d_new]
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]
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]
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]
773 if re_new.he_mode ==
'case2':
774 y0[re_new.i_Qtot] = Q_tot
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
799 re_old.bins, re_new.bins)
800 blk = np.full(i_d_new + P_new * re_new.K_i, C_floor)
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]
808 blk[i_d_new + P_new * k + 1] = mu1_n[k]
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])
821 c_v_old = yj[I_old:I_old + V_old]
823 if re_old.he_mode ==
'case2':
824 Q_tot = yj[re_old.i_Qtot]
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]
835 y0 = np.full(re_new.N_eq, C_floor)
837 y0[I_new:I_new + V_old] = c_v_old
839 if re_new.he_mode ==
'case2':
840 y0[re_new.i_Qtot] = Q_tot
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
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])
956 Remap old y to the new layout and concatenate.
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
963 old_y = accumulated[
'y']
964 new_y = new_segment[
'y']
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])
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
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).")
985 sia_old = old_y[:iv_old, :]
986 vac_old = old_y[iv_old:ih_old, :]
987 he_old = old_y[ih_old:, :]
989 sia_new = new_y[:iv_new, :]
990 vac_new = new_y[iv_new:ih_new, :]
991 he_new = new_y[ih_new:, :]
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])
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)
1006 old_remapped = np.vstack([sia_old, vac_old, he_old])
1007 return np.concatenate([old_remapped, new_y[:, skip:]], axis=1)
1010 progress_callback=None, timeout_s=None,
1011 boundary_threshold=0.01, max_doublings=6,
1012 points_per_segment=10):
1014 Truly adaptive domain doubling via short time-segments.
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*:
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.
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.
1032 boundary_threshold : float
1033 Adapt if tail fraction > this (default 0.01 = 1 %).
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.
1044 if solver_config
is None:
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)
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)
1056 checkpoints = np.linspace(t_begin, t_end, n_segments + 1)
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)]
1076 checkpoints = np.unique(np.concatenate([checkpoints, req]))
1079 n_segments = len(checkpoints) - 1
1089 import time
as _time
1090 _deadline = (_time.monotonic() + timeout_s)
if timeout_s
else None
1100 _deferred_exc =
None
1102 max_iters = n_segments + 2 * max_doublings + 1
1105 while current_t < t_end * (1 - 1e-10)
and seg_count < max_iters:
1111 future = checkpoints[checkpoints > current_t * (1 + 1e-10)]
1112 seg_t_end = float(future[0])
if len(future) > 0
else t_end
1114 print(f
"\n[Segment {seg_count}] I={I_cur} V={V_cur}"
1115 f
" t=[{current_t:.2e}, {seg_t_end:.2e}]")
1117 seg_config = dict(solver_config)
1118 seg_config[
't_span'] = (current_t, seg_t_end)
1119 seg_config[
'n_points'] = points_per_segment
1122 if _deadline
is not None:
1123 _left = _deadline - _time.monotonic()
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.")
1134 solver_config=seg_config,
1136 progress_callback=progress_callback,
1138 y0_override=y0_override,
1142 print(
" Solver failed — returning accumulated results.")
1143 if accumulated
is not None and save_output:
1145 mean_n_i=accumulated[
'mean_n_i'][-1]
1146 if 'mean_n_i' in accumulated
else None)
1153 seg_partial = results.get(
'metadata', {}).get(
1154 'solver_stats', {}).get(
'partial',
False)
1156 print(
" Segment ended early (interrupt or timeout) — "
1157 "stopping adaptive run.")
1170 float(results[
't'][-1] - results[
't'][0]))
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
1177 if can_double_I
or can_double_V:
1180 results, boundary_threshold)
1181 if exceed_idx
is None:
1182 exceed_idx = results[
'y'].shape[1] - 1
1185 results, exceed_idx)
1186 t_exceed = results[
't'][exceed_idx]
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
1201 which.append(f
'I: {I_cur}→{I_new} [{n_doublings_I+1}/{max_doublings}]')
1204 which.append(f
'V: {V_cur}→{V_new} [{n_doublings_V+1}/{max_doublings}]')
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)}")
1212 y_at = results[
'y'][:, exceed_idx]
1214 y0_override = self.
_expand_state(y_at, I_new, V_new, y100_at)
1215 current_t = t_exceed
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)")
1227 print(f
" Tail OK: SIA={frac_I:.3f} VAC={frac_V:.3f}")
1231 current_t = results[
't'][-1]
1234 except KeyboardInterrupt:
1236 print(
"\n\n*** KeyboardInterrupt — stopping gracefully. ***")
1238 if results
is not None and results
is not accumulated:
1244 except BaseException
as _exc:
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 "
1256 if results
is not None and results
is not accumulated:
1264 if accumulated
is not None:
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")
1276 mean_n_i=accumulated[
'mean_n_i'][-1]
1277 if 'mean_n_i' in accumulated
else None)
1280 print(
"\nNo completed segments to save.")
1287 if _deferred_exc
is not None: