IceCube-Gen2 Collaboration and JUNO collaboration members · Phys. Rev. D 101, 032006 (2020)
Combined sensitivity to the neutrino mass ordering with JUNO, the IceCube Upgrade, and PINGU
The combined mass-ordering sensitivity of JUNO (8 reactor cores) and the IceCube Upgrade is computed from Asimov data
for 1–6 years of livetime with the Newtrinos modules juno and ic_upgrade. The wrong-ordering hypothesis is profiled
over the oscillation and nuisance parameters, with the paper's NuFIT 4.0 ranges. The combination comes out close to
the paper: 6.6σ (NO) and 5.6σ (IO) after 6 years, against 6.5σ and 5.1σ. However, the Upgrade on its own is far more
sensitive out of the box than in the paper (5.5σ / 4.4σ against 3.8σ / 1.8σ), while the boost from the combination
is much smaller. JUNO alone comes out slightly stronger: 2.7σ / 2.9σ against 2.4σ / 2.5σ. As an optional variant,
the Upgrade with Spray oscillation probabilities (15 % energy smearing) reproduces the paper's Upgrade-only NO
sensitivity (3.81σ) but not its IO value (2.5σ), and then gives a weaker combination than the paper: 5.4σ / 4.3σ.
Figures
Mass-ordering sensitivity vs. livetime (JUNO 8 cores + IceCube Upgrade) · Fig. 6 (panels with JUNO (8 cores) and the IceCube Upgrade) and Table V
Original figure: IceCube-Gen2 Collaboration and JUNO collaboration members, Phys. Rev. D 101, 032006 (2020), Fig. 6 (panels with JUNO (8 cores) and the IceCube Upgrade) and Table V, doi:10.1103/PhysRevD.101.032006. Not our work; shown for comparison, all rights with the authors and publisher.

