magnus.adiabatic
adiabatic.py
Contains the adiabatic-transport-plus-Magnus-patch (“hybrid”) propagator
used as an alternative strategy to compute the neutrino evolution
operator when the Hamiltonian is position-dependent and develops an
extreme accumulated phase (e.g., low-energy solar neutrinos crossing an
MSW resonance), the regime in which the plain slab-refinement Magnus
engine in magnus.oscprob needs a very large number of slabs (and
may raise ToleranceNotAchievedWarning).
Physical idea: away from an eigenvalue crossing (or narrowly-avoided
crossing) of the instantaneous Hamiltonian \(H(l)\), the adiabatic
theorem says the evolution operator is well approximated by transport in
the instantaneous eigenbasis of \(H(l)\) – a dynamical phase (the
integral of the eigenvalues) plus a geometric (Berry) phase, both cheap
to compute on a coarse grid regardless of how large the accumulated
phase is. Near a genuine MSW resonance, the adiabatic approximation
breaks down over a narrow window, which is patched with an exact,
short-baseline Magnus computation (magnus.magnus.magnus_expansion_multislab(),
the package’s own, already-unitary integrator). The two pieces are
stitched together with the exact composition law of quantum evolution,
\(U(l_2, l_0) = U(l_2, l_1) U(l_1, l_0)\), so the result is exactly
unitary regardless of the approximation’s accuracy.
Where a patch is needed is decided by an exact Hellmann-Feynman diagnostic (no finite-differenced eigenvectors, which are gauge- ambiguous), so this applies to any Hermitian Hamiltonian of any dimension, with any number of simultaneous or sequential resonances – see Adiabatic + Magnus Hybrid Strategy for the full derivation, validation, and worked examples.
This module is self-contained: it depends only on magnus.magnus
(the Magnus-expansion core), not on magnus.oscprob, so it can be
used directly on any Hamiltonian function, independently of the rest of
the oscillation-probability API. magnus.oscprob calls
hybrid_propagator() internally when strategy='hybrid' or
strategy='auto' (the default) is passed to
magnus.oscprob.osc_prob_matter_std_potential(),
magnus.oscprob.osc_prob_matter_nsi(), or
magnus.oscprob.osc_prob_liv() (and, transitively, to every
osc_prob_*_sun/osc_prob_*_sun_nsi/osc_prob_*_sun_liv wrapper),
and also when it is passed to the fully generic user-Hamiltonian entry
points magnus.oscprob.osc_prob_sun() and
magnus.oscprob.osc_prob_earth() (via
magnus.oscprob._osc_prob_with_potential). For osc_prob_earth
the hybrid path is normally declined, since a real Earth trajectory
supplies PREM layer breakpoints; see Adiabatic + Magnus Hybrid Strategy.
Routine listings
- adiabatic_propagator - Evolution operator via pure adiabatic
(instantaneous-eigenbasis) transport, no resonance patching
- find_hidden_features - Detects structure too narrow for any grid
this package lays down to sample
- find_resonance_candidates - Locates every exact eigenvalue-gap
critical point of H(l) via the Hellmann-Feynman theorem
- find_nonadiabatic_windows - Filters/grows/merges candidates into
position windows that need a Magnus patch
- hybrid_propagator - Adiabatic transport with Magnus patches at
non-adiabatic windows, self-certified against successive refinement of every internal tolerance knob
Attributes
Module-level constant |
|
Module-level constant |
|
Module-level constant |
|
Module-level constant |
|
Module-level constant |
|
Module-level constant |
|
Module-level constant |
|
Module-level constant |
Functions
|
Looks for structure too narrow for any grid this package lays down to sample. |
|
How finely does a scan sample the fastest oscillation on its trajectory? |
|
Computes the evolution operator via pure adiabatic (instantaneous-eigenbasis) transport. |
|
Locates every exact eigenvalue-gap critical point of |
|
Finds every position window along |
|
Computes the evolution operator via adiabatic transport, with a Magnus patch at every |
Module Contents
- magnus.adiabatic.GAMMA_TO_ERROR = 0.85[source]
Module-level constant
Probability error to budget per unit of the adiabaticity parameter \(\gamma\), when
hybrid_propagator()decides whether a result with no non-adiabatic window may be certified. The rule isGAMMA_TO_ERROR * gamma_max <= atol + rtol.Write the pure adiabatic answer’s error as \(|\Delta P| = k\,\gamma_\max\). Certifying an empty window list is safe exactly when \(k\,\gamma_\max \le\) tolerance, so this constant is an upper bound on \(k\) – and the only honest way to set it is to measure \(k\).
Measured over 149 configurations (
docs/dev/adversarial_batteries/gamma_slack_sweep.py): resonance width swept over a decade, d = 2-5, 5-80 MeV, 0.5-2 density scale heights, scoring the pure adiabatic operator againstsolve_ivp/DOP853. Only the small-\(\gamma\) rows matter, because this rule governs the no-window case alone: once \(\gamma_\max\) exceeds the active threshold a window opens and the answer is patched exactly, whatever \(k\) is there.population
n
max k
implied bound on gamma_max
gamma_max < 1e-2
76
0.812
<= 1.23 x tolerance
gamma_max < 3e-3
35
0.679
<= 1.47 x tolerance
gamma_max < 1e-3
12
0.502
<= 1.99 x tolerance
all rows, incl. patched
149
1.136
<= 0.88 x tolerance
\(k\) falls towards ~0.5 as \(\gamma\) shrinks, which is what the linear model predicts asymptotically. 0.85 covers the worst case in the governed regime (0.812) with a little margin.
This constant was wrong twice, in opposite directions. It began at 1.0 alongside a slack factor of 2.0, derived from five points that all happened to sit at \(\gamma_\max < 10^{-3}\) where \(k \approx 0.5\); that pair encoded \(k \le 0.5\), right for those five and optimistic by up to 1.6x elsewhere in the governed regime. Reading the unrestricted maximum (1.136) then argued for a far stricter bound – an over-correction, since those rows sit at \(\gamma_\max \sim 0.2\), open a window immediately, and are never decided by this rule. The slack factor is gone: it existed only to compensate for the mis-measured value, and with \(k\) measured in the regime that matters no fudge is needed.
Added in version 1.0.0.
- Type:
- magnus.adiabatic.RESOLUTION_RATIO = 0.7[source]
Module-level constant
Threshold of the probe-scale resolution test in
_profile_is_resolved, which decides whetherH_funcis sampled finely enough for this module’s finite-difference diagnostics to mean anything.Within each probe interval, the test asks what fraction of the variation falls in one half. For a \(C^1\) Hamiltonian the two halves each carry about half, so the ratio tends to 0.5; a jump lands entirely inside one half, so the ratio tends to 1.0.
The two limits are 0.5 and 1.0, but the honest threshold is set by measurement rather than by their midpoint, because a jump comparable in size to the local smooth variation lands between them: a jump \(J\) on top of a smooth change \(S\) within one interval gives \((J + S/2)/(J + S)\). Measured:
population
statistic
192 smooth configurations (6 profile families plus
10 random Fourier sums, d = 2-5, 5-200 MeV)
<= 0.602
a jump 4.7x smaller than the steepest smooth step
0.773
15 random piecewise-constant profiles, d = 2 and 3
1.000
0.70 sits in the gap with margin on both sides – 16% above the smooth ceiling, 10% below the weakest genuine discontinuity. It also states what the test can and cannot catch: solving \((J + S/2)/(J + S) > t\) gives \(J/S > (2t-1)/(1-t)\), so at 0.70 a jump must be at least 1.33x the local smooth variation to be seen. A smaller one is genuinely indistinguishable from steep smooth behaviour at that sampling density.
Two earlier formulations were wrong, both caught by measurement. Comparing the global largest adjacent change at two grid densities masks any jump smaller than the largest smooth variation elsewhere on the path: a discontinuity 4.7x smaller than the steepest smooth step went undetected, and the answer came back wrong by 2.0e-02, silently. Comparing one half against the whole interval fixes that but false-positives at a smooth turning point, where the interval’s net change is near zero while each half is not. Comparing each half against the sum of the two is immune to both: it measures concentration of variation, which is what a jump is.
Added in version 1.0.0.
- Type:
- magnus.adiabatic.LOCAL_JUMP_RATIO = 0.5[source]
Module-level constant
Threshold of the local confirmation in
_profile_is_resolved: having flagged a probe interval whose variation is concentrated in one half, re-sample that interval alone onN_LOCAL_CONFIRMpoints and ask what fraction of its variation still falls in a single adjacent step.The two limits are far apart and are set by arithmetic, not by taste. A jump is not diluted by refinement – one step still carries all of it – so the fraction tends to 1.0. A smooth feature is spread over the whole interval, so with 32 sub-steps the largest one carries roughly \(1/32\) of the variation. Measured (
docs/dev/adversarial_batteries/resolution_fp.py), over the flagged intervals only, which is the population this constant decides:population
n
statistic
10 smooth families x d = 2-5 x 3 energies x 12
sub-intervals (1440 configurations)
79
<= 0.087
3 piecewise-constant families, same sweep
348
>= 1.000
0.5 sits between them with a factor of 5.7 of margin on the smooth side and 2.0 on the jump side. Over the same sweep the completed test reports 0 of 1440 smooth configurations as unresolved, and every sub-interval that genuinely contains a jump as unresolved.
Why this exists. Without it the concentration test is a maximum over intervals, and one interval decides – so the interval containing a smooth turning point decides. There the two halves are genuinely asymmetric: one nearly cancels while the other does not, and the ratio is a draw in \([0.5, 1]\) that depends on where the extremum happens to fall inside its interval. Refining the grid does not remove it; it re-draws it, which is why the two-stage coarse-then-fine protocol could not separate the two either. Measured on a Gaussian bump of width \(10^{-2}\) of the trajectory – a profile the module answers to 3.5e-09 – the statistic hit 0.75 at the interval containing the peak, and 6 of 30 baselines of one ordinary scan were declared discontinuous. The local confirmation is the discriminator the concentration ratio alone does not have: it asks whether the concentration survives refinement, which is the one thing a jump does and a turning point does not.
Costs nothing on an ordinary call: it runs only on intervals the cheap test already flagged, and on a profile that flags none it is never reached.
Added in version 1.0.0.
- Type:
- magnus.adiabatic.N_LOCAL_CONFIRM = 33[source]
Module-level constant
Points used to re-sample one flagged probe interval in
_profile_is_resolved(seeLOCAL_JUMP_RATIO). 32 sub-steps puts the smooth limit at \(1/32 \approx 0.03\), comfortably belowLOCAL_JUMP_RATIO, and resolves a feature down to \(1/(199 \times 32) \approx 1.6\times10^{-4}\) of the trajectory – the same scale as the probe refinement ceilingmax_n_probe = 6400, so the local test does not claim to see anything the caller’s own refinement could not.Added in version 1.0.0.
- Type:
- magnus.adiabatic.MAX_LOCAL_CONFIRMATIONS = 8[source]
Module-level constant
At most this many flagged intervals are confirmed locally, taken in decreasing order of how much variation they carry. A profile with hundreds of genuine jumps is found by the first one, and a bound is needed because the flagged set is unbounded in principle.
Added in version 1.0.0.
- Type:
- magnus.adiabatic.HIDDEN_FEATURE_CONCENTRATION = 0.3[source]
Module-level constant
Threshold of
find_hidden_features(), which looks for structure below the scale any grid in this package samples on. This is the one exposure the adversarial validation could not close: a Gaussian narrower than the probe spacing is invisible to the hybrid detector, to the general ladder’s slab grid and to the cumulative scan alike, so all three agree and all three are wrong – silently, by up to 2.9e-02 against a requested 1e-3.The statistic is concentration, not size, and that choice is the whole design. Within each interval of a reference grid, compare the total variation a much denser grid sees inside it with the change its two endpoints show; the excess is variation hidden between reference samples. Report the largest such excess as a fraction of the profile’s total variation.
A first attempt used the cruder
TV_dense/TV_referenceand was wrong: a sinusoid at exactly the probe spacing sends the denominator to zero and that ratio to \(10^{13}\), while the package answers such a profile to ~1e-11. What separates the two is where the hidden variation sits. An aliased sinusoid hides some in every interval, so its share of the total is ~1/n_ref; one narrow bump hides all of it in one, so its share is ~1. The excess is summed over adjacent pairs of reference intervals, so that a feature landing on an interval boundary is not halved by the split.Measured over 67 profiles the package serves – solar, multi-resonance, noisy, sinusoids at 1x, 2x and 1/2x the probe spacing, 400 crossings, a declared step, 30 random Fourier sums, and 30 Gaussian bumps of random width down to 1e-4 of the trajectory at random positions:
population
concentration
67 smooth/resolvable profiles
max 0.060 p99 0.047
and, over 60 random positions each, the detection rate for features in the band no grid here resolves:
feature width
detection at 0.2
detection at 0.3
detection at 0.5
3e-5
0.70
0.68
0.55
1e-5
0.90
0.90
0.90
3e-6
0.82
0.82
0.82
1e-6
0.73
0.73
0.73
0.3 gives zero false positives over all 67 smooth profiles – five times the measured ceiling – at the best detection the margin allows; 0.2 buys two points of detection for half the margin, and 0.5 costs thirteen.
The caller now varies the sampling density with the size of the request (see
N_HIDDEN_FEATURE_SUBDIVISION), so the ceiling was re-measured at every density the dispatcher can choose rather than only at the default: 0.0597 at 8 sub-steps, 0.0601 at 16, 0.0602 at 32 – 0 of 67 in all three. The statistic is a fraction of the total variation, which is why it barely moves: refining the dense grid adds the same variation to numerator and denominator.This detects most of the class, not all of it, and the shortfall is structural. A feature of width 3e-5 is right at the edge of what
max_n_probe = 6400can partially resolve, so its variation is partly visible to the reference grid and the statistic is diluted; a feature of width 1e-6 is far below the dense spacing (\((l_1-l_0)/51192 \approx 2\times10^{-5}\)), so whether a sample lands inside it is luck. Between those two limits detection is ~0.8-0.9. Against a prior state of zero detection and a silent 2.9e-02 error, that is the improvement on offer; it is not a guarantee, and the docstring says so rather than implying one.Why the reference grid is the ceiling and not the starting density. Against
n_probe0 = 200the same statistic flags widths of 1e-3 and 1e-4 too – correctly, in that they are hidden at that density, and uselessly, in that the refinement resolves them. Keying it tomax_n_probeasks the question that matters: is there structure left that no amount of refinement will reach?Added in version 1.0.0.
- Type:
- magnus.adiabatic.N_HIDDEN_FEATURE_SUBDIVISION = 8[source]
Module-level constant
Sub-steps per reference interval in
find_hidden_features(), so 51 192 samples of the profile in total. Chosen on cost, because this runs on ordinary calls: the statistic is nearly independent of it (the separation is 4.7x at 2 sub-steps and 4.6x at 32), while the cost is not.sub-steps
dense samples
scan cost
4
25 597
0.22 ms
8
51 193
0.37 ms
16
102 385
1.30 ms
32
204 769
2.85 ms
The jump past 8 is superlinear – the arrays stop fitting in cache – and 0.37 ms is about 3% of an ordinary 13 ms single-point call, where 2.85 ms would be 20% and fail the package’s own 10% performance criterion. What it costs in reach is the very narrowest features: the dense spacing is \((l_1-l_0)/51192\), and detection of anything below that is a matter of whether a sample lands inside it (measured 0.73 at a width of 1e-6, against 0.90 at 1e-5).
magnus.oscprobscales this with the number of requested points – 8 below four points, 16 below sixteen, 32 above – because the scan runs once per call whatever the point count, so its share of the work falls as the request grows. That holds it under about 7 % of the call at every size instead of spending 20 % of the cheapest one, and the false-positive rate was re-measured at each of those three densities (0 of 67 every time; seeHIDDEN_FEATURE_CONCENTRATION).Raise it if you have reason to think the profile hides something finer; the scan is
find_hidden_features()and takesn_subdirectly.Added in version 1.0.0.
- Type:
Looks for structure too narrow for any grid this package lays down to sample.
See
HIDDEN_FEATURE_CONCENTRATIONfor the statistic, why it is a concentration rather than a size, and the measured separation.Pass the scalar potential when there is one. For the separable Hamiltonians this package builds, \(H(l) = h_\mathrm{vac}/E + V_{CC}(l)\,P_{ee}\) is affine in \(V_{CC}\), so every difference is \(|\Delta V_{CC}|\) times a constant and the statistic is identical either way – verified bit-for-bit at d = 2, 3 and 5. It is also 18x cheaper (2.6 ms against 48 ms), because sampling a scalar avoids allocating a stack of \(2\times10^5\) matrices. This is a free choice, not an approximation.
Cost is a single vectorized evaluation of
profileand two array passes; no eigendecomposition. It depends on the profile and the interval but not on energy, so a caller scanning many energies or baselines should run it once, not once per point.Added in version 1.0.0.
- Parameters:
profile (Callable) – The position-dependent part of the problem: either the scalar potential (preferred) or
H_funcitself. Must accept an array of positions.l0 (float) – Interval to scan.
l1 (float) – Interval to scan.
n_ref (int, optional) – Reference grid, which should be the finest grid the caller’s machinery can reach – the hybrid strategy’s
max_n_probe. Default: 6400.n_sub (int, optional) – Sub-steps per reference interval. Default:
N_HIDDEN_FEATURE_SUBDIVISION.
- Returns:
'concentration'(the statistic),'hidden'(whether it exceedsHIDDEN_FEATURE_CONCENTRATION),'l_lo'/'l_hi'(the reference interval carrying the excess) and'l_centre'. On a constant profile, concentration 0.0.- Return type:
- magnus.adiabatic.oscillation_sampling(H_func: Callable, l0: float, l1: float, baselines: numpy.ndarray | None = None, n_probe: int | None = 8) Dict[source]
How finely does a scan sample the fastest oscillation on its trajectory?
A long trajectory carries an enormous oscillation phase, and a scan over it usually samples that phase far too coarsely to represent it – so the returned array is a set of correct values that must not be read as a curve. For a solar or supernova problem the observable is normally the phase-averaged probability anyway; see
magnus.avgprob, whosemagnus.avgprob.coherence_report()decides which pairs of eigenvalues have decohered, and which are in neither limit.This reports; it never warns. A Nyquist criterion is objectively correct and fires on essentially everything: measured over the physical profile families, a scan would need 4400 points on a solar trajectory and 73 000 on a supernova ray to sample the fastest oscillation twice per cycle, so 44 of 45 realistic scan sizes are formally aliased (
docs/dev/adversarial_batteries/alias_fp.py). A warning that fires on 98 % of calls is noise however correct each firing is, so the information is offered here and instrategy_infoinstead, and the caller decides.Cost:
n_probeevaluations ofH_funcand oneeigvalsheach. Measured against the scan it describes: 5.5 % of the cheapest scan in the physical population (a 13 ms Earth chord), 1.8 % of a 21 ms one, and under 0.1 % of anything substantial (docs/dev/adversarial_batteries/alias_cost.py). Callers who do not ask forstrategy_infopay nothing –oscprobonly runs this when a report was requested. Eight samples are enough: the fastest oscillation comes from the largest eigenvalue spread, which tracks the matter potential and varies smoothly, and 8 samples agree with 4096 to 2 % over every family measured. (The smallest gap would need far denser sampling, because it has a sharp minimum at an MSW resonance – but that sets the slowest oscillation, not the fastest, and is not what aliases.)Added in version 1.0.0.
- Parameters:
H_func (Callable) – The Hamiltonian as a function of position,
H(l), at the energy of interest [eV].l0 (float) – Interval the scan traverses.
l1 (float) – Interval the scan traverses.
baselines (np.ndarray, optional) – The requested baselines. When given, the spacing and the aliasing verdict are computed from them; when omitted, only the trajectory-level quantities are returned.
n_probe (int, optional) – Samples along the trajectory. Default: 8.
- Returns:
'oscillation_length'(shortest on the trajectory [eV^-1]),'cycles_over_trajectory','nyquist_points'(points a scan would need to sample the fastest oscillation twice per cycle), and, whenbaselinesis given,'spacing','cycles_per_step'and'aliased'. Empty dict if the spectrum is degenerate or the interval has zero length.- Return type:
Examples
import numpy as np import magnus.globaldefs as gd from magnus import adiabatic from magnus.hamiltonians import hamiltonians3nu p = gd.OSC_PARAMS_PREDEFINED['OSC_PARAMS_DEFAULT'] h_vac = np.asarray(hamiltonians3nu.hamiltonian_3nu_vacuum_energy_independent( p['s12'], p['s23'], p['s13'], p['dCP'], p['D21'], p['D31'])) e00 = np.diag([1.0, 0.0, 0.0]) energy = 10.0e6 def H(l): v = 1.0e-11*np.exp(-np.asarray(l, dtype=float)/gd.L_SCALE_SUN) return (1.0/energy)*h_vac + np.asarray(v)[..., None, None]*e00 print(adiabatic.oscillation_sampling(H, 0.0, 3.0*gd.L_SCALE_SUN, 100))
{'oscillation_length': 50129793295.43478, 'cycles_over_trajectory': 19989.20386274845, 'nyquist_points': 39980}Reported, never warned about. A trajectory of twenty thousand cycles sampled at a hundred points is not necessarily wrong – an averaged observable may not care – but it is worth knowing before trusting an instantaneous one.
- magnus.adiabatic.THRESHOLD0_PROVENANCE = 0.1[source]
Module-level constant
The value
hybrid_propagator()starts its adiabaticity-threshold ladder at, recorded here with its measurement because the measurement is what stopped it being changed.What was measured. Over 3 profiles x d = 2, 3 x three requested tolerances (
docs/dev/adversarial_batteries/constants_audit.py), sweepingthreshold0from 1 down to 1e-3 at a fixed baseline: accuracy is identical at every value in 16 of 18 rows, and atrtol <= 1e-3a lower start is up to 6.5x cheaper (1.57 s to 0.24 s on a solar profile atrtol = 1e-5). That is because certifying an empty window list additionally requires \(\gamma\) to fit the tolerance (seeGAMMA_TO_ERROR), so the ladder reaches whatever threshold the request needs regardless of where it starts; the start only decides how many iterations that takes, and each one re-runs the detector at doubledn_probeand the transport at doubledn_points. Atrtol = 1e-2the sign flips – there no window is needed at all, so starting low opens one that is not, and 0.21 s becomes 0.95 s at d = 3.So the right value looked like a rule rather than a constant, and one was built: start at \((\mathrm{atol} + \mathrm{rtol})/\texttt{GAMMA\_TO\_ERROR}\), the exact \(\gamma\) at which an empty window list stops being certifiable, clipped to
[min_threshold, 0.1]. At the default tolerance that is 2.4e-03 rather than 0.1.It was then rejected, on evidence the sweep above could not produce. Scored against
solve_ivpon the package’s bit-identity workloads, which include an energy scan the fixed-baseline sweep did not:workload
t0=0.1the rule
verdict
single point, solar
1.624e-06
1.184e-10
13711x better
sub-threshold scan, N = 8
3.220e-05
3.814e-05
1.2x worse
energy scan, fixed baseline
2.509e-05
4.954e-04
20x worse
All three stay inside the requested 1e-3, but 4.95e-04 spends half the budget where 2.5e-05 spent a fortieth. The mechanism is visible once looked for: starting low opens a window on the first iteration, and
windows_next or windows_prevthen short-circuits the \(\gamma\) check, so agreement can be accepted at a coarser transport grid than the old start would have forced. The saving and the loss are the same effect seen from two sides.The lesson is the one this package keeps re-learning: the sweep that justified the rule ran at a fixed baseline, and the row that refuted it was an energy scan. A population that does not contain the workload you are about to change is not evidence about it – the same shape of mistake that made
GAMMA_TO_ERRORwrong twice. 0.1 stays until a population that spans scans as well as points says otherwise.Added in version 1.0.0.
- Type:
- magnus.adiabatic.adiabatic_propagator(H_func: Callable, l0: float, l1: float, n_points: int | None = 201) numpy.ndarray[source]
Computes the evolution operator via pure adiabatic (instantaneous-eigenbasis) transport.
Diagonalizes
H_funcon a grid ofn_pointspositions betweenl0andl1, integrates each eigenvalue’s dynamical phase with Simpson’s rule (trapezoidal quadrature leaves a spurious residual that can look like a physics limit but is pure quadrature error), and reassembles the evolution operator in the original (flavor) basis:\[U(l_1, l_0) \approx W(l_1)\, \mathrm{diag}\!\left(e^{-i\Phi_k}\right)\, W(l_0)^\dagger , \qquad \Phi_k = \int_{l_0}^{l_1} \lambda_k(l)\, dl ,\]with \(W(l)\) the (parallel-transported; see
_eigs_along) matrix of instantaneous eigenvectors of \(H(l)\) and \(\lambda_k(l)\) its eigenvalues. This is exact in the strict adiabatic limit (no eigenvalue crossing or narrowly-avoided crossing along the trajectory) and remains unitary by construction (a diagonal phase conjugated by unitary matrices) regardless of grid density – the only thingn_pointscontrols is how well the quadrature/parallel-transport approximate the continuum limit, not whether the result is unitary. Seehybrid_propagator()for what to do when the trajectory does cross a resonance.Added in version 1.0.0.
- Parameters:
- Returns:
The evolution operator, exactly unitary.
- Return type:
np.ndarray
Examples
import numpy as np import magnus.globaldefs as gd from magnus import adiabatic from magnus.hamiltonians import hamiltonians3nu p = gd.OSC_PARAMS_PREDEFINED['OSC_PARAMS_DEFAULT'] h_vac = np.asarray(hamiltonians3nu.hamiltonian_3nu_vacuum_energy_independent( p['s12'], p['s23'], p['s13'], p['dCP'], p['D21'], p['D31'])) e00 = np.diag([1.0, 0.0, 0.0]) energy = 10.0e6 def H(l): v = 1.0e-11*np.exp(-np.asarray(l, dtype=float)/gd.L_SCALE_SUN) return (1.0/energy)*h_vac + np.asarray(v)[..., None, None]*e00 U = np.asarray(adiabatic.adiabatic_propagator(H, 0.0, 3.0*gd.L_SCALE_SUN)) print('shape', U.shape) print('unitary to %.1e' % np.max(np.abs(U.conj().T @ U - np.eye(3))))
shape (3, 3) unitary to 6.7e-16
Cheap however large the accumulated phase, because it transports in the instantaneous eigenbasis rather than resolving the oscillation. That is exactly why it needs a patch wherever the transport stops being adiabatic.
- magnus.adiabatic.find_resonance_candidates(H_func: Callable, l0: float, l1: float, n_probe: int | None = 200, fd_step_frac: float | None = 1e-06, info: Dict | None = None) List[Dict][source]
Locates every exact eigenvalue-gap critical point of
H_funcbetweenl0andl1.For every pair of levels \((j, k)\), scans for sign changes of
\[f_{jk}(l) = \langle v_j(l)|\, dH/dl\, |v_j(l)\rangle - \langle v_k(l)|\, dH/dl\, |v_k(l)\rangle ,\]refined by bisection to machine precision in position. By the Hellmann-Feynman theorem, \(d\lambda_k/dl = \langle v_k|\, dH/dl\, |v_k\rangle\) exactly (no eigenvector finite difference, which would be gauge-ambiguous and fragile), so a sign change of \(f_{jk}\) is an exact critical point of the gap \(\lambda_j - \lambda_k\) – a genuine crossing or near-crossing candidate, for any Hermitian
H_funcof any dimension, with no assumption of a separable or otherwise special structure. Every pair is scanned, so any number of simultaneous or sequential resonances (between any pair of levels) are all found.A returned candidate is a structural fact about
H_func(an extremum of that pair’s gap); whether it is actually non-adiabatic (whether it needs a Magnus patch) is a separate question, answered byfind_nonadiabatic_windows().Added in version 1.0.0.
- Parameters:
H_func (Callable) – Hamiltonian, a function of position returning a square (Hermitian) matrix.
l0 (float) – Initial position.
l1 (float) – Final position.
n_probe (int, optional) – Number of positions on the initial scan grid used to bracket sign changes. Default: 200.
fd_step_frac (float, optional) –
Finite-difference step for
_dH_dl, as a fraction ofl1 - l0. Default: 1e-6.Provenance. A central difference trades truncation error (\(\sim h^2\)) against subtractive cancellation (\(\sim \epsilon/h\)), so there is an optimum, and this default had never been checked against it. Measured (
docs/dev/adversarial_batteries/constants_audit.py) against the analytic \(dH/dl\) – available because \(H = h_\mathrm{vac}/E + C\,n_e(l)\,P_{ee}\) and the profiles used have closed-form \(n_e'\), so the reference carries no error of its own – over d = 2, 3 and 10-50 MeV, as a fraction of the largest \(|dH/dl|\) on the path:profile
optimum
error at 1e-6
error at the optimum
solar exponential
1e-5
6.8e-11
1.9e-11
sinusoid, period span/7
1e-6
3.4e-10
3.4e-10
Gaussian bump, w = 1e-2
1e-7 - 1e-8
2.5e-09
2.6e-11
The optimum is not a single number – it moves with the profile’s shortest length scale, which is what the theory predicts. But the curve is flat enough that this does not matter: anywhere in \(10^{-8}\) to \(10^{-5}\) the error stays below \(3\times10^{-9}\) relative on every profile measured, which is six orders below anything that could move a probability at the tolerances this package works to. Outside that band it degrades fast in both directions – 1.6e-04 at \(10^{-12}\) (cancellation), 0.23 at \(10^{-2}\) (truncation) – so the band, not the value, is the thing to preserve.
info (dict, optional) – If given, filled in place with the probe-grid quantities this function had to compute anyway –
'ls'(the grid),'lam','W'(eigenvalues and eigenvectors, shapes(n, d)and(n, d, d)) and'dH'.find_nonadiabatic_windows()sweeps \(\gamma\) on exactly this grid with exactly this finite-difference step, so without this it would recompute all of it: ~600 extra Hamiltonian evaluations and a second eigendecomposition, which measured as 1.4x on an ordinary single-point solar call. Default: None.
- Returns:
One entry per candidate, with keys
'l'(position),'j','k'(the level indices,j < k), and'gap'(\(\lambda_k - \lambda_j\) at that position), sorted by position.- Return type:
Examples
import numpy as np import magnus.globaldefs as gd from magnus import adiabatic from magnus.hamiltonians import hamiltonians3nu p = gd.OSC_PARAMS_PREDEFINED['OSC_PARAMS_DEFAULT'] h_vac = np.asarray(hamiltonians3nu.hamiltonian_3nu_vacuum_energy_independent( p['s12'], p['s23'], p['s13'], p['dCP'], p['D21'], p['D31'])) e00 = np.diag([1.0, 0.0, 0.0]) energy = 10.0e6 def H(l): v = 1.0e-11*np.exp(-np.asarray(l, dtype=float)/gd.L_SCALE_SUN) return (1.0/energy)*h_vac + np.asarray(v)[..., None, None]*e00 for c in adiabatic.find_resonance_candidates(H, 0.0, 3.0*gd.L_SCALE_SUN): print('levels %d-%d cross at l/l_scale = %.3f, gap %.2e eV' % (c['j'], c['k'], c['l']/gd.L_SCALE_SUN, c['gap']))
levels 0-1 cross at l/l_scale = 1.917, gap 3.46e-12 eV
A candidate is a critical point of a pairwise gap, found exactly through Hellmann-Feynman rather than by scanning for a minimum. Whether it is actually non-adiabatic is a separate question –
find_nonadiabatic_windows()answers it.
- magnus.adiabatic.find_nonadiabatic_windows(H_func: Callable, l0: float, l1: float, threshold: float | None = 0.1, n_probe: int | None = 200, fd_step_frac: float | None = 1e-06, info: Dict | None = None) Tuple[List[Tuple[float, float]], List[Dict]][source]
Finds every position window along
[l0, l1]whereH_funcneeds a Magnus patch.Calls
find_resonance_candidates(), evaluates the adiabaticity parameter \(\gamma_{jk}\) (see_point_adiabaticity) at each candidate, grows a window around every candidate with \(\gamma_{jk} > \text{threshold}\) (see_estimate_window_bounds), and merges any windows that overlap or touch – so two (or more) resonances close enough together are correctly folded into a single patch, rather than either double-counted or (worse) silently dropped. This works for any number of simultaneous or sequential resonances, between any pair of levels.Added in version 1.0.0.
- Parameters:
H_func (Callable) – Hamiltonian, a function of position returning a square (Hermitian) matrix.
l0 (float) – Initial position.
l1 (float) – Final position.
threshold (float, optional) – Adiabaticity parameter above which a candidate is treated as non-adiabatic. Default: 0.1.
n_probe (int, optional) – Forwarded to
find_resonance_candidates(). Default: 200.fd_step_frac (float, optional) – Forwarded to
find_resonance_candidates(). Default: 1e-6.info (dict, optional) – If given, filled in place with diagnostics about this call, following the same out-parameter convention as
convergence_infoinmagnus.oscprob.osc_prob(). Currently one key,'gamma_max': the largest adiabaticity parameter seen anywhere on the probe grid or at any candidate, over every level pair. It isinfif some pair’s gap vanishes exactly.hybrid_propagator()uses it to decide whether an empty window list may be certified – without it, “no window opened” is indistinguishable from “no window was looked for hard enough”. Default: None.
- Returns:
The merged, non-overlapping windows (each a
(l_b, l_c)pair, sorted by position), and the candidate list fromfind_resonance_candidates(), each entry additionally carrying its evaluated'gamma'.- Return type:
- magnus.adiabatic.hybrid_propagator(H_func: Callable, l0: float, l1: float, rtol: float | None = 0.001, atol: float | None = 0.001, magnus_exp_order: int | None = 6, integration_method: str | None = 'gl', threshold0: float | None = 0.1, min_threshold: float | None = 1e-06, n_probe0: int | None = 200, max_n_probe: int | None = 6400, n_points0: int | None = 201, max_n_points: int | None = 12864, fd_step_frac: float | None = 1e-06, max_iters: int | None = 12, info: Dict | None = None) Tuple[numpy.ndarray, List[Tuple[float, float]], bool][source]
Computes the evolution operator via adiabatic transport, with a Magnus patch at every non-adiabatic window, self-certified against successive refinement of every internal tolerance knob.
This is the main entry point of this module (see Adiabatic + Magnus Hybrid Strategy for the full derivation and validation). Given any Hermitian
H_funcof any dimension:Locates every non-adiabatic window along
[l0, l1](seefind_nonadiabatic_windows()).If there are none, returns the pure adiabatic-transport operator (see
adiabatic_propagator()).Otherwise, composes adiabatic transport between windows with an exact local Magnus patch inside each window (see
_local_evolution_operator), using the exact composition law of quantum evolution, \(U(l_2, l_0) = U(l_2, l_1)\, U(l_1, l_0)\), so the result is exactly unitary regardless of any approximation’s accuracy.Self-certifies the result: a single fixed adiabaticity
threshold(deciding which candidates count as non-adiabatic) is not safe in general – too loose, and a genuine resonance is patched too narrowly or missed; too tight, and windows are patched needlessly, at some (still usually modest) extra cost. Rather than trust one fixed value, the whole computation (window threshold, adiabatic-transport grid density, and the probe grid used to locate candidates) is repeated with the knobs tightened together (threshold divided by 3,n_points/n_probedoubled) until two successive results agree withinrtol/atol, mirroring the successive-refinement disciplinemagnus.oscprob.osc_prob()already uses for the number of slabs.Each knob stops at its own ceiling (
min_threshold,max_n_probe,max_n_points), which they reach at different iterations, so the later iterations tighten fewer knobs than the earlier ones. Once all of them have saturated, a further iteration would recompute bit-identical inputs and the agreement test would pass trivially, comparing a result with itself; the loop therefore stops at that point and reportscertified=Falserather than certifying on the strength of a comparison that carries no information.
Added in version 1.0.0.
- Parameters:
H_func (Callable) – Hamiltonian, a function of position returning a square (Hermitian) matrix. May be real- or complex-valued, of any dimension.
l0 (float) – Initial position.
l1 (float) – Final position.
rtol (float, optional) – Relative tolerance on the agreement between successive refinement levels, and on the adiabaticity bound in
_certified. Default: 1e-3. Like every tolerance in this package it is a stopping rule rather than a guaranteed accuracy: the loop halts when two successive levels agree, and no error of the returned operator is ever estimated. See thertolentry ofmagnus.oscprob.osc_prob()for what that does and does not promise.certifiedis the flag that says whether the loop stopped because it agreed or because it ran out of room.atol (float, optional) – Absolute tolerance on the same agreement; see
rtol. Default: 1e-3.magnus_exp_order (int, optional) – Magnus expansion order used for the local patch inside each non-adiabatic window. Default: 6.
integration_method (str, optional) – Integration method used for the local patch (‘gl’, ‘trapezoid’, or ‘simpson’). Default: ‘gl’.
threshold0 (float, optional) –
Starting adiabaticity threshold. Default: 0.1. See
THRESHOLD0_PROVENANCEfor what it was measured to do, and for why a tolerance-derived rule was built, tested and then rejected.Provenance, and a reframing. This constant used to decide correctness: if \(\gamma\) never crossed it, no window opened, successive refinements agreed with each other, and the answer was certified while wrong by up to 1.8e-02. Since certification of an empty window list additionally requires
GAMMA_TO_ERROR* \(\gamma_\max\) to fit the tolerance, that failure mode is closed and this is now a cost knob: it sets where thethreshold /= 3ladder starts, not whether the result may be believed.Measured (
docs/dev/adversarial_batteries/constants_audit.py) over 3 profiles x d = 2, 3 x three requested tolerances, sweepingthreshold0from 1 down to 1e-3:Accuracy is identical at every value in 16 of 18 rows. The ladder reaches whatever threshold the tolerance requires regardless of where it starts.
At ``rtol <= 1e-3``, lower is monotonically cheaper – up to 6.5x (1.57 s to 0.24 s on a solar profile at
rtol = 1e-5) – because every step of the ladder re-runs the detector at doubledn_probeand the transport at doubledn_points. Starting low skips those iterations.At ``rtol = 1e-2`` the sign flips. There the tolerance does not require a window at all, so a low threshold opens one that is not needed: 0.21 s becomes 0.95 s at d = 3, buying an accuracy improvement (2.67e-03 to 1.44e-09) nobody asked for.
So the brief’s hypothesis is confirmed: the right value is a rule, not a constant – low when the requested tolerance is tight, high when it is loose. 0.1 is not the optimum at the default
rtol = 1e-3, where 0.01 is 2-3x cheaper at identical accuracy. It is deliberately left unchanged: three profiles at one energy is precisely the size of population that madeGAMMA_TO_ERRORwrong twice, and retuning a default on it would repeat that mistake rather than learn from it. The measurement is recorded here so the next person starts from evidence.min_threshold (float, optional) –
Floor below which the threshold is not tightened further. Default: 1e-6.
Provenance. Swept over 1e-4 to 1e-8 across 18 ordinary workloads: the worst error is 4.49e-04 at every value, because the ladder stops long before reaching the floor. Even at
rtol = atol = 1e-12on a multi-resonance profile it converges in 7 iterations with a window open, and every value from 1e-4 to 1e-10 gives an identical answer in identical time.The regime this constant governs, found by construction rather than assumed: the floor is reached only when \(\gamma_\max\) is below it – so no window can ever open, however far the threshold falls – and the requested tolerance is tighter than
GAMMA_TO_ERROR\(\times \gamma_\max\), so the \(\gamma\) rule cannot certify either. An almost-flat profile (\(\gamma_\max = 3\times10^{-7}\)) atrtol = atol = 1e-9satisfies both:min_thresholderror
windows
iterations
time
1e-4
8.49e-13
0
9
3.3 s
1e-6
8.49e-13
0
13
7.4 s
1e-8
3.24e-12
1
13
7.7 s
1e-10
3.24e-12
1
13
7.9 s
So it does change behaviour there – below \(\gamma_\max\) a window opens – but not usefully: the result is
certified=Falseat every value, and the error is three orders inside the requested tolerance either way, with the window costing a factor of 2.4 in time and making the answer very slightly worse. The floor decides how much work is done in a regime where the answer is already good and known to be uncertified; it does not decide correctness anywhere measured.n_probe0 (int, optional) –
Starting number of positions used to locate resonance candidates. Default: 200.
Provenance. Swept over 50, 100, 200, 400, 800 across 18 workloads spanning single points, baseline scans and energy scans, 3 profile families, d = 2 and 3 (
docs/dev/adversarial_batteries/constants_audit2.py). Worst error over all workloads: 4.98e-04, 5.22e-04, 4.49e-04, 3.38e-04, 3.36e-04 – flat within a factor of 1.6 and inside the requested 1e-3 everywhere. Not load-bearing: the refinement doubles it, so the starting value only shifts which iteration finds a given feature.max_n_probe (int, optional) – Ceiling on the probe grid density. Default: 6400. A cost ceiling rather than a calibration – reaching it is reported rather than absorbed – and it also sets what
find_hidden_features()treats as resolvable.n_points0 (int, optional) –
Starting number of positions used for adiabatic-transport quadrature. Default: 201.
Provenance. Swept over 51, 101, 201, 401, 801 on the same 18 workloads: the worst error is 4.49e-04 at every value, identical to three digits. The refinement doubles this too, so the starting value is invisible in the answer; it buys only iterations.
max_n_points (int, optional) – Ceiling on the adiabatic-transport grid density. Default: 12864. A cost ceiling, as
max_n_probe.fd_step_frac (float, optional) – Finite-difference step for the Hellmann-Feynman diagnostics, as a fraction of
l1 - l0. Default: 1e-6.max_iters (int, optional) – Maximum number of refinement iterations. Default: 12.
info (dict, optional) – If given, filled in place with why this call ended as it did, following the same out-parameter convention as
convergence_infoinmagnus.oscprob.osc_prob(). Keys:'resolved'(whetherH_funcpassed the probe-scale resolution test – see_profile_is_resolved),'gamma_max','n_windows','iterations', and'patches_converged'.certified=Falseon its own does not say which of these failed, and the cures are different: an unresolved profile wantst_breakpoints, an exhausted refinement wants a looser tolerance.magnus.oscprobuses'resolved'to raisemagnus.oscprob.UnmarkedDiscontinuityWarningon the hybrid path instead of declining in silence. Default: None.
- Returns:
The evolution operator (exactly unitary regardless of
certified), the non-adiabatic windows used in the last iteration, and whether the result is certified (True).certifiedisFalseif the refinement exhaustedmax_iters, if every knob reached its ceiling before two successive results agreed, if a local patch failed to converge within its own slab cap, or ifH_funcis not resolved at the probe scale (see_profile_is_resolved) – in all of these the returned operator is the best available estimate, still exactly unitary, but its accuracy is not certified to the requested tolerance.- Return type:
Notes
Two successive results agreeing is necessary but not sufficient, and step 4 above states the reason narrowly. When no window opens at all, successive iterations differ only in the adiabatic-transport grid: they converge to the same adiabatic limit and agree with each other whether or not that limit is the right answer. Certifying an empty window list therefore additionally requires the adiabaticity parameter itself to be small enough for the requested tolerance (see
GAMMA_TO_ERROR); otherwise the loop keeps lowering the threshold until a window does open. Without that, a profile whose \(\gamma\) stays just belowthreshold0everywhere is certified while wrong – measured at 1.8e-02 against a requested 1e-3.Examples
import numpy as np import magnus.globaldefs as gd from magnus import adiabatic from magnus.hamiltonians import hamiltonians3nu p = gd.OSC_PARAMS_PREDEFINED['OSC_PARAMS_DEFAULT'] h_vac = np.asarray(hamiltonians3nu.hamiltonian_3nu_vacuum_energy_independent( p['s12'], p['s23'], p['s13'], p['dCP'], p['D21'], p['D31'])) e00 = np.diag([1.0, 0.0, 0.0]) energy = 10.0e6 def H(l): v = 1.0e-11*np.exp(-np.asarray(l, dtype=float)/gd.L_SCALE_SUN) return (1.0/energy)*h_vac + np.asarray(v)[..., None, None]*e00 U, windows, certified = adiabatic.hybrid_propagator( H, 0.0, 3.0*gd.L_SCALE_SUN) print('non-adiabatic windows :', len(windows)) print('certified :', certified) print('unitary to %.1e' % np.max(np.abs(np.asarray(U).conj().T @ U - np.eye(3))))
non-adiabatic windows : 0 certified : True unitary to 5.6e-16
certifiedis the value to check: it says the result agreed with itself under tightening, not that it is correct. Zero windows means the whole trajectory was adiabatic, which for the real solar mixing angle is the usual answer.