hyperion.models.photon_arrival_time_nflow.training_data
Photon Monte Carlo training-data generation for the shape/counts models.
Runs photon MC in ANTARES/KM3NeT Mediterranean water with absorption. DOM placed at origin (radius 0.30 m); isotropic Cherenkov photon sources at logarithmically spaced distances from d_min to d_max.
Each photon accumulates an absorption survival weight
w = prod_steps exp(-step_size / abs_len(λ)). This weight-tracking
approach avoids killing photons early (improving statistics at large
distances) and is equivalent to the absorption-corrected expected photon
count.
Produces two datasets, compatible with SimpleDataset / DataLoader
and train_shape_model / train_counts_model in
:mod:hyperion.models.photon_arrival_time_nflow.net:
Shape data: one row per detected photon after importance-sampling thinning (i.e., each detected photon is kept with probability proportional to its absorption weight, producing unweighted samples from the correct distribution).
Counts data: one row per (distance x angle-bin) combination, with
log10_survival = log10(sum_weights_detected / n_emitted_in_bin).
Functions:
-
generate_training_data–Run the photon MC and build the shape and counts training datasets.
-
make_step_with_absorption–Build a photon step function extended with absorption weight tracking.
generate_training_data
generate_training_data(n_photons: int, n_distances: int, n_angle_bins: int, d_min: float, d_max: float, wl_min: float, wl_max: float, max_time: float, seed: int)
Run the photon MC and build the shape and counts training datasets.
| Parameters: |
|
|---|
| Returns: |
|
|---|
Source code in hyperion/models/photon_arrival_time_nflow/training_data.py
def generate_training_data(
n_photons: int,
n_distances: int,
n_angle_bins: int,
d_min: float,
d_max: float,
wl_min: float,
wl_max: float,
max_time: float,
seed: int,
):
"""Run the photon MC and build the shape and counts training datasets.
Parameters
----------
n_photons : int
Photons emitted per source distance.
n_distances : int
Number of source distances (log-uniform from ``d_min`` to ``d_max``).
n_angle_bins : int
Emission-angle bins for the counts data.
d_min, d_max : float
Source distance range [m]. ``d_min`` must be greater than
:data:`DOM_RADIUS` to avoid the source sitting exactly on the sphere
surface, which causes d=0 intersection tests to fail the strict d>0
check and suppresses hits.
wl_min, wl_max : float
Cherenkov wavelength range [nm].
max_time : float
Max photon propagation time [ns] (cutoff for un-intersected photons).
seed : int
Random seed.
Returns
-------
shape_data : dict or None
Arrays ``log10_dist``, ``angle``, ``t_residual``, ``weight`` (one row
per detected photon), or ``None`` if no photons were detected.
counts_data : dict or None
Arrays ``log10_dist``, ``angle``, ``log10_survival``, ``n_detected``
(one row per populated distance x angle-bin combination), or ``None``
if no bins were populated.
"""
# Reference speed of light (400 nm) used for t_residual = t_hit - d/c_ref
c_ref = float(_C_VAC_M_NS / antares_ref_index_func(400.0))
distances = np.logspace(np.log10(d_min), np.log10(d_max), n_distances)
angle_edges = np.linspace(0.0, np.pi, n_angle_bins + 1)
angle_centers = 0.5 * (angle_edges[:-1] + angle_edges[1:])
print(f"DOM radius : {DOM_RADIUS:.2f} m")
print(f"c_ref (400nm): {c_ref:.4f} m/ns")
print(f"Distances : {distances[0]:.2f} – {distances[-1]:.2f} m ({n_distances} points)")
print(f"Photons/d : {n_photons:,}")
print(f"Max time : {max_time:.0f} ns")
print()
# --- Build physics functions ---
km3net_abs_len = _make_km3net_abs_len()
isec_f = make_photon_sphere_intersection_func(jnp.zeros(3), DOM_RADIUS)
wl_sampler = make_cherenkov_spectral_sampling_func((wl_min, wl_max), antares_ref_index_func)
step_fn = make_step_with_absorption(
isec_f,
mixed_hg_rayleigh_antares,
sca_len_func_antares,
antares_ref_index_func,
km3net_abs_len,
)
max_time_j = jnp.float64(max_time)
abs_len_grid, cher_w = _absorption_bound_tables(wl_min, wl_max)
# --- JIT-compile batch propagator (compiled once, reused for all distances) ---
@jax.jit
def run_batch(keys, source_pos):
"""Propagate N photons from ``source_pos``; return ``(init_dirs, final_states)``."""
def run_one(key):
k_dir, k_wl, k_prop = random.split(key, 3)
k_theta, k_phi = random.split(k_dir, 2)
cos_theta = random.uniform(k_theta, minval=-1.0, maxval=1.0, dtype=jnp.float64)
phi = random.uniform(k_phi, minval=0.0, maxval=2.0 * np.pi, dtype=jnp.float64)
sin_theta = jnp.sqrt(jnp.maximum(jnp.float64(1.0) - cos_theta**2, jnp.float64(0.0)))
init_dir = jnp.array(
[sin_theta * jnp.cos(phi), sin_theta * jnp.sin(phi), cos_theta],
dtype=jnp.float64,
)
wl = wl_sampler(k_wl)
init_state = {
"pos": jnp.asarray(source_pos, dtype=jnp.float64),
"dir": init_dir,
"time": jnp.float64(0.0),
"isec": jnp.bool_(False),
"stepcnt": jnp.int32(0),
"wavelength": jnp.float64(wl),
"weight": jnp.float64(1.0),
}
final_state, _ = while_loop(
lambda args: ~args[0]["isec"] & (args[0]["time"] < max_time_j),
lambda args: step_fn(args[0], args[1]),
(init_state, k_prop),
)
return init_dir, final_state
return jax.vmap(run_one)(keys)
# --- Storage for training data ---
shape_log10d: list = []
shape_angle: list = []
shape_tres: list = []
shape_weight: list = []
counts_log10d: list = []
counts_angle: list = []
counts_log10surv: list = []
counts_ndet: list = []
# Per-angle-bin record of the most recent *populated* (d, log10_survival) entry.
# Used to propagate the monotonicity upper bound: survival can only decrease
# with distance, bounded by the incremental absorption factor.
_last_surv_by_bin: dict = {} # j -> (d_last, log10surv_last)
rng_key = random.PRNGKey(seed)
print("Compiling JAX batch propagator (first call)...")
t_compile = wall_time()
for i_d, d in enumerate(distances):
if d <= DOM_RADIUS:
print(f" d={d:7.2f} m SKIPPED (source on or inside DOM surface)")
continue
source_pos = jnp.array([0.0, 0.0, float(d)], dtype=jnp.float64)
rng_key, subkey = random.split(rng_key)
keys = random.split(subkey, n_photons)
t0 = wall_time()
init_dirs, final_states = run_batch(keys, source_pos)
jax.block_until_ready(final_states["time"])
if i_d == 0:
logger.info("First-call (compile + run): %.1f s", wall_time() - t_compile)
init_dirs_np = np.asarray(init_dirs) # (N, 3)
isec_np = np.asarray(final_states["isec"]) # (N,) bool
time_np = np.asarray(final_states["time"]) # (N,) float64
weight_np = np.asarray(final_states["weight"]) # (N,) float64
n_detected = int(isec_np.sum())
elapsed = wall_time() - t0
# Emission angle: angle between init_dir and source→DOM axis (0,0,-1)
# cos(em_angle) = dot(init_dir, (0,0,-1)) = -init_dir_z
em_angle_all = np.arccos(np.clip(-init_dirs_np[:, 2], -1.0, 1.0)) # (N,)
print(
f" d={d:7.2f} m hits={n_detected:7d}/{n_photons} "
f"w_sum={weight_np[isec_np].sum():.3e} [{elapsed:.1f} s]"
)
if n_detected == 0:
continue
em_angle_det = em_angle_all[isec_np] # angles of detected photons
weight_det = weight_np[isec_np] # absorption weights of detected photons
t_res_det = time_np[isec_np] - d / c_ref # time residuals [ns]
# --- Shape data: keep all detected photons with absorption weights ---
# Weights are used directly in the weighted NLL loss during training:
# L = -sum(w_i * log p(t_i)) / sum(w_i)
# This avoids the acceptance-rejection thinning that discards >90% of
# photons at large distances, where weights span many orders of magnitude.
shape_log10d.append(np.full(n_detected, np.log10(d), dtype=np.float32))
shape_angle.append(em_angle_det.astype(np.float32))
shape_tres.append(t_res_det.astype(np.float32))
shape_weight.append(weight_det.astype(np.float32))
# --- Counts data: bin detected photons by emission angle ---
for j in range(n_angle_bins):
a_lo = angle_edges[j]
a_hi = angle_edges[j + 1]
in_bin_all = (em_angle_all >= a_lo) & (em_angle_all < a_hi)
in_bin_det = in_bin_all & isec_np
n_emit_bin = int(in_bin_all.sum())
if n_emit_bin == 0:
continue
w_bin = float(weight_np[in_bin_det].sum())
if w_bin <= 0.0:
# No photons reached the DOM from this bin. Compute the tightest
# of three independent upper bounds on the true survival fraction:
#
# (1) Absorption physics: every photon travels the straight-line
# path of length d; scatter-assisted paths are longer so this
# is a genuine upper bound.
bound_abs = np.log10(_absorption_upper_bound(d, abs_len_grid, cher_w))
#
# (2) MC statistics: threw n_emit_bin photons and observed 0 hits;
# Poisson 95 % upper limit on the rate is 3 / n_emit_bin.
bound_mc = np.log10(3.0 / n_emit_bin)
#
# (3) Monotonicity: survival decreases with distance. If the
# closest populated bin at the same angle had log10_survival
# s_prev at d_prev, the upper bound at d is
# s_prev + log10(<exp(-(d-d_prev)/abs_len(λ))>_spectrum).
bounds = [bound_abs, bound_mc]
if j in _last_surv_by_bin:
d_prev, s_prev = _last_surv_by_bin[j]
mono_bound = s_prev + np.log10(
_absorption_upper_incremental(d_prev, d, abs_len_grid, cher_w)
)
bounds.append(mono_bound)
#
log10_surv_upper = min(bounds)
counts_log10d.append(np.float32(np.log10(d)))
counts_angle.append(np.float32(angle_centers[j]))
counts_log10surv.append(np.float32(log10_surv_upper))
counts_ndet.append(np.float32(0)) # training weight = sqrt(0+1) = 1
continue
n_det_bin = int(in_bin_det.sum())
survival = w_bin / n_emit_bin
log10s = float(np.log10(survival))
# Only seed the monotonicity tracker from reliable multi-photon bins.
# Single-photon entries can have extreme absorption weights from rare
# long scatter paths, producing spuriously large negative values that
# would propagate forward and over-constrain all subsequent distances.
if n_det_bin >= 2:
_last_surv_by_bin[j] = (d, log10s)
counts_log10d.append(np.float32(np.log10(d)))
counts_angle.append(np.float32(angle_centers[j]))
counts_log10surv.append(np.float32(log10s))
counts_ndet.append(np.float32(n_det_bin))
# --- Build shape data ---
if shape_log10d:
shape_data = {
"log10_dist": np.concatenate(shape_log10d),
"angle": np.concatenate(shape_angle),
"t_residual": np.concatenate(shape_tres),
"weight": np.concatenate(shape_weight),
}
else:
shape_data = None
# --- Angular monotonicity pass for counts data ---
# Survival is physically non-increasing with angle (forward emission always
# dominates backward). Empty bins near 180° are assigned a loose MC Poisson
# upper bound log10(3/n_emit) that can be orders of magnitude above the true
# value. By sweeping from small to large angles within each distance slice
# we propagate the tightest available bound: any empty bin at angle θ has
# survival ≤ survival(θ_prev) where θ_prev < θ is the most recent bin.
# This only tightens labels that were already upper bounds (n_det = 0); real
# measurements (n_det > 0) are never modified.
if counts_log10d:
_c_log10d = np.array(counts_log10d)
_c_angle = np.array(counts_angle)
_c_log10s = np.array(counts_log10surv)
_c_ndet = np.array(counts_ndet)
unique_log10d = np.unique(_c_log10d)
n_tightened = 0
for ld in unique_log10d:
mask = _c_log10d == ld
idx = np.where(mask)[0]
# Sort by angle within this distance slice
order = np.argsort(_c_angle[idx])
idx_sorted = idx[order]
running_bound = 0.0 # start at 0 (log10(1) = no constraint yet)
for k in idx_sorted:
if _c_ndet[k] >= 2:
# Reliable multi-photon measurement: update running bound
# but do not modify the label. Single-photon entries are
# excluded here because an extreme absorption weight on one
# photon produces spuriously large negative log10_survival
# values that would over-constrain all larger-angle bins.
running_bound = _c_log10s[k]
elif _c_ndet[k] == 0:
# Empty bin: tighten if the angular neighbour bound is stricter
if _c_log10s[k] > running_bound:
n_tightened += 1
_c_log10s[k] = running_bound
# Propagate only if this tightened value is itself tighter
running_bound = min(running_bound, _c_log10s[k])
# n_det == 1: single photon — do not update running_bound,
# but do tighten the label if the running bound is stricter.
elif _c_log10s[k] > running_bound:
n_tightened += 1
_c_log10s[k] = running_bound
if n_tightened:
print(
f" Angular monotonicity pass tightened {n_tightened} empty-bin "
f"labels (of {int((_c_ndet == 0).sum())} total empty bins)."
)
counts_data = {
"log10_dist": _c_log10d,
"angle": _c_angle,
"log10_survival": _c_log10s,
"n_detected": _c_ndet,
}
else:
counts_data = None
return shape_data, counts_data
make_step_with_absorption
make_step_with_absorption(intersection_f, scattering_function, scattering_length_function, ref_index_func, absorption_length_function, dtype=jnp.float64)
Build a photon step function extended with absorption weight tracking.
Identical to hyperion.propagate.make_step_function except that the
photon state carries an additional weight field which is multiplied by
exp(-actual_step / abs_len(λ)) at every step. Photons are never
killed; the weight encodes the cumulative survival probability.
| Parameters: |
|
|---|
| Returns: |
|
|---|
Source code in hyperion/models/photon_arrival_time_nflow/training_data.py
def make_step_with_absorption(
intersection_f,
scattering_function,
scattering_length_function,
ref_index_func,
absorption_length_function,
dtype=jnp.float64,
):
"""Build a photon step function extended with absorption weight tracking.
Identical to ``hyperion.propagate.make_step_function`` except that the
photon state carries an additional ``weight`` field which is multiplied by
``exp(-actual_step / abs_len(λ))`` at every step. Photons are never
killed; the weight encodes the cumulative survival probability.
Parameters
----------
intersection_f : callable
DOM intersection function (from
``hyperion.propagate.make_photon_sphere_intersection_func``).
scattering_function : callable
Scattering angle sampler.
scattering_length_function : callable
Scattering length as a function of wavelength [nm] -> [m].
ref_index_func : callable
Refractive index as a function of wavelength [nm].
absorption_length_function : callable
Absorption length as a function of wavelength [nm] -> [m].
dtype : jnp.dtype, optional
Floating-point precision (default ``jnp.float64``).
Returns
-------
callable
Step function ``(photon_state, rng_key) -> (new_state, new_key)``.
Photon state keys: ``pos``, ``dir``, ``time``, ``isec``, ``stepcnt``,
``wavelength``, ``weight``.
"""
def step(photon_state, rng_key):
pos = photon_state["pos"]
pdir = photon_state["dir"]
time = photon_state["time"]
stepcnt = photon_state["stepcnt"]
wavelength = photon_state["wavelength"]
weight = photon_state["weight"]
k1, k2, k3, k4 = random.split(rng_key, 4)
sca_coeff = dtype(1.0) / scattering_length_function(wavelength)
abs_coeff = dtype(1.0) / absorption_length_function(wavelength)
c_med = dtype(_C_VAC_M_NS / ref_index_func(wavelength))
step_size = -jnp.log(random.uniform(k1, dtype=dtype)) / sca_coeff
new_pos = jnp.asarray(pos + step_size * pdir, dtype=dtype)
new_time = time + step_size / c_med
isec_hit, isec_pos = intersection_f(photon_x=pos, photon_p=pdir, step_size=step_size)
# Distance actually travelled (straight-line to DOM or full step)
dist_to_isec = jnp.linalg.norm(pos - isec_pos)
actual_step = jnp.where(isec_hit, dist_to_isec, step_size)
new_weight = weight * jnp.exp(-actual_step * abs_coeff)
isec_time = time + dist_to_isec / c_med
new_pos = cond(isec_hit, lambda a: a[0], lambda a: a[1], (isec_pos, new_pos))
new_time = cond(isec_hit, lambda a: a[0], lambda a: a[1], (isec_time, new_time))
new_dir = cond(
isec_hit,
lambda a: a[1],
lambda a: calc_new_direction(a[0], a[1], scattering_function),
([k2, k3], pdir),
)
stepcnt = cond(isec_hit, lambda s: s, lambda s: s + jnp.int32(1), stepcnt)
return {
"pos": new_pos,
"dir": new_dir,
"time": new_time,
"isec": isec_hit,
"stepcnt": stepcnt,
"wavelength": wavelength,
"weight": new_weight,
}, k4
return step