Numerical recipes
This page shows what Magνs can compute, with the code that computes it.
Each recipe below is a few lines. The short ones run when this page is built, so the output shown is what the current code produces. Most recipes have a longer form in a notebook, linked beside it, which calls the same functions.
If you are looking for which function rather than how to call it, see
Available probability functions, which lays out the whole osc_prob_* family by environment
and flavor count.
One probability
The simplest call takes an energy and a baseline and uses the default oscillation parameters.
import numpy as np
import magnus.globaldefs as gd
import magnus.oscprob as oscprob
P = oscprob.osc_prob_3nu_vacuum(1.0*gd.UNIT_GEV, 1300.0*gd.UNIT_KM)
print('P_ee = %.6f' % np.asarray(P)[0][0]) # 0.928948
print('P_mue = %.6f' % np.asarray(P)[1][0]) # 0.031266
P_ee = 0.928948
P_mue = 0.031266
The return value is the probability matrix, indexed P[nu_i][nu_f]: the initial
flavor first. Pass nu_i and nu_f to get a single channel instead of the
matrix. The full walk-through is in notebook 01.
The evolution operator, for observables built from amplitudes
A probability is a modulus squared. Some observables need the amplitudes themselves: for example, the content of each mass eigenstate in the state that leaves a dense source, and from it the flavor composition at a detector so far away that the phases have averaged. For those, ask any entry point for the evolution operator alongside the probabilities. The refinement ladder then converges the operator itself, phases included.
import magnus.hamiltonians as hams
OSC = gd.load_nufit_params('NuFIT 6.1')
P, U = oscprob.osc_prob_3nu_matter_exp_density(
1.0*gd.UNIT_GEV, 5000.0*gd.UNIT_KM, 0.0, 10.0, 1000.0*gd.UNIT_KM,
density_matter_is_in_g_per_cm3=True, return_evolution_operator=True,
n_slabs=32, **OSC)
# The mixing matrix in vacuum, the medium past the source; then the
# mass-state content of what leaves, and the flavor content far away
R = hams.pmns_mixing_matrix(OSC['s12'], OSC['s23'], OSC['s13'], OSC['dCP'])
content = abs(R.conj().T @ U)**2
P_far = (abs(R)**2 @ content).T # indexed like P: [initial][final]
print('at the edge of the source, P_ee = %.4f' % np.asarray(P)[0][0]) # 0.7818
print('far away, phases averaged, P_ee = %.4f' % P_far[0, 0]) # 0.4929
at the edge of the source, P_ee = 0.7818
far away, phases averaged, P_ee = 0.4929
P is what the same call returns without the keyword; U is complex and
unitary, indexed U[final, initial], so (abs(U)**2).T is P. See
Available probability functions for what the keyword does to the engine dispatch and the two
combinations it refuses.
A scan, without a loop
Pass arrays, and the whole scan is one call. The engines batch over the energy axis, so for a position-dependent Hamiltonian the matter profile is built once for the whole scan rather than once per point. Batching is the largest saving available.
energies = np.logspace(-1.0, 1.5, 500)*gd.UNIT_GEV
baselines = np.full(500, 1300.0*gd.UNIT_KM)
P = np.asarray(oscprob.osc_prob_3nu_vacuum(energies, baselines))
print('shape returned:', P.shape)
print('P_mue at the first three energies:', np.round(P[:3, 1, 0], 6))
shape returned: (500, 3, 3)
P_mue at the first three energies: [0.339675 0.449521 0.509371]
A batched call returns (n_points, d, d), with the point index first, so
P[:, 1, 0] is \(P_{\mu e}\) along the scan.
Three-flavor vacuum oscillations.
Writing your own H_func so that it accepts an array of positions can save a
further factor of up to about 5, depending on how many slabs the call needs: see
Writing an H_func that takes many positions at once below.
Through the Earth
Give the zenith angle and the chord length. The PREM density profile along the chord is set up automatically, with mandatory slab edges at its layer boundaries, so the quadrature never integrates across a density discontinuity.
import magnus.earth as earth
costhz = -0.5
L = earth.distance_traveled_inside_earth(costhz)*gd.UNIT_KM
P = np.asarray(oscprob.osc_prob_3nu_earth(10.0*gd.UNIT_GEV, costhz=costhz, L=L))
print('chord = %.0f km' % (L/gd.UNIT_KM))
print('P_mue = %.6f' % P[1][0]) # 0.132224
chord = 6371 km
P_mue = 0.132224
A detector underground is the same call with its depth named. The zenith angle is measured at the detector. The baseline follows from the angle and the depth, so it is computed rather than given. A buried detector also sees downward-going neutrinos, which cross its overburden; a detector on the surface does not.
depth = 2.0*gd.UNIT_KM
for costhz_det in (-0.5, 1.0):
L_km = earth.distance_traveled_inside_earth(
costhz_det, detector_depth=depth/gd.UNIT_KM)
P_buried = np.asarray(oscprob.osc_prob_3nu_earth(
10.0*gd.UNIT_GEV, costhz=costhz_det, detector_depth=depth))
print('costhz = %5.2f: %10.3f km, P_mumu = %.6f'
% (costhz_det, L_km, P_buried[1][1]))
costhz = -0.50: 6372.998 km, P_mumu = 0.101955
costhz = 1.00: 2.000 km, P_mumu = 1.000000
PREM’s outermost shell is 3 km of global-average ocean, which does not describe a
detector under rock or ice. Replace its density with density_matter_ocean,
and its composition with electron_fraction_ocean. Both settings matter for a
trajectory close to horizontal, which can spend its whole length inside that
shell.
Notebooks 02 and 03 cover the Earth alongside the other profiles; 06 turns it into an oscillogram.
Probability across zenith angle and energy: one batched energy scan per zenith angle.
Between two named sites
Two site names replace costhz and L: the chord between them is the
baseline, and magnus.earth.loc_coords_dms lists the sites.
import warnings
from magnus.magnus import MagnusConvergenceWarning
E_beam = np.logspace(np.log10(0.3), 1.0, 5)*gd.UNIT_GEV
for site in ('homestake', 'snolab', 'cern'):
a, b = earth.loc_coords_dms['fermilab'], earth.loc_coords_dms[site]
L_km = earth.chord_length_inside_earth(a['lat'], a['lon'], b['lat'], b['lon'])
with warnings.catch_warnings(): # expected on these chords; see diagnostics
warnings.simplefilter('ignore', MagnusConvergenceWarning)
P_site = np.asarray(oscprob.osc_prob_3nu_earth(
E_beam, loc_ini='fermilab', loc_fin=site, nu_i=gd.NUMU, nu_f=gd.NUE))
print('fermilab -> %-9s %6.0f km P_mue:' % (site, L_km), np.round(P_site, 4))
fermilab -> homestake 1285 km P_mue: [0.0651 0.0853 0.0718 0.0345 0.0061]
fermilab -> snolab 763 km P_mue: [0.106 0.0041 0.0526 0.0121 0.0021]
fermilab -> cern 6725 km P_mue: [0.0597 0.0695 0.0151 0.0444 0.1365]
Notebook 04 draws these, with T2K, Hyper-K and ESS.
DUNE: neutrinos and antineutrinos through PREM
This recipe computes the full appearance and disappearance spectra at DUNE, Fermilab to the Homestake mine, at 200 energies, with one call for neutrinos and one for antineutrinos.
import time
import warnings
from magnus.magnus import MagnusConvergenceWarning
E_dune = np.logspace(np.log10(0.5), 1.0, 200)*gd.UNIT_GEV # 0.5 to 10 GeV
def dune(nubar):
with warnings.catch_warnings(): # expected on this chord; see diagnostics
warnings.simplefilter('ignore', MagnusConvergenceWarning)
return np.asarray(oscprob.osc_prob_3nu_earth(
E_dune, loc_ini='fermilab', loc_fin='homestake', nubar=nubar))
dune(False) # the first call pays the setup
t0 = time.perf_counter()
P_nu = dune(False)
P_nubar = dune(True)
t1 = time.perf_counter()
i = np.argmax(np.where(E_dune > 1.5*gd.UNIT_GEV, P_nu[:, 1, 0], 0.0))
print('shape:', P_nu.shape) # (200, 3, 3)
print('first maximum at %.2f GeV' % (E_dune[i]/gd.UNIT_GEV)) # 2.06 GeV
print('P_mue = %.4f, P_mue-bar = %.4f' % (P_nu[i, 1, 0], P_nubar[i, 1, 0])) # 0.0799, 0.0143
print('both spectra: %.1f ms' % (1e3*(t1 - t0)))
shape: (200, 3, 3)
first maximum at 2.06 GeV
P_mue = 0.0799, P_mue-bar = 0.0143
both spectra: 6.0 ms
The two spectra take a few milliseconds together after the first call (under 10 ms on a CI
runner); the first call of a session costs a few hundred milliseconds more, spent on setup.
The gap between P_mue and P_mue-bar is the matter effect together with
\(\delta_{\rm CP}\): the chord is 1285 km, and the default ordering is normal.
An oscillogram
An energy scan is one batched call; an oscillogram is one such call per zenith angle. The result has one row per zenith angle.
import warnings
from magnus.magnus import MagnusConvergenceWarning
cos_grid = np.linspace(-1.0, -0.1, 40)
E_grid = np.logspace(0.0, 1.5, 60)*gd.UNIT_GEV
with warnings.catch_warnings(): # expected on a few chords; see diagnostics
warnings.simplefilter('ignore', MagnusConvergenceWarning)
P_mumu = np.array([
oscprob.osc_prob_3nu_earth(
E_grid, costhz=c, L=earth.distance_traveled_inside_earth(c)*gd.UNIT_KM,
nu_i=gd.NUMU, nu_f=gd.NUMU)
for c in cos_grid])
print('shape:', P_mumu.shape, ' smallest P_mumu = %.3f' % P_mumu.min())
shape: (40, 60) smallest P_mumu = 0.001
magnus.plotting.plot_oscillogram() computes and draws the same map in one
call (see Pre-packaged plotting tools). To draw this array with it instead, pass P_mumu.T:
it takes one row per energy.
A profile of your own
Any callable returning a density as a function of position works. The Sun’s
exponential profile is available as a helper, magnus.matter.exp_density_profile();
at two flavors, a profile built with it can use the faster interaction-picture
engine (Engines and dispatch). Twelve published standard solar models are also included,
and the Sun wrappers take them by name (see Standard solar models).
import magnus.matter as matter
profile = matter.exp_density_profile(gd.NUM_DENSITY_E_SUN_CENTRAL,
gd.L_SCALE_SUN)
osc = gd.load_nufit_params('NuFIT 6.1')
# arguments: flavors, density, energy, baseline, oscillation parameters
P = np.asarray(oscprob.osc_prob_matter_std_potential(
2, profile, 10.0*gd.UNIT_MEV, 0.3*gd.SUN_RADIUS*gd.UNIT_KM,
{'sth': osc['s12'], 'Dm2': osc['D21']},
L0=0.0, density_is_of_number_of_electrons=True))
print('P_ee = %.6f' % P[0][0]) # 0.483533
P_ee = 0.483533
Notebooks 13 and 14 do this with a real tabulated solar model and with a supernova shock front, and measure where the package’s accuracy breaks down in each case.
A sharp shock front. It changes the conversion probability itself, not only the phase of the oscillation, so averaging over energy does not remove the error.
Phase-averaged probabilities
When the oscillation phase is unresolvable — a source far enough away, or an energy resolution wide enough — the observable is the average, not the instantaneous value. Ask for it directly rather than averaging a scan by hand.
kw = dict(L0=0.0, density_is_of_number_of_electrons=True)
params = {'sth': osc['s12'], 'Dm2': osc['D21']}
L_sun = 0.3*gd.SUN_RADIUS*gd.UNIT_KM
inst = np.asarray(oscprob.osc_prob_matter_std_potential(
2, profile, 10.0*gd.UNIT_MEV, L_sun, params, **kw))
avg = np.asarray(oscprob.osc_prob_matter_std_potential(
2, profile, 10.0*gd.UNIT_MEV, L_sun, params, average=True, **kw))
print('instantaneous P_ee = %.6f' % inst[0][0])
print('phase-averaged = %.6f' % avg[0][0]) # 0.410154
instantaneous P_ee = 0.483533
phase-averaged = 0.410154
This matters for accuracy as well as for physics. An error in the phase of the oscillation disappears under averaging; an error in its mean, the probability around which it oscillates, does not. See Phase-averaged probabilities, and notebook 10.
Instantaneous and phase-averaged probabilities: the average is what remains when the phase cannot be resolved.
Flavor composition of astrophysical neutrinos
From a source 100 Mpc away, every oscillation has averaged out, so the flavor composition at Earth is the source composition times the averaged probability matrix.
E_astro = np.array([1e3, 1e5])*gd.UNIT_GEV # 1 TeV and 100 TeV
L_src = 100.0*3.0857e19*gd.UNIT_KM # 100 Mpc
f_source = np.array([1/3, 2/3, 0.0]) # pion decay
P_avg = np.asarray(oscprob.osc_prob_3nu_vacuum(E_astro, L_src, average=True))
for E_i, P_i in zip(E_astro, P_avg):
f_earth = f_source @ P_i
print('%6.0f TeV (f_e, f_mu, f_tau) at Earth =' % (E_i/gd.UNIT_TEV),
np.round(f_earth, 3))
1 TeV (f_e, f_mu, f_tau) at Earth = [0.326 0.343 0.33 ]
100 TeV (f_e, f_mu, f_tau) at Earth = [0.326 0.343 0.33 ]
New physics changes the matrix, and with it the composition; notebook 29 does this for pseudo-Dirac pairs.
Asking for an accuracy instead of a slab count
n_slabs fixes the discretization, not the error. Pass rtol/atol
instead — they are on by default at 1e-3 — and the slab grid is refined until
two successive levels agree.
info = {}
oscprob.osc_prob_3nu_earth(10.0*gd.UNIT_GEV, costhz=costhz, L=L,
rtol=1e-6, atol=1e-6, convergence_info=info)
print('slabs used : %d' % info['n_slabs'])
print('slab edges used : %d (PREM boundaries included)' % info['n_slab_edges'])
print('tolerance met : %s' % info['tolerance_achieved'])
slabs used : 21
slab edges used : 35 (PREM boundaries included)
tolerance met : True
The tolerance is a stopping rule, not a guarantee: the ladder halts when two levels agree,
and never estimates the error of the answer it returns. The stopping rule is usually
conservative, but not always. The rtol entry of magnus.oscprob.osc_prob() says
what it does and does not promise, and What rtol and atol control gives the measured detail.
convergence_info reports what the ladder did — including
tolerance_achieved, which is the programmatic form of
ToleranceNotAchievedWarning. There is deliberately no
error estimate in it; the same section explains why.
Choosing a strategy, and seeing which engine answered
strategy='auto', the default, picks the engine from the phase of the request, its
shape and the tolerance. At the default tolerance, an energy scan goes to the
energy-batched engine. A single point goes to the general Magnus ladder, unless its phase
exceeds 1e4 or it needs too many slabs; then it goes to the adiabatic propagator with
Magnus patches. Dispatch has the full table. 'hybrid' forces the
adiabatic propagator, and 'magnus' keeps to the Magnus engines. The choice affects
accuracy as well as speed: on the NSI configurations of notebook 12, 'magnus' is
faster but less accurate, and issues ToleranceNotAchievedWarning.
report = {}
oscprob.osc_prob_matter_std_potential(
2, profile, 10.0*gd.UNIT_MEV, L_sun, params, strategy_info=report, **kw)
print('engine that answered:', report['engine'])
engine that answered: hybrid
Pass strategy_info whenever you want to know which of the engines produced a
number. See Adiabatic + Magnus hybrid strategy, and notebook 12, which times 'auto',
'hybrid' and 'magnus' against solve_ivp.
Checking an answer two ways
cross_check_strategies() runs the same call through every
engine that applies and reports how far their answers spread. Engines of different
families fail in different ways, so agreement between them is stronger evidence
than any one engine’s own convergence check.
check = oscprob.cross_check_strategies(
oscprob.osc_prob_3nu_sun, np.array([5.0, 8.0, 10.0])*gd.UNIT_MEV,
0.5*gd.SUN_RADIUS*gd.UNIT_KM, 0.0)
print('engines that ran :', check['ran'])
print('largest spread : %.1e' % check['max_spread'])
print('largest spread across families: %.1e' % check['max_spread_independent'])
engines that ran : ('hybrid', 'separable', 'magnus')
largest spread : 4.7e-04
largest spread across families: 4.7e-04
The spread across engine families is the one that counts: two engines of the same
family share their failure modes, and when only one family applies the function
says so with CrossCheckInconclusiveWarning. A large spread
is reported, never raised; read it before trusting a number that matters.
When a spread is large, look first at check['unverified']: the engines that
reported missing the tolerance. A spread involving one of them points at that engine,
and check['max_spread_verified'] gives the spread among the others. For example, on
the Sun at 1 MeV, the hybrid engine certifies an answer 3.8e-5 from a fine reference,
while the slab ladder, stopped at its cap of 20 000 slabs, is 1.9e-3 from it. The
spread is then 1.9e-3, and unverified lists the ladder.
Declaring where the profile is not smooth
High-order quadrature converges at its nominal order only inside a smooth slab. If your profile has a jump or a kink, pass its position as a mandatory slab edge; no number of slabs repairs a slab that straddles the jump.
What a density jump does to a slab, and the two ways of declaring it. With nothing
declared, one slab straddles the jump and the quadrature sees a straight line across
it (shaded). t_breakpoints adds the jump to the refinement grid;
t_slab_edges replaces the grid. From the Magνs paper.
osc = gd.load_nufit_params('NuFIT 6.1')
def rho_func(l): # [g/cm^3]: a jump at 1000 km
return np.where(np.asarray(l) < 1000.0*gd.UNIT_KM, 3.0, 8.0)
P = oscprob.osc_prob_matter_std_potential(
3, rho_func, 10.0*gd.UNIT_GEV, 3000.0*gd.UNIT_KM, osc, L0=0.0,
t_breakpoints=[1000.0*gd.UNIT_KM], density_matter_is_in_g_per_cm3=True)
The Earth entry points do this for you. Do it by hand for a shock front, a
castle-wall profile or a tabulated model with a discontinuous derivative. On a
scan, declaring the jumps fixes the error. On a single point, Magνs finds and
declares the jumps itself (Warnings: what each one means and what to do about it), so passing them only skips that
search. The exception is a single phase-averaged point (average=True). There,
declaring a shock front changes the engine: across 18 shock configurations, it
improves 7 results and worsens 11, so check such a point a second way. Notebook 14
is that measurement.
A layered profile, exactly
The probability for a piecewise-constant profile — a castle wall — is exact once its slab edges are declared: inside each layer the Hamiltonian is constant, and one exponential per layer is the whole answer.
# 24 layers of 250 km, alternating 2 and 8 g/cm^3
n_layers, width = 24, 250.0*gd.UNIT_KM
rho_layers = np.where(np.arange(n_layers) % 2 == 0, 2.0, 8.0)
edges = np.arange(n_layers + 1)*width
def castle_wall(l):
k = np.searchsorted(edges, l, side='right') - 1
return rho_layers[np.clip(k, 0, n_layers - 1)]
E_cw = np.array([1.0, 2.0, 4.0])*gd.UNIT_GEV
for nubar in (False, True):
P_cw = oscprob.osc_prob_matter_std_potential(
3, castle_wall, E_cw, n_layers*width, gd.load_nufit_params('NuFIT 6.1'),
t_breakpoints=edges[1:-1], nubar=nubar, nu_i=gd.NUMU, nu_f=gd.NUE,
density_matter_is_in_g_per_cm3=True)
print('nubar=%-5s P_mue at 1, 2, 4 GeV:' % nubar, np.round(P_cw, 4))
nubar=False P_mue at 1, 2, 4 GeV: [0.025 0.022 0.1365]
nubar=True P_mue at 1, 2, 4 GeV: [0.002 0.0015 0.0159]
The same mean density arranged differently gives different probabilities; notebook 18 compares four arrangements.
New physics
Non-standard interactions, Lorentz-invariance violation and sterile states are each a different Hermitian matrix in the same slot, so they are the same calculation with a different Hamiltonian.
energy = 10.0*gd.UNIT_GEV
costhz = -0.5
L = earth.distance_traveled_inside_earth(costhz)*gd.UNIT_KM
osc = gd.load_nufit_params('NuFIT 6.1')
# NSI: couplings relative to the standard potential; unset ones are zero,
# diagonal ones are real, off-diagonal ones may be complex
P = oscprob.osc_prob_3nu_earth_nsi(energy, costhz=costhz, L=L, **osc,
eps_ee=0.1, eps_em=0.05j)
# LIV: an energy dependence the vacuum term does not have (b's and Lambda in eV)
P = oscprob.osc_prob_3nu_earth_liv(energy, costhz=costhz, L=L, **osc,
b1=1.0e-23, b2=0.0, b3=0.0,
Lambda=1.0e9, n_liv=1)
# 3+1 sterile: the same machinery at one dimension higher
P = oscprob.osc_prob_4nu_earth(energy, costhz=costhz, L=L, **osc,
s14=0.1, s24=0.1, s34=0.0, D41=1.0)
Neutrino against antineutrino as the CP phase runs, for both mass orderings.
Writing an H_func that takes many positions at once
If you supply your own Hamiltonian, writing it so that it can be evaluated for many positions at once is, after batching, the largest saving under your control. The engine samples it at every quadrature node of every slab — often a few hundred positions for one probability, repeated at each refinement level.
energy = 1.0*gd.UNIT_GEV
h_vac = hams.hamiltonian_3nu_vacuum_energy_independent(**OSC)
e00 = np.diag([1.0, 0.0, 0.0])
def vcc(l): # [eV], falls exponentially
return 1.0e-13*np.exp(-np.asarray(l, dtype=float)/(500.0*gd.UNIT_KM))
# Slow: one position at a time
def H_slow(l):
return h_vac/energy + float(vcc(l))*e00
# Fast: the same physics, all positions at once
def H_fast(l):
return h_vac/energy + vcc(l)[..., None, None]*e00
P = oscprob.osc_prob(H_fast, t_ini=0.0, t_fin=10000.0*gd.UNIT_KM,
rtol=1e-8, atol=1e-8)
The trailing [..., None, None] turns one potential per position into a stack of
matrices, so NumPy broadcasts instead of looping in Python. The output is bit-identical,
and the gain grows with the number of positions the ladder evaluates: about 5× for the
call above, which ends at 378 slabs, and about 2× at the default tolerance, which ends
at 22. On a call that needs only a few slabs, the gain is negligible. A scalar-only
H_func issues ScalarHamiltonianWarning once per session.
The builders in magnus.hamiltonians do this for you: each takes its energy, VCC
or position as a number or an array, and given an array, it returns a stack of matrices,
one per entry. hams.hamiltonian_3nu_nsi builds only the NSI term, so a full, vectorized
NSI matter Hamiltonian is:
def H_nsi(l):
v = vcc(l)
return (h_vac/energy + v[..., None, None]*e00
+ hams.hamiltonian_3nu_nsi(v, 0.1, 0.05, 0.0, 0.0, 0.0, 0.0))
Where to go next
Tutorial notebooks — the same calculations with the reasoning around them.
Available probability functions — every
osc_prob_*function, by environment and flavor.Methodology — what the Magnus expansion is and why it is unitary at any order.
Engines and dispatch — which engine answers a call, and how the choice is made.
Performance — the constants and the populations they were measured on.
Accuracy and diagnostics — what each safeguard cannot do, and what every warning means.
Command-line calculator — the same calculations from a shell.