Files
giant/giant/analysis/context.py
T
lars ebd3e0dc71
CI / Lint (ruff check) (push) Successful in 32s
CI / Format (ruff format) (push) Successful in 30s
CI / Sync project version with tag (push) Has been skipped
CI / Type check (ty) (push) Successful in 35s
CI / Lint (ruff check) (pull_request) Successful in 33s
CI / Format (ruff format) (pull_request) Successful in 30s
CI / Type check (ty) (pull_request) Successful in 34s
CI / Sync project version with tag (pull_request) Has been skipped
CI / Tests (push) Successful in 5m59s
CI / Bump version, tag, and update changelog on merge to master (push) Has been skipped
CI / Tests (pull_request) Successful in 4m22s
CI / Bump version, tag, and update changelog on merge to master (pull_request) Has been skipped
Add multi-rollout support to giant analyze (gitea #77)
giant analyze compares N rollout YAMLs against one shared reference file
(all must name the same dataset, checked up front) instead of exactly one
rollout vs one reference, rendering each rollout as its own colored series
against a single reference line/panel. Series names come from a repeated
--label flag, else the YAML stem, else "rollout" for a single YAML — a
single-rollout run keeps rendering identically to before this change.

Bundle now holds a name-keyed dict of rollout sides instead of one fixed
pair, every catalog compute_partial/finalize builds a Reduced.payload
keyed the same way ("series": {name: ...}, "reference": ... as the one
distinguished non-rollout entry), and every renderer draws N series (or
N panels, for the two heatmap-shaped specs and the router/type-embedding
diagnostics, which are inherently one-matrix/one-checkpoint per rollout)
against the reference's fixed dashed-ink style.
2026-08-24 13:23:50 +02:00

192 lines
7.5 KiB
Python

"""Shared analysis context (the ``prep`` step): fixed bin edges + group sets.
Every histogram in the catalog bins against **fixed** edges so each compute job
is a single streaming pass with no min/max range scan. Those edges — plus the
energy-bin quantiles, the top PDG species and the material list to stratify by,
and the shower depth/transverse ranges — are resolved *once* here, on the submit
node, from a hash-subsample plus a few cheap exact ``group_by`` passes, and shipped
in ``shared.json``. Tiny and self-describing; no per-event arrays.
plotstyle-free (runs on the submit node, but also importable by workers).
"""
from __future__ import annotations
import json
from dataclasses import asdict, dataclass, field
from pathlib import Path
import numpy as np
import polars as pl
from giant.analysis.grouping import energy_bin_edges
from giant.analysis.reduce import (
attach_entry_axis,
depth_expr,
entry_axis,
transverse_expr,
)
from giant.analysis.sources import RolloutSpec, Side, open_side, physical_steps, secondaries
from giant.analysis.variables import RANGED_VARS
@dataclass
class Context:
"""Resolved bin edges and grouping sets shared by every compute job."""
n_marginal_bins: int
var_ranges: dict[str, tuple[float, float]] # ranged var -> (lo, hi)
energy_edges: list[float]
top_pdgs: list[int]
materials: list[str]
depth_edges: list[float]
transverse_edges: list[float]
sec_energy_range: tuple[float, float]
n_sec_bins: int
n_events: dict[str, int] = field(default_factory=dict)
# -- (de)serialization -------------------------------------------------
def save(self, path: str | Path) -> None:
Path(path).write_text(json.dumps(asdict(self), indent=2))
@classmethod
def load(cls, path: str | Path) -> "Context":
d = json.loads(Path(path).read_text())
d["var_ranges"] = {k: tuple(v) for k, v in d["var_ranges"].items()}
d["sec_energy_range"] = tuple(d["sec_energy_range"])
return cls(**d)
# -- convenience -------------------------------------------------------
def marginal_edges(self, var: str) -> np.ndarray:
lo, hi = self.var_ranges[var]
return np.linspace(lo, hi, self.n_marginal_bins + 1)
_LO_Q, _HI_Q = 0.001, 0.999
def _row_subsample(lf: pl.LazyFrame, sample_rows: int, seed: int) -> pl.LazyFrame:
"""Hash-subsample ~``sample_rows`` rows (for range estimation only)."""
n_total = lf.select(pl.len()).collect(engine="streaming").item()
if n_total <= sample_rows:
return lf
threshold = int(sample_rows / n_total * 2**32)
return lf.filter((pl.col("pre_E").hash(seed=seed) % 2**32) < threshold)
def _combined_quantiles(vals: list[np.ndarray], lo_q: float, hi_q: float) -> tuple[float, float]:
"""Robust (lo_q, hi_q) range over the union of several value samples."""
both = np.concatenate(vals)
lo, hi = float(np.quantile(both, lo_q)), float(np.quantile(both, hi_q))
if not (hi - lo > 1e-6 * max(abs(hi), 1.0)):
lo, hi = lo - 0.5, hi + 0.5
return lo, hi
def build_context(
rollouts: list[RolloutSpec],
reference: str | Path | pl.LazyFrame,
*,
n_energy_bins: int = 4,
n_marginal_bins: int = 50,
n_sec_bins: int = 40,
top_k_pdg: int = 6,
sample_rows: int = 1_000_000,
seed: int = 0,
) -> Context:
"""Resolve the shared context from the reference + every rollout (the ``prep`` step).
Every range/quantile below is the union of the reference and *all*
rollouts, so a single set of fixed bin edges/group sets is valid for
every series a compute job streams over.
"""
t_all = open_side(reference, Side.reference)
t_lf = physical_steps(t_all, Side.reference)
r_lfs = {rs.name: physical_steps(open_side(rs.source, Side.rollout), Side.rollout) for rs in rollouts}
# Ranged marginal variables: robust ranges over a shared row subsample.
exprs = [e.alias(n) for n, (_, e) in RANGED_VARS.items()]
t_s = _row_subsample(t_lf, sample_rows, seed).select(exprs).collect(engine="streaming")
r_s = {
name: _row_subsample(lf, sample_rows, seed).select(exprs).collect(engine="streaming")
for name, lf in r_lfs.items()
}
var_ranges = {
name: _combined_quantiles([t_s[name].to_numpy(), *(df[name].to_numpy() for df in r_s.values())], _LO_Q, _HI_Q)
for name in RANGED_VARS
}
# Energy-bin edges from exact per-event incident energies (cheap group_by).
def _incident(lf: pl.LazyFrame) -> np.ndarray:
return lf.group_by("event_id").agg(pl.col("pre_E").max()).collect(engine="streaming")["pre_E"].to_numpy()
t_inc = _incident(t_lf)
r_inc = {name: _incident(lf) for name, lf in r_lfs.items()}
energy_edges = energy_bin_edges(np.concatenate([t_inc, *r_inc.values()]), n_energy_bins)
# Top PDG species and material list (cheap single-column group_bys).
def _counts(lf: pl.LazyFrame, col: str) -> pl.DataFrame:
return lf.group_by(col).agg(pl.len().alias("n")).collect(engine="streaming")
pdg_counts = (
pl.concat([_counts(t_lf, "pdg"), *(_counts(lf, "pdg") for lf in r_lfs.values())])
.group_by("pdg")
.agg(pl.col("n").sum())
.sort("n", descending=True)
)
top_pdgs = [int(x) for x in pdg_counts["pdg"].to_list()[:top_k_pdg]]
material_set: set[str] = set(_counts(t_lf, "material")["material"].to_list())
for lf in r_lfs.values():
material_set |= set(_counts(lf, "material")["material"].to_list())
materials = sorted(material_set)
# Shower depth / transverse ranges from a subsampled proxy.
def _proxy(lf: pl.LazyFrame) -> tuple[np.ndarray, np.ndarray]:
ea = entry_axis(lf)
sub = (
attach_entry_axis(_row_subsample(lf, sample_rows, seed), ea)
.select(depth_expr().alias("d"), transverse_expr().alias("t"))
.collect(engine="streaming")
)
return sub["d"].to_numpy(), sub["t"].to_numpy()
t_d, t_t = _proxy(t_lf)
r_proxy = {name: _proxy(lf) for name, lf in r_lfs.items()}
d_lo, d_hi = _combined_quantiles([t_d, *(p[0] for p in r_proxy.values())], _LO_Q, _HI_Q)
depth_edges = np.linspace(d_lo, d_hi, n_marginal_bins + 1)
t_hi = max(float(np.quantile(np.concatenate([t_t, *(p[1] for p in r_proxy.values())]), _HI_Q)), 1e-6)
transverse_edges = np.linspace(0.0, t_hi, n_marginal_bins + 1)
# Secondary energy range.
t_se = _row_sample_col(secondaries(t_all, Side.reference).select("energy"), sample_rows, seed)
r_se = {
name: _row_sample_col(secondaries(lf, Side.rollout).select("energy"), sample_rows, seed)
for name, lf in r_lfs.items()
}
sec_energy_range = _combined_quantiles([t_se, *r_se.values()], _LO_Q, _HI_Q)
return Context(
n_marginal_bins=n_marginal_bins,
var_ranges=var_ranges,
energy_edges=[float(x) for x in energy_edges],
top_pdgs=top_pdgs,
materials=materials,
depth_edges=[float(x) for x in depth_edges],
transverse_edges=[float(x) for x in transverse_edges],
sec_energy_range=sec_energy_range,
n_sec_bins=n_sec_bins,
n_events={
"reference": len(t_inc),
**{name: len(arr) for name, arr in r_inc.items()},
},
)
def _row_sample_col(lf: pl.LazyFrame, sample_rows: int, seed: int) -> np.ndarray:
"""Collect a subsample of a single-column ``energy`` LazyFrame to numpy."""
vals = lf.collect(engine="streaming")["energy"].to_numpy()
if len(vals) > sample_rows:
rng = np.random.default_rng(seed)
vals = vals[rng.choice(len(vals), size=sample_rows, replace=False)]
return vals