TransitionListener 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. transitionlistener/__init__.py +21 -0
  2. transitionlistener/bubbledynamics.py +2250 -0
  3. transitionlistener/bubbledynamics_fixedstep.py +2929 -0
  4. transitionlistener/check_installation.py +309 -0
  5. transitionlistener/ckm.py +91 -0
  6. transitionlistener/colors.py +288 -0
  7. transitionlistener/config.py +185 -0
  8. transitionlistener/constants.py +64 -0
  9. transitionlistener/counterterms/__init__.py +230 -0
  10. transitionlistener/counterterms/cw.py +298 -0
  11. transitionlistener/counterterms/tensors.py +343 -0
  12. transitionlistener/counterterms/twohdm.py +520 -0
  13. transitionlistener/counterterms/twohdm_solver.py +374 -0
  14. transitionlistener/errors.py +315 -0
  15. transitionlistener/examples/bp_et.yaml +64 -0
  16. transitionlistener/examples/bp_lisa.yaml +70 -0
  17. transitionlistener/examples/bp_muares.yaml +69 -0
  18. transitionlistener/examples/bp_pta.yaml +64 -0
  19. transitionlistener/examples/example_dark_photon.yaml +73 -0
  20. transitionlistener/examples/example_grid.yaml +55 -0
  21. transitionlistener/examples/example_line.yaml +51 -0
  22. transitionlistener/examples/example_point.yaml +73 -0
  23. transitionlistener/examples/example_random_conformal.yaml +42 -0
  24. transitionlistener/examples/example_ultranest.yaml +39 -0
  25. transitionlistener/finiteT.py +468 -0
  26. transitionlistener/generic_potential.py +1521 -0
  27. transitionlistener/gridplots.py +592 -0
  28. transitionlistener/gwfopt.py +604 -0
  29. transitionlistener/helper_functions.py +843 -0
  30. transitionlistener/hydrodynamics.py +647 -0
  31. transitionlistener/interface/__init__.py +95 -0
  32. transitionlistener/interface/check.py +51 -0
  33. transitionlistener/interface/cli.py +211 -0
  34. transitionlistener/interface/config_loader.py +264 -0
  35. transitionlistener/interface/logging_utils.py +30 -0
  36. transitionlistener/interface/output_schema.py +296 -0
  37. transitionlistener/interface/pipeline.py +300 -0
  38. transitionlistener/interface/samplers.py +565 -0
  39. transitionlistener/interface/scans.py +468 -0
  40. transitionlistener/interface/single_point.py +581 -0
  41. transitionlistener/interface/state.py +61 -0
  42. transitionlistener/lineplots.py +597 -0
  43. transitionlistener/logo/TL-logo_large.png +0 -0
  44. transitionlistener/logo/TL-logo_small.png +0 -0
  45. transitionlistener/models/TL_2HDM.py +512 -0
  46. transitionlistener/models/TL_conformal_dark_u1.py +202 -0
  47. transitionlistener/models/TL_dark_U1.py +198 -0
  48. transitionlistener/models/TL_dark_U1_g_parameterization.py +198 -0
  49. transitionlistener/models/TL_dark_flipflop.py +274 -0
  50. transitionlistener/models/templatePotential.py +260 -0
  51. transitionlistener/nucleation.py +412 -0
  52. transitionlistener/observability.py +790 -0
  53. transitionlistener/particles.py +436 -0
  54. transitionlistener/pathDeformation.py +1462 -0
  55. transitionlistener/percolation_adaptive_gridbuilders.py +1385 -0
  56. transitionlistener/percolation_adaptive_rate.py +1049 -0
  57. transitionlistener/percolation_adaptivestepsize.py +1632 -0
  58. transitionlistener/percolation_fixedstepsize.py +618 -0
  59. transitionlistener/phases.py +1499 -0
  60. transitionlistener/plot_settings.py +121 -0
  61. transitionlistener/plots.py +1781 -0
  62. transitionlistener/runtime_options.py +278 -0
  63. transitionlistener/tab_data/30f_fs{hd}_100fDMGP_ceffyl/bandwidths.npy +0 -0
  64. transitionlistener/tab_data/30f_fs{hd}_100fDMGP_ceffyl/density.npy +0 -0
  65. transitionlistener/tab_data/30f_fs{hd}_100fDMGP_ceffyl/freqs.npy +0 -0
  66. transitionlistener/tab_data/30f_fs{hd}_100fDMGP_ceffyl/log.txt +16 -0
  67. transitionlistener/tab_data/30f_fs{hd}_100fDMGP_ceffyl/log10rhogrid.npy +0 -0
  68. transitionlistener/tab_data/30f_fs{hd}_100fDMGP_ceffyl/log10rholabels.txt +30 -0
  69. transitionlistener/tab_data/30f_fs{hd}_100fDMGP_ceffyl/pulsar_list.txt +1 -0
  70. transitionlistener/tab_data/HLV-O2-PLI.csv +82 -0
  71. transitionlistener/tab_data/HLV-O2-noise.csv +83 -0
  72. transitionlistener/tab_data/HLVK-design-PLI.csv +61 -0
  73. transitionlistener/tab_data/HLVK-design-noise.csv +83 -0
  74. transitionlistener/tab_data/NG15_PLI_hc.csv +31 -0
  75. transitionlistener/tab_data/NG15_noise_hc.csv +56 -0
  76. transitionlistener/tab_data/PLI-dataset.csv +327 -0
  77. transitionlistener/tab_data/energydensities.dat +1001 -0
  78. transitionlistener/tab_data/finiteT_b.dat.txt +1999 -0
  79. transitionlistener/tab_data/finiteT_f.dat.txt +1000 -0
  80. transitionlistener/tab_data/geff_SM.dat +10009 -0
  81. transitionlistener/tab_data/muAres_f_Hz_PLI_h2OmegaGW.csv +131 -0
  82. transitionlistener/tab_data/muAres_f_Hz_h2Omega_noise.csv +99 -0
  83. transitionlistener/tab_data/noise-dataset.csv +327 -0
  84. transitionlistener/tab_data/numberdensities.dat +1001 -0
  85. transitionlistener/tab_data/pressures.dat +1001 -0
  86. transitionlistener/thermodynamics.py +686 -0
  87. transitionlistener/transitionObservables.py +1199 -0
  88. transitionlistener/transitionObservables_fixedstep.py +976 -0
  89. transitionlistener/transitions.py +1000 -0
  90. transitionlistener/tunneling1D.py +910 -0
  91. transitionlistener-2.0.0.dist-info/METADATA +817 -0
  92. transitionlistener-2.0.0.dist-info/RECORD +95 -0
  93. transitionlistener-2.0.0.dist-info/WHEEL +4 -0
  94. transitionlistener-2.0.0.dist-info/entry_points.txt +2 -0
  95. transitionlistener-2.0.0.dist-info/licenses/LICENSE.txt +674 -0
