Code to compute neutrino oscillation probabilities between an arbitrary number of flavors, for any given Hamiltonian, time-dependent or -independent.
How do I say that? Just like the name Magnus — the Greek letter ν (nu), the neutrino's symbol, simply stands in for the "nu" syllable. (And since most of this package was written while the author was based in Denmark, you are equally welcome to say it the Danish way.)
Magnus computes the neutrino evolution operator via the Magnus expansion: instead of integrating the Schrödinger equation step by step, it exponentiates truncated time-ordered integrals of the Hamiltonian over a chain of position slabs. Any truncation of the Magnus series lives in the Lie algebra, so the resulting evolution operator is exactly unitary by construction — probabilities are non-negative and sum to one at machine precision, at any accuracy setting. See Mathematical method below for the full derivation.
- When is Magnus a win?
- Adiabatic + Magnus hybrid strategy for extreme accumulated phases
- Phase-averaged probabilities for astrophysical neutrinos
- When is Magnus not the right tool?
- File Tree
- Two ways to use Magnus
- What Magnus computes
- Available oscillation-probability functions
- Code architecture
- Mathematical method
- Numerical engine
- Performance
- Pre-packaged plotting tools
- Accuracy and validation
- Continuous Integration
- Requirements
- Changelog
- How to Cite
- License
- Author
Compared to solving the propagation ODE directly (e.g., with an adaptive Runge–Kutta solver), Magnus wins when one or more of these apply:
-
The matter profile varies slowly compared to the oscillation length. A Magnus slab is exact for a constant Hamiltonian no matter how many oscillation cycles it spans, so the slab size is set by how fast the profile changes, not by how fast the phase winds. An ODE solver must resolve every oscillation. For a 1-GeV neutrino crossing the Earth (PREM profile), Magnus needs ~10 slabs plus the ~16 layer crossings, versus thousands of right-hand-side evaluations for
solve_ivp— measured: ~2 ms vs ~360–700 ms per probability at comparable accuracy. -
You scan over energy and/or direction (spectra, oscillograms, sensitivity studies). The Magnus kernel is built from fixed, data-independent matrix operations, so slabs — and, for the standard/NSI/LIV Hamiltonians, the entire energy axis — evaluate as batched NumPy/BLAS calls. Adaptive ODE integration is inherently sequential and cannot share steps across energies. Measured: a 200-energy Earth-crossing scan takes 76 ms (0.4 ms per energy); a 100×100 oscillogram takes ~2 s.
-
Unitarity matters more than raw local error — long baselines, small probabilities, CP/T asymmetries. Runge–Kutta iterates drift off the unitary manifold (probability leaks of ~10⁻⁶ at typical tolerances, growing with baseline); the Magnus route has no leakage to leak, ever (rows sum to 1 to ~10⁻¹⁴).
-
You want arbitrary physics with no per-model work: any number of flavors, any Hermitian Hamiltonian — sterile neutrinos, NSI, Lorentz-invariance violation, or your own matrix function of energy and position.
When is it not the best tool? For a single probability at a single energy,
any method is fast enough. For extreme accumulated phases — e.g.,
~10-MeV neutrinos crossing most of the Sun (~10⁴ rad of matter-dominated
phase) — the plain Magnus slab-refinement method can need a very large slab
count, and warns (ToleranceNotAchievedWarning) instead of failing silently
if it hits its caps first. This regime is now handled automatically:
osc_prob_matter_std_potential,
osc_prob_matter_nsi, osc_prob_liv, and every wrapper built on them
(including all osc_prob_*_sun* functions) handle exactly this regime
automatically, via strategy='auto' (the default): see Adiabatic + Magnus
hybrid strategy
below. A tight-tolerance ODE solver remains the best reference for
validation regardless — Magnus's own test suite uses
scipy.integrate.solve_ivp at rtol=1e-12 as ground truth.
For a position-dependent Hamiltonian with no user-supplied slab edges, every
matter/NSI/LIV oscillation-probability function accepts a strategy keyword:
'auto' (default), 'hybrid', or 'magnus'.
'magnus'uses only the Magnus-expansion machinery described above — the exact behavior of Magnus as it was before the adiabatic strategy was added.'hybrid'additionally tries an adiabatic-transport-plus-Magnus-patch propagator (magnus.adiabatic.hybrid_propagator): away from an eigenvalue crossing of the instantaneous Hamiltonian, the evolution operator is computed via the instantaneous eigenbasis (a dynamical + geometric phase, cheap regardless of how large the accumulated phase is); near a genuine MSW resonance, a short, exact Magnus patch is stitched in via the exact composition law of quantum evolution. The result is exactly unitary regardless of the approximation's accuracy, and the whole computation is self-certified by tightening every internal tolerance knob until two successive results agree. Works for any number of flavors and any number of simultaneous or sequential resonances — not just the two-flavor case.'auto'tries'hybrid'first, silently falling back to'magnus'for any point where it does not apply or fails to self-certify.
import magnus.oscprob as oscprob
import magnus.globaldefs as gd
# 8 MeV, most of the way through the Sun: deep in the regime that used to
# need a very large slab count under strategy='magnus'.
P = oscprob.osc_prob_3nu_sun(8.0*gd.UNIT_MEV, 0.9*gd.SUN_RADIUS*gd.UNIT_KM, 0.0)
# strategy='auto' by default: warning-free, and matches solve_ivp to ~1e-4.See the full derivation, validation, and worked examples in the docs.
A neutrino from an astrophysical source arrives with an oscillation phase of
order 10¹⁵ — far beyond what the source distance, the production region, or the
detector's energy resolution pin down. Every oscillatory term is averaged away
by the measurement, and the probability collapses to the exact
Pass average=True to any oscillation-probability function to get it:
import magnus.oscprob as oscprob
import magnus.globaldefs as gd
osc = gd.load_nufit_params('NuFIT 6.1')
P = oscprob.osc_prob_3nu_vacuum(1.0*gd.UNIT_TEV, 1.0e8*gd.UNIT_KM,
average=True, **osc)This is not an approximation to be refined — it is the exact limit, and it costs one matrix product (~20 μs) instead of resolving 10¹⁵ radians of phase. For vacuum it does not depend on energy or baseline at all, so one matrix serves an entire flux calculation. Matter, NSI and LIV are covered too, and a position-dependent profile decoheres in the eigenbasis at production, is carried along the levels of the instantaneous Hamiltonian — with exact level-crossing probabilities where the evolution stops being adiabatic — and is read out at detection.
Whether the average applies is checked rather than assumed: a pair of eigenvalues whose relative phase is neither ≫ 2π nor ≪ 1 is in no valid limit, and the request warns instead of returning a number the physics does not support. Asking for an averaged probability at a 1000 km beamline does exactly that.
See the full derivation, diagram, and validation in the docs, and notebook 10 for worked examples across 2–5 flavors and a custom Hamiltonian.
Magnus solves the unitary Schrödinger equation for a Hermitian Hamiltonian: any truncation of the Magnus series lives in the Lie algebra, so the package is architecturally committed to norm-preserving, reversible evolution. That rules out several classes of problems that show up in neutrino phenomenology:
-
Quantum decoherence. Wave-packet separation, quantum-gravity-induced decoherence, or any model where coherence between mass eigenstates is damped over the baseline requires evolving a density matrix under a non-unitary master equation (e.g., Lindblad/GKSL), not a state vector under a Hamiltonian. Magnus has no dissipative term and cannot represent one.
-
Open-system coupling to a bath. Any scenario where the neutrino exchanges energy or phase information with an environment — collisional decoherence, thermal baths, stochastic scattering beyond the mean-field matter potential — needs a reduced density matrix with dissipators, which is again outside a Hermitian-Hamiltonian, pure-state framework.
-
Neutrino decay. Invisible or visible decay into lighter states removes probability from the system, so the evolution is no longer norm-preserving. A Hermitian effective Hamiltonian cannot encode a decay width — that requires an anti-Hermitian term, which breaks the unitarity the whole method relies on.
-
Self-consistent collective oscillations. Dense-environment (e.g., supernova) neutrino self-interactions, where the effective Hamiltonian depends on the (unknown, evolving) neutrino/antineutrino flavor content itself, are a nonlinear, self-consistent problem. Magnus assumes the Hamiltonian is a known function of energy and position supplied by the caller, not a functional of the solution.
If your problem needs any of the above, look instead at packages built around density-matrix/Lindblad evolution (for decoherence or decay) or dedicated collective-oscillation codes (for self-interaction problems).
The project structure separates the Magnus-expansion numerical core from the
oscillation-probability API, the physics modules (Hamiltonians, Earth/Sun
environments), documentation, and tests (file tree generated by running
git ls-files):
Magnus/
├── .github/
│ └── workflows/
│ ├── lint.yml # Ruff lint (blocking) + CLI-reference drift check
│ ├── notebooks.yml # Executes every notebook; paths-filtered, so docs-only changes skip it
│ ├── pages.yml # GitHub Pages deployment for the Sphinx documentation
│ ├── publish.yml # PyPI (OIDC) automated publishing workflow, on GitHub Release
│ └── tests.yml # GitHub Actions CI testing pipeline (Python 3.10-3.12) + coverage
├── .gitignore # Build, cache and generated-output artefacts
├── CHANGELOG.md # Version history (Keep a Changelog format)
├── LICENSE # GNU GPL v3 (GPL-3.0-only), the full license text
├── README.md # This file
├── docs/ # Sphinx documentation configuration and source
│ ├── Makefile # Build commands for Unix
│ ├── check_doc_snippets.py # Checks the code snippets quoted in the prose pages still run
│ ├── dev/
│ ├── make.bat # Build commands for Windows
│ ├── make_figures.py # Regenerates the data-driven SVG in source/_static/
│ ├── regen_cli_help.py # Regenerates the --help block quoted in source/cli.rst
│ ├── requirements.txt # Sphinx + theme + extensions needed to build the docs
│ └── source/
│ ├── _static/
│ │ ├── adiabatic_avoided_crossing.svg # Hand-authored: adiabatic against diabatic at a crossing
│ │ ├── adiabatic_segmentation.svg # Hand-authored: adiabatic / patch / adiabatic along the ray
│ │ ├── adiabatic_speedup.svg # Generated by docs/make_figures.py from the measured grid
│ │ ├── averaging_regimes.svg # Hand-authored: when averaging removes an error and when it does not
│ │ └── magnus_logo.png # Sidebar logo
│ ├── adiabatic_strategy.rst # The adiabatic + Magnus hybrid strategy: derivation, diagrams, validation
│ ├── api_reference.rst # Wraps the autoapi-generated module pages
│ ├── architecture.rst # The wrapper/middle/primordial layering, with diagrams
│ ├── averaged_probability.rst # Phase-averaged probabilities: derivation, diagram, validation
│ ├── changelog.rst # Renders the root CHANGELOG.md via myst-parser
│ ├── citing.rst # How to cite the software, and what to state in the text
│ ├── cli.rst # Command-line calculator: flag reference and examples
│ ├── conf.py # Sphinx build configuration (autoapi + napoleon + bibtex + mermaid + myst)
│ ├── expansion_terms.rst # The Omega_k terms to any order, and how they are generated
│ ├── functions.rst # Full osc_prob_{2,3,4,5}nu_* listing, grouped by environment/scenario
│ ├── implementation_details.rst # Engines, dispatch, every tuned constant and its population
│ ├── index.rst # Master documentation page: overview, features, when Magnus wins
│ ├── installation.rst # Requirements, install instructions, file tree
│ ├── methodology.rst # The Magnus expansion, integrators, and performance engineering
│ ├── plotting.rst # The pre-packaged plotting tools
│ ├── quickstart.rst # Worked Python-API code examples for every entry point
│ ├── recipes.rst # What Magnus can compute, with the code -- executed at build time
│ ├── references.rst # Bibliography page rendering
│ ├── refs.bib # BibTeX citations for the Magnus-expansion and PREM literature
│ └── tutorials.rst # Guide to the numbered example notebooks in notebooks/
├── fig/ # Plots produced by the example notebooks
├── img/ # Figures used by the documentation
│ └── gallery/ # Figures lifted from the executed notebooks, embedded in the docs
├── notebooks/ # Numbered Jupyter notebooks -- see docs/source/tutorials.rst
│ ├── 01_magnus_introduction.ipynb # The shortest path to a probability
│ ├── 02_magnus_2nu_vacuum_matter.ipynb # Two flavours, across seven matter profiles
│ ├── 03_magnus_3nu_vacuum_matter.ipynb # The same, with three flavours and a CP phase
│ ├── 04_magnus_long_baseline.ipynb # Between two points on the surface
│ ├── 05_magnus_biprobability.ipynb # The CP ellipse
│ ├── 06_magnus_oscillograms.ipynb # Zenith angle against energy, in one call
│ ├── 07_magnus_bsm_sterile_nu.ipynb # Four and five flavours
│ ├── 08_magnus_bsm_nsi.ipynb # Non-standard interactions
│ ├── 09_magnus_bsm_liv.ipynb # Lorentz-invariance violation
│ ├── 10_magnus_averaged_probability.ipynb # What survives when the phase is unresolvable
│ ├── 11_magnus_matrix_exponential.ipynb # How exp(Omega) is actually built
│ ├── 12_magnus_adiabatic_hybrid_strategy.ipynb # 'auto' against 'magnus', timed against solve_ivp
│ ├── 13_magnus_tabulated_solar_model.ipynb # A real BS05 profile: an error that is a phase
│ ├── 14_magnus_supernova_shock.ipynb # A shock front: an error that is an envelope
│ ├── 15_magnus_antineutrinos.ipynb # Conjugate and flip, and two ways to get it half right
│ ├── 16_magnus_exact_vs_approximations.ipynb # Where the textbook formulas are exact, and where the substitution breaks
│ ├── 17_magnus_ordering_and_octant.ipynb # The sign of D31, and how large the two open questions are
│ ├── 18_magnus_unusual_density_profiles.ipynb # Arrangement beats the mean, except for one exact symmetry
│ ├── 19_magnus_custom_hamiltonian.ipynb # The H_func contract, and the vectorisation trick
│ ├── 20_magnus_numerical_edge_cases.ipynb # Degeneracies that return numbers, and the nine warnings
│ ├── 21_magnus_what_tolerance_means.ipynb # rtol is a stopping criterion, not an error bound
│ ├── 22_magnus_which_engine_answered.ipynb # strategy_info, and an error bar with no oracle
│ ├── 23_magnus_when_averaging_helps.ipynb # Phase error falls away, envelope error does not
│ ├── 24_magnus_performance.ipynb # What is worth doing, and when each trick is worth nothing
│ ├── 25_magnus_against_other_codes.ipynb # Where a closed form wins, and a conventions trap that looks like accuracy
│ ├── 26_magnus_nufit_evolution.ipynb # How the NuFIT likelihood, not just the best fit, moves the probability
│ ├── README.md # This file
│ ├── make_notebooks.py # BUILDS the notebooks above -- edit this, not the .ipynb
│ ├── external_speed_accuracy.json # Five external codes' speed and accuracy (NuOscProbExact project)
│ ├── make_nufit_chi2.py # Extracts notebook 26's NuFIT chi^2 profiles
│ ├── make_shock_reference.py # Freezes notebook 14's solve_ivp oracle
│ ├── matplotlibrc # Shared plot styling for the notebooks
│ ├── nufit_chi2.json # Those profiles, v2.0-v6.1 (NuFIT collaboration)
│ └── shock_reference.json # That oracle, as exact hex floats
├── pyproject.toml # Build system, dependencies, and the `magnus` console-script entry point
├── src/
│ ├── magnus/ # Main Python package
│ │ ├── __init__.py # Explicit named imports from the four hamiltonians{2,3,4,5}nu.py modules
│ │ ├── __main__.py # Entry point for `python -m magnus`
│ │ ├── adiabatic.py # Adiabatic transport + Magnus-patch hybrid strategy (strategy='hybrid'/'auto')
│ │ ├── authors.py # Package author string (internal; not part of the public API)
│ │ ├── avgprob.py # Phase-averaged (decohered) probabilities
│ │ ├── cli.py # `magnus` command-line calculator (also `python -m magnus`)
│ │ ├── earth.py # PREM density profile, chord/zenith-angle geometry
│ │ ├── expansionterms.py # Generates the Omega_k terms symbolically, to any order
│ │ ├── expmkernels.py # Compiled Cayley-Hamilton matrix exponential for 2x2/3x3 (the numba backend)
│ │ ├── globaldefs.py # Units, physical constants, NuFit parameter sets
│ │ ├── hamiltonians/ # 2nu-5nu Hamiltonians: vacuum, matter, NSI, LIV (the one true subpackage)
│ │ │ ├── __init__.py # Explicit named imports from the four hamiltonians{2,3,4,5}nu.py modules
│ │ │ ├── hamiltonians2nu.py
│ │ │ ├── hamiltonians3nu.py
│ │ │ ├── hamiltonians4nu.py
│ │ │ └── hamiltonians5nu.py
│ │ ├── magnus.py # Magnus-expansion numerical core: term recursion, GL integrators, batched kernel
│ │ ├── matter.py # Density profiles, electron number density, CC potential
│ │ ├── oscprob.py # osc_prob and every physics-scenario wrapper (main API)
│ │ ├── oscprobstd.py # Closed-form 2nu/3nu probabilities (used to validate the wrapper API)
│ │ ├── plotting.py # Pre-packaged plotting tools: one call instead of thirty lines
│ │ └── version.py # Resolves the version from pyproject.toml (internal)
│ └── requirements.txt # Sphinx + theme + extensions needed to build the docs
└── tests/ # Test suite (pytest; runs in CI)
├── conftest.py # Path setup so magnus is importable without installation
├── test_adiabatic.py # Adiabatic + Magnus hybrid strategy: detection, merging, ODE cross-checks
├── test_avgprob.py # Phase-averaged probabilities
├── test_cli.py # magnus command-line calculator
├── test_earth_matter.py # PREM profile, chord geometry, electron density
├── test_engines.py # Which engine answers, and the cross-checks between them
├── test_expansionterms.py # The symbolic term generator against the hand-written orders
├── test_expm_backend.py # The two matrix-exponential backends, their switch, and degeneracies
├── test_fuzz_statistics.py # Randomised profiles, scored in bulk
├── test_file_tree.py # This file: generates the tree above and checks it against git
├── test_globaldefs.py # NuFit historical parameter dict/loader
├── test_hamiltonians.py # Hamiltonian/mixing-matrix builders
├── test_invariants.py # Properties that must hold across the whole engine matrix
├── test_magnus_expansion.py # Magnus-core correctness (terms, orders, GL rates, unitarity)
├── test_oscprob.py # Oscillation-probability engine, closed-form and ODE cross-checks
├── test_palindrome.py # The palindromic-profile optimisation and its gate
├── test_plotting.py # Pre-packaged plotting tools: house-style defaults, layouts
├── test_tolerance.py # What rtol/atol promise, and the effective-refinement gate
├── test_validation.py # Input-validation guards and their error messages
└── test_version.py # Version resolution from pyproject.toml / installed metadata
Magnus works both as an importable Python module (the full API —
arbitrary Hamiltonians, energy/direction scans, NSI, LIV, steriles) and as a
command-line calculator (magnus prob ... — one probability, no Python
required). Use the module for anything programmatic (scans, plots, fitting);
use the CLI for a quick one-off number, a shell script, or to sanity-check a
parameter choice.
Install it with pip install --pre magnuspy (the --pre is needed while the
current release is a release candidate; drop it once 1.0.0 final is out), or
pip install -e . from a checkout. The distribution is called magnuspy on
PyPI because plain magnus was already taken by an unrelated project, but the
import package is magnus, as below.
import numpy as np
import magnus.oscprob as oscprob
import magnus.globaldefs as gd
# --- Three-flavor vacuum probability at 1 GeV over 1000 km ---
energy = 1.0*gd.UNIT_GEV # [eV]
L = 1000.0*gd.UNIT_KM # [eV^-1]
P = oscprob.osc_prob_3nu_vacuum(energy, L) # 3x3 matrix, P[i][j] = P(nu_i -> nu_j)
# --- Energy scan through the Earth (PREM), nu_e -> nu_mu ---
energies = np.logspace(-0.3, 1.3, 200)*gd.UNIT_GEV
# integration_method defaults to 'gl' (Gauss-Legendre): fastest and most accurate
P_scan = oscprob.osc_prob_3nu_earth(
energies, costhz=-0.8, L=2.0*6371.0*0.8*gd.UNIT_KM,
nu_i=gd.NUE, nu_f=gd.NUMU)
# --- Your own Hamiltonian through the Earth ---
# H(energy, l, VCC): VCC is the PREM charged-current potential at position l
h_vac = ... # your energy-independent vacuum term [eV^2]
def H(energy, l, VCC):
return (1.0/energy)*h_vac + np.asarray(VCC)[..., None, None]*np.diag([1.0, 0.0, 0.0])
P = oscprob.osc_prob_earth(H, energy, loc_ini='fermilab', loc_fin='homestake')
# --- Fully generic: any Hamiltonian function of position ---
P = oscprob.osc_prob(H_func, t_ini=0.0, t_fin=L) # H_func(l) -> (d, d) arrayOscillation parameters default to the NuFit 6.0 best fit (normal ordering);
pass s12, D31, dCP, ..., or nubar=True, to change them. Find many
worked examples — vacuum, matter, Earth, Sun, oscillograms, biprobability
plots, steriles, NSI, LIV — in the Jupyter notebooks.
Installing the package also installs a magnus command
(equivalently, python -m magnus), for computing a single probability
without writing any Python. magnus prob --help lists every flag; the
full CLI reference documents
all of them. A few real examples (verified output, this version):
$ magnus prob --flavors 3 --environment vacuum \
--energy 1 --energy-unit GeV --baseline 1300 --baseline-unit km
Magnus 1.0.0rc1 -- osc_prob_3nu_vacuum
E = 1 GeV, L = 1300 km
nu_e nu_mu nu_tau
nu_e 0.9297 0.0085 0.0618
nu_mu 0.0311 0.3885 0.5804
nu_tau 0.0393 0.6029 0.3578$ magnus prob --flavors 3 --environment earth \
--energy 1 --energy-unit GeV --costhz -0.8 --baseline 10193.6 --baseline-unit km
Magnus 1.0.0rc1 -- osc_prob_3nu_earth
E = 1 GeV, L = 10193.6 km
nu_e nu_mu nu_tau
nu_e 0.9128 0.0863 0.0009
nu_mu 0.0629 0.6681 0.2690
nu_tau 0.0243 0.2456 0.7301A single channel (rather than the full matrix), and NSI/LIV/sterile flags, work the same way:
$ magnus prob --flavors 3 --environment vacuum --energy 1 --energy-unit GeV \
--baseline 1300 --baseline-unit km --nu-i e --nu-f mu
Magnus 1.0.0rc1 -- osc_prob_3nu_vacuum
E = 1 GeV, L = 1300 km
P = 0.0085
$ magnus prob --flavors 3 --environment matter --scenario nsi --rho 2.7 \
--eps-ee 0.06 --eps-em -0.06 \
--energy 1 --energy-unit GeV --baseline 1000 --baseline-unit km
Magnus 1.0.0rc1 -- osc_prob_3nu_matter_nsi_constant_density
E = 1 GeV, L = 1000 km
nu_e nu_mu nu_tau
nu_e 0.9898 0.0093 0.0009
nu_mu 0.0093 0.9906 0.0001
nu_tau 0.0009 0.0001 0.9990Pass --json for machine-readable output (e.g., to pipe into jq or another
script) instead of the table.
- Flavors: 2ν, 3ν, 4ν (3+1), 5ν (3+2) via dedicated wrappers; any number
of flavors via the generic
osc_prob. - Environments: vacuum, constant-density matter, exponentially falling density, the Earth (PREM, including chords between named detector sites), the Sun; or any density profile you supply.
- Beyond the Standard Model: non-standard interactions (NSI),
CPT-odd Lorentz-invariance violation (LIV), and — via
osc_prob,osc_prob_earth,osc_prob_sun— arbitrary user Hamiltonians. - Neutrinos and antineutrinos, single energies or arrays, full probability matrices or single channels.
Every combination of environment and scenario below has a dedicated,
explicitly-named osc_prob_{N}nu_... function for N in {2, 3, 4, 5}
(e.g. osc_prob_3nu_matter_nsi_constant_density) — see
the full listing with signatures
in the docs. For anything not covered here — any other number of flavors,
or a Hamiltonian this table doesn't anticipate — use the generic
osc_prob/osc_prob_earth/osc_prob_sun entry points directly (see
Quick start above).
| Environment | Scenario | Function pattern ({N} = 2, 3, 4, 5) |
|---|---|---|
| Vacuum | Standard | osc_prob_{N}nu_vacuum |
| Vacuum | LIV | osc_prob_{N}nu_vacuum_liv |
| Matter, constant density | Standard | osc_prob_{N}nu_matter_constant_density |
| Matter, constant density | NSI | osc_prob_{N}nu_matter_nsi_constant_density |
| Matter, constant density | LIV | osc_prob_{N}nu_matter_liv_constant_density |
| Matter, exponential density | Standard | osc_prob_{N}nu_matter_exp_density |
| Matter, exponential density | NSI | osc_prob_{N}nu_matter_nsi_exp_density |
| Matter, exponential density | LIV | osc_prob_{N}nu_matter_liv_exp_density |
| Earth (PREM) | Standard | osc_prob_{N}nu_earth |
| Earth (PREM) | NSI | osc_prob_{N}nu_earth_nsi |
| Earth (PREM) | LIV | osc_prob_{N}nu_earth_liv |
| Sun | Standard | osc_prob_{N}nu_sun |
| Sun | NSI | osc_prob_{N}nu_sun_nsi |
| Sun | LIV | osc_prob_{N}nu_sun_liv |
The command-line calculator exposes this
same table via --environment/--scenario/--flavors.
Magnus's oscillation-probability API (src/magnus/oscprob.py) is
organized as three layers, so that adding a new default or fixing a bug in
one place fixes it everywhere instead of needing to be copy-pasted across
dozens of functions:
- Wrapper layer (~60 functions, e.g.
osc_prob_3nu_earth,osc_prob_2nu_matter_nsi_constant_density) — one function per (flavor count) × (environment) × (BSM scenario) combination. Each just names its physics parameters explicitly (for a good IDE/autocomplete and docs experience) and repackages them for the layer below. - Middle layer (
osc_prob_vacuum,osc_prob_matter_std_potential,osc_prob_matter_nsi,osc_prob_liv) — one function per physics scenario, generic in the number of flavors. Builds the right Hamiltonian (fromhamiltonians{2,3,4,5}nu.py) and the matter potential (frommagnus.earth/magnus.matter), then calls down. - Primordial layer (
osc_prob_energy_baseline,osc_prob) — owns the adaptive slab refinement and the single call into the Magnus core (magnus.magnus).osc_probis also a fully public, generic entry point: pass it any Hamiltonian function of position and it works, no wrapper required.
flowchart TD
W["Wrapper layer<br/>osc_prob_3nu_earth, osc_prob_2nu_matter_nsi_constant_density, ..."]
M["Middle layer<br/>osc_prob_vacuum · osc_prob_matter_std_potential · osc_prob_matter_nsi · osc_prob_liv"]
P["Primordial layer<br/>osc_prob_energy_baseline → osc_prob"]
K["Magnus core (magnus.py)<br/>magnus_expansion_multislab"]
A["Adiabatic + Magnus hybrid (adiabatic.py)<br/>hybrid_propagator"]
H["Hamiltonians<br/>(hamiltonians2nu..5nu.py)"]
E["Earth / Sun / matter density<br/>(VCC_func)"]
U["Your own H_func(l)"]
W --> M --> P --> K
M -. "strategy='hybrid'/'auto'" .-> A
A -. local patches use .-> K
H --> M
E --> M
U -. bypasses the wrapper and middle layers .-> P
magnus.adiabatic is a self-contained sibling of magnus.magnus (it depends
only on the Magnus core, never on oscprob.py): the middle layer calls into
it directly, at the same point where it would otherwise hand off to the
primordial layer, whenever strategy allows it and the Hamiltonian is
position-dependent. See Adiabatic + Magnus hybrid
strategy
above and the dedicated docs
page for the
full derivation.
See Code architecture
in the full documentation for the naming conventions, the **kwargs
forwarding contract between layers, and a walkthrough of how to add your own
wrapper.
This section derives, in full, how Magnus computes the neutrino time-evolution operator and the oscillation probability from it.
A neutrino created in a flavor eigenstate
where
and, because
If
In general, though,
which is exact but numerically awkward: each term is a nested, time-ordered multi-dimensional integral, the series converges slowly, and, more importantly, truncating it at any finite order does not in general give a unitary operator, so naive truncation leaks probability.
The Magnus expansion resolves this by writing the exact solution as a
single, ordinary (non-ordered) exponential of an operator
Define
with
and similarly for src/magnus/magnus.py for the full,
tested expressions). This is not an approximation scheme in the usual sense
of a truncated Taylor series in a small parameter — it is an exact identity
order by order in the number of nested commutators, and it is this
structure, not the truncation itself, that guarantees unitarity (next
section).
This is the central practical guarantee of the method. Every
By induction on the nesting depth, and since a real linear combination of
anti-Hermitian matrices is anti-Hermitian, every
is exactly unitary:
This is qualitatively different from directly integrating the Schrödinger
equation with a generic ODE solver (e.g., Runge–Kutta): the local truncation
error of a generic step is not anti-Hermitian, so the numerically
propagated operator slowly drifts off the unitary manifold, and probability
visibly "leaks" (typically at the
The Magnus series converges absolutely whenever
a sufficient (not necessary) condition, with
and applies the truncated series independently inside each slab, choosing
enough slabs that the accumulated phase per slab stays comfortably under the
bound above (Magnus estimates this automatically to seed the slab count,
and emits MagnusConvergenceWarning if a slab is not comfortably inside the
guaranteed regime — raising the expansion order does not help there; more,
narrower slabs are needed instead).
Within slab
This ordering matters physically whenever consecutive slabs' Hamiltonians do not commute — e.g., an asymmetric matter-density profile together with a nonzero CP-violating phase — exactly as position-ordering mattered in the Dyson series of Section 2; it is the discrete, slab-wise realization of the same physical requirement.
Computing the nested integrals of Section 3 requires sampling
(a) Gauss–Legendre commutator-free integrators ('gl', the default). Following
Blanes, Casas & Ros (2000), orders 2, 4, and 6 can each be reached from only
1, 2, or 3 evaluations of
and, with 3 nodes, defining the intermediate combinations
Because it needs far fewer Hamiltonian evaluations for the same accuracy,
'gl' is simultaneously the fastest and the most accurate method whenever
the Hamiltonian is smooth within a slab — which slab edges aligned to
density discontinuities, e.g. at the PREM layer boundaries inside the Earth,
make the common case. That is why it is the default.
Note that 'gl' uses a fixed 1, 2, or 3 nodes per slab, so
n_tpts_per_slab plays no role for it: accuracy is set by the slab count
alone, and the adaptive refinement grows only n_slabs.
(b) Cumulative quadrature ('trapezoid', 'simpson'). Sample n_tpts_per_slab points spanning the slab and evaluate the
nested integrals via cumulative trapezoid or Simpson quadrature. Slower for
the same accuracy on a smooth profile, but fully general, and so the safer
choice if
Since the truncated
Besides being faster than a general (Padé-based) matrix exponential for
stacks of small matrices — it batches over slabs and, for scans, over
neutrino energies — this construction is unitary by definition:
A neutrino produced with flavor
In code, this is the convention P[i][j] = |U[j][i]|**2, i.e. the returned
probability matrix is
so every row of
For antineutrinos, Magnus builds the antineutrino Hamiltonian
- Magnus expansion to order 6, with the term recursion verified term-by-term against Blanes, Casas, Oteo & Ros, Phys. Rep. 470, 151 (2009).
- Three integration methods. The default,
'gl'— Gauss–Legendre commutator-free integrators of orders 2/4/6 that need only 1/2/3 Hamiltonian evaluations per slab (Blanes, Casas & Ros, BIT 40, 434 (2000)) — is both the fastest and the most accurate for a smooth-per-slab profile, which layer-aligned slabs make the common case.'trapezoid'and'simpson'(cumulative quadrature overn_tpts_per_slabpoints) remain available, and are the safer choice if the Hamiltonian has a kink or a discontinuity inside a slab. - Exactly unitary exponentials from the eigendecomposition of the (anti-Hermitian) Magnus operator, batched over slabs and energies.
- Magnus expansion terms at any order:
magnus.expansiontermsderives them from the Bernoulli recursion symbolically, in exact rational arithmetic, which is what verifies the hard-coded coefficients rather than taking them on trust. Orders 1–6 are written out inline, 7–10 generated. - Method-aware refinement caps:
max_n_slabsdefaults to the cap that suits the integration method (20000 for'gl', 2000 for the quadrature methods), since'gl'costs 1–3 Hamiltonian evaluations per slab against the others'n_tpts_per_slab. A shared cap made'gl'report non-convergence on problems it had in fact resolved more accurately than the quadrature methods managed within the same cap. - Adaptive refinement until two successive levels agree within
rtol/atol, with a phase-based starting slab count, warm starts across scan points, and an always-on warning if the refinement caps are hit before convergence. Note that is an agreement, not a bound on the error — the ladder never estimates the accuracy of what it returns. See whatrtol/atolactually control. - Slab edges aligned with the PREM layer boundaries, so the high-order quadrature never integrates across a density discontinuity.
- Silent vectorization: Hamiltonian and density-profile functions that accept position arrays are detected and used automatically (with a safe scalar fallback); profile evaluations are cached across repeated grids; for standard/NSI/LIV scans, the whole energy axis is batched through the kernel.
Measured on a laptop, 3ν through the Earth (PREM), default tolerance 10⁻³:
| Workload | Time |
|---|---|
| Single probability (1 GeV, cos θz = −0.8) | ~2 ms |
| 200-energy scan, one direction | 76 ms (0.4 ms/energy) |
| 100 × 100 oscillogram (energy × direction) | ~2 s |
Reference: solve_ivp DOP853, single probability, rtol 10⁻⁶ |
~360 ms |
Those figures assume the fast path. If you pass your own Hamiltonian to
osc_prob, the single largest factor under your control is whether it can be
evaluated for many positions at once.
The engine samples the Hamiltonian at every quadrature node of every slab —
often a few hundred positions for one probability, repeated at each level of
the adaptive refinement. It therefore tries a single vectorized call,
H_func(array_of_positions), and uses the result if it has the right shape and
agrees with a scalar spot-check. If that fails it falls back to a Python loop,
one call per position: correct, but measured 4.6× slower on a 3ν
exponential-density profile (7.8 ms → 1.7 ms per osc_prob call), with
bit-identical output.
# Slow: one position at a time
def H_func(l):
VCC = matter.VCC_func(l, num_density_e_func)
return (1.0/energy)*h_vac + hamiltonians.hamiltonian_3nu_matter(VCC)
# Fast: the same physics, all positions at once
e00 = np.diag([1.0, 0.0, 0.0])
def H_func(l):
l = np.asarray(l, dtype=float)
VCC = VCC_central*np.exp(-(l/gd.UNIT_KM)/l_scale) # an array
return (1.0/energy)*h_vac + VCC[..., None, None]*e00The [..., None, None] is the whole trick: it turns one potential per position
into a stack of matrices, so NumPy broadcasts instead of Python looping. Note
that this is a property of your function, not of osc_prob — the engine's own
inner loops are already vectorized.
Two things worth knowing:
- A Hamiltonian that ignores its argument (constant density) is detected separately and broadcast, so it is already fast and needs no change.
- Since 1.0.0 the fallback raises
magnus.magnus.ScalarHamiltonianWarningonce per session, naming the fix. Before that it was silent, which is why the slow path is easy to sit on indefinitely — the shipped example notebooks all did.
A chord through a spherically symmetric Earth meets every radius on the way in
and again on the way out, so its density profile reads the same from either
end. Slab j and slab n−1−j therefore see the same Hamiltonian, and the
engine evaluates your H_func on the first half of the chain only, deriving
the rest by reversal.
Because the saving is halved Hamiltonian evaluations and nothing else, it is
worth exactly what your Hamiltonian costs. Measured through osc_prob_earth
at cos θz = −0.9, 2 GeV:
Your H_func |
Speed-up |
|---|---|
| Plain PREM (a cheap density lookup) | 0.91× |
| Moderately expensive, per position | 1.41× |
| Expensive, per position | 1.67× |
| Expensive, 12- and 40-energy scan | 1.56×–1.64× |
So it pays when you bring your own non-trivial physics to an Earth chord, and costs about 10% when you do not. Turn it off with:
import magnus.magnus
magnus.magnus.USE_PALINDROME = FalseThree things worth knowing:
- Standard PREM energy scans are unaffected. They are answered by the batched separable engine, which already evaluates the profile once and shares it across every energy — the same saving, taken earlier.
- It moves Earth single-point results by up to 8.6×10⁻¹⁵ relative. The
mirrored slab's quadrature nodes are reached by a different floating-point
expression for the same real number, so this is inherent, not incidental.
USE_PALINDROME = Falsereproduces the previous numbers exactly. - Symmetry is declared by the Earth entry points, never guessed. It is a fact of chord geometry there. Detecting it for an arbitrary profile would require the very evaluations the optimisation skips, so there is deliberately no way to claim it for one — a monotonic profile wrongly declared symmetric would be wrong by ~0.3, silently.
Magnus ships the figures its own notebooks use, so a plot is one call rather than thirty lines of Matplotlib. Matplotlib is a dependency, so there is nothing extra to install.
import numpy as np
import magnus.globaldefs as gd
import magnus.oscprob as oscprob
from magnus import plotting
energies = np.linspace(0.5, 10.0, 120)*gd.UNIT_GEV
P = np.asarray(oscprob.osc_prob_3nu_vacuum(
energies, np.full(120, 1300.0*gd.UNIT_KM)))
fig, ax = plotting.plot_probability_vs_energy(
energies/gd.UNIT_GEV,
[{'y': P[:, 1, 0], 'label': plotting.prob_label(1, 0)}],
nu_i=1, nu_f=0)prob_label(1, 0) returns $P_{\nu_\mu \to \nu_e}$, so channel labels are
consistent without being retyped.
| Function | Shape it draws |
|---|---|
plot_curves |
curves against any swept variable, with an optional relative-error subpanel |
plot_probability_vs_energy, ..._vs_baseline |
presets over it, with labels, scales and tick spacings fixed |
plot_curves_stacked |
small multiples -- one panel per configuration down a shared abscissa |
plot_probability_with_profile |
a probability above the matter profile that produced it |
plot_probability_with_average |
instantaneous against phase-averaged |
plot_biprobability |
the CP ellipse, neutrino against antineutrino |
plot_oscillogram |
the two-dimensional map across zenith angle and energy |
The point is consistency rather than brevity: the panels of a stacked figure
must share limits, scales and tick spacings or the reader's comparison between
them is meaningless, and that is what drifts when each figure is built by hand.
House defaults live in HOUSE_FIGSIZE, HOUSE_LEGEND_KW and friends; every
function returns the fig/ax, so anything can still be overridden.
Full documentation: Pre-Packaged Plotting Tools.
The test suite (running in CI on Python 3.10–3.12) validates:
- Magnus terms against an independently coded Bernoulli-number recursion (orders 1–6, machine precision) and Gauss–Legendre convergence rates (measured error ratios 4/16/64 under slab halving);
- probabilities against closed-form expressions (2ν/3ν vacuum, 2ν
constant-density matter, for neutrinos and antineutrinos) and against
high-accuracy
solve_ivpintegrations (asymmetric profiles with complex Hamiltonians, full PREM Earth crossings); - unitarity, slab-ordering, channel conventions, vectorized-vs-scalar consistency, and the energy-batched scan against the per-point path.
Requested tolerances are targets for the difference between successive refinements — the standard adaptive heuristic — not strict global error bounds; in practice the default 10⁻³ setting delivers ~5 × 10⁻⁴ on Earth crossings (verified against 10⁻⁷-tolerance references).
Four GitHub Actions workflows run under .github/workflows/:
-
tests.yml— runs on every push, to any branch, and so on every commit whether or not it belongs to a pull request: GitHub attaches check runs to the commit, so a pull request displays the run its branch already triggered. (pull_requestis kept as a trigger, but its jobs run only for forks, whose branches never push here. Without that, a commit on a branch with an open PR was built twice, identically.) Runspytest tests/ -von a matrix of Python 3.10, 3.11, and 3.12. The suite (seetests/) covers:test_magnus_expansion.py— the Magnus term recursion against an independently coded Bernoulli-number implementation (orders 1–6), Gauss–Legendre convergence rates, exact unitarity of the resulting evolution operators, and the single-slab/multi-slab chaining logic.test_oscprob.py— the oscillation-probability engine: everyosc_prob_{2,3,4,5}nu_*wrapper runs and returns unitary probabilities (rows sum to 1) across vacuum, constant- and exponential-density matter, Earth, and Sun, in the standard, NSI and LIV scenarios alike — the standard wrappers and the BSM ones are swept by two companion tests, the second of which discovers its own list from the module, so a wrapper added later cannot quietly go unexercised; probabilities are cross-checked against closed-form expressions and againstscipy.integrate.solve_ivp; NSI/LIV parameters andnubarare checked to have a real, non-degenerate effect where physically expected; and two permanent structural guards ensure no wrapper re-declares the shared refinement kwargs in its own signature and thatnubarstays consistently exposed across all four flavor counts.test_earth_matter.py— the PREM density profile, chord/zenith-angle geometry through the Earth, and the matter-density-to-electron-number-density conversion.test_hamiltonians.py— the Hamiltonian and mixing-matrix builders: unitarity and fast/slow-path agreement of the 4×4 and 5×5 mixing matrices, exact reduction to the 3ν PMNS matrix when sterile mixing is off, NSI/LIV convention checks, the position-dependent_tdconvenience wrappers, and a sweep over everyhamiltonian_*name the package exports, each built once with complex off-diagonal couplings and checked for Hermiticity — the one invariant all of them share, and the one a dropped conjugation breaks.test_cli.py— themagnuscommand-line calculator: dispatch to the rightosc_prob_*function for every environment/scenario/flavor-count combination, JSON output matching the equivalent direct Python call, and the CLI's own input-validation error paths (e.g. rejecting--scenario nsiwith--environment vacuum).test_globaldefs.py— the historical NuFit parameter data: every release inNUFIT_GLOBAL_FITShas finite, physically sensible values with the correctD31sign per ordering,load_nufit_params's version/ordering/category validation and default selection, that its output feeds directly intoosc_prob_3nu_vacuumto produce a unitary probability matrix, and thatset_color_outputround-trips so ANSI escapes can be kept out of logs and rendered docs.test_adiabatic.py— the adiabatic + Magnus-patch hybrid strategy: resonance detection, window merging, unitarity, agreement withscipy.integrate.solve_ivpon real 3ν solar Hamiltonians, and the four ways the propagator can refuse to certify its own answer (saturated knobs, exhausted iterations, and a non-converged patch on either the first or a later evaluation).test_expansionterms.py— the symbolic generation of Magnus expansion terms in exact rational arithmetic, checked against the numerical core's hand-written coefficients.test_plotting.py— the pre-packaged figures: that the house-style defaults survived the move out of the notebooks (legend keywords, figure size, tick spacings), that the multi-panel layouts stay aligned and suppress the right tick labels, that a misspelled keyword raises instead of being swallowed. Assertions are on the returnedfig/axobjects rather than on pixels.test_invariants.py— the properties that must hold across the whole engine matrix, swept over families, flavour counts, energies and slab counts rather than spot-checked.test_engines.py— which engine answers a given request, and the cross-checks between engines: agreement is informative only between engines that do not share machinery.test_avgprob.py— phase-averaged probabilities against their closed forms, and the regimes where averaging does and does not remove an error.test_palindrome.py— the palindromic-profile optimisation: the exact-equality predicate, the gate that stops it being applied where it was not meant, and the unpaired middle slab of an odd chain.test_tolerance.py— whatrtol/atolactually promise, and the effective-refinement gate that stops the ladder certifying an agreement between two nearly identical grids.test_fuzz_statistics.py— randomised profiles scored in bulk, to catch what a curated population does not.test_file_tree.py— generates the file tree above and ininstallation.rst, and fails if either has drifted fromgit ls-files.test_version.py— version resolution by both routes, the installed distribution's metadata and a direct read ofpyproject.toml, which must agree; the distribution ismagnuspywhile the import package ismagnus, and querying the wrong one reports0.0.0+unknownto installed users while a source checkout looks fine.test_validation.py— the input-validation guards: incomplete or malformed parameter dictionaries per flavor count, out-of-range flavor indices and expansion orders, mismatched energy/baseline arrays, negative densities and composition ratios, and the Earth entry point's location/zenith-angle/baseline combinations. Each asserts that bad input is refused as aValueErrornaming the parameter at fault, rather than as whatever the interpreter happened to raise further downstream.
The same workflow also has a separate Coverage job: it runs the suite once more under
pytest --cov, prints the per-module statement and branch coverage on the run's summary page, and uploadscoverage.xmlas a build artifact. It fails below 90% — a floor rather than a target, two points under the current 92% so that it catches a regression (an untested module added, a test file deleted) without tripping on the fraction of a percent that moves between interpreters. The floor lives inpyproject.toml, so a localpytest --covgates identically; raise it as coverage climbs, and never lower it to turn a red build green. The job is its own rather than a fourth entry in the matrix because coverage does not depend on the interpreter, so measuring it three times would only pay the instrumentation cost three times.It also carries a Codecov upload step that is dormant: it is skipped unless a
CODECOV_TOKENrepository secret exists, so nothing is sent anywhere today. Creating that secret is the whole act of switching public coverage reporting on, which is deferred until the repository goes public. -
lint.yml(named "Code Quality") — three jobs, on the same triggers astests.ymlabove.ruff checkis blocking: a new finding fails the build rather than being reported into a green checkmark, and the rule selection is pinned in[tool.ruff.lint]rather than left to whichever ruff version CI installs.ruff format --checkstays informational (continue-on-error: true), since the codebase predatesruff formatand reformatting it wholesale would bury real changes in diff noise. The other two jobs check thatdocs/source/cli.rststill matches the actual argument parser, and that the documentation builds with-W, so a broken cross-reference or a page dropped from the toctree fails the PR. -
pages.yml— on every push tomain, builds the Sphinx documentation (docs/) and deploys it to GitHub Pages. Several API docstrings (e.g.osc_prob_3nu_vacuum,osc_prob_earth) contain livejupyter-executecode examples: the build actually runs them and embeds the real output, so the docs build itself fails if an example ever stops working, instead of silently shipping stale output. -
publish.yml— on every published GitHub Release, builds the sdist/wheel and publishes to PyPI via trusted (OIDC) publishing.
numpy, scipy (>= 1.9), joblib — see
src/requirements.txt.
matplotlib is among them too, so
magnus.plotting -- the pre-packaged plotting tools --
works in any installation with nothing further to install.
Run the tests with:
pip install -e '.[test]' && pytest tests/(tests/conftest.py puts src/ on the path, so pip install -r src/requirements.txt pytest works too; installing the package is what CI does,
and it additionally exercises the magnus console script.)
The test extra also pulls in pytest-cov, so the same suite can report what
it covers:
pytest tests/ --cov --cov-report=term-missingBranch coverage, the measured source tree and the omitted files are configured
in [tool.coverage.run] in pyproject.toml, so a bare --cov measures the
same thing locally and in CI. Branch coverage is on deliberately: line coverage
alone flatters this package, because oscprob.py is mostly thin wrappers that a
single parametrized test sweeps in one pass, and the interesting question is
whether the dispatch chain, the refinement caps and the warning paths are each
taken in both directions. Expect the suite to run measurably slower under
instrumentation.
The documentation's code examples are executed for real at build time, so a broken one fails CI — but only in the full Sphinx build, which takes minutes. To check them directly, in about a second per page:
python3 docs/check_doc_snippets.py --rst-onlyIt runs every .. jupyter-execute:: block, in the RST pages and in the
docstrings autoapi renders. Drop --rst-only to include the docstrings, which
is thorough but as slow as the examples themselves. This exists because the
fast documentation build stubs those blocks out: it validates the prose and the
cross-references while saying nothing about the code, so a page can build
cleanly and still be broken.
See CHANGELOG.md (also rendered in the docs) for a version-by-version history of what changed and why.
If you use Magnus in your academic work or scientific publications, please cite it and link to the source repository:
Mauricio Bustamante (2026). Magnus: neutrino oscillation probabilities via the Magnus expansion. GitHub Repository: https://github.com/mbustama/Magnus.
Methodology References:
- Sergio Blanes, Fernando Casas, José A. Oteo & José Ros (2009). The Magnus expansion and some of its applications. Physics Reports, 470(5-6), 151-238. doi:10.1016/j.physrep.2008.11.001.
- Sergio Blanes, Fernando Casas & Javier Ros (2000). Improved high order integrators based on the Magnus expansion. BIT Numerical Mathematics, 40(3), 434-450. doi:10.1023/A:1022311628317.
- Adam M. Dziewonski & Don L. Anderson (1981). Preliminary reference Earth model. Physics of the Earth and Planetary Interiors, 25(4), 297-356. doi:10.1016/0031-9201(81)90046-7.
Magnus is released under the GNU General Public License v3.0 only
(GPL-3.0-only). The full text is in LICENSE.
In short: you are free to use, study, modify, and redistribute it, including for commercial purposes, provided that derivative works are distributed under the same license and with source available. If you are unsure whether your intended use is compatible, read the license itself rather than this summary.
Mauricio Bustamante