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 ADDED
@@ -0,0 +1,4 @@
1
+ from .version import __version__ # noqa: F401
2
+
3
+ # Optionally expose top-level API symbols
4
+ # from .propagation import probability, ...
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,3 @@
1
+ from .numpy_backend import make_numpy_backend
2
+ from .torch_backend import make_torch_mps_backend # new
3
+ __all__ = ["make_numpy_backend", "make_torch_mps_backend"]
@@ -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
+
@@ -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)