From f3530b618ee7165f7ef02a31763a4c929137192f Mon Sep 17 00:00:00 2001 From: Carmelo Gonzales Date: Thu, 30 Apr 2026 16:21:25 -0700 Subject: [PATCH 01/68] feat: consolidated radiation transport surrogate model training/eval recipe --- .../radiation_transport/README.md | 653 +++++++++ .../radiation_transport/requirements.txt | 10 + .../radiation_transport/src/checkpointing.py | 595 ++++++++ .../src/compute_normalizations.py | 444 ++++++ .../src/conf/case/hohlraum.yaml | 21 + .../src/conf/case/lattice.yaml | 20 + .../radiation_transport/src/conf/config.yaml | 33 + .../src/conf/data/hohlraum.yaml | 20 + .../src/conf/data/lattice.yaml | 22 + .../src/conf/model/transolver.yaml | 26 + .../src/conf/train/base.yaml | 65 + .../radiation_transport/src/dataset.py | 857 ++++++++++++ .../radiation_transport/src/inference.py | 847 ++++++++++++ .../radiation_transport/src/loader.py | 1202 +++++++++++++++++ .../radiation_transport/src/losses.py | 1095 +++++++++++++++ .../radiation_transport/src/material.py | 532 ++++++++ .../radiation_transport/src/train.py | 494 +++++++ .../radiation_transport/src/trainer.py | 700 ++++++++++ .../radiation_transport/src/transforms.py | 738 ++++++++++ 19 files changed, 8374 insertions(+) create mode 100644 examples/cfd/nuclear_engineering/radiation_transport/README.md create mode 100644 examples/cfd/nuclear_engineering/radiation_transport/requirements.txt create mode 100644 examples/cfd/nuclear_engineering/radiation_transport/src/checkpointing.py create mode 100644 examples/cfd/nuclear_engineering/radiation_transport/src/compute_normalizations.py create mode 100644 examples/cfd/nuclear_engineering/radiation_transport/src/conf/case/hohlraum.yaml create mode 100644 examples/cfd/nuclear_engineering/radiation_transport/src/conf/case/lattice.yaml create mode 100644 examples/cfd/nuclear_engineering/radiation_transport/src/conf/config.yaml create mode 100644 examples/cfd/nuclear_engineering/radiation_transport/src/conf/data/hohlraum.yaml create mode 100644 examples/cfd/nuclear_engineering/radiation_transport/src/conf/data/lattice.yaml create mode 100644 examples/cfd/nuclear_engineering/radiation_transport/src/conf/model/transolver.yaml create mode 100644 examples/cfd/nuclear_engineering/radiation_transport/src/conf/train/base.yaml create mode 100644 examples/cfd/nuclear_engineering/radiation_transport/src/dataset.py create mode 100644 examples/cfd/nuclear_engineering/radiation_transport/src/inference.py create mode 100644 examples/cfd/nuclear_engineering/radiation_transport/src/loader.py create mode 100644 examples/cfd/nuclear_engineering/radiation_transport/src/losses.py create mode 100644 examples/cfd/nuclear_engineering/radiation_transport/src/material.py create mode 100644 examples/cfd/nuclear_engineering/radiation_transport/src/train.py create mode 100644 examples/cfd/nuclear_engineering/radiation_transport/src/trainer.py create mode 100644 examples/cfd/nuclear_engineering/radiation_transport/src/transforms.py diff --git a/examples/cfd/nuclear_engineering/radiation_transport/README.md b/examples/cfd/nuclear_engineering/radiation_transport/README.md new file mode 100644 index 0000000000..4d5e835713 --- /dev/null +++ b/examples/cfd/nuclear_engineering/radiation_transport/README.md @@ -0,0 +1,653 @@ +# Radiation Transport with Transolver + +A PhysicsNeMo example that trains a [Transolver](https://arxiv.org/abs/2402.02366) +surrogate for the steady-state radiative-transfer equation on two canonical +2-D benchmarks from the thermal-radiation-transport literature: **lattice** +and **hohlraum**. The training pipeline uses a physics-informed loss that +combines region-weighted MSE on the scalar flux with a quantity-of-interest +(QoI) penalty. + +--- + +## Table of contents + +1. [The science](#1-the-science) +2. [Installation](#2-installation) +3. [Dataset](#3-dataset) +4. [Training](#4-training) +5. [Evaluation](#5-evaluation) +6. [Interpreting model performance](#6-interpreting-model-performance) +7. [Configuration reference](#7-configuration-reference) +8. [File overview](#8-file-overview) +9. [References](#9-references) + +--- + +## 1. The science + +The model solves the steady-state radiative-transfer equation for a scalar +flux field `φ(x)` over a 2-D domain. Inputs to the surrogate are: + +- **Coordinates** `(x, y)` per cell, normalized to `[-1, 1]` and augmented with + Fourier features (3 frequencies × 2 axes × {sin, cos} = 12 extra channels). +- **Material properties** per cell: absorption coefficient `σ_a`, scattering + coefficient `σ_s`, and total `σ_t = σ_a + σ_s`. Lattice cases additionally + include a heat source `Q`. + +The surrogate predicts the **z-score-of-log scalar flux**, which is then +inverted via `transforms.denormalize_flux` to recover the physical flux. + +### 1.1 Lattice benchmark + +A unit square partitioned into a 7×7 grid of material blocks. Each block is +either **absorber** (high `σ_a`, low `σ_s`), **scatterer** (low `σ_a`, high +`σ_s`), or **source** (interior `Q > 0`). The model has to capture sharp flux +discontinuities at material interfaces and reproduce the integrated +absorption rate in the central region. + +QoI: integrated absorption `∫_Ω_c σ_a · φ dA` over the central source block. + +### 1.2 Hohlraum benchmark + +An axisymmetric cylindrical cavity with optional interior void regions, +representing a simplified inertial-confinement-fusion target. There is no +interior heat source — flux enters from boundary conditions and propagates +through the cavity. Geometry parameters (upper/lower laser-entry radii, +center offsets) vary across simulations. + +QoI: per-region integrated absorption over `{center, vertical strip, +horizontal strip, total domain}`. By default `train.physics_loss.qoi_region=all` +averages all four region losses so every region contributes to the gradient; +set it to a single region (`center`, `vertical`, `horizontal`, `total`) to +backprop on that region alone. Either way, all four region losses are +logged each batch. + +--- + +## 2. Installation + +The example is in the PhysicsNeMo repo. From the example directory: + +```bash +cd physicsnemo/examples/cfd/nuclear_engineering/radiation_transport +pip install -r requirements.txt +``` + +Prerequisites: + +- **PyTorch ≥ 2.6** — `torch.optim.Muon` is built in. Earlier PyTorch versions + work if you stick to the default `train.optimizer.type=adam`. +- **PhysicsNeMo** — install the host repo with `[model-extras,datapipes-extras]` + to get `physicsnemo.models.transolver.Transolver` and the `tensordict`-based + data utilities. We don't need `gnns` / `mesh-extras` / `uq-extras`. + +Quick install via `uv` (PhysicsNeMo's recommended package manager): + +```bash +cd +uv venv .venv --python 3.13 +source .venv/bin/activate +# Pick the CUDA wheel that matches your driver: +# driver supports CUDA 13.x -> cu13 (default in pyproject) +# driver only supports 12.x -> cu12 +uv pip install -e ".[cu12,model-extras,datapipes-extras]" +uv pip install tensorboard # for TB logging +``` + +#### TransformerEngine (default `model.use_te=true`) + +The Transolver model imports `transformer_engine.pytorch` at module load +time, even when `use_te=false`. You must have a TE wheel matching your +PyTorch CUDA version installed. For the cu12 venv: + +```bash +uv pip install --reinstall --no-cache transformer-engine-cu12==2.14.1 +uv pip install transformer-engine-torch transformer_engine +``` + +> **uv quirk:** The first `--reinstall --no-cache` is required. Without +> it, uv may silently drop the 855 MB `libtransformer_engine.so` from the +> wheel and the import will fail with `Could not find shared object file +> for Transformer Engine core lib`. + +If your driver is on CUDA 13, swap `cu12` → `cu13` everywhere above. + +Verify: + +```bash +python -c " +from physicsnemo.models.transolver import Transolver +from torch.optim import Muon +import zarr, tensordict, torch +print('torch:', torch.__version__, 'cuda:', torch.cuda.is_available()) +print('Transolver:', Transolver) +" +``` + +--- + +## 3. Dataset + +### 3.1 Download + +> **TODO:** HuggingFace dataset URL. Until then, raw simulation data has to +> be curated through the upstream RTE workshop pipeline. + +### 3.2 Expected on-disk layout + +``` +/ +├── lattice/ +│ ├── lattice_abs_scatter_p

_q.zarr/ +│ └── ... +├── hohlraum/ +│ ├── hohlraum_variable_cl<...>_q<...>_ulr<...>_llr<...>_<...>.zarr/ +│ └── ... +├── splits/ +│ ├── lattice_splits.json # train/val/test split lists +│ └── hohlraum_splits.json +└── stats/ + ├── lattice_flux_stats.yaml + ├── lattice_material_stats.yaml + ├── hohlraum_flux_stats.yaml + └── hohlraum_material_stats.yaml +``` + +### 3.3 What's in each zarr store + +Each `*.zarr/` directory is one simulation. Keys (read by `dataset.ZarrDataReader`): + +| Key | Shape | Notes | +|---|---|---| +| `scalar_flux` | `(T, N)` or `(N,)` | physical flux (W m⁻²·sr⁻¹). Steady-state stores have `T=1`. | +| `sigma_a`, `sigma_s` | `(N,)` | absorption / scattering coefficients per cell | +| `Q` | `(N,)` | heat source (lattice only; zeros in hohlraum) | +| `coordinates` | `(N, 2)` | cell-center positions in physical units | +| `cell_areas` | `(N,)` | per-cell areas — used by physics loss for surface integrals | +| `material_labels` | `(N,)` | integer region IDs (consumed by `LatticeMaterialMapper` / `HohlraumMaterialMapper`) | +| `metadata` | dict | timestep, sim_time, geometry params (hohlraum), filename | + +`N` is the number of cells per simulation (~tens of thousands). Different +simulations may have different `N` — point-cloud collation handles this. + +### 3.4 Splits file format + +The dataset reader (`dataset._load_split_from_file`) expects a wrapped +JSON document with a `"splits"` key: + +```json +{ + "case_type": "lattice", + "split_name": "default", + "total_samples": 707, + "train_size": 494, + "val_size": 106, + "test_size": 107, + "splits": { + "train": ["lattice_abs52.5_scatter4.6_p0.015_q6", ...], + "val": ["lattice_abs85.0_scatter9.1_p0.015_q6", ...], + "test": ["lattice_abs77.5_scatter4.1_p0.015_q6", ...] + } +} +``` + +Filenames in the splits arrays are zarr **basenames** without the `.zarr` +suffix; the reader appends it automatically when opening stores. + +If the splits file is named with a suffix (e.g. `lattice_splits_default.json`, +`lattice_splits_overfit_1sample.json`), point at it explicitly: + +```bash +... case.split_file=/splits/lattice_splits_overfit_1sample.json +``` + +### 3.5 Computing normalization stats + +If `/stats/_{flux,material}_stats.yaml` are missing (e.g. you +re-curated the data, or you started from a fresh download that only ships +flux stats), generate them with: + +```bash +python src/compute_normalizations.py \ + --data_path /lattice \ + --case_type lattice \ + --output_dir /stats \ + --steady_state + +python src/compute_normalizations.py \ + --data_path /hohlraum \ + --case_type hohlraum \ + --output_dir /stats \ + --steady_state +``` + +Pass `--split_file ...` to compute stats over the train split only (matches +what training will see). Drop `--steady_state` for time-resolved data. + +The flux stats YAML contains the log-flux mean/std/min/max + `clip_threshold`, +used by `RTEFluxLogClip` and `denormalize_flux`. The material stats YAML +contains per-channel mean/std/min/max for `{σ_a, σ_s, σ_t, Q}`. + +--- + +## 4. Training + +### 4.1 Quick start + +Lattice: + +```bash +python src/train.py case=lattice data=lattice case.data_root= +``` + +Hohlraum: + +```bash +python src/train.py case=hohlraum data=hohlraum case.data_root= +``` + +Single-process default: 501 epochs, AMP-bf16, cosine LR with 10 warmup epochs, +peak LR 3e-5, physics loss enabled at weight 0.005 (lattice) / 0.01 (hohlraum). + +### 4.2 Multi-GPU + +```bash +torchrun --nproc_per_node=N src/train.py \ + case=lattice data=lattice case.data_root= +``` + +DDP is auto-detected via `physicsnemo.distributed.DistributedManager`. Set +`data.preload_data=true` (default) so each rank loads its static arrays into +host RAM through a sequenced barrier; this is much faster than re-reading +zarr per epoch but uses ~`N_train × N × 4 bytes × num_static_fields` of memory +on rank 0. + +### 4.3 Common overrides + +| Override | Effect | +|---|---| +| `train.epochs=200` | Shorter run | +| `train.optimizer.type=muon` | Use `torch.optim.Muon` for 2-D weights, Adam for biases / norms | +| `train.amp=false` | Disable mixed precision (debug / numerical parity) | +| `train.physics_loss.qoi_region=center` | Hohlraum-only: backprop on a single region. Default `all` averages the four (center, vertical, horizontal, total). | +| `train.physics_loss.weight=0.0` | Pure MSE training (disables QoI penalty) | +| `train.dataloader.num_workers=4` | DataLoader workers | +| `model.num_spatial_points=8192` | Subsample cells per training step (–1 = use all) | +| `model.n_layers=12 model.n_hidden=384` | Bigger Transolver | +| `model.use_te=true` | Use NVIDIA TransformerEngine layers (requires `[model-extras]`) | +| `train.resume_checkpoint=path/to/checkpoint.0.0.pt` | Resume from a checkpoint | + +### 4.4 Output structure + +Per run, under `outputs/${project.name}/${case.type}/${exp_tag}/`: + +``` +outputs/RTE_Transolver/lattice/transolver/ +├── hydra/ +│ ├── config.yaml # resolved Hydra config (canonical record of the run) +│ ├── hydra.yaml +│ └── overrides.yaml +├── checkpoints/ +│ ├── checkpoint.0.0.pt # latest training-state checkpoint (every train.checkpoint_interval) +│ ├── Transolver.0.0.mdlus # latest model weights only +│ ├── best_model_epoch_/ # snapshot of the lowest val_loss epoch +│ ├── best_qoi_model/ # snapshot of the lowest val_qoi epoch (use this for QoI eval) +│ └── top_model/ # current top-1 by val_loss +├── tensorboard/ # TB event files (open with `tensorboard --logdir tensorboard/`) +└── train.log +``` + +`best_qoi_model/` is the checkpoint to feed `inference.py` when comparing +runs by QoI relative error. `best_model_epoch_/` and `top_model/` track +val MSE. + +--- + +## 5. Evaluation + +### 5.1 Run inference + +```bash +python src/inference.py \ + --checkpoint_dir outputs/RTE_Transolver/lattice/transolver/checkpoints/best_qoi_model \ + --data_path \ + --case_type lattice \ + --output_dir results/lattice +``` + +CLI options: + +| Flag | Effect | +|---|---| +| `--checkpoint_dir DIR` | A directory containing `Transolver.0.0.mdlus` + `checkpoint.0.0.pt`. Pass either a `best_*/` snapshot dir or the run's `checkpoints/` root (where `find_best_checkpoint` will pick the latest). | +| `--data_path DIR` | The same `` you trained against. The script overrides `case.data_root`/`split_file`/`stats` paths from this. | +| `--case_type {lattice,hohlraum}` | Required. | +| `--output_dir DIR` | Where to write metrics + figures. Default: `/evaluation`. | +| `--num_samples N` | Limit to the first `N` test simulations (default: all). | +| `--num_workers N` | DataLoader workers. | +| `--device {cpu,cuda,cuda:0,...}` | Defaults to CUDA if available. | +| `--num_plot_samples N` | Number of `flux_panels_.png` figures to write (default: 4). | + +### 5.2 Outputs + +``` +/ +├── metrics.yaml # field-level metrics over the whole test set +├── qoi_metrics.yaml # per-region QoI relative error +└── figures/ + ├── flux_panels_.png # target / prediction / error 3-panel for sample + ├── true_vs_pred.png # scatter of all (true, pred) flux values + └── error_histogram.png # distribution of pointwise (pred − true) +``` + +### 5.3 Metric definitions + +`metrics.yaml::overall` is computed once over **all** evaluation samples +flattened together (denormalized to physical flux): + +| Key | Definition | +|---|---| +| `mse` | `mean((pred − target)^2)` | +| `rmse` | `sqrt(mse)` | +| `mae` | `mean(|pred − target|)` | +| `l2_relative_error` | `‖pred − target‖₂ / ‖target‖₂` — the headline number | +| `relative_error` | `mean(|pred − target| / |target|)` — sensitive to near-zero target cells, often dominated by void regions | +| `max_error` | `max(|pred − target|)` | + +`metrics.yaml::per_sample_aggregate` reports `{mean, std, min, max}` of each +metric across simulations — useful for catching outliers (one bad simulation +dominating the mean). + +`qoi_metrics.yaml` reports per-region: + +| Key | Definition | +|---|---| +| `mae` | mean absolute error of the integrated QoI scalar | +| `rmse` | RMSE of the integrated QoI scalar | +| `max_error` | worst single-simulation QoI error | +| `mean_relative_error_pct` | mean of `100 · |Q_pred − Q_true| / |Q_true|` | +| `median_relative_error_pct` | median of the same | +| `max_relative_error_pct` | worst single-simulation relative error | + +For lattice, the only region is `cur_absorption` (central source block). For +hohlraum, you'll see entries keyed by whichever `qoi_region` was active +during training. + +### 5.4 Comparing runs + +The single most useful comparison is **`qoi_metrics.yaml::::mean_relative_error_pct`**. +Below 5% on hohlraum-center is competitive with classical solvers on these +benchmarks; below 1% is the published Transolver target after full training. + +For field-level comparisons, use `metrics.yaml::overall::l2_relative_error`. +Values below 5% indicate the model has learned the global flux structure; +below 1% means it's also picking up sharp interface features. + +--- + +## 6. Interpreting model performance + +### 6.1 What "good" looks like (after full training, ~500 epochs) + +| Benchmark | l2_relative_error | QoI mean_relative_error_pct | +|---|---|---| +| Lattice (center QoI) | 1–3% | 0.5–2% | +| Hohlraum (center QoI) | 2–5% | 1–3% | + +These targets assume the default Transolver size (`n_layers=8, n_hidden=256, +slice_num=128`) and the published 7×7 lattice / variable-geometry hohlraum +distribution. + +### 6.2 Reading the training log + +Per-epoch line you'll see in `train.log`: + +``` +Epoch 250: train_loss=4.23e-03, val_loss=5.91e-03, + train_mse=4.18e-03, val_mse=5.84e-03, + train_qoi=2.15e-02, val_qoi=2.43e-02, + train_qoi_center=2.15e-02, val_qoi_center=2.43e-02, + lr=2.81e-05 +``` + +Key signals: + +- **`val_loss` plateauing while `train_loss` keeps falling** → overfitting. + Lower `model.dropout`, raise `train.region_weights.material_weight`, or + use `--num_samples` per-rank subsetting to grow the effective training + data. +- **`val_qoi` stuck near 1.0** while `val_mse` shrinks → model is learning + the bulk flux but not preserving the integral. Increase + `train.physics_loss.weight`, or extend `train.physics_loss.warmup_epochs` + to let MSE settle first. +- **`val_loss` oscillates wildly** → reduce LR (`train.learning_rate=1e-5`) + or shorten `train.warmup_epochs`. +- **`lr` dropping below `train.min_learning_rate`** late in training → cosine + schedule has bottomed out; consider a longer `train.epochs` for a slower + decay. + +### 6.3 Reading the inference figures + +- **`flux_panels_.png`** — three panels: target, prediction, signed + error. Sharp interface features in the target should appear (slightly + blurred) in the prediction; the error panel should be near-zero in + homogeneous regions and concentrated along material interfaces. Persistent + bias of one sign (all-positive or all-negative error) indicates a + systematic offset — usually a normalization stat issue. +- **`true_vs_pred.png`** — points should lie close to the `y = x` diagonal + across the full dynamic range. A "fan" near the origin is normal (low-flux + void cells are hard); fans at the high end are not normal and usually + indicate undertraining or saturation in the model's last layer. +- **`error_histogram.png`** — should be symmetric around zero with thin + tails. Heavy-tailed asymmetric errors typically mean the QoI loss is + off-balance with the MSE loss. + +### 6.4 Common pitfalls + +- **Hohlraum's `embedding_dim` mismatch.** With `case.include_q_in_embedding=false` + the adapter produces 3 channels (no `Q`), so the model's `embedding_dim` + must also be 3. The `case/hohlraum.yaml::embedding_dim_override: 3` + handles this; if you override `model.embedding_dim` directly without + matching the case config you'll get a silent shape mismatch at the first + forward pass. +- **AMP underflow on the QoI integral.** `train.amp=true` casts the forward + pass to bf16, but the physics loss runs in fp32 internally (denormalized + flux is sensitive to log-domain spread). If you see `loss_qoi=NaN` early + in training, check that your dataset's flux range fits inside + `clip_threshold` correctly. +- **Stale `top_model/` after CLI override of `output:`.** The `top_model/` + symlink is per-run; if you rerun with the same `${output}` path the new + run will overwrite the old top model. Either change `exp_tag=...` or + `output=...` to keep separate run trees. + +--- + +## 7. Configuration reference + +All training hyperparameters live under `src/conf/`, composed by Hydra: + +``` +src/conf/ +├── config.yaml # root: composes case / data / model / train +├── case/{lattice,hohlraum}.yaml +├── data/{lattice,hohlraum}.yaml +├── model/transolver.yaml +└── train/base.yaml +``` + +`config.yaml` defaults list: + +```yaml +defaults: + - case: lattice + - data: lattice + - model: transolver + - train: base + - _self_ +``` + +CLI overrides follow Hydra's standard syntax: + +```bash +python src/train.py \ + case=hohlraum data=hohlraum \ + case.data_root=/path/to/data \ + train.epochs=300 \ + train.optimizer.type=muon \ + train.physics_loss.weight=0.02 \ + model.n_layers=12 model.n_hidden=384 +``` + +The Hydra group structure means `case=hohlraum` swaps the entire +`case/hohlraum.yaml` (including `physics_loss_weight`, `qoi_region`, +`include_q_in_embedding`, and `embedding_dim_override`). The downstream +`train/base.yaml` and `model/transolver.yaml` interpolate from `${case.*}` +so case-specific overrides propagate automatically. + +--- + +## 8. File overview + +| File | LOC | Purpose | +|---|---|---| +| `src/train.py` | ~500 | Hydra entry; inlined Transolver build/forward/loss_inputs; dispatches to trainer | +| `src/trainer.py` | ~700 | Training loop, gradient accumulation, DDP primitives, TB logging | +| `src/losses.py` | ~1080 | MSE / region-weighted / physics-informed losses, torch QoI helpers, schedulers | +| `src/checkpointing.py` | ~600 | Save/load checkpoints, resume, optimizer (Adam + `torch.optim.Muon`) and scheduler factory | +| `src/inference.py` | ~850 | Checkpoint inference, metrics, plots; argparse CLI | +| `src/compute_normalizations.py` | ~440 | One-shot CLI to compute flux + material statistics over a zarr root | +| `src/dataset.py` | ~860 | Zarr reader, PyTorch Dataset, stats loaders | +| `src/transforms.py` | ~740 | Transform framework + flux / coordinate / sampling / QoI transforms | +| `src/material.py` | ~530 | Lattice/hohlraum material mappers + material lookup transform | +| `src/loader.py` | ~1200 | TransolverAdapter, collate, datapipe orchestration, DataLoader factory | + +Total: ~7.5 KLOC across 10 flat source files (no `__init__.py`, no +subpackages). + +--- + +## 9. References + +- **Transolver:** Wu, H. et al. ["Transolver: A Fast Transformer Solver for + PDEs on General Geometries"](https://arxiv.org/abs/2402.02366), ICML 2024. +- **Lattice benchmark:** Brunner, T. A. (2002). *Forms of approximate + radiation transport*, SAND2002-1778. +- **Hohlraum benchmark:** Tencer, J. et al. (2018). *A multifidelity Monte + Carlo method for thermal radiation transport*, JCP 376. +- **PhysicsNeMo:** [github.com/NVIDIA/physicsnemo](https://github.com/NVIDIA/physicsnemo). + +--- + +## 10. Full-dataset commands (this workstation) + +Tested layout for the local high-fidelity dataset at +`/home/carmelog/Projects/Datasets/RTE/high_fidel_zarr/new_zarr_stores_t0_tfinal/` +(709 lattice sims, 846 hohlraum sims, splits and pre-computed flux + material +stats already present). The default `case/{lattice,hohlraum}.yaml` paths +match this layout exactly — no `case.split_file` or +`data.flux_normalization_stats_file` overrides needed. + +### Setup (once) + +This workstation has two GPUs: GPU 0 is an RTX A400 (4 GB, too small for the +default model), GPU 1 is an RTX 6000 Ada (48 GB) — these are the indices +`nvidia-smi -L` reports. Pin training to GPU 1. + +> **Note on GPU ordering.** PyTorch defaults to FASTEST_FIRST ordering, which +> swaps the two cards relative to `nvidia-smi`. Setting +> `CUDA_DEVICE_ORDER=PCI_BUS_ID` makes the CUDA indices match +> `nvidia-smi`'s, so `CUDA_VISIBLE_DEVICES=1` reliably picks the 6000 Ada. + +```bash +# Activate the cu12 venv (PyTorch with CUDA 12.8, matches the 570.x driver). +source /home/carmelog/Projects/Workshops/RTE/physicsnemo/.venv/bin/activate +cd /home/carmelog/Projects/Workshops/RTE/physicsnemo/examples/cfd/nuclear_engineering/radiation_transport + +# Pin training to the RTX 6000 Ada (PCI index 1). +export CUDA_DEVICE_ORDER=PCI_BUS_ID +export CUDA_VISIBLE_DEVICES=1 +export DATA_ROOT=/home/carmelog/Projects/Datasets/RTE/high_fidel_zarr/new_zarr_stores_t0_tfinal +``` + +Sanity-check (should print `True NVIDIA RTX 6000 Ada Generation`): + +```bash +python -c "import torch; print(torch.cuda.is_available(), torch.cuda.get_device_name(0))" +``` + +### Train — lattice (default config: 501 epochs, Muon, AMP-bf16) + +Single line (paste-safe — no backslash continuations to break across lines): + +```bash +python src/train.py case=lattice data=lattice case.data_root=$DATA_ROOT train.optimizer.type=muon exp_tag=lattice_full +``` + +### Train — hohlraum + +```bash +python src/train.py case=hohlraum data=hohlraum case.data_root=$DATA_ROOT train.optimizer.type=muon exp_tag=hohlraum_full +``` + +> **Heads-up.** If you see Hydra's `LexerNoViableAltException` with a stray +> `^`, it means an override expanded to empty — usually `$DATA_ROOT` wasn't +> exported in the current shell. Run `echo "$DATA_ROOT"` to confirm it's +> non-empty before launching. + +The hohlraum config picks up `physics_loss_weight=0.01`, `qoi_region=center`, +`include_q_in_embedding=false`, and `embedding_dim_override=3` automatically +from `case/hohlraum.yaml`. + +### Monitor training + +```bash +# Live log +tail -f outputs/RTE_Transolver/lattice/lattice_full/train.log +# Or hohlraum +tail -f outputs/RTE_Transolver/hohlraum/hohlraum_full/train.log + +# TensorBoard +tensorboard --logdir outputs/RTE_Transolver --port 6006 +``` + +### Evaluate — lattice + +```bash +python src/inference.py --checkpoint_dir outputs/RTE_Transolver/lattice/lattice_full/checkpoints/best_qoi_model --data_path $DATA_ROOT --case_type lattice --output_dir results/lattice_full +``` + +### Evaluate — hohlraum + +```bash +python src/inference.py --checkpoint_dir outputs/RTE_Transolver/hohlraum/hohlraum_full/checkpoints/best_qoi_model --data_path $DATA_ROOT --case_type hohlraum --output_dir results/hohlraum_full +``` + +After both runs you'll have: + +``` +results/ +├── lattice_full/ +│ ├── metrics.yaml # field-level: l2_relative_error is the headline number +│ ├── qoi_metrics.yaml # cur_absorption: target <2% mean_relative_error_pct +│ └── figures/{flux_panels_*.png, true_vs_pred.png, error_histogram.png} +└── hohlraum_full/ + ├── metrics.yaml + ├── qoi_metrics.yaml # qoi_: target <3% mean_relative_error_pct on center + └── figures/{...} +``` + +Compare runs by `qoi_metrics.yaml::::mean_relative_error_pct` for QoI +fidelity, and `metrics.yaml::overall::l2_relative_error` for global flux +fidelity. See §6.1 for target ranges on each benchmark. + +### Optional: shorter run for a first pass + +If you want to confirm the pipeline before committing to the full 501-epoch +schedule, run 50 epochs first: + +```bash +python src/train.py case=lattice data=lattice case.data_root=$DATA_ROOT train.optimizer.type=muon train.epochs=50 train.warmup_epochs=5 exp_tag=lattice_quick +``` + +Expect `val_loss` around 5e-2–1e-1 and `val_qoi` somewhere below 0.5 by +epoch 50 with the default model size on the full dataset. diff --git a/examples/cfd/nuclear_engineering/radiation_transport/requirements.txt b/examples/cfd/nuclear_engineering/radiation_transport/requirements.txt new file mode 100644 index 0000000000..725b66adbc --- /dev/null +++ b/examples/cfd/nuclear_engineering/radiation_transport/requirements.txt @@ -0,0 +1,10 @@ +hydra-core>=1.3 +omegaconf>=2.3 +zarr<3 +tensordict +numpy +scipy +matplotlib +pyyaml +pandas +tqdm diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/checkpointing.py b/examples/cfd/nuclear_engineering/radiation_transport/src/checkpointing.py new file mode 100644 index 0000000000..bce5078bcc --- /dev/null +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/checkpointing.py @@ -0,0 +1,595 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. +# SPDX-FileCopyrightText: All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Checkpointing, optimizer factory, and training-state setup. + +Consolidates three concerns that all sit at the boundary between "model" and +"training loop": + +* Optimizer construction (Adam + optional Muon hybrid). +* Best / best-QoI checkpoint management (save, prune, top-model symlink). +* Training-state assembly (`create_training_components`) and resume/pretrain + loading (`resume_or_pretrain`). + +DDP / seeding / batch-size logging helpers live in ``trainer.py``. +""" + +import logging +import os +import shutil +from pathlib import Path +from typing import Any, Dict, List, Literal, Optional, Tuple + +import torch +import torch.nn as nn +from omegaconf import DictConfig +from torch.amp import GradScaler +from torch.utils.tensorboard import SummaryWriter + +from physicsnemo.distributed import DistributedManager +from physicsnemo.utils.checkpoint import load_checkpoint +from physicsnemo.utils.logging.launch import LaunchLogger + +# Sibling import: scheduler factory lives in losses.py. +from losses import create_scheduler + + +# ========================================================================= +# Optimizers +# ========================================================================= + +def create_optimizer( + model: nn.Module, + optimizer_type: Literal["adam", "muon"] = "adam", + learning_rate: float = 1e-3, + weight_decay: float = 0.0, + muon_momentum_beta: float = 0.95, + muon_lr: Optional[float] = None, + logger=None, +) -> torch.optim.Optimizer: + """Create optimizer based on configuration. + + For ``optimizer_type='muon'`` returns a hybrid optimizer that uses Muon for + 2D weight matrices and Adam for 1D parameters (biases, layer norms, etc.). + Muon only supports 2D weight matrices, hence the split. + + Args: + model: The model to optimize. + optimizer_type: ``'adam'`` or ``'muon'``. + learning_rate: Learning rate for Adam (and for 1D params when using Muon). + weight_decay: Weight decay coefficient. + muon_momentum_beta: Momentum beta for the Muon optimizer. + muon_lr: Learning rate for Muon (defaults to ``learning_rate`` if ``None``). + logger: Optional logger for info messages. + + Returns: + Configured optimizer. + """ + if optimizer_type == "adam": + optimizer = torch.optim.Adam( + model.parameters(), + lr=learning_rate, + weight_decay=weight_decay, + ) + if logger: + logger.info( + f"Using Adam optimizer with lr={learning_rate}, weight_decay={weight_decay}" + ) + return optimizer + + elif optimizer_type == "muon": + return _create_muon_optimizer( + model=model, + learning_rate=learning_rate, + weight_decay=weight_decay, + muon_momentum_beta=muon_momentum_beta, + muon_lr=muon_lr, + logger=logger, + ) + + raise ValueError(f"Unknown optimizer type: {optimizer_type}") + + +def _create_muon_optimizer( + model: nn.Module, + learning_rate: float, + weight_decay: float, + muon_momentum_beta: float, + muon_lr: Optional[float], + logger=None, +) -> torch.optim.Optimizer: + """Create a hybrid Muon + Adam optimizer. + + Muon is used for 2D weight matrices, Adam is used for 1D parameters + (biases, layer norms, etc.) and embeddings. + + Uses ``torch.optim.Muon`` (PyTorch's built-in Newton-Schulz orthogonalized + optimizer for 2-D hidden-layer weights). Available since PyTorch 2.6. + """ + try: + from torch.optim import Muon + except ImportError as e: + raise ImportError( + "torch.optim.Muon was not found. Upgrade to PyTorch >= 2.6 " + "(verified working with 2.9)." + ) from e + + muon_lr = muon_lr if muon_lr is not None else learning_rate + + # Separate parameters by dimensionality. + muon_params = [] + adam_params = [] + + for name, param in model.named_parameters(): + if not param.requires_grad: + continue + + if param.ndim == 2: + muon_params.append(param) + else: + adam_params.append(param) + + if logger: + logger.info( + f"Muon optimizer: {len(muon_params)} 2D params, {len(adam_params)} other params" + ) + + optimizers: List[torch.optim.Optimizer] = [] + + if muon_params: + # torch.optim.Muon uses ``momentum=`` (the original emerging-optimizers + # implementation called the same hyperparameter ``momentum_beta``); we + # keep ``muon_momentum_beta`` as the config key for continuity and map + # it through here. + muon_optimizer = Muon( + muon_params, + lr=muon_lr, + momentum=muon_momentum_beta, + weight_decay=weight_decay, + ) + optimizers.append(muon_optimizer) + if logger: + logger.info(f"Muon: lr={muon_lr}, momentum={muon_momentum_beta}") + + if adam_params: + adam_optimizer = torch.optim.Adam( + adam_params, + lr=learning_rate, + weight_decay=weight_decay, + ) + optimizers.append(adam_optimizer) + if logger: + logger.info(f"Adam (for 1D params): lr={learning_rate}") + + if len(optimizers) == 1: + return optimizers[0] + + return CombinedOptimizer(optimizers) + + +class CombinedOptimizer(torch.optim.Optimizer): + """Wrapper to combine multiple optimizers into a single interface. + + This allows using Muon for 2D params and Adam for 1D params while + maintaining a standard optimizer interface for the training loop. + Inherits from ``torch.optim.Optimizer`` for compatibility with LR schedulers. + """ + + def __init__(self, optimizers: List[torch.optim.Optimizer]): + self.optimizers = optimizers + + # Collect all params for the base Optimizer init. + all_params = [] + for opt in optimizers: + for group in opt.param_groups: + all_params.extend(group["params"]) + + # Initialize base Optimizer with dummy defaults; the actual param_groups + # come from the wrapped optimizers below. + super().__init__(all_params, defaults={}) + + # Replace param_groups with the ones from the wrapped optimizers. + self.param_groups = [] + for opt in optimizers: + self.param_groups.extend(opt.param_groups) + + def zero_grad(self, set_to_none: bool = True) -> None: + """Zero gradients for all wrapped optimizers.""" + for opt in self.optimizers: + opt.zero_grad(set_to_none=set_to_none) + + def step(self, closure=None) -> None: + """Step every wrapped optimizer.""" + for opt in self.optimizers: + opt.step(closure=closure) + + def state_dict(self) -> dict: + """Return combined state dict.""" + return { + "optimizers": [opt.state_dict() for opt in self.optimizers], + } + + def load_state_dict(self, state_dict: dict) -> None: + """Load combined state dict.""" + for opt, opt_state in zip(self.optimizers, state_dict["optimizers"]): + opt.load_state_dict(opt_state) + + +# ========================================================================= +# Save / load checkpoints +# ========================================================================= + +# Maximum number of best checkpoints to keep (by validation loss). +MAX_BEST_CHECKPOINTS = 3 + +# Folder name for the single best model (lowest validation loss). +TOP_MODEL_DIR = "top_model" + +# Folder name for the best model by QoI relative error. +BEST_QOI_MODEL_DIR = "best_qoi_model" + + +def save_best_checkpoint( + checkpoint_dir: Path, + epoch: int, + val_loss: float, + best_val_losses: List[Tuple[float, int]], + save_checkpoint_fn, + logger: logging.Logger = None, + **checkpoint_kwargs, +) -> List[Tuple[float, int]]: + """Save checkpoint if it's in top-N best models, and clean up old checkpoints. + + Also maintains a ``top_model`` folder containing the single best model by + validation loss. + + Args: + checkpoint_dir: Directory to save checkpoints. + epoch: Current epoch number. + val_loss: Current validation loss. + best_val_losses: List of ``(loss, epoch)`` tuples for best models + (will be modified in-place and returned). + save_checkpoint_fn: Function to call to save the checkpoint + (e.g. PhysicsNeMo's ``save_checkpoint``). + logger: Optional logger for log messages. + **checkpoint_kwargs: Additional arguments forwarded to ``save_checkpoint_fn``. + + Returns: + Updated list of ``(loss, epoch)`` tuples. + """ + checkpoint_dir = Path(checkpoint_dir) + + # Handle legacy format: convert List[float] to List[Tuple[float, int]]. + if best_val_losses and isinstance(best_val_losses[0], (int, float)): + # Legacy format detected, reset to empty (can't recover epoch info). + best_val_losses = [] + + # Check whether this is a top-N model. + current_losses = [loss for loss, _ in best_val_losses] + is_top_n = len(best_val_losses) < MAX_BEST_CHECKPOINTS or val_loss < max( + current_losses + ) + + if not is_top_n: + return best_val_losses + + # Save new best model to epoch-specific directory. + best_model_dir = checkpoint_dir / f"best_model_epoch_{epoch}" + best_model_dir.mkdir(parents=True, exist_ok=True) + + save_checkpoint_fn(path=str(best_model_dir), epoch=epoch, **checkpoint_kwargs) + + # Update best losses list with the new (loss, epoch) tuple. + best_val_losses.append((val_loss, epoch)) + best_val_losses.sort(key=lambda x: x[0]) # Sort by loss. + + # Cleanup if we have more than MAX_BEST_CHECKPOINTS. + while len(best_val_losses) > MAX_BEST_CHECKPOINTS: + worst_loss, worst_epoch = best_val_losses.pop() + cleanup_checkpoint_by_epoch(checkpoint_dir, worst_epoch, logger) + + # Update top_model folder if this is the new best. + _update_top_model( + checkpoint_dir, + val_loss, + epoch, + best_val_losses, + save_checkpoint_fn, + logger, + **checkpoint_kwargs, + ) + + if logger: + logger.info( + f" Saved top-{MAX_BEST_CHECKPOINTS} model! Val loss: {val_loss:.6f}" + ) + loss_strs = [f"{loss:.6f}" for loss, _ in best_val_losses[:3]] + logger.info(f" Top 3 losses: {loss_strs}") + + return best_val_losses + + +def _update_top_model( + checkpoint_dir: Path, + val_loss: float, + epoch: int, + best_val_losses: List[Tuple[float, int]], + save_checkpoint_fn, + logger: logging.Logger = None, + **checkpoint_kwargs, +) -> None: + """Update the ``top_model`` folder if this epoch has the lowest val loss. + + Args: + checkpoint_dir: Directory containing checkpoints. + val_loss: Current validation loss. + epoch: Current epoch number. + best_val_losses: List of ``(loss, epoch)`` tuples sorted by loss (best first). + save_checkpoint_fn: Function to save the checkpoint. + logger: Optional logger. + **checkpoint_kwargs: Additional arguments forwarded to ``save_checkpoint_fn``. + """ + if not best_val_losses: + return + + # Check whether the current epoch is the best (first in sorted list). + best_loss, best_epoch = best_val_losses[0] + if epoch != best_epoch: + return # Not the best — nothing to update. + + checkpoint_dir = Path(checkpoint_dir) + top_model_path = checkpoint_dir / TOP_MODEL_DIR + + # Remove any existing top_model folder. + if top_model_path.exists(): + shutil.rmtree(top_model_path) + + # Save the best model directly to the top_model folder. + top_model_path.mkdir(parents=True, exist_ok=True) + save_checkpoint_fn(path=str(top_model_path), epoch=epoch, **checkpoint_kwargs) + + if logger: + logger.info(f" Updated top_model (epoch {epoch}, val_loss: {val_loss:.6f})") + + +def save_best_qoi_checkpoint( + checkpoint_dir: Path, + epoch: int, + qoi_error: float, + best_qoi_error: float, + save_checkpoint_fn, + logger: logging.Logger = None, + **checkpoint_kwargs, +) -> float: + """Save checkpoint if QoI loss improved. + + Maintains a single ``best_qoi_model`` folder with the model that achieved + the lowest QoI loss during training. + + Args: + checkpoint_dir: Directory to save checkpoints. + epoch: Current epoch number. + qoi_error: Current QoI loss value (lower is better). + best_qoi_error: Previous best QoI loss value. + save_checkpoint_fn: Function to save the checkpoint. + logger: Optional logger. + **checkpoint_kwargs: Additional arguments forwarded to ``save_checkpoint_fn``. + + Returns: + Updated best QoI loss value. + """ + if qoi_error >= best_qoi_error: + return best_qoi_error + + checkpoint_dir = Path(checkpoint_dir) + qoi_model_path = checkpoint_dir / BEST_QOI_MODEL_DIR + + if qoi_model_path.exists(): + shutil.rmtree(qoi_model_path) + + qoi_model_path.mkdir(parents=True, exist_ok=True) + save_checkpoint_fn(path=str(qoi_model_path), epoch=epoch, **checkpoint_kwargs) + + if logger: + logger.info( + f" New best QoI model! epoch={epoch}, " + f"qoi_loss={qoi_error:.6e} (prev best: {best_qoi_error:.6e})" + ) + + return qoi_error + + +def cleanup_checkpoint_by_epoch( + checkpoint_dir: Path, epoch: int, logger: logging.Logger = None +) -> None: + """Remove the checkpoint directory for a specific epoch. + + Args: + checkpoint_dir: Directory containing checkpoints. + epoch: Epoch number of the checkpoint to remove. + logger: Optional logger for log messages. + """ + checkpoint_dir = Path(checkpoint_dir) + target_dir = checkpoint_dir / f"best_model_epoch_{epoch}" + + if target_dir.exists(): + shutil.rmtree(target_dir) + if logger: + logger.info(f" Removed old checkpoint: {target_dir.name}") + + +# ========================================================================= +# Training-state setup +# ========================================================================= + +def create_training_components( + cfg: DictConfig, + model: nn.Module, + dist: DistributedManager, + logger: Any, + tensorboard: bool = True, +) -> Tuple[ + torch.optim.Optimizer, + Any, + GradScaler, + Optional[SummaryWriter], + str, + List, +]: + """Create optimizer, scheduler, scaler, TensorBoard writer, and checkpoint dir. + + Also initializes ``LaunchLogger`` and returns an empty ``best_val_losses`` list + that the training loop can hand to :func:`save_best_checkpoint`. + + Args: + cfg: Hydra configuration. + model: The model (possibly DDP-wrapped). + dist: ``DistributedManager`` instance. + logger: Logger for rank-0 messages. + tensorboard: Whether to create a TensorBoard writer (default ``True``). + + Returns: + ``(optimizer, scheduler, scaler, writer, checkpoint_dir, best_val_losses)``. + """ + optimizer_cfg = cfg.train.get("optimizer", {}) + optimizer_type = optimizer_cfg.get("type", "adam") + weight_decay = optimizer_cfg.get( + "weight_decay", cfg.train.get("weight_decay", 0.0) + ) + muon_momentum_beta = optimizer_cfg.get("muon_momentum_beta", 0.95) + muon_lr = optimizer_cfg.get("muon_lr", None) + + optimizer = create_optimizer( + model=model, + optimizer_type=optimizer_type, + learning_rate=cfg.train.learning_rate, + weight_decay=weight_decay, + muon_momentum_beta=muon_momentum_beta, + muon_lr=muon_lr, + logger=logger if dist.rank == 0 else None, + ) + + scheduler = create_scheduler(cfg, optimizer, logger if dist.rank == 0 else None) + + # GradScaler is only needed for FP16 AMP. For BF16 (and for amp=false), + # we disable scaling to avoid overhead and potential instability. + amp_enabled = bool(cfg.train.get("amp", False)) + amp_dtype = str(cfg.train.get("amp_dtype", "fp16")).lower() + scaler_enabled = amp_enabled and amp_dtype in ("fp16", "float16") + scaler = GradScaler(enabled=scaler_enabled) + + LaunchLogger.initialize(use_wandb=False, use_mlflow=False) + + writer = None + if tensorboard and dist.rank == 0: + writer = SummaryWriter(os.path.join(cfg.output, "tensorboard")) + + checkpoint_dir = os.path.join(cfg.output, "checkpoints") + os.makedirs(checkpoint_dir, exist_ok=True) + + best_val_losses: List = [] + + return optimizer, scheduler, scaler, writer, checkpoint_dir, best_val_losses + + +def resume_or_pretrain( + cfg: DictConfig, + model: nn.Module, + optimizer: torch.optim.Optimizer, + scheduler: Any, + scaler: GradScaler, + dist: DistributedManager, + logger: Any, +) -> Tuple[int, List, float]: + """Handle checkpoint resume or pretrain weight loading. + + * If ``cfg.train.resume_checkpoint`` exists, loads full training state + (model, optimizer, scheduler, scaler) and returns the next epoch. + * Else if ``cfg.train.pretrain_checkpoint`` exists, loads model weights only + (optimizer/scheduler stay fresh) for fine-tuning from epoch 0. + * Otherwise returns epoch 0 with an empty ``best_val_losses`` list and + ``best_qoi_loss = +inf``. + + Args: + cfg: Hydra config. + model: Model (possibly DDP-wrapped). + optimizer: Optimizer. + scheduler: LR scheduler. + scaler: ``GradScaler``. + dist: ``DistributedManager``. + logger: Logger. + + Returns: + ``(start_epoch, best_val_losses, best_qoi_loss)``. + """ + start_epoch = 0 + best_val_losses: List = [] + best_qoi_loss = float("inf") + + resume_checkpoint = cfg.train.get("resume_checkpoint", None) + pretrain_checkpoint = cfg.train.get("pretrain_checkpoint", None) + + if resume_checkpoint and os.path.exists(resume_checkpoint): + if dist.rank == 0: + logger.info(f"\nResuming from checkpoint: {resume_checkpoint}") + + metadata: Dict[str, Any] = {} + start_epoch = load_checkpoint( + path=resume_checkpoint, + models=model, + optimizer=optimizer, + scheduler=scheduler, + scaler=scaler, + metadata_dict=metadata, + device=dist.device, + ) + + if "best_val_losses" in metadata: + best_val_losses = metadata["best_val_losses"] + if "best_qoi_loss" in metadata: + best_qoi_loss = metadata["best_qoi_loss"] + + if dist.rank == 0: + logger.info(f" Resumed from epoch {start_epoch}") + if best_val_losses: + if isinstance(best_val_losses[0], (int, float)): + loss_strs = [f"{v:.6f}" for v in best_val_losses[:3]] + else: + loss_strs = [f"{loss:.6f}" for loss, _ in best_val_losses[:3]] + logger.info(f" Top val losses: {loss_strs}") + if best_qoi_loss < float("inf"): + logger.info(f" Best QoI loss: {best_qoi_loss:.6e}") + + start_epoch += 1 + + elif pretrain_checkpoint and os.path.exists(pretrain_checkpoint): + if dist.rank == 0: + logger.info( + f"\nLoading pretrained weights for fine-tuning: {pretrain_checkpoint}" + ) + + load_checkpoint( + path=pretrain_checkpoint, + models=model, + device=dist.device, + ) + + if dist.rank == 0: + logger.info(" Pretrained weights loaded successfully") + logger.info(" Optimizer and scheduler reset for fine-tuning") + logger.info(" Starting from epoch 0") + + return start_epoch, best_val_losses, best_qoi_loss diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/compute_normalizations.py b/examples/cfd/nuclear_engineering/radiation_transport/src/compute_normalizations.py new file mode 100644 index 0000000000..b7aebd9944 --- /dev/null +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/compute_normalizations.py @@ -0,0 +1,444 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. +# SPDX-FileCopyrightText: All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Standalone CLI to compute flux + material statistics over a zarr root. + +Run this once before training to produce the two YAML statistics files the +training pipeline expects: + + /_flux_stats.yaml + /_material_stats.yaml + +Usage:: + + python compute_normalizations.py \\ + --data_path /lattice \\ + --case_type lattice \\ + --output_dir /stats + +The flux statistics walk the training split of the dataset, log-clip the raw +``scalar_flux`` field, and accumulate (mean, std, min, max) plus the clip +threshold the training pipeline must use. The material statistics walk the +training split through a minimal transform pipeline that derives the +per-point ``physical_properties`` tensor (sigma_a, sigma_s, sigma_t, Q) and +records (mean, std, min, max) for each component. + +The on-disk YAML schema matches the originals so that ``load_flux_stats`` / +``load_material_stats`` in ``dataset.py`` consume them unchanged. +""" + +from __future__ import annotations + +import argparse +import pathlib +import sys +from pathlib import Path +from typing import Dict, Optional + +import numpy as np +import torch +import yaml + +# Flat-import shim: when invoked as ``python compute_normalizations.py`` the +# script's own directory is already on ``sys.path``; when invoked from +# elsewhere we make sure sibling modules are importable. +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) + +from dataset import RTEBaseDataset # noqa: E402 +from loader import RTEDataPipe # noqa: E402 +from material import MaterialPropertyExtractor # noqa: E402 +from transforms import ( # noqa: E402 + Compose, + NextStepSampler, + RTEFluxLogClip, + SpatialSampler, +) + + +# ========================================================================= +# Flux statistics +# ========================================================================= +# +# Walks the training split of ``RTEBaseDataset`` (no transforms, no +# adapter). For each simulation, applies the same log-clip preprocessing +# the training pipeline uses, and accumulates global mean / std / min / max +# in single precision. Output schema matches the legacy +# ``compute_flux_statistics.py`` so the existing ``load_flux_stats`` reader +# works unchanged. + + +def compute_flux_statistics( + data_path: Path, + case_type: str, + output_file: Path, + split_file: Optional[Path] = None, + clip_threshold: float = 1e-8, + steady_state: bool = False, +) -> Dict[str, float]: + """Compute flux normalization statistics from the training split. + + Args: + data_path: path to the zarr stores for one case. + case_type: ``"lattice"`` or ``"hohlraum"``. + output_file: destination YAML path. + split_file: optional split JSON; defaults to the dataset's internal + random split. + clip_threshold: minimum flux value before ``log10``. + steady_state: when ``True``, only use the first and last timesteps + of each simulation. + + Returns: + The statistics dict written to ``output_file``. + """ + mode_label = ( + "steady state (first + last timestep)" if steady_state else "full trajectory" + ) + print(f"Computing flux statistics for {case_type} [{mode_label}]") + print(f"Data path: {data_path}") + if split_file is not None: + print(f"Split file: {split_file}") + + dataset = RTEBaseDataset( + data_path=data_path, + case_type=case_type, + phase="train", + split_file=split_file, + load_material_properties=False, + load_geometric_features=False, + expand_timesteps=False, + task="steady_state" if steady_state else "next_step", + ) + + print(f"\nProcessing {len(dataset)} training simulations...") + + n_samples = 0 + sum_log_flux = 0.0 + sum_log_flux_sq = 0.0 + min_log_flux = float("inf") + max_log_flux = float("-inf") + + for i in range(len(dataset)): + sample = dataset[i] + flux = sample["scalar_flux"] + if isinstance(flux, torch.Tensor): + flux = flux.detach().cpu().numpy() + flux = np.asarray(flux) + + if steady_state: + # select only first and last timesteps: (T, N) -> (2, N) + flux = np.stack([flux[0], flux[-1]], axis=0) + + # match training-pipeline preprocessing + flux = np.clip(flux, clip_threshold, None) + log_flux = np.log10(flux + clip_threshold) + + n = log_flux.size + n_samples += n + sum_log_flux += float(np.sum(log_flux)) + sum_log_flux_sq += float(np.sum(log_flux**2)) + min_log_flux = min(min_log_flux, float(np.min(log_flux))) + max_log_flux = max(max_log_flux, float(np.max(log_flux))) + + if (i + 1) % 10 == 0: + print(f" Processed {i + 1}/{len(dataset)} simulations") + + mean = sum_log_flux / n_samples + variance = (sum_log_flux_sq / n_samples) - (mean**2) + std = float(np.sqrt(max(variance, 0.0))) + + stats = { + "log_flux_mean": float(mean), + "log_flux_std": float(std), + "log_flux_min": float(min_log_flux), + "log_flux_max": float(max_log_flux), + "clip_threshold": float(clip_threshold), + "num_samples": int(n_samples), + "num_simulations": len(dataset), + "case_type": case_type, + } + + if steady_state: + stats["note"] = "computed from first and last timesteps only (steady state)" + + output_file = Path(output_file) + output_file.parent.mkdir(parents=True, exist_ok=True) + with open(output_file, "w") as f: + yaml.dump(stats, f, default_flow_style=False, sort_keys=False) + + print("\nFlux statistics:") + print(f" Mean (log flux): {mean:.6f}") + print(f" Std (log flux): {std:.6f}") + print(f" Min (log flux): {min_log_flux:.6f}") + print(f" Max (log flux): {max_log_flux:.6f}") + print(f" Total samples: {n_samples:,}") + print(f"\nSaved to: {output_file}") + + return stats + + +# ========================================================================= +# Material statistics +# ========================================================================= +# +# Walks the training split through a minimal transform pipeline: +# +# RTEFluxLogClip -> NextStepSampler -> MaterialPropertyExtractor -> SpatialSampler +# +# The flux log-clip step is required because the dataset reader produces a +# trajectory tensor; the temporal sampler picks one (t, t+1) pair per +# simulation, the material extractor produces ``physical_properties`` with +# shape (N, 4), and ``SpatialSampler`` subsamples to a fixed point count for +# speed. Per-property stats are written in the schema the existing +# ``load_material_stats`` reader expects. + + +def compute_material_statistics( + data_path: Path, + case_type: str, + output_file: Path, + flux_stats_file: Path, + split_file: Optional[Path] = None, + num_spatial_points: int = 2048, + seed: int = 42, +) -> Dict[str, Dict[str, float]]: + """Compute per-property material statistics from the training split. + + Args: + data_path: path to the zarr stores for one case. + case_type: ``"lattice"`` or ``"hohlraum"``. + output_file: destination YAML path. + flux_stats_file: path to the flux stats YAML produced by + :func:`compute_flux_statistics`. Required because the transform + pipeline runs ``RTEFluxLogClip`` first. + split_file: optional split JSON; defaults to the dataset's internal + random split. + num_spatial_points: number of points per simulation drawn by + ``SpatialSampler``. + seed: RNG seed for the temporal and spatial samplers. + + Returns: + The nested statistics dict written to ``output_file``. + """ + print(f"\nComputing material statistics for {case_type}") + print(f"Data path: {data_path}") + print(f"Flux stats: {flux_stats_file}") + if split_file is not None: + print(f"Split file: {split_file}") + + if not Path(flux_stats_file).exists(): + raise FileNotFoundError( + f"Flux statistics file not found: {flux_stats_file}\n" + "Compute flux statistics before material statistics." + ) + + transforms = Compose( + [ + RTEFluxLogClip( + normalization_stats_file=flux_stats_file, + clip_threshold=1e-8, + ), + NextStepSampler(stride=1, seed=seed), + MaterialPropertyExtractor(case_type=case_type), + SpatialSampler(num_points=num_spatial_points, seed=seed), + ] + ) + + print("\nCreating dataset (this may take a moment)...") + dataset = RTEDataPipe( + data_path=data_path, + transforms=transforms, + adapter=None, + case_type=case_type, + phase="train", + split_file=split_file, + expand_timesteps=False, + ) + print(f"Dataset loaded: {len(dataset)} samples") + + print("\nAccumulating physical_properties...") + all_sigma_a, all_sigma_s, all_sigma_t, all_Q = [], [], [], [] + + for i in range(len(dataset)): + sample = dataset.get_transformed_sample(i) + if "physical_properties" not in sample: + raise KeyError( + f"Sample {i} is missing 'physical_properties'. " + "MaterialPropertyExtractor did not produce expected output." + ) + props = sample["physical_properties"] + if isinstance(props, torch.Tensor): + props = props.detach().cpu().numpy() + all_sigma_a.append(props[:, 0]) + all_sigma_s.append(props[:, 1]) + all_sigma_t.append(props[:, 2]) + all_Q.append(props[:, 3]) + if (i + 1) % 100 == 0: + print(f" Processed {i + 1}/{len(dataset)} samples") + + all_sigma_a = np.concatenate(all_sigma_a) + all_sigma_s = np.concatenate(all_sigma_s) + all_sigma_t = np.concatenate(all_sigma_t) + all_Q = np.concatenate(all_Q) + + stats = { + "sigma_a": { + "mean": float(np.mean(all_sigma_a)), + "std": float(np.std(all_sigma_a)), + "min": float(np.min(all_sigma_a)), + "max": float(np.max(all_sigma_a)), + }, + "sigma_s": { + "mean": float(np.mean(all_sigma_s)), + "std": float(np.std(all_sigma_s)), + "min": float(np.min(all_sigma_s)), + "max": float(np.max(all_sigma_s)), + }, + "sigma_t": { + "mean": float(np.mean(all_sigma_t)), + "std": float(np.std(all_sigma_t)), + "min": float(np.min(all_sigma_t)), + "max": float(np.max(all_sigma_t)), + }, + "Q": { + "mean": float(np.mean(all_Q)), + "std": float(np.std(all_Q)), + "min": float(np.min(all_Q)), + "max": float(np.max(all_Q)), + }, + } + + print("\nMaterial statistics:") + print("-" * 60) + for prop_name, prop_stats in stats.items(): + print(f"{prop_name}:") + for stat_name, value in prop_stats.items(): + print(f" {stat_name:6s}: {value:10.4f}") + + output_file = Path(output_file) + output_file.parent.mkdir(parents=True, exist_ok=True) + with open(output_file, "w") as f: + yaml.dump(stats, f, default_flow_style=False, sort_keys=False) + print(f"\nSaved to: {output_file}") + + return stats + + +# ========================================================================= +# CLI entry +# ========================================================================= + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description=( + "Compute flux + material normalization statistics over a zarr root. " + "Emits two YAML files: _flux_stats.yaml and " + "_material_stats.yaml in the output directory." + ) + ) + parser.add_argument( + "--data_path", + type=Path, + required=True, + help="Path to the zarr root for one case (e.g. /lattice).", + ) + parser.add_argument( + "--case_type", + type=str, + required=True, + choices=["lattice", "hohlraum"], + help="Case type.", + ) + parser.add_argument( + "--output_dir", + type=Path, + required=True, + help="Directory to write the two YAML statistics files into.", + ) + parser.add_argument( + "--split_file", + type=Path, + default=None, + help="Optional split JSON; defaults to the dataset's internal random split.", + ) + parser.add_argument( + "--clip_threshold", + type=float, + default=1e-8, + help="Flux clip threshold used during log-transform (default: 1e-8).", + ) + parser.add_argument( + "--steady_state", + action="store_true", + help="Use only first and last timesteps for the flux statistics.", + ) + parser.add_argument( + "--num_spatial_points", + type=int, + default=2048, + help="Points per simulation for material stats subsampling (default: 2048).", + ) + parser.add_argument( + "--seed", + type=int, + default=42, + help="RNG seed for material-stats sampling (default: 42).", + ) + return parser.parse_args() + + +def main() -> int: + args = _parse_args() + + output_dir: Path = args.output_dir + output_dir.mkdir(parents=True, exist_ok=True) + + flux_output = output_dir / f"{args.case_type}_flux_stats.yaml" + material_output = output_dir / f"{args.case_type}_material_stats.yaml" + + print("=" * 80) + print("COMPUTE NORMALIZATIONS") + print("=" * 80) + + compute_flux_statistics( + data_path=args.data_path, + case_type=args.case_type, + output_file=flux_output, + split_file=args.split_file, + clip_threshold=args.clip_threshold, + steady_state=args.steady_state, + ) + + compute_material_statistics( + data_path=args.data_path, + case_type=args.case_type, + output_file=material_output, + flux_stats_file=flux_output, + split_file=args.split_file, + num_spatial_points=args.num_spatial_points, + seed=args.seed, + ) + + print("\n" + "=" * 80) + print("DONE") + print("=" * 80) + print(f" Flux stats: {flux_output}") + print(f" Material stats: {material_output}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/conf/case/hohlraum.yaml b/examples/cfd/nuclear_engineering/radiation_transport/src/conf/case/hohlraum.yaml new file mode 100644 index 0000000000..923f8d45d9 --- /dev/null +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/conf/case/hohlraum.yaml @@ -0,0 +1,21 @@ +# SPDX-FileCopyrightText: Copyright (c) 2023 - 2026 NVIDIA CORPORATION & AFFILIATES. +# SPDX-FileCopyrightText: All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Hohlraum benchmark: axisymmetric cylindrical geometry with optional +# interior void regions. No interior heat source (Q is omitted from the +# embedding). Material properties are mapped via HohlraumMaterialMapper. + +type: hohlraum +data_root: ??? # set by user (HF download root) +data_path: ${case.data_root}/hohlraum +split_file: ${case.data_root}/splits/hohlraum_splits.json + +# Physics-loss configuration (hohlraum-specific override; was the legacy +# `weight_hohlraum: 0.01` per-case override in the original training script). +physics_loss_weight: 0.01 +qoi_region: all # all (mean of 4) | center | vertical | horizontal | total + +# Embedding/material configuration: hohlraum has no Q field. +include_q_in_embedding: false +embedding_dim_override: 3 # sigma_a, sigma_s, sigma_t diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/conf/case/lattice.yaml b/examples/cfd/nuclear_engineering/radiation_transport/src/conf/case/lattice.yaml new file mode 100644 index 0000000000..18b2ba69cc --- /dev/null +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/conf/case/lattice.yaml @@ -0,0 +1,20 @@ +# SPDX-FileCopyrightText: Copyright (c) 2023 - 2026 NVIDIA CORPORATION & AFFILIATES. +# SPDX-FileCopyrightText: All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Lattice benchmark: regular grid geometry with heterogeneous material blocks +# and an interior heat source Q. Material properties are mapped from +# integer region labels via LatticeMaterialMapper. + +type: lattice +data_root: ??? # set by user (HF download root) +data_path: ${case.data_root}/lattice +split_file: ${case.data_root}/splits/lattice_splits.json + +# Physics-loss configuration (lattice-specific) +physics_loss_weight: 0.005 +qoi_region: center + +# Embedding/material configuration +include_q_in_embedding: true # lattice has a heat source +embedding_dim_override: 4 # sigma_a, sigma_s, sigma_t, Q diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/conf/config.yaml b/examples/cfd/nuclear_engineering/radiation_transport/src/conf/config.yaml new file mode 100644 index 0000000000..65ec902630 --- /dev/null +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/conf/config.yaml @@ -0,0 +1,33 @@ +# SPDX-FileCopyrightText: Copyright (c) 2023 - 2026 NVIDIA CORPORATION & AFFILIATES. +# SPDX-FileCopyrightText: All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +defaults: + - case: lattice + - data: lattice + - model: transolver + - train: base + - _self_ + +project: + name: RTE_Transolver + +exp_tag: transolver +output: outputs/${project.name}/${case.type}/${exp_tag} + +hydra: + run: + dir: ${output} + output_subdir: hydra diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/conf/data/hohlraum.yaml b/examples/cfd/nuclear_engineering/radiation_transport/src/conf/data/hohlraum.yaml new file mode 100644 index 0000000000..7b89969e59 --- /dev/null +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/conf/data/hohlraum.yaml @@ -0,0 +1,20 @@ +# SPDX-FileCopyrightText: Copyright (c) 2023 - 2026 NVIDIA CORPORATION & AFFILIATES. +# SPDX-FileCopyrightText: All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +input_dir: ${case.data_path} +task: steady_state +flux_normalization_stats_file: ${case.data_root}/stats/hohlraum_flux_stats.yaml +flux_clip_threshold: 1.0e-8 +expand_timesteps: false +cache_static_arrays: true +max_cache_size: -1 +preload_data: true +train_split: 0.8 +val_split: 0.1 + +use_fourier_features: true +fourier_features: + num_frequencies: 3 + coord_dims: 2 + base_frequency: 2.0 diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/conf/data/lattice.yaml b/examples/cfd/nuclear_engineering/radiation_transport/src/conf/data/lattice.yaml new file mode 100644 index 0000000000..26d337709a --- /dev/null +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/conf/data/lattice.yaml @@ -0,0 +1,22 @@ +# SPDX-FileCopyrightText: Copyright (c) 2023 - 2026 NVIDIA CORPORATION & AFFILIATES. +# SPDX-FileCopyrightText: All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +input_dir: ${case.data_path} +task: steady_state +flux_normalization_stats_file: ${case.data_root}/stats/lattice_flux_stats.yaml +flux_clip_threshold: 1.0e-8 +expand_timesteps: false +cache_static_arrays: true +max_cache_size: -1 +preload_data: true +train_split: 0.8 +val_split: 0.1 + +# Fourier features for coordinates (adds 2 * coord_dims * num_frequencies features). +# Default: 3 freq * 2 coords * 2 (sin/cos) = 12 extra features, on top of 3 raw coords. +use_fourier_features: true +fourier_features: + num_frequencies: 3 + coord_dims: 2 + base_frequency: 2.0 diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/conf/model/transolver.yaml b/examples/cfd/nuclear_engineering/radiation_transport/src/conf/model/transolver.yaml new file mode 100644 index 0000000000..1af3240523 --- /dev/null +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/conf/model/transolver.yaml @@ -0,0 +1,26 @@ +# SPDX-FileCopyrightText: Copyright (c) 2023 - 2026 NVIDIA CORPORATION & AFFILIATES. +# SPDX-FileCopyrightText: All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Transolver hyperparameters. The `_target_` is consumed by hydra.utils.instantiate +# inside train.py::build_model. The two RTE-specific keys at the bottom +# (num_spatial_points, include_q_in_embedding) are stripped before instantiation +# — they configure the data adapter, not the model. + +_target_: physicsnemo.models.transolver.Transolver +functional_dim: 15 # 3 coords + 12 Fourier features +embedding_dim: ${case.embedding_dim_override} +out_dim: 1 # predicted flux +n_layers: 8 +n_hidden: 256 +n_head: 16 +slice_num: 128 +mlp_ratio: 4 +dropout: 0.0 +time_input: false +use_te: true +structured_shape: null + +# RTE-specific (stripped before instantiation in train.py::build_model) +num_spatial_points: -1 +include_q_in_embedding: ${case.include_q_in_embedding} diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/conf/train/base.yaml b/examples/cfd/nuclear_engineering/radiation_transport/src/conf/train/base.yaml new file mode 100644 index 0000000000..262da163aa --- /dev/null +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/conf/train/base.yaml @@ -0,0 +1,65 @@ +# SPDX-FileCopyrightText: Copyright (c) 2023 - 2026 NVIDIA CORPORATION & AFFILIATES. +# SPDX-FileCopyrightText: All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +epochs: 501 +checkpoint_interval: 100 +gradient_accumulation_steps: 1 +seed: 6 +deterministic: false +amp: true +amp_dtype: bf16 # bf16 | fp16 +loss_metric: mse +tensorboard: true + +# Optimizer (Muon is opt-in; install via `pip install emerging-optimizers`). +optimizer: + type: adam # adam | muon + weight_decay: 0.0 + muon_momentum_beta: 0.95 + muon_lr: 1.0e-4 + +learning_rate: 3.0e-5 +min_learning_rate: 1.0e-6 +warmup_epochs: 10 +scheduler_type: cosine + +pretrain_checkpoint: null +resume_checkpoint: null + +# Physics-informed loss (case-specific weight + qoi_region pulled from case group). +use_physics_loss: true +physics_loss: + weight: ${case.physics_loss_weight} + mse_weight: 1.0 + qoi_region: ${case.qoi_region} + warmup_epochs: 0 + warmup_start_fraction: 0.0 + +# Region-weighted MSE (heavier penalty on void points). +use_region_weighted_loss: true +region_weights: + void_weight: 10.0 + material_weight: 1.0 + +dataloader: + batch_size: 1 # only batch_size=1 is supported (point-cloud adapter) + pin_memory: true + num_workers: 8 + prefetch_factor: 4 + persistent_workers: true + +sampler: + shuffle: true + drop_last: false + +val: + dataloader: + batch_size: 1 + pin_memory: true + num_workers: 8 + prefetch_factor: 4 + persistent_workers: true + sampler: + shuffle: false + drop_last: false diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/dataset.py b/examples/cfd/nuclear_engineering/radiation_transport/src/dataset.py new file mode 100644 index 0000000000..f32b5d8be4 --- /dev/null +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/dataset.py @@ -0,0 +1,857 @@ +# SPDX-FileCopyrightText: Copyright (c) 2023 - 2026 NVIDIA CORPORATION & AFFILIATES. +# SPDX-FileCopyrightText: All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""RTE data-source layer: zarr reader, PyTorch Dataset, and stats loaders. + +This module is the bottom of the data dependency tree. It provides the +low-level Zarr access (``ZarrDataReader``), a thin file/timestep-indexed +``Dataset`` wrapper (``RTEBaseDataset``), and helpers for reading the +RTE-specific YAML statistics files into PhysicsNeMo ``Normalize`` kwargs. +""" + +from __future__ import annotations + +import json +import threading +import warnings +from collections import OrderedDict +from pathlib import Path +from typing import Dict, List, Mapping, Optional, Sequence, Tuple, Union + +import numpy as np +import torch +import yaml +import zarr +from physicsnemo.datapipes.readers.base import Reader +from physicsnemo.datapipes.registry import register +from tensordict import TensorDict +from torch.utils.data import Dataset + + +# ========================================================================= +# Zarr reader +# ========================================================================= +# +# Low-level reader for RTE simulation data stored in zarr. Inherits from +# ``physicsnemo.datapipes.readers.base.Reader``; returns ``TensorDict`` from +# ``load()``. The TensorDict carries both the tensor fields and non-tensor +# metadata (``metadata``, ``filename``) via ``NonTensorData`` entries. +# +# RTE-specific kwargs (``timestep_slice``, ``timestep_indices``, +# ``load_flux``, etc.) live on the filename-indexed ``load(filename, ...)`` +# entry. The int-indexed ``_load_sample(index)`` required by the PhysicsNeMo +# ``Reader`` contract uses defaults (load everything, no slicing). + + +_TENSOR_FIELD_NAMES = ( + "coordinates", + "cell_areas", + "scalar_flux", + "timesteps", + "sim_times", + "material_properties", + "geometric_features", + "sigma_t", + "sigma_s", + "sigma_a", + "Q", +) + + +def _to_tensor(array: np.ndarray) -> torch.Tensor: + """Zero-copy when possible; always returns a CPU ``torch.Tensor``.""" + return torch.from_numpy(np.ascontiguousarray(array)) + + +@register("RTEZarrReader") +class ZarrDataReader(Reader): + """Filename-indexed reader over a directory of RTE zarr stores. + + Inherits from ``physicsnemo.datapipes.readers.base.Reader`` so the reader + plugs into any PhysicsNeMo-native pipeline via ``__getitem__(int)`` → + ``(TensorDict, metadata_dict)``. RTE pipelines still reach it via + ``load(filename, **kwargs)`` for the fine-grained timestep-selection + controls the training data loaders rely on. + + Example: + >>> reader = ZarrDataReader("/path/to/zarr_stores/lattice") + >>> filenames = reader.get_filenames() + >>> td = reader.load(filenames[0]) + >>> print(td["coordinates"].shape) # (N, 3) + + The LRU cache is retained verbatim: it stores tensor fields keyed by + filename and evicts in insertion order when ``max_cache_size`` is hit. + """ + + def __init__( + self, + data_path: Path | str, + case_type: Optional[str] = None, + cache_static_arrays: bool = True, + max_cache_size: int = 200, + ): + super().__init__(pin_memory=False, include_index_in_metadata=False) + + self.data_path = Path(data_path) + self.case_type = case_type + self.cache_static_arrays = cache_static_arrays + self.max_cache_size = max_cache_size + + # LRU cache for static arrays keyed by filename; values are dicts of + # ``torch.Tensor``. + self._static_cache: OrderedDict[str, Dict[str, torch.Tensor]] = OrderedDict() + self._cache_hits = 0 + self._cache_misses = 0 + self._cache_evictions = 0 + self._cache_lock = threading.Lock() + + if not self.data_path.exists(): + raise ValueError(f"Data path {self.data_path} does not exist") + if not self.data_path.is_dir(): + raise ValueError(f"Data path {self.data_path} is not a directory") + + # Discover files once at construction time so ``_load_sample`` has a + # stable filename→int mapping for the PhysicsNeMo ``Reader`` protocol. + self._filenames: List[str] = self._scan_filenames() + + # ------------------------------------------------------------------ + # PhysicsNeMo ``Reader`` contract + # ------------------------------------------------------------------ + + def __len__(self) -> int: + return len(self._filenames) + + def _load_sample(self, index: int) -> Dict[str, torch.Tensor]: + """Int-indexed load using defaults (load everything, no slicing).""" + td = self.load(self._filenames[index]) + return {key: td[key] for key in td.keys() if isinstance(td[key], torch.Tensor)} + + def _get_sample_metadata(self, index: int) -> Dict: + """Return per-sample metadata dict for ``(TensorDict, metadata)`` tuple.""" + filename = self._filenames[index] + meta = self.get_metadata(filename) + meta["filename"] = filename + return meta + + # ------------------------------------------------------------------ + # Filename discovery + caching (unchanged behavior) + # ------------------------------------------------------------------ + + def _scan_filenames(self) -> List[str]: + filenames = [] + for item in self.data_path.iterdir(): + if item.suffix == ".zarr" or ( + item.is_dir() and item.name.endswith(".zarr") + ): + if self.case_type is None or item.name.startswith(self.case_type): + filenames.append(item.name) + return sorted(filenames) + + def get_filenames(self) -> List[str]: + """Return a fresh list of discovered zarr store names.""" + return list(self._filenames) + + def get_cache_stats(self) -> Dict[str, float]: + with self._cache_lock: + total = self._cache_hits + self._cache_misses + hit_rate = (self._cache_hits / total * 100) if total > 0 else 0.0 + return { + "cache_size": len(self._static_cache), + "max_cache_size": self.max_cache_size, + "cache_hits": self._cache_hits, + "cache_misses": self._cache_misses, + "cache_evictions": self._cache_evictions, + "hit_rate": hit_rate, + } + + def clear_cache(self): + with self._cache_lock: + self._static_cache.clear() + self._cache_hits = 0 + self._cache_misses = 0 + self._cache_evictions = 0 + + # ------------------------------------------------------------------ + # The filename-indexed ``load`` stays the primary entry point + # ------------------------------------------------------------------ + + def load( + self, + filename: str, + load_material_properties: bool = True, + load_geometric_features: bool = True, + load_sim_times: bool = True, + load_sigma_fields: bool = True, + timestep_slice: Optional[slice] = None, + timestep_indices: Optional[List[int]] = None, + load_flux: bool = True, + ) -> TensorDict: + """Load a zarr store into a ``TensorDict``. + + Tensor fields (``coordinates``, ``cell_areas``, ``scalar_flux``, + ``timesteps`` and the optional ``sim_times`` / ``material_properties`` + / ``geometric_features`` / ``sigma_*`` / ``Q``) are stored as + ``torch.Tensor`` entries. The zarr store's ``.attrs`` dict is stored + as ``NonTensorData`` under the ``metadata`` key. + + ``load_flux=False`` returns a placeholder ``scalar_flux`` of shape + ``(1, N)`` — the caller is expected to overwrite it from a memory + cache. The sentinel matches the pre-Phase-I behavior. + """ + filepath = self.data_path / filename + if not filepath.exists(): + raise FileNotFoundError(f"Zarr store {filepath} not found") + + z = zarr.open(str(filepath), mode="r") + + # ------- flux + timesteps (honors load_flux / timestep_*) ------- + if not load_flux: + num_cells = z["scalar_flux"].shape[1] + scalar_flux = np.zeros((1, num_cells), dtype=np.float32) + timesteps_array = np.array([0]) + sim_times = None + elif timestep_indices is not None: + num_total_timesteps = z["scalar_flux"].shape[0] + resolved = [ + idx if idx >= 0 else num_total_timesteps + idx + for idx in timestep_indices + ] + scalar_flux = np.stack( + [np.array(z["scalar_flux"][idx], dtype=np.float32) for idx in resolved], + axis=0, + ) + timesteps_array = np.array([z["timesteps"][idx] for idx in resolved]) + if load_sim_times and "sim_times" in z: + sim_times = np.array( + [z["sim_times"][idx] for idx in resolved], dtype=np.float32 + ) + else: + sim_times = None + elif timestep_slice is not None: + scalar_flux = np.array(z["scalar_flux"][timestep_slice], dtype=np.float32) + timesteps_array = np.array(z["timesteps"][timestep_slice]) + if load_sim_times and "sim_times" in z: + sim_times = np.array(z["sim_times"][timestep_slice], dtype=np.float32) + else: + sim_times = None + else: + scalar_flux = np.array(z["scalar_flux"], dtype=np.float32) + timesteps_array = np.array(z["timesteps"]) + if load_sim_times and "sim_times" in z: + sim_times = np.array(z["sim_times"], dtype=np.float32) + else: + sim_times = None + + # ------- static-arrays cache lookup ------- + with self._cache_lock: + cache_hit = self.cache_static_arrays and filename in self._static_cache + cached_entry = None + if cache_hit: + self._cache_hits += 1 + self._static_cache.move_to_end(filename) + cached_entry = dict(self._static_cache[filename]) # shallow copy + + td = TensorDict({}, batch_size=[]) + td["scalar_flux"] = _to_tensor(scalar_flux) + td["timesteps"] = _to_tensor(np.asarray(timesteps_array)) + if sim_times is not None: + td["sim_times"] = _to_tensor(np.asarray(sim_times)) + + if cache_hit: + # Reuse cached static tensors; copy references, not data. + for key, tensor in cached_entry.items(): + td[key] = tensor + else: + self._cache_misses += 1 + cell_centers = np.array(z["cell_centers"], dtype=np.float32) + cell_areas = np.array(z["cell_areas"], dtype=np.float32) + td["coordinates"] = _to_tensor(cell_centers) + td["cell_areas"] = _to_tensor(cell_areas) + + if load_material_properties and "material_properties" in z: + td["material_properties"] = _to_tensor( + np.array(z["material_properties"], dtype=np.int32) + ) + elif load_material_properties and "material_properties" not in z: + warnings.warn(f"Material properties not found in {filename}.") + + if load_geometric_features and "geometric_features" in z: + td["geometric_features"] = _to_tensor( + np.array(z["geometric_features"], dtype=np.float32) + ) + + if load_sigma_fields: + for key in ("sigma_t", "sigma_s", "sigma_a", "Q"): + if key in z: + td[key] = _to_tensor(np.array(z[key], dtype=np.float32)) + + if self.cache_static_arrays: + self._maybe_cache_entry(filename, td) + + # Non-tensor metadata ride as NonTensorData so transforms and adapters + # that access ``td["metadata"]`` keep working unchanged. + attrs = dict(z.attrs) if hasattr(z, "attrs") else {} + td.set_non_tensor("metadata", attrs) + return td + + def _maybe_cache_entry(self, filename: str, td: TensorDict) -> None: + """LRU-cache the static tensor fields of ``td`` under ``filename``.""" + with self._cache_lock: + if ( + self.max_cache_size > 0 + and len(self._static_cache) >= self.max_cache_size + and filename not in self._static_cache + ): + evicted = next(iter(self._static_cache)) + del self._static_cache[evicted] + self._cache_evictions += 1 + + entry: Dict[str, torch.Tensor] = {} + for key in ( + "coordinates", + "cell_areas", + "material_properties", + "geometric_features", + "sigma_t", + "sigma_s", + "sigma_a", + "Q", + ): + if key in td: + entry[key] = td[key] + self._static_cache[filename] = entry + + # ------------------------------------------------------------------ + # Metadata helpers + # ------------------------------------------------------------------ + + def get_metadata(self, filename: str) -> Dict: + """Return metadata without loading full sample data.""" + filepath = self.data_path / filename + z = zarr.open(str(filepath), mode="r") + + metadata = dict(z.attrs) if hasattr(z, "attrs") else {} + metadata["num_timesteps"] = z["scalar_flux"].shape[0] + metadata["num_cells"] = z["scalar_flux"].shape[1] + metadata["has_geometric_features"] = "geometric_features" in z + metadata["has_material_properties"] = "material_properties" in z + metadata["has_sim_times"] = "sim_times" in z + if "sim_times" in z: + try: + metadata["max_sim_time"] = float(z["sim_times"][-1]) + except Exception as exc: # pragma: no cover — defensive + raise ValueError( + f"Failed to read sim_times tail from {filename}" + ) from exc + return metadata + + def validate(self, filename: str) -> bool: + """Assert the zarr store has the required top-level arrays.""" + filepath = self.data_path / filename + z = zarr.open(str(filepath), mode="r") + + required = ["cell_centers", "cell_areas", "scalar_flux", "timesteps"] + for key in required: + if key not in z: + raise ValueError(f"Zarr store missing required key: {key}") + + nc_centers = z["cell_centers"].shape[0] + nc_areas = z["cell_areas"].shape[0] + nc_flux = z["scalar_flux"].shape[1] + if nc_centers != nc_flux: + raise ValueError( + f"Shape mismatch: cell_centers has {nc_centers} cells, " + f"scalar_flux has {nc_flux}" + ) + if nc_areas != nc_flux: + raise ValueError( + f"Shape mismatch: cell_areas has {nc_areas} cells, " + f"scalar_flux has {nc_flux}" + ) + return True + + +# ========================================================================= +# PyTorch Dataset +# ========================================================================= +# +# Minimal PyTorch ``Dataset`` that wraps ``ZarrDataReader`` and produces +# per-sample ``TensorDict`` outputs. The reader returns TensorDicts directly; +# this layer only glues together file/timestep selection, the preload cache, +# and per-sample metadata enrichment (filename, ``max_timestep``, +# ``max_sim_time``, ``_timestep_idx``). + + +class RTEBaseDataset(Dataset): + """File- and timestep-indexed dataset over a directory of zarr stores. + + Output of ``__getitem__`` is a ``TensorDict`` with the tensor fields the + reader returned, plus ``filename`` (``NonTensorData``), an updated + ``metadata`` ``NonTensorData`` entry (``max_timestep`` / + ``max_sim_time``), and optionally ``_timestep_idx`` when + ``expand_timesteps=True``. + """ + + def __init__( + self, + data_path: Path | str, + case_type: Optional[str] = None, + phase: str = "train", + split_file: Optional[Path | str] = None, + train_split: float = 0.7, + val_split: float = 0.15, + seed: Optional[int] = None, + load_material_properties: bool = True, + load_geometric_features: bool = True, + load_sigma_fields: bool = True, + expand_timesteps: bool = True, + temporal_stride: int = 1, + cache_static_arrays: bool = True, + max_cache_size: int = 200, + task: str = "next_step", + ): + self.data_path = Path(data_path) + self.case_type = case_type + self.phase = phase + self.split_file = Path(split_file) if split_file else None + self.train_split = train_split + self.val_split = val_split + self.seed = seed + self.load_material_properties = load_material_properties + self.load_geometric_features = load_geometric_features + self.load_sigma_fields = load_sigma_fields + self.expand_timesteps = expand_timesteps + self.temporal_stride = temporal_stride + self.task = task + + self.reader = ZarrDataReader( + data_path, + case_type, + cache_static_arrays=cache_static_arrays, + max_cache_size=max_cache_size, + ) + + if self.split_file: + self.filenames = self._load_split_from_file() + else: + all_filenames = self.reader.get_filenames() + if not all_filenames: + raise ValueError(f"No zarr stores found in {data_path}") + self.filenames = self._split_filenames(all_filenames) + + if not self.filenames: + raise ValueError(f"No files in {phase} split") + + self.timestep_index_map: Optional[List[tuple]] = None + if self.expand_timesteps: + self._build_timestep_index() + + # In-memory cache for flux data (populated by preload_to_memory when + # ``task == 'steady_state'``). Values are ``dict`` mirrors of the + # cached tensor entries. + self._memory_cache: Optional[Dict[str, Dict[str, torch.Tensor]]] = None + + # ------------------------------------------------------------------ + # Split machinery (unchanged semantics) + # ------------------------------------------------------------------ + + def _load_split_from_file(self) -> List[str]: + if not self.split_file.exists(): + raise FileNotFoundError(f"Split file not found: {self.split_file}") + with open(self.split_file, "r", encoding="utf-8") as f: + split_data = json.load(f) + if "splits" not in split_data: + raise ValueError("Invalid split file format: missing 'splits' key") + if self.phase not in split_data["splits"]: + raise ValueError( + f"Phase '{self.phase}' not found in split file. " + f"Available: {list(split_data['splits'].keys())}" + ) + filenames = split_data["splits"][self.phase] + return [f if f.endswith(".zarr") else f + ".zarr" for f in filenames] + + def _split_filenames(self, filenames: List[str]) -> List[str]: + n = len(filenames) + indices = np.arange(n) + if self.seed is not None: + np.random.default_rng(self.seed).shuffle(indices) + train_end = int(n * self.train_split) + val_end = train_end + int(n * self.val_split) + if self.phase == "train": + sel = indices[:train_end] + elif self.phase == "val": + sel = indices[train_end:val_end] + elif self.phase == "test": + sel = indices[val_end:] + else: + raise ValueError(f"Invalid phase: {self.phase}") + return [filenames[i] for i in sel] + + def _build_timestep_index(self): + self.timestep_index_map = [] + for file_idx, filename in enumerate(self.filenames): + meta = self.reader.get_metadata(filename) + num_timesteps = meta["num_timesteps"] + max_start = num_timesteps - self.temporal_stride + if max_start > 0: + for t in range(max_start): + self.timestep_index_map.append((file_idx, t)) + + num_sims = len(self.filenames) + num_pairs = len(self.timestep_index_map) + avg = num_pairs / num_sims if num_sims > 0 else 0 + print(f"Timestep expansion ({self.phase}):") + print(f" {num_sims} simulations → {num_pairs} timestep pairs") + print(f" Average {avg:.1f} pairs per simulation") + print(f" Stride: {self.temporal_stride}") + + # ------------------------------------------------------------------ + # Preload cache + # ------------------------------------------------------------------ + + def preload_to_memory(self, verbose: bool = True, num_workers: int = 8) -> dict: + """Preload static arrays (and optionally flux for steady_state).""" + import time + from concurrent.futures import ThreadPoolExecutor, as_completed + + num_files = len(self.filenames) + preload_flux = self.task == "steady_state" + + if verbose: + if preload_flux: + print( + f"\nPreloading {num_files} files with flux (steady_state mode)..." + ) + print(" Loading ONLY first and last timesteps (2 per file)") + else: + print(f"\nPreloading {num_files} files into memory...") + print(f" Parallel I/O workers: {num_workers}") + + start = time.perf_counter() + if preload_flux: + self._memory_cache = {} + + def load_one(filename: str): + if preload_flux: + td = self.reader.load( + filename, + load_material_properties=self.load_material_properties, + load_geometric_features=self.load_geometric_features, + load_sim_times=True, + load_sigma_fields=self.load_sigma_fields, + timestep_indices=[0, -1], + ) + entry: Dict[str, torch.Tensor] = { + "scalar_flux": td["scalar_flux"].clone(), + "timesteps": td["timesteps"].clone(), + } + if "sim_times" in td: + entry["sim_times"] = td["sim_times"].clone() + return filename, td, entry + td = self.reader.load( + filename, + load_material_properties=self.load_material_properties, + load_geometric_features=self.load_geometric_features, + load_sim_times=True, + load_sigma_fields=self.load_sigma_fields, + timestep_slice=slice(0, 2), + ) + return filename, td, None + + completed = 0 + first_logged = False + with ThreadPoolExecutor(max_workers=num_workers) as executor: + futures = {executor.submit(load_one, fn): fn for fn in self.filenames} + for fut in as_completed(futures): + filename, td, entry = fut.result() + completed += 1 + if entry is not None: + self._memory_cache[filename] = entry + if verbose and not first_logged: + n_cells = td["coordinates"].shape[0] + print(f"\n First file diagnostics ({filename}):") + print(f" scalar_flux shape: {tuple(td['scalar_flux'].shape)}") + print(f" num_cells: {n_cells:,}") + print(f" sigma_t loaded: {'sigma_t' in td}") + print(f" sigma_s loaded: {'sigma_s' in td}") + print("") + first_logged = True + if verbose and completed % 50 == 0: + elapsed = time.perf_counter() - start + rate = completed / elapsed + eta = (num_files - completed) / rate if rate > 0 else 0 + print( + f" Preloaded {completed}/{num_files} files " + f"({rate:.1f} files/s, ETA: {eta:.0f}s)" + ) + + elapsed = time.perf_counter() - start + cache_stats = self.reader.get_cache_stats() + if verbose: + print("\nPreload complete!") + print(f" Files loaded: {num_files}") + print(f" Time: {elapsed:.1f}s ({num_files/elapsed:.1f} files/s)") + print(f" Static arrays cache: {cache_stats['cache_size']} files") + print(f" Cache hits: {cache_stats['cache_hits']}") + print(f" Cache misses: {cache_stats['cache_misses']}") + if preload_flux: + flux_mem = sum( + cached["scalar_flux"].element_size() * cached["scalar_flux"].numel() + + cached["timesteps"].element_size() * cached["timesteps"].numel() + + ( + cached["sim_times"].element_size() * cached["sim_times"].numel() + if "sim_times" in cached + else 0 + ) + for cached in self._memory_cache.values() + ) + print( + f" Flux cache: {len(self._memory_cache)} simulations " + f"({flux_mem / 1024**2:.1f} MB)" + ) + + return { + "num_files": num_files, + "elapsed_seconds": elapsed, + "cache_stats": cache_stats, + "flux_cached": preload_flux, + } + + # ------------------------------------------------------------------ + # Dataset protocol + # ------------------------------------------------------------------ + + def __len__(self) -> int: + if self.expand_timesteps and self.timestep_index_map is not None: + return len(self.timestep_index_map) + return len(self.filenames) + + def __getitem__(self, idx: int) -> TensorDict: + timestep_slice = None + timestep_indices = None + timestep_idx = None + + if self.expand_timesteps and self.timestep_index_map is not None: + file_idx, timestep_idx = self.timestep_index_map[idx] + filename = self.filenames[file_idx] + num_timesteps_needed = self.temporal_stride + 1 + timestep_slice = slice(timestep_idx, timestep_idx + num_timesteps_needed) + elif self.task == "steady_state": + filename = self.filenames[idx] + timestep_indices = [0, -1] + else: + filename = self.filenames[idx] + + if self._memory_cache is not None and filename in self._memory_cache: + cached = self._memory_cache[filename] + td = self.reader.load( + filename, + load_material_properties=self.load_material_properties, + load_geometric_features=self.load_geometric_features, + load_sim_times=False, + load_sigma_fields=self.load_sigma_fields, + load_flux=False, + ) + td["scalar_flux"] = cached["scalar_flux"] + td["timesteps"] = cached.get("timesteps", td["timesteps"]) + if "sim_times" in cached: + td["sim_times"] = cached["sim_times"] + else: + td = self.reader.load( + filename, + load_material_properties=self.load_material_properties, + load_geometric_features=self.load_geometric_features, + load_sim_times=True, + load_sigma_fields=self.load_sigma_fields, + timestep_slice=timestep_slice, + timestep_indices=timestep_indices, + ) + + # Enrich metadata with the per-sample info transforms rely on. + # ``td["metadata"]`` is a NonTensorData dict of zarr attrs; extend it. + attrs = dict(td["metadata"]) if "metadata" in td else {} + if timestep_slice is not None or timestep_indices is not None: + file_meta = self.reader.get_metadata(filename) + attrs["max_timestep"] = file_meta["num_timesteps"] - 1 + attrs["max_sim_time"] = file_meta.get("max_sim_time") + else: + attrs["max_timestep"] = td["scalar_flux"].shape[0] - 1 + if "sim_times" in td and td["sim_times"].numel() > 0: + attrs["max_sim_time"] = float(td["sim_times"][-1].item()) + else: + attrs["max_sim_time"] = None + td.set_non_tensor("metadata", attrs) + td.set_non_tensor("filename", filename) + + if timestep_idx is not None: + td.set_non_tensor("_timestep_idx", timestep_idx) + + return td + + +# ========================================================================= +# Stats loaders +# ========================================================================= +# +# Non-breaking stats-file shim for PhysicsNeMo ``Normalize``. RTE's custom +# normalization transforms are replaced with +# ``physicsnemo.datapipes.transforms.Normalize``. The on-disk YAML stats files +# stay in their current RTE-specific schema; these helpers read them and +# produce the ``(means, stds)`` dicts PhysicsNeMo expects. + + +def load_flux_stats(path: Union[str, Path]) -> dict: + """Read an RTE flux statistics YAML. + + Returns a plain dict with keys ``log_flux_mean``, ``log_flux_std``, + ``clip_threshold``. Raises if any required key is missing. + """ + stats_path = Path(path) + if not stats_path.exists(): + raise FileNotFoundError(f"Flux statistics file not found: {stats_path}") + with open(stats_path, "r") as f: + stats = yaml.safe_load(f) + for key in ("log_flux_mean", "log_flux_std", "clip_threshold"): + if key not in stats: + raise ValueError(f"Flux statistics file missing required key: {key}") + return stats + + +def flux_normalize_kwargs( + stats: Mapping, + field: str = "scalar_flux", +) -> dict: + """Build ``Normalize`` kwargs for the log-clipped flux field. + + Example: + stats = load_flux_stats(path) + Normalize(**flux_normalize_kwargs(stats)) + """ + return { + "input_keys": [field], + "method": "mean_std", + "means": {field: float(stats["log_flux_mean"])}, + "stds": {field: float(stats["log_flux_std"])}, + } + + +def load_material_stats(path: Union[str, Path]) -> dict: + """Read an RTE material statistics YAML. + + Returns the full per-property nested dict. Each of ``sigma_a``, + ``sigma_s``, ``sigma_t``, ``Q`` must be present with ``mean``, ``std``, + ``min``, ``max`` sub-keys. + """ + stats_path = Path(path) + if not stats_path.exists(): + raise FileNotFoundError(f"Material statistics file not found: {stats_path}") + with open(stats_path, "r") as f: + stats = yaml.safe_load(f) + required = ("sigma_a", "sigma_s", "sigma_t", "Q") + for key in required: + if key not in stats: + raise ValueError( + f"Material statistics file missing required property: {key}" + ) + for sub in ("mean", "std"): + if sub not in stats[key]: + raise ValueError( + f"Material statistics[{key!r}] missing required sub-key: {sub!r}" + ) + return stats + + +def material_normalize_kwargs( + stats: Mapping, + field: str = "physical_properties", + order: Sequence[str] = ("sigma_a", "sigma_s", "sigma_t", "Q"), + method: str = "mean_std", +) -> dict: + """Build ``Normalize`` kwargs for ``physical_properties`` as (N, 4). + + The 4 columns are normalized independently via broadcasting: a per-column + ``torch.Tensor`` of shape ``(4,)`` is passed as the mean and the std. This + mirrors what the custom ``MaterialPropertyNormalizer`` did column-by-column, + but delegates the math to ``physicsnemo.datapipes.transforms.Normalize``. + """ + if method == "mean_std": + means = torch.tensor( + [float(stats[k]["mean"]) for k in order], dtype=torch.float32 + ) + stds = torch.tensor( + [float(stats[k]["std"]) for k in order], dtype=torch.float32 + ) + return { + "input_keys": [field], + "method": "mean_std", + "means": {field: means}, + "stds": {field: stds}, + } + if method == "min_max": + mins = torch.tensor( + [float(stats[k]["min"]) for k in order], dtype=torch.float32 + ) + maxs = torch.tensor( + [float(stats[k]["max"]) for k in order], dtype=torch.float32 + ) + return { + "input_keys": [field], + "method": "min_max", + "mins": {field: mins}, + "maxs": {field: maxs}, + } + raise ValueError(f"Unknown method: {method}. Expected 'mean_std' or 'min_max'.") + + +def coord_bounds_for_case(case_type: str) -> Tuple[torch.Tensor, torch.Tensor]: + """Return ``(bbox_min, bbox_max)`` as float32 tensors for a known case. + + Shared with ``loader._build_transforms``; encapsulates the per-case + global domain bounds that were hardcoded in ``CoordinateNormalizer``. + """ + # Sibling import deferred to call time to avoid a circular import at + # module load (transforms.py is allowed to import from dataset.py if it + # ever needs stats helpers, though currently it does not). + from transforms import GLOBAL_DOMAIN_BOUNDS + + if case_type not in GLOBAL_DOMAIN_BOUNDS: + raise ValueError( + f"Unknown case_type '{case_type}'. " + f"Expected one of: {list(GLOBAL_DOMAIN_BOUNDS.keys())}" + ) + bounds = GLOBAL_DOMAIN_BOUNDS[case_type] + return ( + torch.as_tensor(bounds["min"], dtype=torch.float32), + torch.as_tensor(bounds["max"], dtype=torch.float32), + ) + + +def coord_translate_scale_params( + case_type: str, +) -> Tuple[torch.Tensor, torch.Tensor]: + """Compute ``(center, half_extent)`` for ``Translate`` + ``Scale``. + + RTE's ``CoordinateNormalizer`` produced ``(x - bbox_min) * 2 / (bbox_max - + bbox_min) - 1``. Equivalently: subtract the bbox center, then divide by the + bbox half-extent. This helper returns the two tensors in that form so the + caller can wire them straight into + ``Translate(center_key_or_value=center, subtract=True)`` followed by + ``Scale(scale=half_extent, divide=True)``. + """ + bbox_min, bbox_max = coord_bounds_for_case(case_type) + center = 0.5 * (bbox_min + bbox_max) + half_extent = 0.5 * (bbox_max - bbox_min) + return center, half_extent diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/inference.py b/examples/cfd/nuclear_engineering/radiation_transport/src/inference.py new file mode 100644 index 0000000000..372eb1f741 --- /dev/null +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/inference.py @@ -0,0 +1,847 @@ +# SPDX-FileCopyrightText: Copyright (c) 2023 - 2026 NVIDIA CORPORATION & AFFILIATES. +# SPDX-FileCopyrightText: All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Inference / evaluation: load checkpoint, run on test set, compute metrics + plots. + +Standalone CLI invoked after training. Loads the Hydra config that was saved +alongside the checkpoint, builds the test dataloader, runs forward passes, +denormalizes predictions to physical-flux units, computes pointwise + QoI +metrics, and emits a few canonical plots. + +Usage:: + + python src/inference.py \\ + --checkpoint_dir outputs/.../checkpoints/best_qoi \\ + --data_path /path/to/data_root \\ + --case_type lattice +""" + +import argparse +import pathlib +import re +import sys +from pathlib import Path +from typing import Any, Dict, Iterator, Optional, Tuple, Union + +import matplotlib + +matplotlib.use("Agg") +import matplotlib.pyplot as plt +import numpy as np +import torch +import torch.nn as nn +import yaml +from omegaconf import DictConfig, OmegaConf +from torch.amp import autocast +from torch.utils.data import DataLoader, Dataset +from tqdm import tqdm + +# Flat sibling imports — keep this module self-contained relative to ``src/``. +sys.path.insert(0, str(pathlib.Path(__file__).parent)) + +from dataset import load_flux_stats # noqa: E402 +from loader import build_dataloaders, collate_no_padding # noqa: E402 +from transforms import denormalize_flux # noqa: E402 + +from physicsnemo.distributed import DistributedManager # noqa: E402 +from physicsnemo.utils.checkpoint import load_checkpoint # noqa: E402 + + +# ========================================================================= +# Checkpoint loading +# ========================================================================= + + +def load_hydra_config(checkpoint_dir: Union[str, Path]) -> DictConfig: + """Load the Hydra config saved next to a checkpoint. + + Walks up from ``checkpoint_dir`` looking for a ``hydra/config.yaml``; + this lets users point at either the run directory or a specific + ``checkpoints/best_*`` subdirectory. + """ + checkpoint_dir = Path(checkpoint_dir) + search = checkpoint_dir + for _ in range(4): + config_path = search / "hydra" / "config.yaml" + if config_path.exists(): + with open(config_path, "r") as f: + cfg = OmegaConf.create(yaml.safe_load(f)) + OmegaConf.resolve(cfg) + return cfg + if search == search.parent: + break + search = search.parent + raise FileNotFoundError( + f"No hydra/config.yaml found in {checkpoint_dir} or its ancestors" + ) + + +def find_best_checkpoint(run_dir: Union[str, Path]) -> Path: + """Find the best checkpoint directory under a training run. + + Prefers the ``top_model`` directory (single best). Falls back to scanning + ``best_model_epoch_*`` directories and returning the one with the lowest + recorded validation loss. + """ + run_dir = Path(run_dir) + checkpoint_root = run_dir / "checkpoints" + if not checkpoint_root.exists(): + # User may already have pointed at the checkpoints dir. + checkpoint_root = run_dir + + top = checkpoint_root / "top_model" + if top.exists() and list(top.glob("checkpoint.0.*.pt")): + return top + + best_dirs = list(checkpoint_root.glob("best_model_epoch_*")) + if not best_dirs: + raise FileNotFoundError( + f"No checkpoint found under {checkpoint_root} " + "(looked for top_model/ and best_model_epoch_*/)" + ) + + best_path, best_loss = None, float("inf") + for d in best_dirs: + ckpts = list(d.glob("checkpoint.0.*.pt")) + if not ckpts: + continue + try: + data = torch.load(ckpts[0], map_location="cpu", weights_only=False) + except Exception: + continue + val_loss = data.get("metadata", {}).get("val_loss", float("inf")) + if val_loss < best_loss: + best_loss = val_loss + best_path = d + if best_path is None: + raise RuntimeError(f"No loadable checkpoints in {checkpoint_root}") + return best_path + + +def load_model_from_checkpoint( + checkpoint_dir: Union[str, Path], + device: Optional[Union[str, torch.device]] = None, +) -> Tuple[nn.Module, DictConfig, Dict[str, Any]]: + """Build the Transolver model from the saved Hydra config and load weights. + + Returns (model in eval mode, resolved config, metadata dict). + """ + import hydra + + checkpoint_dir = Path(checkpoint_dir) + if not checkpoint_dir.exists(): + raise FileNotFoundError(f"Checkpoint directory not found: {checkpoint_dir}") + + if device is None: + device = torch.device("cuda" if torch.cuda.is_available() else "cpu") + elif isinstance(device, str): + device = torch.device(device) + + cfg = load_hydra_config(checkpoint_dir) + + if not DistributedManager.is_initialized(): + DistributedManager.initialize() + + # Build model from cfg.model. Strip RTE-specific keys consumed elsewhere. + cfg_model = OmegaConf.to_container(cfg.model, resolve=True) + for k in ("num_spatial_points", "include_q_in_embedding"): + cfg_model.pop(k, None) + model = hydra.utils.instantiate(cfg_model).to(device) + + metadata: Dict[str, Any] = {} + epoch = load_checkpoint( + path=str(checkpoint_dir), + models=model, + metadata_dict=metadata, + device=device, + ) + metadata.setdefault("epoch", epoch) + + model.eval() + print( + f"Loaded model from {checkpoint_dir} " + f"(epoch={metadata.get('epoch', '?')}, " + f"params={sum(p.numel() for p in model.parameters()):,})" + ) + return model, cfg, metadata + + +# ========================================================================= +# Metrics +# ========================================================================= + + +def mse(pred: np.ndarray, target: np.ndarray) -> float: + return float(np.mean((pred - target) ** 2)) + + +def rmse(pred: np.ndarray, target: np.ndarray) -> float: + return float(np.sqrt(np.mean((pred - target) ** 2))) + + +def mae(pred: np.ndarray, target: np.ndarray) -> float: + return float(np.mean(np.abs(pred - target))) + + +def l2_relative_error(pred: np.ndarray, target: np.ndarray, eps: float = 1e-10) -> float: + """Sample-wise L2 relative error: ||pred - target||_2 / ||target||_2.""" + num = np.linalg.norm(pred.flatten() - target.flatten()) + den = np.linalg.norm(target.flatten()) + eps + return float(num / den) + + +def relative_error(pred: np.ndarray, target: np.ndarray, eps: float = 1e-10) -> float: + """Mean pointwise relative error |pred - target| / (|target| + eps).""" + return float(np.mean(np.abs(pred - target) / (np.abs(target) + eps))) + + +def compute_metrics(pred: np.ndarray, target: np.ndarray) -> Dict[str, float]: + """Compute the full metric panel for one (pred, target) pair.""" + p, t = pred.flatten(), target.flatten() + return { + "mse": mse(p, t), + "rmse": rmse(p, t), + "mae": mae(p, t), + "l2_relative_error": l2_relative_error(p, t), + "relative_error": relative_error(p, t), + "max_error": float(np.max(np.abs(p - t))), + } + + +def aggregate_metrics(per_sample: list[Dict[str, float]]) -> Dict[str, float]: + """Aggregate per-sample metrics into mean/min/max.""" + if not per_sample: + return {} + keys = per_sample[0].keys() + out: Dict[str, float] = {} + for k in keys: + vals = [s[k] for s in per_sample] + out[f"{k}_mean"] = float(np.mean(vals)) + out[f"{k}_std"] = float(np.std(vals)) + out[f"{k}_min"] = float(np.min(vals)) + out[f"{k}_max"] = float(np.max(vals)) + return out + + +# ========================================================================= +# QoI (numpy side) +# ========================================================================= + + +def _extract_geometry_params(filename: Optional[str]) -> Optional[Dict[str, float]]: + """Parse hohlraum geometry parameters out of a simulation filename.""" + if not filename: + return None + patterns = { + "cx": r"cx([-\d.]+)", + "cy": r"cy([-\d.]+)", + "ulr": r"ulr([-\d.]+)", + "llr": r"llr([-\d.]+)", + "urr": r"urr([-\d.]+)", + "lrr": r"lrr([-\d.]+)", + "hlr": r"hlr([-\d.]+)", + "hrr": r"hrr([-\d.]+)", + } + params: Dict[str, float] = {} + for key, pat in patterns.items(): + m = re.search(pat, filename) + if m: + params[key] = float(m.group(1).rstrip(".")) + return params if params else None + + +def evaluate_lattice_qoi( + cell_centers: np.ndarray, + cell_areas: np.ndarray, + sigma_t: np.ndarray, + sigma_s: np.ndarray, + scalar_flux: np.ndarray, +) -> Dict[str, float]: + """Lattice absorption QoI in the absorbing blocks. Matches KiT-RT. + + ``scalar_flux`` is shape ``(N,)`` for a single steady-state snapshot. + Returns ``{"cur_absorption": ...}``. + """ + x = cell_centers[:, 0] + y = cell_centers[:, 1] + sigma_a = sigma_t - sigma_s + + xy_corrector = -3.5 + lbounds = np.array([1.0, 2.0, 3.0, 4.0, 5.0]) + xy_corrector + ubounds = np.array([2.0, 3.0, 4.0, 5.0, 6.0]) + xy_corrector + in_absorption = np.zeros_like(x, dtype=bool) + for k in range(5): + for l in range(5): # noqa: E741 + if (l + k) % 2 == 1: + continue + if (k == 2 and l == 2) or (k == 2 and l == 4): + continue + in_absorption |= ( + (x >= lbounds[k]) + & (x <= ubounds[k]) + & (y >= lbounds[l]) + & (y <= ubounds[l]) + ) + + flux = scalar_flux.flatten() + absorption_density = flux * sigma_a * cell_areas + return {"cur_absorption": float(np.sum(absorption_density * in_absorption))} + + +def evaluate_hohlraum_qoi( + cell_centers: np.ndarray, + cell_areas: np.ndarray, + sigma_t: np.ndarray, + sigma_s: np.ndarray, + scalar_flux: np.ndarray, + geometry_params: Dict[str, float], +) -> Dict[str, float]: + """Hohlraum absorption QoI (center / vertical / horizontal). Matches KiT-RT. + + ``scalar_flux`` is shape ``(N,)``. ``geometry_params`` carries ``cx``, + ``cy``, ``hlr``, ``hrr``, ``llr``, ``ulr``, ``urr``. + """ + x = cell_centers[:, 0] + y = cell_centers[:, 1] + + cx = geometry_params["cx"] + cy = geometry_params["cy"] + hlr = geometry_params["hlr"] + hrr = geometry_params["hrr"] + llr = geometry_params["llr"] + ulr = geometry_params["ulr"] + urr = geometry_params["urr"] + + sigma_a = sigma_t - sigma_s + + in_center = (x > -0.2 + cx) & (x < 0.2 + cx) & (y > -0.4 + cy) & (y < 0.4 + cy) + # Note: KiT-RT uses ``llr`` for both vertical-wall lower bounds. + in_vertical = ((x < hlr) & (y > llr) & (y < ulr)) | ( + (x > hrr) & (y > llr) & (y < urr) + ) + in_horizontal = (y > 0.6) | (y < -0.6) + + flux = scalar_flux.flatten() + absorption_density = flux * sigma_a * cell_areas + return { + "cur_absorption_center": float(np.sum(absorption_density * in_center)), + "cur_absorption_vertical": float(np.sum(absorption_density * in_vertical)), + "cur_absorption_horizontal": float(np.sum(absorption_density * in_horizontal)), + } + + +def compute_sample_qoi( + pred: np.ndarray, + target: np.ndarray, + metadata: Dict[str, Any], + case_type: str, +) -> Optional[Dict[str, Dict[str, float]]]: + """Compute QoI(pred) vs QoI(target) for one sample. + + Returns a dict ``{region: {predicted, ground_truth, absolute_error, + relative_error_pct}}`` or ``None`` if metadata is incomplete. + """ + coords = metadata.get("coordinates") + cell_areas = metadata.get("cell_areas") + sigma_t = metadata.get("sigma_t") + sigma_s = metadata.get("sigma_s") + if coords is None or cell_areas is None or sigma_t is None or sigma_s is None: + return None + + if case_type == "lattice": + qp = evaluate_lattice_qoi(coords, cell_areas, sigma_t, sigma_s, pred) + qt = evaluate_lattice_qoi(coords, cell_areas, sigma_t, sigma_s, target) + elif case_type == "hohlraum": + gp = _extract_geometry_params(metadata.get("filename")) + if gp is None: + return None + qp = evaluate_hohlraum_qoi(coords, cell_areas, sigma_t, sigma_s, pred, gp) + qt = evaluate_hohlraum_qoi(coords, cell_areas, sigma_t, sigma_s, target, gp) + else: + raise ValueError(f"Unknown case_type: {case_type}") + + out: Dict[str, Dict[str, float]] = {} + for region in qp: + p, t = qp[region], qt[region] + abs_err = abs(p - t) + out[region] = { + "predicted": p, + "ground_truth": t, + "absolute_error": abs_err, + "relative_error_pct": abs_err / (abs(t) + 1e-10) * 100.0, + } + return out + + +def aggregate_qoi( + per_sample_qoi: list[Dict[str, Dict[str, float]]], +) -> Dict[str, Dict[str, float]]: + """Aggregate per-sample QoI dicts into per-region summary statistics.""" + by_region: Dict[str, list] = {} + for sample in per_sample_qoi: + if not sample: + continue + for region, entry in sample.items(): + by_region.setdefault(region, []).append(entry) + + summary: Dict[str, Dict[str, float]] = {} + for region, entries in by_region.items(): + abs_errs = np.array([e["absolute_error"] for e in entries]) + rel_errs = np.array([e["relative_error_pct"] for e in entries]) + summary[region] = { + "num_samples": len(entries), + "mae": float(np.mean(abs_errs)), + "rmse": float(np.sqrt(np.mean(abs_errs**2))), + "max_error": float(np.max(abs_errs)), + "mean_relative_error_pct": float(np.mean(rel_errs)), + "median_relative_error_pct": float(np.median(rel_errs)), + "max_relative_error_pct": float(np.max(rel_errs)), + } + return summary + + +# ========================================================================= +# Plots +# ========================================================================= + + +def plot_flux_panels( + coordinates: np.ndarray, + target: np.ndarray, + prediction: np.ndarray, + output_path: Union[str, Path], + *, + figsize: Tuple[int, int] = (16, 5), + dpi: int = 150, +) -> Path: + """Render a 3-panel figure: target | prediction | absolute error.""" + output_path = Path(output_path) + output_path.parent.mkdir(parents=True, exist_ok=True) + + target = target.flatten() + prediction = prediction.flatten() + error = np.abs(prediction - target) + + x, y = coordinates[:, 0], coordinates[:, 1] + x_pad = (x.max() - x.min()) * 0.01 + y_pad = (y.max() - y.min()) * 0.01 + xlim = (x.min() - x_pad, x.max() + x_pad) + ylim = (y.min() - y_pad, y.max() + y_pad) + + fig, axes = plt.subplots(1, 3, figsize=figsize, dpi=dpi) + flux_vmin = min(target.min(), prediction.min()) + flux_vmax = max(target.max(), prediction.max()) + cmap_flux = plt.get_cmap("viridis") + cmap_err = plt.get_cmap("hot") + + for ax, label, vals, cmap, vmin, vmax in ( + (axes[0], "Target", target, cmap_flux, flux_vmin, flux_vmax), + (axes[1], "Prediction", prediction, cmap_flux, flux_vmin, flux_vmax), + (axes[2], "Absolute Error", error, cmap_err, 0.0, float(error.max())), + ): + sc = ax.scatter(x, y, c=vals, cmap=cmap, vmin=vmin, vmax=vmax, s=1) + ax.set_aspect("equal") + ax.set_xlim(xlim) + ax.set_ylim(ylim) + ax.set_title(label) + plt.colorbar(sc, ax=ax) + + plt.tight_layout() + plt.savefig(output_path, dpi=dpi, bbox_inches="tight") + plt.close(fig) + return output_path + + +def plot_true_vs_pred_scatter( + target: np.ndarray, + prediction: np.ndarray, + output_path: Union[str, Path], + *, + max_points: int = 200_000, + dpi: int = 150, +) -> Path: + """Scatter of predicted vs ground truth with the y=x reference line.""" + output_path = Path(output_path) + output_path.parent.mkdir(parents=True, exist_ok=True) + + t = target.flatten() + p = prediction.flatten() + if t.size > max_points: + idx = np.random.default_rng(0).choice(t.size, max_points, replace=False) + t, p = t[idx], p[idx] + + lo = float(min(t.min(), p.min())) + hi = float(max(t.max(), p.max())) + + fig, ax = plt.subplots(figsize=(6, 6), dpi=dpi) + ax.scatter(t, p, s=1, alpha=0.3) + ax.plot([lo, hi], [lo, hi], "r--", linewidth=1.0, label="y = x") + ax.set_xlabel("Ground truth flux") + ax.set_ylabel("Predicted flux") + ax.set_title("Predicted vs. ground-truth flux") + ax.set_aspect("equal") + ax.legend(loc="best") + plt.tight_layout() + plt.savefig(output_path, dpi=dpi, bbox_inches="tight") + plt.close(fig) + return output_path + + +def plot_error_histogram( + target: np.ndarray, + prediction: np.ndarray, + output_path: Union[str, Path], + *, + bins: int = 80, + dpi: int = 150, +) -> Path: + """Histogram of pointwise absolute errors (log y).""" + output_path = Path(output_path) + output_path.parent.mkdir(parents=True, exist_ok=True) + + errors = np.abs(prediction.flatten() - target.flatten()) + + fig, ax = plt.subplots(figsize=(7, 5), dpi=dpi) + ax.hist(errors, bins=bins, color="C0", edgecolor="black", linewidth=0.3) + ax.set_yscale("log") + ax.set_xlabel("|prediction - target|") + ax.set_ylabel("Count (log)") + ax.set_title( + f"Pointwise error histogram (mean={errors.mean():.3e}, " + f"max={errors.max():.3e})" + ) + plt.tight_layout() + plt.savefig(output_path, dpi=dpi, bbox_inches="tight") + plt.close(fig) + return output_path + + +# ========================================================================= +# Main inference loop +# ========================================================================= + + +def _move_to_device(batch: Dict[str, Any], device: torch.device) -> Dict[str, Any]: + return { + k: v.to(device) if isinstance(v, torch.Tensor) else v for k, v in batch.items() + } + + +def _denormalize(flux_norm: torch.Tensor, stats: Dict[str, float]) -> np.ndarray: + """Apply ``denormalize_flux`` (RTEFluxLogClip + Normalize inverse).""" + return denormalize_flux(flux_norm.detach().cpu(), stats).numpy() + + +@torch.no_grad() +def run_evaluation( + model: nn.Module, + dataloader: DataLoader, + device: torch.device, + flux_stats: Dict[str, float], + *, + use_amp: bool = True, + max_samples: Optional[int] = None, +) -> Iterator[Tuple[np.ndarray, np.ndarray, Dict[str, Any]]]: + """Yield ``(prediction, target, metadata)`` for each test sample. + + Predictions and targets are returned as flattened numpy arrays in + physical-flux units (denormalized). ``metadata`` carries the per-sample + coordinates / cell areas / sigma fields / filename needed for QoI and + plotting. + """ + model.eval() + n = 0 + use_time = bool(getattr(model, "time_input", False)) + + for batch in tqdm(dataloader, desc="evaluating"): + if max_samples is not None and n >= max_samples: + break + batch = _move_to_device(batch, device) + + amp_enabled = use_amp and device.type == "cuda" + with autocast(device_type=device.type, enabled=amp_enabled): + if use_time: + pred = model( + fx=batch["fx"], embedding=batch["embedding"], time=batch.get("time") + ) + else: + pred = model(fx=batch["fx"], embedding=batch["embedding"]) + pred = pred.float() + target = batch["flux_target"].float() + + # Denormalize back to physical flux. ``denormalize_flux`` handles the + # full RTEFluxLogClip + Normalize inverse using the stats dict that + # the dataset transform recorded on the sample. + stats = batch.get("flux_normalization_stats", flux_stats) + if isinstance(stats, list): + stats = stats[0] if stats else flux_stats + + # Batches always carry an outer batch dim of 1 (collate_no_padding). + for b in range(pred.shape[0]): + pred_phys = _denormalize(pred[b].squeeze(-1), stats).flatten() + target_phys = _denormalize(target[b].squeeze(-1), stats).flatten() + + metadata: Dict[str, Any] = {} + coords = batch.get("coordinates_unnormalized") + if coords is None: + coords = batch.get("fx") + if coords is not None: + metadata["coordinates"] = coords[b].detach().cpu().numpy() + for key in ("cell_areas", "sigma_t", "sigma_s"): + if key in batch: + metadata[key] = batch[key][b].detach().cpu().numpy().flatten() + sim_time = batch.get("sim_time") + if sim_time is not None: + metadata["sim_time"] = float(sim_time[b].flatten()[0].item()) + + raw_meta = batch.get("metadata") or {} + if isinstance(raw_meta, list): + raw_meta = raw_meta[0] if raw_meta else {} + filename = raw_meta.get("filename") if isinstance(raw_meta, dict) else None + if filename: + metadata["filename"] = filename + + n += 1 + yield pred_phys, target_phys, metadata + if max_samples is not None and n >= max_samples: + return + + +# ========================================================================= +# CLI entry +# ========================================================================= + + +def _resolve_data_path(cfg: DictConfig, cli_data_path: str) -> None: + """Override ``case.data_root`` and ``data.input_dir`` to the user-supplied path.""" + OmegaConf.update(cfg, "case.data_root", cli_data_path, force_add=True) + case_type = cfg.case.type + OmegaConf.update( + cfg, "case.data_path", str(Path(cli_data_path) / case_type), force_add=True + ) + OmegaConf.update( + cfg, + "data.input_dir", + str(Path(cli_data_path) / case_type), + force_add=True, + ) + flux_stats_file = ( + Path(cli_data_path) / "stats" / f"{case_type}_flux_stats.yaml" + ) + OmegaConf.update( + cfg, + "data.flux_normalization_stats_file", + str(flux_stats_file), + force_add=True, + ) + split_file = Path(cli_data_path) / "splits" / f"{case_type}_splits.json" + OmegaConf.update(cfg, "case.split_file", str(split_file), force_add=True) + + +def main(): + parser = argparse.ArgumentParser( + description="Evaluate a trained RTE Transolver model on the test split.", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + ) + parser.add_argument( + "--checkpoint_dir", + type=Path, + required=True, + help="Path to a checkpoint directory (e.g. .../checkpoints/best_qoi). " + "May also point at the run directory; the script will search.", + ) + parser.add_argument( + "--data_path", + type=Path, + required=True, + help="Dataset root containing /, splits/, and stats/.", + ) + parser.add_argument( + "--case_type", + type=str, + required=True, + choices=("lattice", "hohlraum"), + help="Which benchmark to evaluate.", + ) + parser.add_argument( + "--output_dir", + type=Path, + default=None, + help="Where to write metrics + figures. " + "Defaults to /evaluation.", + ) + parser.add_argument( + "--num_samples", + type=int, + default=None, + help="Cap on the number of test samples (default: all).", + ) + parser.add_argument( + "--num_workers", type=int, default=4, help="Test DataLoader workers." + ) + parser.add_argument( + "--device", type=str, default=None, help="Override torch device." + ) + parser.add_argument( + "--num_plot_samples", + type=int, + default=3, + help="Number of per-sample flux-panel figures to render.", + ) + args = parser.parse_args() + + # Resolve checkpoint: accept either the run dir or a specific checkpoint. + ckpt_dir = args.checkpoint_dir + if not list(ckpt_dir.glob("checkpoint.0.*.pt")): + ckpt_dir = find_best_checkpoint(ckpt_dir) + print(f"Using best checkpoint: {ckpt_dir}") + + device = torch.device( + args.device if args.device else ("cuda" if torch.cuda.is_available() else "cpu") + ) + + model, cfg, metadata = load_model_from_checkpoint(ckpt_dir, device=device) + + # The saved cfg still holds the training-machine paths. Rewrite the + # data-related fields to whatever the user supplied at the CLI. + if cfg.case.type != args.case_type: + print( + f"Warning: checkpoint trained on '{cfg.case.type}', " + f"but --case_type={args.case_type}. Using --case_type." + ) + OmegaConf.update(cfg, "case.type", args.case_type, force_add=True) + _resolve_data_path(cfg, str(args.data_path)) + + # Output dir defaults to ``/evaluation``. + if args.output_dir is None: + run_dir = ckpt_dir + # Walk up to a directory that has a hydra/ subdirectory. + for _ in range(4): + if (run_dir / "hydra").exists(): + break + run_dir = run_dir.parent + output_dir = run_dir / "evaluation" + else: + output_dir = args.output_dir + output_dir.mkdir(parents=True, exist_ok=True) + figures_dir = output_dir / "figures" + figures_dir.mkdir(parents=True, exist_ok=True) + + # Build the test loader. ``test_batch_size=1`` matches the point-cloud + # adapter's invariant. + loaders, _ = build_dataloaders( + cfg, + dist=None, + adapter="transolver", + collate_fn=collate_no_padding, + phases=("test",), + test_batch_size=1, + test_num_workers=args.num_workers, + ) + test_loader = loaders["test"] + print(f"Test set size: {len(test_loader.dataset)}") + + flux_stats = load_flux_stats(cfg.data.flux_normalization_stats_file) + + # Run the inference loop and accumulate metrics + plots. + per_sample_metrics: list[Dict[str, float]] = [] + per_sample_qoi: list[Dict[str, Dict[str, float]]] = [] + all_targets: list[np.ndarray] = [] + all_preds: list[np.ndarray] = [] + plot_indices = set() + if args.num_plot_samples > 0: + # Evenly sample plot indices across the test set. + n_total = ( + args.num_samples + if args.num_samples is not None + else len(test_loader.dataset) + ) + step = max(n_total // max(args.num_plot_samples, 1), 1) + plot_indices = set(range(0, n_total, step)) + + for idx, (pred, target, meta) in enumerate( + run_evaluation( + model, + test_loader, + device, + flux_stats, + max_samples=args.num_samples, + ) + ): + per_sample_metrics.append(compute_metrics(pred, target)) + qoi = compute_sample_qoi(pred, target, meta, args.case_type) + if qoi is not None: + per_sample_qoi.append(qoi) + all_targets.append(target) + all_preds.append(pred) + + if idx in plot_indices and "coordinates" in meta: + plot_flux_panels( + meta["coordinates"], + target, + pred, + figures_dir / f"flux_panels_{idx:04d}.png", + ) + + if not per_sample_metrics: + raise RuntimeError("No samples evaluated; check the test split / data path.") + + # Aggregate metrics over every sample (concatenate first for global stats). + all_target_arr = np.concatenate(all_targets) + all_pred_arr = np.concatenate(all_preds) + overall_metrics = compute_metrics(all_pred_arr, all_target_arr) + aggregated = aggregate_metrics(per_sample_metrics) + + metrics_out = { + "num_samples": len(per_sample_metrics), + "overall": overall_metrics, + "per_sample_aggregate": aggregated, + } + with open(output_dir / "metrics.yaml", "w") as f: + yaml.safe_dump(metrics_out, f, sort_keys=False) + print(f"\nMetrics:") + for k, v in overall_metrics.items(): + print(f" {k}: {v:.6e}") + + # QoI summary. + if per_sample_qoi: + qoi_summary = aggregate_qoi(per_sample_qoi) + with open(output_dir / "qoi_metrics.yaml", "w") as f: + yaml.safe_dump(qoi_summary, f, sort_keys=False) + print("\nQoI summary:") + for region, stats in qoi_summary.items(): + print( + f" {region}: mae={stats['mae']:.4e}, " + f"mean_rel_err={stats['mean_relative_error_pct']:.3f}%" + ) + + # Global plots over every concatenated point. + plot_true_vs_pred_scatter( + all_target_arr, all_pred_arr, figures_dir / "true_vs_pred.png" + ) + plot_error_histogram( + all_target_arr, all_pred_arr, figures_dir / "error_histogram.png" + ) + + print(f"\nResults written to: {output_dir}") + print(f" metrics.yaml") + if per_sample_qoi: + print(f" qoi_metrics.yaml") + print(f" figures/ ({len(plot_indices)} flux panels + 2 global plots)") + + +if __name__ == "__main__": + main() diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/loader.py b/examples/cfd/nuclear_engineering/radiation_transport/src/loader.py new file mode 100644 index 0000000000..b8f46fd9e1 --- /dev/null +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/loader.py @@ -0,0 +1,1202 @@ +# SPDX-FileCopyrightText: Copyright (c) 2023 - 2026 NVIDIA CORPORATION & AFFILIATES. +# SPDX-FileCopyrightText: All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Data plumbing: TransolverAdapter, collate, datapipe orchestration, DataLoader builder. + +This module is the "wiring" layer of the RTE Transolver example. It composes +the ``RTEBaseDataset`` (data source) with a ``Compose`` of transforms and a +``TransolverAdapter`` to produce model-ready batches, and exposes a single +``build_dataloaders`` entry point used by the training and evaluation +scripts. + +Sections: + +* Adapter — ``ModelAdapter`` base + ``TransolverAdapter`` + ``_as_dict`` helper. +* Collation — ``collate_no_padding`` (batch_size=1 unsqueeze). +* Stats / kwargs translation — ``build_rte_dataset_kwargs``. +* Pipeline orchestration — ``RTEDataPipe`` + ``_build_transforms`` + + ``_build_adapter`` + ``from_config`` + ``create_dataset``. +* Distributed preload barrier — file-marker rank-sequencing helpers. +* DataLoader builder — ``build_dataloaders`` + ``_make_loader`` + + ``_log_material_sanity``. +""" + +from __future__ import annotations + +# ========================================================================= +# Imports +# ========================================================================= + +import logging +import time +from abc import ABC, abstractmethod +from pathlib import Path +from typing import ( + Any, + Callable, + Dict, + Iterable, + List, + Literal, + Optional, + Tuple, + Union, +) + +import numpy as np +import torch +import torch.distributed as torch_dist +from omegaconf import DictConfig +from physicsnemo.datapipes.registry import register +from physicsnemo.datapipes.transforms import Compose, Normalize, Scale, Translate +from tensordict import TensorDict +from torch.utils.data import DataLoader, Dataset +from torch.utils.data.distributed import DistributedSampler + +from dataset import ( + RTEBaseDataset, + coord_translate_scale_params, + flux_normalize_kwargs, + load_flux_stats, + load_material_stats, + material_normalize_kwargs, +) +from material import MaterialPropertyExtractor +from transforms import ( + TRANSFORM_REGISTRY, + FourierFeatures, + LoadGroundTruthQoI, + NextStepSampler, + RTEBackupCoords, + RTEFluxLogClip, + SpatialSampler, + SteadyStateSampler, + td_from_dict, +) + + +# Register the material transform into the local TRANSFORM_REGISTRY so +# config-driven lookups by name resolve correctly. ``material.py`` does not +# decorate ``MaterialPropertyExtractor`` with ``@_register_local`` (to avoid +# importing the registry plumbing from a sibling), so we wire it up here at +# loader-import time. This keeps the registry a single source of truth even +# for transforms that live outside ``transforms.py``. +TRANSFORM_REGISTRY.setdefault("RTEMaterialPropertyExtractor", MaterialPropertyExtractor) + + +# ========================================================================= +# Adapter +# ========================================================================= +# +# ``ModelAdapter`` is the abstract base class for converting a transformed +# RTE sample into a model-specific input dict. Only ``TransolverAdapter`` is +# shipped here (GenericAdapter and GeoTransolverAdapter were dropped as part +# of the upstream Transolver-only consolidation). + + +class ModelAdapter(ABC): + """Abstract base class for model-specific data adapters. + + Adapters take a transformed sample (a ``TensorDict`` or plain dict with + numpy arrays / torch tensors) and convert it to the format expected by a + particular model (e.g. Transolver). + """ + + @abstractmethod + def __call__(self, sample: Dict[str, Any]) -> Dict[str, torch.Tensor]: + """Convert a sample to model-specific format.""" + pass + + def __repr__(self) -> str: + return f"{self.__class__.__name__}()" + + +def _as_dict(sample: Union[TensorDict, Dict[str, Any]]) -> Dict[str, Any]: + """Unwrap a ``TensorDict`` into a plain dict; pass through regular dicts. + + Tensor entries remain ``torch.Tensor`` references (no copy). NonTensorData + entries are transparently unwrapped via bracket access. + """ + if isinstance(sample, TensorDict): + return {k: sample[k] for k in sample.keys()} + return sample + + +@register("RTETransolverAdapter") +class TransolverAdapter(ModelAdapter): + """Adapter for the Transolver model. + + Maps RTE data to Transolver's expected input format: + + * ``fx`` — spatial coordinates ``[x, y, z]`` (plus Fourier features when enabled). + * ``embedding`` — material properties ``[sigma_a, sigma_s, sigma_t, Q]`` + (or just the first three when ``include_q_in_embedding=False``). + * ``flux_target`` — target flux to predict. + * ``time`` — normalized timestep (always present). + + Extra fields (``coordinates_unnormalized``, ``material_labels``, + ``cell_areas``, ``sigma_t``, ``sigma_s``, ``sim_time``, ``ground_truth_qoi``, + ``flux_normalization_stats``, ``geometry_params``) are passed through when + present in the sample. + """ + + def __init__( + self, + add_batch_dim: bool = False, + include_q_in_embedding: bool = True, + ): + self.add_batch_dim = add_batch_dim + self.include_q_in_embedding = include_q_in_embedding + + def __call__( + self, data: Union[TensorDict, Dict[str, Any]] + ) -> Dict[str, torch.Tensor]: + """Convert sample to Transolver format.""" + sample = _as_dict(data) + result: Dict[str, Any] = {} + + def to_tensor(x): + if isinstance(x, torch.Tensor): + return x.float() + return torch.from_numpy(x).float() + + if "coordinates" in sample: + coords = to_tensor(sample["coordinates"]) + if self.add_batch_dim: + coords = coords.unsqueeze(0) + result["fx"] = coords + + if "physical_properties" in sample: + mat_props = to_tensor(sample["physical_properties"]) + if not self.include_q_in_embedding: + mat_props = mat_props[..., :3] + if self.add_batch_dim: + mat_props = mat_props.unsqueeze(0) + result["embedding"] = mat_props + + if "coordinates_unnormalized" in sample: + coords_unnorm = to_tensor(sample["coordinates_unnormalized"]) + if self.add_batch_dim: + coords_unnorm = coords_unnorm.unsqueeze(0) + result["coordinates_unnormalized"] = coords_unnorm + + if ( + "material_properties" in sample + and sample["material_properties"] is not None + ): + mat_labels = sample["material_properties"] + if isinstance(mat_labels, np.ndarray): + mat_labels = torch.from_numpy(mat_labels.astype(np.int64)) + elif isinstance(mat_labels, torch.Tensor): + mat_labels = mat_labels.long() + if self.add_batch_dim: + mat_labels = mat_labels.unsqueeze(0) + result["material_labels"] = mat_labels + + if "flux_target" in sample: + flux_tgt = to_tensor(sample["flux_target"]) + if flux_tgt.ndim == 1: + flux_tgt = flux_tgt.unsqueeze(-1) + if self.add_batch_dim: + flux_tgt = flux_tgt.unsqueeze(0) + result["flux_target"] = flux_tgt + + metadata = sample.get("metadata", {}) or {} + max_timestep = metadata.get("max_timestep") if isinstance(metadata, dict) else None + max_sim_time = metadata.get("max_sim_time") if isinstance(metadata, dict) else None + timestep_target = sample.get("timestep_target") + + if "sim_times" in sample and max_sim_time is not None: + sim_times = sample["sim_times"] + sim_time_target = ( + float(sim_times[-1].item()) + if isinstance(sim_times, torch.Tensor) + else float(sim_times[-1]) + ) + time_normalized = sim_time_target / float(max_sim_time) + time_tensor = torch.tensor([[time_normalized]], dtype=torch.float32) + if not self.add_batch_dim: + time_tensor = time_tensor.squeeze(0) + result["time"] = time_tensor + elif timestep_target is not None and max_timestep is not None: + if float(max_timestep) == 0: + time_normalized = 1.0 + else: + time_normalized = float(timestep_target) / float(max_timestep) + time_tensor = torch.tensor([[time_normalized]], dtype=torch.float32) + if not self.add_batch_dim: + time_tensor = time_tensor.squeeze(0) + result["time"] = time_tensor + else: + time_tensor = torch.tensor([[0.0]], dtype=torch.float32) + if not self.add_batch_dim: + time_tensor = time_tensor.squeeze(0) + result["time"] = time_tensor + + if "cell_areas" in sample: + cell_areas = to_tensor(sample["cell_areas"]) + if self.add_batch_dim: + cell_areas = cell_areas.unsqueeze(0) + result["cell_areas"] = cell_areas + + if "sigma_t" in sample: + sigma_t = to_tensor(sample["sigma_t"]) + if self.add_batch_dim: + sigma_t = sigma_t.unsqueeze(0) + result["sigma_t"] = sigma_t + + if "sigma_s" in sample: + sigma_s = to_tensor(sample["sigma_s"]) + if self.add_batch_dim: + sigma_s = sigma_s.unsqueeze(0) + result["sigma_s"] = sigma_s + + if "sim_times" in sample: + sim_times_arr = sample["sim_times"] + if isinstance(sim_times_arr, torch.Tensor) and sim_times_arr.numel() > 0: + sim_time = torch.tensor( + [float(sim_times_arr[-1].item())], dtype=torch.float32 + ) + elif hasattr(sim_times_arr, "__len__") and len(sim_times_arr) > 0: + sim_time = torch.tensor([float(sim_times_arr[-1])], dtype=torch.float32) + else: + sim_time = torch.tensor([0.0], dtype=torch.float32) + if self.add_batch_dim: + sim_time = sim_time.unsqueeze(0) + result["sim_time"] = sim_time + + if "ground_truth_qoi" in sample: + qoi_value = sample["ground_truth_qoi"] + if not isinstance(qoi_value, torch.Tensor): + qoi_value = torch.tensor([qoi_value], dtype=torch.float32) + else: + qoi_value = qoi_value.float() + if self.add_batch_dim: + qoi_value = qoi_value.unsqueeze(0) + result["ground_truth_qoi"] = qoi_value + + if "flux_normalization_stats" in sample: + result["flux_normalization_stats"] = sample["flux_normalization_stats"] + + filename = sample.get("filename", "") or "" + if filename and "hohlraum" in filename.lower(): + geometry_params = self._extract_geometry_params(filename) + if geometry_params: + result["geometry_params"] = geometry_params + + metadata_dict = { + "timestep_input": sample.get("timestep_input"), + "timestep_target": sample.get("timestep_target"), + "max_timestep": max_timestep, + "filename": sample.get("filename"), + "case_type": ( + metadata.get("case_type") if isinstance(metadata, dict) else None + ), + } + result["metadata"] = {k: v for k, v in metadata_dict.items() if v is not None} + + return result + + def _extract_geometry_params(self, filename: str) -> dict: + """Extract hohlraum geometry parameters from the zarr filename.""" + filename = filename.replace(".zarr", "") + parts = filename.split("_") + geometry_params: Dict[str, float] = {} + for part in parts: + if part.startswith("ulr"): + geometry_params["ulr"] = float(part[3:]) + elif part.startswith("llr"): + geometry_params["llr"] = float(part[3:]) + elif part.startswith("urr"): + geometry_params["urr"] = float(part[3:]) + elif part.startswith("lrr"): + geometry_params["lrr"] = float(part[3:]) + elif part.startswith("hlr"): + geometry_params["hlr"] = float(part[3:]) + elif part.startswith("hrr"): + geometry_params["hrr"] = float(part[3:]) + elif part.startswith("cx"): + geometry_params["cx"] = float(part[2:]) + elif part.startswith("cy"): + geometry_params["cy"] = float(part[2:]) + return geometry_params + + def __repr__(self) -> str: + return ( + f"{self.__class__.__name__}(" + f"add_batch_dim={self.add_batch_dim}, " + f"include_q_in_embedding={self.include_q_in_embedding})" + ) + + +# ========================================================================= +# Collation +# ========================================================================= +# +# Only ``collate_no_padding`` ships with the upstream example: all configs +# use ``batch_size=1`` with a fixed ``num_spatial_points`` from +# ``SpatialSampler``, so no padding is needed. The PyG-graph collator was +# dropped along with the MeshGraphNet adapter. + + +@register("RTECollateNoPadding") +def collate_no_padding(batch: List[Dict[str, Any]]) -> Dict[str, Any]: + """Batch-size-1 collate: unsqueeze each tensor, pass non-tensors through. + + Asserts ``len(batch) == 1`` to keep us honest if someone flips the config + without wiring a real multi-sample collator. + """ + assert len(batch) == 1, "collate_no_padding requires batch_size=1" + item = batch[0] + result: Dict[str, Any] = {} + for k, v in item.items(): + if isinstance(v, torch.Tensor): + result[k] = v.unsqueeze(0) + else: + result[k] = v + return result + + +# ========================================================================= +# Stats / kwargs translation +# ========================================================================= +# +# ``build_rte_dataset_kwargs`` translates a Hydra config into the kwargs +# ``create_dataset`` expects. Used by both training (phases ``train``/``val``) +# and evaluation (phase ``test``). + + +def build_rte_dataset_kwargs( + cfg: DictConfig, + *, + adapter: str, + num_spatial_points_key: str = "num_spatial_points", + num_spatial_points_override: Optional[int] = None, + split_file_override: Optional[str] = None, + extra_kwargs: Optional[dict] = None, +) -> dict: + """Translate a Hydra config into the kwargs ``create_dataset`` expects. + + Callers that run against a checkpoint's saved config (evaluation) can + provide overrides for values the CLI wants to win over the config + (``split_file``) or that diverge between current and checkpoint shapes + (``num_spatial_points``). + """ + data_cfg = cfg.data + + flux_stats_file = data_cfg.get("flux_normalization_stats_file") + if flux_stats_file is None: + raise ValueError( + "data.flux_normalization_stats_file must be specified in config." + ) + + task = data_cfg.get("task") + if task is None: + raise ValueError("data.task must be specified in config.") + + flux_clip_threshold = data_cfg.get("flux_clip_threshold") + if flux_clip_threshold is None: + raise ValueError("data.flux_clip_threshold must be specified in config.") + + # num_spatial_points — override for eval when the checkpoint's model + # config carries the authoritative value. + if num_spatial_points_override is not None: + num_spatial_points = num_spatial_points_override + elif "." in num_spatial_points_key: + parts = num_spatial_points_key.split(".") + num_spatial_points = cfg + for part in parts: + num_spatial_points = num_spatial_points[part] + else: + num_spatial_points = cfg.model[num_spatial_points_key] + + split_file = ( + split_file_override if split_file_override else data_cfg.get("split_file") + ) + + # Fourier features config + use_fourier_features = data_cfg.get("use_fourier_features", False) + fourier_num_frequencies = None + fourier_coord_dims = None + fourier_base_frequency = None + if use_fourier_features: + fourier_cfg = data_cfg.get("fourier_features") + if fourier_cfg is None: + raise ValueError( + "use_fourier_features=True but data.fourier_features is missing." + ) + fourier_num_frequencies = fourier_cfg.get("num_frequencies") + fourier_coord_dims = fourier_cfg.get("coord_dims") + fourier_base_frequency = fourier_cfg.get("base_frequency") + if any( + v is None + for v in ( + fourier_num_frequencies, + fourier_coord_dims, + fourier_base_frequency, + ) + ): + raise ValueError( + "fourier_features config must specify num_frequencies, " + f"coord_dims, base_frequency. Got: {dict(fourier_cfg)}" + ) + + kwargs = { + "data_path": cfg.case.data_path, + "task": task, + "num_spatial_points": num_spatial_points, + "adapter": adapter, + "flux_normalization_stats_file": flux_stats_file, + "normalize_coordinates": data_cfg.get("normalize_coordinates", True), + "flux_clip_threshold": flux_clip_threshold, + "split_file": split_file, + "train_split": data_cfg.get("train_split", 0.7), + "val_split": data_cfg.get("val_split", 0.15), + "expand_timesteps": data_cfg.get("expand_timesteps", True), + "temporal_stride": data_cfg.get("temporal_stride", 1), + "load_ground_truth_qoi": data_cfg.get("load_ground_truth_qoi", False), + "cache_static_arrays": data_cfg.get("cache_static_arrays", True), + "max_cache_size": data_cfg.get("max_cache_size", 200), + "include_q_in_embedding": cfg.model.get("include_q_in_embedding", True), + "use_fourier_features": use_fourier_features, + "fourier_num_frequencies": fourier_num_frequencies, + "fourier_coord_dims": fourier_coord_dims, + "fourier_base_frequency": fourier_base_frequency, + } + + if extra_kwargs: + kwargs.update(extra_kwargs) + + return kwargs + + +# ========================================================================= +# Pipeline orchestration +# ========================================================================= +# +# ``RTEDataPipe`` composes ``RTEBaseDataset`` (data source) with a ``Compose`` +# of transforms and a ``TransolverAdapter`` (model adapter). ``from_config`` +# is the simple-configuration entry point; ``_build_transforms`` and +# ``_build_adapter`` are internal builders used by ``from_config``. +# ``create_dataset`` is the convenience wrapper used by ``build_dataloaders``. + + +@register("RTEDataPipe") +class RTEDataPipe(Dataset): + """High-level composable datapipe for RTE data. + + Combines: + + * ``RTEBaseDataset`` (data source / file enumeration / timestep expansion) + * ``Compose`` of transforms (preprocessing pipeline) + * ``TransolverAdapter`` (model-specific tensor packaging) + + For the canonical training configuration, use ``RTEDataPipe.from_config``; + for fully custom pipelines, instantiate directly with explicit + ``transforms`` and ``adapter``. + """ + + def __init__( + self, + data_path: Union[str, Path], + transforms: Optional[Compose] = None, + adapter: Optional[Any] = None, + case_type: Optional[Literal["lattice", "hohlraum"]] = None, + phase: Literal["train", "val", "test"] = "train", + split_file: Optional[Union[str, Path]] = None, + train_split: float = 0.7, + val_split: float = 0.15, + seed: Optional[int] = None, + expand_timesteps: bool = True, + temporal_stride: int = 1, + cache_static_arrays: bool = True, + max_cache_size: int = 200, + task: Literal["next_step", "steady_state"] = "next_step", + ): + """Initialize the datapipe (see ``from_config`` for the simple path).""" + self.base_dataset = RTEBaseDataset( + data_path=data_path, + case_type=case_type, + phase=phase, + split_file=split_file, + train_split=train_split, + val_split=val_split, + seed=seed, + load_sigma_fields=True, # load precomputed material properties for speed + expand_timesteps=expand_timesteps, + temporal_stride=temporal_stride, + cache_static_arrays=cache_static_arrays, + max_cache_size=max_cache_size, + task=task, + ) + + self.transforms = transforms + self.adapter = adapter + + def __len__(self) -> int: + return len(self.base_dataset) + + def __getitem__(self, idx: int) -> Any: + """Get a sample from the dataset. + + ``base_dataset[idx]`` returns a ``TensorDict`` with tensor fields plus + ``metadata`` / ``filename`` / ``_timestep_idx`` as ``NonTensorData`` + entries. Transforms consume and return ``TensorDict``; the adapter + converts to the model-specific format. + """ + td = self.base_dataset[idx] + if not isinstance(td, TensorDict): + # Defensive path for callers that still return a dict. + td = td_from_dict(td) + + if self.transforms is not None: + td = self.transforms(td) + + if self.adapter is not None: + return self.adapter(td) + return td + + @classmethod + def from_config( + cls, + data_path: Union[str, Path], + case_type: Optional[Literal["lattice", "hohlraum"]] = None, + task: Literal["next_step", "steady_state"] = "next_step", + adapter: Optional[Literal["transolver", None]] = "transolver", + phase: Literal["train", "val", "test"] = "train", + # Data processing options + num_spatial_points: int = 2048, + flux_normalization_stats_file: Optional[Union[str, Path]] = None, + normalize_coordinates: bool = True, + flux_clip_threshold: float = 1e-8, + load_ground_truth_qoi: bool = False, + # Advanced options + temporal_stride: int = 1, + expand_timesteps: bool = True, + split_file: Optional[Union[str, Path]] = None, + train_split: float = 0.7, + val_split: float = 0.15, + seed: Optional[int] = None, + # Cache options + cache_static_arrays: bool = True, + max_cache_size: int = 200, + # Transolver-specific options + include_q_in_embedding: bool = True, + # Fourier features options + use_fourier_features: bool = False, + fourier_num_frequencies: int = 3, + fourier_coord_dims: int = 2, + fourier_base_frequency: float = 1.0, + ) -> "RTEDataPipe": + """Create a datapipe from a simple configuration. + + The transform pipeline and adapter are built by ``_build_transforms`` + and ``_build_adapter`` respectively; this method is a thin + orchestrator that validates required inputs, composes both stages, + and returns the configured datapipe. + """ + if flux_normalization_stats_file is None: + raise ValueError( + "flux_normalization_stats_file is required. " + "Run compute_normalizations.py first to generate statistics file." + ) + + transforms = _build_transforms( + data_path=data_path, + case_type=case_type, + task=task, + flux_normalization_stats_file=flux_normalization_stats_file, + flux_clip_threshold=flux_clip_threshold, + temporal_stride=temporal_stride, + seed=seed, + num_spatial_points=num_spatial_points, + normalize_coordinates=normalize_coordinates, + use_fourier_features=use_fourier_features, + fourier_num_frequencies=fourier_num_frequencies, + fourier_coord_dims=fourier_coord_dims, + fourier_base_frequency=fourier_base_frequency, + load_ground_truth_qoi=load_ground_truth_qoi, + ) + + adapter_obj = _build_adapter( + adapter, + include_q_in_embedding=include_q_in_embedding, + ) + + # steady_state always uses t=0 → t=T, so per-step expansion is moot. + if task == "steady_state": + expand_timesteps = False + + return cls( + data_path=data_path, + transforms=transforms, + adapter=adapter_obj, + case_type=case_type, + phase=phase, + split_file=split_file, + train_split=train_split, + val_split=val_split, + seed=seed, + expand_timesteps=expand_timesteps, + temporal_stride=temporal_stride, + cache_static_arrays=cache_static_arrays, + max_cache_size=max_cache_size, + task=task, + ) + + def preload_to_memory(self, verbose: bool = True, num_workers: int = 8) -> dict: + """Preload all static arrays into main process memory. + + Workers inherit the populated cache via fork, eliminating disk I/O. + Uses parallel I/O for faster loading on multi-core systems. + """ + return self.base_dataset.preload_to_memory( + verbose=verbose, num_workers=num_workers + ) + + def get_raw_sample(self, idx: int) -> TensorDict: + """Get a raw sample as a ``TensorDict`` (pre-transform, pre-adapter).""" + td = self.base_dataset[idx] + if not isinstance(td, TensorDict): + td = td_from_dict(td) + return td + + def get_transformed_sample(self, idx: int) -> TensorDict: + """Get sample with transforms applied but no adapter (``TensorDict``).""" + td = self.get_raw_sample(idx) + if self.transforms is not None: + td = self.transforms(td) + return td + + def __repr__(self) -> str: + lines = [ + f"{self.__class__.__name__}(", + f" base_dataset={self.base_dataset}", + f" transforms={self.transforms}", + f" adapter={self.adapter}", + ")", + ] + return "\n".join(lines) + + +def _build_transforms( + *, + data_path: Union[str, Path], + case_type: Optional[str], + task: str, + flux_normalization_stats_file: Union[str, Path], + flux_clip_threshold: float, + temporal_stride: int, + seed: Optional[int], + num_spatial_points: int, + normalize_coordinates: bool, + use_fourier_features: bool, + fourier_num_frequencies: int, + fourier_coord_dims: int, + fourier_base_frequency: float, + load_ground_truth_qoi: bool, +) -> Compose: + """Assemble the canonical RTE transform pipeline. + + Normalization steps are delegated to PhysicsNeMo primitives: + + 1. ``RTEFluxLogClip`` + ``Normalize`` — flux: log+clip, then z-score. + 2. Temporal sampler — ``NextStepSampler`` | ``SteadyStateSampler``. + 3. ``MaterialPropertyExtractor`` — always. + 4. ``Normalize`` (``physical_properties``) — per-column z-score via broadcast. + 5. ``SpatialSampler`` — always. + 6. ``RTEBackupCoords`` + ``Translate`` + ``Scale`` — when normalize_coordinates. + 7. ``FourierFeatures`` — when use_fourier_features. + 8. ``LoadGroundTruthQoI`` — when load_ground_truth_qoi. + """ + flux_stats = load_flux_stats(flux_normalization_stats_file) + if abs(flux_stats["clip_threshold"] - flux_clip_threshold) > 1e-10: + raise ValueError( + f"Clip threshold mismatch: got {flux_clip_threshold}, " + f"stats computed with {flux_stats['clip_threshold']}" + ) + + transform_list = [ + RTEFluxLogClip( + clip_threshold=flux_clip_threshold, + log_flux_mean=flux_stats["log_flux_mean"], + log_flux_std=flux_stats["log_flux_std"], + ), + Normalize(**flux_normalize_kwargs(flux_stats, field="scalar_flux")), + ] + + if task == "next_step": + transform_list.append(NextStepSampler(stride=temporal_stride, seed=seed)) + elif task == "steady_state": + transform_list.append(SteadyStateSampler()) + else: + raise ValueError(f"Unknown task: {task}") + + transform_list.append(MaterialPropertyExtractor(case_type=case_type)) + + material_stats_path = ( + Path(flux_normalization_stats_file).parent / f"{case_type}_material_stats.yaml" + ) + if not material_stats_path.exists(): + raise FileNotFoundError( + f"Material statistics file not found: {material_stats_path}\n" + f"Run compute_normalizations.py to generate it." + ) + material_stats = load_material_stats(material_stats_path) + transform_list.append( + Normalize( + **material_normalize_kwargs( + material_stats, field="physical_properties", method="mean_std" + ) + ) + ) + + transform_list.append(SpatialSampler(num_points=num_spatial_points, seed=seed)) + + if normalize_coordinates: + if case_type is None: + raise ValueError( + "case_type is required when normalize_coordinates=True " + "(used to look up the global domain bounds)." + ) + center, half_extent = coord_translate_scale_params(case_type) + # RTEBackupCoords preserves raw coords AND writes bbox_min / bbox_max + # so downstream readers keep working. + transform_list.append( + RTEBackupCoords( + bbox_min=center - half_extent, + bbox_max=center + half_extent, + ) + ) + transform_list.append( + Translate( + input_keys=["coordinates"], + center_key_or_value=center, + subtract=True, + ) + ) + transform_list.append( + Scale( + input_keys=["coordinates"], + scale=half_extent, + divide=True, + ) + ) + + if use_fourier_features: + transform_list.append( + FourierFeatures( + num_frequencies=fourier_num_frequencies, + coord_dims=fourier_coord_dims, + base_frequency=fourier_base_frequency, + append_to_coordinates=True, + ) + ) + + if load_ground_truth_qoi: + transform_list.append(LoadGroundTruthQoI(data_path=data_path)) + + return Compose(transform_list) + + +def _build_adapter( + adapter: Optional[str], + *, + include_q_in_embedding: bool, +): + """Build the model-specific output adapter (or ``None``). + + Collapsed to a constant for the upstream Transolver-only example: the + only valid non-``None`` value is ``"transolver"``. Kept as a function + for plug-in clarity so ``from_config`` flows naturally. + """ + if adapter is None: + return None + if adapter == "transolver": + return TransolverAdapter(include_q_in_embedding=include_q_in_embedding) + raise ValueError( + f"Unknown adapter: {adapter!r}. The upstream example ships only " + "'transolver' (or None for raw TensorDicts)." + ) + + +def create_dataset( + case_type: Literal["lattice", "hohlraum"], + data_path: Union[str, Path], + phase: Literal["train", "val", "test"] = "train", + task: Literal["next_step", "steady_state"] = "next_step", + adapter: Optional[Literal["transolver"]] = "transolver", + **kwargs, +) -> RTEDataPipe: + """Create a dataset for the given case type.""" + if case_type not in ("lattice", "hohlraum"): + raise ValueError( + f"Unknown case_type: {case_type!r}. Expected 'lattice' or 'hohlraum'." + ) + return RTEDataPipe.from_config( + data_path=data_path, + case_type=case_type, + phase=phase, + task=task, + adapter=adapter, + **kwargs, + ) + + +# ========================================================================= +# Distributed preload barrier +# ========================================================================= +# +# File-marker rank-sequencing helpers used to serialize the per-rank zarr +# preload step in multi-GPU training. Sequencing avoids I/O contention and +# leverages OS page cache reuse across ranks. + + +def _wait_for_rank_preload( + my_rank: int, + target_rank: int, + barrier_dir: str, + timeout: int = 7200, +) -> None: + barrier_path = Path(barrier_dir) + barrier_path.mkdir(parents=True, exist_ok=True) + marker_file = barrier_path / f".preload_done_rank{target_rank}" + + if my_rank == target_rank: + marker_file.touch() + return + + start = time.time() + while not marker_file.exists(): + if time.time() - start > timeout: + raise TimeoutError( + f"Rank {my_rank} timed out waiting for rank {target_rank} " + f"preload after {timeout}s." + ) + time.sleep(1.0) + + +def _cleanup_preload_markers(barrier_dir: str, world_size: int) -> None: + barrier_path = Path(barrier_dir) + for r in range(world_size): + marker_file = barrier_path / f".preload_done_rank{r}" + try: + marker_file.unlink() + except FileNotFoundError: + pass + + +def _distributed_preload( + datasets: Dict[str, Any], + dist, + cfg: DictConfig, + logger: logging.Logger, +) -> None: + """Preload static arrays (and steady-state flux) into main-process memory. + + Distributed variant sequences ranks via file markers to avoid I/O + contention and leverage OS page cache reuse. Single-process variant is + a straight call to ``preload_to_memory`` on each dataset. + """ + targets = [ + (phase, ds) for phase, ds in datasets.items() if phase in ("train", "val") + ] + if not targets: + return + + if dist.distributed and torch_dist.is_initialized() and dist.world_size > 1: + if dist.rank == 0: + logger.info("\n" + "=" * 60) + logger.info("DISTRIBUTED PRELOADING") + logger.info("=" * 60) + logger.info(f"Ranks preload sequentially (world_size={dist.world_size})") + + barrier_dir = cfg.output + if dist.rank == 0: + _cleanup_preload_markers(barrier_dir, dist.world_size) + print("[Rank 0] Cleaned up stale preload markers", flush=True) + + time.sleep(1.0) + + if dist.rank > 0: + print( + f"[Rank {dist.rank}] Waiting for rank {dist.rank - 1} to finish preloading...", + flush=True, + ) + for prev in range(dist.rank): + _wait_for_rank_preload(dist.rank, prev, barrier_dir, timeout=7200) + + print(f"[Rank {dist.rank}] Starting preload...", flush=True) + for _, ds in targets: + ds.preload_to_memory(verbose=True) + print(f"[Rank {dist.rank}] Preload complete!", flush=True) + + _wait_for_rank_preload(dist.rank, dist.rank, barrier_dir, timeout=7200) + print( + f"[Rank {dist.rank}] Signaled completion, waiting for all ranks...", + flush=True, + ) + for other in range(dist.world_size): + _wait_for_rank_preload(dist.rank, other, barrier_dir, timeout=7200) + print(f"[Rank {dist.rank}] All ranks completed preloading!", flush=True) + + torch_dist.barrier() + + if dist.rank == 0: + _cleanup_preload_markers(barrier_dir, dist.world_size) + logger.info("=" * 60 + "\n") + else: + if dist is None or dist.rank == 0: + logger.info("\n" + "=" * 60) + logger.info("SINGLE-GPU PRELOADING") + logger.info("=" * 60) + logger.info("Loading static arrays into memory...") + for _, ds in targets: + ds.preload_to_memory(verbose=(dist is None or dist.rank == 0)) + if dist is None or dist.rank == 0: + logger.info("=" * 60 + "\n") + + +# ========================================================================= +# DataLoader builder +# ========================================================================= +# +# ``build_dataloaders`` is the main entry point used by ``train.py`` and +# ``inference.py``. It orchestrates dataset creation, distributed-preload +# synchronization, material sanity logging, sampler construction, and +# per-phase ``DataLoader`` assembly. + + +def _log_material_sanity(dataset, cfg: DictConfig, logger: logging.Logger) -> None: + """Log material-property ranges from the first sample for diagnostics.""" + if len(dataset) == 0: + return + sample = dataset.get_transformed_sample(0) + if "physical_properties" not in sample or sample["physical_properties"] is None: + return + + phys = sample["physical_properties"] + if isinstance(phys, torch.Tensor): + phys = phys.detach().cpu().numpy() + + sigma_a = phys[:, 0] + sigma_s = phys[:, 1] + Q = phys[:, 3] + + logger.info("\nMaterial property ranges (first sample):") + logger.info( + f" sigma_a: [{sigma_a.min():.2f}, {sigma_a.max():.2f}] " + f"(unique: {len(set(sigma_a.tolist()))})" + ) + logger.info( + f" sigma_s: [{sigma_s.min():.2f}, {sigma_s.max():.2f}] " + f"(unique: {len(set(sigma_s.tolist()))})" + ) + logger.info(f" Q: {sorted(set(Q.tolist()))}") + if len(set(sigma_a.tolist())) > 1 or len(set(sigma_s.tolist())) > 1: + logger.info(f" Heterogeneous materials detected ({cfg.case.type})") + else: + logger.info(f" Homogeneous materials ({cfg.case.type})") + + +def _make_loader( + dataset, + cfg: DictConfig, + phase: str, + sampler: Optional[DistributedSampler], + collate_fn: Optional[Callable], + test_batch_size: int, + test_num_workers: int, +) -> DataLoader: + """Assemble a ``DataLoader`` for one phase, reading per-phase config. + + The ``test`` phase has no matching ``cfg.test.*`` block; callers pass + ``test_batch_size`` / ``test_num_workers`` explicitly. + """ + if phase == "test": + return DataLoader( + dataset, + batch_size=test_batch_size, + num_workers=test_num_workers, + shuffle=False, + pin_memory=False, + collate_fn=collate_fn, + ) + + phase_cfg = cfg.train.dataloader if phase == "train" else cfg.train.val.dataloader + sampler_cfg = cfg.train.sampler if phase == "train" else cfg.train.val.sampler + num_workers = phase_cfg.num_workers + + # sampler handles shuffling when present; keep ``shuffle=False`` to avoid + # the PyTorch "sampler is incompatible with shuffle" error. + shuffle_train = sampler_cfg.shuffle if phase == "train" else False + shuffle = shuffle_train if sampler is None else False + + kwargs = { + "batch_size": phase_cfg.batch_size, + "pin_memory": phase_cfg.pin_memory, + "num_workers": num_workers, + "shuffle": shuffle, + "drop_last": sampler_cfg.get("drop_last", False), + "sampler": sampler, + "collate_fn": collate_fn, + } + if num_workers > 0: + kwargs["prefetch_factor"] = phase_cfg.get("prefetch_factor", 2) + kwargs["persistent_workers"] = phase_cfg.get("persistent_workers", False) + + return DataLoader(dataset, **kwargs) + + +def build_dataloaders( + cfg: DictConfig, + dist=None, + *, + adapter: str = "transolver", + collate_fn: Optional[Callable] = None, + extra_dataset_kwargs: Optional[dict] = None, + phases: Iterable[str] = ("train", "val"), + num_spatial_points_key: str = "num_spatial_points", + num_spatial_points_override: Optional[int] = None, + split_file_override: Optional[str] = None, + test_batch_size: int = 1, + test_num_workers: int = 0, + logger: Optional[logging.Logger] = None, +) -> Tuple[Dict[str, DataLoader], Optional[DistributedSampler]]: + """Build per-phase ``DataLoader`` s for training and/or evaluation. + + Args: + cfg: Hydra configuration (training cfg or a loaded checkpoint cfg). + dist: ``DistributedManager`` for training; ``None`` for eval. + adapter: Model adapter identifier (only ``"transolver"`` is shipped). + collate_fn: Collate function. Defaults to ``collate_no_padding``. + extra_dataset_kwargs: Additional kwargs forwarded to ``create_dataset``. + phases: Which splits to build (subset of ``{"train", "val", "test"}``). + num_spatial_points_key: Where to read ``num_spatial_points`` from + the config (dotted path). Overridden by + ``num_spatial_points_override`` when the caller already knows + the authoritative value (eval path). + num_spatial_points_override: Optional explicit ``num_spatial_points``. + split_file_override: CLI override for ``data.split_file``. + test_batch_size / test_num_workers: Used only when ``test`` is in + ``phases``. + logger: Optional logger; defaults to module logger. + + Returns: + ``({phase: DataLoader}, train_sampler)``. ``train_sampler`` is + ``None`` when ``train`` is not in ``phases`` or ``dist`` is not + distributed. + """ + logger = logger or logging.getLogger(__name__) + phases = tuple(phases) + + # Hardcode the collate to ``collate_no_padding`` — the upstream example + # ships only the point-cloud adapter, which requires batch_size=1. + if collate_fn is None: + collate_fn = collate_no_padding + + rank_zero = dist is None or dist.rank == 0 + + if rank_zero: + logger.info(f"Loading {cfg.case.type} data from: {cfg.case.data_path}") + + common_kwargs = build_rte_dataset_kwargs( + cfg, + adapter=adapter, + num_spatial_points_key=num_spatial_points_key, + num_spatial_points_override=num_spatial_points_override, + split_file_override=split_file_override, + extra_kwargs=extra_dataset_kwargs, + ) + + if rank_zero: + logger.info(f"Task mode: {common_kwargs['task']}") + if common_kwargs["split_file"]: + logger.info(f"Using predefined splits from: {common_kwargs['split_file']}") + if common_kwargs["max_cache_size"] == -1: + logger.info("Data caching: UNLIMITED") + else: + logger.info( + f"Data caching: LRU max_cache_size={common_kwargs['max_cache_size']}" + ) + + datasets = { + phase: create_dataset(cfg.case.type, phase=phase, **common_kwargs) + for phase in phases + } + + # Distributed/single preloading (training only; eval skips). + preload_data = False + if "data" in cfg: + preload_data = cfg.data.get("preload_data", False) + if ( + preload_data + and common_kwargs["max_cache_size"] == -1 + and dist is not None + and any(p in datasets for p in ("train", "val")) + ): + _distributed_preload(datasets, dist, cfg, logger) + + if rank_zero: + split_summary = ", ".join(f"{p}={len(datasets[p])}" for p in phases) + logger.info(f"\nData split summary: {split_summary}") + if "train" in datasets: + _log_material_sanity(datasets["train"], cfg, logger) + + # Samplers + loaders. + train_sampler: Optional[DistributedSampler] = None + loaders: Dict[str, DataLoader] = {} + for phase in phases: + sampler = None + if dist is not None and dist.distributed and phase in ("train", "val"): + shuffle_cfg = cfg.train.sampler.shuffle if phase == "train" else False + drop_last = ( + cfg.train.sampler.get("drop_last", False) + if phase == "train" + else cfg.train.val.sampler.get("drop_last", False) + ) + sampler = DistributedSampler( + datasets[phase], + num_replicas=dist.world_size, + rank=dist.rank, + shuffle=shuffle_cfg, + drop_last=drop_last, + ) + if phase == "train": + train_sampler = sampler + + loaders[phase] = _make_loader( + datasets[phase], + cfg, + phase, + sampler, + collate_fn, + test_batch_size=test_batch_size, + test_num_workers=test_num_workers, + ) + + return loaders, train_sampler + + +__all__ = [ + "ModelAdapter", + "TransolverAdapter", + "collate_no_padding", + "build_rte_dataset_kwargs", + "RTEDataPipe", + "create_dataset", + "build_dataloaders", +] diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/losses.py b/examples/cfd/nuclear_engineering/radiation_transport/src/losses.py new file mode 100644 index 0000000000..22f69950e8 --- /dev/null +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/losses.py @@ -0,0 +1,1095 @@ +# SPDX-FileCopyrightText: Copyright (c) 2023 - 2026 NVIDIA CORPORATION & AFFILIATES. +# SPDX-FileCopyrightText: All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Losses: MSE / region-weighted / physics-informed / QoI helpers + LR schedulers. + +This module consolidates every "loss" concept the trainer touches: + +* Learning-rate schedulers (warmup + cosine, step, plateau, constant). +* Regression losses on the (possibly padded) flux tensor: ``loss_fn``, + ``masked_mse_loss``, ``region_weighted_loss_fn``. +* Physics-informed loss for the radiation-transport surrogate: per-case + QoI loss (lattice / hohlraum) computed in physical flux space using the + differentiable PyTorch QoI evaluators, plus a ``compute_physics_loss`` + dispatcher that ``train.py`` drives. +* Differentiable PyTorch QoI evaluators + (``evaluate_lattice_qoi_torch`` / ``evaluate_hohlraum_qoi_torch``) + used by the physics loss above. The numpy-side evaluators used by + ``inference.py`` live in ``inference.py``. +* ``parse_loss_config`` — pulls the ``train.physics_loss`` and + ``train.region_weights`` blocks out of the Hydra config into a flat dict + the trainer consumes. + +Module is pure compute: it has no dependency on sibling source files. +``denormalize_flux_from_stats`` delegates to ``transforms.denormalize_flux`` so the physics loss +can convert log-normalized model outputs back to physical flux space +without importing from ``transforms.py``. +""" + +from __future__ import annotations + +import math +from typing import Any, Mapping, Optional + +import torch +from omegaconf import DictConfig + + +# ========================================================================= +# Schedulers +# ========================================================================= + + +class WarmupCosineScheduler(torch.optim.lr_scheduler._LRScheduler): + """ + Learning rate scheduler with linear warmup followed by cosine annealing. + + During warmup (epochs 0 to warmup_epochs-1): + lr = min_lr + (max_lr - min_lr) * (epoch / warmup_epochs) + + After warmup (epochs warmup_epochs to total_epochs): + lr = min_lr + 0.5 * (max_lr - min_lr) * (1 + cos(pi * progress)) + where progress = (epoch - warmup_epochs) / (total_epochs - warmup_epochs) + """ + + def __init__( + self, + optimizer: torch.optim.Optimizer, + warmup_epochs: int, + total_epochs: int, + min_lr: float = 1e-6, + last_epoch: int = -1, + ): + self.warmup_epochs = warmup_epochs + self.total_epochs = total_epochs + self.min_lr = min_lr + super().__init__(optimizer, last_epoch) + + def get_lr(self): + if self.last_epoch < self.warmup_epochs: + # Linear warmup + warmup_factor = (self.last_epoch + 1) / max(1, self.warmup_epochs) + return [ + self.min_lr + (base_lr - self.min_lr) * warmup_factor + for base_lr in self.base_lrs + ] + else: + # Cosine annealing + progress = (self.last_epoch - self.warmup_epochs) / max( + 1, self.total_epochs - self.warmup_epochs + ) + cosine_factor = 0.5 * (1 + math.cos(math.pi * progress)) + return [ + self.min_lr + (base_lr - self.min_lr) * cosine_factor + for base_lr in self.base_lrs + ] + + +def create_scheduler(cfg: DictConfig, optimizer: torch.optim.Optimizer, logger=None): + """ + Create learning rate scheduler based on config. + + Supports: + - constant: No learning rate decay (useful for overfit tests) + - cosine: Warmup + cosine annealing (recommended) + - step: Step decay every N epochs + - plateau: Reduce on plateau (adaptive) + """ + scheduler_type = cfg.train.get("scheduler_type", "cosine") + warmup_epochs = cfg.train.get("warmup_epochs", 5) + min_lr = cfg.train.get("min_learning_rate", 1e-6) + + if logger: + logger.info("\nLearning rate schedule:") + logger.info(f" Type: {scheduler_type}") + logger.info(f" Peak LR: {cfg.train.learning_rate}") + logger.info(f" Min LR: {min_lr}") + if warmup_epochs > 0: + logger.info(f" Warmup epochs: {warmup_epochs}") + + if scheduler_type == "constant": + # constant LR - no decay (useful for overfit tests) + scheduler = torch.optim.lr_scheduler.ConstantLR( + optimizer, + factor=1.0, + total_iters=cfg.train.epochs, + ) + elif scheduler_type == "cosine": + scheduler = WarmupCosineScheduler( + optimizer, + warmup_epochs=warmup_epochs, + total_epochs=cfg.train.epochs, + min_lr=min_lr, + ) + elif scheduler_type == "step": + step_size = cfg.train.get("step_size", 50) + step_gamma = cfg.train.get("step_gamma", 0.5) + if logger: + logger.info(f" Step size: {step_size}, Gamma: {step_gamma}") + + # For step scheduler, we use a sequential scheduler with warmup + if warmup_epochs > 0: + warmup_scheduler = torch.optim.lr_scheduler.LinearLR( + optimizer, + start_factor=min_lr / cfg.train.learning_rate, + end_factor=1.0, + total_iters=warmup_epochs, + ) + step_scheduler = torch.optim.lr_scheduler.StepLR( + optimizer, step_size=step_size, gamma=step_gamma + ) + scheduler = torch.optim.lr_scheduler.SequentialLR( + optimizer, + schedulers=[warmup_scheduler, step_scheduler], + milestones=[warmup_epochs], + ) + else: + scheduler = torch.optim.lr_scheduler.StepLR( + optimizer, step_size=step_size, gamma=step_gamma + ) + elif scheduler_type == "plateau": + patience = cfg.train.get("plateau_patience", 10) + factor = cfg.train.get("plateau_factor", 0.5) + threshold = cfg.train.get("plateau_threshold", 0.01) + if logger: + logger.info(f" Patience: {patience}, Factor: {factor}") + + # ReduceLROnPlateau doesn't work well with warmup, so we just use it directly + # and set initial LR lower if warmup is desired + scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau( + optimizer, + mode="min", + factor=factor, + patience=patience, + threshold=threshold, + min_lr=min_lr, + verbose=True, + ) + else: + raise ValueError(f"Unknown scheduler type: {scheduler_type}") + + return scheduler + + +# ========================================================================= +# Regression losses +# ========================================================================= + + +def loss_fn( + output: torch.Tensor, + target: torch.Tensor, + loss_type: str = "mse", + padded_value: float = -10, +) -> torch.Tensor: + """ + Calculate loss with masking for padded values. + + Args: + output: Predicted values + target: Ground truth values + loss_type: Type of loss - "mse" or "rmse" + padded_value: Value used for padding (will be masked out) + + Returns: + Scalar loss value + """ + mask = abs(target - padded_value) > 1e-3 + + num = torch.sum(mask * (output - target) ** 2.0) + + if loss_type == "rmse": + denom = torch.sum(mask * target**2.0) + loss = torch.sqrt(num / denom) + else: # mse + denom = torch.sum(mask) + loss = num / denom + + return loss + + +def masked_mse_loss( + output: torch.Tensor, target: torch.Tensor, mask: torch.Tensor = None +) -> torch.Tensor: + """ + Calculate MSE loss with optional masking for padded values. + + Used by Transolver training. + + Args: + output: Predicted values (B, N, 1) + target: Ground truth values (B, N, 1) + mask: Boolean mask (B, N) - True for real points, False for padding + + Returns: + Scalar loss value + """ + squared_error = (output - target) ** 2 + + if mask is not None: + # expand mask to match output shape + mask_expanded = mask.unsqueeze(-1) + # only compute loss on non-padded points + masked_error = squared_error * mask_expanded + loss = masked_error.sum() / mask_expanded.sum() + else: + loss = torch.mean(squared_error) + + return loss + + +def region_weighted_loss_fn( + output: torch.Tensor, + target: torch.Tensor, + material_labels: torch.Tensor, + case_type: str, + loss_type: str = "mse", + padded_value: float = -10, + void_weight: float = 3.0, + material_weight: float = 1.0, +) -> torch.Tensor: + """ + Calculate region-weighted loss based on material labels. + + Uses discrete material labels from the material mappers to identify regions: + - Void (fill gas): radiation streams through, creates fine features + - Material (walls, capsule, absorbers): solid regions + + Weights void regions more heavily than material regions to improve + fine feature learning where radiation streaming occurs. + + Material label definitions: + Hohlraum: + 0: Black (walls) - material + 1: Red (walls) - material + 2: Green (walls) - material + 3: Blue (capsule) - material + 4: White (fill gas) - void + + Lattice: + 0: Blue (absorber) - material + 1: Red (scattering source) - material + 2: White (background) - void + + Args: + output: Predicted values (B, N, 1) + target: Ground truth values (B, N, 1) + material_labels: Material label per cell (B, N) or (B, N, 1), integer values + case_type: "hohlraum" or "lattice" + loss_type: Type of loss - "mse" or "rmse" + padded_value: Value used for padding (will be masked out) + void_weight: Weight for void (fill gas) regions + material_weight: Weight for solid material regions + + Returns: + Scalar loss value + """ + # Create padding mask + mask = (abs(target - padded_value) > 1e-3).float() + + # Squeeze material_labels if needed + labels = ( + material_labels.squeeze(-1) if material_labels.dim() == 3 else material_labels + ) + + # Identify void regions based on case type + # Void label: 4 for hohlraum (white/fill gas), 2 for lattice (white/background) + if case_type.lower() == "hohlraum": + is_void = (labels == 4).float() # (B, N) + elif case_type.lower() == "lattice": + is_void = (labels == 2).float() # (B, N) + else: + raise ValueError( + f"Unknown case_type: {case_type}. Must be 'hohlraum' or 'lattice'" + ) + + # Compute per-point weights + weights = is_void * void_weight + (1.0 - is_void) * material_weight # (B, N) + weights = weights.unsqueeze(-1) # (B, N, 1) to match output shape + + # Apply padding mask to weights + weights = weights * mask + + # Compute weighted squared error + squared_error = (output - target) ** 2.0 + weighted_error = weights * squared_error + + if loss_type == "rmse": + weighted_target_sq = weights * target**2.0 + loss = torch.sqrt(weighted_error.sum() / (weighted_target_sq.sum() + 1e-8)) + else: # mse + loss = weighted_error.sum() / (weights.sum() + 1e-8) + + return loss + + +def parse_loss_config( + cfg: DictConfig, + dist: Any, + logger: Any, +) -> dict: + """ + Parse the common loss configuration options shared across all models: + physics loss, region-weighted loss. + + Args: + cfg: Hydra config + dist: DistributedManager (only ``dist.rank`` is read) + logger: Logger + + Returns: + Dict with keys: use_physics_loss, physics_loss_weight, physics_loss_mse_weight, + qoi_region, use_region_weighted_loss, region_weight_cfg + """ + use_physics_loss = cfg.train.get("use_physics_loss", False) + if use_physics_loss: + physics_loss_weight = cfg.train.physics_loss.weight + physics_loss_mse_weight = cfg.train.physics_loss.mse_weight + qoi_region = cfg.train.physics_loss.get("qoi_region", "center") + else: + physics_loss_weight = 0.0 + physics_loss_mse_weight = 1.0 + qoi_region = "center" + + use_region_weighted_loss = cfg.train.get("use_region_weighted_loss", False) + region_weight_cfg = { + "void_weight": cfg.train.get("region_weights", {}).get("void_weight", 3.0), + "material_weight": cfg.train.get("region_weights", {}).get( + "material_weight", 1.0 + ), + } + + if dist.rank == 0: + if use_physics_loss: + logger.info("\nPhysics loss configuration:") + logger.info(f" Weight: {physics_loss_weight}") + logger.info(f" MSE weight: {physics_loss_mse_weight}") + logger.info(f" QoI region: {qoi_region}") + if use_region_weighted_loss: + logger.info("Region-weighted loss: enabled") + logger.info(f" Void weight: {region_weight_cfg['void_weight']}") + logger.info(f" Material weight: {region_weight_cfg['material_weight']}") + + return { + "use_physics_loss": use_physics_loss, + "physics_loss_weight": physics_loss_weight, + "physics_loss_mse_weight": physics_loss_mse_weight, + "qoi_region": qoi_region, + "use_region_weighted_loss": use_region_weighted_loss, + "region_weight_cfg": region_weight_cfg, + } + + +# ========================================================================= +# Physics loss +# ========================================================================= +# +# Physics-based loss functions using QoI computations. +# +# Compares physics-based quantities of interest (QoIs) computed from model +# predictions against ground truth. +# +# - QoIs (absorption integrals) are defined in **physical flux space**. +# If your model is trained on a normalized/log-transformed flux, you must +# denormalize before computing QoIs, otherwise the "physics loss" is +# optimizing a different (non-physical) quantity. +# - QoI computations can be numerically sensitive under AMP/FP16 due to large +# reductions (sums over many cells). Prefer running these in FP32 (disable +# autocast) in the training loop. + + +def denormalize_flux_from_stats( + normalized_flux: torch.Tensor, + flux_normalization_stats: Mapping[str, Any], +) -> torch.Tensor: + """Invert the ``RTEFluxLogClip + Normalize`` chain for QoI evaluation. + + Thin wrapper over ``transforms.denormalize_flux`` that enforces the + presence of the stats dict (callers in physics-loss code reach here + only after validating shapes, so the stats must be available). + """ + if flux_normalization_stats is None: + raise ValueError( + "flux_normalization_stats is required for QoI denormalization" + ) + # Sibling import is safe: transforms.py is foundational and does not + # import from losses.py (verified via static cross-module check). + from transforms import denormalize_flux + return denormalize_flux(normalized_flux, flux_normalization_stats) + + +def _relative_squared_error_loss( + pred: torch.Tensor, + target: torch.Tensor, + device: torch.device, + epsilon: float = 1e-10, +) -> tuple[torch.Tensor, dict]: + """Compute mean relative squared error between pred and target vectors.""" + relative_error = (pred - target) / (torch.abs(target) + epsilon) + squared_error = relative_error**2 + + is_valid = ( + torch.isfinite(squared_error) & torch.isfinite(pred) & torch.isfinite(target) + ) + + if not is_valid.any(): + return torch.tensor(0.0, device=device, requires_grad=True), {} + + loss = squared_error[is_valid].mean() + return loss, {} + + +def compute_lattice_qoi_loss( + predicted_flux: torch.Tensor, + target_flux: torch.Tensor, + cell_centers: torch.Tensor, + cell_areas: torch.Tensor, + sigma_t: torch.Tensor, + sigma_s: torch.Tensor, + sim_time: torch.Tensor, + flux_normalization_stats: Optional[Mapping[str, Any]] = None, + epsilon: float = 1e-10, +) -> tuple[torch.Tensor, dict[str, float]]: + """ + Compute QoI-based physics loss for lattice problems. + + Computes instantaneous absorption QoI from predicted flux and target flux, + then compares them using relative squared error to provide scale-invariant gradients. + + QoIs are computed in physical flux space. If `flux_normalization_stats` is provided, + both `predicted_flux` and `target_flux` are denormalized before QoI evaluation. + + Uses PyTorch operations throughout to maintain gradient flow for backpropagation. + + Args: + predicted_flux: Model predictions (normalized), shape (B, N, 1) or (B, N) + target_flux: Ground truth flux (normalized), shape (B, N, 1) or (B, N) + cell_centers: Cell center coordinates (unnormalized), shape (B, N, 3) + cell_areas: Cell areas, shape (B, N) + sigma_t: Total cross-section, shape (B, N) + sigma_s: Scattering cross-section, shape (B, N) + sim_time: Simulation time for each sample, shape (B,) + + Returns: + Scalar tensor with relative squared error loss between predicted and target QoI + """ + # ensure correct shape: (B, N) + if predicted_flux.ndim == 3: + predicted_flux = predicted_flux.squeeze(-1) # (B, N, 1) -> (B, N) + if target_flux.ndim == 3: + target_flux = target_flux.squeeze(-1) # (B, N, 1) -> (B, N) + + # compute QoIs in physical flux space + if flux_normalization_stats is not None: + predicted_flux = denormalize_flux_from_stats( + predicted_flux, flux_normalization_stats + ) + target_flux = denormalize_flux_from_stats( + target_flux, flux_normalization_stats + ) + + # reshape for QoI computation: (B, 1, N) for single timestep + predicted_flux_qoi = predicted_flux.unsqueeze(1) # (B, 1, N) + target_flux_qoi = target_flux.unsqueeze(1) # (B, 1, N) + + # prepare sim_times: (B, 1) + sim_times = sim_time.unsqueeze(-1) if sim_time.ndim == 1 else sim_time # (B, 1) + + # compute QoI for predicted flux using differentiable PyTorch implementation + qoi_pred = evaluate_lattice_qoi_torch( + cell_centers=cell_centers, # (B, N, 3) + cell_areas=cell_areas, # (B, N) + sigma_t=sigma_t, # (B, N) + sigma_s=sigma_s, # (B, N) + scalar_flux=predicted_flux_qoi, # (B, 1, N) + sim_times=sim_times, # (B, 1) + ) + + # compute QoI for target flux (no gradients needed for target) + with torch.no_grad(): + qoi_target = evaluate_lattice_qoi_torch( + cell_centers=cell_centers, + cell_areas=cell_areas, + sigma_t=sigma_t, + sigma_s=sigma_s, + scalar_flux=target_flux_qoi, + sim_times=sim_times, + ) + + # extract instantaneous absorption: (B, 1) -> (B,) + qoi_pred_value = qoi_pred["cur_absorption"][:, 0] + qoi_target_value = qoi_target["cur_absorption"][:, 0] + + loss, loss_details = _relative_squared_error_loss( + qoi_pred_value, qoi_target_value, predicted_flux.device, epsilon + ) + loss_details["loss_qoi_absorption"] = loss.item() + + return loss, loss_details + + +def compute_hohlraum_qoi_loss( + predicted_flux: torch.Tensor, + target_flux: torch.Tensor, + cell_centers: torch.Tensor, + cell_areas: torch.Tensor, + sigma_t: torch.Tensor, + sigma_s: torch.Tensor, + sim_time: torch.Tensor, + geometry_params: dict, + qoi_region: str = "all", + flux_normalization_stats: Optional[Mapping[str, Any]] = None, + epsilon: float = 1e-10, +) -> tuple[torch.Tensor, dict[str, float]]: + """ + Compute QoI-based physics loss for hohlraum problems. + + The loss used for backpropagation is determined by ``qoi_region``: + - "all" (default): mean of the four region losses (center, vertical, + horizontal, total) — every region contributes to the gradient + - "center" | "vertical" | "horizontal": loss on that single region + - "total": loss on the integrated absorption over the whole domain + (computed from the sum of the three spatial regions) + + All four region losses are *always* recorded in the details dict so they + are visible in the training log regardless of which region drives the + gradient. + + Returns: + Tuple of (loss_tensor, details_dict). + """ + # ensure correct shape: (B, N) + if predicted_flux.ndim == 3: + predicted_flux = predicted_flux.squeeze(-1) + if target_flux.ndim == 3: + target_flux = target_flux.squeeze(-1) + + if flux_normalization_stats is not None: + predicted_flux = denormalize_flux_from_stats( + predicted_flux, flux_normalization_stats + ) + target_flux = denormalize_flux_from_stats( + target_flux, flux_normalization_stats + ) + + predicted_flux_qoi = predicted_flux.unsqueeze(1) + target_flux_qoi = target_flux.unsqueeze(1) + sim_times = sim_time.unsqueeze(-1) if sim_time.ndim == 1 else sim_time + + qoi_pred = evaluate_hohlraum_qoi_torch( + cell_centers=cell_centers, + cell_areas=cell_areas, + sigma_t=sigma_t, + sigma_s=sigma_s, + scalar_flux=predicted_flux_qoi, + sim_times=sim_times, + geometry_params=geometry_params, + ) + + with torch.no_grad(): + qoi_target = evaluate_hohlraum_qoi_torch( + cell_centers=cell_centers, + cell_areas=cell_areas, + sigma_t=sigma_t, + sigma_s=sigma_s, + scalar_flux=target_flux_qoi, + sim_times=sim_times, + geometry_params=geometry_params, + ) + + region_keys = ( + "cur_absorption_center", + "cur_absorption_vertical", + "cur_absorption_horizontal", + ) + details: dict[str, float] = {} + + total_pred = torch.zeros(predicted_flux.shape[0], device=predicted_flux.device) + total_target = torch.zeros_like(total_pred) + region_losses: dict[str, torch.Tensor] = {} + + for key in region_keys: + p = qoi_pred[key][:, 0] + t = qoi_target[key][:, 0] + region_loss, _ = _relative_squared_error_loss( + p, t, predicted_flux.device, epsilon + ) + short = key.replace("cur_absorption_", "") + region_losses[short] = region_loss + total_pred = total_pred + p + total_target = total_target + t + + total_loss, _ = _relative_squared_error_loss( + total_pred, total_target, predicted_flux.device, epsilon + ) + region_losses["total"] = total_loss + + # Always log every region's loss so all four are visible in train.log + # regardless of which region(s) drive the gradient. + for region_name, region_loss in region_losses.items(): + details[f"loss_qoi_{region_name}"] = region_loss.item() + + if qoi_region == "all": + # Mean of the four region losses — every region contributes to the gradient. + loss = torch.stack(list(region_losses.values())).mean() + details["loss_qoi_all"] = loss.item() + elif qoi_region in region_losses: + loss = region_losses[qoi_region] + else: + raise ValueError( + f"Unknown qoi_region: {qoi_region}. " + f"Must be 'all' or one of: {list(region_losses.keys())}" + ) + + return loss, details + + +def compute_combined_loss( + predicted_flux: torch.Tensor, + target_flux: torch.Tensor, + cell_centers: torch.Tensor, + cell_areas: torch.Tensor, + sigma_t: torch.Tensor, + sigma_s: torch.Tensor, + sim_time: torch.Tensor, + mse_weight: float = 1.0, + qoi_weight: float = 0.1, + loss_type: str = "mse", + padded_value: float = -10, +) -> tuple[torch.Tensor, dict[str, float]]: + """ + Compute combined MSE + QoI physics loss for lattice problems. + + Args: + predicted_flux: Model predictions (normalized), shape (B, N, 1) + target_flux: Ground truth flux (normalized), shape (B, N, 1) + cell_centers: Cell center coordinates (unnormalized), shape (B, N, 3) + cell_areas: Cell areas, shape (B, N) + sigma_t: Total cross-section, shape (B, N) + sigma_s: Scattering cross-section, shape (B, N) + sim_time: Simulation time for each sample, shape (B,) + mse_weight: Weight for MSE loss component (default: 1.0) + qoi_weight: Weight for QoI loss component (default: 0.1) + loss_type: Type of MSE loss - "mse" or "rmse" + padded_value: Value used for padding (will be masked out) + + Returns: + total_loss: Combined weighted loss + loss_dict: Dictionary with individual loss components + """ + # compute MSE loss with masking + mask = abs(target_flux - padded_value) > 1e-3 + num = torch.sum(mask * (predicted_flux - target_flux) ** 2.0) + + if loss_type == "rmse": + denom = torch.sum(mask * target_flux**2.0) + loss_mse = torch.sqrt(num / denom) + else: + denom = torch.sum(mask) + loss_mse = num / denom + + # compute QoI physics loss (uses normalized flux) + loss_qoi = compute_lattice_qoi_loss( + predicted_flux=predicted_flux, + target_flux=target_flux, + cell_centers=cell_centers, + cell_areas=cell_areas, + sigma_t=sigma_t, + sigma_s=sigma_s, + sim_time=sim_time, + ) + + # combine losses + total_loss = mse_weight * loss_mse + qoi_weight * loss_qoi + + # return loss and components + loss_dict = { + "loss": total_loss.item(), + "loss_mse": loss_mse.item(), + "loss_qoi": loss_qoi.item(), + } + + return total_loss, loss_dict + + +def extract_geometry_params(filename) -> dict: + """Extract hohlraum geometry parameters from zarr filename.""" + # handle list (batched) or single string filename + if isinstance(filename, (list, tuple)): + filename = filename[0] if len(filename) > 0 else "" + + if not isinstance(filename, str): + filename = str(filename) + + # remove .zarr extension if present + filename = filename.replace(".zarr", "") + + parts = filename.split("_") + + geometry_params = {} + for part in parts: + if part.startswith("ulr"): + geometry_params["ulr"] = float(part[3:]) + elif part.startswith("llr"): + geometry_params["llr"] = float(part[3:]) + elif part.startswith("urr"): + geometry_params["urr"] = float(part[3:]) + elif part.startswith("lrr"): + geometry_params["lrr"] = float(part[3:]) + elif part.startswith("hlr"): + geometry_params["hlr"] = float(part[3:]) + elif part.startswith("hrr"): + geometry_params["hrr"] = float(part[3:]) + elif part.startswith("cx"): + geometry_params["cx"] = float(part[2:]) + elif part.startswith("cy"): + geometry_params["cy"] = float(part[2:]) + + return geometry_params + + +def compute_physics_loss( + case_type: str, + predicted_flux: torch.Tensor, + target_flux: torch.Tensor, + cell_centers: torch.Tensor, + cell_areas: torch.Tensor, + sigma_t: torch.Tensor, + sigma_s: torch.Tensor, + sim_time: torch.Tensor, + metadata: list = None, + flux_normalization_stats: dict | None = None, + qoi_epsilon: float = 1e-10, + qoi_region: str = "all", +) -> tuple[torch.Tensor, dict[str, float]]: + """ + Compute physics loss based on case type. + + For hohlraum, ``qoi_region`` selects which region(s) drive the gradient: + ``"all"`` (default) averages the four region losses (center, vertical, + horizontal, total) so every region contributes; ``"center"`` / + ``"vertical"`` / ``"horizontal"`` / ``"total"`` use that single region. + Either way, all four region losses are recorded in the details dict. + + Returns: + Tuple of (loss_tensor, details_dict) with per-region QoI losses for logging. + """ + if case_type == "lattice": + return compute_lattice_qoi_loss( + predicted_flux=predicted_flux, + target_flux=target_flux, + cell_centers=cell_centers, + cell_areas=cell_areas, + sigma_t=sigma_t, + sigma_s=sigma_s, + sim_time=sim_time, + flux_normalization_stats=flux_normalization_stats, + epsilon=qoi_epsilon, + ) + elif case_type == "hohlraum": + if metadata is None: + raise ValueError("hohlraum physics loss requires metadata with filename") + + if isinstance(metadata, dict): + filename = metadata.get("filename", "") + elif isinstance(metadata, list) and len(metadata) > 0: + filename = metadata[0].get("filename", "") + else: + raise ValueError( + f"hohlraum physics loss requires metadata with filename, got: {type(metadata)}" + ) + + geometry_params = extract_geometry_params(filename) + + if not geometry_params: + raise ValueError( + f"could not extract geometry parameters from filename: {filename}" + ) + + return compute_hohlraum_qoi_loss( + predicted_flux=predicted_flux, + target_flux=target_flux, + cell_centers=cell_centers, + cell_areas=cell_areas, + sigma_t=sigma_t, + sigma_s=sigma_s, + sim_time=sim_time, + geometry_params=geometry_params, + qoi_region=qoi_region, + flux_normalization_stats=flux_normalization_stats, + epsilon=qoi_epsilon, + ) + else: + raise ValueError( + f"unknown case type: {case_type}. must be 'lattice' or 'hohlraum'" + ) + + +# ========================================================================= +# QoI helpers (torch) +# ========================================================================= +# +# Differentiable PyTorch QoI evaluators used by the physics loss above. +# These match KiT-RT SNSolverHPC::IterPostprocessing() exactly. +# The numpy-side equivalents (``evaluate_lattice_qoi``, +# ``evaluate_hohlraum_qoi``) live in ``inference.py``. + + +def evaluate_lattice_qoi_torch( + cell_centers: torch.Tensor, + cell_areas: torch.Tensor, + sigma_t: torch.Tensor, + sigma_s: torch.Tensor, + scalar_flux: torch.Tensor, + sim_times: torch.Tensor, +) -> dict[str, torch.Tensor]: + """ + Compute lattice absorption QoI using PyTorch (differentiable). + + Matches KiT-RT SNSolverHPC::IterPostprocessing() exactly. + + Args: + cell_centers: (N, 3) or (B, N, 3) + cell_areas: (N,) or (B, N) + sigma_t: (N,) or (B, N) + sigma_s: (N,) or (B, N) + scalar_flux: (T, N) or (B, T, N) + sim_times: (T,) or (B, T) + + Returns: + {"cur_absorption": (T,) or (B, T), "total_absorption": (T,) or (B, T)} + """ + if cell_centers.ndim == 3: + return _evaluate_lattice_qoi_torch_batched( + cell_centers, cell_areas, sigma_t, sigma_s, scalar_flux, sim_times + ) + + x = cell_centers[:, 0] + y = cell_centers[:, 1] + sigma_a = sigma_t - sigma_s + + xy_corrector = -3.5 + lbounds = torch.tensor([1.0, 2.0, 3.0, 4.0, 5.0]) + xy_corrector + ubounds = torch.tensor([2.0, 3.0, 4.0, 5.0, 6.0]) + xy_corrector + + in_absorption = torch.zeros_like(x, dtype=torch.bool) + for k in range(5): + for l in range(5): # noqa: E741 + if (l + k) % 2 == 1: + continue + if (k == 2 and l == 2) or (k == 2 and l == 4): + continue + in_square = ( + (x >= lbounds[k]) + & (x <= ubounds[k]) + & (y >= lbounds[l]) + & (y <= ubounds[l]) + ) + in_absorption = in_absorption | in_square + + if scalar_flux.ndim != 2: + raise ValueError(f"Expected scalar_flux shape (T, N), got {scalar_flux.shape}") + + num_timesteps = scalar_flux.shape[0] + absorption_density = scalar_flux * sigma_a.unsqueeze(0) * cell_areas.unsqueeze(0) + cur_absorption = torch.sum( + absorption_density * in_absorption.unsqueeze(0).float(), dim=1 + ) + + total_absorption = torch.zeros_like(cur_absorption) + total_absorption[0] = cur_absorption[0] * sim_times[0] + for t in range(1, num_timesteps): + dt = sim_times[t] - sim_times[t - 1] + total_absorption[t] = total_absorption[t - 1] + cur_absorption[t] * dt + + return {"cur_absorption": cur_absorption, "total_absorption": total_absorption} + + +def _evaluate_lattice_qoi_torch_batched( + cell_centers, cell_areas, sigma_t, sigma_s, scalar_flux, sim_times +) -> dict[str, torch.Tensor]: + """Batched version for (B, N, 3) inputs.""" + batch_size = cell_centers.shape[0] + results = [ + evaluate_lattice_qoi_torch( + cell_centers[b], + cell_areas[b], + sigma_t[b], + sigma_s[b], + scalar_flux[b], + sim_times[b] if sim_times.ndim == 2 else sim_times, + ) + for b in range(batch_size) + ] + return { + "cur_absorption": torch.stack([r["cur_absorption"] for r in results]), + "total_absorption": torch.stack([r["total_absorption"] for r in results]), + } + + +def evaluate_hohlraum_qoi_torch( + cell_centers: torch.Tensor, + cell_areas: torch.Tensor, + sigma_t: torch.Tensor, + sigma_s: torch.Tensor, + scalar_flux: torch.Tensor, + sim_times: torch.Tensor, + geometry_params: dict[str, float], +) -> dict[str, torch.Tensor]: + """ + Compute hohlraum absorption QoI using PyTorch (differentiable). + + Matches KiT-RT SNSolverHPC hohlraum geometry exactly (including known KiT-RT + quirk of using pos_red_left_bottom for both vertical wall sides). + + Args: + cell_centers: (N, 3) or (B, N, 3) + cell_areas: (N,) or (B, N) + sigma_t: (N,) or (B, N) + sigma_s: (N,) or (B, N) + scalar_flux: (T, N) or (B, T, N) + sim_times: (T,) or (B, T) + geometry_params: dict with cx, cy, hlr, hrr, llr, ulr, lrr, urr + + Returns: + Dict with cur_absorption_{center,vertical,horizontal}, + cumulated_absorption_{center,vertical,horizontal}, total_absorption + """ + if cell_centers.ndim == 3: + return _evaluate_hohlraum_qoi_torch_batched( + cell_centers, + cell_areas, + sigma_t, + sigma_s, + scalar_flux, + sim_times, + geometry_params, + ) + + x = cell_centers[:, 0] + y = cell_centers[:, 1] + + cx = geometry_params["cx"] + cy = geometry_params["cy"] + pos_red_left_border = geometry_params["hlr"] + pos_red_right_border = geometry_params["hrr"] + pos_red_left_bottom = geometry_params["llr"] + pos_red_left_top = geometry_params["ulr"] + pos_red_right_top = geometry_params["urr"] + + sigma_a = sigma_t - sigma_s + + in_center = (x > -0.2 + cx) & (x < 0.2 + cx) & (y > -0.4 + cy) & (y < 0.4 + cy) + # IMPORTANT: matches KiT-RT's behavior of using pos_red_left_bottom for both sides + in_vertical = ( + (x < pos_red_left_border) & (y > pos_red_left_bottom) & (y < pos_red_left_top) + ) | ( + (x > pos_red_right_border) + & (y > pos_red_left_bottom) + & (y < pos_red_right_top) + ) + in_horizontal = (y > 0.6) | (y < -0.6) + + if scalar_flux.ndim != 2: + raise ValueError(f"Expected scalar_flux shape (T, N), got {scalar_flux.shape}") + + num_timesteps = scalar_flux.shape[0] + absorption_density = scalar_flux * sigma_a.unsqueeze(0) * cell_areas.unsqueeze(0) + + cur_center = torch.sum(absorption_density * in_center.unsqueeze(0).float(), dim=1) + cur_vertical = torch.sum( + absorption_density * in_vertical.unsqueeze(0).float(), dim=1 + ) + cur_horizontal = torch.sum( + absorption_density * in_horizontal.unsqueeze(0).float(), dim=1 + ) + cur_total = torch.sum(absorption_density, dim=1) + + cum_center = torch.zeros_like(cur_center) + cum_vertical = torch.zeros_like(cur_vertical) + cum_horizontal = torch.zeros_like(cur_horizontal) + total_absorption = torch.zeros_like(cur_total) + + cum_center[0] = cur_center[0] * sim_times[0] + cum_vertical[0] = cur_vertical[0] * sim_times[0] + cum_horizontal[0] = cur_horizontal[0] * sim_times[0] + total_absorption[0] = cur_total[0] * sim_times[0] + + for t in range(1, num_timesteps): + dt = sim_times[t] - sim_times[t - 1] + cum_center[t] = cum_center[t - 1] + cur_center[t] * dt + cum_vertical[t] = cum_vertical[t - 1] + cur_vertical[t] * dt + cum_horizontal[t] = cum_horizontal[t - 1] + cur_horizontal[t] * dt + total_absorption[t] = total_absorption[t - 1] + cur_total[t] * dt + + return { + "cur_absorption_center": cur_center, + "cur_absorption_vertical": cur_vertical, + "cur_absorption_horizontal": cur_horizontal, + "cumulated_absorption_center": cum_center, + "cumulated_absorption_vertical": cum_vertical, + "cumulated_absorption_horizontal": cum_horizontal, + "total_absorption": total_absorption, + } + + +def _evaluate_hohlraum_qoi_torch_batched( + cell_centers, + cell_areas, + sigma_t, + sigma_s, + scalar_flux, + sim_times, + geometry_params, +) -> dict[str, torch.Tensor]: + """Batched version for (B, N, 3) inputs.""" + batch_size = cell_centers.shape[0] + results = [ + evaluate_hohlraum_qoi_torch( + cell_centers[b], + cell_areas[b], + sigma_t[b], + sigma_s[b], + scalar_flux[b], + sim_times[b] if sim_times.ndim == 2 else sim_times, + geometry_params, + ) + for b in range(batch_size) + ] + keys = list(results[0].keys()) + return {k: torch.stack([r[k] for r in results]) for k in keys} + + +__all__ = [ + # Schedulers + "WarmupCosineScheduler", + "create_scheduler", + # Regression losses + "loss_fn", + "masked_mse_loss", + "region_weighted_loss_fn", + "parse_loss_config", + # Physics loss + "compute_physics_loss", + "compute_lattice_qoi_loss", + "compute_hohlraum_qoi_loss", + "compute_combined_loss", + "denormalize_flux_from_stats", + "extract_geometry_params", + # QoI helpers (torch) + "evaluate_lattice_qoi_torch", + "evaluate_hohlraum_qoi_torch", +] diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/material.py b/examples/cfd/nuclear_engineering/radiation_transport/src/material.py new file mode 100644 index 0000000000..262006cccd --- /dev/null +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/material.py @@ -0,0 +1,532 @@ +# SPDX-FileCopyrightText: Copyright (c) 2023 - 2026 NVIDIA CORPORATION & AFFILIATES. +# SPDX-FileCopyrightText: All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Material/physics-domain module for radiation-transport surrogates. + +Consolidates two concerns into one file: + +1. Pure-numpy *material mappers* that, given an (x, y) point cloud, assign a + discrete material label and the corresponding cross-section properties + (sigma_a, sigma_s, sigma_t, Q) for the two benchmark geometries: + + - ``LatticeMaterialMapper``: 7x7 grid of square blocks in + [-3.5, 3.5] x [-3.5, 3.5] (blue / red / white). + - ``HohlraumMaterialMapper``: complex hohlraum geometry in + [-0.65, 0.65] x [-0.65, 0.65] (black / red / green / blue / white). + +2. The ``MaterialPropertyExtractor`` *transform* that runs as part of the + per-sample pipeline. It uses precomputed sigma fields when the Zarr store + provides them, otherwise it falls back to invoking the mappers above on + the integer ``material_properties`` labels stored in the sample. + +A separate stats-computation utility (``compute_material_statistics``) is +used by ``compute_normalizations.py`` for offline normalization stats; it +is not a transform and lives outside this module. +""" + +from __future__ import annotations + +import logging +from typing import Any, Dict, Optional + +import numpy as np +import torch +from tensordict import TensorDict + +from transforms import Transform, td_get, to_numpy + + +# ========================================================================= +# Lattice material mapper +# ========================================================================= + + +class LatticeMaterialMapper: + """Maps spatial points to material properties for the lattice dataset. + + Domain: 7x7 grid of square blocks in [-3.5, 3.5] x [-3.5, 3.5] + - Blue blocks (11): pure absorption (sigma_a only) + - Red block (1): scattering source (sigma_s + Q=1) + - White blocks (37): pure scattering (sigma_s only) + """ + + # Domain parameters + DOMAIN_BOUNDS = (-3.5, 3.5) + BLOCK_SIZE = 1.0 + NUM_BLOCKS = 7 + + # Material region definitions + BLUE_BLOCKS = [ + (2, 2), + (4, 2), + (6, 2), + (3, 3), + (5, 3), + (2, 4), + (6, 4), + (3, 5), + (5, 5), + (2, 6), + (6, 6), + ] + RED_BLOCKS = [(4, 4)] + + # Material labels + MATERIAL_LABELS = {"blue": 0, "red": 1, "white": 2} + + # Default material properties + DEFAULT_MATERIAL_PROPERTIES = { + 0: {"sigma_t": None, "sigma_s": 0.0, "sigma_a": None, "Q": 0}, # blue + 1: {"sigma_t": None, "sigma_s": None, "sigma_a": 0.0, "Q": 1}, # red + 2: {"sigma_t": None, "sigma_s": None, "sigma_a": 0.0, "Q": 0}, # white + -1: {"sigma_t": 0, "sigma_s": 0, "sigma_a": 0, "Q": 0}, # outside + } + + def __init__( + self, + logger: Optional[logging.Logger] = None, + simulation_parameters: Optional[Dict[str, Any]] = None, + ): + """Initialize the lattice material mapper.""" + self.logger = logger or logging.getLogger(__name__) + self.simulation_parameters = simulation_parameters or {} + self._material_properties = self._calculate_material_properties() + + def _calculate_material_properties(self) -> Dict[int, Dict[str, float]]: + """Calculate material properties based on simulation parameters.""" + absorption_coeff = self.simulation_parameters.get("absorption_coeff", np.nan) + scattering_coeff = self.simulation_parameters.get("scattering_coeff", np.nan) + + # Blue: pure absorption + blue_props = { + "sigma_a": absorption_coeff, + "sigma_s": 0.0, + "sigma_t": absorption_coeff, + "Q": 0, + } + + # Red: scattering source - TODO: should be pure scattering. + red_props = { + "sigma_a": 0.0, + "sigma_s": scattering_coeff, + "sigma_t": 1.0, + "Q": 1, + } + + # White: pure scattering + white_props = { + "sigma_a": 0.0, + "sigma_s": scattering_coeff, + "sigma_t": scattering_coeff, + "Q": 0, + } + + # Outside domain + outside_props = {"sigma_a": 0, "sigma_s": 0, "sigma_t": 0, "Q": 0} + + return {0: blue_props, 1: red_props, 2: white_props, -1: outside_props} + + def get_block_index(self, x: float, y: float) -> tuple[int, int]: + """Convert x,y coordinates to block indices (1-indexed).""" + # Shift coordinates to [0, 7] range + x_shifted = x - self.DOMAIN_BOUNDS[0] + y_shifted = y - self.DOMAIN_BOUNDS[0] + + # Get block indices (1-indexed) + i = int(np.floor(x_shifted / self.BLOCK_SIZE)) + 1 + j = int(np.floor(y_shifted / self.BLOCK_SIZE)) + 1 + + # Clamp to valid range [1, 7] + i = max(1, min(self.NUM_BLOCKS, i)) + j = max(1, min(self.NUM_BLOCKS, j)) + + return i, j + + def map_coordinates_to_materials(self, coordinates: np.ndarray) -> np.ndarray: + """Map coordinates to material labels.""" + n_points = coordinates.shape[0] + material_labels = np.full(n_points, 2, dtype=np.int32) # Default: white + + for idx in range(n_points): + x, y = coordinates[idx, 0], coordinates[idx, 1] + i, j = self.get_block_index(x, y) + block = (i, j) + + if block in self.BLUE_BLOCKS: + material_labels[idx] = 0 + elif block in self.RED_BLOCKS: + material_labels[idx] = 1 + # else: stays white (2) + + return material_labels + + def get_material_properties(self, coordinates: np.ndarray) -> Dict[str, np.ndarray]: + """Get material property arrays for coordinates.""" + material_labels = self.map_coordinates_to_materials(coordinates) + n_points = len(coordinates) + + # Initialize arrays + sigma_t = np.zeros(n_points, dtype=np.float32) + sigma_s = np.zeros(n_points, dtype=np.float32) + sigma_a = np.zeros(n_points, dtype=np.float32) + Q = np.zeros(n_points, dtype=np.float32) + + # Fill arrays based on material labels + for label in [0, 1, 2]: + mask = material_labels == label + props = self._material_properties[label] + sigma_t[mask] = props["sigma_t"] + sigma_s[mask] = props["sigma_s"] + sigma_a[mask] = props["sigma_a"] + Q[mask] = props["Q"] + + return {"sigma_t": sigma_t, "sigma_s": sigma_s, "sigma_a": sigma_a, "Q": Q} + + +# ========================================================================= +# Hohlraum material mapper +# ========================================================================= + + +class HohlraumMaterialMapper: + """Maps spatial points to material properties for the hohlraum dataset. + + Domain: [-0.65, 0.65] x [-0.65, 0.65] with complex geometric regions + - Black (0): top/bottom horizontal strips + - Red (1): left/right vertical strips + - Green (2): capsule frame + - Blue (3): central capsule interior + - White (4): background region + """ + + # Domain parameters + DOMAIN_BOUNDS = (-0.65, 0.65) + + # Material labels + MATERIAL_LABELS = {"black": 0, "red": 1, "green": 2, "blue": 3, "white": 4} + + # Material properties (fixed values) + MATERIAL_PROPERTIES = { + 0: {"sigma_t": 100, "sigma_s": 50, "sigma_a": 50, "Q": 0}, # black + 1: {"sigma_t": 100, "sigma_s": 95, "sigma_a": 5, "Q": 0}, # red + 2: {"sigma_t": 100, "sigma_s": 90, "sigma_a": 10, "Q": 0}, # green + 3: {"sigma_t": 100, "sigma_s": 0, "sigma_a": 100, "Q": 0}, # blue + 4: {"sigma_t": 0.1, "sigma_s": 0.1, "sigma_a": 0, "Q": 0}, # white + -1: {"sigma_t": 0, "sigma_s": 0, "sigma_a": 0, "Q": 0}, # outside + } + + def __init__( + self, + logger: Optional[logging.Logger] = None, + boundary_thickness: float = 0.05, # Green frame thickness + simulation_parameters: Optional[Dict[str, Any]] = None, + capsule_half_width: float = 0.15, # Blue inner capsule half-width + capsule_half_height: float = 0.35, # Blue inner capsule half-height + ): + """Initialize the hohlraum material mapper.""" + self.logger = logger or logging.getLogger(__name__) + self.boundary_thickness = boundary_thickness + self.simulation_parameters = simulation_parameters or {} + self.capsule_half_width = capsule_half_width + self.capsule_half_height = capsule_half_height + self._material_regions = self._calculate_material_regions() + + def _calculate_material_regions(self) -> Dict: + """Calculate material regions based on simulation parameters.""" + # Get design parameters + ulr = self.simulation_parameters.get("ulr", 0.4) + llr = self.simulation_parameters.get("llr", -0.4) + urr = self.simulation_parameters.get("urr", 0.4) + lrr = self.simulation_parameters.get("lrr", -0.4) + hlr = self.simulation_parameters.get("hlr", -0.5) + hrr = self.simulation_parameters.get("hrr", 0.5) + cx = self.simulation_parameters.get("cx", 0.0) + cy = self.simulation_parameters.get("cy", 0.0) + + regions = {} + + # Black: top/bottom horizontal strips (wide regions, not thin borders) + # Black regions are defined as y > 0.6 or y < -0.6 + # This matches the KiT-RT QoI calculation logic (line 619) + regions["black"] = [ + { + "name": "K1", + "bounds": ( + self.DOMAIN_BOUNDS[0], + self.DOMAIN_BOUNDS[1], + self.DOMAIN_BOUNDS[0], + -0.6, # Bottom: y < -0.6 + ), + }, + { + "name": "K2", + "bounds": ( + self.DOMAIN_BOUNDS[0], + self.DOMAIN_BOUNDS[1], + 0.6, # Top: y > 0.6 + self.DOMAIN_BOUNDS[1], + ), + }, + ] + + # Red: left/right vertical strips (wide regions, not thin borders) + # Red regions are defined as everything left of hlr and right of hrr + # This matches the KiT-RT QoI calculation logic and the physical hohlraum geometry + regions["red"] = [ + { + "name": "R1", + "bounds": (self.DOMAIN_BOUNDS[0], hlr, llr, ulr), + }, # Left: x < hlr + { + "name": "R2", + "bounds": (hrr, self.DOMAIN_BOUNDS[1], lrr, urr), + }, # Right: x > hrr + ] + + # Green: capsule outer box (KiT-RT assigns green to entire outer box, then overwrites with blue) + # This matches KiT-RT lines 79-83: x in [-0.2+cx, 0.2+cx] && y in [-0.4+cy, 0.4+cy] + # The frame thickness is implicit: outer_width/2 - inner_width/2 = 0.2 - 0.15 = 0.05 + x_min_outer = cx - 0.2 + x_max_outer = cx + 0.2 + y_min_outer = cy - 0.4 + y_max_outer = cy + 0.4 + + regions["green"] = [ + { + "name": "G_outer", + "bounds": (x_min_outer, x_max_outer, y_min_outer, y_max_outer), + }, + ] + + # Blue: central capsule interior (checkered area) + # KiT-RT lines 84-88: x in [-0.15+cx, 0.15+cx] && y in [-0.35+cy, 0.35+cy] + x_min_blue = cx - 0.15 + x_max_blue = cx + 0.15 + y_min_blue = cy - 0.35 + y_max_blue = cy + 0.35 + + regions["blue"] = [ + { + "name": "B", + "bounds": (x_min_blue, x_max_blue, y_min_blue, y_max_blue), + }, + ] + + return regions + + def _is_in_blue_region(self, x: float, y: float) -> bool: + """Check if point is in blue capsule region.""" + for region in self._material_regions.get("blue", []): + x_min, x_max, y_min, y_max = region["bounds"] + if x_min <= x <= x_max and y_min <= y <= y_max: + return True + return False + + def _in_rect(self, x: float, y: float, bounds: tuple) -> bool: + """Check if point is in rectangle.""" + x_min, x_max, y_min, y_max = bounds + return x_min <= x <= x_max and y_min <= y <= y_max + + def get_material_property(self, x: float, y: float) -> int: + """Get material label for a single point. + + Uses KiT-RT's exact material assignment logic: + 1. Check black (top/bottom boundary regions) + 2. Check red (left/right vertical regions) + 3. Check green outer box (entire capsule region) + 4. Overwrite with blue if in inner region + 5. Default to white (background) + """ + # Priority order: black > red > green+blue > white + + # Check black (top/bottom strips) - highest priority + for region in self._material_regions.get("black", []): + if self._in_rect(x, y, region["bounds"]): + return 0 + + # Check red (left/right strips) + for region in self._material_regions.get("red", []): + if self._in_rect(x, y, region["bounds"]): + return 1 + + # Check green outer box (entire capsule region including corners) + # This assigns green to the FULL outer box first + for region in self._material_regions.get("green", []): + if self._in_rect(x, y, region["bounds"]): + # Now check if this point should be overwritten with blue (inner region) + if self._is_in_blue_region(x, y): + return 3 # blue overwrites green + return 2 # green + + # Default: white (background) + return 4 + + def map_coordinates_to_materials(self, coordinates: np.ndarray) -> np.ndarray: + """Map coordinates to material labels.""" + n_points = coordinates.shape[0] + material_labels = np.zeros(n_points, dtype=np.int32) + + for idx in range(n_points): + x, y = coordinates[idx, 0], coordinates[idx, 1] + material_labels[idx] = self.get_material_property(x, y) + + return material_labels + + def get_material_properties(self, coordinates: np.ndarray) -> Dict[str, np.ndarray]: + """Get material property arrays for coordinates.""" + material_labels = self.map_coordinates_to_materials(coordinates) + n_points = len(coordinates) + + # Initialize arrays + sigma_t = np.zeros(n_points, dtype=np.float32) + sigma_s = np.zeros(n_points, dtype=np.float32) + sigma_a = np.zeros(n_points, dtype=np.float32) + Q = np.zeros(n_points, dtype=np.float32) + + # Fill arrays based on material labels + for label in range(5): + mask = material_labels == label + props = self.MATERIAL_PROPERTIES[label] + sigma_t[mask] = props["sigma_t"] + sigma_s[mask] = props["sigma_s"] + sigma_a[mask] = props["sigma_a"] + Q[mask] = props["Q"] + + return {"sigma_t": sigma_t, "sigma_s": sigma_s, "sigma_a": sigma_a, "Q": Q} + + +# ========================================================================= +# Material transforms +# ========================================================================= + + +class MaterialPropertyExtractor(Transform): + """Extract physical material properties for radiation transport. + + Uses precomputed sigma fields (``sigma_a``, ``sigma_s``, ``sigma_t``, ``Q``) + when present in the sample; otherwise falls back to computing them from the + integer ``material_properties`` labels via the lattice/hohlraum mappers + defined above. The extracted properties are stored as + ``physical_properties`` with shape ``(N, 4)``: ``[sigma_a, sigma_s, sigma_t, Q]``. + """ + + def __init__(self, case_type: Optional[str] = None, add_to_sample: bool = True): + super().__init__() + self.case_type = case_type + self.add_to_sample = add_to_sample + + def __call__(self, data: TensorDict) -> TensorDict: + has_sigma_a = "sigma_a" in data + has_sigma_s = "sigma_s" in data + has_sigma_t = "sigma_t" in data + has_Q = "Q" in data + + if has_sigma_a and has_sigma_s and has_sigma_t: + if not has_Q: + raise KeyError( + "Zarr store has precomputed sigma fields but is missing 'Q'. " + "All four fields (sigma_a, sigma_s, sigma_t, Q) are required." + ) + physical_props = torch.stack( + [data["sigma_a"], data["sigma_s"], data["sigma_t"], data["Q"]], + dim=-1, + ).to(dtype=torch.float32) + else: + if "material_properties" not in data: + raise KeyError( + "Sample must contain either precomputed sigma fields " + "(sigma_a, sigma_s, sigma_t) or 'material_properties' labels." + ) + + case_type = self.case_type + if case_type is None: + metadata = td_get(data, "metadata", default={}) or {} + case_type = ( + metadata.get("case_type", "") if isinstance(metadata, dict) else "" + ) + case_type = case_type.lower() + + material_labels = to_numpy(data["material_properties"]) + n_points = len(material_labels) + + if case_type == "lattice": + metadata = td_get(data, "metadata", default={}) or {} + sim_params = ( + metadata.get("simulation_params", {}) + if isinstance(metadata, dict) + else {} + ) + params = ( + sim_params.get("parameters", {}) + if isinstance(sim_params, dict) + else {} + ) + absorption_coeff = params.get("absorption_coeff") + scattering_coeff = params.get("scattering_coeff") + if absorption_coeff is None or scattering_coeff is None: + raise ValueError( + "Lattice case requires 'absorption_coeff' and 'scattering_coeff' " + "in metadata.simulation_params.parameters" + ) + + mapper = LatticeMaterialMapper( + simulation_parameters={ + "absorption_coeff": absorption_coeff, + "scattering_coeff": scattering_coeff, + } + ) + material_props = mapper._material_properties + + props_np = np.zeros((n_points, 4), dtype=np.float32) + for label in (0, 1, 2): + mask = material_labels == label + props = material_props[label] + props_np[mask, 0] = props["sigma_a"] + props_np[mask, 1] = props["sigma_s"] + props_np[mask, 2] = props["sigma_t"] + props_np[mask, 3] = props["Q"] + + elif case_type == "hohlraum": + material_props = HohlraumMaterialMapper.MATERIAL_PROPERTIES + props_np = np.zeros((n_points, 4), dtype=np.float32) + for label in (0, 1, 2, 3, 4): + mask = material_labels == label + props = material_props[label] + props_np[mask, 0] = props["sigma_a"] + props_np[mask, 1] = props["sigma_s"] + props_np[mask, 2] = props["sigma_t"] + props_np[mask, 3] = props["Q"] + else: + raise ValueError( + f"Unknown case_type: {case_type}. Must be 'lattice' or 'hohlraum'" + ) + + physical_props = torch.from_numpy(props_np) + + if self.add_to_sample: + data["physical_properties"] = physical_props + + return data + + def extra_repr(self) -> str: + return f"case_type={self.case_type}, add_to_sample={self.add_to_sample}" + + +__all__ = [ + "LatticeMaterialMapper", + "HohlraumMaterialMapper", + "MaterialPropertyExtractor", +] diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/train.py b/examples/cfd/nuclear_engineering/radiation_transport/src/train.py new file mode 100644 index 0000000000..d8865a2d7c --- /dev/null +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/train.py @@ -0,0 +1,494 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. +# SPDX-FileCopyrightText: All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Hydra entry point for the RTE Transolver training sample. + +Composes the flat ``src/`` modules (``loader``, ``losses``, ``checkpointing``, +``trainer``) into a single training driver. The Transolver model spec +(``build_model``, ``to_device``, ``forward``, ``loss_inputs``, +``build_dataloaders_for_training``) is inlined at the top of this file — +there is no model-spec dispatcher because only one model is shipped. + +Usage:: + + python src/train.py case=lattice data=lattice case.data_root=... + +Multi-GPU:: + + torchrun --nproc_per_node=N src/train.py case=lattice data=lattice ... +""" + +from __future__ import annotations + +import pathlib +import sys + +# Flat-module shim: workers and direct ``python src/train.py`` invocations +# both need to be able to resolve sibling modules by their bare names. +sys.path.insert(0, str(pathlib.Path(__file__).parent)) + +from typing import Any, Dict, Optional, Tuple + +import hydra +import torch +import torch.nn as nn +from omegaconf import DictConfig, OmegaConf +from torch.amp import GradScaler, autocast +from torch.utils.data import DataLoader + +from physicsnemo.utils.logging.launch import LaunchLogger + +from checkpointing import create_training_components, resume_or_pretrain +from loader import build_dataloaders, collate_no_padding +from losses import parse_loss_config +from trainer import ( + compute_losses, + flush_partial_accumulation, + grad_step, + log_effective_batch_size, + run_training_loop, + set_seed, + setup_training_environment, + wrap_ddp, +) + + +# ========================================================================= +# Inlined Transolver helpers (was training/model_specs/transolver.py) +# ========================================================================= +# +# A single-model sample doesn't need a spec dispatcher — these helpers are +# called directly from ``train_epoch`` / ``validate`` / ``main`` below. + + +def build_model(cfg: DictConfig, device: torch.device) -> nn.Module: + """Instantiate the Transolver model from the Hydra ``model`` group. + + Two RTE-specific keys (``num_spatial_points``, ``include_q_in_embedding``) + are stripped from the config before ``hydra.utils.instantiate`` because + they are consumed by the data pipeline, not the model constructor. + """ + cfg_model = OmegaConf.to_container(cfg.model, resolve=True) + for k in ("num_spatial_points", "include_q_in_embedding"): + cfg_model.pop(k, None) + return hydra.utils.instantiate(cfg_model).to(device) + + +def build_dataloaders_for_training( + cfg: DictConfig, dist: Any, logger: Any +) -> Tuple[DataLoader, DataLoader, Optional[Any]]: + """Build train / val DataLoaders for the Transolver point-cloud adapter.""" + if cfg.train.dataloader.batch_size != 1: + raise ValueError( + "Only batch_size=1 is supported for the Transolver point-cloud " + "adapter (variable-length padding collate was removed)." + ) + loaders, train_sampler = build_dataloaders( + cfg, + dist, + adapter="transolver", + collate_fn=collate_no_padding, + phases=("train", "val"), + logger=logger, + ) + return loaders["train"], loaders["val"], train_sampler + + +def to_device(batch: Dict[str, Any], device: torch.device) -> Dict[str, Any]: + """Move tensor entries of a batch dict to ``device``; pass through the rest.""" + return { + k: v.to(device) if isinstance(v, torch.Tensor) else v + for k, v in batch.items() + } + + +def forward( + model: nn.Module, + batch: Dict[str, Any], + *, + use_time_embeddings: bool = False, +) -> torch.Tensor: + """Run a forward pass with the Transolver-expected input keys.""" + if use_time_embeddings: + return model( + fx=batch["fx"], embedding=batch["embedding"], time=batch["time"] + ) + return model(fx=batch["fx"], embedding=batch["embedding"]) + + +def loss_inputs(batch: Dict[str, Any]) -> Dict[str, Any]: + """Assemble the dict of optional/physics inputs consumed by ``compute_losses``. + + ``coordinates_unnormalized`` falls back to the model's ``embedding`` tensor + when the dataset did not pre-stash the raw coordinates. + """ + inputs: Dict[str, Any] = {} + if batch.get("padding_mask") is not None: + inputs["padding_mask"] = batch["padding_mask"] + if "material_labels" in batch: + inputs["material_labels"] = batch["material_labels"] + physics_keys = ("cell_areas", "sigma_t", "sigma_s", "sim_time") + if all(k in batch for k in physics_keys): + inputs["coordinates_unnormalized"] = batch.get( + "coordinates_unnormalized", batch["embedding"] + ) + for k in physics_keys: + inputs[k] = batch[k] + for k in ("metadata", "flux_normalization_stats"): + if k in batch: + inputs[k] = batch[k] + return inputs + + +# ========================================================================= +# Per-epoch train / validate (Transolver-specialized) +# ========================================================================= + + +def _log_minibatch( + launch_logger: LaunchLogger, + loss: torch.Tensor, + loss_mse: torch.Tensor, + loss_qoi: Optional[torch.Tensor], + qoi_details: Dict[str, float], + scale: float, +) -> None: + metrics = {"loss": loss.item() * scale, "loss_mse": loss_mse.item()} + if loss_qoi is not None: + metrics["loss_qoi"] = loss_qoi.item() + metrics.update(qoi_details) + launch_logger.log_minibatch(metrics) + + +def train_epoch( + dataloader: DataLoader, + model: nn.Module, + optimizer: torch.optim.Optimizer, + scaler: GradScaler, + device: torch.device, + launch_logger: LaunchLogger, + *, + loss_cfg: Dict[str, Any], + case_type: str, + use_time_embeddings: bool, + gradient_accumulation_steps: int = 1, + use_amp: bool = True, + amp_dtype: Optional[torch.dtype] = None, +) -> None: + """Run one Transolver training epoch.""" + model.train() + optimizer.zero_grad() + + for i, batch in enumerate(dataloader): + batch = to_device(batch, device) + + with autocast(enabled=use_amp, device_type=device.type, dtype=amp_dtype): + prediction = forward( + model, batch, use_time_embeddings=use_time_embeddings + ) + + # Transolver predicts absolute flux directly — no reconstruction step. + pred, target = prediction, batch["flux_target"] + + loss, loss_mse, loss_qoi, qoi_details = compute_losses( + pred=pred.float(), + target=target.float(), + loss_inputs=loss_inputs(batch), + loss_cfg=loss_cfg, + case_type=case_type, + device=device, + ) + + _log_minibatch( + launch_logger, + loss, + loss_mse, + loss_qoi, + qoi_details, + scale=gradient_accumulation_steps, + ) + + grad_step( + loss, + scaler, + optimizer, + model, + step_idx=i, + accum_steps=gradient_accumulation_steps, + ) + + flush_partial_accumulation( + scaler, + optimizer, + model, + total_steps=len(dataloader), + accum_steps=gradient_accumulation_steps, + ) + + +@torch.no_grad() +def validate( + dataloader: DataLoader, + model: nn.Module, + device: torch.device, + launch_logger: LaunchLogger, + *, + loss_cfg: Dict[str, Any], + case_type: str, + use_time_embeddings: bool, + use_amp: bool = True, + amp_dtype: Optional[torch.dtype] = None, +) -> Tuple[float, int]: + """Run one Transolver validation pass and return ``(loss_sum, num_batches)``.""" + model.eval() + + loss_sum = 0.0 + num_batches = 0 + + for batch in dataloader: + batch = to_device(batch, device) + + with autocast(enabled=use_amp, device_type=device.type, dtype=amp_dtype): + prediction = forward( + model, batch, use_time_embeddings=use_time_embeddings + ) + + pred, target = prediction, batch["flux_target"] + + loss, loss_mse, loss_qoi, qoi_details = compute_losses( + pred=pred.float(), + target=target.float(), + loss_inputs=loss_inputs(batch), + loss_cfg=loss_cfg, + case_type=case_type, + device=device, + ) + + _log_minibatch(launch_logger, loss, loss_mse, loss_qoi, qoi_details, scale=1) + + loss_sum += loss.item() + num_batches += 1 + + return loss_sum, num_batches + + +# ========================================================================= +# AMP helper +# ========================================================================= + + +def _parse_amp(cfg: DictConfig) -> Tuple[bool, Optional[torch.dtype], str]: + """Read ``cfg.train.amp`` and ``cfg.train.amp_dtype`` into (use_amp, dtype, label).""" + use_amp = cfg.train.get("amp", True) + dtype_str = str(cfg.train.get("amp_dtype", "bf16")).lower() + if dtype_str in ("bf16", "bfloat16"): + return use_amp, torch.bfloat16, dtype_str + if dtype_str in ("fp16", "float16"): + return use_amp, torch.float16, dtype_str + return use_amp, None, dtype_str + + +# ========================================================================= +# Hydra main +# ========================================================================= + + +@hydra.main(version_base="1.3", config_path="conf", config_name="config") +def main(cfg: DictConfig) -> None: + """Train the Transolver RTE surrogate.""" + # --- environment, seed, AMP --- + dist, logger = setup_training_environment(cfg, "Transolver") + + seed = cfg.train.get("seed", None) + if seed is not None: + effective_seed = seed + dist.rank if dist.distributed else seed + deterministic = cfg.train.get("deterministic", False) + set_seed(effective_seed, deterministic=deterministic) + if dist.rank == 0: + logger.info( + f"Random seed: {seed}" + + (" (deterministic mode)" if deterministic else "") + ) + elif dist.rank == 0: + logger.info("Random seed: not set (non-reproducible)") + + grad_accum_steps = cfg.train.get("gradient_accumulation_steps", 1) + use_amp, amp_dtype, amp_dtype_label = _parse_amp(cfg) + + amp_info = "ENABLED" if use_amp else "DISABLED" + if use_amp: + amp_info += f" (dtype={amp_dtype_label})" + log_effective_batch_size( + cfg, + dist, + logger, + grad_accum_steps, + extra_info={"AMP (mixed precision)": amp_info}, + ) + + # --- dataloaders --- + train_loader, val_loader, train_sampler = build_dataloaders_for_training( + cfg, dist, logger + ) + + # --- model --- + if dist.rank == 0: + logger.info("\nInitializing Transolver model...") + model = build_model(cfg, dist.device) + if dist.rank == 0: + num_params = sum(p.numel() for p in model.parameters() if p.requires_grad) + logger.info(f"Transolver initialized — {num_params:,} trainable parameters") + model = wrap_ddp(model, dist, logger) + + # --- training components --- + use_tensorboard = cfg.train.get("tensorboard", True) + optimizer, scheduler, scaler, writer, checkpoint_dir, best_val_losses = ( + create_training_components( + cfg, model, dist, logger, tensorboard=use_tensorboard + ) + ) + + # --- loss config (case-specific physics weight comes via Hydra interpolation: + # ``training/base.yaml::physics_loss.weight: ${case.physics_loss_weight}``, + # replacing the deleted ``_apply_case_weight_override`` function) --- + loss_cfg = parse_loss_config(cfg, dist, logger) + + loss_metric = cfg.train.get("loss_metric", "mse") + loss_cfg["loss_metric"] = loss_metric + if dist.rank == 0: + logger.info(f"Loss metric: {loss_metric}") + + # --- physics-loss warmup state --- + use_physics_loss = loss_cfg["use_physics_loss"] + physics_loss_weight_base = loss_cfg["physics_loss_weight"] + physics_loss_warmup_epochs = 0 + physics_loss_warmup_start = 0.0 + if use_physics_loss: + physics_loss_warmup_epochs = cfg.train.physics_loss.get("warmup_epochs", 0) + physics_loss_warmup_start = cfg.train.physics_loss.get( + "warmup_start_fraction", 0.0 + ) + if dist.rank == 0 and physics_loss_warmup_epochs > 0: + logger.info( + f" Physics-loss warmup epochs: {physics_loss_warmup_epochs}" + ) + logger.info( + f" Physics-loss warmup start fraction: {physics_loss_warmup_start}" + ) + + # --- resume / pretrain --- + start_epoch, resumed_val_losses, best_qoi_loss = resume_or_pretrain( + cfg, model, optimizer, scheduler, scaler, dist, logger + ) + if resumed_val_losses: + best_val_losses = resumed_val_losses + + # --- forward kwargs --- + use_time_embeddings = bool(cfg.model.get("time_input", False)) + + # --- per-epoch hooks --- + shared_kwargs = { + "loss_cfg": loss_cfg, + "case_type": cfg.case.type, + "use_time_embeddings": use_time_embeddings, + "use_amp": use_amp, + "amp_dtype": amp_dtype, + } + train_epoch_kwargs = { + **shared_kwargs, + "gradient_accumulation_steps": grad_accum_steps, + } + validate_kwargs = dict(shared_kwargs) + + def before_epoch_fn(epoch: int): + """Per-epoch physics-loss-weight ramp-up. + + Linearly ramps ``physics_loss_weight`` from + ``warmup_start_fraction * base`` to ``base`` over the first + ``warmup_epochs``. After warmup, the weight stays at ``base``. + + Both train and val use the same epoch-specific weight so that the + validation ``loss_qoi`` value is comparable across epochs. + """ + if not use_physics_loss or physics_loss_warmup_epochs <= 0: + return {}, {} + if epoch >= physics_loss_warmup_epochs: + current_weight = physics_loss_weight_base + else: + progress = epoch / max(1, physics_loss_warmup_epochs) + current_weight = ( + physics_loss_warmup_start + + (1.0 - physics_loss_warmup_start) * progress + ) * physics_loss_weight_base + if dist.rank == 0 and epoch < physics_loss_warmup_epochs: + logger.info( + f"Physics loss warmup: epoch {epoch}, " + f"weight={current_weight:.6f} (target={physics_loss_weight_base})" + ) + epoch_loss_cfg = {**loss_cfg, "physics_loss_weight": current_weight} + return {"loss_cfg": epoch_loss_cfg}, {"loss_cfg": epoch_loss_cfg} + + def after_epoch_fn(epoch, train_log, val_log, val_loss, current_lr): + train_loss = train_log.epoch_losses.get("loss", 0.0) + log_msg = f"Epoch {epoch}: train_loss={train_loss:.4e}, val_loss={val_loss:.4e}" + log_keys = ["loss_mse", "loss_qoi"] + for key in sorted(train_log.epoch_losses.keys()): + if key.startswith("loss_qoi_") and key not in log_keys: + log_keys.append(key) + for key in log_keys: + t = train_log.epoch_losses.get(key) + if t is not None: + log_msg += f", train_{key.replace('loss_', '')}={t:.4e}" + v = val_log.epoch_losses.get(key) + if v is not None: + log_msg += f", val_{key.replace('loss_', '')}={v:.4e}" + log_msg += f", lr={current_lr:.2e}" + logger.info(log_msg) + + # --- dispatch to shared training loop --- + if dist.rank == 0: + logger.info("\n" + "=" * 70) + logger.info("Starting training...") + logger.info("=" * 70) + + run_training_loop( + cfg=cfg, + dist=dist, + model=model, + train_loader=train_loader, + val_loader=val_loader, + train_sampler=train_sampler, + optimizer=optimizer, + scheduler=scheduler, + scaler=scaler, + train_epoch_fn=train_epoch, + validate_fn=validate, + train_epoch_kwargs=train_epoch_kwargs, + validate_kwargs=validate_kwargs, + logger=logger, + checkpoint_dir=checkpoint_dir, + writer=writer, + best_val_losses=best_val_losses, + start_epoch=start_epoch, + case_type=cfg.case.type, + before_epoch_fn=before_epoch_fn, + after_epoch_fn=after_epoch_fn, + best_qoi_loss=best_qoi_loss, + ) + + +if __name__ == "__main__": + main() diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/trainer.py b/examples/cfd/nuclear_engineering/radiation_transport/src/trainer.py new file mode 100644 index 0000000000..13969ebd14 --- /dev/null +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/trainer.py @@ -0,0 +1,700 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. +# SPDX-FileCopyrightText: All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Training loop, epoch step, DDP primitives, and environment setup. + +Consolidates the per-batch loss composition + gradient-accumulation step, +the epoch-driven training loop with checkpointing, and the DDP / environment +boilerplate that sits beside them: + +* DDP primitives — ``set_seed``, ``setup_training_environment``, ``wrap_ddp``, + ``log_effective_batch_size``, ``synchronize_output_directory``, + ``cleanup_sync_marker``, ``aggregate_validation_loss``. +* Per-step / per-epoch helpers — ``compute_losses``, ``grad_step``, + ``flush_partial_accumulation``. +* Training loop — ``run_training_loop``. + +Optimizer / scheduler construction and checkpoint save/load live in +``checkpointing.py`` and ``losses.py`` respectively. +""" + +import hashlib +import logging +import os +import random +import time +from pathlib import Path +from typing import Any, Callable, Dict, List, Mapping, Optional, Tuple + +import numpy as np +import torch +import torch.distributed as torch_dist +import torch.nn as nn +from omegaconf import DictConfig, OmegaConf +from torch.amp import GradScaler, autocast +from torch.nn.parallel import DistributedDataParallel +from torch.utils.data import DataLoader + +from physicsnemo.distributed import DistributedManager +from physicsnemo.utils.checkpoint import save_checkpoint +from physicsnemo.utils.logging.launch import LaunchLogger + +from checkpointing import save_best_checkpoint, save_best_qoi_checkpoint +from losses import compute_physics_loss, masked_mse_loss, region_weighted_loss_fn + + +# ========================================================================= +# DDP primitives & environment setup +# ========================================================================= + +# Marker file to signal that output directory is ready +SYNC_MARKER_FILE = ".rank0_ready" + + +def _setup_logger(name: str, log_file: Optional[str] = None) -> logging.Logger: + """Create a console (and optional file) logger with a consistent format.""" + logger = logging.getLogger(name) + logger.setLevel(logging.INFO) + logger.propagate = False # prevent duplicate logs from Hydra + logger.handlers.clear() + + formatter = logging.Formatter( + "%(asctime)s - %(name)s - %(levelname)s - %(message)s" + ) + + console_handler = logging.StreamHandler() + console_handler.setFormatter(formatter) + logger.addHandler(console_handler) + + if log_file: + file_handler = logging.FileHandler(log_file) + file_handler.setFormatter(formatter) + logger.addHandler(file_handler) + + return logger + + +def set_seed(seed: int, deterministic: bool = False) -> None: + """Set random seed for reproducibility across all RNGs. + + Args: + seed: Random seed value. + deterministic: If True, use deterministic algorithms (slower). + """ + random.seed(seed) + np.random.seed(seed) + torch.manual_seed(seed) + torch.cuda.manual_seed(seed) + torch.cuda.manual_seed_all(seed) + + if deterministic: + torch.backends.cudnn.deterministic = True + torch.backends.cudnn.benchmark = False + else: + torch.backends.cudnn.benchmark = True + + +def synchronize_output_directory( + cfg: DictConfig, + dist: DistributedManager, + max_wait_seconds: int = 300, + poll_interval: float = 1.0, +) -> str: + """Synchronize the output directory across DDP ranks via a file marker. + + Hydra's timestamp-based output paths can otherwise produce one folder per + rank. Rank 0 creates the directory and writes a marker file containing the + path; other ranks busy-wait on the marker, then read the synchronized path + and update ``cfg.output`` in place. + + Args: + cfg: Hydra configuration with an ``output`` field. + dist: DistributedManager instance. + max_wait_seconds: Maximum time non-rank-0 processes will wait. + poll_interval: How often to check for the marker file. + + Returns: + The synchronized output directory path. + """ + if "output" not in cfg: + output_dir = os.path.join("outputs", "default") + OmegaConf.set_struct(cfg, False) + cfg.output = output_dir + OmegaConf.set_struct(cfg, True) + + output_dir = cfg.output + + if not dist.distributed: + os.makedirs(output_dir, exist_ok=True) + return output_dir + + # Use a shared marker location that all ranks can see + marker_dir = os.path.dirname(output_dir) + os.makedirs(marker_dir, exist_ok=True) + path_hash = hashlib.md5(output_dir.encode()).hexdigest()[:8] + marker_file = os.path.join(marker_dir, f".sync_{path_hash}") + + if dist.rank == 0: + print(f"[Rank 0] Creating output directory: {output_dir}") + os.makedirs(output_dir, exist_ok=True) + os.makedirs(os.path.join(output_dir, "checkpoints"), exist_ok=True) + + with open(marker_file, "w") as f: + f.write(output_dir) + + print(f"[Rank 0] Marker file created: {marker_file}") + + else: + print(f"[Rank {dist.rank}] Waiting for rank 0 to create output directory...") + + waited = 0.0 + while not os.path.exists(marker_file): + if waited >= max_wait_seconds: + raise TimeoutError( + f"[Rank {dist.rank}] Timeout waiting for rank 0 to create " + f"output directory. Waited {max_wait_seconds}s for marker " + f"file: {marker_file}" + ) + time.sleep(poll_interval) + waited += poll_interval + + if waited % 30 == 0: + print(f"[Rank {dist.rank}] Still waiting... ({waited:.0f}s)") + + # Small delay to ensure the file is fully written + time.sleep(0.5) + with open(marker_file, "r") as f: + synced_output_dir = f.read().strip() + + if synced_output_dir != output_dir: + print(f"[Rank {dist.rank}] Syncing to output: {synced_output_dir}") + OmegaConf.set_struct(cfg, False) + cfg.output = synced_output_dir + OmegaConf.set_struct(cfg, True) + output_dir = synced_output_dir + + print(f"[Rank {dist.rank}] Synchronized to output directory: {output_dir}") + + if torch_dist.is_initialized(): + torch_dist.barrier() + + return output_dir + + +def cleanup_sync_marker(output_dir: str) -> None: + """Remove the synchronization marker file at the end of training. + + Should be called by rank 0 only. + """ + marker_dir = os.path.dirname(output_dir) + path_hash = hashlib.md5(output_dir.encode()).hexdigest()[:8] + marker_file = os.path.join(marker_dir, f".sync_{path_hash}") + + if os.path.exists(marker_file): + try: + os.remove(marker_file) + except OSError: + pass + + +def aggregate_validation_loss( + loss_sum: float, + num_batches: int, + dist: DistributedManager, +) -> Tuple[float, int]: + """Aggregate validation loss across all DDP ranks. + + Sums the per-rank loss totals and batch counts then returns the global + mean. In single-GPU mode this reduces to ``loss_sum / num_batches``. + + Args: + loss_sum: Sum of losses on this rank. + num_batches: Number of batches processed on this rank. + dist: DistributedManager instance. + + Returns: + ``(global_mean_loss, global_num_batches)``. + """ + if not dist.distributed: + return loss_sum / max(num_batches, 1), num_batches + + loss_tensor = torch.tensor([loss_sum], dtype=torch.float64, device=dist.device) + torch_dist.all_reduce(loss_tensor, op=torch_dist.ReduceOp.SUM) + + count_tensor = torch.tensor([num_batches], dtype=torch.int64, device=dist.device) + torch_dist.all_reduce(count_tensor, op=torch_dist.ReduceOp.SUM) + + global_loss_sum = loss_tensor.item() + global_num_batches = count_tensor.item() + + return global_loss_sum / max(global_num_batches, 1), global_num_batches + + +def setup_training_environment( + cfg: DictConfig, + model_name: str, +) -> Tuple[DistributedManager, Any]: + """Initialize DDP, sync the output dir, build a logger, and log a banner. + + Args: + cfg: Hydra configuration. + model_name: Human-readable model name for logging (e.g. "Transolver"). + + Returns: + ``(dist, logger)``. + """ + DistributedManager.initialize() + dist = DistributedManager() + + synchronize_output_directory(cfg, dist) + + log_file = os.path.join(cfg.output, "train.log") if dist.rank == 0 else None + logger = _setup_logger(f"RTE_{model_name}", log_file) + + if dist.rank == 0: + logger.info("=" * 70) + logger.info(f"RTE {model_name} Training - {cfg.case.type.upper()}") + logger.info("=" * 70) + if dist.distributed: + logger.info(f"Distributed training: {dist.world_size} GPUs") + logger.info(f"\nConfiguration:\n{OmegaConf.to_yaml(cfg, sort_keys=True)}\n") + + return dist, logger + + +def wrap_ddp( + model: nn.Module, + dist: DistributedManager, + logger: Any, + find_unused_parameters: bool = False, +) -> nn.Module: + """Wrap ``model`` with DistributedDataParallel if running distributed. + + Returns the unwrapped model in single-GPU mode. + """ + if not dist.distributed: + return model + + ddps = torch.cuda.Stream() + with torch.cuda.stream(ddps): + model = DistributedDataParallel( + model, + device_ids=[dist.local_rank], + output_device=dist.device, + broadcast_buffers=dist.broadcast_buffers, + find_unused_parameters=find_unused_parameters, + ) + torch.cuda.current_stream().wait_stream(ddps) + + if dist.rank == 0: + fup = " (find_unused_parameters=True)" if find_unused_parameters else "" + logger.info(f"Using DistributedDataParallel with {dist.world_size} GPUs{fup}") + + return model + + +def log_effective_batch_size( + cfg: DictConfig, + dist: DistributedManager, + logger: Any, + grad_accum_steps: int, + extra_info: Optional[Dict[str, Any]] = None, +) -> None: + """Log device, batch size, gradient accumulation, and effective batch size.""" + if dist.rank != 0: + return + + logger.info(f"Device: {dist.device}") + logger.info(f"Batch size: {cfg.train.dataloader.batch_size}") + logger.info(f"Gradient accumulation steps: {grad_accum_steps}") + + if extra_info: + for key, value in extra_info.items(): + logger.info(f"{key}: {value}") + + world_mult = dist.world_size if dist.distributed else 1 + effective_batch = cfg.train.dataloader.batch_size * grad_accum_steps * world_mult + logger.info(f"Effective batch size: {effective_batch}") + + +# ========================================================================= +# Per-step / per-epoch helpers +# ========================================================================= + + +def compute_losses( + pred: torch.Tensor, + target: torch.Tensor, + loss_inputs: Mapping[str, Any], + loss_cfg: Mapping[str, Any], + case_type: str, + device: torch.device, +) -> Tuple[torch.Tensor, torch.Tensor, Optional[torch.Tensor], dict]: + """Compose the per-batch training loss. + + Args: + pred, target: ``(B, N, 1)`` tensors. + loss_inputs: presence-driven dispatch dict. Recognized keys: + - ``padding_mask`` ``(B, N)``: enables masked MSE. + - ``material_labels`` ``(B, N)`` or ``(B, N, 1)``: enables + region-weighted loss when ``loss_cfg['use_region_weighted_loss']``. + - ``coordinates_unnormalized`` ``(B, N, D)``, ``cell_areas`` + ``(B, N)``, ``sigma_t`` ``(B, N)``, ``sigma_s`` ``(B, N)``, + ``sim_time`` ``(B,)`` or ``(B, 1)``: required for physics loss. + - ``metadata``, ``flux_normalization_stats``: optional physics + context. + loss_cfg: ``use_region_weighted_loss``, ``region_weight_cfg``, + ``loss_metric`` ("mse"|"rmse"), ``use_physics_loss``, + ``physics_loss_weight``, ``physics_loss_mse_weight``, ``qoi_region``. + + Returns: + ``(loss, loss_mse, loss_qoi_or_None, qoi_details_dict)``. + """ + use_region_weighted = loss_cfg.get("use_region_weighted_loss", False) + loss_metric = loss_cfg.get("loss_metric", "mse") + + if use_region_weighted and "material_labels" in loss_inputs: + rw = loss_cfg.get("region_weight_cfg") or {} + loss_mse = region_weighted_loss_fn( + pred, + target, + material_labels=loss_inputs["material_labels"], + case_type=case_type, + loss_type=loss_metric, + padded_value=-10, + void_weight=rw.get("void_weight", 3.0), + material_weight=rw.get("material_weight", 1.0), + ) + else: + loss_mse = masked_mse_loss(pred, target, loss_inputs.get("padding_mask")) + if loss_metric == "rmse": + loss_mse = torch.sqrt(loss_mse) + + if not loss_cfg.get("use_physics_loss", False): + return loss_mse, loss_mse, None, {} + + with autocast(enabled=False, device_type=device.type): + loss_qoi, qoi_details = compute_physics_loss( + case_type=case_type, + predicted_flux=pred, + target_flux=target, + cell_centers=loss_inputs["coordinates_unnormalized"], + cell_areas=loss_inputs["cell_areas"], + sigma_t=loss_inputs["sigma_t"], + sigma_s=loss_inputs["sigma_s"], + sim_time=loss_inputs["sim_time"], + metadata=loss_inputs.get("metadata"), + flux_normalization_stats=loss_inputs.get("flux_normalization_stats"), + qoi_region=loss_cfg.get("qoi_region", "center"), + ) + + physics_w = loss_cfg.get("physics_loss_weight", 0.1) + mse_w = loss_cfg.get("physics_loss_mse_weight", 1.0) + loss = mse_w * loss_mse + physics_w * loss_qoi + return loss, loss_mse, loss_qoi, qoi_details + + +def _finalize_step( + scaler: GradScaler, + optimizer: torch.optim.Optimizer, + model: torch.nn.Module, + max_grad_norm: float, +) -> None: + scaler.unscale_(optimizer) + torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=max_grad_norm) + scaler.step(optimizer) + scaler.update() + optimizer.zero_grad(set_to_none=True) + + +def grad_step( + loss: torch.Tensor, + scaler: GradScaler, + optimizer: torch.optim.Optimizer, + model: torch.nn.Module, + *, + step_idx: int, + accum_steps: int, + max_grad_norm: float = 10.0, +) -> None: + """Scale, backward, and (on accumulation boundary) step + clip + zero_grad. + + Callers invoke this every batch with ``step_idx=i``. Trailing partial + accumulation windows are flushed by a separate call to + ``flush_partial_accumulation`` after the loop finishes. + """ + scaler.scale(loss / accum_steps).backward() + if (step_idx + 1) % accum_steps != 0: + return + _finalize_step(scaler, optimizer, model, max_grad_norm) + + +def flush_partial_accumulation( + scaler: GradScaler, + optimizer: torch.optim.Optimizer, + model: torch.nn.Module, + *, + total_steps: int, + accum_steps: int, + max_grad_norm: float = 10.0, +) -> None: + """Flush leftover gradients when ``total_steps % accum_steps != 0``.""" + if total_steps % accum_steps == 0: + return + _finalize_step(scaler, optimizer, model, max_grad_norm) + + +# ========================================================================= +# Training loop +# ========================================================================= + + +def run_training_loop( + cfg: DictConfig, + dist: Any, + model: torch.nn.Module, + train_loader: DataLoader, + val_loader: DataLoader, + train_sampler: Optional[Any], + optimizer: torch.optim.Optimizer, + scheduler: Any, + scaler: Any, + train_epoch_fn: Callable[..., None], + validate_fn: Callable[..., tuple], + train_epoch_kwargs: Dict[str, Any], + validate_kwargs: Dict[str, Any], + logger: Any, + checkpoint_dir: str, + writer: Optional[Any], + best_val_losses: List, + start_epoch: int, + case_type: str, + before_epoch_fn: Optional[Callable[[int], tuple]] = None, + after_epoch_fn: Optional[Callable[[int, Any, Any, float, float], None]] = None, + finally_fn: Optional[Callable[[], None]] = None, + best_qoi_loss: float = float("inf"), +) -> None: + """Run the main training loop: epochs, validation, checkpointing, logging. + + The caller owns model / dataloader / optimizer / scheduler / scaler + construction and supplies ``train_epoch_fn`` and ``validate_fn``. This + function drives the epoch loop, aggregates validation loss across DDP + ranks, steps the scheduler, saves checkpoints, and handles graceful + completion / interrupt. + + Args: + cfg: Hydra config (uses ``train.epochs``, ``train.checkpoint_interval``, + ``train.scheduler_type``). + dist: DistributedManager instance. + model: Model (possibly DDP-wrapped). + train_loader: Training DataLoader. + val_loader: Validation DataLoader. + train_sampler: DistributedSampler for training (or None). + optimizer: Optimizer. + scheduler: LR scheduler (e.g. CosineAnnealingLR or ReduceLROnPlateau). + scaler: GradScaler for AMP. + train_epoch_fn: ``(train_loader, model, optimizer, scaler, device, + launch_logger, **train_epoch_kwargs) -> None``. + validate_fn: ``(val_loader, model, device, launch_logger, + **validate_kwargs) -> (val_loss_sum, val_num_batches)``. + train_epoch_kwargs: kwargs passed to ``train_epoch_fn``. + validate_kwargs: kwargs passed to ``validate_fn``. + logger: Logger (rank 0). + checkpoint_dir: Directory for checkpoints. + writer: TensorBoard SummaryWriter (rank 0) or None. + best_val_losses: List of best validation losses (updated in place). + start_epoch: First epoch index to run. + case_type: Case type string for metadata (e.g. "lattice", "hohlraum"). + before_epoch_fn: Optional ``(epoch) -> (extra_train_kwargs, + extra_validate_kwargs)``. Used by callers that want to inject + per-epoch state (e.g. physics-loss warmup weights). + after_epoch_fn: Optional ``(epoch, train_log, val_log, val_loss, + current_lr) -> None`` for custom rank-0 logging. + finally_fn: Optional no-arg callback called at the start of the + ``finally`` block (e.g. memory diagnostics). + best_qoi_loss: Best QoI loss seen so far (lower is better). + """ + training_completed = False + try: + for epoch in range(start_epoch, cfg.train.epochs): + if train_sampler is not None: + train_sampler.set_epoch(epoch) + + train_kw = dict(train_epoch_kwargs) + val_kw = dict(validate_kwargs) + if before_epoch_fn is not None: + extra_train, extra_val = before_epoch_fn(epoch) + train_kw.update(extra_train) + val_kw.update(extra_val) + + with LaunchLogger( + "train", + epoch=epoch, + num_mini_batch=len(train_loader), + mini_batch_log_freq=10, + ) as train_log: + train_epoch_fn( + train_loader, + model, + optimizer, + scaler, + dist.device, + train_log, + **train_kw, + ) + + with LaunchLogger( + "val", epoch=epoch, num_mini_batch=len(val_loader) + ) as val_log: + val_loss_sum, val_num_batches = validate_fn( + val_loader, + model, + dist.device, + val_log, + **val_kw, + ) + + train_loss = train_log.epoch_losses.get("loss", 0.0) + val_loss, _ = aggregate_validation_loss(val_loss_sum, val_num_batches, dist) + + scheduler_type = cfg.train.get("scheduler_type", "cosine") + if scheduler_type == "plateau": + scheduler.step(val_loss) + else: + scheduler.step() + + if scheduler_type == "plateau": + current_lr = optimizer.param_groups[0]["lr"] + else: + current_lr = scheduler.get_last_lr()[0] + + if dist.rank == 0: + if after_epoch_fn is not None: + after_epoch_fn(epoch, train_log, val_log, val_loss, current_lr) + else: + logger.info( + f"Epoch {epoch}: train_loss={train_loss:.4e}, " + f"val_loss={val_loss:.4e}, lr={current_lr:.2e}" + ) + if writer: + writer.add_scalar("Loss/train", train_loss, epoch) + writer.add_scalar("Loss/val", val_loss, epoch) + writer.add_scalar("Learning_Rate", current_lr, epoch) + writer.flush() + + val_loss_qoi = val_log.epoch_losses.get("loss_qoi") + if val_loss_qoi is not None: + best_qoi_loss = save_best_qoi_checkpoint( + checkpoint_dir=Path(checkpoint_dir), + epoch=epoch, + qoi_error=val_loss_qoi, + best_qoi_error=best_qoi_loss, + save_checkpoint_fn=save_checkpoint, + logger=logger, + models=model, + optimizer=optimizer, + scheduler=scheduler, + scaler=scaler, + metadata={ + "best_val_losses": best_val_losses, + "best_qoi_loss": best_qoi_loss, + "train_loss": train_loss, + "val_loss": val_loss, + "val_loss_qoi": val_loss_qoi, + "case_type": case_type, + }, + ) + + if epoch % cfg.train.checkpoint_interval == 0: + save_checkpoint( + path=checkpoint_dir, + models=model, + optimizer=optimizer, + scheduler=scheduler, + scaler=scaler, + epoch=epoch, + metadata={ + "best_val_losses": best_val_losses, + "best_qoi_loss": best_qoi_loss, + "train_loss": train_loss, + "val_loss": val_loss, + "case_type": case_type, + }, + ) + logger.info(f" Saved checkpoint at epoch {epoch + 1}") + + best_val_losses[:] = save_best_checkpoint( + checkpoint_dir=Path(checkpoint_dir), + epoch=epoch, + val_loss=val_loss, + best_val_losses=best_val_losses, + save_checkpoint_fn=save_checkpoint, + logger=logger, + models=model, + optimizer=optimizer, + scheduler=scheduler, + scaler=scaler, + metadata={ + "best_val_losses": best_val_losses, + "best_qoi_loss": best_qoi_loss, + "train_loss": train_loss, + "val_loss": val_loss, + "case_type": case_type, + }, + ) + + if val_loss_qoi is not None and writer: + writer.add_scalar("Loss/val_qoi", val_loss_qoi, epoch) + + if dist.distributed: + torch_dist.barrier() + + training_completed = True + + except KeyboardInterrupt: + training_completed = False + if dist.rank == 0: + logger.info("\n" + "=" * 70) + logger.info("Training interrupted by user") + logger.info("=" * 70) + + finally: + if finally_fn is not None: + finally_fn() + if writer: + writer.close() + + if dist.rank == 0: + logger.info("\n" + "=" * 70) + logger.info("Training completed!") + if best_val_losses and isinstance(best_val_losses[0], (int, float)): + loss_strs = [f"{v:.6f}" for v in best_val_losses] + else: + loss_strs = [f"{loss:.6f}" for loss, _ in best_val_losses] + logger.info(f"Top validation losses: {loss_strs}") + if best_qoi_loss < float("inf"): + logger.info(f"Best QoI loss: {best_qoi_loss:.6e}") + logger.info(f"Checkpoints saved to: {checkpoint_dir}") + logger.info("=" * 70) + + if training_completed: + completion_marker = os.path.join(checkpoint_dir, ".training_complete") + with open(completion_marker, "w") as f: + f.write(f"completed_epochs={cfg.train.epochs}\n") + f.write(f"target_epochs={cfg.train.epochs}\n") + logger.info(f"Training complete marker written to: {completion_marker}") + + cleanup_sync_marker(cfg.output) diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/transforms.py b/examples/cfd/nuclear_engineering/radiation_transport/src/transforms.py new file mode 100644 index 0000000000..0cf9343d9e --- /dev/null +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/transforms.py @@ -0,0 +1,738 @@ +# SPDX-FileCopyrightText: Copyright (c) 2023 - 2026 NVIDIA CORPORATION & AFFILIATES. +# SPDX-FileCopyrightText: All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Transform framework + flux / coordinates / sampling / qoi transforms. + +This module consolidates the RTE transform framework with the concrete +preprocessing transforms used by the Transolver pipeline. It is intentionally +flat (no submodules) so the standalone example can be read top-to-bottom: + +* ``Transform`` and ``Compose`` are re-exports from PhysicsNeMo + (``physicsnemo.datapipes.transforms``); RTE transforms subclass ``Transform`` + and operate on ``tensordict.TensorDict`` instances. +* ``TRANSFORM_REGISTRY`` is a module-level dict mapping the string names used + by Hydra configs (``"RTEFluxLogClip"``, etc.) to the transform classes + defined here. Sibling modules (e.g. ``material.py``) register their own + transforms into the same dict. +* The ``@register(...)`` decorator from + ``physicsnemo.datapipes.registry`` populates the global PhysicsNeMo registry; + we apply it alongside the local registry for instantiation by either path. + +Material transforms live in the sibling ``material.py``. +""" + +from __future__ import annotations + +# ========================================================================= +# Imports +# ========================================================================= + +from pathlib import Path +from typing import Any, Dict, Mapping, Optional, Tuple, Type, Union +import warnings + +import numpy as np +import torch +import zarr +from physicsnemo.datapipes.registry import register +from physicsnemo.datapipes.transforms import Compose, Transform +from tensordict import NonTensorData, TensorDict + + +# ========================================================================= +# Framework: Transform base, Compose, TensorDict utilities, registry +# ========================================================================= +# +# ``Transform`` and ``Compose`` are imported above. RTE transforms subclass +# ``Transform`` and operate on ``TensorDict``. The TensorDict helpers below +# bridge numpy / torch / non-tensor (str, dict, None) values that flow through +# the pipeline. ``TRANSFORM_REGISTRY`` is the local string->class map; the +# ``@register(name)`` decorator additionally populates the PhysicsNeMo global +# registry so existing config-driven instantiation paths continue to work. + + +def td_from_dict(sample: Mapping[str, Any]) -> TensorDict: + """Wrap a heterogeneous sample dict into a zero-batch-size ``TensorDict``. + + ``numpy`` arrays and ``torch`` tensors become tensor entries. Any other + value (``None``, dict, str, Python scalar) is stored as ``NonTensorData``. + Use bracket access (``td["key"]``) on the result to retrieve the original + value; ``NonTensorData`` entries are transparently unwrapped. + """ + out = TensorDict({}, batch_size=[]) + for key, value in sample.items(): + if isinstance(value, torch.Tensor): + out[key] = value + elif isinstance(value, np.ndarray): + out[key] = torch.from_numpy(np.ascontiguousarray(value)) + else: + out.set_non_tensor(key, value) + return out + + +def td_get(data: TensorDict, key: str, default: Any = None) -> Any: + """``td[key]``-equivalent lookup with a default for missing keys. + + ``TensorDict.get`` returns the raw ``NonTensorData`` wrapper; bracket access + unwraps it but raises ``KeyError`` for missing keys. This helper combines + both semantics. + """ + if key in data: + return data[key] + return default + + +def to_numpy(value: Any) -> np.ndarray: + """Coerce a torch tensor / numpy array / array-like into a numpy array.""" + if isinstance(value, torch.Tensor): + return value.detach().cpu().numpy() + return np.asarray(value) + + +# Local registry: maps the string name used in configs to the transform class. +# Sibling modules (e.g. ``material.py``) extend this dict at import time. +TRANSFORM_REGISTRY: Dict[str, Type[Transform]] = {} + + +def _register_local(name: str): + """Combined decorator: register with PhysicsNeMo's global registry and + record the class in ``TRANSFORM_REGISTRY`` under the same name.""" + + pnm_register = register(name) + + def _decorator(cls): + TRANSFORM_REGISTRY[name] = cls + return pnm_register(cls) + + return _decorator + + +# ========================================================================= +# Flux +# ========================================================================= +# +# ``RTEFluxLogClip`` is the canonical pre-step that clamps flux and applies +# log10 before z-score normalization (the latter performed by +# ``physicsnemo.datapipes.transforms.Normalize``). ``FluxClipper`` and +# ``LogTransform`` are kept as small standalone utilities for notebooks. +# ``denormalize_flux`` inverts the full ``RTEFluxLogClip + Normalize`` chain +# for evaluation. + + +@_register_local("RTEFluxClipper") +class FluxClipper(Transform): + """Clip flux below a threshold.""" + + def __init__(self, threshold: float = 1e-8): + super().__init__() + self.threshold = threshold + + def __call__(self, data: TensorDict) -> TensorDict: + data["scalar_flux"] = torch.clamp(data["scalar_flux"], min=self.threshold) + return data + + def extra_repr(self) -> str: + return f"threshold={self.threshold}" + + +@_register_local("RTELogTransform") +class LogTransform(Transform): + """Log10 transformation for flux.""" + + def __init__(self, offset: float = 1e-8): + super().__init__() + self.offset = offset + + def __call__(self, data: TensorDict) -> TensorDict: + data["scalar_flux"] = torch.log10(data["scalar_flux"] + self.offset) + return data + + def extra_repr(self) -> str: + return f"offset={self.offset}" + + +def denormalize_flux( + normalized_flux: torch.Tensor, + stats: Dict[str, float], +) -> torch.Tensor: + """Invert the ``RTEFluxLogClip + Normalize`` chain for evaluation/inference. + + ``normalized_flux`` is the model output in z-score-of-log space; + ``stats`` is the ``flux_normalization_stats`` dict that ``RTEFluxLogClip`` + recorded on the sample. + """ + mean = stats["log_flux_mean"] + std = stats["log_flux_std"] + clip = stats["clip_threshold"] + log_flux = normalized_flux * std + mean + log_flux = torch.clamp(log_flux, min=-300, max=300) + flux = torch.pow(10.0, log_flux) - clip + return torch.clamp(flux, min=0.0) + + +@_register_local("RTEFluxLogClip") +class RTEFluxLogClip(Transform): + """Clip flux to a threshold, apply ``log10``, and record denorm stats. + + Input: + ``scalar_flux`` -- shape ``(T, N)`` or ``(N,)``, float tensor. + + Output: + ``scalar_flux`` -- same shape, ``log10(clamp(x, clip) + clip)``. + ``flux_normalization_stats`` -- non-tensor dict with ``log_flux_mean``, + ``log_flux_std``, ``clip_threshold`` for downstream denormalization. + + Args: + clip_threshold: minimum flux value before log. + log_flux_mean / log_flux_std: stats to record for denorm; if a + ``normalization_stats_file`` is provided these are read from it. + normalization_stats_file: optional path to the RTE flux stats YAML + (``load_flux_stats``). When provided, overrides the inline args + and validates ``clip_threshold`` against the file's value. + """ + + def __init__( + self, + clip_threshold: float = 1e-8, + log_flux_mean: Optional[float] = None, + log_flux_std: Optional[float] = None, + normalization_stats_file: Optional[Union[str, Path]] = None, + ) -> None: + super().__init__() + + if normalization_stats_file is not None: + # Imported lazily to avoid a circular import: ``dataset.py`` itself + # may pull symbols from this module via the flat-import shim. + from dataset import load_flux_stats + + stats = load_flux_stats(normalization_stats_file) + if abs(stats["clip_threshold"] - clip_threshold) > 1e-10: + raise ValueError( + f"Clip threshold mismatch: got {clip_threshold}, " + f"stats computed with {stats['clip_threshold']}" + ) + self.clip_threshold = float(stats["clip_threshold"]) + self.log_flux_mean = float(stats["log_flux_mean"]) + self.log_flux_std = float(stats["log_flux_std"]) + elif log_flux_mean is not None and log_flux_std is not None: + self.clip_threshold = float(clip_threshold) + self.log_flux_mean = float(log_flux_mean) + self.log_flux_std = float(log_flux_std) + else: + raise ValueError( + "Either normalization_stats_file or (log_flux_mean, log_flux_std) " + "must be provided." + ) + + def __call__(self, data: TensorDict) -> TensorDict: + flux = data["scalar_flux"] + clip = torch.tensor(self.clip_threshold, dtype=flux.dtype, device=flux.device) + flux = torch.clamp(flux, min=clip) + data["scalar_flux"] = torch.log10(flux + clip) + data.set_non_tensor( + "flux_normalization_stats", + { + "log_flux_mean": self.log_flux_mean, + "log_flux_std": self.log_flux_std, + "clip_threshold": self.clip_threshold, + }, + ) + return data + + def extra_repr(self) -> str: + return ( + f"clip_threshold={self.clip_threshold}, " + f"log_flux_mean={self.log_flux_mean:.4f}, " + f"log_flux_std={self.log_flux_std:.4f}" + ) + + +# ========================================================================= +# Coordinates (Fourier features) +# ========================================================================= +# +# The default coordinate-normalization chain is +# ``[RTEBackupCoords, Translate, Scale]`` (the latter two are stock PhysicsNeMo +# transforms). ``GLOBAL_DOMAIN_BOUNDS`` is the canonical per-case bbox table +# referenced from the config-build site and direct consumers. +# ``FourierFeatures`` has no PhysicsNeMo equivalent and stays custom. + + +GLOBAL_DOMAIN_BOUNDS = { + "lattice": { + "min": np.array([-3.5, -3.5, -0.01], dtype=np.float32), + "max": np.array([3.5, 3.5, 0.01], dtype=np.float32), + }, + "hohlraum": { + "min": np.array([-0.65, -0.65, -0.01], dtype=np.float32), + "max": np.array([0.65, 0.65, 0.01], dtype=np.float32), + }, +} + + +@_register_local("RTEBackupCoords") +class RTEBackupCoords(Transform): + """Clone ``coordinates`` into ``coordinates_unnormalized`` before Translate/Scale. + + Downstream consumers (e.g. graph construction or rasterization) read + ``coordinates_unnormalized`` for physical-space operations. Place this + transform immediately before + ``physicsnemo.datapipes.transforms.Translate`` + ``Scale`` in the + pipeline so the raw coords survive the normalization. + + Optionally also writes ``bbox_min`` / ``bbox_max`` tensors to the + TensorDict so downstream code that previously read them from the legacy + ``CoordinateNormalizer`` output still finds them. + """ + + def __init__( + self, + bbox_min: Optional[torch.Tensor] = None, + bbox_max: Optional[torch.Tensor] = None, + ) -> None: + super().__init__() + self.bbox_min = ( + None if bbox_min is None else torch.as_tensor(bbox_min, dtype=torch.float32) + ) + self.bbox_max = ( + None if bbox_max is None else torch.as_tensor(bbox_max, dtype=torch.float32) + ) + + def __call__(self, data: TensorDict) -> TensorDict: + data["coordinates_unnormalized"] = data["coordinates"].clone() + if self.bbox_min is not None: + data["bbox_min"] = self.bbox_min.clone() + if self.bbox_max is not None: + data["bbox_max"] = self.bbox_max.clone() + return data + + def extra_repr(self) -> str: + if self.bbox_min is None: + return "no bbox recorded" + return f"bbox_min={self.bbox_min.tolist()}, bbox_max={self.bbox_max.tolist()}" + + +@_register_local("RTEFourierFeatures") +class FourierFeatures(Transform): + """Sin/cos positional encoding features at multiple frequency scales.""" + + def __init__( + self, + num_frequencies: int = 3, + coord_dims: int = 2, + base_frequency: float = 1.0, + append_to_coordinates: bool = True, + ): + super().__init__() + self.num_frequencies = num_frequencies + self.coord_dims = coord_dims + self.base_frequency = base_frequency + self.append_to_coordinates = append_to_coordinates + self.frequency_multipliers = [ + 2**i * base_frequency for i in range(num_frequencies) + ] + + def get_output_dim(self) -> int: + return 2 * self.num_frequencies * self.coord_dims + + def __call__(self, data: TensorDict) -> TensorDict: + coords = data["coordinates"] + coords_subset = coords[:, : self.coord_dims].to(dtype=torch.float32) + + two_pi = 2.0 * np.pi + parts = [] + for freq_mult in self.frequency_multipliers: + angle = two_pi * float(freq_mult) * coords_subset + parts.append(torch.sin(angle)) + parts.append(torch.cos(angle)) + + fourier_features = torch.cat(parts, dim=-1).to(dtype=torch.float32) + data["fourier_features"] = fourier_features + + if self.append_to_coordinates: + data["coordinates"] = torch.cat( + [coords.to(dtype=torch.float32), fourier_features], dim=-1 + ) + return data + + def extra_repr(self) -> str: + return ( + f"num_frequencies={self.num_frequencies}, coord_dims={self.coord_dims}, " + f"base_frequency={self.base_frequency}, " + f"append_to_coordinates={self.append_to_coordinates}" + ) + + +@_register_local("RTEStandardScaler") +class StandardScaler(Transform): + """Z-score normalization for coordinates (utility, not used by default chain).""" + + def __init__( + self, mean: Optional[np.ndarray] = None, std: Optional[np.ndarray] = None + ): + super().__init__() + self.mean = mean + self.std = std + + def __call__(self, data: TensorDict) -> TensorDict: + coords = data["coordinates"] + if self.mean is not None: + mean_t = torch.as_tensor( + self.mean, dtype=coords.dtype, device=coords.device + ) + else: + mean_t = coords.mean(dim=0) + if self.std is not None: + std_t = torch.as_tensor(self.std, dtype=coords.dtype, device=coords.device) + else: + std_t = coords.std(dim=0) + std_t = torch.where(std_t < 1e-10, torch.ones_like(std_t), std_t) + + data["coordinates"] = (coords - mean_t) / std_t + data["coord_mean"] = mean_t + data["coord_std"] = std_t + return data + + +# ========================================================================= +# Sampling (spatial + temporal) +# ========================================================================= +# +# ``SpatialSampler`` randomly subsamples / pads point clouds to a target size. +# ``TemporalSampler`` and its subclasses define the prediction task structure +# (next-step vs. steady-state). + + +@_register_local("RTESpatialSampler") +class SpatialSampler(Transform): + """Sample spatial points from mesh. + + Supports random sampling, fixed N, and padding for variable mesh sizes. + """ + + def __init__( + self, + num_points: int, + pad_value: float = -100.0, + seed: Optional[int] = None, + ): + super().__init__() + self.num_points = num_points + self.pad_value = pad_value + self.seed = seed + self.rng = ( + np.random.default_rng(seed) if seed is not None else np.random.default_rng() + ) + + def __call__(self, data: TensorDict) -> TensorDict: + num_available = data["coordinates"].shape[0] + + if self.num_points == -1: + data.set_non_tensor("spatial_indices", None) + data.set_non_tensor("spatial_num_original", num_available) + return data + + needs_sampling = num_available > self.num_points + + if needs_sampling: + indices_np = self.rng.choice(num_available, self.num_points, replace=False) + else: + if num_available == self.num_points: + data.set_non_tensor("spatial_indices", None) + data.set_non_tensor("spatial_num_original", num_available) + return data + indices_np = np.arange(num_available) + + indices = torch.from_numpy(indices_np.astype(np.int64)) + + spatial_keys = [ + "coordinates", + "cell_areas", + "material_properties", + "physical_properties", + "geometric_features", + "sigma_t", + "sigma_s", + "sigma_a", + "Q", + ] + + for key in spatial_keys: + if key in data: + arr = data[key] + if arr is None: + continue + sampled = arr[indices] + if sampled.shape[0] < self.num_points: + sampled = self._pad_tensor(sampled, self.num_points) + data[key] = sampled + + if "scalar_flux" in data: + flux = data["scalar_flux"][:, indices] # (T, N_sampled) + if flux.shape[1] < self.num_points: + flux = self._pad_flux(flux, self.num_points) + data["scalar_flux"] = flux + + for flux_key in ("flux_input", "flux_target"): + if flux_key in data: + flux_1d = data[flux_key][indices] + if flux_1d.shape[0] < self.num_points: + flux_1d = self._pad_tensor(flux_1d, self.num_points) + data[flux_key] = flux_1d + + data["spatial_indices"] = indices + data.set_non_tensor("spatial_num_original", int(num_available)) + return data + + def _pad_tensor(self, tensor: torch.Tensor, target_size: int) -> torch.Tensor: + if tensor.shape[0] >= target_size: + return tensor[:target_size] + pad_shape = list(tensor.shape) + pad_shape[0] = target_size - tensor.shape[0] + padding = torch.full( + pad_shape, float(self.pad_value), dtype=tensor.dtype, device=tensor.device + ) + return torch.cat([tensor, padding], dim=0) + + def _pad_flux(self, flux: torch.Tensor, target_size: int) -> torch.Tensor: + if flux.shape[1] >= target_size: + return flux[:, :target_size] + pad_shape = (flux.shape[0], target_size - flux.shape[1]) + padding = torch.full(pad_shape, -10.0, dtype=flux.dtype, device=flux.device) + return torch.cat([flux, padding], dim=1) + + def extra_repr(self) -> str: + return f"num_points={self.num_points}" + + +class TemporalSampler(Transform): + """Base class for temporal sampling strategies. + + Subclasses implement different prediction tasks: + - NextStepSampler: Predict t+stride from t + - SteadyStateSampler: Predict t=T from t=0 + """ + + def select_time_indices( + self, num_timesteps: int, rng: np.random.Generator + ) -> Tuple[int, int]: + raise NotImplementedError + + def __call__(self, data: TensorDict) -> TensorDict: + """Apply temporal sampling; extracts input/target flux slices.""" + num_timesteps = data["scalar_flux"].shape[0] + + if "_timestep_idx" in data: + original_idx = int(data["_timestep_idx"]) + + metadata = td_get(data, "metadata", default={}) or {} + max_timestep = ( + metadata.get("max_timestep") if isinstance(metadata, dict) else None + ) + if max_timestep is not None: + selective_loading_used = (max_timestep + 1) != num_timesteps + else: + selective_loading_used = original_idx >= num_timesteps + + if selective_loading_used: + input_idx = 0 + target_idx = min(self.stride, num_timesteps - 1) + data.set_non_tensor("timestep_input", original_idx) + data.set_non_tensor("timestep_target", original_idx + self.stride) + else: + input_idx = original_idx + target_idx = min(original_idx + self.stride, num_timesteps - 1) + data.set_non_tensor("timestep_input", input_idx) + data.set_non_tensor("timestep_target", target_idx) + + del data["_timestep_idx"] + else: + rng = np.random.default_rng() + input_idx, target_idx = self.select_time_indices(num_timesteps, rng) + data.set_non_tensor("timestep_input", int(input_idx)) + data.set_non_tensor("timestep_target", int(target_idx)) + + flux_all = data["scalar_flux"] + data["flux_input"] = flux_all[input_idx].clone() + data["flux_target"] = flux_all[target_idx].clone() + return data + + +@_register_local("RTENextStepSampler") +class NextStepSampler(TemporalSampler): + """Sample for next-step prediction task. + + Selects random timestep t and predicts t+stride. + """ + + def __init__(self, stride: int = 1, seed: Optional[int] = None): + super().__init__() + self.stride = stride + self.seed = seed + self.rng = np.random.default_rng(seed) if seed is not None else None + + def select_time_indices( + self, num_timesteps: int, rng: np.random.Generator + ) -> Tuple[int, int]: + if self.rng is not None: + rng = self.rng + + max_start = num_timesteps - self.stride + if max_start <= 0: + warnings.warn( + "Not enough timesteps to sample for next-step prediction. " + "Using first and last timestep." + ) + return 0, num_timesteps - 1 + + input_idx = rng.integers(0, max_start) + target_idx = input_idx + self.stride + return input_idx, target_idx + + def extra_repr(self) -> str: + return f"stride={self.stride}" + + +@_register_local("RTESteadyStateSampler") +class SteadyStateSampler(TemporalSampler): + """Sample for steady state prediction task (t=0 input, t=T target).""" + + def __init__(self): + super().__init__() + + def select_time_indices( + self, num_timesteps: int, rng: np.random.Generator + ) -> Tuple[int, int]: + return 0, num_timesteps - 1 + + +# ========================================================================= +# QoI loader +# ========================================================================= +# +# Loads ground-truth QoI values for the target timestep from each sample's +# zarr ``global_metrics`` array. Lattice writes a single scalar; hohlraum +# writes the three regional cumulated fluxes plus a ``ground_truth_qoi`` +# alias pointing at the center value (used by the loss). + + +@_register_local("RTELoadGroundTruthQoI") +class LoadGroundTruthQoI(Transform): + """Load ground truth QoI for the target timestep from zarr global_metrics.""" + + def __init__(self, data_path: Union[str, Path]): + super().__init__() + self.data_path = Path(data_path) + + def __call__(self, data: TensorDict) -> TensorDict: + filename = td_get(data, "filename") + if filename is None: + raise ValueError( + "Sample is missing 'filename' field required for QoI loading" + ) + + timestep_target_idx = td_get(data, "timestep_target") + if timestep_target_idx is None: + raise ValueError(f"Sample from {filename} missing timestep_target field") + + zarr_path = self.data_path / filename + if not zarr_path.exists(): + raise FileNotFoundError(f"Zarr file not found: {zarr_path}") + + z = zarr.open(str(zarr_path), mode="r") + if "global_metrics" not in z: + raise KeyError(f"'global_metrics' not found in zarr file: {zarr_path}") + + global_metrics = np.array(z["global_metrics"], dtype=np.float32) + timesteps_full = np.array(z["timesteps"]) + + metadata = td_get(data, "metadata", default={}) or {} + case_type = metadata.get("case_type") if isinstance(metadata, dict) else None + if case_type is None: + raise ValueError("metadata.case_type is required for QoI computation") + + timestep_target_idx = int(timestep_target_idx) + if timestep_target_idx >= len(timesteps_full): + raise ValueError( + f"timestep_target index {timestep_target_idx} out of bounds for " + f"full timesteps array of length {len(timesteps_full)} in {filename}" + ) + + global_metrics_idx = timestep_target_idx + if global_metrics_idx >= global_metrics.shape[0]: + raise IndexError( + f"QoI index {global_metrics_idx} out of bounds for global_metrics " + f"shape {global_metrics.shape} in {filename}" + ) + + if case_type == "lattice": + data["ground_truth_qoi"] = torch.tensor( + float(global_metrics[global_metrics_idx, 1]), dtype=torch.float32 + ) + elif case_type == "hohlraum": + center = float(global_metrics[global_metrics_idx, 1]) + vertical = float(global_metrics[global_metrics_idx, 2]) + horizontal = float(global_metrics[global_metrics_idx, 3]) + data["ground_truth_qoi_cumulated_center"] = torch.tensor( + center, dtype=torch.float32 + ) + data["ground_truth_qoi_cumulated_vertical"] = torch.tensor( + vertical, dtype=torch.float32 + ) + data["ground_truth_qoi_cumulated_horizontal"] = torch.tensor( + horizontal, dtype=torch.float32 + ) + data["ground_truth_qoi"] = torch.tensor(center, dtype=torch.float32) + else: + raise ValueError(f"Unknown case type: {case_type}") + + return data + + def extra_repr(self) -> str: + return f"data_path={self.data_path}" + + +# ========================================================================= +# Public API +# ========================================================================= + +__all__ = [ + # Framework + "Transform", + "Compose", + "TRANSFORM_REGISTRY", + "td_from_dict", + "td_get", + "to_numpy", + "NonTensorData", + # Flux + "RTEFluxLogClip", + "FluxClipper", + "LogTransform", + "denormalize_flux", + # Coordinates + "GLOBAL_DOMAIN_BOUNDS", + "RTEBackupCoords", + "FourierFeatures", + "StandardScaler", + # Sampling + "SpatialSampler", + "TemporalSampler", + "NextStepSampler", + "SteadyStateSampler", + # QoI + "LoadGroundTruthQoI", +] From 5b7f762f44bd046daa1188e9f95ef8f0bf187e80 Mon Sep 17 00:00:00 2001 From: Carmelo Gonzales Date: Mon, 4 May 2026 08:18:27 -0700 Subject: [PATCH 02/68] feat: update eval plots --- .../radiation_transport/src/inference.py | 193 +++++++++++++++++- 1 file changed, 187 insertions(+), 6 deletions(-) diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/inference.py b/examples/cfd/nuclear_engineering/radiation_transport/src/inference.py index 372eb1f741..52ceda78bc 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/inference.py +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/inference.py @@ -40,6 +40,7 @@ matplotlib.use("Agg") import matplotlib.pyplot as plt +from matplotlib.colors import LogNorm import numpy as np import torch import torch.nn as nn @@ -413,6 +414,61 @@ def aggregate_qoi( return summary +def collect_qoi_series( + per_sample_qoi: list[Dict[str, Dict[str, float]]], +) -> Dict[str, Tuple[np.ndarray, np.ndarray]]: + """Collect per-component QoI arrays and add a total for multi-component QoIs.""" + component_names: list[str] = [] + for sample in per_sample_qoi: + for name in sample: + if name not in component_names: + component_names.append(name) + + series: Dict[str, Tuple[np.ndarray, np.ndarray]] = {} + for name in component_names: + target_vals = [] + pred_vals = [] + for sample in per_sample_qoi: + entry = sample.get(name) + if entry is None: + continue + target_vals.append(entry["ground_truth"]) + pred_vals.append(entry["predicted"]) + if target_vals: + series[name] = (np.array(target_vals), np.array(pred_vals)) + + if len(series) > 1: + totals_target = [] + totals_pred = [] + for sample in per_sample_qoi: + if not all(name in sample for name in series): + continue + totals_target.append(sum(sample[name]["ground_truth"] for name in series)) + totals_pred.append(sum(sample[name]["predicted"] for name in series)) + if totals_target: + series["total"] = (np.array(totals_target), np.array(totals_pred)) + + return series + + +def summarize_qoi_series( + target: np.ndarray, + prediction: np.ndarray, +) -> Dict[str, float]: + """Summarize absolute and relative QoI errors for one component.""" + abs_errs = np.abs(prediction - target) + rel_errs = abs_errs / (np.abs(target) + 1e-10) * 100.0 + return { + "num_samples": int(target.size), + "mae": float(np.mean(abs_errs)), + "rmse": float(np.sqrt(np.mean(abs_errs**2))), + "max_error": float(np.max(abs_errs)), + "mean_relative_error_pct": float(np.mean(rel_errs)), + "median_relative_error_pct": float(np.median(rel_errs)), + "max_relative_error_pct": float(np.max(rel_errs)), + } + + # ========================================================================= # Plots # ========================================================================= @@ -424,6 +480,7 @@ def plot_flux_panels( prediction: np.ndarray, output_path: Union[str, Path], *, + log_flux: bool = False, figsize: Tuple[int, int] = (16, 5), dpi: int = 150, ) -> Path: @@ -444,19 +501,50 @@ def plot_flux_panels( fig, axes = plt.subplots(1, 3, figsize=figsize, dpi=dpi) flux_vmin = min(target.min(), prediction.min()) flux_vmax = max(target.max(), prediction.max()) + flux_norm = None + if log_flux: + positive_flux = np.concatenate( + [target[target > 0.0], prediction[prediction > 0.0]] + ) + if positive_flux.size: + flux_vmin = float(positive_flux.min()) + flux_vmax = float(positive_flux.max()) + if flux_vmin == flux_vmax: + flux_vmax = flux_vmin * 1.01 + flux_norm = LogNorm(vmin=flux_vmin, vmax=flux_vmax) + else: + log_flux = False cmap_flux = plt.get_cmap("viridis") cmap_err = plt.get_cmap("hot") - for ax, label, vals, cmap, vmin, vmax in ( - (axes[0], "Target", target, cmap_flux, flux_vmin, flux_vmax), - (axes[1], "Prediction", prediction, cmap_flux, flux_vmin, flux_vmax), - (axes[2], "Absolute Error", error, cmap_err, 0.0, float(error.max())), + for ax, label, vals, cmap, vmin, vmax, norm in ( + (axes[0], "Target", target, cmap_flux, flux_vmin, flux_vmax, flux_norm), + ( + axes[1], + "Prediction", + prediction, + cmap_flux, + flux_vmin, + flux_vmax, + flux_norm, + ), + (axes[2], "Absolute Error", error, cmap_err, 0.0, float(error.max()), None), ): - sc = ax.scatter(x, y, c=vals, cmap=cmap, vmin=vmin, vmax=vmax, s=1) + plot_vals = np.clip(vals, flux_vmin, None) if norm is not None else vals + sc = ax.scatter( + x, + y, + c=plot_vals, + cmap=cmap, + vmin=None if norm is not None else vmin, + vmax=None if norm is not None else vmax, + norm=norm, + s=1, + ) ax.set_aspect("equal") ax.set_xlim(xlim) ax.set_ylim(ylim) - ax.set_title(label) + ax.set_title(f"{label} (log)" if norm is not None else label) plt.colorbar(sc, ax=ax) plt.tight_layout() @@ -529,6 +617,90 @@ def plot_error_histogram( return output_path +def _subplot_grid(num_panels: int) -> Tuple[int, int]: + """Choose a compact subplot grid for QoI component plots.""" + ncols = min(num_panels, 3) + nrows = int(np.ceil(num_panels / ncols)) + return nrows, ncols + + +def plot_qoi_true_vs_pred( + qoi_series: Dict[str, Tuple[np.ndarray, np.ndarray]], + output_path: Union[str, Path], + *, + dpi: int = 150, +) -> Path: + """Scatter predicted vs ground-truth QoI values for each component.""" + output_path = Path(output_path) + output_path.parent.mkdir(parents=True, exist_ok=True) + + items = list(qoi_series.items()) + nrows, ncols = _subplot_grid(len(items)) + fig, axes = plt.subplots( + nrows, ncols, figsize=(5 * ncols, 4.5 * nrows), dpi=dpi, squeeze=False + ) + + for ax, (name, (target, prediction)) in zip(axes.flat, items): + lo = float(min(target.min(), prediction.min())) + hi = float(max(target.max(), prediction.max())) + if lo == hi: + pad = max(abs(lo) * 0.05, 1e-12) + lo -= pad + hi += pad + + ax.scatter(target, prediction, s=18, alpha=0.75) + ax.plot([lo, hi], [lo, hi], "r--", linewidth=1.0, label="y = x") + ax.set_title(name) + ax.set_xlabel("Ground truth QoI") + ax.set_ylabel("Predicted QoI") + ax.set_aspect("equal") + ax.legend(loc="best") + + for ax in axes.flat[len(items) :]: + ax.axis("off") + + fig.suptitle("QoI predicted vs. ground truth") + plt.tight_layout() + plt.savefig(output_path, dpi=dpi, bbox_inches="tight") + plt.close(fig) + return output_path + + +def plot_qoi_error_histograms( + qoi_series: Dict[str, Tuple[np.ndarray, np.ndarray]], + output_path: Union[str, Path], + *, + bins: int = 40, + dpi: int = 150, +) -> Path: + """Plot absolute QoI error histograms for each component.""" + output_path = Path(output_path) + output_path.parent.mkdir(parents=True, exist_ok=True) + + items = list(qoi_series.items()) + nrows, ncols = _subplot_grid(len(items)) + fig, axes = plt.subplots( + nrows, ncols, figsize=(5 * ncols, 4.0 * nrows), dpi=dpi, squeeze=False + ) + + for ax, (name, (target, prediction)) in zip(axes.flat, items): + errors = np.abs(prediction - target) + ax.hist(errors, bins=bins, color="C0", edgecolor="black", linewidth=0.3) + ax.set_yscale("log") + ax.set_title(f"{name} error") + ax.set_xlabel("|prediction - target|") + ax.set_ylabel("Count (log)") + + for ax in axes.flat[len(items) :]: + ax.axis("off") + + fig.suptitle("QoI absolute error histograms") + plt.tight_layout() + plt.savefig(output_path, dpi=dpi, bbox_inches="tight") + plt.close(fig) + return output_path + + # ========================================================================= # Main inference loop # ========================================================================= @@ -794,6 +966,7 @@ def main(): target, pred, figures_dir / f"flux_panels_{idx:04d}.png", + log_flux=args.case_type == "lattice", ) if not per_sample_metrics: @@ -819,8 +992,16 @@ def main(): # QoI summary. if per_sample_qoi: qoi_summary = aggregate_qoi(per_sample_qoi) + qoi_series = collect_qoi_series(per_sample_qoi) + if "total" in qoi_series: + total_target, total_prediction = qoi_series["total"] + qoi_summary["total"] = summarize_qoi_series( + total_target, total_prediction + ) with open(output_dir / "qoi_metrics.yaml", "w") as f: yaml.safe_dump(qoi_summary, f, sort_keys=False) + plot_qoi_true_vs_pred(qoi_series, figures_dir / "qoi_true_vs_pred.png") + plot_qoi_error_histograms(qoi_series, figures_dir / "qoi_error_histogram.png") print("\nQoI summary:") for region, stats in qoi_summary.items(): print( From fffa6d10eba83bba7dd1758e4584367449a0c0ec Mon Sep 17 00:00:00 2001 From: Carmelo Gonzales Date: Mon, 4 May 2026 08:18:55 -0700 Subject: [PATCH 03/68] feat: update checkpointing logic --- .../radiation_transport/src/trainer.py | 31 ++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/trainer.py b/examples/cfd/nuclear_engineering/radiation_transport/src/trainer.py index 13969ebd14..0bd878b637 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/trainer.py +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/trainer.py @@ -52,7 +52,11 @@ from physicsnemo.utils.checkpoint import save_checkpoint from physicsnemo.utils.logging.launch import LaunchLogger -from checkpointing import save_best_checkpoint, save_best_qoi_checkpoint +from checkpointing import ( + save_best_checkpoint, + save_best_qoi_checkpoint, + save_latest_checkpoint, +) from losses import compute_physics_loss, masked_mse_loss, region_weighted_loss_fn @@ -656,6 +660,31 @@ def run_training_loop( }, ) + latest_checkpoint_interval = cfg.train.get( + "latest_checkpoint_interval", 1 + ) + if latest_checkpoint_interval > 0 and ( + epoch % latest_checkpoint_interval == 0 + ): + save_latest_checkpoint( + checkpoint_dir=Path(checkpoint_dir), + epoch=epoch, + save_checkpoint_fn=save_checkpoint, + logger=logger, + models=model, + optimizer=optimizer, + scheduler=scheduler, + scaler=scaler, + metadata={ + "best_val_losses": best_val_losses, + "best_qoi_loss": best_qoi_loss, + "train_loss": train_loss, + "val_loss": val_loss, + "val_loss_qoi": val_loss_qoi, + "case_type": case_type, + }, + ) + if val_loss_qoi is not None and writer: writer.add_scalar("Loss/val_qoi", val_loss_qoi, epoch) From 9b17987c35d9259990e0bf3dd40961cfc4b87145 Mon Sep 17 00:00:00 2001 From: Carmelo Gonzales Date: Mon, 4 May 2026 08:24:47 -0700 Subject: [PATCH 04/68] feat: checkpointing updates --- .../radiation_transport/src/conf/train/base.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/conf/train/base.yaml b/examples/cfd/nuclear_engineering/radiation_transport/src/conf/train/base.yaml index 262da163aa..a1906387c0 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/conf/train/base.yaml +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/conf/train/base.yaml @@ -4,6 +4,7 @@ epochs: 501 checkpoint_interval: 100 +latest_checkpoint_interval: null gradient_accumulation_steps: 1 seed: 6 deterministic: false @@ -12,7 +13,6 @@ amp_dtype: bf16 # bf16 | fp16 loss_metric: mse tensorboard: true -# Optimizer (Muon is opt-in; install via `pip install emerging-optimizers`). optimizer: type: adam # adam | muon weight_decay: 0.0 @@ -36,7 +36,7 @@ physics_loss: warmup_epochs: 0 warmup_start_fraction: 0.0 -# Region-weighted MSE (heavier penalty on void points). +# Region-weighted MSE (heavier penalty on low absorption (void) points). use_region_weighted_loss: true region_weights: void_weight: 10.0 From 10fd6636e9f97ebf83f3a124d42d67cc553d5f91 Mon Sep 17 00:00:00 2001 From: Carmelo Gonzales Date: Mon, 4 May 2026 08:28:36 -0700 Subject: [PATCH 05/68] feat: checkpointing updates --- .../radiation_transport/src/checkpointing.py | 145 +++++++++++++++--- 1 file changed, 127 insertions(+), 18 deletions(-) diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/checkpointing.py b/examples/cfd/nuclear_engineering/radiation_transport/src/checkpointing.py index bce5078bcc..2d6936076c 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/checkpointing.py +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/checkpointing.py @@ -224,9 +224,26 @@ def state_dict(self) -> dict: def load_state_dict(self, state_dict: dict) -> None: """Load combined state dict.""" - for opt, opt_state in zip(self.optimizers, state_dict["optimizers"]): + if "optimizers" not in state_dict: + raise KeyError( + "Expected CombinedOptimizer state_dict to contain 'optimizers', " + f"got keys: {list(state_dict.keys())}" + ) + + optimizer_states = state_dict["optimizers"] + if len(optimizer_states) != len(self.optimizers): + raise ValueError( + f"State dict contains {len(optimizer_states)} optimizer(s), " + f"but this CombinedOptimizer has {len(self.optimizers)} optimizer(s)." + ) + + for opt, opt_state in zip(self.optimizers, optimizer_states): opt.load_state_dict(opt_state) + self.param_groups = [] + for opt in self.optimizers: + self.param_groups.extend(opt.param_groups) + # ========================================================================= # Save / load checkpoints @@ -241,6 +258,62 @@ def load_state_dict(self, state_dict: dict) -> None: # Folder name for the best model by QoI relative error. BEST_QOI_MODEL_DIR = "best_qoi_model" +# Folder name for the latest full training-state checkpoint. +LATEST_CHECKPOINT_DIR = "latest_checkpoint" + + +def _checkpoint_kwargs_with_metadata( + checkpoint_kwargs: Dict[str, Any], **metadata_updates +) -> Dict[str, Any]: + """Return checkpoint kwargs with selected metadata keys refreshed.""" + updated_kwargs = dict(checkpoint_kwargs) + metadata = dict(updated_kwargs.get("metadata") or {}) + metadata.update(metadata_updates) + updated_kwargs["metadata"] = metadata + return updated_kwargs + + +def save_latest_checkpoint( + checkpoint_dir: Path, + epoch: int, + save_checkpoint_fn, + logger: logging.Logger = None, + **checkpoint_kwargs, +) -> Path: + """Replace ``latest_checkpoint`` with the most recent training state. + + This checkpoint is meant for robust resume, not model selection. It is + overwritten at the caller's cadence, usually every epoch. + + Args: + checkpoint_dir: Directory containing checkpoint subdirectories. + epoch: Current epoch number. + save_checkpoint_fn: Function to save the checkpoint. + logger: Optional logger. + **checkpoint_kwargs: Additional arguments forwarded to ``save_checkpoint_fn``. + + Returns: + Path to the refreshed ``latest_checkpoint`` directory. + """ + checkpoint_dir = Path(checkpoint_dir) + latest_path = checkpoint_dir / LATEST_CHECKPOINT_DIR + tmp_path = checkpoint_dir / f".{LATEST_CHECKPOINT_DIR}.tmp" + + if tmp_path.exists(): + shutil.rmtree(tmp_path) + tmp_path.mkdir(parents=True, exist_ok=True) + + save_checkpoint_fn(path=str(tmp_path), epoch=epoch, **checkpoint_kwargs) + + if latest_path.exists(): + shutil.rmtree(latest_path) + tmp_path.rename(latest_path) + + if logger: + logger.info(f" Updated latest checkpoint (epoch {epoch})") + + return latest_path + def save_best_checkpoint( checkpoint_dir: Path, @@ -261,7 +334,7 @@ def save_best_checkpoint( epoch: Current epoch number. val_loss: Current validation loss. best_val_losses: List of ``(loss, epoch)`` tuples for best models - (will be modified in-place and returned). + (returned with any updates applied). save_checkpoint_fn: Function to call to save the checkpoint (e.g. PhysicsNeMo's ``save_checkpoint``). logger: Optional logger for log messages. @@ -276,6 +349,8 @@ def save_best_checkpoint( if best_val_losses and isinstance(best_val_losses[0], (int, float)): # Legacy format detected, reset to empty (can't recover epoch info). best_val_losses = [] + else: + best_val_losses = list(best_val_losses) # Check whether this is a top-N model. current_losses = [loss for loss, _ in best_val_losses] @@ -286,19 +361,26 @@ def save_best_checkpoint( if not is_top_n: return best_val_losses + updated_best_val_losses = best_val_losses + [(val_loss, epoch)] + updated_best_val_losses.sort(key=lambda x: x[0]) # Sort by loss. + + pruned_epochs = [] + while len(updated_best_val_losses) > MAX_BEST_CHECKPOINTS: + _worst_loss, worst_epoch = updated_best_val_losses.pop() + pruned_epochs.append(worst_epoch) + + checkpoint_kwargs = _checkpoint_kwargs_with_metadata( + checkpoint_kwargs, + best_val_losses=updated_best_val_losses, + ) + # Save new best model to epoch-specific directory. best_model_dir = checkpoint_dir / f"best_model_epoch_{epoch}" best_model_dir.mkdir(parents=True, exist_ok=True) save_checkpoint_fn(path=str(best_model_dir), epoch=epoch, **checkpoint_kwargs) - # Update best losses list with the new (loss, epoch) tuple. - best_val_losses.append((val_loss, epoch)) - best_val_losses.sort(key=lambda x: x[0]) # Sort by loss. - - # Cleanup if we have more than MAX_BEST_CHECKPOINTS. - while len(best_val_losses) > MAX_BEST_CHECKPOINTS: - worst_loss, worst_epoch = best_val_losses.pop() + for worst_epoch in pruned_epochs: cleanup_checkpoint_by_epoch(checkpoint_dir, worst_epoch, logger) # Update top_model folder if this is the new best. @@ -306,7 +388,7 @@ def save_best_checkpoint( checkpoint_dir, val_loss, epoch, - best_val_losses, + updated_best_val_losses, save_checkpoint_fn, logger, **checkpoint_kwargs, @@ -316,10 +398,10 @@ def save_best_checkpoint( logger.info( f" Saved top-{MAX_BEST_CHECKPOINTS} model! Val loss: {val_loss:.6f}" ) - loss_strs = [f"{loss:.6f}" for loss, _ in best_val_losses[:3]] + loss_strs = [f"{loss:.6f}" for loss, _ in updated_best_val_losses[:3]] logger.info(f" Top 3 losses: {loss_strs}") - return best_val_losses + return updated_best_val_losses def _update_top_model( @@ -400,6 +482,11 @@ def save_best_qoi_checkpoint( if qoi_model_path.exists(): shutil.rmtree(qoi_model_path) + checkpoint_kwargs = _checkpoint_kwargs_with_metadata( + checkpoint_kwargs, + best_qoi_loss=qoi_error, + ) + qoi_model_path.mkdir(parents=True, exist_ok=True) save_checkpoint_fn(path=str(qoi_model_path), epoch=epoch, **checkpoint_kwargs) @@ -542,13 +629,24 @@ def resume_or_pretrain( resume_checkpoint = cfg.train.get("resume_checkpoint", None) pretrain_checkpoint = cfg.train.get("pretrain_checkpoint", None) - if resume_checkpoint and os.path.exists(resume_checkpoint): + if resume_checkpoint: + resume_path = Path(str(resume_checkpoint)) + if not resume_path.exists(): + raise FileNotFoundError( + f"train.resume_checkpoint does not exist: {resume_path}" + ) + if not resume_path.is_dir(): + raise NotADirectoryError( + "train.resume_checkpoint must be a checkpoint directory, " + f"not a file: {resume_path}" + ) + if dist.rank == 0: - logger.info(f"\nResuming from checkpoint: {resume_checkpoint}") + logger.info(f"\nResuming from checkpoint: {resume_path}") metadata: Dict[str, Any] = {} start_epoch = load_checkpoint( - path=resume_checkpoint, + path=str(resume_path), models=model, optimizer=optimizer, scheduler=scheduler, @@ -575,14 +673,25 @@ def resume_or_pretrain( start_epoch += 1 - elif pretrain_checkpoint and os.path.exists(pretrain_checkpoint): + elif pretrain_checkpoint: + pretrain_path = Path(str(pretrain_checkpoint)) + if not pretrain_path.exists(): + raise FileNotFoundError( + f"train.pretrain_checkpoint does not exist: {pretrain_path}" + ) + if not pretrain_path.is_dir(): + raise NotADirectoryError( + "train.pretrain_checkpoint must be a checkpoint directory, " + f"not a file: {pretrain_path}" + ) + if dist.rank == 0: logger.info( - f"\nLoading pretrained weights for fine-tuning: {pretrain_checkpoint}" + f"\nLoading pretrained weights for fine-tuning: {pretrain_path}" ) load_checkpoint( - path=pretrain_checkpoint, + path=str(pretrain_path), models=model, device=dist.device, ) From 3ddd5d7690ae1dcf89267205ff14d15945c1853b Mon Sep 17 00:00:00 2001 From: Carmelo Gonzales Date: Mon, 4 May 2026 08:28:50 -0700 Subject: [PATCH 06/68] feat: checkpoint updates --- .../radiation_transport/src/conf/train/base.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/conf/train/base.yaml b/examples/cfd/nuclear_engineering/radiation_transport/src/conf/train/base.yaml index a1906387c0..303cf6b4a2 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/conf/train/base.yaml +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/conf/train/base.yaml @@ -4,7 +4,7 @@ epochs: 501 checkpoint_interval: 100 -latest_checkpoint_interval: null +latest_checkpoint_interval: null # 0/null disables rolling latest_checkpoint/ gradient_accumulation_steps: 1 seed: 6 deterministic: false From ba96ada41da69ca7fc5a4ee872825e92ca09a6f2 Mon Sep 17 00:00:00 2001 From: Carmelo Gonzales Date: Mon, 4 May 2026 08:29:25 -0700 Subject: [PATCH 07/68] feat: checkpoint logic --- .../radiation_transport/src/trainer.py | 67 ++++++++++++------- 1 file changed, 43 insertions(+), 24 deletions(-) diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/trainer.py b/examples/cfd/nuclear_engineering/radiation_transport/src/trainer.py index 0bd878b637..3af9e3d69d 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/trainer.py +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/trainer.py @@ -466,6 +466,19 @@ def flush_partial_accumulation( # ========================================================================= +def _coerce_optional_checkpoint_interval(value: Any) -> Optional[int]: + """Parse an optional checkpoint cadence; None/0 disables the feature.""" + if value is None: + return None + if isinstance(value, str) and value.strip().lower() in ("", "none", "null"): + return None + + interval = int(value) + if interval < 0: + raise ValueError("latest_checkpoint_interval must be >= 0 or null") + return interval + + def run_training_loop( cfg: DictConfig, dist: Any, @@ -600,6 +613,31 @@ def run_training_loop( writer.flush() val_loss_qoi = val_log.epoch_losses.get("loss_qoi") + metadata_best_qoi_loss = best_qoi_loss + if val_loss_qoi is not None: + metadata_best_qoi_loss = min(best_qoi_loss, val_loss_qoi) + + best_val_losses[:] = save_best_checkpoint( + checkpoint_dir=Path(checkpoint_dir), + epoch=epoch, + val_loss=val_loss, + best_val_losses=best_val_losses, + save_checkpoint_fn=save_checkpoint, + logger=logger, + models=model, + optimizer=optimizer, + scheduler=scheduler, + scaler=scaler, + metadata={ + "best_val_losses": best_val_losses, + "best_qoi_loss": metadata_best_qoi_loss, + "train_loss": train_loss, + "val_loss": val_loss, + "val_loss_qoi": val_loss_qoi, + "case_type": case_type, + }, + ) + if val_loss_qoi is not None: best_qoi_loss = save_best_qoi_checkpoint( checkpoint_dir=Path(checkpoint_dir), @@ -614,7 +652,7 @@ def run_training_loop( scaler=scaler, metadata={ "best_val_losses": best_val_losses, - "best_qoi_loss": best_qoi_loss, + "best_qoi_loss": metadata_best_qoi_loss, "train_loss": train_loss, "val_loss": val_loss, "val_loss_qoi": val_loss_qoi, @@ -635,35 +673,16 @@ def run_training_loop( "best_qoi_loss": best_qoi_loss, "train_loss": train_loss, "val_loss": val_loss, + "val_loss_qoi": val_loss_qoi, "case_type": case_type, }, ) logger.info(f" Saved checkpoint at epoch {epoch + 1}") - best_val_losses[:] = save_best_checkpoint( - checkpoint_dir=Path(checkpoint_dir), - epoch=epoch, - val_loss=val_loss, - best_val_losses=best_val_losses, - save_checkpoint_fn=save_checkpoint, - logger=logger, - models=model, - optimizer=optimizer, - scheduler=scheduler, - scaler=scaler, - metadata={ - "best_val_losses": best_val_losses, - "best_qoi_loss": best_qoi_loss, - "train_loss": train_loss, - "val_loss": val_loss, - "case_type": case_type, - }, - ) - - latest_checkpoint_interval = cfg.train.get( - "latest_checkpoint_interval", 1 + latest_checkpoint_interval = _coerce_optional_checkpoint_interval( + cfg.train.get("latest_checkpoint_interval", 1) ) - if latest_checkpoint_interval > 0 and ( + if latest_checkpoint_interval and ( epoch % latest_checkpoint_interval == 0 ): save_latest_checkpoint( From d16a8572c097c445181cd2978a7b7d03646af1be Mon Sep 17 00:00:00 2001 From: Carmelo Gonzales Date: Mon, 4 May 2026 10:51:56 -0700 Subject: [PATCH 08/68] feat: skip logging nan/inf checkpoints --- .../radiation_transport/src/checkpointing.py | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/checkpointing.py b/examples/cfd/nuclear_engineering/radiation_transport/src/checkpointing.py index 2d6936076c..ea0da00375 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/checkpointing.py +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/checkpointing.py @@ -28,6 +28,7 @@ """ import logging +import math import os import shutil from pathlib import Path @@ -345,6 +346,16 @@ def save_best_checkpoint( """ checkpoint_dir = Path(checkpoint_dir) + if not math.isfinite(float(val_loss)): + if logger: + logger.warning( + " Skipping best-checkpoint save for epoch %s: non-finite " + "val_loss=%s", + epoch, + val_loss, + ) + return list(best_val_losses) + # Handle legacy format: convert List[float] to List[Tuple[float, int]]. if best_val_losses and isinstance(best_val_losses[0], (int, float)): # Legacy format detected, reset to empty (can't recover epoch info). @@ -473,6 +484,16 @@ def save_best_qoi_checkpoint( Returns: Updated best QoI loss value. """ + if not math.isfinite(float(qoi_error)): + if logger: + logger.warning( + " Skipping best-QoI checkpoint save for epoch %s: non-finite " + "qoi_error=%s", + epoch, + qoi_error, + ) + return best_qoi_error + if qoi_error >= best_qoi_error: return best_qoi_error From 92ee2326de1719597d8f10a28840dbbcd98d35da Mon Sep 17 00:00:00 2001 From: Carmelo Gonzales Date: Mon, 4 May 2026 10:52:26 -0700 Subject: [PATCH 09/68] feat: requiring split file --- .../src/compute_normalizations.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/compute_normalizations.py b/examples/cfd/nuclear_engineering/radiation_transport/src/compute_normalizations.py index b7aebd9944..b43e511134 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/compute_normalizations.py +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/compute_normalizations.py @@ -27,6 +27,7 @@ python compute_normalizations.py \\ --data_path /lattice \\ --case_type lattice \\ + --split_file /splits/lattice_splits.json \\ --output_dir /stats The flux statistics walk the training split of the dataset, log-clip the raw @@ -94,8 +95,7 @@ def compute_flux_statistics( data_path: path to the zarr stores for one case. case_type: ``"lattice"`` or ``"hohlraum"``. output_file: destination YAML path. - split_file: optional split JSON; defaults to the dataset's internal - random split. + split_file: split JSON used to select the training split. clip_threshold: minimum flux value before ``log10``. steady_state: when ``True``, only use the first and last timesteps of each simulation. @@ -210,7 +210,8 @@ def compute_material_statistics( case_type: str, output_file: Path, flux_stats_file: Path, - split_file: Optional[Path] = None, + split_file: Path, + clip_threshold: float = 1e-8, num_spatial_points: int = 2048, seed: int = 42, ) -> Dict[str, Dict[str, float]]: @@ -223,8 +224,8 @@ def compute_material_statistics( flux_stats_file: path to the flux stats YAML produced by :func:`compute_flux_statistics`. Required because the transform pipeline runs ``RTEFluxLogClip`` first. - split_file: optional split JSON; defaults to the dataset's internal - random split. + split_file: split JSON used to select the training split. + clip_threshold: flux clip threshold used by the flux transform. num_spatial_points: number of points per simulation drawn by ``SpatialSampler``. seed: RNG seed for the temporal and spatial samplers. @@ -248,7 +249,7 @@ def compute_material_statistics( [ RTEFluxLogClip( normalization_stats_file=flux_stats_file, - clip_threshold=1e-8, + clip_threshold=clip_threshold, ), NextStepSampler(stride=1, seed=seed), MaterialPropertyExtractor(case_type=case_type), @@ -371,8 +372,8 @@ def _parse_args() -> argparse.Namespace: parser.add_argument( "--split_file", type=Path, - default=None, - help="Optional split JSON; defaults to the dataset's internal random split.", + required=True, + help="Required split JSON; statistics are computed on its training split.", ) parser.add_argument( "--clip_threshold", @@ -428,6 +429,7 @@ def main() -> int: output_file=material_output, flux_stats_file=flux_output, split_file=args.split_file, + clip_threshold=args.clip_threshold, num_spatial_points=args.num_spatial_points, seed=args.seed, ) From 055b80bbb1030c8d09c66287815aca07c81d29ab Mon Sep 17 00:00:00 2001 From: Carmelo Gonzales Date: Mon, 4 May 2026 10:53:24 -0700 Subject: [PATCH 10/68] feat: default to top_model for inference --- .../radiation_transport/src/inference.py | 43 +++++++------------ 1 file changed, 16 insertions(+), 27 deletions(-) diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/inference.py b/examples/cfd/nuclear_engineering/radiation_transport/src/inference.py index 52ceda78bc..145a319a19 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/inference.py +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/inference.py @@ -91,11 +91,10 @@ def load_hydra_config(checkpoint_dir: Union[str, Path]) -> DictConfig: def find_best_checkpoint(run_dir: Union[str, Path]) -> Path: - """Find the best checkpoint directory under a training run. + """Find the default checkpoint directory under a training run. - Prefers the ``top_model`` directory (single best). Falls back to scanning - ``best_model_epoch_*`` directories and returning the one with the lowest - recorded validation loss. + Explicit checkpoint directories are consumed by the caller. When a run or + ``checkpoints`` directory is supplied instead, default to ``top_model``. """ run_dir = Path(run_dir) checkpoint_root = run_dir / "checkpoints" @@ -107,29 +106,10 @@ def find_best_checkpoint(run_dir: Union[str, Path]) -> Path: if top.exists() and list(top.glob("checkpoint.0.*.pt")): return top - best_dirs = list(checkpoint_root.glob("best_model_epoch_*")) - if not best_dirs: - raise FileNotFoundError( - f"No checkpoint found under {checkpoint_root} " - "(looked for top_model/ and best_model_epoch_*/)" - ) - - best_path, best_loss = None, float("inf") - for d in best_dirs: - ckpts = list(d.glob("checkpoint.0.*.pt")) - if not ckpts: - continue - try: - data = torch.load(ckpts[0], map_location="cpu", weights_only=False) - except Exception: - continue - val_loss = data.get("metadata", {}).get("val_loss", float("inf")) - if val_loss < best_loss: - best_loss = val_loss - best_path = d - if best_path is None: - raise RuntimeError(f"No loadable checkpoints in {checkpoint_root}") - return best_path + raise FileNotFoundError( + f"No top_model checkpoint found under {checkpoint_root}. Pass a specific " + "checkpoint directory to evaluate something other than top_model." + ) def load_model_from_checkpoint( @@ -821,6 +801,7 @@ def _resolve_data_path(cfg: DictConfig, cli_data_path: str) -> None: ) split_file = Path(cli_data_path) / "splits" / f"{case_type}_splits.json" OmegaConf.update(cfg, "case.split_file", str(split_file), force_add=True) + OmegaConf.update(cfg, "data.split_file", str(split_file), force_add=True) def main(): @@ -897,6 +878,14 @@ def main(): OmegaConf.update(cfg, "case.type", args.case_type, force_add=True) _resolve_data_path(cfg, str(args.data_path)) + num_spatial_points = cfg.model.get("num_spatial_points", -1) + if num_spatial_points != -1: + print( + "Warning: evaluation will use the checkpoint's " + f"num_spatial_points={num_spatial_points}; field metrics and QoI " + "are computed on that subsampled point set." + ) + # Output dir defaults to ``/evaluation``. if args.output_dir is None: run_dir = ckpt_dir From 4a863073eb3ed742c56d48e291f1690d2bd1c4b2 Mon Sep 17 00:00:00 2001 From: Carmelo Gonzales Date: Mon, 4 May 2026 10:56:50 -0700 Subject: [PATCH 11/68] fix: eval sampler removing duplicates --- .../radiation_transport/src/loader.py | 57 +++++++++++++------ 1 file changed, 41 insertions(+), 16 deletions(-) diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/loader.py b/examples/cfd/nuclear_engineering/radiation_transport/src/loader.py index b8f46fd9e1..236ef0e09f 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/loader.py +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/loader.py @@ -63,7 +63,7 @@ from physicsnemo.datapipes.registry import register from physicsnemo.datapipes.transforms import Compose, Normalize, Scale, Translate from tensordict import TensorDict -from torch.utils.data import DataLoader, Dataset +from torch.utils.data import DataLoader, Dataset, Sampler from torch.utils.data.distributed import DistributedSampler from dataset import ( @@ -423,10 +423,17 @@ def build_rte_dataset_kwargs( else: num_spatial_points = cfg.model[num_spatial_points_key] + case_cfg = cfg.get("case", {}) split_file = ( - split_file_override if split_file_override else data_cfg.get("split_file") + split_file_override + if split_file_override + else case_cfg.get("split_file") or data_cfg.get("split_file") ) + seed = data_cfg.get("seed", None) + if seed is None and "train" in cfg: + seed = cfg.train.get("seed", None) + # Fourier features config use_fourier_features = data_cfg.get("use_fourier_features", False) fourier_num_frequencies = None @@ -465,6 +472,7 @@ def build_rte_dataset_kwargs( "split_file": split_file, "train_split": data_cfg.get("train_split", 0.7), "val_split": data_cfg.get("val_split", 0.15), + "seed": seed, "expand_timesteps": data_cfg.get("expand_timesteps", True), "temporal_stride": data_cfg.get("temporal_stride", 1), "load_ground_truth_qoi": data_cfg.get("load_ground_truth_qoi", False), @@ -1016,7 +1024,7 @@ def _make_loader( dataset, cfg: DictConfig, phase: str, - sampler: Optional[DistributedSampler], + sampler: Optional[Sampler], collate_fn: Optional[Callable], test_batch_size: int, test_num_workers: int, @@ -1061,6 +1069,23 @@ def _make_loader( return DataLoader(dataset, **kwargs) +class DistributedEvalSampler(Sampler[int]): + """Shard eval data across ranks without padding or duplicate samples.""" + + def __init__(self, dataset: Dataset, num_replicas: int, rank: int): + self.dataset = dataset + self.num_replicas = num_replicas + self.rank = rank + + def __iter__(self): + return iter(range(self.rank, len(self.dataset), self.num_replicas)) + + def __len__(self) -> int: + if self.rank >= len(self.dataset): + return 0 + return ((len(self.dataset) - 1 - self.rank) // self.num_replicas) + 1 + + def build_dataloaders( cfg: DictConfig, dist=None, @@ -1162,21 +1187,21 @@ def build_dataloaders( for phase in phases: sampler = None if dist is not None and dist.distributed and phase in ("train", "val"): - shuffle_cfg = cfg.train.sampler.shuffle if phase == "train" else False - drop_last = ( - cfg.train.sampler.get("drop_last", False) - if phase == "train" - else cfg.train.val.sampler.get("drop_last", False) - ) - sampler = DistributedSampler( - datasets[phase], - num_replicas=dist.world_size, - rank=dist.rank, - shuffle=shuffle_cfg, - drop_last=drop_last, - ) if phase == "train": + sampler = DistributedSampler( + datasets[phase], + num_replicas=dist.world_size, + rank=dist.rank, + shuffle=cfg.train.sampler.shuffle, + drop_last=cfg.train.sampler.get("drop_last", False), + ) train_sampler = sampler + else: + sampler = DistributedEvalSampler( + datasets[phase], + num_replicas=dist.world_size, + rank=dist.rank, + ) loaders[phase] = _make_loader( datasets[phase], From e8ec47ff8914c3b837eae0b61240e756d66ed43a Mon Sep 17 00:00:00 2001 From: Carmelo Gonzales Date: Mon, 4 May 2026 10:57:40 -0700 Subject: [PATCH 12/68] fix: strict checking for physics loss required keys --- .../radiation_transport/src/train.py | 51 ++++++++++++++----- 1 file changed, 38 insertions(+), 13 deletions(-) diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/train.py b/examples/cfd/nuclear_engineering/radiation_transport/src/train.py index d8865a2d7c..9e301c9193 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/train.py +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/train.py @@ -129,11 +129,11 @@ def forward( return model(fx=batch["fx"], embedding=batch["embedding"]) -def loss_inputs(batch: Dict[str, Any]) -> Dict[str, Any]: +def loss_inputs(batch: Dict[str, Any], *, require_physics: bool = False) -> Dict[str, Any]: """Assemble the dict of optional/physics inputs consumed by ``compute_losses``. - ``coordinates_unnormalized`` falls back to the model's ``embedding`` tensor - when the dataset did not pre-stash the raw coordinates. + Physics loss requires raw, unnormalized coordinates. The model embedding + can contain material features, so it is never a safe coordinate fallback. """ inputs: Dict[str, Any] = {} if batch.get("padding_mask") is not None: @@ -141,10 +141,18 @@ def loss_inputs(batch: Dict[str, Any]) -> Dict[str, Any]: if "material_labels" in batch: inputs["material_labels"] = batch["material_labels"] physics_keys = ("cell_areas", "sigma_t", "sigma_s", "sim_time") + if require_physics and not all(k in batch for k in physics_keys): + missing = [k for k in physics_keys if k not in batch] + raise KeyError(f"Missing physics-loss input(s): {missing}") if all(k in batch for k in physics_keys): - inputs["coordinates_unnormalized"] = batch.get( - "coordinates_unnormalized", batch["embedding"] - ) + if "coordinates_unnormalized" not in batch: + if require_physics: + raise KeyError( + "coordinates_unnormalized is required when physics loss is " + "enabled. Enable coordinate backup before normalization." + ) + return inputs + inputs["coordinates_unnormalized"] = batch["coordinates_unnormalized"] for k in physics_keys: inputs[k] = batch[k] for k in ("metadata", "flux_normalization_stats"): @@ -206,7 +214,9 @@ def train_epoch( loss, loss_mse, loss_qoi, qoi_details = compute_losses( pred=pred.float(), target=target.float(), - loss_inputs=loss_inputs(batch), + loss_inputs=loss_inputs( + batch, require_physics=loss_cfg.get("use_physics_loss", False) + ), loss_cfg=loss_cfg, case_type=case_type, device=device, @@ -218,7 +228,7 @@ def train_epoch( loss_mse, loss_qoi, qoi_details, - scale=gradient_accumulation_steps, + scale=1, ) grad_step( @@ -251,19 +261,27 @@ def validate( use_time_embeddings: bool, use_amp: bool = True, amp_dtype: Optional[torch.dtype] = None, -) -> Tuple[float, int]: - """Run one Transolver validation pass and return ``(loss_sum, num_batches)``.""" +) -> Tuple[float, int, Dict[str, float], Dict[str, int]]: + """Run validation and return loss plus metric sums/counts for DDP reduce.""" model.eval() + eval_model = model.module if hasattr(model, "module") else model loss_sum = 0.0 num_batches = 0 + metric_sums: Dict[str, float] = {} + metric_counts: Dict[str, int] = {} + + def accumulate_metric(name: str, value: Any) -> None: + scalar = float(value) + metric_sums[name] = metric_sums.get(name, 0.0) + scalar + metric_counts[name] = metric_counts.get(name, 0) + 1 for batch in dataloader: batch = to_device(batch, device) with autocast(enabled=use_amp, device_type=device.type, dtype=amp_dtype): prediction = forward( - model, batch, use_time_embeddings=use_time_embeddings + eval_model, batch, use_time_embeddings=use_time_embeddings ) pred, target = prediction, batch["flux_target"] @@ -271,7 +289,9 @@ def validate( loss, loss_mse, loss_qoi, qoi_details = compute_losses( pred=pred.float(), target=target.float(), - loss_inputs=loss_inputs(batch), + loss_inputs=loss_inputs( + batch, require_physics=loss_cfg.get("use_physics_loss", False) + ), loss_cfg=loss_cfg, case_type=case_type, device=device, @@ -281,8 +301,13 @@ def validate( loss_sum += loss.item() num_batches += 1 + accumulate_metric("loss_mse", loss_mse.item()) + if loss_qoi is not None: + accumulate_metric("loss_qoi", loss_qoi.item()) + for key, value in qoi_details.items(): + accumulate_metric(key, value) - return loss_sum, num_batches + return loss_sum, num_batches, metric_sums, metric_counts # ========================================================================= From f9910d65b8e0cf5f0bfc96fdaea579b6b6c9f1f5 Mon Sep 17 00:00:00 2001 From: Carmelo Gonzales Date: Mon, 4 May 2026 10:58:39 -0700 Subject: [PATCH 13/68] fix: validatio metric accregation and checkpointing updates --- .../radiation_transport/src/trainer.py | 225 ++++++++++++------ 1 file changed, 155 insertions(+), 70 deletions(-) diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/trainer.py b/examples/cfd/nuclear_engineering/radiation_transport/src/trainer.py index 3af9e3d69d..5e68aa3245 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/trainer.py +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/trainer.py @@ -33,6 +33,7 @@ import hashlib import logging +import math import os import random import time @@ -247,6 +248,40 @@ def aggregate_validation_loss( return global_loss_sum / max(global_num_batches, 1), global_num_batches +def aggregate_validation_metrics( + metric_sums: Mapping[str, float], + metric_counts: Mapping[str, int], + dist: DistributedManager, +) -> Dict[str, float]: + """Aggregate named validation metrics across ranks.""" + if not dist.distributed: + return { + key: metric_sums[key] / metric_counts[key] + for key in metric_sums + if metric_counts.get(key, 0) > 0 + } + + gathered = [None for _ in range(dist.world_size)] + torch_dist.all_gather_object( + gathered, + (dict(metric_sums), dict(metric_counts)), + ) + + total_sums: Dict[str, float] = {} + total_counts: Dict[str, int] = {} + for rank_sums, rank_counts in gathered: + for key, value in rank_sums.items(): + total_sums[key] = total_sums.get(key, 0.0) + float(value) + for key, value in rank_counts.items(): + total_counts[key] = total_counts.get(key, 0) + int(value) + + return { + key: total_sums[key] / total_counts[key] + for key in total_sums + if total_counts.get(key, 0) > 0 + } + + def setup_training_environment( cfg: DictConfig, model_name: str, @@ -424,6 +459,15 @@ def _finalize_step( optimizer.zero_grad(set_to_none=True) +def _scale_pending_gradients(model: torch.nn.Module, factor: float) -> None: + """Scale accumulated gradients in-place before optimizer finalization.""" + if factor == 1.0: + return + for parameter in model.parameters(): + if parameter.grad is not None: + parameter.grad.mul_(factor) + + def grad_step( loss: torch.Tensor, scaler: GradScaler, @@ -456,8 +500,10 @@ def flush_partial_accumulation( max_grad_norm: float = 10.0, ) -> None: """Flush leftover gradients when ``total_steps % accum_steps != 0``.""" - if total_steps % accum_steps == 0: + remainder = total_steps % accum_steps + if remainder == 0: return + _scale_pending_gradients(model, accum_steps / remainder) _finalize_step(scaler, optimizer, model, max_grad_norm) @@ -479,6 +525,16 @@ def _coerce_optional_checkpoint_interval(value: Any) -> Optional[int]: return interval +def _finite_metric_values(metrics: Mapping[str, Optional[float]]) -> bool: + """Return True when all present metric values are finite.""" + for value in metrics.values(): + if value is None: + continue + if not math.isfinite(float(value)): + return False + return True + + def run_training_loop( cfg: DictConfig, dist: Any, @@ -576,16 +632,31 @@ def run_training_loop( with LaunchLogger( "val", epoch=epoch, num_mini_batch=len(val_loader) ) as val_log: - val_loss_sum, val_num_batches = validate_fn( + validation_result = validate_fn( val_loader, model, dist.device, val_log, **val_kw, ) + if len(validation_result) == 2: + val_loss_sum, val_num_batches = validation_result + val_metric_sums: Dict[str, float] = {} + val_metric_counts: Dict[str, int] = {} + else: + ( + val_loss_sum, + val_num_batches, + val_metric_sums, + val_metric_counts, + ) = validation_result train_loss = train_log.epoch_losses.get("loss", 0.0) val_loss, _ = aggregate_validation_loss(val_loss_sum, val_num_batches, dist) + val_metrics = aggregate_validation_metrics( + val_metric_sums, val_metric_counts, dist + ) + val_log.epoch_losses.update(val_metrics) scheduler_type = cfg.train.get("scheduler_type", "cosine") if scheduler_type == "plateau": @@ -612,38 +683,30 @@ def run_training_loop( writer.add_scalar("Learning_Rate", current_lr, epoch) writer.flush() - val_loss_qoi = val_log.epoch_losses.get("loss_qoi") + val_loss_qoi = val_metrics.get("loss_qoi") metadata_best_qoi_loss = best_qoi_loss if val_loss_qoi is not None: metadata_best_qoi_loss = min(best_qoi_loss, val_loss_qoi) - best_val_losses[:] = save_best_checkpoint( - checkpoint_dir=Path(checkpoint_dir), - epoch=epoch, - val_loss=val_loss, - best_val_losses=best_val_losses, - save_checkpoint_fn=save_checkpoint, - logger=logger, - models=model, - optimizer=optimizer, - scheduler=scheduler, - scaler=scaler, - metadata={ - "best_val_losses": best_val_losses, - "best_qoi_loss": metadata_best_qoi_loss, - "train_loss": train_loss, - "val_loss": val_loss, - "val_loss_qoi": val_loss_qoi, - "case_type": case_type, - }, - ) - - if val_loss_qoi is not None: - best_qoi_loss = save_best_qoi_checkpoint( + checkpoint_metrics = { + "train_loss": train_loss, + "val_loss": val_loss, + "val_loss_qoi": val_loss_qoi, + } + can_write_checkpoint = _finite_metric_values(checkpoint_metrics) + if not can_write_checkpoint: + logger.warning( + "Skipping checkpoint saves for epoch %s because at least " + "one checkpoint metric is NaN or inf: %s", + epoch, + checkpoint_metrics, + ) + else: + best_val_losses[:] = save_best_checkpoint( checkpoint_dir=Path(checkpoint_dir), epoch=epoch, - qoi_error=val_loss_qoi, - best_qoi_error=best_qoi_loss, + val_loss=val_loss, + best_val_losses=best_val_losses, save_checkpoint_fn=save_checkpoint, logger=logger, models=model, @@ -660,49 +723,71 @@ def run_training_loop( }, ) - if epoch % cfg.train.checkpoint_interval == 0: - save_checkpoint( - path=checkpoint_dir, - models=model, - optimizer=optimizer, - scheduler=scheduler, - scaler=scaler, - epoch=epoch, - metadata={ - "best_val_losses": best_val_losses, - "best_qoi_loss": best_qoi_loss, - "train_loss": train_loss, - "val_loss": val_loss, - "val_loss_qoi": val_loss_qoi, - "case_type": case_type, - }, - ) - logger.info(f" Saved checkpoint at epoch {epoch + 1}") - - latest_checkpoint_interval = _coerce_optional_checkpoint_interval( - cfg.train.get("latest_checkpoint_interval", 1) - ) - if latest_checkpoint_interval and ( - epoch % latest_checkpoint_interval == 0 - ): - save_latest_checkpoint( - checkpoint_dir=Path(checkpoint_dir), - epoch=epoch, - save_checkpoint_fn=save_checkpoint, - logger=logger, - models=model, - optimizer=optimizer, - scheduler=scheduler, - scaler=scaler, - metadata={ - "best_val_losses": best_val_losses, - "best_qoi_loss": best_qoi_loss, - "train_loss": train_loss, - "val_loss": val_loss, - "val_loss_qoi": val_loss_qoi, - "case_type": case_type, - }, + if val_loss_qoi is not None: + best_qoi_loss = save_best_qoi_checkpoint( + checkpoint_dir=Path(checkpoint_dir), + epoch=epoch, + qoi_error=val_loss_qoi, + best_qoi_error=best_qoi_loss, + save_checkpoint_fn=save_checkpoint, + logger=logger, + models=model, + optimizer=optimizer, + scheduler=scheduler, + scaler=scaler, + metadata={ + "best_val_losses": best_val_losses, + "best_qoi_loss": metadata_best_qoi_loss, + "train_loss": train_loss, + "val_loss": val_loss, + "val_loss_qoi": val_loss_qoi, + "case_type": case_type, + }, + ) + + if epoch % cfg.train.checkpoint_interval == 0: + save_checkpoint( + path=checkpoint_dir, + models=model, + optimizer=optimizer, + scheduler=scheduler, + scaler=scaler, + epoch=epoch, + metadata={ + "best_val_losses": best_val_losses, + "best_qoi_loss": best_qoi_loss, + "train_loss": train_loss, + "val_loss": val_loss, + "val_loss_qoi": val_loss_qoi, + "case_type": case_type, + }, + ) + logger.info(f" Saved checkpoint at epoch {epoch + 1}") + + latest_checkpoint_interval = _coerce_optional_checkpoint_interval( + cfg.train.get("latest_checkpoint_interval", 1) ) + if latest_checkpoint_interval and ( + epoch % latest_checkpoint_interval == 0 + ): + save_latest_checkpoint( + checkpoint_dir=Path(checkpoint_dir), + epoch=epoch, + save_checkpoint_fn=save_checkpoint, + logger=logger, + models=model, + optimizer=optimizer, + scheduler=scheduler, + scaler=scaler, + metadata={ + "best_val_losses": best_val_losses, + "best_qoi_loss": best_qoi_loss, + "train_loss": train_loss, + "val_loss": val_loss, + "val_loss_qoi": val_loss_qoi, + "case_type": case_type, + }, + ) if val_loss_qoi is not None and writer: writer.add_scalar("Loss/val_qoi", val_loss_qoi, epoch) From e26e68ea11fd5e40a68ac0d3789f1eebec09c7e8 Mon Sep 17 00:00:00 2001 From: Carmelo Gonzales Date: Mon, 4 May 2026 10:59:30 -0700 Subject: [PATCH 14/68] fix: explicit padding to flux --- .../radiation_transport/src/transforms.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/transforms.py b/examples/cfd/nuclear_engineering/radiation_transport/src/transforms.py index 0cf9343d9e..b845ad80c0 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/transforms.py +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/transforms.py @@ -490,7 +490,7 @@ def __call__(self, data: TensorDict) -> TensorDict: if flux_key in data: flux_1d = data[flux_key][indices] if flux_1d.shape[0] < self.num_points: - flux_1d = self._pad_tensor(flux_1d, self.num_points) + flux_1d = self._pad_flux_1d(flux_1d, self.num_points) data[flux_key] = flux_1d data["spatial_indices"] = indices @@ -514,6 +514,14 @@ def _pad_flux(self, flux: torch.Tensor, target_size: int) -> torch.Tensor: padding = torch.full(pad_shape, -10.0, dtype=flux.dtype, device=flux.device) return torch.cat([flux, padding], dim=1) + def _pad_flux_1d(self, flux: torch.Tensor, target_size: int) -> torch.Tensor: + if flux.shape[0] >= target_size: + return flux[:target_size] + pad_shape = list(flux.shape) + pad_shape[0] = target_size - flux.shape[0] + padding = torch.full(pad_shape, -10.0, dtype=flux.dtype, device=flux.device) + return torch.cat([flux, padding], dim=0) + def extra_repr(self) -> str: return f"num_points={self.num_points}" From 01b41d7f7ae4fa75cdff2ceff1b0401d1e71ee9b Mon Sep 17 00:00:00 2001 From: Carmelo Gonzales Date: Mon, 4 May 2026 11:49:37 -0700 Subject: [PATCH 15/68] feat: cleaning time dependent training references --- .../src/compute_normalizations.py | 53 +--- .../src/conf/data/hohlraum.yaml | 4 - .../src/conf/data/lattice.yaml | 4 - .../src/conf/model/transolver.yaml | 1 - .../radiation_transport/src/dataset.py | 261 +++++------------- .../radiation_transport/src/inference.py | 50 +++- .../radiation_transport/src/loader.py | 100 +------ .../radiation_transport/src/train.py | 20 +- .../radiation_transport/src/trainer.py | 30 +- .../radiation_transport/src/transforms.py | 120 ++------ 10 files changed, 196 insertions(+), 447 deletions(-) diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/compute_normalizations.py b/examples/cfd/nuclear_engineering/radiation_transport/src/compute_normalizations.py index b43e511134..989ee62287 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/compute_normalizations.py +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/compute_normalizations.py @@ -47,7 +47,7 @@ import pathlib import sys from pathlib import Path -from typing import Dict, Optional +from typing import Dict import numpy as np import torch @@ -63,9 +63,9 @@ from material import MaterialPropertyExtractor # noqa: E402 from transforms import ( # noqa: E402 Compose, - NextStepSampler, RTEFluxLogClip, SpatialSampler, + SteadyStateSampler, ) @@ -85,9 +85,8 @@ def compute_flux_statistics( data_path: Path, case_type: str, output_file: Path, - split_file: Optional[Path] = None, + split_file: Path, clip_threshold: float = 1e-8, - steady_state: bool = False, ) -> Dict[str, float]: """Compute flux normalization statistics from the training split. @@ -97,19 +96,12 @@ def compute_flux_statistics( output_file: destination YAML path. split_file: split JSON used to select the training split. clip_threshold: minimum flux value before ``log10``. - steady_state: when ``True``, only use the first and last timesteps - of each simulation. - Returns: The statistics dict written to ``output_file``. """ - mode_label = ( - "steady state (first + last timestep)" if steady_state else "full trajectory" - ) - print(f"Computing flux statistics for {case_type} [{mode_label}]") + print(f"Computing flux statistics for {case_type} [steady state]") print(f"Data path: {data_path}") - if split_file is not None: - print(f"Split file: {split_file}") + print(f"Split file: {split_file}") dataset = RTEBaseDataset( data_path=data_path, @@ -118,8 +110,6 @@ def compute_flux_statistics( split_file=split_file, load_material_properties=False, load_geometric_features=False, - expand_timesteps=False, - task="steady_state" if steady_state else "next_step", ) print(f"\nProcessing {len(dataset)} training simulations...") @@ -137,10 +127,6 @@ def compute_flux_statistics( flux = flux.detach().cpu().numpy() flux = np.asarray(flux) - if steady_state: - # select only first and last timesteps: (T, N) -> (2, N) - flux = np.stack([flux[0], flux[-1]], axis=0) - # match training-pipeline preprocessing flux = np.clip(flux, clip_threshold, None) log_flux = np.log10(flux + clip_threshold) @@ -170,8 +156,7 @@ def compute_flux_statistics( "case_type": case_type, } - if steady_state: - stats["note"] = "computed from first and last timesteps only (steady state)" + stats["note"] = "computed from first and final snapshots only (steady state)" output_file = Path(output_file) output_file.parent.mkdir(parents=True, exist_ok=True) @@ -195,14 +180,14 @@ def compute_flux_statistics( # # Walks the training split through a minimal transform pipeline: # -# RTEFluxLogClip -> NextStepSampler -> MaterialPropertyExtractor -> SpatialSampler +# RTEFluxLogClip -> SteadyStateSampler -> MaterialPropertyExtractor -> SpatialSampler # # The flux log-clip step is required because the dataset reader produces a -# trajectory tensor; the temporal sampler picks one (t, t+1) pair per -# simulation, the material extractor produces ``physical_properties`` with -# shape (N, 4), and ``SpatialSampler`` subsamples to a fixed point count for -# speed. Per-property stats are written in the schema the existing -# ``load_material_stats`` reader expects. +# steady-state flux tensor; the sampler picks the first/final pair, the +# material extractor produces ``physical_properties`` with shape (N, 4), and +# ``SpatialSampler`` subsamples to a fixed point count for speed. Per-property +# stats are written in the schema the existing ``load_material_stats`` reader +# expects. def compute_material_statistics( @@ -228,7 +213,7 @@ def compute_material_statistics( clip_threshold: flux clip threshold used by the flux transform. num_spatial_points: number of points per simulation drawn by ``SpatialSampler``. - seed: RNG seed for the temporal and spatial samplers. + seed: RNG seed for spatial sampling. Returns: The nested statistics dict written to ``output_file``. @@ -236,8 +221,7 @@ def compute_material_statistics( print(f"\nComputing material statistics for {case_type}") print(f"Data path: {data_path}") print(f"Flux stats: {flux_stats_file}") - if split_file is not None: - print(f"Split file: {split_file}") + print(f"Split file: {split_file}") if not Path(flux_stats_file).exists(): raise FileNotFoundError( @@ -251,7 +235,7 @@ def compute_material_statistics( normalization_stats_file=flux_stats_file, clip_threshold=clip_threshold, ), - NextStepSampler(stride=1, seed=seed), + SteadyStateSampler(), MaterialPropertyExtractor(case_type=case_type), SpatialSampler(num_points=num_spatial_points, seed=seed), ] @@ -265,7 +249,6 @@ def compute_material_statistics( case_type=case_type, phase="train", split_file=split_file, - expand_timesteps=False, ) print(f"Dataset loaded: {len(dataset)} samples") @@ -381,11 +364,6 @@ def _parse_args() -> argparse.Namespace: default=1e-8, help="Flux clip threshold used during log-transform (default: 1e-8).", ) - parser.add_argument( - "--steady_state", - action="store_true", - help="Use only first and last timesteps for the flux statistics.", - ) parser.add_argument( "--num_spatial_points", type=int, @@ -420,7 +398,6 @@ def main() -> int: output_file=flux_output, split_file=args.split_file, clip_threshold=args.clip_threshold, - steady_state=args.steady_state, ) compute_material_statistics( diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/conf/data/hohlraum.yaml b/examples/cfd/nuclear_engineering/radiation_transport/src/conf/data/hohlraum.yaml index 7b89969e59..1183e44c44 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/conf/data/hohlraum.yaml +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/conf/data/hohlraum.yaml @@ -3,15 +3,11 @@ # SPDX-License-Identifier: Apache-2.0 input_dir: ${case.data_path} -task: steady_state flux_normalization_stats_file: ${case.data_root}/stats/hohlraum_flux_stats.yaml flux_clip_threshold: 1.0e-8 -expand_timesteps: false cache_static_arrays: true max_cache_size: -1 preload_data: true -train_split: 0.8 -val_split: 0.1 use_fourier_features: true fourier_features: diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/conf/data/lattice.yaml b/examples/cfd/nuclear_engineering/radiation_transport/src/conf/data/lattice.yaml index 26d337709a..56a1f2772d 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/conf/data/lattice.yaml +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/conf/data/lattice.yaml @@ -3,15 +3,11 @@ # SPDX-License-Identifier: Apache-2.0 input_dir: ${case.data_path} -task: steady_state flux_normalization_stats_file: ${case.data_root}/stats/lattice_flux_stats.yaml flux_clip_threshold: 1.0e-8 -expand_timesteps: false cache_static_arrays: true max_cache_size: -1 preload_data: true -train_split: 0.8 -val_split: 0.1 # Fourier features for coordinates (adds 2 * coord_dims * num_frequencies features). # Default: 3 freq * 2 coords * 2 (sin/cos) = 12 extra features, on top of 3 raw coords. diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/conf/model/transolver.yaml b/examples/cfd/nuclear_engineering/radiation_transport/src/conf/model/transolver.yaml index 1af3240523..782632d038 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/conf/model/transolver.yaml +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/conf/model/transolver.yaml @@ -17,7 +17,6 @@ n_head: 16 slice_num: 128 mlp_ratio: 4 dropout: 0.0 -time_input: false use_te: true structured_shape: null diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/dataset.py b/examples/cfd/nuclear_engineering/radiation_transport/src/dataset.py index f32b5d8be4..2dca26cb7b 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/dataset.py +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/dataset.py @@ -17,7 +17,7 @@ """RTE data-source layer: zarr reader, PyTorch Dataset, and stats loaders. This module is the bottom of the data dependency tree. It provides the -low-level Zarr access (``ZarrDataReader``), a thin file/timestep-indexed +low-level Zarr access (``ZarrDataReader``), a thin file-indexed ``Dataset`` wrapper (``RTEBaseDataset``), and helpers for reading the RTE-specific YAML statistics files into PhysicsNeMo ``Normalize`` kwargs. """ @@ -50,10 +50,10 @@ # ``load()``. The TensorDict carries both the tensor fields and non-tensor # metadata (``metadata``, ``filename``) via ``NonTensorData`` entries. # -# RTE-specific kwargs (``timestep_slice``, ``timestep_indices``, -# ``load_flux``, etc.) live on the filename-indexed ``load(filename, ...)`` -# entry. The int-indexed ``_load_sample(index)`` required by the PhysicsNeMo -# ``Reader`` contract uses defaults (load everything, no slicing). +# RTE-specific kwargs (``load_flux``, optional field loading, etc.) live on the +# filename-indexed ``load(filename, ...)`` entry. The int-indexed +# ``_load_sample(index)`` required by the PhysicsNeMo ``Reader`` contract uses +# defaults. _TENSOR_FIELD_NAMES = ( @@ -83,8 +83,8 @@ class ZarrDataReader(Reader): Inherits from ``physicsnemo.datapipes.readers.base.Reader`` so the reader plugs into any PhysicsNeMo-native pipeline via ``__getitem__(int)`` → ``(TensorDict, metadata_dict)``. RTE pipelines still reach it via - ``load(filename, **kwargs)`` for the fine-grained timestep-selection - controls the training data loaders rely on. + ``load(filename, **kwargs)`` for the steady-state loader controls the + training data loaders rely on. Example: >>> reader = ZarrDataReader("/path/to/zarr_stores/lattice") @@ -195,8 +195,6 @@ def load( load_geometric_features: bool = True, load_sim_times: bool = True, load_sigma_fields: bool = True, - timestep_slice: Optional[slice] = None, - timestep_indices: Optional[List[int]] = None, load_flux: bool = True, ) -> TensorDict: """Load a zarr store into a ``TensorDict``. @@ -217,43 +215,36 @@ def load( z = zarr.open(str(filepath), mode="r") - # ------- flux + timesteps (honors load_flux / timestep_*) ------- + # ------- flux + timesteps (steady-state first -> final snapshots) ------- if not load_flux: - num_cells = z["scalar_flux"].shape[1] + flux_shape = z["scalar_flux"].shape + num_cells = flux_shape[-1] scalar_flux = np.zeros((1, num_cells), dtype=np.float32) timesteps_array = np.array([0]) sim_times = None - elif timestep_indices is not None: - num_total_timesteps = z["scalar_flux"].shape[0] - resolved = [ - idx if idx >= 0 else num_total_timesteps + idx - for idx in timestep_indices - ] - scalar_flux = np.stack( - [np.array(z["scalar_flux"][idx], dtype=np.float32) for idx in resolved], - axis=0, - ) - timesteps_array = np.array([z["timesteps"][idx] for idx in resolved]) + else: + flux_array = z["scalar_flux"] + if len(flux_array.shape) == 1: + scalar_flux = np.array(flux_array, dtype=np.float32)[None, :] + timesteps_array = np.array([0]) + resolved = [0] + else: + num_timesteps = flux_array.shape[0] + resolved = [0] if num_timesteps == 1 else [0, num_timesteps - 1] + scalar_flux = np.stack( + [ + np.array(flux_array[idx], dtype=np.float32) + for idx in resolved + ], + axis=0, + ) + timesteps_array = np.array([z["timesteps"][idx] for idx in resolved]) if load_sim_times and "sim_times" in z: sim_times = np.array( [z["sim_times"][idx] for idx in resolved], dtype=np.float32 ) else: sim_times = None - elif timestep_slice is not None: - scalar_flux = np.array(z["scalar_flux"][timestep_slice], dtype=np.float32) - timesteps_array = np.array(z["timesteps"][timestep_slice]) - if load_sim_times and "sim_times" in z: - sim_times = np.array(z["sim_times"][timestep_slice], dtype=np.float32) - else: - sim_times = None - else: - scalar_flux = np.array(z["scalar_flux"], dtype=np.float32) - timesteps_array = np.array(z["timesteps"]) - if load_sim_times and "sim_times" in z: - sim_times = np.array(z["sim_times"], dtype=np.float32) - else: - sim_times = None # ------- static-arrays cache lookup ------- with self._cache_lock: @@ -344,8 +335,9 @@ def get_metadata(self, filename: str) -> Dict: z = zarr.open(str(filepath), mode="r") metadata = dict(z.attrs) if hasattr(z, "attrs") else {} - metadata["num_timesteps"] = z["scalar_flux"].shape[0] - metadata["num_cells"] = z["scalar_flux"].shape[1] + flux_shape = z["scalar_flux"].shape + metadata["num_timesteps"] = flux_shape[0] if len(flux_shape) > 1 else 1 + metadata["num_cells"] = flux_shape[-1] metadata["has_geometric_features"] = "geometric_features" in z metadata["has_material_properties"] = "material_properties" in z metadata["has_sim_times"] = "sim_times" in z @@ -370,7 +362,7 @@ def validate(self, filename: str) -> bool: nc_centers = z["cell_centers"].shape[0] nc_areas = z["cell_areas"].shape[0] - nc_flux = z["scalar_flux"].shape[1] + nc_flux = z["scalar_flux"].shape[-1] if nc_centers != nc_flux: raise ValueError( f"Shape mismatch: cell_centers has {nc_centers} cells, " @@ -390,19 +382,16 @@ def validate(self, filename: str) -> bool: # # Minimal PyTorch ``Dataset`` that wraps ``ZarrDataReader`` and produces # per-sample ``TensorDict`` outputs. The reader returns TensorDicts directly; -# this layer only glues together file/timestep selection, the preload cache, -# and per-sample metadata enrichment (filename, ``max_timestep``, -# ``max_sim_time``, ``_timestep_idx``). +# this layer only glues together file selection, the preload cache, and +# per-sample metadata enrichment. class RTEBaseDataset(Dataset): - """File- and timestep-indexed dataset over a directory of zarr stores. + """File-indexed steady-state dataset over a directory of zarr stores. Output of ``__getitem__`` is a ``TensorDict`` with the tensor fields the reader returned, plus ``filename`` (``NonTensorData``), an updated - ``metadata`` ``NonTensorData`` entry (``max_timestep`` / - ``max_sim_time``), and optionally ``_timestep_idx`` when - ``expand_timesteps=True``. + ``metadata`` ``NonTensorData`` entry (``max_timestep`` / ``max_sim_time``). """ def __init__( @@ -411,31 +400,21 @@ def __init__( case_type: Optional[str] = None, phase: str = "train", split_file: Optional[Path | str] = None, - train_split: float = 0.7, - val_split: float = 0.15, seed: Optional[int] = None, load_material_properties: bool = True, load_geometric_features: bool = True, load_sigma_fields: bool = True, - expand_timesteps: bool = True, - temporal_stride: int = 1, cache_static_arrays: bool = True, max_cache_size: int = 200, - task: str = "next_step", ): self.data_path = Path(data_path) self.case_type = case_type self.phase = phase self.split_file = Path(split_file) if split_file else None - self.train_split = train_split - self.val_split = val_split self.seed = seed self.load_material_properties = load_material_properties self.load_geometric_features = load_geometric_features self.load_sigma_fields = load_sigma_fields - self.expand_timesteps = expand_timesteps - self.temporal_stride = temporal_stride - self.task = task self.reader = ZarrDataReader( data_path, @@ -444,28 +423,22 @@ def __init__( max_cache_size=max_cache_size, ) - if self.split_file: - self.filenames = self._load_split_from_file() - else: - all_filenames = self.reader.get_filenames() - if not all_filenames: - raise ValueError(f"No zarr stores found in {data_path}") - self.filenames = self._split_filenames(all_filenames) + if self.split_file is None: + raise ValueError( + "split_file is required. RTE datasets must use explicit " + "train/val/test splits from a JSON split file." + ) + self.filenames = self._load_split_from_file() if not self.filenames: raise ValueError(f"No files in {phase} split") - self.timestep_index_map: Optional[List[tuple]] = None - if self.expand_timesteps: - self._build_timestep_index() - # In-memory cache for flux data (populated by preload_to_memory when - # ``task == 'steady_state'``). Values are ``dict`` mirrors of the - # cached tensor entries. + # enabled). Values are ``dict`` mirrors of the cached tensor entries. self._memory_cache: Optional[Dict[str, Dict[str, torch.Tensor]]] = None # ------------------------------------------------------------------ - # Split machinery (unchanged semantics) + # Split machinery # ------------------------------------------------------------------ def _load_split_from_file(self) -> List[str]: @@ -483,93 +456,36 @@ def _load_split_from_file(self) -> List[str]: filenames = split_data["splits"][self.phase] return [f if f.endswith(".zarr") else f + ".zarr" for f in filenames] - def _split_filenames(self, filenames: List[str]) -> List[str]: - n = len(filenames) - indices = np.arange(n) - if self.seed is not None: - np.random.default_rng(self.seed).shuffle(indices) - train_end = int(n * self.train_split) - val_end = train_end + int(n * self.val_split) - if self.phase == "train": - sel = indices[:train_end] - elif self.phase == "val": - sel = indices[train_end:val_end] - elif self.phase == "test": - sel = indices[val_end:] - else: - raise ValueError(f"Invalid phase: {self.phase}") - return [filenames[i] for i in sel] - - def _build_timestep_index(self): - self.timestep_index_map = [] - for file_idx, filename in enumerate(self.filenames): - meta = self.reader.get_metadata(filename) - num_timesteps = meta["num_timesteps"] - max_start = num_timesteps - self.temporal_stride - if max_start > 0: - for t in range(max_start): - self.timestep_index_map.append((file_idx, t)) - - num_sims = len(self.filenames) - num_pairs = len(self.timestep_index_map) - avg = num_pairs / num_sims if num_sims > 0 else 0 - print(f"Timestep expansion ({self.phase}):") - print(f" {num_sims} simulations → {num_pairs} timestep pairs") - print(f" Average {avg:.1f} pairs per simulation") - print(f" Stride: {self.temporal_stride}") - - # ------------------------------------------------------------------ - # Preload cache - # ------------------------------------------------------------------ - def preload_to_memory(self, verbose: bool = True, num_workers: int = 8) -> dict: - """Preload static arrays (and optionally flux for steady_state).""" + """Preload static arrays and first/final flux snapshots.""" import time from concurrent.futures import ThreadPoolExecutor, as_completed num_files = len(self.filenames) - preload_flux = self.task == "steady_state" + self._memory_cache = {} if verbose: - if preload_flux: - print( - f"\nPreloading {num_files} files with flux (steady_state mode)..." - ) - print(" Loading ONLY first and last timesteps (2 per file)") - else: - print(f"\nPreloading {num_files} files into memory...") + print(f"\nPreloading {num_files} files with steady-state flux...") + print(" Loading ONLY first and final snapshots (2 per file)") print(f" Parallel I/O workers: {num_workers}") start = time.perf_counter() - if preload_flux: - self._memory_cache = {} def load_one(filename: str): - if preload_flux: - td = self.reader.load( - filename, - load_material_properties=self.load_material_properties, - load_geometric_features=self.load_geometric_features, - load_sim_times=True, - load_sigma_fields=self.load_sigma_fields, - timestep_indices=[0, -1], - ) - entry: Dict[str, torch.Tensor] = { - "scalar_flux": td["scalar_flux"].clone(), - "timesteps": td["timesteps"].clone(), - } - if "sim_times" in td: - entry["sim_times"] = td["sim_times"].clone() - return filename, td, entry td = self.reader.load( filename, load_material_properties=self.load_material_properties, load_geometric_features=self.load_geometric_features, load_sim_times=True, load_sigma_fields=self.load_sigma_fields, - timestep_slice=slice(0, 2), ) - return filename, td, None + entry: Dict[str, torch.Tensor] = { + "scalar_flux": td["scalar_flux"].clone(), + "timesteps": td["timesteps"].clone(), + } + if "sim_times" in td: + entry["sim_times"] = td["sim_times"].clone() + return filename, td, entry completed = 0 first_logged = False @@ -578,8 +494,7 @@ def load_one(filename: str): for fut in as_completed(futures): filename, td, entry = fut.result() completed += 1 - if entry is not None: - self._memory_cache[filename] = entry + self._memory_cache[filename] = entry if verbose and not first_logged: n_cells = td["coordinates"].shape[0] print(f"\n First file diagnostics ({filename}):") @@ -607,27 +522,26 @@ def load_one(filename: str): print(f" Static arrays cache: {cache_stats['cache_size']} files") print(f" Cache hits: {cache_stats['cache_hits']}") print(f" Cache misses: {cache_stats['cache_misses']}") - if preload_flux: - flux_mem = sum( - cached["scalar_flux"].element_size() * cached["scalar_flux"].numel() - + cached["timesteps"].element_size() * cached["timesteps"].numel() - + ( - cached["sim_times"].element_size() * cached["sim_times"].numel() - if "sim_times" in cached - else 0 - ) - for cached in self._memory_cache.values() - ) - print( - f" Flux cache: {len(self._memory_cache)} simulations " - f"({flux_mem / 1024**2:.1f} MB)" + flux_mem = sum( + cached["scalar_flux"].element_size() * cached["scalar_flux"].numel() + + cached["timesteps"].element_size() * cached["timesteps"].numel() + + ( + cached["sim_times"].element_size() * cached["sim_times"].numel() + if "sim_times" in cached + else 0 ) + for cached in self._memory_cache.values() + ) + print( + f" Flux cache: {len(self._memory_cache)} simulations " + f"({flux_mem / 1024**2:.1f} MB)" + ) return { "num_files": num_files, "elapsed_seconds": elapsed, "cache_stats": cache_stats, - "flux_cached": preload_flux, + "flux_cached": True, } # ------------------------------------------------------------------ @@ -635,25 +549,10 @@ def load_one(filename: str): # ------------------------------------------------------------------ def __len__(self) -> int: - if self.expand_timesteps and self.timestep_index_map is not None: - return len(self.timestep_index_map) return len(self.filenames) def __getitem__(self, idx: int) -> TensorDict: - timestep_slice = None - timestep_indices = None - timestep_idx = None - - if self.expand_timesteps and self.timestep_index_map is not None: - file_idx, timestep_idx = self.timestep_index_map[idx] - filename = self.filenames[file_idx] - num_timesteps_needed = self.temporal_stride + 1 - timestep_slice = slice(timestep_idx, timestep_idx + num_timesteps_needed) - elif self.task == "steady_state": - filename = self.filenames[idx] - timestep_indices = [0, -1] - else: - filename = self.filenames[idx] + filename = self.filenames[idx] if self._memory_cache is not None and filename in self._memory_cache: cached = self._memory_cache[filename] @@ -676,29 +575,21 @@ def __getitem__(self, idx: int) -> TensorDict: load_geometric_features=self.load_geometric_features, load_sim_times=True, load_sigma_fields=self.load_sigma_fields, - timestep_slice=timestep_slice, - timestep_indices=timestep_indices, ) # Enrich metadata with the per-sample info transforms rely on. # ``td["metadata"]`` is a NonTensorData dict of zarr attrs; extend it. attrs = dict(td["metadata"]) if "metadata" in td else {} - if timestep_slice is not None or timestep_indices is not None: - file_meta = self.reader.get_metadata(filename) - attrs["max_timestep"] = file_meta["num_timesteps"] - 1 - attrs["max_sim_time"] = file_meta.get("max_sim_time") + file_meta = self.reader.get_metadata(filename) + attrs["max_timestep"] = file_meta["num_timesteps"] - 1 + attrs["max_sim_time"] = file_meta.get("max_sim_time") + if "sim_times" in td and td["sim_times"].numel() > 0: + attrs["sim_time"] = float(td["sim_times"][-1].item()) else: - attrs["max_timestep"] = td["scalar_flux"].shape[0] - 1 - if "sim_times" in td and td["sim_times"].numel() > 0: - attrs["max_sim_time"] = float(td["sim_times"][-1].item()) - else: - attrs["max_sim_time"] = None + attrs["sim_time"] = None td.set_non_tensor("metadata", attrs) td.set_non_tensor("filename", filename) - if timestep_idx is not None: - td.set_non_tensor("_timestep_idx", timestep_idx) - return td diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/inference.py b/examples/cfd/nuclear_engineering/radiation_transport/src/inference.py index 145a319a19..4655d847a1 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/inference.py +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/inference.py @@ -30,6 +30,7 @@ """ import argparse +import os import pathlib import re import sys @@ -133,8 +134,7 @@ def load_model_from_checkpoint( cfg = load_hydra_config(checkpoint_dir) - if not DistributedManager.is_initialized(): - DistributedManager.initialize() + _initialize_distributed_manager() # Build model from cfg.model. Strip RTE-specific keys consumed elsewhere. cfg_model = OmegaConf.to_container(cfg.model, resolve=True) @@ -160,6 +160,26 @@ def load_model_from_checkpoint( return model, cfg, metadata +def _initialize_distributed_manager() -> None: + """Use distributed init only for torchrun/explicit distributed launches.""" + if DistributedManager.is_initialized(): + return + + explicit_method = os.getenv("PHYSICSNEMO_DISTRIBUTED_INITIALIZATION_METHOD") + torchrun_env = os.getenv("RANK") is not None and os.getenv("WORLD_SIZE") is not None + openmpi_env = os.getenv("OMPI_COMM_WORLD_RANK") is not None + + if explicit_method or torchrun_env or openmpi_env: + DistributedManager.initialize() + return + + DistributedManager._shared_state["_is_initialized"] = True + dist = DistributedManager() + dist._initialization_method = "single" + if torch.cuda.is_available(): + torch.cuda.set_device(dist.device) + + # ========================================================================= # Metrics # ========================================================================= @@ -716,7 +736,6 @@ def run_evaluation( """ model.eval() n = 0 - use_time = bool(getattr(model, "time_input", False)) for batch in tqdm(dataloader, desc="evaluating"): if max_samples is not None and n >= max_samples: @@ -725,12 +744,7 @@ def run_evaluation( amp_enabled = use_amp and device.type == "cuda" with autocast(device_type=device.type, enabled=amp_enabled): - if use_time: - pred = model( - fx=batch["fx"], embedding=batch["embedding"], time=batch.get("time") - ) - else: - pred = model(fx=batch["fx"], embedding=batch["embedding"]) + pred = model(fx=batch["fx"], embedding=batch["embedding"]) pred = pred.float() target = batch["flux_target"].float() @@ -777,7 +791,11 @@ def run_evaluation( # ========================================================================= -def _resolve_data_path(cfg: DictConfig, cli_data_path: str) -> None: +def _resolve_data_path( + cfg: DictConfig, + cli_data_path: str, + split_file: Union[str, Path], +) -> None: """Override ``case.data_root`` and ``data.input_dir`` to the user-supplied path.""" OmegaConf.update(cfg, "case.data_root", cli_data_path, force_add=True) case_type = cfg.case.type @@ -799,7 +817,7 @@ def _resolve_data_path(cfg: DictConfig, cli_data_path: str) -> None: str(flux_stats_file), force_add=True, ) - split_file = Path(cli_data_path) / "splits" / f"{case_type}_splits.json" + split_file = Path(split_file) OmegaConf.update(cfg, "case.split_file", str(split_file), force_add=True) OmegaConf.update(cfg, "data.split_file", str(split_file), force_add=True) @@ -829,6 +847,12 @@ def main(): choices=("lattice", "hohlraum"), help="Which benchmark to evaluate.", ) + parser.add_argument( + "--split_file", + type=Path, + required=True, + help="Explicit train/val/test split JSON to use for evaluation.", + ) parser.add_argument( "--output_dir", type=Path, @@ -876,7 +900,9 @@ def main(): f"but --case_type={args.case_type}. Using --case_type." ) OmegaConf.update(cfg, "case.type", args.case_type, force_add=True) - _resolve_data_path(cfg, str(args.data_path)) + if not args.split_file.exists(): + raise FileNotFoundError(f"Split file not found: {args.split_file}") + _resolve_data_path(cfg, str(args.data_path), args.split_file) num_spatial_points = cfg.model.get("num_spatial_points", -1) if num_spatial_points != -1: diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/loader.py b/examples/cfd/nuclear_engineering/radiation_transport/src/loader.py index 236ef0e09f..399e0b47c0 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/loader.py +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/loader.py @@ -79,7 +79,6 @@ TRANSFORM_REGISTRY, FourierFeatures, LoadGroundTruthQoI, - NextStepSampler, RTEBackupCoords, RTEFluxLogClip, SpatialSampler, @@ -145,7 +144,6 @@ class TransolverAdapter(ModelAdapter): * ``embedding`` — material properties ``[sigma_a, sigma_s, sigma_t, Q]`` (or just the first three when ``include_q_in_embedding=False``). * ``flux_target`` — target flux to predict. - * ``time`` — normalized timestep (always present). Extra fields (``coordinates_unnormalized``, ``material_labels``, ``cell_areas``, ``sigma_t``, ``sigma_s``, ``sim_time``, ``ground_truth_qoi``, @@ -214,38 +212,6 @@ def to_tensor(x): flux_tgt = flux_tgt.unsqueeze(0) result["flux_target"] = flux_tgt - metadata = sample.get("metadata", {}) or {} - max_timestep = metadata.get("max_timestep") if isinstance(metadata, dict) else None - max_sim_time = metadata.get("max_sim_time") if isinstance(metadata, dict) else None - timestep_target = sample.get("timestep_target") - - if "sim_times" in sample and max_sim_time is not None: - sim_times = sample["sim_times"] - sim_time_target = ( - float(sim_times[-1].item()) - if isinstance(sim_times, torch.Tensor) - else float(sim_times[-1]) - ) - time_normalized = sim_time_target / float(max_sim_time) - time_tensor = torch.tensor([[time_normalized]], dtype=torch.float32) - if not self.add_batch_dim: - time_tensor = time_tensor.squeeze(0) - result["time"] = time_tensor - elif timestep_target is not None and max_timestep is not None: - if float(max_timestep) == 0: - time_normalized = 1.0 - else: - time_normalized = float(timestep_target) / float(max_timestep) - time_tensor = torch.tensor([[time_normalized]], dtype=torch.float32) - if not self.add_batch_dim: - time_tensor = time_tensor.squeeze(0) - result["time"] = time_tensor - else: - time_tensor = torch.tensor([[0.0]], dtype=torch.float32) - if not self.add_batch_dim: - time_tensor = time_tensor.squeeze(0) - result["time"] = time_tensor - if "cell_areas" in sample: cell_areas = to_tensor(sample["cell_areas"]) if self.add_batch_dim: @@ -297,6 +263,8 @@ def to_tensor(x): if geometry_params: result["geometry_params"] = geometry_params + metadata = sample.get("metadata", {}) or {} + max_timestep = metadata.get("max_timestep") if isinstance(metadata, dict) else None metadata_dict = { "timestep_input": sample.get("timestep_input"), "timestep_target": sample.get("timestep_target"), @@ -403,10 +371,6 @@ def build_rte_dataset_kwargs( "data.flux_normalization_stats_file must be specified in config." ) - task = data_cfg.get("task") - if task is None: - raise ValueError("data.task must be specified in config.") - flux_clip_threshold = data_cfg.get("flux_clip_threshold") if flux_clip_threshold is None: raise ValueError("data.flux_clip_threshold must be specified in config.") @@ -429,6 +393,11 @@ def build_rte_dataset_kwargs( if split_file_override else case_cfg.get("split_file") or data_cfg.get("split_file") ) + if not split_file: + raise ValueError( + "case.split_file is required. Configure a JSON split file instead " + "of percentage-based train/val/test splits." + ) seed = data_cfg.get("seed", None) if seed is None and "train" in cfg: @@ -463,18 +432,13 @@ def build_rte_dataset_kwargs( kwargs = { "data_path": cfg.case.data_path, - "task": task, "num_spatial_points": num_spatial_points, "adapter": adapter, "flux_normalization_stats_file": flux_stats_file, "normalize_coordinates": data_cfg.get("normalize_coordinates", True), "flux_clip_threshold": flux_clip_threshold, "split_file": split_file, - "train_split": data_cfg.get("train_split", 0.7), - "val_split": data_cfg.get("val_split", 0.15), "seed": seed, - "expand_timesteps": data_cfg.get("expand_timesteps", True), - "temporal_stride": data_cfg.get("temporal_stride", 1), "load_ground_truth_qoi": data_cfg.get("load_ground_truth_qoi", False), "cache_static_arrays": data_cfg.get("cache_static_arrays", True), "max_cache_size": data_cfg.get("max_cache_size", 200), @@ -508,7 +472,7 @@ class RTEDataPipe(Dataset): Combines: - * ``RTEBaseDataset`` (data source / file enumeration / timestep expansion) + * ``RTEBaseDataset`` (data source / file enumeration) * ``Compose`` of transforms (preprocessing pipeline) * ``TransolverAdapter`` (model-specific tensor packaging) @@ -525,14 +489,9 @@ def __init__( case_type: Optional[Literal["lattice", "hohlraum"]] = None, phase: Literal["train", "val", "test"] = "train", split_file: Optional[Union[str, Path]] = None, - train_split: float = 0.7, - val_split: float = 0.15, seed: Optional[int] = None, - expand_timesteps: bool = True, - temporal_stride: int = 1, cache_static_arrays: bool = True, max_cache_size: int = 200, - task: Literal["next_step", "steady_state"] = "next_step", ): """Initialize the datapipe (see ``from_config`` for the simple path).""" self.base_dataset = RTEBaseDataset( @@ -540,15 +499,10 @@ def __init__( case_type=case_type, phase=phase, split_file=split_file, - train_split=train_split, - val_split=val_split, seed=seed, load_sigma_fields=True, # load precomputed material properties for speed - expand_timesteps=expand_timesteps, - temporal_stride=temporal_stride, cache_static_arrays=cache_static_arrays, max_cache_size=max_cache_size, - task=task, ) self.transforms = transforms @@ -561,9 +515,9 @@ def __getitem__(self, idx: int) -> Any: """Get a sample from the dataset. ``base_dataset[idx]`` returns a ``TensorDict`` with tensor fields plus - ``metadata`` / ``filename`` / ``_timestep_idx`` as ``NonTensorData`` - entries. Transforms consume and return ``TensorDict``; the adapter - converts to the model-specific format. + ``metadata`` / ``filename`` as ``NonTensorData`` entries. Transforms + consume and return ``TensorDict``; the adapter converts to the + model-specific format. """ td = self.base_dataset[idx] if not isinstance(td, TensorDict): @@ -582,7 +536,6 @@ def from_config( cls, data_path: Union[str, Path], case_type: Optional[Literal["lattice", "hohlraum"]] = None, - task: Literal["next_step", "steady_state"] = "next_step", adapter: Optional[Literal["transolver", None]] = "transolver", phase: Literal["train", "val", "test"] = "train", # Data processing options @@ -591,12 +544,7 @@ def from_config( normalize_coordinates: bool = True, flux_clip_threshold: float = 1e-8, load_ground_truth_qoi: bool = False, - # Advanced options - temporal_stride: int = 1, - expand_timesteps: bool = True, split_file: Optional[Union[str, Path]] = None, - train_split: float = 0.7, - val_split: float = 0.15, seed: Optional[int] = None, # Cache options cache_static_arrays: bool = True, @@ -625,10 +573,8 @@ def from_config( transforms = _build_transforms( data_path=data_path, case_type=case_type, - task=task, flux_normalization_stats_file=flux_normalization_stats_file, flux_clip_threshold=flux_clip_threshold, - temporal_stride=temporal_stride, seed=seed, num_spatial_points=num_spatial_points, normalize_coordinates=normalize_coordinates, @@ -644,10 +590,6 @@ def from_config( include_q_in_embedding=include_q_in_embedding, ) - # steady_state always uses t=0 → t=T, so per-step expansion is moot. - if task == "steady_state": - expand_timesteps = False - return cls( data_path=data_path, transforms=transforms, @@ -655,14 +597,9 @@ def from_config( case_type=case_type, phase=phase, split_file=split_file, - train_split=train_split, - val_split=val_split, seed=seed, - expand_timesteps=expand_timesteps, - temporal_stride=temporal_stride, cache_static_arrays=cache_static_arrays, max_cache_size=max_cache_size, - task=task, ) def preload_to_memory(self, verbose: bool = True, num_workers: int = 8) -> dict: @@ -704,10 +641,8 @@ def _build_transforms( *, data_path: Union[str, Path], case_type: Optional[str], - task: str, flux_normalization_stats_file: Union[str, Path], flux_clip_threshold: float, - temporal_stride: int, seed: Optional[int], num_spatial_points: int, normalize_coordinates: bool, @@ -722,7 +657,7 @@ def _build_transforms( Normalization steps are delegated to PhysicsNeMo primitives: 1. ``RTEFluxLogClip`` + ``Normalize`` — flux: log+clip, then z-score. - 2. Temporal sampler — ``NextStepSampler`` | ``SteadyStateSampler``. + 2. ``SteadyStateSampler`` — first snapshot input, final snapshot target. 3. ``MaterialPropertyExtractor`` — always. 4. ``Normalize`` (``physical_properties``) — per-column z-score via broadcast. 5. ``SpatialSampler`` — always. @@ -746,12 +681,7 @@ def _build_transforms( Normalize(**flux_normalize_kwargs(flux_stats, field="scalar_flux")), ] - if task == "next_step": - transform_list.append(NextStepSampler(stride=temporal_stride, seed=seed)) - elif task == "steady_state": - transform_list.append(SteadyStateSampler()) - else: - raise ValueError(f"Unknown task: {task}") + transform_list.append(SteadyStateSampler()) transform_list.append(MaterialPropertyExtractor(case_type=case_type)) @@ -845,7 +775,6 @@ def create_dataset( case_type: Literal["lattice", "hohlraum"], data_path: Union[str, Path], phase: Literal["train", "val", "test"] = "train", - task: Literal["next_step", "steady_state"] = "next_step", adapter: Optional[Literal["transolver"]] = "transolver", **kwargs, ) -> RTEDataPipe: @@ -858,7 +787,6 @@ def create_dataset( data_path=data_path, case_type=case_type, phase=phase, - task=task, adapter=adapter, **kwargs, ) @@ -1148,7 +1076,7 @@ def build_dataloaders( ) if rank_zero: - logger.info(f"Task mode: {common_kwargs['task']}") + logger.info("Mapping mode: steady-state first-to-final flux") if common_kwargs["split_file"]: logger.info(f"Using predefined splits from: {common_kwargs['split_file']}") if common_kwargs["max_cache_size"] == -1: diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/train.py b/examples/cfd/nuclear_engineering/radiation_transport/src/train.py index 9e301c9193..3366be563e 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/train.py +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/train.py @@ -118,14 +118,8 @@ def to_device(batch: Dict[str, Any], device: torch.device) -> Dict[str, Any]: def forward( model: nn.Module, batch: Dict[str, Any], - *, - use_time_embeddings: bool = False, ) -> torch.Tensor: """Run a forward pass with the Transolver-expected input keys.""" - if use_time_embeddings: - return model( - fx=batch["fx"], embedding=batch["embedding"], time=batch["time"] - ) return model(fx=batch["fx"], embedding=batch["embedding"]) @@ -191,7 +185,6 @@ def train_epoch( *, loss_cfg: Dict[str, Any], case_type: str, - use_time_embeddings: bool, gradient_accumulation_steps: int = 1, use_amp: bool = True, amp_dtype: Optional[torch.dtype] = None, @@ -204,9 +197,7 @@ def train_epoch( batch = to_device(batch, device) with autocast(enabled=use_amp, device_type=device.type, dtype=amp_dtype): - prediction = forward( - model, batch, use_time_embeddings=use_time_embeddings - ) + prediction = forward(model, batch) # Transolver predicts absolute flux directly — no reconstruction step. pred, target = prediction, batch["flux_target"] @@ -258,7 +249,6 @@ def validate( *, loss_cfg: Dict[str, Any], case_type: str, - use_time_embeddings: bool, use_amp: bool = True, amp_dtype: Optional[torch.dtype] = None, ) -> Tuple[float, int, Dict[str, float], Dict[str, int]]: @@ -280,9 +270,7 @@ def accumulate_metric(name: str, value: Any) -> None: batch = to_device(batch, device) with autocast(enabled=use_amp, device_type=device.type, dtype=amp_dtype): - prediction = forward( - eval_model, batch, use_time_embeddings=use_time_embeddings - ) + prediction = forward(eval_model, batch) pred, target = prediction, batch["flux_target"] @@ -421,14 +409,10 @@ def main(cfg: DictConfig) -> None: if resumed_val_losses: best_val_losses = resumed_val_losses - # --- forward kwargs --- - use_time_embeddings = bool(cfg.model.get("time_input", False)) - # --- per-epoch hooks --- shared_kwargs = { "loss_cfg": loss_cfg, "case_type": cfg.case.type, - "use_time_embeddings": use_time_embeddings, "use_amp": use_amp, "amp_dtype": amp_dtype, } diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/trainer.py b/examples/cfd/nuclear_engineering/radiation_transport/src/trainer.py index 5e68aa3245..69dd2e4778 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/trainer.py +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/trainer.py @@ -295,7 +295,7 @@ def setup_training_environment( Returns: ``(dist, logger)``. """ - DistributedManager.initialize() + _initialize_distributed_manager() dist = DistributedManager() synchronize_output_directory(cfg, dist) @@ -314,6 +314,34 @@ def setup_training_environment( return dist, logger +def _initialize_distributed_manager() -> None: + """Initialize distributed state without misreading an interactive SLURM shell. + + PhysicsNeMo's default initializer auto-detects SLURM variables. In an + allocated shell, plain ``python src/train.py`` can inherit those variables + even though only one Python process was launched, causing process-group + setup to wait for ranks that do not exist. For this example, DDP should be + entered via ``torchrun`` (or an explicit PhysicsNeMo init method); otherwise + run as a normal single process. + """ + if DistributedManager.is_initialized(): + return + + explicit_method = os.getenv("PHYSICSNEMO_DISTRIBUTED_INITIALIZATION_METHOD") + torchrun_env = os.getenv("RANK") is not None and os.getenv("WORLD_SIZE") is not None + openmpi_env = os.getenv("OMPI_COMM_WORLD_RANK") is not None + + if explicit_method or torchrun_env or openmpi_env: + DistributedManager.initialize() + return + + DistributedManager._shared_state["_is_initialized"] = True + dist = DistributedManager() + dist._initialization_method = "single" + if torch.cuda.is_available(): + torch.cuda.set_device(dist.device) + + def wrap_ddp( model: nn.Module, dist: DistributedManager, diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/transforms.py b/examples/cfd/nuclear_engineering/radiation_transport/src/transforms.py index b845ad80c0..f3e269039a 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/transforms.py +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/transforms.py @@ -41,8 +41,7 @@ # ========================================================================= from pathlib import Path -from typing import Any, Dict, Mapping, Optional, Tuple, Type, Union -import warnings +from typing import Any, Dict, Mapping, Optional, Type, Union import numpy as np import torch @@ -408,12 +407,11 @@ def __call__(self, data: TensorDict) -> TensorDict: # ========================================================================= -# Sampling (spatial + temporal) +# Sampling (spatial + steady-state) # ========================================================================= # # ``SpatialSampler`` randomly subsamples / pads point clouds to a target size. -# ``TemporalSampler`` and its subclasses define the prediction task structure -# (next-step vs. steady-state). +# ``SteadyStateSampler`` extracts the fixed initial->final flux mapping. @_register_local("RTESpatialSampler") @@ -526,107 +524,35 @@ def extra_repr(self) -> str: return f"num_points={self.num_points}" -class TemporalSampler(Transform): - """Base class for temporal sampling strategies. - - Subclasses implement different prediction tasks: - - NextStepSampler: Predict t+stride from t - - SteadyStateSampler: Predict t=T from t=0 - """ +@_register_local("RTESteadyStateSampler") +class SteadyStateSampler(Transform): + """Extract the fixed steady-state mapping: first flux -> final flux.""" - def select_time_indices( - self, num_timesteps: int, rng: np.random.Generator - ) -> Tuple[int, int]: - raise NotImplementedError + def __init__(self): + super().__init__() def __call__(self, data: TensorDict) -> TensorDict: - """Apply temporal sampling; extracts input/target flux slices.""" - num_timesteps = data["scalar_flux"].shape[0] - - if "_timestep_idx" in data: - original_idx = int(data["_timestep_idx"]) + flux_all = data["scalar_flux"] + if flux_all.shape[0] == 0: + raise ValueError("scalar_flux must contain at least one snapshot") - metadata = td_get(data, "metadata", default={}) or {} - max_timestep = ( - metadata.get("max_timestep") if isinstance(metadata, dict) else None - ) - if max_timestep is not None: - selective_loading_used = (max_timestep + 1) != num_timesteps - else: - selective_loading_used = original_idx >= num_timesteps - - if selective_loading_used: - input_idx = 0 - target_idx = min(self.stride, num_timesteps - 1) - data.set_non_tensor("timestep_input", original_idx) - data.set_non_tensor("timestep_target", original_idx + self.stride) - else: - input_idx = original_idx - target_idx = min(original_idx + self.stride, num_timesteps - 1) - data.set_non_tensor("timestep_input", input_idx) - data.set_non_tensor("timestep_target", target_idx) - - del data["_timestep_idx"] - else: - rng = np.random.default_rng() - input_idx, target_idx = self.select_time_indices(num_timesteps, rng) - data.set_non_tensor("timestep_input", int(input_idx)) - data.set_non_tensor("timestep_target", int(target_idx)) + input_idx = 0 + target_idx = flux_all.shape[0] - 1 + metadata = td_get(data, "metadata", default={}) or {} + max_timestep = ( + metadata.get("max_timestep") if isinstance(metadata, dict) else None + ) - flux_all = data["scalar_flux"] data["flux_input"] = flux_all[input_idx].clone() data["flux_target"] = flux_all[target_idx].clone() + data.set_non_tensor("timestep_input", 0) + data.set_non_tensor( + "timestep_target", + int(max_timestep) if max_timestep is not None else int(target_idx), + ) return data -@_register_local("RTENextStepSampler") -class NextStepSampler(TemporalSampler): - """Sample for next-step prediction task. - - Selects random timestep t and predicts t+stride. - """ - - def __init__(self, stride: int = 1, seed: Optional[int] = None): - super().__init__() - self.stride = stride - self.seed = seed - self.rng = np.random.default_rng(seed) if seed is not None else None - - def select_time_indices( - self, num_timesteps: int, rng: np.random.Generator - ) -> Tuple[int, int]: - if self.rng is not None: - rng = self.rng - - max_start = num_timesteps - self.stride - if max_start <= 0: - warnings.warn( - "Not enough timesteps to sample for next-step prediction. " - "Using first and last timestep." - ) - return 0, num_timesteps - 1 - - input_idx = rng.integers(0, max_start) - target_idx = input_idx + self.stride - return input_idx, target_idx - - def extra_repr(self) -> str: - return f"stride={self.stride}" - - -@_register_local("RTESteadyStateSampler") -class SteadyStateSampler(TemporalSampler): - """Sample for steady state prediction task (t=0 input, t=T target).""" - - def __init__(self): - super().__init__() - - def select_time_indices( - self, num_timesteps: int, rng: np.random.Generator - ) -> Tuple[int, int]: - return 0, num_timesteps - 1 - - # ========================================================================= # QoI loader # ========================================================================= @@ -738,8 +664,6 @@ def extra_repr(self) -> str: "StandardScaler", # Sampling "SpatialSampler", - "TemporalSampler", - "NextStepSampler", "SteadyStateSampler", # QoI "LoadGroundTruthQoI", From 5d297ca76b339783b9522df7a8b539724e231928 Mon Sep 17 00:00:00 2001 From: Carmelo Gonzales Date: Mon, 4 May 2026 13:57:39 -0700 Subject: [PATCH 16/68] docs: update readme --- .../radiation_transport/README.md | 406 ++++-------------- .../radiation_transport/requirements.txt | 10 - 2 files changed, 85 insertions(+), 331 deletions(-) delete mode 100644 examples/cfd/nuclear_engineering/radiation_transport/requirements.txt diff --git a/examples/cfd/nuclear_engineering/radiation_transport/README.md b/examples/cfd/nuclear_engineering/radiation_transport/README.md index 4d5e835713..7f07bf4c11 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/README.md +++ b/examples/cfd/nuclear_engineering/radiation_transport/README.md @@ -1,25 +1,12 @@ # Radiation Transport with Transolver A PhysicsNeMo example that trains a [Transolver](https://arxiv.org/abs/2402.02366) -surrogate for the steady-state radiative-transfer equation on two canonical -2-D benchmarks from the thermal-radiation-transport literature: **lattice** -and **hohlraum**. The training pipeline uses a physics-informed loss that -combines region-weighted MSE on the scalar flux with a quantity-of-interest -(QoI) penalty. - ---- - -## Table of contents - -1. [The science](#1-the-science) -2. [Installation](#2-installation) -3. [Dataset](#3-dataset) -4. [Training](#4-training) -5. [Evaluation](#5-evaluation) -6. [Interpreting model performance](#6-interpreting-model-performance) -7. [Configuration reference](#7-configuration-reference) -8. [File overview](#8-file-overview) -9. [References](#9-references) +surrogate model for the 2-D linear radiation transport benchmark defined in +[Reference solutions for linear radiation transport: the Hohlraum and Lattice +benchmarks](https://arxiv.org/pdf/2505.17284). The pipeline learns the +steady-state mapping from the initial flux snapshot to the final scalar flux, +using a physics-informed loss that combines region-weighted MSE with a +quantity-of-interest (QoI) penalty based on absorption in key regions. --- @@ -31,31 +18,32 @@ flux field `φ(x)` over a 2-D domain. Inputs to the surrogate are: - **Coordinates** `(x, y)` per cell, normalized to `[-1, 1]` and augmented with Fourier features (3 frequencies × 2 axes × {sin, cos} = 12 extra channels). - **Material properties** per cell: absorption coefficient `σ_a`, scattering - coefficient `σ_s`, and total `σ_t = σ_a + σ_s`. Lattice cases additionally - include a heat source `Q`. + coefficient `σ_s`, total cross-section `σ_t`, and, for lattice cases, heat + source `Q`. Boundary input flux may be incorporated from upstream hohlraum + data, but it is not used as a model input in this example. The surrogate predicts the **z-score-of-log scalar flux**, which is then inverted via `transforms.denormalize_flux` to recover the physical flux. ### 1.1 Lattice benchmark -A unit square partitioned into a 7×7 grid of material blocks. Each block is +A square domain partitioned into a 7×7 grid of material blocks. Each block is either **absorber** (high `σ_a`, low `σ_s`), **scatterer** (low `σ_a`, high `σ_s`), or **source** (interior `Q > 0`). The model has to capture sharp flux -discontinuities at material interfaces and reproduce the integrated -absorption rate in the central region. +discontinuities at material interfaces and reproduce the instantaneous +absorption rate in the absorbing regions. -QoI: integrated absorption `∫_Ω_c σ_a · φ dA` over the central source block. +QoI: instantaneous absorption `σ_a · φ · A` over the absorbing blocks. ### 1.2 Hohlraum benchmark -An axisymmetric cylindrical cavity with optional interior void regions, +An axisymmetric cylindrical cavity with interior void regions, representing a simplified inertial-confinement-fusion target. There is no interior heat source — flux enters from boundary conditions and propagates through the cavity. Geometry parameters (upper/lower laser-entry radii, center offsets) vary across simulations. -QoI: per-region integrated absorption over `{center, vertical strip, +QoI: per-region instantaneous absorption over `{center, vertical strip, horizontal strip, total domain}`. By default `train.physics_loss.qoi_region=all` averages all four region losses so every region contributes to the gradient; set it to a single region (`center`, `vertical`, `horizontal`, `total`) to @@ -65,73 +53,27 @@ logged each batch. --- ## 2. Installation - -The example is in the PhysicsNeMo repo. From the example directory: - -```bash -cd physicsnemo/examples/cfd/nuclear_engineering/radiation_transport -pip install -r requirements.txt -``` - Prerequisites: - **PyTorch ≥ 2.6** — `torch.optim.Muon` is built in. Earlier PyTorch versions work if you stick to the default `train.optimizer.type=adam`. - **PhysicsNeMo** — install the host repo with `[model-extras,datapipes-extras]` to get `physicsnemo.models.transolver.Transolver` and the `tensordict`-based - data utilities. We don't need `gnns` / `mesh-extras` / `uq-extras`. - -Quick install via `uv` (PhysicsNeMo's recommended package manager): - -```bash -cd -uv venv .venv --python 3.13 -source .venv/bin/activate -# Pick the CUDA wheel that matches your driver: -# driver supports CUDA 13.x -> cu13 (default in pyproject) -# driver only supports 12.x -> cu12 -uv pip install -e ".[cu12,model-extras,datapipes-extras]" -uv pip install tensorboard # for TB logging -``` - -#### TransformerEngine (default `model.use_te=true`) - -The Transolver model imports `transformer_engine.pytorch` at module load -time, even when `use_te=false`. You must have a TE wheel matching your -PyTorch CUDA version installed. For the cu12 venv: - -```bash -uv pip install --reinstall --no-cache transformer-engine-cu12==2.14.1 -uv pip install transformer-engine-torch transformer_engine -``` - -> **uv quirk:** The first `--reinstall --no-cache` is required. Without -> it, uv may silently drop the 855 MB `libtransformer_engine.so` from the -> wheel and the import will fail with `Could not find shared object file -> for Transformer Engine core lib`. - -If your driver is on CUDA 13, swap `cu12` → `cu13` everywhere above. + data utilities. -Verify: +From the PhysicsNeMo repo root, install the example dependencies: ```bash -python -c " -from physicsnemo.models.transolver import Transolver -from torch.optim import Muon -import zarr, tensordict, torch -print('torch:', torch.__version__, 'cuda:', torch.cuda.is_available()) -print('Transolver:', Transolver) -" +uv pip install -e ".[model-extras,datapipes-extras]" tensorboard ``` --- ## 3. Dataset -### 3.1 Download +### 3.1 Data source -> **TODO:** HuggingFace dataset URL. Until then, raw simulation data has to -> be curated through the upstream RTE workshop pipeline. +**TODO:** HuggingFace dataset URL. Until then, raw simulation data may be curated from the [KiT-RT repositories](https://github.com/KiT-RT). ### 3.2 Expected on-disk layout @@ -155,17 +97,18 @@ print('Transolver:', Transolver) ### 3.3 What's in each zarr store -Each `*.zarr/` directory is one simulation. Keys (read by `dataset.ZarrDataReader`): +Each `*.zarr/` directory is one simulation. The loader uses the first and final +`scalar_flux` snapshots and ignores intermediate snapshots. | Key | Shape | Notes | |---|---|---| -| `scalar_flux` | `(T, N)` or `(N,)` | physical flux (W m⁻²·sr⁻¹). Steady-state stores have `T=1`. | +| `scalar_flux` | `(T, N)` or `(N,)` | physical flux | | `sigma_a`, `sigma_s` | `(N,)` | absorption / scattering coefficients per cell | | `Q` | `(N,)` | heat source (lattice only; zeros in hohlraum) | | `coordinates` | `(N, 2)` | cell-center positions in physical units | | `cell_areas` | `(N,)` | per-cell areas — used by physics loss for surface integrals | | `material_labels` | `(N,)` | integer region IDs (consumed by `LatticeMaterialMapper` / `HohlraumMaterialMapper`) | -| `metadata` | dict | timestep, sim_time, geometry params (hohlraum), filename | +| `metadata` | dict | final simulation time, geometry params (hohlraum), filename | `N` is the number of cells per simulation (~tens of thousands). Different simulations may have different `N` — point-cloud collation handles this. @@ -194,11 +137,10 @@ JSON document with a `"splits"` key: Filenames in the splits arrays are zarr **basenames** without the `.zarr` suffix; the reader appends it automatically when opening stores. -If the splits file is named with a suffix (e.g. `lattice_splits_default.json`, -`lattice_splits_overfit_1sample.json`), point at it explicitly: +If the splits file is named with a different suffix, point at it explicitly: ```bash -... case.split_file=/splits/lattice_splits_overfit_1sample.json +... case.split_file=/splits/my_split_file.json ``` ### 3.5 Computing normalization stats @@ -209,20 +151,20 @@ flux stats), generate them with: ```bash python src/compute_normalizations.py \ - --data_path /lattice \ + --data_path /Datasets/lattice \ --case_type lattice \ - --output_dir /stats \ - --steady_state + --split_file /Datasets/splits/lattice_splits.json \ + --output_dir /Datasets/stats python src/compute_normalizations.py \ - --data_path /hohlraum \ + --data_path /Datasets/hohlraum \ --case_type hohlraum \ - --output_dir /stats \ - --steady_state + --split_file /Datasets/splits/hohlraum_splits.json \ + --output_dir /Datasets/stats ``` -Pass `--split_file ...` to compute stats over the train split only (matches -what training will see). Drop `--steady_state` for time-resolved data. +`--split_file` is required so stats are computed over the same train split +used by training. The flux stats YAML contains the log-flux mean/std/min/max + `clip_threshold`, used by `RTEFluxLogClip` and `denormalize_flux`. The material stats YAML @@ -233,6 +175,10 @@ contains per-channel mean/std/min/max for `{σ_a, σ_s, σ_t, Q}`. ## 4. Training ### 4.1 Quick start +Full-mesh training used at least a 48 GB GPU during development (RTX6000 Ada). +By default `data.preload_data=true`, so the train and validation splits are +loaded into host RAM before training. Disable with `data.preload_data=false` +if RAM is tight, at the cost of slower zarr reads. Lattice: @@ -256,11 +202,11 @@ torchrun --nproc_per_node=N src/train.py \ case=lattice data=lattice case.data_root= ``` -DDP is auto-detected via `physicsnemo.distributed.DistributedManager`. Set -`data.preload_data=true` (default) so each rank loads its static arrays into -host RAM through a sequenced barrier; this is much faster than re-reading -zarr per epoch but uses ~`N_train × N × 4 bytes × num_static_fields` of memory -on rank 0. +Use `torchrun` for DDP. A plain `python src/train.py ...` launch runs as a +single process, even inside an allocated SLURM shell. Set `data.preload_data=true` +(default) so each rank loads static arrays through a sequenced barrier; this is +faster than re-reading zarr per epoch but uses host RAM proportional to the +training split size. ### 4.3 Common overrides @@ -275,7 +221,8 @@ on rank 0. | `model.num_spatial_points=8192` | Subsample cells per training step (–1 = use all) | | `model.n_layers=12 model.n_hidden=384` | Bigger Transolver | | `model.use_te=true` | Use NVIDIA TransformerEngine layers (requires `[model-extras]`) | -| `train.resume_checkpoint=path/to/checkpoint.0.0.pt` | Resume from a checkpoint | +| `train.resume_checkpoint=.../checkpoints/latest_checkpoint` | Resume from a checkpoint directory | +| `train.latest_checkpoint_interval=0` | Disable the rolling `latest_checkpoint/` directory (`null` also works) | ### 4.4 Output structure @@ -288,18 +235,19 @@ outputs/RTE_Transolver/lattice/transolver/ │ ├── hydra.yaml │ └── overrides.yaml ├── checkpoints/ -│ ├── checkpoint.0.0.pt # latest training-state checkpoint (every train.checkpoint_interval) -│ ├── Transolver.0.0.mdlus # latest model weights only +│ ├── checkpoint.0.0.pt # periodic training-state checkpoint (every train.checkpoint_interval) +│ ├── Transolver.0.0.mdlus # periodic model weights +│ ├── latest_checkpoint/ # rolling full-state resume checkpoint (train.latest_checkpoint_interval) │ ├── best_model_epoch_/ # snapshot of the lowest val_loss epoch -│ ├── best_qoi_model/ # snapshot of the lowest val_qoi epoch (use this for QoI eval) +│ ├── best_qoi_model/ # snapshot of the lowest validation QoI-loss epoch │ └── top_model/ # current top-1 by val_loss ├── tensorboard/ # TB event files (open with `tensorboard --logdir tensorboard/`) └── train.log ``` -`best_qoi_model/` is the checkpoint to feed `inference.py` when comparing -runs by QoI relative error. `best_model_epoch_/` and `top_model/` track -val MSE. + +When loading checkpoints, `best_model_epoch_/` and `top_model/` track +validation loss, while `best_qoi_model/` tracks validation QoI loss. --- @@ -309,9 +257,10 @@ val MSE. ```bash python src/inference.py \ - --checkpoint_dir outputs/RTE_Transolver/lattice/transolver/checkpoints/best_qoi_model \ + --checkpoint_dir outputs/RTE_Transolver/lattice/transolver/checkpoints/top_model \ --data_path \ --case_type lattice \ + --split_file /splits/lattice_splits.json \ --output_dir results/lattice ``` @@ -319,14 +268,15 @@ CLI options: | Flag | Effect | |---|---| -| `--checkpoint_dir DIR` | A directory containing `Transolver.0.0.mdlus` + `checkpoint.0.0.pt`. Pass either a `best_*/` snapshot dir or the run's `checkpoints/` root (where `find_best_checkpoint` will pick the latest). | -| `--data_path DIR` | The same `` you trained against. The script overrides `case.data_root`/`split_file`/`stats` paths from this. | +| `--checkpoint_dir DIR` | A directory containing `Transolver.0.0.mdlus` + `checkpoint.0.0.pt`. Pass either a `best_*/` snapshot dir or the run's `checkpoints/` root (where inference will use `top_model`). | +| `--data_path DIR` | The same `` you trained against. The script overrides `case.data_root` and stats paths from this. | | `--case_type {lattice,hohlraum}` | Required. | +| `--split_file FILE` | Required explicit split JSON. | | `--output_dir DIR` | Where to write metrics + figures. Default: `/evaluation`. | | `--num_samples N` | Limit to the first `N` test simulations (default: all). | | `--num_workers N` | DataLoader workers. | | `--device {cpu,cuda,cuda:0,...}` | Defaults to CUDA if available. | -| `--num_plot_samples N` | Number of `flux_panels_.png` figures to write (default: 4). | +| `--num_plot_samples N` | Number of `flux_panels_.png` figures to write (default: 3). | ### 5.2 Outputs @@ -337,7 +287,7 @@ CLI options: └── figures/ ├── flux_panels_.png # target / prediction / error 3-panel for sample ├── true_vs_pred.png # scatter of all (true, pred) flux values - └── error_histogram.png # distribution of pointwise (pred − true) + └── error_histogram.png # distribution of pointwise absolute error ``` ### 5.3 Metric definitions @@ -369,19 +319,19 @@ dominating the mean). | `median_relative_error_pct` | median of the same | | `max_relative_error_pct` | worst single-simulation relative error | -For lattice, the only region is `cur_absorption` (central source block). For -hohlraum, you'll see entries keyed by whichever `qoi_region` was active -during training. +For lattice, the only region is `cur_absorption`. For hohlraum, inference +reports center, vertical, horizontal, and total QoI components when metadata is +available. ### 5.4 Comparing runs -The single most useful comparison is **`qoi_metrics.yaml::::mean_relative_error_pct`**. -Below 5% on hohlraum-center is competitive with classical solvers on these -benchmarks; below 1% is the published Transolver target after full training. +The single most useful comparison is +**`qoi_metrics.yaml::::mean_relative_error_pct`**. On the default +randomized splits, a well-trained surrogate should reach low single-digit +percent QoI error. -For field-level comparisons, use `metrics.yaml::overall::l2_relative_error`. -Values below 5% indicate the model has learned the global flux structure; -below 1% means it's also picking up sharp interface features. +For field-level comparisons, use `metrics.yaml::overall::l2_relative_error`, +which helps interpret global flux structure and sharp interface features. --- @@ -391,74 +341,34 @@ below 1% means it's also picking up sharp interface features. | Benchmark | l2_relative_error | QoI mean_relative_error_pct | |---|---|---| -| Lattice (center QoI) | 1–3% | 0.5–2% | -| Hohlraum (center QoI) | 2–5% | 1–3% | +| Lattice (absorption QoI) | 0.60% | 0.23% | +| Hohlraum (regional QoI) | 2.06% | 0.52–0.73% | -These targets assume the default Transolver size (`n_layers=8, n_hidden=256, -slice_num=128`) and the published 7×7 lattice / variable-geometry hohlraum -distribution. +These observed values come from the default full-training runs with defaults configs. Training logs +converged to final validation losses of about `2.10e-05` for lattice and +`1.51e-05` for hohlraum. ### 6.2 Reading the training log -Per-epoch line you'll see in `train.log`: +Each epoch logs train/validation MSE, QoI loss, learning rate, and checkpoint +updates. A typical completed epoch looks like: ``` -Epoch 250: train_loss=4.23e-03, val_loss=5.91e-03, - train_mse=4.18e-03, val_mse=5.84e-03, - train_qoi=2.15e-02, val_qoi=2.43e-02, - train_qoi_center=2.15e-02, val_qoi_center=2.43e-02, - lr=2.81e-05 +Epoch 500: train_loss=1.7081e-05, val_loss=2.0973e-05, train_mse=1.7032e-05, val_mse=2.0900e-05, train_qoi=9.8040e-06, val_qoi=1.4658e-05, lr=1.00e-06 +[checkpoint][INFO] - Saved model state dictionary: ./checkpoints/Transolver.0.500.mdlus +Training completed! +Top validation losses: ['0.000021', '0.000021', '0.000021'] +Best QoI loss: 5.887844e-06 ``` -Key signals: - -- **`val_loss` plateauing while `train_loss` keeps falling** → overfitting. - Lower `model.dropout`, raise `train.region_weights.material_weight`, or - use `--num_samples` per-rank subsetting to grow the effective training - data. -- **`val_qoi` stuck near 1.0** while `val_mse` shrinks → model is learning - the bulk flux but not preserving the integral. Increase - `train.physics_loss.weight`, or extend `train.physics_loss.warmup_epochs` - to let MSE settle first. -- **`val_loss` oscillates wildly** → reduce LR (`train.learning_rate=1e-5`) - or shorten `train.warmup_epochs`. -- **`lr` dropping below `train.min_learning_rate`** late in training → cosine - schedule has bottomed out; consider a longer `train.epochs` for a slower - decay. - ### 6.3 Reading the inference figures -- **`flux_panels_.png`** — three panels: target, prediction, signed - error. Sharp interface features in the target should appear (slightly - blurred) in the prediction; the error panel should be near-zero in - homogeneous regions and concentrated along material interfaces. Persistent - bias of one sign (all-positive or all-negative error) indicates a - systematic offset — usually a normalization stat issue. +- **`flux_panels_.png`** — three panels: target, prediction, absolute + error. - **`true_vs_pred.png`** — points should lie close to the `y = x` diagonal - across the full dynamic range. A "fan" near the origin is normal (low-flux - void cells are hard); fans at the high end are not normal and usually - indicate undertraining or saturation in the model's last layer. -- **`error_histogram.png`** — should be symmetric around zero with thin - tails. Heavy-tailed asymmetric errors typically mean the QoI loss is - off-balance with the MSE loss. - -### 6.4 Common pitfalls - -- **Hohlraum's `embedding_dim` mismatch.** With `case.include_q_in_embedding=false` - the adapter produces 3 channels (no `Q`), so the model's `embedding_dim` - must also be 3. The `case/hohlraum.yaml::embedding_dim_override: 3` - handles this; if you override `model.embedding_dim` directly without - matching the case config you'll get a silent shape mismatch at the first - forward pass. -- **AMP underflow on the QoI integral.** `train.amp=true` casts the forward - pass to bf16, but the physics loss runs in fp32 internally (denormalized - flux is sensitive to log-domain spread). If you see `loss_qoi=NaN` early - in training, check that your dataset's flux range fits inside - `clip_threshold` correctly. -- **Stale `top_model/` after CLI override of `output:`.** The `top_model/` - symlink is per-run; if you rerun with the same `${output}` path the new - run will overwrite the old top model. Either change `exp_tag=...` or - `output=...` to keep separate run trees. + across the full dynamic range. +- **`error_histogram.png`** — distribution of pointwise absolute error; lower + and thinner tails are better. --- @@ -504,150 +414,4 @@ The Hydra group structure means `case=hohlraum` swaps the entire `train/base.yaml` and `model/transolver.yaml` interpolate from `${case.*}` so case-specific overrides propagate automatically. ---- - -## 8. File overview - -| File | LOC | Purpose | -|---|---|---| -| `src/train.py` | ~500 | Hydra entry; inlined Transolver build/forward/loss_inputs; dispatches to trainer | -| `src/trainer.py` | ~700 | Training loop, gradient accumulation, DDP primitives, TB logging | -| `src/losses.py` | ~1080 | MSE / region-weighted / physics-informed losses, torch QoI helpers, schedulers | -| `src/checkpointing.py` | ~600 | Save/load checkpoints, resume, optimizer (Adam + `torch.optim.Muon`) and scheduler factory | -| `src/inference.py` | ~850 | Checkpoint inference, metrics, plots; argparse CLI | -| `src/compute_normalizations.py` | ~440 | One-shot CLI to compute flux + material statistics over a zarr root | -| `src/dataset.py` | ~860 | Zarr reader, PyTorch Dataset, stats loaders | -| `src/transforms.py` | ~740 | Transform framework + flux / coordinate / sampling / QoI transforms | -| `src/material.py` | ~530 | Lattice/hohlraum material mappers + material lookup transform | -| `src/loader.py` | ~1200 | TransolverAdapter, collate, datapipe orchestration, DataLoader factory | - -Total: ~7.5 KLOC across 10 flat source files (no `__init__.py`, no -subpackages). - ---- - -## 9. References - -- **Transolver:** Wu, H. et al. ["Transolver: A Fast Transformer Solver for - PDEs on General Geometries"](https://arxiv.org/abs/2402.02366), ICML 2024. -- **Lattice benchmark:** Brunner, T. A. (2002). *Forms of approximate - radiation transport*, SAND2002-1778. -- **Hohlraum benchmark:** Tencer, J. et al. (2018). *A multifidelity Monte - Carlo method for thermal radiation transport*, JCP 376. -- **PhysicsNeMo:** [github.com/NVIDIA/physicsnemo](https://github.com/NVIDIA/physicsnemo). - ---- - -## 10. Full-dataset commands (this workstation) - -Tested layout for the local high-fidelity dataset at -`/home/carmelog/Projects/Datasets/RTE/high_fidel_zarr/new_zarr_stores_t0_tfinal/` -(709 lattice sims, 846 hohlraum sims, splits and pre-computed flux + material -stats already present). The default `case/{lattice,hohlraum}.yaml` paths -match this layout exactly — no `case.split_file` or -`data.flux_normalization_stats_file` overrides needed. - -### Setup (once) - -This workstation has two GPUs: GPU 0 is an RTX A400 (4 GB, too small for the -default model), GPU 1 is an RTX 6000 Ada (48 GB) — these are the indices -`nvidia-smi -L` reports. Pin training to GPU 1. - -> **Note on GPU ordering.** PyTorch defaults to FASTEST_FIRST ordering, which -> swaps the two cards relative to `nvidia-smi`. Setting -> `CUDA_DEVICE_ORDER=PCI_BUS_ID` makes the CUDA indices match -> `nvidia-smi`'s, so `CUDA_VISIBLE_DEVICES=1` reliably picks the 6000 Ada. - -```bash -# Activate the cu12 venv (PyTorch with CUDA 12.8, matches the 570.x driver). -source /home/carmelog/Projects/Workshops/RTE/physicsnemo/.venv/bin/activate -cd /home/carmelog/Projects/Workshops/RTE/physicsnemo/examples/cfd/nuclear_engineering/radiation_transport - -# Pin training to the RTX 6000 Ada (PCI index 1). -export CUDA_DEVICE_ORDER=PCI_BUS_ID -export CUDA_VISIBLE_DEVICES=1 -export DATA_ROOT=/home/carmelog/Projects/Datasets/RTE/high_fidel_zarr/new_zarr_stores_t0_tfinal -``` - -Sanity-check (should print `True NVIDIA RTX 6000 Ada Generation`): - -```bash -python -c "import torch; print(torch.cuda.is_available(), torch.cuda.get_device_name(0))" -``` - -### Train — lattice (default config: 501 epochs, Muon, AMP-bf16) - -Single line (paste-safe — no backslash continuations to break across lines): - -```bash -python src/train.py case=lattice data=lattice case.data_root=$DATA_ROOT train.optimizer.type=muon exp_tag=lattice_full -``` - -### Train — hohlraum - -```bash -python src/train.py case=hohlraum data=hohlraum case.data_root=$DATA_ROOT train.optimizer.type=muon exp_tag=hohlraum_full -``` - -> **Heads-up.** If you see Hydra's `LexerNoViableAltException` with a stray -> `^`, it means an override expanded to empty — usually `$DATA_ROOT` wasn't -> exported in the current shell. Run `echo "$DATA_ROOT"` to confirm it's -> non-empty before launching. - -The hohlraum config picks up `physics_loss_weight=0.01`, `qoi_region=center`, -`include_q_in_embedding=false`, and `embedding_dim_override=3` automatically -from `case/hohlraum.yaml`. - -### Monitor training - -```bash -# Live log -tail -f outputs/RTE_Transolver/lattice/lattice_full/train.log -# Or hohlraum -tail -f outputs/RTE_Transolver/hohlraum/hohlraum_full/train.log - -# TensorBoard -tensorboard --logdir outputs/RTE_Transolver --port 6006 -``` - -### Evaluate — lattice - -```bash -python src/inference.py --checkpoint_dir outputs/RTE_Transolver/lattice/lattice_full/checkpoints/best_qoi_model --data_path $DATA_ROOT --case_type lattice --output_dir results/lattice_full -``` - -### Evaluate — hohlraum - -```bash -python src/inference.py --checkpoint_dir outputs/RTE_Transolver/hohlraum/hohlraum_full/checkpoints/best_qoi_model --data_path $DATA_ROOT --case_type hohlraum --output_dir results/hohlraum_full -``` - -After both runs you'll have: - -``` -results/ -├── lattice_full/ -│ ├── metrics.yaml # field-level: l2_relative_error is the headline number -│ ├── qoi_metrics.yaml # cur_absorption: target <2% mean_relative_error_pct -│ └── figures/{flux_panels_*.png, true_vs_pred.png, error_histogram.png} -└── hohlraum_full/ - ├── metrics.yaml - ├── qoi_metrics.yaml # qoi_: target <3% mean_relative_error_pct on center - └── figures/{...} -``` - -Compare runs by `qoi_metrics.yaml::::mean_relative_error_pct` for QoI -fidelity, and `metrics.yaml::overall::l2_relative_error` for global flux -fidelity. See §6.1 for target ranges on each benchmark. - -### Optional: shorter run for a first pass - -If you want to confirm the pipeline before committing to the full 501-epoch -schedule, run 50 epochs first: - -```bash -python src/train.py case=lattice data=lattice case.data_root=$DATA_ROOT train.optimizer.type=muon train.epochs=50 train.warmup_epochs=5 exp_tag=lattice_quick -``` -Expect `val_loss` around 5e-2–1e-1 and `val_qoi` somewhere below 0.5 by -epoch 50 with the default model size on the full dataset. diff --git a/examples/cfd/nuclear_engineering/radiation_transport/requirements.txt b/examples/cfd/nuclear_engineering/radiation_transport/requirements.txt deleted file mode 100644 index 725b66adbc..0000000000 --- a/examples/cfd/nuclear_engineering/radiation_transport/requirements.txt +++ /dev/null @@ -1,10 +0,0 @@ -hydra-core>=1.3 -omegaconf>=2.3 -zarr<3 -tensordict -numpy -scipy -matplotlib -pyyaml -pandas -tqdm From c32817de1be67d53ef42ae2aa3cdb7086c3308dc Mon Sep 17 00:00:00 2001 From: Carmelo Gonzales Date: Mon, 4 May 2026 14:12:21 -0700 Subject: [PATCH 17/68] feat: purging some unused code --- .../src/compute_normalizations.py | 2 +- .../radiation_transport/src/dataset.py | 16 +- .../radiation_transport/src/inference.py | 2 +- .../radiation_transport/src/loader.py | 73 +----- .../radiation_transport/src/losses.py | 102 -------- .../radiation_transport/src/transforms.py | 247 ++---------------- 6 files changed, 27 insertions(+), 415 deletions(-) diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/compute_normalizations.py b/examples/cfd/nuclear_engineering/radiation_transport/src/compute_normalizations.py index 989ee62287..6e9b83ae17 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/compute_normalizations.py +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/compute_normalizations.py @@ -52,6 +52,7 @@ import numpy as np import torch import yaml +from physicsnemo.datapipes.transforms import Compose # Flat-import shim: when invoked as ``python compute_normalizations.py`` the # script's own directory is already on ``sys.path``; when invoked from @@ -62,7 +63,6 @@ from loader import RTEDataPipe # noqa: E402 from material import MaterialPropertyExtractor # noqa: E402 from transforms import ( # noqa: E402 - Compose, RTEFluxLogClip, SpatialSampler, SteadyStateSampler, diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/dataset.py b/examples/cfd/nuclear_engineering/radiation_transport/src/dataset.py index 2dca26cb7b..1b76658272 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/dataset.py +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/dataset.py @@ -60,7 +60,6 @@ "coordinates", "cell_areas", "scalar_flux", - "timesteps", "sim_times", "material_properties", "geometric_features", @@ -199,9 +198,9 @@ def load( ) -> TensorDict: """Load a zarr store into a ``TensorDict``. - Tensor fields (``coordinates``, ``cell_areas``, ``scalar_flux``, - ``timesteps`` and the optional ``sim_times`` / ``material_properties`` - / ``geometric_features`` / ``sigma_*`` / ``Q``) are stored as + Tensor fields (``coordinates``, ``cell_areas``, ``scalar_flux``, and + the optional ``sim_times`` / ``material_properties`` / ``geometric_features`` + / ``sigma_*`` / ``Q``) are stored as ``torch.Tensor`` entries. The zarr store's ``.attrs`` dict is stored as ``NonTensorData`` under the ``metadata`` key. @@ -220,13 +219,11 @@ def load( flux_shape = z["scalar_flux"].shape num_cells = flux_shape[-1] scalar_flux = np.zeros((1, num_cells), dtype=np.float32) - timesteps_array = np.array([0]) sim_times = None else: flux_array = z["scalar_flux"] if len(flux_array.shape) == 1: scalar_flux = np.array(flux_array, dtype=np.float32)[None, :] - timesteps_array = np.array([0]) resolved = [0] else: num_timesteps = flux_array.shape[0] @@ -238,7 +235,6 @@ def load( ], axis=0, ) - timesteps_array = np.array([z["timesteps"][idx] for idx in resolved]) if load_sim_times and "sim_times" in z: sim_times = np.array( [z["sim_times"][idx] for idx in resolved], dtype=np.float32 @@ -257,7 +253,6 @@ def load( td = TensorDict({}, batch_size=[]) td["scalar_flux"] = _to_tensor(scalar_flux) - td["timesteps"] = _to_tensor(np.asarray(timesteps_array)) if sim_times is not None: td["sim_times"] = _to_tensor(np.asarray(sim_times)) @@ -355,7 +350,7 @@ def validate(self, filename: str) -> bool: filepath = self.data_path / filename z = zarr.open(str(filepath), mode="r") - required = ["cell_centers", "cell_areas", "scalar_flux", "timesteps"] + required = ["cell_centers", "cell_areas", "scalar_flux"] for key in required: if key not in z: raise ValueError(f"Zarr store missing required key: {key}") @@ -481,7 +476,6 @@ def load_one(filename: str): ) entry: Dict[str, torch.Tensor] = { "scalar_flux": td["scalar_flux"].clone(), - "timesteps": td["timesteps"].clone(), } if "sim_times" in td: entry["sim_times"] = td["sim_times"].clone() @@ -524,7 +518,6 @@ def load_one(filename: str): print(f" Cache misses: {cache_stats['cache_misses']}") flux_mem = sum( cached["scalar_flux"].element_size() * cached["scalar_flux"].numel() - + cached["timesteps"].element_size() * cached["timesteps"].numel() + ( cached["sim_times"].element_size() * cached["sim_times"].numel() if "sim_times" in cached @@ -565,7 +558,6 @@ def __getitem__(self, idx: int) -> TensorDict: load_flux=False, ) td["scalar_flux"] = cached["scalar_flux"] - td["timesteps"] = cached.get("timesteps", td["timesteps"]) if "sim_times" in cached: td["sim_times"] = cached["sim_times"] else: diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/inference.py b/examples/cfd/nuclear_engineering/radiation_transport/src/inference.py index 4655d847a1..3ac3971722 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/inference.py +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/inference.py @@ -48,7 +48,7 @@ import yaml from omegaconf import DictConfig, OmegaConf from torch.amp import autocast -from torch.utils.data import DataLoader, Dataset +from torch.utils.data import DataLoader from tqdm import tqdm # Flat sibling imports — keep this module self-contained relative to ``src/``. diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/loader.py b/examples/cfd/nuclear_engineering/radiation_transport/src/loader.py index 399e0b47c0..adc82d73a7 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/loader.py +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/loader.py @@ -76,9 +76,7 @@ ) from material import MaterialPropertyExtractor from transforms import ( - TRANSFORM_REGISTRY, FourierFeatures, - LoadGroundTruthQoI, RTEBackupCoords, RTEFluxLogClip, SpatialSampler, @@ -87,15 +85,6 @@ ) -# Register the material transform into the local TRANSFORM_REGISTRY so -# config-driven lookups by name resolve correctly. ``material.py`` does not -# decorate ``MaterialPropertyExtractor`` with ``@_register_local`` (to avoid -# importing the registry plumbing from a sibling), so we wire it up here at -# loader-import time. This keeps the registry a single source of truth even -# for transforms that live outside ``transforms.py``. -TRANSFORM_REGISTRY.setdefault("RTEMaterialPropertyExtractor", MaterialPropertyExtractor) - - # ========================================================================= # Adapter # ========================================================================= @@ -146,9 +135,8 @@ class TransolverAdapter(ModelAdapter): * ``flux_target`` — target flux to predict. Extra fields (``coordinates_unnormalized``, ``material_labels``, - ``cell_areas``, ``sigma_t``, ``sigma_s``, ``sim_time``, ``ground_truth_qoi``, - ``flux_normalization_stats``, ``geometry_params``) are passed through when - present in the sample. + ``cell_areas``, ``sigma_t``, ``sigma_s``, ``sim_time``, and + ``flux_normalization_stats``) are passed through when present in the sample. """ def __init__( @@ -244,25 +232,9 @@ def to_tensor(x): sim_time = sim_time.unsqueeze(0) result["sim_time"] = sim_time - if "ground_truth_qoi" in sample: - qoi_value = sample["ground_truth_qoi"] - if not isinstance(qoi_value, torch.Tensor): - qoi_value = torch.tensor([qoi_value], dtype=torch.float32) - else: - qoi_value = qoi_value.float() - if self.add_batch_dim: - qoi_value = qoi_value.unsqueeze(0) - result["ground_truth_qoi"] = qoi_value - if "flux_normalization_stats" in sample: result["flux_normalization_stats"] = sample["flux_normalization_stats"] - filename = sample.get("filename", "") or "" - if filename and "hohlraum" in filename.lower(): - geometry_params = self._extract_geometry_params(filename) - if geometry_params: - result["geometry_params"] = geometry_params - metadata = sample.get("metadata", {}) or {} max_timestep = metadata.get("max_timestep") if isinstance(metadata, dict) else None metadata_dict = { @@ -278,30 +250,6 @@ def to_tensor(x): return result - def _extract_geometry_params(self, filename: str) -> dict: - """Extract hohlraum geometry parameters from the zarr filename.""" - filename = filename.replace(".zarr", "") - parts = filename.split("_") - geometry_params: Dict[str, float] = {} - for part in parts: - if part.startswith("ulr"): - geometry_params["ulr"] = float(part[3:]) - elif part.startswith("llr"): - geometry_params["llr"] = float(part[3:]) - elif part.startswith("urr"): - geometry_params["urr"] = float(part[3:]) - elif part.startswith("lrr"): - geometry_params["lrr"] = float(part[3:]) - elif part.startswith("hlr"): - geometry_params["hlr"] = float(part[3:]) - elif part.startswith("hrr"): - geometry_params["hrr"] = float(part[3:]) - elif part.startswith("cx"): - geometry_params["cx"] = float(part[2:]) - elif part.startswith("cy"): - geometry_params["cy"] = float(part[2:]) - return geometry_params - def __repr__(self) -> str: return ( f"{self.__class__.__name__}(" @@ -439,7 +387,6 @@ def build_rte_dataset_kwargs( "flux_clip_threshold": flux_clip_threshold, "split_file": split_file, "seed": seed, - "load_ground_truth_qoi": data_cfg.get("load_ground_truth_qoi", False), "cache_static_arrays": data_cfg.get("cache_static_arrays", True), "max_cache_size": data_cfg.get("max_cache_size", 200), "include_q_in_embedding": cfg.model.get("include_q_in_embedding", True), @@ -543,7 +490,6 @@ def from_config( flux_normalization_stats_file: Optional[Union[str, Path]] = None, normalize_coordinates: bool = True, flux_clip_threshold: float = 1e-8, - load_ground_truth_qoi: bool = False, split_file: Optional[Union[str, Path]] = None, seed: Optional[int] = None, # Cache options @@ -582,7 +528,6 @@ def from_config( fourier_num_frequencies=fourier_num_frequencies, fourier_coord_dims=fourier_coord_dims, fourier_base_frequency=fourier_base_frequency, - load_ground_truth_qoi=load_ground_truth_qoi, ) adapter_obj = _build_adapter( @@ -650,7 +595,6 @@ def _build_transforms( fourier_num_frequencies: int, fourier_coord_dims: int, fourier_base_frequency: float, - load_ground_truth_qoi: bool, ) -> Compose: """Assemble the canonical RTE transform pipeline. @@ -663,7 +607,6 @@ def _build_transforms( 5. ``SpatialSampler`` — always. 6. ``RTEBackupCoords`` + ``Translate`` + ``Scale`` — when normalize_coordinates. 7. ``FourierFeatures`` — when use_fourier_features. - 8. ``LoadGroundTruthQoI`` — when load_ground_truth_qoi. """ flux_stats = load_flux_stats(flux_normalization_stats_file) if abs(flux_stats["clip_threshold"] - flux_clip_threshold) > 1e-10: @@ -711,14 +654,7 @@ def _build_transforms( "(used to look up the global domain bounds)." ) center, half_extent = coord_translate_scale_params(case_type) - # RTEBackupCoords preserves raw coords AND writes bbox_min / bbox_max - # so downstream readers keep working. - transform_list.append( - RTEBackupCoords( - bbox_min=center - half_extent, - bbox_max=center + half_extent, - ) - ) + transform_list.append(RTEBackupCoords()) transform_list.append( Translate( input_keys=["coordinates"], @@ -744,9 +680,6 @@ def _build_transforms( ) ) - if load_ground_truth_qoi: - transform_list.append(LoadGroundTruthQoI(data_path=data_path)) - return Compose(transform_list) diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/losses.py b/examples/cfd/nuclear_engineering/radiation_transport/src/losses.py index 22f69950e8..e675585d0e 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/losses.py +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/losses.py @@ -189,38 +189,6 @@ def create_scheduler(cfg: DictConfig, optimizer: torch.optim.Optimizer, logger=N # ========================================================================= -def loss_fn( - output: torch.Tensor, - target: torch.Tensor, - loss_type: str = "mse", - padded_value: float = -10, -) -> torch.Tensor: - """ - Calculate loss with masking for padded values. - - Args: - output: Predicted values - target: Ground truth values - loss_type: Type of loss - "mse" or "rmse" - padded_value: Value used for padding (will be masked out) - - Returns: - Scalar loss value - """ - mask = abs(target - padded_value) > 1e-3 - - num = torch.sum(mask * (output - target) ** 2.0) - - if loss_type == "rmse": - denom = torch.sum(mask * target**2.0) - loss = torch.sqrt(num / denom) - else: # mse - denom = torch.sum(mask) - loss = num / denom - - return loss - - def masked_mse_loss( output: torch.Tensor, target: torch.Tensor, mask: torch.Tensor = None ) -> torch.Tensor: @@ -657,74 +625,6 @@ def compute_hohlraum_qoi_loss( return loss, details -def compute_combined_loss( - predicted_flux: torch.Tensor, - target_flux: torch.Tensor, - cell_centers: torch.Tensor, - cell_areas: torch.Tensor, - sigma_t: torch.Tensor, - sigma_s: torch.Tensor, - sim_time: torch.Tensor, - mse_weight: float = 1.0, - qoi_weight: float = 0.1, - loss_type: str = "mse", - padded_value: float = -10, -) -> tuple[torch.Tensor, dict[str, float]]: - """ - Compute combined MSE + QoI physics loss for lattice problems. - - Args: - predicted_flux: Model predictions (normalized), shape (B, N, 1) - target_flux: Ground truth flux (normalized), shape (B, N, 1) - cell_centers: Cell center coordinates (unnormalized), shape (B, N, 3) - cell_areas: Cell areas, shape (B, N) - sigma_t: Total cross-section, shape (B, N) - sigma_s: Scattering cross-section, shape (B, N) - sim_time: Simulation time for each sample, shape (B,) - mse_weight: Weight for MSE loss component (default: 1.0) - qoi_weight: Weight for QoI loss component (default: 0.1) - loss_type: Type of MSE loss - "mse" or "rmse" - padded_value: Value used for padding (will be masked out) - - Returns: - total_loss: Combined weighted loss - loss_dict: Dictionary with individual loss components - """ - # compute MSE loss with masking - mask = abs(target_flux - padded_value) > 1e-3 - num = torch.sum(mask * (predicted_flux - target_flux) ** 2.0) - - if loss_type == "rmse": - denom = torch.sum(mask * target_flux**2.0) - loss_mse = torch.sqrt(num / denom) - else: - denom = torch.sum(mask) - loss_mse = num / denom - - # compute QoI physics loss (uses normalized flux) - loss_qoi = compute_lattice_qoi_loss( - predicted_flux=predicted_flux, - target_flux=target_flux, - cell_centers=cell_centers, - cell_areas=cell_areas, - sigma_t=sigma_t, - sigma_s=sigma_s, - sim_time=sim_time, - ) - - # combine losses - total_loss = mse_weight * loss_mse + qoi_weight * loss_qoi - - # return loss and components - loss_dict = { - "loss": total_loss.item(), - "loss_mse": loss_mse.item(), - "loss_qoi": loss_qoi.item(), - } - - return total_loss, loss_dict - - def extract_geometry_params(filename) -> dict: """Extract hohlraum geometry parameters from zarr filename.""" # handle list (batched) or single string filename @@ -1078,7 +978,6 @@ def _evaluate_hohlraum_qoi_torch_batched( "WarmupCosineScheduler", "create_scheduler", # Regression losses - "loss_fn", "masked_mse_loss", "region_weighted_loss_fn", "parse_loss_config", @@ -1086,7 +985,6 @@ def _evaluate_hohlraum_qoi_torch_batched( "compute_physics_loss", "compute_lattice_qoi_loss", "compute_hohlraum_qoi_loss", - "compute_combined_loss", "denormalize_flux_from_stats", "extract_geometry_params", # QoI helpers (torch) diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/transforms.py b/examples/cfd/nuclear_engineering/radiation_transport/src/transforms.py index f3e269039a..584ea3f315 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/transforms.py +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/transforms.py @@ -14,7 +14,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Transform framework + flux / coordinates / sampling / qoi transforms. +"""Transform framework + flux / coordinates / sampling transforms. This module consolidates the RTE transform framework with the concrete preprocessing transforms used by the Transolver pipeline. It is intentionally @@ -23,13 +23,9 @@ * ``Transform`` and ``Compose`` are re-exports from PhysicsNeMo (``physicsnemo.datapipes.transforms``); RTE transforms subclass ``Transform`` and operate on ``tensordict.TensorDict`` instances. -* ``TRANSFORM_REGISTRY`` is a module-level dict mapping the string names used - by Hydra configs (``"RTEFluxLogClip"``, etc.) to the transform classes - defined here. Sibling modules (e.g. ``material.py``) register their own - transforms into the same dict. * The ``@register(...)`` decorator from ``physicsnemo.datapipes.registry`` populates the global PhysicsNeMo registry; - we apply it alongside the local registry for instantiation by either path. + we apply it for config-driven instantiation if needed. Material transforms live in the sibling ``material.py``. """ @@ -41,26 +37,23 @@ # ========================================================================= from pathlib import Path -from typing import Any, Dict, Mapping, Optional, Type, Union +from typing import Any, Dict, Mapping, Optional, Union import numpy as np import torch -import zarr from physicsnemo.datapipes.registry import register -from physicsnemo.datapipes.transforms import Compose, Transform -from tensordict import NonTensorData, TensorDict +from physicsnemo.datapipes.transforms import Transform +from tensordict import TensorDict # ========================================================================= -# Framework: Transform base, Compose, TensorDict utilities, registry +# Framework: Transform base + TensorDict utilities # ========================================================================= # -# ``Transform`` and ``Compose`` are imported above. RTE transforms subclass -# ``Transform`` and operate on ``TensorDict``. The TensorDict helpers below +# ``Transform`` is imported above. RTE transforms subclass it and operate on +# ``TensorDict``. The TensorDict helpers below # bridge numpy / torch / non-tensor (str, dict, None) values that flow through -# the pipeline. ``TRANSFORM_REGISTRY`` is the local string->class map; the -# ``@register(name)`` decorator additionally populates the PhysicsNeMo global -# registry so existing config-driven instantiation paths continue to work. +# the pipeline. def td_from_dict(sample: Mapping[str, Any]) -> TensorDict: @@ -101,66 +94,14 @@ def to_numpy(value: Any) -> np.ndarray: return np.asarray(value) -# Local registry: maps the string name used in configs to the transform class. -# Sibling modules (e.g. ``material.py``) extend this dict at import time. -TRANSFORM_REGISTRY: Dict[str, Type[Transform]] = {} - - -def _register_local(name: str): - """Combined decorator: register with PhysicsNeMo's global registry and - record the class in ``TRANSFORM_REGISTRY`` under the same name.""" - - pnm_register = register(name) - - def _decorator(cls): - TRANSFORM_REGISTRY[name] = cls - return pnm_register(cls) - - return _decorator - - # ========================================================================= # Flux # ========================================================================= # # ``RTEFluxLogClip`` is the canonical pre-step that clamps flux and applies # log10 before z-score normalization (the latter performed by -# ``physicsnemo.datapipes.transforms.Normalize``). ``FluxClipper`` and -# ``LogTransform`` are kept as small standalone utilities for notebooks. -# ``denormalize_flux`` inverts the full ``RTEFluxLogClip + Normalize`` chain -# for evaluation. - - -@_register_local("RTEFluxClipper") -class FluxClipper(Transform): - """Clip flux below a threshold.""" - - def __init__(self, threshold: float = 1e-8): - super().__init__() - self.threshold = threshold - - def __call__(self, data: TensorDict) -> TensorDict: - data["scalar_flux"] = torch.clamp(data["scalar_flux"], min=self.threshold) - return data - - def extra_repr(self) -> str: - return f"threshold={self.threshold}" - - -@_register_local("RTELogTransform") -class LogTransform(Transform): - """Log10 transformation for flux.""" - - def __init__(self, offset: float = 1e-8): - super().__init__() - self.offset = offset - - def __call__(self, data: TensorDict) -> TensorDict: - data["scalar_flux"] = torch.log10(data["scalar_flux"] + self.offset) - return data - - def extra_repr(self) -> str: - return f"offset={self.offset}" +# ``physicsnemo.datapipes.transforms.Normalize``). ``denormalize_flux`` inverts +# the full ``RTEFluxLogClip + Normalize`` chain for evaluation. def denormalize_flux( @@ -182,7 +123,7 @@ def denormalize_flux( return torch.clamp(flux, min=0.0) -@_register_local("RTEFluxLogClip") +@register("RTEFluxLogClip") class RTEFluxLogClip(Transform): """Clip flux to a threshold, apply ``log10``, and record denorm stats. @@ -282,7 +223,7 @@ def extra_repr(self) -> str: } -@_register_local("RTEBackupCoords") +@register("RTEBackupCoords") class RTEBackupCoords(Transform): """Clone ``coordinates`` into ``coordinates_unnormalized`` before Translate/Scale. @@ -291,40 +232,20 @@ class RTEBackupCoords(Transform): transform immediately before ``physicsnemo.datapipes.transforms.Translate`` + ``Scale`` in the pipeline so the raw coords survive the normalization. - - Optionally also writes ``bbox_min`` / ``bbox_max`` tensors to the - TensorDict so downstream code that previously read them from the legacy - ``CoordinateNormalizer`` output still finds them. """ - def __init__( - self, - bbox_min: Optional[torch.Tensor] = None, - bbox_max: Optional[torch.Tensor] = None, - ) -> None: + def __init__(self) -> None: super().__init__() - self.bbox_min = ( - None if bbox_min is None else torch.as_tensor(bbox_min, dtype=torch.float32) - ) - self.bbox_max = ( - None if bbox_max is None else torch.as_tensor(bbox_max, dtype=torch.float32) - ) def __call__(self, data: TensorDict) -> TensorDict: data["coordinates_unnormalized"] = data["coordinates"].clone() - if self.bbox_min is not None: - data["bbox_min"] = self.bbox_min.clone() - if self.bbox_max is not None: - data["bbox_max"] = self.bbox_max.clone() return data def extra_repr(self) -> str: - if self.bbox_min is None: - return "no bbox recorded" - return f"bbox_min={self.bbox_min.tolist()}, bbox_max={self.bbox_max.tolist()}" + return "preserve raw coordinates" -@_register_local("RTEFourierFeatures") +@register("RTEFourierFeatures") class FourierFeatures(Transform): """Sin/cos positional encoding features at multiple frequency scales.""" @@ -375,37 +296,6 @@ def extra_repr(self) -> str: ) -@_register_local("RTEStandardScaler") -class StandardScaler(Transform): - """Z-score normalization for coordinates (utility, not used by default chain).""" - - def __init__( - self, mean: Optional[np.ndarray] = None, std: Optional[np.ndarray] = None - ): - super().__init__() - self.mean = mean - self.std = std - - def __call__(self, data: TensorDict) -> TensorDict: - coords = data["coordinates"] - if self.mean is not None: - mean_t = torch.as_tensor( - self.mean, dtype=coords.dtype, device=coords.device - ) - else: - mean_t = coords.mean(dim=0) - if self.std is not None: - std_t = torch.as_tensor(self.std, dtype=coords.dtype, device=coords.device) - else: - std_t = coords.std(dim=0) - std_t = torch.where(std_t < 1e-10, torch.ones_like(std_t), std_t) - - data["coordinates"] = (coords - mean_t) / std_t - data["coord_mean"] = mean_t - data["coord_std"] = std_t - return data - - # ========================================================================= # Sampling (spatial + steady-state) # ========================================================================= @@ -414,7 +304,7 @@ def __call__(self, data: TensorDict) -> TensorDict: # ``SteadyStateSampler`` extracts the fixed initial->final flux mapping. -@_register_local("RTESpatialSampler") +@register("RTESpatialSampler") class SpatialSampler(Transform): """Sample spatial points from mesh. @@ -439,8 +329,6 @@ def __call__(self, data: TensorDict) -> TensorDict: num_available = data["coordinates"].shape[0] if self.num_points == -1: - data.set_non_tensor("spatial_indices", None) - data.set_non_tensor("spatial_num_original", num_available) return data needs_sampling = num_available > self.num_points @@ -449,8 +337,6 @@ def __call__(self, data: TensorDict) -> TensorDict: indices_np = self.rng.choice(num_available, self.num_points, replace=False) else: if num_available == self.num_points: - data.set_non_tensor("spatial_indices", None) - data.set_non_tensor("spatial_num_original", num_available) return data indices_np = np.arange(num_available) @@ -491,8 +377,6 @@ def __call__(self, data: TensorDict) -> TensorDict: flux_1d = self._pad_flux_1d(flux_1d, self.num_points) data[flux_key] = flux_1d - data["spatial_indices"] = indices - data.set_non_tensor("spatial_num_original", int(num_available)) return data def _pad_tensor(self, tensor: torch.Tensor, target_size: int) -> torch.Tensor: @@ -524,7 +408,7 @@ def extra_repr(self) -> str: return f"num_points={self.num_points}" -@_register_local("RTESteadyStateSampler") +@register("RTESteadyStateSampler") class SteadyStateSampler(Transform): """Extract the fixed steady-state mapping: first flux -> final flux.""" @@ -552,93 +436,6 @@ def __call__(self, data: TensorDict) -> TensorDict: ) return data - -# ========================================================================= -# QoI loader -# ========================================================================= -# -# Loads ground-truth QoI values for the target timestep from each sample's -# zarr ``global_metrics`` array. Lattice writes a single scalar; hohlraum -# writes the three regional cumulated fluxes plus a ``ground_truth_qoi`` -# alias pointing at the center value (used by the loss). - - -@_register_local("RTELoadGroundTruthQoI") -class LoadGroundTruthQoI(Transform): - """Load ground truth QoI for the target timestep from zarr global_metrics.""" - - def __init__(self, data_path: Union[str, Path]): - super().__init__() - self.data_path = Path(data_path) - - def __call__(self, data: TensorDict) -> TensorDict: - filename = td_get(data, "filename") - if filename is None: - raise ValueError( - "Sample is missing 'filename' field required for QoI loading" - ) - - timestep_target_idx = td_get(data, "timestep_target") - if timestep_target_idx is None: - raise ValueError(f"Sample from {filename} missing timestep_target field") - - zarr_path = self.data_path / filename - if not zarr_path.exists(): - raise FileNotFoundError(f"Zarr file not found: {zarr_path}") - - z = zarr.open(str(zarr_path), mode="r") - if "global_metrics" not in z: - raise KeyError(f"'global_metrics' not found in zarr file: {zarr_path}") - - global_metrics = np.array(z["global_metrics"], dtype=np.float32) - timesteps_full = np.array(z["timesteps"]) - - metadata = td_get(data, "metadata", default={}) or {} - case_type = metadata.get("case_type") if isinstance(metadata, dict) else None - if case_type is None: - raise ValueError("metadata.case_type is required for QoI computation") - - timestep_target_idx = int(timestep_target_idx) - if timestep_target_idx >= len(timesteps_full): - raise ValueError( - f"timestep_target index {timestep_target_idx} out of bounds for " - f"full timesteps array of length {len(timesteps_full)} in {filename}" - ) - - global_metrics_idx = timestep_target_idx - if global_metrics_idx >= global_metrics.shape[0]: - raise IndexError( - f"QoI index {global_metrics_idx} out of bounds for global_metrics " - f"shape {global_metrics.shape} in {filename}" - ) - - if case_type == "lattice": - data["ground_truth_qoi"] = torch.tensor( - float(global_metrics[global_metrics_idx, 1]), dtype=torch.float32 - ) - elif case_type == "hohlraum": - center = float(global_metrics[global_metrics_idx, 1]) - vertical = float(global_metrics[global_metrics_idx, 2]) - horizontal = float(global_metrics[global_metrics_idx, 3]) - data["ground_truth_qoi_cumulated_center"] = torch.tensor( - center, dtype=torch.float32 - ) - data["ground_truth_qoi_cumulated_vertical"] = torch.tensor( - vertical, dtype=torch.float32 - ) - data["ground_truth_qoi_cumulated_horizontal"] = torch.tensor( - horizontal, dtype=torch.float32 - ) - data["ground_truth_qoi"] = torch.tensor(center, dtype=torch.float32) - else: - raise ValueError(f"Unknown case type: {case_type}") - - return data - - def extra_repr(self) -> str: - return f"data_path={self.data_path}" - - # ========================================================================= # Public API # ========================================================================= @@ -646,25 +443,17 @@ def extra_repr(self) -> str: __all__ = [ # Framework "Transform", - "Compose", - "TRANSFORM_REGISTRY", "td_from_dict", "td_get", "to_numpy", - "NonTensorData", # Flux "RTEFluxLogClip", - "FluxClipper", - "LogTransform", "denormalize_flux", # Coordinates "GLOBAL_DOMAIN_BOUNDS", "RTEBackupCoords", "FourierFeatures", - "StandardScaler", # Sampling "SpatialSampler", "SteadyStateSampler", - # QoI - "LoadGroundTruthQoI", ] From ef624d7e3137fd5199f714c1a7ce18fa6f320f35 Mon Sep 17 00:00:00 2001 From: Carmelo Gonzales Date: Mon, 4 May 2026 15:25:53 -0700 Subject: [PATCH 18/68] feat: purging unused code --- .../radiation_transport/README.md | 23 + .../src/compute_normalizations.py | 2 +- .../radiation_transport/src/dataset.py | 70 +-- .../radiation_transport/src/loader.py | 13 +- .../radiation_transport/src/losses.py | 149 ++--- .../radiation_transport/src/material.py | 513 +----------------- .../radiation_transport/src/trainer.py | 14 +- .../radiation_transport/src/transforms.py | 104 +--- 8 files changed, 115 insertions(+), 773 deletions(-) diff --git a/examples/cfd/nuclear_engineering/radiation_transport/README.md b/examples/cfd/nuclear_engineering/radiation_transport/README.md index 7f07bf4c11..cc8e403a77 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/README.md +++ b/examples/cfd/nuclear_engineering/radiation_transport/README.md @@ -8,6 +8,8 @@ steady-state mapping from the initial flux snapshot to the final scalar flux, using a physics-informed loss that combines region-weighted MSE with a quantity-of-interest (QoI) penalty based on absorption in key regions. +The datasets used for this example were generated using [KiT-RT](https://github.com/KiT-RT)[1]. + --- ## 1. The science @@ -415,3 +417,24 @@ The Hydra group structure means `case=hohlraum` swaps the entire so case-specific overrides propagate automatically. +--- + +## References + +[1]: Kusch, J., Schotthöfer, S., Stammer, P., Wolters, J., & Xiao, T. (2023). + "KiT-RT: An extendable framework for radiative transfer and therapy." + *ACM Transactions on Mathematical Software*, **49**(4), 1–24. + + ```bibtex + @article{kitrt2023, + title = {KiT-RT: An extendable framework for radiative transfer and therapy}, + author = {Kusch, Jonas and Schotth{\"o}fer, Steffen and Stammer, Pia + and Wolters, Jannick and Xiao, Tianbai}, + journal = {ACM Transactions on Mathematical Software}, + volume = {49}, + number = {4}, + pages = {1--24}, + year = {2023}, + publisher = {ACM New York, NY} + } + ``` \ No newline at end of file diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/compute_normalizations.py b/examples/cfd/nuclear_engineering/radiation_transport/src/compute_normalizations.py index 6e9b83ae17..5510e906d5 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/compute_normalizations.py +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/compute_normalizations.py @@ -236,7 +236,7 @@ def compute_material_statistics( clip_threshold=clip_threshold, ), SteadyStateSampler(), - MaterialPropertyExtractor(case_type=case_type), + MaterialPropertyExtractor(), SpatialSampler(num_points=num_spatial_points, seed=seed), ] ) diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/dataset.py b/examples/cfd/nuclear_engineering/radiation_transport/src/dataset.py index 1b76658272..1b30af01a3 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/dataset.py +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/dataset.py @@ -345,31 +345,6 @@ def get_metadata(self, filename: str) -> Dict: ) from exc return metadata - def validate(self, filename: str) -> bool: - """Assert the zarr store has the required top-level arrays.""" - filepath = self.data_path / filename - z = zarr.open(str(filepath), mode="r") - - required = ["cell_centers", "cell_areas", "scalar_flux"] - for key in required: - if key not in z: - raise ValueError(f"Zarr store missing required key: {key}") - - nc_centers = z["cell_centers"].shape[0] - nc_areas = z["cell_areas"].shape[0] - nc_flux = z["scalar_flux"].shape[-1] - if nc_centers != nc_flux: - raise ValueError( - f"Shape mismatch: cell_centers has {nc_centers} cells, " - f"scalar_flux has {nc_flux}" - ) - if nc_areas != nc_flux: - raise ValueError( - f"Shape mismatch: cell_areas has {nc_areas} cells, " - f"scalar_flux has {nc_flux}" - ) - return True - # ========================================================================= # PyTorch Dataset @@ -661,42 +636,25 @@ def material_normalize_kwargs( stats: Mapping, field: str = "physical_properties", order: Sequence[str] = ("sigma_a", "sigma_s", "sigma_t", "Q"), - method: str = "mean_std", ) -> dict: """Build ``Normalize`` kwargs for ``physical_properties`` as (N, 4). The 4 columns are normalized independently via broadcasting: a per-column - ``torch.Tensor`` of shape ``(4,)`` is passed as the mean and the std. This - mirrors what the custom ``MaterialPropertyNormalizer`` did column-by-column, - but delegates the math to ``physicsnemo.datapipes.transforms.Normalize``. + ``torch.Tensor`` of shape ``(4,)`` is passed as the mean and the std, + delegating the math to ``physicsnemo.datapipes.transforms.Normalize``. """ - if method == "mean_std": - means = torch.tensor( - [float(stats[k]["mean"]) for k in order], dtype=torch.float32 - ) - stds = torch.tensor( - [float(stats[k]["std"]) for k in order], dtype=torch.float32 - ) - return { - "input_keys": [field], - "method": "mean_std", - "means": {field: means}, - "stds": {field: stds}, - } - if method == "min_max": - mins = torch.tensor( - [float(stats[k]["min"]) for k in order], dtype=torch.float32 - ) - maxs = torch.tensor( - [float(stats[k]["max"]) for k in order], dtype=torch.float32 - ) - return { - "input_keys": [field], - "method": "min_max", - "mins": {field: mins}, - "maxs": {field: maxs}, - } - raise ValueError(f"Unknown method: {method}. Expected 'mean_std' or 'min_max'.") + means = torch.tensor( + [float(stats[k]["mean"]) for k in order], dtype=torch.float32 + ) + stds = torch.tensor( + [float(stats[k]["std"]) for k in order], dtype=torch.float32 + ) + return { + "input_keys": [field], + "method": "mean_std", + "means": {field: means}, + "stds": {field: stds}, + } def coord_bounds_for_case(case_type: str) -> Tuple[torch.Tensor, torch.Tensor]: diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/loader.py b/examples/cfd/nuclear_engineering/radiation_transport/src/loader.py index adc82d73a7..28dee6b3f4 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/loader.py +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/loader.py @@ -81,7 +81,6 @@ RTEFluxLogClip, SpatialSampler, SteadyStateSampler, - td_from_dict, ) @@ -467,9 +466,6 @@ def __getitem__(self, idx: int) -> Any: model-specific format. """ td = self.base_dataset[idx] - if not isinstance(td, TensorDict): - # Defensive path for callers that still return a dict. - td = td_from_dict(td) if self.transforms is not None: td = self.transforms(td) @@ -559,10 +555,7 @@ def preload_to_memory(self, verbose: bool = True, num_workers: int = 8) -> dict: def get_raw_sample(self, idx: int) -> TensorDict: """Get a raw sample as a ``TensorDict`` (pre-transform, pre-adapter).""" - td = self.base_dataset[idx] - if not isinstance(td, TensorDict): - td = td_from_dict(td) - return td + return self.base_dataset[idx] def get_transformed_sample(self, idx: int) -> TensorDict: """Get sample with transforms applied but no adapter (``TensorDict``).""" @@ -626,7 +619,7 @@ def _build_transforms( transform_list.append(SteadyStateSampler()) - transform_list.append(MaterialPropertyExtractor(case_type=case_type)) + transform_list.append(MaterialPropertyExtractor()) material_stats_path = ( Path(flux_normalization_stats_file).parent / f"{case_type}_material_stats.yaml" @@ -640,7 +633,7 @@ def _build_transforms( transform_list.append( Normalize( **material_normalize_kwargs( - material_stats, field="physical_properties", method="mean_std" + material_stats, field="physical_properties" ) ) ) diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/losses.py b/examples/cfd/nuclear_engineering/radiation_transport/src/losses.py index e675585d0e..8564cf7d48 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/losses.py +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/losses.py @@ -18,7 +18,7 @@ This module consolidates every "loss" concept the trainer touches: -* Learning-rate schedulers (warmup + cosine, step, plateau, constant). +* Learning-rate schedulers (warmup + cosine, plus a constant fallback). * Regression losses on the (possibly padded) flux tensor: ``loss_fn``, ``masked_mse_loss``, ``region_weighted_loss_fn``. * Physics-informed loss for the radiation-transport surrogate: per-case @@ -99,14 +99,11 @@ def get_lr(self): def create_scheduler(cfg: DictConfig, optimizer: torch.optim.Optimizer, logger=None): - """ - Create learning rate scheduler based on config. + """Create the LR scheduler. Supports: - - constant: No learning rate decay (useful for overfit tests) - - cosine: Warmup + cosine annealing (recommended) - - step: Step decay every N epochs - - plateau: Reduce on plateau (adaptive) + - cosine: Warmup + cosine annealing (recommended; default) + - constant: No decay (useful for overfit tests) """ scheduler_type = cfg.train.get("scheduler_type", "cosine") warmup_epochs = cfg.train.get("warmup_epochs", 5) @@ -121,67 +118,21 @@ def create_scheduler(cfg: DictConfig, optimizer: torch.optim.Optimizer, logger=N logger.info(f" Warmup epochs: {warmup_epochs}") if scheduler_type == "constant": - # constant LR - no decay (useful for overfit tests) - scheduler = torch.optim.lr_scheduler.ConstantLR( + return torch.optim.lr_scheduler.ConstantLR( optimizer, factor=1.0, total_iters=cfg.train.epochs, ) - elif scheduler_type == "cosine": - scheduler = WarmupCosineScheduler( + if scheduler_type == "cosine": + return WarmupCosineScheduler( optimizer, warmup_epochs=warmup_epochs, total_epochs=cfg.train.epochs, min_lr=min_lr, ) - elif scheduler_type == "step": - step_size = cfg.train.get("step_size", 50) - step_gamma = cfg.train.get("step_gamma", 0.5) - if logger: - logger.info(f" Step size: {step_size}, Gamma: {step_gamma}") - - # For step scheduler, we use a sequential scheduler with warmup - if warmup_epochs > 0: - warmup_scheduler = torch.optim.lr_scheduler.LinearLR( - optimizer, - start_factor=min_lr / cfg.train.learning_rate, - end_factor=1.0, - total_iters=warmup_epochs, - ) - step_scheduler = torch.optim.lr_scheduler.StepLR( - optimizer, step_size=step_size, gamma=step_gamma - ) - scheduler = torch.optim.lr_scheduler.SequentialLR( - optimizer, - schedulers=[warmup_scheduler, step_scheduler], - milestones=[warmup_epochs], - ) - else: - scheduler = torch.optim.lr_scheduler.StepLR( - optimizer, step_size=step_size, gamma=step_gamma - ) - elif scheduler_type == "plateau": - patience = cfg.train.get("plateau_patience", 10) - factor = cfg.train.get("plateau_factor", 0.5) - threshold = cfg.train.get("plateau_threshold", 0.01) - if logger: - logger.info(f" Patience: {patience}, Factor: {factor}") - - # ReduceLROnPlateau doesn't work well with warmup, so we just use it directly - # and set initial LR lower if warmup is desired - scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau( - optimizer, - mode="min", - factor=factor, - patience=patience, - threshold=threshold, - min_lr=min_lr, - verbose=True, - ) - else: - raise ValueError(f"Unknown scheduler type: {scheduler_type}") - - return scheduler + raise ValueError( + f"Unknown scheduler_type {scheduler_type!r}; expected 'cosine' or 'constant'." + ) # ========================================================================= @@ -756,21 +707,22 @@ def evaluate_lattice_qoi_torch( scalar_flux: torch.Tensor, sim_times: torch.Tensor, ) -> dict[str, torch.Tensor]: - """ - Compute lattice absorption QoI using PyTorch (differentiable). + """Compute lattice absorption QoI using PyTorch (differentiable). - Matches KiT-RT SNSolverHPC::IterPostprocessing() exactly. + Matches KiT-RT SNSolverHPC::IterPostprocessing() exactly. Steady-state + surrogate ⇒ T=1; ``sim_times`` is accepted for callsite uniformity but + not used. Args: cell_centers: (N, 3) or (B, N, 3) cell_areas: (N,) or (B, N) sigma_t: (N,) or (B, N) sigma_s: (N,) or (B, N) - scalar_flux: (T, N) or (B, T, N) - sim_times: (T,) or (B, T) + scalar_flux: (T, N) or (B, T, N) — only T=1 is exercised + sim_times: (T,) or (B, T) — unused, kept for callsite uniformity Returns: - {"cur_absorption": (T,) or (B, T), "total_absorption": (T,) or (B, T)} + ``{"cur_absorption": (T,) or (B, T)}`` """ if cell_centers.ndim == 3: return _evaluate_lattice_qoi_torch_batched( @@ -803,19 +755,12 @@ def evaluate_lattice_qoi_torch( if scalar_flux.ndim != 2: raise ValueError(f"Expected scalar_flux shape (T, N), got {scalar_flux.shape}") - num_timesteps = scalar_flux.shape[0] absorption_density = scalar_flux * sigma_a.unsqueeze(0) * cell_areas.unsqueeze(0) cur_absorption = torch.sum( absorption_density * in_absorption.unsqueeze(0).float(), dim=1 ) - total_absorption = torch.zeros_like(cur_absorption) - total_absorption[0] = cur_absorption[0] * sim_times[0] - for t in range(1, num_timesteps): - dt = sim_times[t] - sim_times[t - 1] - total_absorption[t] = total_absorption[t - 1] + cur_absorption[t] * dt - - return {"cur_absorption": cur_absorption, "total_absorption": total_absorption} + return {"cur_absorption": cur_absorption} def _evaluate_lattice_qoi_torch_batched( @@ -836,7 +781,6 @@ def _evaluate_lattice_qoi_torch_batched( ] return { "cur_absorption": torch.stack([r["cur_absorption"] for r in results]), - "total_absorption": torch.stack([r["total_absorption"] for r in results]), } @@ -849,24 +793,24 @@ def evaluate_hohlraum_qoi_torch( sim_times: torch.Tensor, geometry_params: dict[str, float], ) -> dict[str, torch.Tensor]: - """ - Compute hohlraum absorption QoI using PyTorch (differentiable). + """Compute hohlraum absorption QoI using PyTorch (differentiable). Matches KiT-RT SNSolverHPC hohlraum geometry exactly (including known KiT-RT - quirk of using pos_red_left_bottom for both vertical wall sides). + quirk of using pos_red_left_bottom for both vertical wall sides). Steady-state + surrogate ⇒ T=1; ``sim_times`` is accepted for callsite uniformity but + not used. Args: cell_centers: (N, 3) or (B, N, 3) cell_areas: (N,) or (B, N) sigma_t: (N,) or (B, N) sigma_s: (N,) or (B, N) - scalar_flux: (T, N) or (B, T, N) - sim_times: (T,) or (B, T) + scalar_flux: (T, N) or (B, T, N) — only T=1 is exercised + sim_times: (T,) or (B, T) — unused, kept for callsite uniformity geometry_params: dict with cx, cy, hlr, hrr, llr, ulr, lrr, urr Returns: - Dict with cur_absorption_{center,vertical,horizontal}, - cumulated_absorption_{center,vertical,horizontal}, total_absorption + Dict with ``cur_absorption_{center,vertical,horizontal}``. """ if cell_centers.ndim == 3: return _evaluate_hohlraum_qoi_torch_batched( @@ -906,43 +850,18 @@ def evaluate_hohlraum_qoi_torch( if scalar_flux.ndim != 2: raise ValueError(f"Expected scalar_flux shape (T, N), got {scalar_flux.shape}") - num_timesteps = scalar_flux.shape[0] absorption_density = scalar_flux * sigma_a.unsqueeze(0) * cell_areas.unsqueeze(0) - cur_center = torch.sum(absorption_density * in_center.unsqueeze(0).float(), dim=1) - cur_vertical = torch.sum( - absorption_density * in_vertical.unsqueeze(0).float(), dim=1 - ) - cur_horizontal = torch.sum( - absorption_density * in_horizontal.unsqueeze(0).float(), dim=1 - ) - cur_total = torch.sum(absorption_density, dim=1) - - cum_center = torch.zeros_like(cur_center) - cum_vertical = torch.zeros_like(cur_vertical) - cum_horizontal = torch.zeros_like(cur_horizontal) - total_absorption = torch.zeros_like(cur_total) - - cum_center[0] = cur_center[0] * sim_times[0] - cum_vertical[0] = cur_vertical[0] * sim_times[0] - cum_horizontal[0] = cur_horizontal[0] * sim_times[0] - total_absorption[0] = cur_total[0] * sim_times[0] - - for t in range(1, num_timesteps): - dt = sim_times[t] - sim_times[t - 1] - cum_center[t] = cum_center[t - 1] + cur_center[t] * dt - cum_vertical[t] = cum_vertical[t - 1] + cur_vertical[t] * dt - cum_horizontal[t] = cum_horizontal[t - 1] + cur_horizontal[t] * dt - total_absorption[t] = total_absorption[t - 1] + cur_total[t] * dt - return { - "cur_absorption_center": cur_center, - "cur_absorption_vertical": cur_vertical, - "cur_absorption_horizontal": cur_horizontal, - "cumulated_absorption_center": cum_center, - "cumulated_absorption_vertical": cum_vertical, - "cumulated_absorption_horizontal": cum_horizontal, - "total_absorption": total_absorption, + "cur_absorption_center": torch.sum( + absorption_density * in_center.unsqueeze(0).float(), dim=1 + ), + "cur_absorption_vertical": torch.sum( + absorption_density * in_vertical.unsqueeze(0).float(), dim=1 + ), + "cur_absorption_horizontal": torch.sum( + absorption_density * in_horizontal.unsqueeze(0).float(), dim=1 + ), } diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/material.py b/examples/cfd/nuclear_engineering/radiation_transport/src/material.py index 262006cccd..0744f95b7f 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/material.py +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/material.py @@ -14,519 +14,38 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Material/physics-domain module for radiation-transport surrogates. +"""Material-property transform for radiation-transport surrogates. -Consolidates two concerns into one file: - -1. Pure-numpy *material mappers* that, given an (x, y) point cloud, assign a - discrete material label and the corresponding cross-section properties - (sigma_a, sigma_s, sigma_t, Q) for the two benchmark geometries: - - - ``LatticeMaterialMapper``: 7x7 grid of square blocks in - [-3.5, 3.5] x [-3.5, 3.5] (blue / red / white). - - ``HohlraumMaterialMapper``: complex hohlraum geometry in - [-0.65, 0.65] x [-0.65, 0.65] (black / red / green / blue / white). - -2. The ``MaterialPropertyExtractor`` *transform* that runs as part of the - per-sample pipeline. It uses precomputed sigma fields when the Zarr store - provides them, otherwise it falls back to invoking the mappers above on - the integer ``material_properties`` labels stored in the sample. - -A separate stats-computation utility (``compute_material_statistics``) is -used by ``compute_normalizations.py`` for offline normalization stats; it -is not a transform and lives outside this module. +The shipped zarr stores carry precomputed cross-section fields +(``sigma_a``, ``sigma_s``, ``sigma_t``, ``Q``) per cell. ``MaterialPropertyExtractor`` +stacks them into a single ``physical_properties`` tensor of shape ``(N, 4)`` +in the order ``[sigma_a, sigma_s, sigma_t, Q]``. """ from __future__ import annotations -import logging -from typing import Any, Dict, Optional - -import numpy as np import torch from tensordict import TensorDict -from transforms import Transform, td_get, to_numpy - - -# ========================================================================= -# Lattice material mapper -# ========================================================================= - - -class LatticeMaterialMapper: - """Maps spatial points to material properties for the lattice dataset. - - Domain: 7x7 grid of square blocks in [-3.5, 3.5] x [-3.5, 3.5] - - Blue blocks (11): pure absorption (sigma_a only) - - Red block (1): scattering source (sigma_s + Q=1) - - White blocks (37): pure scattering (sigma_s only) - """ - - # Domain parameters - DOMAIN_BOUNDS = (-3.5, 3.5) - BLOCK_SIZE = 1.0 - NUM_BLOCKS = 7 - - # Material region definitions - BLUE_BLOCKS = [ - (2, 2), - (4, 2), - (6, 2), - (3, 3), - (5, 3), - (2, 4), - (6, 4), - (3, 5), - (5, 5), - (2, 6), - (6, 6), - ] - RED_BLOCKS = [(4, 4)] - - # Material labels - MATERIAL_LABELS = {"blue": 0, "red": 1, "white": 2} - - # Default material properties - DEFAULT_MATERIAL_PROPERTIES = { - 0: {"sigma_t": None, "sigma_s": 0.0, "sigma_a": None, "Q": 0}, # blue - 1: {"sigma_t": None, "sigma_s": None, "sigma_a": 0.0, "Q": 1}, # red - 2: {"sigma_t": None, "sigma_s": None, "sigma_a": 0.0, "Q": 0}, # white - -1: {"sigma_t": 0, "sigma_s": 0, "sigma_a": 0, "Q": 0}, # outside - } - - def __init__( - self, - logger: Optional[logging.Logger] = None, - simulation_parameters: Optional[Dict[str, Any]] = None, - ): - """Initialize the lattice material mapper.""" - self.logger = logger or logging.getLogger(__name__) - self.simulation_parameters = simulation_parameters or {} - self._material_properties = self._calculate_material_properties() - - def _calculate_material_properties(self) -> Dict[int, Dict[str, float]]: - """Calculate material properties based on simulation parameters.""" - absorption_coeff = self.simulation_parameters.get("absorption_coeff", np.nan) - scattering_coeff = self.simulation_parameters.get("scattering_coeff", np.nan) - - # Blue: pure absorption - blue_props = { - "sigma_a": absorption_coeff, - "sigma_s": 0.0, - "sigma_t": absorption_coeff, - "Q": 0, - } - - # Red: scattering source - TODO: should be pure scattering. - red_props = { - "sigma_a": 0.0, - "sigma_s": scattering_coeff, - "sigma_t": 1.0, - "Q": 1, - } - - # White: pure scattering - white_props = { - "sigma_a": 0.0, - "sigma_s": scattering_coeff, - "sigma_t": scattering_coeff, - "Q": 0, - } - - # Outside domain - outside_props = {"sigma_a": 0, "sigma_s": 0, "sigma_t": 0, "Q": 0} - - return {0: blue_props, 1: red_props, 2: white_props, -1: outside_props} - - def get_block_index(self, x: float, y: float) -> tuple[int, int]: - """Convert x,y coordinates to block indices (1-indexed).""" - # Shift coordinates to [0, 7] range - x_shifted = x - self.DOMAIN_BOUNDS[0] - y_shifted = y - self.DOMAIN_BOUNDS[0] - - # Get block indices (1-indexed) - i = int(np.floor(x_shifted / self.BLOCK_SIZE)) + 1 - j = int(np.floor(y_shifted / self.BLOCK_SIZE)) + 1 - - # Clamp to valid range [1, 7] - i = max(1, min(self.NUM_BLOCKS, i)) - j = max(1, min(self.NUM_BLOCKS, j)) - - return i, j - - def map_coordinates_to_materials(self, coordinates: np.ndarray) -> np.ndarray: - """Map coordinates to material labels.""" - n_points = coordinates.shape[0] - material_labels = np.full(n_points, 2, dtype=np.int32) # Default: white - - for idx in range(n_points): - x, y = coordinates[idx, 0], coordinates[idx, 1] - i, j = self.get_block_index(x, y) - block = (i, j) - - if block in self.BLUE_BLOCKS: - material_labels[idx] = 0 - elif block in self.RED_BLOCKS: - material_labels[idx] = 1 - # else: stays white (2) - - return material_labels - - def get_material_properties(self, coordinates: np.ndarray) -> Dict[str, np.ndarray]: - """Get material property arrays for coordinates.""" - material_labels = self.map_coordinates_to_materials(coordinates) - n_points = len(coordinates) - - # Initialize arrays - sigma_t = np.zeros(n_points, dtype=np.float32) - sigma_s = np.zeros(n_points, dtype=np.float32) - sigma_a = np.zeros(n_points, dtype=np.float32) - Q = np.zeros(n_points, dtype=np.float32) - - # Fill arrays based on material labels - for label in [0, 1, 2]: - mask = material_labels == label - props = self._material_properties[label] - sigma_t[mask] = props["sigma_t"] - sigma_s[mask] = props["sigma_s"] - sigma_a[mask] = props["sigma_a"] - Q[mask] = props["Q"] - - return {"sigma_t": sigma_t, "sigma_s": sigma_s, "sigma_a": sigma_a, "Q": Q} - - -# ========================================================================= -# Hohlraum material mapper -# ========================================================================= - - -class HohlraumMaterialMapper: - """Maps spatial points to material properties for the hohlraum dataset. - - Domain: [-0.65, 0.65] x [-0.65, 0.65] with complex geometric regions - - Black (0): top/bottom horizontal strips - - Red (1): left/right vertical strips - - Green (2): capsule frame - - Blue (3): central capsule interior - - White (4): background region - """ - - # Domain parameters - DOMAIN_BOUNDS = (-0.65, 0.65) - - # Material labels - MATERIAL_LABELS = {"black": 0, "red": 1, "green": 2, "blue": 3, "white": 4} - - # Material properties (fixed values) - MATERIAL_PROPERTIES = { - 0: {"sigma_t": 100, "sigma_s": 50, "sigma_a": 50, "Q": 0}, # black - 1: {"sigma_t": 100, "sigma_s": 95, "sigma_a": 5, "Q": 0}, # red - 2: {"sigma_t": 100, "sigma_s": 90, "sigma_a": 10, "Q": 0}, # green - 3: {"sigma_t": 100, "sigma_s": 0, "sigma_a": 100, "Q": 0}, # blue - 4: {"sigma_t": 0.1, "sigma_s": 0.1, "sigma_a": 0, "Q": 0}, # white - -1: {"sigma_t": 0, "sigma_s": 0, "sigma_a": 0, "Q": 0}, # outside - } - - def __init__( - self, - logger: Optional[logging.Logger] = None, - boundary_thickness: float = 0.05, # Green frame thickness - simulation_parameters: Optional[Dict[str, Any]] = None, - capsule_half_width: float = 0.15, # Blue inner capsule half-width - capsule_half_height: float = 0.35, # Blue inner capsule half-height - ): - """Initialize the hohlraum material mapper.""" - self.logger = logger or logging.getLogger(__name__) - self.boundary_thickness = boundary_thickness - self.simulation_parameters = simulation_parameters or {} - self.capsule_half_width = capsule_half_width - self.capsule_half_height = capsule_half_height - self._material_regions = self._calculate_material_regions() - - def _calculate_material_regions(self) -> Dict: - """Calculate material regions based on simulation parameters.""" - # Get design parameters - ulr = self.simulation_parameters.get("ulr", 0.4) - llr = self.simulation_parameters.get("llr", -0.4) - urr = self.simulation_parameters.get("urr", 0.4) - lrr = self.simulation_parameters.get("lrr", -0.4) - hlr = self.simulation_parameters.get("hlr", -0.5) - hrr = self.simulation_parameters.get("hrr", 0.5) - cx = self.simulation_parameters.get("cx", 0.0) - cy = self.simulation_parameters.get("cy", 0.0) - - regions = {} - - # Black: top/bottom horizontal strips (wide regions, not thin borders) - # Black regions are defined as y > 0.6 or y < -0.6 - # This matches the KiT-RT QoI calculation logic (line 619) - regions["black"] = [ - { - "name": "K1", - "bounds": ( - self.DOMAIN_BOUNDS[0], - self.DOMAIN_BOUNDS[1], - self.DOMAIN_BOUNDS[0], - -0.6, # Bottom: y < -0.6 - ), - }, - { - "name": "K2", - "bounds": ( - self.DOMAIN_BOUNDS[0], - self.DOMAIN_BOUNDS[1], - 0.6, # Top: y > 0.6 - self.DOMAIN_BOUNDS[1], - ), - }, - ] - - # Red: left/right vertical strips (wide regions, not thin borders) - # Red regions are defined as everything left of hlr and right of hrr - # This matches the KiT-RT QoI calculation logic and the physical hohlraum geometry - regions["red"] = [ - { - "name": "R1", - "bounds": (self.DOMAIN_BOUNDS[0], hlr, llr, ulr), - }, # Left: x < hlr - { - "name": "R2", - "bounds": (hrr, self.DOMAIN_BOUNDS[1], lrr, urr), - }, # Right: x > hrr - ] - - # Green: capsule outer box (KiT-RT assigns green to entire outer box, then overwrites with blue) - # This matches KiT-RT lines 79-83: x in [-0.2+cx, 0.2+cx] && y in [-0.4+cy, 0.4+cy] - # The frame thickness is implicit: outer_width/2 - inner_width/2 = 0.2 - 0.15 = 0.05 - x_min_outer = cx - 0.2 - x_max_outer = cx + 0.2 - y_min_outer = cy - 0.4 - y_max_outer = cy + 0.4 - - regions["green"] = [ - { - "name": "G_outer", - "bounds": (x_min_outer, x_max_outer, y_min_outer, y_max_outer), - }, - ] - - # Blue: central capsule interior (checkered area) - # KiT-RT lines 84-88: x in [-0.15+cx, 0.15+cx] && y in [-0.35+cy, 0.35+cy] - x_min_blue = cx - 0.15 - x_max_blue = cx + 0.15 - y_min_blue = cy - 0.35 - y_max_blue = cy + 0.35 - - regions["blue"] = [ - { - "name": "B", - "bounds": (x_min_blue, x_max_blue, y_min_blue, y_max_blue), - }, - ] - - return regions - - def _is_in_blue_region(self, x: float, y: float) -> bool: - """Check if point is in blue capsule region.""" - for region in self._material_regions.get("blue", []): - x_min, x_max, y_min, y_max = region["bounds"] - if x_min <= x <= x_max and y_min <= y <= y_max: - return True - return False - - def _in_rect(self, x: float, y: float, bounds: tuple) -> bool: - """Check if point is in rectangle.""" - x_min, x_max, y_min, y_max = bounds - return x_min <= x <= x_max and y_min <= y <= y_max - - def get_material_property(self, x: float, y: float) -> int: - """Get material label for a single point. - - Uses KiT-RT's exact material assignment logic: - 1. Check black (top/bottom boundary regions) - 2. Check red (left/right vertical regions) - 3. Check green outer box (entire capsule region) - 4. Overwrite with blue if in inner region - 5. Default to white (background) - """ - # Priority order: black > red > green+blue > white - - # Check black (top/bottom strips) - highest priority - for region in self._material_regions.get("black", []): - if self._in_rect(x, y, region["bounds"]): - return 0 - - # Check red (left/right strips) - for region in self._material_regions.get("red", []): - if self._in_rect(x, y, region["bounds"]): - return 1 - - # Check green outer box (entire capsule region including corners) - # This assigns green to the FULL outer box first - for region in self._material_regions.get("green", []): - if self._in_rect(x, y, region["bounds"]): - # Now check if this point should be overwritten with blue (inner region) - if self._is_in_blue_region(x, y): - return 3 # blue overwrites green - return 2 # green - - # Default: white (background) - return 4 - - def map_coordinates_to_materials(self, coordinates: np.ndarray) -> np.ndarray: - """Map coordinates to material labels.""" - n_points = coordinates.shape[0] - material_labels = np.zeros(n_points, dtype=np.int32) - - for idx in range(n_points): - x, y = coordinates[idx, 0], coordinates[idx, 1] - material_labels[idx] = self.get_material_property(x, y) - - return material_labels - - def get_material_properties(self, coordinates: np.ndarray) -> Dict[str, np.ndarray]: - """Get material property arrays for coordinates.""" - material_labels = self.map_coordinates_to_materials(coordinates) - n_points = len(coordinates) - - # Initialize arrays - sigma_t = np.zeros(n_points, dtype=np.float32) - sigma_s = np.zeros(n_points, dtype=np.float32) - sigma_a = np.zeros(n_points, dtype=np.float32) - Q = np.zeros(n_points, dtype=np.float32) - - # Fill arrays based on material labels - for label in range(5): - mask = material_labels == label - props = self.MATERIAL_PROPERTIES[label] - sigma_t[mask] = props["sigma_t"] - sigma_s[mask] = props["sigma_s"] - sigma_a[mask] = props["sigma_a"] - Q[mask] = props["Q"] - - return {"sigma_t": sigma_t, "sigma_s": sigma_s, "sigma_a": sigma_a, "Q": Q} - - -# ========================================================================= -# Material transforms -# ========================================================================= +from transforms import Transform class MaterialPropertyExtractor(Transform): - """Extract physical material properties for radiation transport. - - Uses precomputed sigma fields (``sigma_a``, ``sigma_s``, ``sigma_t``, ``Q``) - when present in the sample; otherwise falls back to computing them from the - integer ``material_properties`` labels via the lattice/hohlraum mappers - defined above. The extracted properties are stored as - ``physical_properties`` with shape ``(N, 4)``: ``[sigma_a, sigma_s, sigma_t, Q]``. - """ - - def __init__(self, case_type: Optional[str] = None, add_to_sample: bool = True): - super().__init__() - self.case_type = case_type - self.add_to_sample = add_to_sample + """Stack precomputed sigma fields into a per-cell ``(N, 4)`` tensor.""" def __call__(self, data: TensorDict) -> TensorDict: - has_sigma_a = "sigma_a" in data - has_sigma_s = "sigma_s" in data - has_sigma_t = "sigma_t" in data - has_Q = "Q" in data - - if has_sigma_a and has_sigma_s and has_sigma_t: - if not has_Q: + for key in ("sigma_a", "sigma_s", "sigma_t", "Q"): + if key not in data: raise KeyError( - "Zarr store has precomputed sigma fields but is missing 'Q'. " - "All four fields (sigma_a, sigma_s, sigma_t, Q) are required." + f"Zarr store is missing required field {key!r}. " + "All four fields (sigma_a, sigma_s, sigma_t, Q) must be precomputed." ) - physical_props = torch.stack( - [data["sigma_a"], data["sigma_s"], data["sigma_t"], data["Q"]], - dim=-1, - ).to(dtype=torch.float32) - else: - if "material_properties" not in data: - raise KeyError( - "Sample must contain either precomputed sigma fields " - "(sigma_a, sigma_s, sigma_t) or 'material_properties' labels." - ) - - case_type = self.case_type - if case_type is None: - metadata = td_get(data, "metadata", default={}) or {} - case_type = ( - metadata.get("case_type", "") if isinstance(metadata, dict) else "" - ) - case_type = case_type.lower() - - material_labels = to_numpy(data["material_properties"]) - n_points = len(material_labels) - - if case_type == "lattice": - metadata = td_get(data, "metadata", default={}) or {} - sim_params = ( - metadata.get("simulation_params", {}) - if isinstance(metadata, dict) - else {} - ) - params = ( - sim_params.get("parameters", {}) - if isinstance(sim_params, dict) - else {} - ) - absorption_coeff = params.get("absorption_coeff") - scattering_coeff = params.get("scattering_coeff") - if absorption_coeff is None or scattering_coeff is None: - raise ValueError( - "Lattice case requires 'absorption_coeff' and 'scattering_coeff' " - "in metadata.simulation_params.parameters" - ) - - mapper = LatticeMaterialMapper( - simulation_parameters={ - "absorption_coeff": absorption_coeff, - "scattering_coeff": scattering_coeff, - } - ) - material_props = mapper._material_properties - - props_np = np.zeros((n_points, 4), dtype=np.float32) - for label in (0, 1, 2): - mask = material_labels == label - props = material_props[label] - props_np[mask, 0] = props["sigma_a"] - props_np[mask, 1] = props["sigma_s"] - props_np[mask, 2] = props["sigma_t"] - props_np[mask, 3] = props["Q"] - - elif case_type == "hohlraum": - material_props = HohlraumMaterialMapper.MATERIAL_PROPERTIES - props_np = np.zeros((n_points, 4), dtype=np.float32) - for label in (0, 1, 2, 3, 4): - mask = material_labels == label - props = material_props[label] - props_np[mask, 0] = props["sigma_a"] - props_np[mask, 1] = props["sigma_s"] - props_np[mask, 2] = props["sigma_t"] - props_np[mask, 3] = props["Q"] - else: - raise ValueError( - f"Unknown case_type: {case_type}. Must be 'lattice' or 'hohlraum'" - ) - - physical_props = torch.from_numpy(props_np) - - if self.add_to_sample: - data["physical_properties"] = physical_props + data["physical_properties"] = torch.stack( + [data["sigma_a"], data["sigma_s"], data["sigma_t"], data["Q"]], + dim=-1, + ).to(dtype=torch.float32) return data - def extra_repr(self) -> str: - return f"case_type={self.case_type}, add_to_sample={self.add_to_sample}" - -__all__ = [ - "LatticeMaterialMapper", - "HohlraumMaterialMapper", - "MaterialPropertyExtractor", -] +__all__ = ["MaterialPropertyExtractor"] diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/trainer.py b/examples/cfd/nuclear_engineering/radiation_transport/src/trainer.py index 69dd2e4778..5b414ac517 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/trainer.py +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/trainer.py @@ -605,7 +605,7 @@ def run_training_loop( val_loader: Validation DataLoader. train_sampler: DistributedSampler for training (or None). optimizer: Optimizer. - scheduler: LR scheduler (e.g. CosineAnnealingLR or ReduceLROnPlateau). + scheduler: LR scheduler. scaler: GradScaler for AMP. train_epoch_fn: ``(train_loader, model, optimizer, scaler, device, launch_logger, **train_epoch_kwargs) -> None``. @@ -686,16 +686,8 @@ def run_training_loop( ) val_log.epoch_losses.update(val_metrics) - scheduler_type = cfg.train.get("scheduler_type", "cosine") - if scheduler_type == "plateau": - scheduler.step(val_loss) - else: - scheduler.step() - - if scheduler_type == "plateau": - current_lr = optimizer.param_groups[0]["lr"] - else: - current_lr = scheduler.get_last_lr()[0] + scheduler.step() + current_lr = scheduler.get_last_lr()[0] if dist.rank == 0: if after_epoch_fn is not None: diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/transforms.py b/examples/cfd/nuclear_engineering/radiation_transport/src/transforms.py index 584ea3f315..b04aaefa34 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/transforms.py +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/transforms.py @@ -37,7 +37,7 @@ # ========================================================================= from pathlib import Path -from typing import Any, Dict, Mapping, Optional, Union +from typing import Any, Dict, Optional, Union import numpy as np import torch @@ -56,25 +56,6 @@ # the pipeline. -def td_from_dict(sample: Mapping[str, Any]) -> TensorDict: - """Wrap a heterogeneous sample dict into a zero-batch-size ``TensorDict``. - - ``numpy`` arrays and ``torch`` tensors become tensor entries. Any other - value (``None``, dict, str, Python scalar) is stored as ``NonTensorData``. - Use bracket access (``td["key"]``) on the result to retrieve the original - value; ``NonTensorData`` entries are transparently unwrapped. - """ - out = TensorDict({}, batch_size=[]) - for key, value in sample.items(): - if isinstance(value, torch.Tensor): - out[key] = value - elif isinstance(value, np.ndarray): - out[key] = torch.from_numpy(np.ascontiguousarray(value)) - else: - out.set_non_tensor(key, value) - return out - - def td_get(data: TensorDict, key: str, default: Any = None) -> Any: """``td[key]``-equivalent lookup with a default for missing keys. @@ -300,46 +281,42 @@ def extra_repr(self) -> str: # Sampling (spatial + steady-state) # ========================================================================= # -# ``SpatialSampler`` randomly subsamples / pads point clouds to a target size. +# ``SpatialSampler`` randomly subsamples point clouds to a target size. # ``SteadyStateSampler`` extracts the fixed initial->final flux mapping. @register("RTESpatialSampler") class SpatialSampler(Transform): - """Sample spatial points from mesh. + """Randomly subsample spatial points to ``num_points``. - Supports random sampling, fixed N, and padding for variable mesh sizes. + ``num_points = -1`` is a passthrough. Otherwise ``num_available`` must be + ``>= num_points`` (the shipped lattice / hohlraum meshes have tens of + thousands of cells, far above any practical ``num_points``). """ - def __init__( - self, - num_points: int, - pad_value: float = -100.0, - seed: Optional[int] = None, - ): + def __init__(self, num_points: int, seed: Optional[int] = None): super().__init__() self.num_points = num_points - self.pad_value = pad_value self.seed = seed self.rng = ( np.random.default_rng(seed) if seed is not None else np.random.default_rng() ) def __call__(self, data: TensorDict) -> TensorDict: - num_available = data["coordinates"].shape[0] - if self.num_points == -1: return data - needs_sampling = num_available > self.num_points - - if needs_sampling: - indices_np = self.rng.choice(num_available, self.num_points, replace=False) - else: - if num_available == self.num_points: - return data - indices_np = np.arange(num_available) + num_available = data["coordinates"].shape[0] + if num_available == self.num_points: + return data + if num_available < self.num_points: + raise ValueError( + f"SpatialSampler: num_available={num_available} < " + f"num_points={self.num_points}; the shipped meshes are larger " + "than any configured num_points, so this should never happen." + ) + indices_np = self.rng.choice(num_available, self.num_points, replace=False) indices = torch.from_numpy(indices_np.astype(np.int64)) spatial_keys = [ @@ -353,57 +330,19 @@ def __call__(self, data: TensorDict) -> TensorDict: "sigma_a", "Q", ] - for key in spatial_keys: - if key in data: - arr = data[key] - if arr is None: - continue - sampled = arr[indices] - if sampled.shape[0] < self.num_points: - sampled = self._pad_tensor(sampled, self.num_points) - data[key] = sampled + if key in data and data[key] is not None: + data[key] = data[key][indices] if "scalar_flux" in data: - flux = data["scalar_flux"][:, indices] # (T, N_sampled) - if flux.shape[1] < self.num_points: - flux = self._pad_flux(flux, self.num_points) - data["scalar_flux"] = flux + data["scalar_flux"] = data["scalar_flux"][:, indices] for flux_key in ("flux_input", "flux_target"): if flux_key in data: - flux_1d = data[flux_key][indices] - if flux_1d.shape[0] < self.num_points: - flux_1d = self._pad_flux_1d(flux_1d, self.num_points) - data[flux_key] = flux_1d + data[flux_key] = data[flux_key][indices] return data - def _pad_tensor(self, tensor: torch.Tensor, target_size: int) -> torch.Tensor: - if tensor.shape[0] >= target_size: - return tensor[:target_size] - pad_shape = list(tensor.shape) - pad_shape[0] = target_size - tensor.shape[0] - padding = torch.full( - pad_shape, float(self.pad_value), dtype=tensor.dtype, device=tensor.device - ) - return torch.cat([tensor, padding], dim=0) - - def _pad_flux(self, flux: torch.Tensor, target_size: int) -> torch.Tensor: - if flux.shape[1] >= target_size: - return flux[:, :target_size] - pad_shape = (flux.shape[0], target_size - flux.shape[1]) - padding = torch.full(pad_shape, -10.0, dtype=flux.dtype, device=flux.device) - return torch.cat([flux, padding], dim=1) - - def _pad_flux_1d(self, flux: torch.Tensor, target_size: int) -> torch.Tensor: - if flux.shape[0] >= target_size: - return flux[:target_size] - pad_shape = list(flux.shape) - pad_shape[0] = target_size - flux.shape[0] - padding = torch.full(pad_shape, -10.0, dtype=flux.dtype, device=flux.device) - return torch.cat([flux, padding], dim=0) - def extra_repr(self) -> str: return f"num_points={self.num_points}" @@ -443,7 +382,6 @@ def __call__(self, data: TensorDict) -> TensorDict: __all__ = [ # Framework "Transform", - "td_from_dict", "td_get", "to_numpy", # Flux From 63264c809095e25d0ecda56e3d356486d4830d2b Mon Sep 17 00:00:00 2001 From: Carmelo Gonzales Date: Mon, 4 May 2026 15:43:58 -0700 Subject: [PATCH 19/68] feat: purging unused code --- .../radiation_transport/src/inference.py | 77 +--- .../radiation_transport/src/loader.py | 362 +++++------------- .../radiation_transport/src/train.py | 17 +- .../radiation_transport/src/trainer.py | 6 +- 4 files changed, 129 insertions(+), 333 deletions(-) diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/inference.py b/examples/cfd/nuclear_engineering/radiation_transport/src/inference.py index 3ac3971722..49da2e316c 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/inference.py +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/inference.py @@ -32,7 +32,6 @@ import argparse import os import pathlib -import re import sys from pathlib import Path from typing import Any, Dict, Iterator, Optional, Tuple, Union @@ -56,6 +55,8 @@ from dataset import load_flux_stats # noqa: E402 from loader import build_dataloaders, collate_no_padding # noqa: E402 +from losses import extract_geometry_params # noqa: E402 +from trainer import initialize_distributed_manager # noqa: E402 from transforms import denormalize_flux # noqa: E402 from physicsnemo.distributed import DistributedManager # noqa: E402 @@ -134,7 +135,7 @@ def load_model_from_checkpoint( cfg = load_hydra_config(checkpoint_dir) - _initialize_distributed_manager() + initialize_distributed_manager() # Build model from cfg.model. Strip RTE-specific keys consumed elsewhere. cfg_model = OmegaConf.to_container(cfg.model, resolve=True) @@ -160,44 +161,29 @@ def load_model_from_checkpoint( return model, cfg, metadata -def _initialize_distributed_manager() -> None: - """Use distributed init only for torchrun/explicit distributed launches.""" - if DistributedManager.is_initialized(): - return - - explicit_method = os.getenv("PHYSICSNEMO_DISTRIBUTED_INITIALIZATION_METHOD") - torchrun_env = os.getenv("RANK") is not None and os.getenv("WORLD_SIZE") is not None - openmpi_env = os.getenv("OMPI_COMM_WORLD_RANK") is not None - - if explicit_method or torchrun_env or openmpi_env: - DistributedManager.initialize() - return - - DistributedManager._shared_state["_is_initialized"] = True - dist = DistributedManager() - dist._initialization_method = "single" - if torch.cuda.is_available(): - torch.cuda.set_device(dist.device) - - # ========================================================================= # Metrics # ========================================================================= def mse(pred: np.ndarray, target: np.ndarray) -> float: + """Mean squared error.""" return float(np.mean((pred - target) ** 2)) def rmse(pred: np.ndarray, target: np.ndarray) -> float: + """Root mean squared error.""" return float(np.sqrt(np.mean((pred - target) ** 2))) def mae(pred: np.ndarray, target: np.ndarray) -> float: + """Mean absolute error.""" return float(np.mean(np.abs(pred - target))) -def l2_relative_error(pred: np.ndarray, target: np.ndarray, eps: float = 1e-10) -> float: +def l2_relative_error( + pred: np.ndarray, target: np.ndarray, eps: float = 1e-10 +) -> float: """Sample-wise L2 relative error: ||pred - target||_2 / ||target||_2.""" num = np.linalg.norm(pred.flatten() - target.flatten()) den = np.linalg.norm(target.flatten()) + eps @@ -242,28 +228,6 @@ def aggregate_metrics(per_sample: list[Dict[str, float]]) -> Dict[str, float]: # ========================================================================= -def _extract_geometry_params(filename: Optional[str]) -> Optional[Dict[str, float]]: - """Parse hohlraum geometry parameters out of a simulation filename.""" - if not filename: - return None - patterns = { - "cx": r"cx([-\d.]+)", - "cy": r"cy([-\d.]+)", - "ulr": r"ulr([-\d.]+)", - "llr": r"llr([-\d.]+)", - "urr": r"urr([-\d.]+)", - "lrr": r"lrr([-\d.]+)", - "hlr": r"hlr([-\d.]+)", - "hrr": r"hrr([-\d.]+)", - } - params: Dict[str, float] = {} - for key, pat in patterns.items(): - m = re.search(pat, filename) - if m: - params[key] = float(m.group(1).rstrip(".")) - return params if params else None - - def evaluate_lattice_qoi( cell_centers: np.ndarray, cell_areas: np.ndarray, @@ -366,8 +330,11 @@ def compute_sample_qoi( qp = evaluate_lattice_qoi(coords, cell_areas, sigma_t, sigma_s, pred) qt = evaluate_lattice_qoi(coords, cell_areas, sigma_t, sigma_s, target) elif case_type == "hohlraum": - gp = _extract_geometry_params(metadata.get("filename")) - if gp is None: + filename = metadata.get("filename") + if not filename: + return None + gp = extract_geometry_params(filename) + if not gp: return None qp = evaluate_hohlraum_qoi(coords, cell_areas, sigma_t, sigma_s, pred, gp) qt = evaluate_hohlraum_qoi(coords, cell_areas, sigma_t, sigma_s, target, gp) @@ -608,8 +575,7 @@ def plot_error_histogram( ax.set_xlabel("|prediction - target|") ax.set_ylabel("Count (log)") ax.set_title( - f"Pointwise error histogram (mean={errors.mean():.3e}, " - f"max={errors.max():.3e})" + f"Pointwise error histogram (mean={errors.mean():.3e}, max={errors.max():.3e})" ) plt.tight_layout() plt.savefig(output_path, dpi=dpi, bbox_inches="tight") @@ -808,9 +774,7 @@ def _resolve_data_path( str(Path(cli_data_path) / case_type), force_add=True, ) - flux_stats_file = ( - Path(cli_data_path) / "stats" / f"{case_type}_flux_stats.yaml" - ) + flux_stats_file = Path(cli_data_path) / "stats" / f"{case_type}_flux_stats.yaml" OmegaConf.update( cfg, "data.flux_normalization_stats_file", @@ -823,6 +787,7 @@ def _resolve_data_path( def main(): + """CLI entry point: parse args, load checkpoint, run evaluation, write outputs.""" parser = argparse.ArgumentParser( description="Evaluate a trained RTE Transolver model on the test split.", formatter_class=argparse.ArgumentDefaultsHelpFormatter, @@ -857,8 +822,7 @@ def main(): "--output_dir", type=Path, default=None, - help="Where to write metrics + figures. " - "Defaults to /evaluation.", + help="Where to write metrics + figures. Defaults to /evaluation.", ) parser.add_argument( "--num_samples", @@ -932,7 +896,6 @@ def main(): loaders, _ = build_dataloaders( cfg, dist=None, - adapter="transolver", collate_fn=collate_no_padding, phases=("test",), test_batch_size=1, @@ -1010,9 +973,7 @@ def main(): qoi_series = collect_qoi_series(per_sample_qoi) if "total" in qoi_series: total_target, total_prediction = qoi_series["total"] - qoi_summary["total"] = summarize_qoi_series( - total_target, total_prediction - ) + qoi_summary["total"] = summarize_qoi_series(total_target, total_prediction) with open(output_dir / "qoi_metrics.yaml", "w") as f: yaml.safe_dump(qoi_summary, f, sort_keys=False) plot_qoi_true_vs_pred(qoi_series, figures_dir / "qoi_true_vs_pred.png") diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/loader.py b/examples/cfd/nuclear_engineering/radiation_transport/src/loader.py index 28dee6b3f4..2a4886d148 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/loader.py +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/loader.py @@ -16,19 +16,17 @@ """Data plumbing: TransolverAdapter, collate, datapipe orchestration, DataLoader builder. -This module is the "wiring" layer of the RTE Transolver example. It composes -the ``RTEBaseDataset`` (data source) with a ``Compose`` of transforms and a -``TransolverAdapter`` to produce model-ready batches, and exposes a single -``build_dataloaders`` entry point used by the training and evaluation -scripts. +This module composes ``RTEBaseDataset`` (data source) with a ``Compose`` of +transforms and a ``TransolverAdapter``, and exposes a single +``build_dataloaders`` entry point used by ``train.py`` and ``inference.py``. Sections: -* Adapter — ``ModelAdapter`` base + ``TransolverAdapter`` + ``_as_dict`` helper. +* Adapter — ``TransolverAdapter``. * Collation — ``collate_no_padding`` (batch_size=1 unsqueeze). * Stats / kwargs translation — ``build_rte_dataset_kwargs``. * Pipeline orchestration — ``RTEDataPipe`` + ``_build_transforms`` + - ``_build_adapter`` + ``from_config`` + ``create_dataset``. + ``_build_rte_datapipe``. * Distributed preload barrier — file-marker rank-sequencing helpers. * DataLoader builder — ``build_dataloaders`` + ``_make_loader`` + ``_log_material_sanity``. @@ -42,7 +40,6 @@ import logging import time -from abc import ABC, abstractmethod from pathlib import Path from typing import ( Any, @@ -88,69 +85,33 @@ # Adapter # ========================================================================= # -# ``ModelAdapter`` is the abstract base class for converting a transformed -# RTE sample into a model-specific input dict. Only ``TransolverAdapter`` is -# shipped here (GenericAdapter and GeoTransolverAdapter were dropped as part -# of the upstream Transolver-only consolidation). - - -class ModelAdapter(ABC): - """Abstract base class for model-specific data adapters. - - Adapters take a transformed sample (a ``TensorDict`` or plain dict with - numpy arrays / torch tensors) and convert it to the format expected by a - particular model (e.g. Transolver). - """ - - @abstractmethod - def __call__(self, sample: Dict[str, Any]) -> Dict[str, torch.Tensor]: - """Convert a sample to model-specific format.""" - pass - - def __repr__(self) -> str: - return f"{self.__class__.__name__}()" - - -def _as_dict(sample: Union[TensorDict, Dict[str, Any]]) -> Dict[str, Any]: - """Unwrap a ``TensorDict`` into a plain dict; pass through regular dicts. - - Tensor entries remain ``torch.Tensor`` references (no copy). NonTensorData - entries are transparently unwrapped via bracket access. - """ - if isinstance(sample, TensorDict): - return {k: sample[k] for k in sample.keys()} - return sample +# ``TransolverAdapter`` packages a transformed RTE TensorDict into the +# tensor dict that ``physicsnemo.models.transolver.Transolver`` expects. +# Only one model is shipped — there is no plug-in dispatcher. @register("RTETransolverAdapter") -class TransolverAdapter(ModelAdapter): - """Adapter for the Transolver model. +class TransolverAdapter: + """Pack a transformed RTE TensorDict into Transolver-ready tensors. - Maps RTE data to Transolver's expected input format: - - * ``fx`` — spatial coordinates ``[x, y, z]`` (plus Fourier features when enabled). + Output keys: + * ``fx`` — spatial coordinates (plus Fourier features when enabled). * ``embedding`` — material properties ``[sigma_a, sigma_s, sigma_t, Q]`` (or just the first three when ``include_q_in_embedding=False``). * ``flux_target`` — target flux to predict. Extra fields (``coordinates_unnormalized``, ``material_labels``, ``cell_areas``, ``sigma_t``, ``sigma_s``, ``sim_time``, and - ``flux_normalization_stats``) are passed through when present in the sample. + ``flux_normalization_stats``) pass through when present. + + The output has no batch dimension; ``collate_no_padding`` adds one. """ - def __init__( - self, - add_batch_dim: bool = False, - include_q_in_embedding: bool = True, - ): - self.add_batch_dim = add_batch_dim + def __init__(self, include_q_in_embedding: bool = True): self.include_q_in_embedding = include_q_in_embedding - def __call__( - self, data: Union[TensorDict, Dict[str, Any]] - ) -> Dict[str, torch.Tensor]: - """Convert sample to Transolver format.""" - sample = _as_dict(data) + def __call__(self, data: TensorDict) -> Dict[str, torch.Tensor]: + sample = {k: data[k] for k in data.keys()} result: Dict[str, Any] = {} def to_tensor(x): @@ -159,24 +120,18 @@ def to_tensor(x): return torch.from_numpy(x).float() if "coordinates" in sample: - coords = to_tensor(sample["coordinates"]) - if self.add_batch_dim: - coords = coords.unsqueeze(0) - result["fx"] = coords + result["fx"] = to_tensor(sample["coordinates"]) if "physical_properties" in sample: mat_props = to_tensor(sample["physical_properties"]) if not self.include_q_in_embedding: mat_props = mat_props[..., :3] - if self.add_batch_dim: - mat_props = mat_props.unsqueeze(0) result["embedding"] = mat_props if "coordinates_unnormalized" in sample: - coords_unnorm = to_tensor(sample["coordinates_unnormalized"]) - if self.add_batch_dim: - coords_unnorm = coords_unnorm.unsqueeze(0) - result["coordinates_unnormalized"] = coords_unnorm + result["coordinates_unnormalized"] = to_tensor( + sample["coordinates_unnormalized"] + ) if ( "material_properties" in sample @@ -187,74 +142,52 @@ def to_tensor(x): mat_labels = torch.from_numpy(mat_labels.astype(np.int64)) elif isinstance(mat_labels, torch.Tensor): mat_labels = mat_labels.long() - if self.add_batch_dim: - mat_labels = mat_labels.unsqueeze(0) result["material_labels"] = mat_labels if "flux_target" in sample: flux_tgt = to_tensor(sample["flux_target"]) if flux_tgt.ndim == 1: flux_tgt = flux_tgt.unsqueeze(-1) - if self.add_batch_dim: - flux_tgt = flux_tgt.unsqueeze(0) result["flux_target"] = flux_tgt - if "cell_areas" in sample: - cell_areas = to_tensor(sample["cell_areas"]) - if self.add_batch_dim: - cell_areas = cell_areas.unsqueeze(0) - result["cell_areas"] = cell_areas - - if "sigma_t" in sample: - sigma_t = to_tensor(sample["sigma_t"]) - if self.add_batch_dim: - sigma_t = sigma_t.unsqueeze(0) - result["sigma_t"] = sigma_t - - if "sigma_s" in sample: - sigma_s = to_tensor(sample["sigma_s"]) - if self.add_batch_dim: - sigma_s = sigma_s.unsqueeze(0) - result["sigma_s"] = sigma_s + for key in ("cell_areas", "sigma_t", "sigma_s"): + if key in sample: + result[key] = to_tensor(sample[key]) if "sim_times" in sample: sim_times_arr = sample["sim_times"] if isinstance(sim_times_arr, torch.Tensor) and sim_times_arr.numel() > 0: - sim_time = torch.tensor( + result["sim_time"] = torch.tensor( [float(sim_times_arr[-1].item())], dtype=torch.float32 ) elif hasattr(sim_times_arr, "__len__") and len(sim_times_arr) > 0: - sim_time = torch.tensor([float(sim_times_arr[-1])], dtype=torch.float32) + result["sim_time"] = torch.tensor( + [float(sim_times_arr[-1])], dtype=torch.float32 + ) else: - sim_time = torch.tensor([0.0], dtype=torch.float32) - if self.add_batch_dim: - sim_time = sim_time.unsqueeze(0) - result["sim_time"] = sim_time + result["sim_time"] = torch.tensor([0.0], dtype=torch.float32) if "flux_normalization_stats" in sample: result["flux_normalization_stats"] = sample["flux_normalization_stats"] metadata = sample.get("metadata", {}) or {} - max_timestep = metadata.get("max_timestep") if isinstance(metadata, dict) else None metadata_dict = { "timestep_input": sample.get("timestep_input"), "timestep_target": sample.get("timestep_target"), - "max_timestep": max_timestep, + "max_timestep": metadata.get("max_timestep") + if isinstance(metadata, dict) + else None, "filename": sample.get("filename"), - "case_type": ( - metadata.get("case_type") if isinstance(metadata, dict) else None - ), + "case_type": metadata.get("case_type") + if isinstance(metadata, dict) + else None, } result["metadata"] = {k: v for k, v in metadata_dict.items() if v is not None} return result def __repr__(self) -> str: - return ( - f"{self.__class__.__name__}(" - f"add_batch_dim={self.add_batch_dim}, " - f"include_q_in_embedding={self.include_q_in_embedding})" - ) + return f"{self.__class__.__name__}(include_q_in_embedding={self.include_q_in_embedding})" # ========================================================================= @@ -297,13 +230,12 @@ def collate_no_padding(batch: List[Dict[str, Any]]) -> Dict[str, Any]: def build_rte_dataset_kwargs( cfg: DictConfig, *, - adapter: str, num_spatial_points_key: str = "num_spatial_points", num_spatial_points_override: Optional[int] = None, split_file_override: Optional[str] = None, extra_kwargs: Optional[dict] = None, ) -> dict: - """Translate a Hydra config into the kwargs ``create_dataset`` expects. + """Translate a Hydra config into the kwargs ``_build_rte_datapipe`` expects. Callers that run against a checkpoint's saved config (evaluation) can provide overrides for values the CLI wants to win over the config @@ -380,7 +312,6 @@ def build_rte_dataset_kwargs( kwargs = { "data_path": cfg.case.data_path, "num_spatial_points": num_spatial_points, - "adapter": adapter, "flux_normalization_stats_file": flux_stats_file, "normalize_coordinates": data_cfg.get("normalize_coordinates", True), "flux_clip_threshold": flux_clip_threshold, @@ -406,26 +337,15 @@ def build_rte_dataset_kwargs( # ========================================================================= # # ``RTEDataPipe`` composes ``RTEBaseDataset`` (data source) with a ``Compose`` -# of transforms and a ``TransolverAdapter`` (model adapter). ``from_config`` -# is the simple-configuration entry point; ``_build_transforms`` and -# ``_build_adapter`` are internal builders used by ``from_config``. -# ``create_dataset`` is the convenience wrapper used by ``build_dataloaders``. +# of transforms and a ``TransolverAdapter`` (model adapter). +# ``_build_rte_datapipe`` is the high-level builder used by +# ``build_dataloaders``; ``compute_normalizations.py`` instantiates +# ``RTEDataPipe`` directly with a custom transform pipeline and ``adapter=None``. @register("RTEDataPipe") class RTEDataPipe(Dataset): - """High-level composable datapipe for RTE data. - - Combines: - - * ``RTEBaseDataset`` (data source / file enumeration) - * ``Compose`` of transforms (preprocessing pipeline) - * ``TransolverAdapter`` (model-specific tensor packaging) - - For the canonical training configuration, use ``RTEDataPipe.from_config``; - for fully custom pipelines, instantiate directly with explicit - ``transforms`` and ``adapter``. - """ + """``RTEBaseDataset`` + transforms + (optional) adapter, in one ``Dataset``.""" def __init__( self, @@ -439,18 +359,16 @@ def __init__( cache_static_arrays: bool = True, max_cache_size: int = 200, ): - """Initialize the datapipe (see ``from_config`` for the simple path).""" self.base_dataset = RTEBaseDataset( data_path=data_path, case_type=case_type, phase=phase, split_file=split_file, seed=seed, - load_sigma_fields=True, # load precomputed material properties for speed + load_sigma_fields=True, cache_static_arrays=cache_static_arrays, max_cache_size=max_cache_size, ) - self.transforms = transforms self.adapter = adapter @@ -458,108 +376,25 @@ def __len__(self) -> int: return len(self.base_dataset) def __getitem__(self, idx: int) -> Any: - """Get a sample from the dataset. - - ``base_dataset[idx]`` returns a ``TensorDict`` with tensor fields plus - ``metadata`` / ``filename`` as ``NonTensorData`` entries. Transforms - consume and return ``TensorDict``; the adapter converts to the - model-specific format. - """ td = self.base_dataset[idx] - if self.transforms is not None: td = self.transforms(td) - if self.adapter is not None: return self.adapter(td) return td - @classmethod - def from_config( - cls, - data_path: Union[str, Path], - case_type: Optional[Literal["lattice", "hohlraum"]] = None, - adapter: Optional[Literal["transolver", None]] = "transolver", - phase: Literal["train", "val", "test"] = "train", - # Data processing options - num_spatial_points: int = 2048, - flux_normalization_stats_file: Optional[Union[str, Path]] = None, - normalize_coordinates: bool = True, - flux_clip_threshold: float = 1e-8, - split_file: Optional[Union[str, Path]] = None, - seed: Optional[int] = None, - # Cache options - cache_static_arrays: bool = True, - max_cache_size: int = 200, - # Transolver-specific options - include_q_in_embedding: bool = True, - # Fourier features options - use_fourier_features: bool = False, - fourier_num_frequencies: int = 3, - fourier_coord_dims: int = 2, - fourier_base_frequency: float = 1.0, - ) -> "RTEDataPipe": - """Create a datapipe from a simple configuration. - - The transform pipeline and adapter are built by ``_build_transforms`` - and ``_build_adapter`` respectively; this method is a thin - orchestrator that validates required inputs, composes both stages, - and returns the configured datapipe. - """ - if flux_normalization_stats_file is None: - raise ValueError( - "flux_normalization_stats_file is required. " - "Run compute_normalizations.py first to generate statistics file." - ) - - transforms = _build_transforms( - data_path=data_path, - case_type=case_type, - flux_normalization_stats_file=flux_normalization_stats_file, - flux_clip_threshold=flux_clip_threshold, - seed=seed, - num_spatial_points=num_spatial_points, - normalize_coordinates=normalize_coordinates, - use_fourier_features=use_fourier_features, - fourier_num_frequencies=fourier_num_frequencies, - fourier_coord_dims=fourier_coord_dims, - fourier_base_frequency=fourier_base_frequency, - ) - - adapter_obj = _build_adapter( - adapter, - include_q_in_embedding=include_q_in_embedding, - ) - - return cls( - data_path=data_path, - transforms=transforms, - adapter=adapter_obj, - case_type=case_type, - phase=phase, - split_file=split_file, - seed=seed, - cache_static_arrays=cache_static_arrays, - max_cache_size=max_cache_size, - ) - def preload_to_memory(self, verbose: bool = True, num_workers: int = 8) -> dict: """Preload all static arrays into main process memory. Workers inherit the populated cache via fork, eliminating disk I/O. - Uses parallel I/O for faster loading on multi-core systems. """ return self.base_dataset.preload_to_memory( verbose=verbose, num_workers=num_workers ) - def get_raw_sample(self, idx: int) -> TensorDict: - """Get a raw sample as a ``TensorDict`` (pre-transform, pre-adapter).""" - return self.base_dataset[idx] - def get_transformed_sample(self, idx: int) -> TensorDict: """Get sample with transforms applied but no adapter (``TensorDict``).""" - td = self.get_raw_sample(idx) + td = self.base_dataset[idx] if self.transforms is not None: td = self.transforms(td) return td @@ -575,6 +410,58 @@ def __repr__(self) -> str: return "\n".join(lines) +def _build_rte_datapipe( + case_type: Literal["lattice", "hohlraum"], + data_path: Union[str, Path], + phase: Literal["train", "val", "test"], + *, + num_spatial_points: int, + flux_normalization_stats_file: Union[str, Path], + normalize_coordinates: bool, + flux_clip_threshold: float, + split_file: Union[str, Path], + seed: Optional[int], + cache_static_arrays: bool, + max_cache_size: int, + include_q_in_embedding: bool, + use_fourier_features: bool, + fourier_num_frequencies: Optional[int], + fourier_coord_dims: Optional[int], + fourier_base_frequency: Optional[float], +) -> RTEDataPipe: + """Build the canonical training/inference RTE datapipe.""" + if case_type not in ("lattice", "hohlraum"): + raise ValueError( + f"Unknown case_type: {case_type!r}. Expected 'lattice' or 'hohlraum'." + ) + + transforms = _build_transforms( + data_path=data_path, + case_type=case_type, + flux_normalization_stats_file=flux_normalization_stats_file, + flux_clip_threshold=flux_clip_threshold, + seed=seed, + num_spatial_points=num_spatial_points, + normalize_coordinates=normalize_coordinates, + use_fourier_features=use_fourier_features, + fourier_num_frequencies=fourier_num_frequencies, + fourier_coord_dims=fourier_coord_dims, + fourier_base_frequency=fourier_base_frequency, + ) + + return RTEDataPipe( + data_path=data_path, + transforms=transforms, + adapter=TransolverAdapter(include_q_in_embedding=include_q_in_embedding), + case_type=case_type, + phase=phase, + split_file=split_file, + seed=seed, + cache_static_arrays=cache_static_arrays, + max_cache_size=max_cache_size, + ) + + def _build_transforms( *, data_path: Union[str, Path], @@ -632,9 +519,7 @@ def _build_transforms( material_stats = load_material_stats(material_stats_path) transform_list.append( Normalize( - **material_normalize_kwargs( - material_stats, field="physical_properties" - ) + **material_normalize_kwargs(material_stats, field="physical_properties") ) ) @@ -676,48 +561,6 @@ def _build_transforms( return Compose(transform_list) -def _build_adapter( - adapter: Optional[str], - *, - include_q_in_embedding: bool, -): - """Build the model-specific output adapter (or ``None``). - - Collapsed to a constant for the upstream Transolver-only example: the - only valid non-``None`` value is ``"transolver"``. Kept as a function - for plug-in clarity so ``from_config`` flows naturally. - """ - if adapter is None: - return None - if adapter == "transolver": - return TransolverAdapter(include_q_in_embedding=include_q_in_embedding) - raise ValueError( - f"Unknown adapter: {adapter!r}. The upstream example ships only " - "'transolver' (or None for raw TensorDicts)." - ) - - -def create_dataset( - case_type: Literal["lattice", "hohlraum"], - data_path: Union[str, Path], - phase: Literal["train", "val", "test"] = "train", - adapter: Optional[Literal["transolver"]] = "transolver", - **kwargs, -) -> RTEDataPipe: - """Create a dataset for the given case type.""" - if case_type not in ("lattice", "hohlraum"): - raise ValueError( - f"Unknown case_type: {case_type!r}. Expected 'lattice' or 'hohlraum'." - ) - return RTEDataPipe.from_config( - data_path=data_path, - case_type=case_type, - phase=phase, - adapter=adapter, - **kwargs, - ) - - # ========================================================================= # Distributed preload barrier # ========================================================================= @@ -944,7 +787,6 @@ def build_dataloaders( cfg: DictConfig, dist=None, *, - adapter: str = "transolver", collate_fn: Optional[Callable] = None, extra_dataset_kwargs: Optional[dict] = None, phases: Iterable[str] = ("train", "val"), @@ -960,9 +802,8 @@ def build_dataloaders( Args: cfg: Hydra configuration (training cfg or a loaded checkpoint cfg). dist: ``DistributedManager`` for training; ``None`` for eval. - adapter: Model adapter identifier (only ``"transolver"`` is shipped). collate_fn: Collate function. Defaults to ``collate_no_padding``. - extra_dataset_kwargs: Additional kwargs forwarded to ``create_dataset``. + extra_dataset_kwargs: Additional kwargs forwarded to the datapipe. phases: Which splits to build (subset of ``{"train", "val", "test"}``). num_spatial_points_key: Where to read ``num_spatial_points`` from the config (dotted path). Overridden by @@ -994,7 +835,6 @@ def build_dataloaders( common_kwargs = build_rte_dataset_kwargs( cfg, - adapter=adapter, num_spatial_points_key=num_spatial_points_key, num_spatial_points_override=num_spatial_points_override, split_file_override=split_file_override, @@ -1013,7 +853,7 @@ def build_dataloaders( ) datasets = { - phase: create_dataset(cfg.case.type, phase=phase, **common_kwargs) + phase: _build_rte_datapipe(cfg.case.type, phase=phase, **common_kwargs) for phase in phases } @@ -1071,11 +911,9 @@ def build_dataloaders( __all__ = [ - "ModelAdapter", "TransolverAdapter", "collate_no_padding", "build_rte_dataset_kwargs", "RTEDataPipe", - "create_dataset", "build_dataloaders", ] diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/train.py b/examples/cfd/nuclear_engineering/radiation_transport/src/train.py index 3366be563e..e57455d4e0 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/train.py +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/train.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. +# SPDX-FileCopyrightText: Copyright (c) 2023 - 2026 NVIDIA CORPORATION & AFFILIATES. # SPDX-FileCopyrightText: All rights reserved. # SPDX-License-Identifier: Apache-2.0 # @@ -99,7 +99,6 @@ def build_dataloaders_for_training( loaders, train_sampler = build_dataloaders( cfg, dist, - adapter="transolver", collate_fn=collate_no_padding, phases=("train", "val"), logger=logger, @@ -110,8 +109,7 @@ def build_dataloaders_for_training( def to_device(batch: Dict[str, Any], device: torch.device) -> Dict[str, Any]: """Move tensor entries of a batch dict to ``device``; pass through the rest.""" return { - k: v.to(device) if isinstance(v, torch.Tensor) else v - for k, v in batch.items() + k: v.to(device) if isinstance(v, torch.Tensor) else v for k, v in batch.items() } @@ -123,7 +121,9 @@ def forward( return model(fx=batch["fx"], embedding=batch["embedding"]) -def loss_inputs(batch: Dict[str, Any], *, require_physics: bool = False) -> Dict[str, Any]: +def loss_inputs( + batch: Dict[str, Any], *, require_physics: bool = False +) -> Dict[str, Any]: """Assemble the dict of optional/physics inputs consumed by ``compute_losses``. Physics loss requires raw, unnormalized coordinates. The model embedding @@ -395,9 +395,7 @@ def main(cfg: DictConfig) -> None: "warmup_start_fraction", 0.0 ) if dist.rank == 0 and physics_loss_warmup_epochs > 0: - logger.info( - f" Physics-loss warmup epochs: {physics_loss_warmup_epochs}" - ) + logger.info(f" Physics-loss warmup epochs: {physics_loss_warmup_epochs}") logger.info( f" Physics-loss warmup start fraction: {physics_loss_warmup_start}" ) @@ -439,8 +437,7 @@ def before_epoch_fn(epoch: int): else: progress = epoch / max(1, physics_loss_warmup_epochs) current_weight = ( - physics_loss_warmup_start - + (1.0 - physics_loss_warmup_start) * progress + physics_loss_warmup_start + (1.0 - physics_loss_warmup_start) * progress ) * physics_loss_weight_base if dist.rank == 0 and epoch < physics_loss_warmup_epochs: logger.info( diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/trainer.py b/examples/cfd/nuclear_engineering/radiation_transport/src/trainer.py index 5b414ac517..6cb00fae95 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/trainer.py +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/trainer.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. +# SPDX-FileCopyrightText: Copyright (c) 2023 - 2026 NVIDIA CORPORATION & AFFILIATES. # SPDX-FileCopyrightText: All rights reserved. # SPDX-License-Identifier: Apache-2.0 # @@ -295,7 +295,7 @@ def setup_training_environment( Returns: ``(dist, logger)``. """ - _initialize_distributed_manager() + initialize_distributed_manager() dist = DistributedManager() synchronize_output_directory(cfg, dist) @@ -314,7 +314,7 @@ def setup_training_environment( return dist, logger -def _initialize_distributed_manager() -> None: +def initialize_distributed_manager() -> None: """Initialize distributed state without misreading an interactive SLURM shell. PhysicsNeMo's default initializer auto-detects SLURM variables. In an From 619ea5b40b8feef0fccda5ce3e10d0ff117bcaf4 Mon Sep 17 00:00:00 2001 From: Carmelo Gonzales Date: Mon, 4 May 2026 16:51:58 -0700 Subject: [PATCH 20/68] feat: some more refactoring and purging --- .../radiation_transport/src/checkpointing.py | 23 +- .../src/compute_normalizations.py | 98 ++------ .../radiation_transport/src/dataset.py | 77 ++----- .../radiation_transport/src/inference.py | 201 +++++----------- .../radiation_transport/src/loader.py | 217 ++++-------------- .../radiation_transport/src/train.py | 7 - .../radiation_transport/src/trainer.py | 5 +- 7 files changed, 143 insertions(+), 485 deletions(-) diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/checkpointing.py b/examples/cfd/nuclear_engineering/radiation_transport/src/checkpointing.py index ea0da00375..336e33bba0 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/checkpointing.py +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/checkpointing.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. +# SPDX-FileCopyrightText: Copyright (c) 2023 - 2026 NVIDIA CORPORATION & AFFILIATES. # SPDX-FileCopyrightText: All rights reserved. # SPDX-License-Identifier: Apache-2.0 # @@ -52,6 +52,7 @@ # Optimizers # ========================================================================= + def create_optimizer( model: nn.Module, optimizer_type: Literal["adam", "muon"] = "adam", @@ -349,19 +350,13 @@ def save_best_checkpoint( if not math.isfinite(float(val_loss)): if logger: logger.warning( - " Skipping best-checkpoint save for epoch %s: non-finite " - "val_loss=%s", + " Skipping best-checkpoint save for epoch %s: non-finite val_loss=%s", epoch, val_loss, ) return list(best_val_losses) - # Handle legacy format: convert List[float] to List[Tuple[float, int]]. - if best_val_losses and isinstance(best_val_losses[0], (int, float)): - # Legacy format detected, reset to empty (can't recover epoch info). - best_val_losses = [] - else: - best_val_losses = list(best_val_losses) + best_val_losses = list(best_val_losses) # Check whether this is a top-N model. current_losses = [loss for loss, _ in best_val_losses] @@ -543,6 +538,7 @@ def cleanup_checkpoint_by_epoch( # Training-state setup # ========================================================================= + def create_training_components( cfg: DictConfig, model: nn.Module, @@ -574,9 +570,7 @@ def create_training_components( """ optimizer_cfg = cfg.train.get("optimizer", {}) optimizer_type = optimizer_cfg.get("type", "adam") - weight_decay = optimizer_cfg.get( - "weight_decay", cfg.train.get("weight_decay", 0.0) - ) + weight_decay = optimizer_cfg.get("weight_decay", cfg.train.get("weight_decay", 0.0)) muon_momentum_beta = optimizer_cfg.get("muon_momentum_beta", 0.95) muon_lr = optimizer_cfg.get("muon_lr", None) @@ -684,10 +678,7 @@ def resume_or_pretrain( if dist.rank == 0: logger.info(f" Resumed from epoch {start_epoch}") if best_val_losses: - if isinstance(best_val_losses[0], (int, float)): - loss_strs = [f"{v:.6f}" for v in best_val_losses[:3]] - else: - loss_strs = [f"{loss:.6f}" for loss, _ in best_val_losses[:3]] + loss_strs = [f"{loss:.6f}" for loss, _ in best_val_losses[:3]] logger.info(f" Top val losses: {loss_strs}") if best_qoi_loss < float("inf"): logger.info(f" Best QoI loss: {best_qoi_loss:.6e}") diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/compute_normalizations.py b/examples/cfd/nuclear_engineering/radiation_transport/src/compute_normalizations.py index 5510e906d5..22397944e5 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/compute_normalizations.py +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/compute_normalizations.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. +# SPDX-FileCopyrightText: Copyright (c) 2023 - 2026 NVIDIA CORPORATION & AFFILIATES. # SPDX-FileCopyrightText: All rights reserved. # SPDX-License-Identifier: Apache-2.0 # @@ -33,9 +33,9 @@ The flux statistics walk the training split of the dataset, log-clip the raw ``scalar_flux`` field, and accumulate (mean, std, min, max) plus the clip threshold the training pipeline must use. The material statistics walk the -training split through a minimal transform pipeline that derives the -per-point ``physical_properties`` tensor (sigma_a, sigma_s, sigma_t, Q) and -records (mean, std, min, max) for each component. +training split, read the precomputed ``sigma_a / sigma_s / sigma_t / Q`` +fields from each store, and accumulate per-property (mean, std, min, max) +across all cells. The on-disk YAML schema matches the originals so that ``load_flux_stats`` / ``load_material_stats`` in ``dataset.py`` consume them unchanged. @@ -44,7 +44,6 @@ from __future__ import annotations import argparse -import pathlib import sys from pathlib import Path from typing import Dict @@ -52,21 +51,9 @@ import numpy as np import torch import yaml -from physicsnemo.datapipes.transforms import Compose -# Flat-import shim: when invoked as ``python compute_normalizations.py`` the -# script's own directory is already on ``sys.path``; when invoked from -# elsewhere we make sure sibling modules are importable. -sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) - -from dataset import RTEBaseDataset # noqa: E402 -from loader import RTEDataPipe # noqa: E402 -from material import MaterialPropertyExtractor # noqa: E402 -from transforms import ( # noqa: E402 - RTEFluxLogClip, - SpatialSampler, - SteadyStateSampler, -) +from dataset import RTEBaseDataset +from material import MaterialPropertyExtractor # ========================================================================= @@ -178,27 +165,16 @@ def compute_flux_statistics( # Material statistics # ========================================================================= # -# Walks the training split through a minimal transform pipeline: -# -# RTEFluxLogClip -> SteadyStateSampler -> MaterialPropertyExtractor -> SpatialSampler -# -# The flux log-clip step is required because the dataset reader produces a -# steady-state flux tensor; the sampler picks the first/final pair, the -# material extractor produces ``physical_properties`` with shape (N, 4), and -# ``SpatialSampler`` subsamples to a fixed point count for speed. Per-property -# stats are written in the schema the existing ``load_material_stats`` reader -# expects. +# Reads the precomputed sigma_a / sigma_s / sigma_t / Q fields from each zarr +# store in the training split and accumulates per-property mean / std / min / +# max across all cells. Schema matches what ``load_material_stats`` expects. def compute_material_statistics( data_path: Path, case_type: str, output_file: Path, - flux_stats_file: Path, split_file: Path, - clip_threshold: float = 1e-8, - num_spatial_points: int = 2048, - seed: int = 42, ) -> Dict[str, Dict[str, float]]: """Compute per-property material statistics from the training split. @@ -206,62 +182,30 @@ def compute_material_statistics( data_path: path to the zarr stores for one case. case_type: ``"lattice"`` or ``"hohlraum"``. output_file: destination YAML path. - flux_stats_file: path to the flux stats YAML produced by - :func:`compute_flux_statistics`. Required because the transform - pipeline runs ``RTEFluxLogClip`` first. split_file: split JSON used to select the training split. - clip_threshold: flux clip threshold used by the flux transform. - num_spatial_points: number of points per simulation drawn by - ``SpatialSampler``. - seed: RNG seed for spatial sampling. Returns: The nested statistics dict written to ``output_file``. """ print(f"\nComputing material statistics for {case_type}") print(f"Data path: {data_path}") - print(f"Flux stats: {flux_stats_file}") print(f"Split file: {split_file}") - if not Path(flux_stats_file).exists(): - raise FileNotFoundError( - f"Flux statistics file not found: {flux_stats_file}\n" - "Compute flux statistics before material statistics." - ) - - transforms = Compose( - [ - RTEFluxLogClip( - normalization_stats_file=flux_stats_file, - clip_threshold=clip_threshold, - ), - SteadyStateSampler(), - MaterialPropertyExtractor(), - SpatialSampler(num_points=num_spatial_points, seed=seed), - ] - ) - - print("\nCreating dataset (this may take a moment)...") - dataset = RTEDataPipe( + dataset = RTEBaseDataset( data_path=data_path, - transforms=transforms, - adapter=None, case_type=case_type, phase="train", split_file=split_file, + load_geometric_features=False, ) + extractor = MaterialPropertyExtractor() print(f"Dataset loaded: {len(dataset)} samples") print("\nAccumulating physical_properties...") all_sigma_a, all_sigma_s, all_sigma_t, all_Q = [], [], [], [] for i in range(len(dataset)): - sample = dataset.get_transformed_sample(i) - if "physical_properties" not in sample: - raise KeyError( - f"Sample {i} is missing 'physical_properties'. " - "MaterialPropertyExtractor did not produce expected output." - ) + sample = extractor(dataset[i]) props = sample["physical_properties"] if isinstance(props, torch.Tensor): props = props.detach().cpu().numpy() @@ -364,18 +308,6 @@ def _parse_args() -> argparse.Namespace: default=1e-8, help="Flux clip threshold used during log-transform (default: 1e-8).", ) - parser.add_argument( - "--num_spatial_points", - type=int, - default=2048, - help="Points per simulation for material stats subsampling (default: 2048).", - ) - parser.add_argument( - "--seed", - type=int, - default=42, - help="RNG seed for material-stats sampling (default: 42).", - ) return parser.parse_args() @@ -404,11 +336,7 @@ def main() -> int: data_path=args.data_path, case_type=args.case_type, output_file=material_output, - flux_stats_file=flux_output, split_file=args.split_file, - clip_threshold=args.clip_threshold, - num_spatial_points=args.num_spatial_points, - seed=args.seed, ) print("\n" + "=" * 80) diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/dataset.py b/examples/cfd/nuclear_engineering/radiation_transport/src/dataset.py index 1b30af01a3..18d5d696f8 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/dataset.py +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/dataset.py @@ -70,11 +70,6 @@ ) -def _to_tensor(array: np.ndarray) -> torch.Tensor: - """Zero-copy when possible; always returns a CPU ``torch.Tensor``.""" - return torch.from_numpy(np.ascontiguousarray(array)) - - @register("RTEZarrReader") class ZarrDataReader(Reader): """Filename-indexed reader over a directory of RTE zarr stores. @@ -229,10 +224,7 @@ def load( num_timesteps = flux_array.shape[0] resolved = [0] if num_timesteps == 1 else [0, num_timesteps - 1] scalar_flux = np.stack( - [ - np.array(flux_array[idx], dtype=np.float32) - for idx in resolved - ], + [np.array(flux_array[idx], dtype=np.float32) for idx in resolved], axis=0, ) if load_sim_times and "sim_times" in z: @@ -252,9 +244,9 @@ def load( cached_entry = dict(self._static_cache[filename]) # shallow copy td = TensorDict({}, batch_size=[]) - td["scalar_flux"] = _to_tensor(scalar_flux) + td["scalar_flux"] = torch.from_numpy(scalar_flux) if sim_times is not None: - td["sim_times"] = _to_tensor(np.asarray(sim_times)) + td["sim_times"] = torch.from_numpy(np.asarray(sim_times)) if cache_hit: # Reuse cached static tensors; copy references, not data. @@ -262,27 +254,29 @@ def load( td[key] = tensor else: self._cache_misses += 1 - cell_centers = np.array(z["cell_centers"], dtype=np.float32) - cell_areas = np.array(z["cell_areas"], dtype=np.float32) - td["coordinates"] = _to_tensor(cell_centers) - td["cell_areas"] = _to_tensor(cell_areas) + td["coordinates"] = torch.from_numpy( + np.array(z["cell_centers"], dtype=np.float32) + ) + td["cell_areas"] = torch.from_numpy( + np.array(z["cell_areas"], dtype=np.float32) + ) if load_material_properties and "material_properties" in z: - td["material_properties"] = _to_tensor( + td["material_properties"] = torch.from_numpy( np.array(z["material_properties"], dtype=np.int32) ) elif load_material_properties and "material_properties" not in z: warnings.warn(f"Material properties not found in {filename}.") if load_geometric_features and "geometric_features" in z: - td["geometric_features"] = _to_tensor( + td["geometric_features"] = torch.from_numpy( np.array(z["geometric_features"], dtype=np.float32) ) if load_sigma_fields: for key in ("sigma_t", "sigma_s", "sigma_a", "Q"): if key in z: - td[key] = _to_tensor(np.array(z[key], dtype=np.float32)) + td[key] = torch.from_numpy(np.array(z[key], dtype=np.float32)) if self.cache_static_arrays: self._maybe_cache_entry(filename, td) @@ -435,9 +429,9 @@ def preload_to_memory(self, verbose: bool = True, num_workers: int = 8) -> dict: self._memory_cache = {} if verbose: - print(f"\nPreloading {num_files} files with steady-state flux...") - print(" Loading ONLY first and final snapshots (2 per file)") - print(f" Parallel I/O workers: {num_workers}") + print( + f"Preloading {num_files} files (first+final flux, {num_workers} workers)..." + ) start = time.perf_counter() @@ -454,25 +448,15 @@ def load_one(filename: str): } if "sim_times" in td: entry["sim_times"] = td["sim_times"].clone() - return filename, td, entry + return filename, entry completed = 0 - first_logged = False with ThreadPoolExecutor(max_workers=num_workers) as executor: futures = {executor.submit(load_one, fn): fn for fn in self.filenames} for fut in as_completed(futures): - filename, td, entry = fut.result() + filename, entry = fut.result() completed += 1 self._memory_cache[filename] = entry - if verbose and not first_logged: - n_cells = td["coordinates"].shape[0] - print(f"\n First file diagnostics ({filename}):") - print(f" scalar_flux shape: {tuple(td['scalar_flux'].shape)}") - print(f" num_cells: {n_cells:,}") - print(f" sigma_t loaded: {'sigma_t' in td}") - print(f" sigma_s loaded: {'sigma_s' in td}") - print("") - first_logged = True if verbose and completed % 50 == 0: elapsed = time.perf_counter() - start rate = completed / elapsed @@ -485,24 +469,9 @@ def load_one(filename: str): elapsed = time.perf_counter() - start cache_stats = self.reader.get_cache_stats() if verbose: - print("\nPreload complete!") - print(f" Files loaded: {num_files}") - print(f" Time: {elapsed:.1f}s ({num_files/elapsed:.1f} files/s)") - print(f" Static arrays cache: {cache_stats['cache_size']} files") - print(f" Cache hits: {cache_stats['cache_hits']}") - print(f" Cache misses: {cache_stats['cache_misses']}") - flux_mem = sum( - cached["scalar_flux"].element_size() * cached["scalar_flux"].numel() - + ( - cached["sim_times"].element_size() * cached["sim_times"].numel() - if "sim_times" in cached - else 0 - ) - for cached in self._memory_cache.values() - ) print( - f" Flux cache: {len(self._memory_cache)} simulations " - f"({flux_mem / 1024**2:.1f} MB)" + f"Preload complete: {num_files} files in {elapsed:.1f}s " + f"({num_files / elapsed:.1f} files/s)." ) return { @@ -643,12 +612,8 @@ def material_normalize_kwargs( ``torch.Tensor`` of shape ``(4,)`` is passed as the mean and the std, delegating the math to ``physicsnemo.datapipes.transforms.Normalize``. """ - means = torch.tensor( - [float(stats[k]["mean"]) for k in order], dtype=torch.float32 - ) - stds = torch.tensor( - [float(stats[k]["std"]) for k in order], dtype=torch.float32 - ) + means = torch.tensor([float(stats[k]["mean"]) for k in order], dtype=torch.float32) + stds = torch.tensor([float(stats[k]["std"]) for k in order], dtype=torch.float32) return { "input_keys": [field], "method": "mean_std", diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/inference.py b/examples/cfd/nuclear_engineering/radiation_transport/src/inference.py index 49da2e316c..d48dc79b3c 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/inference.py +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/inference.py @@ -31,8 +31,6 @@ import argparse import os -import pathlib -import sys from pathlib import Path from typing import Any, Dict, Iterator, Optional, Tuple, Union @@ -50,17 +48,18 @@ from torch.utils.data import DataLoader from tqdm import tqdm -# Flat sibling imports — keep this module self-contained relative to ``src/``. -sys.path.insert(0, str(pathlib.Path(__file__).parent)) +from dataset import load_flux_stats +from loader import build_dataloaders, collate_no_padding +from losses import ( + evaluate_hohlraum_qoi_torch, + evaluate_lattice_qoi_torch, + extract_geometry_params, +) +from trainer import initialize_distributed_manager +from transforms import denormalize_flux -from dataset import load_flux_stats # noqa: E402 -from loader import build_dataloaders, collate_no_padding # noqa: E402 -from losses import extract_geometry_params # noqa: E402 -from trainer import initialize_distributed_manager # noqa: E402 -from transforms import denormalize_flux # noqa: E402 - -from physicsnemo.distributed import DistributedManager # noqa: E402 -from physicsnemo.utils.checkpoint import load_checkpoint # noqa: E402 +from physicsnemo.distributed import DistributedManager +from physicsnemo.utils.checkpoint import load_checkpoint # ========================================================================= @@ -166,45 +165,21 @@ def load_model_from_checkpoint( # ========================================================================= -def mse(pred: np.ndarray, target: np.ndarray) -> float: - """Mean squared error.""" - return float(np.mean((pred - target) ** 2)) - - -def rmse(pred: np.ndarray, target: np.ndarray) -> float: - """Root mean squared error.""" - return float(np.sqrt(np.mean((pred - target) ** 2))) - - -def mae(pred: np.ndarray, target: np.ndarray) -> float: - """Mean absolute error.""" - return float(np.mean(np.abs(pred - target))) - - -def l2_relative_error( +def compute_metrics( pred: np.ndarray, target: np.ndarray, eps: float = 1e-10 -) -> float: - """Sample-wise L2 relative error: ||pred - target||_2 / ||target||_2.""" - num = np.linalg.norm(pred.flatten() - target.flatten()) - den = np.linalg.norm(target.flatten()) + eps - return float(num / den) - - -def relative_error(pred: np.ndarray, target: np.ndarray, eps: float = 1e-10) -> float: - """Mean pointwise relative error |pred - target| / (|target| + eps).""" - return float(np.mean(np.abs(pred - target) / (np.abs(target) + eps))) - - -def compute_metrics(pred: np.ndarray, target: np.ndarray) -> Dict[str, float]: - """Compute the full metric panel for one (pred, target) pair.""" +) -> Dict[str, float]: + """Compute the full metric panel for one ``(pred, target)`` pair.""" p, t = pred.flatten(), target.flatten() + diff = p - t + abs_diff = np.abs(diff) + mse = float(np.mean(diff**2)) return { - "mse": mse(p, t), - "rmse": rmse(p, t), - "mae": mae(p, t), - "l2_relative_error": l2_relative_error(p, t), - "relative_error": relative_error(p, t), - "max_error": float(np.max(np.abs(p - t))), + "mse": mse, + "rmse": float(np.sqrt(mse)), + "mae": float(np.mean(abs_diff)), + "l2_relative_error": float(np.linalg.norm(diff) / (np.linalg.norm(t) + eps)), + "relative_error": float(np.mean(abs_diff / (np.abs(t) + eps))), + "max_error": float(np.max(abs_diff)), } @@ -224,88 +199,12 @@ def aggregate_metrics(per_sample: list[Dict[str, float]]) -> Dict[str, float]: # ========================================================================= -# QoI (numpy side) +# QoI # ========================================================================= - - -def evaluate_lattice_qoi( - cell_centers: np.ndarray, - cell_areas: np.ndarray, - sigma_t: np.ndarray, - sigma_s: np.ndarray, - scalar_flux: np.ndarray, -) -> Dict[str, float]: - """Lattice absorption QoI in the absorbing blocks. Matches KiT-RT. - - ``scalar_flux`` is shape ``(N,)`` for a single steady-state snapshot. - Returns ``{"cur_absorption": ...}``. - """ - x = cell_centers[:, 0] - y = cell_centers[:, 1] - sigma_a = sigma_t - sigma_s - - xy_corrector = -3.5 - lbounds = np.array([1.0, 2.0, 3.0, 4.0, 5.0]) + xy_corrector - ubounds = np.array([2.0, 3.0, 4.0, 5.0, 6.0]) + xy_corrector - in_absorption = np.zeros_like(x, dtype=bool) - for k in range(5): - for l in range(5): # noqa: E741 - if (l + k) % 2 == 1: - continue - if (k == 2 and l == 2) or (k == 2 and l == 4): - continue - in_absorption |= ( - (x >= lbounds[k]) - & (x <= ubounds[k]) - & (y >= lbounds[l]) - & (y <= ubounds[l]) - ) - - flux = scalar_flux.flatten() - absorption_density = flux * sigma_a * cell_areas - return {"cur_absorption": float(np.sum(absorption_density * in_absorption))} - - -def evaluate_hohlraum_qoi( - cell_centers: np.ndarray, - cell_areas: np.ndarray, - sigma_t: np.ndarray, - sigma_s: np.ndarray, - scalar_flux: np.ndarray, - geometry_params: Dict[str, float], -) -> Dict[str, float]: - """Hohlraum absorption QoI (center / vertical / horizontal). Matches KiT-RT. - - ``scalar_flux`` is shape ``(N,)``. ``geometry_params`` carries ``cx``, - ``cy``, ``hlr``, ``hrr``, ``llr``, ``ulr``, ``urr``. - """ - x = cell_centers[:, 0] - y = cell_centers[:, 1] - - cx = geometry_params["cx"] - cy = geometry_params["cy"] - hlr = geometry_params["hlr"] - hrr = geometry_params["hrr"] - llr = geometry_params["llr"] - ulr = geometry_params["ulr"] - urr = geometry_params["urr"] - - sigma_a = sigma_t - sigma_s - - in_center = (x > -0.2 + cx) & (x < 0.2 + cx) & (y > -0.4 + cy) & (y < 0.4 + cy) - # Note: KiT-RT uses ``llr`` for both vertical-wall lower bounds. - in_vertical = ((x < hlr) & (y > llr) & (y < ulr)) | ( - (x > hrr) & (y > llr) & (y < urr) - ) - in_horizontal = (y > 0.6) | (y < -0.6) - - flux = scalar_flux.flatten() - absorption_density = flux * sigma_a * cell_areas - return { - "cur_absorption_center": float(np.sum(absorption_density * in_center)), - "cur_absorption_vertical": float(np.sum(absorption_density * in_vertical)), - "cur_absorption_horizontal": float(np.sum(absorption_density * in_horizontal)), - } +# +# QoI geometry (lattice block layout, hohlraum region predicates) lives in +# ``losses.evaluate_*_qoi_torch``. Inference reuses those torch evaluators on +# (T=1, N) tensors built from the per-sample numpy metadata. def compute_sample_qoi( @@ -326,9 +225,21 @@ def compute_sample_qoi( if coords is None or cell_areas is None or sigma_t is None or sigma_s is None: return None + cell_centers_t = torch.from_numpy(np.asarray(coords)).float() + cell_areas_t = torch.from_numpy(np.asarray(cell_areas)).float() + sigma_t_t = torch.from_numpy(np.asarray(sigma_t)).float() + sigma_s_t = torch.from_numpy(np.asarray(sigma_s)).float() + pred_t = torch.from_numpy(np.asarray(pred)).float().reshape(1, -1) + target_t = torch.from_numpy(np.asarray(target)).float().reshape(1, -1) + sim_times_t = torch.zeros(1) # unused by the steady-state QoI + if case_type == "lattice": - qp = evaluate_lattice_qoi(coords, cell_areas, sigma_t, sigma_s, pred) - qt = evaluate_lattice_qoi(coords, cell_areas, sigma_t, sigma_s, target) + qp = evaluate_lattice_qoi_torch( + cell_centers_t, cell_areas_t, sigma_t_t, sigma_s_t, pred_t, sim_times_t + ) + qt = evaluate_lattice_qoi_torch( + cell_centers_t, cell_areas_t, sigma_t_t, sigma_s_t, target_t, sim_times_t + ) elif case_type == "hohlraum": filename = metadata.get("filename") if not filename: @@ -336,14 +247,25 @@ def compute_sample_qoi( gp = extract_geometry_params(filename) if not gp: return None - qp = evaluate_hohlraum_qoi(coords, cell_areas, sigma_t, sigma_s, pred, gp) - qt = evaluate_hohlraum_qoi(coords, cell_areas, sigma_t, sigma_s, target, gp) + qp = evaluate_hohlraum_qoi_torch( + cell_centers_t, cell_areas_t, sigma_t_t, sigma_s_t, pred_t, sim_times_t, gp + ) + qt = evaluate_hohlraum_qoi_torch( + cell_centers_t, + cell_areas_t, + sigma_t_t, + sigma_s_t, + target_t, + sim_times_t, + gp, + ) else: raise ValueError(f"Unknown case_type: {case_type}") out: Dict[str, Dict[str, float]] = {} for region in qp: - p, t = qp[region], qt[region] + p = float(qp[region][0].item()) + t = float(qt[region][0].item()) abs_err = abs(p - t) out[region] = { "predicted": p, @@ -672,12 +594,6 @@ def plot_qoi_error_histograms( # ========================================================================= -def _move_to_device(batch: Dict[str, Any], device: torch.device) -> Dict[str, Any]: - return { - k: v.to(device) if isinstance(v, torch.Tensor) else v for k, v in batch.items() - } - - def _denormalize(flux_norm: torch.Tensor, stats: Dict[str, float]) -> np.ndarray: """Apply ``denormalize_flux`` (RTEFluxLogClip + Normalize inverse).""" return denormalize_flux(flux_norm.detach().cpu(), stats).numpy() @@ -706,7 +622,10 @@ def run_evaluation( for batch in tqdm(dataloader, desc="evaluating"): if max_samples is not None and n >= max_samples: break - batch = _move_to_device(batch, device) + batch = { + k: v.to(device) if isinstance(v, torch.Tensor) else v + for k, v in batch.items() + } amp_enabled = use_amp and device.type == "cuda" with autocast(device_type=device.type, enabled=amp_enabled): diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/loader.py b/examples/cfd/nuclear_engineering/radiation_transport/src/loader.py index 2a4886d148..e5173754d2 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/loader.py +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/loader.py @@ -24,7 +24,7 @@ * Adapter — ``TransolverAdapter``. * Collation — ``collate_no_padding`` (batch_size=1 unsqueeze). -* Stats / kwargs translation — ``build_rte_dataset_kwargs``. +* Stats / kwargs translation — ``_build_rte_dataset_kwargs``. * Pipeline orchestration — ``RTEDataPipe`` + ``_build_transforms`` + ``_build_rte_datapipe``. * Distributed preload barrier — file-marker rank-sequencing helpers. @@ -114,73 +114,49 @@ def __call__(self, data: TensorDict) -> Dict[str, torch.Tensor]: sample = {k: data[k] for k in data.keys()} result: Dict[str, Any] = {} - def to_tensor(x): - if isinstance(x, torch.Tensor): - return x.float() - return torch.from_numpy(x).float() - if "coordinates" in sample: - result["fx"] = to_tensor(sample["coordinates"]) + result["fx"] = sample["coordinates"] if "physical_properties" in sample: - mat_props = to_tensor(sample["physical_properties"]) + mat_props = sample["physical_properties"] if not self.include_q_in_embedding: mat_props = mat_props[..., :3] result["embedding"] = mat_props if "coordinates_unnormalized" in sample: - result["coordinates_unnormalized"] = to_tensor( - sample["coordinates_unnormalized"] - ) + result["coordinates_unnormalized"] = sample["coordinates_unnormalized"] if ( "material_properties" in sample and sample["material_properties"] is not None ): - mat_labels = sample["material_properties"] - if isinstance(mat_labels, np.ndarray): - mat_labels = torch.from_numpy(mat_labels.astype(np.int64)) - elif isinstance(mat_labels, torch.Tensor): - mat_labels = mat_labels.long() - result["material_labels"] = mat_labels + result["material_labels"] = sample["material_properties"].long() if "flux_target" in sample: - flux_tgt = to_tensor(sample["flux_target"]) + flux_tgt = sample["flux_target"] if flux_tgt.ndim == 1: flux_tgt = flux_tgt.unsqueeze(-1) result["flux_target"] = flux_tgt for key in ("cell_areas", "sigma_t", "sigma_s"): if key in sample: - result[key] = to_tensor(sample[key]) + result[key] = sample[key] - if "sim_times" in sample: - sim_times_arr = sample["sim_times"] - if isinstance(sim_times_arr, torch.Tensor) and sim_times_arr.numel() > 0: - result["sim_time"] = torch.tensor( - [float(sim_times_arr[-1].item())], dtype=torch.float32 - ) - elif hasattr(sim_times_arr, "__len__") and len(sim_times_arr) > 0: - result["sim_time"] = torch.tensor( - [float(sim_times_arr[-1])], dtype=torch.float32 - ) - else: - result["sim_time"] = torch.tensor([0.0], dtype=torch.float32) + if "sim_times" in sample and sample["sim_times"].numel() > 0: + result["sim_time"] = sample["sim_times"][-1].reshape(1).to(torch.float32) + elif "sim_times" in sample: + result["sim_time"] = torch.tensor([0.0], dtype=torch.float32) if "flux_normalization_stats" in sample: result["flux_normalization_stats"] = sample["flux_normalization_stats"] - metadata = sample.get("metadata", {}) or {} + metadata = sample.get("metadata") or {} metadata_dict = { "timestep_input": sample.get("timestep_input"), "timestep_target": sample.get("timestep_target"), - "max_timestep": metadata.get("max_timestep") - if isinstance(metadata, dict) - else None, + "max_timestep": metadata.get("max_timestep"), "filename": sample.get("filename"), - "case_type": metadata.get("case_type") - if isinstance(metadata, dict) - else None, + "case_type": metadata.get("case_type"), } result["metadata"] = {k: v for k, v in metadata_dict.items() if v is not None} @@ -194,143 +170,55 @@ def __repr__(self) -> str: # Collation # ========================================================================= # -# Only ``collate_no_padding`` ships with the upstream example: all configs -# use ``batch_size=1`` with a fixed ``num_spatial_points`` from -# ``SpatialSampler``, so no padding is needed. The PyG-graph collator was -# dropped along with the MeshGraphNet adapter. +# All configs use ``batch_size=1`` with a fixed ``num_spatial_points`` from +# ``SpatialSampler``, so no padding is needed. ``build_dataloaders_for_training`` +# enforces ``batch_size=1`` upstream, so this collate just unsqueezes the +# single sample and passes non-tensors through (default torch collate would +# choke on the ``metadata`` / ``flux_normalization_stats`` dicts). @register("RTECollateNoPadding") def collate_no_padding(batch: List[Dict[str, Any]]) -> Dict[str, Any]: - """Batch-size-1 collate: unsqueeze each tensor, pass non-tensors through. - - Asserts ``len(batch) == 1`` to keep us honest if someone flips the config - without wiring a real multi-sample collator. - """ - assert len(batch) == 1, "collate_no_padding requires batch_size=1" + """Batch-size-1 collate: unsqueeze each tensor, pass non-tensors through.""" item = batch[0] - result: Dict[str, Any] = {} - for k, v in item.items(): - if isinstance(v, torch.Tensor): - result[k] = v.unsqueeze(0) - else: - result[k] = v - return result + return { + k: v.unsqueeze(0) if isinstance(v, torch.Tensor) else v for k, v in item.items() + } # ========================================================================= # Stats / kwargs translation # ========================================================================= # -# ``build_rte_dataset_kwargs`` translates a Hydra config into the kwargs -# ``create_dataset`` expects. Used by both training (phases ``train``/``val``) -# and evaluation (phase ``test``). +# ``_build_rte_dataset_kwargs`` translates a Hydra config into the kwargs +# ``_build_rte_datapipe`` expects. Required keys (``flux_normalization_stats_file``, +# ``flux_clip_threshold``, ``case.split_file``) raise a clear ``KeyError`` on +# direct access if missing; no manual ``None``-checking is layered on top. -def build_rte_dataset_kwargs( - cfg: DictConfig, - *, - num_spatial_points_key: str = "num_spatial_points", - num_spatial_points_override: Optional[int] = None, - split_file_override: Optional[str] = None, - extra_kwargs: Optional[dict] = None, -) -> dict: - """Translate a Hydra config into the kwargs ``_build_rte_datapipe`` expects. - - Callers that run against a checkpoint's saved config (evaluation) can - provide overrides for values the CLI wants to win over the config - (``split_file``) or that diverge between current and checkpoint shapes - (``num_spatial_points``). - """ +def _build_rte_dataset_kwargs(cfg: DictConfig) -> dict: + """Translate a Hydra config into the kwargs ``_build_rte_datapipe`` expects.""" data_cfg = cfg.data - - flux_stats_file = data_cfg.get("flux_normalization_stats_file") - if flux_stats_file is None: - raise ValueError( - "data.flux_normalization_stats_file must be specified in config." - ) - - flux_clip_threshold = data_cfg.get("flux_clip_threshold") - if flux_clip_threshold is None: - raise ValueError("data.flux_clip_threshold must be specified in config.") - - # num_spatial_points — override for eval when the checkpoint's model - # config carries the authoritative value. - if num_spatial_points_override is not None: - num_spatial_points = num_spatial_points_override - elif "." in num_spatial_points_key: - parts = num_spatial_points_key.split(".") - num_spatial_points = cfg - for part in parts: - num_spatial_points = num_spatial_points[part] - else: - num_spatial_points = cfg.model[num_spatial_points_key] - - case_cfg = cfg.get("case", {}) - split_file = ( - split_file_override - if split_file_override - else case_cfg.get("split_file") or data_cfg.get("split_file") - ) - if not split_file: - raise ValueError( - "case.split_file is required. Configure a JSON split file instead " - "of percentage-based train/val/test splits." - ) - - seed = data_cfg.get("seed", None) - if seed is None and "train" in cfg: - seed = cfg.train.get("seed", None) - - # Fourier features config use_fourier_features = data_cfg.get("use_fourier_features", False) - fourier_num_frequencies = None - fourier_coord_dims = None - fourier_base_frequency = None - if use_fourier_features: - fourier_cfg = data_cfg.get("fourier_features") - if fourier_cfg is None: - raise ValueError( - "use_fourier_features=True but data.fourier_features is missing." - ) - fourier_num_frequencies = fourier_cfg.get("num_frequencies") - fourier_coord_dims = fourier_cfg.get("coord_dims") - fourier_base_frequency = fourier_cfg.get("base_frequency") - if any( - v is None - for v in ( - fourier_num_frequencies, - fourier_coord_dims, - fourier_base_frequency, - ) - ): - raise ValueError( - "fourier_features config must specify num_frequencies, " - f"coord_dims, base_frequency. Got: {dict(fourier_cfg)}" - ) + fourier_cfg = data_cfg.get("fourier_features") if use_fourier_features else None - kwargs = { + return { "data_path": cfg.case.data_path, - "num_spatial_points": num_spatial_points, - "flux_normalization_stats_file": flux_stats_file, + "num_spatial_points": cfg.model.num_spatial_points, + "flux_normalization_stats_file": data_cfg.flux_normalization_stats_file, "normalize_coordinates": data_cfg.get("normalize_coordinates", True), - "flux_clip_threshold": flux_clip_threshold, - "split_file": split_file, - "seed": seed, + "flux_clip_threshold": data_cfg.flux_clip_threshold, + "split_file": cfg.case.split_file, + "seed": data_cfg.get("seed") or cfg.get("train", {}).get("seed"), "cache_static_arrays": data_cfg.get("cache_static_arrays", True), "max_cache_size": data_cfg.get("max_cache_size", 200), "include_q_in_embedding": cfg.model.get("include_q_in_embedding", True), "use_fourier_features": use_fourier_features, - "fourier_num_frequencies": fourier_num_frequencies, - "fourier_coord_dims": fourier_coord_dims, - "fourier_base_frequency": fourier_base_frequency, + "fourier_num_frequencies": fourier_cfg.num_frequencies if fourier_cfg else None, + "fourier_coord_dims": fourier_cfg.coord_dims if fourier_cfg else None, + "fourier_base_frequency": fourier_cfg.base_frequency if fourier_cfg else None, } - if extra_kwargs: - kwargs.update(extra_kwargs) - - return kwargs - # ========================================================================= # Pipeline orchestration @@ -788,11 +676,7 @@ def build_dataloaders( dist=None, *, collate_fn: Optional[Callable] = None, - extra_dataset_kwargs: Optional[dict] = None, phases: Iterable[str] = ("train", "val"), - num_spatial_points_key: str = "num_spatial_points", - num_spatial_points_override: Optional[int] = None, - split_file_override: Optional[str] = None, test_batch_size: int = 1, test_num_workers: int = 0, logger: Optional[logging.Logger] = None, @@ -803,14 +687,7 @@ def build_dataloaders( cfg: Hydra configuration (training cfg or a loaded checkpoint cfg). dist: ``DistributedManager`` for training; ``None`` for eval. collate_fn: Collate function. Defaults to ``collate_no_padding``. - extra_dataset_kwargs: Additional kwargs forwarded to the datapipe. phases: Which splits to build (subset of ``{"train", "val", "test"}``). - num_spatial_points_key: Where to read ``num_spatial_points`` from - the config (dotted path). Overridden by - ``num_spatial_points_override`` when the caller already knows - the authoritative value (eval path). - num_spatial_points_override: Optional explicit ``num_spatial_points``. - split_file_override: CLI override for ``data.split_file``. test_batch_size / test_num_workers: Used only when ``test`` is in ``phases``. logger: Optional logger; defaults to module logger. @@ -823,8 +700,6 @@ def build_dataloaders( logger = logger or logging.getLogger(__name__) phases = tuple(phases) - # Hardcode the collate to ``collate_no_padding`` — the upstream example - # ships only the point-cloud adapter, which requires batch_size=1. if collate_fn is None: collate_fn = collate_no_padding @@ -833,13 +708,7 @@ def build_dataloaders( if rank_zero: logger.info(f"Loading {cfg.case.type} data from: {cfg.case.data_path}") - common_kwargs = build_rte_dataset_kwargs( - cfg, - num_spatial_points_key=num_spatial_points_key, - num_spatial_points_override=num_spatial_points_override, - split_file_override=split_file_override, - extra_kwargs=extra_dataset_kwargs, - ) + common_kwargs = _build_rte_dataset_kwargs(cfg) if rank_zero: logger.info("Mapping mode: steady-state first-to-final flux") @@ -858,11 +727,8 @@ def build_dataloaders( } # Distributed/single preloading (training only; eval skips). - preload_data = False - if "data" in cfg: - preload_data = cfg.data.get("preload_data", False) if ( - preload_data + cfg.data.get("preload_data", False) and common_kwargs["max_cache_size"] == -1 and dist is not None and any(p in datasets for p in ("train", "val")) @@ -913,7 +779,6 @@ def build_dataloaders( __all__ = [ "TransolverAdapter", "collate_no_padding", - "build_rte_dataset_kwargs", "RTEDataPipe", "build_dataloaders", ] diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/train.py b/examples/cfd/nuclear_engineering/radiation_transport/src/train.py index e57455d4e0..d754bac282 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/train.py +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/train.py @@ -33,13 +33,6 @@ from __future__ import annotations -import pathlib -import sys - -# Flat-module shim: workers and direct ``python src/train.py`` invocations -# both need to be able to resolve sibling modules by their bare names. -sys.path.insert(0, str(pathlib.Path(__file__).parent)) - from typing import Any, Dict, Optional, Tuple import hydra diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/trainer.py b/examples/cfd/nuclear_engineering/radiation_transport/src/trainer.py index 6cb00fae95..d24cbbcfa4 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/trainer.py +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/trainer.py @@ -833,10 +833,7 @@ def run_training_loop( if dist.rank == 0: logger.info("\n" + "=" * 70) logger.info("Training completed!") - if best_val_losses and isinstance(best_val_losses[0], (int, float)): - loss_strs = [f"{v:.6f}" for v in best_val_losses] - else: - loss_strs = [f"{loss:.6f}" for loss, _ in best_val_losses] + loss_strs = [f"{loss:.6f}" for loss, _ in best_val_losses] logger.info(f"Top validation losses: {loss_strs}") if best_qoi_loss < float("inf"): logger.info(f"Best QoI loss: {best_qoi_loss:.6e}") From 8a1f11297c72c5ee360766d761fa96e9b1efc60e Mon Sep 17 00:00:00 2001 From: Carmelo Gonzales Date: Mon, 4 May 2026 18:04:58 -0700 Subject: [PATCH 21/68] feat: some more refactoring and purging --- .../radiation_transport/src/losses.py | 115 +++++++----------- 1 file changed, 43 insertions(+), 72 deletions(-) diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/losses.py b/examples/cfd/nuclear_engineering/radiation_transport/src/losses.py index 8564cf7d48..cbe1d1e5c3 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/losses.py +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/losses.py @@ -711,23 +711,35 @@ def evaluate_lattice_qoi_torch( Matches KiT-RT SNSolverHPC::IterPostprocessing() exactly. Steady-state surrogate ⇒ T=1; ``sim_times`` is accepted for callsite uniformity but - not used. + not used. ``batch_size=1`` is enforced repo-wide; if a leading batch dim + is present we recurse on the squeezed slot and re-add it on the way out. Args: - cell_centers: (N, 3) or (B, N, 3) - cell_areas: (N,) or (B, N) - sigma_t: (N,) or (B, N) - sigma_s: (N,) or (B, N) - scalar_flux: (T, N) or (B, T, N) — only T=1 is exercised - sim_times: (T,) or (B, T) — unused, kept for callsite uniformity + cell_centers: (N, 3) or (1, N, 3) + cell_areas: (N,) or (1, N) + sigma_t: (N,) or (1, N) + sigma_s: (N,) or (1, N) + scalar_flux: (T, N) or (1, T, N) — only T=1 is exercised + sim_times: (T,) or (1, T) — unused, kept for callsite uniformity Returns: - ``{"cur_absorption": (T,) or (B, T)}`` + ``{"cur_absorption": (T,) or (1, T)}`` """ if cell_centers.ndim == 3: - return _evaluate_lattice_qoi_torch_batched( - cell_centers, cell_areas, sigma_t, sigma_s, scalar_flux, sim_times + if cell_centers.shape[0] != 1: + raise NotImplementedError( + "evaluate_lattice_qoi_torch only supports batch_size=1; " + f"got batch={cell_centers.shape[0]}." + ) + result = evaluate_lattice_qoi_torch( + cell_centers[0], + cell_areas[0], + sigma_t[0], + sigma_s[0], + scalar_flux[0], + sim_times[0] if sim_times.ndim == 2 else sim_times, ) + return {k: v.unsqueeze(0) for k, v in result.items()} x = cell_centers[:, 0] y = cell_centers[:, 1] @@ -763,27 +775,6 @@ def evaluate_lattice_qoi_torch( return {"cur_absorption": cur_absorption} -def _evaluate_lattice_qoi_torch_batched( - cell_centers, cell_areas, sigma_t, sigma_s, scalar_flux, sim_times -) -> dict[str, torch.Tensor]: - """Batched version for (B, N, 3) inputs.""" - batch_size = cell_centers.shape[0] - results = [ - evaluate_lattice_qoi_torch( - cell_centers[b], - cell_areas[b], - sigma_t[b], - sigma_s[b], - scalar_flux[b], - sim_times[b] if sim_times.ndim == 2 else sim_times, - ) - for b in range(batch_size) - ] - return { - "cur_absorption": torch.stack([r["cur_absorption"] for r in results]), - } - - def evaluate_hohlraum_qoi_torch( cell_centers: torch.Tensor, cell_areas: torch.Tensor, @@ -798,30 +789,37 @@ def evaluate_hohlraum_qoi_torch( Matches KiT-RT SNSolverHPC hohlraum geometry exactly (including known KiT-RT quirk of using pos_red_left_bottom for both vertical wall sides). Steady-state surrogate ⇒ T=1; ``sim_times`` is accepted for callsite uniformity but - not used. + not used. ``batch_size=1`` is enforced repo-wide; if a leading batch dim + is present we recurse on the squeezed slot and re-add it on the way out. Args: - cell_centers: (N, 3) or (B, N, 3) - cell_areas: (N,) or (B, N) - sigma_t: (N,) or (B, N) - sigma_s: (N,) or (B, N) - scalar_flux: (T, N) or (B, T, N) — only T=1 is exercised - sim_times: (T,) or (B, T) — unused, kept for callsite uniformity + cell_centers: (N, 3) or (1, N, 3) + cell_areas: (N,) or (1, N) + sigma_t: (N,) or (1, N) + sigma_s: (N,) or (1, N) + scalar_flux: (T, N) or (1, T, N) — only T=1 is exercised + sim_times: (T,) or (1, T) — unused, kept for callsite uniformity geometry_params: dict with cx, cy, hlr, hrr, llr, ulr, lrr, urr Returns: Dict with ``cur_absorption_{center,vertical,horizontal}``. """ if cell_centers.ndim == 3: - return _evaluate_hohlraum_qoi_torch_batched( - cell_centers, - cell_areas, - sigma_t, - sigma_s, - scalar_flux, - sim_times, + if cell_centers.shape[0] != 1: + raise NotImplementedError( + "evaluate_hohlraum_qoi_torch only supports batch_size=1; " + f"got batch={cell_centers.shape[0]}." + ) + result = evaluate_hohlraum_qoi_torch( + cell_centers[0], + cell_areas[0], + sigma_t[0], + sigma_s[0], + scalar_flux[0], + sim_times[0] if sim_times.ndim == 2 else sim_times, geometry_params, ) + return {k: v.unsqueeze(0) for k, v in result.items()} x = cell_centers[:, 0] y = cell_centers[:, 1] @@ -865,33 +863,6 @@ def evaluate_hohlraum_qoi_torch( } -def _evaluate_hohlraum_qoi_torch_batched( - cell_centers, - cell_areas, - sigma_t, - sigma_s, - scalar_flux, - sim_times, - geometry_params, -) -> dict[str, torch.Tensor]: - """Batched version for (B, N, 3) inputs.""" - batch_size = cell_centers.shape[0] - results = [ - evaluate_hohlraum_qoi_torch( - cell_centers[b], - cell_areas[b], - sigma_t[b], - sigma_s[b], - scalar_flux[b], - sim_times[b] if sim_times.ndim == 2 else sim_times, - geometry_params, - ) - for b in range(batch_size) - ] - keys = list(results[0].keys()) - return {k: torch.stack([r[k] for r in results]) for k in keys} - - __all__ = [ # Schedulers "WarmupCosineScheduler", From 7e6661e1bb7986265006f24aa20f1b4619fbda15 Mon Sep 17 00:00:00 2001 From: Carmelo Gonzales Date: Tue, 5 May 2026 08:49:18 -0700 Subject: [PATCH 22/68] feat: a few fixes and cleaning up nits --- .../radiation_transport/src/checkpointing.py | 122 ++++++++---------- .../src/compute_normalizations.py | 77 ++++++----- .../src/conf/case/lattice.yaml | 14 +- .../src/conf/data/hohlraum.yaml | 13 ++ .../src/conf/data/lattice.yaml | 13 ++ .../src/conf/model/transolver.yaml | 14 +- .../src/conf/train/base.yaml | 13 ++ .../radiation_transport/src/dataset.py | 58 +++++++-- .../radiation_transport/src/inference.py | 14 +- .../radiation_transport/src/loader.py | 37 +++++- .../radiation_transport/src/losses.py | 22 ++-- .../radiation_transport/src/material.py | 6 +- .../radiation_transport/src/train.py | 11 +- .../radiation_transport/src/trainer.py | 119 ++++++----------- .../radiation_transport/src/transforms.py | 27 +++- 15 files changed, 332 insertions(+), 228 deletions(-) diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/checkpointing.py b/examples/cfd/nuclear_engineering/radiation_transport/src/checkpointing.py index 336e33bba0..61ddc39bba 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/checkpointing.py +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/checkpointing.py @@ -34,6 +34,7 @@ from pathlib import Path from typing import Any, Dict, List, Literal, Optional, Tuple +import numpy as np import torch import torch.nn as nn from omegaconf import DictConfig @@ -41,6 +42,7 @@ from torch.utils.tensorboard import SummaryWriter from physicsnemo.distributed import DistributedManager +from physicsnemo.optim import CombinedOptimizer from physicsnemo.utils.checkpoint import load_checkpoint from physicsnemo.utils.logging.launch import LaunchLogger @@ -182,71 +184,6 @@ def _create_muon_optimizer( return CombinedOptimizer(optimizers) -class CombinedOptimizer(torch.optim.Optimizer): - """Wrapper to combine multiple optimizers into a single interface. - - This allows using Muon for 2D params and Adam for 1D params while - maintaining a standard optimizer interface for the training loop. - Inherits from ``torch.optim.Optimizer`` for compatibility with LR schedulers. - """ - - def __init__(self, optimizers: List[torch.optim.Optimizer]): - self.optimizers = optimizers - - # Collect all params for the base Optimizer init. - all_params = [] - for opt in optimizers: - for group in opt.param_groups: - all_params.extend(group["params"]) - - # Initialize base Optimizer with dummy defaults; the actual param_groups - # come from the wrapped optimizers below. - super().__init__(all_params, defaults={}) - - # Replace param_groups with the ones from the wrapped optimizers. - self.param_groups = [] - for opt in optimizers: - self.param_groups.extend(opt.param_groups) - - def zero_grad(self, set_to_none: bool = True) -> None: - """Zero gradients for all wrapped optimizers.""" - for opt in self.optimizers: - opt.zero_grad(set_to_none=set_to_none) - - def step(self, closure=None) -> None: - """Step every wrapped optimizer.""" - for opt in self.optimizers: - opt.step(closure=closure) - - def state_dict(self) -> dict: - """Return combined state dict.""" - return { - "optimizers": [opt.state_dict() for opt in self.optimizers], - } - - def load_state_dict(self, state_dict: dict) -> None: - """Load combined state dict.""" - if "optimizers" not in state_dict: - raise KeyError( - "Expected CombinedOptimizer state_dict to contain 'optimizers', " - f"got keys: {list(state_dict.keys())}" - ) - - optimizer_states = state_dict["optimizers"] - if len(optimizer_states) != len(self.optimizers): - raise ValueError( - f"State dict contains {len(optimizer_states)} optimizer(s), " - f"but this CombinedOptimizer has {len(self.optimizers)} optimizer(s)." - ) - - for opt, opt_state in zip(self.optimizers, optimizer_states): - opt.load_state_dict(opt_state) - - self.param_groups = [] - for opt in self.optimizers: - self.param_groups.extend(opt.param_groups) - - # ========================================================================= # Save / load checkpoints # ========================================================================= @@ -264,13 +201,46 @@ def load_state_dict(self, state_dict: dict) -> None: LATEST_CHECKPOINT_DIR = "latest_checkpoint" +def _capture_rng_state() -> Dict[str, Any]: + """Snapshot torch / numpy RNGs for exact-reproducible resume.""" + state: Dict[str, Any] = { + "torch": torch.get_rng_state(), + "numpy": np.random.get_state(), + } + if torch.cuda.is_available(): + state["torch_cuda_all"] = torch.cuda.get_rng_state_all() + return state + + +def _require_checkpoint_files(path: Path, *, require_training_state: bool) -> None: + """Raise FileNotFoundError if expected checkpoint files are missing.""" + pt_files = list(path.glob("*.pt")) + mdlus_files = list(path.glob("*.mdlus")) + missing = [] + if require_training_state and not pt_files: + missing.append("*.pt (training state)") + if not mdlus_files: + missing.append("*.mdlus (model state)") + if missing: + present = [f.name for f in path.iterdir() if f.is_file()] + raise FileNotFoundError( + f"Checkpoint at {path} is incomplete; missing {missing}. " + f"Files present: {present}" + ) + + def _checkpoint_kwargs_with_metadata( checkpoint_kwargs: Dict[str, Any], **metadata_updates ) -> Dict[str, Any]: - """Return checkpoint kwargs with selected metadata keys refreshed.""" + """Return checkpoint kwargs with selected metadata keys refreshed. + + Always refreshes ``rng_state`` so every save automatically captures the + current torch / numpy RNG state for reproducible resume. + """ updated_kwargs = dict(checkpoint_kwargs) metadata = dict(updated_kwargs.get("metadata") or {}) metadata.update(metadata_updates) + metadata["rng_state"] = _capture_rng_state() updated_kwargs["metadata"] = metadata return updated_kwargs @@ -305,6 +275,10 @@ def save_latest_checkpoint( shutil.rmtree(tmp_path) tmp_path.mkdir(parents=True, exist_ok=True) + # Route through metadata helper so every latest checkpoint captures the + # current RNG state for reproducible resume. + checkpoint_kwargs = _checkpoint_kwargs_with_metadata(checkpoint_kwargs) + save_checkpoint_fn(path=str(tmp_path), epoch=epoch, **checkpoint_kwargs) if latest_path.exists(): @@ -655,6 +629,7 @@ def resume_or_pretrain( "train.resume_checkpoint must be a checkpoint directory, " f"not a file: {resume_path}" ) + _require_checkpoint_files(resume_path, require_training_state=True) if dist.rank == 0: logger.info(f"\nResuming from checkpoint: {resume_path}") @@ -675,6 +650,20 @@ def resume_or_pretrain( if "best_qoi_loss" in metadata: best_qoi_loss = metadata["best_qoi_loss"] + rng_state = metadata.get("rng_state") + if rng_state: + if "torch" in rng_state: + torch.set_rng_state(rng_state["torch"].cpu().to(torch.uint8)) + if "numpy" in rng_state: + np.random.set_state(rng_state["numpy"]) + if "torch_cuda_all" in rng_state and torch.cuda.is_available(): + cuda_states = [ + s.cpu().to(torch.uint8) for s in rng_state["torch_cuda_all"] + ] + torch.cuda.set_rng_state_all(cuda_states) + if dist.rank == 0: + logger.info(" RNG state restored from checkpoint") + if dist.rank == 0: logger.info(f" Resumed from epoch {start_epoch}") if best_val_losses: @@ -696,6 +685,7 @@ def resume_or_pretrain( "train.pretrain_checkpoint must be a checkpoint directory, " f"not a file: {pretrain_path}" ) + _require_checkpoint_files(pretrain_path, require_training_state=False) if dist.rank == 0: logger.info( diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/compute_normalizations.py b/examples/cfd/nuclear_engineering/radiation_transport/src/compute_normalizations.py index 22397944e5..ad02b5fb0d 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/compute_normalizations.py +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/compute_normalizations.py @@ -44,6 +44,7 @@ from __future__ import annotations import argparse +import os import sys from pathlib import Path from typing import Dict @@ -202,50 +203,59 @@ def compute_material_statistics( print(f"Dataset loaded: {len(dataset)} samples") print("\nAccumulating physical_properties...") - all_sigma_a, all_sigma_s, all_sigma_t, all_Q = [], [], [], [] + + # We track count, running mean, and M2 (sum of squared deviations from + # the running mean); the population std is sqrt(M2 / count) + prop_names = ("sigma_a", "sigma_s", "sigma_t", "Q") + count = 0 + mean_running = np.zeros(len(prop_names), dtype=np.float64) + m2_running = np.zeros(len(prop_names), dtype=np.float64) + min_running = np.full(len(prop_names), np.inf, dtype=np.float64) + max_running = np.full(len(prop_names), -np.inf, dtype=np.float64) for i in range(len(dataset)): sample = extractor(dataset[i]) props = sample["physical_properties"] if isinstance(props, torch.Tensor): props = props.detach().cpu().numpy() - all_sigma_a.append(props[:, 0]) - all_sigma_s.append(props[:, 1]) - all_sigma_t.append(props[:, 2]) - all_Q.append(props[:, 3]) + # Cast to float64 for the accumulator; the on-disk tensors are fp32. + props = np.asarray(props, dtype=np.float64) + n_i = props.shape[0] + if n_i == 0: + continue + + # Per-batch sufficient stats (mean and M2) for combination + batch_mean = props.mean(axis=0) + batch_m2 = ((props - batch_mean) ** 2).sum(axis=0) + + new_count = count + n_i + delta = batch_mean - mean_running + mean_running = mean_running + delta * (n_i / new_count) + m2_running = m2_running + batch_m2 + (delta**2) * (count * n_i / new_count) + count = new_count + + np.minimum(min_running, props.min(axis=0), out=min_running) + np.maximum(max_running, props.max(axis=0), out=max_running) + if (i + 1) % 100 == 0: print(f" Processed {i + 1}/{len(dataset)} samples") - all_sigma_a = np.concatenate(all_sigma_a) - all_sigma_s = np.concatenate(all_sigma_s) - all_sigma_t = np.concatenate(all_sigma_t) - all_Q = np.concatenate(all_Q) + if count == 0: + raise RuntimeError( + "compute_material_statistics: dataset produced zero cells; " + "cannot compute stats." + ) + + std_running = np.sqrt(m2_running / count) stats = { - "sigma_a": { - "mean": float(np.mean(all_sigma_a)), - "std": float(np.std(all_sigma_a)), - "min": float(np.min(all_sigma_a)), - "max": float(np.max(all_sigma_a)), - }, - "sigma_s": { - "mean": float(np.mean(all_sigma_s)), - "std": float(np.std(all_sigma_s)), - "min": float(np.min(all_sigma_s)), - "max": float(np.max(all_sigma_s)), - }, - "sigma_t": { - "mean": float(np.mean(all_sigma_t)), - "std": float(np.std(all_sigma_t)), - "min": float(np.min(all_sigma_t)), - "max": float(np.max(all_sigma_t)), - }, - "Q": { - "mean": float(np.mean(all_Q)), - "std": float(np.std(all_Q)), - "min": float(np.min(all_Q)), - "max": float(np.max(all_Q)), - }, + name: { + "mean": float(mean_running[j]), + "std": float(std_running[j]), + "min": float(min_running[j]), + "max": float(max_running[j]), + } + for j, name in enumerate(prop_names) } print("\nMaterial statistics:") @@ -312,6 +322,7 @@ def _parse_args() -> argparse.Namespace: def main() -> int: + """CLI entry: compute and write flux + material statistics YAMLs.""" args = _parse_args() output_dir: Path = args.output_dir diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/conf/case/lattice.yaml b/examples/cfd/nuclear_engineering/radiation_transport/src/conf/case/lattice.yaml index 18b2ba69cc..7b85c91410 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/conf/case/lattice.yaml +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/conf/case/lattice.yaml @@ -1,6 +1,18 @@ # SPDX-FileCopyrightText: Copyright (c) 2023 - 2026 NVIDIA CORPORATION & AFFILIATES. # SPDX-FileCopyrightText: All rights reserved. # SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. # Lattice benchmark: regular grid geometry with heterogeneous material blocks # and an interior heat source Q. Material properties are mapped from @@ -13,7 +25,7 @@ split_file: ${case.data_root}/splits/lattice_splits.json # Physics-loss configuration (lattice-specific) physics_loss_weight: 0.005 -qoi_region: center +qoi_region: center # Note: lattice QoI uses cur_absorption regardless of this value. # Embedding/material configuration include_q_in_embedding: true # lattice has a heat source diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/conf/data/hohlraum.yaml b/examples/cfd/nuclear_engineering/radiation_transport/src/conf/data/hohlraum.yaml index 1183e44c44..38fddc05d3 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/conf/data/hohlraum.yaml +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/conf/data/hohlraum.yaml @@ -1,10 +1,23 @@ # SPDX-FileCopyrightText: Copyright (c) 2023 - 2026 NVIDIA CORPORATION & AFFILIATES. # SPDX-FileCopyrightText: All rights reserved. # SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. input_dir: ${case.data_path} flux_normalization_stats_file: ${case.data_root}/stats/hohlraum_flux_stats.yaml flux_clip_threshold: 1.0e-8 +normalize_coordinates: true cache_static_arrays: true max_cache_size: -1 preload_data: true diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/conf/data/lattice.yaml b/examples/cfd/nuclear_engineering/radiation_transport/src/conf/data/lattice.yaml index 56a1f2772d..c01f321dd2 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/conf/data/lattice.yaml +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/conf/data/lattice.yaml @@ -1,10 +1,23 @@ # SPDX-FileCopyrightText: Copyright (c) 2023 - 2026 NVIDIA CORPORATION & AFFILIATES. # SPDX-FileCopyrightText: All rights reserved. # SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. input_dir: ${case.data_path} flux_normalization_stats_file: ${case.data_root}/stats/lattice_flux_stats.yaml flux_clip_threshold: 1.0e-8 +normalize_coordinates: true cache_static_arrays: true max_cache_size: -1 preload_data: true diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/conf/model/transolver.yaml b/examples/cfd/nuclear_engineering/radiation_transport/src/conf/model/transolver.yaml index 782632d038..e5f0707bf0 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/conf/model/transolver.yaml +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/conf/model/transolver.yaml @@ -1,6 +1,18 @@ # SPDX-FileCopyrightText: Copyright (c) 2023 - 2026 NVIDIA CORPORATION & AFFILIATES. # SPDX-FileCopyrightText: All rights reserved. # SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. # Transolver hyperparameters. The `_target_` is consumed by hydra.utils.instantiate # inside train.py::build_model. The two RTE-specific keys at the bottom @@ -17,7 +29,7 @@ n_head: 16 slice_num: 128 mlp_ratio: 4 dropout: 0.0 -use_te: true +use_te: false # default false for portability; flip to true if Transformer Engine is installed. structured_shape: null # RTE-specific (stripped before instantiation in train.py::build_model) diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/conf/train/base.yaml b/examples/cfd/nuclear_engineering/radiation_transport/src/conf/train/base.yaml index 303cf6b4a2..b5083c21ef 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/conf/train/base.yaml +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/conf/train/base.yaml @@ -1,6 +1,18 @@ # SPDX-FileCopyrightText: Copyright (c) 2023 - 2026 NVIDIA CORPORATION & AFFILIATES. # SPDX-FileCopyrightText: All rights reserved. # SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. epochs: 501 checkpoint_interval: 100 @@ -27,6 +39,7 @@ scheduler_type: cosine pretrain_checkpoint: null resume_checkpoint: null +# objective = mse_weight * regression_mse + physics_loss.weight * (regression_mse + qoi_loss); region_weighted swaps regression_mse for the weighted variant. # Physics-informed loss (case-specific weight + qoi_region pulled from case group). use_physics_loss: true physics_loss: diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/dataset.py b/examples/cfd/nuclear_engineering/radiation_transport/src/dataset.py index 18d5d696f8..2145cfb0e2 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/dataset.py +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/dataset.py @@ -104,14 +104,23 @@ def __init__( self.cache_static_arrays = cache_static_arrays self.max_cache_size = max_cache_size - # LRU cache for static arrays keyed by filename; values are dicts of - # ``torch.Tensor``. - self._static_cache: OrderedDict[str, Dict[str, torch.Tensor]] = OrderedDict() + # LRU cache for static arrays keyed by ``(filename, load_material_properties, + # load_geometric_features, load_sigma_fields)``; values are dicts of + # ``torch.Tensor``. Keying on the load-flag tuple prevents a second + # caller from getting a stale partial entry that was loaded with a + # different set of flags. + self._static_cache: OrderedDict[ + Tuple[str, bool, bool, bool], Dict[str, torch.Tensor] + ] = OrderedDict() self._cache_hits = 0 self._cache_misses = 0 self._cache_evictions = 0 self._cache_lock = threading.Lock() + # Per-file metadata cache. Metadata is static so we can reopen zarr + # at most once per filename instead of every ``__getitem__`` call. + self._metadata_cache: Dict[str, Dict] = {} + if not self.data_path.exists(): raise ValueError(f"Data path {self.data_path} does not exist") if not self.data_path.is_dir(): @@ -159,6 +168,7 @@ def get_filenames(self) -> List[str]: return list(self._filenames) def get_cache_stats(self) -> Dict[str, float]: + """Return current static-array cache hit/miss counters and size.""" with self._cache_lock: total = self._cache_hits + self._cache_misses hit_rate = (self._cache_hits / total * 100) if total > 0 else 0.0 @@ -172,6 +182,7 @@ def get_cache_stats(self) -> Dict[str, float]: } def clear_cache(self): + """Drop the static-array cache and reset hit/miss/eviction counters.""" with self._cache_lock: self._static_cache.clear() self._cache_hits = 0 @@ -207,6 +218,15 @@ def load( if not filepath.exists(): raise FileNotFoundError(f"Zarr store {filepath} not found") + # Cache key includes load-flag tuple so a second caller asking for + # fields not loaded the first time does not get stale partial data. + cache_key = ( + filename, + bool(load_material_properties), + bool(load_geometric_features), + bool(load_sigma_fields), + ) + z = zarr.open(str(filepath), mode="r") # ------- flux + timesteps (steady-state first -> final snapshots) ------- @@ -236,12 +256,12 @@ def load( # ------- static-arrays cache lookup ------- with self._cache_lock: - cache_hit = self.cache_static_arrays and filename in self._static_cache + cache_hit = self.cache_static_arrays and cache_key in self._static_cache cached_entry = None if cache_hit: self._cache_hits += 1 - self._static_cache.move_to_end(filename) - cached_entry = dict(self._static_cache[filename]) # shallow copy + self._static_cache.move_to_end(cache_key) + cached_entry = dict(self._static_cache[cache_key]) # shallow copy td = TensorDict({}, batch_size=[]) td["scalar_flux"] = torch.from_numpy(scalar_flux) @@ -279,7 +299,7 @@ def load( td[key] = torch.from_numpy(np.array(z[key], dtype=np.float32)) if self.cache_static_arrays: - self._maybe_cache_entry(filename, td) + self._maybe_cache_entry(cache_key, td) # Non-tensor metadata ride as NonTensorData so transforms and adapters # that access ``td["metadata"]`` keep working unchanged. @@ -287,13 +307,17 @@ def load( td.set_non_tensor("metadata", attrs) return td - def _maybe_cache_entry(self, filename: str, td: TensorDict) -> None: - """LRU-cache the static tensor fields of ``td`` under ``filename``.""" + def _maybe_cache_entry( + self, + cache_key: Tuple[str, bool, bool, bool], + td: TensorDict, + ) -> None: + """LRU-cache the static tensor fields of ``td`` under ``cache_key``.""" with self._cache_lock: if ( self.max_cache_size > 0 and len(self._static_cache) >= self.max_cache_size - and filename not in self._static_cache + and cache_key not in self._static_cache ): evicted = next(iter(self._static_cache)) del self._static_cache[evicted] @@ -312,14 +336,22 @@ def _maybe_cache_entry(self, filename: str, td: TensorDict) -> None: ): if key in td: entry[key] = td[key] - self._static_cache[filename] = entry + self._static_cache[cache_key] = entry # ------------------------------------------------------------------ # Metadata helpers # ------------------------------------------------------------------ def get_metadata(self, filename: str) -> Dict: - """Return metadata without loading full sample data.""" + """Return metadata without loading full sample data. + + Metadata is static per file, so the result is cached on the reader + and subsequent calls for the same ``filename`` skip the zarr open. + """ + cached = self._metadata_cache.get(filename) + if cached is not None: + return cached + filepath = self.data_path / filename z = zarr.open(str(filepath), mode="r") @@ -337,6 +369,8 @@ def get_metadata(self, filename: str) -> Dict: raise ValueError( f"Failed to read sim_times tail from {filename}" ) from exc + + self._metadata_cache[filename] = metadata return metadata diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/inference.py b/examples/cfd/nuclear_engineering/radiation_transport/src/inference.py index d48dc79b3c..512b40e5d8 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/inference.py +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/inference.py @@ -55,10 +55,8 @@ evaluate_lattice_qoi_torch, extract_geometry_params, ) -from trainer import initialize_distributed_manager from transforms import denormalize_flux -from physicsnemo.distributed import DistributedManager from physicsnemo.utils.checkpoint import load_checkpoint @@ -134,8 +132,6 @@ def load_model_from_checkpoint( cfg = load_hydra_config(checkpoint_dir) - initialize_distributed_manager() - # Build model from cfg.model. Strip RTE-specific keys consumed elsewhere. cfg_model = OmegaConf.to_container(cfg.model, resolve=True) for k in ("num_spatial_points", "include_q_in_embedding"): @@ -787,13 +783,9 @@ def main(): raise FileNotFoundError(f"Split file not found: {args.split_file}") _resolve_data_path(cfg, str(args.data_path), args.split_file) - num_spatial_points = cfg.model.get("num_spatial_points", -1) - if num_spatial_points != -1: - print( - "Warning: evaluation will use the checkpoint's " - f"num_spatial_points={num_spatial_points}; field metrics and QoI " - "are computed on that subsampled point set." - ) + if cfg.model.get("num_spatial_points", -1) != -1: + OmegaConf.update(cfg, "model.num_spatial_points", -1, force_add=True) + print("Forcing num_spatial_points=-1 for full-mesh evaluation.") # Output dir defaults to ``/evaluation``. if args.output_dir is None: diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/loader.py b/examples/cfd/nuclear_engineering/radiation_transport/src/loader.py index e5173754d2..8a107a538c 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/loader.py +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/loader.py @@ -180,6 +180,9 @@ def __repr__(self) -> str: @register("RTECollateNoPadding") def collate_no_padding(batch: List[Dict[str, Any]]) -> Dict[str, Any]: """Batch-size-1 collate: unsqueeze each tensor, pass non-tensors through.""" + assert len(batch) == 1, ( + f"collate_no_padding requires batch_size=1; got {len(batch)}" + ) item = batch[0] return { k: v.unsqueeze(0) if isinstance(v, torch.Tensor) else v for k, v in item.items() @@ -209,7 +212,11 @@ def _build_rte_dataset_kwargs(cfg: DictConfig) -> dict: "normalize_coordinates": data_cfg.get("normalize_coordinates", True), "flux_clip_threshold": data_cfg.flux_clip_threshold, "split_file": cfg.case.split_file, - "seed": data_cfg.get("seed") or cfg.get("train", {}).get("seed"), + "seed": ( + data_cfg.get("seed", None) + if data_cfg.get("seed", None) is not None + else cfg.get("train", {}).get("seed", None) + ), "cache_static_arrays": data_cfg.get("cache_static_arrays", True), "max_cache_size": data_cfg.get("max_cache_size", 200), "include_q_in_embedding": cfg.model.get("include_q_in_embedding", True), @@ -754,6 +761,7 @@ def build_dataloaders( rank=dist.rank, shuffle=cfg.train.sampler.shuffle, drop_last=cfg.train.sampler.get("drop_last", False), + seed=int(cfg.train.get("seed", 0) or 0), ) train_sampler = sampler else: @@ -776,9 +784,36 @@ def build_dataloaders( return loaders, train_sampler +def set_epoch_on_transforms(loader: Optional[DataLoader], epoch: int) -> None: + """Forward ``epoch`` to any transform exposing ``set_epoch``. + + Walks ``loader.dataset`` (an ``RTEDataPipe``), pulls the ``Compose`` of + transforms, and calls ``set_epoch(epoch)`` on every child that defines + one (e.g. ``SpatialSampler``). Safe no-op when the loader, dataset, or + transforms are missing. + """ + if loader is None: + return + dataset = getattr(loader, "dataset", None) + transforms = getattr(dataset, "transforms", None) + if transforms is None: + return + # ``Compose`` defines its own ``set_epoch`` that propagates to children. + set_epoch = getattr(transforms, "set_epoch", None) + if callable(set_epoch): + set_epoch(epoch) + return + # Fallback: iterate manually. + for t in transforms: + fn = getattr(t, "set_epoch", None) + if callable(fn): + fn(epoch) + + __all__ = [ "TransolverAdapter", "collate_no_padding", "RTEDataPipe", "build_dataloaders", + "set_epoch_on_transforms", ] diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/losses.py b/examples/cfd/nuclear_engineering/radiation_transport/src/losses.py index cbe1d1e5c3..0fb89144a3 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/losses.py +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/losses.py @@ -79,6 +79,7 @@ def __init__( super().__init__(optimizer, last_epoch) def get_lr(self): + """Return the per-group learning rates for the current ``last_epoch``.""" if self.last_epoch < self.warmup_epochs: # Linear warmup warmup_factor = (self.last_epoch + 1) / max(1, self.warmup_epochs) @@ -208,7 +209,7 @@ def region_weighted_loss_fn( target: Ground truth values (B, N, 1) material_labels: Material label per cell (B, N) or (B, N, 1), integer values case_type: "hohlraum" or "lattice" - loss_type: Type of loss - "mse" or "rmse" + loss_type: Type of loss - "mse" or "weighted_rel_l2" (relative-L2 form, not true RMSE) padded_value: Value used for padding (will be masked out) void_weight: Weight for void (fill gas) regions material_weight: Weight for solid material regions @@ -246,7 +247,7 @@ def region_weighted_loss_fn( squared_error = (output - target) ** 2.0 weighted_error = weights * squared_error - if loss_type == "rmse": + if loss_type == "weighted_rel_l2": weighted_target_sq = weights * target**2.0 loss = torch.sqrt(weighted_error.sum() / (weighted_target_sq.sum() + 1e-8)) else: # mse @@ -341,12 +342,11 @@ def denormalize_flux_from_stats( only after validating shapes, so the stats must be available). """ if flux_normalization_stats is None: - raise ValueError( - "flux_normalization_stats is required for QoI denormalization" - ) + raise ValueError("flux_normalization_stats is required for QoI denormalization") # Sibling import is safe: transforms.py is foundational and does not # import from losses.py (verified via static cross-module check). from transforms import denormalize_flux + return denormalize_flux(normalized_flux, flux_normalization_stats) @@ -416,9 +416,7 @@ def compute_lattice_qoi_loss( predicted_flux = denormalize_flux_from_stats( predicted_flux, flux_normalization_stats ) - target_flux = denormalize_flux_from_stats( - target_flux, flux_normalization_stats - ) + target_flux = denormalize_flux_from_stats(target_flux, flux_normalization_stats) # reshape for QoI computation: (B, 1, N) for single timestep predicted_flux_qoi = predicted_flux.unsqueeze(1) # (B, 1, N) @@ -500,9 +498,7 @@ def compute_hohlraum_qoi_loss( predicted_flux = denormalize_flux_from_stats( predicted_flux, flux_normalization_stats ) - target_flux = denormalize_flux_from_stats( - target_flux, flux_normalization_stats - ) + target_flux = denormalize_flux_from_stats(target_flux, flux_normalization_stats) predicted_flux_qoi = predicted_flux.unsqueeze(1) target_flux_qoi = target_flux.unsqueeze(1) @@ -839,9 +835,7 @@ def evaluate_hohlraum_qoi_torch( in_vertical = ( (x < pos_red_left_border) & (y > pos_red_left_bottom) & (y < pos_red_left_top) ) | ( - (x > pos_red_right_border) - & (y > pos_red_left_bottom) - & (y < pos_red_right_top) + (x > pos_red_right_border) & (y > pos_red_left_bottom) & (y < pos_red_right_top) ) in_horizontal = (y > 0.6) | (y < -0.6) diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/material.py b/examples/cfd/nuclear_engineering/radiation_transport/src/material.py index 0744f95b7f..8635068308 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/material.py +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/material.py @@ -31,7 +31,11 @@ class MaterialPropertyExtractor(Transform): - """Stack precomputed sigma fields into a per-cell ``(N, 4)`` tensor.""" + """Stack precomputed sigma fields into a per-cell ``(N, 4)`` tensor. + + Q must be present in the source data; it may be all-zero for source-free + regimes (e.g., hohlraum). + """ def __call__(self, data: TensorDict) -> TensorDict: for key in ("sigma_a", "sigma_s", "sigma_t", "Q"): diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/train.py b/examples/cfd/nuclear_engineering/radiation_transport/src/train.py index d754bac282..6489e87089 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/train.py +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/train.py @@ -304,7 +304,10 @@ def _parse_amp(cfg: DictConfig) -> Tuple[bool, Optional[torch.dtype], str]: return use_amp, torch.bfloat16, dtype_str if dtype_str in ("fp16", "float16"): return use_amp, torch.float16, dtype_str - return use_amp, None, dtype_str + raise ValueError( + f"Unsupported amp_dtype {dtype_str!r}; " + "allowed values are 'bf16', 'bfloat16', 'fp16', 'float16'." + ) # ========================================================================= @@ -420,8 +423,8 @@ def before_epoch_fn(epoch: int): ``warmup_start_fraction * base`` to ``base`` over the first ``warmup_epochs``. After warmup, the weight stays at ``base``. - Both train and val use the same epoch-specific weight so that the - validation ``loss_qoi`` value is comparable across epochs. + Validation always uses the unwarmed-up final ``loss_cfg`` so val_loss + is comparable across epochs and best-checkpoint selection is meaningful. """ if not use_physics_loss or physics_loss_warmup_epochs <= 0: return {}, {} @@ -438,7 +441,7 @@ def before_epoch_fn(epoch: int): f"weight={current_weight:.6f} (target={physics_loss_weight_base})" ) epoch_loss_cfg = {**loss_cfg, "physics_loss_weight": current_weight} - return {"loss_cfg": epoch_loss_cfg}, {"loss_cfg": epoch_loss_cfg} + return {"loss_cfg": epoch_loss_cfg}, {"loss_cfg": loss_cfg} def after_epoch_fn(epoch, train_log, val_log, val_loss, current_lr): train_loss = train_log.epoch_losses.get("loss", 0.0) diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/trainer.py b/examples/cfd/nuclear_engineering/radiation_transport/src/trainer.py index d24cbbcfa4..24f2cc2de5 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/trainer.py +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/trainer.py @@ -22,7 +22,7 @@ * DDP primitives — ``set_seed``, ``setup_training_environment``, ``wrap_ddp``, ``log_effective_batch_size``, ``synchronize_output_directory``, - ``cleanup_sync_marker``, ``aggregate_validation_loss``. + ``aggregate_validation_loss``. * Per-step / per-epoch helpers — ``compute_losses``, ``grad_step``, ``flush_partial_accumulation``. * Training loop — ``run_training_loop``. @@ -31,12 +31,10 @@ ``checkpointing.py`` and ``losses.py`` respectively. """ -import hashlib import logging import math import os import random -import time from pathlib import Path from typing import Any, Callable, Dict, List, Mapping, Optional, Tuple @@ -58,6 +56,7 @@ save_best_qoi_checkpoint, save_latest_checkpoint, ) +from loader import set_epoch_on_transforms from losses import compute_physics_loss, masked_mse_loss, region_weighted_loss_fn @@ -65,9 +64,6 @@ # DDP primitives & environment setup # ========================================================================= -# Marker file to signal that output directory is ready -SYNC_MARKER_FILE = ".rank0_ready" - def _setup_logger(name: str, log_file: Optional[str] = None) -> logging.Logger: """Create a console (and optional file) logger with a consistent format.""" @@ -115,21 +111,18 @@ def set_seed(seed: int, deterministic: bool = False) -> None: def synchronize_output_directory( cfg: DictConfig, dist: DistributedManager, - max_wait_seconds: int = 300, - poll_interval: float = 1.0, ) -> str: - """Synchronize the output directory across DDP ranks via a file marker. + """Synchronize the output directory across DDP ranks via torch broadcast. Hydra's timestamp-based output paths can otherwise produce one folder per - rank. Rank 0 creates the directory and writes a marker file containing the - path; other ranks busy-wait on the marker, then read the synchronized path - and update ``cfg.output`` in place. + rank. Rank 0 creates the directory; the resolved path is broadcast from + rank 0 to every other rank, which updates ``cfg.output`` in place if it + differs and ensures the directory exists locally. Ends with a barrier so + no rank proceeds before the directory is in place. Args: cfg: Hydra configuration with an ``output`` field. dist: DistributedManager instance. - max_wait_seconds: Maximum time non-rank-0 processes will wait. - poll_interval: How often to check for the marker file. Returns: The synchronized output directory path. @@ -146,75 +139,31 @@ def synchronize_output_directory( os.makedirs(output_dir, exist_ok=True) return output_dir - # Use a shared marker location that all ranks can see - marker_dir = os.path.dirname(output_dir) - os.makedirs(marker_dir, exist_ok=True) - path_hash = hashlib.md5(output_dir.encode()).hexdigest()[:8] - marker_file = os.path.join(marker_dir, f".sync_{path_hash}") - + # By the time this is called, DistributedManager.initialize() has run, so + # torch_dist.is_initialized() is True for distributed runs. if dist.rank == 0: print(f"[Rank 0] Creating output directory: {output_dir}") os.makedirs(output_dir, exist_ok=True) os.makedirs(os.path.join(output_dir, "checkpoints"), exist_ok=True) - with open(marker_file, "w") as f: - f.write(output_dir) - - print(f"[Rank 0] Marker file created: {marker_file}") - - else: - print(f"[Rank {dist.rank}] Waiting for rank 0 to create output directory...") - - waited = 0.0 - while not os.path.exists(marker_file): - if waited >= max_wait_seconds: - raise TimeoutError( - f"[Rank {dist.rank}] Timeout waiting for rank 0 to create " - f"output directory. Waited {max_wait_seconds}s for marker " - f"file: {marker_file}" - ) - time.sleep(poll_interval) - waited += poll_interval - - if waited % 30 == 0: - print(f"[Rank {dist.rank}] Still waiting... ({waited:.0f}s)") - - # Small delay to ensure the file is fully written - time.sleep(0.5) - with open(marker_file, "r") as f: - synced_output_dir = f.read().strip() + payload = [output_dir] + torch_dist.broadcast_object_list(payload, src=0) + synced_output_dir = payload[0] + if dist.rank != 0: if synced_output_dir != output_dir: print(f"[Rank {dist.rank}] Syncing to output: {synced_output_dir}") OmegaConf.set_struct(cfg, False) cfg.output = synced_output_dir OmegaConf.set_struct(cfg, True) output_dir = synced_output_dir - + os.makedirs(output_dir, exist_ok=True) print(f"[Rank {dist.rank}] Synchronized to output directory: {output_dir}") - if torch_dist.is_initialized(): - torch_dist.barrier() - + torch_dist.barrier() return output_dir -def cleanup_sync_marker(output_dir: str) -> None: - """Remove the synchronization marker file at the end of training. - - Should be called by rank 0 only. - """ - marker_dir = os.path.dirname(output_dir) - path_hash = hashlib.md5(output_dir.encode()).hexdigest()[:8] - marker_file = os.path.join(marker_dir, f".sync_{path_hash}") - - if os.path.exists(marker_file): - try: - os.remove(marker_file) - except OSError: - pass - - def aggregate_validation_loss( loss_sum: float, num_batches: int, @@ -453,6 +402,12 @@ def compute_losses( if not loss_cfg.get("use_physics_loss", False): return loss_mse, loss_mse, None, {} + physics_w = loss_cfg.get("physics_loss_weight", 0.1) + if not physics_w: + # Zero (or missing/None) weight -> physics loss is disabled; skip the + # QoI computation entirely. + return loss_mse, loss_mse, None, {} + with autocast(enabled=False, device_type=device.type): loss_qoi, qoi_details = compute_physics_loss( case_type=case_type, @@ -468,7 +423,6 @@ def compute_losses( qoi_region=loss_cfg.get("qoi_region", "center"), ) - physics_w = loss_cfg.get("physics_loss_weight", 0.1) mse_w = loss_cfg.get("physics_loss_mse_weight", 1.0) loss = mse_w * loss_mse + physics_w * loss_qoi return loss, loss_mse, loss_qoi, qoi_details @@ -633,6 +587,10 @@ def run_training_loop( for epoch in range(start_epoch, cfg.train.epochs): if train_sampler is not None: train_sampler.set_epoch(epoch) + # Propagate epoch to spatial samplers / other stochastic transforms + # so each rank reshuffles deterministically per epoch. + set_epoch_on_transforms(train_loader, epoch) + set_epoch_on_transforms(val_loader, epoch) train_kw = dict(train_epoch_kwargs) val_kw = dict(validate_kwargs) @@ -823,6 +781,9 @@ def run_training_loop( logger.info("\n" + "=" * 70) logger.info("Training interrupted by user") logger.info("=" * 70) + # Re-raise so the process exits non-zero and callers can distinguish + # an interrupted run from a clean finish. + raise finally: if finally_fn is not None: @@ -831,20 +792,22 @@ def run_training_loop( writer.close() if dist.rank == 0: - logger.info("\n" + "=" * 70) - logger.info("Training completed!") - loss_strs = [f"{loss:.6f}" for loss, _ in best_val_losses] - logger.info(f"Top validation losses: {loss_strs}") - if best_qoi_loss < float("inf"): - logger.info(f"Best QoI loss: {best_qoi_loss:.6e}") - logger.info(f"Checkpoints saved to: {checkpoint_dir}") - logger.info("=" * 70) - if training_completed: + logger.info("\n" + "=" * 70) + logger.info("Training completed!") + loss_strs = [f"{loss:.6f}" for loss, _ in best_val_losses] + logger.info(f"Top validation losses: {loss_strs}") + if best_qoi_loss < float("inf"): + logger.info(f"Best QoI loss: {best_qoi_loss:.6e}") + logger.info(f"Checkpoints saved to: {checkpoint_dir}") + logger.info("=" * 70) + completion_marker = os.path.join(checkpoint_dir, ".training_complete") with open(completion_marker, "w") as f: f.write(f"completed_epochs={cfg.train.epochs}\n") f.write(f"target_epochs={cfg.train.epochs}\n") logger.info(f"Training complete marker written to: {completion_marker}") - - cleanup_sync_marker(cfg.output) + else: + logger.info("\n" + "=" * 70) + logger.info("Training interrupted (no completion marker written)") + logger.info("=" * 70) diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/transforms.py b/examples/cfd/nuclear_engineering/radiation_transport/src/transforms.py index b04aaefa34..6757e71281 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/transforms.py +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/transforms.py @@ -99,7 +99,7 @@ def denormalize_flux( std = stats["log_flux_std"] clip = stats["clip_threshold"] log_flux = normalized_flux * std + mean - log_flux = torch.clamp(log_flux, min=-300, max=300) + log_flux = torch.clamp(log_flux, min=-38, max=38) flux = torch.pow(10.0, log_flux) - clip return torch.clamp(flux, min=0.0) @@ -247,6 +247,7 @@ def __init__( ] def get_output_dim(self) -> int: + """Number of Fourier-feature channels emitted (``2 * num_frequencies * coord_dims``).""" return 2 * self.num_frequencies * self.coord_dims def __call__(self, data: TensorDict) -> TensorDict: @@ -294,13 +295,26 @@ class SpatialSampler(Transform): thousands of cells, far above any practical ``num_points``). """ + # Stride used when re-seeding per epoch; large prime keeps streams disjoint. + _EPOCH_PRIME: int = 1_000_003 + def __init__(self, num_points: int, seed: Optional[int] = None): super().__init__() self.num_points = num_points self.seed = seed - self.rng = ( - np.random.default_rng(seed) if seed is not None else np.random.default_rng() - ) + self.gen = torch.Generator() + if seed is not None: + self.gen.manual_seed(int(seed)) + + def set_epoch(self, epoch: int) -> None: + """Re-seed the generator for a new epoch (deterministic reshuffle). + + No-op when ``self.seed`` is ``None`` (caller opted into a non-deterministic + run; preserve current generator state). + """ + if self.seed is None: + return + self.gen.manual_seed(int(self.seed) + int(epoch) * self._EPOCH_PRIME) def __call__(self, data: TensorDict) -> TensorDict: if self.num_points == -1: @@ -316,8 +330,8 @@ def __call__(self, data: TensorDict) -> TensorDict: "than any configured num_points, so this should never happen." ) - indices_np = self.rng.choice(num_available, self.num_points, replace=False) - indices = torch.from_numpy(indices_np.astype(np.int64)) + indices = torch.randperm(num_available, generator=self.gen)[: self.num_points] + indices = indices.to(torch.int64) spatial_keys = [ "coordinates", @@ -375,6 +389,7 @@ def __call__(self, data: TensorDict) -> TensorDict: ) return data + # ========================================================================= # Public API # ========================================================================= From 1d7d3a132b1a172ec839d3d7820c814575a36a22 Mon Sep 17 00:00:00 2001 From: Carmelo Gonzales Date: Tue, 5 May 2026 09:29:10 -0700 Subject: [PATCH 23/68] fix: forcing single process inference --- .../nuclear_engineering/radiation_transport/src/inference.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/inference.py b/examples/cfd/nuclear_engineering/radiation_transport/src/inference.py index 512b40e5d8..d1edf47f25 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/inference.py +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/inference.py @@ -57,6 +57,7 @@ ) from transforms import denormalize_flux +from physicsnemo.distributed import DistributedManager from physicsnemo.utils.checkpoint import load_checkpoint @@ -64,6 +65,10 @@ # Checkpoint loading # ========================================================================= +# Inference is single-process; avoid PhysicsNeMo checkpoint loading auto-detecting +# SLURM variables from an interactive allocation and waiting for missing ranks. +if not DistributedManager.is_initialized(): + DistributedManager._shared_state["_is_initialized"] = True def load_hydra_config(checkpoint_dir: Union[str, Path]) -> DictConfig: """Load the Hydra config saved next to a checkpoint. From 1935ba8661e6dc304579c7b1914fba424d60d14e Mon Sep 17 00:00:00 2001 From: Carmelo Gonzales Date: Tue, 5 May 2026 09:32:02 -0700 Subject: [PATCH 24/68] fix: pre-commit --- .../cfd/nuclear_engineering/radiation_transport/src/inference.py | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/inference.py b/examples/cfd/nuclear_engineering/radiation_transport/src/inference.py index d1edf47f25..51d5e386bf 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/inference.py +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/inference.py @@ -70,6 +70,7 @@ if not DistributedManager.is_initialized(): DistributedManager._shared_state["_is_initialized"] = True + def load_hydra_config(checkpoint_dir: Union[str, Path]) -> DictConfig: """Load the Hydra config saved next to a checkpoint. From c35d974e7eafa8865f887634303e3219eecd39ce Mon Sep 17 00:00:00 2001 From: Carmelo Gonzales Date: Tue, 5 May 2026 13:45:32 -0700 Subject: [PATCH 25/68] refactor: porting to pmsh --- .../src/compute_normalizations.py | 5 +- .../src/conf/train/base.yaml | 10 +- .../radiation_transport/src/dataset.py | 372 ++++++++++------ .../radiation_transport/src/inference.py | 7 +- .../radiation_transport/src/loader.py | 417 +++++++++--------- .../radiation_transport/src/train.py | 2 +- .../radiation_transport/src/trainer.py | 16 +- .../radiation_transport/src/transforms.py | 13 +- 8 files changed, 459 insertions(+), 383 deletions(-) diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/compute_normalizations.py b/examples/cfd/nuclear_engineering/radiation_transport/src/compute_normalizations.py index ad02b5fb0d..675a3ebd5e 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/compute_normalizations.py +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/compute_normalizations.py @@ -109,7 +109,7 @@ def compute_flux_statistics( max_log_flux = float("-inf") for i in range(len(dataset)): - sample = dataset[i] + sample, _ = dataset[i] flux = sample["scalar_flux"] if isinstance(flux, torch.Tensor): flux = flux.detach().cpu().numpy() @@ -214,7 +214,8 @@ def compute_material_statistics( max_running = np.full(len(prop_names), -np.inf, dtype=np.float64) for i in range(len(dataset)): - sample = extractor(dataset[i]) + td, _ = dataset[i] + sample = extractor(td) props = sample["physical_properties"] if isinstance(props, torch.Tensor): props = props.detach().cpu().numpy() diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/conf/train/base.yaml b/examples/cfd/nuclear_engineering/radiation_transport/src/conf/train/base.yaml index b5083c21ef..19ef2fefd1 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/conf/train/base.yaml +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/conf/train/base.yaml @@ -57,10 +57,9 @@ region_weights: dataloader: batch_size: 1 # only batch_size=1 is supported (point-cloud adapter) - pin_memory: true - num_workers: 8 prefetch_factor: 4 - persistent_workers: true + num_streams: 4 + use_streams: true sampler: shuffle: true @@ -69,10 +68,9 @@ sampler: val: dataloader: batch_size: 1 - pin_memory: true - num_workers: 8 prefetch_factor: 4 - persistent_workers: true + num_streams: 4 + use_streams: true sampler: shuffle: false drop_last: false diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/dataset.py b/examples/cfd/nuclear_engineering/radiation_transport/src/dataset.py index 2145cfb0e2..42f00f2d94 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/dataset.py +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/dataset.py @@ -14,12 +14,13 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""RTE data-source layer: zarr reader, PyTorch Dataset, and stats loaders. +"""RTE data-source layer: mesh reader, PhysicsNeMo Dataset, and stats loaders. This module is the bottom of the data dependency tree. It provides the -low-level Zarr access (``ZarrDataReader``), a thin file-indexed -``Dataset`` wrapper (``RTEBaseDataset``), and helpers for reading the -RTE-specific YAML statistics files into PhysicsNeMo ``Normalize`` kwargs. +low-level Mesh access (``MeshDataReader``), a thin file-indexed +``physicsnemo.datapipes.Dataset`` subclass (``RTEBaseDataset``), and +helpers for reading the RTE-specific YAML statistics files into +PhysicsNeMo ``Normalize`` kwargs. """ from __future__ import annotations @@ -29,23 +30,24 @@ import warnings from collections import OrderedDict from pathlib import Path -from typing import Dict, List, Mapping, Optional, Sequence, Tuple, Union +from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple, Union -import numpy as np import torch import yaml -import zarr +from physicsnemo.datapipes.dataset import Dataset as PhysicsNeMoDataset from physicsnemo.datapipes.readers.base import Reader from physicsnemo.datapipes.registry import register +from physicsnemo.datapipes.transforms.base import Transform +from physicsnemo.mesh import Mesh from tensordict import TensorDict -from torch.utils.data import Dataset # ========================================================================= -# Zarr reader +# Mesh reader # ========================================================================= # -# Low-level reader for RTE simulation data stored in zarr. Inherits from +# Filename-indexed reader over a directory of ``.mesh/`` memmap +# directories produced by ``convert_zarr_to_mesh.py``. Inherits from # ``physicsnemo.datapipes.readers.base.Reader``; returns ``TensorDict`` from # ``load()``. The TensorDict carries both the tensor fields and non-tensor # metadata (``metadata``, ``filename``) via ``NonTensorData`` entries. @@ -56,38 +58,20 @@ # defaults. -_TENSOR_FIELD_NAMES = ( - "coordinates", - "cell_areas", - "scalar_flux", - "sim_times", - "material_properties", - "geometric_features", - "sigma_t", - "sigma_s", - "sigma_a", - "Q", -) +@register("RTEMeshReader") +class MeshDataReader(Reader): + """Filename-indexed reader over a directory of RTE Mesh memmap stores. - -@register("RTEZarrReader") -class ZarrDataReader(Reader): - """Filename-indexed reader over a directory of RTE zarr stores. - - Inherits from ``physicsnemo.datapipes.readers.base.Reader`` so the reader - plugs into any PhysicsNeMo-native pipeline via ``__getitem__(int)`` → - ``(TensorDict, metadata_dict)``. RTE pipelines still reach it via - ``load(filename, **kwargs)`` for the steady-state loader controls the - training data loaders rely on. + The ``TensorDict`` returned by ``load(filename, ...)`` carries the + tensor fields RTE training and inference rely on. The on-disk format + is the PhysicsNeMo ``Mesh`` memmap layout (``.mesh/`` + + ``.attrs.json`` sidecar) emitted by ``convert_zarr_to_mesh.py``. Example: - >>> reader = ZarrDataReader("/path/to/zarr_stores/lattice") + >>> reader = MeshDataReader("/path/to/mesh_stores/lattice") >>> filenames = reader.get_filenames() >>> td = reader.load(filenames[0]) >>> print(td["coordinates"].shape) # (N, 3) - - The LRU cache is retained verbatim: it stores tensor fields keyed by - filename and evicts in insertion order when ``max_cache_size`` is hit. """ def __init__( @@ -104,11 +88,6 @@ def __init__( self.cache_static_arrays = cache_static_arrays self.max_cache_size = max_cache_size - # LRU cache for static arrays keyed by ``(filename, load_material_properties, - # load_geometric_features, load_sigma_fields)``; values are dicts of - # ``torch.Tensor``. Keying on the load-flag tuple prevents a second - # caller from getting a stale partial entry that was loaded with a - # different set of flags. self._static_cache: OrderedDict[ Tuple[str, bool, bool, bool], Dict[str, torch.Tensor] ] = OrderedDict() @@ -117,8 +96,6 @@ def __init__( self._cache_evictions = 0 self._cache_lock = threading.Lock() - # Per-file metadata cache. Metadata is static so we can reopen zarr - # at most once per filename instead of every ``__getitem__`` call. self._metadata_cache: Dict[str, Dict] = {} if not self.data_path.exists(): @@ -126,8 +103,6 @@ def __init__( if not self.data_path.is_dir(): raise ValueError(f"Data path {self.data_path} is not a directory") - # Discover files once at construction time so ``_load_sample`` has a - # stable filename→int mapping for the PhysicsNeMo ``Reader`` protocol. self._filenames: List[str] = self._scan_filenames() # ------------------------------------------------------------------ @@ -138,33 +113,29 @@ def __len__(self) -> int: return len(self._filenames) def _load_sample(self, index: int) -> Dict[str, torch.Tensor]: - """Int-indexed load using defaults (load everything, no slicing).""" td = self.load(self._filenames[index]) return {key: td[key] for key in td.keys() if isinstance(td[key], torch.Tensor)} def _get_sample_metadata(self, index: int) -> Dict: - """Return per-sample metadata dict for ``(TensorDict, metadata)`` tuple.""" filename = self._filenames[index] meta = self.get_metadata(filename) meta["filename"] = filename return meta # ------------------------------------------------------------------ - # Filename discovery + caching (unchanged behavior) + # Filename discovery + caching # ------------------------------------------------------------------ def _scan_filenames(self) -> List[str]: filenames = [] for item in self.data_path.iterdir(): - if item.suffix == ".zarr" or ( - item.is_dir() and item.name.endswith(".zarr") - ): + if item.is_dir() and item.name.endswith(".mesh"): if self.case_type is None or item.name.startswith(self.case_type): filenames.append(item.name) return sorted(filenames) def get_filenames(self) -> List[str]: - """Return a fresh list of discovered zarr store names.""" + """Return a fresh list of discovered mesh store names.""" return list(self._filenames) def get_cache_stats(self) -> Dict[str, float]: @@ -189,6 +160,22 @@ def clear_cache(self): self._cache_misses = 0 self._cache_evictions = 0 + # ------------------------------------------------------------------ + # Sidecar + Mesh helpers + # ------------------------------------------------------------------ + + def _sidecar_path(self, filename: str) -> Path: + # ``.mesh`` -> ``.attrs.json`` + stem = filename[: -len(".mesh")] if filename.endswith(".mesh") else filename + return self.data_path / f"{stem}.attrs.json" + + def _read_sidecar(self, filename: str) -> Dict: + sidecar = self._sidecar_path(filename) + if not sidecar.exists(): + return {} + with open(sidecar, "r", encoding="utf-8") as f: + return json.load(f) + # ------------------------------------------------------------------ # The filename-indexed ``load`` stays the primary entry point # ------------------------------------------------------------------ @@ -202,24 +189,21 @@ def load( load_sigma_fields: bool = True, load_flux: bool = True, ) -> TensorDict: - """Load a zarr store into a ``TensorDict``. + """Load a Mesh memmap store into a ``TensorDict``. Tensor fields (``coordinates``, ``cell_areas``, ``scalar_flux``, and the optional ``sim_times`` / ``material_properties`` / ``geometric_features`` - / ``sigma_*`` / ``Q``) are stored as - ``torch.Tensor`` entries. The zarr store's ``.attrs`` dict is stored - as ``NonTensorData`` under the ``metadata`` key. + / ``sigma_*`` / ``Q``) are stored as ``torch.Tensor`` entries. The + sidecar attrs dict is stored as ``NonTensorData`` under ``metadata``. ``load_flux=False`` returns a placeholder ``scalar_flux`` of shape ``(1, N)`` — the caller is expected to overwrite it from a memory - cache. The sentinel matches the pre-Phase-I behavior. + cache. """ filepath = self.data_path / filename if not filepath.exists(): - raise FileNotFoundError(f"Zarr store {filepath} not found") + raise FileNotFoundError(f"Mesh store {filepath} not found") - # Cache key includes load-flag tuple so a second caller asking for - # fields not loaded the first time does not get stale partial data. cache_key = ( filename, bool(load_material_properties), @@ -227,32 +211,44 @@ def load( bool(load_sigma_fields), ) - z = zarr.open(str(filepath), mode="r") + # ``Mesh.load`` returns memmap-backed tensors. The single-process + # ``physicsnemo.datapipes.DataLoader`` (CUDA streams, no fork) keeps + # the tensors live in this process, so we let Mesh hand back the + # memmap views directly — the prior ``.clone()`` was only needed to + # survive cross-process serialization with the legacy + # ``torch.utils.data.DataLoader``. + mesh = Mesh.load(str(filepath)) + point_data = mesh.point_data + global_data = mesh.global_data # ------- flux + timesteps (steady-state first -> final snapshots) ------- + if "scalar_flux" in point_data.keys(): + flux_nT = point_data["scalar_flux"] # (N, T) + num_cells = flux_nT.shape[0] + num_timesteps = flux_nT.shape[1] if flux_nT.ndim == 2 else 1 + else: + flux_nT = None + num_cells = mesh.points.shape[0] + num_timesteps = 1 + if not load_flux: - flux_shape = z["scalar_flux"].shape - num_cells = flux_shape[-1] - scalar_flux = np.zeros((1, num_cells), dtype=np.float32) - sim_times = None + scalar_flux = torch.zeros((1, num_cells), dtype=torch.float32) + sim_times_t = None else: - flux_array = z["scalar_flux"] - if len(flux_array.shape) == 1: - scalar_flux = np.array(flux_array, dtype=np.float32)[None, :] - resolved = [0] - else: - num_timesteps = flux_array.shape[0] - resolved = [0] if num_timesteps == 1 else [0, num_timesteps - 1] - scalar_flux = np.stack( - [np.array(flux_array[idx], dtype=np.float32) for idx in resolved], - axis=0, - ) - if load_sim_times and "sim_times" in z: - sim_times = np.array( - [z["sim_times"][idx] for idx in resolved], dtype=np.float32 - ) + if flux_nT is None: + raise KeyError(f"scalar_flux missing from {filepath}") + full = flux_nT.transpose(0, 1).contiguous().to(torch.float32) # (T, N) + resolved = [0] if num_timesteps == 1 else [0, num_timesteps - 1] + scalar_flux = full[resolved].contiguous() + if ( + load_sim_times + and "sim_times" in global_data.keys() + and global_data["sim_times"].numel() > 0 + ): + sim_t = global_data["sim_times"].to(torch.float32) + sim_times_t = sim_t[resolved].contiguous() else: - sim_times = None + sim_times_t = None # ------- static-arrays cache lookup ------- with self._cache_lock: @@ -261,49 +257,49 @@ def load( if cache_hit: self._cache_hits += 1 self._static_cache.move_to_end(cache_key) - cached_entry = dict(self._static_cache[cache_key]) # shallow copy + cached_entry = dict(self._static_cache[cache_key]) td = TensorDict({}, batch_size=[]) - td["scalar_flux"] = torch.from_numpy(scalar_flux) - if sim_times is not None: - td["sim_times"] = torch.from_numpy(np.asarray(sim_times)) + td["scalar_flux"] = scalar_flux + if sim_times_t is not None: + td["sim_times"] = sim_times_t if cache_hit: - # Reuse cached static tensors; copy references, not data. for key, tensor in cached_entry.items(): td[key] = tensor else: self._cache_misses += 1 - td["coordinates"] = torch.from_numpy( - np.array(z["cell_centers"], dtype=np.float32) - ) - td["cell_areas"] = torch.from_numpy( - np.array(z["cell_areas"], dtype=np.float32) - ) + td["coordinates"] = mesh.points.to(torch.float32).contiguous() + if "cell_areas" in point_data.keys(): + td["cell_areas"] = ( + point_data["cell_areas"].to(torch.float32).contiguous() + ) - if load_material_properties and "material_properties" in z: - td["material_properties"] = torch.from_numpy( - np.array(z["material_properties"], dtype=np.int32) + if load_material_properties and "material_properties" in point_data.keys(): + td["material_properties"] = ( + point_data["material_properties"].to(torch.int32).contiguous() ) - elif load_material_properties and "material_properties" not in z: + elif ( + load_material_properties + and "material_properties" not in point_data.keys() + ): warnings.warn(f"Material properties not found in {filename}.") - if load_geometric_features and "geometric_features" in z: - td["geometric_features"] = torch.from_numpy( - np.array(z["geometric_features"], dtype=np.float32) + if load_geometric_features and "geometric_features" in point_data.keys(): + td["geometric_features"] = ( + point_data["geometric_features"].to(torch.float32).contiguous() ) if load_sigma_fields: for key in ("sigma_t", "sigma_s", "sigma_a", "Q"): - if key in z: - td[key] = torch.from_numpy(np.array(z[key], dtype=np.float32)) + if key in point_data.keys(): + td[key] = point_data[key].to(torch.float32).contiguous() if self.cache_static_arrays: self._maybe_cache_entry(cache_key, td) - # Non-tensor metadata ride as NonTensorData so transforms and adapters - # that access ``td["metadata"]`` keep working unchanged. - attrs = dict(z.attrs) if hasattr(z, "attrs") else {} + sidecar = self._read_sidecar(filename) + attrs = dict(sidecar.get("raw_attrs", {})) td.set_non_tensor("metadata", attrs) return td @@ -312,7 +308,6 @@ def _maybe_cache_entry( cache_key: Tuple[str, bool, bool, bool], td: TensorDict, ) -> None: - """LRU-cache the static tensor fields of ``td`` under ``cache_key``.""" with self._cache_lock: if ( self.max_cache_size > 0 @@ -343,28 +338,36 @@ def _maybe_cache_entry( # ------------------------------------------------------------------ def get_metadata(self, filename: str) -> Dict: - """Return metadata without loading full sample data. - - Metadata is static per file, so the result is cached on the reader - and subsequent calls for the same ``filename`` skip the zarr open. - """ + """Return metadata (sidecar attrs + shape facts) without a full load.""" cached = self._metadata_cache.get(filename) if cached is not None: return cached filepath = self.data_path / filename - z = zarr.open(str(filepath), mode="r") - - metadata = dict(z.attrs) if hasattr(z, "attrs") else {} - flux_shape = z["scalar_flux"].shape - metadata["num_timesteps"] = flux_shape[0] if len(flux_shape) > 1 else 1 - metadata["num_cells"] = flux_shape[-1] - metadata["has_geometric_features"] = "geometric_features" in z - metadata["has_material_properties"] = "material_properties" in z - metadata["has_sim_times"] = "sim_times" in z - if "sim_times" in z: + mesh = Mesh.load(str(filepath)) + point_data = mesh.point_data + global_data = mesh.global_data + + sidecar = self._read_sidecar(filename) + metadata: Dict = dict(sidecar.get("raw_attrs", {})) + + if "scalar_flux" in point_data.keys(): + flux_shape = point_data["scalar_flux"].shape # (N, T) + metadata["num_cells"] = int(flux_shape[0]) + metadata["num_timesteps"] = int(flux_shape[1]) if len(flux_shape) > 1 else 1 + else: + metadata["num_cells"] = int(mesh.points.shape[0]) + metadata["num_timesteps"] = 1 + + metadata["has_geometric_features"] = "geometric_features" in point_data.keys() + metadata["has_material_properties"] = "material_properties" in point_data.keys() + has_sim_times = ( + "sim_times" in global_data.keys() and global_data["sim_times"].numel() > 0 + ) + metadata["has_sim_times"] = has_sim_times + if has_sim_times: try: - metadata["max_sim_time"] = float(z["sim_times"][-1]) + metadata["max_sim_time"] = float(global_data["sim_times"][-1].item()) except Exception as exc: # pragma: no cover — defensive raise ValueError( f"Failed to read sim_times tail from {filename}" @@ -375,21 +378,30 @@ def get_metadata(self, filename: str) -> Dict: # ========================================================================= -# PyTorch Dataset +# PhysicsNeMo Dataset # ========================================================================= # -# Minimal PyTorch ``Dataset`` that wraps ``ZarrDataReader`` and produces -# per-sample ``TensorDict`` outputs. The reader returns TensorDicts directly; -# this layer only glues together file selection, the preload cache, and -# per-sample metadata enrichment. - - -class RTEBaseDataset(Dataset): - """File-indexed steady-state dataset over a directory of zarr stores. - - Output of ``__getitem__`` is a ``TensorDict`` with the tensor fields the - reader returned, plus ``filename`` (``NonTensorData``), an updated - ``metadata`` ``NonTensorData`` entry (``max_timestep`` / ``max_sim_time``). +# ``RTEBaseDataset`` extends :class:`physicsnemo.datapipes.Dataset` so the +# example plugs into ``physicsnemo.datapipes.DataLoader`` (CUDA-stream-based, +# single-process, no fork). The class still owns the file-split logic and +# the in-memory flux cache; everything device-transfer / thread-prefetch +# related is inherited from the base class. + + +class RTEBaseDataset(PhysicsNeMoDataset): + """File-indexed steady-state dataset over a directory of mesh stores. + + Wraps :class:`MeshDataReader` and produces ``(TensorDict, metadata)`` + tuples per the :class:`physicsnemo.datapipes.Dataset` contract. The + metadata dict carries the source sidecar attrs plus ``filename``, + ``max_timestep``, ``max_sim_time`` and the resolved ``sim_time`` so the + rest of the pipeline can read them without unpacking ``NonTensorData``. + + The TensorDict still carries the per-sample tensor fields the reader + returned (``coordinates``, ``cell_areas``, ``scalar_flux``, etc.). + Transforms run on it in order; the trailing model adapter (e.g. + :class:`TransolverAdapter`) is wired in by the caller via the + ``transforms`` arg. """ def __init__( @@ -404,6 +416,8 @@ def __init__( load_sigma_fields: bool = True, cache_static_arrays: bool = True, max_cache_size: int = 200, + transforms: Optional[Transform | Sequence[Transform]] = None, + device: Optional[Union[str, torch.device]] = None, ): self.data_path = Path(data_path) self.case_type = case_type @@ -414,7 +428,7 @@ def __init__( self.load_geometric_features = load_geometric_features self.load_sigma_fields = load_sigma_fields - self.reader = ZarrDataReader( + reader = MeshDataReader( data_path, case_type, cache_static_arrays=cache_static_arrays, @@ -435,6 +449,8 @@ def __init__( # enabled). Values are ``dict`` mirrors of the cached tensor entries. self._memory_cache: Optional[Dict[str, Dict[str, torch.Tensor]]] = None + super().__init__(reader=reader, transforms=transforms, device=device) + # ------------------------------------------------------------------ # Split machinery # ------------------------------------------------------------------ @@ -452,7 +468,18 @@ def _load_split_from_file(self) -> List[str]: f"Available: {list(split_data['splits'].keys())}" ) filenames = split_data["splits"][self.phase] - return [f if f.endswith(".zarr") else f + ".zarr" for f in filenames] + # Split files may list basenames with or without a format suffix + # (e.g. ``.zarr`` from a legacy split). Strip any known suffix and + # append ``.mesh`` so the result always points at a mesh store. + normalized: List[str] = [] + for f in filenames: + base = f + for known in (".zarr", ".mesh"): + if base.endswith(known): + base = base[: -len(known)] + break + normalized.append(base + ".mesh") + return normalized def preload_to_memory(self, verbose: bool = True, num_workers: int = 8) -> dict: """Preload static arrays and first/final flux snapshots.""" @@ -522,9 +549,8 @@ def load_one(filename: str): def __len__(self) -> int: return len(self.filenames) - def __getitem__(self, idx: int) -> TensorDict: - filename = self.filenames[idx] - + def _read_sample(self, filename: str) -> TensorDict: + """Read one sample, honoring the in-memory flux cache when populated.""" if self._memory_cache is not None and filename in self._memory_cache: cached = self._memory_cache[filename] td = self.reader.load( @@ -546,9 +572,10 @@ def __getitem__(self, idx: int) -> TensorDict: load_sim_times=True, load_sigma_fields=self.load_sigma_fields, ) + return td - # Enrich metadata with the per-sample info transforms rely on. - # ``td["metadata"]`` is a NonTensorData dict of zarr attrs; extend it. + def _build_metadata(self, filename: str, td: TensorDict) -> Dict[str, Any]: + """Per-sample metadata dict consumed by transforms + downstream code.""" attrs = dict(td["metadata"]) if "metadata" in td else {} file_meta = self.reader.get_metadata(filename) attrs["max_timestep"] = file_meta["num_timesteps"] - 1 @@ -557,9 +584,68 @@ def __getitem__(self, idx: int) -> TensorDict: attrs["sim_time"] = float(td["sim_times"][-1].item()) else: attrs["sim_time"] = None - td.set_non_tensor("metadata", attrs) + attrs["filename"] = filename + attrs["case_type"] = self.case_type + return attrs + + def _read_one(self, idx: int) -> Tuple[TensorDict, Dict[str, Any]]: + """Reader-side hook: load CPU TensorDict + metadata for index ``idx``. + + Routes through ``_read_sample`` (honoring the in-memory flux cache) + and ``_build_metadata`` (filename / max_timestep / sim_time). The + ``filename`` and ``metadata`` NonTensorData entries are kept on the + TensorDict so transforms that read them (e.g. ``SteadyStateSampler``) + keep working unchanged. + """ + filename = self.filenames[idx] + td = self._read_sample(filename) + metadata = self._build_metadata(filename, td) td.set_non_tensor("filename", filename) - + td.set_non_tensor("metadata", metadata) + return td, metadata + + def _load(self, idx: int) -> Tuple[TensorDict, Dict[str, Any]]: + """Synchronous load: ``_read_one`` -> device -> transforms.""" + td, metadata = self._read_one(idx) + if self.target_device is not None: + td = td.to(self.target_device, non_blocking=True) + if self.transforms is not None: + td = self.transforms(td) + return td, metadata + + def _load_and_transform(self, index, stream=None): + """Stream-aware variant of ``_load`` used by the prefetch path.""" + from physicsnemo.datapipes.protocols import _PrefetchResult + + result = _PrefetchResult(index=index) + try: + td, metadata = self._read_one(index) + + if self.target_device is not None: + if stream is not None: + with torch.cuda.stream(stream): + td = td.to(self.target_device, non_blocking=True) + else: + td = td.to(self.target_device, non_blocking=True) + + if self.transforms is not None: + if stream is not None: + with torch.cuda.stream(stream): + td = self.transforms(td) + result.event = torch.cuda.Event() + result.event.record(stream) + else: + td = self.transforms(td) + + result.data = td + result.metadata = metadata + except Exception as exc: # pragma: no cover — surfaced via __getitem__ + result.error = exc + return result + + def get_transformed_sample(self, idx: int) -> TensorDict: + """Backwards-compat helper: return the transformed TensorDict only.""" + td, _ = self._load(idx) return td diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/inference.py b/examples/cfd/nuclear_engineering/radiation_transport/src/inference.py index 51d5e386bf..8897622185 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/inference.py +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/inference.py @@ -45,9 +45,10 @@ import yaml from omegaconf import DictConfig, OmegaConf from torch.amp import autocast -from torch.utils.data import DataLoader from tqdm import tqdm +from physicsnemo.datapipes import DataLoader + from dataset import load_flux_stats from loader import build_dataloaders, collate_no_padding from losses import ( @@ -751,9 +752,6 @@ def main(): default=None, help="Cap on the number of test samples (default: all).", ) - parser.add_argument( - "--num_workers", type=int, default=4, help="Test DataLoader workers." - ) parser.add_argument( "--device", type=str, default=None, help="Override torch device." ) @@ -816,7 +814,6 @@ def main(): collate_fn=collate_no_padding, phases=("test",), test_batch_size=1, - test_num_workers=args.num_workers, ) test_loader = loaders["test"] print(f"Test set size: {len(test_loader.dataset)}") diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/loader.py b/examples/cfd/nuclear_engineering/radiation_transport/src/loader.py index 8a107a538c..81a833d6da 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/loader.py +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/loader.py @@ -14,22 +14,23 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Data plumbing: TransolverAdapter, collate, datapipe orchestration, DataLoader builder. +"""Data plumbing: TransolverAdapter (Transform), collate, dataset+loader builder. -This module composes ``RTEBaseDataset`` (data source) with a ``Compose`` of -transforms and a ``TransolverAdapter``, and exposes a single -``build_dataloaders`` entry point used by ``train.py`` and ``inference.py``. +This module composes :class:`RTEBaseDataset` (a +:class:`physicsnemo.datapipes.Dataset` subclass) with a ``Compose`` of +transforms — including the trailing :class:`TransolverAdapter` Transform — +and exposes a single ``build_dataloaders`` entry point used by ``train.py`` +and ``inference.py``. Sections: -* Adapter — ``TransolverAdapter``. -* Collation — ``collate_no_padding`` (batch_size=1 unsqueeze). -* Stats / kwargs translation — ``_build_rte_dataset_kwargs``. -* Pipeline orchestration — ``RTEDataPipe`` + ``_build_transforms`` + - ``_build_rte_datapipe``. +* Adapter — :class:`TransolverAdapter` (a :class:`Transform`). +* Collation — :func:`collate_no_padding` (batch_size=1 unsqueeze). +* Stats / kwargs translation — :func:`_build_rte_dataset_kwargs`. +* Pipeline orchestration — :func:`_build_transforms` + :func:`_build_rte_dataset`. * Distributed preload barrier — file-marker rank-sequencing helpers. -* DataLoader builder — ``build_dataloaders`` + ``_make_loader`` + - ``_log_material_sanity``. +* DataLoader builder — :func:`build_dataloaders` + :func:`_make_loader` + + :func:`_log_material_sanity`. """ from __future__ import annotations @@ -49,18 +50,20 @@ List, Literal, Optional, + Sequence, Tuple, Union, ) -import numpy as np import torch import torch.distributed as torch_dist from omegaconf import DictConfig +from physicsnemo.datapipes import DataLoader from physicsnemo.datapipes.registry import register from physicsnemo.datapipes.transforms import Compose, Normalize, Scale, Translate +from physicsnemo.datapipes.transforms.base import Transform from tensordict import TensorDict -from torch.utils.data import DataLoader, Dataset, Sampler +from torch.utils.data import Dataset, Sampler from torch.utils.data.distributed import DistributedSampler from dataset import ( @@ -85,85 +88,102 @@ # Adapter # ========================================================================= # -# ``TransolverAdapter`` packages a transformed RTE TensorDict into the -# tensor dict that ``physicsnemo.models.transolver.Transolver`` expects. -# Only one model is shipped — there is no plug-in dispatcher. +# ``TransolverAdapter`` is the trailing :class:`Transform` in the RTE pipeline. +# It rewrites the field-name layout of the TensorDict to match what +# :class:`physicsnemo.models.transolver.Transolver` expects (``fx``, +# ``embedding``, ``flux_target``, ...) and drops fields the model never reads. @register("RTETransolverAdapter") -class TransolverAdapter: - """Pack a transformed RTE TensorDict into Transolver-ready tensors. +class TransolverAdapter(Transform): + """Pack a transformed RTE ``TensorDict`` into Transolver-ready fields. + + Output TensorDict keys: - Output keys: * ``fx`` — spatial coordinates (plus Fourier features when enabled). * ``embedding`` — material properties ``[sigma_a, sigma_s, sigma_t, Q]`` (or just the first three when ``include_q_in_embedding=False``). * ``flux_target`` — target flux to predict. - Extra fields (``coordinates_unnormalized``, ``material_labels``, - ``cell_areas``, ``sigma_t``, ``sigma_s``, ``sim_time``, and - ``flux_normalization_stats``) pass through when present. + Pass-through fields when present: ``coordinates_unnormalized``, + ``material_labels``, ``cell_areas``, ``sigma_t``, ``sigma_s``, + ``sim_time``, and ``flux_normalization_stats`` (NonTensorData). + + A trimmed ``metadata`` dict (timestep / filename / case_type) is also + re-attached as NonTensorData so downstream physics-loss + inference + code paths keep their existing ``batch["metadata"]`` access pattern. - The output has no batch dimension; ``collate_no_padding`` adds one. + The output has no batch dimension; :func:`collate_no_padding` adds one. """ def __init__(self, include_q_in_embedding: bool = True): + super().__init__() self.include_q_in_embedding = include_q_in_embedding - def __call__(self, data: TensorDict) -> Dict[str, torch.Tensor]: - sample = {k: data[k] for k in data.keys()} - result: Dict[str, Any] = {} + def __call__(self, data: TensorDict) -> TensorDict: + out = TensorDict({}, batch_size=data.batch_size, device=data.device) - if "coordinates" in sample: - result["fx"] = sample["coordinates"] + if "coordinates" in data: + out["fx"] = data["coordinates"] - if "physical_properties" in sample: - mat_props = sample["physical_properties"] + if "physical_properties" in data: + mat_props = data["physical_properties"] if not self.include_q_in_embedding: mat_props = mat_props[..., :3] - result["embedding"] = mat_props + out["embedding"] = mat_props - if "coordinates_unnormalized" in sample: - result["coordinates_unnormalized"] = sample["coordinates_unnormalized"] + if "coordinates_unnormalized" in data: + out["coordinates_unnormalized"] = data["coordinates_unnormalized"] - if ( - "material_properties" in sample - and sample["material_properties"] is not None - ): - result["material_labels"] = sample["material_properties"].long() + if "material_properties" in data and data["material_properties"] is not None: + out["material_labels"] = data["material_properties"].long() - if "flux_target" in sample: - flux_tgt = sample["flux_target"] + if "flux_target" in data: + flux_tgt = data["flux_target"] if flux_tgt.ndim == 1: flux_tgt = flux_tgt.unsqueeze(-1) - result["flux_target"] = flux_tgt + out["flux_target"] = flux_tgt for key in ("cell_areas", "sigma_t", "sigma_s"): - if key in sample: - result[key] = sample[key] - - if "sim_times" in sample and sample["sim_times"].numel() > 0: - result["sim_time"] = sample["sim_times"][-1].reshape(1).to(torch.float32) - elif "sim_times" in sample: - result["sim_time"] = torch.tensor([0.0], dtype=torch.float32) - - if "flux_normalization_stats" in sample: - result["flux_normalization_stats"] = sample["flux_normalization_stats"] - - metadata = sample.get("metadata") or {} - metadata_dict = { - "timestep_input": sample.get("timestep_input"), - "timestep_target": sample.get("timestep_target"), - "max_timestep": metadata.get("max_timestep"), - "filename": sample.get("filename"), - "case_type": metadata.get("case_type"), - } - result["metadata"] = {k: v for k, v in metadata_dict.items() if v is not None} + if key in data: + out[key] = data[key] + + if "sim_times" in data and data["sim_times"].numel() > 0: + out["sim_time"] = data["sim_times"][-1].reshape(1).to(torch.float32) + elif "sim_times" in data: + out["sim_time"] = torch.tensor( + [0.0], dtype=torch.float32, device=data.device + ) + + if "flux_normalization_stats" in data: + out.set_non_tensor( + "flux_normalization_stats", data["flux_normalization_stats"] + ) - return result + # Trim metadata to the keys downstream code reads. The full metadata + # dict is also delivered as the second tuple element by the dataset. + src_meta = data["metadata"] if "metadata" in data else {} + if not isinstance(src_meta, dict): + src_meta = {} + trimmed = { + "timestep_input": data["timestep_input"] + if "timestep_input" in data + else None, + "timestep_target": data["timestep_target"] + if "timestep_target" in data + else None, + "max_timestep": src_meta.get("max_timestep"), + "filename": data["filename"] if "filename" in data else None, + "case_type": src_meta.get("case_type"), + } + trimmed = {k: v for k, v in trimmed.items() if v is not None} + out.set_non_tensor("metadata", trimmed) + if "filename" in data: + out.set_non_tensor("filename", data["filename"]) + return out - def __repr__(self) -> str: - return f"{self.__class__.__name__}(include_q_in_embedding={self.include_q_in_embedding})" + def extra_repr(self) -> str: + return f"include_q_in_embedding={self.include_q_in_embedding}" # ========================================================================= @@ -173,20 +193,61 @@ def __repr__(self) -> str: # All configs use ``batch_size=1`` with a fixed ``num_spatial_points`` from # ``SpatialSampler``, so no padding is needed. ``build_dataloaders_for_training`` # enforces ``batch_size=1`` upstream, so this collate just unsqueezes the -# single sample and passes non-tensors through (default torch collate would -# choke on the ``metadata`` / ``flux_normalization_stats`` dicts). +# single sample. +# +# ``physicsnemo.datapipes.DataLoader`` passes ``list[tuple[TensorDict, dict]]`` +# into the collate function. We unpack the tuple, unsqueeze each tensor in the +# TensorDict, and merge any TD-side NonTensorData ("metadata", +# "flux_normalization_stats", "filename") plus the second-element metadata +# back into the returned dict. @register("RTECollateNoPadding") -def collate_no_padding(batch: List[Dict[str, Any]]) -> Dict[str, Any]: - """Batch-size-1 collate: unsqueeze each tensor, pass non-tensors through.""" +def collate_no_padding( + batch: Sequence[Tuple[TensorDict, Dict[str, Any]]], +) -> Dict[str, Any]: + """Batch-size-1 collate. + + Expects the :class:`physicsnemo.datapipes.DataLoader` calling convention: + ``list[tuple[TensorDict, dict]]``. Returns a plain dict (not a TensorDict) + so downstream training code can keep using ``batch["fx"]`` / ``batch["filename"]`` + etc. without unpacking. + """ assert len(batch) == 1, ( f"collate_no_padding requires batch_size=1; got {len(batch)}" ) item = batch[0] - return { - k: v.unsqueeze(0) if isinstance(v, torch.Tensor) else v for k, v in item.items() - } + # ``physicsnemo.datapipes.DataLoader`` always passes (TensorDict, dict) + # tuples through. Be defensive against accidental dict-only inputs from + # legacy callers. + if isinstance(item, tuple) and len(item) == 2: + td, metadata = item + else: + td = item + metadata = {} + + out: Dict[str, Any] = {} + if isinstance(td, TensorDict): + for key in td.keys(): + value = td[key] + if isinstance(value, torch.Tensor): + out[key] = value.unsqueeze(0) + else: + out[key] = value + elif isinstance(td, dict): + for key, value in td.items(): + out[key] = value.unsqueeze(0) if isinstance(value, torch.Tensor) else value + + # Merge the trailing metadata dict back into the batch under "metadata". + # ``filename`` is also surfaced at the top level for the legacy access + # pattern ``batch["filename"]`` used by some downstream code. + if metadata: + existing = out.get("metadata") if isinstance(out.get("metadata"), dict) else {} + merged_meta = {**metadata, **(existing or {})} + out["metadata"] = merged_meta + if "filename" in metadata and "filename" not in out: + out["filename"] = metadata["filename"] + return out # ========================================================================= @@ -194,13 +255,13 @@ def collate_no_padding(batch: List[Dict[str, Any]]) -> Dict[str, Any]: # ========================================================================= # # ``_build_rte_dataset_kwargs`` translates a Hydra config into the kwargs -# ``_build_rte_datapipe`` expects. Required keys (``flux_normalization_stats_file``, +# ``_build_rte_dataset`` expects. Required keys (``flux_normalization_stats_file``, # ``flux_clip_threshold``, ``case.split_file``) raise a clear ``KeyError`` on # direct access if missing; no manual ``None``-checking is layered on top. def _build_rte_dataset_kwargs(cfg: DictConfig) -> dict: - """Translate a Hydra config into the kwargs ``_build_rte_datapipe`` expects.""" + """Translate a Hydra config into the kwargs ``_build_rte_dataset`` expects.""" data_cfg = cfg.data use_fourier_features = data_cfg.get("use_fourier_features", False) fourier_cfg = data_cfg.get("fourier_features") if use_fourier_features else None @@ -231,81 +292,12 @@ def _build_rte_dataset_kwargs(cfg: DictConfig) -> dict: # Pipeline orchestration # ========================================================================= # -# ``RTEDataPipe`` composes ``RTEBaseDataset`` (data source) with a ``Compose`` -# of transforms and a ``TransolverAdapter`` (model adapter). -# ``_build_rte_datapipe`` is the high-level builder used by -# ``build_dataloaders``; ``compute_normalizations.py`` instantiates -# ``RTEDataPipe`` directly with a custom transform pipeline and ``adapter=None``. - - -@register("RTEDataPipe") -class RTEDataPipe(Dataset): - """``RTEBaseDataset`` + transforms + (optional) adapter, in one ``Dataset``.""" - - def __init__( - self, - data_path: Union[str, Path], - transforms: Optional[Compose] = None, - adapter: Optional[Any] = None, - case_type: Optional[Literal["lattice", "hohlraum"]] = None, - phase: Literal["train", "val", "test"] = "train", - split_file: Optional[Union[str, Path]] = None, - seed: Optional[int] = None, - cache_static_arrays: bool = True, - max_cache_size: int = 200, - ): - self.base_dataset = RTEBaseDataset( - data_path=data_path, - case_type=case_type, - phase=phase, - split_file=split_file, - seed=seed, - load_sigma_fields=True, - cache_static_arrays=cache_static_arrays, - max_cache_size=max_cache_size, - ) - self.transforms = transforms - self.adapter = adapter +# ``_build_rte_dataset`` is the high-level builder used by ``build_dataloaders``; +# ``compute_normalizations.py`` instantiates ``RTEBaseDataset`` directly with +# ``transforms=None`` to walk raw samples for stats. - def __len__(self) -> int: - return len(self.base_dataset) - - def __getitem__(self, idx: int) -> Any: - td = self.base_dataset[idx] - if self.transforms is not None: - td = self.transforms(td) - if self.adapter is not None: - return self.adapter(td) - return td - - def preload_to_memory(self, verbose: bool = True, num_workers: int = 8) -> dict: - """Preload all static arrays into main process memory. - - Workers inherit the populated cache via fork, eliminating disk I/O. - """ - return self.base_dataset.preload_to_memory( - verbose=verbose, num_workers=num_workers - ) - def get_transformed_sample(self, idx: int) -> TensorDict: - """Get sample with transforms applied but no adapter (``TensorDict``).""" - td = self.base_dataset[idx] - if self.transforms is not None: - td = self.transforms(td) - return td - - def __repr__(self) -> str: - lines = [ - f"{self.__class__.__name__}(", - f" base_dataset={self.base_dataset}", - f" transforms={self.transforms}", - f" adapter={self.adapter}", - ")", - ] - return "\n".join(lines) - - -def _build_rte_datapipe( +def _build_rte_dataset( case_type: Literal["lattice", "hohlraum"], data_path: Union[str, Path], phase: Literal["train", "val", "test"], @@ -323,8 +315,9 @@ def _build_rte_datapipe( fourier_num_frequencies: Optional[int], fourier_coord_dims: Optional[int], fourier_base_frequency: Optional[float], -) -> RTEDataPipe: - """Build the canonical training/inference RTE datapipe.""" + device: Optional[Union[str, torch.device]] = None, +) -> RTEBaseDataset: + """Build the canonical training/inference RTE dataset (transforms baked in).""" if case_type not in ("lattice", "hohlraum"): raise ValueError( f"Unknown case_type: {case_type!r}. Expected 'lattice' or 'hohlraum'." @@ -342,18 +335,20 @@ def _build_rte_datapipe( fourier_num_frequencies=fourier_num_frequencies, fourier_coord_dims=fourier_coord_dims, fourier_base_frequency=fourier_base_frequency, + include_q_in_embedding=include_q_in_embedding, ) - return RTEDataPipe( + return RTEBaseDataset( data_path=data_path, - transforms=transforms, - adapter=TransolverAdapter(include_q_in_embedding=include_q_in_embedding), case_type=case_type, phase=phase, split_file=split_file, seed=seed, + load_sigma_fields=True, cache_static_arrays=cache_static_arrays, max_cache_size=max_cache_size, + transforms=transforms, + device=device, ) @@ -370,10 +365,11 @@ def _build_transforms( fourier_num_frequencies: int, fourier_coord_dims: int, fourier_base_frequency: float, + include_q_in_embedding: bool = True, ) -> Compose: """Assemble the canonical RTE transform pipeline. - Normalization steps are delegated to PhysicsNeMo primitives: + Steps: 1. ``RTEFluxLogClip`` + ``Normalize`` — flux: log+clip, then z-score. 2. ``SteadyStateSampler`` — first snapshot input, final snapshot target. @@ -382,6 +378,7 @@ def _build_transforms( 5. ``SpatialSampler`` — always. 6. ``RTEBackupCoords`` + ``Translate`` + ``Scale`` — when normalize_coordinates. 7. ``FourierFeatures`` — when use_fourier_features. + 8. ``TransolverAdapter`` — repack into Transolver-ready fields. """ flux_stats = load_flux_stats(flux_normalization_stats_file) if abs(flux_stats["clip_threshold"] - flux_clip_threshold) > 1e-10: @@ -390,7 +387,7 @@ def _build_transforms( f"stats computed with {flux_stats['clip_threshold']}" ) - transform_list = [ + transform_list: List[Transform] = [ RTEFluxLogClip( clip_threshold=flux_clip_threshold, log_flux_mean=flux_stats["log_flux_mean"], @@ -400,7 +397,6 @@ def _build_transforms( ] transform_list.append(SteadyStateSampler()) - transform_list.append(MaterialPropertyExtractor()) material_stats_path = ( @@ -453,6 +449,10 @@ def _build_transforms( ) ) + transform_list.append( + TransolverAdapter(include_q_in_embedding=include_q_in_embedding) + ) + return Compose(transform_list) @@ -460,7 +460,7 @@ def _build_transforms( # Distributed preload barrier # ========================================================================= # -# File-marker rank-sequencing helpers used to serialize the per-rank zarr +# File-marker rank-sequencing helpers used to serialize the per-rank mesh # preload step in multi-GPU training. Sequencing avoids I/O contention and # leverages OS page cache reuse across ranks. @@ -577,7 +577,7 @@ def _distributed_preload( # ``build_dataloaders`` is the main entry point used by ``train.py`` and # ``inference.py``. It orchestrates dataset creation, distributed-preload # synchronization, material sanity logging, sampler construction, and -# per-phase ``DataLoader`` assembly. +# per-phase :class:`physicsnemo.datapipes.DataLoader` assembly. def _log_material_sanity(dataset, cfg: DictConfig, logger: logging.Logger) -> None: @@ -585,16 +585,22 @@ def _log_material_sanity(dataset, cfg: DictConfig, logger: logging.Logger) -> No if len(dataset) == 0: return sample = dataset.get_transformed_sample(0) - if "physical_properties" not in sample or sample["physical_properties"] is None: + # The trailing ``TransolverAdapter`` produces a TensorDict whose material + # info lives under ``embedding`` (sigma_a, sigma_s, sigma_t, [Q]). Fall back + # to the un-adapted ``physical_properties`` key if the adapter wasn't run. + if "embedding" in sample: + phys = sample["embedding"] + elif "physical_properties" in sample: + phys = sample["physical_properties"] + else: return - phys = sample["physical_properties"] if isinstance(phys, torch.Tensor): phys = phys.detach().cpu().numpy() sigma_a = phys[:, 0] sigma_s = phys[:, 1] - Q = phys[:, 3] + Q = phys[:, 3] if phys.shape[1] >= 4 else None logger.info("\nMaterial property ranges (first sample):") logger.info( @@ -605,7 +611,8 @@ def _log_material_sanity(dataset, cfg: DictConfig, logger: logging.Logger) -> No f" sigma_s: [{sigma_s.min():.2f}, {sigma_s.max():.2f}] " f"(unique: {len(set(sigma_s.tolist()))})" ) - logger.info(f" Q: {sorted(set(Q.tolist()))}") + if Q is not None: + logger.info(f" Q: {sorted(set(Q.tolist()))}") if len(set(sigma_a.tolist())) > 1 or len(set(sigma_s.tolist())) > 1: logger.info(f" Heterogeneous materials detected ({cfg.case.type})") else: @@ -619,46 +626,45 @@ def _make_loader( sampler: Optional[Sampler], collate_fn: Optional[Callable], test_batch_size: int, - test_num_workers: int, ) -> DataLoader: - """Assemble a ``DataLoader`` for one phase, reading per-phase config. + """Assemble a :class:`physicsnemo.datapipes.DataLoader` for one phase. The ``test`` phase has no matching ``cfg.test.*`` block; callers pass - ``test_batch_size`` / ``test_num_workers`` explicitly. + ``test_batch_size`` explicitly. Stream-based prefetching defaults + (``num_streams=4``, ``use_streams=true``) come from the per-phase + Hydra config when present. """ if phase == "test": return DataLoader( dataset, batch_size=test_batch_size, - num_workers=test_num_workers, shuffle=False, - pin_memory=False, collate_fn=collate_fn, ) phase_cfg = cfg.train.dataloader if phase == "train" else cfg.train.val.dataloader sampler_cfg = cfg.train.sampler if phase == "train" else cfg.train.val.sampler - num_workers = phase_cfg.num_workers # sampler handles shuffling when present; keep ``shuffle=False`` to avoid - # the PyTorch "sampler is incompatible with shuffle" error. + # the "sampler is incompatible with shuffle" path inside the DataLoader. shuffle_train = sampler_cfg.shuffle if phase == "train" else False shuffle = shuffle_train if sampler is None else False - kwargs = { - "batch_size": phase_cfg.batch_size, - "pin_memory": phase_cfg.pin_memory, - "num_workers": num_workers, - "shuffle": shuffle, - "drop_last": sampler_cfg.get("drop_last", False), - "sampler": sampler, - "collate_fn": collate_fn, - } - if num_workers > 0: - kwargs["prefetch_factor"] = phase_cfg.get("prefetch_factor", 2) - kwargs["persistent_workers"] = phase_cfg.get("persistent_workers", False) - - return DataLoader(dataset, **kwargs) + seed = cfg.train.get("seed", None) + seed = int(seed) if seed is not None else None + + return DataLoader( + dataset, + batch_size=phase_cfg.batch_size, + shuffle=shuffle, + drop_last=sampler_cfg.get("drop_last", False), + sampler=sampler, + collate_fn=collate_fn, + prefetch_factor=phase_cfg.get("prefetch_factor", 2), + num_streams=phase_cfg.get("num_streams", 4), + use_streams=phase_cfg.get("use_streams", True), + seed=seed, + ) class DistributedEvalSampler(Sampler[int]): @@ -685,18 +691,16 @@ def build_dataloaders( collate_fn: Optional[Callable] = None, phases: Iterable[str] = ("train", "val"), test_batch_size: int = 1, - test_num_workers: int = 0, logger: Optional[logging.Logger] = None, ) -> Tuple[Dict[str, DataLoader], Optional[DistributedSampler]]: - """Build per-phase ``DataLoader`` s for training and/or evaluation. + """Build per-phase DataLoaders for training and/or evaluation. Args: cfg: Hydra configuration (training cfg or a loaded checkpoint cfg). dist: ``DistributedManager`` for training; ``None`` for eval. - collate_fn: Collate function. Defaults to ``collate_no_padding``. + collate_fn: Collate function. Defaults to :func:`collate_no_padding`. phases: Which splits to build (subset of ``{"train", "val", "test"}``). - test_batch_size / test_num_workers: Used only when ``test`` is in - ``phases``. + test_batch_size: Used only when ``test`` is in ``phases``. logger: Optional logger; defaults to module logger. Returns: @@ -728,8 +732,17 @@ def build_dataloaders( f"Data caching: LRU max_cache_size={common_kwargs['max_cache_size']}" ) + # Pick a sensible device default. The PhysicsNeMo Dataset will move + # tensors there before transforms run; transforms then operate on GPU. + if dist is not None and getattr(dist, "device", None) is not None: + device = dist.device + else: + device = "cuda" if torch.cuda.is_available() else "cpu" + datasets = { - phase: _build_rte_datapipe(cfg.case.type, phase=phase, **common_kwargs) + phase: _build_rte_dataset( + cfg.case.type, phase=phase, device=device, **common_kwargs + ) for phase in phases } @@ -778,42 +791,14 @@ def build_dataloaders( sampler, collate_fn, test_batch_size=test_batch_size, - test_num_workers=test_num_workers, ) return loaders, train_sampler -def set_epoch_on_transforms(loader: Optional[DataLoader], epoch: int) -> None: - """Forward ``epoch`` to any transform exposing ``set_epoch``. - - Walks ``loader.dataset`` (an ``RTEDataPipe``), pulls the ``Compose`` of - transforms, and calls ``set_epoch(epoch)`` on every child that defines - one (e.g. ``SpatialSampler``). Safe no-op when the loader, dataset, or - transforms are missing. - """ - if loader is None: - return - dataset = getattr(loader, "dataset", None) - transforms = getattr(dataset, "transforms", None) - if transforms is None: - return - # ``Compose`` defines its own ``set_epoch`` that propagates to children. - set_epoch = getattr(transforms, "set_epoch", None) - if callable(set_epoch): - set_epoch(epoch) - return - # Fallback: iterate manually. - for t in transforms: - fn = getattr(t, "set_epoch", None) - if callable(fn): - fn(epoch) - - __all__ = [ "TransolverAdapter", "collate_no_padding", - "RTEDataPipe", "build_dataloaders", - "set_epoch_on_transforms", + "DistributedEvalSampler", ] diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/train.py b/examples/cfd/nuclear_engineering/radiation_transport/src/train.py index 6489e87089..f170f61537 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/train.py +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/train.py @@ -40,8 +40,8 @@ import torch.nn as nn from omegaconf import DictConfig, OmegaConf from torch.amp import GradScaler, autocast -from torch.utils.data import DataLoader +from physicsnemo.datapipes import DataLoader from physicsnemo.utils.logging.launch import LaunchLogger from checkpointing import create_training_components, resume_or_pretrain diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/trainer.py b/examples/cfd/nuclear_engineering/radiation_transport/src/trainer.py index 24f2cc2de5..2db102cf1c 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/trainer.py +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/trainer.py @@ -45,8 +45,7 @@ from omegaconf import DictConfig, OmegaConf from torch.amp import GradScaler, autocast from torch.nn.parallel import DistributedDataParallel -from torch.utils.data import DataLoader - +from physicsnemo.datapipes import DataLoader from physicsnemo.distributed import DistributedManager from physicsnemo.utils.checkpoint import save_checkpoint from physicsnemo.utils.logging.launch import LaunchLogger @@ -56,7 +55,6 @@ save_best_qoi_checkpoint, save_latest_checkpoint, ) -from loader import set_epoch_on_transforms from losses import compute_physics_loss, masked_mse_loss, region_weighted_loss_fn @@ -585,12 +583,12 @@ def run_training_loop( training_completed = False try: for epoch in range(start_epoch, cfg.train.epochs): - if train_sampler is not None: - train_sampler.set_epoch(epoch) - # Propagate epoch to spatial samplers / other stochastic transforms - # so each rank reshuffles deterministically per epoch. - set_epoch_on_transforms(train_loader, epoch) - set_epoch_on_transforms(val_loader, epoch) + # ``physicsnemo.datapipes.DataLoader.set_epoch`` propagates the + # epoch to (1) the sampler — including DistributedSampler — and + # (2) the dataset, which in turn forwards it to the reader and + # every transform in the Compose chain (e.g. ``SpatialSampler``). + train_loader.set_epoch(epoch) + val_loader.set_epoch(epoch) train_kw = dict(train_epoch_kwargs) val_kw = dict(validate_kwargs) diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/transforms.py b/examples/cfd/nuclear_engineering/radiation_transport/src/transforms.py index 6757e71281..e5c7770820 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/transforms.py +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/transforms.py @@ -316,6 +316,17 @@ def set_epoch(self, epoch: int) -> None: return self.gen.manual_seed(int(self.seed) + int(epoch) * self._EPOCH_PRIME) + def to(self, device): + """Override base ``Transform.to`` to keep ``self.gen`` on CPU. + + ``torch.randperm`` insists the generator's device match the output + tensor's device. We pin index draws on CPU and ``.to(device)`` only + the selected indices below, so leaving the generator on CPU keeps + the transform device-agnostic. + """ + self._device = torch.device(device) if isinstance(device, str) else device + return self + def __call__(self, data: TensorDict) -> TensorDict: if self.num_points == -1: return data @@ -331,7 +342,7 @@ def __call__(self, data: TensorDict) -> TensorDict: ) indices = torch.randperm(num_available, generator=self.gen)[: self.num_points] - indices = indices.to(torch.int64) + indices = indices.to(torch.int64).to(data["coordinates"].device) spatial_keys = [ "coordinates", From efb7126d55fab8994938e60de70d4982aa796f7e Mon Sep 17 00:00:00 2001 From: Carmelo Gonzales Date: Tue, 5 May 2026 13:48:14 -0700 Subject: [PATCH 26/68] docs: refresh readme --- .../radiation_transport/README.md | 144 ++++++++++++------ 1 file changed, 96 insertions(+), 48 deletions(-) diff --git a/examples/cfd/nuclear_engineering/radiation_transport/README.md b/examples/cfd/nuclear_engineering/radiation_transport/README.md index cc8e403a77..28b9b41f6f 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/README.md +++ b/examples/cfd/nuclear_engineering/radiation_transport/README.md @@ -8,7 +8,8 @@ steady-state mapping from the initial flux snapshot to the final scalar flux, using a physics-informed loss that combines region-weighted MSE with a quantity-of-interest (QoI) penalty based on absorption in key regions. -The datasets used for this example were generated using [KiT-RT](https://github.com/KiT-RT)[1]. +The datasets used for this example were generated using +[KiT-RT](https://github.com/KiT-RT) [^1]. --- @@ -55,6 +56,7 @@ logged each batch. --- ## 2. Installation + Prerequisites: - **PyTorch ≥ 2.6** — `torch.optim.Muon` is built in. Earlier PyTorch versions @@ -75,17 +77,24 @@ uv pip install -e ".[model-extras,datapipes-extras]" tensorboard ### 3.1 Data source -**TODO:** HuggingFace dataset URL. Until then, raw simulation data may be curated from the [KiT-RT repositories](https://github.com/KiT-RT). +**TODO:** HuggingFace dataset URL. Until then, raw simulation data may be +curated from the [KiT-RT repositories](https://github.com/KiT-RT). ### 3.2 Expected on-disk layout -``` +The runtime data format is the PhysicsNeMo `Mesh` memmap layout. Each +simulation lives in a `.mesh/` directory next to a `.attrs.json` +sidecar, loaded via `physicsnemo.mesh.Mesh.load(.mesh)`. + +```text / ├── lattice/ -│ ├── lattice_abs_scatter_p

_q.zarr/ +│ ├── lattice_abs_scatter_p

_q.mesh/ +│ ├── lattice_abs_scatter_p

_q.attrs.json │ └── ... ├── hohlraum/ -│ ├── hohlraum_variable_cl<...>_q<...>_ulr<...>_llr<...>_<...>.zarr/ +│ ├── hohlraum_variable_cl<...>_q<...>_ulr<...>_llr<...>_<...>.mesh/ +│ ├── hohlraum_variable_cl<...>_q<...>_ulr<...>_llr<...>_<...>.attrs.json │ └── ... ├── splits/ │ ├── lattice_splits.json # train/val/test split lists @@ -97,20 +106,38 @@ uv pip install -e ".[model-extras,datapipes-extras]" tensorboard └── hohlraum_material_stats.yaml ``` -### 3.3 What's in each zarr store +### 3.3 What's in each mesh store + +Each `*.mesh/` directory is one simulation, written by +`physicsnemo.mesh.Mesh.save(...)`. The loader uses the first and final +`scalar_flux` snapshots and ignores intermediate snapshots. The fields are: + +`Mesh.points` — `(N, 3)` float32 cell-center coordinates. + +`Mesh.point_data` (per-cell tensors): -Each `*.zarr/` directory is one simulation. The loader uses the first and final -`scalar_flux` snapshots and ignores intermediate snapshots. +| Key | Shape | Dtype | Notes | +|---|---|---|---| +| `cell_areas` | `(N,)` | float32 | per-cell areas — used by physics loss for surface integrals | +| `sigma_a`, `sigma_s`, `sigma_t` | `(N,)` | float32 | absorption / scattering / total cross-section per cell | +| `Q` | `(N,)` | float32 | heat source (non-zero in lattice; zeros in hohlraum) | +| `geometric_features` | `(N, k)` | float32 | optional per-cell geometric features | +| `material_properties` | `(N,)` | int64 | integer region IDs (consumed by `LatticeMaterialMapper` / `HohlraumMaterialMapper`) | +| `scalar_flux` | `(N, T)` | float32 | physical flux, transposed to put cells first | + +`Mesh.global_data` (per-simulation tensors): | Key | Shape | Notes | |---|---|---| -| `scalar_flux` | `(T, N)` or `(N,)` | physical flux | -| `sigma_a`, `sigma_s` | `(N,)` | absorption / scattering coefficients per cell | -| `Q` | `(N,)` | heat source (lattice only; zeros in hohlraum) | -| `coordinates` | `(N, 2)` | cell-center positions in physical units | -| `cell_areas` | `(N,)` | per-cell areas — used by physics loss for surface integrals | -| `material_labels` | `(N,)` | integer region IDs (consumed by `LatticeMaterialMapper` / `HohlraumMaterialMapper`) | -| `metadata` | dict | final simulation time, geometry params (hohlraum), filename | +| `sim_times` | `(T,)` | simulation times for each flux snapshot | +| `attr__` | scalar / `(...)` | numeric simulation attributes flattened from the source curator | + +`.attrs.json` (sidecar): +A JSON file holding `raw_attrs` (the verbatim source attrs dict — final +simulation time, geometry params, etc.) and `residue_attrs` (the +non-numeric attrs that don't fit in `global_data`). `MeshDataReader.load` +exposes `raw_attrs` as the `metadata` `NonTensorData` entry on the returned +`TensorDict`. `N` is the number of cells per simulation (~tens of thousands). Different simulations may have different `N` — point-cloud collation handles this. @@ -136,8 +163,8 @@ JSON document with a `"splits"` key: } ``` -Filenames in the splits arrays are zarr **basenames** without the `.zarr` -suffix; the reader appends it automatically when opening stores. +Filenames in the splits arrays are **basenames** without any format +suffix; the reader appends `.mesh` when opening stores. If the splits file is named with a different suffix, point at it explicitly: @@ -177,10 +204,11 @@ contains per-channel mean/std/min/max for `{σ_a, σ_s, σ_t, Q}`. ## 4. Training ### 4.1 Quick start + Full-mesh training used at least a 48 GB GPU during development (RTX6000 Ada). By default `data.preload_data=true`, so the train and validation splits are loaded into host RAM before training. Disable with `data.preload_data=false` -if RAM is tight, at the cost of slower zarr reads. +if RAM is tight, at the cost of slower per-epoch reads from disk. Lattice: @@ -207,7 +235,7 @@ torchrun --nproc_per_node=N src/train.py \ Use `torchrun` for DDP. A plain `python src/train.py ...` launch runs as a single process, even inside an allocated SLURM shell. Set `data.preload_data=true` (default) so each rank loads static arrays through a sequenced barrier; this is -faster than re-reading zarr per epoch but uses host RAM proportional to the +faster than re-reading the mesh stores per epoch but uses host RAM proportional to the training split size. ### 4.3 Common overrides @@ -219,7 +247,9 @@ training split size. | `train.amp=false` | Disable mixed precision (debug / numerical parity) | | `train.physics_loss.qoi_region=center` | Hohlraum-only: backprop on a single region. Default `all` averages the four (center, vertical, horizontal, total). | | `train.physics_loss.weight=0.0` | Pure MSE training (disables QoI penalty) | -| `train.dataloader.num_workers=4` | DataLoader workers | +| `train.dataloader.num_streams=4` | CUDA streams used by `physicsnemo.datapipes.DataLoader` for prefetch overlap (no CPU fork workers) | +| `train.dataloader.use_streams=false` | Disable CUDA-stream prefetching — useful for debugging or CPU-only runs | +| `train.dataloader.prefetch_factor=4` | How many batches to prefetch ahead | | `model.num_spatial_points=8192` | Subsample cells per training step (–1 = use all) | | `model.n_layers=12 model.n_hidden=384` | Bigger Transolver | | `model.use_te=true` | Use NVIDIA TransformerEngine layers (requires `[model-extras]`) | @@ -230,7 +260,7 @@ training split size. Per run, under `outputs/${project.name}/${case.type}/${exp_tag}/`: -``` +```text outputs/RTE_Transolver/lattice/transolver/ ├── hydra/ │ ├── config.yaml # resolved Hydra config (canonical record of the run) @@ -247,7 +277,6 @@ outputs/RTE_Transolver/lattice/transolver/ └── train.log ``` - When loading checkpoints, `best_model_epoch_/` and `top_model/` track validation loss, while `best_qoi_model/` tracks validation QoI loss. @@ -276,13 +305,12 @@ CLI options: | `--split_file FILE` | Required explicit split JSON. | | `--output_dir DIR` | Where to write metrics + figures. Default: `/evaluation`. | | `--num_samples N` | Limit to the first `N` test simulations (default: all). | -| `--num_workers N` | DataLoader workers. | | `--device {cpu,cuda,cuda:0,...}` | Defaults to CUDA if available. | | `--num_plot_samples N` | Number of `flux_panels_.png` figures to write (default: 3). | ### 5.2 Outputs -``` +```text / ├── metrics.yaml # field-level metrics over the whole test set ├── qoi_metrics.yaml # per-region QoI relative error @@ -346,18 +374,21 @@ which helps interpret global flux structure and sharp interface features. | Lattice (absorption QoI) | 0.60% | 0.23% | | Hohlraum (regional QoI) | 2.06% | 0.52–0.73% | -These observed values come from the default full-training runs with defaults configs. Training logs -converged to final validation losses of about `2.10e-05` for lattice and -`1.51e-05` for hohlraum. +These observed values come from the default full-training runs with defaults +configs. Training logs converged to final validation losses of about +`2.10e-05` for lattice and `1.51e-05` for hohlraum. ### 6.2 Reading the training log Each epoch logs train/validation MSE, QoI loss, learning rate, and checkpoint updates. A typical completed epoch looks like: -``` -Epoch 500: train_loss=1.7081e-05, val_loss=2.0973e-05, train_mse=1.7032e-05, val_mse=2.0900e-05, train_qoi=9.8040e-06, val_qoi=1.4658e-05, lr=1.00e-06 -[checkpoint][INFO] - Saved model state dictionary: ./checkpoints/Transolver.0.500.mdlus +```text +Epoch 500: train_loss=1.7081e-05, val_loss=2.0973e-05, + train_mse=1.7032e-05, val_mse=2.0900e-05, + train_qoi=9.8040e-06, val_qoi=1.4658e-05, lr=1.00e-06 +[checkpoint][INFO] - Saved model state dictionary: + ./checkpoints/Transolver.0.500.mdlus Training completed! Top validation losses: ['0.000021', '0.000021', '0.000021'] Best QoI loss: 5.887844e-06 @@ -378,7 +409,7 @@ Best QoI loss: 5.887844e-06 All training hyperparameters live under `src/conf/`, composed by Hydra: -``` +```text src/conf/ ├── config.yaml # root: composes case / data / model / train ├── case/{lattice,hohlraum}.yaml @@ -416,25 +447,42 @@ The Hydra group structure means `case=hohlraum` swaps the entire `train/base.yaml` and `model/transolver.yaml` interpolate from `${case.*}` so case-specific overrides propagate automatically. +--- + +## 8. Tests + +Pipeline regression tests live under `tests/test_pipeline.py` and exercise the +dataset / dataloader contract end-to-end against a small dev split. They skip +cleanly when the dataset isn't present. + +```bash +python -m pytest examples/cfd/nuclear_engineering/radiation_transport/tests/ -v +``` + +The tests expect a converted dev dataset at +`/home//Projects/Datasets/RTE/devset/mesh/{lattice,hohlraum}/` (12 mesh +stores per case) plus the matching `splits/` and `stats/` directories. If your +layout differs, adjust the `_DATASET_ROOT` constant at the top of the test +file. --- ## References -[1]: Kusch, J., Schotthöfer, S., Stammer, P., Wolters, J., & Xiao, T. (2023). - "KiT-RT: An extendable framework for radiative transfer and therapy." - *ACM Transactions on Mathematical Software*, **49**(4), 1–24. - - ```bibtex - @article{kitrt2023, - title = {KiT-RT: An extendable framework for radiative transfer and therapy}, - author = {Kusch, Jonas and Schotth{\"o}fer, Steffen and Stammer, Pia - and Wolters, Jannick and Xiao, Tianbai}, - journal = {ACM Transactions on Mathematical Software}, - volume = {49}, - number = {4}, - pages = {1--24}, - year = {2023}, - publisher = {ACM New York, NY} - } - ``` \ No newline at end of file +[^1]: Kusch, J., Schotthöfer, S., Stammer, P., Wolters, J., & Xiao, T. (2023). +"KiT-RT: An extendable framework for radiative transfer and therapy." +*ACM Transactions on Mathematical Software*, **49**(4), 1–24. + +```bibtex +@article{kitrt2023, + title = {KiT-RT: An extendable framework for radiative transfer and therapy}, + author = {Kusch, Jonas and Schotth{\"o}fer, Steffen and Stammer, Pia + and Wolters, Jannick and Xiao, Tianbai}, + journal = {ACM Transactions on Mathematical Software}, + volume = {49}, + number = {4}, + pages = {1--24}, + year = {2023}, + publisher = {ACM New York, NY} +} +``` From 42b7ebb389874e6a3f0509af625f628db4fbca69 Mon Sep 17 00:00:00 2001 From: Carmelo Gonzales Date: Tue, 5 May 2026 13:51:31 -0700 Subject: [PATCH 27/68] refactor: read geometry info from sidecar json --- .../radiation_transport/src/inference.py | 5 +- .../radiation_transport/src/losses.py | 68 ++++++------------- 2 files changed, 23 insertions(+), 50 deletions(-) diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/inference.py b/examples/cfd/nuclear_engineering/radiation_transport/src/inference.py index 8897622185..a98a05de85 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/inference.py +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/inference.py @@ -244,10 +244,7 @@ def compute_sample_qoi( cell_centers_t, cell_areas_t, sigma_t_t, sigma_s_t, target_t, sim_times_t ) elif case_type == "hohlraum": - filename = metadata.get("filename") - if not filename: - return None - gp = extract_geometry_params(filename) + gp = extract_geometry_params(metadata) if not gp: return None qp = evaluate_hohlraum_qoi_torch( diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/losses.py b/examples/cfd/nuclear_engineering/radiation_transport/src/losses.py index 0fb89144a3..80d04f83c5 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/losses.py +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/losses.py @@ -572,40 +572,24 @@ def compute_hohlraum_qoi_loss( return loss, details -def extract_geometry_params(filename) -> dict: - """Extract hohlraum geometry parameters from zarr filename.""" - # handle list (batched) or single string filename - if isinstance(filename, (list, tuple)): - filename = filename[0] if len(filename) > 0 else "" - - if not isinstance(filename, str): - filename = str(filename) - - # remove .zarr extension if present - filename = filename.replace(".zarr", "") - - parts = filename.split("_") - - geometry_params = {} - for part in parts: - if part.startswith("ulr"): - geometry_params["ulr"] = float(part[3:]) - elif part.startswith("llr"): - geometry_params["llr"] = float(part[3:]) - elif part.startswith("urr"): - geometry_params["urr"] = float(part[3:]) - elif part.startswith("lrr"): - geometry_params["lrr"] = float(part[3:]) - elif part.startswith("hlr"): - geometry_params["hlr"] = float(part[3:]) - elif part.startswith("hrr"): - geometry_params["hrr"] = float(part[3:]) - elif part.startswith("cx"): - geometry_params["cx"] = float(part[2:]) - elif part.startswith("cy"): - geometry_params["cy"] = float(part[2:]) - - return geometry_params +_HOHLRAUM_GEOMETRY_KEYS = ("ulr", "llr", "urr", "lrr", "hlr", "hrr", "cx", "cy") + + +def extract_geometry_params(metadata) -> dict: + """Extract hohlraum geometry parameters from sample metadata. + + Reads ``simulation_params.parameters`` out of the sidecar-derived metadata + dict and returns the 8-key geometry dict consumed by the hohlraum QoI + evaluator (``ulr, llr, urr, lrr, hlr, hrr, cx, cy``). Accepts either a + single metadata dict or a batched list of dicts. + """ + if isinstance(metadata, (list, tuple)): + metadata = metadata[0] if metadata else {} + if not isinstance(metadata, dict): + return {} + + params = metadata.get("simulation_params", {}).get("parameters", {}) + return {k: float(params[k]) for k in _HOHLRAUM_GEOMETRY_KEYS if k in params} def compute_physics_loss( @@ -648,22 +632,14 @@ def compute_physics_loss( ) elif case_type == "hohlraum": if metadata is None: - raise ValueError("hohlraum physics loss requires metadata with filename") - - if isinstance(metadata, dict): - filename = metadata.get("filename", "") - elif isinstance(metadata, list) and len(metadata) > 0: - filename = metadata[0].get("filename", "") - else: - raise ValueError( - f"hohlraum physics loss requires metadata with filename, got: {type(metadata)}" - ) + raise ValueError("hohlraum physics loss requires sample metadata") - geometry_params = extract_geometry_params(filename) + geometry_params = extract_geometry_params(metadata) if not geometry_params: raise ValueError( - f"could not extract geometry parameters from filename: {filename}" + "could not read hohlraum geometry parameters from metadata's " + "simulation_params.parameters" ) return compute_hohlraum_qoi_loss( From 1c8911b49200415ed887c89314ab339cd6b2d764 Mon Sep 17 00:00:00 2001 From: Carmelo Gonzales Date: Tue, 5 May 2026 13:58:49 -0700 Subject: [PATCH 28/68] fix: rename zarr to mesh --- .../src/compute_normalizations.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/compute_normalizations.py b/examples/cfd/nuclear_engineering/radiation_transport/src/compute_normalizations.py index 675a3ebd5e..0028b46cbc 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/compute_normalizations.py +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/compute_normalizations.py @@ -14,7 +14,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Standalone CLI to compute flux + material statistics over a zarr root. +"""Standalone CLI to compute flux + material statistics over a mesh data root. Run this once before training to produce the two YAML statistics files the training pipeline expects: @@ -79,7 +79,7 @@ def compute_flux_statistics( """Compute flux normalization statistics from the training split. Args: - data_path: path to the zarr stores for one case. + data_path: path to the mesh stores for one case. case_type: ``"lattice"`` or ``"hohlraum"``. output_file: destination YAML path. split_file: split JSON used to select the training split. @@ -166,7 +166,7 @@ def compute_flux_statistics( # Material statistics # ========================================================================= # -# Reads the precomputed sigma_a / sigma_s / sigma_t / Q fields from each zarr +# Reads the precomputed sigma_a / sigma_s / sigma_t / Q fields from each mesh # store in the training split and accumulates per-property mean / std / min / # max across all cells. Schema matches what ``load_material_stats`` expects. @@ -180,7 +180,7 @@ def compute_material_statistics( """Compute per-property material statistics from the training split. Args: - data_path: path to the zarr stores for one case. + data_path: path to the mesh stores for one case. case_type: ``"lattice"`` or ``"hohlraum"``. output_file: destination YAML path. split_file: split JSON used to select the training split. @@ -283,8 +283,8 @@ def compute_material_statistics( def _parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser( description=( - "Compute flux + material normalization statistics over a zarr root. " - "Emits two YAML files: _flux_stats.yaml and " + "Compute flux + material normalization statistics over a mesh data " + "root. Emits two YAML files: _flux_stats.yaml and " "_material_stats.yaml in the output directory." ) ) @@ -292,7 +292,7 @@ def _parse_args() -> argparse.Namespace: "--data_path", type=Path, required=True, - help="Path to the zarr root for one case (e.g. /lattice).", + help="Path to the mesh data root for one case (e.g. /lattice).", ) parser.add_argument( "--case_type", From 5309800f371ee18ac49ab34ea60a5e395c46aab4 Mon Sep 17 00:00:00 2001 From: Carmelo Gonzales Date: Tue, 5 May 2026 14:40:24 -0700 Subject: [PATCH 29/68] fix: inference flux stats required or infered from hydra --- .../radiation_transport/README.md | 6 ++- .../radiation_transport/src/inference.py | 38 ++++++++++++++----- 2 files changed, 33 insertions(+), 11 deletions(-) diff --git a/examples/cfd/nuclear_engineering/radiation_transport/README.md b/examples/cfd/nuclear_engineering/radiation_transport/README.md index 28b9b41f6f..07bc5df5b4 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/README.md +++ b/examples/cfd/nuclear_engineering/radiation_transport/README.md @@ -295,14 +295,18 @@ python src/inference.py \ --output_dir results/lattice ``` +By default, `--flux_stats_file` is read from the checkpoint's saved hydra +config — pass `--flux_stats_file ` to override. + CLI options: | Flag | Effect | |---|---| | `--checkpoint_dir DIR` | A directory containing `Transolver.0.0.mdlus` + `checkpoint.0.0.pt`. Pass either a `best_*/` snapshot dir or the run's `checkpoints/` root (where inference will use `top_model`). | -| `--data_path DIR` | The same `` you trained against. The script overrides `case.data_root` and stats paths from this. | +| `--data_path DIR` | The dataset root containing the per-case mesh stores (e.g. `/lattice/*.mesh`). | | `--case_type {lattice,hohlraum}` | Required. | | `--split_file FILE` | Required explicit split JSON. | +| `--flux_stats_file FILE` | Optional override for the flux-normalization YAML recorded in the checkpoint's hydra config. If omitted, the training-time path is reused. The matching `_material_stats.yaml` is read from the same directory. | | `--output_dir DIR` | Where to write metrics + figures. Default: `/evaluation`. | | `--num_samples N` | Limit to the first `N` test simulations (default: all). | | `--device {cpu,cuda,cuda:0,...}` | Defaults to CUDA if available. | diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/inference.py b/examples/cfd/nuclear_engineering/radiation_transport/src/inference.py index a98a05de85..06a765841c 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/inference.py +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/inference.py @@ -680,8 +680,14 @@ def _resolve_data_path( cfg: DictConfig, cli_data_path: str, split_file: Union[str, Path], + flux_stats_file: Optional[Union[str, Path]] = None, ) -> None: - """Override ``case.data_root`` and ``data.input_dir`` to the user-supplied path.""" + """Override data-source paths in the saved config to the user-supplied locations. + + ``flux_stats_file`` is optional: when ``None`` the saved Hydra config's + ``data.flux_normalization_stats_file`` is left untouched (so the path the + model was trained against is reused). + """ OmegaConf.update(cfg, "case.data_root", cli_data_path, force_add=True) case_type = cfg.case.type OmegaConf.update( @@ -693,13 +699,13 @@ def _resolve_data_path( str(Path(cli_data_path) / case_type), force_add=True, ) - flux_stats_file = Path(cli_data_path) / "stats" / f"{case_type}_flux_stats.yaml" - OmegaConf.update( - cfg, - "data.flux_normalization_stats_file", - str(flux_stats_file), - force_add=True, - ) + if flux_stats_file is not None: + OmegaConf.update( + cfg, + "data.flux_normalization_stats_file", + str(flux_stats_file), + force_add=True, + ) split_file = Path(split_file) OmegaConf.update(cfg, "case.split_file", str(split_file), force_add=True) OmegaConf.update(cfg, "data.split_file", str(split_file), force_add=True) @@ -722,7 +728,8 @@ def main(): "--data_path", type=Path, required=True, - help="Dataset root containing /, splits/, and stats/.", + help="Dataset root containing the per-case mesh stores " + "(e.g. /lattice/*.mesh).", ) parser.add_argument( "--case_type", @@ -737,6 +744,15 @@ def main(): required=True, help="Explicit train/val/test split JSON to use for evaluation.", ) + parser.add_argument( + "--flux_stats_file", + type=Path, + default=None, + help="Override the flux normalization stats YAML recorded in the " + "checkpoint's hydra config (e.g. /stats/_flux_stats.yaml). " + "If omitted, the path saved at training time is reused. The matching " + "_material_stats.yaml is read from the same directory.", + ) parser.add_argument( "--output_dir", type=Path, @@ -782,7 +798,9 @@ def main(): OmegaConf.update(cfg, "case.type", args.case_type, force_add=True) if not args.split_file.exists(): raise FileNotFoundError(f"Split file not found: {args.split_file}") - _resolve_data_path(cfg, str(args.data_path), args.split_file) + if args.flux_stats_file is not None and not args.flux_stats_file.exists(): + raise FileNotFoundError(f"Flux stats file not found: {args.flux_stats_file}") + _resolve_data_path(cfg, str(args.data_path), args.split_file, args.flux_stats_file) if cfg.model.get("num_spatial_points", -1) != -1: OmegaConf.update(cfg, "model.num_spatial_points", -1, force_add=True) From b0ebc5960724b3fed540729531018e93dd5ad35e Mon Sep 17 00:00:00 2001 From: Carmelo Gonzales Date: Wed, 6 May 2026 11:41:41 -0700 Subject: [PATCH 30/68] fix: purging zarr refs, update some npy->torch, remove legacy comments --- .../src/compute_normalizations.py | 4 +- .../src/conf/case/hohlraum.yaml | 15 +- .../radiation_transport/src/dataset.py | 25 ++- .../radiation_transport/src/inference.py | 148 ++++++++++-------- .../radiation_transport/src/loader.py | 7 +- .../radiation_transport/src/material.py | 4 +- .../radiation_transport/src/transforms.py | 11 +- 7 files changed, 120 insertions(+), 94 deletions(-) diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/compute_normalizations.py b/examples/cfd/nuclear_engineering/radiation_transport/src/compute_normalizations.py index 0028b46cbc..8b5c5f82ce 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/compute_normalizations.py +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/compute_normalizations.py @@ -64,9 +64,7 @@ # Walks the training split of ``RTEBaseDataset`` (no transforms, no # adapter). For each simulation, applies the same log-clip preprocessing # the training pipeline uses, and accumulates global mean / std / min / max -# in single precision. Output schema matches the legacy -# ``compute_flux_statistics.py`` so the existing ``load_flux_stats`` reader -# works unchanged. +# in single precision. def compute_flux_statistics( diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/conf/case/hohlraum.yaml b/examples/cfd/nuclear_engineering/radiation_transport/src/conf/case/hohlraum.yaml index 923f8d45d9..11c4f4c208 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/conf/case/hohlraum.yaml +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/conf/case/hohlraum.yaml @@ -1,6 +1,18 @@ # SPDX-FileCopyrightText: Copyright (c) 2023 - 2026 NVIDIA CORPORATION & AFFILIATES. # SPDX-FileCopyrightText: All rights reserved. # SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. # Hohlraum benchmark: axisymmetric cylindrical geometry with optional # interior void regions. No interior heat source (Q is omitted from the @@ -11,8 +23,7 @@ data_root: ??? # set by user (HF download r data_path: ${case.data_root}/hohlraum split_file: ${case.data_root}/splits/hohlraum_splits.json -# Physics-loss configuration (hohlraum-specific override; was the legacy -# `weight_hohlraum: 0.01` per-case override in the original training script). +# Physics-loss configuration (hohlraum-specific override). physics_loss_weight: 0.01 qoi_region: all # all (mean of 4) | center | vertical | horizontal | total diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/dataset.py b/examples/cfd/nuclear_engineering/radiation_transport/src/dataset.py index 42f00f2d94..35bdf8b785 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/dataset.py +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/dataset.py @@ -47,10 +47,10 @@ # ========================================================================= # # Filename-indexed reader over a directory of ``.mesh/`` memmap -# directories produced by ``convert_zarr_to_mesh.py``. Inherits from -# ``physicsnemo.datapipes.readers.base.Reader``; returns ``TensorDict`` from -# ``load()``. The TensorDict carries both the tensor fields and non-tensor -# metadata (``metadata``, ``filename``) via ``NonTensorData`` entries. +# directories. Inherits from ``physicsnemo.datapipes.readers.base.Reader``; +# returns ``TensorDict`` from ``load()``. The TensorDict carries both the +# tensor fields and non-tensor metadata (``metadata``, ``filename``) via +# ``NonTensorData`` entries. # # RTE-specific kwargs (``load_flux``, optional field loading, etc.) live on the # filename-indexed ``load(filename, ...)`` entry. The int-indexed @@ -65,7 +65,7 @@ class MeshDataReader(Reader): The ``TensorDict`` returned by ``load(filename, ...)`` carries the tensor fields RTE training and inference rely on. The on-disk format is the PhysicsNeMo ``Mesh`` memmap layout (``.mesh/`` + - ``.attrs.json`` sidecar) emitted by ``convert_zarr_to_mesh.py``. + ``.attrs.json`` sidecar). Example: >>> reader = MeshDataReader("/path/to/mesh_stores/lattice") @@ -214,9 +214,7 @@ def load( # ``Mesh.load`` returns memmap-backed tensors. The single-process # ``physicsnemo.datapipes.DataLoader`` (CUDA streams, no fork) keeps # the tensors live in this process, so we let Mesh hand back the - # memmap views directly — the prior ``.clone()`` was only needed to - # survive cross-process serialization with the legacy - # ``torch.utils.data.DataLoader``. + # memmap views directly mesh = Mesh.load(str(filepath)) point_data = mesh.point_data global_data = mesh.global_data @@ -468,16 +466,11 @@ def _load_split_from_file(self) -> List[str]: f"Available: {list(split_data['splits'].keys())}" ) filenames = split_data["splits"][self.phase] - # Split files may list basenames with or without a format suffix - # (e.g. ``.zarr`` from a legacy split). Strip any known suffix and - # append ``.mesh`` so the result always points at a mesh store. + # Split files may list basenames with or without a ``.mesh`` suffix. + # Normalize to always point at a mesh store. normalized: List[str] = [] for f in filenames: - base = f - for known in (".zarr", ".mesh"): - if base.endswith(known): - base = base[: -len(known)] - break + base = f[: -len(".mesh")] if f.endswith(".mesh") else f normalized.append(base + ".mesh") return normalized diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/inference.py b/examples/cfd/nuclear_engineering/radiation_transport/src/inference.py index 06a765841c..a05d60207c 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/inference.py +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/inference.py @@ -211,53 +211,42 @@ def aggregate_metrics(per_sample: list[Dict[str, float]]) -> Dict[str, float]: def compute_sample_qoi( - pred: np.ndarray, - target: np.ndarray, - metadata: Dict[str, Any], + pred: torch.Tensor, + target: torch.Tensor, + cell_centers: torch.Tensor, + cell_areas: torch.Tensor, + sigma_t: torch.Tensor, + sigma_s: torch.Tensor, + raw_metadata: Dict[str, Any], case_type: str, ) -> Optional[Dict[str, Dict[str, float]]]: - """Compute QoI(pred) vs QoI(target) for one sample. + """Compute QoI(pred) vs QoI(target) for one sample on the tensors' device. - Returns a dict ``{region: {predicted, ground_truth, absolute_error, - relative_error_pct}}`` or ``None`` if metadata is incomplete. + All tensor inputs may live on GPU; only the scalar QoI values are + materialized to host (via ``.item()``). Returns ``{region: {predicted, + ground_truth, absolute_error, relative_error_pct}}`` or ``None`` for the + hohlraum case when geometry params are missing from ``raw_metadata``. """ - coords = metadata.get("coordinates") - cell_areas = metadata.get("cell_areas") - sigma_t = metadata.get("sigma_t") - sigma_s = metadata.get("sigma_s") - if coords is None or cell_areas is None or sigma_t is None or sigma_s is None: - return None - - cell_centers_t = torch.from_numpy(np.asarray(coords)).float() - cell_areas_t = torch.from_numpy(np.asarray(cell_areas)).float() - sigma_t_t = torch.from_numpy(np.asarray(sigma_t)).float() - sigma_s_t = torch.from_numpy(np.asarray(sigma_s)).float() - pred_t = torch.from_numpy(np.asarray(pred)).float().reshape(1, -1) - target_t = torch.from_numpy(np.asarray(target)).float().reshape(1, -1) - sim_times_t = torch.zeros(1) # unused by the steady-state QoI + pred_t = pred.float().reshape(1, -1) + target_t = target.float().reshape(1, -1) + centers = cell_centers.float() + areas = cell_areas.float().flatten() + st = sigma_t.float().flatten() + ss = sigma_s.float().flatten() + sim_times_t = torch.zeros(1, device=pred.device) # unused by the steady-state QoI if case_type == "lattice": - qp = evaluate_lattice_qoi_torch( - cell_centers_t, cell_areas_t, sigma_t_t, sigma_s_t, pred_t, sim_times_t - ) - qt = evaluate_lattice_qoi_torch( - cell_centers_t, cell_areas_t, sigma_t_t, sigma_s_t, target_t, sim_times_t - ) + qp = evaluate_lattice_qoi_torch(centers, areas, st, ss, pred_t, sim_times_t) + qt = evaluate_lattice_qoi_torch(centers, areas, st, ss, target_t, sim_times_t) elif case_type == "hohlraum": - gp = extract_geometry_params(metadata) + gp = extract_geometry_params(raw_metadata) if not gp: return None qp = evaluate_hohlraum_qoi_torch( - cell_centers_t, cell_areas_t, sigma_t_t, sigma_s_t, pred_t, sim_times_t, gp + centers, areas, st, ss, pred_t, sim_times_t, gp ) qt = evaluate_hohlraum_qoi_torch( - cell_centers_t, - cell_areas_t, - sigma_t_t, - sigma_s_t, - target_t, - sim_times_t, - gp, + centers, areas, st, ss, target_t, sim_times_t, gp ) else: raise ValueError(f"Unknown case_type: {case_type}") @@ -594,9 +583,9 @@ def plot_qoi_error_histograms( # ========================================================================= -def _denormalize(flux_norm: torch.Tensor, stats: Dict[str, float]) -> np.ndarray: - """Apply ``denormalize_flux`` (RTEFluxLogClip + Normalize inverse).""" - return denormalize_flux(flux_norm.detach().cpu(), stats).numpy() +def _denormalize(flux_norm: torch.Tensor, stats: Dict[str, float]) -> torch.Tensor: + """Apply ``denormalize_flux`` (RTEFluxLogClip + Normalize inverse) on-device.""" + return denormalize_flux(flux_norm, stats) @torch.no_grad() @@ -605,16 +594,21 @@ def run_evaluation( dataloader: DataLoader, device: torch.device, flux_stats: Dict[str, float], + case_type: str, *, use_amp: bool = True, max_samples: Optional[int] = None, -) -> Iterator[Tuple[np.ndarray, np.ndarray, Dict[str, Any]]]: - """Yield ``(prediction, target, metadata)`` for each test sample. - - Predictions and targets are returned as flattened numpy arrays in - physical-flux units (denormalized). ``metadata`` carries the per-sample - coordinates / cell areas / sigma fields / filename needed for QoI and - plotting. +) -> Iterator[ + Tuple[np.ndarray, np.ndarray, Optional[Dict[str, Dict[str, float]]], Dict[str, Any]] +]: + """Yield ``(prediction, target, qoi, metadata)`` for each test sample. + + Predictions and targets are denormalized to physical-flux units and + returned as flattened numpy arrays for downstream pointwise metrics and + plotting. The QoI dict (or ``None``) is computed on-device before the + GPU→CPU transfer to avoid round-tripping per-mesh tensors through numpy. + ``metadata`` carries the per-sample coordinates / cell areas / sigma + fields / filename for follow-up plotting. """ model.eval() n = 0 @@ -640,33 +634,63 @@ def run_evaluation( if isinstance(stats, list): stats = stats[0] if stats else flux_stats + coords_t = batch.get("coordinates_unnormalized") + if coords_t is None: + coords_t = batch.get("fx") + cell_areas_t = batch.get("cell_areas") + sigma_t_t = batch.get("sigma_t") + sigma_s_t = batch.get("sigma_s") + raw_meta = batch.get("metadata") or {} + if isinstance(raw_meta, list): + raw_meta = raw_meta[0] if raw_meta else {} + # Batches always carry an outer batch dim of 1 (collate_no_padding). for b in range(pred.shape[0]): - pred_phys = _denormalize(pred[b].squeeze(-1), stats).flatten() - target_phys = _denormalize(target[b].squeeze(-1), stats).flatten() + pred_phys_t = _denormalize(pred[b].squeeze(-1), stats).flatten() + target_phys_t = _denormalize(target[b].squeeze(-1), stats).flatten() + + qoi: Optional[Dict[str, Dict[str, float]]] = None + if ( + coords_t is not None + and cell_areas_t is not None + and sigma_t_t is not None + and sigma_s_t is not None + ): + qoi = compute_sample_qoi( + pred_phys_t, + target_phys_t, + coords_t[b], + cell_areas_t[b], + sigma_t_t[b], + sigma_s_t[b], + raw_meta, + case_type, + ) metadata: Dict[str, Any] = {} - coords = batch.get("coordinates_unnormalized") - if coords is None: - coords = batch.get("fx") - if coords is not None: - metadata["coordinates"] = coords[b].detach().cpu().numpy() - for key in ("cell_areas", "sigma_t", "sigma_s"): - if key in batch: - metadata[key] = batch[key][b].detach().cpu().numpy().flatten() + if coords_t is not None: + metadata["coordinates"] = coords_t[b].detach().cpu().numpy() + for key, t in ( + ("cell_areas", cell_areas_t), + ("sigma_t", sigma_t_t), + ("sigma_s", sigma_s_t), + ): + if t is not None: + metadata[key] = t[b].detach().cpu().numpy().flatten() sim_time = batch.get("sim_time") if sim_time is not None: metadata["sim_time"] = float(sim_time[b].flatten()[0].item()) - - raw_meta = batch.get("metadata") or {} - if isinstance(raw_meta, list): - raw_meta = raw_meta[0] if raw_meta else {} filename = raw_meta.get("filename") if isinstance(raw_meta, dict) else None if filename: metadata["filename"] = filename n += 1 - yield pred_phys, target_phys, metadata + yield ( + pred_phys_t.detach().cpu().numpy(), + target_phys_t.detach().cpu().numpy(), + qoi, + metadata, + ) if max_samples is not None and n >= max_samples: return @@ -851,17 +875,17 @@ def main(): step = max(n_total // max(args.num_plot_samples, 1), 1) plot_indices = set(range(0, n_total, step)) - for idx, (pred, target, meta) in enumerate( + for idx, (pred, target, qoi, meta) in enumerate( run_evaluation( model, test_loader, device, flux_stats, + args.case_type, max_samples=args.num_samples, ) ): per_sample_metrics.append(compute_metrics(pred, target)) - qoi = compute_sample_qoi(pred, target, meta, args.case_type) if qoi is not None: per_sample_qoi.append(qoi) all_targets.append(target) diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/loader.py b/examples/cfd/nuclear_engineering/radiation_transport/src/loader.py index 81a833d6da..2dd649f7a2 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/loader.py +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/loader.py @@ -218,8 +218,7 @@ def collate_no_padding( ) item = batch[0] # ``physicsnemo.datapipes.DataLoader`` always passes (TensorDict, dict) - # tuples through. Be defensive against accidental dict-only inputs from - # legacy callers. + # tuples through. Be defensive against accidental dict-only inputs. if isinstance(item, tuple) and len(item) == 2: td, metadata = item else: @@ -239,8 +238,8 @@ def collate_no_padding( out[key] = value.unsqueeze(0) if isinstance(value, torch.Tensor) else value # Merge the trailing metadata dict back into the batch under "metadata". - # ``filename`` is also surfaced at the top level for the legacy access - # pattern ``batch["filename"]`` used by some downstream code. + # ``filename`` is also surfaced at the top level so downstream code can + # use ``batch["filename"]`` directly. if metadata: existing = out.get("metadata") if isinstance(out.get("metadata"), dict) else {} merged_meta = {**metadata, **(existing or {})} diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/material.py b/examples/cfd/nuclear_engineering/radiation_transport/src/material.py index 8635068308..ede031dbc8 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/material.py +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/material.py @@ -16,7 +16,7 @@ """Material-property transform for radiation-transport surrogates. -The shipped zarr stores carry precomputed cross-section fields +The shipped mesh stores carry precomputed cross-section fields (``sigma_a``, ``sigma_s``, ``sigma_t``, ``Q``) per cell. ``MaterialPropertyExtractor`` stacks them into a single ``physical_properties`` tensor of shape ``(N, 4)`` in the order ``[sigma_a, sigma_s, sigma_t, Q]``. @@ -41,7 +41,7 @@ def __call__(self, data: TensorDict) -> TensorDict: for key in ("sigma_a", "sigma_s", "sigma_t", "Q"): if key not in data: raise KeyError( - f"Zarr store is missing required field {key!r}. " + f"Mesh store is missing required field {key!r}. " "All four fields (sigma_a, sigma_s, sigma_t, Q) must be precomputed." ) diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/transforms.py b/examples/cfd/nuclear_engineering/radiation_transport/src/transforms.py index e5c7770820..f02755b4ef 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/transforms.py +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/transforms.py @@ -36,6 +36,7 @@ # Imports # ========================================================================= +import math from pathlib import Path from typing import Any, Dict, Optional, Union @@ -194,12 +195,12 @@ def extra_repr(self) -> str: GLOBAL_DOMAIN_BOUNDS = { "lattice": { - "min": np.array([-3.5, -3.5, -0.01], dtype=np.float32), - "max": np.array([3.5, 3.5, 0.01], dtype=np.float32), + "min": torch.tensor([-3.5, -3.5, -0.01], dtype=torch.float32), + "max": torch.tensor([3.5, 3.5, 0.01], dtype=torch.float32), }, "hohlraum": { - "min": np.array([-0.65, -0.65, -0.01], dtype=np.float32), - "max": np.array([0.65, 0.65, 0.01], dtype=np.float32), + "min": torch.tensor([-0.65, -0.65, -0.01], dtype=torch.float32), + "max": torch.tensor([0.65, 0.65, 0.01], dtype=torch.float32), }, } @@ -254,7 +255,7 @@ def __call__(self, data: TensorDict) -> TensorDict: coords = data["coordinates"] coords_subset = coords[:, : self.coord_dims].to(dtype=torch.float32) - two_pi = 2.0 * np.pi + two_pi = 2.0 * math.pi parts = [] for freq_mult in self.frequency_multipliers: angle = two_pi * float(freq_mult) * coords_subset From 1d4fab43d8f91a7a63d1fae2c79afe8e897235bf Mon Sep 17 00:00:00 2001 From: Carmelo Gonzales Date: Wed, 6 May 2026 11:48:35 -0700 Subject: [PATCH 31/68] fix: cleaning some old comments, imports --- .../radiation_transport/src/compute_normalizations.py | 9 --------- .../radiation_transport/src/inference.py | 11 +++++------ 2 files changed, 5 insertions(+), 15 deletions(-) diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/compute_normalizations.py b/examples/cfd/nuclear_engineering/radiation_transport/src/compute_normalizations.py index 8b5c5f82ce..7c24d1b4e6 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/compute_normalizations.py +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/compute_normalizations.py @@ -36,15 +36,11 @@ training split, read the precomputed ``sigma_a / sigma_s / sigma_t / Q`` fields from each store, and accumulate per-property (mean, std, min, max) across all cells. - -The on-disk YAML schema matches the originals so that ``load_flux_stats`` / -``load_material_stats`` in ``dataset.py`` consume them unchanged. """ from __future__ import annotations import argparse -import os import sys from pathlib import Path from typing import Dict @@ -60,11 +56,6 @@ # ========================================================================= # Flux statistics # ========================================================================= -# -# Walks the training split of ``RTEBaseDataset`` (no transforms, no -# adapter). For each simulation, applies the same log-clip preprocessing -# the training pipeline uses, and accumulates global mean / std / min / max -# in single precision. def compute_flux_statistics( diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/inference.py b/examples/cfd/nuclear_engineering/radiation_transport/src/inference.py index a05d60207c..30fc8f661b 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/inference.py +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/inference.py @@ -30,7 +30,6 @@ """ import argparse -import os from pathlib import Path from typing import Any, Dict, Iterator, Optional, Tuple, Union @@ -206,8 +205,8 @@ def aggregate_metrics(per_sample: list[Dict[str, float]]) -> Dict[str, float]: # ========================================================================= # # QoI geometry (lattice block layout, hohlraum region predicates) lives in -# ``losses.evaluate_*_qoi_torch``. Inference reuses those torch evaluators on -# (T=1, N) tensors built from the per-sample numpy metadata. +# ``losses.evaluate_*_qoi_torch``. Inference reuses those torch evaluators +# on the on-device tensors yielded by ``run_evaluation``. def compute_sample_qoi( @@ -916,7 +915,7 @@ def main(): } with open(output_dir / "metrics.yaml", "w") as f: yaml.safe_dump(metrics_out, f, sort_keys=False) - print(f"\nMetrics:") + print("\nMetrics:") for k, v in overall_metrics.items(): print(f" {k}: {v:.6e}") @@ -947,9 +946,9 @@ def main(): ) print(f"\nResults written to: {output_dir}") - print(f" metrics.yaml") + print(" metrics.yaml") if per_sample_qoi: - print(f" qoi_metrics.yaml") + print(" qoi_metrics.yaml") print(f" figures/ ({len(plot_indices)} flux panels + 2 global plots)") From 6f0cea5c55921398e955902e79f0db78c72224e0 Mon Sep 17 00:00:00 2001 From: Carmelo Gonzales Date: Tue, 12 May 2026 13:48:06 -0700 Subject: [PATCH 32/68] refactor: use only one metric for checkpointing --- .../radiation_transport/src/checkpointing.py | 702 ++++-------------- 1 file changed, 126 insertions(+), 576 deletions(-) diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/checkpointing.py b/examples/cfd/nuclear_engineering/radiation_transport/src/checkpointing.py index 61ddc39bba..bf059d7a1d 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/checkpointing.py +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/checkpointing.py @@ -14,97 +14,59 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Checkpointing, optimizer factory, and training-state setup. - -Consolidates three concerns that all sit at the boundary between "model" and -"training loop": - -* Optimizer construction (Adam + optional Muon hybrid). -* Best / best-QoI checkpoint management (save, prune, top-model symlink). -* Training-state assembly (`create_training_components`) and resume/pretrain - loading (`resume_or_pretrain`). - -DDP / seeding / batch-size logging helpers live in ``trainer.py``. -""" - -import logging import math -import os import shutil from pathlib import Path -from typing import Any, Dict, List, Literal, Optional, Tuple +from typing import Any, Dict, Tuple, Union -import numpy as np +import hydra import torch import torch.nn as nn -from omegaconf import DictConfig -from torch.amp import GradScaler -from torch.utils.tensorboard import SummaryWriter +from omegaconf import DictConfig, OmegaConf from physicsnemo.distributed import DistributedManager from physicsnemo.optim import CombinedOptimizer from physicsnemo.utils.checkpoint import load_checkpoint -from physicsnemo.utils.logging.launch import LaunchLogger - -# Sibling import: scheduler factory lives in losses.py. -from losses import create_scheduler - - -# ========================================================================= -# Optimizers -# ========================================================================= def create_optimizer( model: nn.Module, - optimizer_type: Literal["adam", "muon"] = "adam", + optimizer_type: str = "adam", learning_rate: float = 1e-3, weight_decay: float = 0.0, muon_momentum_beta: float = 0.95, - muon_lr: Optional[float] = None, logger=None, ) -> torch.optim.Optimizer: """Create optimizer based on configuration. - For ``optimizer_type='muon'`` returns a hybrid optimizer that uses Muon for - 2D weight matrices and Adam for 1D parameters (biases, layer norms, etc.). - Muon only supports 2D weight matrices, hence the split. - - Args: - model: The model to optimize. - optimizer_type: ``'adam'`` or ``'muon'``. - learning_rate: Learning rate for Adam (and for 1D params when using Muon). - weight_decay: Weight decay coefficient. - muon_momentum_beta: Momentum beta for the Muon optimizer. - muon_lr: Learning rate for Muon (defaults to ``learning_rate`` if ``None``). - logger: Optional logger for info messages. - - Returns: - Configured optimizer. + For ``optimizer_type='muon'`` returns a hybrid: Muon for 2D weight + matrices, AdamW for 1D params (biases, layer norms, embeddings). Muon + only supports 2D weight matrices, hence the split. The shared + ``learning_rate`` drives both halves because Muon is constructed with + ``adjust_lr_fn='match_rms_adamw'``. """ - if optimizer_type == "adam": - optimizer = torch.optim.Adam( - model.parameters(), - lr=learning_rate, - weight_decay=weight_decay, - ) - if logger: - logger.info( - f"Using Adam optimizer with lr={learning_rate}, weight_decay={weight_decay}" - ) - return optimizer + if optimizer_type not in ("adam", "muon"): + raise ValueError(f"Unknown optimizer type: {optimizer_type}") - elif optimizer_type == "muon": + if optimizer_type == "muon": return _create_muon_optimizer( model=model, learning_rate=learning_rate, weight_decay=weight_decay, muon_momentum_beta=muon_momentum_beta, - muon_lr=muon_lr, logger=logger, ) - raise ValueError(f"Unknown optimizer type: {optimizer_type}") + optimizer = torch.optim.Adam( + model.parameters(), + lr=learning_rate, + weight_decay=weight_decay, + ) + if logger: + logger.info( + f"Using Adam optimizer with lr={learning_rate}, weight_decay={weight_decay}" + ) + return optimizer def _create_muon_optimizer( @@ -112,528 +74,115 @@ def _create_muon_optimizer( learning_rate: float, weight_decay: float, muon_momentum_beta: float, - muon_lr: Optional[float], logger=None, ) -> torch.optim.Optimizer: - """Create a hybrid Muon + Adam optimizer. - - Muon is used for 2D weight matrices, Adam is used for 1D parameters - (biases, layer norms, etc.) and embeddings. + """Build a Muon + AdamW combined optimizer (Muon for 2D, AdamW for the rest). - Uses ``torch.optim.Muon`` (PyTorch's built-in Newton-Schulz orthogonalized - optimizer for 2-D hidden-layer weights). Available since PyTorch 2.6. + Requires PyTorch >= 2.9 for ``torch.optim.Muon`` with the + ``adjust_lr_fn`` argument. """ - try: - from torch.optim import Muon - except ImportError as e: + if not hasattr(torch.optim, "Muon"): raise ImportError( - "torch.optim.Muon was not found. Upgrade to PyTorch >= 2.6 " - "(verified working with 2.9)." - ) from e - - muon_lr = muon_lr if muon_lr is not None else learning_rate - - # Separate parameters by dimensionality. - muon_params = [] - adam_params = [] - - for name, param in model.named_parameters(): - if not param.requires_grad: - continue - - if param.ndim == 2: - muon_params.append(param) - else: - adam_params.append(param) + "Muon optimizer requires PyTorch >= 2.9. " + "Install a newer PyTorch or use optimizer.type=adam." + ) + base_model = model.module if hasattr(model, "module") else model + muon_params = [p for p in base_model.parameters() if p.ndim == 2] + other_params = [p for p in base_model.parameters() if p.ndim != 2] if logger: logger.info( - f"Muon optimizer: {len(muon_params)} 2D params, {len(adam_params)} other params" + f"Muon optimizer: {len(muon_params)} 2D params, " + f"{len(other_params)} other params, lr={learning_rate}" ) - optimizers: List[torch.optim.Optimizer] = [] - - if muon_params: - # torch.optim.Muon uses ``momentum=`` (the original emerging-optimizers - # implementation called the same hyperparameter ``momentum_beta``); we - # keep ``muon_momentum_beta`` as the config key for continuity and map - # it through here. - muon_optimizer = Muon( + muon = ( + torch.optim.Muon( muon_params, - lr=muon_lr, + lr=learning_rate, momentum=muon_momentum_beta, weight_decay=weight_decay, + adjust_lr_fn="match_rms_adamw", ) - optimizers.append(muon_optimizer) - if logger: - logger.info(f"Muon: lr={muon_lr}, momentum={muon_momentum_beta}") - - if adam_params: - adam_optimizer = torch.optim.Adam( - adam_params, + if muon_params + else None + ) + adamw = ( + torch.optim.AdamW( + other_params, lr=learning_rate, weight_decay=weight_decay, ) - optimizers.append(adam_optimizer) - if logger: - logger.info(f"Adam (for 1D params): lr={learning_rate}") - - if len(optimizers) == 1: - return optimizers[0] - - return CombinedOptimizer(optimizers) - - -# ========================================================================= -# Save / load checkpoints -# ========================================================================= - -# Maximum number of best checkpoints to keep (by validation loss). -MAX_BEST_CHECKPOINTS = 3 - -# Folder name for the single best model (lowest validation loss). -TOP_MODEL_DIR = "top_model" - -# Folder name for the best model by QoI relative error. -BEST_QOI_MODEL_DIR = "best_qoi_model" - -# Folder name for the latest full training-state checkpoint. -LATEST_CHECKPOINT_DIR = "latest_checkpoint" - - -def _capture_rng_state() -> Dict[str, Any]: - """Snapshot torch / numpy RNGs for exact-reproducible resume.""" - state: Dict[str, Any] = { - "torch": torch.get_rng_state(), - "numpy": np.random.get_state(), - } - if torch.cuda.is_available(): - state["torch_cuda_all"] = torch.cuda.get_rng_state_all() - return state - - -def _require_checkpoint_files(path: Path, *, require_training_state: bool) -> None: - """Raise FileNotFoundError if expected checkpoint files are missing.""" - pt_files = list(path.glob("*.pt")) - mdlus_files = list(path.glob("*.mdlus")) - missing = [] - if require_training_state and not pt_files: - missing.append("*.pt (training state)") - if not mdlus_files: - missing.append("*.mdlus (model state)") - if missing: - present = [f.name for f in path.iterdir() if f.is_file()] - raise FileNotFoundError( - f"Checkpoint at {path} is incomplete; missing {missing}. " - f"Files present: {present}" - ) - - -def _checkpoint_kwargs_with_metadata( - checkpoint_kwargs: Dict[str, Any], **metadata_updates -) -> Dict[str, Any]: - """Return checkpoint kwargs with selected metadata keys refreshed. - - Always refreshes ``rng_state`` so every save automatically captures the - current torch / numpy RNG state for reproducible resume. - """ - updated_kwargs = dict(checkpoint_kwargs) - metadata = dict(updated_kwargs.get("metadata") or {}) - metadata.update(metadata_updates) - metadata["rng_state"] = _capture_rng_state() - updated_kwargs["metadata"] = metadata - return updated_kwargs - - -def save_latest_checkpoint( - checkpoint_dir: Path, - epoch: int, - save_checkpoint_fn, - logger: logging.Logger = None, - **checkpoint_kwargs, -) -> Path: - """Replace ``latest_checkpoint`` with the most recent training state. - - This checkpoint is meant for robust resume, not model selection. It is - overwritten at the caller's cadence, usually every epoch. - - Args: - checkpoint_dir: Directory containing checkpoint subdirectories. - epoch: Current epoch number. - save_checkpoint_fn: Function to save the checkpoint. - logger: Optional logger. - **checkpoint_kwargs: Additional arguments forwarded to ``save_checkpoint_fn``. - - Returns: - Path to the refreshed ``latest_checkpoint`` directory. - """ - checkpoint_dir = Path(checkpoint_dir) - latest_path = checkpoint_dir / LATEST_CHECKPOINT_DIR - tmp_path = checkpoint_dir / f".{LATEST_CHECKPOINT_DIR}.tmp" - - if tmp_path.exists(): - shutil.rmtree(tmp_path) - tmp_path.mkdir(parents=True, exist_ok=True) - - # Route through metadata helper so every latest checkpoint captures the - # current RNG state for reproducible resume. - checkpoint_kwargs = _checkpoint_kwargs_with_metadata(checkpoint_kwargs) - - save_checkpoint_fn(path=str(tmp_path), epoch=epoch, **checkpoint_kwargs) - - if latest_path.exists(): - shutil.rmtree(latest_path) - tmp_path.rename(latest_path) - - if logger: - logger.info(f" Updated latest checkpoint (epoch {epoch})") + if other_params + else None + ) - return latest_path + if muon and adamw: + return CombinedOptimizer([muon, adamw]) + return muon or adamw def save_best_checkpoint( checkpoint_dir: Path, - epoch: int, val_loss: float, - best_val_losses: List[Tuple[float, int]], + best_val_loss: float, save_checkpoint_fn, - logger: logging.Logger = None, + logger=None, **checkpoint_kwargs, -) -> List[Tuple[float, int]]: - """Save checkpoint if it's in top-N best models, and clean up old checkpoints. - - Also maintains a ``top_model`` folder containing the single best model by - validation loss. - - Args: - checkpoint_dir: Directory to save checkpoints. - epoch: Current epoch number. - val_loss: Current validation loss. - best_val_losses: List of ``(loss, epoch)`` tuples for best models - (returned with any updates applied). - save_checkpoint_fn: Function to call to save the checkpoint - (e.g. PhysicsNeMo's ``save_checkpoint``). - logger: Optional logger for log messages. - **checkpoint_kwargs: Additional arguments forwarded to ``save_checkpoint_fn``. - - Returns: - Updated list of ``(loss, epoch)`` tuples. - """ - checkpoint_dir = Path(checkpoint_dir) +) -> float: + """Save a single ``best_model/`` checkpoint when ``val_loss`` improves. + Returns the (possibly unchanged) ``best_val_loss``. Skips with a warning + when ``val_loss`` is not finite, and is a no-op when the current loss does + not beat the previous best. + """ if not math.isfinite(float(val_loss)): if logger: logger.warning( - " Skipping best-checkpoint save for epoch %s: non-finite val_loss=%s", - epoch, - val_loss, + " Skipping best-checkpoint save: non-finite val_loss=%s", val_loss ) - return list(best_val_losses) - - best_val_losses = list(best_val_losses) - - # Check whether this is a top-N model. - current_losses = [loss for loss, _ in best_val_losses] - is_top_n = len(best_val_losses) < MAX_BEST_CHECKPOINTS or val_loss < max( - current_losses - ) - - if not is_top_n: - return best_val_losses + return best_val_loss - updated_best_val_losses = best_val_losses + [(val_loss, epoch)] - updated_best_val_losses.sort(key=lambda x: x[0]) # Sort by loss. + if val_loss >= best_val_loss: + return best_val_loss - pruned_epochs = [] - while len(updated_best_val_losses) > MAX_BEST_CHECKPOINTS: - _worst_loss, worst_epoch = updated_best_val_losses.pop() - pruned_epochs.append(worst_epoch) - - checkpoint_kwargs = _checkpoint_kwargs_with_metadata( - checkpoint_kwargs, - best_val_losses=updated_best_val_losses, - ) - - # Save new best model to epoch-specific directory. - best_model_dir = checkpoint_dir / f"best_model_epoch_{epoch}" + checkpoint_dir = Path(checkpoint_dir) + best_model_dir = checkpoint_dir / "best_model" + if best_model_dir.exists(): + shutil.rmtree(best_model_dir) best_model_dir.mkdir(parents=True, exist_ok=True) + epoch = checkpoint_kwargs.pop("epoch") save_checkpoint_fn(path=str(best_model_dir), epoch=epoch, **checkpoint_kwargs) - for worst_epoch in pruned_epochs: - cleanup_checkpoint_by_epoch(checkpoint_dir, worst_epoch, logger) - - # Update top_model folder if this is the new best. - _update_top_model( - checkpoint_dir, - val_loss, - epoch, - updated_best_val_losses, - save_checkpoint_fn, - logger, - **checkpoint_kwargs, - ) - if logger: logger.info( - f" Saved top-{MAX_BEST_CHECKPOINTS} model! Val loss: {val_loss:.6f}" + f" New best model! val_loss={val_loss:.6f} (prev best: {best_val_loss:.6f})" ) - loss_strs = [f"{loss:.6f}" for loss, _ in updated_best_val_losses[:3]] - logger.info(f" Top 3 losses: {loss_strs}") - - return updated_best_val_losses + return float(val_loss) -def _update_top_model( - checkpoint_dir: Path, - val_loss: float, - epoch: int, - best_val_losses: List[Tuple[float, int]], - save_checkpoint_fn, - logger: logging.Logger = None, - **checkpoint_kwargs, -) -> None: - """Update the ``top_model`` folder if this epoch has the lowest val loss. - - Args: - checkpoint_dir: Directory containing checkpoints. - val_loss: Current validation loss. - epoch: Current epoch number. - best_val_losses: List of ``(loss, epoch)`` tuples sorted by loss (best first). - save_checkpoint_fn: Function to save the checkpoint. - logger: Optional logger. - **checkpoint_kwargs: Additional arguments forwarded to ``save_checkpoint_fn``. - """ - if not best_val_losses: - return - - # Check whether the current epoch is the best (first in sorted list). - best_loss, best_epoch = best_val_losses[0] - if epoch != best_epoch: - return # Not the best — nothing to update. - - checkpoint_dir = Path(checkpoint_dir) - top_model_path = checkpoint_dir / TOP_MODEL_DIR - - # Remove any existing top_model folder. - if top_model_path.exists(): - shutil.rmtree(top_model_path) - - # Save the best model directly to the top_model folder. - top_model_path.mkdir(parents=True, exist_ok=True) - save_checkpoint_fn(path=str(top_model_path), epoch=epoch, **checkpoint_kwargs) - - if logger: - logger.info(f" Updated top_model (epoch {epoch}, val_loss: {val_loss:.6f})") - - -def save_best_qoi_checkpoint( - checkpoint_dir: Path, - epoch: int, - qoi_error: float, - best_qoi_error: float, - save_checkpoint_fn, - logger: logging.Logger = None, - **checkpoint_kwargs, -) -> float: - """Save checkpoint if QoI loss improved. - - Maintains a single ``best_qoi_model`` folder with the model that achieved - the lowest QoI loss during training. - - Args: - checkpoint_dir: Directory to save checkpoints. - epoch: Current epoch number. - qoi_error: Current QoI loss value (lower is better). - best_qoi_error: Previous best QoI loss value. - save_checkpoint_fn: Function to save the checkpoint. - logger: Optional logger. - **checkpoint_kwargs: Additional arguments forwarded to ``save_checkpoint_fn``. - - Returns: - Updated best QoI loss value. - """ - if not math.isfinite(float(qoi_error)): - if logger: - logger.warning( - " Skipping best-QoI checkpoint save for epoch %s: non-finite " - "qoi_error=%s", - epoch, - qoi_error, - ) - return best_qoi_error - - if qoi_error >= best_qoi_error: - return best_qoi_error - - checkpoint_dir = Path(checkpoint_dir) - qoi_model_path = checkpoint_dir / BEST_QOI_MODEL_DIR - - if qoi_model_path.exists(): - shutil.rmtree(qoi_model_path) - - checkpoint_kwargs = _checkpoint_kwargs_with_metadata( - checkpoint_kwargs, - best_qoi_loss=qoi_error, - ) - - qoi_model_path.mkdir(parents=True, exist_ok=True) - save_checkpoint_fn(path=str(qoi_model_path), epoch=epoch, **checkpoint_kwargs) - - if logger: - logger.info( - f" New best QoI model! epoch={epoch}, " - f"qoi_loss={qoi_error:.6e} (prev best: {best_qoi_error:.6e})" - ) - - return qoi_error - - -def cleanup_checkpoint_by_epoch( - checkpoint_dir: Path, epoch: int, logger: logging.Logger = None -) -> None: - """Remove the checkpoint directory for a specific epoch. - - Args: - checkpoint_dir: Directory containing checkpoints. - epoch: Epoch number of the checkpoint to remove. - logger: Optional logger for log messages. - """ - checkpoint_dir = Path(checkpoint_dir) - target_dir = checkpoint_dir / f"best_model_epoch_{epoch}" - - if target_dir.exists(): - shutil.rmtree(target_dir) - if logger: - logger.info(f" Removed old checkpoint: {target_dir.name}") - - -# ========================================================================= -# Training-state setup -# ========================================================================= - - -def create_training_components( - cfg: DictConfig, - model: nn.Module, - dist: DistributedManager, - logger: Any, - tensorboard: bool = True, -) -> Tuple[ - torch.optim.Optimizer, - Any, - GradScaler, - Optional[SummaryWriter], - str, - List, -]: - """Create optimizer, scheduler, scaler, TensorBoard writer, and checkpoint dir. - - Also initializes ``LaunchLogger`` and returns an empty ``best_val_losses`` list - that the training loop can hand to :func:`save_best_checkpoint`. - - Args: - cfg: Hydra configuration. - model: The model (possibly DDP-wrapped). - dist: ``DistributedManager`` instance. - logger: Logger for rank-0 messages. - tensorboard: Whether to create a TensorBoard writer (default ``True``). - - Returns: - ``(optimizer, scheduler, scaler, writer, checkpoint_dir, best_val_losses)``. - """ - optimizer_cfg = cfg.train.get("optimizer", {}) - optimizer_type = optimizer_cfg.get("type", "adam") - weight_decay = optimizer_cfg.get("weight_decay", cfg.train.get("weight_decay", 0.0)) - muon_momentum_beta = optimizer_cfg.get("muon_momentum_beta", 0.95) - muon_lr = optimizer_cfg.get("muon_lr", None) - - optimizer = create_optimizer( - model=model, - optimizer_type=optimizer_type, - learning_rate=cfg.train.learning_rate, - weight_decay=weight_decay, - muon_momentum_beta=muon_momentum_beta, - muon_lr=muon_lr, - logger=logger if dist.rank == 0 else None, - ) - - scheduler = create_scheduler(cfg, optimizer, logger if dist.rank == 0 else None) - - # GradScaler is only needed for FP16 AMP. For BF16 (and for amp=false), - # we disable scaling to avoid overhead and potential instability. - amp_enabled = bool(cfg.train.get("amp", False)) - amp_dtype = str(cfg.train.get("amp_dtype", "fp16")).lower() - scaler_enabled = amp_enabled and amp_dtype in ("fp16", "float16") - scaler = GradScaler(enabled=scaler_enabled) - - LaunchLogger.initialize(use_wandb=False, use_mlflow=False) - - writer = None - if tensorboard and dist.rank == 0: - writer = SummaryWriter(os.path.join(cfg.output, "tensorboard")) - - checkpoint_dir = os.path.join(cfg.output, "checkpoints") - os.makedirs(checkpoint_dir, exist_ok=True) - - best_val_losses: List = [] - - return optimizer, scheduler, scaler, writer, checkpoint_dir, best_val_losses - - -def resume_or_pretrain( +def resume_if_available( cfg: DictConfig, model: nn.Module, optimizer: torch.optim.Optimizer, scheduler: Any, - scaler: GradScaler, + scaler: Any, dist: DistributedManager, logger: Any, -) -> Tuple[int, List, float]: - """Handle checkpoint resume or pretrain weight loading. - - * If ``cfg.train.resume_checkpoint`` exists, loads full training state - (model, optimizer, scheduler, scaler) and returns the next epoch. - * Else if ``cfg.train.pretrain_checkpoint`` exists, loads model weights only - (optimizer/scheduler stay fresh) for fine-tuning from epoch 0. - * Otherwise returns epoch 0 with an empty ``best_val_losses`` list and - ``best_qoi_loss = +inf``. - - Args: - cfg: Hydra config. - model: Model (possibly DDP-wrapped). - optimizer: Optimizer. - scheduler: LR scheduler. - scaler: ``GradScaler``. - dist: ``DistributedManager``. - logger: Logger. - - Returns: - ``(start_epoch, best_val_losses, best_qoi_loss)``. - """ - start_epoch = 0 - best_val_losses: List = [] - best_qoi_loss = float("inf") +) -> Tuple[int, float]: + """Resume full training state or load pretrain weights, if configured. + Returns ``(start_epoch, best_val_loss)``. PhysicsNeMo's ``load_checkpoint`` + raises on missing files, so no pre-validation is performed here. + """ resume_checkpoint = cfg.train.get("resume_checkpoint", None) pretrain_checkpoint = cfg.train.get("pretrain_checkpoint", None) if resume_checkpoint: resume_path = Path(str(resume_checkpoint)) - if not resume_path.exists(): - raise FileNotFoundError( - f"train.resume_checkpoint does not exist: {resume_path}" - ) - if not resume_path.is_dir(): - raise NotADirectoryError( - "train.resume_checkpoint must be a checkpoint directory, " - f"not a file: {resume_path}" - ) - _require_checkpoint_files(resume_path, require_training_state=True) - if dist.rank == 0: logger.info(f"\nResuming from checkpoint: {resume_path}") - metadata: Dict[str, Any] = {} start_epoch = load_checkpoint( path=str(resume_path), @@ -644,63 +193,64 @@ def resume_or_pretrain( metadata_dict=metadata, device=dist.device, ) - - if "best_val_losses" in metadata: - best_val_losses = metadata["best_val_losses"] - if "best_qoi_loss" in metadata: - best_qoi_loss = metadata["best_qoi_loss"] - - rng_state = metadata.get("rng_state") - if rng_state: - if "torch" in rng_state: - torch.set_rng_state(rng_state["torch"].cpu().to(torch.uint8)) - if "numpy" in rng_state: - np.random.set_state(rng_state["numpy"]) - if "torch_cuda_all" in rng_state and torch.cuda.is_available(): - cuda_states = [ - s.cpu().to(torch.uint8) for s in rng_state["torch_cuda_all"] - ] - torch.cuda.set_rng_state_all(cuda_states) - if dist.rank == 0: - logger.info(" RNG state restored from checkpoint") - + best_val_loss = float(metadata.get("best_val_loss", float("inf"))) if dist.rank == 0: logger.info(f" Resumed from epoch {start_epoch}") - if best_val_losses: - loss_strs = [f"{loss:.6f}" for loss, _ in best_val_losses[:3]] - logger.info(f" Top val losses: {loss_strs}") - if best_qoi_loss < float("inf"): - logger.info(f" Best QoI loss: {best_qoi_loss:.6e}") - - start_epoch += 1 + if best_val_loss < float("inf"): + logger.info(f" Best val_loss: {best_val_loss:.6f}") + return start_epoch + 1, best_val_loss - elif pretrain_checkpoint: + if pretrain_checkpoint: pretrain_path = Path(str(pretrain_checkpoint)) - if not pretrain_path.exists(): - raise FileNotFoundError( - f"train.pretrain_checkpoint does not exist: {pretrain_path}" - ) - if not pretrain_path.is_dir(): - raise NotADirectoryError( - "train.pretrain_checkpoint must be a checkpoint directory, " - f"not a file: {pretrain_path}" - ) - _require_checkpoint_files(pretrain_path, require_training_state=False) - if dist.rank == 0: logger.info( f"\nLoading pretrained weights for fine-tuning: {pretrain_path}" ) + load_checkpoint(path=str(pretrain_path), models=model, device=dist.device) + if dist.rank == 0: + logger.info(" Pretrained weights loaded; starting from epoch 0") + return 0, float("inf") - load_checkpoint( - path=str(pretrain_path), - models=model, - device=dist.device, - ) + return 0, float("inf") - if dist.rank == 0: - logger.info(" Pretrained weights loaded successfully") - logger.info(" Optimizer and scheduler reset for fine-tuning") - logger.info(" Starting from epoch 0") - return start_epoch, best_val_losses, best_qoi_loss +def load_model_from_checkpoint( + checkpoint_path: Union[str, Path], + cfg: DictConfig, + device: torch.device, +) -> Tuple[nn.Module, Dict[str, Any]]: + """Build the Transolver model from cfg.model and load weights from checkpoint_path. + + The caller supplies the full Hydra cfg (so the model definition is + fully controlled by the inference-time config, not pulled from a + saved training-time snapshot). The checkpoint_path must contain + matching ``checkpoint.0.*.pt`` + ``Transolver.0.*.mdlus`` shards. + + Returns (model in eval mode, metadata dict from the checkpoint). + """ + checkpoint_path = Path(checkpoint_path) + if not checkpoint_path.exists(): + raise FileNotFoundError(f"Checkpoint directory not found: {checkpoint_path}") + + # Build model from cfg.model. Strip RTE-specific keys consumed elsewhere. + cfg_model = OmegaConf.to_container(cfg.model, resolve=True) + for k in ("num_spatial_points", "include_q_in_embedding"): + cfg_model.pop(k, None) + model = hydra.utils.instantiate(cfg_model).to(device) + + metadata: Dict[str, Any] = {} + epoch = load_checkpoint( + path=str(checkpoint_path), + models=model, + metadata_dict=metadata, + device=device, + ) + metadata.setdefault("epoch", epoch) + + model.eval() + print( + f"Loaded model from {checkpoint_path} " + f"(epoch={metadata.get('epoch', '?')}, " + f"params={sum(p.numel() for p in model.parameters()):,})" + ) + return model, metadata From e194e63601b96ea2f5ebf014788892741363ae0c Mon Sep 17 00:00:00 2001 From: Carmelo Gonzales Date: Tue, 12 May 2026 13:49:22 -0700 Subject: [PATCH 33/68] refactor: clearing out stale code, comments, and duplicate caching --- .../radiation_transport/src/dataset.py | 485 +++--------------- 1 file changed, 76 insertions(+), 409 deletions(-) diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/dataset.py b/examples/cfd/nuclear_engineering/radiation_transport/src/dataset.py index 35bdf8b785..a757e284ad 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/dataset.py +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/dataset.py @@ -14,21 +14,10 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""RTE data-source layer: mesh reader, PhysicsNeMo Dataset, and stats loaders. - -This module is the bottom of the data dependency tree. It provides the -low-level Mesh access (``MeshDataReader``), a thin file-indexed -``physicsnemo.datapipes.Dataset`` subclass (``RTEBaseDataset``), and -helpers for reading the RTE-specific YAML statistics files into -PhysicsNeMo ``Normalize`` kwargs. -""" - from __future__ import annotations import json -import threading import warnings -from collections import OrderedDict from pathlib import Path from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple, Union @@ -42,30 +31,14 @@ from tensordict import TensorDict -# ========================================================================= -# Mesh reader -# ========================================================================= -# -# Filename-indexed reader over a directory of ``.mesh/`` memmap -# directories. Inherits from ``physicsnemo.datapipes.readers.base.Reader``; -# returns ``TensorDict`` from ``load()``. The TensorDict carries both the -# tensor fields and non-tensor metadata (``metadata``, ``filename``) via -# ``NonTensorData`` entries. -# -# RTE-specific kwargs (``load_flux``, optional field loading, etc.) live on the -# filename-indexed ``load(filename, ...)`` entry. The int-indexed -# ``_load_sample(index)`` required by the PhysicsNeMo ``Reader`` contract uses -# defaults. - - @register("RTEMeshReader") class MeshDataReader(Reader): """Filename-indexed reader over a directory of RTE Mesh memmap stores. - The ``TensorDict`` returned by ``load(filename, ...)`` carries the - tensor fields RTE training and inference rely on. The on-disk format - is the PhysicsNeMo ``Mesh`` memmap layout (``.mesh/`` + - ``.attrs.json`` sidecar). + The ``TensorDict`` returned by ``load(filename)`` carries the tensor + fields RTE training and inference rely on. The on-disk format is the + PhysicsNeMo ``Mesh`` memmap layout (``.pmsh/`` + ``.attrs.json`` + sidecar). Example: >>> reader = MeshDataReader("/path/to/mesh_stores/lattice") @@ -79,22 +52,17 @@ def __init__( data_path: Path | str, case_type: Optional[str] = None, cache_static_arrays: bool = True, - max_cache_size: int = 200, ): super().__init__(pin_memory=False, include_index_in_metadata=False) self.data_path = Path(data_path) self.case_type = case_type self.cache_static_arrays = cache_static_arrays - self.max_cache_size = max_cache_size - self._static_cache: OrderedDict[ - Tuple[str, bool, bool, bool], Dict[str, torch.Tensor] - ] = OrderedDict() - self._cache_hits = 0 - self._cache_misses = 0 - self._cache_evictions = 0 - self._cache_lock = threading.Lock() + # Plain dict cache of static-only fields keyed by filename. Mesh + # stores are small enough that the full train+val split fits in RAM + # without eviction. + self._static_cache: Dict[str, Dict[str, torch.Tensor]] = {} self._metadata_cache: Dict[str, Dict] = {} @@ -105,10 +73,6 @@ def __init__( self._filenames: List[str] = self._scan_filenames() - # ------------------------------------------------------------------ - # PhysicsNeMo ``Reader`` contract - # ------------------------------------------------------------------ - def __len__(self) -> int: return len(self._filenames) @@ -122,14 +86,10 @@ def _get_sample_metadata(self, index: int) -> Dict: meta["filename"] = filename return meta - # ------------------------------------------------------------------ - # Filename discovery + caching - # ------------------------------------------------------------------ - def _scan_filenames(self) -> List[str]: filenames = [] for item in self.data_path.iterdir(): - if item.is_dir() and item.name.endswith(".mesh"): + if item.is_dir() and item.name.endswith(".pmsh"): if self.case_type is None or item.name.startswith(self.case_type): filenames.append(item.name) return sorted(filenames) @@ -138,35 +98,9 @@ def get_filenames(self) -> List[str]: """Return a fresh list of discovered mesh store names.""" return list(self._filenames) - def get_cache_stats(self) -> Dict[str, float]: - """Return current static-array cache hit/miss counters and size.""" - with self._cache_lock: - total = self._cache_hits + self._cache_misses - hit_rate = (self._cache_hits / total * 100) if total > 0 else 0.0 - return { - "cache_size": len(self._static_cache), - "max_cache_size": self.max_cache_size, - "cache_hits": self._cache_hits, - "cache_misses": self._cache_misses, - "cache_evictions": self._cache_evictions, - "hit_rate": hit_rate, - } - - def clear_cache(self): - """Drop the static-array cache and reset hit/miss/eviction counters.""" - with self._cache_lock: - self._static_cache.clear() - self._cache_hits = 0 - self._cache_misses = 0 - self._cache_evictions = 0 - - # ------------------------------------------------------------------ - # Sidecar + Mesh helpers - # ------------------------------------------------------------------ - def _sidecar_path(self, filename: str) -> Path: - # ``.mesh`` -> ``.attrs.json`` - stem = filename[: -len(".mesh")] if filename.endswith(".mesh") else filename + # ``.pmsh`` -> ``.attrs.json`` + stem = filename[: -len(".pmsh")] if filename.endswith(".pmsh") else filename return self.data_path / f"{stem}.attrs.json" def _read_sidecar(self, filename: str) -> Dict: @@ -176,165 +110,80 @@ def _read_sidecar(self, filename: str) -> Dict: with open(sidecar, "r", encoding="utf-8") as f: return json.load(f) - # ------------------------------------------------------------------ - # The filename-indexed ``load`` stays the primary entry point - # ------------------------------------------------------------------ - - def load( - self, - filename: str, - load_material_properties: bool = True, - load_geometric_features: bool = True, - load_sim_times: bool = True, - load_sigma_fields: bool = True, - load_flux: bool = True, - ) -> TensorDict: + def load(self, filename: str) -> TensorDict: """Load a Mesh memmap store into a ``TensorDict``. - Tensor fields (``coordinates``, ``cell_areas``, ``scalar_flux``, and - the optional ``sim_times`` / ``material_properties`` / ``geometric_features`` - / ``sigma_*`` / ``Q``) are stored as ``torch.Tensor`` entries. The + Tensor fields (``coordinates``, ``cell_areas``, ``scalar_flux``, + ``sim_times``, ``material_properties``, ``geometric_features``, + ``sigma_*``, ``Q``) are stored as ``torch.Tensor`` entries. The sidecar attrs dict is stored as ``NonTensorData`` under ``metadata``. - - ``load_flux=False`` returns a placeholder ``scalar_flux`` of shape - ``(1, N)`` — the caller is expected to overwrite it from a memory - cache. """ filepath = self.data_path / filename if not filepath.exists(): raise FileNotFoundError(f"Mesh store {filepath} not found") - cache_key = ( - filename, - bool(load_material_properties), - bool(load_geometric_features), - bool(load_sigma_fields), - ) - - # ``Mesh.load`` returns memmap-backed tensors. The single-process - # ``physicsnemo.datapipes.DataLoader`` (CUDA streams, no fork) keeps - # the tensors live in this process, so we let Mesh hand back the - # memmap views directly mesh = Mesh.load(str(filepath)) point_data = mesh.point_data global_data = mesh.global_data - # ------- flux + timesteps (steady-state first -> final snapshots) ------- - if "scalar_flux" in point_data.keys(): - flux_nT = point_data["scalar_flux"] # (N, T) - num_cells = flux_nT.shape[0] - num_timesteps = flux_nT.shape[1] if flux_nT.ndim == 2 else 1 - else: - flux_nT = None - num_cells = mesh.points.shape[0] - num_timesteps = 1 - - if not load_flux: - scalar_flux = torch.zeros((1, num_cells), dtype=torch.float32) - sim_times_t = None - else: - if flux_nT is None: - raise KeyError(f"scalar_flux missing from {filepath}") - full = flux_nT.transpose(0, 1).contiguous().to(torch.float32) # (T, N) - resolved = [0] if num_timesteps == 1 else [0, num_timesteps - 1] - scalar_flux = full[resolved].contiguous() - if ( - load_sim_times - and "sim_times" in global_data.keys() - and global_data["sim_times"].numel() > 0 - ): - sim_t = global_data["sim_times"].to(torch.float32) - sim_times_t = sim_t[resolved].contiguous() - else: - sim_times_t = None - - # ------- static-arrays cache lookup ------- - with self._cache_lock: - cache_hit = self.cache_static_arrays and cache_key in self._static_cache - cached_entry = None - if cache_hit: - self._cache_hits += 1 - self._static_cache.move_to_end(cache_key) - cached_entry = dict(self._static_cache[cache_key]) + # Flux + timesteps (steady-state first -> final snapshots). + if "scalar_flux" not in point_data.keys(): + raise KeyError(f"scalar_flux missing from {filepath}") + flux_nT = point_data["scalar_flux"] # (N, T) + num_timesteps = flux_nT.shape[1] if flux_nT.ndim == 2 else 1 + full = flux_nT.transpose(0, 1).contiguous().to(torch.float32) # (T, N) + resolved = [0] if num_timesteps == 1 else [0, num_timesteps - 1] td = TensorDict({}, batch_size=[]) - td["scalar_flux"] = scalar_flux - if sim_times_t is not None: - td["sim_times"] = sim_times_t + td["scalar_flux"] = full[resolved].contiguous() + if "sim_times" in global_data.keys() and global_data["sim_times"].numel() > 0: + td["sim_times"] = ( + global_data["sim_times"].to(torch.float32)[resolved].contiguous() + ) - if cache_hit: - for key, tensor in cached_entry.items(): + if self.cache_static_arrays and filename in self._static_cache: + for key, tensor in self._static_cache[filename].items(): td[key] = tensor else: - self._cache_misses += 1 td["coordinates"] = mesh.points.to(torch.float32).contiguous() if "cell_areas" in point_data.keys(): td["cell_areas"] = ( point_data["cell_areas"].to(torch.float32).contiguous() ) - - if load_material_properties and "material_properties" in point_data.keys(): + if "material_properties" in point_data.keys(): td["material_properties"] = ( point_data["material_properties"].to(torch.int32).contiguous() ) - elif ( - load_material_properties - and "material_properties" not in point_data.keys() - ): + else: warnings.warn(f"Material properties not found in {filename}.") - - if load_geometric_features and "geometric_features" in point_data.keys(): + if "geometric_features" in point_data.keys(): td["geometric_features"] = ( point_data["geometric_features"].to(torch.float32).contiguous() ) - - if load_sigma_fields: - for key in ("sigma_t", "sigma_s", "sigma_a", "Q"): - if key in point_data.keys(): - td[key] = point_data[key].to(torch.float32).contiguous() + for key in ("sigma_t", "sigma_s", "sigma_a", "Q"): + if key in point_data.keys(): + td[key] = point_data[key].to(torch.float32).contiguous() if self.cache_static_arrays: - self._maybe_cache_entry(cache_key, td) + self._static_cache[filename] = { + k: td[k] + for k in ( + "coordinates", + "cell_areas", + "material_properties", + "geometric_features", + "sigma_t", + "sigma_s", + "sigma_a", + "Q", + ) + if k in td + } sidecar = self._read_sidecar(filename) - attrs = dict(sidecar.get("raw_attrs", {})) - td.set_non_tensor("metadata", attrs) + td.set_non_tensor("metadata", dict(sidecar.get("raw_attrs", {}))) return td - def _maybe_cache_entry( - self, - cache_key: Tuple[str, bool, bool, bool], - td: TensorDict, - ) -> None: - with self._cache_lock: - if ( - self.max_cache_size > 0 - and len(self._static_cache) >= self.max_cache_size - and cache_key not in self._static_cache - ): - evicted = next(iter(self._static_cache)) - del self._static_cache[evicted] - self._cache_evictions += 1 - - entry: Dict[str, torch.Tensor] = {} - for key in ( - "coordinates", - "cell_areas", - "material_properties", - "geometric_features", - "sigma_t", - "sigma_s", - "sigma_a", - "Q", - ): - if key in td: - entry[key] = td[key] - self._static_cache[cache_key] = entry - - # ------------------------------------------------------------------ - # Metadata helpers - # ------------------------------------------------------------------ - def get_metadata(self, filename: str) -> Dict: """Return metadata (sidecar attrs + shape facts) without a full load.""" cached = self._metadata_cache.get(filename) @@ -375,17 +224,6 @@ def get_metadata(self, filename: str) -> Dict: return metadata -# ========================================================================= -# PhysicsNeMo Dataset -# ========================================================================= -# -# ``RTEBaseDataset`` extends :class:`physicsnemo.datapipes.Dataset` so the -# example plugs into ``physicsnemo.datapipes.DataLoader`` (CUDA-stream-based, -# single-process, no fork). The class still owns the file-split logic and -# the in-memory flux cache; everything device-transfer / thread-prefetch -# related is inherited from the base class. - - class RTEBaseDataset(PhysicsNeMoDataset): """File-indexed steady-state dataset over a directory of mesh stores. @@ -409,11 +247,7 @@ def __init__( phase: str = "train", split_file: Optional[Path | str] = None, seed: Optional[int] = None, - load_material_properties: bool = True, - load_geometric_features: bool = True, - load_sigma_fields: bool = True, cache_static_arrays: bool = True, - max_cache_size: int = 200, transforms: Optional[Transform | Sequence[Transform]] = None, device: Optional[Union[str, torch.device]] = None, ): @@ -422,15 +256,11 @@ def __init__( self.phase = phase self.split_file = Path(split_file) if split_file else None self.seed = seed - self.load_material_properties = load_material_properties - self.load_geometric_features = load_geometric_features - self.load_sigma_fields = load_sigma_fields reader = MeshDataReader( data_path, case_type, cache_static_arrays=cache_static_arrays, - max_cache_size=max_cache_size, ) if self.split_file is None: @@ -443,16 +273,8 @@ def __init__( if not self.filenames: raise ValueError(f"No files in {phase} split") - # In-memory cache for flux data (populated by preload_to_memory when - # enabled). Values are ``dict`` mirrors of the cached tensor entries. - self._memory_cache: Optional[Dict[str, Dict[str, torch.Tensor]]] = None - super().__init__(reader=reader, transforms=transforms, device=device) - # ------------------------------------------------------------------ - # Split machinery - # ------------------------------------------------------------------ - def _load_split_from_file(self) -> List[str]: if not self.split_file.exists(): raise FileNotFoundError(f"Split file not found: {self.split_file}") @@ -466,109 +288,19 @@ def _load_split_from_file(self) -> List[str]: f"Available: {list(split_data['splits'].keys())}" ) filenames = split_data["splits"][self.phase] - # Split files may list basenames with or without a ``.mesh`` suffix. + # Split files may list basenames with or without a ``.pmsh`` suffix. # Normalize to always point at a mesh store. normalized: List[str] = [] for f in filenames: - base = f[: -len(".mesh")] if f.endswith(".mesh") else f - normalized.append(base + ".mesh") + base = f[: -len(".pmsh")] if f.endswith(".pmsh") else f + normalized.append(base + ".pmsh") return normalized - def preload_to_memory(self, verbose: bool = True, num_workers: int = 8) -> dict: - """Preload static arrays and first/final flux snapshots.""" - import time - from concurrent.futures import ThreadPoolExecutor, as_completed - - num_files = len(self.filenames) - self._memory_cache = {} - - if verbose: - print( - f"Preloading {num_files} files (first+final flux, {num_workers} workers)..." - ) - - start = time.perf_counter() - - def load_one(filename: str): - td = self.reader.load( - filename, - load_material_properties=self.load_material_properties, - load_geometric_features=self.load_geometric_features, - load_sim_times=True, - load_sigma_fields=self.load_sigma_fields, - ) - entry: Dict[str, torch.Tensor] = { - "scalar_flux": td["scalar_flux"].clone(), - } - if "sim_times" in td: - entry["sim_times"] = td["sim_times"].clone() - return filename, entry - - completed = 0 - with ThreadPoolExecutor(max_workers=num_workers) as executor: - futures = {executor.submit(load_one, fn): fn for fn in self.filenames} - for fut in as_completed(futures): - filename, entry = fut.result() - completed += 1 - self._memory_cache[filename] = entry - if verbose and completed % 50 == 0: - elapsed = time.perf_counter() - start - rate = completed / elapsed - eta = (num_files - completed) / rate if rate > 0 else 0 - print( - f" Preloaded {completed}/{num_files} files " - f"({rate:.1f} files/s, ETA: {eta:.0f}s)" - ) - - elapsed = time.perf_counter() - start - cache_stats = self.reader.get_cache_stats() - if verbose: - print( - f"Preload complete: {num_files} files in {elapsed:.1f}s " - f"({num_files / elapsed:.1f} files/s)." - ) - - return { - "num_files": num_files, - "elapsed_seconds": elapsed, - "cache_stats": cache_stats, - "flux_cached": True, - } - - # ------------------------------------------------------------------ - # Dataset protocol - # ------------------------------------------------------------------ - def __len__(self) -> int: return len(self.filenames) - def _read_sample(self, filename: str) -> TensorDict: - """Read one sample, honoring the in-memory flux cache when populated.""" - if self._memory_cache is not None and filename in self._memory_cache: - cached = self._memory_cache[filename] - td = self.reader.load( - filename, - load_material_properties=self.load_material_properties, - load_geometric_features=self.load_geometric_features, - load_sim_times=False, - load_sigma_fields=self.load_sigma_fields, - load_flux=False, - ) - td["scalar_flux"] = cached["scalar_flux"] - if "sim_times" in cached: - td["sim_times"] = cached["sim_times"] - else: - td = self.reader.load( - filename, - load_material_properties=self.load_material_properties, - load_geometric_features=self.load_geometric_features, - load_sim_times=True, - load_sigma_fields=self.load_sigma_fields, - ) - return td - def _build_metadata(self, filename: str, td: TensorDict) -> Dict[str, Any]: - """Per-sample metadata dict consumed by transforms + downstream code.""" + """Per-sample metadata dict: sidecar attrs + filename + sim-time facts.""" attrs = dict(td["metadata"]) if "metadata" in td else {} file_meta = self.reader.get_metadata(filename) attrs["max_timestep"] = file_meta["num_timesteps"] - 1 @@ -581,25 +313,22 @@ def _build_metadata(self, filename: str, td: TensorDict) -> Dict[str, Any]: attrs["case_type"] = self.case_type return attrs - def _read_one(self, idx: int) -> Tuple[TensorDict, Dict[str, Any]]: - """Reader-side hook: load CPU TensorDict + metadata for index ``idx``. - - Routes through ``_read_sample`` (honoring the in-memory flux cache) - and ``_build_metadata`` (filename / max_timestep / sim_time). The - ``filename`` and ``metadata`` NonTensorData entries are kept on the - TensorDict so transforms that read them (e.g. ``SteadyStateSampler``) - keep working unchanged. + def _load(self, idx: int) -> Tuple[TensorDict, Dict[str, Any]]: + """Synchronous load: filename-indexed reader -> device -> transforms. + + Overrides the base ``Dataset._load`` because its int-indexed path + (``self.reader[index]``) goes through ``Reader.__getitem__`` -> + ``_load_sample``, which returns only ``dict[str, Tensor]`` and so + drops the NonTensorData entries (``filename`` / ``metadata``) that + RTE transforms and :class:`TransolverAdapter` read directly off + the TensorDict. """ filename = self.filenames[idx] - td = self._read_sample(filename) + td = self.reader.load(filename) metadata = self._build_metadata(filename, td) td.set_non_tensor("filename", filename) td.set_non_tensor("metadata", metadata) - return td, metadata - def _load(self, idx: int) -> Tuple[TensorDict, Dict[str, Any]]: - """Synchronous load: ``_read_one`` -> device -> transforms.""" - td, metadata = self._read_one(idx) if self.target_device is not None: td = td.to(self.target_device, non_blocking=True) if self.transforms is not None: @@ -607,51 +336,30 @@ def _load(self, idx: int) -> Tuple[TensorDict, Dict[str, Any]]: return td, metadata def _load_and_transform(self, index, stream=None): - """Stream-aware variant of ``_load`` used by the prefetch path.""" + """Stream-aware variant of ``_load`` used by the prefetch path. + + The base class's prefetch path goes through ``self.reader[index]`` + directly (skipping ``self._load``), so we override here too. Thread + pool + CUDA-stream wiring is inherited from the base class. + """ from physicsnemo.datapipes.protocols import _PrefetchResult result = _PrefetchResult(index=index) try: - td, metadata = self._read_one(index) - - if self.target_device is not None: - if stream is not None: - with torch.cuda.stream(stream): - td = td.to(self.target_device, non_blocking=True) - else: - td = td.to(self.target_device, non_blocking=True) - - if self.transforms is not None: - if stream is not None: - with torch.cuda.stream(stream): - td = self.transforms(td) + if stream is not None: + with torch.cuda.stream(stream): + td, metadata = self._load(index) + if self.transforms is not None: result.event = torch.cuda.Event() result.event.record(stream) - else: - td = self.transforms(td) - + else: + td, metadata = self._load(index) result.data = td result.metadata = metadata except Exception as exc: # pragma: no cover — surfaced via __getitem__ result.error = exc return result - def get_transformed_sample(self, idx: int) -> TensorDict: - """Backwards-compat helper: return the transformed TensorDict only.""" - td, _ = self._load(idx) - return td - - -# ========================================================================= -# Stats loaders -# ========================================================================= -# -# Non-breaking stats-file shim for PhysicsNeMo ``Normalize``. RTE's custom -# normalization transforms are replaced with -# ``physicsnemo.datapipes.transforms.Normalize``. The on-disk YAML stats files -# stay in their current RTE-specific schema; these helpers read them and -# produce the ``(means, stds)`` dicts PhysicsNeMo expects. - def load_flux_stats(path: Union[str, Path]) -> dict: """Read an RTE flux statistics YAML. @@ -733,44 +441,3 @@ def material_normalize_kwargs( "means": {field: means}, "stds": {field: stds}, } - - -def coord_bounds_for_case(case_type: str) -> Tuple[torch.Tensor, torch.Tensor]: - """Return ``(bbox_min, bbox_max)`` as float32 tensors for a known case. - - Shared with ``loader._build_transforms``; encapsulates the per-case - global domain bounds that were hardcoded in ``CoordinateNormalizer``. - """ - # Sibling import deferred to call time to avoid a circular import at - # module load (transforms.py is allowed to import from dataset.py if it - # ever needs stats helpers, though currently it does not). - from transforms import GLOBAL_DOMAIN_BOUNDS - - if case_type not in GLOBAL_DOMAIN_BOUNDS: - raise ValueError( - f"Unknown case_type '{case_type}'. " - f"Expected one of: {list(GLOBAL_DOMAIN_BOUNDS.keys())}" - ) - bounds = GLOBAL_DOMAIN_BOUNDS[case_type] - return ( - torch.as_tensor(bounds["min"], dtype=torch.float32), - torch.as_tensor(bounds["max"], dtype=torch.float32), - ) - - -def coord_translate_scale_params( - case_type: str, -) -> Tuple[torch.Tensor, torch.Tensor]: - """Compute ``(center, half_extent)`` for ``Translate`` + ``Scale``. - - RTE's ``CoordinateNormalizer`` produced ``(x - bbox_min) * 2 / (bbox_max - - bbox_min) - 1``. Equivalently: subtract the bbox center, then divide by the - bbox half-extent. This helper returns the two tensors in that form so the - caller can wire them straight into - ``Translate(center_key_or_value=center, subtract=True)`` followed by - ``Scale(scale=half_extent, divide=True)``. - """ - bbox_min, bbox_max = coord_bounds_for_case(case_type) - center = 0.5 * (bbox_min + bbox_max) - half_extent = 0.5 * (bbox_max - bbox_min) - return center, half_extent From 86c6efc072af631f949ab7fe56a5b7c673f398b6 Mon Sep 17 00:00:00 2001 From: Carmelo Gonzales Date: Tue, 12 May 2026 13:50:17 -0700 Subject: [PATCH 34/68] refactor: inference script cleanup - moving plotting to standalone file, cleaning up checkpoint loading, metrics calculations --- .../radiation_transport/src/inference.py | 816 ++---------------- 1 file changed, 60 insertions(+), 756 deletions(-) diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/inference.py b/examples/cfd/nuclear_engineering/radiation_transport/src/inference.py index 30fc8f661b..493eaeb751 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/inference.py +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/inference.py @@ -14,30 +14,10 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Inference / evaluation: load checkpoint, run on test set, compute metrics + plots. - -Standalone CLI invoked after training. Loads the Hydra config that was saved -alongside the checkpoint, builds the test dataloader, runs forward passes, -denormalizes predictions to physical-flux units, computes pointwise + QoI -metrics, and emits a few canonical plots. - -Usage:: - - python src/inference.py \\ - --checkpoint_dir outputs/.../checkpoints/best_qoi \\ - --data_path /path/to/data_root \\ - --case_type lattice -""" - -import argparse from pathlib import Path -from typing import Any, Dict, Iterator, Optional, Tuple, Union - -import matplotlib +from typing import Any, Dict, Iterator, Optional, Tuple -matplotlib.use("Agg") -import matplotlib.pyplot as plt -from matplotlib.colors import LogNorm +import hydra import numpy as np import torch import torch.nn as nn @@ -48,543 +28,19 @@ from physicsnemo.datapipes import DataLoader +from checkpointing import load_model_from_checkpoint from dataset import load_flux_stats -from loader import build_dataloaders, collate_no_padding -from losses import ( - evaluate_hohlraum_qoi_torch, - evaluate_lattice_qoi_torch, - extract_geometry_params, +from evaluation_metrics import ( + aggregate_metrics, + aggregate_qoi, + compute_metrics, + compute_sample_qoi, ) +from loader import build_dataloaders, collate_no_padding from transforms import denormalize_flux +from viz import plot_flux_panels, plot_qoi_true_vs_pred from physicsnemo.distributed import DistributedManager -from physicsnemo.utils.checkpoint import load_checkpoint - - -# ========================================================================= -# Checkpoint loading -# ========================================================================= - -# Inference is single-process; avoid PhysicsNeMo checkpoint loading auto-detecting -# SLURM variables from an interactive allocation and waiting for missing ranks. -if not DistributedManager.is_initialized(): - DistributedManager._shared_state["_is_initialized"] = True - - -def load_hydra_config(checkpoint_dir: Union[str, Path]) -> DictConfig: - """Load the Hydra config saved next to a checkpoint. - - Walks up from ``checkpoint_dir`` looking for a ``hydra/config.yaml``; - this lets users point at either the run directory or a specific - ``checkpoints/best_*`` subdirectory. - """ - checkpoint_dir = Path(checkpoint_dir) - search = checkpoint_dir - for _ in range(4): - config_path = search / "hydra" / "config.yaml" - if config_path.exists(): - with open(config_path, "r") as f: - cfg = OmegaConf.create(yaml.safe_load(f)) - OmegaConf.resolve(cfg) - return cfg - if search == search.parent: - break - search = search.parent - raise FileNotFoundError( - f"No hydra/config.yaml found in {checkpoint_dir} or its ancestors" - ) - - -def find_best_checkpoint(run_dir: Union[str, Path]) -> Path: - """Find the default checkpoint directory under a training run. - - Explicit checkpoint directories are consumed by the caller. When a run or - ``checkpoints`` directory is supplied instead, default to ``top_model``. - """ - run_dir = Path(run_dir) - checkpoint_root = run_dir / "checkpoints" - if not checkpoint_root.exists(): - # User may already have pointed at the checkpoints dir. - checkpoint_root = run_dir - - top = checkpoint_root / "top_model" - if top.exists() and list(top.glob("checkpoint.0.*.pt")): - return top - - raise FileNotFoundError( - f"No top_model checkpoint found under {checkpoint_root}. Pass a specific " - "checkpoint directory to evaluate something other than top_model." - ) - - -def load_model_from_checkpoint( - checkpoint_dir: Union[str, Path], - device: Optional[Union[str, torch.device]] = None, -) -> Tuple[nn.Module, DictConfig, Dict[str, Any]]: - """Build the Transolver model from the saved Hydra config and load weights. - - Returns (model in eval mode, resolved config, metadata dict). - """ - import hydra - - checkpoint_dir = Path(checkpoint_dir) - if not checkpoint_dir.exists(): - raise FileNotFoundError(f"Checkpoint directory not found: {checkpoint_dir}") - - if device is None: - device = torch.device("cuda" if torch.cuda.is_available() else "cpu") - elif isinstance(device, str): - device = torch.device(device) - - cfg = load_hydra_config(checkpoint_dir) - - # Build model from cfg.model. Strip RTE-specific keys consumed elsewhere. - cfg_model = OmegaConf.to_container(cfg.model, resolve=True) - for k in ("num_spatial_points", "include_q_in_embedding"): - cfg_model.pop(k, None) - model = hydra.utils.instantiate(cfg_model).to(device) - - metadata: Dict[str, Any] = {} - epoch = load_checkpoint( - path=str(checkpoint_dir), - models=model, - metadata_dict=metadata, - device=device, - ) - metadata.setdefault("epoch", epoch) - - model.eval() - print( - f"Loaded model from {checkpoint_dir} " - f"(epoch={metadata.get('epoch', '?')}, " - f"params={sum(p.numel() for p in model.parameters()):,})" - ) - return model, cfg, metadata - - -# ========================================================================= -# Metrics -# ========================================================================= - - -def compute_metrics( - pred: np.ndarray, target: np.ndarray, eps: float = 1e-10 -) -> Dict[str, float]: - """Compute the full metric panel for one ``(pred, target)`` pair.""" - p, t = pred.flatten(), target.flatten() - diff = p - t - abs_diff = np.abs(diff) - mse = float(np.mean(diff**2)) - return { - "mse": mse, - "rmse": float(np.sqrt(mse)), - "mae": float(np.mean(abs_diff)), - "l2_relative_error": float(np.linalg.norm(diff) / (np.linalg.norm(t) + eps)), - "relative_error": float(np.mean(abs_diff / (np.abs(t) + eps))), - "max_error": float(np.max(abs_diff)), - } - - -def aggregate_metrics(per_sample: list[Dict[str, float]]) -> Dict[str, float]: - """Aggregate per-sample metrics into mean/min/max.""" - if not per_sample: - return {} - keys = per_sample[0].keys() - out: Dict[str, float] = {} - for k in keys: - vals = [s[k] for s in per_sample] - out[f"{k}_mean"] = float(np.mean(vals)) - out[f"{k}_std"] = float(np.std(vals)) - out[f"{k}_min"] = float(np.min(vals)) - out[f"{k}_max"] = float(np.max(vals)) - return out - - -# ========================================================================= -# QoI -# ========================================================================= -# -# QoI geometry (lattice block layout, hohlraum region predicates) lives in -# ``losses.evaluate_*_qoi_torch``. Inference reuses those torch evaluators -# on the on-device tensors yielded by ``run_evaluation``. - - -def compute_sample_qoi( - pred: torch.Tensor, - target: torch.Tensor, - cell_centers: torch.Tensor, - cell_areas: torch.Tensor, - sigma_t: torch.Tensor, - sigma_s: torch.Tensor, - raw_metadata: Dict[str, Any], - case_type: str, -) -> Optional[Dict[str, Dict[str, float]]]: - """Compute QoI(pred) vs QoI(target) for one sample on the tensors' device. - - All tensor inputs may live on GPU; only the scalar QoI values are - materialized to host (via ``.item()``). Returns ``{region: {predicted, - ground_truth, absolute_error, relative_error_pct}}`` or ``None`` for the - hohlraum case when geometry params are missing from ``raw_metadata``. - """ - pred_t = pred.float().reshape(1, -1) - target_t = target.float().reshape(1, -1) - centers = cell_centers.float() - areas = cell_areas.float().flatten() - st = sigma_t.float().flatten() - ss = sigma_s.float().flatten() - sim_times_t = torch.zeros(1, device=pred.device) # unused by the steady-state QoI - - if case_type == "lattice": - qp = evaluate_lattice_qoi_torch(centers, areas, st, ss, pred_t, sim_times_t) - qt = evaluate_lattice_qoi_torch(centers, areas, st, ss, target_t, sim_times_t) - elif case_type == "hohlraum": - gp = extract_geometry_params(raw_metadata) - if not gp: - return None - qp = evaluate_hohlraum_qoi_torch( - centers, areas, st, ss, pred_t, sim_times_t, gp - ) - qt = evaluate_hohlraum_qoi_torch( - centers, areas, st, ss, target_t, sim_times_t, gp - ) - else: - raise ValueError(f"Unknown case_type: {case_type}") - - out: Dict[str, Dict[str, float]] = {} - for region in qp: - p = float(qp[region][0].item()) - t = float(qt[region][0].item()) - abs_err = abs(p - t) - out[region] = { - "predicted": p, - "ground_truth": t, - "absolute_error": abs_err, - "relative_error_pct": abs_err / (abs(t) + 1e-10) * 100.0, - } - return out - - -def aggregate_qoi( - per_sample_qoi: list[Dict[str, Dict[str, float]]], -) -> Dict[str, Dict[str, float]]: - """Aggregate per-sample QoI dicts into per-region summary statistics.""" - by_region: Dict[str, list] = {} - for sample in per_sample_qoi: - if not sample: - continue - for region, entry in sample.items(): - by_region.setdefault(region, []).append(entry) - - summary: Dict[str, Dict[str, float]] = {} - for region, entries in by_region.items(): - abs_errs = np.array([e["absolute_error"] for e in entries]) - rel_errs = np.array([e["relative_error_pct"] for e in entries]) - summary[region] = { - "num_samples": len(entries), - "mae": float(np.mean(abs_errs)), - "rmse": float(np.sqrt(np.mean(abs_errs**2))), - "max_error": float(np.max(abs_errs)), - "mean_relative_error_pct": float(np.mean(rel_errs)), - "median_relative_error_pct": float(np.median(rel_errs)), - "max_relative_error_pct": float(np.max(rel_errs)), - } - return summary - - -def collect_qoi_series( - per_sample_qoi: list[Dict[str, Dict[str, float]]], -) -> Dict[str, Tuple[np.ndarray, np.ndarray]]: - """Collect per-component QoI arrays and add a total for multi-component QoIs.""" - component_names: list[str] = [] - for sample in per_sample_qoi: - for name in sample: - if name not in component_names: - component_names.append(name) - - series: Dict[str, Tuple[np.ndarray, np.ndarray]] = {} - for name in component_names: - target_vals = [] - pred_vals = [] - for sample in per_sample_qoi: - entry = sample.get(name) - if entry is None: - continue - target_vals.append(entry["ground_truth"]) - pred_vals.append(entry["predicted"]) - if target_vals: - series[name] = (np.array(target_vals), np.array(pred_vals)) - - if len(series) > 1: - totals_target = [] - totals_pred = [] - for sample in per_sample_qoi: - if not all(name in sample for name in series): - continue - totals_target.append(sum(sample[name]["ground_truth"] for name in series)) - totals_pred.append(sum(sample[name]["predicted"] for name in series)) - if totals_target: - series["total"] = (np.array(totals_target), np.array(totals_pred)) - - return series - - -def summarize_qoi_series( - target: np.ndarray, - prediction: np.ndarray, -) -> Dict[str, float]: - """Summarize absolute and relative QoI errors for one component.""" - abs_errs = np.abs(prediction - target) - rel_errs = abs_errs / (np.abs(target) + 1e-10) * 100.0 - return { - "num_samples": int(target.size), - "mae": float(np.mean(abs_errs)), - "rmse": float(np.sqrt(np.mean(abs_errs**2))), - "max_error": float(np.max(abs_errs)), - "mean_relative_error_pct": float(np.mean(rel_errs)), - "median_relative_error_pct": float(np.median(rel_errs)), - "max_relative_error_pct": float(np.max(rel_errs)), - } - - -# ========================================================================= -# Plots -# ========================================================================= - - -def plot_flux_panels( - coordinates: np.ndarray, - target: np.ndarray, - prediction: np.ndarray, - output_path: Union[str, Path], - *, - log_flux: bool = False, - figsize: Tuple[int, int] = (16, 5), - dpi: int = 150, -) -> Path: - """Render a 3-panel figure: target | prediction | absolute error.""" - output_path = Path(output_path) - output_path.parent.mkdir(parents=True, exist_ok=True) - - target = target.flatten() - prediction = prediction.flatten() - error = np.abs(prediction - target) - - x, y = coordinates[:, 0], coordinates[:, 1] - x_pad = (x.max() - x.min()) * 0.01 - y_pad = (y.max() - y.min()) * 0.01 - xlim = (x.min() - x_pad, x.max() + x_pad) - ylim = (y.min() - y_pad, y.max() + y_pad) - - fig, axes = plt.subplots(1, 3, figsize=figsize, dpi=dpi) - flux_vmin = min(target.min(), prediction.min()) - flux_vmax = max(target.max(), prediction.max()) - flux_norm = None - if log_flux: - positive_flux = np.concatenate( - [target[target > 0.0], prediction[prediction > 0.0]] - ) - if positive_flux.size: - flux_vmin = float(positive_flux.min()) - flux_vmax = float(positive_flux.max()) - if flux_vmin == flux_vmax: - flux_vmax = flux_vmin * 1.01 - flux_norm = LogNorm(vmin=flux_vmin, vmax=flux_vmax) - else: - log_flux = False - cmap_flux = plt.get_cmap("viridis") - cmap_err = plt.get_cmap("hot") - - for ax, label, vals, cmap, vmin, vmax, norm in ( - (axes[0], "Target", target, cmap_flux, flux_vmin, flux_vmax, flux_norm), - ( - axes[1], - "Prediction", - prediction, - cmap_flux, - flux_vmin, - flux_vmax, - flux_norm, - ), - (axes[2], "Absolute Error", error, cmap_err, 0.0, float(error.max()), None), - ): - plot_vals = np.clip(vals, flux_vmin, None) if norm is not None else vals - sc = ax.scatter( - x, - y, - c=plot_vals, - cmap=cmap, - vmin=None if norm is not None else vmin, - vmax=None if norm is not None else vmax, - norm=norm, - s=1, - ) - ax.set_aspect("equal") - ax.set_xlim(xlim) - ax.set_ylim(ylim) - ax.set_title(f"{label} (log)" if norm is not None else label) - plt.colorbar(sc, ax=ax) - - plt.tight_layout() - plt.savefig(output_path, dpi=dpi, bbox_inches="tight") - plt.close(fig) - return output_path - - -def plot_true_vs_pred_scatter( - target: np.ndarray, - prediction: np.ndarray, - output_path: Union[str, Path], - *, - max_points: int = 200_000, - dpi: int = 150, -) -> Path: - """Scatter of predicted vs ground truth with the y=x reference line.""" - output_path = Path(output_path) - output_path.parent.mkdir(parents=True, exist_ok=True) - - t = target.flatten() - p = prediction.flatten() - if t.size > max_points: - idx = np.random.default_rng(0).choice(t.size, max_points, replace=False) - t, p = t[idx], p[idx] - - lo = float(min(t.min(), p.min())) - hi = float(max(t.max(), p.max())) - - fig, ax = plt.subplots(figsize=(6, 6), dpi=dpi) - ax.scatter(t, p, s=1, alpha=0.3) - ax.plot([lo, hi], [lo, hi], "r--", linewidth=1.0, label="y = x") - ax.set_xlabel("Ground truth flux") - ax.set_ylabel("Predicted flux") - ax.set_title("Predicted vs. ground-truth flux") - ax.set_aspect("equal") - ax.legend(loc="best") - plt.tight_layout() - plt.savefig(output_path, dpi=dpi, bbox_inches="tight") - plt.close(fig) - return output_path - - -def plot_error_histogram( - target: np.ndarray, - prediction: np.ndarray, - output_path: Union[str, Path], - *, - bins: int = 80, - dpi: int = 150, -) -> Path: - """Histogram of pointwise absolute errors (log y).""" - output_path = Path(output_path) - output_path.parent.mkdir(parents=True, exist_ok=True) - - errors = np.abs(prediction.flatten() - target.flatten()) - - fig, ax = plt.subplots(figsize=(7, 5), dpi=dpi) - ax.hist(errors, bins=bins, color="C0", edgecolor="black", linewidth=0.3) - ax.set_yscale("log") - ax.set_xlabel("|prediction - target|") - ax.set_ylabel("Count (log)") - ax.set_title( - f"Pointwise error histogram (mean={errors.mean():.3e}, max={errors.max():.3e})" - ) - plt.tight_layout() - plt.savefig(output_path, dpi=dpi, bbox_inches="tight") - plt.close(fig) - return output_path - - -def _subplot_grid(num_panels: int) -> Tuple[int, int]: - """Choose a compact subplot grid for QoI component plots.""" - ncols = min(num_panels, 3) - nrows = int(np.ceil(num_panels / ncols)) - return nrows, ncols - - -def plot_qoi_true_vs_pred( - qoi_series: Dict[str, Tuple[np.ndarray, np.ndarray]], - output_path: Union[str, Path], - *, - dpi: int = 150, -) -> Path: - """Scatter predicted vs ground-truth QoI values for each component.""" - output_path = Path(output_path) - output_path.parent.mkdir(parents=True, exist_ok=True) - - items = list(qoi_series.items()) - nrows, ncols = _subplot_grid(len(items)) - fig, axes = plt.subplots( - nrows, ncols, figsize=(5 * ncols, 4.5 * nrows), dpi=dpi, squeeze=False - ) - - for ax, (name, (target, prediction)) in zip(axes.flat, items): - lo = float(min(target.min(), prediction.min())) - hi = float(max(target.max(), prediction.max())) - if lo == hi: - pad = max(abs(lo) * 0.05, 1e-12) - lo -= pad - hi += pad - - ax.scatter(target, prediction, s=18, alpha=0.75) - ax.plot([lo, hi], [lo, hi], "r--", linewidth=1.0, label="y = x") - ax.set_title(name) - ax.set_xlabel("Ground truth QoI") - ax.set_ylabel("Predicted QoI") - ax.set_aspect("equal") - ax.legend(loc="best") - - for ax in axes.flat[len(items) :]: - ax.axis("off") - - fig.suptitle("QoI predicted vs. ground truth") - plt.tight_layout() - plt.savefig(output_path, dpi=dpi, bbox_inches="tight") - plt.close(fig) - return output_path - - -def plot_qoi_error_histograms( - qoi_series: Dict[str, Tuple[np.ndarray, np.ndarray]], - output_path: Union[str, Path], - *, - bins: int = 40, - dpi: int = 150, -) -> Path: - """Plot absolute QoI error histograms for each component.""" - output_path = Path(output_path) - output_path.parent.mkdir(parents=True, exist_ok=True) - - items = list(qoi_series.items()) - nrows, ncols = _subplot_grid(len(items)) - fig, axes = plt.subplots( - nrows, ncols, figsize=(5 * ncols, 4.0 * nrows), dpi=dpi, squeeze=False - ) - - for ax, (name, (target, prediction)) in zip(axes.flat, items): - errors = np.abs(prediction - target) - ax.hist(errors, bins=bins, color="C0", edgecolor="black", linewidth=0.3) - ax.set_yscale("log") - ax.set_title(f"{name} error") - ax.set_xlabel("|prediction - target|") - ax.set_ylabel("Count (log)") - - for ax in axes.flat[len(items) :]: - ax.axis("off") - - fig.suptitle("QoI absolute error histograms") - plt.tight_layout() - plt.savefig(output_path, dpi=dpi, bbox_inches="tight") - plt.close(fig) - return output_path - - -# ========================================================================= -# Main inference loop -# ========================================================================= - - -def _denormalize(flux_norm: torch.Tensor, stats: Dict[str, float]) -> torch.Tensor: - """Apply ``denormalize_flux`` (RTEFluxLogClip + Normalize inverse) on-device.""" - return denormalize_flux(flux_norm, stats) @torch.no_grad() @@ -594,20 +50,25 @@ def run_evaluation( device: torch.device, flux_stats: Dict[str, float], case_type: str, - *, use_amp: bool = True, max_samples: Optional[int] = None, ) -> Iterator[ - Tuple[np.ndarray, np.ndarray, Optional[Dict[str, Dict[str, float]]], Dict[str, Any]] + Tuple[ + np.ndarray, + np.ndarray, + Optional[Dict[str, Dict[str, float]]], + Optional[np.ndarray], + Optional[str], + ] ]: - """Yield ``(prediction, target, qoi, metadata)`` for each test sample. + """Yield ``(prediction, target, qoi, coordinates, filename)`` per sample. Predictions and targets are denormalized to physical-flux units and returned as flattened numpy arrays for downstream pointwise metrics and plotting. The QoI dict (or ``None``) is computed on-device before the - GPU→CPU transfer to avoid round-tripping per-mesh tensors through numpy. - ``metadata`` carries the per-sample coordinates / cell areas / sigma - fields / filename for follow-up plotting. + GPU->CPU transfer to avoid round-tripping per-mesh tensors through numpy. + ``coordinates`` is the per-sample point cloud (or ``None`` if absent); + ``filename`` is the sidecar filename (or ``None``). """ model.eval() n = 0 @@ -634,8 +95,6 @@ def run_evaluation( stats = stats[0] if stats else flux_stats coords_t = batch.get("coordinates_unnormalized") - if coords_t is None: - coords_t = batch.get("fx") cell_areas_t = batch.get("cell_areas") sigma_t_t = batch.get("sigma_t") sigma_s_t = batch.get("sigma_s") @@ -645,8 +104,8 @@ def run_evaluation( # Batches always carry an outer batch dim of 1 (collate_no_padding). for b in range(pred.shape[0]): - pred_phys_t = _denormalize(pred[b].squeeze(-1), stats).flatten() - target_phys_t = _denormalize(target[b].squeeze(-1), stats).flatten() + pred_phys_t = denormalize_flux(pred[b].squeeze(-1), stats).flatten() + target_phys_t = denormalize_flux(target[b].squeeze(-1), stats).flatten() qoi: Optional[Dict[str, Dict[str, float]]] = None if ( @@ -666,183 +125,44 @@ def run_evaluation( case_type, ) - metadata: Dict[str, Any] = {} + coords_np: Optional[np.ndarray] = None if coords_t is not None: - metadata["coordinates"] = coords_t[b].detach().cpu().numpy() - for key, t in ( - ("cell_areas", cell_areas_t), - ("sigma_t", sigma_t_t), - ("sigma_s", sigma_s_t), - ): - if t is not None: - metadata[key] = t[b].detach().cpu().numpy().flatten() - sim_time = batch.get("sim_time") - if sim_time is not None: - metadata["sim_time"] = float(sim_time[b].flatten()[0].item()) + coords_np = coords_t[b].detach().cpu().numpy() filename = raw_meta.get("filename") if isinstance(raw_meta, dict) else None - if filename: - metadata["filename"] = filename n += 1 yield ( pred_phys_t.detach().cpu().numpy(), target_phys_t.detach().cpu().numpy(), qoi, - metadata, + coords_np, + filename, ) if max_samples is not None and n >= max_samples: return -# ========================================================================= -# CLI entry -# ========================================================================= - - -def _resolve_data_path( - cfg: DictConfig, - cli_data_path: str, - split_file: Union[str, Path], - flux_stats_file: Optional[Union[str, Path]] = None, -) -> None: - """Override data-source paths in the saved config to the user-supplied locations. - - ``flux_stats_file`` is optional: when ``None`` the saved Hydra config's - ``data.flux_normalization_stats_file`` is left untouched (so the path the - model was trained against is reused). - """ - OmegaConf.update(cfg, "case.data_root", cli_data_path, force_add=True) - case_type = cfg.case.type - OmegaConf.update( - cfg, "case.data_path", str(Path(cli_data_path) / case_type), force_add=True - ) - OmegaConf.update( - cfg, - "data.input_dir", - str(Path(cli_data_path) / case_type), - force_add=True, - ) - if flux_stats_file is not None: - OmegaConf.update( - cfg, - "data.flux_normalization_stats_file", - str(flux_stats_file), - force_add=True, - ) - split_file = Path(split_file) - OmegaConf.update(cfg, "case.split_file", str(split_file), force_add=True) - OmegaConf.update(cfg, "data.split_file", str(split_file), force_add=True) - - -def main(): - """CLI entry point: parse args, load checkpoint, run evaluation, write outputs.""" - parser = argparse.ArgumentParser( - description="Evaluate a trained RTE Transolver model on the test split.", - formatter_class=argparse.ArgumentDefaultsHelpFormatter, - ) - parser.add_argument( - "--checkpoint_dir", - type=Path, - required=True, - help="Path to a checkpoint directory (e.g. .../checkpoints/best_qoi). " - "May also point at the run directory; the script will search.", - ) - parser.add_argument( - "--data_path", - type=Path, - required=True, - help="Dataset root containing the per-case mesh stores " - "(e.g. /lattice/*.mesh).", - ) - parser.add_argument( - "--case_type", - type=str, - required=True, - choices=("lattice", "hohlraum"), - help="Which benchmark to evaluate.", - ) - parser.add_argument( - "--split_file", - type=Path, - required=True, - help="Explicit train/val/test split JSON to use for evaluation.", - ) - parser.add_argument( - "--flux_stats_file", - type=Path, - default=None, - help="Override the flux normalization stats YAML recorded in the " - "checkpoint's hydra config (e.g. /stats/_flux_stats.yaml). " - "If omitted, the path saved at training time is reused. The matching " - "_material_stats.yaml is read from the same directory.", - ) - parser.add_argument( - "--output_dir", - type=Path, - default=None, - help="Where to write metrics + figures. Defaults to /evaluation.", - ) - parser.add_argument( - "--num_samples", - type=int, - default=None, - help="Cap on the number of test samples (default: all).", - ) - parser.add_argument( - "--device", type=str, default=None, help="Override torch device." - ) - parser.add_argument( - "--num_plot_samples", - type=int, - default=3, - help="Number of per-sample flux-panel figures to render.", - ) - args = parser.parse_args() +@hydra.main(version_base="1.3", config_path="conf", config_name="config") +def main(cfg: DictConfig) -> None: + """Hydra entry: load checkpoint, run evaluation, write metrics + figures.""" + DistributedManager.initialize() + # Full-mesh evaluation always — disable any training-time subsampling. + OmegaConf.update(cfg, "model.num_spatial_points", -1) - # Resolve checkpoint: accept either the run dir or a specific checkpoint. - ckpt_dir = args.checkpoint_dir - if not list(ckpt_dir.glob("checkpoint.0.*.pt")): - ckpt_dir = find_best_checkpoint(ckpt_dir) - print(f"Using best checkpoint: {ckpt_dir}") + output_dir = Path(cfg.inference.output_dir) + figures_dir = output_dir / "figures" + figures_dir.mkdir(parents=True, exist_ok=True) device = torch.device( - args.device if args.device else ("cuda" if torch.cuda.is_available() else "cpu") + cfg.inference.device or ("cuda" if torch.cuda.is_available() else "cpu") ) - model, cfg, metadata = load_model_from_checkpoint(ckpt_dir, device=device) - - # The saved cfg still holds the training-machine paths. Rewrite the - # data-related fields to whatever the user supplied at the CLI. - if cfg.case.type != args.case_type: - print( - f"Warning: checkpoint trained on '{cfg.case.type}', " - f"but --case_type={args.case_type}. Using --case_type." - ) - OmegaConf.update(cfg, "case.type", args.case_type, force_add=True) - if not args.split_file.exists(): - raise FileNotFoundError(f"Split file not found: {args.split_file}") - if args.flux_stats_file is not None and not args.flux_stats_file.exists(): - raise FileNotFoundError(f"Flux stats file not found: {args.flux_stats_file}") - _resolve_data_path(cfg, str(args.data_path), args.split_file, args.flux_stats_file) - - if cfg.model.get("num_spatial_points", -1) != -1: - OmegaConf.update(cfg, "model.num_spatial_points", -1, force_add=True) - print("Forcing num_spatial_points=-1 for full-mesh evaluation.") - - # Output dir defaults to ``/evaluation``. - if args.output_dir is None: - run_dir = ckpt_dir - # Walk up to a directory that has a hydra/ subdirectory. - for _ in range(4): - if (run_dir / "hydra").exists(): - break - run_dir = run_dir.parent - output_dir = run_dir / "evaluation" - else: - output_dir = args.output_dir - output_dir.mkdir(parents=True, exist_ok=True) - figures_dir = output_dir / "figures" - figures_dir.mkdir(parents=True, exist_ok=True) + # Downstream calls (load_model_from_checkpoint, build_dataloaders -> + # MeshDataReader / split-file loader, load_flux_stats) all raise + # ``FileNotFoundError`` with the offending path if anything is missing. + model, _ = load_model_from_checkpoint( + Path(cfg.inference.checkpoint_path), cfg, device + ) # Build the test loader. ``test_batch_size=1`` matches the point-cloud # adapter's invariant. @@ -857,31 +177,29 @@ def main(): print(f"Test set size: {len(test_loader.dataset)}") flux_stats = load_flux_stats(cfg.data.flux_normalization_stats_file) + case_type = cfg.case.type + + # Evenly sample plot indices across the test set. + n_total = cfg.inference.num_samples or len(test_loader.dataset) + n_plots = cfg.inference.num_plot_samples + plot_indices: set[int] = set() + if n_plots > 0: + plot_indices = set(np.linspace(0, n_total - 1, n_plots, dtype=int).tolist()) - # Run the inference loop and accumulate metrics + plots. per_sample_metrics: list[Dict[str, float]] = [] per_sample_qoi: list[Dict[str, Dict[str, float]]] = [] all_targets: list[np.ndarray] = [] all_preds: list[np.ndarray] = [] - plot_indices = set() - if args.num_plot_samples > 0: - # Evenly sample plot indices across the test set. - n_total = ( - args.num_samples - if args.num_samples is not None - else len(test_loader.dataset) - ) - step = max(n_total // max(args.num_plot_samples, 1), 1) - plot_indices = set(range(0, n_total, step)) - for idx, (pred, target, qoi, meta) in enumerate( + for idx, (pred, target, qoi, coords, _filename) in enumerate( run_evaluation( model, test_loader, device, flux_stats, - args.case_type, - max_samples=args.num_samples, + case_type, + use_amp=cfg.inference.use_amp, + max_samples=cfg.inference.num_samples, ) ): per_sample_metrics.append(compute_metrics(pred, target)) @@ -890,13 +208,13 @@ def main(): all_targets.append(target) all_preds.append(pred) - if idx in plot_indices and "coordinates" in meta: + if idx in plot_indices and coords is not None: plot_flux_panels( - meta["coordinates"], + coords, target, pred, figures_dir / f"flux_panels_{idx:04d}.png", - log_flux=args.case_type == "lattice", + log_flux=case_type == "lattice", ) if not per_sample_metrics: @@ -908,7 +226,7 @@ def main(): overall_metrics = compute_metrics(all_pred_arr, all_target_arr) aggregated = aggregate_metrics(per_sample_metrics) - metrics_out = { + metrics_out: Dict[str, Any] = { "num_samples": len(per_sample_metrics), "overall": overall_metrics, "per_sample_aggregate": aggregated, @@ -922,14 +240,9 @@ def main(): # QoI summary. if per_sample_qoi: qoi_summary = aggregate_qoi(per_sample_qoi) - qoi_series = collect_qoi_series(per_sample_qoi) - if "total" in qoi_series: - total_target, total_prediction = qoi_series["total"] - qoi_summary["total"] = summarize_qoi_series(total_target, total_prediction) with open(output_dir / "qoi_metrics.yaml", "w") as f: yaml.safe_dump(qoi_summary, f, sort_keys=False) - plot_qoi_true_vs_pred(qoi_series, figures_dir / "qoi_true_vs_pred.png") - plot_qoi_error_histograms(qoi_series, figures_dir / "qoi_error_histogram.png") + plot_qoi_true_vs_pred(per_sample_qoi, figures_dir / "qoi_true_vs_pred.png") print("\nQoI summary:") for region, stats in qoi_summary.items(): print( @@ -937,19 +250,10 @@ def main(): f"mean_rel_err={stats['mean_relative_error_pct']:.3f}%" ) - # Global plots over every concatenated point. - plot_true_vs_pred_scatter( - all_target_arr, all_pred_arr, figures_dir / "true_vs_pred.png" - ) - plot_error_histogram( - all_target_arr, all_pred_arr, figures_dir / "error_histogram.png" - ) - print(f"\nResults written to: {output_dir}") print(" metrics.yaml") if per_sample_qoi: print(" qoi_metrics.yaml") - print(f" figures/ ({len(plot_indices)} flux panels + 2 global plots)") if __name__ == "__main__": From 32471d404386127948f2749e4a8b806d9789aeb0 Mon Sep 17 00:00:00 2001 From: Carmelo Gonzales Date: Tue, 12 May 2026 13:51:10 -0700 Subject: [PATCH 35/68] refactor: cleaning out stale transforms code and consolidating upstream updates --- .../radiation_transport/src/loader.py | 464 ++++-------------- 1 file changed, 82 insertions(+), 382 deletions(-) diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/loader.py b/examples/cfd/nuclear_engineering/radiation_transport/src/loader.py index 2dd649f7a2..efa8688d67 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/loader.py +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/loader.py @@ -14,33 +14,9 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Data plumbing: TransolverAdapter (Transform), collate, dataset+loader builder. - -This module composes :class:`RTEBaseDataset` (a -:class:`physicsnemo.datapipes.Dataset` subclass) with a ``Compose`` of -transforms — including the trailing :class:`TransolverAdapter` Transform — -and exposes a single ``build_dataloaders`` entry point used by ``train.py`` -and ``inference.py``. - -Sections: - -* Adapter — :class:`TransolverAdapter` (a :class:`Transform`). -* Collation — :func:`collate_no_padding` (batch_size=1 unsqueeze). -* Stats / kwargs translation — :func:`_build_rte_dataset_kwargs`. -* Pipeline orchestration — :func:`_build_transforms` + :func:`_build_rte_dataset`. -* Distributed preload barrier — file-marker rank-sequencing helpers. -* DataLoader builder — :func:`build_dataloaders` + :func:`_make_loader` + - :func:`_log_material_sanity`. -""" - from __future__ import annotations -# ========================================================================= -# Imports -# ========================================================================= - import logging -import time from pathlib import Path from typing import ( Any, @@ -48,7 +24,6 @@ Dict, Iterable, List, - Literal, Optional, Sequence, Tuple, @@ -56,42 +31,37 @@ ) import torch -import torch.distributed as torch_dist from omegaconf import DictConfig from physicsnemo.datapipes import DataLoader from physicsnemo.datapipes.registry import register from physicsnemo.datapipes.transforms import Compose, Normalize, Scale, Translate from physicsnemo.datapipes.transforms.base import Transform from tensordict import TensorDict -from torch.utils.data import Dataset, Sampler +from torch.utils.data import Sampler from torch.utils.data.distributed import DistributedSampler from dataset import ( RTEBaseDataset, - coord_translate_scale_params, flux_normalize_kwargs, load_flux_stats, load_material_stats, material_normalize_kwargs, ) -from material import MaterialPropertyExtractor from transforms import ( FourierFeatures, + MaterialPropertyExtractor, RTEBackupCoords, RTEFluxLogClip, SpatialSampler, SteadyStateSampler, + coord_translate_scale_params, ) - -# ========================================================================= -# Adapter -# ========================================================================= -# -# ``TransolverAdapter`` is the trailing :class:`Transform` in the RTE pipeline. -# It rewrites the field-name layout of the TensorDict to match what -# :class:`physicsnemo.models.transolver.Transolver` expects (``fx``, -# ``embedding``, ``flux_target``, ...) and drops fields the model never reads. +__all__ = [ + "TransolverAdapter", + "collate_no_padding", + "build_dataloaders", +] @register("RTETransolverAdapter") @@ -120,145 +90,120 @@ def __init__(self, include_q_in_embedding: bool = True): super().__init__() self.include_q_in_embedding = include_q_in_embedding + # Simple passthroughs: same key on both sides, no transform. + _PASSTHROUGH_KEYS = ( + "coordinates_unnormalized", + "cell_areas", + "sigma_t", + "sigma_s", + ) + def __call__(self, data: TensorDict) -> TensorDict: out = TensorDict({}, batch_size=data.batch_size, device=data.device) + # Rename: coordinates -> fx (Transolver's positional input). if "coordinates" in data: out["fx"] = data["coordinates"] + # Passthroughs. + for key in self._PASSTHROUGH_KEYS: + if key in data: + out[key] = data[key] + + # physical_properties -> embedding (optionally drop Q for hohlraum). if "physical_properties" in data: mat_props = data["physical_properties"] if not self.include_q_in_embedding: mat_props = mat_props[..., :3] out["embedding"] = mat_props - if "coordinates_unnormalized" in data: - out["coordinates_unnormalized"] = data["coordinates_unnormalized"] - - if "material_properties" in data and data["material_properties"] is not None: - out["material_labels"] = data["material_properties"].long() + # material_properties -> material_labels (long dtype for embedding lookups). + if "material_properties" in data: + out["material_labels"] = data["material_properties"].to(dtype=torch.long) + # flux_target promoted to shape (N, 1) if delivered as (N,). if "flux_target" in data: flux_tgt = data["flux_target"] - if flux_tgt.ndim == 1: - flux_tgt = flux_tgt.unsqueeze(-1) - out["flux_target"] = flux_tgt - - for key in ("cell_areas", "sigma_t", "sigma_s"): - if key in data: - out[key] = data[key] + out["flux_target"] = ( + flux_tgt.unsqueeze(-1) if flux_tgt.ndim == 1 else flux_tgt + ) - if "sim_times" in data and data["sim_times"].numel() > 0: - out["sim_time"] = data["sim_times"][-1].reshape(1).to(torch.float32) - elif "sim_times" in data: - out["sim_time"] = torch.tensor( - [0.0], dtype=torch.float32, device=data.device + # sim_times -> single-scalar sim_time at the final snapshot; + # zero-tensor placeholder when the source series is empty. + if "sim_times" in data: + sim_times = data["sim_times"] + out["sim_time"] = ( + sim_times[-1].reshape(1).to(dtype=torch.float32) + if sim_times.numel() > 0 + else torch.zeros(1, dtype=torch.float32, device=data.device) ) + # NonTensorData passthroughs. if "flux_normalization_stats" in data: out.set_non_tensor( "flux_normalization_stats", data["flux_normalization_stats"] ) + if "filename" in data: + out.set_non_tensor("filename", data["filename"]) - # Trim metadata to the keys downstream code reads. The full metadata - # dict is also delivered as the second tuple element by the dataset. - src_meta = data["metadata"] if "metadata" in data else {} + # Trim the metadata dict to keys downstream code reads. The full + # metadata dict is also delivered as the second tuple element by + # the dataset; this is a convenience view for ``batch["metadata"]``. + # Bracket access (``data[key]``) unwraps ``NonTensorData`` to the raw + # payload; ``TensorDict.get`` does not, so prefer the former here. + get = lambda key: data[key] if key in data else None # noqa: E731 + src_meta = get("metadata") or {} if not isinstance(src_meta, dict): src_meta = {} - trimmed = { - "timestep_input": data["timestep_input"] - if "timestep_input" in data - else None, - "timestep_target": data["timestep_target"] - if "timestep_target" in data - else None, + candidates = { + "timestep_input": get("timestep_input"), + "timestep_target": get("timestep_target"), "max_timestep": src_meta.get("max_timestep"), - "filename": data["filename"] if "filename" in data else None, + "filename": get("filename"), "case_type": src_meta.get("case_type"), } - trimmed = {k: v for k, v in trimmed.items() if v is not None} - out.set_non_tensor("metadata", trimmed) - if "filename" in data: - out.set_non_tensor("filename", data["filename"]) + out.set_non_tensor( + "metadata", {k: v for k, v in candidates.items() if v is not None} + ) return out def extra_repr(self) -> str: return f"include_q_in_embedding={self.include_q_in_embedding}" -# ========================================================================= -# Collation -# ========================================================================= -# -# All configs use ``batch_size=1`` with a fixed ``num_spatial_points`` from -# ``SpatialSampler``, so no padding is needed. ``build_dataloaders_for_training`` -# enforces ``batch_size=1`` upstream, so this collate just unsqueezes the -# single sample. -# -# ``physicsnemo.datapipes.DataLoader`` passes ``list[tuple[TensorDict, dict]]`` -# into the collate function. We unpack the tuple, unsqueeze each tensor in the -# TensorDict, and merge any TD-side NonTensorData ("metadata", -# "flux_normalization_stats", "filename") plus the second-element metadata -# back into the returned dict. - - @register("RTECollateNoPadding") def collate_no_padding( batch: Sequence[Tuple[TensorDict, Dict[str, Any]]], ) -> Dict[str, Any]: - """Batch-size-1 collate. - - Expects the :class:`physicsnemo.datapipes.DataLoader` calling convention: - ``list[tuple[TensorDict, dict]]``. Returns a plain dict (not a TensorDict) - so downstream training code can keep using ``batch["fx"]`` / ``batch["filename"]`` - etc. without unpacking. + """Batch-size-1 collate for the ``physicsnemo.datapipes.DataLoader``. + + Unsqueezes each tensor in the TensorDict to add a ``B=1`` leading + dim, passes NonTensorData entries through unchanged, and merges the + trailing metadata dict under ``batch["metadata"]``. Returns a plain + dict so downstream code can use ``batch["fx"]`` / ``batch["filename"]`` + without unpacking a TensorDict. ``build_dataloaders_for_training`` + enforces ``batch_size=1`` upstream so no padding is needed. """ assert len(batch) == 1, ( f"collate_no_padding requires batch_size=1; got {len(batch)}" ) - item = batch[0] - # ``physicsnemo.datapipes.DataLoader`` always passes (TensorDict, dict) - # tuples through. Be defensive against accidental dict-only inputs. - if isinstance(item, tuple) and len(item) == 2: - td, metadata = item - else: - td = item - metadata = {} + td, metadata = batch[0] out: Dict[str, Any] = {} - if isinstance(td, TensorDict): - for key in td.keys(): - value = td[key] - if isinstance(value, torch.Tensor): - out[key] = value.unsqueeze(0) - else: - out[key] = value - elif isinstance(td, dict): - for key, value in td.items(): - out[key] = value.unsqueeze(0) if isinstance(value, torch.Tensor) else value - - # Merge the trailing metadata dict back into the batch under "metadata". - # ``filename`` is also surfaced at the top level so downstream code can - # use ``batch["filename"]`` directly. + for key in td.keys(): + value = td[key] + out[key] = value.unsqueeze(0) if isinstance(value, torch.Tensor) else value + + # Merge the trailing metadata dict with the trimmed view that + # TransolverAdapter set under ``metadata``. Overlapping keys + # (``filename``, ``case_type``, ``max_timestep``) carry the same + # values in both, so the merge is idempotent there. if metadata: - existing = out.get("metadata") if isinstance(out.get("metadata"), dict) else {} - merged_meta = {**metadata, **(existing or {})} - out["metadata"] = merged_meta - if "filename" in metadata and "filename" not in out: - out["filename"] = metadata["filename"] + existing = out.get("metadata") or {} + out["metadata"] = {**metadata, **existing} return out -# ========================================================================= -# Stats / kwargs translation -# ========================================================================= -# -# ``_build_rte_dataset_kwargs`` translates a Hydra config into the kwargs -# ``_build_rte_dataset`` expects. Required keys (``flux_normalization_stats_file``, -# ``flux_clip_threshold``, ``case.split_file``) raise a clear ``KeyError`` on -# direct access if missing; no manual ``None``-checking is layered on top. - - def _build_rte_dataset_kwargs(cfg: DictConfig) -> dict: """Translate a Hydra config into the kwargs ``_build_rte_dataset`` expects.""" data_cfg = cfg.data @@ -272,13 +217,8 @@ def _build_rte_dataset_kwargs(cfg: DictConfig) -> dict: "normalize_coordinates": data_cfg.get("normalize_coordinates", True), "flux_clip_threshold": data_cfg.flux_clip_threshold, "split_file": cfg.case.split_file, - "seed": ( - data_cfg.get("seed", None) - if data_cfg.get("seed", None) is not None - else cfg.get("train", {}).get("seed", None) - ), + "seed": data_cfg.get("seed") or cfg.train.get("seed"), "cache_static_arrays": data_cfg.get("cache_static_arrays", True), - "max_cache_size": data_cfg.get("max_cache_size", 200), "include_q_in_embedding": cfg.model.get("include_q_in_embedding", True), "use_fourier_features": use_fourier_features, "fourier_num_frequencies": fourier_cfg.num_frequencies if fourier_cfg else None, @@ -287,20 +227,10 @@ def _build_rte_dataset_kwargs(cfg: DictConfig) -> dict: } -# ========================================================================= -# Pipeline orchestration -# ========================================================================= -# -# ``_build_rte_dataset`` is the high-level builder used by ``build_dataloaders``; -# ``compute_normalizations.py`` instantiates ``RTEBaseDataset`` directly with -# ``transforms=None`` to walk raw samples for stats. - - def _build_rte_dataset( - case_type: Literal["lattice", "hohlraum"], + case_type: str, data_path: Union[str, Path], - phase: Literal["train", "val", "test"], - *, + phase: str, num_spatial_points: int, flux_normalization_stats_file: Union[str, Path], normalize_coordinates: bool, @@ -308,7 +238,6 @@ def _build_rte_dataset( split_file: Union[str, Path], seed: Optional[int], cache_static_arrays: bool, - max_cache_size: int, include_q_in_embedding: bool, use_fourier_features: bool, fourier_num_frequencies: Optional[int], @@ -323,7 +252,6 @@ def _build_rte_dataset( ) transforms = _build_transforms( - data_path=data_path, case_type=case_type, flux_normalization_stats_file=flux_normalization_stats_file, flux_clip_threshold=flux_clip_threshold, @@ -343,18 +271,14 @@ def _build_rte_dataset( phase=phase, split_file=split_file, seed=seed, - load_sigma_fields=True, cache_static_arrays=cache_static_arrays, - max_cache_size=max_cache_size, transforms=transforms, device=device, ) def _build_transforms( - *, - data_path: Union[str, Path], - case_type: Optional[str], + case_type: str, flux_normalization_stats_file: Union[str, Path], flux_clip_threshold: float, seed: Optional[int], @@ -366,19 +290,7 @@ def _build_transforms( fourier_base_frequency: float, include_q_in_embedding: bool = True, ) -> Compose: - """Assemble the canonical RTE transform pipeline. - - Steps: - - 1. ``RTEFluxLogClip`` + ``Normalize`` — flux: log+clip, then z-score. - 2. ``SteadyStateSampler`` — first snapshot input, final snapshot target. - 3. ``MaterialPropertyExtractor`` — always. - 4. ``Normalize`` (``physical_properties``) — per-column z-score via broadcast. - 5. ``SpatialSampler`` — always. - 6. ``RTEBackupCoords`` + ``Translate`` + ``Scale`` — when normalize_coordinates. - 7. ``FourierFeatures`` — when use_fourier_features. - 8. ``TransolverAdapter`` — repack into Transolver-ready fields. - """ + """Assemble the canonical RTE transform pipeline.""" flux_stats = load_flux_stats(flux_normalization_stats_file) if abs(flux_stats["clip_threshold"] - flux_clip_threshold) > 1e-10: raise ValueError( @@ -404,7 +316,7 @@ def _build_transforms( if not material_stats_path.exists(): raise FileNotFoundError( f"Material statistics file not found: {material_stats_path}\n" - f"Run compute_normalizations.py to generate it." + f"Run scripts/compute_normalizations.py to generate it." ) material_stats = load_material_stats(material_stats_path) transform_list.append( @@ -416,11 +328,6 @@ def _build_transforms( transform_list.append(SpatialSampler(num_points=num_spatial_points, seed=seed)) if normalize_coordinates: - if case_type is None: - raise ValueError( - "case_type is required when normalize_coordinates=True " - "(used to look up the global domain bounds)." - ) center, half_extent = coord_translate_scale_params(case_type) transform_list.append(RTEBackupCoords()) transform_list.append( @@ -455,169 +362,6 @@ def _build_transforms( return Compose(transform_list) -# ========================================================================= -# Distributed preload barrier -# ========================================================================= -# -# File-marker rank-sequencing helpers used to serialize the per-rank mesh -# preload step in multi-GPU training. Sequencing avoids I/O contention and -# leverages OS page cache reuse across ranks. - - -def _wait_for_rank_preload( - my_rank: int, - target_rank: int, - barrier_dir: str, - timeout: int = 7200, -) -> None: - barrier_path = Path(barrier_dir) - barrier_path.mkdir(parents=True, exist_ok=True) - marker_file = barrier_path / f".preload_done_rank{target_rank}" - - if my_rank == target_rank: - marker_file.touch() - return - - start = time.time() - while not marker_file.exists(): - if time.time() - start > timeout: - raise TimeoutError( - f"Rank {my_rank} timed out waiting for rank {target_rank} " - f"preload after {timeout}s." - ) - time.sleep(1.0) - - -def _cleanup_preload_markers(barrier_dir: str, world_size: int) -> None: - barrier_path = Path(barrier_dir) - for r in range(world_size): - marker_file = barrier_path / f".preload_done_rank{r}" - try: - marker_file.unlink() - except FileNotFoundError: - pass - - -def _distributed_preload( - datasets: Dict[str, Any], - dist, - cfg: DictConfig, - logger: logging.Logger, -) -> None: - """Preload static arrays (and steady-state flux) into main-process memory. - - Distributed variant sequences ranks via file markers to avoid I/O - contention and leverage OS page cache reuse. Single-process variant is - a straight call to ``preload_to_memory`` on each dataset. - """ - targets = [ - (phase, ds) for phase, ds in datasets.items() if phase in ("train", "val") - ] - if not targets: - return - - if dist.distributed and torch_dist.is_initialized() and dist.world_size > 1: - if dist.rank == 0: - logger.info("\n" + "=" * 60) - logger.info("DISTRIBUTED PRELOADING") - logger.info("=" * 60) - logger.info(f"Ranks preload sequentially (world_size={dist.world_size})") - - barrier_dir = cfg.output - if dist.rank == 0: - _cleanup_preload_markers(barrier_dir, dist.world_size) - print("[Rank 0] Cleaned up stale preload markers", flush=True) - - time.sleep(1.0) - - if dist.rank > 0: - print( - f"[Rank {dist.rank}] Waiting for rank {dist.rank - 1} to finish preloading...", - flush=True, - ) - for prev in range(dist.rank): - _wait_for_rank_preload(dist.rank, prev, barrier_dir, timeout=7200) - - print(f"[Rank {dist.rank}] Starting preload...", flush=True) - for _, ds in targets: - ds.preload_to_memory(verbose=True) - print(f"[Rank {dist.rank}] Preload complete!", flush=True) - - _wait_for_rank_preload(dist.rank, dist.rank, barrier_dir, timeout=7200) - print( - f"[Rank {dist.rank}] Signaled completion, waiting for all ranks...", - flush=True, - ) - for other in range(dist.world_size): - _wait_for_rank_preload(dist.rank, other, barrier_dir, timeout=7200) - print(f"[Rank {dist.rank}] All ranks completed preloading!", flush=True) - - torch_dist.barrier() - - if dist.rank == 0: - _cleanup_preload_markers(barrier_dir, dist.world_size) - logger.info("=" * 60 + "\n") - else: - if dist is None or dist.rank == 0: - logger.info("\n" + "=" * 60) - logger.info("SINGLE-GPU PRELOADING") - logger.info("=" * 60) - logger.info("Loading static arrays into memory...") - for _, ds in targets: - ds.preload_to_memory(verbose=(dist is None or dist.rank == 0)) - if dist is None or dist.rank == 0: - logger.info("=" * 60 + "\n") - - -# ========================================================================= -# DataLoader builder -# ========================================================================= -# -# ``build_dataloaders`` is the main entry point used by ``train.py`` and -# ``inference.py``. It orchestrates dataset creation, distributed-preload -# synchronization, material sanity logging, sampler construction, and -# per-phase :class:`physicsnemo.datapipes.DataLoader` assembly. - - -def _log_material_sanity(dataset, cfg: DictConfig, logger: logging.Logger) -> None: - """Log material-property ranges from the first sample for diagnostics.""" - if len(dataset) == 0: - return - sample = dataset.get_transformed_sample(0) - # The trailing ``TransolverAdapter`` produces a TensorDict whose material - # info lives under ``embedding`` (sigma_a, sigma_s, sigma_t, [Q]). Fall back - # to the un-adapted ``physical_properties`` key if the adapter wasn't run. - if "embedding" in sample: - phys = sample["embedding"] - elif "physical_properties" in sample: - phys = sample["physical_properties"] - else: - return - - if isinstance(phys, torch.Tensor): - phys = phys.detach().cpu().numpy() - - sigma_a = phys[:, 0] - sigma_s = phys[:, 1] - Q = phys[:, 3] if phys.shape[1] >= 4 else None - - logger.info("\nMaterial property ranges (first sample):") - logger.info( - f" sigma_a: [{sigma_a.min():.2f}, {sigma_a.max():.2f}] " - f"(unique: {len(set(sigma_a.tolist()))})" - ) - logger.info( - f" sigma_s: [{sigma_s.min():.2f}, {sigma_s.max():.2f}] " - f"(unique: {len(set(sigma_s.tolist()))})" - ) - if Q is not None: - logger.info(f" Q: {sorted(set(Q.tolist()))}") - if len(set(sigma_a.tolist())) > 1 or len(set(sigma_s.tolist())) > 1: - logger.info(f" Heterogeneous materials detected ({cfg.case.type})") - else: - logger.info(f" Homogeneous materials ({cfg.case.type})") - - def _make_loader( dataset, cfg: DictConfig, @@ -666,27 +410,9 @@ def _make_loader( ) -class DistributedEvalSampler(Sampler[int]): - """Shard eval data across ranks without padding or duplicate samples.""" - - def __init__(self, dataset: Dataset, num_replicas: int, rank: int): - self.dataset = dataset - self.num_replicas = num_replicas - self.rank = rank - - def __iter__(self): - return iter(range(self.rank, len(self.dataset), self.num_replicas)) - - def __len__(self) -> int: - if self.rank >= len(self.dataset): - return 0 - return ((len(self.dataset) - 1 - self.rank) // self.num_replicas) + 1 - - def build_dataloaders( cfg: DictConfig, dist=None, - *, collate_fn: Optional[Callable] = None, phases: Iterable[str] = ("train", "val"), test_batch_size: int = 1, @@ -724,15 +450,7 @@ def build_dataloaders( logger.info("Mapping mode: steady-state first-to-final flux") if common_kwargs["split_file"]: logger.info(f"Using predefined splits from: {common_kwargs['split_file']}") - if common_kwargs["max_cache_size"] == -1: - logger.info("Data caching: UNLIMITED") - else: - logger.info( - f"Data caching: LRU max_cache_size={common_kwargs['max_cache_size']}" - ) - # Pick a sensible device default. The PhysicsNeMo Dataset will move - # tensors there before transforms run; transforms then operate on GPU. if dist is not None and getattr(dist, "device", None) is not None: device = dist.device else: @@ -745,20 +463,9 @@ def build_dataloaders( for phase in phases } - # Distributed/single preloading (training only; eval skips). - if ( - cfg.data.get("preload_data", False) - and common_kwargs["max_cache_size"] == -1 - and dist is not None - and any(p in datasets for p in ("train", "val")) - ): - _distributed_preload(datasets, dist, cfg, logger) - if rank_zero: split_summary = ", ".join(f"{p}={len(datasets[p])}" for p in phases) logger.info(f"\nData split summary: {split_summary}") - if "train" in datasets: - _log_material_sanity(datasets["train"], cfg, logger) # Samplers + loaders. train_sampler: Optional[DistributedSampler] = None @@ -777,10 +484,11 @@ def build_dataloaders( ) train_sampler = sampler else: - sampler = DistributedEvalSampler( + sampler = DistributedSampler( datasets[phase], num_replicas=dist.world_size, rank=dist.rank, + shuffle=False, ) loaders[phase] = _make_loader( @@ -793,11 +501,3 @@ def build_dataloaders( ) return loaders, train_sampler - - -__all__ = [ - "TransolverAdapter", - "collate_no_padding", - "build_dataloaders", - "DistributedEvalSampler", -] From adfe2b90900aefcea13fe2ea9c9bbe8f36a08aba Mon Sep 17 00:00:00 2001 From: Carmelo Gonzales Date: Tue, 12 May 2026 13:52:04 -0700 Subject: [PATCH 36/68] refactor: consolidating loss calculations, pinning one scheduler, removing stale code --- .../radiation_transport/src/losses.py | 864 ++++-------------- 1 file changed, 202 insertions(+), 662 deletions(-) diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/losses.py b/examples/cfd/nuclear_engineering/radiation_transport/src/losses.py index 80d04f83c5..ebffbe083d 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/losses.py +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/losses.py @@ -14,161 +14,66 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Losses: MSE / region-weighted / physics-informed / QoI helpers + LR schedulers. - -This module consolidates every "loss" concept the trainer touches: - -* Learning-rate schedulers (warmup + cosine, plus a constant fallback). -* Regression losses on the (possibly padded) flux tensor: ``loss_fn``, - ``masked_mse_loss``, ``region_weighted_loss_fn``. -* Physics-informed loss for the radiation-transport surrogate: per-case - QoI loss (lattice / hohlraum) computed in physical flux space using the - differentiable PyTorch QoI evaluators, plus a ``compute_physics_loss`` - dispatcher that ``train.py`` drives. -* Differentiable PyTorch QoI evaluators - (``evaluate_lattice_qoi_torch`` / ``evaluate_hohlraum_qoi_torch``) - used by the physics loss above. The numpy-side evaluators used by - ``inference.py`` live in ``inference.py``. -* ``parse_loss_config`` — pulls the ``train.physics_loss`` and - ``train.region_weights`` blocks out of the Hydra config into a flat dict - the trainer consumes. - -Module is pure compute: it has no dependency on sibling source files. -``denormalize_flux_from_stats`` delegates to ``transforms.denormalize_flux`` so the physics loss -can convert log-normalized model outputs back to physical flux space -without importing from ``transforms.py``. -""" - from __future__ import annotations -import math from typing import Any, Mapping, Optional import torch from omegaconf import DictConfig +from qoi import ( + evaluate_hohlraum_qoi_torch, + evaluate_lattice_qoi_torch, + extract_geometry_params, +) +from transforms import denormalize_flux -# ========================================================================= -# Schedulers -# ========================================================================= - - -class WarmupCosineScheduler(torch.optim.lr_scheduler._LRScheduler): - """ - Learning rate scheduler with linear warmup followed by cosine annealing. - - During warmup (epochs 0 to warmup_epochs-1): - lr = min_lr + (max_lr - min_lr) * (epoch / warmup_epochs) - - After warmup (epochs warmup_epochs to total_epochs): - lr = min_lr + 0.5 * (max_lr - min_lr) * (1 + cos(pi * progress)) - where progress = (epoch - warmup_epochs) / (total_epochs - warmup_epochs) - """ - - def __init__( - self, - optimizer: torch.optim.Optimizer, - warmup_epochs: int, - total_epochs: int, - min_lr: float = 1e-6, - last_epoch: int = -1, - ): - self.warmup_epochs = warmup_epochs - self.total_epochs = total_epochs - self.min_lr = min_lr - super().__init__(optimizer, last_epoch) - - def get_lr(self): - """Return the per-group learning rates for the current ``last_epoch``.""" - if self.last_epoch < self.warmup_epochs: - # Linear warmup - warmup_factor = (self.last_epoch + 1) / max(1, self.warmup_epochs) - return [ - self.min_lr + (base_lr - self.min_lr) * warmup_factor - for base_lr in self.base_lrs - ] - else: - # Cosine annealing - progress = (self.last_epoch - self.warmup_epochs) / max( - 1, self.total_epochs - self.warmup_epochs - ) - cosine_factor = 0.5 * (1 + math.cos(math.pi * progress)) - return [ - self.min_lr + (base_lr - self.min_lr) * cosine_factor - for base_lr in self.base_lrs - ] +__all__ = [ + # Schedulers + "create_scheduler", + # Regression losses + "region_weighted_loss_fn", + "parse_loss_config", + "physics_loss_weight_for_epoch", + # Physics loss + "compute_physics_loss", + "compute_lattice_qoi_loss", + "compute_hohlraum_qoi_loss", +] def create_scheduler(cfg: DictConfig, optimizer: torch.optim.Optimizer, logger=None): - """Create the LR scheduler. - - Supports: - - cosine: Warmup + cosine annealing (recommended; default) - - constant: No decay (useful for overfit tests) - """ - scheduler_type = cfg.train.get("scheduler_type", "cosine") + """Build the LR scheduler: linear warmup chained into cosine annealing.""" warmup_epochs = cfg.train.get("warmup_epochs", 5) + peak_lr = cfg.train.learning_rate min_lr = cfg.train.get("min_learning_rate", 1e-6) + total_epochs = cfg.train.epochs if logger: - logger.info("\nLearning rate schedule:") - logger.info(f" Type: {scheduler_type}") - logger.info(f" Peak LR: {cfg.train.learning_rate}") + logger.info("\nLearning rate schedule (warmup + cosine):") + logger.info(f" Peak LR: {peak_lr}") logger.info(f" Min LR: {min_lr}") - if warmup_epochs > 0: - logger.info(f" Warmup epochs: {warmup_epochs}") - - if scheduler_type == "constant": - return torch.optim.lr_scheduler.ConstantLR( - optimizer, - factor=1.0, - total_iters=cfg.train.epochs, - ) - if scheduler_type == "cosine": - return WarmupCosineScheduler( - optimizer, - warmup_epochs=warmup_epochs, - total_epochs=cfg.train.epochs, - min_lr=min_lr, - ) - raise ValueError( - f"Unknown scheduler_type {scheduler_type!r}; expected 'cosine' or 'constant'." - ) - - -# ========================================================================= -# Regression losses -# ========================================================================= + logger.info(f" Warmup epochs: {warmup_epochs}") + warmup = torch.optim.lr_scheduler.LinearLR( + optimizer, + start_factor=min_lr / peak_lr, + end_factor=1.0, + total_iters=max(warmup_epochs, 1), + ) + cosine = torch.optim.lr_scheduler.CosineAnnealingLR( + optimizer, + T_max=max(total_epochs - warmup_epochs, 1), + eta_min=min_lr, + ) + return torch.optim.lr_scheduler.SequentialLR( + optimizer, + schedulers=[warmup, cosine], + milestones=[warmup_epochs], + ) -def masked_mse_loss( - output: torch.Tensor, target: torch.Tensor, mask: torch.Tensor = None -) -> torch.Tensor: - """ - Calculate MSE loss with optional masking for padded values. - - Used by Transolver training. - - Args: - output: Predicted values (B, N, 1) - target: Ground truth values (B, N, 1) - mask: Boolean mask (B, N) - True for real points, False for padding - - Returns: - Scalar loss value - """ - squared_error = (output - target) ** 2 - - if mask is not None: - # expand mask to match output shape - mask_expanded = mask.unsqueeze(-1) - # only compute loss on non-padded points - masked_error = squared_error * mask_expanded - loss = masked_error.sum() / mask_expanded.sum() - else: - loss = torch.mean(squared_error) - return loss +_VOID_LABELS = {"hohlraum": 4, "lattice": 2} def region_weighted_loss_fn( @@ -176,84 +81,48 @@ def region_weighted_loss_fn( target: torch.Tensor, material_labels: torch.Tensor, case_type: str, - loss_type: str = "mse", - padded_value: float = -10, void_weight: float = 3.0, material_weight: float = 1.0, ) -> torch.Tensor: - """ - Calculate region-weighted loss based on material labels. + """Weighted MSE that penalizes void cells more than material cells. - Uses discrete material labels from the material mappers to identify regions: - - Void (fill gas): radiation streams through, creates fine features - - Material (walls, capsule, absorbers): solid regions + Material-label definitions (set by ``MaterialPropertyExtractor``): - Weights void regions more heavily than material regions to improve - fine feature learning where radiation streaming occurs. + * Hohlraum: ``0`` black wall, ``1`` red wall, ``2`` green wall, + ``3`` blue capsule (all material); ``4`` white fill gas (void). + * Lattice: ``0`` blue absorber, ``1`` red scattering source + (material); ``2`` white background (void). - Material label definitions: - Hohlraum: - 0: Black (walls) - material - 1: Red (walls) - material - 2: Green (walls) - material - 3: Blue (capsule) - material - 4: White (fill gas) - void - - Lattice: - 0: Blue (absorber) - material - 1: Red (scattering source) - material - 2: White (background) - void + Void cells are where radiation streams through and the surrogate has + to capture fine flux features, so we weight their squared error more + heavily. Args: - output: Predicted values (B, N, 1) - target: Ground truth values (B, N, 1) - material_labels: Material label per cell (B, N) or (B, N, 1), integer values - case_type: "hohlraum" or "lattice" - loss_type: Type of loss - "mse" or "weighted_rel_l2" (relative-L2 form, not true RMSE) - padded_value: Value used for padding (will be masked out) - void_weight: Weight for void (fill gas) regions - material_weight: Weight for solid material regions + output, target: Predicted vs ground-truth flux, shape ``(B, N, 1)``. + material_labels: Per-cell label, shape ``(B, N)`` or ``(B, N, 1)``. + case_type: ``"hohlraum"`` or ``"lattice"``. + void_weight, material_weight: Per-region weights. Returns: - Scalar loss value + Scalar weighted-MSE loss. """ - # Create padding mask - mask = (abs(target - padded_value) > 1e-3).float() + if case_type not in _VOID_LABELS: + raise ValueError( + f"Unknown case_type: {case_type}. Must be 'hohlraum' or 'lattice'." + ) - # Squeeze material_labels if needed labels = ( material_labels.squeeze(-1) if material_labels.dim() == 3 else material_labels ) + is_void = labels == _VOID_LABELS[case_type] # (B, N) bool + weights = ( + torch.where(is_void, float(void_weight), float(material_weight)) + .to(dtype=torch.float32) + .unsqueeze(-1) + ) # (B, N, 1) - # Identify void regions based on case type - # Void label: 4 for hohlraum (white/fill gas), 2 for lattice (white/background) - if case_type.lower() == "hohlraum": - is_void = (labels == 4).float() # (B, N) - elif case_type.lower() == "lattice": - is_void = (labels == 2).float() # (B, N) - else: - raise ValueError( - f"Unknown case_type: {case_type}. Must be 'hohlraum' or 'lattice'" - ) - - # Compute per-point weights - weights = is_void * void_weight + (1.0 - is_void) * material_weight # (B, N) - weights = weights.unsqueeze(-1) # (B, N, 1) to match output shape - - # Apply padding mask to weights - weights = weights * mask - - # Compute weighted squared error - squared_error = (output - target) ** 2.0 - weighted_error = weights * squared_error - - if loss_type == "weighted_rel_l2": - weighted_target_sq = weights * target**2.0 - loss = torch.sqrt(weighted_error.sum() / (weighted_target_sq.sum() + 1e-8)) - else: # mse - loss = weighted_error.sum() / (weights.sum() + 1e-8) - - return loss + squared_error = (output - target) ** 2 + return (weights * squared_error).sum() / (weights.sum() + 1e-8) def parse_loss_config( @@ -263,7 +132,11 @@ def parse_loss_config( ) -> dict: """ Parse the common loss configuration options shared across all models: - physics loss, region-weighted loss. + physics loss (including warmup schedule), region-weighted loss. + + The returned ``physics_loss_weight`` is the **base** weight; per-epoch + warmup ramping is applied by :func:`physics_loss_weight_for_epoch` inside + the trainer loop. Args: cfg: Hydra config @@ -271,18 +144,24 @@ def parse_loss_config( logger: Logger Returns: - Dict with keys: use_physics_loss, physics_loss_weight, physics_loss_mse_weight, - qoi_region, use_region_weighted_loss, region_weight_cfg + Dict with keys: ``use_physics_loss``, ``physics_loss_weight``, + ``physics_loss_mse_weight``, ``physics_loss_warmup_epochs``, + ``physics_loss_warmup_start_fraction``, + ``use_region_weighted_loss``, ``region_weight_cfg``. """ use_physics_loss = cfg.train.get("use_physics_loss", False) if use_physics_loss: physics_loss_weight = cfg.train.physics_loss.weight physics_loss_mse_weight = cfg.train.physics_loss.mse_weight - qoi_region = cfg.train.physics_loss.get("qoi_region", "center") + physics_loss_warmup_epochs = cfg.train.physics_loss.get("warmup_epochs", 0) + physics_loss_warmup_start_fraction = cfg.train.physics_loss.get( + "warmup_start_fraction", 0.0 + ) else: physics_loss_weight = 0.0 physics_loss_mse_weight = 1.0 - qoi_region = "center" + physics_loss_warmup_epochs = 0 + physics_loss_warmup_start_fraction = 0.0 use_region_weighted_loss = cfg.train.get("use_region_weighted_loss", False) region_weight_cfg = { @@ -297,7 +176,11 @@ def parse_loss_config( logger.info("\nPhysics loss configuration:") logger.info(f" Weight: {physics_loss_weight}") logger.info(f" MSE weight: {physics_loss_mse_weight}") - logger.info(f" QoI region: {qoi_region}") + if physics_loss_warmup_epochs > 0: + logger.info(f" Warmup epochs: {physics_loss_warmup_epochs}") + logger.info( + f" Warmup start fraction: {physics_loss_warmup_start_fraction}" + ) if use_region_weighted_loss: logger.info("Region-weighted loss: enabled") logger.info(f" Void weight: {region_weight_cfg['void_weight']}") @@ -307,68 +190,62 @@ def parse_loss_config( "use_physics_loss": use_physics_loss, "physics_loss_weight": physics_loss_weight, "physics_loss_mse_weight": physics_loss_mse_weight, - "qoi_region": qoi_region, + "physics_loss_warmup_epochs": physics_loss_warmup_epochs, + "physics_loss_warmup_start_fraction": physics_loss_warmup_start_fraction, "use_region_weighted_loss": use_region_weighted_loss, "region_weight_cfg": region_weight_cfg, } -# ========================================================================= -# Physics loss -# ========================================================================= -# -# Physics-based loss functions using QoI computations. -# -# Compares physics-based quantities of interest (QoIs) computed from model -# predictions against ground truth. -# -# - QoIs (absorption integrals) are defined in **physical flux space**. -# If your model is trained on a normalized/log-transformed flux, you must -# denormalize before computing QoIs, otherwise the "physics loss" is -# optimizing a different (non-physical) quantity. -# - QoI computations can be numerically sensitive under AMP/FP16 due to large -# reductions (sums over many cells). Prefer running these in FP32 (disable -# autocast) in the training loop. - - -def denormalize_flux_from_stats( - normalized_flux: torch.Tensor, - flux_normalization_stats: Mapping[str, Any], -) -> torch.Tensor: - """Invert the ``RTEFluxLogClip + Normalize`` chain for QoI evaluation. +def physics_loss_weight_for_epoch(loss_cfg: dict, epoch: int) -> float: + """Linear ramp of the physics-loss weight over the warmup window. - Thin wrapper over ``transforms.denormalize_flux`` that enforces the - presence of the stats dict (callers in physics-loss code reach here - only after validating shapes, so the stats must be available). + Ramps from ``warmup_start_fraction * base`` at epoch 0 to ``base`` at + ``warmup_epochs``, then stays at ``base``. With no warmup configured + (``warmup_epochs <= 0``), returns ``base`` unchanged. """ - if flux_normalization_stats is None: - raise ValueError("flux_normalization_stats is required for QoI denormalization") - # Sibling import is safe: transforms.py is foundational and does not - # import from losses.py (verified via static cross-module check). - from transforms import denormalize_flux - - return denormalize_flux(normalized_flux, flux_normalization_stats) + base = loss_cfg.get("physics_loss_weight", 0.0) + warmup_epochs = loss_cfg.get("physics_loss_warmup_epochs", 0) + if warmup_epochs <= 0 or epoch >= warmup_epochs: + return base + start_frac = loss_cfg.get("physics_loss_warmup_start_fraction", 0.0) + progress = epoch / max(1, warmup_epochs) + return (start_frac + (1.0 - start_frac) * progress) * base def _relative_squared_error_loss( pred: torch.Tensor, target: torch.Tensor, - device: torch.device, epsilon: float = 1e-10, -) -> tuple[torch.Tensor, dict]: - """Compute mean relative squared error between pred and target vectors.""" - relative_error = (pred - target) / (torch.abs(target) + epsilon) - squared_error = relative_error**2 - - is_valid = ( - torch.isfinite(squared_error) & torch.isfinite(pred) & torch.isfinite(target) - ) +) -> torch.Tensor: + """Mean of ``((pred - target) / |target|)^2`` over finite cells. + Returns ``0.0`` (no graph) when every cell is non-finite — degenerate but + keeps the trainer alive instead of propagating NaN. + """ + squared = ((pred - target) / (torch.abs(target) + epsilon)) ** 2 + is_valid = torch.isfinite(squared) & torch.isfinite(pred) & torch.isfinite(target) if not is_valid.any(): - return torch.tensor(0.0, device=device, requires_grad=True), {} + return torch.zeros((), device=pred.device) + return squared[is_valid].mean() + - loss = squared_error[is_valid].mean() - return loss, {} +def _prepare_for_qoi( + pred: torch.Tensor, + target: torch.Tensor, + sim_time: torch.Tensor, + stats: Optional[Mapping[str, Any]], +) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: + """Squeeze ``(B, N, 1) -> (B, N)``, denormalize, then ``(B, 1, N)`` for QoI.""" + if pred.ndim == 3: + pred = pred.squeeze(-1) + if target.ndim == 3: + target = target.squeeze(-1) + if stats is not None: + pred = denormalize_flux(pred, stats) + target = denormalize_flux(target, stats) + sim_times = sim_time.unsqueeze(-1) if sim_time.ndim == 1 else sim_time + return pred.unsqueeze(1), target.unsqueeze(1), sim_times def compute_lattice_qoi_loss( @@ -382,80 +259,38 @@ def compute_lattice_qoi_loss( flux_normalization_stats: Optional[Mapping[str, Any]] = None, epsilon: float = 1e-10, ) -> tuple[torch.Tensor, dict[str, float]]: - """ - Compute QoI-based physics loss for lattice problems. - - Computes instantaneous absorption QoI from predicted flux and target flux, - then compares them using relative squared error to provide scale-invariant gradients. - - QoIs are computed in physical flux space. If `flux_normalization_stats` is provided, - both `predicted_flux` and `target_flux` are denormalized before QoI evaluation. + """Relative-squared-error loss on the lattice absorption QoI. - Uses PyTorch operations throughout to maintain gradient flow for backpropagation. - - Args: - predicted_flux: Model predictions (normalized), shape (B, N, 1) or (B, N) - target_flux: Ground truth flux (normalized), shape (B, N, 1) or (B, N) - cell_centers: Cell center coordinates (unnormalized), shape (B, N, 3) - cell_areas: Cell areas, shape (B, N) - sigma_t: Total cross-section, shape (B, N) - sigma_s: Scattering cross-section, shape (B, N) - sim_time: Simulation time for each sample, shape (B,) - - Returns: - Scalar tensor with relative squared error loss between predicted and target QoI + QoIs are evaluated in physical flux space; if normalization stats are + supplied, both flux tensors are denormalized first. Differentiable end + to end so the loss backprops into the model. """ - # ensure correct shape: (B, N) - if predicted_flux.ndim == 3: - predicted_flux = predicted_flux.squeeze(-1) # (B, N, 1) -> (B, N) - if target_flux.ndim == 3: - target_flux = target_flux.squeeze(-1) # (B, N, 1) -> (B, N) - - # compute QoIs in physical flux space - if flux_normalization_stats is not None: - predicted_flux = denormalize_flux_from_stats( - predicted_flux, flux_normalization_stats - ) - target_flux = denormalize_flux_from_stats(target_flux, flux_normalization_stats) - - # reshape for QoI computation: (B, 1, N) for single timestep - predicted_flux_qoi = predicted_flux.unsqueeze(1) # (B, 1, N) - target_flux_qoi = target_flux.unsqueeze(1) # (B, 1, N) - - # prepare sim_times: (B, 1) - sim_times = sim_time.unsqueeze(-1) if sim_time.ndim == 1 else sim_time # (B, 1) - - # compute QoI for predicted flux using differentiable PyTorch implementation + pred_qoi, target_qoi, sim_times = _prepare_for_qoi( + predicted_flux, target_flux, sim_time, flux_normalization_stats + ) qoi_pred = evaluate_lattice_qoi_torch( - cell_centers=cell_centers, # (B, N, 3) - cell_areas=cell_areas, # (B, N) - sigma_t=sigma_t, # (B, N) - sigma_s=sigma_s, # (B, N) - scalar_flux=predicted_flux_qoi, # (B, 1, N) - sim_times=sim_times, # (B, 1) + cell_centers, + cell_areas, + sigma_t, + sigma_s, + pred_qoi, + sim_times, ) - - # compute QoI for target flux (no gradients needed for target) with torch.no_grad(): qoi_target = evaluate_lattice_qoi_torch( - cell_centers=cell_centers, - cell_areas=cell_areas, - sigma_t=sigma_t, - sigma_s=sigma_s, - scalar_flux=target_flux_qoi, - sim_times=sim_times, + cell_centers, + cell_areas, + sigma_t, + sigma_s, + target_qoi, + sim_times, ) - - # extract instantaneous absorption: (B, 1) -> (B,) - qoi_pred_value = qoi_pred["cur_absorption"][:, 0] - qoi_target_value = qoi_target["cur_absorption"][:, 0] - - loss, loss_details = _relative_squared_error_loss( - qoi_pred_value, qoi_target_value, predicted_flux.device, epsilon + loss = _relative_squared_error_loss( + qoi_pred["cur_absorption"][:, 0], + qoi_target["cur_absorption"][:, 0], + epsilon, ) - loss_details["loss_qoi_absorption"] = loss.item() - - return loss, loss_details + return loss, {"loss_qoi_absorption": loss.item()} def compute_hohlraum_qoi_loss( @@ -467,131 +302,57 @@ def compute_hohlraum_qoi_loss( sigma_s: torch.Tensor, sim_time: torch.Tensor, geometry_params: dict, - qoi_region: str = "all", flux_normalization_stats: Optional[Mapping[str, Any]] = None, epsilon: float = 1e-10, ) -> tuple[torch.Tensor, dict[str, float]]: - """ - Compute QoI-based physics loss for hohlraum problems. - - The loss used for backpropagation is determined by ``qoi_region``: - - "all" (default): mean of the four region losses (center, vertical, - horizontal, total) — every region contributes to the gradient - - "center" | "vertical" | "horizontal": loss on that single region - - "total": loss on the integrated absorption over the whole domain - (computed from the sum of the three spatial regions) + """Mean of the four hohlraum region relative-squared-error losses. - All four region losses are *always* recorded in the details dict so they - are visible in the training log regardless of which region drives the - gradient. - - Returns: - Tuple of (loss_tensor, details_dict). + Loss = mean of {center, vertical, horizontal, total} so every region + contributes to the gradient. All four are recorded in the details dict. """ - # ensure correct shape: (B, N) - if predicted_flux.ndim == 3: - predicted_flux = predicted_flux.squeeze(-1) - if target_flux.ndim == 3: - target_flux = target_flux.squeeze(-1) - - if flux_normalization_stats is not None: - predicted_flux = denormalize_flux_from_stats( - predicted_flux, flux_normalization_stats - ) - target_flux = denormalize_flux_from_stats(target_flux, flux_normalization_stats) - - predicted_flux_qoi = predicted_flux.unsqueeze(1) - target_flux_qoi = target_flux.unsqueeze(1) - sim_times = sim_time.unsqueeze(-1) if sim_time.ndim == 1 else sim_time - + pred_qoi, target_qoi, sim_times = _prepare_for_qoi( + predicted_flux, target_flux, sim_time, flux_normalization_stats + ) qoi_pred = evaluate_hohlraum_qoi_torch( - cell_centers=cell_centers, - cell_areas=cell_areas, - sigma_t=sigma_t, - sigma_s=sigma_s, - scalar_flux=predicted_flux_qoi, - sim_times=sim_times, - geometry_params=geometry_params, + cell_centers, + cell_areas, + sigma_t, + sigma_s, + pred_qoi, + sim_times, + geometry_params, ) - with torch.no_grad(): qoi_target = evaluate_hohlraum_qoi_torch( - cell_centers=cell_centers, - cell_areas=cell_areas, - sigma_t=sigma_t, - sigma_s=sigma_s, - scalar_flux=target_flux_qoi, - sim_times=sim_times, - geometry_params=geometry_params, + cell_centers, + cell_areas, + sigma_t, + sigma_s, + target_qoi, + sim_times, + geometry_params, ) - region_keys = ( + region_losses: dict[str, torch.Tensor] = {} + pred_sum = target_sum = None + for key in ( "cur_absorption_center", "cur_absorption_vertical", "cur_absorption_horizontal", - ) - details: dict[str, float] = {} - - total_pred = torch.zeros(predicted_flux.shape[0], device=predicted_flux.device) - total_target = torch.zeros_like(total_pred) - region_losses: dict[str, torch.Tensor] = {} - - for key in region_keys: - p = qoi_pred[key][:, 0] - t = qoi_target[key][:, 0] - region_loss, _ = _relative_squared_error_loss( - p, t, predicted_flux.device, epsilon - ) - short = key.replace("cur_absorption_", "") - region_losses[short] = region_loss - total_pred = total_pred + p - total_target = total_target + t - - total_loss, _ = _relative_squared_error_loss( - total_pred, total_target, predicted_flux.device, epsilon - ) - region_losses["total"] = total_loss - - # Always log every region's loss so all four are visible in train.log - # regardless of which region(s) drive the gradient. - for region_name, region_loss in region_losses.items(): - details[f"loss_qoi_{region_name}"] = region_loss.item() - - if qoi_region == "all": - # Mean of the four region losses — every region contributes to the gradient. - loss = torch.stack(list(region_losses.values())).mean() - details["loss_qoi_all"] = loss.item() - elif qoi_region in region_losses: - loss = region_losses[qoi_region] - else: - raise ValueError( - f"Unknown qoi_region: {qoi_region}. " - f"Must be 'all' or one of: {list(region_losses.keys())}" + ): + p, t = qoi_pred[key][:, 0], qoi_target[key][:, 0] + region_losses[key.removeprefix("cur_absorption_")] = ( + _relative_squared_error_loss(p, t, epsilon) ) + pred_sum = p if pred_sum is None else pred_sum + p + target_sum = t if target_sum is None else target_sum + t + region_losses["total"] = _relative_squared_error_loss(pred_sum, target_sum, epsilon) + loss = torch.stack(list(region_losses.values())).mean() + details = {f"loss_qoi_{name}": val.item() for name, val in region_losses.items()} return loss, details -_HOHLRAUM_GEOMETRY_KEYS = ("ulr", "llr", "urr", "lrr", "hlr", "hrr", "cx", "cy") - - -def extract_geometry_params(metadata) -> dict: - """Extract hohlraum geometry parameters from sample metadata. - - Reads ``simulation_params.parameters`` out of the sidecar-derived metadata - dict and returns the 8-key geometry dict consumed by the hohlraum QoI - evaluator (``ulr, llr, urr, lrr, hlr, hrr, cx, cy``). Accepts either a - single metadata dict or a batched list of dicts. - """ - if isinstance(metadata, (list, tuple)): - metadata = metadata[0] if metadata else {} - if not isinstance(metadata, dict): - return {} - - params = metadata.get("simulation_params", {}).get("parameters", {}) - return {k: float(params[k]) for k in _HOHLRAUM_GEOMETRY_KEYS if k in params} - - def compute_physics_loss( case_type: str, predicted_flux: torch.Tensor, @@ -604,250 +365,29 @@ def compute_physics_loss( metadata: list = None, flux_normalization_stats: dict | None = None, qoi_epsilon: float = 1e-10, - qoi_region: str = "all", ) -> tuple[torch.Tensor, dict[str, float]]: - """ - Compute physics loss based on case type. - - For hohlraum, ``qoi_region`` selects which region(s) drive the gradient: - ``"all"`` (default) averages the four region losses (center, vertical, - horizontal, total) so every region contributes; ``"center"`` / - ``"vertical"`` / ``"horizontal"`` / ``"total"`` use that single region. - Either way, all four region losses are recorded in the details dict. - - Returns: - Tuple of (loss_tensor, details_dict) with per-region QoI losses for logging. - """ + """Dispatch the per-case QoI loss; returns ``(loss, per-region details)``.""" + common = dict( + predicted_flux=predicted_flux, + target_flux=target_flux, + cell_centers=cell_centers, + cell_areas=cell_areas, + sigma_t=sigma_t, + sigma_s=sigma_s, + sim_time=sim_time, + flux_normalization_stats=flux_normalization_stats, + epsilon=qoi_epsilon, + ) if case_type == "lattice": - return compute_lattice_qoi_loss( - predicted_flux=predicted_flux, - target_flux=target_flux, - cell_centers=cell_centers, - cell_areas=cell_areas, - sigma_t=sigma_t, - sigma_s=sigma_s, - sim_time=sim_time, - flux_normalization_stats=flux_normalization_stats, - epsilon=qoi_epsilon, - ) - elif case_type == "hohlraum": - if metadata is None: - raise ValueError("hohlraum physics loss requires sample metadata") - - geometry_params = extract_geometry_params(metadata) - + return compute_lattice_qoi_loss(**common) + if case_type == "hohlraum": + geometry_params = extract_geometry_params(metadata) if metadata else {} if not geometry_params: raise ValueError( - "could not read hohlraum geometry parameters from metadata's " + "hohlraum physics loss requires sample metadata with " "simulation_params.parameters" ) - - return compute_hohlraum_qoi_loss( - predicted_flux=predicted_flux, - target_flux=target_flux, - cell_centers=cell_centers, - cell_areas=cell_areas, - sigma_t=sigma_t, - sigma_s=sigma_s, - sim_time=sim_time, - geometry_params=geometry_params, - qoi_region=qoi_region, - flux_normalization_stats=flux_normalization_stats, - epsilon=qoi_epsilon, - ) - else: - raise ValueError( - f"unknown case type: {case_type}. must be 'lattice' or 'hohlraum'" - ) - - -# ========================================================================= -# QoI helpers (torch) -# ========================================================================= -# -# Differentiable PyTorch QoI evaluators used by the physics loss above. -# These match KiT-RT SNSolverHPC::IterPostprocessing() exactly. -# The numpy-side equivalents (``evaluate_lattice_qoi``, -# ``evaluate_hohlraum_qoi``) live in ``inference.py``. - - -def evaluate_lattice_qoi_torch( - cell_centers: torch.Tensor, - cell_areas: torch.Tensor, - sigma_t: torch.Tensor, - sigma_s: torch.Tensor, - scalar_flux: torch.Tensor, - sim_times: torch.Tensor, -) -> dict[str, torch.Tensor]: - """Compute lattice absorption QoI using PyTorch (differentiable). - - Matches KiT-RT SNSolverHPC::IterPostprocessing() exactly. Steady-state - surrogate ⇒ T=1; ``sim_times`` is accepted for callsite uniformity but - not used. ``batch_size=1`` is enforced repo-wide; if a leading batch dim - is present we recurse on the squeezed slot and re-add it on the way out. - - Args: - cell_centers: (N, 3) or (1, N, 3) - cell_areas: (N,) or (1, N) - sigma_t: (N,) or (1, N) - sigma_s: (N,) or (1, N) - scalar_flux: (T, N) or (1, T, N) — only T=1 is exercised - sim_times: (T,) or (1, T) — unused, kept for callsite uniformity - - Returns: - ``{"cur_absorption": (T,) or (1, T)}`` - """ - if cell_centers.ndim == 3: - if cell_centers.shape[0] != 1: - raise NotImplementedError( - "evaluate_lattice_qoi_torch only supports batch_size=1; " - f"got batch={cell_centers.shape[0]}." - ) - result = evaluate_lattice_qoi_torch( - cell_centers[0], - cell_areas[0], - sigma_t[0], - sigma_s[0], - scalar_flux[0], - sim_times[0] if sim_times.ndim == 2 else sim_times, - ) - return {k: v.unsqueeze(0) for k, v in result.items()} - - x = cell_centers[:, 0] - y = cell_centers[:, 1] - sigma_a = sigma_t - sigma_s - - xy_corrector = -3.5 - lbounds = torch.tensor([1.0, 2.0, 3.0, 4.0, 5.0]) + xy_corrector - ubounds = torch.tensor([2.0, 3.0, 4.0, 5.0, 6.0]) + xy_corrector - - in_absorption = torch.zeros_like(x, dtype=torch.bool) - for k in range(5): - for l in range(5): # noqa: E741 - if (l + k) % 2 == 1: - continue - if (k == 2 and l == 2) or (k == 2 and l == 4): - continue - in_square = ( - (x >= lbounds[k]) - & (x <= ubounds[k]) - & (y >= lbounds[l]) - & (y <= ubounds[l]) - ) - in_absorption = in_absorption | in_square - - if scalar_flux.ndim != 2: - raise ValueError(f"Expected scalar_flux shape (T, N), got {scalar_flux.shape}") - - absorption_density = scalar_flux * sigma_a.unsqueeze(0) * cell_areas.unsqueeze(0) - cur_absorption = torch.sum( - absorption_density * in_absorption.unsqueeze(0).float(), dim=1 - ) - - return {"cur_absorption": cur_absorption} - - -def evaluate_hohlraum_qoi_torch( - cell_centers: torch.Tensor, - cell_areas: torch.Tensor, - sigma_t: torch.Tensor, - sigma_s: torch.Tensor, - scalar_flux: torch.Tensor, - sim_times: torch.Tensor, - geometry_params: dict[str, float], -) -> dict[str, torch.Tensor]: - """Compute hohlraum absorption QoI using PyTorch (differentiable). - - Matches KiT-RT SNSolverHPC hohlraum geometry exactly (including known KiT-RT - quirk of using pos_red_left_bottom for both vertical wall sides). Steady-state - surrogate ⇒ T=1; ``sim_times`` is accepted for callsite uniformity but - not used. ``batch_size=1`` is enforced repo-wide; if a leading batch dim - is present we recurse on the squeezed slot and re-add it on the way out. - - Args: - cell_centers: (N, 3) or (1, N, 3) - cell_areas: (N,) or (1, N) - sigma_t: (N,) or (1, N) - sigma_s: (N,) or (1, N) - scalar_flux: (T, N) or (1, T, N) — only T=1 is exercised - sim_times: (T,) or (1, T) — unused, kept for callsite uniformity - geometry_params: dict with cx, cy, hlr, hrr, llr, ulr, lrr, urr - - Returns: - Dict with ``cur_absorption_{center,vertical,horizontal}``. - """ - if cell_centers.ndim == 3: - if cell_centers.shape[0] != 1: - raise NotImplementedError( - "evaluate_hohlraum_qoi_torch only supports batch_size=1; " - f"got batch={cell_centers.shape[0]}." - ) - result = evaluate_hohlraum_qoi_torch( - cell_centers[0], - cell_areas[0], - sigma_t[0], - sigma_s[0], - scalar_flux[0], - sim_times[0] if sim_times.ndim == 2 else sim_times, - geometry_params, - ) - return {k: v.unsqueeze(0) for k, v in result.items()} - - x = cell_centers[:, 0] - y = cell_centers[:, 1] - - cx = geometry_params["cx"] - cy = geometry_params["cy"] - pos_red_left_border = geometry_params["hlr"] - pos_red_right_border = geometry_params["hrr"] - pos_red_left_bottom = geometry_params["llr"] - pos_red_left_top = geometry_params["ulr"] - pos_red_right_top = geometry_params["urr"] - - sigma_a = sigma_t - sigma_s - - in_center = (x > -0.2 + cx) & (x < 0.2 + cx) & (y > -0.4 + cy) & (y < 0.4 + cy) - # IMPORTANT: matches KiT-RT's behavior of using pos_red_left_bottom for both sides - in_vertical = ( - (x < pos_red_left_border) & (y > pos_red_left_bottom) & (y < pos_red_left_top) - ) | ( - (x > pos_red_right_border) & (y > pos_red_left_bottom) & (y < pos_red_right_top) + return compute_hohlraum_qoi_loss(**common, geometry_params=geometry_params) + raise ValueError( + f"Unknown case type: {case_type}. Must be 'lattice' or 'hohlraum'." ) - in_horizontal = (y > 0.6) | (y < -0.6) - - if scalar_flux.ndim != 2: - raise ValueError(f"Expected scalar_flux shape (T, N), got {scalar_flux.shape}") - - absorption_density = scalar_flux * sigma_a.unsqueeze(0) * cell_areas.unsqueeze(0) - - return { - "cur_absorption_center": torch.sum( - absorption_density * in_center.unsqueeze(0).float(), dim=1 - ), - "cur_absorption_vertical": torch.sum( - absorption_density * in_vertical.unsqueeze(0).float(), dim=1 - ), - "cur_absorption_horizontal": torch.sum( - absorption_density * in_horizontal.unsqueeze(0).float(), dim=1 - ), - } - - -__all__ = [ - # Schedulers - "WarmupCosineScheduler", - "create_scheduler", - # Regression losses - "masked_mse_loss", - "region_weighted_loss_fn", - "parse_loss_config", - # Physics loss - "compute_physics_loss", - "compute_lattice_qoi_loss", - "compute_hohlraum_qoi_loss", - "denormalize_flux_from_stats", - "extract_geometry_params", - # QoI helpers (torch) - "evaluate_lattice_qoi_torch", - "evaluate_hohlraum_qoi_torch", -] From 0832765f459e4da6fd80762c100f1a510fcda406 Mon Sep 17 00:00:00 2001 From: Carmelo Gonzales Date: Tue, 12 May 2026 13:53:37 -0700 Subject: [PATCH 37/68] refactor: consolidating train/trainer logic --- .../radiation_transport/src/train.py | 440 +------- .../radiation_transport/src/trainer.py | 988 ++++++++---------- 2 files changed, 489 insertions(+), 939 deletions(-) diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/train.py b/examples/cfd/nuclear_engineering/radiation_transport/src/train.py index f170f61537..0374c87629 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/train.py +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/train.py @@ -14,44 +14,26 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Hydra entry point for the RTE Transolver training sample. - -Composes the flat ``src/`` modules (``loader``, ``losses``, ``checkpointing``, -``trainer``) into a single training driver. The Transolver model spec -(``build_model``, ``to_device``, ``forward``, ``loss_inputs``, -``build_dataloaders_for_training``) is inlined at the top of this file — -there is no model-spec dispatcher because only one model is shipped. - -Usage:: - - python src/train.py case=lattice data=lattice case.data_root=... - -Multi-GPU:: - - torchrun --nproc_per_node=N src/train.py case=lattice data=lattice ... -""" - from __future__ import annotations -from typing import Any, Dict, Optional, Tuple +import os +from typing import Any, Optional, Tuple import hydra import torch import torch.nn as nn from omegaconf import DictConfig, OmegaConf -from torch.amp import GradScaler, autocast +from torch.amp import GradScaler +from torch.utils.tensorboard import SummaryWriter from physicsnemo.datapipes import DataLoader from physicsnemo.utils.logging.launch import LaunchLogger -from checkpointing import create_training_components, resume_or_pretrain +from checkpointing import create_optimizer, resume_if_available from loader import build_dataloaders, collate_no_padding -from losses import parse_loss_config +from losses import create_scheduler, parse_loss_config from trainer import ( - compute_losses, - flush_partial_accumulation, - grad_step, - log_effective_batch_size, + _parse_amp, run_training_loop, set_seed, setup_training_environment, @@ -59,14 +41,6 @@ ) -# ========================================================================= -# Inlined Transolver helpers (was training/model_specs/transolver.py) -# ========================================================================= -# -# A single-model sample doesn't need a spec dispatcher — these helpers are -# called directly from ``train_epoch`` / ``validate`` / ``main`` below. - - def build_model(cfg: DictConfig, device: torch.device) -> nn.Module: """Instantiate the Transolver model from the Hydra ``model`` group. @@ -86,8 +60,7 @@ def build_dataloaders_for_training( """Build train / val DataLoaders for the Transolver point-cloud adapter.""" if cfg.train.dataloader.batch_size != 1: raise ValueError( - "Only batch_size=1 is supported for the Transolver point-cloud " - "adapter (variable-length padding collate was removed)." + "Only batch_size=1 is supported for the Transolver point-cloud adapter." ) loaders, train_sampler = build_dataloaders( cfg, @@ -99,372 +72,79 @@ def build_dataloaders_for_training( return loaders["train"], loaders["val"], train_sampler -def to_device(batch: Dict[str, Any], device: torch.device) -> Dict[str, Any]: - """Move tensor entries of a batch dict to ``device``; pass through the rest.""" - return { - k: v.to(device) if isinstance(v, torch.Tensor) else v for k, v in batch.items() - } - - -def forward( - model: nn.Module, - batch: Dict[str, Any], -) -> torch.Tensor: - """Run a forward pass with the Transolver-expected input keys.""" - return model(fx=batch["fx"], embedding=batch["embedding"]) - - -def loss_inputs( - batch: Dict[str, Any], *, require_physics: bool = False -) -> Dict[str, Any]: - """Assemble the dict of optional/physics inputs consumed by ``compute_losses``. - - Physics loss requires raw, unnormalized coordinates. The model embedding - can contain material features, so it is never a safe coordinate fallback. - """ - inputs: Dict[str, Any] = {} - if batch.get("padding_mask") is not None: - inputs["padding_mask"] = batch["padding_mask"] - if "material_labels" in batch: - inputs["material_labels"] = batch["material_labels"] - physics_keys = ("cell_areas", "sigma_t", "sigma_s", "sim_time") - if require_physics and not all(k in batch for k in physics_keys): - missing = [k for k in physics_keys if k not in batch] - raise KeyError(f"Missing physics-loss input(s): {missing}") - if all(k in batch for k in physics_keys): - if "coordinates_unnormalized" not in batch: - if require_physics: - raise KeyError( - "coordinates_unnormalized is required when physics loss is " - "enabled. Enable coordinate backup before normalization." - ) - return inputs - inputs["coordinates_unnormalized"] = batch["coordinates_unnormalized"] - for k in physics_keys: - inputs[k] = batch[k] - for k in ("metadata", "flux_normalization_stats"): - if k in batch: - inputs[k] = batch[k] - return inputs - - -# ========================================================================= -# Per-epoch train / validate (Transolver-specialized) -# ========================================================================= - - -def _log_minibatch( - launch_logger: LaunchLogger, - loss: torch.Tensor, - loss_mse: torch.Tensor, - loss_qoi: Optional[torch.Tensor], - qoi_details: Dict[str, float], - scale: float, -) -> None: - metrics = {"loss": loss.item() * scale, "loss_mse": loss_mse.item()} - if loss_qoi is not None: - metrics["loss_qoi"] = loss_qoi.item() - metrics.update(qoi_details) - launch_logger.log_minibatch(metrics) - - -def train_epoch( - dataloader: DataLoader, - model: nn.Module, - optimizer: torch.optim.Optimizer, - scaler: GradScaler, - device: torch.device, - launch_logger: LaunchLogger, - *, - loss_cfg: Dict[str, Any], - case_type: str, - gradient_accumulation_steps: int = 1, - use_amp: bool = True, - amp_dtype: Optional[torch.dtype] = None, -) -> None: - """Run one Transolver training epoch.""" - model.train() - optimizer.zero_grad() - - for i, batch in enumerate(dataloader): - batch = to_device(batch, device) - - with autocast(enabled=use_amp, device_type=device.type, dtype=amp_dtype): - prediction = forward(model, batch) - - # Transolver predicts absolute flux directly — no reconstruction step. - pred, target = prediction, batch["flux_target"] - - loss, loss_mse, loss_qoi, qoi_details = compute_losses( - pred=pred.float(), - target=target.float(), - loss_inputs=loss_inputs( - batch, require_physics=loss_cfg.get("use_physics_loss", False) - ), - loss_cfg=loss_cfg, - case_type=case_type, - device=device, - ) - - _log_minibatch( - launch_logger, - loss, - loss_mse, - loss_qoi, - qoi_details, - scale=1, - ) - - grad_step( - loss, - scaler, - optimizer, - model, - step_idx=i, - accum_steps=gradient_accumulation_steps, - ) - - flush_partial_accumulation( - scaler, - optimizer, - model, - total_steps=len(dataloader), - accum_steps=gradient_accumulation_steps, - ) - - -@torch.no_grad() -def validate( - dataloader: DataLoader, - model: nn.Module, - device: torch.device, - launch_logger: LaunchLogger, - *, - loss_cfg: Dict[str, Any], - case_type: str, - use_amp: bool = True, - amp_dtype: Optional[torch.dtype] = None, -) -> Tuple[float, int, Dict[str, float], Dict[str, int]]: - """Run validation and return loss plus metric sums/counts for DDP reduce.""" - model.eval() - eval_model = model.module if hasattr(model, "module") else model - - loss_sum = 0.0 - num_batches = 0 - metric_sums: Dict[str, float] = {} - metric_counts: Dict[str, int] = {} - - def accumulate_metric(name: str, value: Any) -> None: - scalar = float(value) - metric_sums[name] = metric_sums.get(name, 0.0) + scalar - metric_counts[name] = metric_counts.get(name, 0) + 1 - - for batch in dataloader: - batch = to_device(batch, device) - - with autocast(enabled=use_amp, device_type=device.type, dtype=amp_dtype): - prediction = forward(eval_model, batch) - - pred, target = prediction, batch["flux_target"] - - loss, loss_mse, loss_qoi, qoi_details = compute_losses( - pred=pred.float(), - target=target.float(), - loss_inputs=loss_inputs( - batch, require_physics=loss_cfg.get("use_physics_loss", False) - ), - loss_cfg=loss_cfg, - case_type=case_type, - device=device, - ) - - _log_minibatch(launch_logger, loss, loss_mse, loss_qoi, qoi_details, scale=1) - - loss_sum += loss.item() - num_batches += 1 - accumulate_metric("loss_mse", loss_mse.item()) - if loss_qoi is not None: - accumulate_metric("loss_qoi", loss_qoi.item()) - for key, value in qoi_details.items(): - accumulate_metric(key, value) - - return loss_sum, num_batches, metric_sums, metric_counts - - -# ========================================================================= -# AMP helper -# ========================================================================= - - -def _parse_amp(cfg: DictConfig) -> Tuple[bool, Optional[torch.dtype], str]: - """Read ``cfg.train.amp`` and ``cfg.train.amp_dtype`` into (use_amp, dtype, label).""" - use_amp = cfg.train.get("amp", True) - dtype_str = str(cfg.train.get("amp_dtype", "bf16")).lower() - if dtype_str in ("bf16", "bfloat16"): - return use_amp, torch.bfloat16, dtype_str - if dtype_str in ("fp16", "float16"): - return use_amp, torch.float16, dtype_str - raise ValueError( - f"Unsupported amp_dtype {dtype_str!r}; " - "allowed values are 'bf16', 'bfloat16', 'fp16', 'float16'." - ) - - -# ========================================================================= -# Hydra main -# ========================================================================= - - @hydra.main(version_base="1.3", config_path="conf", config_name="config") def main(cfg: DictConfig) -> None: """Train the Transolver RTE surrogate.""" - # --- environment, seed, AMP --- dist, logger = setup_training_environment(cfg, "Transolver") seed = cfg.train.get("seed", None) if seed is not None: - effective_seed = seed + dist.rank if dist.distributed else seed - deterministic = cfg.train.get("deterministic", False) - set_seed(effective_seed, deterministic=deterministic) - if dist.rank == 0: - logger.info( - f"Random seed: {seed}" - + (" (deterministic mode)" if deterministic else "") - ) - elif dist.rank == 0: + set_seed(seed + dist.rank if dist.distributed else seed) + logger.info(f"Random seed: {seed}") + else: logger.info("Random seed: not set (non-reproducible)") grad_accum_steps = cfg.train.get("gradient_accumulation_steps", 1) - use_amp, amp_dtype, amp_dtype_label = _parse_amp(cfg) + use_amp, amp_dtype = _parse_amp(cfg) - amp_info = "ENABLED" if use_amp else "DISABLED" - if use_amp: - amp_info += f" (dtype={amp_dtype_label})" - log_effective_batch_size( - cfg, - dist, - logger, - grad_accum_steps, - extra_info={"AMP (mixed precision)": amp_info}, + amp_info = ( + f"ENABLED (dtype={cfg.train.get('amp_dtype', 'bf16')})" + if use_amp + else "DISABLED" ) + batch_size = cfg.train.dataloader.batch_size + world_size = dist.world_size if dist.distributed else 1 + logger.info(f"Device: {dist.device}") + logger.info(f"Batch size: {batch_size}") + logger.info(f"Gradient accumulation steps: {grad_accum_steps}") + logger.info(f"AMP (mixed precision): {amp_info}") + logger.info(f"Effective batch size: {batch_size * grad_accum_steps * world_size}") - # --- dataloaders --- - train_loader, val_loader, train_sampler = build_dataloaders_for_training( - cfg, dist, logger - ) + train_loader, val_loader, _ = build_dataloaders_for_training(cfg, dist, logger) - # --- model --- - if dist.rank == 0: - logger.info("\nInitializing Transolver model...") + logger.info("\nInitializing Transolver model...") model = build_model(cfg, dist.device) - if dist.rank == 0: - num_params = sum(p.numel() for p in model.parameters() if p.requires_grad) - logger.info(f"Transolver initialized — {num_params:,} trainable parameters") + num_params = sum(p.numel() for p in model.parameters() if p.requires_grad) + logger.info(f"Transolver initialized — {num_params:,} trainable parameters") model = wrap_ddp(model, dist, logger) - # --- training components --- + optimizer_cfg = cfg.train.get("optimizer", {}) + optimizer = create_optimizer( + model=model, + optimizer_type=optimizer_cfg.get("type", "adam"), + learning_rate=cfg.train.learning_rate, + weight_decay=optimizer_cfg.get( + "weight_decay", cfg.train.get("weight_decay", 0.0) + ), + muon_momentum_beta=optimizer_cfg.get("muon_momentum_beta", 0.95), + logger=logger, + ) + scheduler = create_scheduler(cfg, optimizer, logger) + # GradScaler is only meaningful for fp16 AMP; bf16 doesn't underflow and + # disabling avoids the overhead + masks fp16-specific failure modes. + scaler = GradScaler(enabled=use_amp and amp_dtype is torch.float16) + LaunchLogger.initialize(use_wandb=False, use_mlflow=False) use_tensorboard = cfg.train.get("tensorboard", True) - optimizer, scheduler, scaler, writer, checkpoint_dir, best_val_losses = ( - create_training_components( - cfg, model, dist, logger, tensorboard=use_tensorboard - ) + writer = ( + SummaryWriter(os.path.join(cfg.output, "tensorboard")) + if (use_tensorboard and dist.rank == 0) + else None ) + checkpoint_dir = os.path.join(cfg.output, "checkpoints") + os.makedirs(checkpoint_dir, exist_ok=True) - # --- loss config (case-specific physics weight comes via Hydra interpolation: - # ``training/base.yaml::physics_loss.weight: ${case.physics_loss_weight}``, - # replacing the deleted ``_apply_case_weight_override`` function) --- loss_cfg = parse_loss_config(cfg, dist, logger) - loss_metric = cfg.train.get("loss_metric", "mse") loss_cfg["loss_metric"] = loss_metric - if dist.rank == 0: - logger.info(f"Loss metric: {loss_metric}") - - # --- physics-loss warmup state --- - use_physics_loss = loss_cfg["use_physics_loss"] - physics_loss_weight_base = loss_cfg["physics_loss_weight"] - physics_loss_warmup_epochs = 0 - physics_loss_warmup_start = 0.0 - if use_physics_loss: - physics_loss_warmup_epochs = cfg.train.physics_loss.get("warmup_epochs", 0) - physics_loss_warmup_start = cfg.train.physics_loss.get( - "warmup_start_fraction", 0.0 - ) - if dist.rank == 0 and physics_loss_warmup_epochs > 0: - logger.info(f" Physics-loss warmup epochs: {physics_loss_warmup_epochs}") - logger.info( - f" Physics-loss warmup start fraction: {physics_loss_warmup_start}" - ) + logger.info(f"Loss metric: {loss_metric}") - # --- resume / pretrain --- - start_epoch, resumed_val_losses, best_qoi_loss = resume_or_pretrain( + start_epoch, best_val_loss = resume_if_available( cfg, model, optimizer, scheduler, scaler, dist, logger ) - if resumed_val_losses: - best_val_losses = resumed_val_losses - - # --- per-epoch hooks --- - shared_kwargs = { - "loss_cfg": loss_cfg, - "case_type": cfg.case.type, - "use_amp": use_amp, - "amp_dtype": amp_dtype, - } - train_epoch_kwargs = { - **shared_kwargs, - "gradient_accumulation_steps": grad_accum_steps, - } - validate_kwargs = dict(shared_kwargs) - - def before_epoch_fn(epoch: int): - """Per-epoch physics-loss-weight ramp-up. - - Linearly ramps ``physics_loss_weight`` from - ``warmup_start_fraction * base`` to ``base`` over the first - ``warmup_epochs``. After warmup, the weight stays at ``base``. - - Validation always uses the unwarmed-up final ``loss_cfg`` so val_loss - is comparable across epochs and best-checkpoint selection is meaningful. - """ - if not use_physics_loss or physics_loss_warmup_epochs <= 0: - return {}, {} - if epoch >= physics_loss_warmup_epochs: - current_weight = physics_loss_weight_base - else: - progress = epoch / max(1, physics_loss_warmup_epochs) - current_weight = ( - physics_loss_warmup_start + (1.0 - physics_loss_warmup_start) * progress - ) * physics_loss_weight_base - if dist.rank == 0 and epoch < physics_loss_warmup_epochs: - logger.info( - f"Physics loss warmup: epoch {epoch}, " - f"weight={current_weight:.6f} (target={physics_loss_weight_base})" - ) - epoch_loss_cfg = {**loss_cfg, "physics_loss_weight": current_weight} - return {"loss_cfg": epoch_loss_cfg}, {"loss_cfg": loss_cfg} - - def after_epoch_fn(epoch, train_log, val_log, val_loss, current_lr): - train_loss = train_log.epoch_losses.get("loss", 0.0) - log_msg = f"Epoch {epoch}: train_loss={train_loss:.4e}, val_loss={val_loss:.4e}" - log_keys = ["loss_mse", "loss_qoi"] - for key in sorted(train_log.epoch_losses.keys()): - if key.startswith("loss_qoi_") and key not in log_keys: - log_keys.append(key) - for key in log_keys: - t = train_log.epoch_losses.get(key) - if t is not None: - log_msg += f", train_{key.replace('loss_', '')}={t:.4e}" - v = val_log.epoch_losses.get(key) - if v is not None: - log_msg += f", val_{key.replace('loss_', '')}={v:.4e}" - log_msg += f", lr={current_lr:.2e}" - logger.info(log_msg) - # --- dispatch to shared training loop --- - if dist.rank == 0: - logger.info("\n" + "=" * 70) - logger.info("Starting training...") - logger.info("=" * 70) + logger.info("\n" + "=" * 70) + logger.info("Starting training...") + logger.info("=" * 70) run_training_loop( cfg=cfg, @@ -472,23 +152,15 @@ def after_epoch_fn(epoch, train_log, val_log, val_loss, current_lr): model=model, train_loader=train_loader, val_loader=val_loader, - train_sampler=train_sampler, optimizer=optimizer, scheduler=scheduler, scaler=scaler, - train_epoch_fn=train_epoch, - validate_fn=validate, - train_epoch_kwargs=train_epoch_kwargs, - validate_kwargs=validate_kwargs, + loss_cfg=loss_cfg, logger=logger, checkpoint_dir=checkpoint_dir, writer=writer, - best_val_losses=best_val_losses, + best_val_loss=best_val_loss, start_epoch=start_epoch, - case_type=cfg.case.type, - before_epoch_fn=before_epoch_fn, - after_epoch_fn=after_epoch_fn, - best_qoi_loss=best_qoi_loss, ) diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/trainer.py b/examples/cfd/nuclear_engineering/radiation_transport/src/trainer.py index 2db102cf1c..6b2438fc26 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/trainer.py +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/trainer.py @@ -14,29 +14,12 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Training loop, epoch step, DDP primitives, and environment setup. - -Consolidates the per-batch loss composition + gradient-accumulation step, -the epoch-driven training loop with checkpointing, and the DDP / environment -boilerplate that sits beside them: - -* DDP primitives — ``set_seed``, ``setup_training_environment``, ``wrap_ddp``, - ``log_effective_batch_size``, ``synchronize_output_directory``, - ``aggregate_validation_loss``. -* Per-step / per-epoch helpers — ``compute_losses``, ``grad_step``, - ``flush_partial_accumulation``. -* Training loop — ``run_training_loop``. - -Optimizer / scheduler construction and checkpoint save/load live in -``checkpointing.py`` and ``losses.py`` respectively. -""" - -import logging import math import os import random +from contextlib import nullcontext from pathlib import Path -from typing import Any, Callable, Dict, List, Mapping, Optional, Tuple +from typing import Any, Dict, Mapping, Optional, Tuple import numpy as np import torch @@ -45,120 +28,67 @@ from omegaconf import DictConfig, OmegaConf from torch.amp import GradScaler, autocast from torch.nn.parallel import DistributedDataParallel +from torch.utils.tensorboard import SummaryWriter from physicsnemo.datapipes import DataLoader from physicsnemo.distributed import DistributedManager +from physicsnemo.distributed.utils import reduce_loss from physicsnemo.utils.checkpoint import save_checkpoint +from physicsnemo.utils.logging import PythonLogger, RankZeroLoggingWrapper from physicsnemo.utils.logging.launch import LaunchLogger -from checkpointing import ( - save_best_checkpoint, - save_best_qoi_checkpoint, - save_latest_checkpoint, +from checkpointing import save_best_checkpoint +from losses import ( + compute_physics_loss, + physics_loss_weight_for_epoch, + region_weighted_loss_fn, ) -from losses import compute_physics_loss, masked_mse_loss, region_weighted_loss_fn - -# ========================================================================= -# DDP primitives & environment setup -# ========================================================================= - - -def _setup_logger(name: str, log_file: Optional[str] = None) -> logging.Logger: - """Create a console (and optional file) logger with a consistent format.""" - logger = logging.getLogger(name) - logger.setLevel(logging.INFO) - logger.propagate = False # prevent duplicate logs from Hydra - logger.handlers.clear() - - formatter = logging.Formatter( - "%(asctime)s - %(name)s - %(levelname)s - %(message)s" - ) - console_handler = logging.StreamHandler() - console_handler.setFormatter(formatter) - logger.addHandler(console_handler) - - if log_file: - file_handler = logging.FileHandler(log_file) - file_handler.setFormatter(formatter) - logger.addHandler(file_handler) - - return logger - - -def set_seed(seed: int, deterministic: bool = False) -> None: - """Set random seed for reproducibility across all RNGs. - - Args: - seed: Random seed value. - deterministic: If True, use deterministic algorithms (slower). - """ +def set_seed(seed: int) -> None: + """Set random seed for reproducibility across all RNGs.""" random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) - torch.cuda.manual_seed(seed) torch.cuda.manual_seed_all(seed) + torch.backends.cudnn.benchmark = True - if deterministic: - torch.backends.cudnn.deterministic = True - torch.backends.cudnn.benchmark = False - else: - torch.backends.cudnn.benchmark = True + +_AMP_DTYPES: Dict[str, torch.dtype] = { + "bf16": torch.bfloat16, + "fp16": torch.float16, +} + + +def _parse_amp(cfg: DictConfig) -> Tuple[bool, torch.dtype]: + """Read ``cfg.train.amp`` and ``cfg.train.amp_dtype`` into ``(use_amp, dtype)``.""" + name = cfg.train.get("amp_dtype", "bf16") + if name not in _AMP_DTYPES: + raise ValueError( + f"Unsupported amp_dtype {name!r}; allowed: {sorted(_AMP_DTYPES)}." + ) + return cfg.train.get("amp", True), _AMP_DTYPES[name] def synchronize_output_directory( cfg: DictConfig, dist: DistributedManager, ) -> str: - """Synchronize the output directory across DDP ranks via torch broadcast. - - Hydra's timestamp-based output paths can otherwise produce one folder per - rank. Rank 0 creates the directory; the resolved path is broadcast from - rank 0 to every other rank, which updates ``cfg.output`` in place if it - differs and ensures the directory exists locally. Ends with a barrier so - no rank proceeds before the directory is in place. - - Args: - cfg: Hydra configuration with an ``output`` field. - dist: DistributedManager instance. + """Ensure ``cfg.output`` exists; barrier so DDP ranks don't race past it. - Returns: - The synchronized output directory path. + Rank 0 creates the directory tree; + a final barrier keeps the other ranks from proceeding before it lands. """ if "output" not in cfg: - output_dir = os.path.join("outputs", "default") OmegaConf.set_struct(cfg, False) - cfg.output = output_dir + cfg.output = os.path.join("outputs", "default") OmegaConf.set_struct(cfg, True) output_dir = cfg.output - - if not dist.distributed: - os.makedirs(output_dir, exist_ok=True) - return output_dir - - # By the time this is called, DistributedManager.initialize() has run, so - # torch_dist.is_initialized() is True for distributed runs. if dist.rank == 0: - print(f"[Rank 0] Creating output directory: {output_dir}") os.makedirs(output_dir, exist_ok=True) os.makedirs(os.path.join(output_dir, "checkpoints"), exist_ok=True) - - payload = [output_dir] - torch_dist.broadcast_object_list(payload, src=0) - synced_output_dir = payload[0] - - if dist.rank != 0: - if synced_output_dir != output_dir: - print(f"[Rank {dist.rank}] Syncing to output: {synced_output_dir}") - OmegaConf.set_struct(cfg, False) - cfg.output = synced_output_dir - OmegaConf.set_struct(cfg, True) - output_dir = synced_output_dir - os.makedirs(output_dir, exist_ok=True) - print(f"[Rank {dist.rank}] Synchronized to output directory: {output_dir}") - - torch_dist.barrier() + if dist.distributed: + torch_dist.barrier() return output_dir @@ -166,33 +96,19 @@ def aggregate_validation_loss( loss_sum: float, num_batches: int, dist: DistributedManager, -) -> Tuple[float, int]: - """Aggregate validation loss across all DDP ranks. +) -> float: + """Aggregate validation loss across DDP ranks via ``reduce_loss``. - Sums the per-rank loss totals and batch counts then returns the global - mean. In single-GPU mode this reduces to ``loss_sum / num_batches``. - - Args: - loss_sum: Sum of losses on this rank. - num_batches: Number of batches processed on this rank. - dist: DistributedManager instance. - - Returns: - ``(global_mean_loss, global_num_batches)``. + Returns the rank-0 mean-of-means; non-rank-0 ranks get their local mean + (unused downstream). Eval sampler pads the split to equal length across + ranks, so the mean-of-means equals the global mean up to at most + ``world_size - 1`` duplicate samples. """ + per_rank_mean = loss_sum / max(num_batches, 1) if not dist.distributed: - return loss_sum / max(num_batches, 1), num_batches - - loss_tensor = torch.tensor([loss_sum], dtype=torch.float64, device=dist.device) - torch_dist.all_reduce(loss_tensor, op=torch_dist.ReduceOp.SUM) - - count_tensor = torch.tensor([num_batches], dtype=torch.int64, device=dist.device) - torch_dist.all_reduce(count_tensor, op=torch_dist.ReduceOp.SUM) - - global_loss_sum = loss_tensor.item() - global_num_batches = count_tensor.item() - - return global_loss_sum / max(global_num_batches, 1), global_num_batches + return per_rank_mean + reduced = reduce_loss(per_rank_mean, dst_rank=0, mean=True) + return reduced if reduced is not None else per_rank_mean def aggregate_validation_metrics( @@ -200,7 +116,13 @@ def aggregate_validation_metrics( metric_counts: Mapping[str, int], dist: DistributedManager, ) -> Dict[str, float]: - """Aggregate named validation metrics across ranks.""" + """Aggregate named validation metrics via tensor ``all_reduce`` over a + known schema. + + Every rank emits the same metric keys (the schema is fixed at config + time, not per-batch), so we sort keys, stack values into a single + tensor, and issue one collective per (sums, counts) pair. + """ if not dist.distributed: return { key: metric_sums[key] / metric_counts[key] @@ -208,24 +130,27 @@ def aggregate_validation_metrics( if metric_counts.get(key, 0) > 0 } - gathered = [None for _ in range(dist.world_size)] - torch_dist.all_gather_object( - gathered, - (dict(metric_sums), dict(metric_counts)), - ) + keys = sorted(metric_sums.keys()) + if not keys: + return {} - total_sums: Dict[str, float] = {} - total_counts: Dict[str, int] = {} - for rank_sums, rank_counts in gathered: - for key, value in rank_sums.items(): - total_sums[key] = total_sums.get(key, 0.0) + float(value) - for key, value in rank_counts.items(): - total_counts[key] = total_counts.get(key, 0) + int(value) + sums = torch.tensor( + [float(metric_sums[k]) for k in keys], + dtype=torch.float64, + device=dist.device, + ) + counts = torch.tensor( + [int(metric_counts.get(k, 0)) for k in keys], + dtype=torch.int64, + device=dist.device, + ) + torch_dist.all_reduce(sums, op=torch_dist.ReduceOp.SUM) + torch_dist.all_reduce(counts, op=torch_dist.ReduceOp.SUM) return { - key: total_sums[key] / total_counts[key] - for key in total_sums - if total_counts.get(key, 0) > 0 + key: float(sums[i].item() / counts[i].item()) + for i, key in enumerate(keys) + if counts[i].item() > 0 } @@ -242,51 +167,23 @@ def setup_training_environment( Returns: ``(dist, logger)``. """ - initialize_distributed_manager() + DistributedManager.initialize() dist = DistributedManager() synchronize_output_directory(cfg, dist) - log_file = os.path.join(cfg.output, "train.log") if dist.rank == 0 else None - logger = _setup_logger(f"RTE_{model_name}", log_file) - + logger = RankZeroLoggingWrapper(PythonLogger(f"RTE_{model_name}"), dist) if dist.rank == 0: - logger.info("=" * 70) - logger.info(f"RTE {model_name} Training - {cfg.case.type.upper()}") - logger.info("=" * 70) - if dist.distributed: - logger.info(f"Distributed training: {dist.world_size} GPUs") - logger.info(f"\nConfiguration:\n{OmegaConf.to_yaml(cfg, sort_keys=True)}\n") + logger.file_logging(os.path.join(cfg.output, "train.log")) - return dist, logger + logger.info("=" * 70) + logger.info(f"RTE {model_name} Training - {cfg.case.type.upper()}") + logger.info("=" * 70) + if dist.distributed: + logger.info(f"Distributed training: {dist.world_size} GPUs") + logger.info(f"\nConfiguration:\n{OmegaConf.to_yaml(cfg, sort_keys=True)}\n") - -def initialize_distributed_manager() -> None: - """Initialize distributed state without misreading an interactive SLURM shell. - - PhysicsNeMo's default initializer auto-detects SLURM variables. In an - allocated shell, plain ``python src/train.py`` can inherit those variables - even though only one Python process was launched, causing process-group - setup to wait for ranks that do not exist. For this example, DDP should be - entered via ``torchrun`` (or an explicit PhysicsNeMo init method); otherwise - run as a normal single process. - """ - if DistributedManager.is_initialized(): - return - - explicit_method = os.getenv("PHYSICSNEMO_DISTRIBUTED_INITIALIZATION_METHOD") - torchrun_env = os.getenv("RANK") is not None and os.getenv("WORLD_SIZE") is not None - openmpi_env = os.getenv("OMPI_COMM_WORLD_RANK") is not None - - if explicit_method or torchrun_env or openmpi_env: - DistributedManager.initialize() - return - - DistributedManager._shared_state["_is_initialized"] = True - dist = DistributedManager() - dist._initialization_method = "single" - if torch.cuda.is_available(): - torch.cuda.set_device(dist.device) + return dist, logger def wrap_ddp( @@ -313,42 +210,11 @@ def wrap_ddp( ) torch.cuda.current_stream().wait_stream(ddps) - if dist.rank == 0: - fup = " (find_unused_parameters=True)" if find_unused_parameters else "" - logger.info(f"Using DistributedDataParallel with {dist.world_size} GPUs{fup}") - + fup = " (find_unused_parameters=True)" if find_unused_parameters else "" + logger.info(f"Using DistributedDataParallel with {dist.world_size} GPUs{fup}") return model -def log_effective_batch_size( - cfg: DictConfig, - dist: DistributedManager, - logger: Any, - grad_accum_steps: int, - extra_info: Optional[Dict[str, Any]] = None, -) -> None: - """Log device, batch size, gradient accumulation, and effective batch size.""" - if dist.rank != 0: - return - - logger.info(f"Device: {dist.device}") - logger.info(f"Batch size: {cfg.train.dataloader.batch_size}") - logger.info(f"Gradient accumulation steps: {grad_accum_steps}") - - if extra_info: - for key, value in extra_info.items(): - logger.info(f"{key}: {value}") - - world_mult = dist.world_size if dist.distributed else 1 - effective_batch = cfg.train.dataloader.batch_size * grad_accum_steps * world_mult - logger.info(f"Effective batch size: {effective_batch}") - - -# ========================================================================= -# Per-step / per-epoch helpers -# ========================================================================= - - def compute_losses( pred: torch.Tensor, target: torch.Tensor, @@ -362,7 +228,6 @@ def compute_losses( Args: pred, target: ``(B, N, 1)`` tensors. loss_inputs: presence-driven dispatch dict. Recognized keys: - - ``padding_mask`` ``(B, N)``: enables masked MSE. - ``material_labels`` ``(B, N)`` or ``(B, N, 1)``: enables region-weighted loss when ``loss_cfg['use_region_weighted_loss']``. - ``coordinates_unnormalized`` ``(B, N, D)``, ``cell_areas`` @@ -372,7 +237,7 @@ def compute_losses( context. loss_cfg: ``use_region_weighted_loss``, ``region_weight_cfg``, ``loss_metric`` ("mse"|"rmse"), ``use_physics_loss``, - ``physics_loss_weight``, ``physics_loss_mse_weight``, ``qoi_region``. + ``physics_loss_weight``, ``physics_loss_mse_weight``. Returns: ``(loss, loss_mse, loss_qoi_or_None, qoi_details_dict)``. @@ -387,15 +252,13 @@ def compute_losses( target, material_labels=loss_inputs["material_labels"], case_type=case_type, - loss_type=loss_metric, - padded_value=-10, void_weight=rw.get("void_weight", 3.0), material_weight=rw.get("material_weight", 1.0), ) else: - loss_mse = masked_mse_loss(pred, target, loss_inputs.get("padding_mask")) - if loss_metric == "rmse": - loss_mse = torch.sqrt(loss_mse) + loss_mse = ((pred - target) ** 2).mean() + if loss_metric == "rmse": + loss_mse = torch.sqrt(loss_mse) if not loss_cfg.get("use_physics_loss", False): return loss_mse, loss_mse, None, {} @@ -418,7 +281,6 @@ def compute_losses( sim_time=loss_inputs["sim_time"], metadata=loss_inputs.get("metadata"), flux_normalization_stats=loss_inputs.get("flux_normalization_stats"), - qoi_region=loss_cfg.get("qoi_region", "center"), ) mse_w = loss_cfg.get("physics_loss_mse_weight", 1.0) @@ -426,386 +288,402 @@ def compute_losses( return loss, loss_mse, loss_qoi, qoi_details -def _finalize_step( - scaler: GradScaler, - optimizer: torch.optim.Optimizer, - model: torch.nn.Module, - max_grad_norm: float, -) -> None: - scaler.unscale_(optimizer) - torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=max_grad_norm) - scaler.step(optimizer) - scaler.update() - optimizer.zero_grad(set_to_none=True) +def to_device(batch: Dict[str, Any], device: torch.device) -> Dict[str, Any]: + """Move tensor entries of a batch dict to ``device``; pass through the rest.""" + return { + k: v.to(device) if isinstance(v, torch.Tensor) else v for k, v in batch.items() + } -def _scale_pending_gradients(model: torch.nn.Module, factor: float) -> None: - """Scale accumulated gradients in-place before optimizer finalization.""" - if factor == 1.0: - return - for parameter in model.parameters(): - if parameter.grad is not None: - parameter.grad.mul_(factor) +def forward( + model: nn.Module, + batch: Dict[str, Any], +) -> torch.Tensor: + """Run a forward pass with the Transolver-expected input keys.""" + return model(fx=batch["fx"], embedding=batch["embedding"]) + + +_PHYSICS_KEYS = ( + "coordinates_unnormalized", + "cell_areas", + "sigma_t", + "sigma_s", + "sim_time", +) + +def loss_inputs(batch: Dict[str, Any], require_physics: bool = False) -> Dict[str, Any]: + """Assemble the optional/physics inputs consumed by ``compute_losses``. -def grad_step( + Always copies ``material_labels`` if present. Physics-loss tensors are + copied only when all of ``_PHYSICS_KEYS`` are in the batch; + ``require_physics=True`` raises if any is missing. ``metadata`` and + ``flux_normalization_stats`` are forwarded when present. + """ + inputs: Dict[str, Any] = {} + if "material_labels" in batch: + inputs["material_labels"] = batch["material_labels"] + + missing = [k for k in _PHYSICS_KEYS if k not in batch] + if missing: + if require_physics: + msg = f"Missing physics-loss input(s): {missing}." + if "coordinates_unnormalized" in missing: + msg += " (Enable the RTEBackupCoords transform in the data pipeline.)" + raise KeyError(msg) + return inputs + + for k in _PHYSICS_KEYS: + inputs[k] = batch[k] + for k in ("metadata", "flux_normalization_stats"): + if k in batch: + inputs[k] = batch[k] + return inputs + + +def _log_minibatch( + launch_logger: LaunchLogger, loss: torch.Tensor, - scaler: GradScaler, + loss_mse: torch.Tensor, + loss_qoi: Optional[torch.Tensor], + qoi_details: Dict[str, float], + scale: float, +) -> None: + metrics = {"loss": loss.item() * scale, "loss_mse": loss_mse.item()} + if loss_qoi is not None: + metrics["loss_qoi"] = loss_qoi.item() + metrics.update(qoi_details) + launch_logger.log_minibatch(metrics) + + +def train_epoch( + cfg: DictConfig, + dataloader: DataLoader, + model: nn.Module, optimizer: torch.optim.Optimizer, - model: torch.nn.Module, - *, - step_idx: int, - accum_steps: int, - max_grad_norm: float = 10.0, + scaler: GradScaler, + device: torch.device, + launch_logger: LaunchLogger, + loss_cfg: Dict[str, Any], ) -> None: - """Scale, backward, and (on accumulation boundary) step + clip + zero_grad. + """Run one Transolver training epoch. - Callers invoke this every batch with ``step_idx=i``. Trailing partial - accumulation windows are flushed by a separate call to - ``flush_partial_accumulation`` after the loop finishes. + Reads ``cfg.case.type``, ``cfg.train.amp*``, and + ``cfg.train.gradient_accumulation_steps`` directly so callers only + thread the per-epoch ``loss_cfg`` (which is pre-processed by + :func:`losses.parse_loss_config` and varies per epoch via warmup). """ - scaler.scale(loss / accum_steps).backward() - if (step_idx + 1) % accum_steps != 0: - return - _finalize_step(scaler, optimizer, model, max_grad_norm) + case_type = cfg.case.type + use_amp, amp_dtype = _parse_amp(cfg) + accum_steps = cfg.train.get("gradient_accumulation_steps", 1) + max_grad_norm = 10.0 + + model.train() + epoch_len = len(dataloader) + + for i, batch in enumerate(dataloader): + # Gradient accumulation with DDP-aware grad-sync skip: zero at window + # start, run backward inside ``model.no_sync()`` until the boundary + # step (or the final batch of the epoch), then step + clip + update. + if i % accum_steps == 0: + optimizer.zero_grad(set_to_none=True) + is_step_boundary = (i + 1) % accum_steps == 0 or (i + 1) == epoch_len + + batch = to_device(batch, device) + + with autocast(enabled=use_amp, device_type=device.type, dtype=amp_dtype): + prediction = forward(model, batch) + + pred, target = prediction, batch["flux_target"] + + loss, loss_mse, loss_qoi, qoi_details = compute_losses( + pred=pred.float(), + target=target.float(), + loss_inputs=loss_inputs( + batch, require_physics=loss_cfg.get("use_physics_loss", False) + ), + loss_cfg=loss_cfg, + case_type=case_type, + device=device, + ) + _log_minibatch( + launch_logger, + loss, + loss_mse, + loss_qoi, + qoi_details, + scale=1, + ) -def flush_partial_accumulation( - scaler: GradScaler, - optimizer: torch.optim.Optimizer, - model: torch.nn.Module, - *, - total_steps: int, - accum_steps: int, - max_grad_norm: float = 10.0, -) -> None: - """Flush leftover gradients when ``total_steps % accum_steps != 0``.""" - remainder = total_steps % accum_steps - if remainder == 0: - return - _scale_pending_gradients(model, accum_steps / remainder) - _finalize_step(scaler, optimizer, model, max_grad_norm) + sync_ctx = ( + model.no_sync() + if (not is_step_boundary and hasattr(model, "no_sync")) + else nullcontext() + ) + with sync_ctx: + scaler.scale(loss / accum_steps).backward() + + if is_step_boundary: + scaler.unscale_(optimizer) + torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=max_grad_norm) + scaler.step(optimizer) + scaler.update() -# ========================================================================= -# Training loop -# ========================================================================= +@torch.no_grad() +def validate( + cfg: DictConfig, + dataloader: DataLoader, + model: nn.Module, + device: torch.device, + launch_logger: LaunchLogger, + loss_cfg: Dict[str, Any], +) -> Tuple[float, int, Dict[str, float], Dict[str, int]]: + """Run validation and return loss plus metric sums/counts for DDP reduce.""" + case_type = cfg.case.type + use_amp, amp_dtype = _parse_amp(cfg) + + model.eval() + eval_model = model.module if hasattr(model, "module") else model + + loss_sum = 0.0 + num_batches = 0 + metric_sums: Dict[str, float] = {} + metric_counts: Dict[str, int] = {} + + def accumulate_metric(name: str, value: Any) -> None: + scalar = float(value) + metric_sums[name] = metric_sums.get(name, 0.0) + scalar + metric_counts[name] = metric_counts.get(name, 0) + 1 + + for batch in dataloader: + batch = to_device(batch, device) + + with autocast(enabled=use_amp, device_type=device.type, dtype=amp_dtype): + prediction = forward(eval_model, batch) + + pred, target = prediction, batch["flux_target"] + + loss, loss_mse, loss_qoi, qoi_details = compute_losses( + pred=pred.float(), + target=target.float(), + loss_inputs=loss_inputs( + batch, require_physics=loss_cfg.get("use_physics_loss", False) + ), + loss_cfg=loss_cfg, + case_type=case_type, + device=device, + ) + _log_minibatch(launch_logger, loss, loss_mse, loss_qoi, qoi_details, scale=1) -def _coerce_optional_checkpoint_interval(value: Any) -> Optional[int]: - """Parse an optional checkpoint cadence; None/0 disables the feature.""" - if value is None: - return None - if isinstance(value, str) and value.strip().lower() in ("", "none", "null"): - return None + loss_sum += loss.item() + num_batches += 1 + accumulate_metric("loss_mse", loss_mse.item()) + if loss_qoi is not None: + accumulate_metric("loss_qoi", loss_qoi.item()) + for key, value in qoi_details.items(): + accumulate_metric(key, value) - interval = int(value) - if interval < 0: - raise ValueError("latest_checkpoint_interval must be >= 0 or null") - return interval + return loss_sum, num_batches, metric_sums, metric_counts -def _finite_metric_values(metrics: Mapping[str, Optional[float]]) -> bool: - """Return True when all present metric values are finite.""" - for value in metrics.values(): - if value is None: - continue - if not math.isfinite(float(value)): - return False - return True +def _format_epoch_log( + epoch: int, + train_log: Any, + val_log: Any, + val_loss: float, + current_lr: float, +) -> str: + """Build the per-epoch rank-0 log line. + + Emits ``train_loss`` / ``val_loss`` first, then ``train_X`` / ``val_X`` + pairs for every other metric key present in either log (sorted), then + ``lr``. Joined with ", ". + """ + parts = [ + f"train_loss={train_log.epoch_losses.get('loss', 0.0):.4e}", + f"val_loss={val_loss:.4e}", + ] + extra_keys = sorted( + {k for k in (*train_log.epoch_losses, *val_log.epoch_losses) if k != "loss"} + ) + for key in extra_keys: + short = key.removeprefix("loss_") + if key in train_log.epoch_losses: + parts.append(f"train_{short}={train_log.epoch_losses[key]:.4e}") + if key in val_log.epoch_losses: + parts.append(f"val_{short}={val_log.epoch_losses[key]:.4e}") + parts.append(f"lr={current_lr:.2e}") + return f"Epoch {epoch}: " + ", ".join(parts) def run_training_loop( cfg: DictConfig, - dist: Any, + dist: DistributedManager, model: torch.nn.Module, train_loader: DataLoader, val_loader: DataLoader, - train_sampler: Optional[Any], optimizer: torch.optim.Optimizer, scheduler: Any, - scaler: Any, - train_epoch_fn: Callable[..., None], - validate_fn: Callable[..., tuple], - train_epoch_kwargs: Dict[str, Any], - validate_kwargs: Dict[str, Any], + scaler: GradScaler, + loss_cfg: Dict[str, Any], logger: Any, checkpoint_dir: str, - writer: Optional[Any], - best_val_losses: List, + writer: Optional[SummaryWriter], + best_val_loss: float, start_epoch: int, - case_type: str, - before_epoch_fn: Optional[Callable[[int], tuple]] = None, - after_epoch_fn: Optional[Callable[[int, Any, Any, float, float], None]] = None, - finally_fn: Optional[Callable[[], None]] = None, - best_qoi_loss: float = float("inf"), ) -> None: """Run the main training loop: epochs, validation, checkpointing, logging. - The caller owns model / dataloader / optimizer / scheduler / scaler - construction and supplies ``train_epoch_fn`` and ``validate_fn``. This - function drives the epoch loop, aggregates validation loss across DDP - ranks, steps the scheduler, saves checkpoints, and handles graceful - completion / interrupt. + Drives the epoch loop, applies the physics-loss warmup ramp inline, + aggregates validation loss across DDP ranks, steps the scheduler, and + saves the single best-by-val_loss checkpoint. The per-epoch train and + validate steps are :func:`train_epoch` and :func:`validate` in this + module; they read case type, AMP, and gradient-accumulation settings + from ``cfg`` directly. Args: - cfg: Hydra config (uses ``train.epochs``, ``train.checkpoint_interval``, - ``train.scheduler_type``). + cfg: Hydra config (uses ``train.epochs``, ``case.type``, ``train.amp*``, + and ``train.gradient_accumulation_steps``). dist: DistributedManager instance. - model: Model (possibly DDP-wrapped). + model: Model (possibly DDP-wrapped). The DistributedSampler is + already attached to ``train_loader``; the loader forwards + ``set_epoch`` to it. train_loader: Training DataLoader. val_loader: Validation DataLoader. - train_sampler: DistributedSampler for training (or None). optimizer: Optimizer. scheduler: LR scheduler. scaler: GradScaler for AMP. - train_epoch_fn: ``(train_loader, model, optimizer, scaler, device, - launch_logger, **train_epoch_kwargs) -> None``. - validate_fn: ``(val_loader, model, device, launch_logger, - **validate_kwargs) -> (val_loss_sum, val_num_batches)``. - train_epoch_kwargs: kwargs passed to ``train_epoch_fn``. - validate_kwargs: kwargs passed to ``validate_fn``. + loss_cfg: Loss configuration from + :func:`losses.parse_loss_config`. The trainer applies the + physics-loss warmup ramp to the ``physics_loss_weight`` per epoch + for the training pass; validation always uses the unwarmed dict. logger: Logger (rank 0). checkpoint_dir: Directory for checkpoints. writer: TensorBoard SummaryWriter (rank 0) or None. - best_val_losses: List of best validation losses (updated in place). + best_val_loss: Best validation loss seen so far (lower is better). start_epoch: First epoch index to run. - case_type: Case type string for metadata (e.g. "lattice", "hohlraum"). - before_epoch_fn: Optional ``(epoch) -> (extra_train_kwargs, - extra_validate_kwargs)``. Used by callers that want to inject - per-epoch state (e.g. physics-loss warmup weights). - after_epoch_fn: Optional ``(epoch, train_log, val_log, val_loss, - current_lr) -> None`` for custom rank-0 logging. - finally_fn: Optional no-arg callback called at the start of the - ``finally`` block (e.g. memory diagnostics). - best_qoi_loss: Best QoI loss seen so far (lower is better). """ - training_completed = False - try: - for epoch in range(start_epoch, cfg.train.epochs): - # ``physicsnemo.datapipes.DataLoader.set_epoch`` propagates the - # epoch to (1) the sampler — including DistributedSampler — and - # (2) the dataset, which in turn forwards it to the reader and - # every transform in the Compose chain (e.g. ``SpatialSampler``). - train_loader.set_epoch(epoch) - val_loader.set_epoch(epoch) - - train_kw = dict(train_epoch_kwargs) - val_kw = dict(validate_kwargs) - if before_epoch_fn is not None: - extra_train, extra_val = before_epoch_fn(epoch) - train_kw.update(extra_train) - val_kw.update(extra_val) - - with LaunchLogger( - "train", - epoch=epoch, - num_mini_batch=len(train_loader), - mini_batch_log_freq=10, - ) as train_log: - train_epoch_fn( - train_loader, - model, - optimizer, - scaler, - dist.device, - train_log, - **train_kw, - ) + case_type = cfg.case.type - with LaunchLogger( - "val", epoch=epoch, num_mini_batch=len(val_loader) - ) as val_log: - validation_result = validate_fn( - val_loader, - model, - dist.device, - val_log, - **val_kw, - ) - if len(validation_result) == 2: - val_loss_sum, val_num_batches = validation_result - val_metric_sums: Dict[str, float] = {} - val_metric_counts: Dict[str, int] = {} - else: - ( - val_loss_sum, - val_num_batches, - val_metric_sums, - val_metric_counts, - ) = validation_result - - train_loss = train_log.epoch_losses.get("loss", 0.0) - val_loss, _ = aggregate_validation_loss(val_loss_sum, val_num_batches, dist) - val_metrics = aggregate_validation_metrics( - val_metric_sums, val_metric_counts, dist + for epoch in range(start_epoch, cfg.train.epochs): + train_loader.set_epoch(epoch) + val_loader.set_epoch(epoch) + + current_physics_weight = physics_loss_weight_for_epoch(loss_cfg, epoch) + epoch_loss_cfg = { + **loss_cfg, + "physics_loss_weight": current_physics_weight, + } + if current_physics_weight != loss_cfg["physics_loss_weight"]: + logger.info( + f"Physics loss warmup: epoch {epoch}, " + f"weight={current_physics_weight:.6f}" ) - val_log.epoch_losses.update(val_metrics) - - scheduler.step() - current_lr = scheduler.get_last_lr()[0] - - if dist.rank == 0: - if after_epoch_fn is not None: - after_epoch_fn(epoch, train_log, val_log, val_loss, current_lr) - else: - logger.info( - f"Epoch {epoch}: train_loss={train_loss:.4e}, " - f"val_loss={val_loss:.4e}, lr={current_lr:.2e}" - ) - if writer: - writer.add_scalar("Loss/train", train_loss, epoch) - writer.add_scalar("Loss/val", val_loss, epoch) - writer.add_scalar("Learning_Rate", current_lr, epoch) - writer.flush() - - val_loss_qoi = val_metrics.get("loss_qoi") - metadata_best_qoi_loss = best_qoi_loss - if val_loss_qoi is not None: - metadata_best_qoi_loss = min(best_qoi_loss, val_loss_qoi) - - checkpoint_metrics = { - "train_loss": train_loss, - "val_loss": val_loss, - "val_loss_qoi": val_loss_qoi, - } - can_write_checkpoint = _finite_metric_values(checkpoint_metrics) - if not can_write_checkpoint: - logger.warning( - "Skipping checkpoint saves for epoch %s because at least " - "one checkpoint metric is NaN or inf: %s", - epoch, - checkpoint_metrics, - ) - else: - best_val_losses[:] = save_best_checkpoint( - checkpoint_dir=Path(checkpoint_dir), - epoch=epoch, - val_loss=val_loss, - best_val_losses=best_val_losses, - save_checkpoint_fn=save_checkpoint, - logger=logger, - models=model, - optimizer=optimizer, - scheduler=scheduler, - scaler=scaler, - metadata={ - "best_val_losses": best_val_losses, - "best_qoi_loss": metadata_best_qoi_loss, - "train_loss": train_loss, - "val_loss": val_loss, - "val_loss_qoi": val_loss_qoi, - "case_type": case_type, - }, - ) - - if val_loss_qoi is not None: - best_qoi_loss = save_best_qoi_checkpoint( - checkpoint_dir=Path(checkpoint_dir), - epoch=epoch, - qoi_error=val_loss_qoi, - best_qoi_error=best_qoi_loss, - save_checkpoint_fn=save_checkpoint, - logger=logger, - models=model, - optimizer=optimizer, - scheduler=scheduler, - scaler=scaler, - metadata={ - "best_val_losses": best_val_losses, - "best_qoi_loss": metadata_best_qoi_loss, - "train_loss": train_loss, - "val_loss": val_loss, - "val_loss_qoi": val_loss_qoi, - "case_type": case_type, - }, - ) - - if epoch % cfg.train.checkpoint_interval == 0: - save_checkpoint( - path=checkpoint_dir, - models=model, - optimizer=optimizer, - scheduler=scheduler, - scaler=scaler, - epoch=epoch, - metadata={ - "best_val_losses": best_val_losses, - "best_qoi_loss": best_qoi_loss, - "train_loss": train_loss, - "val_loss": val_loss, - "val_loss_qoi": val_loss_qoi, - "case_type": case_type, - }, - ) - logger.info(f" Saved checkpoint at epoch {epoch + 1}") - - latest_checkpoint_interval = _coerce_optional_checkpoint_interval( - cfg.train.get("latest_checkpoint_interval", 1) - ) - if latest_checkpoint_interval and ( - epoch % latest_checkpoint_interval == 0 - ): - save_latest_checkpoint( - checkpoint_dir=Path(checkpoint_dir), - epoch=epoch, - save_checkpoint_fn=save_checkpoint, - logger=logger, - models=model, - optimizer=optimizer, - scheduler=scheduler, - scaler=scaler, - metadata={ - "best_val_losses": best_val_losses, - "best_qoi_loss": best_qoi_loss, - "train_loss": train_loss, - "val_loss": val_loss, - "val_loss_qoi": val_loss_qoi, - "case_type": case_type, - }, - ) - - if val_loss_qoi is not None and writer: - writer.add_scalar("Loss/val_qoi", val_loss_qoi, epoch) - - if dist.distributed: - torch_dist.barrier() - - training_completed = True - - except KeyboardInterrupt: - training_completed = False - if dist.rank == 0: - logger.info("\n" + "=" * 70) - logger.info("Training interrupted by user") - logger.info("=" * 70) - # Re-raise so the process exits non-zero and callers can distinguish - # an interrupted run from a clean finish. - raise - - finally: - if finally_fn is not None: - finally_fn() - if writer: - writer.close() + + with LaunchLogger( + "train", + epoch=epoch, + num_mini_batch=len(train_loader), + mini_batch_log_freq=10, + ) as train_log: + train_epoch( + cfg, + train_loader, + model, + optimizer, + scaler, + dist.device, + train_log, + loss_cfg=epoch_loss_cfg, + ) + + with LaunchLogger( + "val", epoch=epoch, num_mini_batch=len(val_loader) + ) as val_log: + ( + val_loss_sum, + val_num_batches, + val_metric_sums, + val_metric_counts, + ) = validate( + cfg, + val_loader, + model, + dist.device, + val_log, + loss_cfg=loss_cfg, + ) + + train_loss = train_log.epoch_losses.get("loss", 0.0) + val_loss = aggregate_validation_loss(val_loss_sum, val_num_batches, dist) + val_metrics = aggregate_validation_metrics( + val_metric_sums, val_metric_counts, dist + ) + val_log.epoch_losses.update(val_metrics) + + scheduler.step() + current_lr = scheduler.get_last_lr()[0] + + logger.info(_format_epoch_log(epoch, train_log, val_log, val_loss, current_lr)) + + val_loss_qoi = val_metrics.get("loss_qoi") if dist.rank == 0: - if training_completed: - logger.info("\n" + "=" * 70) - logger.info("Training completed!") - loss_strs = [f"{loss:.6f}" for loss, _ in best_val_losses] - logger.info(f"Top validation losses: {loss_strs}") - if best_qoi_loss < float("inf"): - logger.info(f"Best QoI loss: {best_qoi_loss:.6e}") - logger.info(f"Checkpoints saved to: {checkpoint_dir}") - logger.info("=" * 70) - - completion_marker = os.path.join(checkpoint_dir, ".training_complete") - with open(completion_marker, "w") as f: - f.write(f"completed_epochs={cfg.train.epochs}\n") - f.write(f"target_epochs={cfg.train.epochs}\n") - logger.info(f"Training complete marker written to: {completion_marker}") + if writer: + writer.add_scalar("Loss/train", train_loss, epoch) + writer.add_scalar("Loss/val", val_loss, epoch) + writer.add_scalar("Learning_Rate", current_lr, epoch) + + if not ( + math.isfinite(train_loss) + and math.isfinite(val_loss) + and (val_loss_qoi is None or math.isfinite(val_loss_qoi)) + ): + logger.warning( + "Skipping checkpoint save for epoch %s because at least " + "one checkpoint metric is NaN or inf: " + "train_loss=%s, val_loss=%s, val_loss_qoi=%s", + epoch, + train_loss, + val_loss, + val_loss_qoi, + ) else: - logger.info("\n" + "=" * 70) - logger.info("Training interrupted (no completion marker written)") - logger.info("=" * 70) + best_val_loss = save_best_checkpoint( + checkpoint_dir=Path(checkpoint_dir), + val_loss=val_loss, + best_val_loss=best_val_loss, + save_checkpoint_fn=save_checkpoint, + logger=logger, + models=model, + optimizer=optimizer, + scheduler=scheduler, + scaler=scaler, + epoch=epoch, + metadata={ + "best_val_loss": best_val_loss, + "train_loss": train_loss, + "val_loss": val_loss, + "val_loss_qoi": val_loss_qoi, + "case_type": case_type, + }, + ) + + if val_loss_qoi is not None and writer: + writer.add_scalar("Loss/val_qoi", val_loss_qoi, epoch) + + if dist.distributed: + torch_dist.barrier() + + if writer: + writer.close() + + logger.info("=" * 70) + logger.info("Training completed!") + if best_val_loss < float("inf"): + logger.info(f"Best validation loss: {best_val_loss:.6f}") + logger.info(f"Checkpoints saved to: {checkpoint_dir}") + logger.info("=" * 70) From b357d90897b52b5a141530f9c8f000a5400cb091 Mon Sep 17 00:00:00 2001 From: Carmelo Gonzales Date: Tue, 12 May 2026 13:55:06 -0700 Subject: [PATCH 38/68] refactor: cleaning out stale transforms code, adding materials utils --- .../radiation_transport/src/transforms.py | 217 ++++++------------ 1 file changed, 74 insertions(+), 143 deletions(-) diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/transforms.py b/examples/cfd/nuclear_engineering/radiation_transport/src/transforms.py index f02755b4ef..742f4eeeb7 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/transforms.py +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/transforms.py @@ -14,76 +14,29 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Transform framework + flux / coordinates / sampling transforms. - -This module consolidates the RTE transform framework with the concrete -preprocessing transforms used by the Transolver pipeline. It is intentionally -flat (no submodules) so the standalone example can be read top-to-bottom: - -* ``Transform`` and ``Compose`` are re-exports from PhysicsNeMo - (``physicsnemo.datapipes.transforms``); RTE transforms subclass ``Transform`` - and operate on ``tensordict.TensorDict`` instances. -* The ``@register(...)`` decorator from - ``physicsnemo.datapipes.registry`` populates the global PhysicsNeMo registry; - we apply it for config-driven instantiation if needed. - -Material transforms live in the sibling ``material.py``. -""" - from __future__ import annotations -# ========================================================================= -# Imports -# ========================================================================= - import math -from pathlib import Path -from typing import Any, Dict, Optional, Union +from typing import Any, Dict, Optional, Tuple -import numpy as np import torch from physicsnemo.datapipes.registry import register from physicsnemo.datapipes.transforms import Transform from tensordict import TensorDict - -# ========================================================================= -# Framework: Transform base + TensorDict utilities -# ========================================================================= -# -# ``Transform`` is imported above. RTE transforms subclass it and operate on -# ``TensorDict``. The TensorDict helpers below -# bridge numpy / torch / non-tensor (str, dict, None) values that flow through -# the pipeline. - - -def td_get(data: TensorDict, key: str, default: Any = None) -> Any: - """``td[key]``-equivalent lookup with a default for missing keys. - - ``TensorDict.get`` returns the raw ``NonTensorData`` wrapper; bracket access - unwraps it but raises ``KeyError`` for missing keys. This helper combines - both semantics. - """ - if key in data: - return data[key] - return default - - -def to_numpy(value: Any) -> np.ndarray: - """Coerce a torch tensor / numpy array / array-like into a numpy array.""" - if isinstance(value, torch.Tensor): - return value.detach().cpu().numpy() - return np.asarray(value) - - -# ========================================================================= -# Flux -# ========================================================================= -# -# ``RTEFluxLogClip`` is the canonical pre-step that clamps flux and applies -# log10 before z-score normalization (the latter performed by -# ``physicsnemo.datapipes.transforms.Normalize``). ``denormalize_flux`` inverts -# the full ``RTEFluxLogClip + Normalize`` chain for evaluation. +__all__ = [ + "Transform", + "RTEFluxLogClip", + "denormalize_flux", + "GLOBAL_DOMAIN_BOUNDS", + "RTEBackupCoords", + "FourierFeatures", + "coord_bounds_for_case", + "coord_translate_scale_params", + "MaterialPropertyExtractor", + "SpatialSampler", + "SteadyStateSampler", +] def denormalize_flux( @@ -116,48 +69,18 @@ class RTEFluxLogClip(Transform): ``scalar_flux`` -- same shape, ``log10(clamp(x, clip) + clip)``. ``flux_normalization_stats`` -- non-tensor dict with ``log_flux_mean``, ``log_flux_std``, ``clip_threshold`` for downstream denormalization. - - Args: - clip_threshold: minimum flux value before log. - log_flux_mean / log_flux_std: stats to record for denorm; if a - ``normalization_stats_file`` is provided these are read from it. - normalization_stats_file: optional path to the RTE flux stats YAML - (``load_flux_stats``). When provided, overrides the inline args - and validates ``clip_threshold`` against the file's value. """ def __init__( self, - clip_threshold: float = 1e-8, - log_flux_mean: Optional[float] = None, - log_flux_std: Optional[float] = None, - normalization_stats_file: Optional[Union[str, Path]] = None, + clip_threshold: float, + log_flux_mean: float, + log_flux_std: float, ) -> None: super().__init__() - - if normalization_stats_file is not None: - # Imported lazily to avoid a circular import: ``dataset.py`` itself - # may pull symbols from this module via the flat-import shim. - from dataset import load_flux_stats - - stats = load_flux_stats(normalization_stats_file) - if abs(stats["clip_threshold"] - clip_threshold) > 1e-10: - raise ValueError( - f"Clip threshold mismatch: got {clip_threshold}, " - f"stats computed with {stats['clip_threshold']}" - ) - self.clip_threshold = float(stats["clip_threshold"]) - self.log_flux_mean = float(stats["log_flux_mean"]) - self.log_flux_std = float(stats["log_flux_std"]) - elif log_flux_mean is not None and log_flux_std is not None: - self.clip_threshold = float(clip_threshold) - self.log_flux_mean = float(log_flux_mean) - self.log_flux_std = float(log_flux_std) - else: - raise ValueError( - "Either normalization_stats_file or (log_flux_mean, log_flux_std) " - "must be provided." - ) + self.clip_threshold = float(clip_threshold) + self.log_flux_mean = float(log_flux_mean) + self.log_flux_std = float(log_flux_std) def __call__(self, data: TensorDict) -> TensorDict: flux = data["scalar_flux"] @@ -182,17 +105,6 @@ def extra_repr(self) -> str: ) -# ========================================================================= -# Coordinates (Fourier features) -# ========================================================================= -# -# The default coordinate-normalization chain is -# ``[RTEBackupCoords, Translate, Scale]`` (the latter two are stock PhysicsNeMo -# transforms). ``GLOBAL_DOMAIN_BOUNDS`` is the canonical per-case bbox table -# referenced from the config-build site and direct consumers. -# ``FourierFeatures`` has no PhysicsNeMo equivalent and stays custom. - - GLOBAL_DOMAIN_BOUNDS = { "lattice": { "min": torch.tensor([-3.5, -3.5, -0.01], dtype=torch.float32), @@ -279,14 +191,6 @@ def extra_repr(self) -> str: ) -# ========================================================================= -# Sampling (spatial + steady-state) -# ========================================================================= -# -# ``SpatialSampler`` randomly subsamples point clouds to a target size. -# ``SteadyStateSampler`` extracts the fixed initial->final flux mapping. - - @register("RTESpatialSampler") class SpatialSampler(Transform): """Randomly subsample spatial points to ``num_points``. @@ -318,14 +222,10 @@ def set_epoch(self, epoch: int) -> None: self.gen.manual_seed(int(self.seed) + int(epoch) * self._EPOCH_PRIME) def to(self, device): - """Override base ``Transform.to`` to keep ``self.gen`` on CPU. - - ``torch.randperm`` insists the generator's device match the output - tensor's device. We pin index draws on CPU and ``.to(device)`` only - the selected indices below, so leaving the generator on CPU keeps - the transform device-agnostic. + """No-op device move. ``self.gen`` stays pinned to CPU because + ``torch.randperm`` requires its generator and output to share a + device; selected indices are moved inside ``__call__``. """ - self._device = torch.device(device) if isinstance(device, str) else device return self def __call__(self, data: TensorDict) -> TensorDict: @@ -387,7 +287,7 @@ def __call__(self, data: TensorDict) -> TensorDict: input_idx = 0 target_idx = flux_all.shape[0] - 1 - metadata = td_get(data, "metadata", default={}) or {} + metadata = data["metadata"] if "metadata" in data else {} max_timestep = ( metadata.get("max_timestep") if isinstance(metadata, dict) else None ) @@ -402,23 +302,54 @@ def __call__(self, data: TensorDict) -> TensorDict: return data -# ========================================================================= -# Public API -# ========================================================================= +@register("RTEMaterialPropertyExtractor") +class MaterialPropertyExtractor(Transform): + """Stack precomputed sigma fields into a per-cell ``(N, 4)`` tensor. -__all__ = [ - # Framework - "Transform", - "td_get", - "to_numpy", - # Flux - "RTEFluxLogClip", - "denormalize_flux", - # Coordinates - "GLOBAL_DOMAIN_BOUNDS", - "RTEBackupCoords", - "FourierFeatures", - # Sampling - "SpatialSampler", - "SteadyStateSampler", -] + Q must be present in the source data; it may be all-zero for source-free + regimes (e.g., hohlraum). + """ + + def __call__(self, data: TensorDict) -> TensorDict: + for key in ("sigma_a", "sigma_s", "sigma_t", "Q"): + if key not in data: + raise KeyError( + f"Mesh store is missing required field {key!r}. " + "All four fields (sigma_a, sigma_s, sigma_t, Q) must be precomputed." + ) + + data["physical_properties"] = torch.stack( + [data["sigma_a"], data["sigma_s"], data["sigma_t"], data["Q"]], + dim=-1, + ).to(dtype=torch.float32) + return data + + +def coord_bounds_for_case(case_type: str) -> Tuple[torch.Tensor, torch.Tensor]: + """Return ``(bbox_min, bbox_max)`` as float32 tensors for a known case.""" + if case_type not in GLOBAL_DOMAIN_BOUNDS: + raise ValueError( + f"Unknown case_type '{case_type}'. " + f"Expected one of: {list(GLOBAL_DOMAIN_BOUNDS.keys())}" + ) + bounds = GLOBAL_DOMAIN_BOUNDS[case_type] + return ( + torch.as_tensor(bounds["min"], dtype=torch.float32), + torch.as_tensor(bounds["max"], dtype=torch.float32), + ) + + +def coord_translate_scale_params( + case_type: str, +) -> Tuple[torch.Tensor, torch.Tensor]: + """Compute ``(center, half_extent)`` for ``Translate`` + ``Scale``. + + Returns the tensors so the caller can wire them straight into + ``Translate(center_key_or_value=center, subtract=True)`` followed by + ``Scale(scale=half_extent, divide=True)`` — i.e. the standard + ``(x - center) / half_extent`` normalization into ``[-1, 1]``. + """ + bbox_min, bbox_max = coord_bounds_for_case(case_type) + center = 0.5 * (bbox_min + bbox_max) + half_extent = 0.5 * (bbox_max - bbox_min) + return center, half_extent From f2f5ac81ec042a8d983a2237f644fd6db6251221 Mon Sep 17 00:00:00 2001 From: Carmelo Gonzales Date: Tue, 12 May 2026 13:58:24 -0700 Subject: [PATCH 39/68] fix: purging some excessive comments --- .../src/compute_normalizations.py | 23 ++----------------- 1 file changed, 2 insertions(+), 21 deletions(-) diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/compute_normalizations.py b/examples/cfd/nuclear_engineering/radiation_transport/src/compute_normalizations.py index 7c24d1b4e6..ba32310e24 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/compute_normalizations.py +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/compute_normalizations.py @@ -24,7 +24,7 @@ Usage:: - python compute_normalizations.py \\ + python src/compute_normalizations.py \\ --data_path /lattice \\ --case_type lattice \\ --split_file /splits/lattice_splits.json \\ @@ -50,12 +50,7 @@ import yaml from dataset import RTEBaseDataset -from material import MaterialPropertyExtractor - - -# ========================================================================= -# Flux statistics -# ========================================================================= +from transforms import MaterialPropertyExtractor def compute_flux_statistics( @@ -151,15 +146,6 @@ def compute_flux_statistics( return stats -# ========================================================================= -# Material statistics -# ========================================================================= -# -# Reads the precomputed sigma_a / sigma_s / sigma_t / Q fields from each mesh -# store in the training split and accumulates per-property mean / std / min / -# max across all cells. Schema matches what ``load_material_stats`` expects. - - def compute_material_statistics( data_path: Path, case_type: str, @@ -264,11 +250,6 @@ def compute_material_statistics( return stats -# ========================================================================= -# CLI entry -# ========================================================================= - - def _parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser( description=( From 93e13a67e4022a24f9030b4a77e2b44d858ec11b Mon Sep 17 00:00:00 2001 From: Carmelo Gonzales Date: Tue, 12 May 2026 13:58:55 -0700 Subject: [PATCH 40/68] refactor: delete material file --- .../radiation_transport/src/material.py | 55 ------------------- 1 file changed, 55 deletions(-) delete mode 100644 examples/cfd/nuclear_engineering/radiation_transport/src/material.py diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/material.py b/examples/cfd/nuclear_engineering/radiation_transport/src/material.py deleted file mode 100644 index ede031dbc8..0000000000 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/material.py +++ /dev/null @@ -1,55 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2023 - 2026 NVIDIA CORPORATION & AFFILIATES. -# SPDX-FileCopyrightText: All rights reserved. -# SPDX-License-Identifier: Apache-2.0 -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Material-property transform for radiation-transport surrogates. - -The shipped mesh stores carry precomputed cross-section fields -(``sigma_a``, ``sigma_s``, ``sigma_t``, ``Q``) per cell. ``MaterialPropertyExtractor`` -stacks them into a single ``physical_properties`` tensor of shape ``(N, 4)`` -in the order ``[sigma_a, sigma_s, sigma_t, Q]``. -""" - -from __future__ import annotations - -import torch -from tensordict import TensorDict - -from transforms import Transform - - -class MaterialPropertyExtractor(Transform): - """Stack precomputed sigma fields into a per-cell ``(N, 4)`` tensor. - - Q must be present in the source data; it may be all-zero for source-free - regimes (e.g., hohlraum). - """ - - def __call__(self, data: TensorDict) -> TensorDict: - for key in ("sigma_a", "sigma_s", "sigma_t", "Q"): - if key not in data: - raise KeyError( - f"Mesh store is missing required field {key!r}. " - "All four fields (sigma_a, sigma_s, sigma_t, Q) must be precomputed." - ) - - data["physical_properties"] = torch.stack( - [data["sigma_a"], data["sigma_s"], data["sigma_t"], data["Q"]], - dim=-1, - ).to(dtype=torch.float32) - return data - - -__all__ = ["MaterialPropertyExtractor"] From 42dd8257028da94ea907541e14340baff07a9026 Mon Sep 17 00:00:00 2001 From: Carmelo Gonzales Date: Tue, 12 May 2026 14:00:20 -0700 Subject: [PATCH 41/68] refactor: updating configs per refactors --- .../src/conf/case/hohlraum.yaml | 1 - .../src/conf/case/lattice.yaml | 1 - .../radiation_transport/src/conf/config.yaml | 1 + .../src/conf/data/hohlraum.yaml | 2 -- .../src/conf/data/lattice.yaml | 2 -- .../src/conf/inference/default.yaml | 26 +++++++++++++++++++ .../src/conf/train/base.yaml | 10 ++----- 7 files changed, 29 insertions(+), 14 deletions(-) create mode 100644 examples/cfd/nuclear_engineering/radiation_transport/src/conf/inference/default.yaml diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/conf/case/hohlraum.yaml b/examples/cfd/nuclear_engineering/radiation_transport/src/conf/case/hohlraum.yaml index 11c4f4c208..ac2fb9bc1b 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/conf/case/hohlraum.yaml +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/conf/case/hohlraum.yaml @@ -25,7 +25,6 @@ split_file: ${case.data_root}/splits/hohlraum_splits.json # Physics-loss configuration (hohlraum-specific override). physics_loss_weight: 0.01 -qoi_region: all # all (mean of 4) | center | vertical | horizontal | total # Embedding/material configuration: hohlraum has no Q field. include_q_in_embedding: false diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/conf/case/lattice.yaml b/examples/cfd/nuclear_engineering/radiation_transport/src/conf/case/lattice.yaml index 7b85c91410..61f4273d1d 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/conf/case/lattice.yaml +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/conf/case/lattice.yaml @@ -25,7 +25,6 @@ split_file: ${case.data_root}/splits/lattice_splits.json # Physics-loss configuration (lattice-specific) physics_loss_weight: 0.005 -qoi_region: center # Note: lattice QoI uses cur_absorption regardless of this value. # Embedding/material configuration include_q_in_embedding: true # lattice has a heat source diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/conf/config.yaml b/examples/cfd/nuclear_engineering/radiation_transport/src/conf/config.yaml index 65ec902630..5598a7db3b 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/conf/config.yaml +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/conf/config.yaml @@ -19,6 +19,7 @@ defaults: - data: lattice - model: transolver - train: base + - inference: default - _self_ project: diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/conf/data/hohlraum.yaml b/examples/cfd/nuclear_engineering/radiation_transport/src/conf/data/hohlraum.yaml index 38fddc05d3..ebb261fb35 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/conf/data/hohlraum.yaml +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/conf/data/hohlraum.yaml @@ -19,8 +19,6 @@ flux_normalization_stats_file: ${case.data_root}/stats/hohlraum_flux_stats.yaml flux_clip_threshold: 1.0e-8 normalize_coordinates: true cache_static_arrays: true -max_cache_size: -1 -preload_data: true use_fourier_features: true fourier_features: diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/conf/data/lattice.yaml b/examples/cfd/nuclear_engineering/radiation_transport/src/conf/data/lattice.yaml index c01f321dd2..91a4eccd7f 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/conf/data/lattice.yaml +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/conf/data/lattice.yaml @@ -19,8 +19,6 @@ flux_normalization_stats_file: ${case.data_root}/stats/lattice_flux_stats.yaml flux_clip_threshold: 1.0e-8 normalize_coordinates: true cache_static_arrays: true -max_cache_size: -1 -preload_data: true # Fourier features for coordinates (adds 2 * coord_dims * num_frequencies features). # Default: 3 freq * 2 coords * 2 (sin/cos) = 12 extra features, on top of 3 raw coords. diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/conf/inference/default.yaml b/examples/cfd/nuclear_engineering/radiation_transport/src/conf/inference/default.yaml new file mode 100644 index 0000000000..1b03053ea7 --- /dev/null +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/conf/inference/default.yaml @@ -0,0 +1,26 @@ +# SPDX-FileCopyrightText: Copyright (c) 2023 - 2026 NVIDIA CORPORATION & AFFILIATES. +# SPDX-FileCopyrightText: All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Inference / evaluation knobs. All fields are required to be set +# explicitly (no fallbacks from the training-time config); the user +# supplies them via Hydra overrides on the CLI. + +checkpoint_path: ??? # path to the best_model directory (contains checkpoint.0.*.pt + Transolver.0.*.mdlus) +output_dir: ??? # where to write metrics.yaml, qoi_metrics.yaml, and figures/ +num_samples: null # cap on number of test samples; null = all +num_plot_samples: 3 # number of per-sample flux-panel figures (evenly sampled across the test set) +device: null # torch device override; null = cuda if available else cpu +use_amp: true # autocast in eval; bf16 on CUDA, off on CPU diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/conf/train/base.yaml b/examples/cfd/nuclear_engineering/radiation_transport/src/conf/train/base.yaml index 19ef2fefd1..94a2ed03a4 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/conf/train/base.yaml +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/conf/train/base.yaml @@ -15,11 +15,8 @@ # limitations under the License. epochs: 501 -checkpoint_interval: 100 -latest_checkpoint_interval: null # 0/null disables rolling latest_checkpoint/ gradient_accumulation_steps: 1 seed: 6 -deterministic: false amp: true amp_dtype: bf16 # bf16 | fp16 loss_metric: mse @@ -28,24 +25,21 @@ tensorboard: true optimizer: type: adam # adam | muon weight_decay: 0.0 - muon_momentum_beta: 0.95 - muon_lr: 1.0e-4 + muon_momentum_beta: 0.95 # Muon-only; AdamW (for 1D params) uses the shared learning_rate via match_rms_adamw learning_rate: 3.0e-5 min_learning_rate: 1.0e-6 warmup_epochs: 10 -scheduler_type: cosine pretrain_checkpoint: null resume_checkpoint: null # objective = mse_weight * regression_mse + physics_loss.weight * (regression_mse + qoi_loss); region_weighted swaps regression_mse for the weighted variant. -# Physics-informed loss (case-specific weight + qoi_region pulled from case group). +# Physics-informed loss (case-specific weight). use_physics_loss: true physics_loss: weight: ${case.physics_loss_weight} mse_weight: 1.0 - qoi_region: ${case.qoi_region} warmup_epochs: 0 warmup_start_fraction: 0.0 From c220502ad54a59e37ecc1e131e8cafad949f1474 Mon Sep 17 00:00:00 2001 From: Carmelo Gonzales Date: Tue, 12 May 2026 14:00:42 -0700 Subject: [PATCH 42/68] feat: adding eval metrics file --- .../src/evaluation_metrics.py | 170 ++++++++++++++++++ 1 file changed, 170 insertions(+) create mode 100644 examples/cfd/nuclear_engineering/radiation_transport/src/evaluation_metrics.py diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/evaluation_metrics.py b/examples/cfd/nuclear_engineering/radiation_transport/src/evaluation_metrics.py new file mode 100644 index 0000000000..dea53db1a3 --- /dev/null +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/evaluation_metrics.py @@ -0,0 +1,170 @@ +# SPDX-FileCopyrightText: Copyright (c) 2023 - 2026 NVIDIA CORPORATION & AFFILIATES. +# SPDX-FileCopyrightText: All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from typing import Any, Dict, Optional + +import numpy as np +import torch + +from qoi import ( + evaluate_hohlraum_qoi_torch, + evaluate_lattice_qoi_torch, + extract_geometry_params, +) + +__all__ = [ + "compute_metrics", + "aggregate_metrics", + "compute_sample_qoi", + "aggregate_qoi", +] + + +def compute_metrics( + pred: np.ndarray, target: np.ndarray, eps: float = 1e-10 +) -> Dict[str, float]: + """Compute the full metric panel for one ``(pred, target)`` pair.""" + pred_flat = pred.flatten() + target_flat = target.flatten() + diff = pred_flat - target_flat + abs_diff = np.abs(diff) + mse = float(np.mean(diff**2)) + return { + "mse": mse, + "rmse": float(np.sqrt(mse)), + "mae": float(np.mean(abs_diff)), + "l2_relative_error": float( + np.linalg.norm(diff) / (np.linalg.norm(target_flat) + eps) + ), + "relative_error": float(np.mean(abs_diff / (np.abs(target_flat) + eps))), + "max_error": float(np.max(abs_diff)), + } + + +def aggregate_metrics(per_sample: list[Dict[str, float]]) -> Dict[str, float]: + """Aggregate per-sample metrics into mean/min/max.""" + if not per_sample: + return {} + keys = per_sample[0].keys() + out: Dict[str, float] = {} + for k in keys: + vals = [s[k] for s in per_sample] + out[f"{k}_mean"] = float(np.mean(vals)) + out[f"{k}_std"] = float(np.std(vals)) + out[f"{k}_min"] = float(np.min(vals)) + out[f"{k}_max"] = float(np.max(vals)) + return out + + +def compute_sample_qoi( + pred: torch.Tensor, + target: torch.Tensor, + cell_centers: torch.Tensor, + cell_areas: torch.Tensor, + sigma_t: torch.Tensor, + sigma_s: torch.Tensor, + raw_metadata: Dict[str, Any], + case_type: str, +) -> Optional[Dict[str, Dict[str, float]]]: + """Compute QoI(pred) vs QoI(target) for one sample on the tensors' device. + + All tensor inputs may live on GPU; only the scalar QoI values are + materialized to host (via ``.item()``). Returns ``{region: {predicted, + ground_truth, absolute_error, relative_error_pct}}`` or ``None`` for the + hohlraum case when geometry params are missing from ``raw_metadata``. + """ + # The QoI evaluators expect ``(1, N)`` batched flux + flat (N,) cell fields. + pred_batched = pred.float().reshape(1, -1) + target_batched = target.float().reshape(1, -1) + centers = cell_centers.float() + areas = cell_areas.float().flatten() + sigma_t_flat = sigma_t.float().flatten() + sigma_s_flat = sigma_s.float().flatten() + # Placeholder — the steady-state QoI evaluators accept ``sim_times`` only + # for callsite uniformity with the time-dependent variants. + sim_times = torch.zeros(1, device=pred.device) + + if case_type == "lattice": + qoi_pred = evaluate_lattice_qoi_torch( + centers, areas, sigma_t_flat, sigma_s_flat, pred_batched, sim_times + ) + qoi_target = evaluate_lattice_qoi_torch( + centers, areas, sigma_t_flat, sigma_s_flat, target_batched, sim_times + ) + elif case_type == "hohlraum": + geometry_params = extract_geometry_params(raw_metadata) + if not geometry_params: + return None + qoi_pred = evaluate_hohlraum_qoi_torch( + centers, + areas, + sigma_t_flat, + sigma_s_flat, + pred_batched, + sim_times, + geometry_params, + ) + qoi_target = evaluate_hohlraum_qoi_torch( + centers, + areas, + sigma_t_flat, + sigma_s_flat, + target_batched, + sim_times, + geometry_params, + ) + else: + raise ValueError(f"Unknown case_type: {case_type}") + + out: Dict[str, Dict[str, float]] = {} + for region in qoi_pred: + pred_value = float(qoi_pred[region][0].item()) + target_value = float(qoi_target[region][0].item()) + abs_err = abs(pred_value - target_value) + out[region] = { + "predicted": pred_value, + "ground_truth": target_value, + "absolute_error": abs_err, + "relative_error_pct": abs_err / (abs(target_value) + 1e-10) * 100.0, + } + return out + + +def aggregate_qoi( + per_sample_qoi: list[Dict[str, Dict[str, float]]], +) -> Dict[str, Dict[str, float]]: + """Aggregate per-sample QoI dicts into per-region summary statistics.""" + by_region: Dict[str, list] = {} + for sample in per_sample_qoi: + if not sample: + continue + for region, entry in sample.items(): + by_region.setdefault(region, []).append(entry) + + summary: Dict[str, Dict[str, float]] = {} + for region, entries in by_region.items(): + abs_errs = np.array([e["absolute_error"] for e in entries]) + rel_errs = np.array([e["relative_error_pct"] for e in entries]) + summary[region] = { + "num_samples": len(entries), + "mae": float(np.mean(abs_errs)), + "rmse": float(np.sqrt(np.mean(abs_errs**2))), + "max_error": float(np.max(abs_errs)), + "mean_relative_error_pct": float(np.mean(rel_errs)), + "median_relative_error_pct": float(np.median(rel_errs)), + "max_relative_error_pct": float(np.max(rel_errs)), + } + return summary From bc9b60e5d96724ab44d4329b7460d3449dbf9332 Mon Sep 17 00:00:00 2001 From: Carmelo Gonzales Date: Tue, 12 May 2026 14:00:54 -0700 Subject: [PATCH 43/68] feat: adding qoi file --- .../radiation_transport/src/qoi.py | 218 ++++++++++++++++++ 1 file changed, 218 insertions(+) create mode 100644 examples/cfd/nuclear_engineering/radiation_transport/src/qoi.py diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/qoi.py b/examples/cfd/nuclear_engineering/radiation_transport/src/qoi.py new file mode 100644 index 0000000000..62bed83265 --- /dev/null +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/qoi.py @@ -0,0 +1,218 @@ +# SPDX-FileCopyrightText: Copyright (c) 2023 - 2026 NVIDIA CORPORATION & AFFILIATES. +# SPDX-FileCopyrightText: All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Differentiable PyTorch QoI evaluators for the RTE benchmarks. + +These match KiT-RT's SNSolverHPC::IterPostprocessing() and are shared by the +training-time physics loss (losses.py) and the evaluation-time QoI metrics +(inference.py). + +https://github.com/KiT-RT/kitrt_code/blob/d257b1a3c6fb3fa13d8a346adca5360a95101932/src/solvers/snsolver_hpc.cpp#L594 + +The evaluators are differentiable and steady-state (T=1); ``sim_times`` is +accepted only for callsite uniformity with the time-dependent variants. +""" + +from __future__ import annotations + +import torch + +__all__ = [ + "evaluate_lattice_qoi_torch", + "evaluate_hohlraum_qoi_torch", + "extract_geometry_params", +] + + +_HOHLRAUM_GEOMETRY_KEYS = ("ulr", "llr", "urr", "lrr", "hlr", "hrr", "cx", "cy") + + +def extract_geometry_params(metadata) -> dict: + """Extract hohlraum geometry parameters from sample metadata. + + Reads ``simulation_params.parameters`` out of the sidecar-derived metadata + dict and returns the 8-key geometry dict consumed by the hohlraum QoI + evaluator (``ulr, llr, urr, lrr, hlr, hrr, cx, cy``). Accepts either a + single metadata dict or a batched list of dicts. + """ + if isinstance(metadata, (list, tuple)): + metadata = metadata[0] if metadata else {} + if not isinstance(metadata, dict): + return {} + + params = metadata.get("simulation_params", {}).get("parameters", {}) + return {k: float(params[k]) for k in _HOHLRAUM_GEOMETRY_KEYS if k in params} + + +def evaluate_lattice_qoi_torch( + cell_centers: torch.Tensor, + cell_areas: torch.Tensor, + sigma_t: torch.Tensor, + sigma_s: torch.Tensor, + scalar_flux: torch.Tensor, + sim_times: torch.Tensor, +) -> dict[str, torch.Tensor]: + """Lattice absorption QoI, differentiable. + + When the leading dim of ``cell_centers`` is a (size-1) batch dim, the + call recurses on the squeezed slot and re-adds the dim on the way out. + + Args: + cell_centers: (N, 3) or (1, N, 3) + cell_areas: (N,) or (1, N) + sigma_t: (N,) or (1, N) + sigma_s: (N,) or (1, N) + scalar_flux: (T, N) or (1, T, N) — only T=1 is exercised + sim_times: (T,) or (1, T) — accepted for callsite uniformity, unused + + Returns: + ``{"cur_absorption": (T,) or (1, T)}`` + """ + if cell_centers.ndim == 3: + if cell_centers.shape[0] != 1: + raise NotImplementedError( + "evaluate_lattice_qoi_torch only supports batch_size=1; " + f"got batch={cell_centers.shape[0]}." + ) + result = evaluate_lattice_qoi_torch( + cell_centers[0], + cell_areas[0], + sigma_t[0], + sigma_s[0], + scalar_flux[0], + sim_times[0] if sim_times.ndim == 2 else sim_times, + ) + return {k: v.unsqueeze(0) for k, v in result.items()} + + if scalar_flux.ndim != 2: + raise ValueError(f"Expected scalar_flux shape (T, N), got {scalar_flux.shape}") + + x = cell_centers[:, 0] + y = cell_centers[:, 1] + sigma_a = sigma_t - sigma_s + + xy_corrector = -3.5 + lbounds = torch.tensor([1.0, 2.0, 3.0, 4.0, 5.0]) + xy_corrector + ubounds = torch.tensor([2.0, 3.0, 4.0, 5.0, 6.0]) + xy_corrector + + in_absorption = torch.zeros_like(x, dtype=torch.bool) + for k in range(5): + for l in range(5): # noqa: E741 + if (l + k) % 2 == 1: + continue + if (k == 2 and l == 2) or (k == 2 and l == 4): + continue + in_square = ( + (x >= lbounds[k]) + & (x <= ubounds[k]) + & (y >= lbounds[l]) + & (y <= ubounds[l]) + ) + in_absorption = in_absorption | in_square + + absorption_density = scalar_flux * sigma_a.unsqueeze(0) * cell_areas.unsqueeze(0) + cur_absorption = torch.sum( + absorption_density * in_absorption.unsqueeze(0).to(dtype=torch.float32), + dim=1, + ) + return {"cur_absorption": cur_absorption} + + +def evaluate_hohlraum_qoi_torch( + cell_centers: torch.Tensor, + cell_areas: torch.Tensor, + sigma_t: torch.Tensor, + sigma_s: torch.Tensor, + scalar_flux: torch.Tensor, + sim_times: torch.Tensor, + geometry_params: dict[str, float], +) -> dict[str, torch.Tensor]: + """Hohlraum per-region absorption QoI, differentiable. + + Three regions are returned: ``center`` (the capsule volume), ``vertical`` + (red wall strips on either x boundary), and ``horizontal`` (the top + bottom + strips). The vertical-wall predicate uses ``pos_red_left_bottom`` for both + sides — see the inline ``NOTE`` for why. + + When the leading dim of ``cell_centers`` is a (size-1) batch dim, the + call recurses on the squeezed slot and re-adds the dim on the way out. + + Args: + cell_centers: (N, 3) or (1, N, 3) + cell_areas: (N,) or (1, N) + sigma_t: (N,) or (1, N) + sigma_s: (N,) or (1, N) + scalar_flux: (T, N) or (1, T, N) — only T=1 is exercised + sim_times: (T,) or (1, T) — accepted for callsite uniformity, unused + geometry_params: dict with cx, cy, hlr, hrr, llr, ulr, lrr, urr + + Returns: + Dict with ``cur_absorption_{center,vertical,horizontal}``. + """ + if cell_centers.ndim == 3: + if cell_centers.shape[0] != 1: + raise NotImplementedError( + "evaluate_hohlraum_qoi_torch only supports batch_size=1; " + f"got batch={cell_centers.shape[0]}." + ) + result = evaluate_hohlraum_qoi_torch( + cell_centers[0], + cell_areas[0], + sigma_t[0], + sigma_s[0], + scalar_flux[0], + sim_times[0] if sim_times.ndim == 2 else sim_times, + geometry_params, + ) + return {k: v.unsqueeze(0) for k, v in result.items()} + + if scalar_flux.ndim != 2: + raise ValueError(f"Expected scalar_flux shape (T, N), got {scalar_flux.shape}") + + x = cell_centers[:, 0] + y = cell_centers[:, 1] + + cx = geometry_params["cx"] + cy = geometry_params["cy"] + pos_red_left_border = geometry_params["hlr"] + pos_red_right_border = geometry_params["hrr"] + pos_red_left_bottom = geometry_params["llr"] + pos_red_left_top = geometry_params["ulr"] + pos_red_right_top = geometry_params["urr"] + + sigma_a = sigma_t - sigma_s + + in_center = (x > -0.2 + cx) & (x < 0.2 + cx) & (y > -0.4 + cy) & (y < 0.4 + cy) + # NOTE: matches KiT-RT's behavior of using pos_red_left_bottom for both sides + in_vertical = ( + (x < pos_red_left_border) & (y > pos_red_left_bottom) & (y < pos_red_left_top) + ) | ( + (x > pos_red_right_border) & (y > pos_red_left_bottom) & (y < pos_red_right_top) + ) + in_horizontal = (y > 0.6) | (y < -0.6) + + absorption_density = scalar_flux * sigma_a.unsqueeze(0) * cell_areas.unsqueeze(0) + + def _region_sum(mask: torch.Tensor) -> torch.Tensor: + return torch.sum( + absorption_density * mask.unsqueeze(0).to(dtype=torch.float32), dim=1 + ) + + return { + "cur_absorption_center": _region_sum(in_center), + "cur_absorption_vertical": _region_sum(in_vertical), + "cur_absorption_horizontal": _region_sum(in_horizontal), + } From 8b997d161c68e2a37fd2b1f52790cc4400b77d0b Mon Sep 17 00:00:00 2001 From: Carmelo Gonzales Date: Tue, 12 May 2026 14:36:04 -0700 Subject: [PATCH 44/68] feat: add visualizations file --- .../radiation_transport/src/viz.py | 176 ++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 examples/cfd/nuclear_engineering/radiation_transport/src/viz.py diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/viz.py b/examples/cfd/nuclear_engineering/radiation_transport/src/viz.py new file mode 100644 index 0000000000..6e5953b6ce --- /dev/null +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/viz.py @@ -0,0 +1,176 @@ +# SPDX-FileCopyrightText: Copyright (c) 2023 - 2026 NVIDIA CORPORATION & AFFILIATES. +# SPDX-FileCopyrightText: All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from pathlib import Path +from typing import Dict, Tuple, Union + +import matplotlib + +matplotlib.use("Agg") +import matplotlib.pyplot as plt +from matplotlib.colors import LogNorm +import numpy as np + +__all__ = ["plot_flux_panels", "plot_qoi_true_vs_pred"] + + +def plot_flux_panels( + coordinates: np.ndarray, + target: np.ndarray, + prediction: np.ndarray, + output_path: Union[str, Path], + log_flux: bool = False, + figsize: Tuple[int, int] = (16, 5), + dpi: int = 150, +) -> Path: + """Render a 3-panel figure: target | prediction | absolute error.""" + output_path = Path(output_path) + output_path.parent.mkdir(parents=True, exist_ok=True) + + target = target.flatten() + prediction = prediction.flatten() + error = np.abs(prediction - target) + + x, y = coordinates[:, 0], coordinates[:, 1] + x_pad = (x.max() - x.min()) * 0.01 + y_pad = (y.max() - y.min()) * 0.01 + xlim = (x.min() - x_pad, x.max() + x_pad) + ylim = (y.min() - y_pad, y.max() + y_pad) + + fig, axes = plt.subplots(1, 3, figsize=figsize, dpi=dpi) + flux_vmin = min(target.min(), prediction.min()) + flux_vmax = max(target.max(), prediction.max()) + flux_norm = None + if log_flux: + positive_flux = np.concatenate( + [target[target > 0.0], prediction[prediction > 0.0]] + ) + if positive_flux.size: + flux_vmin = float(positive_flux.min()) + flux_vmax = float(positive_flux.max()) + if flux_vmin == flux_vmax: + flux_vmax = flux_vmin * 1.01 + flux_norm = LogNorm(vmin=flux_vmin, vmax=flux_vmax) + else: + log_flux = False + cmap_flux = plt.get_cmap("viridis") + cmap_err = plt.get_cmap("hot") + + for ax, label, vals, cmap, vmin, vmax, norm in ( + (axes[0], "Target", target, cmap_flux, flux_vmin, flux_vmax, flux_norm), + ( + axes[1], + "Prediction", + prediction, + cmap_flux, + flux_vmin, + flux_vmax, + flux_norm, + ), + (axes[2], "Absolute Error", error, cmap_err, 0.0, float(error.max()), None), + ): + plot_vals = np.clip(vals, flux_vmin, None) if norm is not None else vals + sc = ax.scatter( + x, + y, + c=plot_vals, + cmap=cmap, + vmin=None if norm is not None else vmin, + vmax=None if norm is not None else vmax, + norm=norm, + s=1, + ) + ax.set_aspect("equal") + ax.set_xlim(xlim) + ax.set_ylim(ylim) + ax.set_title(f"{label} (log)" if norm is not None else label) + plt.colorbar(sc, ax=ax) + + plt.tight_layout() + plt.savefig(output_path, dpi=dpi, bbox_inches="tight") + plt.close(fig) + return output_path + + +def plot_qoi_true_vs_pred( + per_sample_qoi: list[Dict[str, Dict[str, float]]], + output_path: Union[str, Path], + dpi: int = 150, +) -> Path: + """Scatter predicted vs ground-truth QoI values for each component. + + Takes the same per-sample QoI list that ``aggregate_qoi`` consumes; the + per-component arrays are flattened inline rather than via a separate + collector. + """ + output_path = Path(output_path) + output_path.parent.mkdir(parents=True, exist_ok=True) + + # Preserve first-seen order of component names across samples. + component_names: list[str] = [] + for sample in per_sample_qoi: + for name in sample: + if name not in component_names: + component_names.append(name) + + series: Dict[str, Tuple[np.ndarray, np.ndarray]] = {} + for name in component_names: + target_vals: list[float] = [] + pred_vals: list[float] = [] + for sample in per_sample_qoi: + entry = sample.get(name) + if entry is None: + continue + target_vals.append(entry["ground_truth"]) + pred_vals.append(entry["predicted"]) + if target_vals: + series[name] = (np.array(target_vals), np.array(pred_vals)) + + items = list(series.items()) + if not items: + plt.close(plt.figure()) + return output_path + + ncols = min(len(items), 3) + nrows = int(np.ceil(len(items) / ncols)) + fig, axes = plt.subplots( + nrows, ncols, figsize=(5 * ncols, 4.5 * nrows), dpi=dpi, squeeze=False + ) + + for ax, (name, (target, prediction)) in zip(axes.flat, items): + lo = float(min(target.min(), prediction.min())) + hi = float(max(target.max(), prediction.max())) + if lo == hi: + pad = max(abs(lo) * 0.05, 1e-12) + lo -= pad + hi += pad + + ax.scatter(target, prediction, s=18, alpha=0.75) + ax.plot([lo, hi], [lo, hi], "r--", linewidth=1.0, label="y = x") + ax.set_title(name) + ax.set_xlabel("Ground truth QoI") + ax.set_ylabel("Predicted QoI") + ax.set_aspect("equal") + ax.legend(loc="best") + + for ax in axes.flat[len(items) :]: + ax.axis("off") + + fig.suptitle("QoI predicted vs. ground truth") + plt.tight_layout() + plt.savefig(output_path, dpi=dpi, bbox_inches="tight") + plt.close(fig) + return output_path From 11f2efd964d53f27012f3ccfa1045613c68d9567 Mon Sep 17 00:00:00 2001 From: Carmelo Gonzales Date: Tue, 12 May 2026 15:01:18 -0700 Subject: [PATCH 45/68] fix: path update --- .../cfd/nuclear_engineering/radiation_transport/src/loader.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/loader.py b/examples/cfd/nuclear_engineering/radiation_transport/src/loader.py index efa8688d67..e3b4a8ee32 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/loader.py +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/loader.py @@ -316,7 +316,7 @@ def _build_transforms( if not material_stats_path.exists(): raise FileNotFoundError( f"Material statistics file not found: {material_stats_path}\n" - f"Run scripts/compute_normalizations.py to generate it." + f"Run src/compute_normalizations.py to generate it." ) material_stats = load_material_stats(material_stats_path) transform_list.append( From 234d2c2cf5e0b9097dae12b785c9b001f92fa5ce Mon Sep 17 00:00:00 2001 From: Carmelo Gonzales Date: Tue, 12 May 2026 15:24:14 -0700 Subject: [PATCH 46/68] fix: rename steady state to final time --- .../radiation_transport/README.md | 180 ++++++++---------- .../src/compute_normalizations.py | 4 +- .../radiation_transport/src/dataset.py | 4 +- .../src/evaluation_metrics.py | 2 +- .../radiation_transport/src/loader.py | 6 +- .../radiation_transport/src/qoi.py | 2 +- .../radiation_transport/src/transforms.py | 8 +- 7 files changed, 94 insertions(+), 112 deletions(-) diff --git a/examples/cfd/nuclear_engineering/radiation_transport/README.md b/examples/cfd/nuclear_engineering/radiation_transport/README.md index 07bc5df5b4..f682dfca22 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/README.md +++ b/examples/cfd/nuclear_engineering/radiation_transport/README.md @@ -4,7 +4,7 @@ A PhysicsNeMo example that trains a [Transolver](https://arxiv.org/abs/2402.0236 surrogate model for the 2-D linear radiation transport benchmark defined in [Reference solutions for linear radiation transport: the Hohlraum and Lattice benchmarks](https://arxiv.org/pdf/2505.17284). The pipeline learns the -steady-state mapping from the initial flux snapshot to the final scalar flux, +final-time mapping from the initial flux snapshot to the final scalar flux, using a physics-informed loss that combines region-weighted MSE with a quantity-of-interest (QoI) penalty based on absorption in key regions. @@ -15,8 +15,10 @@ The datasets used for this example were generated using ## 1. The science -The model solves the steady-state radiative-transfer equation for a scalar -flux field `φ(x)` over a 2-D domain. Inputs to the surrogate are: +The model approximates the final-time scalar flux `φ(x)` of the 2-D linear +radiative-transfer equation. The simulator is run forward in time and the +training target is the last snapshot — the underlying transport problem +is not run to convergence. Inputs to the surrogate are: - **Coordinates** `(x, y)` per cell, normalized to `[-1, 1]` and augmented with Fourier features (3 frequencies × 2 axes × {sin, cos} = 12 extra channels). @@ -46,12 +48,10 @@ interior heat source — flux enters from boundary conditions and propagates through the cavity. Geometry parameters (upper/lower laser-entry radii, center offsets) vary across simulations. -QoI: per-region instantaneous absorption over `{center, vertical strip, -horizontal strip, total domain}`. By default `train.physics_loss.qoi_region=all` -averages all four region losses so every region contributes to the gradient; -set it to a single region (`center`, `vertical`, `horizontal`, `total`) to -backprop on that region alone. Either way, all four region losses are -logged each batch. +QoI: per-region instantaneous absorption over +`{center, vertical strip, horizontal strip}` plus the total. The +training-time physics loss averages all four (mean-of-regions) so every +region contributes to the gradient. --- @@ -59,8 +59,9 @@ logged each batch. Prerequisites: -- **PyTorch ≥ 2.6** — `torch.optim.Muon` is built in. Earlier PyTorch versions - work if you stick to the default `train.optimizer.type=adam`. +- **PyTorch ≥ 2.6** for the default `train.optimizer.type=adam` path. + `train.optimizer.type=muon` additionally requires **PyTorch ≥ 2.9** + (uses `torch.optim.Muon` with `adjust_lr_fn="match_rms_adamw"`). - **PhysicsNeMo** — install the host repo with `[model-extras,datapipes-extras]` to get `physicsnemo.models.transolver.Transolver` and the `tensordict`-based data utilities. @@ -83,17 +84,17 @@ curated from the [KiT-RT repositories](https://github.com/KiT-RT). ### 3.2 Expected on-disk layout The runtime data format is the PhysicsNeMo `Mesh` memmap layout. Each -simulation lives in a `.mesh/` directory next to a `.attrs.json` -sidecar, loaded via `physicsnemo.mesh.Mesh.load(.mesh)`. +simulation lives in a `.pmsh/` directory next to a `.attrs.json` +sidecar, loaded via `physicsnemo.mesh.Mesh.load(.pmsh)`. ```text / ├── lattice/ -│ ├── lattice_abs_scatter_p

_q.mesh/ +│ ├── lattice_abs_scatter_p

_q.pmsh/ │ ├── lattice_abs_scatter_p

_q.attrs.json │ └── ... ├── hohlraum/ -│ ├── hohlraum_variable_cl<...>_q<...>_ulr<...>_llr<...>_<...>.mesh/ +│ ├── hohlraum_variable_cl<...>_q<...>_ulr<...>_llr<...>_<...>.pmsh/ │ ├── hohlraum_variable_cl<...>_q<...>_ulr<...>_llr<...>_<...>.attrs.json │ └── ... ├── splits/ @@ -108,7 +109,7 @@ sidecar, loaded via `physicsnemo.mesh.Mesh.load(.mesh)`. ### 3.3 What's in each mesh store -Each `*.mesh/` directory is one simulation, written by +Each `*.pmsh/` directory is one simulation, written by `physicsnemo.mesh.Mesh.save(...)`. The loader uses the first and final `scalar_flux` snapshots and ignores intermediate snapshots. The fields are: @@ -135,9 +136,9 @@ Each `*.mesh/` directory is one simulation, written by `.attrs.json` (sidecar): A JSON file holding `raw_attrs` (the verbatim source attrs dict — final simulation time, geometry params, etc.) and `residue_attrs` (the -non-numeric attrs that don't fit in `global_data`). `MeshDataReader.load` -exposes `raw_attrs` as the `metadata` `NonTensorData` entry on the returned -`TensorDict`. +non-numeric attrs that don't fit in `global_data`). `RTEBaseDataset._load` +reads the sidecar and exposes `raw_attrs` as the `metadata` +`NonTensorData` entry on the returned `TensorDict`. `N` is the number of cells per simulation (~tens of thousands). Different simulations may have different `N` — point-cloud collation handles this. @@ -164,7 +165,7 @@ JSON document with a `"splits"` key: ``` Filenames in the splits arrays are **basenames** without any format -suffix; the reader appends `.mesh` when opening stores. +suffix; the reader appends `.pmsh` when opening stores. If the splits file is named with a different suffix, point at it explicitly: @@ -206,9 +207,6 @@ contains per-channel mean/std/min/max for `{σ_a, σ_s, σ_t, Q}`. ### 4.1 Quick start Full-mesh training used at least a 48 GB GPU during development (RTX6000 Ada). -By default `data.preload_data=true`, so the train and validation splits are -loaded into host RAM before training. Disable with `data.preload_data=false` -if RAM is tight, at the cost of slower per-epoch reads from disk. Lattice: @@ -222,7 +220,7 @@ Hohlraum: python src/train.py case=hohlraum data=hohlraum case.data_root= ``` -Single-process default: 501 epochs, AMP-bf16, cosine LR with 10 warmup epochs, +Single-process default: 500 epochs, AMP-bf16, cosine LR with 10 warmup epochs, peak LR 3e-5, physics loss enabled at weight 0.005 (lattice) / 0.01 (hohlraum). ### 4.2 Multi-GPU @@ -233,10 +231,7 @@ torchrun --nproc_per_node=N src/train.py \ ``` Use `torchrun` for DDP. A plain `python src/train.py ...` launch runs as a -single process, even inside an allocated SLURM shell. Set `data.preload_data=true` -(default) so each rank loads static arrays through a sequenced barrier; this is -faster than re-reading the mesh stores per epoch but uses host RAM proportional to the -training split size. +single process. ### 4.3 Common overrides @@ -245,7 +240,6 @@ training split size. | `train.epochs=200` | Shorter run | | `train.optimizer.type=muon` | Use `torch.optim.Muon` for 2-D weights, Adam for biases / norms | | `train.amp=false` | Disable mixed precision (debug / numerical parity) | -| `train.physics_loss.qoi_region=center` | Hohlraum-only: backprop on a single region. Default `all` averages the four (center, vertical, horizontal, total). | | `train.physics_loss.weight=0.0` | Pure MSE training (disables QoI penalty) | | `train.dataloader.num_streams=4` | CUDA streams used by `physicsnemo.datapipes.DataLoader` for prefetch overlap (no CPU fork workers) | | `train.dataloader.use_streams=false` | Disable CUDA-stream prefetching — useful for debugging or CPU-only runs | @@ -253,8 +247,7 @@ training split size. | `model.num_spatial_points=8192` | Subsample cells per training step (–1 = use all) | | `model.n_layers=12 model.n_hidden=384` | Bigger Transolver | | `model.use_te=true` | Use NVIDIA TransformerEngine layers (requires `[model-extras]`) | -| `train.resume_checkpoint=.../checkpoints/latest_checkpoint` | Resume from a checkpoint directory | -| `train.latest_checkpoint_interval=0` | Disable the rolling `latest_checkpoint/` directory (`null` also works) | +| `train.resume_checkpoint=.../checkpoints/best_model` | Resume from a checkpoint directory | ### 4.4 Output structure @@ -267,18 +260,16 @@ outputs/RTE_Transolver/lattice/transolver/ │ ├── hydra.yaml │ └── overrides.yaml ├── checkpoints/ -│ ├── checkpoint.0.0.pt # periodic training-state checkpoint (every train.checkpoint_interval) -│ ├── Transolver.0.0.mdlus # periodic model weights -│ ├── latest_checkpoint/ # rolling full-state resume checkpoint (train.latest_checkpoint_interval) -│ ├── best_model_epoch_/ # snapshot of the lowest val_loss epoch -│ ├── best_qoi_model/ # snapshot of the lowest validation QoI-loss epoch -│ └── top_model/ # current top-1 by val_loss +│ └── best_model/ # the lowest-val_loss snapshot to date +│ ├── checkpoint.0.0.pt # training state (optimizer, scheduler, scaler, metadata) +│ └── Transolver.0.0.mdlus # model state dict ├── tensorboard/ # TB event files (open with `tensorboard --logdir tensorboard/`) └── train.log ``` -When loading checkpoints, `best_model_epoch_/` and `top_model/` track -validation loss, while `best_qoi_model/` tracks validation QoI loss. +Inference defaults to `checkpoints/best_model/` — the single +best-by-val_loss checkpoint maintained during training. No periodic, +rolling, or per-epoch snapshots are kept. --- @@ -286,42 +277,49 @@ validation loss, while `best_qoi_model/` tracks validation QoI loss. ### 5.1 Run inference +Inference is Hydra-driven; supply the checkpoint path, data root, and split +file as standard Hydra overrides: + ```bash +RUN=outputs/RTE_Transolver/lattice/transolver python src/inference.py \ - --checkpoint_dir outputs/RTE_Transolver/lattice/transolver/checkpoints/top_model \ - --data_path \ - --case_type lattice \ - --split_file /splits/lattice_splits.json \ - --output_dir results/lattice + case=lattice data=lattice \ + case.data_root=/path/to/data_root \ + case.split_file=/path/to/splits.json \ + inference.checkpoint_path=$RUN/checkpoints/best_model \ + inference.output_dir=$RUN/evaluation ``` -By default, `--flux_stats_file` is read from the checkpoint's saved hydra -config — pass `--flux_stats_file ` to override. +The flux normalization stats file is read from +`cfg.data.flux_normalization_stats_file` (interpolated from `case.data_root` +by default); override it directly via +`data.flux_normalization_stats_file=` if you keep stats elsewhere. -CLI options: +Inference-specific config keys (under `inference.*`): -| Flag | Effect | +| Key | Effect | |---|---| -| `--checkpoint_dir DIR` | A directory containing `Transolver.0.0.mdlus` + `checkpoint.0.0.pt`. Pass either a `best_*/` snapshot dir or the run's `checkpoints/` root (where inference will use `top_model`). | -| `--data_path DIR` | The dataset root containing the per-case mesh stores (e.g. `/lattice/*.mesh`). | -| `--case_type {lattice,hohlraum}` | Required. | -| `--split_file FILE` | Required explicit split JSON. | -| `--flux_stats_file FILE` | Optional override for the flux-normalization YAML recorded in the checkpoint's hydra config. If omitted, the training-time path is reused. The matching `_material_stats.yaml` is read from the same directory. | -| `--output_dir DIR` | Where to write metrics + figures. Default: `/evaluation`. | -| `--num_samples N` | Limit to the first `N` test simulations (default: all). | -| `--device {cpu,cuda,cuda:0,...}` | Defaults to CUDA if available. | -| `--num_plot_samples N` | Number of `flux_panels_.png` figures to write (default: 3). | +| `inference.checkpoint_path` | Required. Directory containing `Transolver.0.0.mdlus` + `checkpoint.0.0.pt`. Point at the `best_model/` directory under the run's `checkpoints/`. | +| `inference.output_dir` | Required. Where to write `metrics.yaml`, `qoi_metrics.yaml`, and `figures/`. | +| `inference.num_samples` | Cap on the number of test simulations (default: `null` = all). | +| `inference.num_plot_samples` | Number of `flux_panels_.png` figures to write (default: 3, evenly sampled across the test set). | +| `inference.device` | Override torch device (default: `null` = CUDA if available). | +| `inference.use_amp` | Autocast in eval; bf16 on CUDA, off on CPU (default: `true`). | + +The case (`lattice` / `hohlraum`) is selected the same way as in training: +`case= data=`. The dataset root, split file, and material/flux +stats paths interpolate from `case.data_root` exactly as during training. ### 5.2 Outputs ```text / -├── metrics.yaml # field-level metrics over the whole test set -├── qoi_metrics.yaml # per-region QoI relative error +├── metrics.yaml # field-level metrics over the whole test set +├── qoi_metrics.yaml # per-region QoI relative error └── figures/ - ├── flux_panels_.png # target / prediction / error 3-panel for sample - ├── true_vs_pred.png # scatter of all (true, pred) flux values - └── error_histogram.png # distribution of pointwise absolute error + ├── flux_panels_0000.png # target / prediction / error 3-panel per plotted sample + ├── ... + └── qoi_true_vs_pred.png # predicted vs ground-truth QoI scatter (one panel per region) ``` ### 5.3 Metric definitions @@ -354,8 +352,10 @@ dominating the mean). | `max_relative_error_pct` | worst single-simulation relative error | For lattice, the only region is `cur_absorption`. For hohlraum, inference -reports center, vertical, horizontal, and total QoI components when metadata is -available. +reports `cur_absorption_{center, vertical, horizontal}` when geometry +metadata is available on the sample (the training-time physics loss +additionally averages in a synthesized `total` term across those three +regions; inference does not). ### 5.4 Comparing runs @@ -384,28 +384,26 @@ configs. Training logs converged to final validation losses of about ### 6.2 Reading the training log -Each epoch logs train/validation MSE, QoI loss, learning rate, and checkpoint -updates. A typical completed epoch looks like: +Each epoch logs train/validation loss and any per-component sub-losses +present (`mse`, `qoi`, `qoi_`, ...) followed by the current +learning rate. A typical line looks like: ```text Epoch 500: train_loss=1.7081e-05, val_loss=2.0973e-05, train_mse=1.7032e-05, val_mse=2.0900e-05, - train_qoi=9.8040e-06, val_qoi=1.4658e-05, lr=1.00e-06 -[checkpoint][INFO] - Saved model state dictionary: - ./checkpoints/Transolver.0.500.mdlus -Training completed! -Top validation losses: ['0.000021', '0.000021', '0.000021'] -Best QoI loss: 5.887844e-06 + train_qoi=9.8040e-06, val_qoi=1.4658e-05, lr=1.00e-06 ``` +A `best_model/` checkpoint is written whenever `val_loss` improves; no +periodic per-epoch snapshots are kept. + ### 6.3 Reading the inference figures -- **`flux_panels_.png`** — three panels: target, prediction, absolute - error. -- **`true_vs_pred.png`** — points should lie close to the `y = x` diagonal - across the full dynamic range. -- **`error_histogram.png`** — distribution of pointwise absolute error; lower - and thinner tails are better. +- **`flux_panels_.png`** — three panels per sample: target, + prediction, absolute error. +- **`qoi_true_vs_pred.png`** — predicted vs ground-truth QoI scatter, one + panel per region. Points should lie close to the `y = x` diagonal + across the full test set. --- @@ -415,11 +413,12 @@ All training hyperparameters live under `src/conf/`, composed by Hydra: ```text src/conf/ -├── config.yaml # root: composes case / data / model / train +├── config.yaml # root: composes case / data / model / train / inference ├── case/{lattice,hohlraum}.yaml ├── data/{lattice,hohlraum}.yaml ├── model/transolver.yaml -└── train/base.yaml +├── train/base.yaml +└── inference/default.yaml ``` `config.yaml` defaults list: @@ -430,6 +429,7 @@ defaults: - data: lattice - model: transolver - train: base + - inference: default - _self_ ``` @@ -446,31 +446,13 @@ python src/train.py \ ``` The Hydra group structure means `case=hohlraum` swaps the entire -`case/hohlraum.yaml` (including `physics_loss_weight`, `qoi_region`, +`case/hohlraum.yaml` (including `physics_loss_weight`, `include_q_in_embedding`, and `embedding_dim_override`). The downstream `train/base.yaml` and `model/transolver.yaml` interpolate from `${case.*}` so case-specific overrides propagate automatically. --- -## 8. Tests - -Pipeline regression tests live under `tests/test_pipeline.py` and exercise the -dataset / dataloader contract end-to-end against a small dev split. They skip -cleanly when the dataset isn't present. - -```bash -python -m pytest examples/cfd/nuclear_engineering/radiation_transport/tests/ -v -``` - -The tests expect a converted dev dataset at -`/home//Projects/Datasets/RTE/devset/mesh/{lattice,hohlraum}/` (12 mesh -stores per case) plus the matching `splits/` and `stats/` directories. If your -layout differs, adjust the `_DATASET_ROOT` constant at the top of the test -file. - ---- - ## References [^1]: Kusch, J., Schotthöfer, S., Stammer, P., Wolters, J., & Xiao, T. (2023). diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/compute_normalizations.py b/examples/cfd/nuclear_engineering/radiation_transport/src/compute_normalizations.py index ba32310e24..85720864e7 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/compute_normalizations.py +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/compute_normalizations.py @@ -71,7 +71,7 @@ def compute_flux_statistics( Returns: The statistics dict written to ``output_file``. """ - print(f"Computing flux statistics for {case_type} [steady state]") + print(f"Computing flux statistics for {case_type} [final time]") print(f"Data path: {data_path}") print(f"Split file: {split_file}") @@ -128,7 +128,7 @@ def compute_flux_statistics( "case_type": case_type, } - stats["note"] = "computed from first and final snapshots only (steady state)" + stats["note"] = "computed from first and final snapshots only (final time)" output_file = Path(output_file) output_file.parent.mkdir(parents=True, exist_ok=True) diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/dataset.py b/examples/cfd/nuclear_engineering/radiation_transport/src/dataset.py index a757e284ad..6c3842f516 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/dataset.py +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/dataset.py @@ -126,7 +126,7 @@ def load(self, filename: str) -> TensorDict: point_data = mesh.point_data global_data = mesh.global_data - # Flux + timesteps (steady-state first -> final snapshots). + # Flux + timesteps (first -> final-time snapshots). if "scalar_flux" not in point_data.keys(): raise KeyError(f"scalar_flux missing from {filepath}") flux_nT = point_data["scalar_flux"] # (N, T) @@ -225,7 +225,7 @@ def get_metadata(self, filename: str) -> Dict: class RTEBaseDataset(PhysicsNeMoDataset): - """File-indexed steady-state dataset over a directory of mesh stores. + """File-indexed final-time dataset over a directory of mesh stores. Wraps :class:`MeshDataReader` and produces ``(TensorDict, metadata)`` tuples per the :class:`physicsnemo.datapipes.Dataset` contract. The diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/evaluation_metrics.py b/examples/cfd/nuclear_engineering/radiation_transport/src/evaluation_metrics.py index dea53db1a3..1c88e6b282 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/evaluation_metrics.py +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/evaluation_metrics.py @@ -93,7 +93,7 @@ def compute_sample_qoi( areas = cell_areas.float().flatten() sigma_t_flat = sigma_t.float().flatten() sigma_s_flat = sigma_s.float().flatten() - # Placeholder — the steady-state QoI evaluators accept ``sim_times`` only + # Placeholder — the final-time QoI evaluators accept ``sim_times`` only # for callsite uniformity with the time-dependent variants. sim_times = torch.zeros(1, device=pred.device) diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/loader.py b/examples/cfd/nuclear_engineering/radiation_transport/src/loader.py index e3b4a8ee32..64c689ee7b 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/loader.py +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/loader.py @@ -53,7 +53,7 @@ RTEBackupCoords, RTEFluxLogClip, SpatialSampler, - SteadyStateSampler, + FinalTimeSampler, coord_translate_scale_params, ) @@ -307,7 +307,7 @@ def _build_transforms( Normalize(**flux_normalize_kwargs(flux_stats, field="scalar_flux")), ] - transform_list.append(SteadyStateSampler()) + transform_list.append(FinalTimeSampler()) transform_list.append(MaterialPropertyExtractor()) material_stats_path = ( @@ -447,7 +447,7 @@ def build_dataloaders( common_kwargs = _build_rte_dataset_kwargs(cfg) if rank_zero: - logger.info("Mapping mode: steady-state first-to-final flux") + logger.info("Mapping mode: first-snapshot -> final-time flux") if common_kwargs["split_file"]: logger.info(f"Using predefined splits from: {common_kwargs['split_file']}") diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/qoi.py b/examples/cfd/nuclear_engineering/radiation_transport/src/qoi.py index 62bed83265..57f88284e0 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/qoi.py +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/qoi.py @@ -22,7 +22,7 @@ https://github.com/KiT-RT/kitrt_code/blob/d257b1a3c6fb3fa13d8a346adca5360a95101932/src/solvers/snsolver_hpc.cpp#L594 -The evaluators are differentiable and steady-state (T=1); ``sim_times`` is +The evaluators are differentiable and final-time (T=1); ``sim_times`` is accepted only for callsite uniformity with the time-dependent variants. """ diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/transforms.py b/examples/cfd/nuclear_engineering/radiation_transport/src/transforms.py index 742f4eeeb7..c4fce23550 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/src/transforms.py +++ b/examples/cfd/nuclear_engineering/radiation_transport/src/transforms.py @@ -35,7 +35,7 @@ "coord_translate_scale_params", "MaterialPropertyExtractor", "SpatialSampler", - "SteadyStateSampler", + "FinalTimeSampler", ] @@ -273,9 +273,9 @@ def extra_repr(self) -> str: return f"num_points={self.num_points}" -@register("RTESteadyStateSampler") -class SteadyStateSampler(Transform): - """Extract the fixed steady-state mapping: first flux -> final flux.""" +@register("RTEFinalTimeSampler") +class FinalTimeSampler(Transform): + """Extract the fixed final-time mapping: first flux -> final flux.""" def __init__(self): super().__init__() From 487394bfbb6de1fd921ab3486b233fe162a4d931 Mon Sep 17 00:00:00 2001 From: Carmelo Gonzales Date: Tue, 12 May 2026 15:40:50 -0700 Subject: [PATCH 47/68] docs: update readme QoI --- .../radiation_transport/README.md | 41 +++++++++++-------- 1 file changed, 25 insertions(+), 16 deletions(-) diff --git a/examples/cfd/nuclear_engineering/radiation_transport/README.md b/examples/cfd/nuclear_engineering/radiation_transport/README.md index f682dfca22..738d34c0b8 100644 --- a/examples/cfd/nuclear_engineering/radiation_transport/README.md +++ b/examples/cfd/nuclear_engineering/radiation_transport/README.md @@ -35,10 +35,16 @@ inverted via `transforms.denormalize_flux` to recover the physical flux. A square domain partitioned into a 7×7 grid of material blocks. Each block is either **absorber** (high `σ_a`, low `σ_s`), **scatterer** (low `σ_a`, high `σ_s`), or **source** (interior `Q > 0`). The model has to capture sharp flux -discontinuities at material interfaces and reproduce the instantaneous -absorption rate in the absorbing regions. +discontinuities at material interfaces and reproduce the integrated +absorption in the absorbing regions. -QoI: instantaneous absorption `σ_a · φ · A` over the absorbing blocks. +**QoI** — matches **QoI-3** of the reference paper (Kusch et al. 2025, §3.1): +the final-time radiation absorption over the absorbing blocks `B`: + +$$\mathrm{QoI}_{\mathrm{Lattice}} = \int_{B} \sigma_a(x)\,\phi(x, T)\,dx.$$ + +In code this is `cur_absorption`, computed as +`Σ_{c ∈ B} σ_a,c · φ_c · A_c` over absorber cells. ### 1.2 Hohlraum benchmark @@ -48,10 +54,18 @@ interior heat source — flux enters from boundary conditions and propagates through the cavity. Geometry parameters (upper/lower laser-entry radii, center offsets) vary across simulations. -QoI: per-region instantaneous absorption over -`{center, vertical strip, horizontal strip}` plus the total. The -training-time physics loss averages all four (mean-of-regions) so every -region contributes to the gradient. +**QoI** — variation of **QoI-2** of the reference paper (Kusch et al. +2025, §3.2): per-material final-time absorption, evaluated separately +over each of three regions `S ∈ {G ∪ B, R, K}`: + +$$\mathrm{QoI}_{\mathrm{Hohlraum}, S} = \int_{S} \sigma_a(x)\,\phi(x, T)\,dx.$$ + +In code the three regions are labeled +`cur_absorption_{center, vertical, horizontal}` and each is computed as +`Σ_{c ∈ S} σ_a,c · φ_c · A_c`. The training-time physics loss +additionally synthesizes a fourth `total` term as the mean of the three, +so every region contributes to the gradient (mean-of-four). Inference +reports the three component QoIs only. --- @@ -371,16 +385,11 @@ which helps interpret global flux structure and sharp interface features. ## 6. Interpreting model performance -### 6.1 What "good" looks like (after full training, ~500 epochs) - -| Benchmark | l2_relative_error | QoI mean_relative_error_pct | -|---|---|---| -| Lattice (absorption QoI) | 0.60% | 0.23% | -| Hohlraum (regional QoI) | 2.06% | 0.52–0.73% | +### 6.1 What "good" looks like -These observed values come from the default full-training runs with defaults -configs. Training logs converged to final validation losses of about -`2.10e-05` for lattice and `1.51e-05` for hohlraum. +A converged model on either benchmark typically reaches `l2_relative_error` +in the **1–2%** range and per-region QoI `mean_relative_error_pct` **below +1%**. ### 6.2 Reading the training log From d13b8f24b48eeb0b4a5b9e4cc9e5f7c3c2e51009 Mon Sep 17 00:00:00 2001 From: Carmelo Gonzales Date: Tue, 12 May 2026 15:44:13 -0700 Subject: [PATCH 48/68] fix: update example location --- .../{cfd => }/nuclear_engineering/radiation_transport/README.md | 0 .../nuclear_engineering/radiation_transport/src/checkpointing.py | 0 .../radiation_transport/src/compute_normalizations.py | 0 .../radiation_transport/src/conf/case/hohlraum.yaml | 0 .../radiation_transport/src/conf/case/lattice.yaml | 0 .../nuclear_engineering/radiation_transport/src/conf/config.yaml | 0 .../radiation_transport/src/conf/data/hohlraum.yaml | 0 .../radiation_transport/src/conf/data/lattice.yaml | 0 .../radiation_transport/src/conf/inference/default.yaml | 0 .../radiation_transport/src/conf/model/transolver.yaml | 0 .../radiation_transport/src/conf/train/base.yaml | 0 .../nuclear_engineering/radiation_transport/src/dataset.py | 0 .../radiation_transport/src/evaluation_metrics.py | 0 .../nuclear_engineering/radiation_transport/src/inference.py | 0 .../nuclear_engineering/radiation_transport/src/loader.py | 0 .../nuclear_engineering/radiation_transport/src/losses.py | 0 .../{cfd => }/nuclear_engineering/radiation_transport/src/qoi.py | 0 .../nuclear_engineering/radiation_transport/src/train.py | 0 .../nuclear_engineering/radiation_transport/src/trainer.py | 0 .../nuclear_engineering/radiation_transport/src/transforms.py | 0 .../{cfd => }/nuclear_engineering/radiation_transport/src/viz.py | 0 21 files changed, 0 insertions(+), 0 deletions(-) rename examples/{cfd => }/nuclear_engineering/radiation_transport/README.md (100%) rename examples/{cfd => }/nuclear_engineering/radiation_transport/src/checkpointing.py (100%) rename examples/{cfd => }/nuclear_engineering/radiation_transport/src/compute_normalizations.py (100%) rename examples/{cfd => }/nuclear_engineering/radiation_transport/src/conf/case/hohlraum.yaml (100%) rename examples/{cfd => }/nuclear_engineering/radiation_transport/src/conf/case/lattice.yaml (100%) rename examples/{cfd => }/nuclear_engineering/radiation_transport/src/conf/config.yaml (100%) rename examples/{cfd => }/nuclear_engineering/radiation_transport/src/conf/data/hohlraum.yaml (100%) rename examples/{cfd => }/nuclear_engineering/radiation_transport/src/conf/data/lattice.yaml (100%) rename examples/{cfd => }/nuclear_engineering/radiation_transport/src/conf/inference/default.yaml (100%) rename examples/{cfd => }/nuclear_engineering/radiation_transport/src/conf/model/transolver.yaml (100%) rename examples/{cfd => }/nuclear_engineering/radiation_transport/src/conf/train/base.yaml (100%) rename examples/{cfd => }/nuclear_engineering/radiation_transport/src/dataset.py (100%) rename examples/{cfd => }/nuclear_engineering/radiation_transport/src/evaluation_metrics.py (100%) rename examples/{cfd => }/nuclear_engineering/radiation_transport/src/inference.py (100%) rename examples/{cfd => }/nuclear_engineering/radiation_transport/src/loader.py (100%) rename examples/{cfd => }/nuclear_engineering/radiation_transport/src/losses.py (100%) rename examples/{cfd => }/nuclear_engineering/radiation_transport/src/qoi.py (100%) rename examples/{cfd => }/nuclear_engineering/radiation_transport/src/train.py (100%) rename examples/{cfd => }/nuclear_engineering/radiation_transport/src/trainer.py (100%) rename examples/{cfd => }/nuclear_engineering/radiation_transport/src/transforms.py (100%) rename examples/{cfd => }/nuclear_engineering/radiation_transport/src/viz.py (100%) diff --git a/examples/cfd/nuclear_engineering/radiation_transport/README.md b/examples/nuclear_engineering/radiation_transport/README.md similarity index 100% rename from examples/cfd/nuclear_engineering/radiation_transport/README.md rename to examples/nuclear_engineering/radiation_transport/README.md diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/checkpointing.py b/examples/nuclear_engineering/radiation_transport/src/checkpointing.py similarity index 100% rename from examples/cfd/nuclear_engineering/radiation_transport/src/checkpointing.py rename to examples/nuclear_engineering/radiation_transport/src/checkpointing.py diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/compute_normalizations.py b/examples/nuclear_engineering/radiation_transport/src/compute_normalizations.py similarity index 100% rename from examples/cfd/nuclear_engineering/radiation_transport/src/compute_normalizations.py rename to examples/nuclear_engineering/radiation_transport/src/compute_normalizations.py diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/conf/case/hohlraum.yaml b/examples/nuclear_engineering/radiation_transport/src/conf/case/hohlraum.yaml similarity index 100% rename from examples/cfd/nuclear_engineering/radiation_transport/src/conf/case/hohlraum.yaml rename to examples/nuclear_engineering/radiation_transport/src/conf/case/hohlraum.yaml diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/conf/case/lattice.yaml b/examples/nuclear_engineering/radiation_transport/src/conf/case/lattice.yaml similarity index 100% rename from examples/cfd/nuclear_engineering/radiation_transport/src/conf/case/lattice.yaml rename to examples/nuclear_engineering/radiation_transport/src/conf/case/lattice.yaml diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/conf/config.yaml b/examples/nuclear_engineering/radiation_transport/src/conf/config.yaml similarity index 100% rename from examples/cfd/nuclear_engineering/radiation_transport/src/conf/config.yaml rename to examples/nuclear_engineering/radiation_transport/src/conf/config.yaml diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/conf/data/hohlraum.yaml b/examples/nuclear_engineering/radiation_transport/src/conf/data/hohlraum.yaml similarity index 100% rename from examples/cfd/nuclear_engineering/radiation_transport/src/conf/data/hohlraum.yaml rename to examples/nuclear_engineering/radiation_transport/src/conf/data/hohlraum.yaml diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/conf/data/lattice.yaml b/examples/nuclear_engineering/radiation_transport/src/conf/data/lattice.yaml similarity index 100% rename from examples/cfd/nuclear_engineering/radiation_transport/src/conf/data/lattice.yaml rename to examples/nuclear_engineering/radiation_transport/src/conf/data/lattice.yaml diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/conf/inference/default.yaml b/examples/nuclear_engineering/radiation_transport/src/conf/inference/default.yaml similarity index 100% rename from examples/cfd/nuclear_engineering/radiation_transport/src/conf/inference/default.yaml rename to examples/nuclear_engineering/radiation_transport/src/conf/inference/default.yaml diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/conf/model/transolver.yaml b/examples/nuclear_engineering/radiation_transport/src/conf/model/transolver.yaml similarity index 100% rename from examples/cfd/nuclear_engineering/radiation_transport/src/conf/model/transolver.yaml rename to examples/nuclear_engineering/radiation_transport/src/conf/model/transolver.yaml diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/conf/train/base.yaml b/examples/nuclear_engineering/radiation_transport/src/conf/train/base.yaml similarity index 100% rename from examples/cfd/nuclear_engineering/radiation_transport/src/conf/train/base.yaml rename to examples/nuclear_engineering/radiation_transport/src/conf/train/base.yaml diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/dataset.py b/examples/nuclear_engineering/radiation_transport/src/dataset.py similarity index 100% rename from examples/cfd/nuclear_engineering/radiation_transport/src/dataset.py rename to examples/nuclear_engineering/radiation_transport/src/dataset.py diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/evaluation_metrics.py b/examples/nuclear_engineering/radiation_transport/src/evaluation_metrics.py similarity index 100% rename from examples/cfd/nuclear_engineering/radiation_transport/src/evaluation_metrics.py rename to examples/nuclear_engineering/radiation_transport/src/evaluation_metrics.py diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/inference.py b/examples/nuclear_engineering/radiation_transport/src/inference.py similarity index 100% rename from examples/cfd/nuclear_engineering/radiation_transport/src/inference.py rename to examples/nuclear_engineering/radiation_transport/src/inference.py diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/loader.py b/examples/nuclear_engineering/radiation_transport/src/loader.py similarity index 100% rename from examples/cfd/nuclear_engineering/radiation_transport/src/loader.py rename to examples/nuclear_engineering/radiation_transport/src/loader.py diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/losses.py b/examples/nuclear_engineering/radiation_transport/src/losses.py similarity index 100% rename from examples/cfd/nuclear_engineering/radiation_transport/src/losses.py rename to examples/nuclear_engineering/radiation_transport/src/losses.py diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/qoi.py b/examples/nuclear_engineering/radiation_transport/src/qoi.py similarity index 100% rename from examples/cfd/nuclear_engineering/radiation_transport/src/qoi.py rename to examples/nuclear_engineering/radiation_transport/src/qoi.py diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/train.py b/examples/nuclear_engineering/radiation_transport/src/train.py similarity index 100% rename from examples/cfd/nuclear_engineering/radiation_transport/src/train.py rename to examples/nuclear_engineering/radiation_transport/src/train.py diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/trainer.py b/examples/nuclear_engineering/radiation_transport/src/trainer.py similarity index 100% rename from examples/cfd/nuclear_engineering/radiation_transport/src/trainer.py rename to examples/nuclear_engineering/radiation_transport/src/trainer.py diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/transforms.py b/examples/nuclear_engineering/radiation_transport/src/transforms.py similarity index 100% rename from examples/cfd/nuclear_engineering/radiation_transport/src/transforms.py rename to examples/nuclear_engineering/radiation_transport/src/transforms.py diff --git a/examples/cfd/nuclear_engineering/radiation_transport/src/viz.py b/examples/nuclear_engineering/radiation_transport/src/viz.py similarity index 100% rename from examples/cfd/nuclear_engineering/radiation_transport/src/viz.py rename to examples/nuclear_engineering/radiation_transport/src/viz.py From dde6535a368b44f6cd38301a7e41d0c408584032 Mon Sep 17 00:00:00 2001 From: Carmelo Gonzales Date: Wed, 13 May 2026 08:32:28 -0700 Subject: [PATCH 49/68] docs: update readem --- examples/nuclear_engineering/radiation_transport/README.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/examples/nuclear_engineering/radiation_transport/README.md b/examples/nuclear_engineering/radiation_transport/README.md index 738d34c0b8..149c4e468d 100644 --- a/examples/nuclear_engineering/radiation_transport/README.md +++ b/examples/nuclear_engineering/radiation_transport/README.md @@ -73,9 +73,6 @@ reports the three component QoIs only. Prerequisites: -- **PyTorch ≥ 2.6** for the default `train.optimizer.type=adam` path. - `train.optimizer.type=muon` additionally requires **PyTorch ≥ 2.9** - (uses `torch.optim.Muon` with `adjust_lr_fn="match_rms_adamw"`). - **PhysicsNeMo** — install the host repo with `[model-extras,datapipes-extras]` to get `physicsnemo.models.transolver.Transolver` and the `tensordict`-based data utilities. From 9cf34973cd3d3a04d8625d79ee70c77c781d259d Mon Sep 17 00:00:00 2001 From: Carmelo Gonzales Date: Thu, 14 May 2026 08:23:13 -0700 Subject: [PATCH 50/68] docs: update changelog with RTE example --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8062a3fe42..be59868051 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Adds radiation transport example (`examples/nuclear_engineering/radiation_transport`) - Adds GLOBE model (`physicsnemo.experimental.models.globe.model.GLOBE`), including new variant that uses a dual tree traversal algorithm to reduce the complexity of the kernel evaluations from O(N^2) to O(N). From 0382855a2d219abfef99f260e4f761d78d320b86 Mon Sep 17 00:00:00 2001 From: Carmelo Gonzales Date: Thu, 14 May 2026 08:52:21 -0700 Subject: [PATCH 51/68] fix: removing unused args --- .../radiation_transport/src/compute_normalizations.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/examples/nuclear_engineering/radiation_transport/src/compute_normalizations.py b/examples/nuclear_engineering/radiation_transport/src/compute_normalizations.py index 85720864e7..fe4c9bbf6d 100644 --- a/examples/nuclear_engineering/radiation_transport/src/compute_normalizations.py +++ b/examples/nuclear_engineering/radiation_transport/src/compute_normalizations.py @@ -80,8 +80,6 @@ def compute_flux_statistics( case_type=case_type, phase="train", split_file=split_file, - load_material_properties=False, - load_geometric_features=False, ) print(f"\nProcessing {len(dataset)} training simulations...") @@ -172,7 +170,6 @@ def compute_material_statistics( case_type=case_type, phase="train", split_file=split_file, - load_geometric_features=False, ) extractor = MaterialPropertyExtractor() print(f"Dataset loaded: {len(dataset)} samples") From 63edc362f8ab2e089520c1e4cdf5fdcdc7db4575 Mon Sep 17 00:00:00 2001 From: Carmelo Gonzales Date: Thu, 14 May 2026 08:55:30 -0700 Subject: [PATCH 52/68] docs: add split file to args for training --- .../nuclear_engineering/radiation_transport/README.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/examples/nuclear_engineering/radiation_transport/README.md b/examples/nuclear_engineering/radiation_transport/README.md index 149c4e468d..3a82a2bb55 100644 --- a/examples/nuclear_engineering/radiation_transport/README.md +++ b/examples/nuclear_engineering/radiation_transport/README.md @@ -222,13 +222,17 @@ Full-mesh training used at least a 48 GB GPU during development (RTX6000 Ada). Lattice: ```bash -python src/train.py case=lattice data=lattice case.data_root= +python src/train.py case=lattice data=lattice \ + case.data_root= \ + case.split_file=./path/to/lattice_splits.json ``` Hohlraum: ```bash -python src/train.py case=hohlraum data=hohlraum case.data_root= +python src/train.py case=hohlraum data=hohlraum \ + case.data_root= \ + case.split_file=./path/to/hohlraum_splits.json ``` Single-process default: 500 epochs, AMP-bf16, cosine LR with 10 warmup epochs, From 9d509f1e8930474f16eb03ef159f779e0071a870 Mon Sep 17 00:00:00 2001 From: Carmelo Gonzales Date: Thu, 14 May 2026 09:07:47 -0700 Subject: [PATCH 53/68] fix: dropping torch.backends.cudnn.benchmark = True --- examples/nuclear_engineering/radiation_transport/src/trainer.py | 1 - 1 file changed, 1 deletion(-) diff --git a/examples/nuclear_engineering/radiation_transport/src/trainer.py b/examples/nuclear_engineering/radiation_transport/src/trainer.py index 6b2438fc26..2f90cbb940 100644 --- a/examples/nuclear_engineering/radiation_transport/src/trainer.py +++ b/examples/nuclear_engineering/radiation_transport/src/trainer.py @@ -50,7 +50,6 @@ def set_seed(seed: int) -> None: np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) - torch.backends.cudnn.benchmark = True _AMP_DTYPES: Dict[str, torch.dtype] = { From a16491172b47e85774308f0201e0e5dcb95347f0 Mon Sep 17 00:00:00 2001 From: Carmelo Gonzales Date: Thu, 14 May 2026 09:10:08 -0700 Subject: [PATCH 54/68] fix: move grad norm to configurable arg --- .../radiation_transport/src/conf/train/base.yaml | 1 + examples/nuclear_engineering/radiation_transport/src/trainer.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/examples/nuclear_engineering/radiation_transport/src/conf/train/base.yaml b/examples/nuclear_engineering/radiation_transport/src/conf/train/base.yaml index 94a2ed03a4..fd2601a578 100644 --- a/examples/nuclear_engineering/radiation_transport/src/conf/train/base.yaml +++ b/examples/nuclear_engineering/radiation_transport/src/conf/train/base.yaml @@ -30,6 +30,7 @@ optimizer: learning_rate: 3.0e-5 min_learning_rate: 1.0e-6 warmup_epochs: 10 +max_grad_norm: 10.0 # gradient L2-norm clip applied each optimizer step pretrain_checkpoint: null resume_checkpoint: null diff --git a/examples/nuclear_engineering/radiation_transport/src/trainer.py b/examples/nuclear_engineering/radiation_transport/src/trainer.py index 2f90cbb940..f412c2338d 100644 --- a/examples/nuclear_engineering/radiation_transport/src/trainer.py +++ b/examples/nuclear_engineering/radiation_transport/src/trainer.py @@ -375,7 +375,7 @@ def train_epoch( case_type = cfg.case.type use_amp, amp_dtype = _parse_amp(cfg) accum_steps = cfg.train.get("gradient_accumulation_steps", 1) - max_grad_norm = 10.0 + max_grad_norm = float(cfg.train.get("max_grad_norm", 10.0)) model.train() epoch_len = len(dataloader) From ca45499ea80a2bc1ac5adef26ce40c36c29c40e7 Mon Sep 17 00:00:00 2001 From: Carmelo Gonzales Date: Thu, 14 May 2026 09:10:21 -0700 Subject: [PATCH 55/68] docs: add note for grad norm --- examples/nuclear_engineering/radiation_transport/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/nuclear_engineering/radiation_transport/README.md b/examples/nuclear_engineering/radiation_transport/README.md index 3a82a2bb55..535c19dd57 100644 --- a/examples/nuclear_engineering/radiation_transport/README.md +++ b/examples/nuclear_engineering/radiation_transport/README.md @@ -256,6 +256,7 @@ single process. | `train.optimizer.type=muon` | Use `torch.optim.Muon` for 2-D weights, Adam for biases / norms | | `train.amp=false` | Disable mixed precision (debug / numerical parity) | | `train.physics_loss.weight=0.0` | Pure MSE training (disables QoI penalty) | +| `train.max_grad_norm=1.0` | Tighter gradient L2-norm clip (default `10.0`) | | `train.dataloader.num_streams=4` | CUDA streams used by `physicsnemo.datapipes.DataLoader` for prefetch overlap (no CPU fork workers) | | `train.dataloader.use_streams=false` | Disable CUDA-stream prefetching — useful for debugging or CPU-only runs | | `train.dataloader.prefetch_factor=4` | How many batches to prefetch ahead | From e0b2077c05546775672fe98cef0d372280cbbeae Mon Sep 17 00:00:00 2001 From: Carmelo Gonzales Date: Thu, 14 May 2026 09:13:18 -0700 Subject: [PATCH 56/68] fix: updating the parse_amp fn --- .../nuclear_engineering/radiation_transport/src/train.py | 4 ++-- .../nuclear_engineering/radiation_transport/src/trainer.py | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/examples/nuclear_engineering/radiation_transport/src/train.py b/examples/nuclear_engineering/radiation_transport/src/train.py index 0374c87629..4f29e7f24b 100644 --- a/examples/nuclear_engineering/radiation_transport/src/train.py +++ b/examples/nuclear_engineering/radiation_transport/src/train.py @@ -33,7 +33,7 @@ from loader import build_dataloaders, collate_no_padding from losses import create_scheduler, parse_loss_config from trainer import ( - _parse_amp, + parse_amp, run_training_loop, set_seed, setup_training_environment, @@ -85,7 +85,7 @@ def main(cfg: DictConfig) -> None: logger.info("Random seed: not set (non-reproducible)") grad_accum_steps = cfg.train.get("gradient_accumulation_steps", 1) - use_amp, amp_dtype = _parse_amp(cfg) + use_amp, amp_dtype = parse_amp(cfg) amp_info = ( f"ENABLED (dtype={cfg.train.get('amp_dtype', 'bf16')})" diff --git a/examples/nuclear_engineering/radiation_transport/src/trainer.py b/examples/nuclear_engineering/radiation_transport/src/trainer.py index f412c2338d..03ff2d11a2 100644 --- a/examples/nuclear_engineering/radiation_transport/src/trainer.py +++ b/examples/nuclear_engineering/radiation_transport/src/trainer.py @@ -58,7 +58,7 @@ def set_seed(seed: int) -> None: } -def _parse_amp(cfg: DictConfig) -> Tuple[bool, torch.dtype]: +def parse_amp(cfg: DictConfig) -> Tuple[bool, torch.dtype]: """Read ``cfg.train.amp`` and ``cfg.train.amp_dtype`` into ``(use_amp, dtype)``.""" name = cfg.train.get("amp_dtype", "bf16") if name not in _AMP_DTYPES: @@ -373,7 +373,7 @@ def train_epoch( :func:`losses.parse_loss_config` and varies per epoch via warmup). """ case_type = cfg.case.type - use_amp, amp_dtype = _parse_amp(cfg) + use_amp, amp_dtype = parse_amp(cfg) accum_steps = cfg.train.get("gradient_accumulation_steps", 1) max_grad_norm = float(cfg.train.get("max_grad_norm", 10.0)) @@ -441,7 +441,7 @@ def validate( ) -> Tuple[float, int, Dict[str, float], Dict[str, int]]: """Run validation and return loss plus metric sums/counts for DDP reduce.""" case_type = cfg.case.type - use_amp, amp_dtype = _parse_amp(cfg) + use_amp, amp_dtype = parse_amp(cfg) model.eval() eval_model = model.module if hasattr(model, "module") else model From 24bef6b4f5d70ba13377b5ebb86c04247b71f86f Mon Sep 17 00:00:00 2001 From: Carmelo Gonzales Date: Thu, 14 May 2026 09:18:39 -0700 Subject: [PATCH 57/68] fix: val loss tracking metadata bug --- examples/nuclear_engineering/radiation_transport/src/trainer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/nuclear_engineering/radiation_transport/src/trainer.py b/examples/nuclear_engineering/radiation_transport/src/trainer.py index 03ff2d11a2..1b82bf5a03 100644 --- a/examples/nuclear_engineering/radiation_transport/src/trainer.py +++ b/examples/nuclear_engineering/radiation_transport/src/trainer.py @@ -663,7 +663,7 @@ def run_training_loop( scaler=scaler, epoch=epoch, metadata={ - "best_val_loss": best_val_loss, + "best_val_loss": val_loss, "train_loss": train_loss, "val_loss": val_loss, "val_loss_qoi": val_loss_qoi, From a9d0e62930c0e391e8d08dc4dd742e45924a9f75 Mon Sep 17 00:00:00 2001 From: Carmelo Gonzales Date: Fri, 15 May 2026 14:36:29 -0700 Subject: [PATCH 58/68] fix: update data dims based on final dataset version --- .../nuclear_engineering/radiation_transport/README.md | 2 +- .../radiation_transport/src/conf/data/lattice.yaml | 2 +- .../radiation_transport/src/conf/model/transolver.yaml | 2 +- .../radiation_transport/src/dataset.py | 2 +- .../nuclear_engineering/radiation_transport/src/qoi.py | 4 ++-- .../radiation_transport/src/transforms.py | 8 ++++---- 6 files changed, 10 insertions(+), 10 deletions(-) diff --git a/examples/nuclear_engineering/radiation_transport/README.md b/examples/nuclear_engineering/radiation_transport/README.md index 535c19dd57..05416cec10 100644 --- a/examples/nuclear_engineering/radiation_transport/README.md +++ b/examples/nuclear_engineering/radiation_transport/README.md @@ -124,7 +124,7 @@ Each `*.pmsh/` directory is one simulation, written by `physicsnemo.mesh.Mesh.save(...)`. The loader uses the first and final `scalar_flux` snapshots and ignores intermediate snapshots. The fields are: -`Mesh.points` — `(N, 3)` float32 cell-center coordinates. +`Mesh.points` — `(N, 2)` float32 cell-center coordinates. `Mesh.point_data` (per-cell tensors): diff --git a/examples/nuclear_engineering/radiation_transport/src/conf/data/lattice.yaml b/examples/nuclear_engineering/radiation_transport/src/conf/data/lattice.yaml index 91a4eccd7f..adbe5a91de 100644 --- a/examples/nuclear_engineering/radiation_transport/src/conf/data/lattice.yaml +++ b/examples/nuclear_engineering/radiation_transport/src/conf/data/lattice.yaml @@ -21,7 +21,7 @@ normalize_coordinates: true cache_static_arrays: true # Fourier features for coordinates (adds 2 * coord_dims * num_frequencies features). -# Default: 3 freq * 2 coords * 2 (sin/cos) = 12 extra features, on top of 3 raw coords. +# Default: 3 freq * 2 coords * 2 (sin/cos) = 12 extra features, on top of 2 raw coords (x, y). use_fourier_features: true fourier_features: num_frequencies: 3 diff --git a/examples/nuclear_engineering/radiation_transport/src/conf/model/transolver.yaml b/examples/nuclear_engineering/radiation_transport/src/conf/model/transolver.yaml index e5f0707bf0..a6f7951a33 100644 --- a/examples/nuclear_engineering/radiation_transport/src/conf/model/transolver.yaml +++ b/examples/nuclear_engineering/radiation_transport/src/conf/model/transolver.yaml @@ -20,7 +20,7 @@ # — they configure the data adapter, not the model. _target_: physicsnemo.models.transolver.Transolver -functional_dim: 15 # 3 coords + 12 Fourier features +functional_dim: 14 # 2 coords + 12 Fourier features (2-D simulations) embedding_dim: ${case.embedding_dim_override} out_dim: 1 # predicted flux n_layers: 8 diff --git a/examples/nuclear_engineering/radiation_transport/src/dataset.py b/examples/nuclear_engineering/radiation_transport/src/dataset.py index 6c3842f516..abc5858ba5 100644 --- a/examples/nuclear_engineering/radiation_transport/src/dataset.py +++ b/examples/nuclear_engineering/radiation_transport/src/dataset.py @@ -44,7 +44,7 @@ class MeshDataReader(Reader): >>> reader = MeshDataReader("/path/to/mesh_stores/lattice") >>> filenames = reader.get_filenames() >>> td = reader.load(filenames[0]) - >>> print(td["coordinates"].shape) # (N, 3) + >>> print(td["coordinates"].shape) # (N, 2) """ def __init__( diff --git a/examples/nuclear_engineering/radiation_transport/src/qoi.py b/examples/nuclear_engineering/radiation_transport/src/qoi.py index 57f88284e0..f9afcbec31 100644 --- a/examples/nuclear_engineering/radiation_transport/src/qoi.py +++ b/examples/nuclear_engineering/radiation_transport/src/qoi.py @@ -71,7 +71,7 @@ def evaluate_lattice_qoi_torch( call recurses on the squeezed slot and re-adds the dim on the way out. Args: - cell_centers: (N, 3) or (1, N, 3) + cell_centers: (N, 2) or (1, N, 2) cell_areas: (N,) or (1, N) sigma_t: (N,) or (1, N) sigma_s: (N,) or (1, N) @@ -151,7 +151,7 @@ def evaluate_hohlraum_qoi_torch( call recurses on the squeezed slot and re-adds the dim on the way out. Args: - cell_centers: (N, 3) or (1, N, 3) + cell_centers: (N, 2) or (1, N, 2) cell_areas: (N,) or (1, N) sigma_t: (N,) or (1, N) sigma_s: (N,) or (1, N) diff --git a/examples/nuclear_engineering/radiation_transport/src/transforms.py b/examples/nuclear_engineering/radiation_transport/src/transforms.py index c4fce23550..3ec9c51d4e 100644 --- a/examples/nuclear_engineering/radiation_transport/src/transforms.py +++ b/examples/nuclear_engineering/radiation_transport/src/transforms.py @@ -107,12 +107,12 @@ def extra_repr(self) -> str: GLOBAL_DOMAIN_BOUNDS = { "lattice": { - "min": torch.tensor([-3.5, -3.5, -0.01], dtype=torch.float32), - "max": torch.tensor([3.5, 3.5, 0.01], dtype=torch.float32), + "min": torch.tensor([-3.5, -3.5], dtype=torch.float32), + "max": torch.tensor([3.5, 3.5], dtype=torch.float32), }, "hohlraum": { - "min": torch.tensor([-0.65, -0.65, -0.01], dtype=torch.float32), - "max": torch.tensor([0.65, 0.65, 0.01], dtype=torch.float32), + "min": torch.tensor([-0.65, -0.65], dtype=torch.float32), + "max": torch.tensor([0.65, 0.65], dtype=torch.float32), }, } From 0f0e6622fe6f1bda42e3375eb189569a63e60746 Mon Sep 17 00:00:00 2001 From: Carmelo Gonzales Date: Fri, 15 May 2026 15:49:49 -0700 Subject: [PATCH 59/68] docs: adding dataset link --- .../radiation_transport/README.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/examples/nuclear_engineering/radiation_transport/README.md b/examples/nuclear_engineering/radiation_transport/README.md index 05416cec10..8e3ec1fd0d 100644 --- a/examples/nuclear_engineering/radiation_transport/README.md +++ b/examples/nuclear_engineering/radiation_transport/README.md @@ -8,8 +8,11 @@ final-time mapping from the initial flux snapshot to the final scalar flux, using a physics-informed loss that combines region-weighted MSE with a quantity-of-interest (QoI) penalty based on absorption in key regions. -The datasets used for this example were generated using -[KiT-RT](https://github.com/KiT-RT) [^1]. +The dataset used for this example was generated using +[KiT-RT](https://github.com/KiT-RT) [^1], and can be found on Hugging Face: +[Linear Radiation Transport][hf-rte]. + +[hf-rte]: https://huggingface.co/datasets/nvidia/Linear-Radiation-Transport --- @@ -89,8 +92,9 @@ uv pip install -e ".[model-extras,datapipes-extras]" tensorboard ### 3.1 Data source -**TODO:** HuggingFace dataset URL. Until then, raw simulation data may be -curated from the [KiT-RT repositories](https://github.com/KiT-RT). +The dataset is available on Hugging Face: +[Linear Radiation Transport][hf-rte]. Alternatively, raw simulation data +may be curated from the [KiT-RT repositories](https://github.com/KiT-RT). ### 3.2 Expected on-disk layout From d2c91a89f8838d7ab9c6583f1bb6d25bf92d4a71 Mon Sep 17 00:00:00 2001 From: Carmelo Gonzales Date: Mon, 18 May 2026 13:20:36 -0700 Subject: [PATCH 60/68] fix: dist training logging bug --- .../nuclear_engineering/radiation_transport/src/trainer.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/examples/nuclear_engineering/radiation_transport/src/trainer.py b/examples/nuclear_engineering/radiation_transport/src/trainer.py index 1b82bf5a03..05e817811f 100644 --- a/examples/nuclear_engineering/radiation_transport/src/trainer.py +++ b/examples/nuclear_engineering/radiation_transport/src/trainer.py @@ -626,11 +626,13 @@ def run_training_loop( scheduler.step() current_lr = scheduler.get_last_lr()[0] - logger.info(_format_epoch_log(epoch, train_log, val_log, val_loss, current_lr)) - val_loss_qoi = val_metrics.get("loss_qoi") if dist.rank == 0: + logger.info( + _format_epoch_log(epoch, train_log, val_log, val_loss, current_lr) + ) + if writer: writer.add_scalar("Loss/train", train_loss, epoch) writer.add_scalar("Loss/val", val_loss, epoch) From 683396d784e518041ad764c29005f4b989548b74 Mon Sep 17 00:00:00 2001 From: Carmelo Gonzales Date: Mon, 18 May 2026 14:24:46 -0700 Subject: [PATCH 61/68] fix: make muon default --- .../radiation_transport/src/conf/train/base.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/nuclear_engineering/radiation_transport/src/conf/train/base.yaml b/examples/nuclear_engineering/radiation_transport/src/conf/train/base.yaml index fd2601a578..cf7641f9a0 100644 --- a/examples/nuclear_engineering/radiation_transport/src/conf/train/base.yaml +++ b/examples/nuclear_engineering/radiation_transport/src/conf/train/base.yaml @@ -23,7 +23,7 @@ loss_metric: mse tensorboard: true optimizer: - type: adam # adam | muon + type: muon # adam | muon weight_decay: 0.0 muon_momentum_beta: 0.95 # Muon-only; AdamW (for 1D params) uses the shared learning_rate via match_rms_adamw From fc04d0e3eeb22ca49e651d165a0af82df725f76d Mon Sep 17 00:00:00 2001 From: Carmelo Gonzales Date: Mon, 18 May 2026 18:07:35 -0700 Subject: [PATCH 62/68] feat: update dataset to work with updated pmsh --- .../radiation_transport/src/dataset.py | 87 ++++++++----------- 1 file changed, 38 insertions(+), 49 deletions(-) diff --git a/examples/nuclear_engineering/radiation_transport/src/dataset.py b/examples/nuclear_engineering/radiation_transport/src/dataset.py index abc5858ba5..0c9a1e1762 100644 --- a/examples/nuclear_engineering/radiation_transport/src/dataset.py +++ b/examples/nuclear_engineering/radiation_transport/src/dataset.py @@ -113,9 +113,10 @@ def _read_sidecar(self, filename: str) -> Dict: def load(self, filename: str) -> TensorDict: """Load a Mesh memmap store into a ``TensorDict``. - Tensor fields (``coordinates``, ``cell_areas``, ``scalar_flux``, - ``sim_times``, ``material_properties``, ``geometric_features``, - ``sigma_*``, ``Q``) are stored as ``torch.Tensor`` entries. The + Reads cell-primary fields from ``mesh.cell_data`` and derives + ``coordinates`` and ``cell_areas`` from the mesh topology. Returned + tensor fields: ``coordinates``, ``cell_areas``, ``scalar_flux``, + ``sim_times``, ``material_properties``, ``sigma_a/s/t``, ``Q``. The sidecar attrs dict is stored as ``NonTensorData`` under ``metadata``. """ filepath = self.data_path / filename @@ -123,46 +124,43 @@ def load(self, filename: str) -> TensorDict: raise FileNotFoundError(f"Mesh store {filepath} not found") mesh = Mesh.load(str(filepath)) - point_data = mesh.point_data + cell_data = mesh.cell_data global_data = mesh.global_data - # Flux + timesteps (first -> final-time snapshots). - if "scalar_flux" not in point_data.keys(): - raise KeyError(f"scalar_flux missing from {filepath}") - flux_nT = point_data["scalar_flux"] # (N, T) + # Flux + timesteps (first -> final-time snapshots from the curated + # time series). ``cell_data['scalar_flux']`` is ``(n_cells, T)``. + if "scalar_flux" not in cell_data.keys(): + raise KeyError(f"cell_data['scalar_flux'] missing from {filepath}") + flux_nT = cell_data["scalar_flux"] num_timesteps = flux_nT.shape[1] if flux_nT.ndim == 2 else 1 - full = flux_nT.transpose(0, 1).contiguous().to(torch.float32) # (T, N) + full = flux_nT.transpose(0, 1).contiguous().to(torch.float32) # (T, n_cells) resolved = [0] if num_timesteps == 1 else [0, num_timesteps - 1] td = TensorDict({}, batch_size=[]) td["scalar_flux"] = full[resolved].contiguous() - if "sim_times" in global_data.keys() and global_data["sim_times"].numel() > 0: + if "sim_time" in global_data.keys() and global_data["sim_time"].numel() > 0: td["sim_times"] = ( - global_data["sim_times"].to(torch.float32)[resolved].contiguous() + global_data["sim_time"].to(torch.float32)[resolved].contiguous() ) if self.cache_static_arrays and filename in self._static_cache: for key, tensor in self._static_cache[filename].items(): td[key] = tensor else: - td["coordinates"] = mesh.points.to(torch.float32).contiguous() - if "cell_areas" in point_data.keys(): - td["cell_areas"] = ( - point_data["cell_areas"].to(torch.float32).contiguous() - ) - if "material_properties" in point_data.keys(): - td["material_properties"] = ( - point_data["material_properties"].to(torch.int32).contiguous() - ) - else: - warnings.warn(f"Material properties not found in {filename}.") - if "geometric_features" in point_data.keys(): - td["geometric_features"] = ( - point_data["geometric_features"].to(torch.float32).contiguous() - ) + # Coordinates and cell areas come from the topology (Mesh + # properties) so the cell-primary fields share the same (n_cells,) + # indexing. + td["coordinates"] = mesh.cell_centroids.to(torch.float32).contiguous() + td["cell_areas"] = mesh.cell_areas.to(torch.float32).contiguous() + if "material_id" not in cell_data.keys(): + raise KeyError(f"cell_data['material_id'] missing from {filepath}") + td["material_properties"] = ( + cell_data["material_id"].to(torch.int32).contiguous() + ) for key in ("sigma_t", "sigma_s", "sigma_a", "Q"): - if key in point_data.keys(): - td[key] = point_data[key].to(torch.float32).contiguous() + if key not in cell_data.keys(): + raise KeyError(f"cell_data['{key}'] missing from {filepath}") + td[key] = cell_data[key].to(torch.float32).contiguous() if self.cache_static_arrays: self._static_cache[filename] = { @@ -171,17 +169,16 @@ def load(self, filename: str) -> TensorDict: "coordinates", "cell_areas", "material_properties", - "geometric_features", "sigma_t", "sigma_s", "sigma_a", "Q", ) - if k in td } sidecar = self._read_sidecar(filename) - td.set_non_tensor("metadata", dict(sidecar.get("raw_attrs", {}))) + attrs = {k: v for k, v in sidecar.items() if k != "missing_fields"} + td.set_non_tensor("metadata", attrs) return td def get_metadata(self, filename: str) -> Dict: @@ -192,33 +189,25 @@ def get_metadata(self, filename: str) -> Dict: filepath = self.data_path / filename mesh = Mesh.load(str(filepath)) - point_data = mesh.point_data + cell_data = mesh.cell_data global_data = mesh.global_data sidecar = self._read_sidecar(filename) - metadata: Dict = dict(sidecar.get("raw_attrs", {})) + metadata: Dict = {k: v for k, v in sidecar.items() if k != "missing_fields"} - if "scalar_flux" in point_data.keys(): - flux_shape = point_data["scalar_flux"].shape # (N, T) - metadata["num_cells"] = int(flux_shape[0]) - metadata["num_timesteps"] = int(flux_shape[1]) if len(flux_shape) > 1 else 1 - else: - metadata["num_cells"] = int(mesh.points.shape[0]) - metadata["num_timesteps"] = 1 + if "scalar_flux" not in cell_data.keys(): + raise KeyError(f"cell_data['scalar_flux'] missing from {filepath}") + flux_shape = cell_data["scalar_flux"].shape # (n_cells, T) + metadata["num_cells"] = int(flux_shape[0]) + metadata["num_timesteps"] = int(flux_shape[1]) if len(flux_shape) > 1 else 1 - metadata["has_geometric_features"] = "geometric_features" in point_data.keys() - metadata["has_material_properties"] = "material_properties" in point_data.keys() + metadata["has_material_properties"] = "material_id" in cell_data.keys() has_sim_times = ( - "sim_times" in global_data.keys() and global_data["sim_times"].numel() > 0 + "sim_time" in global_data.keys() and global_data["sim_time"].numel() > 0 ) metadata["has_sim_times"] = has_sim_times if has_sim_times: - try: - metadata["max_sim_time"] = float(global_data["sim_times"][-1].item()) - except Exception as exc: # pragma: no cover — defensive - raise ValueError( - f"Failed to read sim_times tail from {filename}" - ) from exc + metadata["max_sim_time"] = float(global_data["sim_time"][-1].item()) self._metadata_cache[filename] = metadata return metadata From bb5d5d892f8ac442c157551eaf930fb52697caf1 Mon Sep 17 00:00:00 2001 From: Carmelo Gonzales Date: Thu, 21 May 2026 08:11:22 -0700 Subject: [PATCH 63/68] fix: update comment about physics objective definition --- .../radiation_transport/src/conf/train/base.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/nuclear_engineering/radiation_transport/src/conf/train/base.yaml b/examples/nuclear_engineering/radiation_transport/src/conf/train/base.yaml index cf7641f9a0..5baee1d5f0 100644 --- a/examples/nuclear_engineering/radiation_transport/src/conf/train/base.yaml +++ b/examples/nuclear_engineering/radiation_transport/src/conf/train/base.yaml @@ -35,7 +35,7 @@ max_grad_norm: 10.0 # gradient L2-norm clip appl pretrain_checkpoint: null resume_checkpoint: null -# objective = mse_weight * regression_mse + physics_loss.weight * (regression_mse + qoi_loss); region_weighted swaps regression_mse for the weighted variant. +# objective = mse_weight * regression_mse + physics_loss.weight * qoi_loss; region_weighted swaps regression_mse for the weighted variant. # Physics-informed loss (case-specific weight). use_physics_loss: true physics_loss: From 5bbd56e586b2b02f9333084f081eda213ce795f5 Mon Sep 17 00:00:00 2001 From: Carmelo Gonzales Date: Thu, 21 May 2026 08:47:32 -0700 Subject: [PATCH 64/68] docs: update readme, mesh store description updates --- .../radiation_transport/README.md | 44 +++++++++---------- 1 file changed, 20 insertions(+), 24 deletions(-) diff --git a/examples/nuclear_engineering/radiation_transport/README.md b/examples/nuclear_engineering/radiation_transport/README.md index 8e3ec1fd0d..45db6f7805 100644 --- a/examples/nuclear_engineering/radiation_transport/README.md +++ b/examples/nuclear_engineering/radiation_transport/README.md @@ -124,36 +124,32 @@ sidecar, loaded via `physicsnemo.mesh.Mesh.load(.pmsh)`. ### 3.3 What's in each mesh store -Each `*.pmsh/` directory is one simulation, written by -`physicsnemo.mesh.Mesh.save(...)`. The loader uses the first and final -`scalar_flux` snapshots and ignores intermediate snapshots. The fields are: +Each `*.pmsh/` directory is one simulation written via +`physicsnemo.mesh.Mesh.save(...)`. The flux series is stored as just +the first and final snapshots (`T = 2`); only those are used. -`Mesh.points` — `(N, 2)` float32 cell-center coordinates. +Cell-center coordinates and per-cell areas are not stored as fields — +the loader derives them from the mesh topology via `mesh.cell_centroids` +and `mesh.cell_areas`. -`Mesh.point_data` (per-cell tensors): +`Mesh.cell_data` (per-cell tensors the loader requires): | Key | Shape | Dtype | Notes | |---|---|---|---| -| `cell_areas` | `(N,)` | float32 | per-cell areas — used by physics loss for surface integrals | -| `sigma_a`, `sigma_s`, `sigma_t` | `(N,)` | float32 | absorption / scattering / total cross-section per cell | +| `scalar_flux` | `(N, 2)` | float32 | flux at first / final snapshot, cells-first | +| `material_id` | `(N,)` | int64 | region IDs (mapped by `LatticeMaterialMapper` / `HohlraumMaterialMapper`) | +| `sigma_a`, `sigma_s`, `sigma_t` | `(N,)` | float32 | absorption / scattering / total cross-section | | `Q` | `(N,)` | float32 | heat source (non-zero in lattice; zeros in hohlraum) | -| `geometric_features` | `(N, k)` | float32 | optional per-cell geometric features | -| `material_properties` | `(N,)` | int64 | integer region IDs (consumed by `LatticeMaterialMapper` / `HohlraumMaterialMapper`) | -| `scalar_flux` | `(N, T)` | float32 | physical flux, transposed to put cells first | - -`Mesh.global_data` (per-simulation tensors): - -| Key | Shape | Notes | -|---|---|---| -| `sim_times` | `(T,)` | simulation times for each flux snapshot | -| `attr__` | scalar / `(...)` | numeric simulation attributes flattened from the source curator | - -`.attrs.json` (sidecar): -A JSON file holding `raw_attrs` (the verbatim source attrs dict — final -simulation time, geometry params, etc.) and `residue_attrs` (the -non-numeric attrs that don't fit in `global_data`). `RTEBaseDataset._load` -reads the sidecar and exposes `raw_attrs` as the `metadata` -`NonTensorData` entry on the returned `TensorDict`. + +`Mesh.global_data`: the loader consumes only `sim_time` (shape `(2,)`, +simulation time of each flux snapshot). Other simulation diagnostics +shipped with the data (`cur_absorption`, `total_absorption`, `mass`, +...) are ignored at training time, but may be useful for other downstream tasks. + +`.attrs.json` (sidecar): JSON with `case_type`, +`simulation_params`, `solver_config`, and `mesh_info`. The loader +exposes the full dict as a `metadata` `NonTensorData` entry on the +returned `TensorDict`. `N` is the number of cells per simulation (~tens of thousands). Different simulations may have different `N` — point-cloud collation handles this. From 73c6514ef2c69b0be8faf97fbb1fd2f7c8f55671 Mon Sep 17 00:00:00 2001 From: Carmelo Gonzales Date: Thu, 21 May 2026 09:05:18 -0700 Subject: [PATCH 65/68] docs: adding RTE images --- .../transolver_hohlraum.png | Bin 0 -> 482368 bytes .../radiation_transport/transolver_lattice.png | Bin 0 -> 623456 bytes .../radiation_transport/README.md | 8 ++++++++ 3 files changed, 8 insertions(+) create mode 100644 docs/img/radiation_transport/transolver_hohlraum.png create mode 100644 docs/img/radiation_transport/transolver_lattice.png diff --git a/docs/img/radiation_transport/transolver_hohlraum.png b/docs/img/radiation_transport/transolver_hohlraum.png new file mode 100644 index 0000000000000000000000000000000000000000..88d1e6a44bcec6487c697c25e7126642cb71c1ba GIT binary patch literal 482368 zcmZ^~2RM~){6CIVW{8k7Iw)kzJ}7&XmB`M>9@&mr$liO)N{VC?4%vH#?Cm7$*y9+# z`|&e&6r8E}wCX^E~%`zhCdyct0Vk%5ua6Gz3^!Si}nQ(&|`PH=bf)VfW+R z0Pi%BwSB)xJ;Uqz0#qh7w! z9~hb86ASL1KQYiUoH>VAri;9C>Gwv?d8)x+LjTJP9{EY1>VNy*>^v^%&HwQg)Ipn$ zwK)GjzR)%P6S8rT2(w9e#=QUkuEOz*oVaN~Sn#`@HYz>=0a-&s`kVLPVVS2p z$liQFCPPL}PEJHjd_Fc+X*(sLSz@qBhX*ZuPzS$GF(p>My5%#^`B~5H6{jrB{Ysq( zZX)5621l@LJ{5V7t@3!uu%G9j4+j9CsMf`ND*VMEsb+;XX=I z$*sbgOd%h7+{NdUD%6lKE_acW5PRr1ET{Zb75Dt7lyAU2b2VOGJup3H1vth1YYmQ)gbDq4sj= z&x5T?8WIb{HZ7xSGQuW^wSDznC*IZ{6x*-RZoA*4KW@4iyhJ_crgY=Kg}KR#@*f&f z%RJvOk7L3gH}K?;l9JkkJ)?=ZK_a$K+IY6YReQ3Uwzj!>-hjt&H?{BKRF&;g`#t;R z&uMmxu}Yi>4curZ1=5(gZm+C^wccK$4tifW2^@{3Sa_Bm_KdZS)@}ONYG0bZl``0f zIchupC3FpsF_zES5Bo^>h z=1VS5OHR7Akj)8N23>numtF>#$?;ewe1+I|Q?}I(Lo!r5rBUADVO+>$!TJT_I(-X? z)ID!kqI?Sa3v|%kyLXL7zrG?OBI*^D1IO&zwQGCz8b@Px?%SSoJ>$qje&p$BiQ^xm zpuV~>t4c**Ux@>c^J?SqnkVsrT^Ts9%oJ?~1L72Ua0N)m}P?t*kj0&rGTF85V5zKuo(zs{$C-nRFgno4XP zVjul}Y@09a9;u#fq^y4udi8 zi$x!8&$59kyP0li%fUGX<Bxv#S@kyzusK|-By7`cG+2WHuh&0)1I5OC(S-SP`(W5=56J!($ z(dy8fVxHNRb+MK;JU7?)No>eAm)*?GnaXA0JKq%NI=vkAe`km@rZ3A7T+QG0brVO& zST3&x!urF?k_^FWS+xN5(48HJMWKFFlg(D|@dvxvPo1=;*5@{c`M>?F4{XFaChyMd ztxImQZW%Hyg<6?s$aWZ<9>#+B!WSlp=XM^V7vW zQ?6ETb%o3K#Ba)O=4XN+awme8tgg-a0M1q266&JIiz)D|GrkvgsCy*#@1k+)aAM{} zKR(pI#pE8qe0RN=`&{n72zR?A($eMR*|aUa>E4lKfc(&5XKR$5Grj!-M`qFXrY0;MYt8R^!UJ}l7yYeK`F~g@ z5I@BpQTjO22Az?A?FJ;LxB}XQ{A{vjXAR&3CI|mU3GLD< zjY`+G|LNI<8+mw$P_fC%$pMthaO(v>!`ZN}KK5O|PB#r!-s3elkiqs7^KgnY6z0c} zJifYE8g@yA@y_-f_x5?@BP0Z`T1Zw8>bPFkwYpJ0uytINhSeDmX=Cg)`pw{a)2NnV zpH}1PE4#*XS2J_-!twR=cg^?^BENm}h9T?Bb!)oorZ^hAV(hmSzq0TO#o?5_1cX() zVAsN}D!cI?r8gBwoLeg9-`254f^+CF@iwUxG&pdDJz)m>FoT^?t#=C_sdz<2dzP1( z20Ul?KvUq`?b?HS-C`KmD)@C)HHuQl6-DT0(Et^Bv%b)K{C&V#yL{W{xP{(0d;wtj z!oP1NpGU16ny_!8R|Oj<#Hg>J%op8h^8tp4Cfkv5d_aC9hh;I-b8Eqmkn>Y@brR^I zn2quYq_-$4-RYLwoVOr=%CnQKOJVx6`I}BbVcT9PQ9aOuY$nQ>j~u;vbZ47~|)DCz@-lX>;ojcO5wxfkGuprZe&3de80kp+==&z3JyV15&&mJL< zN?kW~K{ZvXW36avB$&zW_{I>x#{!}EVyUZ63dKy!mdng9mT z_s!sd&0se+$(fX4+k4ydE*;(Hl9H&FTcY73DiRsx1LA3y$5EG^gk71w7biuAjkAas zA`pt4DM$B}WL@kR2&0>>A-a?dn$5-c?tZUbrMWmTyi9l`wx&?`qVD(H+>jqJUixM!a9n&Sw3Jcdpxgq*QV3)4;m`HhWPX60Q9k1|?W3({k!gj-r# z^c=gG^_=?npU@EBxN+mSTmr+_FOJzbn>=Fjb0);s_I*1p$jjT@A#vd_yPH^z2B?5! z96g~zqOW^+dhU2eN|KdNrff$ zTIesLq5IxHI9Nc?+52LqPKKghK^^Mrt8eZt_hy?w3)M6q0VOQEFsm5Uap$KL zxmysE0(bkn^Mi4x-L6MYOA&(LF6uu<`JOG+@8@%^0EA=exbH*+=#i?p@Z)^YBzi(( zqP#au>i^1c<$#i?u3zB*%lf@$kpR#r9qZYH?`}IeR1J9V2{~)tJM{|(S%0q$)I#ni zc+Fp>ynxPjV^mb&!- z4+os~n!`!!TCnjD*X-0b&uEL0qw7kW-GAu1@#hzGMd{ARX^kiMGLPy}YMe{eTZvXJm@o>W2>0e>NYGxuI zAD^@uZuAr};3KlRAA(g&#(CNyaNCUnbw(=?H@u^cn)z7%l=YKfozq*D$m zW9S>gfGjzQ_EG~nK(gtY1}!+m@E{KTTt#JXtQwgB)$Zf(HGUA?L3pP3T?sJsl=SNH zpjGAZa1ye0!aOS?c-b-;3D_^6sOVJ8VVMvKj-JoS%GHL2^Yza!(O(L6(0Q^$CrkL? z_sf)wjJ~6LckeFzF^O6sG(5&BLG)Q4A9qOfTN~`rAi+_8cec~vwAUwyvSg|pO9x4-c~JDJZZ8{e-{XWHhJ9oNIXU~TpT4l zVC3ZmciVl<7O&0@I{e%behWZPe82Z3zQ-+a2&O2bddI$wub)jdp5LR=dxs5uxG=zV zp^M`s#HO`jPlnrU*LdTF3y?=ZKS3+oh^cE_im>-$Ofv9bg(?oQ6K2>8NQ@Fh+ZkZt z5fKi<3XgVvjyFV_M2RITan=Tazi{BSrewIts)8VpIO$T^m0#F6GrZVMg3hM&PH_Q-Qnn(Z1z`cK;E{VH6IJmR3GZ@^)^RJ^{;XuK`V6jXgtq$xk z7*s;0=cfn5HUgrDjhj_dxY6bPB1+Osa|Y7Ob#v$6kC9v}j@rnRKKs?I%MEjhxJv7B z#rqwOGcM!!3@W~-BdE$vu*YC+R(`)!*|gXORP(Kch5RW7^VZq9!X#^NK*A8K_INQ& zMw%%UMn-lrcZu;OWSBeOa+`VKcBQ@lN(vYny!&+>rxWz{4~VI=nYr@}EXTjuLEW95 z@)su(Kr04sW6C@ZtZD%ug1>lBsM4h-N6QR@8J-R&;YKGR_w)B6G4Ta(nTjz-(92e` zya75=K2?VAv?8V?zQK?(EiU)h!R*7qBIHj0*55ds*=sb#02~n z9L^3%B_vAbyF@);`K1cVDW`dbN7 z6ADhk#Om)gTa7=E<^0CMy^wmodChd zwc{;O?H`JP@1+b-|3|v2s&lgqXiq*UU;VW@cEcw4mZ_{ zHS_g2d%)4KI{~=Jvh90`S@EYo`;_iBi%>7rSYA!D&osXo5}fY2RSOo)X0~1!5i&cAR>E&L&0g9(c(A8bjO*$fJ)xV9J z0;UVCl{PY758RxQ}Eprl<$F-FSGBVx$o-6U0_^r>){5s(6g6SP^ozT zdkIR#hnH*o$S8=m9qRLL09fD~e4(&KAu}7BaG^-VSb1h!VL@i9@5S!|@dSt}yJvz{ zyd;qmzsO&LAjT@z40ElE2u4FaO(=2?W>Tz4m`?%qV?b?Ii^}`EU)n4Ddj@gDSoOkI zps@?5$pIJo{Q0vZSKP+ynYNd-S3c3jU`$`c<=MbxFQ5j^z){fy2UGfR&esK0Y;sM zXKjYjO!#UPpfM`MG96jPS9~$Q-_hIKi}eF6Ji=8fmdR$SsyLU_(=5nqH!cg5uEEjI z@$5=Kd4YGN5R0B1M+!vwq@<^}9(l(y8G)lwp~C@@K&Wzn+|AWc5n8~FSDywnx8GzK zl0^${IQbM;3=kXyIOwo|Qh{4`oU|-h5s=hW$_ZxAAA(dTkUGVhkFy>_6q6hm8$!#a zr6nx*{-6}tA!5PlL5K9WJ&8M`B|cDxV!vcBXPgCV!w9RG+Jv88^;bn!P?OD(uIy&0 z@bK_uC#M^b&)_dRy~j|ET_(Vie+>FuTXFSlH>7nTlfq@gxBad;)OR@> z&m3iCWhuim*8=lW_yNyDNn8q#8~Tb#oUf_W0!?)SBxvpYMT&mn&Lew|k+jzh9{ivi zgTn>dPz&I3AK_L^<>Rv_{cejG@jkOSCIx7xnjpfqRg0p#0OUXAV10ZKyvZF!WLK$E z^t#kf0qmy?^oP z0Su6X)rD|~{Q~BBl2>Qaam$b4(}WGEqTQU=+D1h!Bog(x?uo(BtpOWJaMqm>*8J-x zeR!}l#cFpbf>|Nf_tv}3%5iZ10Shv6bmXNnC`)BN@W`m!`WeQdTlH@K!%bJ}XTW3V zaE$wY<;k^p4PTr8AB@N(^I{=26v$U9BynL0O1aDJ zo|Y`uG_kMs>y@JwZF%ztg{w|K(H2QZI5JHFdCYvZ*d zt7g&bUxCE;YSzEk)c{vW`0$U(4xFp8>e?+KhUz#!hNsfh)YP3XUH{{PX96*O4!YHL zE2rxvD02zST}4*51OhBbt6kNu?*+=&@$&qre9i~Mk?d%-x7vqbE4Il%f_u{})@d6n z)`j|$@af-N;kCTDV~BP@RpS7f|1C%@qG}6>PR838R01tQpHBhx4lIDMnb8G|Tw_+A!*Q9w#(|7ds1KZ+l#3(4 zH@UaZMy*C0ENZPOufJplLmj$b#p<%CHzRV)d24|o%I78G82D@U6+khFP5E9fTUo^ixfBwAY-rp-9 z{0CL%>LlcJ0U7Bzd!){yIvb&0_&df|v}Ym%p@RcIsvYRt3?g#4|MJ^taz@7WH^7bR z>QabTC*+K;+w>PR8vm}(lK*#ev+syw@?Krt=X^=lHsHa6_KV1fHr_cXnz>u}!wJ5bjYy-~x7DuP_~d32ot#=7_<$@hc`3o2Rgm6_fewUmrRibH+`;2z8urzMvj&#?2< z3ww1h&7FV%5bp$h6brJ&A%=2{4wC4-qowx?`U2<%j64y0ucdp4$&t((O+b_ZXa;Vg zUN~!i)~HoNW+oi~D$rwQi?Md6%F>|yl@93PYD_QjIrz3`6CVsEXc-`wFOM!S48Pa! zDgrOj{vF`qD=|zng@vJ(IXNtn))`Z`qbNL0OiZhu6>4bmg;Ex5E7VAJszv)RlJ7^*~>kxl13&#oCF9 z@(O^AhIHp)g(CgBM1ZDyz$N1r5$R6lw-!k!1RiDzumgaR0-PwMf7WY@TcbKC)=y>Y z{WZbtQZm&MrKG$@H|9Yx_ix636e4&J?vtJMx<;uoO|)p9>3F)Z3$;njZuev1omTRR zA4V-X_5H?&Q^r$o4|Nki!^`zUaYA_lfq{>gPXTCLe^&O)gc8_~DDN8B-&;}E6fJaN zzEo7_+MFGl-$rAyXl#`Gxry-oJ~7Tn9r~^cr@jbGNeWaC1?-U6ga{H$d-2pMWV?WE zzoA>hLId?p0p16^z{#H>G-vwS0$`+zYbw4TK||4Zg(nUHeO2OWhzGsfIurW}MCZ{r z%{M;B(6)I{wN2g&n;@Cl^P*%{Pv6FzoCMe1R+cWL8*T<5-+nldsEe<%6Q|E&q zZXTYPYO(TryxaW%ssKk;*@4!yZp;|BtH8r_bP&H*|g6hwJq}C!J z72USky<7jp$0Wy>uC5D z!gUY~!0Q;oPLF|r@Z$;O>lb1iz$q2Qppe;^u8rH-+3{N30*C|*U`}uxpn`Z$ z>_kBZ$uS-{JX}50cB+bpkMG)_W<9Wb!FVrUz9b?cLD8j6EVIDsw_Hk=AZ`cw;=oU& zdqzqc_(0-pf#d{aiVCE-wLfC+(2Idw7%|af(EdtGzilPSF!LVvGg5Rumv;!{91(KW zM>F8Mzf?&D%onqyuAc8rUB3J#T^2uST)+PpgrJY=P@?m&?ii z7vvhQ^dva}C(IEb2d3tGx!I%bK#v`2r)o} zaBuEd7WDNL`fbP?hvGhaV1EK)@1m?xq+5NN0bhb7rr2w}b%p0j0jZ2w&6oZX3ZgOtK75Npe~5@gaCmL7(Z(L{0mrRa@K zO#?ua-G$me7zdEZ@-L?`OL+|fgh_a6+mScWQx_r7X0U_`0qKT7iOzMs5S(d+FuGz4 zG5HP9@<_Kv%uM~d{%3bLIkjMkF1Re7~a8vFd+dBP776WB_tDeANlS++U+$Le+i7{ z68S=nSYQYW*vlP)48KJcvO2DQrj=YJ9*WDMfrYy?G_e2?x}o!m0OV~M^yYZrEv{B|fS7{? z2qUT4EucCyZwW7xYFAn%z_$?talkWDfNu0viAO@kl&wOn^p%qaZ}QhSIe9UB9hh3 z+S7mEL{Fh$xfa^H;qmcDz?F-JZXINkP^{$GN+#F&Z}b~<_aIb!tm3su{uUqtt@pqG zpv>YAb{6o^u8b>i9cco#vE%it{Ci`+C+aJ~$FHrg$LSP5TpuqOjX=RH^nZ|V3v4bJU6N7{GJl68dEJq${BfV;Y*U=4rK2Y7u9P^`QBB{veda!mH#>$>@)Rx9H^2cud&7np6X$6DVvgf2m7 zv>9pM2S{isA@kzyk`UYnbj0bH5+1_4`baOtR(3Lih8qVcx9s>buzg54doXBO^pLN!i1vI5H6hA}OUp4Z8 zBb)~zWHy!wRveiAXYYYVg={m(zy~TFX}h}iDc;!3ED*}QKncEw23^3pFI@738pF*X z2@Q}YfO@HGXMMJRM~ifU(4!1H5JiFsA5xdvW8 zSfMN*WXV}zkmjljfO(w^xpFhxyR@{-p*i&t3bBK8b1KwAD*0BasWl(2tX4@wToPvz(><7xgbOf%? z5YJ6B%$#En2V|yz84Q33o#mMaMF^KCKZQdemKcnug6MuJw3=)pViV+A*Z24D-}=S1 zlo!#o-$1Gl4tc4|x+VndP}&Pdp@14XNrwvw$x{cOh(j}5kkJNY5q#C_Fc0+>NKCzZ7Y(^o0P;CP+@ha^R#NYz3nl^kz%Pi^O-~hyj zqV6&5p)#xqaP$ zdvf9bMi_k>|77(27<3D>y_84ow?!u1QR1L;%64KusIMzmqbr(hCSzN}P9Mj2@BUO@>DmT|vU&f;Ch5e&<3VkP3eQF{ z+Va2NAfG$|5mFDxQb$WSLmp*zR*oC=Y*x?p0~Dg-_v76THvU!I_I-ppE^7**dNmp# z1n2dfkJ*X~xfbQT)nBFmb+eSTHiovuqGuyw=ErYqgV2bwIsvW~kN@XpbOo9uo%+?k zl-e{d!fRerv`D)C$|NwSic0G%_T+2a>Vr4d)q3;X%fZxm>f(9XuXWhnjLg?NVk|vM zG?oyaY6A=)5E`TOQM#d7KC#@%7`6PpZfg;&>fNCGj(EVrwfEUegAzb5V7DxN2QYuyVsH5#Ti1X2=lb_0@MQu@3^>`qt=E9&C4K-84;qg7 zzg}=F`Bhnl%aC_Fr9q**8<6Pr9OX5TCeDY@HD2%p4zg%|LXePD{LcXCT6se z7&(z6(+(Hq7nuH^8-lfS|CtCgu1#A)5jNhKy5%Rl3ybT9mm-ie5AejXwpVv{mr_~m z;I~*PgSKvhI_MvU3t&(U1}X$J>aim27GSTO`=+~2T4K^!7GeL(xbC4Em;^*2pt%W% z<3o_2?zP)>2CUSpnVBSg*SqFf-mb_Q$xhr@XzEMP`(RYhec>ZC^$BI0>4?=7i{c5O zu$Gjtx;P(3np_cC6BMWMt7lkTsw~VF*A+i#HcKXDct8Buc$Lr4(MLDG=F-m!`*K#! zgw6P7lI-*7Cw11`cq7pFP+*e9uwyypyaC`iG-Y7+xqy_h)1az%53ZVe=`yCB?lWmw zm$6Li0XTpfEZM6Id$TMmY$1)G;_V=K<&@JLC z!XVSUVGu1Daq~d+bSHE#Q+B@w_GBRakOl16_(65xWP|S$( z2ZyO(`!}e1T9CHp(UW%y1(dPkhv&TM_79|N@5Bpk4>;E~yGTqo|NTt_!-CH$S9$bIQ~U)3}%>?W<*@fK>I%1Lx48i*C#mF3$o5c-wRN8evngsWq$z; zNw~|X{j(FW?OiD6gotre8m3zJAq(HE*IYqkV^mT-y3Gr1bG^=P`E%4+YKZ3}%g0eu zRk*$FbfR(Z6NgzoMLbkzJNoh?F95dnb0`CuK0_k8b2+0%V^vMkw{_&C5dtmYY-9<+ zFS$-*I4&Mpx3RqHeqr#62uvU}8yS!*P+h3?(Vz4D=IaCYKzd{pRl{-17j^F;J}tI8 z|0-#=>(III%ZY(EIIk7YWCffo=F#UQpVW(MrfNt&>3w&c-jUlm@x3%eH;|N{Q}GDu zpKvFnu5Q_iPd$QvZZ+{E{TLmblmA54rGJ&j!{EuVdPJ|>E@$st$sd}^R+Fo_NKumu z$x-Ln=JULB5cA_&WJ7i+9sc|m;*-LbeYI%2s&||PbK-8mEv^Zxl)Ka~DREyAaD85Z2 z*=_h*t{*R{0U8HgAR$W?enj{q(ly2ZlI9KV5A4V9rwP)RG1Igizi-~85;LpR$p>R{ zK_lAyssjOv7Xi$y>rGH+1(F}i%7uVMyz$SKz5X{+_EMEtQvyPwhQ}ba$>kxAsyS!N5x6E5&?;58|qX!-Pr--}1?N>3S(W1uC`n zJBE7?@>}j|0jz0!+l!5 z8F8EgefiM&U!C6XcV7f#GmFQzvcwHi%E!Z+85OcruO(l%iC|}I&2!AyFdN&f;x`rC z?n*x{aJ4o!73gP7ko=vyKP#%+0ye;eR+dO|NY{;arn<^&;XD=hh+QAz=jKK*7j!?5rM_FlD<-zN5$@gI zKiKVF?Hs3SF&w=tjkgoe)o9uR*FwG)`%W+R5gD*JQyJ^%< zWE@-Xia^Jq@1sg|WJpGCUN^pRf(lXDmt&r&Po!N1Z-{ZJ(_$s@`=m#-cy07D2UF$$ z&iO_wW^rQ%ZF_sgX(a|LraX}%q9mRI1J&$Cy?5$5E2l&k-Lep4cPeyK+cy-?CVBT; zZTF7|A}EH=uVe>Dcc$tGgZ0_uaC`N49PPLA5b*3h2U4)Gan={#hSH`~{HupD;(LE~=n-3iK z{(CZ>T!K%2gm-jJ3g;#cd6OyC+JXGAm22a*)HyiuF{5o5gpu3BRmqLtJ!~ozHdm-q zm#x3lT0|Rsx&g-{!t(wT9DV)+rz_Uxt_r$-TFXhn+N4+vP7*&1(kia(>S{r^+hTy> z)Sm73yi7CQ`Uf(6pd`SYs~>}WRQ|AlJNQpCGqYU{otc9nETBw7f}v@^RhbcNyM_bJ zNTEeMpc^5JgT4_b80)~}&gNsqo3zi%T_uJmpQ&WY)n7Yh!*`G(V-CG7z%G}sl9tyh z4NJsD6PjXw3?gJtd8oLO-6zZXB<20E<mul~Y(whdO>N!wlQA>=FF( zJmL?$4BV^{ zeOr}b6VuJdA`UD8SX~1~l&_yq*v6Wc=*dFZ&=@AxHO@*{eBIA^lY!Q$u9B`72lg(0 zl5!Sjsdq7VM32ItigJovA+}nJsqu#X4>ifRws}?xc-LB%!#gfksQE?%W+SB4%}%<( zBz#@<^?3e!#wMFYYD)KJZZk?hEXm#cQNrzv-#I{ZqwS(e-*~-KqZB4T$bZD4VgH;* zBFulwBQxK+Uoc8?WF~h^!76l}9*j@*iq;ks-9Dq6RjYien^^I%?}S{`M{8KPS?N-F zIO|Rm3wN*ju(OiUKtR62L+w$eduhkOpM>1CD5g?vL11x+x)oc}^l#W(WAJ8}8_V~# zb=H~jsh|O1z}W@bslhZnCC}XUL2K>WdePMWkQDx z0`in#ZY{$+K6R$OCMM=(4aFz&ith1DcWw~0`m1I5JxA3JYp{pG8GL?X1@F0jL9m*7 zkx6rW4>xOlE35kN_uvt$2VB7?V*=-syv6IX-|Eb+G2G)fq!Q)aw*E{sGm{(Ea~fIM zqtiCl=h5ymt7Nl0Re9TU>T{1rD+jxS2^Bg5!ZkWuV{3Psy2~F%H1E0Yxmp#erpGkn z?BQCeFwrZ8r{2?bF@k?B3{b^?`89Mm(es<*Ka5uCx*I|dRM53c52b`jb!=sVRYwC^bB7dz zWflsQiSCL~8D|juB%XVIHn^6a-ZvidulGR5>oTA}cwo_UAzvA<4UbKwi3NkiWump^ z?!~{at^3?4ogrg{if{h>i7T_y&%Xj&*T_`EJYQ0wEK8J*djoo(v-F9N>&KKkWvXW2 ze)VsmKW#Rold?q53-E2;;iRmNvyVF~Z)wjQ$PqyAX z+m<7b)S=)xTQR(V=Iv(78}0;xhuI+CE{Ek`@Gf`;5e!@alP)8SiExyAeRETWwGCJn zPD8DG@NgbTd0G@hPyPx2`;xmF!X9ObG_n^k5O`g%Jue|?JMl4mPS#fcx|65>TkpV- zKw0a*#J3+#rN%z$rM4Eh*-gMGj7ZF^(fIpFt?KE|gXW4He7q0TlZqy)@njV|Oly~k zt=ts}LBC=d9DXhFGTvAU-$)WB`2s7)76^%@n57V#)CB`C!v8;pBr7C zRK^m`ezw?8#^+=j+^ef+DxK?0M)UcHz3dxjP6LgLU3344@mCsyRWbD>RCK+o9t`Up z&#@HMRb1>pxSAiqv_GwOOLlLl|Eq_`en`H4V{UF`QFG_do~AZ?-v@YT$K{JtAGaeX zm5$-s&H~Zlf#ezSiAF?S7+s`Os}yAlKdP^oQutEOD!6w2hjd$duq8oe^j&S%*Cj8q z<1}7*%422cm(v>jBaVzFD9^|wW#$REJ<`sorun5fn)J*5ByNuMf~TbK3u+qD$&tVz z7PI*#bJC-tH$NJKh%xr_nVvyMf3iY&#`s-PVi@p6sugbMTjuT^jpw<_9CFI7RWTFEzUF4r;Cu9L`B3hk%}_(1@odC| z9_?q_CoE-JFlp{#ZKoHjF;93^TD8BlN6l60ud-(o4t?|azzv>G7&rT18;VMr{#5DP zrbInQU#zxJYGn@3oZ5d~&R1p;lJ{nbvkDBb)1w+ahsl_K-9CLmwQ*5IEHl+)((lln zR;`&_PNKnSwIR&>Ll&>DDu(&6_@8?aaklBUL%|na9(?B8#?C))1rXo^=8MFW&OulH zC>mEKcr#9Ew8?Xm)W(DDg8Tlvzu3pf#*VgHgQg+7?jTW zVqr99L5fZor2JU0(Oemk<-!S`cKD4_DrM9StAzFyxWWMPkCzfe(AL;Onw)7QTyO^ zR1P}f6k-zm&2c8_0_cYTLifkh69n zAZ05dZnCpB?5*@Z5+m*X_Aoj>t`q*d4{pC}Y#Q95_phPI(9iF<@TZTTbG<6z{hmkI zqX}j|&==M`wOUdZ8^`9u<|9hiSA`;Eds?H5R^HxL>^^jwVv&CKDZt~+mt#qOie^=d z$_GW39$FsBH|?bl*Nw)}LL_zw(t&TAh<_@K3bz@R_PC>aKaOyO9CAF?Hj2d?I&bZJ zC=%e>qsno2@&Wt0La;F=T=TXJpIU%UG7p>Ywpln#RZ`)Z?#3J}RK6(A&y*}nGE=(j zI1iVajT)x*WnYXrW5ApF_eg9#pL;F(?}LEW+K$SObf@=0gU0=qxv~9iBHrH<%5d_E zlgvgePS(uZYDpnELeLbzB0a$vx~|zdB)(hq?^+e+>MXyWfZ+s+l$KI6*lRg z{c-sImr{mS**eEhi@z1B4eY#v|0FMbO9lhC_TNnWNuyW|XU`dJ@u`2s8(YA{&Lc2AWVDR?_b+I%J}qB# zxK|}Zqe9yhd#Xvv(~i})dviuC7)p1N+bR%$6vTq7y&I7$nr6?EHABPX{ z9l_ogBWRcld}^_o2 z0YPnR@uOn>L1|0dB!)Xy#`(JyOccnY-AS&mCch?xo4O2C95Z5`l+EY3XQL-;spQl- zG&ySC&1N(<$WeZXL+t6saD?Se=%Fdfag_MtD$aQ_A;WhvoK;k=+s=?%5asF&0c&F0&HKf-B^{!+^lqW&TM zWo?aIhiJv06Ah1kCfhnK(i`3}>Agw>l>ui@n3WSn=S;pEygyf#j~`@@RVpOa>yjUg z4`$0>v|dfo1ldjeh~>Vlfbwsq_D>cCII4%Ys&?>7nWUJUj5A3p25a$(WxlXU=`&MP zycntn(NM!AM_+?)?9z8umLa3_G1RNOv2&2~$Xdp+v@Siq!P>KG|ksFySIT8GS8 z0fny}+7s-&h5BLNM4hek7`q|4o9(NdM8X>8``d$m$!0B#p++)UMvj_GTIB7zbepf$ zc5XA>5j|DZ)K_Fd z=Qm$iq!?+*B%dp?OY-G=t+&>aPL{w@b1cImcn{QJDg~~AH+w@rbEv8d>{{KBh!STN zfw^0nYR2FGh#mM;qV(wf%4CpNX$AvNNWZ_KT7p4K_q1>Y@zgo_@|-7LRB6 z?fZq_e{uFoaP}H4)Q_3rQdwKdB8=e%`X=J@#e$5NkOu?Z0# z465Gx_~$7r>DN0(69g|R?a((gB`GDi6(b`Ru%hbdN!fueoVs zrfP|0Qdv93u&|SVg`(0*51u8CHGW2y3-Uiryl{TI_T6AVhYF0K)cZq^Zk~bXFdkK= zx`>E@+yu(G3Nl=E=s0$v89lf9CEBx5!}B{}=KUdv)4^CV^k^kCu4ackG0Ox9PKJx! zPAv`|Edy~`(7TONFh&&?%7dxE!r>O-RW7Drj3Zej2@E(FzlJn2ejThcXsG0F(JICC zna0XYCv*kLE&i=AsaO#GCtQ{_d+RQZt&B<=>D@L{sq!3~WLsVA`~sWCEK6&}X8|Rj zXdnCWR!Eb{jJ+(K<0HK{nM0NlyW7e#KtS`-Hi|Wu)w+d%me1tLi_#mOS?2WKYJ>7m zEb7p_#>87f`T?Vr&mISdD>m@i8pR6TvA+H3D-n`c8isJ!sQQGLz|E2Bs8dw(fQ}fB z8U7sblt2H`6G;*W#hmEze|XFX(yzw&*cf%oPTAV@W*nZ891u!3W+r`?yaR7g$A;O; zvD}EeeOG&*pzYW5zrm}W_4B*~zg|249{|xnF29~)q@4;pMNhUY_iu5KN_m~U7;UZ` zr#aPkH>mhFY^cLyR4u`H$h~A2D0IKrgjo~@bh^a1_Gx~y#`;rMMW;YYhT>YFm zp~CC(d@M#Un7uR*{6#$fmSXI747zE(ouup7S-$&qbrG-}@ zld57xg^Yl-f?%hhR}?5S!oiA#2@Pd?B*3n~XOy2mPnOI`h|_lXN-&bO#lCEwvMF4o0>|=79}l* zLQlm|t0}y)Mgku(ElSZaTke|T;jKFuSiNBhB!gg#{aKO$AOgx*Zw0s9-)Eq;W_ogl zb~H^rnnYBS%Gx?!JjSwRLehf%y0s;y8;W{G7@bmtKD643R-_oJ7@Cn{rlnX}j+ts| zDxsko8pdXQv=TgJ#gIy9N6AK8gMwOMXvGR0B!~yiw!(;_C$JN`F+${N0xfA|YJ{L@ zB`_6xC=JWK2(5(4*drDw>y*4BVcd8~nkus)Di=r+psWm`!bVbwqNJ@bX+4V-Mg*W3Qu#7d(rZt*uS!fSO&@COifGCyi5aEyQfQ2g?n=c| zFBSg1f^){4xNAo)TT93zHIr=nRHqG57L$*ynP@^`zAw%%dBj>?TV*b_pqI`WJ|>Ok z?VPKN#I`An*IMSr=i&mF%p4&I6eQb6^97)%rRTa|0N_q@Mu`uOa z{hwH=jv7}T|L5=Ke48aG*epfyZnw{HY7-SBwUYvon$;#OM#kz~s};0Tx!qlSb~C4k zl9&z~C5r+zso>{#*&)RUZ|R6wRfJ-rMguYVy@=EIrj$n7OS*OvgbH??NHQTdTN^1` z6VlfOp$4d>hL(@8%!`Obp|xFiH4`foYlME^&=)2o2FBYyz7_^5F@d&rhUY9)-U1c&ZO?IH=d(AaJkSYss%j>DZ4m`|riwO0^F zyhRAa;=YE?XqLt1j6Fr3bc-d$vA(-MmMVZOG%uNg7$u8{APYsUnMK;N*gCsN=$hYv z)YiM12hAhwgGZC7S4-$Wc0+paNi<`Lef?+%PP-H6Sb`w=NcwLfkZ+?a z?X=T)!3$o%bD#TMzWL2>a`)YL^UZI5lXAICyWPg~Jihn6?{Uj5xA3iReTy^CJTpJW zyWjn8e)X$g@to&8hjY$3hrz)?F2DS8>h(Hb|N7Sn!*I9JDEqVkqLii-D26s1fc65+ zc@^)M(6eKo`MtimfBN};CY8>$td*|Mf}wQPuEg{M>FS%Ig8N@xgu zYd{BH>g5**ek_!HAxi8%Jtadmfwl+rvNft_T8dU=({N?PN5wO=BE|Z?HZ7x>oYoiv zRT2VE<0(NK8*<%WN?7J4%(gY66qUeW5}1uOMqowN$^gwIt7#%kwmoa~DuvMy$BLFw zXhohEHxWgU8ub-P>tu+c*N^Gf3C$Q3in14BjE!LF(T2VtW;70n1tJo(*g~%wTUc0& zp=InfR8@q*5*?wksXIthfB0AtXu{arTvKuCRb~Ar{FLZ5q9~~tAE7C89$9rs@Rdy$ z6r_uRNbEHta|EhiI1%XJOIx8THMo?5F>4-!q8U%CE9wtvk)hCv8vuQul z^QQY&X%i{0gD}Xve+_e~nL33id!Ddx4li9Wx6?k)(lHem*hO*8D|kU#qQG>dpa=^Q z3xU|ha@IGQ7iC=VGS-Fbx9MA?Kwwyu3glR@Xvj#us1U`W1zIRV$-D+sM?IgpXH`;f<Y7+B{M z_E~w=jBX+Fm($a~n%=$|V)kr`W#Wf1OEeb5vX%Q%IHrRo(>%{7^qZ77J)N2Bwh;8d ztQS%)HwnugaRk+}(2VVKIM9;{d1XZ+b}lZ3)`7wIg-S^XJUiBw14#u1Ay9MKT0GCr zV_I9cfNCHFo-iv4KM-o6wLvCkHPtX8&@eWm34KE=(v*EzRZp01DdM(`I2rWqd}`-! zDEY!c^)@rHrWI=jf`qa*Otm$wSkW6AdcwpCjcG(hE73%Wbq$ZOWMVV6qhp|i5<&xQ z?G6o|XGqLk9iLS}o0d@+qZshpU~I&zRzj~%Fgj*B)}R%!C@_*n!X&vMAB&={5`t7< zF&N59?Ej*PMbV=Z8yzdQ7E6tU*_1-oYR-K3fc`Y6e;#3qVHpWgI=hG1NZj1{Asr{f z)XvD}Q|s)Kq~7VBsej@^O3s=jh56A&3T168OJZ~TDlbiQn%F4ILj5*NZYxS8U~J?~ zmJ+Zc2~9K!+RpjuoK}{EIGr!EIn2+abjh7sqIBNMgq*B~T7JKoOLr#u!fj5p&*q=Z zF3E^+fEISnP%0zt=89lF7XbGA@gV-IZVL$H0(?R{vvB1yA$`eIYT zd8s`!mp;zq(M(|)os>l&%{k+!j_9YNql+)nVc%p?2&F8_I<*h1P&a4eWYHYfw$gbd z$XqHYJJ-Z%8@g>4Ph?(AyAIo`s}>){6u2S6ikYb5vXaypo(dWrePFy9b=TH zo!ILd^4fUX8Zk;ik4|VB8~t9wPzwwVqnU~{zGt1>d?mJald(dI@7d?bBzB!r^9{z> zh|!v_NR+i$sL%nt@I77 zr*EJKt@cc<31E!PT(DHzVNL*X^Y^~vX#rjn8o!2#7zVKU-S-2AyGTBtxuWQP`tB8@ zCbB3BUHH74{7V)gwKbioVY+MW75C51S5Cig71lu!ogU+c?)pH*61o=6u50NQ7Ix=r ztoA^D7m3KAa1Gsa#X?aUKJ%j!OxLl6PS5Y8i!_x5h}t3{x=^>XB?nvu} zA|uoS_h7NekcC8sqKEsVv7#yVwXk?OX5B?p(?t%eLYw>r1fGS2xT08>bP0FG|BHQ? zEFd7t0`HLD^}hIpYsj9%xX^Z4L?ql(ogdh9^vs@wp2h8k`OiYK08y{IoMY%3YjoE( z$pZJCMMQ>fQD?ENA3mKEWIG251OV1OhZH~Ln8!s@Xx}jPUc1>!Uu`SvfCDyZA0kN?~f!mRyq=i|m=c)jWNHNrtFxk+=3G}35 zMPI4SWYdEH2C9Zu%N{2}F%#K^gr{L3h>6l%TxDgL70)j71e6kbyoBk*!%Mxw1sK#Z zlg2instpa%l$6Os-%AJ@`*}s#mu^-(`gD>Oo>i2!U9dLNbUJMmZPE042`$li+JHt% zTqp&xNkt7MOeS{0U($BrT30a^(DfA*)mGy(mqKEqAIwd%a(s>=2L=;WsQ8I}YI5hZ@Aj%_2#e_sz z_r4$%3t}`O2@xKpls;(SQzju6TXZ;+oGN)fa3M(VWg-ftY}8QEqg1EI36xC_*Fr-k z6e*i>F7rgKkmO>XE(khVYA0KJNagh^8fipUls+p}B}4)zs z657U&4Ja7$+POD!HMFGWOk2|**ynY!ZQofViuE;1rOmW8eI+`S25%jn+^qdsM0w8u6aZLL-v@u+$nL*!oM3nb>!rlp-;PT|39= z9}EdrtphVcZ)u9kz;>1&?lb)B8TwZEj1L>8M-uvn6r;O@IEJ1&jOjACY1 z<9P;O3#$eU4~}^B1~HQjMJa%RTEck40|kSN@OE45@p+yfc6wgl6vkd zm}+Wz%JzLb4uAyP-!Zg0Gl3c zGWMuRf(Wee+jb5~G-VPRqN!ofN|+IkelN2f3>Br)`9$KBu$al zO4A93sV!oZwF!)|&r?mM_4873LPV%2LrXkjvGd-n@(9vM!z8s25D2xPjll<|#iNRK zXqm>RR7ic8Ef8n%&@xx|DG)=VZ97fs$*<7FN>MgRT3-(PJt~cAwsv=vMgV67e@(?C z0@XAMLd2$E^pxNmW9M@q#0pTQRP+#;i!PoKp9(hZWGvQJn#E63I9dB9^J=cC7;PlC z4|@8YKx|-t&N(Xs46Q0 zc-Ut&5=C7ZM8SCCkr+i?TN^Ga*hnQ6+xw87uc8wgiKWlVB-Uw$#NJcS5Ty1@klHpj z($fkTHEqwA_=;9yg-S0KJyn{n($nMw{*aDP9>j_LEKr8N(AwsqC-{LCek;DM%d~SQ z$Q?&|e8cjdnBhr}-jbn~7%HMzJ7`k~PBs+vl2Gh{kJ{e&Y7l`j7+3gj4ly%p&wYrE%vj9o!7YJ0{MN$`Qv1IMa zM1^iqU?K1JqStxcMMigLP$-h^-9&y|xE~SoYcXo(QsrcAsuskmjQmZy$ll_$cGp77 zt|jcEN)3xg6Lmk2-O-_{OJwL4%(|n5N(JBZAQjJo!Zl^-fa1b4*v_3!>@_#iXDqvC zVWD5LHu!~OCtdxb3;Sg6Qo>xsK}2T?Cf(H!rLMkgcYQBif>8FX_=Wd(p@<%{z&%L1 zh<4K@u=vdP{a0Om?$eq5Ax^I$8#_wrHIxePg_n*8-PaKmNc`;GDvmF$LjNX(XT4OQ z+{3~<9~9bJ?DwJoRqA?HtLZb+NWagNyk?=YX~;h1_an6^+ZLA`Fn(ko?Hxg4}_ zJ)Z{$gjz+AC_&XU13d<%p|aK*zydU8pnn;(W?=aMv|7-ZRjlYu7@JTG^$Vd7I)I)& zn3#a6hEG|?R7*B@_~f)A^cB68#1_trJ?i~X?>A`?J%yUE#D1Wx_fk*A;CnVax~FC+ zg^F6!FjzNCPAitx6Z&h0R$H;Wp3rC-!ax{rYkXhuG(?dlL`P-_+m(~O0BDfLbd6;TsHZK&35x(pwv`G)bPW;WJTD>m zp`NCuC@Ucl`+7IpHdlH*NLa2TCS%`TcUh>IIgk9_G(YcH)1$6!XnN+YN_x!12-?_1 zX)Fp+mb@tI1Yd+%rBhE*OT2hWh>fDvwoyI-f({I272_p>XwYdbfo#DEP{EQ)Q?ZRo zsgi(7XlW162qA`9vEH-;zV&;IjP-KOB2ewd&N-Xr zq%MnXV@r9MR5;VhdL2hHSI{+3CQqkU6GI~vCQ3?ZiM`KB%1tK%fl7;|rXG^9C~BU~ z2Q30kqwtN`^b4X0bQ(>Q77NY>vW8dzQ^b;28f-3qvGpo!iY!~C*iWOH{50L226}x% z**7#}jc0^9QC?ffdP8e;nm?uF3BGTIin3^YZQtisWbaSYSb-ZF$G@JfP2);?%y8?90)TU)2MYN*wq))ssXzNcsJ!l+5Jru@f6t$8;Je%fgu-8y&3V{y>Ln*XlOo|xHK(~J~V-;V!dO^+}Xs=iQ;ZTo{Hfy5HGUSJ43p<-+bP*PHw*ha7f zo-pJ`G$PHkvBxSaEAo0uD2Xj_Jlodv`!RthOt-Co(J+?mjU-JCB-W|JGuF(^& z-54_$eV95blqIGin$R;0rdQQ6DouxEk2#l0K^V|>u2VoMZSAb_QXw6hXnJ*I9UeSu zFV)kzzZgB)qZ8WNI?-hIlM+bM=%AJ;&`?rVG}E57WkM}96Kl6vCll*X(-MWB*>Gap zlw~Ihlu2z*$som5pW! zQd_O0>>Qre^3hmPDlyhU#wU$DRn|Vt(myFBd74DD+q{s3KxiDv~5th|`8d zY-CbjnAm9f)CMjoK_>>^*!77BRD44tv9@DbCwRU_V*6b8dsavbJcF;H>e+~E&lpO+ zFcWEFqZp{z`!JhmOZs|3s|ChbXRXi^`YQ(C7iuN&d@C$zBo_^RPY6SyzXC&8akw`$ zXsq2+s|cPilss!}_XP>d`wZjLilHHUki^(~x4J)K<0k89F+8lOR)lq%Kzo{I8+upz z%xsV7S!36p-ijWSuh4^igsW;yj#oi1W9Rmt+VGpDSPo|IQea6I0ph0beF{dyl>uBM zaG!z!vBE-9ARX%kvMeLGD=G1o5$UZ0nUY0;Bwf)A8R6`A5s#7*f=%_$AdJBjZMe?}#eKEJcSLo-gKTCysStMxNsV91|eq_)kc6fzn zAz|Uv*co9Pr~8KmlDX*RzK{pMW_~K6j8JYBKG$7jGhKZVGe7lLF$$t6L`8I_`0=_Z z+3s{lL4jb-i0s({X`d9%5f<)k_WbCAz*anVg}IsvMX^BWtizLu0NF83q21Xv)zfoL zvvBIS;V!|(oC;`PfwsWGGDhQKBu_oK6QaE;RL2Svcr`2ygo|t&t#!wDnGljBNiK$DksuI5$WSUPYCS?&QnY7Z2|*w<8;YK)5CkwY1EW)# z4SqtY1d`aOkjMxov4rnzQ(1yG9o4jkBvJT&j8ejEOB4Fgj-VAO4ymMw!IZEHBnN+LQsS{^{_V3 zN;IuRb%ahUm3q0RX+9*kLedl#ej2qDdBlc1Up_#8B^CA3{+VfON}jPoqY`E#&1_^B z>{%x&Y_x@^Qv+H_{_NPuu`FGSVydN>ZQEJ_T0xQ$JpI+QE>1!o!B$P{Wf+07FL<6X z)v)hExfB!n!uXVZ-+QYO+Jos?h4w*fVSHNCR};!5>-DM>gk^}^_T8UuDyC;OgZ+lS zx}nyu?YP<2ge4F$XkQV83WH|X!wKs>O{J2iX9*M~-(y3MPqkkYMKH4~qCMGW^#Pim zwPg|$5=}R$uZ2>NM`e8(|Dg3K4^(K&f=RngxcQIRF>^2zvW9xSNvj!A>0e52ZXq#- zWT^s(O4T*BZ%)jA$PplMl+ZuW!}vtNG*4i|p&rrrFskRj!7C9QGKOh28A=3cwMiN= zYPM+!WY0sDv`y3I34;Qr8H3g+Ph%QQR{DOPpl5^cVWyh2CL(IUw)f!Kk ze7w*~sIP-kC`7TwBtofVFhQ}i-4OxRe$C{Lgi^21##%@+(Ijfa^sa=JzGB%L zO%Pc3(_}VAl;+|46-f-e{fe-xQCiWOHUw3T7aMe_h}%NDVIu@QPcytzsMjGeFuW6# zCq$7DXkley?F`RXRKl2YN$`A4V@A-vVs_RJX}-2%y-poFTCpWhC#S75L|;9k(NZ=N zqiWNbPR(js5gcBPh>fyBa459fcHo|F*yx2Y5KLktUXwJPpVlzjQizK2HB2`Yp&t_j zF($F8Pd#nzkH8l^EzGtx?Z}R)8-fHs9b=6FBQY~ATQ5Z^!5D?-CG=Db&92}G_@c`X<@RZ zX(x)3CzR9qvk@y|(S#aiBRe0}!-y#Lv~L-^k5V?;&sWwC5P@o7D0!04cVOqgL|{Nc zpoOw;a>o&^V|yvmhnBIojRR_zMcC$ou(%cE5MZfHc9Fb?|VWcO}iMU$Ex^-K+m185~Sx#tZije z%4&MT#M&Za?FJ*(Cbag3qUuM4+6od$>hvMXw!za-_H3%HMw~vYz7-@$Q=XbMg1V&b zyxB?=%|zvObuxmX99T!4%wSNd7?DMzYJ^sj*!n@KEo?;L>x4>R?SId+sd}Tt3Y67A zn2i+8NT-fhXGAq&*2zQ+R7x-tL93?6X&?3FG4m--F(sG=-?K z>kpMiki;m)#xxr@B=q$d>b+2@DW)c{V_9lY`rYAI$6GEw~DTTr+qbZjJ?L*I+ z62?GtBB43lBnaUNPpP7eCTS#iDx$yc+}g0?+F=5BrjZUurv%%6DZy40 zCNi<2mXcqj(HL|QpX2G@Qw4z{BiW1j=qZrI-U43WQV<3*@^g7g+RdizE2o5AtIIpT zQ4l`a9xmQ;rQhODXj~n zcGh;?qiP?uU8=WrwVOt z7542Wl|4Gs8x{L3=@Qn0uBZ#TM2xtwU9}*5HM^oH)B^X=6s|Gp5;7!x2Eu}ue%KWm zl8I-kaLpAu3t?9aB9JOXS9pcCWnx*QAmo^WXeC{Ol2?c}Dbbns$`r)8N}(TAy072$ z&Q{WQib7kuYf^NkwC?0brEp!kATBIafII4X-irc>DhL2l5FDz7YYGa_axuyzp)*BX zvG90tTq%Cm?h*kPy7ppZPVs&vh4(KgTw{sOv~Q(!j3Rw+{KEauo|oBlTrKVs7TT0O z7h(E-dFgmX`g^7@X848iP#1){?0L_Gr|g-m7w*%c!2e+DLiu#2%qypTGm!p2llIHQ z>3Q!-f67a<93Ei=Nb-p1J*5=B@Bc>Ic%JvTu>+PYfM~7QxqTNqwryqAx{b_wz4Wcy z0IQBcMt+1^`849Yz5zi6bcJ}^E%;A=4y|8bOR(P)F|RnZ8=Ol2z~1m5B*xG4+@QGXiUS}O$poXhL!7J+ZL$QEh!NT ztXvJd9)c$yVHn#E^?vgGD!tI0f+rkg7~2W;9vB{lB!ZqQjEumF74|Lft;5u;6(0Kg zg`LBSf#t%?Gz?Y~JOxPrwHl0%!t!OXZHJ;-vgwbG(`bXXCaf45ZCKTlFh1k4zHUjGb_8|bAZ^WHk70b;3S;d=Q4&Q@ z$$I0i8i<&hwM0l$6p4YMnlLe=8SF_IoAwwe3oT!mX=>^vVRTxtsxM)3R?{0A`n4q~ zN}*wR(xV(2CK{eyXs6L0eHFvb36IsiF%z?jYG_HJMx?0v!c5CBSQDn3iZx|BxXrW_ zeO1Hgw5DFRgnY@Dl$0@S=#3bk)$~;iqthPR7ktkOS-o|`OiM9P7MhVISpv^4wyU97 zGCWOlRM82`D|Y*yvan-PQ>z);85vVHG@F`Ly%A%xignc(lM)#fUu=4on$Tz{R@4kp zV#S)W4^s`rieAHXLsKss;=~e7)zGG!Ti+WqF{7x2LSz(C1ie*ZY&yMfGgd?k6^Jpc z>M=|<6unhpwy6jM>pj_yp{HUPoAMayO&FQ7DHTegCFEB2MT|~q`s;?-rpD8j44Z8! z25Pp5^8S5>sfI=yD=Gwu;h<%Pu_;Yonr=@i2m{09jK{LRgk9qv^-986Q=@&T_`>*% zX7yl#X=&CE*=IFLthe>fF~zc~FgdLltP7?J%@!;hfU$8{whTtcpc;bm6QTrG55mM4 z9JW7tT2s?01kbkbMW*(@agy@atReU|?t&Yu2nJEN>;;u$e@SLaBtAnjqeO z7yj~fMEBl_zhWK9giYk2>b=BUw-D@qAkmh4(Syq{lha76iML`o(S7$59I%0S>sGvh z0YnUDR^#>e5kI(%o@cKoy8l7E{vMOJU>2swVQ6i_{l9`08(}<2?F)g4VHjEo4{w1r8(?Mv`m2eJrkQ|M>tOo>Hd@!T zg;jk9KZMyS@M^GQ2do}~ZQE>QZ>SQQO>1jBxJ|Kag|!93z&cOF4aKVE!tkhK#gH(W zj{mbQMI1qIUD!71v0^A;Vn#7oN$~4deCg}4`z`CY$1DY~yec$Wih*8XczkZ&=&8h* zGBjG6Wi`X_q^4FCMjMKuO2S~trU2_L8Fr0(4E4mc+6rGoY&1~}J(YxQQ$F=R!%SPz z9}4Bb&UyVc8xgmxH=)(g43;gqI@QordkizPnxT3`v#qF=QfHP((Ob4P4A%A7`MXxK z)Xh{=v$htoZOo^ylF*D56=mn5cBENWjTxQR4Am3HXFVIis;r2;tS@0aZOg=rrW6Q6 zWteFw26_z-4trEfhKZJ<$2U|0J1?&3Ycn$C(O)-=&ng0C#h%fIW@Rm5*Q927Eus;n zwK_D6&S(bv6SmEG4ED@6cF?Vs3^TK^uHP^*qv@?0rkjd5 zf!UO>dY_l~86F(g4E739tf&Cxnsr*J zR}C}MnsvQx8?kGkUJ*toH7f@V+ed9*MX^mkRw>(Od}zqfnAY_7S^J_=jafb@>>7m? zLohN1rG8jKQY; z42@Y>wjR6yX2xM)H9T||46K5wQM>+Gwig{hlCfcE zG|+=XnCWqX^#@~i-bb+E2xttW`ZvSSk;r6;=%IvP-*O`l-?udtKh$m=Gesn|F_vg& zz})u685;YRvjyCv;K?cZ`EV+FybfCo{m+35fImuwhC6|$0sjF!6ZmfGF+7=)Wlsct z3LFXCkbXA{+yxw%{yqeFkhvW5I)XHk%e3&ehw)}~k|rBWb-d(*!M((9c``>t6aog3*-Mw*6Q zf=gu~ zDxImxk^<4+qO)#I)~;IDF_Nw*oh&t3PeGK+yyust_pcwDnkY+oHC%`gDL#7{@!b#X zO5c&}Su3Y~S4*$0H*IqRSe3qGE7SL_C;dE5kFhNk21g5GR5LxsXu99Fw70-E_h)VT|B>l+WuIM?&JpX=G5CL$BvJe( zMbMq2#qV4IQA#m6KFR<6KmWqU12<7AX;!YT;sql}lu+*ZHJ;f(r5d8V5<03AjxI;d zK9|r8k<4r)D3|bBRaEIH^h7_Z|42gJiwwkI3|?48AJjlsdSKHme%}DnY7l4zK?p-l zyq+G^Py<~qBTvN7tiraIZeuV})l z2rrfDO(FzkO?5gVtSYKA5x#Fjt&)yeX%s60Z5M<#g2R3nctRAD z=Q*|trX{7b{dATCIrVak6HC~{@tjwGV(j0Eu?x#~np!CH6!bl4#}IfnipEbxQez~I zR8fS!{hfW6q!BGqV)x5_qioy#^fN1Z*i;``%Cx{wuQ~PF)LPQ>SufQrAG}RZVZV>F zV46HYB7}``PqCGq>edf~r!qAdskUnd8lmcOMqzFpZ-zE`- zA1DrtRsPKRfz1PN&;$V_#ztdAkw*L0gI$!ORMy0?rrM)ZFKg=|F1k#qtQc%Ur6*u{ zTTq^dh$4OPc>a`&nAdqVvk!7a_n8zxxyZ=dSx099m9q zb(VTxmADyFsX$O(Nm(r;+}=YnwT?=o7iA1V=%d;tRL~@t=p*qa3F8`61XVUD31M}M zQrHh`8u*n8B7(o%Lxm-DPXn)~2h%f)SF+1pofv|86*-_yyIg`+L{P4zp>Z)uImB-# zsB(#Nvq4yk!4Ht;G`ge+qX^{*rk#L}P%6Q+8mN*-r4ge;Fzv(!Dk+1sBXsCdX|(Xl z9+hT{5*ukS5D9)+Q<;wNtD0KdR=*M>B<%#hOln_fs*M=!S)0W~F+mAxvqD%_47ChO z+thtYE5;8MgN=x=tccr*O|qmFNh>A@g`t@Q-;0PN(Ac)NXA^=z*qEmJT-ye%5<54< zmJmIlW$lbO0#DiWi*W>jm#`_Co5Q@+EirBx0?%-8tSANPysfOqx{X$~DgDAQwOO$9 zZ<0owwA;2`Qf%hD@T0a#=L8#Rn9ZkxO`n>5Ru1iaFNvgSC~b5@E3)%-lBAKmHiIU< z7Mg9H&gs@8-r5f2^L*e7act*aJLlPXw-swjCGyBYmD&P<4~-_2Lelyz_S)lQZcZb_!W23p#{#UAC ztN3t8WS_Gnjok4x={-r?QnEF)wCCYzAu=AN^gRdza$7O*gjVcPD%mK|Fi0au1#3Ge zHYMDFZAIXx`)Q9LeI7F#A}rauhk_;u6`LY^Pn(e<2vQqIX~IykzGdIZI97OpMrlZl zPY}ZT7F22;QA^=he7peKEss(~vApFGRy1*2(B(3I;L&a=N>$CWSqRD=Nv!coAxaB< zaY(7xr#=f|y^4uIbjglQZ6Cj4^*t&1H=G_TBV%XNb1h4Mmw|N!QoC6o z6XOEW+3oG$ohGSS@ZQdZib{ca?J0;XS=2;EthWo>6$`m%-td_>db>bAP12c~C?n?E zDY4rw?3;8&DNyLA%$t6;Al6J2q6fND_7qbCNr7}#g@~M}i@Yx;p2@@wDfB^;&IC2H zUC{vv^9wG|6g=;XX^pbThKwYxcb!`oh}>c^c(s6_RVs)l#i$2U5J`%{NHJ0*DcrwW zK@jo^f=@ql>436(TIK4(`P@6NNCcTvaMPWQ+m_w1`Ee77rgBKl!y)rp83V+ zcUk(`vVzc*30^&gKG0o}DRnxFX%}nRn6B@Nksn#)Lbb46mXfPn5H~V$sWYsjag(xIX(VR`g>)e4gCdyrugh;f|+zhu|Q{G`0RI2*FE!7L9$e6dn?^8dp4v? z;LD!xFcpPVVO(s`nXa!mo_K}#ZM5)yOr&kwk#0X(c)qr!-`$sZJkvs7n+rMqg#oPNS)qy&@IE4=zNE=)IbOWG@7bz1`m7Q4>G_8g0YyjuCcl zA7oluxx_?}PLTOvse%Jb1-h(hBn4^X1e%R|JA@JmxR zRf-6~kSR=8asDIOG%69I+F+u&u;;i*pi=gpIDO{qR3UMTKw07;)Cw^M(ol6t5?edM z2%(De#hd~pi3znJi4f4XMJ3as2a+U&=`l+Z*)%@!3`l|)Lq%enc0=qoh^^Hiv7t(W zNuVTgx>`y=1W61%X>n4eNLhZH3p)GE7{J6G@02l6({tNo9@Q#I(!o>mLgUs;N+3z{ z!g3E2!zIG<45KTYYRl)VN|qcVj?=ATb> z9JITADG{9J!_Gd-{s$K9f2Gqr=r)fPc;4JLX{xil&01N}Do+)aepc)wPjy`Qg8iXY z$9_d-Ty?jz%X3d@o!3q2*hT?LqqR+yViSUEdvA*Um*!_sq)ZmY?|l1PYeegg`-S%B z`U+6Km+mJBI`22MXRj}7pSDVu@;pRov<6XrzAZW>Fmzzk1maW8@=a(TybwWC^%O>Y z$~rYYy%6R3hzh7`jUk}yd7!JdAG`ojB|;6Ns`gx>P+kuz&vaSAR{rn_2$-1(NLnr0 ztp-s~i^kM6Q4rCZouwH!L9ge&`=@EN+psfXdTIqp)M9c{F*z|syE)0k=mhQ79ZXL( zm>3`D;fHro?pwiFD`fvA1#~=e0Ysd^u_(2KJ^RdH;LVG@9zz-9`u#rA*5ztOYN$Y_WCQjLhI8E1PY^bQRb7FwCAp(^y4U*WVnn_GZtCQXm8b0}6!#3Mm|MPL4R^ zh=0Iu;AikZ$Zy~PA|yco1e)wd^SpEZYDfS8h2W#|T0O=L}vKypv_V<5(@IIF|ZC z$JKAB0M;Zmj6Uq&y-ZQ=VF=i zS^XWfhDi0nqxML|xs18B!@E6WUC#16VC2Zx>jW8&>ff9^Z?rygMPX5D9R_=n`c0l~ zXuo4uY5~r}XIclz&UXQ;V|^mO%hHU{KQl|CMRu;#9;QYrr#6Ghfw<&?kS2{f| z_$?pQLuqSb-9GbNZDanheXbl#VlFiiT|V#IF{V;;jK_HASk-z*&NIlF!VZ93JK7kb z*%0cO=+}thb-Bp3XQO zPFNpKNb_If?|$`&hx!Kkv@Ll{UXJ|M@}!gtdNrd;QtJl7M&rIe`DC{P%MM7XCGWU&;4=FyQ~c zl?wU4m5$%f0tkK(5bm7-Ved^B`mOoi-=f*JIQi^ez_#C*&S~x3{4y2oXnL>TtjW?#^-{e;Wl@gpuX zF!HbtYb&)GlExV#$s2OP@0_18=-gV%kvSI0Tysj^XzoKc*S^|HsUn2X=lH0Ch|Dqf z11x`G^=ZmgS85&%o`?5h(zk4mdxYv3ZZ1n&YjnGi*qUjhkV9J;8a@IISYTzxq$vU z6iP;e{+_uE8KtNeK!B7yNkEQe0Oo|8mI6SewG3E!LzKxlYXA)^z>&Zy6>@h7FFir$6se{LjH`)?pPFQ2~4_u+7g#@1qo{VWj%DM zn=C^oCYk%Ee@C1c6d>D)3^mX|0c_be9`;2+^|B2+!E`Wi!FF8AC5jw*74)T1kwY2u1r)`@%t5J(AyVZHS$)FT6g zi|<`QO1teQN`)|@ahU)pm2+7S83UBcGHQ5JOR=Qjy32^%CZL8np8|3KYdu#5P{{4r zAOT(hm2&*3t}hkb3mTnVfw{?i0OwDn4f>3 z7o}WAN-wphur8=?udn8%L2KIz@&(BAp0S)CaXff{GlIK8K7Ydf`4bMu1M(C=e8l;l zadUgb;V>h42k$&qA|TCh&LM;V=Lj)5AO)<Dg3)bq42iogOnMfVWax5CLhaghm+)g@_>3 zMu>7RC}|?sd5$GzjJHyc=>LPjc>{AU1PPb=g7vu+&*k5FL&~WE!UQ00g(lAtF-wNo zCN)ut;G9c6!jS!t8X)J|0FV?l&n5Umw;uqKeGYMHpt!v0xgqbYWDmC*z(VFJAt?ec z%>wPY&!!neewB5(!}^pkW%K+b^F2|g*5@9Xx=M?R%-5e+S!x5H+jkxH!&b7=5VjAw zQY#Xn20~m%W&7vwwOWAsj*$B77nSh^v%o0N4ep8N-Odhs4jq*cw~HD^o~1BS(4kf# z-Cvj*jV|j6cpBJ~hH4N|ZQjx5hpvQ@&~j!5QSO^LFgDSU*+w}Y&H=Pj=t!@*!jyP^2{7@r#9+K)DW+a{v*tM>k0(@3o*6R4U6cb!A_r_Q+u_ zILX^m_Q4;K)<=*F$dLfI09#RS!ruV#T$(_z zfd$$kmvDXrmh2EzhJYro|{P6up zaL$P5Gve|I^Gx8hU_JzF-~KCLy2CsFJefgSXC$dJURXQm_w$0PufC zv6A|0Aq;K=^!nC-o(~2fy+^Z&@hEBz4e!;i{~yupirA1K8u0tA{Qe~Ge?I`!Y=HZZ z24tQMpsOJ0(E!obKi_tcE4Vn?ASqa>;ANI=_XClk0QnhxCW6f^@xx=lo8{=XC-gR0 zVE}|}Zo|*e)G&-bxZDr0pl%xsoaoSp$y>#XGeiKRSCwqTveAvWjocW!-7T?mjTo?s z8w>Dzb3Ak(+(2O1d=Skxu|$pjdoloXHpktY&tOonFpxVo^JX%~b5!Se6wjWrPrlr9cSCdBZ%XD*YBl&ZSK&NaIU6wB{-fM$)GrV1G265uBT+~+*Z zZCj{yo9HT;V7n!`dl+7j24p6}sy?8iVd(bFS>VBRYQhE|a<^DWryvW!({G4u3(+MG zZznp%W2ziO=Ai}hO6ASKg^aB#bhz8+eq7`okoSev{_g%2P)d)1o)og@jhhf*9?gpSB+c<$JVqLz&7J<|06LKaj>j~%6sgNC~6POt>F_vY+>2v~!ux<$t4-1y_ zh9AHGh?p|Y_Y1ZqVqG`fe||vR5*~m2EgT)-ZoYwIe(`0j9PDdwhBr*&D|>M%!B6fE zw>aT+IwN=w9|D5>m-COfySqi010EmGINlr)<^t+HKDPd>yLWeBW-R9oKKK&OeVB`? z02$6XoR zM_HOK%2bm4QHYRLp7SzZHI0WvrF|PqwZYrfjJI{G5jRz`jYjCjo53 zhG{ma1WH%4koXm~@myZ4~0GLj8$uiWInIE$^z`;k`x#RqE2FJ zpYzl`KimEf=zI74eMSmTrlI7C=V~F$c1{bM<6xhaK-u5m+VUL@{TOE7E+n|TPgZA^ z8UNTUVd{`U>2Yzq*)%^~peJm?ZMkofFzX>(OAI9hnR8id0Wvdv`@H4`;2 zvj1~YxdNbYkXmGtwD_XrzOQ`_GSHIQ;nI56BEW+&{uSJ|O(X&)_}1s%Cgio$=b5 zT<`@8PcLjdrfq04L?-7?F*fy{`;L>Kx zqqojV!+K`(_swiCUs=E1em2*b9{}!cAY8Kfd)gtC#%Ep{2!S!2+maR-NCt0U+tCKg zUWdW_zp4(>m2JSVKf&x{G}p4d*WIukRuy8m*&cI`-rgz?rIivE_I(=8c`*C58RE~9 zP09Q_ptp&G+4p3wZCl#J9m2CsI7(&>~xHOx6R0YSKAu-sHIZE2B=!5DvGe)Q{ia$dpw zxpY-djK_zJ7$Xjc1Hco=ImFW$IcJ0^Ag7F!5@L$DxhW!|l#&1t1$-zSS%f5N1R_Gt z851UC)0ywRM~oY`ZN)Slq%aL2OgP-En8+csElqA@!m~=pUlw3pOm|6c+uCUsWg_7k zIHAL8=h;&SF-YP0#W>=8MUX{=Q!D`nT8`2BHR zrZcpBpPK?ie{Yo^IbSYA&o{AASzsSLXLKE>SFh`opT6jI8^?}D8h~3fQOA{Ka<-kK zc%7A>QPPzCqJ2Qg95dU&S*_NLg;lp*<9Xw~iG3xx&VuikJpv@poD=Ozz919L`%9vc8%SXfx^KNjad;kd8wv%M; zB&3|NoX?0cVqI4(=LK2D<@?VMc({MWwnjYOKjQKJ5iv$QKAZpsK7IU*ZHqWPoUqXh z{^no6O@~70()0XX&T2D(*JpsdffHX70|Ef}eDlu{@Bba%rxTWWhK~{6JKWq9ANu>x z54gEK!UvBtpRp|)j_+>ZeNlQ45k7x@z+o=;&U~1WQ&C4c?-93%`B0Q0dYz<{a5x^3 zQ!33C=QE~xZWyhA_|tqqN@vV7!#Rg_P4M2sIgf3dv26$3+@9e*gV_~}n|dY3Mo5B+ z0tBkQnxwXsrip7HH_^qPxi(lR0juEwLYAg{IRzz|mXN7oa@w=sTVvI;RV_jA}K0XM6z2JO47d76p;{NjkPLF4#l<@iEXQY_$ zaQ}du8OyTb@!^cLZFoFq(7V6H?YsAg+w$V${Y4od`7Ha@hw^qvwFlwST9o^{htge z!iKmA0|=?>IT@f&iI4?-Shb=TXY`pEP1D+zyPEd_y{h9d94E^erva!QflsR5YcCgSOC~6IADNU48XMo&+|~ZrGbc{;TW+wUv{|*`ao#AFQYog zT5#PP@*$$P%UXv=|Ab3RB!fP6SrQnQWeE>UEZFL8#bpD5C4v(A43^O*jL@rJED@7& zseDQUv@)uTFc1vu0OqHG%(L5J&%GtzCR_&H@9nD+Bxmzm8urT)IoKgVEIH)Ov19ZK zF?*d@d$Exmwbz$b?^uXx89uWl*qBOTiKfZiN70Z6%TTS1gdpvZ&)J~1Co_f~43T^s zh`G&tW`paqIsPZ~{W5I6yP&8@H1K^zv)xiM>whIg_;)h?{`EjkJw4BTv%val2#`|K zdSE-@@!^a)+<`eG_!--};nNmzb9+2ku3XULBV@(vbT!vgwM^0Sk)j@0-P zRG1BrFMz~VB4p2@i@BMSKe7DRZqKfb$OznY_EXpajh-9(hTn?a#IyH~0X&$wD8y>_ zr|SU`eV-B#ih`p6A6Ysy=L$sBallQ0pDPGT{wS)BsgfU{Ac~Nw{=0l8?>Pc=ha#9i zAg3chXCXhd6LZ0~u7$9U5iyobg7f2o$NLAY>jGxRkKg}@^<2iSP151d%Yu{>Qp))J z@gw;5udw+8a$I{=%`?|;&bioVz4+pwr^mRr*Pkc-A*9#Q4f{ z&MyDyI*p&HRi3!_YMsK@*Fm@>t?a+|no(*~0=HZdInSIK6voh$C+38EWL3^h-Kk9| z&RuEkEln46?ei}hxA-zt#nuWho@CJ_;q(NVQ-f0Ib?cy!^svX&`ATTOs8PrZWT)#A zf`qi;LhB7TOQVG}XwWQ*dWDwWA*BPB1=!Y%oD-I%5F95_nVwFMNGahgc2_xPoKI(b z{&Wu_!nUsX{P7+sCd4fwWk%dK+<$t2_Z~m~{v#4ipxbY;E+_clUqn2ytSgTJflm%Y zdADKf?e*tPerS;He}EEV``@DY%kK@5i-2#=fMzS2UCWIDLlvaFA3*U(v|w^ocX$JK zo((MK4asnn|JP+b8qhKt@Q(~=XoJdmAYv3WRYGD#v%5(K>{B35ZFv+d)pczHKnb&5 zlR0)r^tM5MAUYJ}-dvtJlUj4@}04S{wT{=T8Na!bQ| zG9Y}~S+2Dqvp~4tA&_)AZrGQxbYOG6hMjee$JxHO%jX?p#j2ki`Vedy?h7RW&%<_E zf-(-joy~n`$E}Fot}5&tW2+RhnI-nxn9Z_RL$2~b8qz?jSSz}-*_YKE6IG2VDZ3bg z)f)oKTK3s%YeH`sx1d*-u_2wD`7fG(ud;oADMW?li+nF=hs_01WyvDs_8G-O3qokW zN3$dR7xH(t&iW|(_^qtxWtG5}5+E6GU7de)0)!=K*n#qfbvfY^GTvq2o5TBJMYTqp zaKiidci5J#$UB2aI%XV?2b@o5gcypg#5{qh__|Kh1agFRS+T7f!W3{g9B?|Fa5x;` zy>t=CV#F+fhW8H6d8AZstCS+V4+vqxvJjS)aC6L(v9gR0V}ux4g(0lw$qZ;Zi`+8%$CX3e%ItB_(vE1 z&lJL?I^#)Y$N;2|h5tZaaEzBTjE=nFeegoo^(rDGTmSAa1mE%i!n{}qkCshh0yLSQ zu&OZK>|{Xl{u=oQ9_ScNx$r_TpjnZKwTXL0SEC-&r@#l6Kv=TdMo%wu*nYibFRshI zbA|kK4%0j%#SPPZK+Xy4dd4)(r4hpg8M+hJb;5iAX7XV6 zU}R9OD>UkJt@kQhWvL5N3%Yn{FAHQUvdvAIp*Yk;?q zC*-I$T-XK)?vySAd6cA%4r3llZuU;k)iI`Fq}Fo&iDrvy^}tI()GNeBS9{oek1u%* zbw=ORu~SQ2%mG|SY{0oL6Qth<0{mQR3nfckDC3KaJ8HdIh>ifbsi;7h4e_DD?E>+V z!l4H+ZsDAV4`-~O%h+>1mtOR3+psPR;XWbZW3wLaq)K7zhv>hSjZ`df#~q) zVzcV<+_0P^=Qg0XJ4!nhpd}~l-?!n~w4{r!I}O`{;oOfIG<%=hyc>_+W{q~PNtk1R zfso^Ytg{5iG>{#8N+7*{Zw-NwajC#)hPnMd+VvmMD`r;o0rGJmSu&c9mE8x2-a>6J z7i1dFlO3<385it1ap)Cft06E(lE?P9A-5yoTy_4nqXpRBz5I`{Fu>Jclg|Jpq zS@S^1kR2Zk8Dq)93BX^;GEVZl1;B4mg3yyZ3l@2%ysRGhQVXoNYU58pfRssUc1JwC z`wtQLM})XySynvUKNj`I?G3<;(`hN-gDZjN%jt}q5^nEqt8LZ8{UdJQ-Czm<+qMB!#@Zwh+yvMe#$lllQDX11g8JbH7sDt+br&GYTy~BK1 z5yDx_fQ+bQeP`zO^?-~~7E zq;`3}0?gR!W_6@izSwf&8PE%%&1Df zxNU-4GPW(^{{8{Wc|lyavaN^rn0(1}qE~cTeyIZFtzh_?UR3RYmhdey0uH{+q%|fi)o&bg$z;KHv*$c$YAV$9}vNjdS9>fr@p;T?dT_h-~Ee3t*@mnHOd`QDkj zhx$-;Hn5D=GPN;=FN5C4S|vZxps{l-hRUV4>l%oi7dNn|az=AjRQ157R{TRLHi-J1 zurFN3SUc$Ubz+Q{UK@M0ubJ(w?Ow(Y`HiYgWoMYw z=4vtuk*TOY;H3^S$`E&ejc6r$2gV(UfORE&`dEmJBu#CcGtTF80d{3RSQ;pb+BD}} znkvr4vUpv}+*9Y1DJLvz`CE)dokqv^2!~@CkDcT*zF-{5IlWo|l5#2`==Amk{~BI8 zEja@C8vwr&@M{%t>wA=LUJcTHK(ntI0b^BASZkf5fMzH3fyGwIa1sFKEdT!jP3^E5 zz*6%;oBQZ2w5yjLRA`Fw)L?GxmePuieveAYCvV zk2sKDVTW+@1KDI%9^-km*%hNw#^W;7-rh3{F2lm@@$rV(Sk1Ar1cD{9wt;M!cCHZ{ zaDQv=H3+erVfE>aP)oyk-Gi_2A>|}QMyN%wsv4a4#WPk1ECFldw!sJOs*hk!#qx>>%o)>k0P~FH zG$TzDjyDeiCX|Am=nUp^YY-WL0#u56jshr<>9Q1|pOdh24e)fX1e(_Oq3d)c@#*Fq zrP%fWB{YjG*DNF|;5vbG0=axSITdL;8)AecN2McHDiOk+MHW@7Chkd)m#RZ}u(I0e z+O%QYaIVXTXjz}!xqQ`E%70ia(`ItKv~acn!gE$zHW#AWM!iy@G7u4LIyYO0E_!0a ztb-GGRU0#3uj2Un_vxt)Ovckz6aLGA3v%xI@e0>%A4Y& zfhX2U7l4Lu*&jr3ek$*ofwTeBO${&YIxx#c*&e85hz|uoIhSQ=CnilGnnbat%bWqI zBBX%4DMcZVsgfNj6>wl(SDa60EXyJwMd{ky)~$dOrxok6VOuwBTLA|+XC!w(B9A%v zHdeh5;LMo?@P2s)2ob+IQ+#dE1Q9%WIF2CB*kVFTj3Cc4?-xwdgy=n(P(pmUG+O{D zAeoGT-g~U;ikM0knRA5oynu)>yIE?6D2g)@8&9slR#A31P>fX>5~it;ASuClk4r9J2$Q^H`UFZFM*t35VIb>E6~UHqi{w)^a>3l>0TOQV)=Cq=i1a{h`z$q6*Ra zrl`Rt5DQ^JqkX3ZaQCLTW;_A`HQzh7anQ+h&)9F0bX#-gi)Ry@??cU>d)2ywYX?0K zwS}!oE)=X|o#I4YGb&eK`qGf^f9QcEnSk{1SjF7mBWdS3ZtKC6fAVd}`A-nGcnw6h z#138OPXFQg*`cA~8DgYska(izxTsHT6NM#0_SB|gRaJ6O2B_(RSfTzi+mL}=>Ht5P zx*#;Qsj=QGi;~}x>AfVGCsmu^SuBPfz=ZQ@!n)2#si^;p>c5Z{1Yi;MM~cmAb2%?~ zxIdLTBNg@DT@2sd42YQqBEqB37Z~lN6g&=w%&-8t!f#_2cQVJ#+5stmxelQ22=d$g zivwX{6%(p#vB%G$4`9YXlDHi})+$FVP`nNM>vu>Bz5<|GKzG|&zpb#;5-1IDWRVIgU`Qt$GOpyugkIr*onub}xfF0|H{}P=8s^%y z=f*0Qcp&ET&N68DPTC=qutQQ=0)&V4Ta}9+t|1K9m>b@Qfjk-WO=uu`+!G{=h0T>T z4Ti+@=3WTHHE56hQNDK`?!SmW*xizbRt==;lQVkjuhozaY>1OP^tm;YAx1RR+>)8! zpyhm6E3ie@d1r_Rdpr-r_@vu$gJN0rN#1{Bw)3Mb`$kBW&0I_0qF86WlXX9$&q;bO zWXsRYv3xl-@g*4`Zv@DnlmGz;i2)8bKs>>*Lz3XR;)9Li=Xzd2G~+l0Y+FRmjQKEC zA|u9#beM5_cZ+S?kaMxC(E2&W1TS5g+6m-?ufNv}f@z*>UPoC!!%J33_6ac-Ym4LY z4k38toUtsAI35Y!AB5xx$eckYDu^5xFoGBP&Lm;x5*+Bz)F3VtJFM#S?V4{t$Kibz zZF~mOBA4_q*%fe;w@2gsP38$^Ji2YLrh*12nIx5f&@gICZs>3L1%NOOlc=YHGpz;1 zXyr5z2aSASLwfM#cgJnHHpv)4mzRJI{POO^F1O?gK-*b97;@xE8?2G&7!(*C5wY|A zZv9UX8&AG}!S{=2_P!c~#T)$(>Z$z_U}Mbwcq2k9DT2|ErTErB@!bOT@p za{yz*wv{kw=Smm%wrx0{PVn9f5mEpN&G~T7A*Cew3+3Kj&ZUIUsdQx$NIEqcdAb9+ zLS8ec7b`$Cd*S8bq`ZIMyuH4PSI!yn_#NCz@TpvD%eo@vVyBs6#JV~J=K5^kwkVlk z4z#tp!1+=eth81YY1k4@@q{piss>SOw|ScDwXLcUFUpW&$+A>6h`yF(f%m0m0T?lE z2mzuf0k&lV_!jd)h`Bn&Z83&Kvk(nZ5IYa>L4wlF^jE!z+(Cf-LWGb2k<5iOA#$cW zpTw^pMFmpxKvJni>iA^H63yp{NdVSTW2hpelDs+9@svE|JGlbD{8I8mk7w>7hc5J6 zKj$YhD#lk#dxOdK=luBH%zr!)*=sC6|ADrN6aRVU89ZfcMfhn}Q!nN}=xH0xEZJy& zr%~d$ED&Axb9P_Ul%E&dqC|+DPpCr8sx&~Y;X>vuwQ&MenkQW7#|39?I@Jpd(z#{MWtUyaaLOrxMC<>uTH^~s(3#(are9l)Awb&)G{uGjnA)MQ85_SB zAncz2_%{H4i_%$qi_#7JAf(8h{QEZsm=0)GQ3~>@MO2pm*YIj<>0~|hwM$t+)^FwW z&uB^#3?R4?IgbPRVY|S|{5u(-()#a@o#Q}n&E*Fm{06-(jwLg!U6QpSVgrbV9WdR3 zxTF2iNHky|2Y3LP{XhWOb&SLi1_Hz`KN!N}U;t-JaA1Hp$E*bV_dO!VsyFO0_B;FQ zcDB)mn~&tiGyvV!ib;X;FkAzcobo&8SkLKZ0D5~K!mwXsrU?z_Wio`^UdBcm$Ouc; z#DT=~1F7N7eg#}AF!qkMGvq=Vu0MP2j5b&_kbG*LEjLpB7zyF0S`}=|1`o`R&2NcUKwLAQ+EZ^R5ALZHbJ6tND zo-b$_FE1Qk`~A1qACv$oHM~P|6E=9vxpW^g12Gm5Y(C7GCXW;owq*ky%WbxtOM!U2 zJ;Dc%xJ5kPKjL_MtYM?)^BF`(%=3(#GFD!z9Yq!}PD4qZbH!>!NDl=k5F^OB0(?lZ zzHMa)aL$X*_ZibvZu1yj0Y$`eheT{vz=VSU5|Cm(38|4=5rvIH}m{aBg-@LXz;|3mW~m4Uuutg}IZ1 z(sh3hUXLq910)DP;PR)74VHz)I@^3+`)I9kxUC9m*2q3}~{8KZFR^3dqYzsJkb zxqUmWYu~lY(~Ob&cU)1aHpN#VItG%&A6kx>pNyRq87nMm8@sS{Fn zLe58ICT!cIWPWf_@d;@WWo{Q^#Q9wEK4L0RP)Z4>(;3^g0j|t7%kPr(T@@uBrCgP! z+w=`Gd)znzgJll(vRjaUf&%2t0qg5}O#<8py=hzxjyPLHrA} zP;|9r`6&OM%`#^L&dn&D!#|*PW!tdi(XL2A+dBdEk^vwUFjZxURZ~PWYzFk*>=vk< z4FG!>2pkvyb}|G=8o<_rA$GKzTtQT|v02Tw`hh?R28_-FFw6sCV8hbK>v+VZ7ayANQt-%C%45GhH> zV#wGW<+mRJ{Ix7oZL2g7WRWrUTeH2YK$&FO!4S45G^?^t!#RBVWzGIxWAc{Y9^8Kl z0)%tvs3gwV+$|FS0H=P~C^jjJ*h_d{Y*e;ogYyoP*hZXBXH4^iAnK0uX~7gCyuTH4 zVgu)*?(kFq3}%MQrIXV+SBa40v5_av6(YljP(XO;%JCtQT=_F zKf+CAa8+?ZO2S;)VZGRo`-PSk&vioX1)sh+z@G8CfcJkyuNRRYd%rP22VDaf{Gj?6 z?YVZz4Wr-?tGEt~y8vli50;Fbnoa;p{ambkNPVYiw9Rlez?j@DiVz`OHS>d|^dM1v zFoW|Mh8!}ylPt2WWX?>} z1m_%<^BFu9pL`-JUi0SK3__Nu;!m^4axVEG03mG?d?>XC!-*0^lv$jWwiBB=2hQT| zO_MYjoQ*;(qgvdNZ)B$elMX=K_KRz^(9>FlgcwO%15Cl)I7k#~-9a*@^_H2TszHbd zyhm!NJ*N^W&suC=x!-Nm#B=1wpd97BY%>59++_m>p(qX<0g(rS+X<2M6UUhR2Uy0x zXDleI<=6OZO)RJaz5Li}hRRPXO7`oLJ@SHD-TizmYAmS6TEb?H**e75HbavJIp5#o z&puzNS!t3$B6$Nmm0Cj;AuLTBWS8%o{S~QdMEW`mpq=X0OYRg%yJ2N~jfHcAZ)UE3|0wjH*0_3gK z_{!!PXx-(>0Q(*-uvfD-j`Ft$l+NQj6y?X~0puezkNI1aZsJd9s*QCZ9-JXmHe3c= zTW~d?SEj&_By2#>lYoTQzR7~ZmJAqyAO(@*0AP*^jAX##ggzT$9mtPu0Q>F!SV(U| zuSl_WT((D4u7}ct?k;U0eqj)8`JpVSXpZ_d#n`HUD51>5-#&H_LLen z&n4{u_?9qxX2EcW%Y2Stt{d9<9OxApdxS_Fj>#By zKU!R+VZXM47>iFFkAzEWvejJ6VIWjSl8Oc*&?-tS2{|gF@|E9@Stl5-5rxOCQbj@k zQSp>_-dpm|s!Di=z_DYARfO2>*>I_z!49dyXf}RfAkRiyE;eNE5tmkC){5^JGXAPs z>>G3aKBCxF5t_o|&XCe)Ay!yE-v)A)&Az`!Q$HmcOaGl&pCuPR%HK5*{qSl6 zt06!@gq({PcjOt#P4IGSt(y`Zg$P;K^4L?Gh!`V$^yPseKp9ZNNK?uw|WW=EnMHeDv0_Std`v4@6n46tNok+w!lvIe4Hlb0X zpp+Y&WxFI8nR9A+1Uk{LfvseShBA5L7-bSg4T!Lxr_}FLN`v7e^3cnoYc6!=d7{*eIY-t*;WcS=(+5I%HUU9Qz&@)hD&^1;3tTX zVg$PhY4$idk9QA$U7`6$R0U!)m^f#w%Nd8m5flP)PKa^EG#%=2w=75a7;reoVTxEf zj)`tsC_0=dPgi4mbrj31+;?H8?HK~#nm;1-4?9tub??K}l% znZ-U-#yTdG;V~#*cNL>M%2lEZ709*SDr2~iMai5i;A)6__iU!8pL>PCrk@suu1zIx zB*+U0gQqN_==!n!DRsx#^zfonO8a%e)p~TN>EdEo>~lYux!hwySa#$`tr1Ft1<{V0 z)xcpz6*6}a2XhvJ7Oe>b03j4Rsd`^e16ucg6oN#-(Fvs^*#d`}9kLq0QS&V<8KL3jtJ&7s zfbSy-pa5jVWd_Cwu8;Oov9CxvfOHx_@y!q&YD=SC-Pr)OhXJ^?M2NMZis-w@m4uqn zt4*whmNy{jhRaNa5mX+PB?-M<&mLK{4%>M&Bv}&TAeiqt^ugtRhkyVsl?9_JV9(Oa z4f_IIT6&GZzuN)&Y!1kTKCpWvJKWB3#6S$Co#p%Cy4)ju?7cW%%X#>>{crEux62%D z$go5FC0vHHTR?x$Ld}vdc_4o*!Qs&>O)`4>FiV=)C$YUg4+DW^p|9?brJLa|5$UTVgYhtcLG zPpphL1<0R@00CEzZDfy)4)9SraisINSX0HK2yqDufTL@66t<(!tr1Jkm=80;6iR1L z&e-CH+uNJs!M&_-n?sm-oy{w(&@`z?MU)&tUce{Aw7F()qNAV%w=Tra(A^|ZI z*xpv0g=o+q_1yA4ys^cqK&pn7s^Ub#O2NfOOY=fZe#O<$Qnj|qM!`V@-nL6(Lp$0X z`s@)$Jy>#OqM>#ISGz2Adk4UxG{-)dx$#Wb|^fEEbYeI>&342knHk%l*nJf~JGqYh@YaL+kQx7gOmR4@Uz&gi4Zd zUW)=mt+F_R=p8@@S$!Gf$T7Un*fwC>RxIaJQEkMCn2Oy*&ZWx|%y5pt46N&lX%1M< z3(lvdjKk7ZuOM+w<#((WgG2>nv;fTu@0;{03oIQD-n_ZLy0JzH3P&aUSpccb3?~3s zdG?p*ON^xq57EPiq7qSchG&L%9vc^B!#qz&vE_SA9N;~$ZKZJ|OrhkFIfooGHrhHX zE>%LQ?dG->l2274s(A9DfQ~Wh=LfOJ3dq?Tm_Yj*Z=eyWWqc_2EA!g2LA3a#N$jv} z9*8PAWeUP(%Fb52euW4jvE z``-!3cP{|o5q&2*8PI<*V4xBg2Q(|MGkTjUZ}vA2WRMpiQ7fb|vw(L%XNy#%0m!yi zRCxee_kz;*2#C?LYC|6k4eTpr+yFZ7B-ud?!2##SHG=R(jy}g=RU)l9d z!}g7V;|KIA6>C>zE!*e?uHcj5p^ zk9Jyd*mhN#%mX3f&38xA(dMN@LzqqIL&@>XHS=6%j2#!!a31XXu|wdDmS(Eta>H2a z2Esv)MKH^s%{}}eGosmyA(}0u$N1+mR^7|*erC>DM6)#fjr?Af9VBDS2ehn_cjkV0 zFJy}5ig@|%LAGlVLPTw`_6Xt^Xid#k@LC1P8v*hsAwYCu&dl(R;M2J}{;+iZ>Z#wh z4de*U6LL)A=j#MKlTOSGOjLYe^JB*0ct8jNIWyKZf*Cj*X6Ys_x2cBl=A3alop3xJ zC3sXiKZOj@@H@3s&P8G!!UX4zl?-7fgiwYumePYX$dd{e%UpUA2p*tZ3hNqt zF7^+q3ZcP{LgnQ(*xcG!3`3UY0i;-Mt7_+@E-#GXqpr8b0z@5R0OMrxNj$FSgN(uF zHs7Oq54Rxm3S8`w86<&_Os0d=b-y2Nuy!ZHjwB3qAU~L20De3LV1Ie2`bD?sbHi8q z_jrOt=(`MGGW!_19G@jWp6dSG0~5L~OfmtWJAegn2|QfbOPKdNA?o|vEcdxg)SYyZ zcOXLDR;o&^K0Cx2u)?+9k@)F@hf7&Ioz)g6O9qIS;Bh1Rr3s)=GnN>T@w0%)Ii;F| z;G6@w0#KCqCHEtzgb=(aKzgNyRpjLP1M+kS#|cDpAxrYh9_9sdHmN}#onlFkv* zmda$td3kQ30(JU4(D##srzliW+wFv=JeBDI zN4b}SWRgW0AH?0a))~I#1XX-FHm|im%5q8YvLtnI9<13HTfG|?edDO z5gM(p>hs?1lQ5&&*%B+-RM8{SUta8e2+}&a&#yLYsanoQS^U&lEh~W;wsNH$X zFD58jok6eq{u=@E^}Q6hyd8jK&9Qhe;NT{pn6)d)17OL96u3vRWl@mj2ed$RpoN|X zTxJIVm)0~kgnBih9l~W;y8Z45CGH^rBQoFykXMhxVIVxPLmV)A%Of5@ zWZOkQUQ>1(rs2J{7s5a=S(42U=ig>!sN!gk$QeO$OA_!vP(=gA^FWSFcwSv#_d&^( zIGj@pzNaVP;XR0X%)A(h0egP)aNpT$d(2YtxU^y$@4Y;XH8iX@o64UCP&S!s*Xjgx zn>3T+2ABCEz`pg^-n9y|TzjtEaE;k(C=d6i4NBL0|7b{3dtE&Y_nsw+k`NNs`tP0j z?PnpDCm}JE*vRN@tFl>_j%WAs-#?M@{!ad5`Ma*;(?IAR z$CoQW-UyJdh5)HIIC*4Fh~W;CbUNB7o$D5M(v29xkEtlokuFOK7w8^bzgXnfR=SseXb|FgAUFXVaunr;m*0=! zqq+KW7f!}xeWcV#7cce}9T5Suyh|XVk^sf?rsaXCvLl-JHZMejMl0zeghApSOkl2n zLu?I>B>$fTe&E`E{M%>g~;RJrrGu9)4nyO!)UcWcfM3-~iY_C%;9ijh7M` zuO&XNfDfGt!|jn7JupK4GN+>h7%r@_2GGJNJ~*X|e)#bqboN+8a`4v5iXSprflxlPV=M{5>BN~w+mI(DDW3zqX* zh-^TB;Z&@Pasw?=$^v=-$sIspDq*z%!aTp2p!hNiEYv)PHzMRKd2s_Do%0}$$TT6j zBV5{w-6fZxasW6S$}}|PRC2x~&qLLyUZ3|#{^lKGieg#C2po_-W0UK6p66zBCD*-@ zB?XjAa?3kJsYSHmLP?UGGY+!eg73G+3ngV9?k60M3DcBI!v(X1qTAA2Nrt1?U~O`% zAHZ3arlu*v%(#fWSk{PDo)eOT^YFPo_o^FEk+6{;C8t2I^) z9Z%-D>3Yj6+dwhqj*XuW@2d*+NlPqhJl=BeZK8bybb|GAO}{uD96dQDw48IVJ`JYPVmknz0_)xUtec@NnP^x`tx{c zNAM3aWPF3(8fikYM)`!631Z0!XUK$%KCD^6xL=`#{jLI}X?OEFfNy(X?-;&4o9&oz zX=`;2jKu){=7CtSLC_<~Gg{O{1HjtOZR^`_l?G!#w8y0~MO7iw@ENdgnUr_FH-2}d zF~@ufn=LU7;5-lDxmIWM4iRFr50o5;!@AuL!7+l>c_0%Mg|b1`^8mv0K#W*2Y_zkA zW_f$-ONO`rnm&0Nj;mDxjkzUuUmW@j3Px|^H0ELey=sSdhy%YvI*ixKXni#jgI0y& zckTm!g$TnAsiGEeZrC@wZEn~OyZ!_E0Qo!|)A1Tv&}a704soYyv^WqO*ddi{wn!e9 zozN>f?782BfN(ow$ia}j$AP%~V75g=$-}UGZ?0QnOV zAg$ToVVS?f5ohGQ6~aOb&g_s9;Ijuaux+t1klrDt1Segbvvf?R7(s3lwsUc4z@xWW5UT7=-Rt}{Rx^Q-_s zdEAi<4@Aw1hVxc}LBRq}n9fP~W={dvLW{}%-4G{vGhwD^iV_l{Bg+k=q=*eX^|f5|YUL`dSDQ1z!tl~?s zyatQ*S2{se8AP}aG`tiGc**y#Zr>$wLVJGS>Y1;B=;}!yrqY=SvAzmZ0T=7>GuBr* zmH>41+pfT;D#>imifiB}2{Fr%Y>=ZYJ|e|ir0AuKF=1O*0UOGFrND%CIhKI)0u-N5 zO9hN_&P8N{)+Mi9oJoO-w3ac(0e+g0b$osy$nd2KkT+Q3>mxxbpyVd3^EYtVFs)~# zL=~*%#CChG&7duT1`n$vz;CKDKSb~;Z zF>b`buF7>FW-PJd8sSp1(4J7P*6pL7ydUctON{Kb^?j*|L~E zhewXFql<&aocrf6XUP!@JN1!KvfID2U1pYXs`P|z+l1vDkkbQzLL$elSZwMTT2uT6BT~*dN`3aye7e5Q_yPsUTc5>O@v@!w zR(JV5inR)%SgM@mzaU`VX92{H=q;!`iZ#}1NC7244rri-q6NimXSoGu?dYV0&4S(nM{`P&0V)}-6Fr;lwhD~R?9+}HEoVCI{4#y&!^;yzmBqPS_M%QJ7&#Se9 zvN zzd%z9z9g5WH(Gnk0QpnCUIlNK{14xIGZ6e{v;3mQ(2l^4`O{0?uvQR*Tnd+puk$bQ85=dlMg7E%=vZL6T0; z(m|9;Ax;eEvbo(RGsI7@f<2q#eqV+UN3B6VGtaMXbZAwFDJ;@@pb^Re`8ZY_IH5Cax0n}Xm&2#n`ul)W~+muq@HEcnP&W2)Gwz1e^nQWI% zY5-CCq}&A7{2+3GpG6TS?@8`&PH1*oQWmQ6BS(PO@^3Fw6po^13z!cZ=2`86HVJ+X zrGS$hh0WMP#;0kq&uE0A&c5)@Wm{S=Sj1rYO?w5R8tF*zi5Ub?NxZ$SWYR`q^B z9@eX5Os4>JeLX-1MEBHc4>!~!&P^wt>x@`eP7dE94BW~63!fhYpe24+i1Re({-Ju4kqs})SVaW8SOcFuzDMbKvVZV znMsb5mSy-*Y93XAWKodNcEUU#MBxuv_Y3*=%wJSzyj2=s&r6@GM+3Ae zV0ee-6R);Vj{@BNLcVXozLNpvv?EzVvVqbetU%zs0m(FP!#>?@_x)n-JPSfwB*JEA zI0NQeaK~Cq9dMcXFrn`N*Sq_K7B+4zZt?)dWdRYtH^3qfE4}k%!&j#!b!+VcjsbII+qqknN zgg`LooDHyS1GBx^UR4a;7*b?5-?u=Z%}kjMNUiIGIe)<{e;SC6aa(C1r>v2WAC|Wb z1cP2DBN=4RI|g!PFK2~@^J$eadX3+h>%!WCIrR2dZul?YvOjgq8gT+OmIb0+)H1nX@#@yehT2{! z7(uCYO(_VHwnnTt)JaDf>=;OLn+f58v^C;_sD-G@enIRf$f=E%>?2SE(l4waw40O} zysi6IS1{p;7WlPRSXLoIBn|ClvcqxcWVAlpY)Y4bfa&cvnkc4gH^^)}xpBSgQi1e6 zMTdYEoUYqskvCZ&&jGGg&s$+!1vmz9!1X{51$X)V@!H+7uIl%}UHyLld{e~~T|X(z?LO4MIXS^;l`+;= zD?IqQ1&?a~np*EM%fB5soh2asu8biZ0rmn;l)eojkCbkal7shzo16DY@dxA-Yp&Ay z@e$ir6n^tO_WJ)O!L}7+=MIn+> zLI{B42ryw?Croof--mP%fKP65o$65#;xmkKmV+~nEC1-uBjw_ ztKZ7=1MS&z&0C}+B{aK@|6y!CtD2XuKi983TQ722p0m{)EXpp4j6V$FQMt6xKRCln<|GQjSy(R}0;j8*XdAphP3c>B2l@P0(Ew6NfP z5U_1Cz@GxGy3BO|BUM!}8PY=m!x@)WRCfj-)a9OKxw9cXqWOM6uUFZLqg~el*{)IS7`Bh-Uo<1JF7Ho@TSpHlUku+5NA-x2lhI2XM~AKH8x9J;jDq z3AllL+0X}L1AV4RKp&oMi7gBu{xpzd264Kuw;Crr>|3<$ zLERX($r2LQ4s0*KW-rr2uV*#{r60EMj6RQrcZdp`+Y$$|N(qn~GY+26t5RrZY__e{?6Rb^rELv!k1-$yjfuVX$TNOnG5hxE+qvY92l94zqR*S z{8uw5MtJY4Jq3s`%S5DfPjU^{)yZK9p?G$q*i~q^=lNXnK<0Ut;y7WNr)o`MJ)3P< z`nIkWoT%R3;t!s4!n&R-XyTm1`Q(uz;c!f%1|cxdZE{~ltL4!^q#c(syihK-QzYT# zkcl>%;mcZ7a590Xny(=?H!R)U2vuD|(C~3VXrSLWWrq*-_axGHhP-Db1td?P29c9w z6bzx{VwJ^2eL$%p4M>QQ`Wpp~rHj1UTU0>A0^q32VjC_BFZK_6op6KIgc)sl2T&jZ zQTQ6zP%}ZWs|ZnRhHD<#WB`wjka&3_((A#43qZvXBLbMHMVNk(Ooi^#v4#nG>V;PIw3{U`bnZglGU=Hv;;`Z(n?my2sol3^S z?cF=vKiq?tMF5MrlF4BXH2~dvUxABaMOeBwy{JSg@S8IdCgkZIGEE>b9C^tpdD;E( z7J~kd<3)z>Qc#QCge2q{32>gX7r%F|Su=5qI2;eyL`CX@1G{3!=FL5xazc(7vnxbM z+#(oU-iv*k-rAGoj1|?B2RVXEuBx1Bo|)Kq1`~|#nGvSRG-V_ZIpj>(wzcl(GzFZ_ zH{eW|<_*1hz@fncB-3&h0}9OxQ){c5A!e+v$cv3q(k_7Exi(3NxsnJ{hg5Px)g>h) znb~BDac*R+*maT%UB+1BF|LG}Ytu!w(DWdmhah%ug4kOQL|3Pbgm)Ke?)URSSOHSC z){@^l?rMOG-*{+6}8&%k@Eidq;>0;+D_3kpVd4mZi*CJXQljP2>?H`<%CR<4%o1|M29;Pd*02byaKeTI&wP;MK;9)DwbxL`SW`pCg@Wq1dTenIni} zZ}-c&G+lJ&Fl7!!E@PgH0`>m>BXZ7|4~GK6E=$d5RP{wuk)q}`rJ4^(Sl8Axr$k8- zOI~&}vOglH1Z0MLN&Ze(YkZjn)?1^+*GGW7AHdv*(nWkHfc;1LPdkt`r1xQfuM}wh zz5MQ-0mE#z!8-#q9t_ZaZ(y+V0H{xfc(EPuv-#dVnli;&;k+AwPisx&&G*QV4@wxU z2DsPE4<%YGnG?|~AuMP*0+#bHE)4r@Gd8Y)@RoS6z^d)@d}`J?2C0wX+~fLSpw$s*_rvnT zKqg@apdWiuEg_Ni;1~26GZ@ICr+PT8+Q4q!R|RaUNhC?e-s)F462m z9EdUO5F>kbZDYoWwVGnHj&$XmM>DSQK-^d>H_g9L(Eor-#m!`{ztNuSJP<<;y-Me9 zAd8bhOIP&jCOtQ4Ac9HAjz<80Wk`!vh>J%moqLqFC%IG8bSw$AsxvZes<>^xq0V z_owui!;W#S$gQLVW1tQ0=mm)Bu20?5x5R5OS!UL;5$DT+IWiphrp|@#XgF z2OcB554GEQSx%Ve8+h-qt{%)Y!W7}$2Je%Em^;Y>DMJ=f0Q1s@u~-s@WjXkIr4({&jAk4e*dN9$FqRV%l7ZJ z01IdF#J*-lqNx}I0$q@Ll7!{H!f z`UdA6ra53;SGZKRORa^njuo;mI`$Qy<9XH8m)cqtwc;Bx@%0fQR4t*10CoWx9!_ke zrfMnGGC7iDf=ts?2r~h8{lV93p(-{qYHh&)W>;*o2*5N?l_$q-Ltwj=;&!T2;4)eh?wzZAb%d*sr zCMV5kn#ZJ;g=#OTWVrVpi{uT>hZ)PVR1}gqgHskURU2~{fC~X(nqOWod{GE`HhC*= zudm@{1I$kbM7@*GZw&AcAxkW1qQLK2ey;%DUkP}(4S?uufJg-(t+}>>>vW+c=@+l$_DqL-`$$donlAdS*M1ZDJL=+1!aFu(Hy zNu}$Z&2bCnTqSdD@NgYj)j%Fdi+O-tEf8;Qp1McWKvGy@!JBik_jD$6o`K%Phj+-0 z(Vl59@;UDi1~y;C26^Wla*nVcL~m6OZig@$b3m*uoK_h1OUp33 z9~h3o7?VvqB$B-+t;)m?$3h8)qny8ofzVQdVR)1@@`I9Vfld)Uv;Xa^cfml+_-dl@|?{2sb zKBB2{UQU2~X$Hs}rtwE6Kw5Wlv!?)3cn6oZ>i1rJxl{F(&za#Q>w}~t(EBE54pR^% z1|!7;nn*G<3z!i?@x?E{2Xa>BMgiRjz)S~t2_-Ec1|d+W9mvi(_z=oX86!3#OhS%i zR(mcdvg$=FVFGgY*wz`$GrXTMA0FYHUh*xriez2&mRAKw*A+wt_)`mt)JPI?(zzvF zkUUGbWona)8hSn#@HE9jDiDCYNrzq(!onK`i44&uu(bT%5F8pdno}WYoGMgge}u^4 z7%u}qH4j9y46cQhn)fzvluS20=Uj*uGPYGapm^=tf6CVCn(dXH%wPMxr)r~hZTZ@n zwo8;;*cqTr80ByFx7wwse{0rVeDT>I0e$Sr8@zyYxE6SRh1j^-mHCu^_Y*c3yI=-g z`hj;ASVWh$6oci}#d61M@fwJt*Za79GFBLzrQ=zEKqOFAcH{z1`bmP*J2s$KpumzL z3V2epR-p!DmQ11*pj!X|gjhgTzQHt=Xvg#U5zG0Elw#>%cMhl18SApbdyfSRZx&epzP}2FZWN(JATXjk z!X3WF9PiuEoEgp)a4h8n57${z*+5zKW%qUMU^ zRG;x35u)~*Danl7gP6fFwWTp%j?Xe4+uB6c7f`TMnk)zyl&H=Gx%)mSedn3RDPIT3 zy>81+s5uWx((XMQWv41K^2a5gwJ-LDu8dVv z2w1Qp=c3LFlgG9uP+Z`-sQya1))ad$J5Bl37FhYs0_&@MeKfOqeaifm0BheHLSQyP z?=5;8oe9la$_Y4T0TyRK{?&kRs_p!(*#;Yo9t~J%gN12_fN%!*y%A8)`sxGCI?IBT zaR5biJFEs(0n*bz7-a)cVyFs6fRzUVCK+Hh835Mqo2@joyM0D)Gc`Q{JO^CbBH3*` z%Q>**6YtQ!X*fS|AZlQMUOnb3`mk=>**;om>9(dFLTJo(uxgo+7_mgfj6T0141~x$ z5C(hw0~Xk~`(f2C4wqrs)6TKB>IELwYgI#OXL;9@J$cwxzN|_w6dogK;D_tOp2uYN zHxJj4{(Bt=L94vT!#0f;SZv76@f=(7Ee*$#uUtptb+(|XSS)$94r3k<=fx_v9Gc=| zRI2DUSw)BvCEi@q=V9M%j*MsP*B$(SJMs^cGVoyQ@ zCwFy94DVSinz?$lri9{y?TaEofdnmnozFmQ))K`^!dH28l<>QGb`|8%fYT5{wP?}z zWt~ap&c2c->$1S71RnytSYwsKJ7b=Y(#5%8o@ZIcjCCX293P7%O~NCj-Elay?1D|C2u z4n&iLEvBmz<4S_q4oeHbnyimzRbjva4Rf?znLq)Vw5v`hhxRwNvyHiQ4cZB5zTzA0 zTJW+01$fG~>M2_+1@x~HDT6(Pk^pL_afN*8C-ZxX4D%gN{ZFaol`yw;RzQNxJ}JnL zr{0&o-tKcZkT=hfCC>o_13*Eq0Tir7+D`>DUOEFdU<9XB{kH2fDCKhRm4WW^+P2r} z#qx{23;C&mZz&lYE-v7q4`v&-7T#(zLr5-8P9~UioQ`NWnTJ0Q*(-WTF|%}cc@^dTUGjOaI{bHY4L5)56e0-bZ^dyb0L4jqx1ky9+7 zL{)&i;IxR%#G3`yKMES8T96c_Maj${d5&eya7u0n(cm1F(l8a3204dqOPHocgs2r3 z5rJ|pqzQSr$<;a{OaYV#2@=%nB#a$YJC<|K7b-WcGq!cD>j0Wsb&@(FQ<-X{RIdGE z!I>&K0}!@##yl_b{DXlnB3S>%GWFuvy0+Q$ly$Z*RGKuJya8Qb9l5gZoTL_^Vwr~w z#HBSr$ak%(RRK|EFJ(|}B=IW%L2|AU9fkziU52%}s%jFhAUv76!e)il-)Km>)-T$* zt>1U1Hc;i7HjJ2O!-X}LW|g=`?(SM!85^%g>_|Wa}itxSe`%eHIJ zL!DDlySF*UmSMKjgtNPDt0dth$cD}n1TJ%$G1Nm!%n^k7iMq+U?MrhQ&Qf3A!utY^ zF&OKzblDwhVeH7k$uTFiEGQS8*lGgKJNV$RJS@m!>*sy>9U+0;gbX24!ep#gUukLe z%J0ANA75LQ(QKA%7yBccT}n0pa~-PDI+t(I0?rkb{vJ(*VY3cS27I)@U&f^clZLWu zMu^Ri2m&%%3!a3c{)i}cSW4VD1N3;akGj4yE)^2dY{OzI1q}kWo!Zgt`^i))W5_iO z=o-xL*Z|C^hL zwrOs-mbRU9lFc~9xKwzo1F^RZpN~X{s-?mXL1vY0S|Kd*-%lM zZeCXDnk)6q0_*D`Km=H3DU8*Zo+&ieHf69BusJ8>tU<|zz(^?8SiCU8B*Ez@6VDi9 z2|o|bo?;9eb>3I9LS@l9m(<^fIly^WgVD!gRr5az=uy-whr?0u%!n9Qgm4SyEcqaF z>D;W3fP*Ow+VN=M1DYk3wXM<)IPyX|NboonA8l5vh(T0Hk_W=#vt2tg)yg6`Irt?^ zwXAP5`5-bBr_Icd%5kqGM`}Lcb4ZBdDEsVDiYv?*JEy{h^E-Br1~U@uQz&f%W$Ks$S&abWArSFQMdFX){D7 zMhH%7gq9n^C^=tFRH`v1go(-|G8cjb0K8+cYbsH#HMCj7!Bra;00_Z@g5-)V8{Dy7 zez)vOsu@5tD1bv5gsJ8yF!R#5%N0K^POrz!v2bYNQ6OVtvWa+dsm~y_5F` zNwL*znINtkYv4@3AInq2&wW-|Mz8Q7Re_9sm<`m{XEcS-vOqcl++A8v>9}hxwg71Y zF&3F)Z7`12x{N{LQNGWnNkYJIZSdG5ZuHq}n;i7HpG{Usrv$ag4RyI{1_hQ``VNq- z3Kbz*Miu6s($!|=?GiI}J>19{YfJit&)+AXyHAbjTBE(96zGELUs7v~+u&YcdG*vj zzQhuf@pP@Bq?|TLTx93Hpz%Y95_nf^H?z4{iFykxH;FBtke+Tf1!A+>EIDRw$Q2=z z4YUC?OLkcRbH-sl!I8(qgM%Xv=L4pxWQ%2EUAXd!-~+-GkW#E9iC(iI_}ah|LckOf zqKimL#v~_AW}<*A%KyGre(F>*GxC>MVC8Wh`1bm9e>s$H;LihqtCm)3=k!6o|6o8# z1tU*rR$7F}qU&1GXM*T3XM|~zZqtZmIZ5|t zsJELlBOP=3JLzpq9>%u`eH7KuLCIOVCY7|v5{gbG*c_0_2U>?GIgockOe9f@SQ{;r zU}R1}j)pXe=4tK*;6EU5XQSYdK+}{eNSn9K5FrAFXt4GeY;LWts7=ai$hv?5R`J8O zle5}qq(-)A#)sNdknzb@?_rxiVu=_b2{^Glsi}ovGt0PRj)$kF2HCawqDCs|zG+^_ z?#)6i)zu8lyH?ZCBNnR_6Pk{fz24&URX`92D^Z32v#ayPGl zKh}GUkwG;KnZfA<3b!D4ko$E3+|i6tQLOT8+~5r63C>Npx$$65SfwRonkJ-_u&yh@ z6aYG6ixJ!AF(2mI86T!eNN86{)&(m%OLfT3~%C0rK|x`d&UvaKR%- zE|WG6$bLpnXQZ^j*I?Wvwkp0hI245yNqrD8yQvT+qWA=%+E$fy`4WUK)`O}NO(`J+ zSIG+^0*PHqwcDhkhO$;zsyxjpAqja{gV2-IJF}OZlSZJF?Jv_RW-1C%_F%Bo8Kw8D z5DHssfN-K3>#lWu#+DHDQkED)h%QG6onn<-5b`NCyG`bPn(L$|lMMmEJ&9F`5ElR+ z%X8f-GE5doQJ)OfRmQ@zS5)NQatp3BoK%v7YIa9QqFAM=B`&n7V<19W1BQ&eai@WT z`B_lklDyX9O3zcZ%yQH>oh0rX4A?yT7?)w|b)dXlKcp6~)^bd(ZoBq;t#(bOUdVr- z@sv!)ibGQZ?=@NMJ>$K5dRv~T9cYhesLM0=#A|D;7Z4E?o|tmQSZe6e?)3^W@@x>g z+Re%`i`A8Hgqg|nD5Qo!AK{2zJ!-?x6Ufb`QOh*a{-wNm*L*Y5DK$d{-Xhw-9Rc}0DWynNkZ$2pUiP^=)FIj0ir#6 zMa?jC>xd7lbg=}9H-v$;l(GbrRd@is#gP+`Hk<9y><|Ul-Eclf3$299tc*PT=bnIY zX@^AEv)ft*k_os}eDFX*jA7`!v%WZ-TRoOmec%VuD&W$L$lgy2dMmL!9G6i!qxb6p zm$q+lxQA@^M>5AO;!>UB%r$P0=Qdm?n(H$S>xt&?d56r}&?}rKbFM~p&$e^CCUd;) zwPE+&l6^`(sjBVWK*&reA?Dx9y{y?Fx{gl+;W3%(e>J4ZJ(@Da+Wh^(jLRPkS$%89 zs$ll}EZd^z{%r0as~CCN6X-Q{!)w3)_WFYoAP!~nP`cWr(~>;4=^a8$AZ&7aBKsm!@?d&U`J8vwkI3R?8oC~nwec6_rGvc~|#B#+shb?ZU zmNyd)?dF{4S%UU9>3p85_iUL{JERolZxPNFjxj}#$A?=Sjt}r|sgI7*owQcKffZm> zAV7!iOa_G9$^RgfjwzM8vkdztA4DfGMM08V7nk~MJG*><{9H(oBxQyMk!GXz05t%F zTv3$7t@#YAjg@}x%>-*ltu1F(DA_s}MkZK6gZ^!8HdRUE-iNP8;_ zxMxCE2hiooPSe+76{_@js=>|Lb6xw6+aWdfR3z7e&J7S&L4Yxz!@tmlQGMWF5Nz6i zXQ#9Cg_$7CT>R>pFMyrmA3X4Bc_CTp0S))glWR@<_IFf zwneeL+CZRc3*pGYlROoihYu6dwv~Jl@-OOW$jnHo{P#Lil9vAA6 zEDak7O~s+z$XaW3mYYh9tSh_{BROF`pD;}`*v%kc)Ustc6PSVX=@#>R!Zbaa=~^Bj zi!s<-)c__pEDK>#GlIA%$tW~{^PRoDJ@A~3Rg}~Vs67^Wc&5|S;Jd0o^*J56BQUD!(>`PE@VHmMjLWwTA)0Zg z%?_#(d4Zy%%C?kDx^oi@#Y3OC9x@Vb)HGY2$RRU6?M%NJ08S-c4hC!l`dkYI`4sT~h<%G5+5v^r0H7*xcM?GF4$ZpD<5Gd(a2XCg zss*M2q*h|YT7iwB$ol&keP}ohfbd9|xPc6LFu%3R6y5e2O(ijsB6%R2&Sv{uyHhxvxY^M6+MBN(QScIiR;I0(!NIANJdV)VA8ZLF=AR z=sWx|?9XULM%Y)rSR)^+Y={P+#{i-;dRwV!xR3Zr?6SWXl3A}Md~Arg+gYxX16Dn? z4c9t`W68t5gn=9!lO6Q?^Ugi0;p9BrBRr6l0lfley!L7MwDG?V>g_Gys&;BlZmJ!b zNdP-bbkMey{1231^t46HN5IVvF>XSDI82k5Z7lncvqw%7ygvf`P;ciFPRgY-lequ| zoXTXP%$@}VUt9NgGHMYQ>cpaCIt*@sYGajE9bs&;ksqGY|&Z4QTPbKJdb51kA%8A~s^OlKnl5GYYF zdW7pOwllde0E{`_Le5mH4o8J#gHi2$sp%NK^nlaq;nwPg5LQ3noH@@B%;F!tWVI?o4kdWf1 zou#YOA1dL+ITpaNlBg0I46%;GB34=soF7FAa)5IgA3kh2JreHkOQ*f_4#(pW#9S00 znya)$+}+(^ngTw`nCrd6GzFX;Axs|gJmGvkm(ENgc+QAoPej_a`NGqcb4L2Y43IYy ztgn#-p*H!*+;Sa2$Z&AnNa+$no8erjLaoidI0sw&Ivx#B0uA(Q2(+Qepo zs0z}DMw0X!)`mdle(ZIoIl?q+bRp0cST`zEuz9YlWt0bE`+b+;F)kyeD**T~J{v-1 ze5Tkj-v+O@z;NpFrtEXJWoR@Ts}{80p5qsx>oh!HI}0uJmY^nUyp_X8Kff z6g9f!f=~eARDhY_v2O1X!i1b7&gX~}W9b~7CXjQ*b`yY~0{rSRA0}jGEa#=D=A7nz z6oPx23iPyXWzOXD3gU#6V!gj#l!XJT&xO}!fFuo0e{;b4imHqjlDim^!5I+Gqje4+ zhM~wCpzSE%QD1tXS1?QlcvEoDg7pgK%?5Bz2HeXi-Ory4NNKHU)YirA0HQnqoe_O@ zg;flk2C&zH%QN}_@D07qlfLvgt?08B6r6lCAi6hz>lwX`js=6Q3cv#2c>uN30AiC_ zhY|(Wt}G42#MlXMKR+2TSGV8Va@ohO%`*sw=<;SCbp4Yd)mHO;pwG`xI3b{^eUbso zt?ilZwx7`l$d8}%KvMWCgo%Y-=ixV_DrmI78VRmwh&p?o{SG-|x5=tE^gbm+iX4V( zKB2eH+6K^bBm*%V`#2DMdd<(~_Y?X|j?se3Dt(l&SO>C-2ZGTJL}oT)(>yGHBtOWk zFYer*Ge(pB{_|-dxejLB8gpN!;XG|S#Fo9EjkdI^%ihbiuiK@{J9|uLGXt_pk2^FK zoEI|tpU8Howc!U@=STUwD)m&s_d%AUWFKUlJp=e>^4srZA07=U{rBeFMIlpuj$U!| z^y^DAK;A5{{v-rQ?ZJ~tts_EqC9F5YgLo<3lmY-W8;LU9w&Jn`Tocw^ z$goufGS6y>vDGfqo@ZUfx~g=kmbtME|B~-f)EdroUdn_d8~L<$Y1R(TNjf-lpXZ?g zq~xT_%1oYJ5Q`fgXNJw!ImM>HaHYGv1*OwYJ_dEZzich5Y_~cLQKIYb)F2j|P$C2^ zTvXLX7F4#@6c)6o#E1b+((?)lo#>WMPQILod-?zT1TkSk#z!Kd3(XuA6MNyOqqSCT zL&*EV?cVj$cYMHKco-x9{NmdpH4Aymw74_vEvm-z>2HF}=va zg$a3G3WrNnY~IYE$R_05UKhn;vt27WvpjlQzK8eCGAqWSTw*YisKNqK?FKIHJd!UW z%9A?Ib9t7qYwN3lzOE-zyjo?|nh5aDOCv}G(Nu|&7&Cm31`;s9?1q;xQM5dibGxR=SG@h;HiZyfg#Er^iiwwm);p7;(5pq5pKp=?dX?=(uG-V&$8P4w zUQ>hj%^po_hp_=;Z~fS@(cB{?cFsBHpV+Zi6;AFIO&;85hP^H+m<}@zDP!9r z4u=Cy52rfLJMU`#n3B&-9v&O=dO|+jz>Dh7x!2fjeyIi4+v{t4X(#Wm&|96{8^Cxm zz^Vq}sw(3HO2DpyuNl3)jWL%?Bxkm%8!mL}T zg$D!Vujo}G*$^9K2n-w49dW7naOgX`X&?j`mm%2G0PZWX;&5r#G!hH;`+4|W$(Fps zb&nYfR+(Ul0Uih{9*7!3AK0A@nP7>JjLVMtQ7L0>c#`?O8?G5k{#bzAcHLV*Z&cfa z0k|IXBu1r!CABQ^FxoJ&0n*cOz4>8%W6p)mO4%c^#%p^sWJ$uMJr)L1EbS0EZXo6` z+;g;ZZD;g(8^K%y)^beu*X!!qlncgM@(Dd6~auq!h)+dx9fEOdf$VsN=iM z@IzA@YO7w}0)09WPSRlsv6^VZS@HQ!?YA_5no5-DduNH3NdQuD5+wrp3E&8h!uwV$ ztkk+QiNGXafp&RT06>Tb8apq^_`zCNNVj45?>Mn=)r?J88#E6%lFiGrR^zFfJPEHx_oa*n(^~L#H(71 z@nuE_4FHP*1ln!4M}FAgcZUly$bO&b1tFx~UD}z9%QK+H1wb(>E%wNj5wN=;wERBk zMbrZX)xu>5lyPndxutfj@<2+$ELVw;dR&u`A_aitcq+DYZjui12*gJq+{%7$#R>}v z488y+*KGp9W_@~a%Js(sJf>+9k_l*B_*^JNNP@&fKB$;@g6VYRga zT_^x^W{~6hcLmHs@RHG>wFZHG3r5%9rqrTK_iXc0N}aWq))}<{L~08!O0Yu4l)9pH zLuWYgv?mZ!ns7eNINm(KyEAtCIypXs^Pyg-%Z;9Vgy zwC2d$@C<$-(5ju~IbQpAe3a%#t*eG3}ea5S8 zmI+b6XoxU5C8GiNb2l-K{e}D2LVb+8E7o35Dok-*9csxFHph#6&3hZ|tS;)yQQe6_ zJ#@{cOPhM!&M}~8-tR3>Ylyuji(T@^Jx!|ACZ>x6UQ`=y>c3wbZBl8*sP`o|WvbRC z=_Er?A#Rx;<=TFSdCmyIf#?H>9PU4V#55P8c#}r8X`1lin-6$=d=!#n!jC_G#^L5z zY!PEZTq6MGKpMZCs7-My#L2pDGW=1=)kCQpS`HIGc^$E-PncguT(!F7teu(ROHS=lutiOpryE_l*HSpr``1;mR=&13{v{ISlI> zZHp{v6bDjd1nL!J-F65e3%V=tYk_L32=K!;(Eu8cGU?BE+M&1q+5m9ElLCy0Cz`B?ovQKJ4`n zahY55+1w-RK)BgsjNu&faQv)NYP1b=J0uGaWFeceAmh>|Et`AXUhmsLpjmR}fJV^lv(;QDjLT4Sx^fL!(#CGHs^_elYZ|Vz8*_ZD zC7sVvI6ZX<&v6B{*Hf(n$jGh^YF_29ZzaoWWv?RhG9QglVv@h;U+~ zRV=RnlIR|yg8+zZ@;`#6TcZY+lCh?6Q?V@Sh!7M&kR`wrX0l60AgaU)n@6$QUzko$ zR3C2(DsUq=*gS@vFLRij%3^DnXfjg#GErva66k1cs}wL1@}x@lZH9Eyfw574^j{7zv^R=uHYF1r!eb)!)P7%ZHe00CjZ5n`xMpEnm5PSaG3LuSH zE+d#?0S#+IvIjC~m+!?g26Dx_mk*RgUC6mzmgazT+ma#>rmowPbFttnm5DE5#A;2G zoe3w-V95rl78jhsX_YZ$mVA)_xKM)8(^ zA8>j+;r4I<0r=sEA21(h{OlLsV*C4|;JdlIsa6zpCb1EmFwGO(!var*z{qiHHbRHz zAO0!l>i_+!NiNYF|M8W<5)2@vlGoMWvkWF*h!Bnv4sLREn2~dm=7DBA#jJ`Dv@9N? zX4wy_gmgv%XAfpqtx!tCgj#lribV3ql7yn`cBtAzgVDA2n5LzS zeu#4{@9k86j0!bMnEC)TQzvdZ=LptM68*k?73%@uM?c*v&fLgVdk>p3%)Vb5iE zjQdc@7s=9@=l$B~8o@EHi_Lt5(Q-c&8gi4=8kbR?%n_8;j#uxWC`}rnjX@Lucc@Jn z47Il_{iG~oEKtU|X`aC>xl8km%#8Et1m_$+eE5JL(~pSjhM)cXTR0+o|Jxt%?)@EN zD$juP<5C;p=6S-hMo?Nnju4jxxC6Fz9U#kV<(sv@df7|ApPAnru)d-f1NgTFP+x_h zcqc%Z^}1I8`CIf>QO?TRSH`wu>PXHgjG^g2WG`lQ+w;autV z%)@%deuYtiGm6t7^W&Y9k*@`SrO4I=OUWo1ZLYX zjIHDT+zVkns?t6H_!ly6FDUAd870r-@6l8o?*Xg?{F=+(ek0p(XO7jKJSTpGmc#Q& z{ub~W(e0ZB)>lJ-)EeHG4oFH2N3H+{A z@4aX20|3q_2y7Mz?NqY%b2h}nQ(@+Sned|ptkN)8)OG^u!3x{$>8^XGcd82>0IRVr zMa~h8M9Nbtk{y8KS<9`bKpP8g z?_32BUVHn$P66`vkpKFS2~~8I{wJznBsG4Uf~cubtu*zS@4Xkrs90Mu5Vw-`LEcr6 zOP}#EMtJscldE|km5^ps+s-zW@C5)?y7T+Ll_9eHaa~u0X{tnrk{!WZ|3#G&YrW%r zQH8V|5IY^wx?$;``8Bv$?{#lfV$>`zCB&G#183d4XIJP=lGt&Sifi0AEwSAU*LudI)A0tIQHKv8cnd0D570hx<6CK+dQy{DwP0{$p!JWr8k^8w#E-3ricP!EDDdC+dB{umh&0jd)(aI;QsR`q?qv+zx)e4K0M;{=X)HE z2OMr@kSEO31l%yTwFIX#JbVZUo^Z2%kCc8{k7It7rPb@|44v=3y}nKoMDKF7VaWyv zBmwGf0Q~pz^t&^F_d$O5BU;z6&4)M|pzpH*dR1968#3i!p76R&H}d-j^V^KpQJ)Nm zsOxq+q(?BIyMl>}0Z~_6sz2C(yb+gHLu1Fgh3Ib3hb}A7JR1Nr4dAOmP;iuW+rZ~& zK0lzh3){<|NCrU0Gq9mOW*L`OU)MshZ53pXfek)S=KRtCq>mO@9+w#{Fx!R!gdTH8 z4u%AnhR;Wft2}`9_V}e?U3xASvmKUF_5)dDE!D<+2)aUIoCo5Ec8Cv{WzldvM`cbP zzCYSPB|{MT;W&@hX;v{|l}mm&24migRg#&dmdEr_8v_s$LMoz>^VQ+GLM7|sS9&GozUCCEyMN9hV0SnFPigrGS}e; zGp4c-67xWW9E8O7<~si*+y4OIo`7?Sp2pvy<$U~yhCs@)?Ei@&g?=UE%VJ2-7qm|7 zj5aTMTD>Vi{`dq4M*NkGM7UU+)Q&XM1(liMxaEI1^kJYm<>vWErIRv?1i6Homro}@ zA&8%=b_=nCSW~!8QIb-Emkvg?x}ZR{V>HGHX0D;=$Qd!`;#Du5V`|T(-J}4+IWIxz zgq$r$>PR$1J2wO0>o6DHY(np$hD5j?o|z5)sw9xsZN z5Sl%Nbb_)C6%W2CN`~cYhhCk$%OtYvBJ~6&?f?uX0S3BBt=e%Ir30Cq3ri;0aL@~4 z#xyMw^-p4=%lj`s<;^`j?&&%3egF=LcXDc*FZiXs)fE84RuHg6!{E0~15mJMxkZCo zr*>psvCepkxOpz5yb?)Q0G3f9vIn40vvbi7UgiWORY$4hi+8SbB|OSSKzgs+b6f8f zDM83p>nrke0Uz>KJiEP=#yK{nnC1xN6-=(=43G!mGngO2`5k~;e0YC?_rS*=3CEjv zc=!HWJbeC$AAbCdpZ&}M`4OLvGv2+s!#y+dZ4s^B$p4>CXL#>$yt~8g=LbA2pK(4N zirpYy?z5f0Fa*8a?{5$LuLY7Q3d?3Umzm+6Lk=@inlK@PI3lxyXKFS*oQ2FU*NQe$ zXm>Dkmd71kDDO%KZw4_HOU~T#LY#NVIU#2<%8W*QXyzG9Si6!jT0>~gnYC$hzSSo# zMMI3R3@09!{f5?UxJ}1O!-OhNU9)tmbJso#h|{!kQS)ke!WJTuJn<{G=W z<4dcGP$$+ttqpW} zHcw<1gs0k2PxiUJYmIdI{!)25W`&vOv7?K8Fl&?b9AWTkB0}@Y68E*!SW7AQQ5zPh z&odco9Z`$Wkh=l*6j5M9<7vMc#y*Gj-0U<#kXL(N89zkL+!{Nmv$3kziGJpKwa&%$ zqsPHLbL^jO(s)5W8PVnRnI-Gbc{wJok;+Q4B?&9{Mtz!Az#l}dlmK2pLEy%`)iDEZ z2ImFHu~}?pfkT`x%^B%rj;9ABg0O-FQkf>g&wh5mr%wT=(<7$&h`V0uj!>0EI*0;jiiW_Tff#UxL^z=N$A5>` zHLt$=Gnx&H6VhYsSBnO$Jq&>If~G{VN)bPR#MTzahBm9t)aD3G2AH-0qE$Wk9U^I! z?GFR^INDYz>EX;ijmsV_g^q?O(d|E(?Tu!CrvY9ZkE2zq^}aiCr3V_u2PkvN!Z#F9bw9NBZ|%|7#R?di3<4REXy zbF+CL%z8$PsgYm+TD}5y2n!74L>_>Cd%iY9_Stj4nPYDiCcwVhWDFY5hE%lY#SPb+ zLvK4~$wZsuVwG5A&PCd}U-Qnj8L*#Q!sa}U`7L?4uhgO|3}ncoA!AfsVbx7%LrUg> zlp2!(VaS{t`Q7i$eer0Pt>nl@G<&RH56AjsNV3fk9hNxsW(=POBJ)Owj|nBW;|$<; z0RD!7zj_b+{Q?}UC ze}n9A5JdH0(kyECO>c3PD+!{1(j_#>UZxs=UM9o3{YQYJbXi*B zL%|6RGfkDK=pNJ6HcM0-JO~%H(>twg!bDWE0J2PQassEN*+IhV;${jKf56yQELzxHxTq&~n@15-!)2Axn=Ph#1J%$?muH^H|Zn#1x)aS|m5 zY>ovD!JIZD(p=9rjFjBYWWRPjHll;ITXLMM?pahh>N`43cso7g?c7c7sZG9h@}=8r z^GIF{gpdtq?E#9;bKG4AD0Yd94*2Ma7NQr1ml_g;M%BpPSFibG@7QZSsrsNxfCa`< z>msXU04y#8*L?*ey8E>wVtjvpyQu&VX_XLk>9UOHVni%K#n4-sU5~Hvk;~#~}V5%)dkABaX*!5Plx9uBEr)c=HQiDF=>L1%{b%3e zZ~p$D<8S{a;^F>#oK9!__Se71`E_WyI+66ewBdl92O(9ZDFAHKdxZ1|A3t4y@FiD_Ycqt7 zl{FxjeS1D?zM}*DFGvu0UnT6l+i=;yL#~byb%b1q!GB8F(%K$v&I+p2J#i z5P@Ud2cgGs_vhZ%SAL4F`6HPrdThZXa<` zg<5{!G@7)A3cZ%_r1FyX?69ucW?GVDM`5bRmAt=BWY~IfB+Tr2+^ZwHwntwrS-V;n zc$w1F5P0+o`>dS@sN|kxP;R7zDnQ!4iFynywx0~7O~_?!dMZs5(78yCU@QQu^3=8b z5X}doNz@<&;4{2C!yh*=CLC{W@a`R9SrX1?;O^bG_~zSN8l>z+LfXDX+bhIF@b|-&}X15d3=Q6xE z0Hy7^CIQeGt@Bvzp{#9=B?Kn)N(}A5{zgE$Y`{V?Amt3;d$i8*MS!{^`T*ivG#jrQ z`TNNL#z)y^k3NXo5&#Lk>S8j$=dD?$DiKuKVu_24-g-;_uBwj*d9NMaN3(q^dK({` z*2@k{4QE65fJM|HuRg%Ou1( z59d9ys3WR$&K2M3%qv{ZGT|x{|KQ5)T!4l`X!Lm?&6B$V3Yn2nzMm4{gRjA%Q5Ibr|;x52$14%6it%@ckVT|{)&F*MmPV0iKii8?Q%GbdKM(=fh}jl3ibZo7Zw#Z)b|Mj3mV$2z>nS!A=C^HP7&nA`ijti26EL4yaQ2Eu0%&6 zH`sJ{>p3bQ6X&gr2jRAb7^ib3Cz)m15=lJvQEYbdir|6i{fxL~toOgi-OYam(2N{c z{Or3=c=zs~;?t*};qU(D*9alt``>+!!(qY?-+#p8!wHX%=L!y=&u7f@jJtPt;QQ~N zfBx`gR@950TW_GmSIB{g;gjfDmkL|}MicE~Bh(IlB&z%-E|Aufp!OH^25MC*dI zi9M&+95AuaWQUYW@maFWyacABDmRJ>veY4h#tfcVh>tQ= zJsc+E8feFmGSy;!z@-^O$7DA}2YIPCY);sYBE#l{RZC818Z?HAkcIf5Aa;Ak&6}gK zvXZe;Y_5u^&NeWpdP5pC9L&F6v%Knv3u7UfhQlowXC5ywVgr6Z{#wL`#XMU?oTw6zO>!@{sDu=ax?Xo&d!@{Ukey;AQ z=au$}lp(|AxfKwq7PC&8;~>@5<@{j8)Q6^2%Fj81x;O`qtDrpKa=_Q zVE{LETWvt}Y=Cw3%&=gz&CSrR?Ztq!H|F@N@i@?Dd|1-~n@uu;%a#Dh0|^lZVkr#7 zh!SFYEDq>9_0@(-$pLFWmJL`Qht?9i4Hh`QLsJ)6Kz!N(f^CPtRlZmifG%e*6U7>w zE<40c+#xRPb#pM=lb#?oEK#WURUFQr?!P4qMpcM6gqYnAhu+G`o`Y*+f&I51NEVyb z;)naus)vrlb!6{PZ??y(hq4(Htct^`D{LM|7|0txkUSHbLL%*uTB9OF@56f;pKgcW zSOUy$%Qo!WIuI!KIO@~>!4NMe^lF+kY;Q8xz(I(SNA#9oj7zmtFxSP=EH9u1o<9ij z@fpCsCg42-|GRI1{|y89zX9xl|BGDn|8Ep4tfM*pdY=CofFDpSvVH{cBlqOn^Qd}$ z1({*5#JATUl>lk3D}ARtQ31+)N+pa_hCVi(Ud&m-Nriw&0upcwLoaSjxqxmCV(EbD zy`5qzU2)P)RjjcHE+)8%kdm>+l1Z?3acW32<9erNP6<&}1kPcSF!beo#^HEG2$R_t zz_~)sv_~1*P)n;!oP-Erqvn7g)E*`jy(&VuT4Rx$N=R`MYb`f5qND4wG((&7KwJdC zP`SRy-?{klRs~8o+4K$Qu;4^a0-}4d^+~-YRnCSqak729pARwwnd@Y)PB!f_Yey%V z^_HESS|VmI^w9)zvke9xC-=cOvPD6aoaLD9+|gPu{M}(Cjl& zpFPrbUB0ZFb8q6@5gX5hoYzUc1uVuO_C4Zb42pfpWS(&8b4`X^x_Z1#w=DHJAQ*{? z=U&bYIVk%U8oR`Xy=I?4@>%aO=L;B9@5^H0Q;$#1eFr$X0yv~q*5zA3u#PAGP{s}y zigF}Nz^{%e;Z6X%Gr*zvb|VScQ7o{K9%LD!Hpwd(--BreP6?DhV&489X$wfd`!}F) zhxuRp=eWH+;pX;V;Cy-jIYwL~wspgNm~nG=gX8gl?^DLQtT=vq#NqZ9^E|&O(kpWk z8=}{BKeX`Gx7Szm(#D0f728Ttawc*>+K~ML8{Q-22YHT*(ucD&H22KBOHf7#Q&pqn zO!7#}U<8nnOqXR_f;t#=sDu(bv~KQhKn=z^53M$-1Z!@}w44MG9s2Gn5t~INQY@)z zlLh(q+;EbK=Joq9cARQ?=D}%g&vgtcIaQu=-g(Yz##ri!2@7KDG7x)}swK$lGXz~I zdd(nF<*8hw_Blplz`Fh06|!C5*BZ#$gbmNV+R%{ri}N+Q?H!-{j-VPA(&ze@gsAT| zdDtwBy4q(N8g`l<$egEYH@2tp;#$WySccOX$iCRmIV#N)RcXVLU6!P-2r@S4J>|jt zDC3JSwGJNTUc5!-jKlE}#4F~#`SCO6JHQ|B@K^tI#$W%t z2|xS!&+*$|{~EWqH+Z;z#PK+Tyeo}uVMa#D7I`U|kzQG6XkC)|rC#1|uRn`21I-3Y zEvgi>|13b_KSxoCtOMA)8Y0Bz7pQ&JogqbT(3BWDOMizxD*%Q7uqusc2oi6IjmH6K zv;eI>J?DYEhz7iS7(h+816+IAP9BH}3*a8m+x#5Na&;TuquJl+cCF~`nO5{+*@|e) zvb|^YUHsPeXv`Y0XiPT8WrPr)3O}FF+ZEaM*kxKGh|D%yWy~JYVYRzXpJbLBFI{Y>ouvkeD3&cO7&u-yLW4H4neoDt+{4TGDg0WXT|@_1HXyD@}kn=OBEn*7Fd6L0>s>H&Us`ilW?jXlSS~v zocjPFZUN_7V3wjWcV5}0?)R0X&?i*RTtiNC&PZE=53R#8#xm@wbwrG@VA>&6n=J3W z1fKTRTK4~y7*TagB7zS=6etPXw&8H_@V*SEF=qHc@V)>Mj9CDR@`zVJrjQ_H?5nui z+LR7TYC(fF`@@8n4u1b^3v&y8stI+IAPm5$OlWek>Z+Dmdtf(tQGXR&b8R7K<62P9V<3_D;Jxb1Y${VvEITkuK0stSk6b@<9|3jgQ5u!Ugd53B;u| zVi2(Y3i$ddC`wZyD3*m0g0CUvS#!*gYpA+YwWQ5G(|}}Z znI!WxRVytUt{&qi^~;2mimg{^;usylCHun=8AL(Ijjh;YRT3l$$Z3=yLV6S;CDuBF z{9ILHosy)o0(2$IgZoSm40b?D=xZKHX3!*cPm<}5pFmDQRFj>=VzH^jh}I2NE$h0F z@(Wd{B|^Na-qIy`Zb@_Ln6HWf1;Q0HuS!*uBVz3i_db6rT>GU@8LuSBKj=;Ohh3K2 zSC(4aPHWj&^S!Q+H?{tm-*8Cu36|-MTn?a^H!R-suk9`<&JRz z^X~vUfZRJUZ{YOr0A7)k^j5CFLCysZ=JW4Ch@j~`^7*%jr{5vH|AfE#tA7Q~AMp2o zcfhtSNcnqAQ$Uyk=4pZt9_!&-y!-AKT|?eeH5^ML$`>g>nBVx1ukfX6gJ1wS4e@>) zNP)!=0UKJE@s0fUVF3Enx@iQrt>3>^{*MMwen7K4nhoH8He^6V-_31s}1AAOE zS}60Q0kihPt4Hf4n+xKw-~B%gwnxiAIweCOtmr%PX}AWG0l@RlIm?EK z$U9_@J&*Q$`C<8of&8F>Xt8PvXSQFzdyDJVP+_%5NkrUBdlQ}~S?!jJe zhX5MMuSfKmIWXsV8HhhSX6Q9cW?ZoZ+4vs@GDt1;@^B2-fq21iUmnq`jO=xo2SSky zp|qK8KcKh#vMQ;A`K^+be=W!9XDB%yA9akRk^%BRUI3SXUjq0~<#_x8z~9Qa_FjJb zEsCP#PJaKN0Qk?Te4d`eioVzaD?YpQx7R-)0a8v~100=9MluB?e*>R4Ft5ll7uzZ{ z3#yR>VQ|guDEKvt^AUqwhGQq#He=gj3ptMoo0ldUrM>l>gxqyjvk+9$&V-Z`!cYc_LLqG*3w0!A}R=+}>cGCp^AnJWvw- z%>wHm#fuf(tG6tPvLhk;85~_zlR9!>q6>n|s#VMLyb&SHfXj?beRi1E7LLofuUTS* zVxgH*nF3D&-shY_pcZsqp7fy58Y?UbV#yf0uPH@1=Me%Sr9y;w?}s7AitEdCp?K|2 zU@nSJ-m}Fbhl}1nwZEg1C!(F(pdh(lOU(xk_|zZECV8=xUkC8szgQio<^mr zeFlvH`ct1@308lV;$Tlv`iCCtXq;aElw}#Bs*+PyEdyx=y0RU6&)U&$%2PkX*7H#7 zoG0px+T7#!H{Yssx=o`$EYb1QvW)WiqS>Rf%XKfe%rd||$5U0Ak_M%lx&mnhXlqK; zcovdX6d{Nu{2W1Y;}Wnvh|=|@kSP2BrcgrEX#;OR%JL>)JAv1~Ewzfj0k5Bs@Baog ze*mWw_{YCNzW+V^FaHOibVkk|?>~G8ayRgS@Z!c){r71AE3*L`ZKZIFzI%H@A6{)knAHkQNuV+7L;=(r176=6SWW@`59T<1 zMhj2&W|;~mpU^C&)&X?azbUAz+i@q`vJ9X6t!Lo=mV*p zAs9CF*(DghZ$a`2y()tT0>xfic0VjZMuwPkhJXqKxNg^Pi4XgkJqIiLKzV0KI}N|y z%(2?ccC7=X8wV1{9v2$Ga2tl622yU!49NpYM8o>66_eW`h1@`T?HyZdBS*tA9mBQL z@ZQ>O?J1J%J-C=_#~u%BVYLqA#JFs`uVn6}h)b2nVu*}@%V2jutixWz<8@{4DNA13 z@99`-ExiurZ_bPz!R$vwubdfe;|}IpRhzG5$m=i=9iPqq{7%;YS3-KcN3kZ==A7d_`uKWlUuzB6|2*(y$op|7 z^>g2(+q^-mfP*~)9I)EZz;ve~K6Mn|T|fWo^_3++cy3_vZr^9p?TktZC6qN1ik`e%d%j# zS`~$eb@>G)PhyN%<~e7dc=Za8Glszf+!+%3(C5MjQ1)QLKnOi^aYiOrau;Er!$cygJ zKnjLlF+x7zO~v9}_k_JiH|k*K>L=6}Cl8Ce6G?LP`P+z#+m?45=+XZzrqc?L8!WVn z=X(<#8uA>U#6^vylDMykCnTT|Y&7_8jhf zLHyFM!#(&Af?pxx93gD6*={h71GZ-yy#1vonVc2@c26-Kt`Sqh*>;PoI|Lvn3MX|h z6@vci^u#TB7AhQNqF6MwD1B zbJMs+IUw1qt*d)(sTrgX9PgiLUrr#_0dDX|wp%WcV#B+d8*;<%`83|)+3)T^3KUz) z(;t>7D!T46ylx;in&&wa0`D+$ z1#7x~mI>B0BR%^(+%N*pBds4G_zl*p4Tj+H;L$^T>5Fdz4u~niBVj8$#3f?29ub0n z;r;QlA?Pt>HR7w+d*|fl45K7}8lc0-%3&Vfjf z9&kMni7+B}757;P2AFf8_!WV#3jv$40}weJ`Kgiz7A8Cq2(%&KIS?pn_DKwK?g?-N zjukB+B!ofz9&lxll|k`9M2{&c4Der}TJ6jo;c=kj2qGX3IPRWz-8PMAl{y^!KBCPp z@kDz0?&lP^->W?YZ5Vv7R7oAN)8}7s#)|8}a|Ww95LyU4b{!CJ1j0n;=Mrt8b|iwT zw_amXA<^=9|JWj|R|hZvKI2%Skh;HdzRUUqN#MKx$Br0TPMo9E5hrULt1I|v+lQCi zaR_LYOtJgUov#3C1?yYc* zNR}R%Zd9Nl)rq|Ur3I*fc2c4~E*kJ45s9Ln(ALFhU_;3V(Js+A)l2}uxZo?f&$~(Px%6sVEos8~|>vp$y zB+6aKe`$kd?*b;=>2o(6j~9UrB9LXNs-pttN&PpZ0R}DjHc#ShFkCX2r^JXW)^Y&i z0&#P*&-tD;yDWh$Gu*JMe~Sw+ZYw~TmfT@keD@O^rhM3a9+z?^h-Bg;O_1q%9yj8I zH0^+^Cy;rUyW*ceN4mbs9oy3Z7zem<4cUJYF`OYhK487RK#IWm*%{8y&oGVy4u>g& z5f9$P`Gdz8#uefn{naf{I(OXP2TywCN%0=#K$;nd*<_|9aB)WRD@e*&I7uW=Uc9Fq z5DQO7m(!8Q*O(&Q5Ri}{K^n^{d(KBy8;s+qw^hXK9EKs3Y!Dw4#?=UTAW;sP?E{lA zcKz~aVvO)1l6N5>5Bi{4L`w%suhgDP8w@Bq`^&jUZ;oz66#j*U?e(_vdTKv(F+u{Ccd< zuORL*!Z6~|qenQuILqT_jM&8i^Th|SJ=^BF<4vmF8)}WazRI}%3YU1#y%-E-5@05< zbxmXjhbzC043cr@vw?0~1s?aP9oUxpVRl(_x2~lFl})YiTnP_*?G>bBke+Q~I45JG zWo?MiV2d6jLe5b`-;F@ILiO`EDLE`l>xC#7IEU)(gE^; zfc}9%_cPRv`rb;aC)rrF5j)VF)fV=*J$-KK?gb9p_Gp8yPuZoh)fN+UzB^9~+Pn)U zC^$U4e}!(p`;ui%+t15jg$=r6&F7VKX&C(X1m5q_hHW2cnThtvbp%o0`_BYaIzeo( z^%~nnaY#I?K5RVMbjOvmJES{@Y#nyfIkY?!BNi`EGk;u1teC=$iPaU2Np_6ZbYG09 z8Az3c|x#WVYM1D$AnASVE+7XVZ9o#Uaz&=@-EoY4c2nEUM{cfDc*apDmQJ$ zb;&TClLVh;_;f&mgJH2YE@UDjKd*AvFB;oR@2d*cJ4aa@4#P0y-!(f-L~^c}cNo3Z z05eh&Y`1HK5HL@3CQf{S^G=m2{%A%PLe39KT8oV1m>Voa5aR;p#xhNt=Ls<`*lb6= z#mz)vp{ahIMmv|MJDK=c3Lyf@_sB}n*s)8k_&-}}>2jR@NEen~#!AgHi&IsSA~g*Y zw){NKjTuyB%T6B(0GijwyiV96gT= z6d$_ghZ5?o^~ey4>bcY!MX{1j*Ac+cbK_<*(7=4lC;1B9jaH8=sf?-bDzV%XA4~#7 z0g~2@!5>W=bDhCThFC45oNJpCinZiTBtp8Cgeb?NsG3mK7lD*K{du-*7+$9?Lzbo1 z4OSO;f7+V1Si;pT8`oQ{agAU#Z(Q{J1qd|sJPes|OgSeI(egZmvU@VgDaqy;I;TmC zHV-8YWVhh-4CP2%faL4=<2)RE9t+Zx=Q_&-nVyyWAvx?Imro({RO}EjO-N6lz(0Be z4heGkX{629U>s)*gTpkc2k%g;YrT+O)&fgjMS#4A zDh$i?I3pnB|EJ(rBH;Kyz$E8nY|&ct7#O-k?PNCs!9tp!6NqiVgqS!rOJ#}p~3sS5CL&PL|sCgKf%^? z6P-8CL*e7^XguJ264z~;>yBTa*>Rw2l7r5HV|y+^s}u`pm0b&scjoT&OyA$4X2UFW ze?}sZ?B3fF;W~C@?KzIsJbay)#nsr2-S+#s8&g*Lx6kYS`MUmm9>Aw)TfU_0c~jT_ zR7sKt`twxBZ(AO3{~Fca?+iH~WC!4*dxdhtoe&MYvcP(;5FoA_0=2uy`+y{>rVv2j zkpu{!PCHk*Rx1=UF(PnI;6_JXo1%EWAPnxv(>oI#IbQ@0r4w^Ctl*qSj0w}UU>rjM zD}n@sArzv+mK7)?B*utw7#d>4nO#Lnh;C65#3RNT)3nDhj7Vw3G>u5fV;Bzzexk>~ z+)UsFb@K1rn5E;!{%I(2wW;uVa-iMg&W}3z&%>Q_K7$mg%M3|#Rb<$NrT`0Mb>)@s zjtga#&^c~8I#oqcg3+N0679nDI-!ZN=8p_srS+yZ$=5({9aI4a1&kWS4{FdjlORQ| z?+X~wBH^zS>%wxHnlQ}=^O>Te?|fF{fb0O?DT0T{w3Oanb`2FGkq&BiH-mb(&Q~4`y&jr|ZI3fedJbq2`<@ zXi>YErBQQKuugxU4%L<=sfCt{^?ooVRZI{}3S?wkitNFkNslrbTexXbQpD@B7ni#+ z(*lGwWcM6$xXk-DtRVYq$o^8*CIe(%kal|@D(E5$WdB6{z_+-#Sn&3jvTa~W3DY4@ zJ67WeObg;XO^YcLJ4z_W1U5<|P&HC<4@{44vOy}SRQITp==UqpN5G6wFezax^Q_;Z<># zmYfNOtT=U=Cj=!fGb%6_#Yg_ULZ9;`upF`_Cp_eE1vy;hMlJv{O*-ZDKo}68y_M}( z!x`XbKz10$O(v`71y`5XSe6+H9y09U>@`D zLk`J3*C*_Oz&^_b8T9=?;AcP%7r*F0TeEO7Ab&$3ML;4Ta_Www?f7*TN`WMn=;I=GJf z;L$gn0I&KSf&-5Gq}ld~l@$zD_jwa5v{pZ>jtJ=UGkiyk*m3q9xnUr=KLP5q8p*1- zD?Q(5XhXHxwg}yFQU}IEJJMyOV>Qr^N2}nl>-M28YmYXAXQpu>(0vs;BF}Z> zgxxC_L~M8(UqHu&)mQx8p}THJWL3sS$Kj~kc}B$X`}O-@p!J{8vES$z`?j9bx0Ez7 zC7kW621IP&=Sz5rkfgZ_ zB~)3854ZQVi-;W2la*X5`2%r@kRVu(Yt8ORjh#jBqdx7qGBQL?1!L3QGe^-71qBNlUF_ zMIca5JlkD`5l9i~`4h-~m-lmCAcrZFACkc&nHON5 zAlJ{~AAAUqOI+{13gc?T+4&iU5Y(Y2kJrO$gVlPCfBDH@$JOOk?$W=*wIg>82i2YgMT^(>l;~TDyq3>B1T-O5=r`er9DGuSJQI3{viQTZCc8dObsmh&-y= zl;MKhPgrxtZg_SSfBvOfU2G8wrjr+cA+>&m+4nz{&^6WlEh};>uliO>lFZT_3^o%t7 zTT;v>vKdQKgU;`2aoH0KT>n|!z{i;zPZH(82!LtHfc`2pM1;^a($oMkR%M3mGv~s* z;5lNoO>M-{W5IPtA`2`BRaGyw;UdxJnbcs*HJm^Oc{`}NqSi;G*hzJ)km*qCmJlGz zr1ze0?$LY<6ml@7iex2TAwG?ii?e1F`kFiFhuk2A1WZ?I$-4q%2bnIlX=2buj4R0g zI$M7(3uM37+QjF^uDAgEJ>2>G;D!;B9Q=5JAzWp3zxSBt8RHOu)dp+-2+yBA!*6}= z^LdQE!~J1(#$E5E{Q3B<5sLFEMu;4U*x+1`XUJI_ zXY@A%C?C-Bw?vTGooS%&YYN6q1e%X%JI>EKBIkmDmd@SpAWRAQ`%2`nrBTqV4|Q5rG3DZ6>s;lh6V9BU)P^vo$hx4TI*(4I~M-hj57Z zsv|8t+F*Ix{sRHEW-r9VheMm8l4x6ITJ}Uh^3Va@ARxOBf#z;_*AZ-qh<-xzm3l za*as_1N3_!kn>lV3~(oiDe1_m32hFGZFeLxj@34yBUm`|qd%qw+)v%U^EJh43EnTx zL7}$ZhE(8lyX^=<5HWN~#K<+W65wG6)?cIAKN-$Gio= zKT@*gZovA>T42Rj5Ad%z0fIKf*f}{0HI9CTVcEkYX#;bDz!w`V7d!=mie`lPb`qhA zhdfX41voI0BHLxviIAk+MVSc>;!hngR0BZv)QcBr& zL%W$xsbb%Yscn_6xMA5fP1}nF~@*3 znImM@aZ1mrOf%&A8Q>h!@*Jz-OBlxwVjM<9wIvyb0da|l(++U2<0D`F5q$2G+dGAa zvNgqhEwEkz6Yni(!t$-S?r`40i9<|ERO>S>L6so|@Iy;Hn6+iBc9}V2BWBA@bC)UV zko+<#9!+D3rogL;;t>cKC zNjDQ;DFIUrj8{grtG8NWBVYIaWbIUHAzyO_#W}U7a>t~WWqCOdjB6!562vPBVnm2% zd(iH>h!Y^xoV6UXGRy~Hy3DGru*u6<1hRh$#9bagV$L;_IOlCyW+g!)Fi)MmoPq#8 ztKHr5B`o+o7+0?$rGJSe39r5W7|-8+8!pZeB)s{)_u*^4_QQ8^z$(Pk%UEE&Qh>a7 zUIykqS5VA=$Y-e5JC=oTu0WRoh!0S0qpTy^z(NE3wrFz{_NZ1WLQm}t0e=>(eTBBO zen4xN!=cCwhTCj@raRO+(9dXHGZ81Yubd$w1VBDP4gX~@^?~lAfvsy)Wy(b0_Cx^V zBigr9`4=4G>wOZ1G^q3IxY;r z8_>;`W#b7F#0ayYJ2t(w7oTGeXlFG9hm>;|JXiYV=$=DI{%{|Cf4n?hw^m^@NilT9j;TL(bT8Sx_?(tK;aC;I zR%~;3PfTbn;+U{u^iGJ#;QatvbyT0ywIY%NG^4On|+@ZZ3BRhbYrTKwRPt$LG3HDqGA|F_2>>b+it2lL#7XzqFz~-9JVKl8 zp(II}%vcr&XK+Xj8q79I6#2xUK_NL(C0(3QkU39mPboMUr1HvHP-wfalRalv69!zM z>w3TM3de~0ry?y^qrZtl2zw_oW60L1Sc%faH!u;x0KX8h z;O@{#s-fhrx3l17+(euZLDr~B%xbHUZUE+`1;Rv05cZ2V#YPXPq)dV~%Z$ATe0QHM zOC>TzGAIy+a$ib}HH00eQc=J{$72_l+~p}L6BUpgX6k$nIiII;4AWtkZ71xSUlz#a zWj^#v%3ko2DsY$t*ld8+2rPR@oH49(JY*-Lq5&E1OMtw3 zz2{zG7+Qjz$Phr_BqBKv52uaR)ymQcj9gpfnqe5+(e)rkoJfiIEo6s?APl+T0W%QB zP%=Rz3S2?){r1o0qRU4MG zbeFNnXETEGQ@e@ ze7vNaaf`H{?e5)Ua;zhz__;rum1?d{#R0G;F5i+XODcJX_akK9XZ6Q0LY4*aE~~w4 z`<&Wl76q9qQe6O+xz+`40A}r%bejLM?16a5`GlpG+Cfsx+mKRGf0Xl)n#m%tS^?`p zkMn>e8>BQK#i-AWeC+qT2^+uQu-Ra<-D0&`>zwg!O67YKAn8?|@m_k_RN_j(EmLc3 z38+n|3JL=wx2XQ_7EEp4_ty%DS%CI|fZqjM9M^9-kwMc zw(nt*!0c((sMa?I;u?s)bYQ%Jp#u>NgYM50)GUv10<>nO$G#&NW~xpm99uqx6Z^t& zAlno<0xnJcGSG4E^Bod_$_B*l&}M$v{&=*xFoC|ar(@6H>P(*SBi;FC@)HF1 zvr5LFD2e(k8s8l+CM!JJv_RL*3L=q~Lf3lih{u^oUnc$fed4?mR(H6LL~?X3cp`k! zZQn@ibVOcoHqah5D`lg^gpH$L)X%*}*Q=2uHtsz{R&9I%Rl)Z}@0Ur(;#u#t&+B*2 z=vncI2=nKt8A+d|>*H<&$SVu1_Y48T1)tL(akl75&Lf5mf?s7dgb1AGRag+I0T`l| z3o)W7G|F;BG7hf+gKS%6Dv)8!#79!_(1#q#yG%3Odd%UZ%Yp%!^zcfOEX#t`devBE zz=$2!cAW}&bel3H&pq-GZ2w-CS+UO*cGqVZh6BdcwRXb~EqCk8Gk0Hq*WkbA@!fYs zy3B(X^2u3ux(-7^#EK&ksUTZorMYH>*u>msq^CfThVVE+81#TdKLKZJpc7=0eb*5w z(S$!YY7?9$NlsW+nH|NEmv{fY6O)^pR2nY0+R1nm&~cjzgeJScZRAHkaer5Scg;9n zw!i5LMfPu&YNb`jgN{~H`zwU1_Ck`JL4upXAX7>-wpgcpzB$DF#CaD|1eFd<`n($k z>I$vfS=Lubi3+Iju`7Uv1(F*eV(TgQ0;(dVAzo4$iv?K$<;;^FxocT`4R4oug3O0{ zO-@rD%M5^kL)u-}ZqJk;yFG9?WXrQ@(T-ZF*Jw zz&Ped&1$oO-vJko9s@q)T!lL@?-^u>FJ*!C3YvItz3OwA>?+~lLqJSqQ5L+|b1b{!T)r*jbTjbfyJH$UsM6{p&7E?LE7Y7y$T4Zt*BUoI*I%%0_WbH!_AKI{ z?j{SQBOh3O6W`h7rzkdrYI&e(Q0RMKj`o>}iBrGhmP3-d_33j2mgA$gTBUZd8sWs} zZ~Wa zG0D;sf5i?{m8LOi4w)+8=DFVE-a{-DT}cQa75gz@$TdRLCM;n>&lM$x$W$6CvfA~! zs>7B;UWScDY0kAt31dfAeaweSpl~*yvkbxnJbebYJqO0DDwFUSNzM`D6AWR%5CZ0D z*7=;r#rYY=)dtU>U*0JXjO&bhSzyIfo*l1V?|~WvZCA2^ZUbt^uvrELdg21r0>^;4 zHIWEU2`o5K%M%(Q^&^8acej|qF zZu=R?1%do*!?dFFV}#L)h$1`p6A?LfeXxaEMB5cV6Yy(Q*^d4lIxw06U<0$cDl@>> zY=(LZu7S3JL+N(}Htz{+Pu>6ddSZK{K1|%oJmJ`Cs}BfATS+%0M7n_-V#1EiBUat#*g7iGwKLMXN7@%|eOh(b zG?Sj7|8sT;h+uIY?9Pf1CT-Sw-LSGvh)^BTDtmB(7-I4-q783na@f&#S%njc44Cjf584V|4#Nl^1|>$K z7TJM-j9k#q^BOUZ*lgcM@N@RV1qY4$8aysZ?|!w3Fv|)b^mhh5)gxQ#jgSLDT@5EJ zKE3&oCh?#?-|XS7ldagtTcLuf-X?+u7RuytP0CJ;E#>?h)Eb&&r=U&T`8*DG`bCdQ@a9*A#(17^1*1D{F zpFiKf-~29B3E^(Rr&21oTP8t#N1PB5P}ZGlIJ$yBMx@;IEI8`oWDBVp0?-m8r#d{% z_nNY23i(k6!W%k5nRww&MkYoK0Ap`*CLDV}&bhj~PO`!ykP~l=IR(UJ)ftvqvpNpI zFl10>nOk+;bjY8HOV0VQ?Me|yf;2C{v=k7lfOKlt;a__U=VA^*4;u{bElkr2-Z^Zx zYb-Hh90R7qgpfSWE*{{wKKFS%d-l9!FT4n3NSeoR?-2A?Dv41;g-#LO=0RF+a}t9DGDSk93tMNyZQz*Mypnn-L;{X__#O zW790cmRLrB_(a{?^qQs}fDJ+z6}04ORyr!CYyKBa+hjQ&gp1wg=?93E+_2Ch3%5c_ zxh}BmpwsyuAvYAHC3k`!MQiq?gS6yv0j7IPA$P|I;|m9jsvzZNj3bLo(cFVu)C}Mo_&IfPXso(t#ip}Dlc4B=59yg5>{pXETVxq); zxA%Qj>@)-h*MO|r1RzVc=skHE-INo?HE3V+#dx#2`DD<2SMx}BT)CzJPFsiO$xll( z>h_l!R1zI$Q;wsIX+>FXA?VHl!>Gq{fs>$3T*z~eNZYnVb>AcN70ES9X^u&#M2C?L zysr?*Je{_Ea}KyThkN)C*gt_>{NE6U9p?R0>~=f&;E^O@HI7(fMBG2edNpE+32(ji zB+oHlPz$_t2)dBM=+*1JQD-1`0-L4DN`b5g1Tvq|({|L~uk>>d74&*b|GuWrjrw=C zd}1ZZ0oB)@!RUd&(=|b3Zz`bu9Bs~of%dEfSvr6*&~mJk-PSDxrdr_oL4iUeG9nQo z*F*+9Mz#6bDtH)aIV<`+Pc`QbbmpLTA@b=pRGTm%fPAK7wGi2~MH?i|ISFi?W7z`N zv`!9FPjo((4$S3iXF=Q1&uSPWzYKuqp!lV$njBiy26xX}IP*of9JU2o(1x({Hc#EQ zMYPHoPXra)sx2Ks(32r-yJY8M#BqBLUtg))U(WFeXjL!11JZl)peG;%$Chp&5|2p} zK40jF70x<|9Vj1ZeVkL{x@#?=&DY?t`AFM-(BSsd#6sxNomR^YyUYGZjSE7@clp?SQf)CXm1A<`~ONVAwsW^~z|07dsS+2K&yi*{4u2)+SVPe%_?Z2wogaxv(Q?8iAm&L%wh;k^pa%sf zD0{?@HO$cYI*DO`gvk&6{Z5^fCnkW!+NA5UJnF(f<;~4`8YjK6Z=Pfp;>RovdO$3` z3qgjrS#q5MBHoGWgTL=xOI!eV`@Qd4ebM_TKYvSyXi~Eo>*^E*X1uDaH2cE^U&au3 zqkUDP7#w#a2~j|hQ#mmG?|mRSPrkWp*E&p#!X&l%ApAY@DKDTJ+jDf*7)RT{@AGHM z{Q)99ThKP}*>r|NmWa}sw69f)1 zY~bPoCkcXZ1YiZa51nK6Y5iPPbjs2DDJ8@uVi<>_4#^E3;e^VBR4W>^Ja#X zDJA%#=1^-mtus=J@F(sB??X||0>H8i7zQht$0K+_8nWY-T#Y7Y>(u9jh!X1Fw&L$d zeM!J6P#ZFih?Wz9!D*>viPaI(xk(q*+;Rh;d>e3|PB&fLLbl>X0O)OmM3?z+r{(or zQj&I@ey8Kuiy~I!ma)jI$nUJA6s>vQm)=m@%eX1EmXn*MPJ&ktKTq@HVJcRa*ixIRN`1 zYL_v0QZL=QW*m2V4>-@Bo5XoH>m(D5}L58r{*a|f=n`Xrn@t5a?~!OQd5Y z9cjSrEKC3}c)up#ocGN>8#`hEXmcYHTIB=hA_O9pnCR$jx!C>)9g)D>HFRW->#jQ{ zJ2<4i&wNO<&zye|aM}>aj>DdaNG1b(w@t1iS$I3GgEdQabm~D?w2e5{WT&@Bawq|)Ae#rGo-I3vSiY6-c%`XD-r6E?X4aH_&U1B zFO(eLA}jNZAYT#bDn!b>Tv0CX(&t}{i8%MKcgM!PEwD0(6_6w%HDQ;43q$UNajTq% zr?)`1n5Y4yfh@0F!SAH$+yn%n$qiwU!WI8w=i5Aw7$bb}aN>#ui;*2!6*4rFn?da; zQc4IUH%}?SbwG-3OH2_#51nP4EX$15YOR2U$20*Uc!Urk;`H{ohDa8II=|6QsfDbl z!U=XSRX`zW7C;F}N1G3l7R?T^fJ77$1jvdKiCVU~pc74%=xf)e#EHCxm3%R9)pr(E z{6usPE)01B^s+?39n}{%k_iPI&RzYELo)A3@X5OnFnZ@@-k1DP;-H2@-f>yvS)3=$ zZ2gjNX11L(^%iK7EeeJmeZJXel>1W_vZP?L#)8d_7=gyn*uKG*ubuzgbt@ftBxb2~ zN`ayTn_U+%HrHJt!0edxe(MZ&m}Og^A0l*4BH7MRn0o_klw9dIGe31t?t@fcZIOp0RoO z5RhvX*m%Ua;QDF@2an(~8ME1LN*=;p9@;6zms5c7h573BJ06+vAs}e@xZ|8DICuvv zVTG6$gyjO^pteGM;ho3u~?%_K{8dw8^oFF)) zjw81!s;p*6wT3{Sp=C->{+iZ3mic7Ii&P{T(2d97@?8pepXAfpi zQ3_E4B;Wf2Wc6uk6|Pw3quR{wsa$u=wbrt8YT0fv=IiNji6p;?@WwYG$XiH~^B;W( zI2)VD(zNd8>+`7x@!(}n=jogRY~t$(6pdX3tJJrfDg2T`xlW;txBaxts1 z1CdOjBSJWo+3a!pb#Qn8h}JS|Mem^_7Y=kx7aV6Z^fo}YJVQe^M0h0}ck1&o2ihDH zwlZVOD(;YHWq<+G3k|V{j&x$RhnxTddA*6=V{#825ipz}jnI)gAb{QolR)1ehzJ;n zc;mbnCKEW9!sFOhi?`3yx$Et-&=Dn(t|_*w+R}X+y7&EwYk;r4(2)^#Zj4}9qqS&T z)A5pyusL;3?E2@NprQM0pmD*76W$J1p6pSr{W!zL;n>;?L|zhVSrjTl;|%8$#f}6z z(01CgEdR#xn7)9jxU(@K5dmR#U?0#i%f`hs9h1)J9$BNRMJ!{-#?OaDc0AGjxIoPw zGF!Wk0QfXc=h?iVa-bt{AKE^?^z$yp*kIcI@2?1u`+!RdBuMfZv@AiNx&KE{K~uA% z@ZKGHd|QAiXN1(|Yk~99fLSLCP3!s^@-UGB2^8Wb#!T2`04*j9V4&A4A}%im zD0(pAwr=rL-@he0o{rFsMFy z6|j(bq2EOT8&h*oZb=>-jx04Q@nXsewG9ZY5K&@8iB9-b_us%Ae?k=^1+#GCb+Z5a3SEZJK*g6!`Pf% z6*rFcW`z)PAZ-{{cz*dj2b11`ov^X{We5 zX||c~>KW%;$@?&m{#t8fThL*MSdDY{{A7cOrI6F*pqRy`P*#|!#q4iNen_v_kfqJ| zxb=ZZ&umHEbeJrynrF@p!RbdK=jk+g;a&_lA7zX2KAg%xyQvPi3Eb{%4V&hJUIp7^ zecX^YmgI#wDt&G9!p4zP4H2i*qORP8Eg92yxZ87@o&$w=uv$PWA!Jz_(E4ANL_`Vw zexi0Mzc016;Ln>?oso~QT(Q{I0iGbHlyVJeWTh$(>bZJ}=Y`duy$aN>3nR{MAxg?~ zo%73XtR3$_mUYj1>A!Evm=JQ4L}sA1X-db3Bx=v+YNm+us^~zms+Fj9gpDuDMCw^x zUYtuKO0D%PL00P(bTz-a4U8*D%(aSNZ6MP#Y|auMKi*(>Jwj5#dcDTQ#TkaTt^f#j z`yH;Y2eMhd<=F$dcb#EG_A3J9z4sCV<#xy&!JHjnt&7$%Z5a_D80vs(+hPg{1Me@< z=6CGTcJEskv;o;W1?3pby(ZxDnt;VY!9fGYEgTyJSTcBSgpGAV540Tt$2kao#rYRI+6PTp) zkP?kQfuQt&)?#d-`xLY-V>f>FLG=@kvpvw=GXq+6jH7Mi>(Rze3otiQS!fLUEmT$7 zM#rZes*252BX839ZTH=pu0Pk2L~A8a{uQbc@O`LuS~lKpbzKXxYGi+JY9hWQ0rK(| zSRn))4hMYt)1SsyfAv>mwOSpyAG{(!ZUvO;1krg9CpDx{7V6@L0q)gn8=>{t&ht_s zI|@hh$sWY07ePBuk`t2xRh&pB2AmtPT8(gUSmtbjWxn%?x~F(MOj7Q3KaX(l7!kry zEGq1~!4C?q3>XG>%FB-d=PVB--*&=)L;)Wp+fo(lE1~W-Rd_TYg9e=!6!^vycf&-< z6do=UD>oJLsS`x<#NPSbq1R99)9FOb+-2{|`MUk4$1zBMhsnl`M9EE)#XArQN5}Lw zAn4Z4PZH6Hv~~`*+iV>br2|op?H>vNfR5;}Ky|i>It6|tDkoyQAF}8mso5|xS1@5lr zlsD2bk#+V3^RnhxZ7L|srVuEmO1UX$wb)g-Hi(_pRlo<> zF(xnpnw67MvwvCPp#WiwkZGNckeApkb zIoo2hSz#9go=gF63?A$CMnPb?+q1v-O}yj1`{j5oYR54Q175w}We%i}imr4LJFPi< z7~oTWUL_acFu-}IjmlDNFHN~oEt@j1EKZt6ig}J$TR5;JxKy9zV%Pnkb;=SE)}gVn z8pc6$zf!Tk^xiBu)o#=Inj?}DQq1{bqQAqrJjrlQZ8WPOJ%e#k>x@i-#1t_29Zebf z9!G94u^bSxy);6B^Tqn-tyy15I{y4!i}g62AWR&bAF6F<|BUzUezS->SX<>f0A>7i z!%)ZC7lpRpxhOs{=Jd77U97NPq;P)`(3&(`&lB)3XrJ-UH4O)O7VnBWLm)*NS`-?t zTV7FcNd24TJrutQRd!CXex8XFQ)ZHN)sclJ*D2ie;i;T8!V?ZovSP(~mlYItOnfKz zclCK9rs7l^E|In_II_EZ(euWF&pCJOR9$!ikazTaM@)4e?(@83r7EwF6%}2bE-{i_ zR-)gtt(|DTVa-gL;QWxAEanM_b5+sA1ete0iup6hmRHWyBq>4Wsf4iS^)H2BDf=E% zV>jv~Aztq@nKEvHaScfmAPK`5FdYs^%YuiGF0k8Ac>C?=SY6CmueZgf_|C`hK6Qr3 zm#xk)^2HkJUcKJMIse>&Bit!p0F%6Hv1W1jm8OtBx1}S}P^C5~8-!22`E6cY%lvbo6#QL0qsRg_R%- z`f}@5>Og0pwbx2S9xSM;6^G+C5mtBL1fcJ`b=m(9^zRwRR#-hL!K#x@_xW|VUu@e1 zBIF|Nr|Up$oFHC!A6MP-J&v`?oT-5mq)H!5&c||vHdLI63Lz35P2cp@q~`YkBBU^923y^$LcLp_8B2}N!xTz zgpxzfLE6w*G81{nwrOnt1>AK?k#0DOw@nW2^LaGbw)Y-C`lCOJ|LK4FpYX;TZ{SN` z`Vzk9d%g!h{KG$7<0o#YNxb*?tAF*c;&1-VzlnLCwQ;p(aR1a#{S-d>(T~386eah| z0D&uAi7{5!g=o}g_Zpw^I2DB%LPZX014DwcMi`cLHxRHnpenSwgp zHu(U)1;XV-XmZ~{Stk6hk{)4HaJ6QFqefs*g4RnPVkRguo5!5?xGf1UUGz56*jF z@Y(yDkF5cl-s`@_uESQZnT80l?%WeW$Wdw(LLi-3<4c_4P(IU+h5f|da?BPEz* zA%LRTaT)nxfQo%y0hg*`@!4OS02nbZZClnnXOKw2h%`-*B^Hvww#l|tZVU9agTCJ6 zj`-;c{$i7}4MHY(hcV#!(`UH4yvF%Cn<|)QHy{Sr%+&!Ed9l}xL#4v=W(ZcG0ybTUM zc+D|O@NUR8fk?L5bST+oA)w@r4FHBAk1fkw>x`;AiPdhiA0I>@5}VpbsL?^hY4gNw z*L;8o)eTNs^5Y~)*gwbO38=ueO&T|yK+XY4%ZcU4iK+cPU%o2(?$!)p`35(9$G~c; zCGMKtb&>=@*9_9DL2s%vj8HhK%zVMELz!4b{Xk6+wGK$+P-YDXwT92n@mPtEqCBNj zw`ko^$P=mgT~2Dlgw!u(H*Z9KOveW2-*3 z{msRYX!BSg(qt*bv!- zt(V>jWx-0qSFf)O0b*8HW~E{s=H^jvUDejPzDMpr-Y9TpmMUfgH4~_37000i^sV#Q z*14zmpd<_D1~4egp8El<9h7t+bnHMm10PK#Vu}#X8+eWylDu@_b0pB#wr?U}`2qdS z6F@Em7On|23>}EQJ^|d1IPSi`?uY_GJEiTb3-0)5Fqf^r_@@VaC$xDg9Ll{Alxd*+ zgf?U5KtOUtt5#v93llp`u8g#=q1(>|t@TdoNTbwMS%TyI3nl~l{0~-R^tN40K!p<^ zd!%d6wqrx23D7D&&=DD~so0OnJ)l*?fQS!PLTw00cR04v>T^Imj`K!_j$E5N;5&3c ze^2YUzl{9gc0QTu0`d zvIASt+NLcu2CnFSv14_ue?RL8Jv#?aP*p5!n+F^RvD?^fSx%2pReZ4x%#MKmAYfxBk}O!vFri|L^!if9Mb4=YRg^ z@h#u-E%?%xzJ&k&zyI%VBtdMh{HwqEt9bwW-;Y1=2mU}4Eg>R!^mNMY%j!sr+dDf6pfDRJdMX}SUKn8AbV>?= z*aA2BZX48MqyMf*#yZG`OO{x}eNS3v5fP#j#{#INY*$m}-o&?A-bdM2@`jd_w4%{K zQqB26a7`DgS(><0_9CT4pgY*NT4xC~`?D%oZiDAeem~y+x6X_E2#7QA#a&!4=F>#Iw(9=dxM!&f>4-Nyc(`l+A7^XJd;J>T;^SglsC(1Ul8|HxKU zLsnX(1PlYBKDT3BYIwPGkg(GGEGf@f%kq*jCJEjTsT zp(;q7&tCp_2?)1FjMO?9OJkQ6+hsZ5ks!2RH?8wNIato;4tdTLxa)JC$8vAEaOs_2 z!0E-~liZdeA}0dWZ_NQgs}$vDhK=i~Q;Tu~hBFH}sn1WYRZ=Qy)siW}L%`t0gtCVt`+6KKLUGL%d6WnTzbon`ii?0RT zq&2cb@E-f!r1r*w!|n>tpFY9a`n9`-pbPF@XXMW(yrgkjnRj~c@z4I*Kf^!zNB;=l z@-5$j-}8HZ&#j%AuU_x4&d}NT69unKsj(%H@l5{?A~PN820Tgr{<1~0-aax%(v}5r+dd#|OZgAjup8>-m@GBx9>^ztqRv#{I$LPGf4mcl%6+Wz9@h8aB zNW_mvYZVqcLTl*uZ6vbCqg9^S%hW}tqIgJS+zL52i3zF^t)-jo+m+r^n{M0q-hHOq zVAr~h%WUO$NyO!Wwv}z`Y>aj^hBI06P`B;#HzKg7UN2*Tbn(#;d$RELf^WXe8_~9S^Vf?v2_vi5Ye&6qVp%UZ=e&7f2r~mYy zzPV(5kJT#o+FDnDPPM;MqQR3@l+%f+_)t^?7IJPDG_JE;Fn@OGIuT1JH4z$IBiG<% zR{+{TC!q0t*NK!@AMRyZFi%;^V?U+x9!!%+dR#D~|DAK?9f7)MN#5*FI9U912lBIWQqDi>*iw{TXLj?aYx;* zITinJ;feDpaO>1H7t_sM!%_oZTd?lf8ml1|UPR{=LHuJAL8QS z1MGGO#AQZG5%aPjrMqQ-*fT^RM~cB0{k-hUY?S`D|LuRncYpVHo2-eZ{;Ow)vUo=dZZ)f!e*Cc2j5Ja;ysQsl<@F`QHZ(#K|=A}sVh$J4XW=4Lpb zK}r{HW;rG1IzigZG4tZr0pkTk3WeGGP{P{p&UJkG3TrnE?eZLMRC3;{iQ=Z(n(a8ORUGJBZHc4>MWS}d`OJPHHS4j#8}8J z`|Q2dhdk!QbURVD#A9V<|DC=@vh&_&(pWQvRFz1x{bCIjvDPjzLgqvMyRGZ6&$WeF zT}=mw5|omZIMKW@Sd)hRUY0p))*M}D(+rs-{AvX`?2xXW!3}45D2p@J+ck#4V|%v2 z2S4)u>`)LVxHRE#*yC{6-|N})GC%L+Stlac@Avqw@A@u$-}ikVe(vXft~?)Jy}tY} z25kog%C6CNeKR~|-S!5US%B`fewY3I%_izCRO=KY1bPb@0|X7YeG|3I`=Nq>X1T?! zl$WUXQVh`Uh%D*zJUDm2P>4(2R?z;<1Wuccl>wWR5=NE>Vg!;9z&#UK3nFza=io}W z)9hvrbeuv5pmJsfgRhQ2a3*xDgM5cJdt#5)l88b2-Xe*K6Sgp7fZC4xnn2!xj?-{d z^|TKDtbpjz1Pj|X@R`H5r2}U@9jiouci$45I*@uxh!j_c1MOoV5Zv}{L;D+uG>JC= zimBVq{@Q0E&Jn=PFH^a2M#_N*B+hS;Zr=wwe)hAbV_`~zp*xnhRKv}A2py@?t65ml zBOM`Ps);kyT#+?eYb!qHBb^f_2R(gurOVu)4VT}eDv?-~)wgB?$Ep|U&h3gwOzF;_ z;Mm5^b!5@5BNv|O`L*m0BMS#2j-ToG?0VRt&C!|ZIyFM{ipGa$sCh|?j;UWl&Ji)< z`=A8Iujw)$>lihjAOszN*NC{b@#g&}u7MYz8ZY^SJJldoKm7QQ|2P2PJHPWg)rBcH z;Qa^x!G8b%_=%tRi96LBmzS4Ef19T1JzJ;TPqAa!A5?=obX_$%xfVC*+)Hl{?XYaa zMeBtig42ApbH$t657s#;+C1bM@WGW}PgSdg8e;6on>*0o=XoxkWk!4$sUsGGzFHN_ zie;WK3-4ffN;bt4rKK;!O;)uLyj-9l4oFhev0@sbM7us1V8h~nD zolH9UHW)pJm0B)9Dr${lPhp^;swo;9J=c+;JZrG`oNE;z))jf`TI)d|v!&{l9-S(Q z19_eI+E7{T1beS>*H|yPW3b7{6|-MV7Vb{xh~4yD25N|MoFSYI(m!loCX?e- zkol?FH4M2+{iLeybVG*3djyhLZboNcWwny@DnBA7j%=@P8kbJlVscXg)dBYWcbdq@ z_Er4Fs_?jB9sF$I8pNShkbq`?tnQY!j*P3NX3`OuPsl9pUoq-@;>C4(AETKnA^EmEp^F;?rFYN_OoQ9ZNW z=rt4bWi*U%>kCMlbe!~>>*z2~3s$QU$)6#dznLwQ@1QJmO6*!goV#P)kWxZiGO6uf zLIYy9&L98y$MI{w_G=i1p*Rw}dcAuu>wih5{2%o_=ThE|24u*g)3t{l%*4W}W`)BHi!${Y`px68p zRC~UGu91=M!#*6I*O9twnaRKns$yrL^D^K#s$06TDG_P$8fq5GioUx;Rd?9$Pjwtv z(J_BS_XAto?Px48OT*72XDe-W{4fhdv+{eW?}P5IUP&|1wYk=1Ugs5eW8e2R43BeLQ- zjq*OIKfK2@H3P)B1- zm7rnuM#}3j<%X$GtXopJ+tYp)4Iy8s3v|&Rt1#?$s{-u1LccHV-z2q!i}dyukwAme z!AYLw%L(NPP5?FLH+|HBdP4`Lvw&1rlo8ImBm1iYV8{!=y;2RuBcFGf=S(zMcW7^K z(t~9;t*0k8dg5=MfoZP91$PsRK&C1GEK5}dMZGSi5`N{r>Cf>^_nZjaY6bV;0o)jn z_D^&8vD@S7>KfA_Vu^DZtJmvQQ3h_$&Wb(OT?h%ge((Qt{|BQf06+Es5kc<0v5(>F zzV7Sr?ce_G_`(;y@QMI=*I%MV-H0$`MP;kRFja?Mv}q}Jn-mw;WPq^Rf{R6Nbl|`7 z`R8mfR-yLAVoIWo8Ls@@K3f#B&J`ty-IrE(2>siR0dJdgUWpJ#ki{+Fyexn$s28*(MbQV;2IS-i-biZE69(;N1^KJvP zIjsAp`h%56@tDx)$KmqqCKX?E*kj7pQl#FjwSwAZF6pSQD1<>ut&$|k@wPPT9~z4n zRbxb|GnP~xlajPQ`rNT+eO8551#D^T5H!cAC`bC`*)oN`AEmvPNMVbZl_91U5&hkc zOR3Flzcpv3QA2W*!);bcY@7A6uS9ATt6e*#kf}jrSz47!Ung|RkYbJ1KhtixzO(X+ zNJLYCM9m?aXH}-?&m4inl>fg(oyR4}l2sm7x5=`o>I2lQ)2dS(0KXpLFV2ys9n$`} zTKWY{(*>r(9`l^-jK|dqNE6a@g<%|(9JxaVh{!wox$pR8p`pL{i@%7!^LPFZKK}8K zt9{q20Q9fK>w^kfnNPggs#s^Tks(0s2HtkytpT8K6PYq7S-|-Y+`ZphMj4R)gh-dS z2=w0RPr~UOhmpWS>(I6*;x*a~folRat=oD};HY&_4+K0KaUi;n7F0ZTK)C2}n22cD z6Cmq}6f!HVFA-3_BQSD>Hvhnm-$6geHdwuV75|&FT-efQpzSem^P&UB6Iu(VL=fae z0PC87(q5&3Zd-?rpu!2Dp6$VG`8~+IMO9$*R#hXtA5e2`dPT$v$970PKCEVEu4nAME%Z(5fbS zJ21|H;m?EagGAS-T|W~Ml@nc?Yg8qNZQD7KA);jCsOvH%lvy=BBVv(>j5Te;aN^nm z-Di8+hmr0xJLW682AFtb)z3skB&)H`mHe=4Ua#>>zw}G^mT&nM{3rj(e}co|@Q#(Rza6iaOLp}hE=3|9Xd>^RM18vso;ZIkK_jG7Mv)=gm!5qN#%}lzOrE!wr6C}Hgi1f?-8L1~ zMh-ZQHHfs4vX>x(s`5xWO!xlWW|?&o$moa>zfwR&0g=H~62bYS&PC@#OLF+os5zQI z(jKsGCYc>^a#!!{5a3QJN^Ejx6Tladt+#*|jU^Wdkf(__Iy;A>9FUkY|5buKQw|R$ zYO;U@9Ee+dl7UpRVO#|?7Ae7>C8)f3aBIl9Wp^0B?bWY-sojRI4{W z4%vs>x=rbQUVq*`-6e6QgVT-r*tca-)=2i^4>cPF(sV&K*(a#p`b>h>Oc8f?lI?D& zSq;N90b?fBBbx8Gq|<{Vn|3ul*YS{Gb2xuh4?8;LD+Z_Ip+S)b3{S8GdjwH^JaF zXUwcDhvwW4!%%98l;i{#Uo$!iVPaWlI#yWs4clc}?cxnU)a;}fW1Fv}^+&09Qp(w4 zr3s*|bB^)LyNn%y!pdDs0yKSnwsS-7eosq&Hv6FYVzD+xoXGRY8@AlM4OXL=T1WD@ zm_-{)B}z_=eJ+gJV3FuM@piEJ)?;FU@N%cmbMwp5x>yB!supotYpAV|bhU#-wJQCceq8XR$k4_H%L*}q%1D|(YT!BM?F+4{ z!&z<_(B^Wy=-LV4w6fXi&{Khvre-}wp2SoYC6&NWMn1 z&2ZZTD{+BL2c7n*%EQ#8UULO0EmiFOrdEkWy5O|pN|htE0V2VTD)mX%XuF_i{W0MFnm}sq{C!AI;Y6VN4!Ilo2?5Xs67C5cT?>chHK=To;%BBxK9y`)Rx)u`yVmTkfp-*+Kq|Jel zo&l!=&im{IwltcE^w`k3?{h$Av%J;Y zU1`7p^?SN5>~A}|u4e_e?Y=QZ0|%~iaQ2FhFjod4A9uRN1N4RELgYAysx#sDntuPHBdqSw-Gcjvpy$tf@9`i0hkslfO7pz-o4@&+@xT7B z|Lak0F-;S$udng&;lnbY<=SMk*j>{XhH=l zi2gnnRY*+0P#(*!_qD(h(U^Cy1Ur_yrZfP%y&L zxssHLqVj(5t<3_TvpWG|&}F;;Q@jBr5VEpjvcjzXAh7~23{)xEa}-|Nzc?t=h!xAZ zIc1rWa`&xMJFGs0J$L8Q-{z%N;E3EQQ@K5-E~{LwuaNdT3~!tP!v)-MhVAx(us|)l z{D9^9DboHDbBq{QI0a^W@ArN${>%UJzr=?= z^dUTZ_Ux7V<10ykI9DxfoQI^G7b99rc;}Ja0Js2gH;az@rU_Gq)+P!RTg_qg1)S|c zD6eFuDN1db!H1*z#E3`de75g|YnI0i9CjZCB|umKQnw%Qe!;j(G!(JH1{cS20-a9n zpvMXY^9)1Ebw(*ejA)-vOmT1N6yPR{hS<=?43DG6hTQ0}P`(yUtW%0|?4@7no|z2y z?P%(^Ujg5?wou~3#_LG+94i*s(um0FlG(vyb|QkQCpPBPeP2p*kP&T-eJp@98`2qHL|r*d&{OgoKtIWDWbE+Ol0JS4O(7Tx*|#Y{U}6Qpf-#+y>6mp z>rC(RuGEKP-(fUnDF;lWefF>9nY|qutA3|Q1ObcXpfD0qs<`J-Z z1FQ7}?^7AFM01rSCCK&Ln5OxtJMyOe7J!t>cO*bk&5az#@r5-;-)QmI|N39YvuDrn zLqGIG_dS7wFO_(51XivHpk%<+zIR0c^o%w<9ca70xw7bUF$AqekaWmRuQuT>e{{qL zI=~wz&>2r)_Kt|7(2+9-B0{Y5pYtH>dyznLQyeUGeJ|a)jr2JXLC`BcCi>e#WSEg> zGhJIiRYW)flY3BpL92w}5c@MC{|2=7NOI!*4mh1B!vWt5TJ?xsZ=Q(Bh0ZaPeG$i& zV3V$smlf#R_T6>G3Nce|ahUj~BU-QNxO*ZXgvblrvY&mBIYS>yhf;Fh4F{St`8v>d zU3V_~na4=ShON_<6NEB|5WYfdHO9xB?@6G|TS-J3+ws0aYk|iG!KUhaP5*w0s#dzx zzn!5~!t98!wQadOsQ&UH=qV+9{Nq20f8*cy{aAE9@%ZuMqj@iYVyVe4M_i{a%YyB8 zdn0jS^V{$Fp6}6VO?55!)^Gh*{Ja0|zl*>5H~%I+{_&6FjW^yn&d>baL4cr4lJW!} z*}GbcWgk4^l5+Py0EYx|Ljf#qEC79Vuxv9ulu$tpM6KPtf^iJ+A^X1iP^~J)RhBy^ zNeBoH=#cAiU)JrM!!QozeN%vV%{j4)!+DrNvEd34;NX?CsX^qDP6eNL;p3Jaauhz^Ze|8SUl1gH%lmRdxp99} z@0%xYE`;hc%#*qb`*9UkKk&z@5G0qe5 z!d6_?h3Rzp1?bP)>wwncivDdx40oamM5_x0FkA~@SoTBN9{s-ECe9|QM~V+6ru+%- zZR^bCoDF{qAYe9FM0E6Boecod+kBmxNYiz8QVC)~>Gp2}MyVsWX7`-vE}o`ldy*+W zdH_RCpQ}^1tJ5)79np{Rl3`H7XMh_XBH#+Z6^3yIfgmnX^9}%nE!G#0vTBYx;a~Ld z&w@|2+q#7_<#R@{vQ6-Z+r&RG{HHC^?Hr}>_7X@@c8lL=7Ak!-cAE0e&k1f zBm>>=p{|Da^h?yjatI~#+@)AZf#3(sZiYj|7&N;*sn{2RJIa^Jg&=Ac2L=AJl+5lm=k>ogO z3DkVw=V#DegU?Uph?$((>?xxv3KDID)(8D~E=jxPJ*f>WVr#m&bwkoRFs_2njk2bi^Id0R6lE%PL$T3lHKTzL zY+uBk?wpnI0M+(te*%0SI`ScQ+vEt;Ga|yg|BbjfCxiptHZaKk3~dKK6G#lKuFzUd zah}6Lx54ax7>G9jdFnvsnE==wZ4<+gi3pLAmIne+d$QxyjKGKq-yMh0f!^)}VC%bm zWrYEopDwhnE3}y}XS8oZz<%lo86&Rtv@d*}L^?O7J}_d#NS={MzC%X}%^ffwI}n}0 z?lm0;&R;P#l>N=F-wU)hYD_?KmJSnIfv%~5)-sKIOGdP6l%)gSr6Vp_G2sZ@pStBT zX&^-24MYa6h#ci>qzCvtodZ@wEI1BG@7toK+wY$AV!~3bo4`|=` zy4<4LMGbW9ddo5%A7)yHZRg>{J$9huY-7z#$9>n4zt=Rr{8MDxtb>kOPf#;HeiPZ| z%Es$2A}jUIkd-VS!Cfq{-YEf+&+4~*+jrm_zw!6Kpe)Qelka=q`|#_({_8j#4mdkI zE11VLO?dL;3BK;@zOJ|)Slx1};gb~}G3MN}Z~Vq@#Lxfy&*PJy{3PCZEAU3 zNS*gNnmlk1%X*8}c~yjOND5!NEKR+^UAavMW|AV!_8}7?WjW5P zBBgqLM=d(bJYB&F3DOZIz9DH01hIg;VaSRP=e!aiDB-C#G`jL=ab1ss1)4AI5XTA* ziO0i;DJ>ep!oZgRaj6X{Ey0)iNNRl3V+*D>=hx+qS_V*jAkX2h9j~*daL#L2KTTwO zxZNwcAzw%VbIR`Gl$yi>pu3>QX7bpB6>4Eofb1T)o^|qOp754OA$cNhfxM+VKhmQi z>BNmsFM$)Y9bA1v#_n3~NsyAkM`v2cVht zdUpgsxZrUIW9Va|<9HT)Hw8scKAr;APL8{-2{kqQGoN3wTN)3@E7iFTPW5I6h?GL5Ng{n;gI2J8a zo2H1IOi(>)2uR%}D_tp^q$OA4an~9WYGzjnG)GmW1X5kPmI!f1G|};L2K?*xIt~x_ z{(c>8r$WzfA_MJw^Rwhd*<-hnA4Z(@cCx3o=cea-jJeHW7*Q1b%Rl2QS;iogIlkG;1QpH7OV3QU>6@@zw_AduMuO!ye!!7 z4_IQtywv8EyYWZ@LAqC+A^ObmA>iur62I^ZzfcH}7-I}c@o`BqmNC5-nX97YEd<`AY@98mN9og26ZngWs92g5eV2F(5i73 zA|}joNa*+;(Aq_<(dvqO3pBR(GV3P0?nVNhZFvTSABfyw8#KNj?Al|M(>YozCARL` z5Gmw4VvW^Uz9S}hS+3jOp(D@Y2|~_w=Y`ch5v}S3-L)`u-}MC8vu})D6P%O6A@XdQ zX7?Hs7`+mSRWDp+uh1&Vm?%qV6+P13Ge8?+Z!aUSSUJZQcastaX7gq18R#AuIuhA; z#L|k6_j5WgiI#DOHdy_f#*`;Co_tW>e-7Yts2Lv6`FNzw}FZ`|Y=}-ENx(jA@$iZ~a^URx{V_$&bU~ zfG>RE3;6JdKYUa(GDiMhd)=P^@u5nc4S)xCQ|CfE`4PdC3p_F4uu@ULT#hXwO6VwZ zmw6am^FD)uWjSa)S1pj#PE7IemfVp%jze<-LkJDK<-EsA0g1RQ2*XgUuSDvFl9I!I zAF$p4w*9SNV%+O?4rQwqVE+|h&Zy>s+?D`OYs#zNRzJ9 zsWnw_HBgmk`8+X0wZZ~hz5&Z2lQO5qtzrYvHdJ@kS5*yTb{VNBHc)Z1V$Bt3#Bj}` z+Vuq@IxXZr3LqX%q*&iLM-~_k)h3GVB*N*zSz^lDb02`ihz_y=VVjWBx$E7@F3fu~ z&ZI&q#NJU}PHJ;$OnFkwZc}IBhGUTKRszT!KixwfU)*L5cX6J_o~RDEZiVgHV>tJA z0fFafMiPg0y2AAQZEViAc>MSQo_^}Z^x!K1PY6tjP_I|Z`*KtaIs&Uj`a$=Y zX%r_-lY>aod=cN-WdTV9p6sZymwYPZf@OJx5Q^PqRd`fu&6LuyY6>-D!iYB?0-`ET zB_)WGZy|(g^CIka%}o-$!ZZ^3K}`!TP+ph~n4zl;l=#qE!@)uP&{%Cc=OJdFRhHS- z1-B9jw*`ee0^9p>S;?+a_i$Zu59Q~vN>q+AzV2i#O}7tCpYo92Q7HB*bYmhSqiY5gUQzf#l#u zpBrE_FD0vbA|yGuRk+BMhw{o>i5MD;#Bym%~xiyHH z%5|BmO1;z$sS@7hir!q~eZClH*VuRyw28{B;rhs;v;c=Z_Y4m%vbuVI1%L5j z$Z!T(9wNpC%e>?oBPPTpVGJH|dJ0LvYp=bgP696`-_uRGEiV{LuCK1}4d3wl@elvu zKfK}6sB_B@0{+22_y_o>|MZ{YU;K-IktgHBP;4w7J$iH}%g$G?clI*yIdnurAW$(N z+b1}h}E6WxIBLkHaRIDllXxpbc;CJNd1jF`x}+ z2iiP|nMkS?p-FF}s&CeGF75cN(YD<(SZx3~TPN*^EF0-MJ||##>8@GZZzKFb%e8CP zz~+(280knnz7`VN@astD($pbB+v#wecVXvdq-%nSFCpTSONVnhcL%iANI;u~10@tU zw6E*#vxkl#Tl5+?B5=WR4h-92@pWLpzJ2GKNLM2fuTBtAk*@iK<4hRf*k%kJ$>lpj z@|5z0NkTryMp#)^$CgN%NCewNq-&sKe=1+b)4AZldnQX9ox?!uFcR)s?;+QX#h<6K zhsnVMfKTf(uJv53bZmX3fB&p5>jS!uyHyVfCmYi4NPu|niwnU`&lIvlg|J0{Gg|GeEuPylFum0+<;y?P2{v-U_ zul-tS&aid=;xGOp*6THX@9+J+?>Qpme$|z0iHxIjL!^aIGei<~`nY0aRqUsRer`~` zv1{Xg&HON6!h2PFXeVa2v;cx;CREbHHQkiu#5jjxs3E2`%)HuDcs;qxJi~jBam+(* z_IX{w`+$@pVhk7tV6{HLhiq%*8bO)D&O%(SEc&}CLQ?h1c6v)1k>Q+zI%;p=vc*__ z->iZLhy|r1ho{T3*YA6p+|z9t=MyAG2Vk5ECk=ykcf|@wcvk~WojVRWKjrCrVq$kP zY*daXk}ia{LyoM8a`30G0I(?pEU-PQC-)MO_H(xOSp-hME7qmC#OisCY_}u^RP2{_ z(sa&5vZJ0VL=K9Zmnp#HVfd(6GcZj4RUaW3CG)>#>6x;DKk zH~3xzZKp2GYhH-!xd0~!tQKLb3MK3ErL4z^voCmIv4)v9w*CrsIP>MUK7dd&N=jZz z*X39;L?V$Ar=BbB2H@Xy{^i`souuvTE6E z{Dafor)*Vnda>>xYPOgAmSec79&Q{qyrJhTyS}4r>Jp z$8mKh!c-(7rAYqucPCYybGW{~F6s>f6y%v5@2=0VS|2cuQ~PW^>cc`eeTnT@wv}L z%=3c7w8v^S-Wg~xE9ASD8KR%JI>J1%IcT(RvY6)?-}}Aai?8{bufc!!-~D&Ey1K%8 zy~Z#9@-G7b{_fxXyZ9&n##QDV6C6qp;`fL2`nE8oHP~36#@Kgw>6@* zgR+3_J+0F~bp}k?p>Xbqo|%Y=71|64+efnji$n+*5Pcv(n{9$D+rda41NWckXU}N= z8BE_2_~~gowp1Y*Nx(KcY~#d0s;6a-IL=QA9bj(j&}*a)tx6H0GSDv1Cds~+fOEKMg?viU0V-PGZQ=k{CrKH5s7BGFcTfG4cegWu>;o= zZ5L=|wD$BwpN(Au{#IoT)dMl^mbMG4T`Mm;A|pwCWOh(rO7gRxm{GN29Z z-stD|`W~Na&Mmn@RYO4k{uEW^&_O;&ZBeYu;QP$>>6*qBKK`~nJNlld@zPW_GmSAu z8aM!Nq75~Fglez05D7DPB#jZhd$gH5t|JjSJBYJv_?QK{*DTEaT*vMm5uTz%##7p_ z|1W@lMd$E~w2e|-CpU;K-Iu`K74 zpZp|#?8kl#-~avJkJV~b>Xc7>;uH9hANdhHdi3aba|n7D;^~il;H{W;5F{UpTB4aa zp(;O24PtgwC(k!(AZiT`%@e2!JURFfG*`HGj2fX~0i#X~lc2qzEBk zT&+~fykHEWgvofH!3^&MR_h%?(C(`;+|(GL0uabT44zFErM+E5`E}Y({SrykHY?tj%+eN_P@rfEV-3G+M`P-DN}W3^f}wpg!T zUtUzgfV-GtzTch87EuN!!E2L&Ao(p^x`vZl+86bxf~7WCIalqi`UZ~1V$;=zh$f6Y z)%2~_mJ^NDX3Yts>nnJ~6ib2VRpnvUSAHCeq9lJm`>-Akdu%pa>` zYq&_+W>b5ZT(NYysi>BtqDxHq={%eta}84(4#pZ<{z4^(x9})xs8BU*O2_Q8^T03y zMvU-t-kJgKnu%Z!uUp8J(lkNiu$%%vd#kHH%-U2dUcu#*0GEk88FaZzRGT%m^0QDv zQ+ROAp^XJ?Bfn^lOs(9_t|>Vwy&fe%-9*3^1}?R9tOQC`e3U%uMX#1c;D=oMl=V_w z<6TyKI`0}HKQGH=+f5@dRJmvIFmX=sqoceJz>ltEt(hXWoWrP2r%~WkY3iJ`%@)DI z4-TT+3wt~VAnp^w$`|)G#Jq2=kTqtpneQK8CAV5z>p8Yicjs?&OdwMMY( zrdLRKUr7_IE%^E`^>w`lYrcvcHx!jR$T!z_LyUCwhs<-m517=m>)(6LclT;KYR%f^u*qQ$2zxE9gxW|9e0omedeqq zf`mZlfxux9Il^R#kt=*mpX+*z=-4T+X@L4=2OqwMnoq%b4@@o@nG|VTJc0Uq0^%Kg zhXc|b?GJ;@oSDM*K739?2joVyLF-H`dAcTMI<8mrorE_3!}f_YPPnqNZMo1NCeSuS zQjPTefrt=vB#o!@X1||xqzs5iWb3ztHe2Kxtun{JaI<6M`{D}K^6Egxm2*iP0quKT z|3E|zTfG_S@g?2<>kiOoGS0Sn-CZ|Ef~|-USm>BVB^sXS@~%;>@tzRDH_~~s`}mx; z;cZmgwn&8KhW5F)J&PS=ZV061b*?T0ehAgo~s1Own_t zW9kaP8z=7TTRYPPG5pNW{0zSS>%ShK`qZcJ zfBYZ+2R`<(kKyAV|M*dS#P2!+q%YvgWLd$5TJ(ovfh8$6))bcE;hZ#c-X4TN@q5<` ze^AgsYAC5qxGE{)%f!VBPy;ezjK!1Nd#}NGzFZu`FqBYI2E@uXd9%5Q7={(bajkXy zf@N_CfgSt!v0ecQ5$e8_`S{`%iVo}ZFc8UZA{C;_@#qt7)?6p`S(szQg zf4H0m3pxv}0!T=w;JC?>!Z|&bF90$aoZv}a(_x?L1g;5DRDi`f$h1Iqxde;58R@V` zN~+>;K%8O;FPA0dPPLdr%+s8Tg_I}}Qowz*1c;R8HbIKngT59ZQpgFL)TiV)ceZ$M zry_#59kMFomL&O5%dxnD1Q-pu(Kw7Sucd?uOuQidAgbhq9Iwx|*02|Hu7C>(Uez)A zG&%iD@9pj2y+9l=ggj(84>&LRl7`D2-97_!!+BnI*n|o`d0)E~sm;R~lab_v4Ta=Z zRO@zZKrL6*ITlcRDvX?#S8OQy6TzPk?$!ylW1@q%$MSA&w3(3Hahy8fge*@ab(4E0 zLrz&_-TpAhz~0q0+?xSz+yW92{Di~dDfat4#?_c*`p+&g48Z#00WKcBfoVVEi(hKu=;Lbc2gQ*DG$Gdo0lb8X~p>XfR; z%gTyU6ory=fgW3`{uqbGPOCIqP@{&uw=4_BaV$1qz^t5S*D%2dGR%IGx{BmL>Qk;W zj=BoE76_k9Hc2h{(Gba;6E+^(W%eHrtK57cOWOpImR>e^Qst4=A}z?9$%EzOh>?MC z%jsAuDB0(W6>Gg}kt$qUoC>1mz-jH?m%{=q3*r=!4hzx}5vP<1iaFOAX~~`KP6RSX zh}v7aq|bf|D9^nVwW%oELS^F2^6gSoQck3eMpNQJyoRWwHW=g~)anz_!y%23HY;Ik z^Mu#$399nL2UkSK=1k}n=Xbs~3dAM<-TQp4JDd;I?B?MjSUxaG;5tu3hsVI>K$ z+&%GhdnT?AZdn^YozoHmzM{GkaHd{pIu?M+hM)gVmf<2E-o(c?Ah~t?cJl< z;(i3k{pyU>YK8Cr{_j6}|7U;pXYtcN{nPlq@B2Rd@jw2@UljuH;wvi1_6XVU{Zc`^ z5pAgNXY?}$*3ATdpA#|g25L@4Ab^x@q^$G)On;HQN9g*#ALP z8<)^Nor`i}pk=+$frA&Q9o}{fxMP2-f1f(goS)pijaJw0d6bUCOlV5<=Ly3l#&5N#01Yox}O$Rs0uI4F99)(UJz z1k#$0!%V=sSF-A{o?`=%XCe%YoVvhq#)tv;BZ2LX&Y#&TnSGXBm!RX~sm&%4xxv{r zu_Mm7XWgT37li~j=O{LSBtkAC!{rDl2Uwb$^| zKmF7A%x6A>PkiDN`0Qsti*NnbZ^e)O*pJ~uANtUHwgz}}Q9IjbMquyyWX5CSbD?JiqpMaF%eGopK!xV@MbRu%8`Xf0%H+ zci2x3^WqRZupR}Mdx!N1943d=kh?MS-vlW+Tx=4qr`+)u_3&=jg6DgWi%s6%ame7n z5Cr1@Tpb(+FIb|(`9^Sk$RxyS$d*QD>-@LD3l2+OS4<8|1RielHs!{>yiG$8ObhT} zE4bR{zb(mOGv;%-pB=^^*iCtT_89;e2f=zJxZdZ?k?Xy~gR_JsI;513!S3Mk=prAV z^G$xwd*E!9yJOcYL0o`gB?tqs+U)W0b%*tXfX(Y8+;)K5j#<@ps1tFw&Pa>IC3j5* zUve_s5bE!ZX}KILFP7@B&7^}PV4;u#mP2D;f!P6;-@6 z^Ed{@+Y@{UZ8m|cVvm}gh!aO^yRvUo80HoPS-biv#7KoTlCKIu-B|lDmW|^Csr0EimOSO0MCscPzb`@JP z5u|zQ$l^tb5nsn#5CCm;gafd|+K3W#-0qcp%L3USAo~O27oK5xe!$@i6Rw{oJo!Sx z;W{7R!{l(7@^jxgtzMoHt=w94@vA^ODJt^(dHT zhhfP7Ki>%UlSATg+flHa9hN0GGb}OJGna>~HqD<&2tL z=KQ;Nz`S@2LxPCI#Rj>q03a#b+72FgzW3O!66VF>Y@NxSCAvcDi~-nB4%@XLru^@5 z$mGv<1?(qaH3Bi^W)kOs;04=t-oMoV{eLEA<_K)oz_KJfJkQ56C99PZ&bNZ+*SThz z=3L8&xSXr4$6`GS#*k|f=YZ8J;nC}YvkQlZ9~iNEZ9sT^3%?$KIoA=0sXkYIKAG9_ zj7Wf(Ai=|pA^+Rd4Fiz`4&@pXv%3wJ|CJOWbG5+%Wak+ZB)XzLDm4J-p;?V=>W*p7 z?+rnfqN%;FmRR-&)0~M5B`R`u-dr<`V$A!X>lIp;(_D~75uy83@>!ye7ol1L%Vc$s zWUJaCz^(GS(_xP=AAn5+;!`+zf?;@s&31znb5qtZj!1EalZ30QD?GSZ->Ga6B^vKd zfZVrnM4RXAxa@X2q?GU*zwsLo5&Xt){05#pd4gdWaB*?*svvk5UIx5gpbgSJ&{Oo9 zK>ur0+na+D2q4B1!`w4x2M72#~P@28=&4OkP_PA)X0zlgXwB6$%0D7Q(V(Ti-;o#79 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

*55s^BOIFI*`N;5<+eCdaIa4ykc4|8L4nv$Q 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 }u0 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

s|4sPwr!;*zh4lE!AXHKA5nG$WEx=^ADc@ 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

|E$72WEM@tlRj} zP6<`FCC|A-e#AGGqfuJ4m|MEGjy=I?Qjg=rqq~-#b+Oq%WOF+fuiAPTB+-U;t+9k! z*=|Mc!=m2)$J%J#m1!Ef z0ggQ%{CW4Xz58L*_n%SussHPv#YthA^ZoMblNqGtE>kGDFZPMLMnWTj-oo;bV&0;^ z3R&ObGaEI_kKGC+B8sw}dDN(B49^eCXo}G~TDTvv6T#ue*u6eDdVal-O=@ec&cDWx z2Voddi@E$7^yF~a&zo^auofV+-3$}|pnrti7O01vokMjevEX~o8>g5%7jZb!DlWfk zO>H=9t&25V(tttZ#t_S}9A~rWv!I@~s`r>RAB;nldMYQW2wUs@xR?n~ zPbk@QWXYw@QO3H4Si=|?2T66Ukf8Y1zIZ}64626`t2%a>akJopbiJ=+U%6Qj`jS#> z;;(9PQA@s@0RlPhugqWf>=uxl_?@*c!Ziu=0C#a6WGAK4*eL#ZWbYoo!|%eC#fgDh z$%`H0f00s$x>RAsGn(r{|mSFoNCpZa}u~dp6l&=TzcQd>GYWY*$1sV6mIty6Ts|JszT~jkv18gg+(oM zys%~hae9Bcl#n%;ujXon@VMe7gBK)S<7#_E!xjZ$y=Orzhzg-M5RYU8oZv{Egr}gv zM{=ue02PF~S;3MFcq>BuF(aOHVCV!H^-_5PYyk%mYI|KP?!9UzZ??!@xX$>M=LfnA zm(~)jF;NdrIDOIQLeQIq3)!9w#%~0*8S7ykx|GQVn+o)pZ5BfjhH9otK_ZVxKRY$ zkWIDCr8@LTlCJpt49{gWwnwkVOf@bcb0bs!M`k{UnNISG&bquv4Y)rvBfY<6%P(90 z)F47>JfaMH43W?arAi$7OUbEJk|=XHf$V?pYtEP^sKGcxG)QZE6S4;_L@sAwezwf=)yielv4}~FvT^!$`k_*&w?8`hO(*DFCWLA-UinlW{5c0drb{tR9N-mjbNOEwGyRG97GHvgpOo2V=X3IV)dwo-gY-E|c9X zCiJ!uYGtwY>`51lmH~)&DQ*UWRpB9*r2|sN1mr07g`v&lZ56&&<+CTbTEoVd!22r>Wrbl;7!vGLDBM*Bs!RX%jKU;u%di>* za#Z+yoSDpnq$=9rTiWiWrI!$jP~17b|0?v73i)?e^-sG%(0doU7oC%4HfiIBDxI4m z)WDekvTwXc9s5ZFT~9b-)5U>eeJqO>zYJ>NLABmYV_u1(rfAuf@JNy#>7iU1tE>+Y zn>4op?X5_LCRS_GphiS485YM8V4qtdiu69(@I>qOZ~cuO$t_^V)o(gcxAV5M33o!e zRsc5DS^vblDF0UqE5lQcWtE$or8EZpUHfh4ue3QOSLV7D-Z8IOEGN!ifG>IsEzqgL zvD8}Prf$c=M&njQ>nfi1ThUUTl_M?N_Z_WXER4#8p{ZkN?j@`gVX5%rT=7t)crC&^ zFTxTLi`nZaHzX()UQtFl6J*z=pa@}ZGK}!;C`H>;EL1YsQK-S zcvWhPc`K)}`#p6zLq}IRMXK-|On{4GZ%#34eAagP2m^T9DW$s-0pW_QQZ`rdMs zEB^bQN3H?#WaT?Mu^5oDP;Q)Mr^ts%i`~ZGLybxx+=Eh@4uM}C!p-gI-y)9AcAvmW ze+(rWT<3qU}Aa9>nTPRu&P2 z7-i)txBU^#NamjKs(dX{b2Ec~KF@#FW`th1KWN<1f3}3|(>|ev?qWH!b3{ZPZuT_6 zrns{@7SbY-E=^tf-6sCcBPbf;Z$+kJhuZi7$dLcb;T2B3@s@Uo{H7ska+}{;EICim zC~1LkwLf8}nmUyQgq@TS*|OY!2|jyKZ24x49PoSCW@UfBfQ;rA*VClkmlc=YbO{bV zC7oz+;uCY@Q1R={tdL8bJ615lP|*|Y9fi|>&ubtl^57aB27IO>RXv_f6Clv%ffY4w zX*vUquj>!xyoI50wIupFP7qW`R#`VYpjV}rx%7uAUWc)IAqvcy zX{W6VjYoOXoUv0{-V9l7Ea%|jsc>%^Zs2H)nQja5bKVtgJFqxnH&ZZRUWbt3d;tMxeOwI=}~>9;2)Y(S7>je*TP8AClc@KbVhrt=C_ z(F3*Zy!nKS^zAfP6JBF;0iEiv0=fX@X6bGs4BMnC<4ZTyS3I7(NR|(FQ5l~j{+>nm zY23#vmgE>fP9GhKJf~W=X=h!?D?%B<4OB9?q@9Nwo1DdeFI>`^U6sNr5%i$fJ9i%v zg_r18KFGfEQ*fg2OgJBbhlEMcwO%tc8#fu) zK%YZ_cda5DrC9r0iA?wI=^tWBRQpZO*x>KC_r(6er6qM6z+oOl25YiQWk<6peQgtRHt9wnPg)JObEZq=MjD@CoOZ3p*c?|MuB?>6Eo@>2&|S%+Iar&Oa1 zc1eI06>3TA!gA1_ShB#8=}_PWP54d+4b-kwIqXwiNqxmbD6ojJw zpKGZT8Q*t0gorInO<7)cDfV02YND=5hVu8!h-(%HltIOuwZ9P#W0IcGZxZ$Th=;Iw z1HrXv8a@$TYHCd%)N+K83`lj%Ya`s`dW2E3T$(T_pK6U_m$Sx9vrd}i=XkD9oYotz zZLx+<7U|Z$A2c%`C%+CVgf->gaT3-?TsbbibqOLq7e1~O3(dM15qF=-*~E6C+yFvf znzH*^g6o(YB0$FxHDTCzl$a}=4FDpa;gg8)F^8ZK*6VKu=0A0lQ(Q~ee+ZM@@dNoh z8Evqe-k=1}46nd|Jid!0MATFZK+jAQhR$m*0t~@fyI7Qiq$s<=w%4k3Nh;F;D}OEq z0eaV61dTm~(inO(rGu#gU?l;|z59-WR|_QJs68Yr^25p47v&VMsJTd+5T zY&7asDv%rELvvc3bK}Up!LsLvmDMsx4hU}+Y+nOcxDv& zeYMKix7Il}xi)-?{o*ndt#%dE^Jn2#;IDGR=wDyUnyn?lS1-b2x^79etY-2I7(ZNr zcYn}NI~cGm8^t+##m~>b;a*fGG1Sqv#elK}fP}UF&xc%I*T3-N*lTf2k8g$$Lh7LI z$Yos6w9}^+J@btgefUs+a9abR^y-+q`*p`Gu+Lc4CE%y&Gc|N++S4eOPaya%cZMnj z)&RF-LAn2>c1$Rj^e1uR3EnB5nI8=EsyNPXKT!aLIh4kNe|U>5eq{MyLMD!w5INdb zl$)?z4R-q7l`}uOxT|F+=<|rav|_QC><9811D}>C1>@HdKw+S9`<*QIc=G`UUuobA z>}GT&1_gc;Eyum4lpp0a|HU#?@ow&g$Y~!>#yf9`MaICB_P8Bls?#gwMF8G(|{8Lj>a8=xfSO;B6`WHKEY@SM|kLNFQ z{vcNh*E6N6AJ&|-CGwVGFyzbbtb)u3I0|8MIYoH8!PmnsJ(1ZW6;}~|q}I2w=aV69 z73eC27~GHWgx&QDRt7EJVRW7@wV_SDQHM|n?)SN&aN5GKC}o_Mie?7sQU6i~6~O8$ zQzP-0an|0|SEpx%i7owK;VBSY{906{f<$>`XDkb)~7qTDtlGr6e{ zA>Ce@2uM6IYA<}mX9cG~%p$C(;krs``eIkQMB@O9**HaIXN7#Mhmiw@ug*Z=ud+7p zwR6u|s&a=+mQ@xH0@jyuO55uK!%_s>(l=bNpukBpavF4)hA4Kr1I5Ci6lc#oj!HEr zN`;m_F&3$xqAEO?{GB!?6%O_{=0fRx9VFPTGOO}wbOC4dwVJF-9AQ<7>@?jwtiKxV zA@Xi~C6&98Py9h8KVb)u<*PYSY2Z$|KfM*EzUhTvsrZT(X<@HnDnIYhPp5I%DBs*> z_vij`nVA22pLf5T zz{mUOTwofoUg>emB%RBolm>4TEf$pWQI=B*s~dt(Y7BdfX9BW~2^jK%nM?Rc75FSP zo9wumkJEY7*~f(``DxqnEl|6nXO;m*Ch{aVjj$KB@E_*YKMMSVv#goH#SgcI`Nm+w z-M>R&=Ar>!)@L5P3630g%HBpG=;5uT4;>4jfYJ=U|4ob;+pdZvMmCe&I$B5$|5CX^ z(U?3Ncro!ST?5m2JZa<;id?0vN)ZIAOq6Gmc%0HDYaqhN|5_|`%q@J!2|qKutfkjt zcDfnylg~DyuOlRDna^I@6L?#^9(c(Zh@L-(DbCwe= z1ZJ{Zd7fi0QXSC`DTHTZNdTVn>ILkp21CjxwrCOXWfh>WHE5~QLyRH&n<5Bf5-8Xv z?s9%P&=O^1&_O%k#4{0O&IB8GG~jqP`8zR(I~R0@r}s!CkUdzV#u+c zWMhk`lLsnhVqPl)gdoIuYc0Eq zCZC1O*dDkwjT2_%mTN;%19bu4NClvANyEa&$#zh!VWkNd@>3Bq+Q4_imTwam(WJN3 zFMQ33qa=8FBM>n=%;3jP1tdcD2HV8(K9GcNpqn$5)%szfujL{P{eaHi1wksxpo`LW z2pU=ZqlbZSU=Ypms(!A!(d}^Y$3{D7Fo8zIM^f@novICOWZdv9w-%S1Bul~rrqM`0 zid+8l+XgXiDc4wHelRX?gt|OJo+!p3=uz0rDNzc*{_bI=0)+Cr)e7{HcL|GQ_bqMG0u%P^8-nD3%p(FRb7o z%=3#r=TZQbH_Y4g-87#MH5QiH@Sup#(k}rJUz?}?#EY5a_JdH z{!+w_piWYW0z3Woat@9w9skC3Ar5Il8}75B7`Pd$;I>nGj(W_3aGcDC^ENA5xkf{7 z!(5acA94>E?uMhC*X0kw zZuy4&+dbw^?_oW&u{*8gUoQJ%61K(RT_D^MN?oXhT zwLWgm?_ZqKC-o1mM0@iA$SF~Op;o-f-=)rzIH6Q(A{9{9v6g4=h(4*U=jw_w(7Z{; zPmM7j%HGvzvD8sDr~Iu@S31@h5;H5K4P)b6^eXrkR>s<#NetT8`R~ z-|TBJlZKhik=@QRqLw|;Zz~^uKS?fAA(P7lSh_k1mtfJb!&4Mkg%n|s`P*BHxTw=N zSGY=_)^jU(u-*6hM@Ftn5K`)FW`R><>O+JwSf>~1SrODPjAhvcYFlwJA>te$V(S>1 z^heVPlZbroi_HBeMmJiF|98lIbUJH;-wUA4M=3$iI0PE!&xO1QQ4N1&IHw~Guu!Z} zNx{foQvK)#7|%8c3~@7Qw)D#%of_qbCx9#zy+UlAOO%9>nZS>b^RMeFG(%d{$z4|}0q6Du zqAQ5cipS9$l?+*9a4saj>V}&s-(i*HID|@K#lYgI%EFYJf&& zGO$4Ycf*0?-`+JJ(hNraK;4EFH8WeODtyJj1iyUkt!;RYJ$2$`MlZg3KbSLwrqzT; zDz78tWeihnk4D7p93U&S5T*8bpWtUodF497t-nAq!!J+jny;{1c4 zp0er#mfMTvFdi#v`A3K28M#EUn6TrnGcpcqm3pp89`V7@lo$EuI=8~BePsfMbBngh zbZ*Lrc& zYD~gIbkUCifvfl&Cdj($47M<%L%JU5~ikR%}LV7ZnaAQ;`9I(iuDWw zU6y~5VF^+&xvVzVJ_yo`n?kw3KmyY8mZ{P}e{)EL;2RRMn96fg*1gX8KxdJdKz{`8 z;TrzQy7!ZWgbps{nVd*gb20Y^c@SaStg>M?K#Tu|parTkKb9?o^j`h@Yf~`8`0JK^-!*{oX7z+kJ%e z9_`Ytx3(%y8kkb-e)3K)W;`~Y6?^m>e8gIvn08A*`pWZQPJ@LZn@J}+gFV4KYo;8> zq@f9HC0^p&t$Prqn9zozn+$UE_oaVIt+GJb)J$fJ#-E3M_}f#7DZkr+50Z z2?6(DY-88wHX=VHqEQ|oH`*&07uII35Kec}LVVTo=?UIabUFDyu0`fI2sH?wD5;+@Sm@d0W+Q zx;P@4JXrC==0cG~G{}hF!p;r9DaU^e&r*)R^4v_0&7Wu1*MEZ( zhscv+e?k^#{~ew}ibGC|LY~P=Mm0#U4|2YaJ8F!dGu8sV4Chx{MgiS+Rv8INXRP>V z5-n6yFyc*&r=7Vn@1pi2Z~FpKWbou$goLT!OW&3?acpC_;cmpkAi5zak z@z9;{PxE_P^U%!{AO6qEy4|E+CM6N;{EW#1$z04;R!OAHQA z%4lh&UJXT1Qxp`GUiC>YO~%uSP$5R(mr1U8;sKI&oF!Yu^~j+|Wb9VB`#g*7EY95r z2xzrqUwv0^!rEEcFFRKs#>RKw*Y0;rR7B6F=|@p=l_IT3le{xMY2&0^u=xcHo+ z(e;qKw;{6{2lc6WN_*~5WDv73KH`ULB8;r*T_zcxiDyXMXG(sgz(Tu_HJvQV`Idqe;R1Tz-37L3G^0gl z+cs4k9?51j$>)3$$pzXf4@c{{66ONFZD53&X-7FLa{-_tf;*khE4b8v^p-|uqY5o3 z1g!)|9AAg%od#ptV`=qF1On!zssi20j?gh~jn}<<%`9DG`}qo=RHxeqtCA9JWsOhn zgN3|etCFJSnUYg6JG1l#6Y|E}1eMpYNj7L<1Hrf6DoZN6Vzpvt;tZv0L7`fs!Fb9O z7g{4)VC*~-@5qcH!%~}ysoBC{@HiIiiq=ZcU-`a0VNb~53E|=hkPy67fm9WD-+ebr zOG_L+e3-$Yw~g!DU+rcRNY{laqp*ZA79SiQPi;!ACjFkzCogkU-*=PtBT2tI6IEXCL(><}@g zM)EPPhy+Wz5NqxP(|WH7sj=PX3)F0!C>^B|n%+pIOyWb95=VZ{6RbJ^`D+lm5>@t5 zuEBgYay(v}tkIgzdv6)#6_r-zOzT{dNlfc8wpAAlt?R^MHZgzCjmH_=Bw8$Fj&UN% zIdK_NFTfj!^aV*3&dddBS_#vc_e?{KXbl0!)QrWOii+G0F+S$1_KVxg7FS%f1eR#j z1p=S`^rx9jCLBC?kj2Hten9*7lxl-yzHthaPZSh)z+DI-a*fV8N0ADGrz?OPiGqE! z-WDg2YJ;GF`C$t-gMpSo)H$3x*ODmLEokM2lc(q>100&b(^p_RhH@QlH1LN34h`YD zy-HkXB2Dy}^KiO^BWK{o8^C{B9^keV--nN^!N|bxPvGhS+&7Wq98}Qks6M^#3Gl%H zFE(xU#+CxwaSnSPQYB$fQ*?x6P{l5mbiZFn=I+#3KAObuO#r(n6Be(-7DA|GdL3rk z2lTZl4K+lFf(XK;5Xr{huw$8V}vteY@Vr^%6fou4fZdB9m4uKIKAGEMJtxhn0ia@0)s4wma!94$lL_-2r8J+0S%gxekBX$ z!S0#MSSl(O^Qr-=eK3ugWZZW90(fWdkB|WT={A<24~2FC9Stty-|6-O4A_Q1Nc)x= zY5KYIaVZ&+OdevO9U7UeRCBxW1W9DD<_V8&i>IUnBNpJK+va9_Hf8En5)Irl{a@a< zm=KK;BghamdltM29av;uZlZHdOgmo9OTjcIw#KwoC@Ii1Ulo!PEDcbh_rfr%nbtXz zYR~~cBG^%>DQhdsX0gU~w#%%i_LfVjyA=2i`8y#RMHsH{Q| z$qHjAgQLQ;MTs{KkHe7R!VF^u8e;VA@;n_c-UQtRPnPAJJ$r(0`?hamG#c^FcfOM& zM~*x$t?s_{?MnW>G;p0Ms3;jJ*k^zlj!k9^$*~v=hOOGOklChU&bh!)8H~z{*7Y!x zMcG0o(nk{)7}>xmbIe@EAoJ=OA%0XrD+uS~irIWp1VXk{DlxT^R}ERzDeK z%#U9hSNJeT-UN#=u2b!5ll~YZ2t&NLGUFL$HEXj`6W7H`;26sev>G8q*ldb>^QK!IVV&SL9kDhAp(tP+#nWE$o6 zTF+NbY$4SjbzSrQ-~as-MZr7X@s7vs0n)d={Z#?^tb()g_j3w%U$0>HeF}EptYG{m zlJS4N0=)Mr;Qn$1$TtW<@Oj`_y6-h7K>Q$sFp%f%{zW)Gh64jwD8L!<&8LvTug@ekhv*LWC=1q~}pxPKz1*87lT#P_dDM*n)8 z?+rY6sMQVz#tuGG%KMSzc&6gvu|j26vXV52EYeY0!ey@qS_R9aN%`*r&()u? zzP^>rWdnrt03n#7ywV|kf0|scnznl8KE#AOzBfXMkSQ|$h4v+Sh3rAV{09({D<#7o z(0v}#{hZY69uqWn(-H<(X@BGS9}KWKY|rORfX{3Ri4o}uqO_9E+?_I1v3qpHrJq7ffMK3 zN{t*?)i}m^$TBdCa>9F7Ae@7fUI?$J>DYXRuDn?d{ECjzU(>^{(h^}HUMo>{%EP72>{)p~A<2I>hN!pyZ zO@3z!#!Cf71T5khq@TA|q^?pU^!R>eB!90=!Hm#KDWy8x%mEE%?*AxVW)+ZcD<`0^ zBDE0abftac`?0-xBsXEsZ$u=$Y?P-kew&O|P;jS_7D;;_gwSyFMyA;4tu71p;2$-E zR{Go}s+xV7Kw&+b2{_>btz<|D%#bQ0(*Z+#GlseG%!1rs)&z#ub8a@E^oCIu7+XW_ zVNxgH3sC#EnkGuS01R!0cd%(DfMF@@V#AzMVDL274zYfO4>S5g(<=d_kSeFLE;)SY z0Kfk0zs~aVGI^fs_q_)x`t}qF?6MWM+$k3A9xxd>IRlL=hGCh3wL(6GKoP{GJ3i}O z5OG0^kjI&oxMHMLiJMi<^1_V5$~;p#t5v3506v`qYn3O8$q_cr&k0~%WWJFXlB!4B zU0k@VGuO<^UDd&Em*q^K+`(Y8uwz^?KVQYTB5id8y0=vb(HkPh9Wky5-ZZaA&_2GO zYjQ=iSkn2w^gGB^(v{8HRxP5Mk+a%72GFW-yTLK~pRR>F^ zH6f`VN7|w2fsztFAsN&8WEJ0uD=sXh)k%ivB-ymmsm4e}M;%Njc8d3oHGyfJv6$DL z`yd^Q%y=p?N}pi~3^LF8GH2!txyr3f>(=-@#%h&|afRH$6Yp_qc%KK$m?<@mrLc>2 za~5MVwyF~Z1DRdI`80>OZ%<@g!9~Xw;DVzl3f}q7ck;BSJ*|aUT#&`p1#nJC z4iIDNx)7g@>oqhuzX2i`bzmSTQuVY&s&9{%PHVsh>ReEc3Ltr)2vuiyXw*=VF2PzI`#`ioci%`qtJK-g~_F4QA1|FY-FMAA-yGb;<1t znHf=qDO11BlHJQ<4&c?hMFZC5fQ}MCWEzuxoiZO0`nx5^lg|`lwM3^$93N9(;`8G3pIuDNk1|&+xnvf#8f>1 z0~5$?AZP{6C<~mfS}zyz#|h1ZWPmaRD%D&x3RYFYVK6L&B}y}6E38raEi40yY!|co zRR6fT98btgl)w@q#IUfiz+f=AD5;CS_3g@Tp3E+jnMGC6Wfl4-S0B6x%7A1@E25=6Wa0t4%tBmscZw+7h(LzpuaTdxKka6QHqQdJmO z$ZIv>c8%20JelDy-~-mi2VDSuJ|O|vHYd(k1CW5^E|GI|iMe`aJU(RG-f#910F?-h z`>%s(WHt@5*spze%y+6p4LdBBVrF1SK5I?4>SAPR=J}a}VdgQ7$+{3E>A4r`>lV$> zhY4ZeykVH99$$LANKzlJdc zma2vW>iu!uRq`wjdf?M1!EM4d5rsb`X6heI`nj9doO%->ah`zy|Es`lz`a{=>jF$l zI9~}VaW?|CKTkiqi=@rkzC@^-=0Xnuc0M` z{wDD2z-NK4LMm>aRY6tBK9;Wnv!orb5vF0E{&=vTffXz9)d09Jz@7{a=QMKMCoAz8 zvGMxOV0A*HId)$qWYVhMODA+(x3sUn1N@#Ia;ER!q~8-uYcd}zccLFitg0#WUg>K| zpA0LnzZd~xt;Jf~#}!Xt>j{v{)(ixP4--6&jp`_S2Uk;?l7Pb#$OBks@w!A@$Fpb) zhGgpLU^rZ4jr5fZuy5d49eB-{7l7EBuniojob5a`BOsk>Fb7=>&s@eRb1dXF79kl5 z>#$ZT0wOuFlUGOgHPgsmgd~eHHk&*+g0~{Kz^ib9004r!NklgVr=WH9JqUFn!`x5rK*Le@Fm~!q7FJ z4^qJpD?SWhlm2)NS>~x?^^FPSX=RMdREk9M+v;G*w@GW+ZMx^qcQv}_L8Q!tZRAJ_ z9(AvY$d!e8FAz1UU&w3fGGpc~mCMMj?^GC#>zt*$CKYsX&NN!{vEn7Zw`WjOY5x~9 z%hY8_7BM_B^;AA!G1PcAd`YIlCl&%Q{sCwYQC#yTloWDyb%M!8G$UNMc1uey*t3_ zGA!?bP>V!(HG#DaC>=a>46eNvCdY+%c#aVtk%I^UvZcpAtzh&E_4gZSiJC8LLQ%_k zuPWyK399qX%sPXlkEHN}37j@|Fl zny#Zww3t03;1&V>%fCf+nWu z+Y{G%0^~A=5tUz;+B<4Ow#p{w8JQJ=PLUN37oy>0pu(}?J-HoWlt|d91N#QDDjQia zR-`5d)5ND@^RrebD5fZw#{i}$1DLVLLxIYzW{zLvvOI$n?{PtsXRiv z@C+SW$dF1?g|1Xp>_bY5xX?;$#6@*HR+L=wVFy5@0Zn!SOhf{qN-95M_DQ;Z1d4Y0 zyco|XK!*|XBUXo`T90vDvmGj7JVMw+AR`1r>9WRyMF^Y>Yzuz`ZyHn3xQ*2+k~prZ z)Q3;R#DbP~dIc&Zqq6m533E~s?e(?^7$?ArQ@~-6IWps!)`O;!#UyelPNh;tiE*in zt!XPk8Xa~TZEvTBx{G;Wt1@I3&dx%6FA&%WGfF%I1wb5uDJ8Y3)qlrQQBwLXDtDSz zMZI6-UY>x$U$joT4~{&`Id$q3uYUEb8I4B#r~mYydJmAkJ!Rqw=cjlhYSP9OY=!e= zW`Ni4QcX@pja4Gp8}U#YsAOYiVYvv*G_HuiaiwuZOOTs(QKlM4wYO9u z>nzmbJYH*Z!otusu|w%H7PBsKxnp9*-0wayBad-Gj4NEA1D>Z9tR!~@Gv=HrSK?iK@X)qwV0iHg(*r12MvqYP*& zNtT;-eyp_$Bp$bN8Ah)|h<|sUvtgT5SBxc71M~Ded{U+3)blIG71qpM)w$SW{vK#e z=(Q@xdKU7EQT;Izsxy#28JF0vSfLVQtU6d$2cFYY`EG31q4lQ&rlDdQ zYI05Cu^47ll7ylb=@nbd!Y1BtHnC8jw|V*E3W*3W4s0+6%Btd9zU5meih`f~$)Ds! zFM3fQSM=>E3HFtW^TxSt3jxX(Xe%G0SAtXUcO9upIHXU%&+Ff>&}|oiZ%2rbUj;r{ zLRg1f5&wOHRdXIm*gqHGV>R4fz-KC$Xm#RQ15Z2DMU9W27L|+~IC&m+)v#O(F#oI| znD;WRRMlxD`#%2uT;Qd&q{6wW0Ql5Dk@GP6&I1nSqJ`<_8g;R)*>xov7w(^ z*ZoA3@1geVK*T-t|L8puWzo**wtHwRgcg$XOU>S+S4ymi7{<7xmTSLX&;4HDR|0%f zXywBfP}wZtp;`!nm+Rkq5t0V&(@XT6r}}d*@D&SSR^+5jjt~|T?eJ7D`U$xZ#z3CI z=e^wTrH9W|aLW+xtR$J}vwF@4jQHdcz--A$dLy5Ej`sPSj@cOar}{nkUOn^&fZx{p ztfuvJ8R|8O_oYjIkK@?4C&x4KVkC7!_66CjrvtPr`DAxp(IjKg_` zW(k935Jorx6EkBej3l#9D?cW|v0@5xYnZygS_LbGVX2Vp|EZTn*dWoncDm@VeQQAO zgUl$i+A0~LnQ=k17*mOaxS)>=vT3S!F2YKE_NFk0|VZ5k5LDiT%yTkBtdr4<@U4^SD9{~ zKIAGd6SzglkS2>Wz`%56FH*?84wgYeVAP=nIWpB?&xs_9Z}hkuz$vIaNJ6N@ZK7WI zydO(SO2x_^r=Ua)6t;zi>I7)AY8sQH@G5AE?4-`Atq~HBR({Ke zIjIpu!pb=@Lbt$T7MRwC%{nl4qQ_kr*z!)sPV*-+EMsO=re+uxn3=J7fA}+uiUT;m zMeR3}P@`{8ctCN{5?GS`Uip$kd-m|tKmF4zEiG~A&>_87`oN-ZPnA$yp2(y80ON3u z1y(5-VihnR+bJu4si7>6jAo#h;NIxfsmCj`X%)9P+xyk5;~^ojWs=&a3Er+xnB` zrX@5+Hf*crw5C1Be2x%_vZY_YX3Ltg2MV{dy&eN*f)|X#?X*MQ!ka$ zi@6YjTMo7=@ogJe%hY+Q5Xi_hA;VCbnwcpnLV-69OUAl8jEi(Etxhv>;r>T`dnp!#y018FN5K%}u=slfDU zoiBeAxPvyi@Kw6)2P!B}%RaVr-jT9YB#uzc(Fxx)d_PDJZ|7KWFtE7j%Va^?X0C{o6u5cfa;Ao;OMa zj1eiV{j|o`B~tP7GNcORISS~11~E;)6R8eZ&|^J|R)*|Fda5IY7`jjQyH~;aPtjr% zQKl>=9Y)XZSiv*Y{%`62N`!QZ5?j~l@nd|juE#XW8(gF36_1CG#X%j5v5sG+pSu&0 zsM^$h9Mabp5yIdh#L)g0mGFGF?mv2A`~gDxypk5LiTCJ0_ZOWzDQRX^Xh(>{g zV1s&dNHs!7ZYfGDCDI1z-_63jk)Sdif@I!M6L!|@j@hI>0Uc)gw*|1Cah;32RZ{s- zr&*l7D@PRq%$#X-7rh>6UYFY|@Q^O{9j!KXBXtq=w{;&?JTGssHjq}*JI!{85?hW| zGdp<8guqhfngFKumSNTfc%nR6>`%c3{5>8;D#Xm`WT(WLQO)n*O#>#m2uuYU;%fdn zgpAZ)=M8IR0j6t&KFmBzSzFyVRI)rR>GvuZWpuu)eAbBz>d;DQ!p;>U^K(y> z>D+12G9Oo@b4;C7h-B7NxvcdZv!1cbD81>#ENP;L3vGo*6JI$)723F9C%>>Lcje~# z8n45=Ea!X@hSN;gG!dinVgetMzjGVo3YZ$pV&=PXg^~p^uE;W%R9~2FvR6FHH8Gv} zJM8?K7*_-X!8g|+1~OSw?V1XT%4c&E7-cn8uw*)h>s8*V0`VbZka<>$ddFm(ScoFt zIJSv@t&u9IlQS(&!C;N&H0P*{!)hwd0*jQU)_oab4o?-B6(69@qi{U^rye|#@P z0R7hl*-u=Body0E;vIvD2V`^i?Uvn^py+^?8l1_y+Az&uj z&%MCVgH=thx)PG%5ro)?b8M`xFtqYdLC0YqQa})m-M@eU>>wcd3PL8_i0nIp;<3`A zLaI!T>FZvP@i~M**@uu1E4uBP?qd@H%|8JAZ$0+Sx{rf;U7n}sa+bE@#L!BaWLQI{ZCD@M6;021S>;}FSF%>4k0@sZUl4dn3Nbz`-ja%^j!r(;%qy)2VL0yx| zHc5tM9ZV;iGsTwrV${?ehR{Tt?B8d;bT3@p| zKDm+4#!1W4W`E9{p~zw;BOSBUI0L3%RPPOyF$62VC*Fk4HTD4Bs`QoI5W)BkIfY*H z@F>7Vh$xg2To(d4=FQPvuw;l2NJ#FhIPRJ8>?%smmqRk^*{Xz`vIYv%nwF1ixpxOP zG!-JEpDxF$T-IotmrZ4oNVbeJ7}o}443C8KtlM>kD8OPlv^& zVa)(zEY^&a0O{N1c{+xRmcY_Gg%AQ;TU#tIFJp}9wYvNE6l#tTGQ6*t_)`Qt({K`( zm+UI8Ca6938Kx9wvGaX%!LutYFfbXJ4J_oA5PU<($2nx`vJUA!-^O*;AwOblF!hF! zm3gkPo`t-obQuFBMNA^E7~>M=fa=GJ8Gs9VfHWhdRUI)gBraOpxI(H|E0y+aNP{4L z8b*Vh%6BESM8e86Ppw+38)IDIyj0gTafNP|nIxGai7R5&2DGF|u!*l&NPIxNycfMe z=HrSw7>rNI0Mn8TXgoOc@~n(i6S)c4c>X4IBsZhP*?e5F(*n@X#T8*r@+ze>=HrT% zypXt}d5^hj-e6c6RGgg+F-Gpe%8S%kX43dd{9A^a;1iO>oS5J-aT&P@O>7r}RH!WF zaJ~%W#&R-jan7t0OeFCvXZ#5Qp2CbYj+}9}yq7vmC9c3=%>d`O`p480+)VPEyA%nm z_}mxvJ_iN`@0at61 z!3ZigS^I`V9!dbA(n7?Lpv^Iu>z`ekMX7)U=f*Uq)Lsa!kkfK7`QNgAglwwEv$0 zJ_dX>tzXIwNcG2t{{0cX4u40-J6$;zdj25fLkTQec;RlvWnwTk)eeqjpo|07MBeIL zXtA4I`+E~Y&ir>h=1%}`(>|WnpX=y;?p45-B>nolj@gTKTt2VYFx8&O=5Vz@FMai7 zYiR|@#XLjf+UPmYc@DSTc3b;ik6C=tTTg&o<`BY%DU~x?67R|F5|gk-2!^3qW*XL+ zg-xVSgVAz03?$ykp##RDS&7|xm6V%FOZ-M`Y0E$ zS!e9Z9jn7x^IDWdNEfp;rZEVOCZkc-D&5W}X5;AJo^FMJ4YsKR3A%9#)BDghEcL-E zdC>wTWh8k_Xgnoy6Et%)k-0Ekz#)PPsaMGSq!Xnk>N!pBlsqwW54X6jS6%LyVVIfj zXLFloDyn3bX(gf}&`_A52CQCnz5`V-3=&=QTFHydwp!UfgpT%kI%aWMC`3{M6dBWk zUlG(%?e~ye&nyXeo%)KT<715%*)+W`mWqn8%h{+3N+myPFWwzZpA2l(IfFs9Z4v7c zi4}l_%v04OL9t#5v9&q~RL*iTtg{iOEU>`LOtDz1Fkv&CA!opX*^Li1TmBrGU7&Pp z_%H?U6{P4Vq00qTgy1e(0xJa9d}H%G$6DJPi}md(_x%Z>q;{KFJ0Re(X26V1HvCBz z>?&25G7DQQ5Lh&;8dnT4WE>6WxQ>17D5&a=Rk_Mf zwy)O-sJ&U|tQ0jXMb*S8)&z3v=X@qiSE5m4hDOq|0q)y4bQ26Q z|D=`BY-7b{U-6!ZGF)?ni!q)MES1Y}!7#9nVdmJV3Z{AwHkHB2>@u!1io$p9(HO7I zG_DwCUOd%+Eoa%P47&=L)EVdem=k`D5hF^bI+iu#a8_cgOwN7Np9GtM0~aI@)%i`> zvHqlQPehZL!d!G*5R@$J2#N6X3VKIOJ(3*JQ)3k|Gyk-X%Pu|t zM-T$wkd9q64i7qJg`WFENJYj#kNFaPuhxAZ*1pAS@p-ycqEV(SN^@OJ*RXsYAvo?s zh>fQsK>rG@S^aeg5%T5w_bEM()3gN2!`lDbwf`I1=QDc!4(tAQAq0#C{v5dutH9q% zV!WAt?!9`h2lVx8fj5)7j&{knDAo4qx%+eq+&U7BrBZ#YEV3rCY-JJbdAT2kv?Nfh z%&GP7f2#Y-_4nrjU#HhS8kw)?HHp41PVcofJ?8_oo--fPbF_2#Qs18BaRrw~fW+-5 zlL_AYMryNfU;NgO@{{6=Q({@fngN4+7Y0M^CKUMsE|g>xjKg^fvq(Wf9tsSGvwp&A zvB`DCGA>BA+D2vAKXi?uWn8#peJ7i8R_9Ed=F`PRxiOtgyvkcf`An)w;sRLZl{&zH z%NpWj=Myxd72Y{#vt#yP?QJ8E6c^}{(HNS~r4O9irweTXF(UY$o4_=YqsWp9mlUvZ zeqK(?bTV3;mu!&M-(+Vn!iP4?Ff;SgPYPJX&)Qax8$D)`Nj;(MG zOO6kYfTs#GHE7?34<*je@S*HMiYG-#5iaR7Aq2+bG1giJgF&B7(YL2qljI5$a;(W2 zWGfglDmP|mSMZ@GBd27-fJHJ2iZCFZlsT@L|UsQ?9+KsWEWH2-8yCUiN{!-#1*A)&CO$SMd}M?wF0;KTl?VBxFU4pik)T~@umpPXY5XK z1+8Cr1e{VrrpQBQe<8UZF=ptPmj@WwHfhFr+f;p6-G`-H_1X0UNh2~N=e>hTyw}Q% zG~Spe!77)NMvf&g%Y|Z$ZDK*%z8YX5_na>? zwrXLR2F5VT9mgi?WX58pjueMu9L_g)st=Aj%m~3#*hL6IrLQJ!T5{hmb6gQF;WGkM zOs7-wJnw;uzV+=Az$0#ro858)rjMOz!10hQHcMx*33zdGnoFjd z>4E^ug>Nc2_6?^DGuLDUric9L3(z8NNwIY@#1JTXg+i}ZO}i?GAr zv&3{Y05fMhWJawOA@O`&Xyvb(AScPx6m!_^53E$$!}&00_^CbwnQ6d?c^{Kf5u0Y@ zjL1)U9GFG;o*y~^z2G@*tIj7m3j$Qk=#cI}M9J83w+Q>?nIX!X=cJ2zM z``rmva=}ss-w<&fua{sH1PkmNmYkT38X{(`>ShjG;CyL0xKKCdri3)ZwD}e>agvQ{ z^!Uf!Ix{n2K#o_{_zm|k6MveF0oD%Ls!!wmO!}ge?|kPwIdbF(-uqrxvu{tSklgz*HeXHR3fQcUQDiFwJhj_moe6vM z8}T?Cj$i_(+&W{o!cs24{?y0BfiBQLsq*GAX@S#aPUX9mlRg+$2abUf1*=8HvPxeK z8|kaC%~ZQik{9BBfMni@^H)m%#Hx`D+85(|=`xB$wk!IA2*J@M{e2QsNL4`A0r6)l zyBXt_D6>ft!<4=oqm-_j9VYL$^L<*0D)W2jjJ51n%-3VQ7nuJH%p6T6C5RMOG)Iru zrLal)y~YhLi7N^lT9Z^Iy3(W?H@A40_vC;JSA)hr&&MXvP5cPz1(H_3dc7|S>pS@P$!7P6A6ytE#HvO>cS=MN#mMcf5ly z`?4?VLd@+UaQ)THlfAg%QE9SRE0imxc&v9}qx(s6Wx0V+edN0_Gdw z+x5J5(aK@jWX{~wV+|1yK9U7-yJyf6F$F?W%(Rb{j@7LQaS-QctDo7VtwebUF-DJa zW9tanbd4S_j(yBpkF!$N4qZc*tRdAl=MdkEO$4le2HnJ#iS{{G{2W1~y`s^2GoP>Q?&guME5?59b1M}}e{8h$!toI^>(`)p2Cb?I0-FK#A5vy;O z5ucaiw9;vPyRzD)N?`f9XJy}>xYiROPm(Yp;Hca>Wtz(vDvT)<*sGWrnQj(~1BBqH zaUA4&&dp%oNC1SzTnxZ7(_(o$yG>k=nPJ7V{6o+KZ8vNj{e@z?}zclTu9&gD3%ILStkaF@h*ZVG~KJ zna>(6Ym~$oW?j8w6MSo;uG^*NuXgSlMeX&Ni|LaLUlL$w9$-oVB?CB@QE0Yi%!bXZ zSAP}(lvaw0^(trLa!My)%`j{4%i7C5wpm&B44q1abamkaFmaZV4U|q~w9d^u13zMB zj!|N;CS&F{I6M6uA0W8nf`i`nIZ3=Na2nRG1y(ib%*u5}$Ow zo{KBC&&js&F^gj~Z+u>>y@Xj1~@xm6NfPw&d)eKyBp^xlEr5R_)s>HP#Y2T z6{Sy>xPps~EsP|p<$2Ce{nSr!!womEZ{NN?uIO9e9`(D{ChScR|AiUijj&7S=)Fib z{4oWjhP0IliwFRJFRkf#BuO6B*Po@I8Lj9n;lSkT`%%ejTQw}@B6l=(a-2o&M+Dj< z=CE7)74PB{kyfHA2?>J*2whFhUe;&{j*twoXebfZ*!6ZW}8K4kI8q2$}F_ z+Q;SO{G*&wMJvS=|NcBe#smaVf3EhoQJO$Vl?B>L(I^wO3n4dZeQ%_H=X%UX5D zgc@UVj2W;|9>w_?!5EfU{q6U6Mri4zZn z1EX1MzKIcI>pAZak(06=kfMEE-m8=#Nx_1d%P3tY(pfH}^pZ{KlAp@kM%Kv(L+LY0 zXB%_#(q-GEnmk>JsLFNav?6HHn1&`*4v~QIb6<>|K!^w$RKAs`*lDuzAyE0YqGW#g z8$pn4PP5z6m8_8dgroU_vt~=*j$7qq?9#EV+z#Wfy4saqtEtNy z0xFJ^TqE0jCd>g<4kOy%DY=q@C3+tn7zzltS&O-B<>_44+yusThV$FaZROszDhC!A zW-zYdTv@Z%>|zhQ@JOodTKNSq24kci+WDz`x0LuWs)Q;&5nr8*{F_Dx^;}n7*KH79ANA`XJ_(6AE|s) zDk+&(Z5jhx>cbRgsA#+`bG~8hEPDrKC+VV^lfi5}x?Hx+4fWAO~HeDk$H(X zoc_dl!8h@N%u}`R#u2rW8$O9U(*C$aCJF@9w@EriJVrDP4G9~Kx@DA4C9-) zf(yqL(8LuXsaEaud2avpoHvMLKCWor%au%U8iz*0sGgIts&wp{*uskxYWkYQIPJA- zUXRzv*^W0{Bu(nj#((p1h1W4>j<|~VQ(RQ87A1uZtW}v-(UK%`NTNjL4O3^O(v~(` zHzv@`ykTGi3sz#elMSy)@uK!2BjNJ-8zYxj7x4?5~P0c}o{AiSZN1i~o!KgKu(0{pKc(p)!be~%(W!YM?? z=y||vwLdZWU{^AqY#~(uRq}p}69%-EAS?Pm2#9}N&;0>faNg0y60G)n8Szzdy6p-5 z+@_A_tq52@(|%5A<+i4}p8^_r>tPZrc>UaG5W;B}ZB+)el@87PtovP`+jp$UNO2aI zeC|QuHxMbSV@S1!MGWJA7x+JV-3N%L%zgTq-<3(GpjsZnuw? z{K$c!h1DYI;7Np`uo7<#2QbRuKkMf{hg2x-LMlS02nqHf-RDg2^H?#n4mDwYG_5oR6{R+^#r%R0_4f`1qv0$h^=PmNbm%Vk-|bBD%Q(~*)_b8Ys?`s zGJ>f&U&5i0_&vx(Z^)2}k94v*Gs{^kO+#)>yw1t@F~Nd(_rnAfoiBM2!n zb6H0`B32BfM9I9rMyZS2d5MhDWz9m{#>Yu4gbCXF!LB?eH?Axd9aL|3}f#kfr# zSAGtHm?e@FL4k-4P@&*_d4U-!f)>_n2Q^%bBy9JASb3V2~0$ z-m;KLd$>7iiTP@cc_OCCY;CevZKpy+zz@r$7L(Dm*~Cz_*3V^0mgUT446G+NzRB)& z!8S(lBFEtwS;xR;jZaJEVd_k098OMi_6D@BulyH!L2qE^=fl~4W_J> z4^p`?nJEay6GGLDg!eVpN~q+0MW3npBv>h3Vn86<%jagZSwnvG9w2>t>IUYqF2O3f zD_^n@Jl5)RH+a^{2iP;Zg=;v>kb>Gc&dv<`hJk@qkUX^2b|J~iKVMqTmh-ZNu#iD! z+wDe~Z=T&oV}{Dx#>_itT#-WF(V#p{W~iO*9H-J`i|7L(9~57!OnoIxoO4XK@`7|R z?(JMvMteq?2_1j1OhNS+BgjwKmS|B2S>#4?#D(o~MeQa2*r~-i-T+`H88mT)`oVSNGYtddvBt1qmDCBa<&t{=I2){-H{EKMNo^ZoW|RdU z90w|ID2(SIdpTz|IbS`@q&|l=NzP_tvh8)=G0FF?Zgi zL<#?A>;_xN?;@@0f*`7H2G;IAWIB{QVT;#7=7=vEJ`&{mEN z5P*A(fc6zqP;glLSR^sRA|ks5VovT5!epr1okalsA+4O)!?dQ{Q{Cno{b?`_#5-f3 z9w&N(p#6GSLBL(fImGn86~vG}I#xw_u>Ygyby$yk3$5Qpqjo`?-F{yCRM1v*)X8;> z6*aYvccs_;X?lHfgm^ly$2fpgN_<+cRV0Wufv?s597C#uo=xkYvKRPL;G=qN145px z>iJaKk5PbAY5_XPz@L}b7Bzq~2F`oAcfUlh({UY_bICRPu-@NG+Rv-BpG*23h^Fd1b_VoSAXnBp>(VMf;x&$5rCFBsgZTuto&b50eT4*=wViA+3A~*?GCh;( z7z4Y+Lh&rNLWRRq7|YD(*d&#&4jBU*nuW6qaCEE&p$WOLSsSie2ozQbi`_*<=`2O& zI!2N+mys)&G1Dpn+Z6ey-XBqtU~VB#w_*h7W`8!)TI~W_Dl?Ld%&=W%D`ti6B#%|A z$3^W;LpH=dI^SkB`e>Lor@5W}Uwg?Q@dT_f+k8Mm0!&zAn#v9V90FyKBM*`_lQ&vr zl4K{&oP0JC2Kii{j5Fj}>%|hhVUU=IMzE-3h8ns)9^M-=CA^wzM!=IunkCX;QO|qc z4VL=9tjbW(5-Kmzg=uN(G4k#5hAS5s_0t}`Z<*^ z^4*XMn)+v+({`b?!BO_N;V8@WoO)d|}d9xhy1WXP-RRU>~C=gm%BVVbN( zXy+%Ffc7!bqNxy3W9`zLZd{?~-ujTWPqXy@#-mNi4;PZSBE-s(_I!iJ6*h@0g1&Ds z58+!!1^m4iaCF zkoe3+Lwc7y+dT&FGO|3V%$L#gZ3U-hK4T$wEM}fkD>+Wj43oO+k+M~bMCMZ7j=>skV0-k~qLl0}^y(;0=IrHW&Y)O!Q4ti3pv+!K1U1=Xz^sR3f|IIm)JRze$ z!yhByafX1#&q{#A5%5Pyg~c;yf&0%Onf}o<{2HKe0>(Q85I+j|3~nBZWYNYZtd&A~ zjA-)^qe1&Ld9Boa477hk;7<@>IvQ#(#<)Ybv$P%|OL{I&x36d`872q`aTo#gBN1~H zxKZDa=I$RzVv3rUtT?a7jD*Na5>rGXBN9Qobl>N7yD~ZN3<2Ix>$SN~_qU~e&h>hn zOZK@3sdo4rLQF+0ol|_=eYeGf3EJ4U8e`(doY=Ol#!edBX`IH5Z8x^f#NyN^>ZwBXNZ4NzTmA|pN4tlnRp!p_`u%L0Efj;#>RheP zbjds<$HF zE@>3(hVx-*^+)JD_{6y;iCBSO9qRzf@rO6k_f57K#G!|&nQ-aGJrH!Hf@SmWe;AqJ=rAfz(zf5R<5lPISSc+_PnDH_??-r6!id&NX zv(K)H3&EY)DLp}Bm{WnK*QK@y4aLf|>yV>@NY7Ht*C6uWQz_4^)qZ+?<4#q&<63<@w|&;lVJ(xGf)4~tH#~*P zYtdFwL^QAC@^124@fW#^|EXQqeoKIw4k0shICbO-Zg)emsyClYcLkZIOg@Z!GuZlVr(`ST>eqBwtes zT`JqR!F)}}t75`bM}Z#ho@e8;b?3@vEMQHh-<$gMMfmdvEwq858k`5zI%R{@DE2|+ql{uXQmjJQEnBKJG z;RXL7-Rgh&x}mgx4WePWHCE1WFHA*jM6p#5UAHv|)}}z{Q-WP$P@R~``XwC{d-fB@ z04{N$I~@)fFuOwS4a6^*>NpP|y3K`lq>iVVMP#zwD$Xf?xawuUL7!H4-J6kB!|4yP zE27C?$M}j^xZ+1j8B)R_QhiuQD?{=}^vKfRqIkm6<*WP?X&I_#e$;+4V5HI<3NVvx zE6a^BP5zT04O{gqE}<~ejHn5ICjFI-rBv9#pF9o@xuHs6)pwApSPw1!ABlo{)mX8B zv?_bANy6T~KS4&8J{GSo`f&lRQf3(t zm$n#l{gY-TfkggRTB;?Bvy=GU$ITb3 znaMCaT{KBg@^LNzcwnnhIZ z(YMaiV#u4WLWXP0f&<0nB zIaiHqmD>&-@!=4lrLbY3JFu2QMO-gQ&?aW}$-?(e&|L{V@evQPY$=>KCySlsQfdGc z(K__YWfGSDH>5r{)(H*RB#~g-sTLJF*=S&sDCZr@l7LT)JN1@<7wQD*lq!*?=|n53Opd?i16<7N99spLs^n7*DoT3SV}`;XOKWO4*-WSNne^JX~Y- zXq>*P_)gFqzVp0~YaH9;ij}e1&+htaP94jOJy4SA6Vb^QLb7LVZ6(ljWNlp&w3#E4w_V~bYD-h(5EZ&~H+G+=*+FhZWrOltjs?EpxH^&EKv2;nrw%lr(7@KV%aj zS2Vl|II{e3KHAH`qy;5o64Ctm6nCp;aIY2vP-hOgA8_D@{2}f;Kq%f6MsAQ24TO3O zo%(UR?e@bY1F?ISAXF=}eh(@u1CR4`dXx0{w~ET_ zleg~KFL^o)KG^iP_d-r2vA;i3N)2l=yDQvfqj20}(Qx z?4sGWSd%N&4tgo^*GqAR<#*G}d5X!AuN%Qi7##%^wpm>+X0&viiUb5b-mTIBuJ#rn zn~9b6{;MLAUCd+9VEZ6k+DjGfTf`Sa={K56>pvP@M7i&MSqcleW`oJmgf^0xE5vl} zv0Yc^(pLVgUn<(FoF?yiH^53t0i?G_1)lO=`*q%+@w3kpgCy)L?kbwR)NlllVQ+)y z;%vM|KP2{2&0H4fA9a=dt85kksMe)Ea<1!Hueh~y61Nu#v(2TcE|@o4wT3y065AR1 z_MABR>)40;@^JNvpo7O%9q_!UwvP`|ge}%e zcjhRN=10bL8;41C^-#-S&fVs0c3QnfvXl<54u*YK=k;XAFJ2_zUCkmAHWs_tQHIi* zFFmDrglxs7leInbef!-wn6sK+M5TXqBnjOEH7tm%c%+%suY zTx>D?!$F&V1we61CX!B4*n}g_+qGSON2~dyE4(p`PHX6fC>_=OeGlD=^27!}H4p`b zH;fu1k)!2-sT*lDdH zzRB9*p_`?i#o1$TXADipKM{{PUGrgL)%|6yl`wu;!=~}xZ-isSo6FDMl_C_an*PY- znVNs4rk|UZR+$&B!*Mprd^G7c*KcUlxT%2NE%b$tz?86W=foOJqXzclFB~=G;}M@S z2P=a1n-0K%9WBdbGD#jt`M)GB=hAE9Z6EYt_mfmS$v+6)gLF-=3s8ne>r-DJU;-Jz6zoLwOH^2Kp=qP_K86?3#o($H+EZf zu>_f=N{&JS6KvR&9xw3?7+`Q*_<3Z?1Es;-Vt4BijqoEox0uPs^jDqfMV!-sd?Ex{ zy6Eh?q(VHy&*LwSx#pW8rmwtv9)xPqN2sh0<0^y);K51uNRY!3VoQ z8l{u?cV;+3ND>hYjlzYhNw_Tc3QW%ubV~JTFUpUoTImciNJV4K;kLrD@u*x-Kx~KV z3F}~%51zB>U{ecu3<^5XwO;g$#V5H1s!fW9$&b1a3gwIlP};#!qXs80 z$4^arogkJL%?}N>)76rrYqB=!?;WvOc-yf^aPhT?NgBHqM^6zd9u{R`SVHmt&R|G09%E=TB8XNSs z0rY3G`pX}QfV+Bl$FA^rDj*;}4vR2Xr01m^t!IlOQ97!j&R}Y3DIaAB0J&j9)Te{G zbCASKT>G-0Ox(SA9Z?O2;=op-c~S~tyXMX~Js1%Ui40kbH$S8Ehly_tp|<4M?})M| z#;Fl1RoQ?}7jA?*NsvkRMqkkqjs7f|$qWg^E;X)Ra;Ks|`&S*3e96qTlpBaUs+A`L z3~O$+3%|bd1P`xNUwUp_O7TDB=SUWrc+0<{nv`$!oT4h5{dt^6u_B?!Yp7cL@JGEV zp7za^+tDuYjo1p5DLQ&}d!~q#amL0NDMuAC(0TzhfgjuaE@Qm87eG2uou;lh9`zXq zTv{^4AT?w}j5!W|YtNyy!hTUcsbAw)o8>a-A|U2sO&SmOO;5ulu2~fYKPFCck-3g` z+e%WcIK>!pybuoR=>8!$?@{!VcKHEnFeJCi*vAsu`2#he#V-{?K5n2ErY2Y zr(?1>Yq+hpy^;GrZXNToPNKpe#lTI+raK0lCdkws+_7bkMRPnj?r|mJ{2B^r_Z+8Z zMX21n^O4VC1l@+J@Y-O-TK7uXsfN)!#E53` zt>n4gP#8ngyb6O{KzJNtx`IepORmCK%YzuoD)^KA1oeu^pXW~|Jj@HlR=Nq3qNGw- z-yz>?UnmB#5|h!|oIgM8jab(@TFobu2qI`0U@?yRv1hi%shq^5$4!eXp%^>K z6pWiRuM;n(;Y1U>`oJEpmH6mgpYzz;VzM^f_yF?JEOBLqHL|8V=NnV2onT@3hW!rv zhivK+RsDvl2NWCP^gVDSUkIzTju)swIZKk@5-$A{OVW`Ye4gEgyr)q5TzMBE63Ecb-@MIY3cRqq}Z^$C>{l2;( z@xa=+nK<&fT7o0Dsrji~xq~k8!WS}ixqjzW>4QmZy+naxsiM=i2`H!s!Jx%Dvc3u3 zp&O@m!rz{9cF1&Y(BVJ(RP3PefnB|15DXD9o8jxdqYd_PYbAV-XRV4&SP_R^VdHY5 z5CBk5)@E5CvZW9z8$?X-I(ku~W96LlZXC)?1tyqj{KKzlLkg5yGK?JVi4?1mmO8~O-A;tj|6Aoo2tGT(v zmq^%#>8S}pH(k`y@~3u&$)cUV7a+@_Ti?}DK8MeX-zum}v zQ_{~j379DLaWZ0X`L*f_6KNd68ArTiiv&BGJz(g>&?!y>5R{VmeJk&@Bs2Mr00zr7 zNW&vB$$DbS74mErjO>HwP8}soHeb?V*)bR|N28L5j4!mRe@v>2tcyER$3}6~sKYIm z_edM1@vJ)ePIcJObfe22 zCC%$UW$f+#B`x_$lxV&8lvRIRa>AWLAnx6C79`Kxv5?W>owww=OUawlWN5wYm?S)zcGW`!md4;5lcnzUQ}r(;T0s*;t}P$nlAgPsU_7|B zpc2EU5W6@s*RDT4y03+JGtjOx6%}j_ftdIt@W6v=z-j9~B>y3y#AJm=MwDMNkl*!z zuN`#9UG9gSFs{A0JLGx_HYBU@Kj5J9lqzq1q6gP3=atAq?I1NmQd>SYV zVy*lXlRqs-qnAP^H60}vVJJN3tIS$CbQ#Z)I=#IK_~@3Y)~omvj>V~@Z!MBp>=E-J zAR}~OV>yzz-0dA$GKW8{!+%(?x}+WVx#U8ErR;hOFX9EG4K+#i2+rM>LaI_Yyjo?DxZZV%^x%MyIbnU->ow1p_ffC2_x&2j|K8=#l46KTG^?1tUL9u z+ZK9j)S4eh%~NXC53-mMbmP70H!bQdwZAg#2O1r4SNH23@zo$=%V0B<6x%IU3J>Qa zrp?cOLc%B<;zf^zfj11purw$YooYY#n2^X=(q*guBq|>gZhCh-b`E-M zk-YXXA!piL&8s#V9Y8^^OL8ZYSB4qBH1#YA1edLj9H4R(cpgM!b=tGIAH1e{V-!Wzv@)qxWM7pvG)?xDGIq7ri=?x>n9-@3^p36 zCnRJ)7pminaG}T!HGthfB48J72k%A)Ug}2pef>Than^`(_p{Zj0}67ILd%gK>WG;X z3#T0ZPc{W$a0vCcSAgkBTw@$?l`bNyYTP^)72Tk8t&=64>PK)PPthwAD)gDci6$Qi zp-JJ6@E4khX(Ys1ExHj4SBV7r4nF_n!b|spFJgjpGzhWC`6gNYhBuL+_AjM=x$-Nk z%nqE)snrd<2&`B{HB~So>&qrZgZU#Rgk!~gxEhlN#h$!03zk>que&FePHZaWQsD2D zU)nRi4wn8Dp={Ide!7>vQ>4KYk_}r5&^a;jTvKAa_JaXj*aIIDC;vX`Bp<-7AypDw-8sgz}M`GgPuqDBG!hp#pg&`(tDaGHC# z7Fvd2H05^K8QHHhPQ$+5Yint{Ect0X1jJ!Eoj@}4wOCnSOehJCI3VmI!s`VRf!dIoIJziQ2;Q>{NV{S% z=Y&KLhDd+z5iyrCE&X1nLJE6M4}(k3V?%F^9N%G%^c; z6H{AmRS3%O*V4XDpqa%fT{#|)S`Qy~Yg8l6bPeTTsndr7I1mtd2E`LcwH6@brAPeE zfDk&By#}pTI)!#Fpojcaghl{fa-Wu%!WP?Z%T3yQW0h>QZa(iuBGNK7{%&BT?Nr8o z(^|xE@oo1>5dUp%`B<~2KjbvYN{*vViFw8fQ)Cl(3A~R+vewJe#>R_J0-3=K+Zq}| z0jpn8P;2CLmEgv-Q+D~pu<(N7UsJgDs59KdCb_;-EA_V)U*pLb2l`Zv)~X+u&wpFX zs5w7Q483CHnI7d(-(+#!Xfaln5tqe}lTFm@v(#lp3gi>`WjPYZo6w4mC$%M1MLZRa z9!FDRx$`wlH4!;RZch-pl|!xr>t~00TaU5%_Ypv;7-r8no)6O(b|q{&?lI+m8BP(f z$5v_}Z6184t9fMJp(L<@Du(_i*_?vrYH*~b=H{Ttk4_l}_J3pzSS65yjUWxi(qJ1b z#3CGE(zp7?EN}Bkb`vy{iU~z!dfx+nh7^P|rV7;!?TD$#HoRUBI_sY!D|S`JQDLtw zJCV`!Eqlv5(vkEc@YyZ-zfMoq_N~X-Hkt1~1%=seaID@51Y%8lnCE^jKtgso18_e8 ziNc>H;THXRJzF9WmuVCJ1S00iUxxQlS-gqJaETY~1R_;Y&gLFxpFeS-HVb^cAuFdO zW8;xZEJp&lACc||hd_tK{jJjrg?dhbkU zY+UnnsDxTj$UEalwbeG+(tsB^p6wgm6f-Z#!};7PhZ*x)VQ08YPBYoVz|tvk>j$kD z%O1RXw*6b3D*I+pS!4R$jOo05WAj2q=biva@m)makmyw$A_XwVBigP^8p<$8H;94k zPjOl8rfDoLq{grkv`eWAS}Vdz*&S@f5a(Y_yN6;qh~M#}UZ;)VD5Qd1JK=&VE8k>{ z+VoStejr8~l^<&e5`gA4@^N}Of>3&#p^;jjpsx`7ujS{oV^^{>Cob`q(=23yiB8=o z$f07GFgQ_bs77EkQAIkVbQUZzOpZY2$0#yw*K+Q0lcmIi=8aaZHU%v7FDRzq>7R6h zul0gs;E;Qmo?m{)zitk=x}PHvzP-3n`7bfJrq$tb;-;B#rlTV=JgISx22#WJWK{Dg z#jUWZL_ou( zBROJUP)ra_G zw=tB%N83&LMZ+`PU5L%al9qomzkprGBNbZ zEIS*Qeq1C^J#b(k)M-_Ftp5|rCz-esyVHN3s(vw?$~-qv&q3z>V|_Sa{>uindQ=B0 zaHa*1ng{{4#wW<$&WLUXYc~#D zbpKkoAi}kEi#B}Vg3EpWt6cr$ISj;lWRo>HI&X<3y%?Vee3mdML$lT~H&%s+m8Ac6 zpIXe?HvjdNQshVf?MnHf_OVS^q6yaRH3=h`b8;#rLio5EWp1?or{*)12-HRxNX_m< zfD;wnr267JehP`~VE3%^N+Hz|G1SHUE+L=`yYwJp*2p6MYZ`g0U+(m)Yy7s3)B4R` z+k?VBJx--Z{+D=Es7an^*0>BYymECDUBk-G!e>FhkE5A_w>yPT_M{QOCBFk$T5NfL zSgZavo=+oG)xw-ETdQ+HIjK-1LHsFM$Irl0h!_}%M*6AAV+IN=_={1aN!?{`ONT8N z$Q4K-Lyp36oeFb`6#$QGh{y3}*t}NFlpyG>gcXAicjhAYQ%KaC|ExN{ zpD3O>#k65^TdSCGWc`r~K?;?%on*y5*{-2>c!wLcDyW9AIz7kyiKFLqfWMV@q(@CLckW5fiQHNke;r^ z3*%pSN?ubvqdt$0RN`-DQ^@*oz*5+l+o$? zo1xSZQnIYuFuny*$uJamUS=c&5f(`AL_$0U0*KHURK{iWupM8Y3%dp5Xn~szE@YYXS9$Dhg9~juIa(aoy9}9-z2!U*Xsyg~7RPMv(Z!ETpG%u2aSPwonyOB|q zYsd3u2JD6`1`*eqP6H;L51NdFh}~~v-Q$nGTaVW|-S;|#ye{EOofod_zK}kzouJ3wYO=4OOoZhiJW5 zmiWX;Fvbggp4xbonlQBq5dUf;sk|hkE#(%bZ2T8pbZK6+g>{^q@e{H~m(!op9A>sC z1|&Ew-MDF!%X4`O-g%?rq0=8Mf`=1{W^B6Tb>8bVUbE*CO*k0;p^l{9HheH8 zOn>cpO}tKrFo0dpdz8BaMoH|l{@X#kT1u)7cM{E@#;YFFTV)PI6?nBOs#Zsvwb$C} z*1z0Jdij_EI7HYXm0~{Ao$q3cAe+JSB)(Gx?tn$Owf!rI2ODCBE4b%A!8g&5gRQsBx8vLo+9{s{ATzCbD`91ISf_E&mpDlu znpjhBO>RM-ZomSR8;W-Q;`}?M?6-4F*+=1#P|_bDwZtMkera=OiDK|R^@)OHda!5q1p>dUbO|wb zgR&ZMiOFC`r0dQZ!w`PHE0c^)4JDBGsc^M$;c)UNR>W7JiN36fxj8pqI=*XURr7@b zWP;~$)Psi}aE6X`*ETRf-IzjMO*T7rADL8PmH)Ec1m8^H0BO?Vt8(GeI7}K{D;n_o zsi7)R*RQOffYRJRy;l(?&7*+2FqCeXpVO&{)h(A=t5t^Qj3jb=N&VYB@#Mb1msD92 z1C&&ZQBwYjYsBFefwi#!#4v~O(k{JHnc}GT)?f;&rXto*gO7cKgZ8e|_T}Z}BiWbR zHNPE<+-(}a9h#@o+!tDb_hSVFOP>VZG**zuQv2pWv+r8-^<9ZzwvK&k$bXq9Z@)fo z3|@48J1B62aPNo(Z8bDGDAL=1s#51v6w7vE&{9|O)x0olUa3%T)+YDnDOnC*l>YLh zTpI1U=QSx78*E8^{(V!-njI^cYL6w6Wii`Ya<_33_oTu*m*5z{$!hrOE_h#cB;aez zA$q)@hAl;X6@KlBuK||J#rd1#W}oe9N+}enroN8YM&BgTky(w(yN36&0I7;-Yui#3 zb|SxJs$B@-x04RZ?8dB)JeyN*R8{I*85dC^do;9=k0ykOY1d2hjT$FeZ(I!Razv>W zceQpkJ$E8oA65AejpmkaLaSJ$@!Z3g0-H``C#S5%=_)mr^I#%4m-?ddVs)J*kR*%| zplQnRbT&D5ci;(G#u(BdIda%49^;})RCS%MYsE9{o-@0jGku2Kec(_8p0;D1!~sYb zosaq-6Wz~T9q;E3hZH2w@QbT5scSDO2G3X*ExX9}?dP0Toe$c6+i-pXk6n+Qb$-Vq zab5hq6SPO2I-h=Gy*q+J;Za!NGm0i6YT{lY)U-iW^;Cd^Fde_W;&#jIJ29LeQYpHY zBxR96v?|d$%o`rmbT=lMNLTV=_?z(goToPP*C2?;A+(i3=LXmnO9S~iX%bJErX|ZS zxku@&d6m}Tq_58{o5dXWv5a$eT=ylQ!9z+$yc%hlCI(WWH`vPPr8h#Kyb@zDnFvf< zWY?lr=tZ*=KTsknLE3OjQQx7Hw#ycI)53PCU?Vy_`knG(Hs4l_3vEABX?o2Y8x~)d z=-jYCI23OkNybRVUQ9JF1fQXkAf4xN{zxu1LRZ;5uGsupadBIyUr_sCm$FDgwOP$| z@|+_Pp2~haU`4X~k$*TMd-?D1c&n4v4Gd1p!A_##;^chFBY7e7yEyc#^7Jji1#J6X zdXs=Tsvj!tW@`Is?1l`vJxF%J& zg7L(79X|siTv{z>-^m`o69`AVZPRsN^R(#j zuI~3>;on3ibP$9L&gy##5*!{g`1lb^A~*>yheJ^YZ^j=B-7gwzp!l?R&%E50a~u6z ze1WS#6tGg6CQJSH;PA=m|6cuh|3M9g3gc@SifteM)-!L`vK;=%f+U!98cf00097Pd z>5{$71R@FUo`}zZ@FlGNOz`gNrqt?N$Wa|nhf-JJn(eG%3B#0wpSK0OGh!Dy9Qtl& zT}VYLR!N`p8FX4OtO+zK39;#B9K)23-ds(XeG~gO6F`!hl5RTZla56F|H+IS_mfY5~ma8q!gRf z;hRa=IJiaAlaG*HN?|!p12Py|1zNp^!vX1pCh;)$+DR?sgOrEmS+{0`woFyr+&}f8 zr|`I}hAA*8)th*Kko8dPBjVWJ@{h3UT4-p&!z>~mrgp`_nV+ns5NPP?+M?}7-X2cf zR&Kpi`kXKMF?L@eboWri@y&MRg4w#-|6PPsoUW^ob>}Jmtncoz-!EE%EbVa+F1@$LWb|(mi2@VBgT$yW$l5TK?sMk58q%_FZBQ3D z==2}#^>Q^!90nA5ON1U-VHbYjM-@8rtDH^Ti8-#I9{(GtuegC)n` za*u5uPi=A+-X#m}I(dId053pdvgkrvRQ*s5#3Y$t@X!N&e{EyV9GWnu>wTp{=0v|S z+>Gj@HS|wCSJsYO#7@FvNF*L6SK`5#v zHkmy6wX5_H=O&F(-8riA@l7kcL`D=UgKGiyMjNdQBBjz4mQv>wGGW-~CVZ5?ex?)ys9HIxX z&;;gZr72w{j5yR<@#BfM8bR6)qWL34F`!nJ8QSIah>Zz~ zc4HO)n6Kb(xql+iXWD89Fha#fubU=9;)`Yc72~A>Q)Unu za1!ECRi(ZY0@Ph|C+;X_fIO;iq;Y%!84sAsif|-u;0ziDBOB2$}kKr=Z59_eS zBj5&#Y!j~7HM3L4OOxK{^Ba5d0qnO5qr3ng&fhF`ON4#CK3@+l{mo#mv3p&7GKjYPK4SL^AK7B~L#+4GB&Xxv%%S`!2b&5+Xmx zY(2%;nHANp-)Z>mYk0h_ZB4#zefsHt-28&Rn*G(4+d2iDD2jddwHI$P zJ)P+X+`5W?vdKRqwt9H&U#QXlv~LmBc>)RsVQ}};LDp?m$@3%Wc`Q3T*r-WASZ7kB z-?+OKvW=)5sIWUHpOKcUZnf;8FdRyKa7?9kzeY$RimnY}p>yMY#aIO(1gSSP)yd81 zdLInjQe<<{qY&J?;ak-s%VtaamSWCp>!BU_b#cai!O}O0_|2AK=qRxr%2+QU*BJwW z=GYgAgo#)qJPXl;tCE%v21zKg!t-d=0Or(N)q8<;o+?^wGcDv?F;GSQ=n}>~Pu^EG z{}}TEqNfM3V6R|-lKK5*3M6fXJ?cchgW5Y2#ZDnW)7E|nc?D{(q=7QDa?0WHl|j@E z2z9FVVtIPyd)-NJNCm1U?dtM8-Y_uko=23z&n`8q;GeT?@LU>vt7@pi$xpV0Map*awvkBG1<+saqlaJFGZXx)(;&bTkceoo% zf5SKAJtg=A_Zbl9vq#~xvE)kxc2@jT$$i1>e)R5M+Jf@+7SRECqEM3ls3LuzqMf!& z;^6~pwp)uP;X{4uP+fw*3ws5$tHTfznqP>u!HNw1^hI)^rZ{C-RsO`$6dLfZ{T`U$ z@&~o=0flaxU+K!b`+*kfrupS)ZoGkaz7deNGI z>=MiO4Qfd`)Jd9GFTSO3==4elrSd|rnlNm_be77a{-Q%JS$Bsot#*Vvr9ayBtCKOG zIyOl{HYpMyYQG%U77j7H5)MiG@X3v&SRVMr#m*qLDOmDLR_^)Pnp8Zj z3hGp^Saoh|`3#yX@83m1ShHRV!McIp z&Ic&4aR@>Rum8rqFUS2avah-G+^%Dv(ZR^c>G~&bAD90v*KH3p%GINwx}|Z9)JLc8~lLf+qu}cbVc#(`X$^%5H~lVHHa5mJ4#H) zN3S*6`!65{9~&I(4j%qlx*$O>?B{s(t)Qm@Sv+-~(SD|xA*axriPsk%qoF?9{5JJyDkbEylL{d^9Xrh~Yl51Fl zrNY>Orms1mE8E6RYf0m*$o!%r9cn)9<2I2ql*p<|p&Vy1icJoY=t^vsD5qFvARY>g zO~flouU)!yfCyk!G2c5gF0DlT66@S(;ciY~*b4&=N}%_oM@fY=se~G_D^#VtU4ZE` zK3M4PW5>%2LE89DY3mOm`L_&D#$mYvDGHD1GxfH}=yIAyfSi#v5pVTW5rGt~w}`Gs z+e|EIz3JT%a#6bPL#Zbip03wAr=ETaZz(pAmU}=ndIe(lDsRxx$!}Eq9zLk%?;ITS z-!Qz`9%_t-v_9LkIc~b>+VL`#5#uIF`kYw=VD?p1{`P-blfiR}!3dX69~VpJgc~Ad zmh;;#xYD(rH?4U*j~U3Tso|Pwf{_A$FnzkHj#+=~O-&}k!~ z4s|UA|9j5<`@Oz@@myWkF@)TX0lClVxxG9?kA9brH=W?6lK-5FmY@vq;e?qW)zL4B zT!#!uL5Kx+CMPsWje-3aRdmpcd#J?1A5d(lN=Uw4^fHOeGobWPlZ%*Mj*JD9iN$b0 zo@a_=t7=`7EwxUX0vB2jgj{i6OQfoYSkwe>78fQLH==%uQ?tw2n|^%DwxWcs7{nUv zkxuBxl0RX$!;r+yf-V^}A%oPSvlzx;rcZwqL#Dm|qE)V)bhms0VSS~WhXIun{j(>llp81=YPvTsF`QJpV%oeVSKJz| zqGA-VVWh<;9zXtwM00CquDwzdvs$kmzN7&lGDmK*zIKW@JpO4^FseDJhoRxY5ko?) zPqYXMsg$AQtQ<@xF!8hoo1R<%fsYGvUa)%m zM=i9b0LaguSGm+l74iV2n5Fz%V3fj+%nw79N;2PJ;Qg!qCnNu&KFsJAbVp*-==K!q z+=UHRVQk?5v~+a;^O2FSe^%D61N5bkX@@lbB;K~(YHoDX&A(`ScPzVsp)W)QbIyf> zo>Va8T5QD}kb?Y@spDgs8aQ7HOB6~ppA#nn8xwv<_)8kZr9aS)%AGIR*d*eTF4cO5 zc3Ir>=1kk*Y3$=EOQDTKh)g~I-h?i(jA-bScJm2nV%vk3b|ai+#U+Qm?FPJ|oR`CN z>BV9A%I)Ba%}&sW4R=bX;da8>^f-{3uFbh*f^^qm-%5#_r6)OJ!Z^{SnwS-;HV&jYi%x+P8e{XX?eFR=+;_B+^{x`z zW~|mR7P@_ky%&vi_<{7a=!oLjJ%A?bpb7A84-?wA%b&&&kHKxA&1o}&$)K8Qc%U|< z^oS!TWrK(j20`L9TgoU78*(pKY<|qR2{%1ZgAiJZlE%|F=TvFdR6Id=2iatun@o;R zy{QzUE%h_w?_RR%_%{xn`h0j8V6p0WjVb1yX-sPelK_4#*uODv94jx?3lUes2IS*V z0h-e?s3?u4DY6wA212UEufv5|pwp$wW>)gtvMBqWK;Y7UDiAWPVGl!={=ua*QT2wV zMm-0`{8UU4MI4u-r&WT>MHS0v#Dh{np;&`LV8WeD4fTZ%i!(3j1zy+0NB9h-0zLYV z8eI~xzE|yQ+p6veQ#{7GSnurJr-)EafZg|p)kOO~=5wqj6@^X&xNH~I z0;B)l%u^@m4wruy7aR6Ou1u|5v2+pdzNHQ2;>XC4 z$ll+P=am3DV#i2v>!kA-smf&ad#cfZ^u zdYuO+&<^Z=ovPsNb99VP&@%qTA5da!YaL2vVF>Hv_dp{;1dzOaVum)MUMvo5pms4vWS zEMTk9C@uwzM5EGxw^$Xu@bkAQYR8Q6H9W2hBGIE0_NqBRHFOgk_t??nSDYZVlYYE} zKhfw0_>v%Sk8JVhX8~d(I(g1G$fv=K`s>NOChUB&>;!JykW#{_jeH)$T0$=yw>4kR zYO=HlKpIH4otE~6KCcI@p^B1)1Bm@MoSoV3j9$Gi|JVV~@Wkkk^Y;^rI<+wm9gLzr zD7Aoe6hzyeFirjw@e4L;8;`#E6$xUFL3|*MnDW_(I%8sfQon~@#{^~7vLI$l>CH*w zfjB2)8mSb%K02dxic}^~Ye|!J1T8FH48R&|+jhk(QZBoPAS{%pq&zY*WA`5<%qgq~ z-L-D%l<_x@!VgT1eJ?D;MYh*`qTg7(P6rB=A%k;Pg&V2neLaE*vJ2MMc{|q`v_=D$&D%kAWM&wmI8;g31smqqnq z2(u4(r0TL0+0xzV{iwXe@p-ZJ{v9kT`~B<-LxH|J2fxMFUFPKeYlQ!6s5{ZTwsI6} zjwzKRYDu1wp+2CSEabekkBgwcnkpomJ-s&^97#YP)Y{+rZ8#*Cd9^_;k<6USlXj8i zs*@6J95RB$hrRDx(!mE)RfIE9(}oMlGUR-P{?=dy?r4wn^_gu1V-H{CksE_qKRGPi zTw3_9GQ$_1jZ4kI&u^~#cfgrL3iZK?qgM``#k%fgte};D?h4JkL)4c#+E&%7UAD>5 z!WHAgE^F;6gexU`@{}$Ql)Ijqk4ne zBX5gOs(BqZY>hRuMNeITXXmI zC6?v6qaO)Q;*Frc`nGyjU<<1C&a_$)>QY~ug@PkvtX&etgPjt?UZHk>cYJ{)A?}n z+8umV2E3$DPNNn|C4pmYtNa z>evM#Dd$j%rclIqxHNz%D3(ewTm}!HAhA39g&6M%spX5_Dbeq4k}KF zNliAvegM89IfN%CC9smU1zP>bM0n%5X$D^Dfz+5`B@)A7mx+KunvhY!N~uxWm+52z zypnB(Ah9>^x!!MhTM6hUO0%PIEQ|G~(5=mKi~S(T1m5LPT_^p3Fp+8nz#j>`JtpY5bssB2C=g~(nRa-P~6*9G2>2I|7GUPNls>VU3;Cqzi*W$AfC?}i3UIC%Ld3m<6?}9P~zu4QW_Oun@d~ImZPtdAEMSC7x_q;89U$>N(ob&rLf)Z(Mnv*j$o_j~3L2kg=LLH*K1SQ5#Nz*$&pEO>>sWl!ep9ezylrvFXJh%h)YW zB#Fk{)j9Y8H&up~cTAPD$VRUb{Lr(+5xx(X>aN?}a>`FhN{_MNxHTnRceclGJKooc zo2C6hPIT+&R_VgWJGH)rYzhaFc6e^=pud%clRn*fr>+`z$+PExfL8i^3UCkW%OY$H zFI`0wpQtLTyQN-y%3$FY`AUwCN8z&q(_hAy>C(U~3G|SUTHT5wV=SyaUm`S|@*2NP zMWqa8BQ+Iz^iInr$tpyayxkCVIU~o-iZ!K+UzSuD2!)tvjZDdBM#RXw+L*nDma;Ou zafjTH)lT=m4ARU`*HB>Is!Oq!Kv$dAznio!{TXboA0e8C>EII+iCI-suo!ciZi5dh zLKVpxmeTy}5(}pvHN>fK)yPTF;9Fod4UIhBr(;f2HkAzdT9k&&a1*Z9Naf&~E^brK z7fpo5FMM>@4v;|9J?5@c>YZntZIlb!&2R#cMjV19-JkkSbIUz1sGYApNo64x922tL z^iEWVy4-;)qx-_6__q(P<^|}asN%?$$sot0ELVQSWN}Nm{C|9L@o`%*gVoyZ`(_m#BmW?TN!#gjz5Y4FqTfKHdK%mPvK&uuRt?^~ zo>KUt)AEpfT@Wq-q$~^q|9EM%Z??Cq8X44KS9Cnf*vj*P^ANh{tLEiz{Ha}ap$hB7 zWwCtC*8ELKit($e@h!|Vgo#7J$UgL8vUIaK4msZYi5r)OIKBL{o10&wvhmBk1k-;c zg;Y*BvMAFZY)=1`kA%Y_(mOG1U-8Iu?+C(9dYqD+Y>lYc>gqF8kaF2UZ zqobn@CPz(vU6tU+>lys&K@ehe@a=>@L5*NPSQVhi{X!k zK0Ga%{4m=De{Gq2zocU4WI%P7xB#E_L6 zS7%1R5XE`d*V;ySm5+#>gIKmWG3KvBp8aQK0u}c2o9ow$L|JJQ*Y3u>R&^n~(BNQq z%y@$12+9hGw}03}E5UWq#WCxY(@VXw217C#Z&Jd&X}fJiq|9~V+GxOK=1IA} zaz)GMjC())eN#kCuqZl|}X0w!$O}`Xs6ZY`&(}q z7N7nweCj+|X-|`@71m4#*c^()pXmGioblOi4r)OA(2x@AK!|n_dC~R@ zRuDw(2m%}iHh%1D>r_1gQR&m!NO@{Ef%^^?dNbc1=R^kc4ej)r*`1IO*ni%9B?<06 zC???fxWeM33YPm@9uB*U!Fb3%7vvFs6qP?DQ}S}m9{~3Qk}Vm0s|juOHMVO0nP2e0o@FdWRDdk#Z-lM!(jc_x9ritH^lO zbyotdlI7Ah9@U{&QJ?KtQozNltuA1>3yVl29X_ z-g{XIu~5S#BjNdq0^)7kYaK?kgZ&oz=CM}TXSPMXs^|}rR|#wbEb5{h z7uua(x2~|Gp_6knNCT$#M98KA6U?}3ln!t(6O%7J3&-KIXHlnYEo72sh0yqwSM>1q zFpje1F|XDIk5v*4#gcTzWJt>JpG2YIzLmgQko)-!YI0=y$mjv7jSb##gLDZXmCaz_ zp2aMP@)NXM4GSwzDVRY*wNZPfD8J8J6?L%eHg@bmnv}W4S^ekH#a{UBc?|;kxrroK zMn(pcsAt+(HcUPd96GKN#zi@Fpm-p#A&8d^z&R?E&@Q~fMn0uTynxxr8Nle{0W! zU%hlC^0C9&TH$1F85yS3OU;3YWSrj|%an7#(3Q1=^Cq|L-qc464n6gGe7>DErEljA_~rPu7>3=C zX#*X70sY#&0;mIu9&H%DY)I(xKI%fIO0LS>+#7J!N_#8K$I7URC4qXJ6J<5_>xt5s z1Hp{O@NFWq|9gJ;{(B$&W=-F!3i}7!!L7UM>JcZ?@>ah1F{%$luZY2N)}rP&RlQ@n zIjj-`VwqWDG<)5m1%o+Ky~gE*z3n)dF76WDd^)2YYpn-c{vy=QPwjv)my%2agTewh z@X_;&h?%vzUREEJ*UF)p-Dvn*0V|_T!R(T>talF2*Q^l-TTeTgy(kpJI2x&vryZnOgKO$lT~y|t&{3%>YiqmW$_=fA!|UAc zo=|1&nfiTJk9I%SxQ-5A_p~*WpVP$E?*pv6CWRH}AG}l(&of`aHe|`2ljSj}HCSg9 zq7#g*!sX9mmDC?d^TsD{IU_Q7n-Q>juM6Honye;0s~-6A^wJt%pQ3BU@Gq&w1r!t0rIOvyKyJr4wF80Bl zK@mcNLw9mFZQb?`nlOVTLP-Sf6&JI0SSy|c9Hjexl6!xtFNNeI9M!Mm# zrpFJ3Qt{|X=<%dVOaX5T&B4NLOYZi?A7I$CYA*chA}cGKS?dM` z7`_Ji5ki?NwiLSuaJ*#{PHL2kje98am~HbE42j$;0e$BxAkYIRUrt^jTyA=wjphUx zR4*?t|1j~PhEU)Nv@oSBk-L<47E2}KfeUjByNG-=c}(RINuX;ez-yAqyVnFH`3?d* zcq&#`o#E8@q9ba}voww$7B<&CD;EB-rbgE$7ZebtQMX!%e-EUSo00WAnsN-K zpoT)MY%SfxtEJ|D_jzO1!GPW8Z#?7k&kk-J8s}L9bE_`-j|yuCVvZ7KdMT>Mzw_?A6@GE?gg1&rR@4umnqu&_-CAEXgrF0NJ^0b%)k^#4prm4TfP zc-L}o!^kIr5O@`f+;j$Euz-P4BYq*IiMWy@5VsBKPWB-mJ-dHUlX)UFf*5pW3IYYW zkfb1RUf-HOV~Gf><}Cs95fYB$W;uHG~$B1W4GLF}p{(>_0l@U1MkOri=3FvKC8okHe*c3?i1j}tYGjYj#{ z&6zspvf#xLmq+2l_)xaMd4FE~e4wyRhLTMQ?%8k%VHQgA_WgnWEQzW4CB9Ud(^SI5 zOE=XuHyh_;6Ai@6>rCg^(`4@-uUo+Zr42eB1Eo0g1GJ1>e-MhaV8|4S#N)|AZMPLG zd&2h&p?ij;*@oYR9o+E}r6;e#$g$MShOHa+xFx_YTmz^X1Ki6iD}h!{d6H5yTVDiz z+OR(Y^fv<)!{|;H#0Vf31yuc3*^z_<YH0;p)@{s>6 zn)%H>oonOoh(+3m;@iO9f8lsLFb%6lb;o7XK2iW&as?-BTk*hfJVp>*77pqgsv8@7 z05Ko-KA!O2p<-A^Xg0D)6`)J1RB_43au;1t>hzy^InBs5q=q21Aaj85geut?BGYfQ z4~w+>sF-{!-n71J2h&Yl6ed3eEJcPnbkW3Ns20_LWD4$T4&wb&%Emq6dq05bNpIP1 zE-Omk%EhQ7FzQqg_1hhPKFHL}%#xRPh=B0n$V-K;C(At?WFo+vCK*yi_je}`559V) zlvD9JVu~;^C8klK3;hZEPg&&>iaZOwk&jJ+Wz}V%j^|O|sXB-s)(voV7y(pI4)9K4 zt=zfe@jtVG@swc)vl;J+{8<%16)j&IX;+<`5B^;!)JIbpU7VJdN{Tk-`;CC@|1X_Z z;cZS_$@K^~r+c2aT&~L`IR3G@vbUz3QRdIzVH*NE-89FJnD|wR2~zmK&7WxClPzXg zb(c%YF6;)%-GQrn;qN^9pGHYAh-$8l{t*;FUrhl~BYx{G#J*2wTEj7Awt Date: Thu, 21 May 2026 13:29:01 -0700 Subject: [PATCH 66/68] fix: hohlraum parameter wiring, updated to eliminate dependence on private classes/methods --- .../radiation_transport/src/dataset.py | 103 +++++------------- .../src/evaluation_metrics.py | 12 +- .../radiation_transport/src/inference.py | 2 +- .../radiation_transport/src/loader.py | 50 +++------ .../radiation_transport/src/losses.py | 15 ++- .../radiation_transport/src/qoi.py | 36 ++++-- .../radiation_transport/src/trainer.py | 6 +- .../radiation_transport/src/transforms.py | 9 +- 8 files changed, 95 insertions(+), 138 deletions(-) diff --git a/examples/nuclear_engineering/radiation_transport/src/dataset.py b/examples/nuclear_engineering/radiation_transport/src/dataset.py index 0c9a1e1762..75e0ffa06c 100644 --- a/examples/nuclear_engineering/radiation_transport/src/dataset.py +++ b/examples/nuclear_engineering/radiation_transport/src/dataset.py @@ -116,8 +116,9 @@ def load(self, filename: str) -> TensorDict: Reads cell-primary fields from ``mesh.cell_data`` and derives ``coordinates`` and ``cell_areas`` from the mesh topology. Returned tensor fields: ``coordinates``, ``cell_areas``, ``scalar_flux``, - ``sim_times``, ``material_properties``, ``sigma_a/s/t``, ``Q``. The - sidecar attrs dict is stored as ``NonTensorData`` under ``metadata``. + ``sim_times``, ``material_properties``, ``sigma_a/s/t``, ``Q``, + plus the eight hohlraum geometry parameters (``ulr, llr, urr, lrr, + hlr, hrr, cx, cy``) when present on the store (hohlraum only). """ filepath = self.data_path / filename if not filepath.exists(): @@ -162,23 +163,34 @@ def load(self, filename: str) -> TensorDict: raise KeyError(f"cell_data['{key}'] missing from {filepath}") td[key] = cell_data[key].to(torch.float32).contiguous() + # Hohlraum geometry parameters: eight 0-D float32 tensors in + # ``mesh.global_data``. + for key in ("ulr", "llr", "urr", "lrr", "hlr", "hrr", "cx", "cy"): + if key in global_data.keys(): + td[key] = global_data[key].to(torch.float32).contiguous() + if self.cache_static_arrays: + cached_keys = ( + "coordinates", + "cell_areas", + "material_properties", + "sigma_t", + "sigma_s", + "sigma_a", + "Q", + "ulr", + "llr", + "urr", + "lrr", + "hlr", + "hrr", + "cx", + "cy", + ) self._static_cache[filename] = { - k: td[k] - for k in ( - "coordinates", - "cell_areas", - "material_properties", - "sigma_t", - "sigma_s", - "sigma_a", - "Q", - ) + k: td[k] for k in cached_keys if k in td } - sidecar = self._read_sidecar(filename) - attrs = {k: v for k, v in sidecar.items() if k != "missing_fields"} - td.set_non_tensor("metadata", attrs) return td def get_metadata(self, filename: str) -> Dict: @@ -288,67 +300,6 @@ def _load_split_from_file(self) -> List[str]: def __len__(self) -> int: return len(self.filenames) - def _build_metadata(self, filename: str, td: TensorDict) -> Dict[str, Any]: - """Per-sample metadata dict: sidecar attrs + filename + sim-time facts.""" - attrs = dict(td["metadata"]) if "metadata" in td else {} - file_meta = self.reader.get_metadata(filename) - attrs["max_timestep"] = file_meta["num_timesteps"] - 1 - attrs["max_sim_time"] = file_meta.get("max_sim_time") - if "sim_times" in td and td["sim_times"].numel() > 0: - attrs["sim_time"] = float(td["sim_times"][-1].item()) - else: - attrs["sim_time"] = None - attrs["filename"] = filename - attrs["case_type"] = self.case_type - return attrs - - def _load(self, idx: int) -> Tuple[TensorDict, Dict[str, Any]]: - """Synchronous load: filename-indexed reader -> device -> transforms. - - Overrides the base ``Dataset._load`` because its int-indexed path - (``self.reader[index]``) goes through ``Reader.__getitem__`` -> - ``_load_sample``, which returns only ``dict[str, Tensor]`` and so - drops the NonTensorData entries (``filename`` / ``metadata``) that - RTE transforms and :class:`TransolverAdapter` read directly off - the TensorDict. - """ - filename = self.filenames[idx] - td = self.reader.load(filename) - metadata = self._build_metadata(filename, td) - td.set_non_tensor("filename", filename) - td.set_non_tensor("metadata", metadata) - - if self.target_device is not None: - td = td.to(self.target_device, non_blocking=True) - if self.transforms is not None: - td = self.transforms(td) - return td, metadata - - def _load_and_transform(self, index, stream=None): - """Stream-aware variant of ``_load`` used by the prefetch path. - - The base class's prefetch path goes through ``self.reader[index]`` - directly (skipping ``self._load``), so we override here too. Thread - pool + CUDA-stream wiring is inherited from the base class. - """ - from physicsnemo.datapipes.protocols import _PrefetchResult - - result = _PrefetchResult(index=index) - try: - if stream is not None: - with torch.cuda.stream(stream): - td, metadata = self._load(index) - if self.transforms is not None: - result.event = torch.cuda.Event() - result.event.record(stream) - else: - td, metadata = self._load(index) - result.data = td - result.metadata = metadata - except Exception as exc: # pragma: no cover — surfaced via __getitem__ - result.error = exc - return result - def load_flux_stats(path: Union[str, Path]) -> dict: """Read an RTE flux statistics YAML. diff --git a/examples/nuclear_engineering/radiation_transport/src/evaluation_metrics.py b/examples/nuclear_engineering/radiation_transport/src/evaluation_metrics.py index 1c88e6b282..fbe53d7a0e 100644 --- a/examples/nuclear_engineering/radiation_transport/src/evaluation_metrics.py +++ b/examples/nuclear_engineering/radiation_transport/src/evaluation_metrics.py @@ -76,7 +76,7 @@ def compute_sample_qoi( cell_areas: torch.Tensor, sigma_t: torch.Tensor, sigma_s: torch.Tensor, - raw_metadata: Dict[str, Any], + sample: Any, case_type: str, ) -> Optional[Dict[str, Dict[str, float]]]: """Compute QoI(pred) vs QoI(target) for one sample on the tensors' device. @@ -84,7 +84,13 @@ def compute_sample_qoi( All tensor inputs may live on GPU; only the scalar QoI values are materialized to host (via ``.item()``). Returns ``{region: {predicted, ground_truth, absolute_error, relative_error_pct}}`` or ``None`` for the - hohlraum case when geometry params are missing from ``raw_metadata``. + hohlraum case when geometry params are missing from ``sample``. + + Args: + sample: For ``case_type="hohlraum"``, a per-sample mapping carrying + the eight 0-D float32 geometry tensors (``ulr`` ... ``cy``), + typically the batch sliced at index ``b``, or a fresh dict + built from those entries by the caller. Ignored for lattice. """ # The QoI evaluators expect ``(1, N)`` batched flux + flat (N,) cell fields. pred_batched = pred.float().reshape(1, -1) @@ -105,7 +111,7 @@ def compute_sample_qoi( centers, areas, sigma_t_flat, sigma_s_flat, target_batched, sim_times ) elif case_type == "hohlraum": - geometry_params = extract_geometry_params(raw_metadata) + geometry_params = extract_geometry_params(sample) if not geometry_params: return None qoi_pred = evaluate_hohlraum_qoi_torch( diff --git a/examples/nuclear_engineering/radiation_transport/src/inference.py b/examples/nuclear_engineering/radiation_transport/src/inference.py index 493eaeb751..2ebe04f693 100644 --- a/examples/nuclear_engineering/radiation_transport/src/inference.py +++ b/examples/nuclear_engineering/radiation_transport/src/inference.py @@ -121,7 +121,7 @@ def run_evaluation( cell_areas_t[b], sigma_t_t[b], sigma_s_t[b], - raw_meta, + batch, case_type, ) diff --git a/examples/nuclear_engineering/radiation_transport/src/loader.py b/examples/nuclear_engineering/radiation_transport/src/loader.py index 64c689ee7b..e0025520fa 100644 --- a/examples/nuclear_engineering/radiation_transport/src/loader.py +++ b/examples/nuclear_engineering/radiation_transport/src/loader.py @@ -77,11 +77,9 @@ class TransolverAdapter(Transform): Pass-through fields when present: ``coordinates_unnormalized``, ``material_labels``, ``cell_areas``, ``sigma_t``, ``sigma_s``, - ``sim_time``, and ``flux_normalization_stats`` (NonTensorData). - - A trimmed ``metadata`` dict (timestep / filename / case_type) is also - re-attached as NonTensorData so downstream physics-loss + inference - code paths keep their existing ``batch["metadata"]`` access pattern. + ``sim_time``, ``flux_normalization_stats`` (NonTensorData), and the + eight hohlraum geometry parameters (``ulr``, ``llr``, ``urr``, + ``lrr``, ``hlr``, ``hrr``, ``cx``, ``cy``). The output has no batch dimension; :func:`collate_no_padding` adds one. """ @@ -143,28 +141,13 @@ def __call__(self, data: TensorDict) -> TensorDict: out.set_non_tensor( "flux_normalization_stats", data["flux_normalization_stats"] ) - if "filename" in data: - out.set_non_tensor("filename", data["filename"]) - - # Trim the metadata dict to keys downstream code reads. The full - # metadata dict is also delivered as the second tuple element by - # the dataset; this is a convenience view for ``batch["metadata"]``. - # Bracket access (``data[key]``) unwraps ``NonTensorData`` to the raw - # payload; ``TensorDict.get`` does not, so prefer the former here. - get = lambda key: data[key] if key in data else None # noqa: E731 - src_meta = get("metadata") or {} - if not isinstance(src_meta, dict): - src_meta = {} - candidates = { - "timestep_input": get("timestep_input"), - "timestep_target": get("timestep_target"), - "max_timestep": src_meta.get("max_timestep"), - "filename": get("filename"), - "case_type": src_meta.get("case_type"), - } - out.set_non_tensor( - "metadata", {k: v for k, v in candidates.items() if v is not None} - ) + + # Forward the eight hohlraum geometry parameters (0-D float32 + # tensors). Lattice samples never carry these keys. + for key in ("ulr", "llr", "urr", "lrr", "hlr", "hrr", "cx", "cy"): + if key in data: + out[key] = data[key] + return out def extra_repr(self) -> str: @@ -194,13 +177,16 @@ def collate_no_padding( value = td[key] out[key] = value.unsqueeze(0) if isinstance(value, torch.Tensor) else value - # Merge the trailing metadata dict with the trimmed view that - # TransolverAdapter set under ``metadata``. Overlapping keys - # (``filename``, ``case_type``, ``max_timestep``) carry the same - # values in both, so the merge is idempotent there. + # Merge the trailing metadata dict (filename / case_type / num_cells + # / num_timesteps / max_sim_time) under ``out["metadata"]``. Surface + # ``filename`` at the top level too for callers that use + # ``batch["filename"]`` directly (e.g. inference figure naming). if metadata: existing = out.get("metadata") or {} - out["metadata"] = {**metadata, **existing} + merged = {**metadata, **existing} + out["metadata"] = merged + if "filename" in merged and "filename" not in out: + out["filename"] = merged["filename"] return out diff --git a/examples/nuclear_engineering/radiation_transport/src/losses.py b/examples/nuclear_engineering/radiation_transport/src/losses.py index ebffbe083d..566233232e 100644 --- a/examples/nuclear_engineering/radiation_transport/src/losses.py +++ b/examples/nuclear_engineering/radiation_transport/src/losses.py @@ -362,7 +362,7 @@ def compute_physics_loss( sigma_t: torch.Tensor, sigma_s: torch.Tensor, sim_time: torch.Tensor, - metadata: list = None, + sample=None, flux_normalization_stats: dict | None = None, qoi_epsilon: float = 1e-10, ) -> tuple[torch.Tensor, dict[str, float]]: @@ -381,11 +381,18 @@ def compute_physics_loss( if case_type == "lattice": return compute_lattice_qoi_loss(**common) if case_type == "hohlraum": - geometry_params = extract_geometry_params(metadata) if metadata else {} + if sample is None: + raise ValueError( + "hohlraum physics loss requires the sample TensorDict to read " + "geometry parameters (ulr, llr, urr, lrr, hlr, hrr, cx, cy)" + ) + geometry_params = extract_geometry_params(sample) if not geometry_params: raise ValueError( - "hohlraum physics loss requires sample metadata with " - "simulation_params.parameters" + "could not read hohlraum geometry parameters from the sample " + "TensorDict; expected 8 0-D float32 tensors (ulr, llr, urr, " + "lrr, hlr, hrr, cx, cy) on the TD top level (see " + "MeshDataReader.load)" ) return compute_hohlraum_qoi_loss(**common, geometry_params=geometry_params) raise ValueError( diff --git a/examples/nuclear_engineering/radiation_transport/src/qoi.py b/examples/nuclear_engineering/radiation_transport/src/qoi.py index f9afcbec31..e1c613b7d3 100644 --- a/examples/nuclear_engineering/radiation_transport/src/qoi.py +++ b/examples/nuclear_engineering/radiation_transport/src/qoi.py @@ -40,21 +40,33 @@ _HOHLRAUM_GEOMETRY_KEYS = ("ulr", "llr", "urr", "lrr", "hlr", "hrr", "cx", "cy") -def extract_geometry_params(metadata) -> dict: - """Extract hohlraum geometry parameters from sample metadata. - - Reads ``simulation_params.parameters`` out of the sidecar-derived metadata - dict and returns the 8-key geometry dict consumed by the hohlraum QoI - evaluator (``ulr, llr, urr, lrr, hlr, hrr, cx, cy``). Accepts either a - single metadata dict or a batched list of dicts. +def extract_geometry_params(sample) -> dict: + """Extract hohlraum geometry parameters from a sample TensorDict. + + Reads the eight 0-D float32 tensors that the curator writes into + ``mesh.global_data`` for hohlraum stores (``ulr, llr, urr, lrr, hlr, + hrr, cx, cy``) and that :meth:`MeshDataReader.load` promotes to the + TensorDict top level. Returns ``{}`` if any key is missing (e.g. on a + lattice sample, which has no geometry parameters). """ - if isinstance(metadata, (list, tuple)): - metadata = metadata[0] if metadata else {} - if not isinstance(metadata, dict): + if sample is None: + return {} + try: + if not all(k in sample for k in _HOHLRAUM_GEOMETRY_KEYS): + return {} + except TypeError: return {} - params = metadata.get("simulation_params", {}).get("parameters", {}) - return {k: float(params[k]) for k in _HOHLRAUM_GEOMETRY_KEYS if k in params} + out: dict = {} + for k in _HOHLRAUM_GEOMETRY_KEYS: + v = sample[k] + if hasattr(v, "ndim") and v.ndim > 0: + # Batched value (e.g. shape ``(B,)``): collapse to a single + # scalar by picking the first entry. Geometry parameters are + # static per simulation, so every batch element matches. + v = v.reshape(-1)[0] + out[k] = float(v.item() if hasattr(v, "item") else v) + return out def evaluate_lattice_qoi_torch( diff --git a/examples/nuclear_engineering/radiation_transport/src/trainer.py b/examples/nuclear_engineering/radiation_transport/src/trainer.py index 05e817811f..b146a5d51a 100644 --- a/examples/nuclear_engineering/radiation_transport/src/trainer.py +++ b/examples/nuclear_engineering/radiation_transport/src/trainer.py @@ -278,7 +278,7 @@ def compute_losses( sigma_t=loss_inputs["sigma_t"], sigma_s=loss_inputs["sigma_s"], sim_time=loss_inputs["sim_time"], - metadata=loss_inputs.get("metadata"), + sample=loss_inputs, flux_normalization_stats=loss_inputs.get("flux_normalization_stats"), ) @@ -334,9 +334,11 @@ def loss_inputs(batch: Dict[str, Any], require_physics: bool = False) -> Dict[st for k in _PHYSICS_KEYS: inputs[k] = batch[k] - for k in ("metadata", "flux_normalization_stats"): + for k in ("ulr", "llr", "urr", "lrr", "hlr", "hrr", "cx", "cy"): if k in batch: inputs[k] = batch[k] + if "flux_normalization_stats" in batch: + inputs["flux_normalization_stats"] = batch["flux_normalization_stats"] return inputs diff --git a/examples/nuclear_engineering/radiation_transport/src/transforms.py b/examples/nuclear_engineering/radiation_transport/src/transforms.py index 3ec9c51d4e..574d708f52 100644 --- a/examples/nuclear_engineering/radiation_transport/src/transforms.py +++ b/examples/nuclear_engineering/radiation_transport/src/transforms.py @@ -287,18 +287,11 @@ def __call__(self, data: TensorDict) -> TensorDict: input_idx = 0 target_idx = flux_all.shape[0] - 1 - metadata = data["metadata"] if "metadata" in data else {} - max_timestep = ( - metadata.get("max_timestep") if isinstance(metadata, dict) else None - ) data["flux_input"] = flux_all[input_idx].clone() data["flux_target"] = flux_all[target_idx].clone() data.set_non_tensor("timestep_input", 0) - data.set_non_tensor( - "timestep_target", - int(max_timestep) if max_timestep is not None else int(target_idx), - ) + data.set_non_tensor("timestep_target", int(target_idx)) return data From 084d54b2c3e305bc016d07a0a927e110898562ab Mon Sep 17 00:00:00 2001 From: Carmelo Gonzales Date: Thu, 21 May 2026 16:45:45 -0700 Subject: [PATCH 67/68] fix: norm computation fix --- .../radiation_transport/src/compute_normalizations.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/examples/nuclear_engineering/radiation_transport/src/compute_normalizations.py b/examples/nuclear_engineering/radiation_transport/src/compute_normalizations.py index fe4c9bbf6d..a2057ae7b1 100644 --- a/examples/nuclear_engineering/radiation_transport/src/compute_normalizations.py +++ b/examples/nuclear_engineering/radiation_transport/src/compute_normalizations.py @@ -71,7 +71,7 @@ def compute_flux_statistics( Returns: The statistics dict written to ``output_file``. """ - print(f"Computing flux statistics for {case_type} [final time]") + print(f"Computing flux statistics for {case_type} [final time only]") print(f"Data path: {data_path}") print(f"Split file: {split_file}") @@ -97,6 +97,12 @@ def compute_flux_statistics( flux = flux.detach().cpu().numpy() flux = np.asarray(flux) + # ``scalar_flux`` from the reader is shape (T, n_cells) with T=2 + # (first + final snapshots). The target the model predicts is the + # final-time only. + if flux.ndim > 1: + flux = flux[-1] + # match training-pipeline preprocessing flux = np.clip(flux, clip_threshold, None) log_flux = np.log10(flux + clip_threshold) @@ -126,7 +132,7 @@ def compute_flux_statistics( "case_type": case_type, } - stats["note"] = "computed from first and final snapshots only (final time)" + stats["note"] = "computed from the final-time snapshot only" output_file = Path(output_file) output_file.parent.mkdir(parents=True, exist_ok=True) From 8c9c3e4633e6e43ac1b41cdc54ae4a891ddcebb9 Mon Sep 17 00:00:00 2001 From: Carmelo Gonzales Date: Fri, 22 May 2026 15:03:00 -0700 Subject: [PATCH 68/68] fix: deterministic loading of samples from file lists --- .../radiation_transport/src/dataset.py | 46 +++++++++---------- 1 file changed, 22 insertions(+), 24 deletions(-) diff --git a/examples/nuclear_engineering/radiation_transport/src/dataset.py b/examples/nuclear_engineering/radiation_transport/src/dataset.py index 75e0ffa06c..b34cbeb06f 100644 --- a/examples/nuclear_engineering/radiation_transport/src/dataset.py +++ b/examples/nuclear_engineering/radiation_transport/src/dataset.py @@ -17,9 +17,8 @@ from __future__ import annotations import json -import warnings from pathlib import Path -from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple, Union +from typing import Dict, List, Mapping, Optional, Sequence, Union import torch import yaml @@ -41,15 +40,18 @@ class MeshDataReader(Reader): sidecar). Example: - >>> reader = MeshDataReader("/path/to/mesh_stores/lattice") - >>> filenames = reader.get_filenames() - >>> td = reader.load(filenames[0]) + >>> reader = MeshDataReader( + ... "/path/to/mesh_stores/lattice", + ... filenames=["lattice_abs10.0_scatter0.1_p0.015_q6.pmsh"], + ... ) + >>> td = reader.load(reader.get_filenames()[0]) >>> print(td["coordinates"].shape) # (N, 2) """ def __init__( self, data_path: Path | str, + filenames: Sequence[str], case_type: Optional[str] = None, cache_static_arrays: bool = True, ): @@ -71,7 +73,10 @@ def __init__( if not self.data_path.is_dir(): raise ValueError(f"Data path {self.data_path} is not a directory") - self._filenames: List[str] = self._scan_filenames() + # ``filenames`` is required for train/val/test list + # (typically derived from a split JSON) so that + # ``Reader.__getitem__(idx)`` maps to a stable, intended file. + self._filenames: List[str] = list(filenames) def __len__(self) -> int: return len(self._filenames) @@ -86,16 +91,8 @@ def _get_sample_metadata(self, index: int) -> Dict: meta["filename"] = filename return meta - def _scan_filenames(self) -> List[str]: - filenames = [] - for item in self.data_path.iterdir(): - if item.is_dir() and item.name.endswith(".pmsh"): - if self.case_type is None or item.name.startswith(self.case_type): - filenames.append(item.name) - return sorted(filenames) - def get_filenames(self) -> List[str]: - """Return a fresh list of discovered mesh store names.""" + """Return a copy of the filenames the reader was constructed with.""" return list(self._filenames) def _sidecar_path(self, filename: str) -> Path: @@ -258,12 +255,6 @@ def __init__( self.split_file = Path(split_file) if split_file else None self.seed = seed - reader = MeshDataReader( - data_path, - case_type, - cache_static_arrays=cache_static_arrays, - ) - if self.split_file is None: raise ValueError( "split_file is required. RTE datasets must use explicit " @@ -274,6 +265,16 @@ def __init__( if not self.filenames: raise ValueError(f"No files in {phase} split") + # Hand the split list to the reader so its int-indexed + # ``__getitem__`` (called by ``Dataset._load``) resolves to the + # split's files. + reader = MeshDataReader( + data_path=data_path, + filenames=self.filenames, + case_type=case_type, + cache_static_arrays=cache_static_arrays, + ) + super().__init__(reader=reader, transforms=transforms, device=device) def _load_split_from_file(self) -> List[str]: @@ -297,9 +298,6 @@ def _load_split_from_file(self) -> List[str]: normalized.append(base + ".pmsh") return normalized - def __len__(self) -> int: - return len(self.filenames) - def load_flux_stats(path: Union[str, Path]) -> dict: """Read an RTE flux statistics YAML.

;nVA8+KZr4AN`8EmLvnxKQZh3`FWG^`;+&5w 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⪻O;xAy%f@?PMzXgzx7)`YLmmqYJvdR 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 y?_-z2QG}q+t2okJ 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