@@ -0,0 +1,1049 @@
1
+ """Rate-peak and hot-onset helpers for adaptive_step_size percolation.
2
+
3
+ Part of TransitionListener v2.0
4
+ Documentation: https://tasillo.de/TransitionListener/
5
+
6
+ Authors:
7
+ Jonas Matuszak <jonas.matuszak@kit.edu>
8
+ Carlo Tasillo <carlo.tasillo@ific.uv.es>
9
+ """
10
+
11
+ import math
12
+
13
+ import numpy as np
14
+ from scipy import interpolate
15
+ from scipy import optimize
16
+
17
+ from transitionlistener.bubbledynamics import _temperature_grid
18
+
19
+ HOT_RATE_REFINE_LOG = 1.0
20
+ HOT_RATE_ACCEPT_LOG = 0.0
21
+ HOT_RATE_ANCHORS = (3.0, 2.0, 1.0, 0.0)
22
+ HOT_PROBABILITY_ANCHORS = (1.0e-3, 3.0e-3, 1.0e-2)
23
+ HOT_SHOULDER_LOG10_RATE_THRESHOLD = 1.0
24
+ HOT_SHOULDER_MAX_LOG_STEP = 0.3
25
+ HOT_SHOULDER_ONSET_P_ZERO = 1.0e-8
26
+ HOT_SHOULDER_ONSET_MAX_LOG_STEP = 0.05
27
+
28
+ RATE_PEAK_REFINE_FLOOR_LOG10 = -10.0
29
+ RATE_PEAK_ONRAMP_FLOOR_LOG10 = -18.0
30
+ RATE_PEAK_ONRAMP_MIN_GAIN_LOG10 = 8.0
31
+ RATE_PEAK_ONRAMP_MIN_LOG_WIDTH = 0.12
32
+ SUBCRITICAL_RATE_PEAK_MIN_PROMINENCE = 1.0
33
+ RATE_PEAK_INERT_MIN_PEAK_LOG10 = -3.0
34
+ RATE_PEAK_INERT_LOG10 = -10.0
35
+ RATE_PEAK_INERT_MAX_INTERVAL_DI = 1.0e-4
36
+ RATE_PEAK_INERT_MIN_MONOTONE_POINTS = 2
37
+ RATE_PEAK_INERT_MONOTONE_TOLERANCE_LOG10 = 0.25
38
+
39
+
40
+ def _temperature_midpoint(high: float, low: float) -> float | None:
41
+ """Return a geometric or arithmetic midpoint suited to logarithmic temperature scans."""
42
+ high = float(high)
43
+ low = float(low)
44
+ if not (np.isfinite(high) and np.isfinite(low)) or high <= low:
45
+ return None
46
+ if high > 0.0 and low > 0.0 and high / low >= 2.0:
47
+ return float(np.sqrt(high * low))
48
+ return 0.5 * (high + low)
49
+
50
+
51
+ def _temperature_tolerance(
52
+ values: np.ndarray | list[float] | tuple[float, ...] | None,
53
+ candidate: float,
54
+ ) -> float:
55
+ """Return the absolute tolerance used when deduplicating temperature samples."""
56
+ candidate = float(candidate)
57
+ values_arr = np.asarray([] if values is None else values, dtype=float)
58
+ scale = max(
59
+ abs(candidate),
60
+ np.max(np.abs(values_arr)) if values_arr.size else 0.0,
61
+ 1.0,
62
+ )
63
+ return 1e-6 * scale
64
+
65
+
66
+ def _temperature_present(
67
+ values: np.ndarray | list[float] | tuple[float, ...] | None,
68
+ candidate: float,
69
+ ) -> bool:
70
+ """Return whether ``candidate`` is already represented inside ``values``."""
71
+ values_arr = np.asarray([] if values is None else values, dtype=float)
72
+ if values_arr.size == 0:
73
+ return False
74
+ return bool(np.any(np.isclose(values_arr, float(candidate), atol=_temperature_tolerance(values_arr, candidate), rtol=0.0)))
75
+
76
+
77
+ def _find_value_crossing(
78
+ temperatures: np.ndarray,
79
+ values: np.ndarray,
80
+ target: float,
81
+ ) -> tuple[float | None, float | None, int | None]:
82
+ """Locate the first linearised crossing of ``values`` through ``target``."""
83
+ temps = np.asarray(temperatures, dtype=float)
84
+ sampled = np.asarray(values, dtype=float)
85
+ if temps.size != sampled.size or temps.size < 2:
86
+ return None, None, None
87
+
88
+ bracket_index = None
89
+ for index in range(sampled.size - 1):
90
+ value_high = float(sampled[index])
91
+ value_low = float(sampled[index + 1])
92
+ if not np.isfinite(value_high) or not np.isfinite(value_low):
93
+ continue
94
+ if value_high == target or value_low == target or (
95
+ value_high < target < value_low or value_low < target < value_high
96
+ ):
97
+ bracket_index = index
98
+ break
99
+ if bracket_index is None:
100
+ return None, None, None
101
+
102
+ i_hi = int(bracket_index)
103
+ i_lo = min(i_hi + 1, len(temps) - 1)
104
+ T_hi = float(temps[i_hi])
105
+ T_lo = float(temps[i_lo])
106
+ V_hi = float(sampled[i_hi])
107
+ V_lo = float(sampled[i_lo])
108
+ if not (np.isfinite(T_hi) and np.isfinite(T_lo) and np.isfinite(V_hi) and np.isfinite(V_lo)):
109
+ return None, None, None
110
+ if abs(V_lo - V_hi) < 1e-15:
111
+ return 0.5 * (T_hi + T_lo), abs(T_hi - T_lo), i_hi
112
+ crossing = T_hi + (float(target) - V_hi) / (V_lo - V_hi) * (T_lo - T_hi)
113
+ return float(crossing), abs(T_hi - T_lo), i_hi
114
+
115
+
116
+ def _probability_at(temperature: float, temperatures: np.ndarray, probabilities: np.ndarray) -> float | None:
117
+ """Interpolate the false-vacuum probability profile at one temperature."""
118
+ temps = np.asarray(temperatures, dtype=float)
119
+ probs = np.asarray(probabilities, dtype=float)
120
+ finite = np.isfinite(temps) & np.isfinite(probs)
121
+ temps = temps[finite]
122
+ probs = probs[finite]
123
+ if temps.size < 2:
124
+ return None
125
+ temperature = float(temperature)
126
+ scale = max(np.max(np.abs(temps)), abs(temperature), 1.0)
127
+ matches = np.flatnonzero(np.isclose(temps, temperature, atol=1e-10 * scale, rtol=0.0))
128
+ if matches.size:
129
+ value = float(probs[int(matches[0])])
130
+ return value if np.isfinite(value) else None
131
+ order = np.argsort(temps)
132
+ temps = temps[order]
133
+ probs = probs[order]
134
+ if np.any(np.diff(temps) <= 0.0):
135
+ return None
136
+ value = float(np.interp(temperature, temps, probs, left=np.nan, right=np.nan))
137
+ if not np.isfinite(value):
138
+ return None
139
+ return float(min(max(value, 0.0), 1.0))
140
+
141
+
142
+ def _remesh_dp_limit(probability_high: float, probability_low: float, settings) -> float:
143
+ """Choose the allowed probability jump for a remeshed interval based on its regime."""
144
+ generic = max(float(settings.large_delta_p_refine_threshold), 0.0)
145
+ strict = 0.05
146
+ p_min = min(float(probability_high), float(probability_low))
147
+ p_max = max(float(probability_high), float(probability_low))
148
+ if p_max <= 0.2 or p_min < 0.2:
149
+ return strict
150
+ if p_min >= 0.8 or p_max > 0.8:
151
+ return min(generic, strict) if generic > 0.0 else strict
152
+ return generic
153
+
154
+
155
+ def _log_gamma_h4_array(
156
+ temperatures: np.ndarray,
157
+ actions: np.ndarray,
158
+ hubble: np.ndarray,
159
+ ) -> np.ndarray:
160
+ """Vectorized ``log(Gamma/H^4)`` for support arrays."""
161
+
162
+ from transitionlistener import bubbledynamics as bd
163
+
164
+ temps = np.asarray(temperatures, dtype=float)
165
+ acts = np.asarray(actions, dtype=float)
166
+ hub = np.asarray(hubble, dtype=float)
167
+ result = np.full(temps.shape, np.nan, dtype=float)
168
+ valid = np.isfinite(temps) & np.isfinite(acts) & np.isfinite(hub) & (temps > 0.0) & (hub > 0.0)
169
+ if np.any(valid):
170
+ result[valid] = np.asarray(bd.logGamma(temps[valid], acts[valid]), dtype=float) - 4.0 * np.log(hub[valid])
171
+ return result
172
+
173
+
174
+ def _log10_gamma_h4_array(
175
+ temperatures: np.ndarray,
176
+ actions: np.ndarray,
177
+ hubble: np.ndarray,
178
+ ) -> np.ndarray:
179
+ """Vectorized ``log10(Gamma/H^4)`` for support arrays."""
180
+
181
+ return _log_gamma_h4_array(temperatures, actions, hubble) / math.log(10.0)
182
+
183
+
184
+ def _refine_step1_rate_peak(
185
+ temperatures: np.ndarray,
186
+ log_gamma_h4_sym: np.ndarray,
187
+ ) -> tuple[float | None, float | None, int | None]:
188
+ """Return one interior peak of log(Gamma/H^4_sym), refined from cached scout points."""
189
+
190
+ temps = np.asarray(temperatures, dtype=float)
191
+ log_rate = np.asarray(log_gamma_h4_sym, dtype=float)
192
+ if temps.size != log_rate.size or temps.size < 3:
193
+ return None, None, None
194
+
195
+ peak_index = int(np.nanargmax(log_rate))
196
+ if peak_index <= 0 or peak_index >= temps.size - 1:
197
+ return None, None, None
198
+
199
+ peak_value = float(log_rate[peak_index])
200
+ left_neighbor = float(log_rate[peak_index - 1])
201
+ right_neighbor = float(log_rate[peak_index + 1])
202
+ boundary_prominence = peak_value - max(float(log_rate[0]), float(log_rate[-1]))
203
+ if not (peak_value > left_neighbor and peak_value > right_neighbor and boundary_prominence > 1.0):
204
+ return None, None, None
205
+
206
+ x = np.asarray(temps[::-1], dtype=float)
207
+ y = np.asarray(log_rate[::-1], dtype=float)
208
+ interpolant = interpolate.PchipInterpolator(x, y, extrapolate=False)
209
+ lower = float(temps[peak_index + 1])
210
+ upper = float(temps[peak_index - 1])
211
+ if not (np.isfinite(lower) and np.isfinite(upper) and lower < upper):
212
+ return float(temps[peak_index]), peak_value, peak_index
213
+
214
+ try:
215
+ optimum = optimize.minimize_scalar(
216
+ lambda value: -float(interpolant(float(value))),
217
+ bounds=(lower, upper),
218
+ method="bounded",
219
+ options={"xatol": max(1e-10 * max(abs(lower), abs(upper), 1.0), 1e-14)},
220
+ )
221
+ except Exception:
222
+ optimum = None
223
+
224
+ if optimum is None or not bool(getattr(optimum, "success", False)):
225
+ return float(temps[peak_index]), peak_value, peak_index
226
+
227
+ peak_temperature = float(optimum.x)
228
+ peak_log_rate = float(interpolant(peak_temperature))
229
+ if not (np.isfinite(peak_temperature) and np.isfinite(peak_log_rate)):
230
+ return float(temps[peak_index]), peak_value, peak_index
231
+ return peak_temperature, peak_log_rate, peak_index
232
+
233
+
234
+ def _build_peak_centered_step1_window(
235
+ temperatures: np.ndarray,
236
+ log_gamma_h4_sym: np.ndarray,
237
+ settings,
238
+ ) -> tuple[float | None, float | None, float | None, float | None]:
239
+ """Build a startup window around the interior rate peak of a U-shaped action."""
240
+
241
+ temps = np.asarray(temperatures, dtype=float)
242
+ log_rate = np.asarray(log_gamma_h4_sym, dtype=float)
243
+ peak_temperature, peak_log_rate, peak_index = _refine_step1_rate_peak(temps, log_rate)
244
+ if peak_temperature is None or peak_log_rate is None or peak_index is None:
245
+ return None, None, None, None
246
+
247
+ drop = max(4.0, -math.log(max(float(settings.f_start), 1e-12)))
248
+ threshold = float(peak_log_rate) - float(drop)
249
+
250
+ start_index = int(peak_index)
251
+ while start_index > 0 and float(log_rate[start_index - 1]) >= threshold:
252
+ start_index -= 1
253
+ end_index = int(peak_index)
254
+ while end_index < len(log_rate) - 1 and float(log_rate[end_index + 1]) >= threshold:
255
+ end_index += 1
256
+
257
+ window_high = float(temps[start_index])
258
+ window_low = float(temps[end_index])
259
+
260
+ if start_index > 0:
261
+ T_hi = float(temps[start_index - 1])
262
+ T_lo = float(temps[start_index])
263
+ y_hi = float(log_rate[start_index - 1])
264
+ y_lo = float(log_rate[start_index])
265
+ if np.isfinite(y_hi) and np.isfinite(y_lo) and abs(y_lo - y_hi) > 1e-15:
266
+ frac = (threshold - y_hi) / (y_lo - y_hi)
267
+ if 0.0 <= frac <= 1.0:
268
+ window_high = float(T_hi + frac * (T_lo - T_hi))
269
+
270
+ if end_index < len(log_rate) - 1:
271
+ T_hi = float(temps[end_index])
272
+ T_lo = float(temps[end_index + 1])
273
+ y_hi = float(log_rate[end_index])
274
+ y_lo = float(log_rate[end_index + 1])
275
+ if np.isfinite(y_hi) and np.isfinite(y_lo) and abs(y_lo - y_hi) > 1e-15:
276
+ frac = (threshold - y_hi) / (y_lo - y_hi)
277
+ if 0.0 <= frac <= 1.0:
278
+ window_low = float(T_hi + frac * (T_lo - T_hi))
279
+
280
+ if not (np.isfinite(window_high) and np.isfinite(window_low) and window_high > window_low):
281
+ return None, None, None, None
282
+ return float(peak_temperature), float(peak_log_rate), float(window_high), float(window_low)
283
+
284
+
285
+ def _build_log_temperature_bridge_grid(
286
+ hot_temperature: float,
287
+ cold_temperature: float,
288
+ *,
289
+ max_log_step: float,
290
+ max_new_points: int,
291
+ ) -> np.ndarray | None:
292
+ """Return geometric bridge points so the interval obeys one ``Delta ln T`` limit."""
293
+
294
+ hot_temperature = float(hot_temperature)
295
+ cold_temperature = float(cold_temperature)
296
+ if (
297
+ max_new_points <= 0
298
+ or not np.isfinite(hot_temperature)
299
+ or not np.isfinite(cold_temperature)
300
+ or hot_temperature <= cold_temperature
301
+ or cold_temperature <= 0.0
302
+ or hot_temperature <= 0.0
303
+ ):
304
+ return None
305
+
306
+ dln_t = float(math.log(hot_temperature / cold_temperature))
307
+ if not np.isfinite(dln_t) or dln_t <= float(max_log_step):
308
+ return None
309
+
310
+ n_segments = max(int(math.ceil(dln_t / max(float(max_log_step), 1.0e-6))), 2)
311
+ n_points = min(max(int(n_segments - 1), 1), int(max_new_points))
312
+ bridge = np.geomspace(hot_temperature, cold_temperature, n_points + 2, dtype=float)[1:-1]
313
+ bridge = _temperature_grid(bridge)
314
+ return bridge if bridge.size > 0 else None
315
+
316
+
317
+ def _build_dynamiczoomwindow_hot_shoulder_grid(
318
+ temperatures: np.ndarray,
319
+ probabilities: np.ndarray,
320
+ actions: np.ndarray,
321
+ hubble: np.ndarray,
322
+ settings,
323
+ *,
324
+ max_new_points: int,
325
+ ) -> tuple[np.ndarray | None, str | None]:
326
+ """Resolve low-P hot-shoulder gaps where the local rate is already active."""
327
+
328
+ if max_new_points <= 0:
329
+ return None, None
330
+
331
+ temps = np.asarray(temperatures, dtype=float)
332
+ probs = np.asarray(probabilities, dtype=float)
333
+ acts = np.asarray(actions, dtype=float)
334
+ hub = np.asarray(hubble, dtype=float)
335
+ if not (temps.size >= 2 and temps.size == probs.size == acts.size == hub.size):
336
+ return None, None
337
+ order = np.argsort(-temps)
338
+ temps = temps[order]
339
+ probs = probs[order]
340
+ acts = acts[order]
341
+ hub = hub[order]
342
+
343
+ p_upper = max(max(HOT_PROBABILITY_ANCHORS), float(settings.f_start))
344
+ rate_threshold = HOT_SHOULDER_LOG10_RATE_THRESHOLD
345
+ max_log_step = HOT_SHOULDER_MAX_LOG_STEP
346
+ onset_max_log_step = HOT_SHOULDER_ONSET_MAX_LOG_STEP
347
+ onset_p_zero = HOT_SHOULDER_ONSET_P_ZERO
348
+ best_interval: tuple[float, float, float, float, bool] | None = None
349
+ log10_rate = _log10_gamma_h4_array(temps, acts, hub)
350
+
351
+ for i in range(temps.size - 1):
352
+ hot_temperature = float(temps[i])
353
+ cold_temperature = float(temps[i + 1])
354
+ hot_probability = float(probs[i])
355
+ cold_probability = float(probs[i + 1])
356
+ if max(hot_probability, cold_probability) > p_upper + 1.0e-12:
357
+ continue
358
+ if hot_temperature <= 0.0 or cold_temperature <= 0.0:
359
+ continue
360
+ dln_t = float(math.log(hot_temperature / cold_temperature))
361
+ if not np.isfinite(dln_t):
362
+ continue
363
+ is_onset_crossing = hot_probability < onset_p_zero and cold_probability > onset_p_zero
364
+ effective_threshold = onset_max_log_step if is_onset_crossing else max_log_step
365
+ if dln_t <= effective_threshold:
366
+ continue
367
+ endpoint_rates = log10_rate[[i, i + 1]]
368
+ finite_endpoint_rates = endpoint_rates[np.isfinite(endpoint_rates)]
369
+ rate_level = float(np.max(finite_endpoint_rates)) if finite_endpoint_rates.size else -np.inf
370
+ rate_active = np.isfinite(rate_level) and rate_level > rate_threshold
371
+ onset_buried = is_onset_crossing or (
372
+ hot_probability < onset_p_zero
373
+ and cold_probability < onset_p_zero
374
+ and (i + 2) < temps.size
375
+ and float(probs[i + 2]) > onset_p_zero
376
+ )
377
+ if not (rate_active or onset_buried):
378
+ continue
379
+ if best_interval is None or dln_t > best_interval[3]:
380
+ best_interval = (hot_temperature, cold_temperature, rate_level, dln_t, is_onset_crossing)
381
+
382
+ if best_interval is None:
383
+ return None, None
384
+
385
+ hot_temperature, cold_temperature, rate_level, dln_t, is_onset_crossing = best_interval
386
+ bridge_max_log_step = onset_max_log_step if is_onset_crossing else max_log_step
387
+ candidate = _build_log_temperature_bridge_grid(
388
+ hot_temperature,
389
+ cold_temperature,
390
+ max_log_step=bridge_max_log_step,
391
+ max_new_points=int(max_new_points),
392
+ )
393
+ if candidate is None or candidate.size == 0:
394
+ return None, None
395
+ mode_tag = "onset_crossing" if is_onset_crossing else "low-P"
396
+ reason = (
397
+ f"hot_shoulder_refine from {mode_tag} interval "
398
+ f"T={hot_temperature:2.5g}->{cold_temperature:2.5g}, "
399
+ f"dlnT={dln_t:2.3g}, max log10(Gamma/H^4)={rate_level:2.3g}"
400
+ )
401
+ return candidate, reason
402
+
403
+
404
+ def _build_dynamiczoomwindow_rate_peak_grid(
405
+ temperatures: np.ndarray,
406
+ actions: np.ndarray,
407
+ hubble: np.ndarray,
408
+ settings,
409
+ *,
410
+ max_new_points: int,
411
+ ) -> tuple[np.ndarray | None, str | None]:
412
+ """Refine around a sampled local maximum or unresolved on-ramp of ``Gamma/H^4``."""
413
+
414
+ if max_new_points <= 0:
415
+ return None, None
416
+
417
+ temps = np.asarray(temperatures, dtype=float)
418
+ acts = np.asarray(actions, dtype=float)
419
+ hub = np.asarray(hubble, dtype=float)
420
+ if not (temps.size >= 3 and temps.size == acts.size == hub.size):
421
+ return None, None
422
+
423
+ order = np.argsort(-temps)
424
+ temps = temps[order]
425
+ acts = acts[order]
426
+ hub = hub[order]
427
+ log10_rate = _log10_gamma_h4_array(temps, acts, hub)
428
+ finite = np.isfinite(temps) & np.isfinite(log10_rate) & (temps > 0.0)
429
+ if np.count_nonzero(finite) < 2:
430
+ return None, None
431
+
432
+ candidate = np.asarray([], dtype=float)
433
+ occupied = _temperature_grid(temps)
434
+
435
+ def add_candidate(value: float | None) -> bool:
436
+ nonlocal candidate
437
+ if value is None:
438
+ return False
439
+ try:
440
+ numeric = float(value)
441
+ except Exception:
442
+ return False
443
+ if not np.isfinite(numeric):
444
+ return False
445
+ if _temperature_present(occupied, numeric) or _temperature_present(candidate, numeric):
446
+ return False
447
+ candidate = _temperature_grid(np.append(candidate, numeric))
448
+ return True
449
+
450
+ def add_interval_points(high: float, low: float, budget: int) -> None:
451
+ if budget <= 0:
452
+ return
453
+ high = float(high)
454
+ low = float(low)
455
+ if not (np.isfinite(high) and np.isfinite(low) and high > low and low > 0.0):
456
+ return
457
+ if high / low >= 1.25:
458
+ points = np.geomspace(high, low, int(budget) + 2, dtype=float)[1:-1]
459
+ else:
460
+ points = np.linspace(high, low, int(budget) + 2, dtype=float)[1:-1]
461
+ for point in points:
462
+ add_candidate(float(point))
463
+
464
+ def build_rate_onramp_candidate() -> tuple[np.ndarray | None, str | None]:
465
+ nonlocal candidate
466
+ candidate = np.asarray([], dtype=float)
467
+ rate_floor = RATE_PEAK_REFINE_FLOOR_LOG10
468
+ onramp_floor = RATE_PEAK_ONRAMP_FLOOR_LOG10
469
+ min_rate_gain = RATE_PEAK_ONRAMP_MIN_GAIN_LOG10
470
+ min_log_width = RATE_PEAK_ONRAMP_MIN_LOG_WIDTH
471
+
472
+ best_interval: tuple[float, int, float, float, float, float] | None = None
473
+ for index in range(temps.size - 1):
474
+ if not (finite[index] and finite[index + 1]):
475
+ continue
476
+ hot_temperature = float(temps[index])
477
+ cold_temperature = float(temps[index + 1])
478
+ if not (hot_temperature > cold_temperature > 0.0):
479
+ continue
480
+ hot_log_rate = float(log10_rate[index])
481
+ cold_log_rate = float(log10_rate[index + 1])
482
+ rate_gain = cold_log_rate - hot_log_rate
483
+ log_width = float(math.log(hot_temperature / cold_temperature))
484
+ if not (np.isfinite(rate_gain) and np.isfinite(log_width)):
485
+ continue
486
+ if rate_gain < min_rate_gain or log_width < min_log_width:
487
+ continue
488
+ if cold_log_rate < onramp_floor and max(hot_log_rate, cold_log_rate) < rate_floor:
489
+ continue
490
+
491
+ score = log_width * max(rate_gain, 0.0) * (1.0 + max(cold_log_rate - onramp_floor, 0.0))
492
+ if best_interval is None or score > best_interval[0]:
493
+ best_interval = (score, index, hot_temperature, cold_temperature, hot_log_rate, cold_log_rate)
494
+
495
+ if best_interval is None:
496
+ return None, None
497
+
498
+ _, _, hot_temperature, cold_temperature, hot_log_rate, cold_log_rate = best_interval
499
+ for target in (rate_floor, -5.0, 0.0):
500
+ if candidate.size >= int(max_new_points):
501
+ break
502
+ if min(hot_log_rate, cold_log_rate) <= float(target) <= max(hot_log_rate, cold_log_rate):
503
+ if abs(cold_log_rate - hot_log_rate) > 1.0e-15:
504
+ frac = (float(target) - hot_log_rate) / (cold_log_rate - hot_log_rate)
505
+ log_hot = math.log(hot_temperature)
506
+ log_cold = math.log(cold_temperature)
507
+ add_candidate(float(math.exp(log_hot + frac * (log_cold - log_hot))))
508
+
509
+ if candidate.size < int(max_new_points):
510
+ add_interval_points(
511
+ hot_temperature,
512
+ cold_temperature,
513
+ int(max_new_points) - int(candidate.size),
514
+ )
515
+
516
+ if candidate.size == 0:
517
+ return None, None
518
+ reason = (
519
+ "rate_peak_refine on unresolved on-ramp "
520
+ f"T={hot_temperature:2.5g}->{cold_temperature:2.5g}, "
521
+ f"log10(Gamma/H^4)={hot_log_rate:2.3g}->{cold_log_rate:2.3g}"
522
+ )
523
+ return candidate[: int(max_new_points)], reason
524
+
525
+ def add_rate_crossings(target: float) -> None:
526
+ for index in range(temps.size - 1):
527
+ left = float(log10_rate[index])
528
+ right = float(log10_rate[index + 1])
529
+ if not (np.isfinite(left) and np.isfinite(right)):
530
+ continue
531
+ if (left - target) * (right - target) > 0.0:
532
+ continue
533
+ if abs(right - left) < 1.0e-15:
534
+ add_candidate(0.5 * (float(temps[index]) + float(temps[index + 1])))
535
+ continue
536
+ frac = (float(target) - left) / (right - left)
537
+ if 0.0 <= frac <= 1.0:
538
+ add_candidate(float(temps[index]) + frac * (float(temps[index + 1]) - float(temps[index])))
539
+
540
+ def add_largest_rate_band_gaps(max_points: int) -> None:
541
+ if max_points <= 0:
542
+ return
543
+ rate_floor = RATE_PEAK_REFINE_FLOOR_LOG10
544
+ band_points: list[float] = []
545
+ for temp, rate in zip(temps, log10_rate):
546
+ if np.isfinite(rate) and float(rate) >= rate_floor:
547
+ band_points.append(float(temp))
548
+ band_points.extend(float(value) for value in candidate.tolist())
549
+ band_points = [
550
+ float(value)
551
+ for value in _temperature_grid(band_points)
552
+ if np.isfinite(float(value)) and float(value) > 0.0
553
+ ]
554
+ if len(band_points) < 2:
555
+ return
556
+ for _ in range(int(max_points)):
557
+ points = _temperature_grid(band_points)
558
+ gaps: list[tuple[float, float, float]] = []
559
+ for high, low in zip(points[:-1], points[1:]):
560
+ if float(high) <= float(low) or float(low) <= 0.0:
561
+ continue
562
+ gaps.append((float(math.log(float(high) / float(low))), float(high), float(low)))
563
+ if not gaps:
564
+ return
565
+ _, high, low = max(gaps, key=lambda item: item[0])
566
+ new_point = _temperature_midpoint(high, low)
567
+ if not add_candidate(new_point):
568
+ return
569
+ band_points.append(float(new_point))
570
+
571
+ budget = max(int(max_new_points), 1)
572
+ rate_floor = RATE_PEAK_REFINE_FLOOR_LOG10
573
+ onramp_grid, onramp_reason = build_rate_onramp_candidate()
574
+ if onramp_grid is not None and onramp_grid.size > 0:
575
+ return onramp_grid, onramp_reason
576
+
577
+ if np.count_nonzero(finite) < 3:
578
+ return None, None
579
+ finite_indices = np.flatnonzero(finite)
580
+ peak_index = int(finite_indices[int(np.nanargmax(log10_rate[finite]))])
581
+ if peak_index <= 0 or peak_index >= temps.size - 1:
582
+ return None, None
583
+ if not (finite[peak_index - 1] and finite[peak_index + 1]):
584
+ return None, None
585
+
586
+ peak_rate = float(log10_rate[peak_index])
587
+ hot_rate = float(log10_rate[peak_index - 1])
588
+ cold_rate = float(log10_rate[peak_index + 1])
589
+ if not (peak_rate > hot_rate and peak_rate > cold_rate):
590
+ return None, None
591
+ prominence = peak_rate - max(hot_rate, cold_rate)
592
+ min_prominence = SUBCRITICAL_RATE_PEAK_MIN_PROMINENCE
593
+ if prominence < min_prominence:
594
+ return None, None
595
+
596
+ candidate = np.asarray([], dtype=float)
597
+ if peak_rate >= rate_floor:
598
+ peak_temperature, _, _ = _refine_step1_rate_peak(temps, log10_rate)
599
+ add_candidate(peak_temperature)
600
+ for target in (rate_floor, -5.0, 0.0):
601
+ if candidate.size >= budget:
602
+ break
603
+ if float(target) <= peak_rate + 1.0e-12:
604
+ add_rate_crossings(float(target))
605
+ if candidate.size < budget:
606
+ add_largest_rate_band_gaps(budget - int(candidate.size))
607
+ else:
608
+ hot_budget = max(1, budget // 2)
609
+ cold_budget = max(1, budget - hot_budget)
610
+ if budget == 1:
611
+ hot_gap = math.log(float(temps[peak_index - 1]) / float(temps[peak_index]))
612
+ cold_gap = math.log(float(temps[peak_index]) / float(temps[peak_index + 1]))
613
+ if hot_gap >= cold_gap:
614
+ add_interval_points(float(temps[peak_index - 1]), float(temps[peak_index]), 1)
615
+ else:
616
+ add_interval_points(float(temps[peak_index]), float(temps[peak_index + 1]), 1)
617
+ else:
618
+ add_interval_points(float(temps[peak_index - 1]), float(temps[peak_index]), hot_budget)
619
+ add_interval_points(float(temps[peak_index]), float(temps[peak_index + 1]), cold_budget)
620
+
621
+ if candidate.size == 0:
622
+ return None, None
623
+ candidate = candidate[: int(max_new_points)]
624
+ reason = (
625
+ "rate_peak_refine around "
626
+ f"T={float(temps[peak_index]):2.5g}, "
627
+ f"max log10(Gamma/H^4)={peak_rate:2.3g}, "
628
+ f"prominence={prominence:2.3g}"
629
+ )
630
+ return candidate, reason
631
+
632
+
633
+ def _hot_head_underresolved(
634
+ temperatures: np.ndarray,
635
+ probabilities: np.ndarray,
636
+ actions: np.ndarray,
637
+ hubble: np.ndarray,
638
+ settings,
639
+ *,
640
+ for_acceptance: bool,
641
+ ) -> tuple[bool, float | None, float | None]:
642
+ """Return whether the hottest retained support point still starts too late."""
643
+
644
+ temps = np.asarray(temperatures, dtype=float)
645
+ probs = np.asarray(probabilities, dtype=float)
646
+ acts = np.asarray(actions, dtype=float)
647
+ hub = np.asarray(hubble, dtype=float)
648
+ if temps.size == 0 or probs.size == 0 or acts.size == 0 or hub.size == 0:
649
+ return False, None, None
650
+ if not (temps.size == probs.size == acts.size == hub.size):
651
+ return False, None, None
652
+
653
+ probability_hot = float(probs[0]) if np.isfinite(probs[0]) else None
654
+ log_rate = _log_gamma_h4_array(temps, acts, hub)
655
+ log_gamma_h4_hot = float(log_rate[0]) if log_rate.size and np.isfinite(log_rate[0]) else None
656
+ if probability_hot is None or log_gamma_h4_hot is None:
657
+ return False, probability_hot, log_gamma_h4_hot
658
+
659
+ if probability_hot > float(settings.f_start):
660
+ return False, probability_hot, log_gamma_h4_hot
661
+
662
+ threshold = HOT_RATE_ACCEPT_LOG if for_acceptance else HOT_RATE_REFINE_LOG
663
+ if log_gamma_h4_hot <= threshold:
664
+ return False, probability_hot, log_gamma_h4_hot
665
+
666
+ hot_band_upper = float(min(HOT_PROBABILITY_ANCHORS))
667
+ hot_band_upper = max(float(hot_band_upper), 1.0e-12)
668
+ hot_band_mask = np.isfinite(probs) & (probs <= hot_band_upper + 1.0e-12)
669
+ if np.count_nonzero(hot_band_mask) >= 3:
670
+ hot_band_indices = np.flatnonzero(hot_band_mask)
671
+ hot_band_probs = probs[hot_band_mask]
672
+ band_reaches_anchor = bool(np.nanmax(hot_band_probs) >= 0.5 * hot_band_upper)
673
+ if band_reaches_anchor:
674
+ resolved_index = int(hot_band_indices[int(np.nanargmax(hot_band_probs))])
675
+ resolved_log_rate = float(log_rate[resolved_index])
676
+ if np.isfinite(resolved_log_rate) and resolved_log_rate <= threshold:
677
+ return False, probability_hot, log_gamma_h4_hot
678
+
679
+ return True, probability_hot, log_gamma_h4_hot
680
+
681
+
682
+ def _probability_anchor_presence_tolerance(
683
+ target: float,
684
+ settings,
685
+ ) -> float:
686
+ """Return the probability-space tolerance used to mark one anchor as covered."""
687
+
688
+ target = float(target)
689
+ threshold = _remesh_dp_limit(target, target, settings)
690
+ tolerance = 0.35 * float(threshold) if threshold > 0.0 else float("inf")
691
+
692
+ if any(
693
+ math.isclose(
694
+ target,
695
+ level,
696
+ rel_tol=0.0,
697
+ abs_tol=max(1.0e-12, 1.0e-9 * max(abs(level), abs(target), 1.0)),
698
+ )
699
+ for level in HOT_PROBABILITY_ANCHORS
700
+ ):
701
+ tolerance = min(tolerance, max(1.0e-4, 0.25 * abs(target)))
702
+ return float(tolerance)
703
+
704
+
705
+ def _build_step1_hot_rate_anchor_grid(
706
+ profile,
707
+ settings,
708
+ current_grid: np.ndarray,
709
+ ) -> np.ndarray | None:
710
+ """Carry a few hotter ``log(Gamma/H^4)`` anchors from the scout into the active grid."""
711
+
712
+ temps = np.asarray(profile.temperatures, dtype=float)
713
+ probs = np.asarray(profile.probabilities, dtype=float)
714
+ log_rate = np.asarray(profile.log_gamma_h4_sym, dtype=float)
715
+ active = _temperature_grid(current_grid)
716
+ if temps.size < 2 or temps.size != probs.size or temps.size != log_rate.size or active.size == 0:
717
+ return None
718
+
719
+ candidate = np.asarray([], dtype=float)
720
+ current_hot = float(active[0])
721
+ scale = max(np.max(np.abs(active)), np.max(np.abs(temps)), 1.0)
722
+ atol = 1e-10 * scale
723
+ hot_probability = float(probs[0]) if np.isfinite(probs[0]) else None
724
+ hot_log_rate = float(log_rate[0]) if np.isfinite(log_rate[0]) else None
725
+ if hot_probability is None or hot_log_rate is None:
726
+ return None
727
+ if hot_probability > float(settings.f_start):
728
+ return None
729
+
730
+ levels = sorted(
731
+ {
732
+ float(value)
733
+ for value in (
734
+ *HOT_RATE_ANCHORS,
735
+ HOT_RATE_REFINE_LOG,
736
+ HOT_RATE_ACCEPT_LOG,
737
+ )
738
+ if np.isfinite(float(value))
739
+ },
740
+ reverse=True,
741
+ )
742
+
743
+ def add_candidate(value: float | None) -> None:
744
+ nonlocal candidate
745
+ if value is None or not np.isfinite(float(value)):
746
+ return
747
+ if float(value) <= current_hot + atol:
748
+ return
749
+ if _temperature_present(active, float(value)) or _temperature_present(candidate, float(value)):
750
+ return
751
+ candidate = _temperature_grid(np.append(candidate, float(value)))
752
+
753
+ if hot_log_rate > HOT_RATE_ACCEPT_LOG:
754
+ add_candidate(float(temps[0]))
755
+
756
+ for level in levels:
757
+ if hot_log_rate < float(level):
758
+ continue
759
+ crossing, _, _ = _find_value_crossing(temps, log_rate, float(level))
760
+ add_candidate(crossing)
761
+
762
+ return candidate if candidate.size > 0 else None
763
+
764
+
765
+ def _build_step1_hot_probability_anchor_grid(
766
+ profile,
767
+ settings,
768
+ current_grid: np.ndarray,
769
+ ) -> np.ndarray | None:
770
+ """Carry explicit hot-side probability anchors from the scout into the active grid."""
771
+
772
+ temps = np.asarray(profile.temperatures, dtype=float)
773
+ probs = np.asarray(profile.probabilities, dtype=float)
774
+ active = _temperature_grid(current_grid)
775
+ if temps.size < 2 or temps.size != probs.size or active.size == 0:
776
+ return None
777
+
778
+ candidate = np.asarray([], dtype=float)
779
+
780
+ def occupied_probabilities(temperatures_to_sample: np.ndarray) -> np.ndarray:
781
+ values: list[float] = []
782
+ for value in np.asarray(temperatures_to_sample, dtype=float):
783
+ probability = _probability_at(value, temps, probs)
784
+ values.append(float("nan") if probability is None else float(probability))
785
+ return np.asarray(values, dtype=float)
786
+
787
+ occupied_probs = occupied_probabilities(active)
788
+
789
+ def add_candidate(value: float | None) -> None:
790
+ nonlocal candidate, active, occupied_probs
791
+ if value is None or not np.isfinite(float(value)):
792
+ return
793
+ numeric = float(value)
794
+ if _temperature_present(active, numeric) or _temperature_present(candidate, numeric):
795
+ return
796
+ candidate = _temperature_grid(np.append(candidate, numeric))
797
+ active = _temperature_grid(np.append(active, numeric))
798
+ occupied_probs = occupied_probabilities(active)
799
+
800
+ p_min = float(np.nanmin(probs))
801
+ p_max = float(np.nanmax(probs))
802
+ for target in HOT_PROBABILITY_ANCHORS:
803
+ if not (p_min + 1.0e-12 < target < p_max - 1.0e-12):
804
+ continue
805
+ tolerance = _probability_anchor_presence_tolerance(target, settings)
806
+ if np.any(np.isfinite(occupied_probs) & (np.abs(occupied_probs - target) <= tolerance)):
807
+ continue
808
+ crossing, _, crossing_index = _find_value_crossing(temps, probs, target)
809
+ if crossing_index is None:
810
+ continue
811
+ add_candidate(crossing)
812
+
813
+ return candidate if candidate.size > 0 else None
814
+
815
+
816
+ def _build_dynamiczoomwindow_hot_head_grid(
817
+ temperatures: np.ndarray,
818
+ probabilities: np.ndarray,
819
+ actions: np.ndarray,
820
+ hubble: np.ndarray,
821
+ settings,
822
+ *,
823
+ new_high: float,
824
+ max_new_points: int,
825
+ ) -> tuple[np.ndarray | None, str | None]:
826
+ """Return a focused hot-side refinement batch for unresolved onset support."""
827
+
828
+ if max_new_points <= 0:
829
+ return None, None
830
+
831
+ temps = np.asarray(temperatures, dtype=float)
832
+ probs = np.asarray(probabilities, dtype=float)
833
+ acts = np.asarray(actions, dtype=float)
834
+ hub = np.asarray(hubble, dtype=float)
835
+ if not (temps.size == probs.size == acts.size == hub.size) or temps.size < 2:
836
+ return None, None
837
+
838
+ finite_mask = np.isfinite(temps) & np.isfinite(probs)
839
+ temps = temps[finite_mask]
840
+ probs = probs[finite_mask]
841
+ acts = acts[finite_mask]
842
+ hub = hub[finite_mask]
843
+ if temps.size < 2:
844
+ return None, None
845
+
846
+ current = _temperature_grid(temps)
847
+ if current.size < 2:
848
+ return None, None
849
+
850
+ candidate = np.asarray([], dtype=float)
851
+ occupied = current.copy()
852
+ added_labels: list[str] = []
853
+
854
+ def occupied_probabilities(temperatures_to_sample: np.ndarray) -> np.ndarray:
855
+ values: list[float] = []
856
+ for value in np.asarray(temperatures_to_sample, dtype=float):
857
+ probability = _probability_at(value, temps, probs)
858
+ values.append(float("nan") if probability is None else float(probability))
859
+ return np.asarray(values, dtype=float)
860
+
861
+ occupied_probs = occupied_probabilities(occupied)
862
+
863
+ def add_candidate(value: float | None, label: str) -> bool:
864
+ nonlocal candidate, occupied, occupied_probs
865
+ if value is None or not np.isfinite(float(value)):
866
+ return False
867
+ numeric = float(value)
868
+ if _temperature_present(occupied, numeric) or _temperature_present(candidate, numeric):
869
+ return False
870
+ candidate = _temperature_grid(np.append(candidate, numeric))
871
+ occupied = _temperature_grid(np.append(occupied, numeric))
872
+ occupied_probs = occupied_probabilities(occupied)
873
+ added_labels.append(label)
874
+ return True
875
+
876
+ p_min = float(np.nanmin(probs))
877
+ p_max = float(np.nanmax(probs))
878
+ for target in HOT_PROBABILITY_ANCHORS:
879
+ if candidate.size >= int(max_new_points):
880
+ break
881
+ if not (p_min + 1.0e-12 < target < p_max - 1.0e-12):
882
+ continue
883
+ tolerance = _probability_anchor_presence_tolerance(target, settings)
884
+ if np.any(np.isfinite(occupied_probs) & (np.abs(occupied_probs - target) <= tolerance)):
885
+ continue
886
+ crossing, _, crossing_index = _find_value_crossing(temps, probs, target)
887
+ if crossing_index is None:
888
+ continue
889
+ add_candidate(crossing, f"P={target:2.3g}")
890
+
891
+ log_rate = _log_gamma_h4_array(temps, acts, hub)
892
+ hot_log_rate = float(log_rate[0]) if log_rate.size and np.isfinite(log_rate[0]) else None
893
+ if hot_log_rate is not None:
894
+ levels = sorted(
895
+ {
896
+ float(value)
897
+ for value in (
898
+ *HOT_RATE_ANCHORS,
899
+ HOT_RATE_REFINE_LOG,
900
+ HOT_RATE_ACCEPT_LOG,
901
+ )
902
+ if np.isfinite(float(value))
903
+ },
904
+ reverse=True,
905
+ )
906
+ for level in levels:
907
+ if candidate.size >= int(max_new_points):
908
+ break
909
+ if hot_log_rate < float(level):
910
+ continue
911
+ crossing, _, crossing_index = _find_value_crossing(temps, log_rate, float(level))
912
+ if crossing_index is None:
913
+ continue
914
+ add_candidate(crossing, f"logR={level:2.3g}")
915
+
916
+ remaining = int(max_new_points) - int(candidate.size)
917
+ current_high = float(current[0])
918
+ target_high = float(max(new_high, current_high))
919
+ if remaining > 0 and target_high > current_high:
920
+ n_high = min(remaining, 2)
921
+ if current_high > 0.0 and target_high / current_high >= 2.0:
922
+ extra_hot = np.geomspace(target_high, current_high, n_high + 1, dtype=float)[:-1]
923
+ else:
924
+ extra_hot = np.linspace(target_high, current_high, n_high + 1, dtype=float)[:-1]
925
+ for value in extra_hot:
926
+ if candidate.size >= int(max_new_points):
927
+ break
928
+ add_candidate(value, f"hot@{float(value):2.5g}")
929
+
930
+ if candidate.size == 0:
931
+ return None, None
932
+ return candidate, "hot_head_refine added " + ", ".join(added_labels)
933
+
934
+
935
+ def _rate_interval_is_post_peak_inert(
936
+ temperatures: np.ndarray,
937
+ actions: np.ndarray | None,
938
+ hubble: np.ndarray | None,
939
+ interval_high: float,
940
+ interval_low: float,
941
+ settings,
942
+ *,
943
+ vw: float = 1.0,
944
+ ) -> bool:
945
+ """Return whether one interval is safely beyond a resolved rate peak."""
946
+
947
+ if actions is None or hubble is None:
948
+ return False
949
+ temps = np.asarray(temperatures, dtype=float)
950
+ acts = np.asarray(actions, dtype=float)
951
+ hub = np.asarray(hubble, dtype=float)
952
+ if temps.size < 3 or not (temps.size == acts.size == hub.size):
953
+ return False
954
+
955
+ order = np.argsort(-temps)
956
+ temps = temps[order]
957
+ acts = acts[order]
958
+ hub = hub[order]
959
+ log10_rate = _log10_gamma_h4_array(temps, acts, hub)
960
+ finite = np.isfinite(temps) & np.isfinite(log10_rate) & (temps > 0.0)
961
+ if np.count_nonzero(finite) < 3:
962
+ return False
963
+
964
+ finite_indices = np.flatnonzero(finite)
965
+ peak_index = int(finite_indices[int(np.nanargmax(log10_rate[finite]))])
966
+ if peak_index <= 0 or peak_index >= temps.size - 1:
967
+ return False
968
+
969
+ peak_rate = float(log10_rate[peak_index])
970
+ if peak_rate < RATE_PEAK_INERT_MIN_PEAK_LOG10:
971
+ return False
972
+
973
+ high = float(interval_high)
974
+ low = float(interval_low)
975
+ if not (np.isfinite(high) and np.isfinite(low) and high > low):
976
+ return False
977
+
978
+ scale = max(np.max(np.abs(temps)), abs(high), abs(low), 1.0)
979
+ atol = _temperature_tolerance(temps, high) + _temperature_tolerance(temps, low) + 1.0e-12 * scale
980
+ high_matches = np.flatnonzero(np.isclose(temps, high, rtol=0.0, atol=atol))
981
+ low_matches = np.flatnonzero(np.isclose(temps, low, rtol=0.0, atol=atol))
982
+ if high_matches.size == 0 or low_matches.size == 0:
983
+ return False
984
+ high_index = int(high_matches[0])
985
+ low_index = int(low_matches[0])
986
+ if high_index >= low_index:
987
+ return False
988
+
989
+ endpoint_rates = log10_rate[[high_index, low_index]]
990
+ if not np.all(np.isfinite(endpoint_rates)):
991
+ return False
992
+ if float(np.max(endpoint_rates)) > RATE_PEAK_INERT_LOG10:
993
+ return False
994
+
995
+ max_interval_dI = RATE_PEAK_INERT_MAX_INTERVAL_DI
996
+ if max_interval_dI > 0.0:
997
+ log_a = np.full_like(log10_rate, -np.inf, dtype=float)
998
+ valid_a = np.isfinite(log10_rate) & np.isfinite(hub) & (hub > 0.0) & np.isfinite(temps) & (temps > 0.0)
999
+ log_a[valid_a] = log10_rate[valid_a] * math.log(10.0) + 3.0 * np.log(hub[valid_a]) - 4.0 * np.log(temps[valid_a])
1000
+ inv_h = np.where(np.isfinite(hub) & (hub > 0.0), 1.0 / hub, 0.0)
1001
+ volume_to_cold = np.zeros_like(temps, dtype=float)
1002
+ for index in range(max(temps.size - 2, -1), -1, -1):
1003
+ dt = abs(float(temps[index]) - float(temps[index + 1]))
1004
+ volume_to_cold[index] = volume_to_cold[index + 1] + 0.5 * dt * (
1005
+ abs(float(inv_h[index])) + abs(float(inv_h[index + 1]))
1006
+ )
1007
+
1008
+ interval_log_a = log_a[[high_index, low_index]]
1009
+ if not np.all(np.isfinite(interval_log_a)):
1010
+ return False
1011
+ max_log_a = float(np.max(interval_log_a))
1012
+ max_volume = float(np.max(volume_to_cold[[high_index, low_index]]))
1013
+ interval_width = abs(float(high) - float(low))
1014
+ wall_velocity = max(float(vw), 0.0)
1015
+ if max_volume <= 0.0 or interval_width <= 0.0 or wall_velocity <= 0.0:
1016
+ interval_dI_bound = 0.0
1017
+ else:
1018
+ log_bound = (
1019
+ math.log(4.0 * math.pi / 3.0)
1020
+ + 3.0 * math.log(wall_velocity)
1021
+ + max_log_a
1022
+ + 3.0 * math.log(max_volume)
1023
+ + math.log(interval_width)
1024
+ )
1025
+ if log_bound > 700.0:
1026
+ interval_dI_bound = float("inf")
1027
+ elif log_bound < -745.0:
1028
+ interval_dI_bound = 0.0
1029
+ else:
1030
+ interval_dI_bound = float(math.exp(log_bound))
1031
+ if not np.isfinite(interval_dI_bound) or interval_dI_bound > max_interval_dI:
1032
+ return False
1033
+
1034
+ min_monotone_points = RATE_PEAK_INERT_MIN_MONOTONE_POINTS
1035
+ monotone_tolerance = RATE_PEAK_INERT_MONOTONE_TOLERANCE_LOG10
1036
+
1037
+ if high_index > peak_index and low_index > peak_index:
1038
+ shoulder_rates = log10_rate[peak_index : low_index + 1]
1039
+ if np.count_nonzero(np.isfinite(shoulder_rates)) < min_monotone_points + 1:
1040
+ return False
1041
+ diffs = np.diff(shoulder_rates[np.isfinite(shoulder_rates)])
1042
+ return bool(np.all(diffs <= monotone_tolerance))
1043
+ if high_index < peak_index and low_index < peak_index:
1044
+ shoulder_rates = log10_rate[high_index : peak_index + 1]
1045
+ if np.count_nonzero(np.isfinite(shoulder_rates)) < min_monotone_points + 1:
1046
+ return False
1047
+ diffs = np.diff(shoulder_rates[np.isfinite(shoulder_rates)])
1048
+ return bool(np.all(diffs >= -monotone_tolerance))
1049
+ return False