nu-waves 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nu_waves/__init__.py +4 -0
- nu_waves/backend.py +15 -0
- nu_waves/backends/__init__.py +3 -0
- nu_waves/backends/interface.py +21 -0
- nu_waves/backends/numpy_backend.py +104 -0
- nu_waves/backends/torch_backend.py +224 -0
- nu_waves/hamiltonian/base.py +91 -0
- nu_waves/matter/prem.py +197 -0
- nu_waves/matter/profile.py +45 -0
- nu_waves/matter/solar.py +97 -0
- nu_waves/models/mixing.py +89 -0
- nu_waves/models/spectrum.py +229 -0
- nu_waves/propagation/oscillator.py +352 -0
- nu_waves/py.typed.py +0 -0
- nu_waves/utils/flavors.py +4 -0
- nu_waves/utils/style.py +12 -0
- nu_waves/utils/units.py +4 -0
- nu_waves/version.py +2 -0
- nu_waves-1.0.0.dist-info/METADATA +134 -0
- nu_waves-1.0.0.dist-info/RECORD +22 -0
- nu_waves-1.0.0.dist-info/WHEEL +4 -0
- nu_waves-1.0.0.dist-info/licenses/LICENSE +504 -0
nu_waves/__init__.py
ADDED
nu_waves/backend.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
def pick_backend(prefer: str | None = None):
|
|
2
|
+
if prefer == "torch":
|
|
3
|
+
try:
|
|
4
|
+
import torch as xp # type: ignore
|
|
5
|
+
return xp, "torch"
|
|
6
|
+
except Exception:
|
|
7
|
+
pass
|
|
8
|
+
if prefer == "cupy":
|
|
9
|
+
try:
|
|
10
|
+
import cupy as xp # type: ignore
|
|
11
|
+
return xp, "cupy"
|
|
12
|
+
except Exception:
|
|
13
|
+
pass
|
|
14
|
+
import numpy as xp
|
|
15
|
+
return xp, "numpy"
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Minimal array-backend interface used by nu_waves numerics.
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class ArrayBackend:
|
|
8
|
+
"""
|
|
9
|
+
Thin wrapper around a numeric library (NumPy for now; Torch/JAX later).
|
|
10
|
+
"""
|
|
11
|
+
xp: any # array namespace (np or torch)
|
|
12
|
+
linalg: any # linalg namespace (np.linalg or torch.linalg)
|
|
13
|
+
rng: any # random generator (np.random.Generator-like)
|
|
14
|
+
dtype_real: any # default real dtype
|
|
15
|
+
dtype_complex: any # default complex dtype
|
|
16
|
+
|
|
17
|
+
def to_device(self, x): # no-op for NumPy; overridden in GPU backends
|
|
18
|
+
return x
|
|
19
|
+
|
|
20
|
+
def from_device(self, x): # no-op for NumPy; overridden in GPU backends
|
|
21
|
+
return x
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
|
|
3
|
+
class _NumpyXP:
|
|
4
|
+
def __init__(self):
|
|
5
|
+
# allow x[..., xp.newaxis] in user code
|
|
6
|
+
self.newaxis = np.newaxis
|
|
7
|
+
|
|
8
|
+
def __getattr__(self, name):
|
|
9
|
+
# fallback to numpy for anything we didn't wrap explicitly
|
|
10
|
+
if hasattr(np, name):
|
|
11
|
+
return getattr(np, name)
|
|
12
|
+
raise AttributeError(f"_NumpyXP has no attribute {name}")
|
|
13
|
+
|
|
14
|
+
# common helpers used by the codebase
|
|
15
|
+
def normal(self, loc=0.0, scale=1.0, size=None):
|
|
16
|
+
return np.random.normal(loc=loc, scale=scale, size=size)
|
|
17
|
+
|
|
18
|
+
def uniform(self, low=0.0, high=1.0, size=None):
|
|
19
|
+
return np.random.uniform(low=low, high=high, size=size)
|
|
20
|
+
|
|
21
|
+
def random(self, size=None):
|
|
22
|
+
return np.random.random(size=size)
|
|
23
|
+
|
|
24
|
+
def asarray(self, x, dtype=None):
|
|
25
|
+
return np.asarray(x, dtype=dtype)
|
|
26
|
+
|
|
27
|
+
def repeat_last(self, x, n):
|
|
28
|
+
return np.repeat(x[..., np.newaxis], n, axis=-1)
|
|
29
|
+
|
|
30
|
+
def isscalar(self, x):
|
|
31
|
+
return np.isscalar(x)
|
|
32
|
+
|
|
33
|
+
def stack(self, arrays, axis=0):
|
|
34
|
+
return np.stack(arrays, axis=axis)
|
|
35
|
+
|
|
36
|
+
def argmax(self, x, axis=None):
|
|
37
|
+
return np.argmax(x, axis=axis)
|
|
38
|
+
|
|
39
|
+
def angle(self, z):
|
|
40
|
+
return np.angle(z)
|
|
41
|
+
|
|
42
|
+
def eye(self, n, dtype=None):
|
|
43
|
+
return np.eye(n, dtype=dtype)
|
|
44
|
+
|
|
45
|
+
def abs(self, x):
|
|
46
|
+
return np.abs(x)
|
|
47
|
+
|
|
48
|
+
def conj(self, x):
|
|
49
|
+
return np.conjugate(x)
|
|
50
|
+
|
|
51
|
+
def conjugate(self, x):
|
|
52
|
+
return np.conjugate(x)
|
|
53
|
+
|
|
54
|
+
def exp(self, x):
|
|
55
|
+
return np.exp(x)
|
|
56
|
+
|
|
57
|
+
def einsum(self, subs, *ops):
|
|
58
|
+
return np.einsum(subs, *ops)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class _NumpyLinalg:
|
|
62
|
+
def eigh(self, A):
|
|
63
|
+
# works for (N,N) or (...,N,N)
|
|
64
|
+
return np.linalg.eigh(A)
|
|
65
|
+
|
|
66
|
+
def matrix_exp(self, A):
|
|
67
|
+
"""
|
|
68
|
+
Stable numpy implementation without SciPy: eig-based exp.
|
|
69
|
+
Supports (N,N) or (...,N,N).
|
|
70
|
+
"""
|
|
71
|
+
A = np.asarray(A)
|
|
72
|
+
if A.ndim == 2:
|
|
73
|
+
w, V = np.linalg.eig(A)
|
|
74
|
+
Vinv = np.linalg.inv(V)
|
|
75
|
+
return (V * np.exp(w)[np.newaxis, :]) @ Vinv
|
|
76
|
+
|
|
77
|
+
# batched
|
|
78
|
+
*b, n, _ = A.shape
|
|
79
|
+
A2 = A.reshape((-1, n, n))
|
|
80
|
+
out = np.empty_like(A2, dtype=A.dtype)
|
|
81
|
+
for i in range(A2.shape[0]):
|
|
82
|
+
w, V = np.linalg.eig(A2[i])
|
|
83
|
+
Vinv = np.linalg.inv(V)
|
|
84
|
+
out[i] = (V * np.exp(w)[np.newaxis, :]) @ Vinv
|
|
85
|
+
return out.reshape((*b, n, n))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def make_numpy_backend(seed=0):
|
|
89
|
+
class Backend: ...
|
|
90
|
+
backend = Backend()
|
|
91
|
+
backend.device = "cpu"
|
|
92
|
+
backend.dtype_real = np.float64
|
|
93
|
+
backend.dtype_complex = np.complex128
|
|
94
|
+
backend.xp = _NumpyXP()
|
|
95
|
+
backend.linalg = _NumpyLinalg()
|
|
96
|
+
backend.from_device = lambda x: np.asarray(x)
|
|
97
|
+
|
|
98
|
+
def to_device(x): return x
|
|
99
|
+
def from_device(x): return x
|
|
100
|
+
|
|
101
|
+
backend.to_device = to_device
|
|
102
|
+
backend.from_device = from_device
|
|
103
|
+
|
|
104
|
+
return backend
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
# torch_backend.py
|
|
2
|
+
import torch
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
def _map_dtype_torch(requested, dtype_real, dtype_complex):
|
|
6
|
+
if requested is None:
|
|
7
|
+
return dtype_real
|
|
8
|
+
# ---- integers (FIX) ----
|
|
9
|
+
if requested in (int, np.int64, torch.int64):
|
|
10
|
+
return torch.int64
|
|
11
|
+
if requested in (np.int32, torch.int32):
|
|
12
|
+
return torch.int32
|
|
13
|
+
# ---- floats ----
|
|
14
|
+
if requested in (float, np.float32, torch.float32):
|
|
15
|
+
return torch.float32
|
|
16
|
+
if requested in (np.float64, torch.float64):
|
|
17
|
+
return torch.float64
|
|
18
|
+
# ---- complex ----
|
|
19
|
+
if requested in (complex, np.complex64, torch.complex64):
|
|
20
|
+
return torch.complex64
|
|
21
|
+
if requested in (np.complex128, torch.complex128):
|
|
22
|
+
return torch.complex128
|
|
23
|
+
# fallback to backend defaults
|
|
24
|
+
return dtype_real
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class _TorchXP:
|
|
28
|
+
def __init__(self, device, dtype_real, dtype_complex):
|
|
29
|
+
self.device = device
|
|
30
|
+
self.dtype_real = dtype_real
|
|
31
|
+
self.dtype_complex = dtype_complex
|
|
32
|
+
# allow x[..., xp.newaxis] indexing
|
|
33
|
+
self.newaxis = None
|
|
34
|
+
|
|
35
|
+
def __getattr__(self, name):
|
|
36
|
+
# delegate missing ops to torch when possible
|
|
37
|
+
if hasattr(torch, name):
|
|
38
|
+
return getattr(torch, name)
|
|
39
|
+
raise AttributeError(f"_TorchXP has no attribute {name}")
|
|
40
|
+
|
|
41
|
+
def maximum(self, x, y):
|
|
42
|
+
# torch.maximum only works on tensors — make sure both are tensors
|
|
43
|
+
x_t = x if isinstance(x, torch.Tensor) else torch.as_tensor(x, device=self.device)
|
|
44
|
+
y_t = y if isinstance(y, torch.Tensor) else torch.as_tensor(y, device=self.device)
|
|
45
|
+
return torch.maximum(x_t, y_t)
|
|
46
|
+
|
|
47
|
+
def minimum(self, x, y):
|
|
48
|
+
x_t = x if isinstance(x, torch.Tensor) else torch.as_tensor(x, device=self.device)
|
|
49
|
+
y_t = y if isinstance(y, torch.Tensor) else torch.as_tensor(y, device=self.device)
|
|
50
|
+
return torch.minimum(x_t, y_t)
|
|
51
|
+
|
|
52
|
+
def normal(self, loc=0.0, scale=1.0, size=None):
|
|
53
|
+
"""
|
|
54
|
+
Torch equivalent of np.random.normal, supporting tensor broadcasting
|
|
55
|
+
and explicit size expansion (unlike torch.normal).
|
|
56
|
+
"""
|
|
57
|
+
# Convert numpy arrays to tensors if needed
|
|
58
|
+
if not isinstance(loc, torch.Tensor):
|
|
59
|
+
loc = torch.as_tensor(loc, device=self.device)
|
|
60
|
+
if not isinstance(scale, torch.Tensor):
|
|
61
|
+
scale = torch.as_tensor(scale, device=self.device)
|
|
62
|
+
|
|
63
|
+
# Case 1: both tensors
|
|
64
|
+
if isinstance(loc, torch.Tensor) and isinstance(scale, torch.Tensor):
|
|
65
|
+
if size is None or tuple(size) == tuple(loc.shape):
|
|
66
|
+
# Default: same shape as loc
|
|
67
|
+
return torch.normal(mean=loc, std=scale)
|
|
68
|
+
else:
|
|
69
|
+
# Manually expand via broadcasting to requested size
|
|
70
|
+
loc_b = loc.expand(size)
|
|
71
|
+
scale_b = scale.expand(size)
|
|
72
|
+
return torch.normal(mean=loc_b, std=scale_b)
|
|
73
|
+
|
|
74
|
+
# Case 2: at least one scalar → use size directly
|
|
75
|
+
mean_val = float(loc) if not isinstance(loc, torch.Tensor) else float(loc)
|
|
76
|
+
std_val = float(scale) if not isinstance(scale, torch.Tensor) else float(scale)
|
|
77
|
+
return torch.normal(mean=mean_val, std=std_val, size=size, device=self.device)
|
|
78
|
+
|
|
79
|
+
def uniform(self, low=0.0, high=1.0, size=None):
|
|
80
|
+
if size is None:
|
|
81
|
+
return (high - low) * torch.rand((), device=self.device) + low
|
|
82
|
+
return (high - low) * torch.rand(size, device=self.device) + low
|
|
83
|
+
|
|
84
|
+
def random(self, size=None):
|
|
85
|
+
return torch.rand(size, device=self.device)
|
|
86
|
+
|
|
87
|
+
def broadcast_arrays(self, *xs):
|
|
88
|
+
# torch.broadcast_tensors requires tensors, not python scalars
|
|
89
|
+
ts = [x if isinstance(x, torch.Tensor) else torch.as_tensor(x, device=self.device) for x in xs]
|
|
90
|
+
return torch.broadcast_tensors(*ts)
|
|
91
|
+
|
|
92
|
+
def asarray(self, x, dtype=None):
|
|
93
|
+
dt = _map_dtype_torch(dtype, self.dtype_real, self.dtype_complex)
|
|
94
|
+
return torch.as_tensor(x, dtype=dt, device=self.device)
|
|
95
|
+
|
|
96
|
+
def repeat_last(self, x, n):
|
|
97
|
+
# x[..., None].repeat(..., n) would also work
|
|
98
|
+
return x.unsqueeze(-1).repeat_interleave(n, dim=-1)
|
|
99
|
+
|
|
100
|
+
def isscalar(self, x):
|
|
101
|
+
return not isinstance(x, torch.Tensor) or x.ndim == 0
|
|
102
|
+
|
|
103
|
+
def stack(self, arrays, axis=0):
|
|
104
|
+
return torch.stack(arrays, dim=axis)
|
|
105
|
+
|
|
106
|
+
def argmax(self, x, axis=None):
|
|
107
|
+
return torch.argmax(x, dim=axis)
|
|
108
|
+
|
|
109
|
+
def angle(self, z):
|
|
110
|
+
return torch.atan2(torch.imag(z), torch.real(z))
|
|
111
|
+
|
|
112
|
+
def eye(self, n, dtype=None):
|
|
113
|
+
dt = _map_dtype_torch(dtype, self.dtype_real, self.dtype_complex)
|
|
114
|
+
return torch.eye(n, dtype=dt, device=self.device)
|
|
115
|
+
|
|
116
|
+
def abs(self, x):
|
|
117
|
+
return torch.abs(x)
|
|
118
|
+
|
|
119
|
+
def conj(self, x):
|
|
120
|
+
return torch.conj(x)
|
|
121
|
+
|
|
122
|
+
def conjugate(self, x):
|
|
123
|
+
return torch.conj(x)
|
|
124
|
+
|
|
125
|
+
def exp(self, x):
|
|
126
|
+
return torch.exp(x)
|
|
127
|
+
|
|
128
|
+
def einsum(self, subs, *ops):
|
|
129
|
+
return torch.einsum(subs, *ops)
|
|
130
|
+
|
|
131
|
+
def zeros(self, shape, dtype=None):
|
|
132
|
+
dt = _map_dtype_torch(dtype, self.dtype_real, self.dtype_complex)
|
|
133
|
+
return torch.zeros(shape, dtype=dt, device=self.device)
|
|
134
|
+
|
|
135
|
+
def ones(self, shape, dtype=None):
|
|
136
|
+
dt = _map_dtype_torch(dtype, self.dtype_real, self.dtype_complex)
|
|
137
|
+
return torch.ones(shape, dtype=dt, device=self.device)
|
|
138
|
+
|
|
139
|
+
def full(self, shape, fill_value, dtype=None):
|
|
140
|
+
dt = _map_dtype_torch(dtype, self.dtype_real, self.dtype_complex)
|
|
141
|
+
return torch.full(shape, fill_value, dtype=dt, device=self.device)
|
|
142
|
+
|
|
143
|
+
def zeros_like(self, x, dtype=None):
|
|
144
|
+
dt = _map_dtype_torch(dtype, x.dtype, self.dtype_complex if x.is_complex() else self.dtype_real)
|
|
145
|
+
return torch.zeros_like(x, dtype=dt, device=x.device)
|
|
146
|
+
|
|
147
|
+
def ones_like(self, x, dtype=None):
|
|
148
|
+
dt = _map_dtype_torch(dtype, x.dtype, self.dtype_complex if x.is_complex() else self.dtype_real)
|
|
149
|
+
return torch.ones_like(x, dtype=dt, device=x.device)
|
|
150
|
+
|
|
151
|
+
def diag(self, v, diagonal=0):
|
|
152
|
+
# ensure v is on device first
|
|
153
|
+
vt = v if isinstance(v, torch.Tensor) else torch.as_tensor(v, device=self.device)
|
|
154
|
+
return torch.diag(vt, diagonal=diagonal)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class _TorchLinalg:
|
|
158
|
+
def __init__(self, device, dtype_real, dtype_complex):
|
|
159
|
+
self.device = device
|
|
160
|
+
self.dtype_real = dtype_real
|
|
161
|
+
self.dtype_complex = dtype_complex
|
|
162
|
+
|
|
163
|
+
def eigh(self, A):
|
|
164
|
+
"""
|
|
165
|
+
Deterministic & stable Hermitian eigendecomposition:
|
|
166
|
+
run on CPU in float64/complex128, then cast back.
|
|
167
|
+
Works for (N,N) or (...,N,N).
|
|
168
|
+
"""
|
|
169
|
+
A_cpu = A.to("cpu")
|
|
170
|
+
if A_cpu.dtype.is_complex:
|
|
171
|
+
A_cpu = A_cpu.to(torch.complex128)
|
|
172
|
+
else:
|
|
173
|
+
A_cpu = A_cpu.to(torch.float64)
|
|
174
|
+
|
|
175
|
+
w_cpu, V_cpu = torch.linalg.eigh(A_cpu) # batched OK
|
|
176
|
+
w = w_cpu.to(self.device, dtype=self.dtype_real)
|
|
177
|
+
V = V_cpu.to(self.device, dtype=self.dtype_complex)
|
|
178
|
+
return w, V
|
|
179
|
+
|
|
180
|
+
def matrix_exp(self, A):
|
|
181
|
+
"""
|
|
182
|
+
Use CPU fallback in float64/complex128 for numerical stability
|
|
183
|
+
and MPS compatibility; cast result back to original device/dtype.
|
|
184
|
+
"""
|
|
185
|
+
A_cpu = A.to("cpu")
|
|
186
|
+
if A_cpu.dtype.is_complex:
|
|
187
|
+
A_cpu = A_cpu.to(torch.complex128)
|
|
188
|
+
else:
|
|
189
|
+
A_cpu = A_cpu.to(torch.float64)
|
|
190
|
+
Y_cpu = torch.linalg.matrix_exp(A_cpu)
|
|
191
|
+
return Y_cpu.to(self.device, dtype=A.dtype)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def make_torch_mps_backend(seed=0, use_complex64=True):
|
|
195
|
+
torch.manual_seed(seed)
|
|
196
|
+
|
|
197
|
+
if torch.backends.mps.is_available():
|
|
198
|
+
device = torch.device("mps")
|
|
199
|
+
elif torch.backends.cudnn.is_available():
|
|
200
|
+
device = torch.device("cuda")
|
|
201
|
+
else:
|
|
202
|
+
device = torch.device("cpu")
|
|
203
|
+
|
|
204
|
+
dtype_real = torch.float32 if use_complex64 else torch.float64
|
|
205
|
+
dtype_complex = torch.complex64 if use_complex64 else torch.complex128
|
|
206
|
+
|
|
207
|
+
class Backend: ...
|
|
208
|
+
backend = Backend()
|
|
209
|
+
backend.device = device
|
|
210
|
+
backend.dtype_real = dtype_real
|
|
211
|
+
backend.dtype_complex = dtype_complex
|
|
212
|
+
backend.xp = _TorchXP(device, dtype_real, dtype_complex)
|
|
213
|
+
backend.linalg = _TorchLinalg(device, dtype_real, dtype_complex)
|
|
214
|
+
|
|
215
|
+
def to_device(x):
|
|
216
|
+
return torch.as_tensor(x, device=device)
|
|
217
|
+
|
|
218
|
+
def from_device(x):
|
|
219
|
+
# detach is safe for tensors; passthrough for numpy scalars
|
|
220
|
+
return x.detach().cpu().numpy() if isinstance(x, torch.Tensor) else np.asarray(x)
|
|
221
|
+
|
|
222
|
+
backend.to_device = to_device
|
|
223
|
+
backend.from_device = from_device
|
|
224
|
+
return backend
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
from nu_waves.backends import make_numpy_backend
|
|
2
|
+
from nu_waves.utils.units import GEV_TO_EV
|
|
3
|
+
from nu_waves.utils.units import VCOEFF_EV
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Hamiltonian:
|
|
7
|
+
def __init__(self, mixing_matrix, m2_diag, backend=None):
|
|
8
|
+
"""
|
|
9
|
+
U: (N,N) complex PMNS (ou 3+N)
|
|
10
|
+
m2_diag: (N,N) diag(m_i^2) [eV^2]
|
|
11
|
+
"""
|
|
12
|
+
self.backend = backend or make_numpy_backend()
|
|
13
|
+
xp = self.backend.xp
|
|
14
|
+
|
|
15
|
+
self.U = xp.asarray(mixing_matrix, dtype=self.backend.dtype_complex)
|
|
16
|
+
m2 = xp.asarray(m2_diag, dtype=self.backend.dtype_real)
|
|
17
|
+
if m2.ndim == 2:
|
|
18
|
+
# accept diagonal matrix but store vector
|
|
19
|
+
m2 = xp.diag(m2)
|
|
20
|
+
self.m2_diag = m2.reshape(-1)
|
|
21
|
+
# self.U = mixing_matrix
|
|
22
|
+
# self.m2_diag = m2_diag
|
|
23
|
+
|
|
24
|
+
def vacuum(self, E_GeV, antineutrino: bool = False):
|
|
25
|
+
"""
|
|
26
|
+
Return the flavor-basis Hamiltonian in vacuum.
|
|
27
|
+
|
|
28
|
+
Parameters
|
|
29
|
+
----------
|
|
30
|
+
E_GeV : float or array
|
|
31
|
+
Neutrino energy in GeV.
|
|
32
|
+
antineutrino : bool, optional
|
|
33
|
+
If True, uses complex-conjugated mixing matrix (U*).
|
|
34
|
+
"""
|
|
35
|
+
xp = self.backend.xp
|
|
36
|
+
E = xp.asarray(E_GeV, dtype=self.backend.dtype_real)
|
|
37
|
+
E = E.reshape(()) if E.ndim == 0 else E.reshape(-1) # () or (nE,)
|
|
38
|
+
|
|
39
|
+
EV_PER_GEV = self.backend.xp.asarray(GEV_TO_EV, dtype=self.backend.dtype_real)
|
|
40
|
+
E_eV = E * EV_PER_GEV
|
|
41
|
+
# E_eV = E * GEV_TO_EV
|
|
42
|
+
|
|
43
|
+
U = xp.conjugate(self.U) if antineutrino else self.U
|
|
44
|
+
# (U * m2) @ U^† (scale columns by m2)
|
|
45
|
+
# H0 = (U * self.m2_diag[xp.newaxis, :]) @ xp.conjugate(U).T # (N,N)
|
|
46
|
+
# H0 = (U * self.m2_diag[xp.newaxis, :]) @ xp.swapaxes(xp.conj(U), -1, -2)
|
|
47
|
+
H0 = (U * self.m2_diag[self.backend.xp.newaxis, :]) @ self.backend.xp.swapaxes(self.backend.xp.conj(U), -1, -2)
|
|
48
|
+
H = H0 / (2.0 * E_eV[..., xp.newaxis, xp.newaxis]) # ()→(N,N) or (nE,N,N)
|
|
49
|
+
return H
|
|
50
|
+
|
|
51
|
+
# E_eV = np.asarray(E_GeV, dtype=float) * GEV_TO_EV
|
|
52
|
+
# U = np.conjugate(self.U) if antineutrino else self.U
|
|
53
|
+
# D = np.diag(self.m2_diag)
|
|
54
|
+
# H = U @ D @ U.conj().T
|
|
55
|
+
# H = H / (2.0 * E_eV[..., None, None]) # broadcast over E
|
|
56
|
+
# return H
|
|
57
|
+
|
|
58
|
+
def matter_constant(self,
|
|
59
|
+
E_GeV,
|
|
60
|
+
rho_gcm3: float,
|
|
61
|
+
Ye: float = 0.5,
|
|
62
|
+
antineutrino: bool = False):
|
|
63
|
+
"""
|
|
64
|
+
H_matter(E) = U diag(m^2)/(2E) U^† + diag(Ve, 0, 0, ...),
|
|
65
|
+
with Ve = + 7.632e-14 * rho[g/cm^3] * Ye [eV] for neutrinos,
|
|
66
|
+
= - Ve for antineutrinos.
|
|
67
|
+
Works for scalar or vector E and arbitrary dimension >= 1.
|
|
68
|
+
"""
|
|
69
|
+
xp = self.backend.xp
|
|
70
|
+
|
|
71
|
+
# Energies on backend dtype/device
|
|
72
|
+
E = xp.asarray(E_GeV, dtype=self.backend.dtype_real)
|
|
73
|
+
E = E.reshape(()) if E.ndim == 0 else E.reshape(-1) # () or (nE,)
|
|
74
|
+
EV_PER_GEV = xp.asarray(GEV_TO_EV, dtype=self.backend.dtype_real)
|
|
75
|
+
|
|
76
|
+
# Vacuum term (respect anti-ν via U*)
|
|
77
|
+
U = xp.conj(self.U) if antineutrino else self.U
|
|
78
|
+
H0 = (U * self.m2_diag[xp.newaxis, :]) @ xp.swapaxes(xp.conj(U), -1, -2) # (N,N)
|
|
79
|
+
H_vac = H0 / (2.0 * (E * EV_PER_GEV)[..., xp.newaxis, xp.newaxis]) # (..,N,N)
|
|
80
|
+
|
|
81
|
+
# Matter potential in flavor basis (complex dtype for uniformity)
|
|
82
|
+
N = self.U.shape[0]
|
|
83
|
+
V_f = xp.zeros((N, N), dtype=self.backend.dtype_complex)
|
|
84
|
+
Ve = xp.asarray(VCOEFF_EV * rho_gcm3 * Ye, dtype=self.backend.dtype_real)
|
|
85
|
+
sign = -1.0 if antineutrino else +1.0
|
|
86
|
+
V_f = V_f + 0 # no-op; safe to remove if you prefer
|
|
87
|
+
V_f[..., 0, 0] = sign * Ve # only e-flavor gets the CC potential
|
|
88
|
+
|
|
89
|
+
# Broadcast across energies if vector E
|
|
90
|
+
return H_vac + (V_f if E.ndim == 0 else V_f[xp.newaxis, :, :])
|
|
91
|
+
|
nu_waves/matter/prem.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
from .profile import MatterLayer, MatterProfile
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class PREMModel:
|
|
9
|
+
"""
|
|
10
|
+
Minimal PREM density model (Dziewonski & Anderson 1981) with two discretization modes:
|
|
11
|
+
- 'prem_layers': cut the chord at PREM radial boundaries and use the local density.
|
|
12
|
+
- 'hist_density': sample along the chord, bin by density (nbins), and merge contiguous bins.
|
|
13
|
+
|
|
14
|
+
Densities are in g/cm^3. Electron fraction Ye defaults: mantle≈0.495, core≈0.467.
|
|
15
|
+
"""
|
|
16
|
+
R_earth_km: float = 6371.0
|
|
17
|
+
# PREM region boundaries in km (increasing radii)
|
|
18
|
+
# 0–1221.5 (inner core), 1221.5–3480 (outer core), 3480–... mantle shells, crustal layers
|
|
19
|
+
prem_boundaries_km: tuple = (0.0, 1221.5, 3480.0, 5701.0, 5771.0, 5971.0,
|
|
20
|
+
6151.0, 6346.6, 6356.0, 6368.0, 6371.0)
|
|
21
|
+
# Ye defaults
|
|
22
|
+
Ye_mantle: float = 0.495
|
|
23
|
+
Ye_core: float = 0.467
|
|
24
|
+
|
|
25
|
+
# --- PREM density polynomials ρ(r) in g/cm^3; x = r/R_earth ---
|
|
26
|
+
def rho(self, r_km: np.ndarray) -> np.ndarray:
|
|
27
|
+
r = np.asarray(r_km, float)
|
|
28
|
+
x = r / self.R_earth_km
|
|
29
|
+
rho = np.empty_like(x)
|
|
30
|
+
|
|
31
|
+
# regions defined by r
|
|
32
|
+
b = self.prem_boundaries_km
|
|
33
|
+
|
|
34
|
+
# 0–1221.5 km (inner core)
|
|
35
|
+
m = (r >= b[0]) & (r < b[1])
|
|
36
|
+
rho[m] = 13.0885 - 8.8381 * x[m]**2
|
|
37
|
+
|
|
38
|
+
# 1221.5–3480 km (outer core)
|
|
39
|
+
m = (r >= b[1]) & (r < b[2])
|
|
40
|
+
rho[m] = 12.5815 - 1.2638 * x[m] - 3.6426 * x[m]**2 - 5.5281 * x[m]**3
|
|
41
|
+
|
|
42
|
+
# 3480–5701 km (lower mantle)
|
|
43
|
+
m = (r >= b[2]) & (r < b[3])
|
|
44
|
+
rho[m] = 7.9565 - 6.4761 * x[m] + 5.5283 * x[m]**2 - 3.0807 * x[m]**3
|
|
45
|
+
|
|
46
|
+
# 5701–5771 km (TZ 1)
|
|
47
|
+
m = (r >= b[3]) & (r < b[4])
|
|
48
|
+
rho[m] = 5.3197 - 1.4836 * x[m]
|
|
49
|
+
|
|
50
|
+
# 5771–5971 km (TZ 2)
|
|
51
|
+
m = (r >= b[4]) & (r < b[5])
|
|
52
|
+
rho[m] = 11.2494 - 8.0298 * x[m]
|
|
53
|
+
|
|
54
|
+
# 5971–6151 km (upper mantle low)
|
|
55
|
+
m = (r >= b[5]) & (r < b[6])
|
|
56
|
+
rho[m] = 7.1089 - 3.8045 * x[m]
|
|
57
|
+
|
|
58
|
+
# 6151–6346.6 km (upper mantle high)
|
|
59
|
+
m = (r >= b[6]) & (r < b[7])
|
|
60
|
+
rho[m] = 2.6910 + 0.6924 * x[m]
|
|
61
|
+
|
|
62
|
+
# 6346.6–6356 km (crust 1)
|
|
63
|
+
m = (r >= b[7]) & (r < b[8])
|
|
64
|
+
rho[m] = 2.900
|
|
65
|
+
|
|
66
|
+
# 6356–6368 km (crust 2)
|
|
67
|
+
m = (r >= b[8]) & (r < b[9])
|
|
68
|
+
rho[m] = 2.600
|
|
69
|
+
|
|
70
|
+
# 6368–6371 km (ocean)
|
|
71
|
+
m = (r >= b[9]) & (r <= b[10])
|
|
72
|
+
rho[m] = 1.020
|
|
73
|
+
|
|
74
|
+
return rho
|
|
75
|
+
|
|
76
|
+
def Ye(self, r_km: np.ndarray) -> np.ndarray:
|
|
77
|
+
"""Simple two-zone Ye: core vs mantle/crust."""
|
|
78
|
+
r = np.asarray(r_km, float)
|
|
79
|
+
return np.where(r <= 3480.0, self.Ye_core, self.Ye_mantle)
|
|
80
|
+
|
|
81
|
+
# --- chord geometry helpers ---
|
|
82
|
+
def _chord_length_km(self, cosz: float) -> float:
|
|
83
|
+
cz = float(cosz)
|
|
84
|
+
return 0.0 if cz >= 0.0 else -2.0 * self.R_earth_km * cz
|
|
85
|
+
|
|
86
|
+
def _impact_parameter_km(self, cosz: float) -> float:
|
|
87
|
+
cz = float(cosz)
|
|
88
|
+
return self.R_earth_km * np.sqrt(max(0.0, 1.0 - cz*cz))
|
|
89
|
+
|
|
90
|
+
# atmosphere thickness
|
|
91
|
+
def _atm_path_km(self, cosz: float, h_km: float) -> float:
|
|
92
|
+
if h_km <= 0: return 0.0
|
|
93
|
+
R, Rp = self.R_earth_km, self.R_earth_km + h_km
|
|
94
|
+
b = self._impact_parameter_km(cosz)
|
|
95
|
+
if b >= Rp: # ray misses the atmosphere
|
|
96
|
+
return 0.0
|
|
97
|
+
seg = lambda r: np.sqrt(max(r * r - b * b, 0.0))
|
|
98
|
+
# ONE segment from production altitude Rp down to the surface R, irrespective of sign(cosz)
|
|
99
|
+
return seg(Rp) - seg(R)
|
|
100
|
+
|
|
101
|
+
# --- layer builders ---
|
|
102
|
+
def profile_from_coszen(self, cosz: float,
|
|
103
|
+
scheme: str = "prem_layers",
|
|
104
|
+
n_bins: int = 800,
|
|
105
|
+
nbins_density: int = 24,
|
|
106
|
+
merge_tol: float = 0.0,
|
|
107
|
+
h_atm_km: float = 15.0) -> MatterProfile:
|
|
108
|
+
cz = float(cosz)
|
|
109
|
+
Ltot = self._chord_length_km(cz)
|
|
110
|
+
b = self._impact_parameter_km(cz)
|
|
111
|
+
|
|
112
|
+
layers: list[MatterLayer] = []
|
|
113
|
+
|
|
114
|
+
# --- Earth chord (only if upgoing) ---
|
|
115
|
+
if Ltot > 0.0:
|
|
116
|
+
half = 0.5 * Ltot
|
|
117
|
+
|
|
118
|
+
def r_of_t(t):
|
|
119
|
+
return np.sqrt(b * b + t * t)
|
|
120
|
+
|
|
121
|
+
if scheme == "prem_layers":
|
|
122
|
+
# cut at PREM boundaries intersected by the chord
|
|
123
|
+
t_pts = [-half, +half]
|
|
124
|
+
for rb in self.prem_boundaries_km[1:-1]:
|
|
125
|
+
if rb > b:
|
|
126
|
+
dt = np.sqrt(rb * rb - b * b)
|
|
127
|
+
t_pts += [-dt, +dt]
|
|
128
|
+
t_pts = np.array(sorted(tp for tp in t_pts if -half <= tp <= half))
|
|
129
|
+
for t0, t1 in zip(t_pts[:-1], t_pts[1:]):
|
|
130
|
+
dL = float(t1 - t0)
|
|
131
|
+
r_mid = r_of_t(0.5 * (t0 + t1))
|
|
132
|
+
rho_mid = float(self.rho(r_mid))
|
|
133
|
+
Ye_mid = float(self.Ye(r_mid))
|
|
134
|
+
layers.append(MatterLayer(rho_mid, Ye_mid, dL, "absolute"))
|
|
135
|
+
|
|
136
|
+
elif scheme == "hist_density":
|
|
137
|
+
t_edges = np.linspace(-half, +half, n_bins + 1)
|
|
138
|
+
t_mid = 0.5 * (t_edges[:-1] + t_edges[1:])
|
|
139
|
+
dL = np.diff(t_edges)
|
|
140
|
+
r_mid = r_of_t(t_mid)
|
|
141
|
+
rho_mid = self.rho(r_mid)
|
|
142
|
+
Ye_mid = self.Ye(r_mid)
|
|
143
|
+
|
|
144
|
+
rmin, rmax = rho_mid.min(), rho_mid.max()
|
|
145
|
+
edges = np.linspace(rmin, rmax, nbins_density + 1)
|
|
146
|
+
idx = np.minimum(np.searchsorted(edges, rho_mid, side="right") - 1, nbins_density - 1)
|
|
147
|
+
|
|
148
|
+
accL = accR = accY = 0.0
|
|
149
|
+
prev = idx[0]
|
|
150
|
+
|
|
151
|
+
def flush():
|
|
152
|
+
nonlocal accL, accR, accY
|
|
153
|
+
if accL > 0.0:
|
|
154
|
+
layers.append(MatterLayer(accR / accL, accY / accL, accL, "absolute"))
|
|
155
|
+
accL = accR = accY = 0.0
|
|
156
|
+
|
|
157
|
+
for i in range(len(dL)):
|
|
158
|
+
if idx[i] != prev:
|
|
159
|
+
flush()
|
|
160
|
+
prev = idx[i]
|
|
161
|
+
accL += float(dL[i])
|
|
162
|
+
accR += float(dL[i] * rho_mid[i])
|
|
163
|
+
accY += float(dL[i] * Ye_mid[i])
|
|
164
|
+
flush()
|
|
165
|
+
|
|
166
|
+
if merge_tol > 0 and len(layers) > 1:
|
|
167
|
+
merged = [layers[0]]
|
|
168
|
+
for nxt in layers[1:]:
|
|
169
|
+
cur = merged[-1]
|
|
170
|
+
if abs(nxt.rho_gcm3 - cur.rho_gcm3) <= merge_tol:
|
|
171
|
+
Lsum = cur.weight + nxt.weight
|
|
172
|
+
merged[-1] = MatterLayer(
|
|
173
|
+
(cur.rho_gcm3 * cur.weight + nxt.rho_gcm3 * nxt.weight) / Lsum,
|
|
174
|
+
(cur.Ye * cur.weight + nxt.Ye * nxt.weight) / Lsum,
|
|
175
|
+
Lsum, "absolute"
|
|
176
|
+
)
|
|
177
|
+
else:
|
|
178
|
+
merged.append(nxt)
|
|
179
|
+
layers = merged
|
|
180
|
+
else:
|
|
181
|
+
raise ValueError(f"Unknown scheme '{scheme}'")
|
|
182
|
+
# else: no Earth layers for cz>=0
|
|
183
|
+
|
|
184
|
+
# --- Atmosphere around the Earth chord (both signs of cosz) ---
|
|
185
|
+
L_atm = self._atm_path_km(cosz, h_atm_km)
|
|
186
|
+
if L_atm > 0.0:
|
|
187
|
+
atm = MatterLayer(0.0, self.Ye_mantle, L_atm, "absolute")
|
|
188
|
+
if Ltot > 0.0: # upgoing: atmosphere is on the FAR side, before Earth
|
|
189
|
+
layers = [atm] + layers
|
|
190
|
+
else: # downgoing: atmosphere is on the NEAR side, straight to detector
|
|
191
|
+
layers = [atm]
|
|
192
|
+
|
|
193
|
+
# Ensure non-empty profile to keep the engine happy
|
|
194
|
+
if not layers:
|
|
195
|
+
layers = [MatterLayer(0.0, self.Ye_mantle, 0.0, "absolute")]
|
|
196
|
+
|
|
197
|
+
return MatterProfile(layers)
|