Rewrite analysis as streaming rollout-vs-reference plotting pipeline
CI / Lint (ruff check) (push) Failing after 4s
CI / Format (ruff format) (push) Failing after 4s
CI / Type check (ty) (push) Failing after 3s
CI / Tests (push) Failing after 4s
CI / Bump version, build & publish wheel (push) Has been skipped

Replace the monolithic giant/analysis.py (predict-local + RolloutVsTruth
diagnostics) with a lean giant/analysis/ package that compares one
autoregressive `giant rollout` for a checkpoint against a held-out
miniCaloSim reference file, and generates publication-styled plots in
parallel on HTCondor.

Rollout output and a raw reference file share a world-frame physical
column subset under identical names, so the old ALR/local-frame decode
machinery is gone — everything is world-frame mm/MeV.

- sources.py: canonical LazyFrames, synthetic-termination-row filtering,
  the secondary view (rollout generation>0 tracks vs reference sec_*_list).
- reduce.py: streaming primitives — a single hist1d group_by pass, per-event
  scalars, edep-weighted depth/transverse profiles, species share, leakage.
- context.py/grouping.py: prep resolves fixed bin edges + energy/pdg/material
  group sets once into shared.json, so each compute job is one pass, no range
  scan (histogram efficiency).
- catalog.py: declarative PlotSpec registry — marginals x {overall,energy,pdg,
  material}, per-event totals, shower profiles, species/leakage, secondaries.
- render.py: the only plotstyle/LaTeX importer; PDFs + gallery metadata.
- condor.py + `giant analyze` CLI (prep/compute-one/list/render/submit):
  one job per plot, compute/render split (workers polars-only, no LaTeX).

