Quickstart
The routines that compute probabilities take a Hamiltonian, as a plain nested list or a NumPy array, and a baseline; the ones that return the evolution operator take the same two. There is no object to construct and no state to configure.
Units
The three core modules are unit-agnostic: they require only that the Hamiltonian and the baseline be given in reciprocal units, so that the product \(H L\) is dimensionless.
Everywhere else in NuOscProbExact the convention is:
Quantity |
Units |
|---|---|
Hamiltonian |
eV |
Mass-squared differences |
eV2 |
Neutrino energy |
eV |
Baseline |
eV-1 |
Matter potential |
eV |
Mixing angles, CP phases |
radian |
globaldefs provides CONV_KM_TO_INV_EV to convert a baseline in km
into eV-1.
An arbitrary Hamiltonian
The shortest possible use: hand the code a Hermitian matrix and a baseline.
import oscprob3nu
hamiltonian = [
[1.0+0.0j, 0.0+2.0j, 0.0-1.0j],
[0.0-2.0j, 3.0+0.0j, 3.0+0.0j],
[0.0+1.0j, 3.0-0.0j, -5.0+0.0j],
]
Pee, Pem, Pet, Pme, Pmm, Pmt, Pte, Ptm, Ptt = \
oscprob3nu.probabilities_3nu(hamiltonian, 1.0)
print('Pee = %.5f, Pem = %.5f, Pet = %.5f' % (Pee, Pem, Pet))
Pee = 0.54465, Pem = 0.37436, Pet = 0.08099
The probabilities are ordered with the initial flavor varying slowest, so
Pem is \(P(\nu_e \to \nu_\mu)\).
The Hamiltonian must be Hermitian, and is checked: one that is not raises
ValueError rather than returning numbers, because the numbers it would
return still sum to one and so would betray nothing. See
oscprob3nu.CHECK_HERMITICITY for the cost of that check and how to
decline it. The trace is discarded, since it contributes only an overall
phase that cancels in the probabilities.
Oscillations in vacuum
import numpy as np
import oscprob3nu
import hamiltonians3nu
from globaldefs import *
energy = 1.e9 # [eV]
baseline = 1.3e3 # [km]
h_vacuum_energy_indep = \
hamiltonians3nu.hamiltonian_3nu_vacuum_energy_independent(
S12_NO_BF, S23_NO_BF, S13_NO_BF, DCP_NO_BF, D21_NO_BF, D31_NO_BF)
h_vacuum = np.multiply(1./energy, h_vacuum_energy_indep)
Pee, Pem, Pet, Pme, Pmm, Pmt, Pte, Ptm, Ptt = \
oscprob3nu.probabilities_3nu(h_vacuum, baseline*CONV_KM_TO_INV_EV)
print('Pee = %.5f, Pem = %.5f, Pet = %.5f' % (Pee, Pem, Pet))
print('Pme = %.5f, Pmm = %.5f, Pmt = %.5f' % (Pme, Pmm, Pmt))
print('Pte = %.5f, Ptm = %.5f, Ptt = %.5f' % (Pte, Ptm, Ptt))
Pee = 0.92768, Pem = 0.01432, Pet = 0.05800
Pme = 0.04023, Pmm = 0.37887, Pmt = 0.58090
Pte = 0.03210, Ptm = 0.60680, Ptt = 0.36110
hamiltonian_3nu_vacuum_energy_independent deliberately leaves out the
factor \(1/E\), so that the energy-independent part can be computed once
and reused across an energy scan.
Matter, NSI, and LIV
The remaining sample Hamiltonians all take that same energy-independent vacuum term and add to it:
# Matter of constant density
h_matter = hamiltonians3nu.hamiltonian_3nu_matter(
h_vacuum_energy_indep, energy, VCC_EARTH_CRUST)
# Non-standard interactions
h_nsi = hamiltonians3nu.hamiltonian_3nu_nsi(
h_vacuum_energy_indep, energy, VCC_EARTH_CRUST, EPS_3)
# Lorentz invariance-violating background
h_liv = hamiltonians3nu.hamiltonian_3nu_liv(
h_vacuum_energy_indep, energy, SXI12, SXI23, SXI13, DXICP,
B1, B2, B3, LAMBDA)
The matter potential VCC is positive for neutrinos. Pass its negative
for antineutrinos; see Sign conventions for why that is the whole
difference.
Two flavors
oscprob2nu mirrors oscprob3nu throughout:
import oscprob2nu
import hamiltonians2nu
# `sth` is sin(theta), not the angle itself
h2_vacuum_energy_indep = \
hamiltonians2nu.hamiltonian_2nu_vacuum_energy_independent(
S23_NO_BF, D31_NO_BF)
h2_vacuum = np.multiply(1./energy, h2_vacuum_energy_indep)
Pee, Pem, Pme, Pmm = \
oscprob2nu.probabilities_2nu(h2_vacuum, baseline*CONV_KM_TO_INV_EV)
print('Pee = %.5f, Pem = %.5f' % (Pee, Pem))
Pee = 0.29595, Pem = 0.70405
Four flavors
oscprob4nu mirrors the other two in turn, with a \(4\times4\)
Hamiltonian and sixteen probabilities. With the fourth state read as
sterile, the flavor order is
\((\nu_e, \nu_\mu, \nu_\tau, \nu_s)\):
import oscprob4nu
import hamiltonians4nu
# Three extra mixing angles and one extra splitting, here
# Dm41^2 = 1 eV^2. As everywhere, the angles are given as sines.
h4_vacuum_energy_indep = \
hamiltonians4nu.hamiltonian_4nu_vacuum_energy_independent(
S12_NO_BF, S23_NO_BF, S13_NO_BF,
np.sqrt(0.10), np.sqrt(0.10), 0.0,
DCP_NO_BF, D21_NO_BF, D31_NO_BF, 1.0)
h4_vacuum = np.multiply(1./energy, h4_vacuum_energy_indep)
prob = oscprob4nu.probabilities_4nu(h4_vacuum,
baseline*CONV_KM_TO_INV_EV)
print('%d probabilities' % len(prob))
print('Pee = %.5f, Pes = %.5f' % (prob[0], prob[3]))
print('they sum to %.5f' % sum(prob[0:4]))
16 probabilities
Pee = 0.76700, Pes = 0.17931
they sum to 1.00000
A sterile state does not feel the neutral-current potential, so that
potential no longer cancels between the flavors: in matter it leaves
\(-V_{NC}\) on the sterile entry, which is what places the sterile
matter resonance. hamiltonians4nu.hamiltonian_4nu_matter() takes
both potentials for that reason.
h4_matter = hamiltonians4nu.hamiltonian_4nu_matter(
h4_vacuum_energy_indep, energy, VCC_EARTH_CRUST, VNC_EARTH_CRUST)
print('sterile entry: %+.4e eV' % h4_matter[3][3].real)
sterile entry: +4.0511e-10 eV
Layered matter and the Earth work at four flavors too —
slabs.probabilities_4nu_slabs() and
earth.probabilities_4nu_earth() — which is what a 3+1 scenario
needs to be propagated through PREM rather than through a single average
density.
The evolution operator
If you need the evolution operator itself — to compose it across segments, or to propagate a density matrix — ask for it directly:
U3 = oscprob3nu.evolution_operator_3nu(h_vacuum,
baseline*CONV_KM_TO_INV_EV)
print('|U3[0][0]| = %.6f' % abs(U3[0][0]))
|U3[0][0]| = 0.963160
and the expansion coefficients, if you want those:
h_coeffs = oscprob3nu.hamiltonian_3nu_coefficients(h_vacuum)
u_coeffs = oscprob3nu.evolution_operator_3nu_u_coefficients(
h_vacuum, baseline*CONV_KM_TO_INV_EV)
print('h has %d coefficients' % len(h_coeffs))
h has 8 coefficients
Because \(H\) is time-independent, the evolution operator obeys the group property \(U(L_1+L_2) = U(L_2) U(L_1)\), which is what makes composing across segments of constant density legitimate.
Layered matter: slabs
A slab is a stretch of the trajectory over which the density is taken
constant. slabs solves each one exactly and multiplies the resulting
operators together, so the only approximation anywhere is the caller’s —
how finely to cut a profile that really varies continuously. Within a slab
there is none.
Each slab carries its own density, and so its own Hamiltonian and its own exactly-solved \(U_k\). The dashed ties are the point of the ordering: the slab crossed first is the rightmost factor.
Give it one Hamiltonian per slab and one width per slab:
import earth
import slabs
# A denser layer between two lighter ones
densities = [3.0, 5.0, 3.0] # [g cm^-3]
widths = np.array([1000.0, 2000.0, 1000.0])*CONV_KM_TO_INV_EV
h_layers = hamiltonians3nu.hamiltonian_3nu_matter(
h_vacuum_energy_indep, energy, earth.matter_potential(densities))
prob = slabs.probabilities_3nu_slabs(h_layers, widths)
print('h_layers has shape', np.shape(h_layers))
print('P_mue across the three layers = %.6f' % prob[3])
h_layers has shape (3, 3, 3)
P_mue across the three layers = 0.138932
Both arguments are ordered along the trajectory, and the slab met first is
applied first — rightmost in \(U = U_n \cdots U_2 U_1\), since the
operators act to the left on the initial state. The widths are baselines
like any other, so they are in eV-1, which is what
CONV_KM_TO_INV_EV is doing there.
The group property quoted just above is what licenses all of this, and it is visible directly: cutting a uniform profile into slabs must change nothing.
uniform = hamiltonians3nu.hamiltonian_3nu_matter(
h_vacuum_energy_indep, energy, VCC_EARTH_CRUST)
total = 4000.0*CONV_KM_TO_INV_EV
one_call = oscprob3nu.probabilities_3nu(uniform, total)
split = slabs.probabilities_3nu_slabs(np.stack([uniform]*4),
np.full(4, total/4))
print('one call %.9f' % one_call[3])
print('four slabs %.9f' % split[3])
one call 0.133015739
four slabs 0.133015739
The two differ by \(6 \times 10^{-16}\) here, and by \(1 \times 10^{-14}\) if the same 4000 km is cut into forty slabs instead: round-off in the extra matrix products, and nothing else.
For the Earth you need not build the slabs at all — earth cuts a
chord into PREM layers for you. For the case where the arrangement of the
matter, and not merely its mean, is what changes the answer, see “An
arbitrary matter profile” in Numerical recipes. Two and four flavors work the
same way, through slabs.probabilities_2nu_slabs() and
slabs.probabilities_4nu_slabs().
Scanning: pass arrays
probabilities_3nu and evolution_operator_3nu — and their two- and
four-flavor counterparts — accept a stack of Hamiltonians, a stack of
baselines, or both, and evaluate the whole stack at once. The routines that
return expansion coefficients are scalar-only; they are diagnostics rather
than the path a scan takes. This is
between one and two orders of magnitude faster than the same calls in a
Python loop, and it is the recommended way to produce a curve or a grid.
Versus baseline — one Hamiltonian, many baselines:
baselines = np.linspace(1.0, 1.3e4, 2000)*CONV_KM_TO_INV_EV
prob = oscprob3nu.probabilities_3nu(h_vacuum, baselines)
Pem = prob[:, 1] # same ordering as the scalar return
print('shape:', prob.shape)
shape: (2000, 9)
The characteristic equation depends only on the Hamiltonian, so it is solved once for the whole scan rather than once per baseline.
Versus energy — many Hamiltonians, one baseline. Since \(H \propto 1/E\), build the stack by dividing the energy-independent term by an array of energies:
energies = np.logspace(-1, 1, 2000)*1.e9 # [eV]
h_stack = h_vacuum_energy_indep/energies[:, None, None]
prob = oscprob3nu.probabilities_3nu(h_stack, baseline*CONV_KM_TO_INV_EV)
print('shape:', prob.shape)
shape: (2000, 9)
The sample Hamiltonians do this for you: pass an array of energies and they return one Hamiltonian per energy, in exactly that shape.
h_stack = hamiltonians3nu.hamiltonian_3nu_matter(
h_vacuum_energy_indep, energies, VCC_EARTH_CRUST)
prob = oscprob3nu.probabilities_3nu(h_stack, baseline*CONV_KM_TO_INV_EV)
print('shape:', prob.shape)
shape: (2000, 9)
so a whole scan in matter, with NSI, or with LIV is two calls and no Python loop. The matter potential may be an array too, for a scan across a density profile alongside the energy.
An oscillogram is the outer combination of the two: give the Hamiltonians and the baselines separate axes and let them broadcast.
prob = oscprob3nu.probabilities_3nu(h_stack[:, None, :, :],
baselines[:200][None, :])
print('shape:', prob.shape) # (energies, baselines, 9)
shape: (2000, 200, 9)
A single Hamiltonian and a scalar baseline still return a plain tuple, exactly as before, so existing code is unaffected.
Going faster still
If Numba is installed, the batched paths are evaluated by compiled kernels instead of NumPy, which is worth roughly 1.5x to 20x on large stacks, depending on the number of flavors:
pip install "nuoscprobexact[fast]"
Nothing in your code changes; fastkernels is picked up
automatically, and the answers are the same to round-off. If it is not
installed, the NumPy path is used and everything works as before.
It is used only where it is faster. For three flavors that is every stack
size, by between two and sixteen times, and for four flavors likewise, by
between five and nineteen; for two flavors the NumPy path is already lean
enough to win below about fifty thousand elements, so it is kept there.
fastkernels.worthwhile() makes that choice from measured thresholds,
so installing the extra can only help.
Checking the input has a cost too, and it is the larger of the two on a big scan. Every entry point verifies that the Hamiltonian is Hermitian, because one that is not returns probabilities that still sum to one and so betrays nothing. That check is a pass over the stack, the same order of work as evaluating it: 1.3x to 1.8x on a 2000-point scan and 3.2x to 5.7x on a 200 000-point one. Where the Hamiltonians come from a trusted construction, decline it with
import oscprob3nu
oscprob3nu.CHECK_HERMITICITY = False
and likewise for oscprob2nu and oscprob4nu. Everything
hamiltonians3nu and its siblings build is Hermitian to round-off.
The first call compiles, which takes a few seconds. The kernels are cached on disk, so later runs start in milliseconds. To force the NumPy path — to compare the two, say — set
import fastkernels
fastkernels.USE_NUMBA = False
The scalar path is deliberately left uncompiled: a single probability takes about eight microseconds, which is not worth a compilation pause. At two and three flavors short stacks are also evaluated one element at a time, since below thirteen elements at three flavors, and twelve at two, the array machinery costs more than it saves; four flavors has no such shortcut, because it has no separate scalar closed form to fall back to.
More examples
The examples/ directory holds a runnable script for each of the cases
above, each printing the probabilities it computes. The README walks
through the first of them and links the rest, rather than transcribing them,
so that there is one copy of each to keep correct.
Note
This directory was called test/ in version 1.0.0 of the code, and is
named that way in version 2 of the paper. It was renamed to examples/ to
stop it being confused with tests/, which holds the regression suite.