Known limitations
- Out of the box, the Upgrade module (point-like oscillation probabilities at the true energies of the public MC) resolves the fast atmospheric oscillations far better than the paper's simulation did. That gives an optimistic Upgrade-only sensitivity. Restricting the fit ranges to those of the paper lowers it only to 4.5σ / 3.5σ. Smearing the oscillation probabilities with Spray at 15 % in energy brings NO to the paper's value, but 3 % smearing changes nothing, so this is not a sampling artefact of the probabilities.
- With the optimistic Upgrade the combination happens to match the paper, but for the wrong reason: the gain over the simple sum of Δχ² is about 0.5σ here against about 2σ in the paper. The paper's boost comes from the tension between the |Δm²₃₁| preferred by each experiment in the wrong ordering. Our Upgrade already pins the ordering on its own, which leaves little room for that boost. With Spray, the combination falls 1σ short of the paper.
- The public Upgrade sample (the ICRC2019 release) is not stated to be the simulation the paper used, and its systematics differ: Newtrinos has an energy scale N(1, 0.10), a livetime N(T, 0.1T) and a 0.05 spectral index; the up/down, up/horizontal, NC and ντ normalisations are fixed.
- JUNO: the module models the current JUNO configuration, rescaled to 300 days per year as in the paper. The remaining 10–15 % difference in significance is not investigated.
- The paper's PINGU panels are not reproduced (no public PINGU sample).
Reproduce
git clone https://github.com/Newtrinos-org/Newtrinos.jl.git && cd Newtrinos.jl && git checkout 58f30fb8dc- Download reproduce.jl and the
data/folder of this entry into an empty directory. julia -t 8 --project=/path/to/Newtrinos.jl reproduce.jl
# Combined neutrino-mass-ordering sensitivity of JUNO and the IceCube Upgrade with Newtrinos.jl (modules `juno` with
# its 8-core reactor configuration and `ic_upgrade`, built on the public IceCube Upgrade MC release), compared with
# IceCube-Gen2 & JUNO members, Phys. Rev. D 101, 032006 (2020), Table V and the livetime figure (JUNO 8 cores + Upgrade).
# The paper's curves are extracted from the PGF figure of the arXiv source (data/extract_livetime.py).
# Run from this directory with the Newtrinos.jl environment at the pinned commit:
# julia -t 16 --project=<path to Newtrinos.jl> reproduce.jl
using Newtrinos, Distributions, DataStructures, FileIO, CairoMakie, CSV, DataFrames, Printf, BAT
# up/horizontal flux parameter of the Barr model (spelled "uphorizonzal" before Newtrinos 2026-10-12)
const UPHOR = :atm_flux_uphorizontal_sigma in keys(Newtrinos.atm_flux.get_params(Newtrinos.atm_flux.Barr())) ?
:atm_flux_uphorizontal_sigma : :atm_flux_uphorizonzal_sigma
using BAT: distprod
cd(@__DIR__); mkpath("ours"); mkpath("results"); mkpath("cache")
# true parameters (Table III of the paper, NuFIT 4.0): δCP = 0 and Δm²₂₁ fixed
TRUE = Dict(:NO => (Δm²₃₁ = 2.525e-3, θ₁₂ = deg2rad(33.82), θ₁₃ = deg2rad(8.61), θ₂₃ = deg2rad(49.6), δCP = 0.0, Δm²₂₁ = 7.39e-5),
:IO => (Δm²₃₁ = -2.438e-3, θ₁₂ = deg2rad(33.82), θ₁₃ = deg2rad(8.65), θ₂₃ = deg2rad(49.8), δCP = 0.0, Δm²₂₁ = 7.39e-5))
LIVETIMES = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0]
# experiments for a livetime T [years]: JUNO with 300 days per year (6 years = 1800 days, as in the paper),
# the Upgrade with 365.25 days per year (its livetime parameter, in years)
const O = Newtrinos.osc
const UPGRADE = Newtrinos.ic_upgrade.configure() # module default (point-like oscillation probabilities)
# optional: ray-to-spray averaging of the Upgrade oscillation probabilities with the default 15 % (Gaussian) width
const UPGRADE_SPRAY = Newtrinos.ic_upgrade.configure(merge(Newtrinos.ic_upgrade.default_physics(),
(osc = O.configure(O.OscillationConfig(interaction = O.SI(), propagation = O.Spray())),)))
function experiments(T, which; spray = false)
e = OrderedDict{Symbol, Any}()
which in (:juno, :combined) && (e[:juno] = Newtrinos.juno.configure(; livetime_years = T * 300 / 365))
which in (:upgrade, :combined) && (e[:ic_upgrade] = spray ? UPGRADE_SPRAY : UPGRADE)
NamedTuple(e)
end
# nuisance model. Upgrade, as in the paper's Table II: spectral index 5 %, energy scale 10 %, effective-area
# (normalisation) scale 10 %, flux ratios (Barr νe/νμ, ν/ν̄ uncertainties); no zenith-shape, NC or ντ nuisances.
# JUNO: the module's systematics. Oscillation: θ₁₂ and θ₂₃ free within the NuFIT 4.0 3σ ranges, θ₁₃ with a ±0.13°
# prior, Δm²₃₁ within ±3σ of the true value (true ordering) or of its mirror (wrong ordering).
function priors(exps, truth, T; wrong)
pr = Newtrinos.get_priors(exps)
s = sign(truth.Δm²₃₁) * (wrong ? -1 : 1)
dm = s > 0 ? Uniform(2.36e-3, 2.69e-3) : Uniform(-2.60e-3, -2.27e-3) # equal widths: priors cancel in Δχ²
osc = (Δm²₃₁ = dm, θ₁₂ = Uniform(deg2rad(31.61), deg2rad(36.27)), θ₁₃ = Truncated(Normal(truth.θ₁₃, deg2rad(0.13)), 0.1, 0.2),
θ₂₃ = Uniform(deg2rad(40.3), deg2rad(52.4)), δCP = 0.0, Δm²₂₁ = truth.Δm²₂₁)
pr = merge(pr, osc)
if haskey(exps, :ic_upgrade)
pr = merge(pr, (atm_flux_delta_spectral_index = Truncated(Normal(0, 0.05), -0.3, 0.3),
ic_upgrade_energy_scale = Truncated(Normal(1, 0.10), 0.5, 1.5),
ic_upgrade_lifetime = Truncated(Normal(T, 0.10T), 0.3T, 1.7T),
atm_flux_updown_sigma = 0.0, nc_norm = 1.0, nutau_cc_norm = 1.0), NamedTuple{(UPHOR,)}((0.0,)))
end
pr
end
function sensitivity(T, which, ord; spray = false, wo_start = nothing)
exps = experiments(T, which; spray)
truth = merge(Newtrinos.get_params(exps), TRUE[ord], haskey(exps, :ic_upgrade) ? (ic_upgrade_lifetime = T,) : (;))
lik = Newtrinos.generate_likelihood(exps, Newtrinos.generate_asimov_data(exps, truth))
fit(pr, start) = (r = Newtrinos.find_mle(lik, distprod(; pr...), start); (r[2], r[3])) # (log posterior, best fit)
lp_true = fit(priors(exps, truth, T; wrong = false), truth)[1]
# wrong ordering: several starting points (both θ₂₃ octants, |Δm²₃₁| shifted in both directions), or a given start
m = abs(truth.Δm²₃₁)
starts = wo_start === nothing ?
[merge(truth, (Δm²₃₁ = -sign(truth.Δm²₃₁) * dm, θ₂₃ = th)) for dm in (m - 0.13e-3, m - 0.035e-3, m + 0.035e-3, m + 0.13e-3)
for th in (deg2rad(42.0), truth.θ₂₃)] : [merge(truth, wo_start)]
fits = [fit(priors(exps, truth, T; wrong = true), s) for s in starts]
lp_wrong, best = fits[argmax(first.(fits))]
Δχ² = max(2 * (lp_true - lp_wrong), 0.0)
(livetime = T, experiment = String(which), true_ordering = String(ord), spray = spray, dchi2 = Δχ², significance = sqrt(Δχ²),
wo_dm31 = best.Δm²₃₁, wo_theta23_deg = rad2deg(best.θ₂₃), wo_best = best)
end
# each task is checkpointed in cache/ (a rerun at the same Newtrinos commit resumes from there)
function cached(f, name)
file = "cache/$name.jld2"
isfile(file) && return FileIO.load(file, "r")
r = f(); FileIO.save(file, "r", r); r
end
tasks = [(T, w, o) for T in LIVETIMES for w in (:juno, :upgrade, :combined) for o in (:NO, :IO)]
# expensive tasks first, so that the threads stay busy until the end
sort!(tasks, by = t -> (t[2] == :juno, -t[1]))
out = Vector{Any}(undef, length(tasks))
Threads.@threads :dynamic for i in eachindex(tasks)
T, w, o = tasks[i]
out[i] = cached(() -> sensitivity(T, w, o), "task_$(w)_$(o)_$(Int(T))y")
@info "done" T w o out[i].significance
end
# optional Spray (15 %) points at 6 years for the Upgrade and the combination, wrong-ordering fit started from the
# best fit of the default calculation (Spray is ~20x slower)
sp_tasks = [(w, o) for w in (:upgrade, :combined) for o in (:NO, :IO)]
sp = Vector{Any}(undef, length(sp_tasks))
Threads.@threads :dynamic for i in eachindex(sp_tasks)
w, o = sp_tasks[i]
b = only(r for r in out if r.livetime == 6.0 && r.experiment == String(w) && r.true_ordering == String(o))
sp[i] = cached(() -> sensitivity(6.0, w, o; spray = true, wo_start = b.wo_best), "task_spray_$(w)_$(o)_6y")
@info "done (spray)" w o sp[i].significance
end
tab = select(DataFrame(vcat(out, sp)), Not(:wo_best))
sort!(tab, [:spray, :true_ordering, :experiment, :livetime])
# simple (quadratic) sum of the stand-alone sensitivities, as in the paper
for o in ("NO", "IO"), T in LIVETIMES
sel(w) = tab[(tab.true_ordering .== o) .& (tab.experiment .== w) .& (tab.livetime .== T) .& .!tab.spray, :dchi2][1]
push!(tab, (livetime = T, experiment = "simple_sum", true_ordering = o, spray = false, dchi2 = sel("juno") + sel("upgrade"),
significance = sqrt(sel("juno") + sel("upgrade")), wo_dm31 = NaN, wo_theta23_deg = NaN))
end
CSV.write("results/nmo_sensitivity_vs_livetime.csv", tab)
# ---- figure: significance vs livetime, paper (thick, transparent) and Newtrinos (thin, markers)
paper = CSV.read("data/livetime_nmo_sensitivity.csv", DataFrame)
COL = Dict("upgrade" => :royalblue, "juno" => :black, "combined" => :orangered, "simple_sum" => :darkorange)
PCOL = Dict("upgrade" => "upgrade", "juno" => "juno_8cores", "combined" => "combined", "simple_sum" => "simple_sum")
fig = Figure(size = (1000, 430))
for (k, o) in enumerate(("NO", "IO"))
ax = Axis(fig[1, k], xlabel = "livetime (years)", ylabel = k == 1 ? "significance (σ)" : "", title = "true $o",
limits = (0.5, 6.5, 0, 8))
hlines!(ax, [3, 5], color = (:gray, 0.4), linestyle = :dash)
for c in ("juno", "upgrade", "simple_sum", "combined")
p = paper[(paper.true_ordering .== o) .& (paper.curve .== PCOL[c]), :]
lines!(ax, p.livetime_years, p.significance_sigma, color = (COL[c], 0.35), linewidth = 5,
linestyle = c == "simple_sum" ? :dash : :solid)
n = tab[(tab.true_ordering .== o) .& (tab.experiment .== c) .& .!tab.spray, :]
scatterlines!(ax, n.livetime, n.significance, color = COL[c], linewidth = 1.5, markersize = 7,
linestyle = c == "simple_sum" ? :dash : :solid)
s = tab[(tab.true_ordering .== o) .& (tab.experiment .== c) .& tab.spray, :]
nrow(s) > 0 && scatter!(ax, s.livetime, s.significance, color = COL[c], marker = :star5, markersize = 16,
strokecolor = :white, strokewidth = 0.8)
end
if k == 1
axislegend(ax, [LineElement(color = (:gray, 0.5), linewidth = 5), MarkerElement(color = :gray, marker = :circle),
MarkerElement(color = :gray, marker = :star5, markersize = 14),
LineElement(color = :orangered), LineElement(color = :darkorange, linestyle = :dash),
LineElement(color = :royalblue), LineElement(color = :black)],
["paper (PRD 101, 032006)", "Newtrinos", "Newtrinos, Upgrade with Spray (15 %), optional", "combined", "simple sum", "IceCube Upgrade", "JUNO (8 cores)"],
position = :lt, framevisible = false, labelsize = 11)
end
end
save("ours/fig_livetime.png", fig)
six = tab[tab.livetime .== 6.0, :]
show(stdout, six[:, [:spray, :true_ordering, :experiment, :significance, :wo_dm31, :wo_theta23_deg]], allrows = true); println()
Provenance
| Newtrinos.jl commit | 58f30fb8dc (juno, ic_upgrade, osc (SI, Vacuum, Spray), atm_flux, earth_layers, xsec) |
|---|---|
| Run date | 2026-10-05T15:14:25Z |
| Wall time | 1027.0 min (16 threads) |
| Julia | 1.13.0 |
| Machine | 13th Gen Intel(R) Core(TM) i7-13700H, 20 hardware threads, Linux |
| Environment | newtrinos_Manifest.toml · run.toml |
Downloads
CSV files hold one row per scan point: the scanned and profiled parameters
(sin2_theta23, dm2_32 in eV², dcp in rad, nuisance parameters), dchi2 = −2Δ log L
and the log-likelihood. JLD2 files contain the full NewtrinosResult objects.
| File | Size | SHA-256 |
|---|---|---|
| ours/fig_livetime.png | 196.8 kB | 2748e2650cb7… |
| results/nmo_sensitivity_vs_livetime.csv | 4.5 kB | 80cebbd03cd7… |