Styling via ETPlot's plotstyle (added to the analysis extra). New tests cover
the reduce primitives, catalog id uniqueness + compute, condor submit, and a
guarded render smoke test. Delete the two predict-diagnostics notebooks.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 17:38:09 +02:00
parent 4f785c43e6
commit f4c2545e8b
22 changed files with 2394 additions and 4680 deletions
+6 -1
View File
@@ -14,6 +14,9 @@ giant train path/to/steps.parquet --mode flow # train (flow matching)
giant train path/to/steps.parquet --mode ddpm # train (DDPM baseline)
giant predict path/to/steps.parquet --checkpoint ckpt/best.pt # per-step predictions
giant rollout path/to/steps.parquet --checkpoint ckpt/best.pt --geometry oracle.pkl # full showers
giant analyze submit --rollout roll.parquet --reference test.parquet --out-dir run/ \
--accounting-group cms # parallel rollout-vs-reference analysis on HTCondor
giant analyze render --reduced-dir run/reduced --out run/plots --gallery # render PDFs + HTML gallery
dwarf --help # dataset/tooling CLI: convert, migrate, bump-gen,
# bump-schema, status, update-manifest, create-manifest,
# make-root, build-geometry-oracle, hparam-scan
@@ -54,7 +57,9 @@ GIANT is a conditional generative surrogate for the Geant4 step function. It rep
**Samplers** (`giant/sample.py`): DDPM, DDIM, and flow matching (ODE integration, ~10 steps). Flow matching is the primary mode.
**Validation** (`giant/validate.py`): step-level marginal comparisons. `giant/analysis.py` is a fully-streaming (lazy polars) diagnostics module, sized for predict/rollout files larger than RAM, with no in-memory `SampleCollection` and no full-array materialization. It covers one-step-ahead `giant predict --coord local` output (`compute_event_observables_pl` + `plot_total_energy`/`plot_longitudinal_profile`/etc. for shower-level observables, plus the marginal/correlation/constraint tiers) and, via the `RolloutVsTruth` source type, a full autoregressive `giant rollout` shower compared against held-out truth data (`compute_rollout_vs_truth_observables_pl` for shower-level observables, reusing the same plot functions) — see the module docstring.
**Validation** (`giant/validate.py`): step-level marginal comparisons.
**Analysis** (`giant/analysis/`, `giant analyze` CLI): a lean, streaming rollout-vs-reference plotting pipeline that compares one autoregressive `giant rollout` (for a given checkpoint) against a held-out miniCaloSim reference steps file, and produces publication-styled PDFs assembled into an HTML gallery. It exploits the fact that rollout output and a raw reference file share a world-frame physical column subset under identical names (`pre_*`/`post_*`/`edep`/`step_length`/`pdg`/`material`/`event_id`), so no ALR/local-frame decode is needed — everything is world-frame mm/MeV. Structure: `sources.py` (canonical LazyFrames + synthetic-termination-row filtering + the secondary view, which is `generation>0 & step_no==0` rollout tracks vs exploded `sec_*_list` reference columns), `reduce.py` (the streaming primitives — a single `hist1d` `group_by([group,bin]).len()` pass, per-event scalars, edep-weighted depth/transverse profiles, species share, leakage), `grouping.py`/`context.py` (fixed bin edges + energy-quantile/pdg/material group sets resolved once by `prep` into `shared.json`, so every compute job is one pass with no range scan), `catalog.py` (the declarative `PlotSpec` registry — marginals × {overall,energy,pdg,material}, per-event totals, shower profiles, species/leakage, secondaries), and `render.py` (the only module importing ETPlot's `plotstyle`/LaTeX; dispatches on `Reduced.kind`, writes PDFs + `metadata.yaml`). **Compute/render split:** `giant analyze submit` runs `prep` then submits one HTCondor job per plot (`compute-one`, polars/numpy only — no LaTeX on workers), each writing a small `reduced/<id>.json`; the local `giant analyze render` turns those into the styled PDF/gallery tree. See `giant/analysis/__init__.py`.
**Shower rollout** (`giant/rollout.py`, `giant rollout` CLI): autoregressively steps the two-stage model into a full shower — each primary post-step becomes the next pre-step, secondaries are pushed as new tracks, and per-step `material`/`layer_id` come from a `GeometryOracle` (`giant/geometry.py`, built via `dwarf build-geometry-oracle`) that learns position → (material, layer_id) from data and flags detector escape by nearest-neighbour distance. Tracks terminate on energy cutoff, per-track max steps, escape, or natural end; energy is deposited locally on every stop except escape (leakage), so showers conserve energy by construction.
-438
View File
@@ -1,438 +0,0 @@
{
"cells": [
{
"cell_type": "code",
"execution_count": null,
"id": "f91460f3",
"metadata": {},
"outputs": [],
"source": [
"# Auto-reload edited modules (e.g. giant.analysis) without restarting the kernel.\n",
"%load_ext autoreload\n",
"%autoreload 2"
]
},
{
"cell_type": "markdown",
"id": "0f10da93",
"metadata": {},
"source": [
"# GIANT rollout-vs-truth validation notebook\n",
"\n",
"Diagnostics for a full autoregressive `giant rollout` shower, compared against a held-out ground-truth steps file (the same schema `giant train` consumes — see `giant.data.loader.load_steps`) rather than one-step-ahead `giant predict` output.\n",
"\n",
"This is the sibling of `validation.ipynb`: that notebook checks whether one-step generation (conditioned on the *real* preceding state, every row) reproduces real marginals/correlations/shower observables. This one checks the thing that actually matters for deployment — whether a shower **rolled out autoregressively from the model's own outputs** still looks physical, which is where covariate shift (small per-step errors compounding across a track) would show up.\n",
"\n",
"Built on `RolloutVsTruth`, which treats the rollout file as \"generated\" and the truth file as \"real\". Unlike the paired predict-parquet `source` (`pred_*`/`true_*` columns of the same row), the two files here are **independent, unpaired datasets** — a rollout doesn't replay real events row-for-row, so real/generated may have different lengths and there's no per-row correspondence. Everything below only ever compares real-vs-generated *distributions*, never individual paired rows, and every check still streams (no `SampleCollection`, no full-file materialization) — see `giant.analysis`'s module docstring for the `RolloutVsTruth` mechanics.\n",
"\n",
"Same four tiers as `validation.ipynb`, all built on the same functions — pass a `RolloutVsTruth` in place of the predict-parquet path/LazyFrame everywhere:\n",
"\n",
"1. **stratified marginals** — per-dimension real-vs-generated, sliced by pdg/material/energy\n",
"2. **joint structure** — correlation matrices, physically-coupled pairwise plots, direction alignment\n",
"3. **physical constraints** — unit-norm directions, non-negative step_length/delta_e/edep (checked on the rollout's own output — with autoregression, a constraint violation early in a track can compound into later steps, unlike one-step-ahead validation)\n",
"4. **event-level (shower) observables** — total/mean/median energy and length per event, longitudinal/transverse profiles, shower-max depth, computed directly from the rollout shower against the truth file's own events (`compute_rollout_vs_truth_observables_pl`, the Tier 4 counterpart to `RolloutVsTruth`)"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "251dc1f7",
"metadata": {},
"outputs": [],
"source": [
"from giant.analysis import RolloutVsTruth, plot_kl_bars_pl\n",
"\n",
"# `giant rollout` output for the shower(s) under test.\n",
"ROLLOUT_FILE = (\n",
" \"/ceph/lbogner/geant_steps/predictions/9e76bc2c-f4ef-4488-9f62-b6d14e1f298e.parquet\"\n",
")\n",
"# Any held-out file sharing giant train's input schema (real miniCaloSim\n",
"# steps) — e.g. the val split the rollout's seed events were drawn from.\n",
"TRUTH_FILE = (\n",
" \"/ceph/lbogner/geant_steps/processed/steps/gen3/schema2/pbwo4/shard-009.parquet\"\n",
")\n",
"\n",
"# sample_frac subsamples each side of the Tier 1-3 checks independently\n",
"# (kept memory-bounded for large files); defaults to every row. Tier 4\n",
"# (compute_rollout_vs_truth_observables_pl, below) always streams every row\n",
"# regardless — per-event sums would be silently corrupted by row subsampling.\n",
"SOURCE = RolloutVsTruth(rollout=ROLLOUT_FILE, truth=TRUTH_FILE)"
]
},
{
"cell_type": "markdown",
"id": "df4bf24b",
"metadata": {},
"source": [
"## Tier 1: stratified marginals\n",
"\n",
"KL(real || generated) per target dimension, streamed straight from both files."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "13cd0838",
"metadata": {},
"outputs": [],
"source": [
"for grouping in [None, \"energy\", \"pdg\", \"material\"]:\n",
" fig = plot_kl_bars_pl(SOURCE, group_by=grouping)\n",
" fig.show()"
]
},
{
"cell_type": "markdown",
"id": "aba92bf9",
"metadata": {},
"source": [
"## Detailed marginals (Tier 1, overlaid histograms)"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "7bab2e35",
"metadata": {},
"outputs": [],
"source": [
"from giant.analysis import plot_marginals, plot_correlation_matrices, plot_pairwise\n",
"from giant.analysis import plot_direction_alignment, plot_constraint_violations\n",
"\n",
"_ = plot_marginals(SOURCE)"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "6c2958e7",
"metadata": {},
"outputs": [],
"source": [
"_ = plot_marginals(SOURCE, group_by=\"energy\")"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "0fe70835",
"metadata": {},
"outputs": [],
"source": [
"_ = plot_marginals(SOURCE, group_by=\"pdg\")"
]
},
{
"cell_type": "markdown",
"id": "038dda3b",
"metadata": {},
"source": [
"## Tier 2: joint structure"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "3c463d50",
"metadata": {},
"outputs": [],
"source": [
"# Real vs. generated Pearson correlation matrices (+ their difference) over\n",
"# the 9 raw target dims — catches a model that decorrelates targets that are\n",
"# physically coupled even when every individual marginal looks clean.\n",
"_ = plot_correlation_matrices(SOURCE)"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "393c6845",
"metadata": {},
"outputs": [],
"source": [
"# Scatter for physically-coupled pairs (step_length/delta_e/edep) — the\n",
"# joint-structure check correlation matrices alone can't fully capture.\n",
"_ = plot_pairwise(SOURCE, n_sample=10000)"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "1dab5305",
"metadata": {},
"outputs": [],
"source": [
"# cos(angle) between post_dir and travel_dir — coupled through the\n",
"# scattering physics, so this is another joint-structure check.\n",
"_ = plot_direction_alignment(SOURCE)"
]
},
{
"cell_type": "markdown",
"id": "7232d3b4",
"metadata": {},
"source": [
"## Tier 3: physical constraints\n",
"\n",
"Unit-norm direction vectors, non-negative step_length/delta_e/edep. `constraint_report_pl`/`plot_constraint_violations` only ever check the *generated* side (here the rollout output) — under autoregression a violation isn't just a one-off artifact, it can feed the next step's conditioning, so this is worth watching more closely here than in one-step-ahead validation."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "86be25fa",
"metadata": {},
"outputs": [],
"source": [
"_ = plot_constraint_violations(SOURCE)"
]
},
{
"cell_type": "markdown",
"id": "ed4d3037",
"metadata": {},
"source": [
"## Tier 4: event-level (shower) observables\n",
"\n",
"Built on `compute_rollout_vs_truth_observables_pl`, not `compute_event_observables_pl` — the rollout file carries its own `track_id`/`termination_reason` columns the event-level aggregation needs, and the shower here already *is* a full autoregressive rollout rather than one-step generations re-aggregated by event. Entry axis/point and per-event totals are computed separately per side (rollout and truth events are unrelated), but depth/transverse bin edges are shared across both so the profiles below overlay on one binning.\n",
"\n",
"Returns the same `EventObservables` `compute_event_observables_pl` does, so every plot function from `validation.ipynb` works unchanged here too."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "9175876a",
"metadata": {},
"outputs": [],
"source": [
"from giant.analysis import compute_rollout_vs_truth_observables_pl\n",
"from giant.analysis import plot_total_energy, plot_total_length\n",
"from giant.analysis import plot_mean_energy_per_step, plot_mean_length_per_step\n",
"from giant.analysis import plot_longitudinal_profile, plot_transverse_profile\n",
"from giant.analysis import plot_shower_max_depth\n",
"\n",
"obs = compute_rollout_vs_truth_observables_pl(ROLLOUT_FILE, TRUTH_FILE)"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "83e6dd0e",
"metadata": {},
"outputs": [],
"source": [
"_ = plot_total_energy(obs)"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "1e35a387",
"metadata": {},
"outputs": [],
"source": [
"_ = plot_total_length(obs)"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "9221e682",
"metadata": {},
"outputs": [],
"source": [
"_ = plot_mean_energy_per_step(obs)"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "4536acb0",
"metadata": {},
"outputs": [],
"source": [
"_ = plot_mean_length_per_step(obs)"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "3a03c04f",
"metadata": {},
"outputs": [],
"source": [
"_ = plot_longitudinal_profile(obs)"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "eae0536e",
"metadata": {},
"outputs": [],
"source": [
"_ = plot_transverse_profile(obs)"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "98c221e7",
"metadata": {},
"outputs": [],
"source": [
"_ = plot_shower_max_depth(obs)"
]
},
{
"cell_type": "markdown",
"id": "208ca6e4",
"metadata": {},
"source": [
"---\n",
"\n",
"For the dataset-wide breakdown of which particle species contributed how much of the total energy/length (`pdg_contribution_table_pl`), see `validation.ipynb` — it needs the paired predict schema, which this rollout-vs-truth comparison doesn't have."
]
},
{
"cell_type": "markdown",
"id": "7fb27b941602401d91542211134fc71a",
"metadata": {},
"source": [
"## Router gating showcase (MoE)\n",
"\n",
"Every other section above is file-only — it reads `ROLLOUT_FILE` and never touches\n",
"a checkpoint (see `giant.analysis`'s module docstring). This section is the one\n",
"deliberate exception: soft gate weights only exist inside the trained `Router`,\n",
"not in the rollout parquet, so this loads the checkpoint that produced\n",
"`ROLLOUT_FILE` and calls `model.router.gate(...)` directly on that shower's\n",
"pre-step conditioning.\n",
"\n",
"`model.router` is Stage 1's router; Stage 2 (`sec_decoder.router`) is a separate,\n",
"independently trained `Router` instance over the same axis (see\n",
"`giant.model.network.build_models`) and isn't shown here.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "acae54e37e7d407bbb7b55eff062a284",
"metadata": {},
"outputs": [],
"source": [
"import numpy as np\n",
"import polars as pl\n",
"import torch\n",
"\n",
"from giant.analysis import plot_router_gating\n",
"from giant.data.transforms import Normalizer, build_cond_features\n",
"from giant.model.network import build_models\n",
"\n",
"# Checkpoint that produced ROLLOUT_FILE (needs `model.router` enabled at\n",
"# train time, i.e. trained with `--router` / `model.router.enabled = true`).\n",
"CHECKPOINT = \"/ceph/lbogner/geant_steps/checkpoints/REPLACE_ME/best.pt\"\n",
"\n",
"ckpt = torch.load(CHECKPOINT, map_location=\"cpu\", weights_only=False)\n",
"model_cfg = ckpt[\"model_config\"]\n",
"conditioning = model_cfg.get(\"conditioning\", \"embedding\")\n",
"pdg_map = {int(k): v for k, v in ckpt[\"pdg_map\"].items()}\n",
"mat_map = {str(k): v for k, v in ckpt[\"mat_map\"].items()}\n",
"cond_norm = Normalizer.from_dict(ckpt[\"normalizer\"][\"cond\"])\n",
"\n",
"model, _sec_decoder = build_models(model_cfg)\n",
"model.load_state_dict(ckpt[\"model\"])\n",
"model.eval()\n",
"\n",
"if not hasattr(model, \"router\"):\n",
" raise RuntimeError(\n",
" f\"{CHECKPOINT} has no router — it was trained with model.router.enabled=False\"\n",
" )"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "9a63283cbaf04dbcab1f6479b197f3a8",
"metadata": {},
"outputs": [],
"source": [
"# Pre-step conditioning for every row of the rollout shower, reconstructed\n",
"# the same way `giant predict`/`giant rollout` do (giant.data.transforms).\n",
"cols = [\n",
" \"pdg\",\n",
" \"pre_x\",\n",
" \"pre_y\",\n",
" \"pre_z\",\n",
" \"pre_E\",\n",
" \"pre_dx\",\n",
" \"pre_dy\",\n",
" \"pre_dz\",\n",
" \"material\",\n",
" \"layer_id\",\n",
"]\n",
"df = pl.read_parquet(ROLLOUT_FILE, columns=cols)\n",
"\n",
"# Rows whose pdg/material fell outside the training vocab can't be encoded\n",
"# (mirrors the pdg_mask filtering in `giant predict`'s CLI path).\n",
"known = df[\"pdg\"].map_elements(\n",
" lambda p: int(p) in pdg_map, return_dtype=pl.Boolean\n",
") & df[\"material\"].map_elements(lambda m: str(m) in mat_map, return_dtype=pl.Boolean)\n",
"n_dropped = (~known).sum()\n",
"if n_dropped:\n",
" print(f\"dropping {n_dropped}/{len(df)} rows with unknown pdg/material\")\n",
"df = df.filter(known)\n",
"\n",
"data = {\n",
" \"pre_pos\": df.select(\"pre_x\", \"pre_y\", \"pre_z\").to_numpy().astype(np.float32),\n",
" \"pre_E\": df[\"pre_E\"].to_numpy().astype(np.float32),\n",
" \"pre_dir\": df.select(\"pre_dx\", \"pre_dy\", \"pre_dz\").to_numpy().astype(np.float32),\n",
" \"layer_id\": df[\"layer_id\"].to_numpy(),\n",
" \"pdg\": df[\"pdg\"].to_numpy(),\n",
" \"material\": df[\"material\"].to_numpy(),\n",
"}\n",
"cond_cont, cond_cat = build_cond_features(\n",
" data, pdg_map, mat_map, cond_norm, conditioning=conditioning\n",
")\n",
"cc = torch.from_numpy(cond_cont).float()\n",
"ck = torch.from_numpy(cond_cat).long()\n",
"\n",
"with torch.no_grad():\n",
" gate_weights = model.router.gate(cc, ck).numpy() # (N, n_experts), rows sum to 1"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "8dd0d8092fe74a7c96281538738b07e2",
"metadata": {},
"outputs": [],
"source": [
"# EnergyRouter gates on pre-step energy, so that's the natural x-axis here —\n",
"# swap for a categorical plot if this checkpoint used a different router type.\n",
"_ = plot_router_gating(data[\"pre_E\"], gate_weights, x_label=\"pre_E\")"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "giant (3.12.13.final.0)",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.12.13"
}
},
"nbformat": 4,
"nbformat_minor": 5
}
File diff suppressed because one or more lines are too long
-2359
View File
File diff suppressed because it is too large Load Diff
+30
View File
@@ -0,0 +1,30 @@
"""Rollout-vs-reference analysis: streaming compute + plotstyle rendering.
Compares one autoregressive ``giant rollout`` against a held-out miniCaloSim
reference file, producing publication-styled comparison plots generated in
parallel on HTCondor (one job per plot, compute/render split).
Only ``render`` (and the ``render`` CLI path) imports plotstyle/LaTeX; everything
re-exported here is plotstyle-free so it runs on a compute worker. Import
``giant.analysis.render`` explicitly for the local render step.
"""
from giant.analysis.catalog import build_catalog, catalog_ids, get_spec
from giant.analysis.condor import SubmitConfig, compute_one, prep, write_submit
from giant.analysis.context import Context, build_context
from giant.analysis.reduced import Reduced
from giant.analysis.sources import Side
__all__ = [
"build_catalog",
"catalog_ids",
"get_spec",
"SubmitConfig",
"compute_one",
"prep",
"write_submit",
"Context",
"build_context",
"Reduced",
"Side",
]
+574
View File
@@ -0,0 +1,574 @@
"""The declarative plot catalog: one ``PlotSpec`` per figure.
Each spec knows its stable ``id`` (used for the reduced-data filename, the PDF
stem and the condor queue item), its gallery ``family`` (subdirectory), and a
``compute(bundle) -> Reduced`` that runs the streaming reduction. Rendering lives
in ``render.py`` and dispatches on ``Reduced.kind`` — the catalog itself never
imports plotstyle, so ``compute-one`` jobs stay LaTeX-free.
The registry is built by expanding parametric families (marginals over
variable x grouping, secondaries, ...) into concrete specs.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Callable
import numpy as np
import polars as pl
from giant.analysis.context import Context
from giant.analysis.grouping import (
energy_bin_labels,
event_energy_bins,
material_label,
pdg_label,
)
from giant.analysis.reduce import (
attach_entry_axis,
depth_expr,
entry_axis,
event_scalars,
hist1d,
leakage_fraction,
species_share,
transverse_expr,
weighted_profile,
)
from giant.analysis.reduced import Reduced
from giant.analysis.sources import Side, open_side, physical_steps, secondaries
from giant.analysis.variables import RANGED_VARS, cos_scatter_expr
@dataclass
class Bundle:
"""Everything a compute runs against — built once per ``compute-one`` job."""
ctx: Context
r_all: pl.LazyFrame # rollout, all rows (incl. synthetic termination rows)
t_all: pl.LazyFrame # reference, all rows
r_phys: pl.LazyFrame # rollout, physical steps only
t_phys: pl.LazyFrame # reference, physical steps only
@classmethod
def open(cls, rollout, reference, ctx: Context) -> "Bundle":
r_all = open_side(rollout, Side.rollout)
t_all = open_side(reference, Side.reference)
return cls(
ctx=ctx,
r_all=r_all,
t_all=t_all,
r_phys=physical_steps(r_all, Side.rollout),
t_phys=physical_steps(t_all, Side.reference),
)
@dataclass
class PlotSpec:
id: str
family: str
compute: Callable[[Bundle], Reduced]
# ---------------------------------------------------------------------------
# small numpy/hist helpers
# ---------------------------------------------------------------------------
_ROLL = "rollout"
_REF = "reference"
def _counts(h: dict, key, nbins: int) -> list[int]:
return h.get(key, np.zeros(nbins, dtype=np.int64)).astype(np.int64).tolist()
def _np_hist_pair(
r: np.ndarray, t: np.ndarray, nbins: int
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
"""Shared-edge histogram of two small per-event arrays (robust range)."""
both = np.concatenate([r, t]) if (len(r) or len(t)) else np.array([0.0, 1.0])
lo, hi = float(np.quantile(both, 0.001)), float(np.quantile(both, 0.999))
if not (hi - lo > 1e-6 * max(abs(hi), 1.0)):
lo, hi = lo - 0.5, hi + 0.5
edges = np.linspace(lo, hi, nbins + 1)
return edges, np.histogram(r, edges)[0], np.histogram(t, edges)[0]
# Human-readable figure titles per marginal variable (the axis labels carry units;
# these read cleanly as a title without them).
_TITLE_NAMES = {
"step_length": "Step length",
"edep": "Deposited energy per step",
"delta_e": "Energy loss per step",
"post_E": "Post-step energy",
"cos_scatter": "Scattering cosine",
}
def _var(var: str):
"""(axis label, value expr) for a marginal variable name."""
if var == "cos_scatter":
return ("cos of scattering angle", cos_scatter_expr())
label, expr = RANGED_VARS[var]
return (label, expr)
def _marginal_edges(ctx: Context, var: str) -> np.ndarray:
if var == "cos_scatter":
return np.linspace(-1.0, 1.0, ctx.n_marginal_bins + 1)
return ctx.marginal_edges(var)
# ---------------------------------------------------------------------------
# marginals: variable x {overall, energy, pdg, material}
# ---------------------------------------------------------------------------
def _marginal_overall(b: Bundle, var: str) -> Reduced:
label, expr = _var(var)
edges = _marginal_edges(b.ctx, var)
nb = len(edges) - 1
r = hist1d(b.r_phys, expr, edges)
t = hist1d(b.t_phys, expr, edges)
return Reduced(
id=f"marginal_{var}",
family="marginals",
kind="overlay_hist",
title=_TITLE_NAMES[var],
xlabel=label,
payload={
"edges": edges.tolist(),
_ROLL: _counts(r, 0, nb),
_REF: _counts(t, 0, nb),
"log_y": True,
},
)
def _energy_group_expr(lf: pl.LazyFrame, edges: np.ndarray) -> pl.Expr:
ids, bins = event_energy_bins(lf, edges)
return pl.col("event_id").replace_strict(
ids, bins, default=-1, return_dtype=pl.Int64
)
def _marginal_grouped(b: Bundle, var: str, axis: str) -> Reduced:
label, expr = _var(var)
edges = _marginal_edges(b.ctx, var)
nb = len(edges) - 1
groups: dict[str, dict] = {}
if axis == "pdg":
r = hist1d(b.r_phys, expr, edges, group=pl.col("pdg"))
t = hist1d(b.t_phys, expr, edges, group=pl.col("pdg"))
for k in b.ctx.top_pdgs:
groups[pdg_label(k)] = {_ROLL: _counts(r, k, nb), _REF: _counts(t, k, nb)}
elif axis == "material":
r = hist1d(b.r_phys, expr, edges, group=pl.col("material"))
t = hist1d(b.t_phys, expr, edges, group=pl.col("material"))
for m in b.ctx.materials:
groups[material_label(m)] = {
_ROLL: _counts(r, m, nb),
_REF: _counts(t, m, nb),
}
else: # energy
e_edges = np.asarray(b.ctx.energy_edges)
r = hist1d(b.r_phys, expr, edges, group=_energy_group_expr(b.r_phys, e_edges))
t = hist1d(b.t_phys, expr, edges, group=_energy_group_expr(b.t_phys, e_edges))
for bi, lbl in enumerate(energy_bin_labels(e_edges)):
groups[lbl] = {_ROLL: _counts(r, bi, nb), _REF: _counts(t, bi, nb)}
return Reduced(
id=f"marginal_{var}_by_{axis}",
family="marginals",
kind="grouped_hist",
title=f"{_TITLE_NAMES[var]} by {axis}",
xlabel=label,
payload={"edges": edges.tolist(), "groups": groups, "log_y": True},
)
# ---------------------------------------------------------------------------
# per-event scalar observables
# ---------------------------------------------------------------------------
def _event_scalar(
b: Bundle, spec_id: str, title: str, xlabel: str, col: str, use_all: bool
) -> Reduced:
r_lf, t_lf = (b.r_all, b.t_all) if use_all else (b.r_phys, b.t_phys)
r = event_scalars(r_lf)[col].to_numpy()
t = event_scalars(t_lf)[col].to_numpy()
edges, rc, tc = _np_hist_pair(r, t, b.ctx.n_marginal_bins)
return Reduced(
id=spec_id,
family="event",
kind="overlay_hist",
title=title,
xlabel=xlabel,
payload={
"edges": edges.tolist(),
_ROLL: rc.astype(np.int64).tolist(),
_REF: tc.astype(np.int64).tolist(),
"log_y": False,
},
)
def _event_total_edep_by_energy(b: Bundle) -> Reduced:
e_edges = np.asarray(b.ctx.energy_edges)
r = event_scalars(b.r_all)
t = event_scalars(b.t_all)
r_bin = np.clip(
np.digitize(r["incident_E"].to_numpy(), e_edges[1:-1]), 0, len(e_edges) - 2
)
t_bin = np.clip(
np.digitize(t["incident_E"].to_numpy(), e_edges[1:-1]), 0, len(e_edges) - 2
)
r_val, t_val = r["total_edep"].to_numpy(), t["total_edep"].to_numpy()
edges, _, _ = _np_hist_pair(r_val, t_val, b.ctx.n_marginal_bins)
groups: dict[str, dict] = {}
for bi, lbl in enumerate(energy_bin_labels(e_edges)):
rc = np.histogram(r_val[r_bin == bi], edges)[0]
tc = np.histogram(t_val[t_bin == bi], edges)[0]
groups[lbl] = {
_ROLL: rc.astype(np.int64).tolist(),
_REF: tc.astype(np.int64).tolist(),
}
return Reduced(
id="event_total_edep_by_energy",
family="event",
kind="grouped_hist",
title="Total deposited energy per event by incident energy",
xlabel="total deposited energy [MeV]",
payload={"edges": edges.tolist(), "groups": groups, "log_y": False},
)
# ---------------------------------------------------------------------------
# shower shape profiles
# ---------------------------------------------------------------------------
def _profile(
b: Bundle, spec_id: str, title: str, xlabel: str, coord_fn, edges_key: str
) -> Reduced:
edges = np.asarray(getattr(b.ctx, edges_key))
r_ea, t_ea = entry_axis(b.r_all), entry_axis(b.t_all)
r_lf = attach_entry_axis(b.r_all, r_ea)
t_lf = attach_entry_axis(b.t_all, t_ea)
r_mean, r_std = weighted_profile(r_lf, coord_fn(), edges, pl.col("edep"))
t_mean, t_std = weighted_profile(t_lf, coord_fn(), edges, pl.col("edep"))
return Reduced(
id=spec_id,
family="shower",
kind="profile",
title=title,
xlabel=xlabel,
payload={
"edges": edges.tolist(),
"rollout_mean": r_mean.tolist(),
"rollout_std": r_std.tolist(),
"reference_mean": t_mean.tolist(),
"reference_std": t_std.tolist(),
"ylabel": "mean deposited energy per event [MeV]",
},
)
# ---------------------------------------------------------------------------
# species share + leakage
# ---------------------------------------------------------------------------
def _species_share(b: Bundle) -> Reduced:
r = species_share(b.r_all)
t = species_share(b.t_all)
r_map = dict(zip(r["pdg"].to_list(), r["total_edep"].to_list()))
t_map = dict(zip(t["pdg"].to_list(), t["total_edep"].to_list()))
r_tot = sum(r_map.values()) or 1.0
t_tot = sum(t_map.values()) or 1.0
labels = [pdg_label(k) for k in b.ctx.top_pdgs]
return Reduced(
id="species_edep_share",
family="species",
kind="bar",
title="Deposited-energy share by species",
xlabel="species",
payload={
"labels": labels,
_ROLL: [r_map.get(k, 0.0) / r_tot for k in b.ctx.top_pdgs],
_REF: [t_map.get(k, 0.0) / t_tot for k in b.ctx.top_pdgs],
"ylabel": "fraction of total deposited energy",
},
)
def _leakage(b: Bundle) -> Reduced:
frac = leakage_fraction(b.r_all)
edges = np.linspace(
0.0,
max(float(frac.max()) if len(frac) else 1.0, 1e-3),
b.ctx.n_marginal_bins + 1,
)
counts = np.histogram(frac, edges)[0]
return Reduced(
id="leakage_fraction",
family="species",
kind="single_hist",
title="Escaped (leakage) energy fraction per shower",
xlabel="escaped energy fraction",
payload={
"edges": edges.tolist(),
_ROLL: counts.astype(np.int64).tolist(),
"log_y": True,
"note": "rollout only; the reference has no detector-escape concept",
},
)
# ---------------------------------------------------------------------------
# secondaries
# ---------------------------------------------------------------------------
def _sec_frames(b: Bundle):
return (
secondaries(b.r_phys, Side.rollout),
secondaries(b.t_all, Side.reference),
)
def _sec_count_per_event(b: Bundle) -> Reduced:
r_sec, t_sec = _sec_frames(b)
r = (
r_sec.group_by("event_id")
.agg(pl.len().alias("n"))
.collect(engine="streaming")["n"]
.to_numpy()
)
t = (
t_sec.group_by("event_id")
.agg(pl.len().alias("n"))
.collect(engine="streaming")["n"]
.to_numpy()
)
edges, rc, tc = _np_hist_pair(
r.astype(float), t.astype(float), min(b.ctx.n_marginal_bins, 40)
)
return Reduced(
id="sec_count_per_event",
family="secondaries",
kind="overlay_hist",
title="Number of secondaries per event",
xlabel="secondaries per event",
payload={
"edges": edges.tolist(),
_ROLL: rc.astype(np.int64).tolist(),
_REF: tc.astype(np.int64).tolist(),
"log_y": False,
},
)
def _sec_count_per_species(b: Bundle) -> Reduced:
r_sec, t_sec = _sec_frames(b)
r = dict(
zip(
*[
r_sec.group_by("pdg")
.agg(pl.len().alias("n"))
.collect(engine="streaming")[c]
.to_list()
for c in ("pdg", "n")
]
)
)
t = dict(
zip(
*[
t_sec.group_by("pdg")
.agg(pl.len().alias("n"))
.collect(engine="streaming")[c]
.to_list()
for c in ("pdg", "n")
]
)
)
keys = sorted(set(r) | set(t), key=lambda k: -(r.get(k, 0) + t.get(k, 0)))[
: len(b.ctx.top_pdgs)
]
return Reduced(
id="sec_count_per_species",
family="secondaries",
kind="bar",
title="Secondary count by species",
xlabel="species",
payload={
"labels": [pdg_label(k) for k in keys],
_ROLL: [float(r.get(k, 0)) for k in keys],
_REF: [float(t.get(k, 0)) for k in keys],
"ylabel": "secondary count",
},
)
def _sec_energy(b: Bundle) -> Reduced:
r_sec, t_sec = _sec_frames(b)
edges = np.linspace(*b.ctx.sec_energy_range, b.ctx.n_sec_bins + 1)
r = hist1d(r_sec, pl.col("energy"), edges)
t = hist1d(t_sec, pl.col("energy"), edges)
nb = len(edges) - 1
return Reduced(
id="sec_energy",
family="secondaries",
kind="overlay_hist",
title="Secondary birth energy",
xlabel="secondary energy [MeV]",
payload={
"edges": edges.tolist(),
_ROLL: _counts(r, 0, nb),
_REF: _counts(t, 0, nb),
"log_y": True,
},
)
def _sec_cos_angle(b: Bundle) -> Reduced:
edges = np.linspace(-1.0, 1.0, b.ctx.n_sec_bins + 1)
nb = len(edges) - 1
cos = (
pl.col("sdx") * pl.col("axis_x")
+ pl.col("sdy") * pl.col("axis_y")
+ pl.col("sdz") * pl.col("axis_z")
).clip(-1.0, 1.0)
def _side(sec_lf: pl.LazyFrame, steps_lf: pl.LazyFrame) -> list[int]:
ea = entry_axis(steps_lf)
return _counts(hist1d(attach_entry_axis(sec_lf, ea), cos, edges), 0, nb)
r_sec, t_sec = _sec_frames(b)
return Reduced(
id="sec_cos_angle",
family="secondaries",
kind="overlay_hist",
title="Secondary emission angle relative to the shower axis",
xlabel="cos of emission angle",
payload={
"edges": edges.tolist(),
_ROLL: _side(r_sec, b.r_phys),
_REF: _side(t_sec, b.t_all),
"log_y": False,
},
)
# ---------------------------------------------------------------------------
# registry assembly
# ---------------------------------------------------------------------------
MARGINAL_VARS = ["step_length", "edep", "delta_e", "post_E", "cos_scatter"]
GROUPING_AXES = ["energy", "pdg", "material"]
def build_catalog() -> list[PlotSpec]:
"""All concrete plot specs, each with a unique id."""
specs: list[PlotSpec] = []
for var in MARGINAL_VARS:
specs.append(
PlotSpec(
f"marginal_{var}", "marginals", lambda b, v=var: _marginal_overall(b, v)
)
)
for axis in GROUPING_AXES:
specs.append(
PlotSpec(
f"marginal_{var}_by_{axis}",
"marginals",
lambda b, v=var, a=axis: _marginal_grouped(b, v, a),
)
)
specs += [
PlotSpec(
"event_total_edep",
"event",
lambda b: _event_scalar(
b,
"event_total_edep",
"Total deposited energy per event",
"total deposited energy [MeV]",
"total_edep",
use_all=True,
),
),
PlotSpec("event_total_edep_by_energy", "event", _event_total_edep_by_energy),
PlotSpec(
"event_mean_length",
"event",
lambda b: _event_scalar(
b,
"event_mean_length",
"Mean step length per event",
"mean step length [mm]",
"mean_length",
use_all=False,
),
),
PlotSpec(
"event_n_steps",
"event",
lambda b: _event_scalar(
b,
"event_n_steps",
"Number of steps per event",
"steps per event",
"n_steps",
use_all=False,
),
),
PlotSpec(
"shower_longitudinal",
"shower",
lambda b: _profile(
b,
"shower_longitudinal",
"Longitudinal shower profile",
"depth along shower axis [mm]",
depth_expr,
"depth_edges",
),
),
PlotSpec(
"shower_transverse",
"shower",
lambda b: _profile(
b,
"shower_transverse",
"Transverse shower profile",
"radius from shower axis [mm]",
transverse_expr,
"transverse_edges",
),
),
PlotSpec("species_edep_share", "species", _species_share),
PlotSpec("leakage_fraction", "species", _leakage),
PlotSpec("sec_count_per_event", "secondaries", _sec_count_per_event),
PlotSpec("sec_count_per_species", "secondaries", _sec_count_per_species),
PlotSpec("sec_energy", "secondaries", _sec_energy),
PlotSpec("sec_cos_angle", "secondaries", _sec_cos_angle),
]
return specs
def catalog_ids() -> list[str]:
return [s.id for s in build_catalog()]
def get_spec(spec_id: str) -> PlotSpec:
for s in build_catalog():
if s.id == spec_id:
return s
raise KeyError(f"unknown plot id: {spec_id!r}")
+148
View File
@@ -0,0 +1,148 @@
"""HTCondor orchestration: prep, per-plot compute, and the submit description.
Job model (one condor job per plot, compute/render split):
1. ``prep`` runs once on the submit node — resolves the shared context (fixed bin
edges, energy quantiles, top species/materials) from a subsample and writes
``shared.json``. Cheap; no LaTeX.
2. one job per catalog id runs ``giant analyze compute-one`` on a worker — a
single streaming pass producing ``reduced/<id>.json``. polars/numpy only, no
LaTeX, so it needs no plotstyle in the container.
3. a final *local* ``giant analyze render`` turns every reduced artifact into a
styled PDF + gallery metadata (that step imports plotstyle/LaTeX).
Files on ``/ceph`` or ``/work`` are reached via ``ProvidesETPResources``; no
HTCondor file transfer of the multi-GB inputs.
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from giant.analysis.catalog import Bundle, catalog_ids, get_spec
from giant.analysis.context import Context, build_context
# ---------------------------------------------------------------------------
# per-plot compute (what each condor job runs)
# ---------------------------------------------------------------------------
def compute_one(
spec_id: str,
rollout: str | Path,
reference: str | Path,
shared: str | Path,
out: str | Path,
) -> Path:
"""Run one plot's streaming reduction and write its ``Reduced`` JSON."""
ctx = Context.load(shared)
bundle = Bundle.open(rollout, reference, ctx)
reduced = get_spec(spec_id).compute(bundle)
out = Path(out)
reduced.save(out)
return out
# ---------------------------------------------------------------------------
# submit description
# ---------------------------------------------------------------------------
@dataclass
class SubmitConfig:
rollout: Path
reference: Path
out_dir: Path
accounting_group: str
repo_dir: Path
docker_image: str = "mschnepf/slc7-condocker"
request_memory_mb: int = 4096
request_cpus: int = 1
request_walltime_s: int = 3600
remote: bool = False # +RemoteJob (grid I/O) vs ProvidesETPResources (local files)
_WRAPPER = """#!/bin/bash
set -euo pipefail
cd {repo_dir}
exec uv run giant analyze compute-one \\
--id "$1" \\
--rollout {rollout} \\
--reference {reference} \\
--shared {shared} \\
--out {reduced_dir}/"$1".json
"""
def _submit_description(cfg: SubmitConfig, wrapper: Path, ids_file: Path) -> str:
reqs_attrs = (
"+RemoteJob = True\nrequest_walltime = {wt}\n".format(wt=cfg.request_walltime_s)
if cfg.remote
else "requirements = TARGET.ProvidesETPResources\n"
)
return (
"universe = docker\n"
f"docker_image = {cfg.docker_image}\n"
f"executable = {wrapper}\n"
"arguments = $(plotid)\n"
"should_transfer_files = YES\n"
"when_to_transfer_output = ON_EXIT\n"
f"request_memory = {cfg.request_memory_mb}\n"
f"request_cpus = {cfg.request_cpus}\n"
f"+RequestWalltime = {cfg.request_walltime_s}\n"
f"accounting_group = {cfg.accounting_group}\n"
f"{reqs_attrs}"
"output = logs/$(plotid).out\n"
"error = logs/$(plotid).err\n"
"log = logs/condor.log\n"
f"queue plotid from {ids_file}\n"
)
def write_submit(cfg: SubmitConfig, ids: list[str] | None = None) -> Path:
"""Write the wrapper script, plot-id list, and HTCondor submit description.
Returns the path to the submit description (``<out_dir>/analyze.sub``). Does
not submit — call ``condor_submit`` on the returned file, or use ``submit``.
"""
ids = ids or catalog_ids()
out_dir = cfg.out_dir
reduced_dir = out_dir / "reduced"
(out_dir / "logs").mkdir(parents=True, exist_ok=True)
reduced_dir.mkdir(parents=True, exist_ok=True)
wrapper = out_dir / "run_compute.sh"
wrapper.write_text(
_WRAPPER.format(
repo_dir=cfg.repo_dir,
rollout=cfg.rollout,
reference=cfg.reference,
shared=out_dir / "shared.json",
reduced_dir=reduced_dir,
)
)
wrapper.chmod(0o755)
ids_file = out_dir / "plotids.txt"
ids_file.write_text("\n".join(ids) + "\n")
sub = out_dir / "analyze.sub"
sub.write_text(_submit_description(cfg, wrapper, ids_file))
return sub
def prep(
rollout: str | Path,
reference: str | Path,
out_dir: str | Path,
**kwargs,
) -> Path:
"""Build and save the shared context (``<out_dir>/shared.json``)."""
out_dir = Path(out_dir)
out_dir.mkdir(parents=True, exist_ok=True)
ctx = build_context(rollout, reference, **kwargs)
shared = out_dir / "shared.json"
ctx.save(shared)
return shared
+199
View File
@@ -0,0 +1,199 @@
"""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 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(
r_vals: np.ndarray, t_vals: np.ndarray, lo_q: float, hi_q: float
) -> tuple[float, float]:
"""Robust (lo_q, hi_q) range over the union of two value samples."""
both = np.concatenate([r_vals, t_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(
rollout: str | Path | pl.LazyFrame,
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 two files (the ``prep`` step)."""
r_all = open_side(rollout, Side.rollout)
t_all = open_side(reference, Side.reference)
r_lf = physical_steps(r_all, Side.rollout)
t_lf = physical_steps(t_all, Side.reference)
# Ranged marginal variables: robust ranges over a shared row subsample.
exprs = [e.alias(n) for n, (_, e) in RANGED_VARS.items()]
r_s = (
_row_subsample(r_lf, sample_rows, seed)
.select(exprs)
.collect(engine="streaming")
)
t_s = (
_row_subsample(t_lf, sample_rows, seed)
.select(exprs)
.collect(engine="streaming")
)
var_ranges = {
name: _combined_quantiles(
r_s[name].to_numpy(), t_s[name].to_numpy(), _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()
)
r_inc, t_inc = _incident(r_lf), _incident(t_lf)
energy_edges = energy_bin_edges(np.concatenate([r_inc, t_inc]), 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(r_lf, "pdg"), _counts(t_lf, "pdg")])
.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]]
materials = sorted(
set(_counts(r_lf, "material")["material"].to_list())
| set(_counts(t_lf, "material")["material"].to_list())
)
# 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()
r_d, r_t = _proxy(r_lf)
t_d, t_t = _proxy(t_lf)
d_lo, d_hi = _combined_quantiles(r_d, t_d, _LO_Q, _HI_Q)
depth_edges = np.linspace(d_lo, d_hi, n_marginal_bins + 1)
t_hi = max(float(np.quantile(np.concatenate([r_t, t_t]), _HI_Q)), 1e-6)
transverse_edges = np.linspace(0.0, t_hi, n_marginal_bins + 1)
# Secondary energy range.
r_se = secondaries(r_lf, Side.rollout).select("energy")
t_se = secondaries(t_all, Side.reference).select("energy")
r_se = _row_sample_col(r_se, sample_rows, seed)
t_se = _row_sample_col(t_se, sample_rows, seed)
sec_energy_range = _combined_quantiles(r_se, t_se, _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={
"rollout": len(r_inc),
"reference": len(t_inc),
},
)
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
+108
View File
@@ -0,0 +1,108 @@
"""Grouping axes (overall / energy / pdg / material) and their labels.
Energy grouping is by the event's **incident (primary) energy** — the largest
``pre_E`` in the event so every step of a shower lands in one bin, the physically
meaningful stratification for a calorimeter surrogate. The quantile bin *edges* are
sized once in ``prep`` (from a subsample) and shipped in ``shared.json``; a compute
job that needs them re-derives the small per-event ``event_id -> bin`` map itself
(one bounded streaming ``group_by`` over ``pre_E``), so ``shared.json`` stays tiny.
Pure/plotstyle-free so it can run on the compute workers.
"""
from __future__ import annotations
import numpy as np
import polars as pl
# Common electromagnetic/hadronic species; anything else falls back to its code.
PDG_NAMES: dict[int, str] = {
11: "e-",
-11: "e+",
22: "gamma",
2112: "n",
2212: "p",
-2212: "pbar",
111: "pi0",
211: "pi+",
-211: "pi-",
13: "mu-",
-13: "mu+",
321: "K+",
-321: "K-",
130: "K0L",
}
def pdg_label(code: int) -> str:
"""Human-readable species label for a PDG code (falls back to the code)."""
code = int(code)
if code in PDG_NAMES:
return PDG_NAMES[code]
if abs(code) > 1_000_000_000:
return f"ion {code}"
return str(code)
def material_label(name: str) -> str:
"""Display label for a Geant4 material, dropping the ``G4_`` prefix."""
return name[3:] if name.startswith("G4_") else name
def energy_bin_edges(incident_E: np.ndarray, n_bins: int = 4) -> np.ndarray:
"""Equal-population (quantile) bin edges over per-event incident energies.
Returns ``n_bins + 1`` monotonically non-decreasing edges. The top edge is
nudged up so the largest value falls inside the last bin under a
right-open convention. Degenerate (single-value) input widens by +/-0.5.
"""
incident_E = np.asarray(incident_E, dtype=np.float64)
edges = np.quantile(incident_E, np.linspace(0.0, 1.0, n_bins + 1))
edges = np.unique(edges)
if edges.size < 2:
v = edges[0] if edges.size else 0.0
edges = np.array([v - 0.5, v + 0.5])
edges[-1] = np.nextafter(edges[-1], np.inf)
return edges
def energy_bin_labels(edges: np.ndarray) -> list[str]:
"""``E in [lo, hi)`` labels for each bin defined by ``edges`` (MeV)."""
return [
f"E in [{edges[i]:.3g}, {edges[i + 1]:.3g}) MeV" for i in range(len(edges) - 1)
]
def digitize_expr(value: pl.Expr, edges: np.ndarray) -> pl.Expr:
"""Bin index of ``value`` under arbitrary (possibly non-uniform) ``edges``.
``bin = (#interior edges <= value)``, clipped to ``[0, n_bins-1]`` — matches
``np.digitize(value, edges[1:-1])`` and works for the quantile energy edges.
Vectorized as a sum of boolean comparisons; no per-row Python.
"""
interior = [float(e) for e in edges[1:-1]]
n_bins = len(edges) - 1
idx = pl.lit(0, dtype=pl.Int32)
for e in interior:
idx = idx + (value >= e).cast(pl.Int32)
return idx.clip(0, n_bins - 1)
def event_energy_bins(
lf: pl.LazyFrame, edges: np.ndarray
) -> tuple[np.ndarray, np.ndarray]:
"""Per-event incident-energy bin: ``(event_ids, bin_idx)`` numpy arrays.
Incident energy is ``max(pre_E)`` per event (the primary). One bounded
streaming ``group_by``; the tiny per-event result is digitized in numpy.
"""
per_event = (
lf.group_by("event_id")
.agg(pl.col("pre_E").max().alias("incident_E"))
.collect(engine="streaming")
.sort("event_id")
)
event_ids = per_event["event_id"].to_numpy()
incident = per_event["incident_E"].to_numpy()
bin_idx = np.clip(np.digitize(incident, edges[1:-1]), 0, len(edges) - 2)
return event_ids, bin_idx.astype(np.int64)
+218
View File
@@ -0,0 +1,218 @@
"""Streaming compute primitives — the reduce half of the analysis.
Everything here turns a (possibly larger-than-RAM) LazyFrame into a *compact*
numpy/DataFrame artifact in bounded memory, and never imports plotstyle so it can
run on an HTCondor worker. Efficiency rules (see the plan's "Histogram
efficiency" section):
* ``hist1d`` is a single streaming ``group_by([group, bin]).len()`` pass against
**fixed** edges (no min/max range pass) with a strict column projection only
the columns the value/group expressions reference are read from the parquet.
* the multi-quantity reductions (``event_scalars``, profiles, ``species_share``,
``leakage_fraction``) each emit *all* their outputs from one ``group_by``.
* per-event -> per-row lookups (shower entry/axis) use ``replace_strict`` (a hash
map applied as an expression, bounded memory), never a streaming join.
"""
from __future__ import annotations
import numpy as np
import polars as pl
from giant.constants import TERM_ESCAPED
# ---------------------------------------------------------------------------
# 1-D histogram primitive
# ---------------------------------------------------------------------------
def _bin_expr(value: pl.Expr, lo: float, hi: float, nbins: int) -> pl.Expr:
"""Uniform bin index of ``value`` over ``[lo, hi]`` into ``nbins`` bins."""
return ((value - lo) / (hi - lo) * nbins).floor().cast(pl.Int32).clip(0, nbins - 1)
def hist1d(
lf: pl.LazyFrame,
value: pl.Expr,
edges: np.ndarray,
group: pl.Expr | None = None,
) -> dict[object, np.ndarray]:
"""Streaming histogram of ``value`` over fixed uniform ``edges``, by ``group``.
Returns ``{group_key: counts}`` (counts is an ``int64`` array of length
``len(edges)-1``). One hash pass; runtime is independent of group cardinality,
so every pdg/material/energy stratum falls out together. Only the tiny
``(n_groups x nbins)`` result is materialized.
"""
lo, hi, nbins = float(edges[0]), float(edges[-1]), len(edges) - 1
group = pl.lit(0, dtype=pl.Int64) if group is None else group
res = (
lf.select(group.alias("_g"), _bin_expr(value, lo, hi, nbins).alias("_b"))
.group_by("_g", "_b")
.agg(pl.len().alias("_n"))
.collect(engine="streaming")
)
out: dict[object, np.ndarray] = {}
for g, b, n in res.iter_rows():
out.setdefault(g, np.zeros(nbins, dtype=np.int64))[b] = n
return out
# ---------------------------------------------------------------------------
# Per-event scalar observables (one bounded group_by pass)
# ---------------------------------------------------------------------------
def event_scalars(lf: pl.LazyFrame) -> pl.DataFrame:
"""One row per event: total/mean deposited energy, path length, step count.
Columns: ``event_id, total_edep, total_length, n_steps, mean_length,
incident_E`` (incident = ``max(pre_E)``, the primary). The caller chooses
whether ``lf`` includes the rollout's synthetic termination rows — pass the
full scan for energy totals (they carry the deposited remainder), physical
steps only for step-count / mean-length.
"""
return (
lf.group_by("event_id")
.agg(
pl.col("edep").sum().alias("total_edep"),
pl.col("step_length").sum().alias("total_length"),
pl.len().alias("n_steps"),
pl.col("pre_E").max().alias("incident_E"),
)
.with_columns((pl.col("total_length") / pl.col("n_steps")).alias("mean_length"))
.collect(engine="streaming")
)
# ---------------------------------------------------------------------------
# Shower shape: entry/axis + edep-weighted longitudinal / transverse profiles
# ---------------------------------------------------------------------------
def entry_axis(lf: pl.LazyFrame) -> pl.DataFrame:
"""Per-event shower entry point and axis (from the highest-``pre_E`` step).
One bounded ``group_by``: the primary's ``pre_pos`` becomes the entry point
and its ``pre_dir`` the shower axis.
"""
return (
lf.group_by("event_id")
.agg(
pl.col("pre_x").get(pl.col("pre_E").arg_max()).alias("entry_x"),
pl.col("pre_y").get(pl.col("pre_E").arg_max()).alias("entry_y"),
pl.col("pre_z").get(pl.col("pre_E").arg_max()).alias("entry_z"),
pl.col("pre_dx").get(pl.col("pre_E").arg_max()).alias("axis_x"),
pl.col("pre_dy").get(pl.col("pre_E").arg_max()).alias("axis_y"),
pl.col("pre_dz").get(pl.col("pre_E").arg_max()).alias("axis_z"),
)
.collect(engine="streaming")
.sort("event_id")
)
_ENTRY_AXIS_COLS = ("entry_x", "entry_y", "entry_z", "axis_x", "axis_y", "axis_z")
def attach_entry_axis(lf: pl.LazyFrame, entry: pl.DataFrame) -> pl.LazyFrame:
"""Broadcast each event's entry/axis onto its rows via ``replace_strict``.
A hash map applied as an expression streams in bounded memory, unlike a
join which would buffer the whole file-sized left side.
"""
ids = entry["event_id"].to_numpy()
return lf.with_columns(
pl.col("event_id")
.replace_strict(ids, entry[col].to_numpy(), return_dtype=pl.Float64)
.alias(col)
for col in _ENTRY_AXIS_COLS
)
def depth_expr() -> pl.Expr:
"""Signed distance of ``post_pos`` from the entry point along the shower axis."""
dx = pl.col("post_x") - pl.col("entry_x")
dy = pl.col("post_y") - pl.col("entry_y")
dz = pl.col("post_z") - pl.col("entry_z")
return dx * pl.col("axis_x") + dy * pl.col("axis_y") + dz * pl.col("axis_z")
def transverse_expr() -> pl.Expr:
"""Perpendicular distance of ``post_pos`` from the shower axis."""
dx = pl.col("post_x") - pl.col("entry_x")
dy = pl.col("post_y") - pl.col("entry_y")
dz = pl.col("post_z") - pl.col("entry_z")
depth = dx * pl.col("axis_x") + dy * pl.col("axis_y") + dz * pl.col("axis_z")
tx = dx - depth * pl.col("axis_x")
ty = dy - depth * pl.col("axis_y")
tz = dz - depth * pl.col("axis_z")
return (tx**2 + ty**2 + tz**2).sqrt()
def weighted_profile(
lf: pl.LazyFrame,
coord: pl.Expr,
edges: np.ndarray,
weight: pl.Expr,
) -> tuple[np.ndarray, np.ndarray]:
"""Event-averaged, ``weight``-summed profile of ``coord``, with an event-RMS band.
One streaming ``group_by(event_id, bin)`` sums ``weight`` per (event, bin);
collapsed in numpy to the per-bin mean over events and its event-to-event std
(the band). ``coord``/``weight`` require the entry/axis columns attached.
Returns ``(mean, std)``, each length ``len(edges)-1``.
"""
lo, hi, nbins = float(edges[0]), float(edges[-1]), len(edges) - 1
grid = (
lf.select(
"event_id",
_bin_expr(coord, lo, hi, nbins).alias("_b"),
weight.alias("_w"),
)
.group_by("event_id", "_b")
.agg(pl.col("_w").sum().alias("_ws"))
.collect(engine="streaming")
)
ev = grid["event_id"].to_numpy()
uniq, inv = np.unique(ev, return_inverse=True)
mat = np.zeros((len(uniq), nbins), dtype=np.float64)
np.add.at(mat, (inv, grid["_b"].to_numpy()), grid["_ws"].to_numpy())
return mat.mean(axis=0), mat.std(axis=0)
# ---------------------------------------------------------------------------
# Species contribution and leakage
# ---------------------------------------------------------------------------
def species_share(lf: pl.LazyFrame) -> pl.DataFrame:
"""Total deposited energy per PDG species (``pdg, total_edep``), one pass."""
return (
lf.group_by("pdg")
.agg(pl.col("edep").sum().alias("total_edep"))
.collect(engine="streaming")
.sort("total_edep", descending=True)
)
def leakage_fraction(lf: pl.LazyFrame) -> np.ndarray:
"""Per-event escaped-energy fraction (rollout only), one bounded pass.
Escaped rows carry the leaked energy in ``pre_E`` (``edep`` is 0 there); the
fraction is ``escaped / (deposited + escaped)`` per event.
"""
per_event = (
lf.group_by("event_id")
.agg(
pl.col("edep").sum().alias("deposited"),
pl.col("pre_E")
.filter(pl.col("termination_reason") == TERM_ESCAPED)
.sum()
.alias("escaped"),
)
.collect(engine="streaming")
)
deposited = per_event["deposited"].to_numpy()
escaped = per_event["escaped"].fill_null(0.0).to_numpy()
total = deposited + escaped
return np.where(total > 0, escaped / total, 0.0)
+38
View File
@@ -0,0 +1,38 @@
"""The compact, self-describing artifact a compute job produces per plot.
Serialized as small JSON (no pickle, no per-event arrays) so it is trivially
transferable off the batch worker and human-inspectable. ``render.py`` dispatches
on ``kind`` and needs nothing but this file.
"""
from __future__ import annotations
import json
from dataclasses import asdict, dataclass, field
from pathlib import Path
# Reduced.kind values:
# "overlay_hist" rollout vs reference density histogram over shared edges
# "grouped_hist" one panel per group (energy/pdg/material), each an overlay
# "profile" edep-weighted mean +/- event-RMS vs depth/radius, two series
# "bar" per-category rollout vs reference bars (share / counts)
# "single_hist" one series only (e.g. rollout leakage; reference has none)
@dataclass
class Reduced:
id: str
family: str
kind: str
title: str
xlabel: str
payload: dict
meta: dict = field(default_factory=dict)
def save(self, path: str | Path) -> None:
Path(path).parent.mkdir(parents=True, exist_ok=True)
Path(path).write_text(json.dumps(asdict(self)))
@classmethod
def load(cls, path: str | Path) -> "Reduced":
return cls(**json.loads(Path(path).read_text()))
+206
View File
@@ -0,0 +1,206 @@
"""Render reduced artifacts to styled PDFs + gallery metadata (the local step).
This is the *only* module that imports ``plotstyle`` (ETPlot's KIT matplotlib
theme), which renders through a real LaTeX toolchain so it runs on the
submit/login node, never on a compute worker. It reads nothing but the small
``Reduced`` JSON files a run produced, so it is fully decoupled from the heavy
streaming compute.
For each reduced artifact it writes ``<out>/<family>/<id>.pdf`` plus a sibling
``<id>.yaml`` (per-plot gallery metadata) and a per-family ``metadata.yaml``.
Optionally runs ``gallery generate`` to build the static HTML site.
"""
from __future__ import annotations
import subprocess
from pathlib import Path
import numpy as np
import plotstyle as ps # ty: ignore[unresolved-import]
import yaml
from giant.analysis.reduced import Reduced
_SERIES_LABELS = {"rollout": "rollout", "reference": "reference (Geant4)"}
def _density(counts: list[int] | np.ndarray, edges: np.ndarray) -> np.ndarray:
counts = np.asarray(counts, dtype=np.float64)
total = counts.sum()
if total == 0:
return counts
return counts / (total * (edges[1] - edges[0]))
def _overlay(ax, edges: np.ndarray, series: dict[str, list], log_y: bool) -> None:
for key in ("reference", "rollout"):
if key in series:
ax.stairs(_density(series[key], edges), edges, label=_SERIES_LABELS[key])
if log_y:
ax.set_yscale("log")
def _render_overlay(r: Reduced):
edges = np.asarray(r.payload["edges"])
fig, ax = ps.new_figure("thesis-single", title=r.title)
_overlay(ax, edges, r.payload, r.payload.get("log_y", False))
ax.set_xlabel(r.xlabel)
ax.set_ylabel("density")
ps.style_legend(ax, title="source")
return fig
def _render_single(r: Reduced):
edges = np.asarray(r.payload["edges"])
fig, ax = ps.new_figure("thesis-single", title=r.title)
ax.stairs(
_density(r.payload["rollout"], edges), edges, label=_SERIES_LABELS["rollout"]
)
if r.payload.get("log_y"):
ax.set_yscale("log")
ax.set_xlabel(r.xlabel)
ax.set_ylabel("density")
ps.style_legend(ax, title="source")
return fig
def _render_grouped(r: Reduced):
edges = np.asarray(r.payload["edges"])
groups = r.payload["groups"]
labels = list(groups)
n = len(labels)
ncols = min(3, n) or 1
nrows = (n + ncols - 1) // ncols
fig, axes = ps.new_figure(
"slide-16x9", title=r.title, nrows=nrows, ncols=ncols, squeeze=False
)
flat = axes.ravel()
for i, lbl in enumerate(labels):
ax = flat[i]
_overlay(ax, edges, groups[lbl], r.payload.get("log_y", False))
ax.set_title(lbl, fontsize=8)
ax.set_xlabel(r.xlabel)
for j in range(n, len(flat)):
flat[j].set_visible(False)
ps.style_legend(flat[0], title="source")
return fig
def _render_profile(r: Reduced):
edges = np.asarray(r.payload["edges"])
centers = 0.5 * (edges[:-1] + edges[1:])
fig, ax = ps.new_figure("thesis-single", title=r.title)
for key in ("reference", "rollout"):
mean = np.asarray(r.payload[f"{key}_mean"])
std = np.asarray(r.payload[f"{key}_std"])
(line,) = ax.plot(centers, mean, label=_SERIES_LABELS[key])
ax.fill_between(
centers, mean - std, mean + std, alpha=0.2, color=line.get_color()
)
ax.set_xlabel(r.xlabel)
ax.set_ylabel(r.payload.get("ylabel", "mean deposited energy [MeV]"))
ps.style_legend(ax, title="source")
return fig
def _render_bar(r: Reduced):
labels = r.payload["labels"]
x = np.arange(len(labels))
width = 0.4
fig, ax = ps.new_figure("thesis-single", title=r.title)
ax.bar(
x - width / 2, r.payload["reference"], width, label=_SERIES_LABELS["reference"]
)
ax.bar(x + width / 2, r.payload["rollout"], width, label=_SERIES_LABELS["rollout"])
ax.set_xticks(x)
ax.set_xticklabels(labels, rotation=45, ha="right")
ax.set_ylabel(r.payload.get("ylabel", "value"))
ps.style_legend(ax, title="source")
return fig
_RENDERERS = {
"overlay_hist": _render_overlay,
"single_hist": _render_single,
"grouped_hist": _render_grouped,
"profile": _render_profile,
"bar": _render_bar,
}
def render(r: Reduced):
"""Build the matplotlib figure for one reduced artifact (dispatch on kind)."""
return _RENDERERS[r.kind](r)
def _plot_metadata(r: Reduced, run_meta: dict) -> dict:
meta = {
"title": r.title,
"description": f"Rollout vs reference: {r.title}.",
"plot_type": r.kind,
"family": r.family,
}
meta.update(r.meta)
if "note" in r.payload:
meta["note"] = r.payload["note"]
return meta
def render_all(
reduced_dir: str | Path,
out_dir: str | Path,
run_meta: dict | None = None,
*,
run_gallery: bool = False,
) -> list[Path]:
"""Render every reduced artifact under ``reduced_dir`` to a PDF tree.
Writes ``<out>/<family>/<id>.pdf`` + ``<id>.yaml`` and a per-family
``metadata.yaml`` (carrying the run's checkpoint/paths as gallery params).
Returns the list of PDF paths written.
"""
ps.use()
run_meta = run_meta or {}
reduced_dir, out_dir = Path(reduced_dir), Path(out_dir)
pdfs: list[Path] = []
families: set[str] = set()
for jf in sorted(reduced_dir.glob("*.json")):
r = Reduced.load(jf)
family_dir = out_dir / r.family
family_dir.mkdir(parents=True, exist_ok=True)
families.add(r.family)
fig = render(r)
ps.savefig(fig, str(family_dir / r.id), formats=("pdf",))
(family_dir / f"{r.id}.yaml").write_text(
yaml.safe_dump(_plot_metadata(r, run_meta), sort_keys=False)
)
pdfs.append(family_dir / f"{r.id}.pdf")
import matplotlib.pyplot as plt
plt.close(fig)
# Root + per-family gallery metadata.
out_dir.mkdir(parents=True, exist_ok=True)
(out_dir / "metadata.yaml").write_text(
yaml.safe_dump(
{
"title": run_meta.get("title", "GIANT rollout analysis"),
"description": "Autoregressive rollout compared against held-out Geant4 reference steps.",
"experiment": "GIANT",
"parameters": run_meta,
},
sort_keys=False,
)
)
for fam in families:
(out_dir / fam / "metadata.yaml").write_text(
yaml.safe_dump(
{"title": fam, "description": f"{fam} plots."}, sort_keys=False
)
)
if run_gallery:
subprocess.run(["gallery", "generate", "--source", str(out_dir)], check=True)
return pdfs
+165
View File
@@ -0,0 +1,165 @@
"""Canonical world-frame LazyFrame builders for the two sides of a comparison.
The analysis compares one autoregressive ``giant rollout`` (the *generated* side)
against a raw miniCaloSim steps file (the *reference* / real side). Both carry a
**shared world-frame physical column subset** under identical names, so no
renaming or coordinate decode is needed everything is already in world-frame
mm / MeV:
event_id, track_id, step_no, pdg,
pre_x, pre_y, pre_z, pre_E, pre_dx, pre_dy, pre_dz,
post_x, post_y, post_z, post_E, post_dx, post_dy, post_dz,
edep, step_length, material, layer_id
(rollout: ``rollout.py:_RECORD_KEYS``; reference: minicalosim ``RunAction.cc``
Steps ntuple passed through by ``dwarf convert``.)
The two files differ in their *extra* columns the rollout adds ``parent_id``,
``generation``, ``n_sec_pred``, ``termination_reason``; the reference adds
``process``, field columns, ``child_track_ids`` and the ``sec_*_list`` secondary
birth-state lists. Those are only touched by the side-specific helpers here
(synthetic-row filtering, the secondary view).
Nothing in this module (or ``reduce.py``) imports plotstyle compute runs on
HTCondor workers that have no LaTeX toolchain.
"""
from __future__ import annotations
from enum import Enum
from pathlib import Path
import polars as pl
import pyarrow.parquet as pq
from giant.constants import (
PREDICT_COORD_METADATA_KEY,
ROLLOUT_COORD_VALUE,
TERM_ENERGY_CUTOFF,
TERM_ESCAPED,
TERM_MAX_STEPS,
TERM_UNKNOWN_PDG,
)
# The world-frame physical columns both sides share under identical names.
PHYS_COLS: tuple[str, ...] = (
"event_id",
"pdg",
"pre_x",
"pre_y",
"pre_z",
"pre_E",
"pre_dx",
"pre_dy",
"pre_dz",
"post_x",
"post_y",
"post_z",
"post_E",
"post_dx",
"post_dy",
"post_dz",
"edep",
"step_length",
"material",
"layer_id",
)
# Rollout rows written purely for bookkeeping (a track's forced stop): they carry
# step_length=0, post_pos=pre_pos, and — for every reason but escaped — the
# track's whole remaining pre_E dumped into edep so the shower still conserves
# energy. They are not physical steps (the reference has no equivalent), so a
# per-step marginal comparison must drop them; a per-event energy total must keep
# them. See rollout.py's terminal-row handling.
SYNTHETIC_TERMINATION_REASONS: frozenset[str] = frozenset(
{TERM_ESCAPED, TERM_UNKNOWN_PDG, TERM_ENERGY_CUTOFF, TERM_MAX_STEPS}
)
class Side(str, Enum):
"""Which of the two comparison inputs a file is."""
rollout = "rollout"
reference = "reference"
def _check_rollout_metadata(path: Path) -> None:
"""Raise if ``path`` carries coord metadata that isn't the rollout tag.
A missing tag (older rollout output, predating tagging) is allowed through,
matching ``giant rollout``'s own leniency; a tag that is present but wrong is
a real mismatch and worth failing on before the column layout is trusted.
"""
metadata = pq.read_schema(path).metadata or {}
coord = metadata.get(PREDICT_COORD_METADATA_KEY.encode())
if coord is not None and coord.decode() != ROLLOUT_COORD_VALUE:
raise ValueError(
f"{path} is not a rollout file (coord={coord.decode()!r}); "
"expected `giant rollout` output"
)
def open_side(source: str | Path | pl.LazyFrame, side: Side) -> pl.LazyFrame:
"""Lazily scan one side's file, verifying the rollout tag when applicable.
Returns the *full* lazy scan (no column projection) so downstream reductions
can push their own narrow projection into the parquet read the single
biggest lever on a larger-than-RAM file. ``pl.LazyFrame`` inputs pass straight
through (used by tests).
"""
if isinstance(source, pl.LazyFrame):
return source
path = Path(source)
if side is Side.rollout:
_check_rollout_metadata(path)
return pl.scan_parquet(path)
def physical_steps(lf: pl.LazyFrame, side: Side) -> pl.LazyFrame:
"""Real, physical steps only — drops the rollout's synthetic termination rows.
The predicate is pushed down so the dropped rows are never decoded. The
reference has no such rows, so it is returned unchanged.
"""
if side is Side.reference:
return lf
return lf.filter(
~pl.col("termination_reason").is_in(list(SYNTHETIC_TERMINATION_REASONS))
)
def secondaries(lf: pl.LazyFrame, side: Side) -> pl.LazyFrame:
"""Per-secondary birth state, one row per produced secondary.
Canonical columns: ``event_id, energy, pdg, sdx, sdy, sdz`` (birth energy in
MeV, PDG code, birth unit direction in the world frame). The two sides encode
secondaries differently:
- rollout: each secondary is its own track, so its birth state is the row with
``generation > 0`` and ``step_no == 0`` (``pre_E`` / ``pre_dir`` there).
- reference: secondaries live in per-parent-step ``sec_*_list`` columns; the
lists are exploded together and empty (no-secondary) steps drop out.
"""
if side is Side.rollout:
return lf.filter((pl.col("generation") > 0) & (pl.col("step_no") == 0)).select(
"event_id",
pl.col("pre_E").alias("energy"),
"pdg",
pl.col("pre_dx").alias("sdx"),
pl.col("pre_dy").alias("sdy"),
pl.col("pre_dz").alias("sdz"),
)
lists = ["sec_E_list", "sec_pdg_list", "sec_dx_list", "sec_dy_list", "sec_dz_list"]
return (
lf.select("event_id", *lists)
.explode(lists)
.drop_nulls("sec_E_list")
.select(
"event_id",
pl.col("sec_E_list").alias("energy"),
pl.col("sec_pdg_list").alias("pdg"),
pl.col("sec_dx_list").alias("sdx"),
pl.col("sec_dy_list").alias("sdy"),
pl.col("sec_dz_list").alias("sdz"),
)
)
+28
View File
@@ -0,0 +1,28 @@
"""Per-step value expressions shared by ``context`` (range sizing) and ``catalog``.
Kept separate from both so the range-sizing prep and the plot registry agree on
exactly what each variable *is*, with no import cycle. plotstyle-free.
"""
from __future__ import annotations
import polars as pl
# Ranged marginal variables: name -> (axis label, value expression). Their
# histogram ranges are sized from data in `context.build_context`.
RANGED_VARS: dict[str, tuple[str, pl.Expr]] = {
"step_length": ("step length [mm]", pl.col("step_length")),
"edep": ("deposited energy [MeV]", pl.col("edep")),
"delta_e": ("energy loss [MeV]", pl.col("pre_E") - pl.col("post_E")),
"post_E": ("post-step energy [MeV]", pl.col("post_E")),
}
def cos_scatter_expr() -> pl.Expr:
"""cos of the scattering angle: ``pre_dir . post_dir`` (both unit), in [-1, 1]."""
dot = (
pl.col("pre_dx") * pl.col("post_dx")
+ pl.col("pre_dy") * pl.col("post_dy")
+ pl.col("pre_dz") * pl.col("post_dz")
)
return dot.clip(-1.0, 1.0)
+128 -1
View File
@@ -891,7 +891,7 @@ def _seed_from_data(files: list[Path], n_events: int | None) -> dict[str, np.nda
Streams conditioning columns and keeps the highest-pre_E step per event_id
the codebase's convention for the primary (a secondary always carries less
energy than its parent). See giant/analysis.py:_entry_axis_and_bin_edges.
energy than its parent). See giant/analysis/reduce.py:entry_axis.
"""
best_E: dict[int, float] = {}
best: dict[int, tuple] = {}
@@ -1114,5 +1114,132 @@ def rollout(
typer.echo(f"reference: {ref_path}")
analyze_app = typer.Typer(
no_args_is_help=True,
help="Rollout-vs-reference analysis: parallel compute on HTCondor + local render.",
)
app.add_typer(analyze_app, name="analyze")
@analyze_app.command("prep")
def analyze_prep(
rollout: Annotated[Path, typer.Option("--rollout", help="giant rollout parquet")],
reference: Annotated[
Path, typer.Option("--reference", help="Reference miniCaloSim steps parquet")
],
out_dir: Annotated[
Path,
typer.Option(
"--out-dir", "-o", help="Run directory for shared.json / reduced / plots"
),
],
n_energy_bins: Annotated[int, typer.Option("--energy-bins")] = 4,
n_marginal_bins: Annotated[int, typer.Option("--bins")] = 50,
top_k_pdg: Annotated[int, typer.Option("--top-pdg")] = 6,
) -> None:
"""Resolve the shared context (fixed bin edges / group sets) → shared.json."""
from giant.analysis import prep
shared = prep(
rollout,
reference,
out_dir,
n_energy_bins=n_energy_bins,
n_marginal_bins=n_marginal_bins,
top_k_pdg=top_k_pdg,
)
typer.echo(f"wrote {shared}")
@analyze_app.command("compute-one")
def analyze_compute_one(
id: Annotated[
str, typer.Option("--id", help="Catalog plot id (see `analyze list`)")
],
rollout: Annotated[Path, typer.Option("--rollout")],
reference: Annotated[Path, typer.Option("--reference")],
shared: Annotated[
Path, typer.Option("--shared", help="shared.json from `analyze prep`")
],
out: Annotated[Path, typer.Option("--out", help="Output reduced JSON path")],
) -> None:
"""Run one plot's streaming reduction (this is what each condor job runs)."""
from giant.analysis import compute_one
path = compute_one(id, rollout, reference, shared, out)
typer.echo(f"wrote {path}")
@analyze_app.command("list")
def analyze_list() -> None:
"""Print every catalog plot id."""
from giant.analysis import catalog_ids
for pid in catalog_ids():
typer.echo(pid)
@analyze_app.command("render")
def analyze_render(
reduced_dir: Annotated[
Path, typer.Option("--reduced-dir", help="Directory of reduced *.json")
],
out: Annotated[Path, typer.Option("--out", "-o", help="Output PDF/gallery tree")],
gallery: Annotated[
bool,
typer.Option(
"--gallery/--no-gallery", help="Run `gallery generate` after rendering"
),
] = False,
) -> None:
"""Render reduced artifacts to styled PDFs + gallery metadata (local; needs LaTeX)."""
from giant.analysis.render import render_all
pdfs = render_all(reduced_dir, out, run_gallery=gallery)
typer.echo(f"rendered {len(pdfs)} plots → {out}")
@analyze_app.command("submit")
def analyze_submit(
rollout: Annotated[Path, typer.Option("--rollout")],
reference: Annotated[Path, typer.Option("--reference")],
out_dir: Annotated[Path, typer.Option("--out-dir", "-o")],
accounting_group: Annotated[str, typer.Option("--accounting-group")],
docker_image: Annotated[
str, typer.Option("--docker-image")
] = "mschnepf/slc7-condocker",
request_memory: Annotated[int, typer.Option("--request-memory", help="MB")] = 4096,
remote: Annotated[
bool,
typer.Option("--remote/--local", help="+RemoteJob vs ProvidesETPResources"),
] = False,
dry_run: Annotated[
bool, typer.Option("--dry-run", help="Write files but don't condor_submit")
] = False,
) -> None:
"""prep + write the HTCondor submit description (one job per plot), then submit."""
import subprocess
from giant.analysis import SubmitConfig, prep, write_submit
prep(rollout, reference, out_dir)
cfg = SubmitConfig(
rollout=rollout.resolve(),
reference=reference.resolve(),
out_dir=out_dir,
accounting_group=accounting_group,
repo_dir=Path.cwd(),
docker_image=docker_image,
request_memory_mb=request_memory,
remote=remote,
)
sub = write_submit(cfg)
typer.echo(f"wrote submit description: {sub}")
if dry_run:
typer.echo("dry-run: not submitting")
return
subprocess.run(["condor_submit", str(sub)], check=True)
if __name__ == "__main__":
app()
+7
View File
@@ -39,6 +39,9 @@ analysis = [
"matplotlib>=3.8,<4",
"polars>=1.0,<2",
"ipykernel>=7.3.0",
# ETPlot's plotstyle (KIT matplotlib theme) + gallery CLI. Only the local
# `giant analyze render` step imports it; compute workers never do.
"gallery[plotting]",
]
[project.scripts]
@@ -65,6 +68,10 @@ torch = [
{ index = "pytorch-cpu", extra = "cpu" },
{ index = "pytorch-cu118", extra = "cuda" },
]
# ETPlot ships the `gallery` distribution (which provides the `plotstyle`
# package under its `plotting` extra). Local checkout next to this repo; swap
# for `{ git = "https://git.larsbogner.de/lars/ETPlot" }` off-machine.
gallery = { path = "../ETPlot", editable = true }
[[tool.uv.index]]
name = "pytorch-cpu"
File diff suppressed because it is too large Load Diff
+171
View File
@@ -0,0 +1,171 @@
"""Tests for the streaming compute primitives (giant.analysis.reduce/sources/grouping)."""
from __future__ import annotations
import numpy as np
import polars as pl
from giant.analysis import grouping as G
from giant.analysis import reduce as R
from giant.analysis.sources import (
SYNTHETIC_TERMINATION_REASONS,
Side,
physical_steps,
secondaries,
)
def _rollout_frame() -> pl.LazyFrame:
# event 1: primary (2 steps) + 1 secondary track + 1 escaped bookkeeping row
# event 2: primary (1 step)
return pl.DataFrame(
{
"event_id": [1, 1, 1, 1, 2],
"track_id": [0, 0, 1, 0, 0],
"parent_id": [-1, -1, 0, -1, -1],
"generation": [0, 0, 1, 0, 0],
"step_no": [0, 1, 0, 99, 0],
"pdg": [11, 11, 22, 11, 11],
"pre_x": [0.0, 0.0, 0.0, 0.0, 0.0],
"pre_y": [0.0, 0.0, 0.0, 0.0, 0.0],
"pre_z": [0.0, 1.0, 1.0, 2.0, 0.0],
"pre_E": [100.0, 60.0, 20.0, 30.0, 50.0],
"pre_dx": [0.0, 0.0, 1.0, 0.0, 0.0],
"pre_dy": [0.0, 0.0, 0.0, 0.0, 0.0],
"pre_dz": [1.0, 1.0, 0.0, 1.0, 1.0],
"post_x": [0.0, 0.0, 1.0, 0.0, 0.0],
"post_y": [0.0, 0.0, 0.0, 0.0, 0.0],
"post_z": [1.0, 2.0, 1.0, 2.0, 1.0],
"post_E": [60.0, 30.0, 0.0, 0.0, 20.0],
"post_dx": [0.0, 0.0, 1.0, 0.0, 0.0],
"post_dy": [0.0, 0.0, 0.0, 0.0, 0.0],
"post_dz": [1.0, 1.0, 0.0, 1.0, 1.0],
"edep": [40.0, 30.0, 20.0, 0.0, 30.0],
"step_length": [1.0, 1.0, 1.0, 0.0, 1.0],
"material": ["G4_PbWO4"] * 5,
"layer_id": [0, 1, 1, -1, 0],
"n_sec_pred": [1, 0, 0, 0, 0],
"termination_reason": [
"",
"natural_end",
"natural_end",
"escaped",
"natural_end",
],
}
).lazy()
def _reference_frame() -> pl.LazyFrame:
return pl.DataFrame(
{
"event_id": [1, 1, 2],
"track_id": [0, 0, 0],
"step_no": [0, 1, 0],
"pdg": [11, 11, 11],
"pre_x": [0.0, 0.0, 0.0],
"pre_y": [0.0, 0.0, 0.0],
"pre_z": [0.0, 1.0, 0.0],
"pre_E": [100.0, 60.0, 50.0],
"pre_dx": [0.0, 0.0, 0.0],
"pre_dy": [0.0, 0.0, 0.0],
"pre_dz": [1.0, 1.0, 1.0],
"post_x": [0.0, 0.0, 0.0],
"post_y": [0.0, 0.0, 0.0],
"post_z": [1.0, 2.0, 1.0],
"post_E": [60.0, 30.0, 20.0],
"post_dx": [0.0, 0.0, 0.0],
"post_dy": [0.0, 0.0, 0.0],
"post_dz": [1.0, 1.0, 1.0],
"edep": [40.0, 30.0, 30.0],
"step_length": [1.0, 1.0, 1.0],
"material": ["G4_PbWO4", "G4_PbWO4", "G4_Pb"],
"layer_id": [0, 1, 0],
"sec_E_list": [[20.0], [], [10.0]],
"sec_pdg_list": [[22], [], [22]],
"sec_dx_list": [[1.0], [], [0.0]],
"sec_dy_list": [[0.0], [], [0.0]],
"sec_dz_list": [[0.0], [], [1.0]],
}
).lazy()
def test_hist1d_overall_and_grouped():
lf = _rollout_frame()
edges = np.linspace(0.0, 50.0, 6) # width 10
h = R.hist1d(lf, pl.col("edep"), edges)
# edep values: 40,30,20,0,30 -> bins [0),[10),[20),[30),[40)
assert h[0].tolist() == [1, 0, 1, 2, 1]
# grouped by pdg: pdg 22 has a single edep=20
hg = R.hist1d(lf, pl.col("edep"), edges, group=pl.col("pdg"))
assert hg[22].tolist() == [0, 0, 1, 0, 0]
assert hg[11].sum() == 4
def test_physical_steps_drops_synthetic_rollout_rows_only():
lf = _rollout_frame()
phys = physical_steps(lf, Side.rollout).collect()
assert phys.height == 4 # dropped the escaped bookkeeping row
assert "escaped" not in phys["termination_reason"].to_list()
assert SYNTHETIC_TERMINATION_REASONS # non-empty guard
# reference passes through unchanged
ref = _reference_frame()
assert physical_steps(ref, Side.reference).collect().height == ref.collect().height
def test_event_scalars_totals_include_all_rows():
lf = _rollout_frame()
es = R.event_scalars(lf).sort("event_id")
row1 = es.filter(pl.col("event_id") == 1).to_dicts()[0]
assert row1["total_edep"] == 90.0 # 40+30+20+0
assert row1["incident_E"] == 100.0
assert row1["n_steps"] == 4
def test_secondaries_rollout_vs_reference_align():
r = secondaries(_rollout_frame(), Side.rollout).collect().sort("event_id")
assert r["energy"].to_list() == [20.0] # only the generation>0, step_no==0 row
assert r["pdg"].to_list() == [22]
t = secondaries(_reference_frame(), Side.reference).collect().sort("event_id")
# two secondaries (event 1 and event 2); empty list dropped
assert sorted(t["energy"].to_list()) == [10.0, 20.0]
assert t["pdg"].to_list() == [22, 22]
def test_leakage_fraction():
frac = R.leakage_fraction(_rollout_frame())
# event 1: escaped pre_E=30, deposited=90 -> 30/120 = 0.25; event 2: 0
assert sorted(round(f, 6) for f in frac) == [0.0, 0.25]
def test_weighted_profile_matches_manual_bincount():
lf = _rollout_frame()
ea = R.entry_axis(lf)
lf2 = R.attach_entry_axis(lf, ea)
edges = np.linspace(0.0, 3.0, 4) # depth bins along +z
mean, std = R.weighted_profile(lf2, R.depth_expr(), edges, pl.col("edep"))
assert mean.shape == (3,)
# totals conserved: sum over bins == mean total edep per event
assert np.isclose(mean.sum() * 1, (90.0 + 30.0) / 2) # 2 events
def test_energy_bins_edges_and_event_map():
incident = np.array([100.0, 100.0, 1000.0, 1000.0])
edges = G.energy_bin_edges(incident, n_bins=2)
assert len(edges) == 3 and edges[0] <= 100.0 < edges[-1]
ids, bins = G.event_energy_bins(_rollout_frame(), edges)
assert set(bins.tolist()) <= {0, 1}
assert len(ids) == 2
def test_digitize_expr_matches_numpy():
edges = np.array([0.0, 10.0, 100.0, 1000.0])
df = pl.DataFrame({"v": [5.0, 50.0, 500.0, 2000.0]})
got = df.select(G.digitize_expr(pl.col("v"), edges).alias("b"))["b"].to_list()
assert got == np.digitize([5.0, 50.0, 500.0, 2000.0], edges[1:-1]).tolist()
def test_pdg_and_material_labels():
assert G.pdg_label(22) == "gamma"
assert G.pdg_label(999999) == "999999"
assert G.material_label("G4_PbWO4") == "PbWO4"
+69
View File
@@ -0,0 +1,69 @@
"""Tests for the plot catalog: id uniqueness + every spec computes a valid Reduced."""
from __future__ import annotations
import pytest
from giant.analysis import build_catalog, catalog_ids, get_spec
from giant.analysis.catalog import Bundle
from giant.analysis.context import build_context
from tests.test_analysis_reduce import _reference_frame, _rollout_frame
@pytest.fixture(scope="module")
def bundle() -> Bundle:
r, t = _rollout_frame(), _reference_frame()
ctx = build_context(
r, t, n_energy_bins=2, n_marginal_bins=10, top_k_pdg=3, sample_rows=1000
)
return Bundle.open(r, t, ctx)
def test_catalog_ids_unique_and_nonempty():
ids = catalog_ids()
assert ids and len(ids) == len(set(ids))
# the required families are all present
fams = {s.family for s in build_catalog()}
assert {"marginals", "event", "shower", "species", "secondaries"} <= fams
def test_get_spec_roundtrip_and_unknown():
spec = get_spec("marginal_edep")
assert spec.id == "marginal_edep" and spec.family == "marginals"
with pytest.raises(KeyError):
get_spec("does_not_exist")
def test_every_spec_computes_valid_reduced(bundle: Bundle):
for spec in build_catalog():
r = spec.compute(bundle)
assert r.id == spec.id
assert r.kind in {
"overlay_hist",
"grouped_hist",
"profile",
"bar",
"single_hist",
}
assert r.title and r.xlabel
_validate_payload(r)
def _validate_payload(r) -> None:
p = r.payload
if r.kind == "overlay_hist":
n = len(p["edges"]) - 1
assert len(p["rollout"]) == n and len(p["reference"]) == n
elif r.kind == "single_hist":
assert len(p["rollout"]) == len(p["edges"]) - 1
elif r.kind == "grouped_hist":
n = len(p["edges"]) - 1
assert p["groups"], "grouped hist must have at least one group"
for g in p["groups"].values():
assert len(g["rollout"]) == n and len(g["reference"]) == n
elif r.kind == "profile":
n = len(p["edges"]) - 1
for k in ("rollout_mean", "rollout_std", "reference_mean", "reference_std"):
assert len(p[k]) == n
elif r.kind == "bar":
assert len(p["labels"]) == len(p["rollout"]) == len(p["reference"])
+86
View File
@@ -0,0 +1,86 @@
"""Tests for the HTCondor submit description + the compute_one round-trip."""
from __future__ import annotations
from pathlib import Path
from giant.analysis import (
Context,
SubmitConfig,
build_context,
catalog_ids,
compute_one,
prep,
write_submit,
)
from giant.analysis.reduced import Reduced
from tests.test_analysis_reduce import _reference_frame, _rollout_frame
def test_prep_and_compute_one_roundtrip(tmp_path: Path):
r, t = _rollout_frame(), _reference_frame()
ctx = build_context(
r, t, n_energy_bins=2, n_marginal_bins=8, top_k_pdg=3, sample_rows=1000
)
shared = tmp_path / "shared.json"
ctx.save(shared)
assert Context.load(shared).top_pdgs == ctx.top_pdgs
out = compute_one("marginal_edep", r, t, shared, tmp_path / "marginal_edep.json")
reduced = Reduced.load(out)
assert reduced.id == "marginal_edep"
assert len(reduced.payload["rollout"]) == len(reduced.payload["edges"]) - 1
def test_prep_writes_shared_json(tmp_path: Path):
r, t = _rollout_frame(), _reference_frame()
shared = prep(
r,
t,
tmp_path / "run",
n_energy_bins=2,
n_marginal_bins=8,
top_k_pdg=3,
sample_rows=1000,
)
assert shared.exists()
ctx = Context.load(shared)
assert set(ctx.var_ranges) == {"step_length", "edep", "delta_e", "post_E"}
def test_write_submit_description(tmp_path: Path):
cfg = SubmitConfig(
rollout=tmp_path / "r.parquet",
reference=tmp_path / "t.parquet",
out_dir=tmp_path / "run",
accounting_group="cms",
repo_dir=tmp_path,
)
sub = write_submit(cfg)
txt = sub.read_text()
assert "universe = docker" in txt
assert "docker_image = mschnepf/slc7-condocker" in txt
assert "requirements = TARGET.ProvidesETPResources" in txt
assert "accounting_group = cms" in txt
assert "queue plotid from" in txt
# one queue item per catalog id
ids = (cfg.out_dir / "plotids.txt").read_text().split()
assert ids == catalog_ids()
# wrapper is executable and self-contained
wrapper = cfg.out_dir / "run_compute.sh"
assert wrapper.exists() and (wrapper.stat().st_mode & 0o111)
assert "giant analyze compute-one" in wrapper.read_text()
def test_write_submit_remote_flag(tmp_path: Path):
cfg = SubmitConfig(
rollout=tmp_path / "r.parquet",
reference=tmp_path / "t.parquet",
out_dir=tmp_path / "run",
accounting_group="cms",
repo_dir=tmp_path,
remote=True,
)
txt = write_submit(cfg).read_text()
assert "+RemoteJob = True" in txt
assert "ProvidesETPResources" not in txt
+92
View File
@@ -0,0 +1,92 @@
"""Render smoke test — skipped where plotstyle / LaTeX is unavailable."""
from __future__ import annotations
from pathlib import Path
import pytest
pytest.importorskip("plotstyle")
from giant.analysis.reduced import Reduced # noqa: E402
def _try_render(reduced: list[Reduced], out: Path):
from giant.analysis.render import render_all
for r in reduced:
r.save(out / "reduced" / f"{r.id}.json")
return render_all(out / "reduced", out / "plots")
def test_render_one_of_each_kind(tmp_path: Path):
reduced = [
Reduced(
"m",
"marginals",
"overlay_hist",
"Overlay",
"x",
{
"edges": [0, 1, 2, 3],
"rollout": [1, 2, 3],
"reference": [3, 2, 1],
"log_y": False,
},
),
Reduced(
"g",
"marginals",
"grouped_hist",
"Grouped",
"x",
{
"edges": [0, 1, 2],
"groups": {"a": {"rollout": [1, 2], "reference": [2, 1]}},
"log_y": False,
},
),
Reduced(
"p",
"shower",
"profile",
"Profile",
"depth",
{
"edges": [0, 1, 2],
"rollout_mean": [1, 2],
"rollout_std": [0.1, 0.2],
"reference_mean": [1.1, 1.9],
"reference_std": [0.1, 0.1],
"ylabel": "e",
},
),
Reduced(
"b",
"species",
"bar",
"Bar",
"species",
{
"labels": ["e-", "gamma"],
"rollout": [0.6, 0.4],
"reference": [0.5, 0.5],
"ylabel": "frac",
},
),
Reduced(
"s",
"species",
"single_hist",
"Single",
"x",
{"edges": [0, 1, 2], "rollout": [5, 1], "log_y": True},
),
]
try:
pdfs = _try_render(reduced, tmp_path)
except RuntimeError as e: # LaTeX missing at render time
pytest.skip(f"LaTeX rendering unavailable: {e}")
assert len(pdfs) == len(reduced)
assert all(p.exists() for p in pdfs)
assert (tmp_path / "plots" / "metadata.yaml").exists()
Generated
+121
View File
@@ -45,6 +45,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/81/29/5ecc3a15d5a33e31b26c11426c45c501e439cb865d0bff96315d86443b78/appnope-0.1.4-py2.py3-none-any.whl", hash = "sha256:502575ee11cd7a28c0205f379b525beefebab9d161b7c964670864014ed7213c", size = 4321, upload-time = "2024-02-06T09:43:09.663Z" },
]
[[package]]
name = "argcomplete"
version = "3.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/95/c0/c8e94135e66fabf89a120d9b4b123fe6993506beca6c1938a74c24cfa5fd/argcomplete-3.7.0.tar.gz", hash = "sha256:afde224f753f874807b1dc1414e883ab8fe0cda9c04807b6047dcb8e1ac23913", size = 73284, upload-time = "2026-06-30T22:28:22.249Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/12/f6/5b8ec087cd9cfa9449491ec83f76fb6b7006b4dff57d2ba8aaab330fe8e4/argcomplete-3.7.0-py3-none-any.whl", hash = "sha256:d8f0f22d2a8a7caa383be1e22b6caf1ecaf0ebd10d8f83cc125e36540c95830c", size = 42575, upload-time = "2026-06-30T22:28:20.547Z" },
]
[[package]]
name = "asttokens"
version = "3.0.1"
@@ -441,6 +450,43 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/22/4222d7ddf3da30f363edaa98e329c2bce6c65497c9cb2810931c8b2c0fbc/fsspec-2026.6.0-py3-none-any.whl", hash = "sha256:02e0b71817df9b2169dc30a16832045764def1191b43dcff5bb85bdee212d2a1", size = 203949, upload-time = "2026-06-16T01:57:26.358Z" },
]
[[package]]
name = "gallery"
version = "0.1.3"
source = { editable = "../ETPlot" }
dependencies = [
{ name = "argcomplete" },
{ name = "jinja2" },
{ name = "platformdirs" },
{ name = "pymupdf" },
{ name = "pytest" },
{ name = "pyyaml" },
{ name = "textual" },
]
[package.optional-dependencies]
plotting = [
{ name = "matplotlib" },
]
[package.metadata]
requires-dist = [
{ name = "argcomplete", specifier = ">=3.0" },
{ name = "jinja2", specifier = ">=3.0.0" },
{ name = "matplotlib", marker = "extra == 'plotting'", specifier = ">=3.7" },
{ name = "pip-audit", marker = "extra == 'dev'", specifier = ">=2.7" },
{ name = "platformdirs", specifier = ">=3.0" },
{ name = "pymupdf", specifier = ">=1.23" },
{ name = "pytest" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0" },
{ name = "pyyaml", specifier = ">=5.0" },
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.6" },
{ name = "setuptools", marker = "extra == 'dev'", specifier = ">=83.0.0" },
{ name = "textual", specifier = ">=0.50" },
{ name = "ty", marker = "extra == 'dev'", specifier = ">=0.0.1" },
]
provides-extras = ["dev", "plotting"]
[[package]]
name = "giant"
version = "0.1.0"
@@ -457,6 +503,7 @@ dependencies = [
[package.optional-dependencies]
analysis = [
{ name = "gallery", extra = ["plotting"] },
{ name = "ipykernel" },
{ name = "matplotlib" },
{ name = "polars" },
@@ -475,6 +522,7 @@ cuda = [
]
dev = [
{ name = "awkward" },
{ name = "gallery", extra = ["plotting"] },
{ name = "ipykernel" },
{ name = "matplotlib" },
{ name = "polars" },
@@ -491,6 +539,7 @@ geometry = [
[package.metadata]
requires-dist = [
{ name = "awkward", marker = "extra == 'convert'", specifier = ">=2.6,<3" },
{ name = "gallery", extras = ["plotting"], marker = "extra == 'analysis'", editable = "../ETPlot" },
{ name = "giant", extras = ["convert", "analysis", "geometry"], marker = "extra == 'dev'" },
{ name = "ipykernel", marker = "extra == 'analysis'", specifier = ">=7.3.0" },
{ name = "matplotlib", marker = "extra == 'analysis'", specifier = ">=3.8,<4" },
@@ -750,6 +799,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b5/91/53255615acd2a1eaca307ede3c90eb550bae9c94581f8c00081b6b1c8f44/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:1f1489f769582498610e015a8ef2d36f28f505ab3096d0e16b4858a9ec214f57", size = 75987, upload-time = "2026-03-09T13:15:39.65Z" },
]
[[package]]
name = "linkify-it-py"
version = "2.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "uc-micro-py" },
]
sdist = { url = "https://files.pythonhosted.org/packages/2e/c9/06ea13676ef354f0af6169587ae292d3e2406e212876a413bf9eece4eb23/linkify_it_py-2.1.0.tar.gz", hash = "sha256:43360231720999c10e9328dc3691160e27a718e280673d444c38d7d3aaa3b98b", size = 29158, upload-time = "2026-03-01T07:48:47.683Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b4/de/88b3be5c31b22333b3ca2f6ff1de4e863d8fe45aaea7485f591970ec1d3e/linkify_it_py-2.1.0-py3-none-any.whl", hash = "sha256:0d252c1594ecba2ecedc444053db5d3a9b7ec1b0dd929c8f1d74dce89f86c05e", size = 19878, upload-time = "2026-03-01T07:48:46.098Z" },
]
[[package]]
name = "markdown-it-py"
version = "4.2.0"
@@ -762,6 +823,11 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" },
]
[package.optional-dependencies]
linkify = [
{ name = "linkify-it-py" },
]
[[package]]
name = "markupsafe"
version = "3.0.3"
@@ -891,6 +957,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/41/09/5b161152e2d90f7b87f781c2e1267494aef9c32498df793f73ad0a0a494a/matplotlib_inline-0.2.2-py3-none-any.whl", hash = "sha256:3c821cf1c209f59fb2d2d64abbf5b23b67bcb2210d663f9918dd851c6da1fcf6", size = 9534, upload-time = "2026-05-08T17:33:32.055Z" },
]
[[package]]
name = "mdit-py-plugins"
version = "0.6.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
]
sdist = { url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz", hash = "sha256:a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0", size = 56114, upload-time = "2026-05-13T09:03:38.91Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl", hash = "sha256:214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d", size = 66663, upload-time = "2026-05-13T09:03:37.76Z" },
]
[[package]]
name = "mdurl"
version = "0.1.2"
@@ -1463,6 +1541,23 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
]
[[package]]
name = "pymupdf"
version = "1.28.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8e/e9/6d6c5d6c0a3551bffd47681a6240caf941727f195b45593cf20ab36f018f/pymupdf-1.28.0.tar.gz", hash = "sha256:e53f3567403a92da15caa9e7ae0164327fff48817e9f40175367fb9de524258d", size = 87637751, upload-time = "2026-06-29T09:08:47.547Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c8/b7/88043e38cc7529de070f0c9bd267fa258035cca0b4ad5260536b994594a7/pymupdf-1.28.0-cp310-abi3-macosx_10_15_x86_64.whl", hash = "sha256:892b89ba88e8f98b53133b62877a9dc9b5e7dc6a4aeb837b612db56a8d2e03ac", size = 24597385, upload-time = "2026-06-29T09:03:30.608Z" },
{ url = "https://files.pythonhosted.org/packages/33/f4/23775bbda0781b61fc398cc75079a2b0e64696d8fcf93271748883e9627e/pymupdf-1.28.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:4d692dcf44d3566ae96bc6f6346c6ad432274a29ba617bf7a9fe18009e24adb4", size = 23828292, upload-time = "2026-06-29T09:03:46.129Z" },
{ url = "https://files.pythonhosted.org/packages/1c/f5/bf75fc7a415722f8b33662054f82d88520c0cbfd4c36d0e08aeaec605e49/pymupdf-1.28.0-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:47a5c29ed4eb0744de9c4e37bb49b1259b18d4d75fcc8a7c130f7c9fa15956f6", size = 25045507, upload-time = "2026-06-29T09:04:03.86Z" },
{ url = "https://files.pythonhosted.org/packages/58/69/5d12c9f1f2d76f28383d6110a069c79fbfced5a4f97bb1ee6e8354f52bb7/pymupdf-1.28.0-cp310-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:44f0973f5e5edbaec95bc34b64e71d1959d4ee90b1328de1b4f4f5b4fa78673f", size = 25716599, upload-time = "2026-06-29T09:04:19.367Z" },
{ url = "https://files.pythonhosted.org/packages/4d/b4/ec0e017bc42857cc86bd651441dbc41cc18be48d4698ecd27aac491e0c9a/pymupdf-1.28.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4d61ec323a706e153a12e262e51febfb43eeaa20977785ace135d18d48bcdc83", size = 25940489, upload-time = "2026-06-29T09:04:36.624Z" },
{ url = "https://files.pythonhosted.org/packages/06/86/f831fef09013f33b3c9c09fb3923f2ff53e1e437f6ace14b8ae46392f558/pymupdf-1.28.0-cp310-abi3-win32.whl", hash = "sha256:caea2b3b67347fd79e5d15ed7929b0e886aac594ea228073b6d39de0078189da", size = 18489703, upload-time = "2026-06-29T20:50:30.599Z" },
{ url = "https://files.pythonhosted.org/packages/2e/5d/1a03f53eb0449900469335fcfc742ca28e3ba159b7d650e0921d50b8b308/pymupdf-1.28.0-cp310-abi3-win_amd64.whl", hash = "sha256:e01e90fd86abfeb37ceb921eddb951f988a11d45ff6ce6b7664f2039849068ec", size = 19773102, upload-time = "2026-06-29T09:04:49.773Z" },
{ url = "https://files.pythonhosted.org/packages/72/f6/1e52ce243ca792254f6223b4017c5667194c146ce9b88baf37bc5eb3d1c9/pymupdf-1.28.0-cp313-abi3-pyemscripten_2025_0_wasm32.whl", hash = "sha256:74c6d00ba2a9aad3a635db73b07c15db462b480741d831a34a75a56535ebc22b", size = 18357011, upload-time = "2026-06-29T20:50:50.353Z" },
{ url = "https://files.pythonhosted.org/packages/62/b1/46b5b3d8ef3cc71114667cf10c4d8b33f39af97253af32e9a0986775b638/pymupdf-1.28.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:b3e1399c7a64c6914239116a369efcdaac4cfb9e838bde2656d7accc4a85c72d", size = 25753599, upload-time = "2026-06-29T09:05:09.398Z" },
]
[[package]]
name = "pyparsing"
version = "3.3.2"
@@ -1772,6 +1867,23 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9b/24/84ce997e8ae6296168a74d0d9c4dde572d90fb23fd7c0b219c30ff71e00e/tbb-2021.13.1-py3-none-win_amd64.whl", hash = "sha256:cbf024b2463fdab3ebe3fa6ff453026358e6b903839c80d647e08ad6d0796ee9", size = 286908, upload-time = "2024-08-07T15:09:05.677Z" },
]
[[package]]
name = "textual"
version = "8.2.8"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py", extra = ["linkify"] },
{ name = "mdit-py-plugins" },
{ name = "platformdirs" },
{ name = "pygments" },
{ name = "rich" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/00/21/39a76b01bd5eea82a04baaca7580e105d8c59450df03998345bb2cfb307b/textual-8.2.8.tar.gz", hash = "sha256:3f106a9fbc73e39dd266c9712432087de78a6d644084c7c241d6a25c3169115b", size = 1860502, upload-time = "2026-06-30T06:51:24.495Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fb/be/35261223d9416a0751cdff1c7b4a6f881387218a12d439fe22fefebc8c04/textual-8.2.8-py3-none-any.whl", hash = "sha256:267375fd402dc8d981457212efa71f0e3365fd17bba144ba9bb3ed7563cb374a", size = 731418, upload-time = "2026-06-30T06:51:26.364Z" },
]
[[package]]
name = "threadpoolctl"
version = "3.6.0"
@@ -1960,6 +2072,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" },
]
[[package]]
name = "uc-micro-py"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/78/67/9a363818028526e2d4579334460df777115bdec1bb77c08f9db88f6389f2/uc_micro_py-2.0.0.tar.gz", hash = "sha256:c53691e495c8db60e16ffc4861a35469b0ba0821fe409a8a7a0a71864d33a811", size = 6611, upload-time = "2026-03-01T06:31:27.526Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/61/73/d21edf5b204d1467e06500080a50f79d49ef2b997c79123a536d4a17d97c/uc_micro_py-2.0.0-py3-none-any.whl", hash = "sha256:3603a3859af53e5a39bc7677713c78ea6589ff188d70f4fee165db88e22b242c", size = 6383, upload-time = "2026-03-01T06:31:26.257Z" },
]
[[package]]
name = "uproot"
version = "5.7.4"