SRTC Web Viewer — User Guide

Running SRTC from the release bundle. These docs are written against the srtc command line. In the distributed bundle that command lives inside the container, so run it through Compose from the unpacked bundle directory:

docker compose run --rm -v "$PWD":/io backend srtc --help

Files you pass in should sit under the mounted /io, and that is also where to write outputs. On Linux the container runs as an unprivileged user, so add --user "$(id -u):$(id -g)" when writing to a mounted directory or the run fails with a permission error creating the output directory:

docker compose run --rm --user "$(id -u):$(id -g)" -v "$PWD":/io backend \
  srtc -i examples/fwd_transport/oltaris_al_si.deck -o /io/out/results.out \
     --depth-g-cm2 1.0 --histories 100000 --seed 1

The physics libraries are already in place, and the shipped example decks resolve against them, so the examples below run as written. See Downloads to get the bundle, and the Viewer guide for the browser GUI, which needs none of this.

The SRTC web viewer is a browser front-end for the whole SRTC workflow: build and edit CSG geometry in 3D, define the source environment, and launch raytrace and adjoint Monte Carlo calculations — all from a tabbed side menu. The written deck/config is always the source of truth: every run assembles a real SRTC deck and executes the srtc binary.


0. Quick start (worked example)

On first load the viewer opens its default geometry: examples/viewer/al1mm_si_detector.mcnp — a 1 mm aluminium spherical shell with a silicon point detector at the centre (the canonical "dose behind 1 mm Al to a Si detector" setup). Walk through it to learn the workflow:

  1. Geometry tab — the Geometry tree shows the loaded deck under deck geometry: cell_10 (void) (the cavity) and cell_20 (m182) (the 1 mm Al shell). The detectors section lists ★ FC1 (point) at the centre. Click any of them to highlight it in 3D.
  2. Environment tab — the default AP9 proton / AE9 electron 350 km spectra are already loaded and plotted.
  3. Run ▸ Raytrace — pick the ap9_proton_al.csv curve, particle proton, and the FC1 detector (or type 0 0 0). Run raytrace: it casts rays from the point detector out through the shell — every ray traverses 0.27 g/cm² = exactly 1 mm Al — and reports a dose (≈0.05 Gy/day for the default proton environment). This is a point-detector calculation end to end.
  4. Assign the detector a Silicon dose response via its dropdown in the tree (or the raytrace detector material) to score dose-to-silicon.

To build your own: add shells/detectors from the Geometry tab, or Load path / Upload deck a different .mcnp.


1. Running the viewer

SRTC is distributed as a self-contained container bundle — see Downloads. Docker is the only prerequisite.

tar xzf srtc-<version>-<arch>.tar.gz
./srtc-<version>-<arch>/install.sh
# -> http://localhost:8080

That starts the browser GUI and brings the physics libraries with it; nothing else needs installing. To use the command line described below, run it through Compose from the unpacked bundle directory:

docker compose run --rm -v "$PWD":/io backend srtc --help

2. The 3D view

Selecting geometry and the context popup

Click any cell to select it. A context popup opens at the cursor with:

The bottom-right info panel mirrors the current selection. Detectors render as markers (point detectors) or highlight their region (volume detectors); click a point detector to select it.


3. Geometry tab

Add geometry

Build a primitive with the Add geometry form:

User-defined materials (periodic-table builder)

Every material select has a pinned + New material… row that opens the builder: click elements on the periodic table, set each one's atom % (normalized on save; the live wt% preview shows the conversion), then a name and bulk density. Saved materials get ids ≥ 411, live in user_materials.csv / user_material_elements.csv in the server work dir, and appear in every select with a user badge. The dialog also lists your materials for deletion.

Physics reach: raytrace and 1D forward dose curves work immediately (the analytic elemental synthesis covers any composition; the server passes SRTC_USER_MATERIALS_DIR to every srtc job so the ids resolve). The 3D adjoint needs multigroup data, which is built automatically before the first run that uses the material (or on demand via build adjoint data in the builder — a srtc data build-multigroup --merge job). Either way needs a source checkout with the nuclear-data inputs. The badge flips to adjoint when the build lands.

Since the Bragg-additivity work, a user material's stopping power is synthesized from its elemental composition rather than left empty, so its multigroup tables carry real ionization slowdown. Measured against NIST (srtc-data/tests/bragg_stopping.rs):

band agreement
electrons, >100 keV within 10% (Al +7.7%, Si +7.3%, Fe +9.1%, W +8.8%)
protons, >1 MeV within 15% (Al +4.0%, Fe +5.7%, W +12.8%; most <5%)
protons, <1 MeV low by 45-82% — pinned, not fixed

The sub-MeV proton deficit is the missing shell correction: Bethe's maximum sits later and lower than the true Bragg peak, and the √E branch below it inherits that. Sub-MeV protons range out in under ~15 µm of aluminium, so the error lives in the end-of-range CSDA operator rather than in transport across a shield. Fixing it means adding Andersen-Ziegler per-element low-energy coefficients.

The Adjoint tab flags any run that uses a synthesized material so the provenance is visible in the results, not just in the build log.

Geometry hierarchy tree (CAD-style)

The tree opens with a read-only deck geometry section listing the cells parsed from the loaded input deck (e.g. cell_10 (void), cell_20 (m182)) — so the hierarchy reflects the whole geometry, not just GUI-authored shapes. Click a deck cell with the icon to highlight it in 3D. GUI shapes you add live below it under the model group.

The Geometry tree also organizes your shapes into nested groups (sub-assemblies):

Internally the tree is flattened to absolute geometry before it's sent to the backend, so runs and saves always see resolved coordinates.

Detectors in the tree

Detectors appear at the bottom of the tree marked with a . Click one to select it (highlighted in 3D). Use its dropdown to assign a dose-response material — the material whose response function converts the detector's flux to dose.

Point detectors are fully supported by the transport code. Each FC … TYPE POINT card becomes a real PointDetector in srtc mc3d (an MCNP-style next-event estimator scoring fluence/dose) and is the origin the Raytrace tab casts rays from. So a point detector at the centre of a shield gives a dose end to end — the default deck demonstrates exactly this (Quick start, §0). Assign it a material to score dose in that material (e.g. Silicon).

Collapsing side panels

Every side-menu panel has a ▾/▸ header — click it to minimize/expand the panel, so you can collapse menus you're done with while setting up a run.

Save deck

The Save deck panel writes the base deck plus your GUI geometry:

Load geometry

Top bar: Load path (a server-side .mcnp/.deck path) or Upload deck (a local file). Loading a new base keeps your GUI-added shapes layered on top.


4. Environment tab

Defines the source spectra that feed runs. On startup it loads default AP9 proton and AE9 electron 350 km LEO spectra.

Spectrum import prompt (delimiter, flux vs fluence)

Picking a file opens a dialog before anything is loaded, because every one of these choices is a silent-wrong-answer if it is guessed:

Control Options Why it matters
column delimiter auto-detect, comma, tab, space, semicolon, any whitespace A space-delimited export read as comma-delimited yields zero points and looks like an empty file. Auto-detect picks whichever delimiter parses the most rows.
quantity fluence (already time-integrated) / flux (rate) A flux is scaled to SRTC's per-day convention and its dose is reported as rad/day; a fluence is a one-shot exposure and its dose is rad.
form differential (/cm²/MeV) / integral above each energy (/cm²) An integral table read as differential is wrong by the bin width at every energy.
per N ⟨unit⟩ (flux only) seconds…years Shown with the exact scale factor applied, e.g. × 8.640e4 → /day.

The dialog shows the first lines of the file as split under the current delimiter, the point count and energy span, and every line it could not use with the reason (only 1 column under this delimiter, non-numeric energy or value, …). It then plots the spectrum as SRTC will read it, so a wrong form or time base is visible as a shifted curve before it is committed.

It blocks the load on: no usable rows, a single point (nothing to integrate over), or an all-zero/negative spectrum. It warns (without blocking) on negative values, duplicate energies, skipped lines, an energy range that looks like keV or eV rather than MeV, and a form/monotonicity mismatch — values that rise with energy while declared "integral above" mean the file is really differential.

The declared convention lives with the source, so a later change to it re-normalizes the already-uploaded points; there is no need to re-upload.

Heavy-ion (GCR) environment

Two formats are accepted, detected from the file's content, not its name:

Format Recognised by Axis as written Flux as written
OLTARIS BON2020 its … array dimensions and data header cards MeV/amu #/(cm²·day·EU*)
CREME96 anything else MeV/nucleon #/(m²·s·sr·MeV/nuc)

BON2020 already writes SRTC's per-cm²-per-day, omnidirectional convention, so the only conversion is the per-nucleon energy axis. A CREME96 flux is a directional rate — per steradian, per second, per m² — and reaching SRTC's units multiplies by 4π sr, by 1e-4 (m²→cm²) and by 86400 (s→day): a factor of ~108.6, which is exactly the kind of thing that silently produces a two-orders-of-magnitude wrong dose if guessed. So the units are read out of the file's own header when it states them, defaulted to CREME96's standard when it does not, and the assumption is printed in the panel rather than buried (CREME96: #/(m²·s·sr·MeV/nuc) vs MeV/nucleon — ×1.0857e2 to #/(cm²·day·MeV) vs total MeV). Force a reader with srtc hi-environment --format oltaris|creme96 if detection ever gets it wrong.

CREME96 files may be laid out either as per-element blocks (a Z = 26 / Fe / Fe56 / ION 26 56 header line, then energy flux rows, repeated) or as a wide table (Energy H He Li … header, then one energy and one flux column per element). Two-column data appearing before any species header is an error, not a guess. CREME96 tabulates per element, so each Z transports as its most abundant isotope (Z=26 → Fe-56, Z=28 → Ni-58). test_spectrums/creme96/format_example.flx is a runnable layout example — illustrative numbers, not a physics reference.

Below the NIEL controls, environment file lists the environment files under SRTC_HI_ENV_DIR, grouped into Uploaded and Shipped. On startup the panel loads SRTC_DEFAULT_HI_ENV (the BON2020 boundary flux — free-space GCR), so heavy ions arrive populated like the AP9/AE9 proton and electron spectra rather than needing a selection first. The hi_Flux_*gcm2 files in the same directory are already-transported spectra behind aluminium; using one as a source double-counts that shielding.

Load file uploads your own .dat/.txt (BON2020) or .flx/.txt (CREME96), which is then selected and parsed. Uploads land in <work_dir>/hi_env/uploads/ (a volume in containers) and shadow a shipped file of the same name. Unlike the proton/electron Load CSV buttons — two-column files the browser parses itself — a multi-species environment file's reader lives in srtc-rs, so the bytes go to the server first.

Selecting a file parses it server-side (srtc hi-environment) into per-species spectra — 28 species for the shipped boundary file — with energies converted from the file's per-nucleon axis to total MeV per ion.

The parse is cached by (file, mtime, LET) for the session, so re-selecting an environment is instant, and the LET evaluation loads only em_stopping.h5 (2.7 MB) rather than the whole library set (67 MB, most of it hadronic.h5) — LET is dE/dx and nothing else. Measured on the shipped boundary file: 0.70 s → 0.16 s cold, 0.01 s cached. If a load is still slow, the time is I/O on the physics volume or an emulated-architecture container, not the parse — check docs/DEPLOY_DOCKER.md before looking anywhere else.

LET units

LET is displayed in MeV·cm²/mg in silicon, which is mass stopping power: no density enters the quantity, and "in silicon" names the material whose stopping power it is. This is the unit device SEE cross-section curves σ(LET) are published against, so a spectrum here can be folded against a datasheet without converting.

The internals stay in keV/µm — the LetGrid bins, the # LET section of a curve CSV, and let_vs_depth.csv from hi-depth-sweep all carry keV/µm column names, and srtc hi-environment --with-let emits let_si_kev_um. Only the GUI converts, by 233.0 keV/µm per MeV·cm²/mg (ρ_Si = 2.33 g/cm³). That factor is owned by let_spectrum::kev_um_per_mev_cm2_mg and pinned by a Rust test that names the web-side constant, so a compendium density edit fails a test instead of silently mislabelling every LET axis in the viewer.

The LET spectrum mode bins each selected species' fluence onto the same grid hi-depth-sweep writes (24 bins/decade), sums them into an "all species" total, and offers differential (dN/dLET) or integral-above — the latter being the curve an SEE rate integral consumes. Neutral and leptonic species (photon, neutron, electron) are excluded from it, the same exclusion raytrace::hi_depth makes: photons and neutrons have no silicon LET at all, and an electron does, so leaving it in would quietly inflate the total.

Heavy-ion tracking cut (min energy [MeV/u])

min energy [MeV/u] (default 10, the CREME96 convention) is the energy per nucleon below which an ion is neither transported nor counted in any LET spectrum. It is a transport cut, not a display filter: below it the ion is removed from the state and never scored.

The reason is range against LET. Fe-56 at 10 MeV/u has a residual range of ~0.03 g/cm² — it cannot cross a shield, and it cannot reach a sensitive volume from anywhere outside one. But LET rises as an ion slows, so an ion at that energy sits at the very top of the LET spectrum (~8 MeV·cm²/mg), which is precisely the region an SEE rate integral is most sensitive to. Without the cut, tracks that go nowhere dominate the part of the answer that matters most.

Consequences worth knowing:

The same number reaches every consumer: the 1D Transport tab's curve (§4b), the Raytrace tab's per-ray ion transport, and the deck field TRANSPORT ION_EMIN_MEV_PER_NUCLEON= for hand-written decks.

The panel states the two approximations inline: no fragment production (so heavy-ion flux at depth is a lower bound) and unrestricted LET (an upper bound for thin sensitive volumes).

Selected species carry over to both run paths, and in both they ride the same job as the proton/electron pass rather than a job per species: the 1D Transport tab's dose-depth curve (§4b) and the Raytrace tab (one deck with many SPECTRUM blocks).


4b. 1D Transport tab

Pushes the environment through slab ladders. One action transports everything loaded on the Environment tab — proton and electron spectra plus, when one is selected, the heavy-ion (GCR) environment — through the same depth ladder, into one curve file. Generating them together is the point: a curve-mode raytrace reads that one file, so a curve built without the ion leg silently carries no GCR dose.

Checkboxes pick which light species contribute; transport N heavy-ion species (GCR) (on by default whenever an environment is selected) adds the ion leg. The depth ladder, shield/detector material and curve naming work as before, and the curve feeds the Raytrace tab's dropdown.

What the ion leg adds to the same curve file:

Where Contents
heavy_ion (rad/day) column ion dose at each depth, summed over the selected species, inside the curve's total — so a curve-mode raytrace picks up GCR dose with no extra step
# HI section per depth and species: integral flux, dose, mean silicon LET
# LET section silicon LET spectrum vs depth, differential and integral-above per bin
# SPECTRA section (only with also spectrum vs depth) per-species ion flux vs depth, as ion_<species> alongside the proton/electron/neutron/photon rows

The curve viewer grows a heavy ions (GCR) vs depth panel for such a curve: a depth picker, the LET spectrum (integral-above by default — the SEE convention, since an event rate is a rate above a threshold) and the per-species table sorted by dose.

The ion leg transports exactly the species ticked on the Environment tab, which by default includes the environment's own H and He rows. Those are GCR protons and alphas — a different population from a trapped-proton spectrum loaded as a source, so counting both is correct. Untick them if your proton source already covers the same population; the # HI table shows each species' contribution, so a double count is visible rather than hidden in a total.

A heavy-ion environment is always a rate. If the proton/electron sources are a fluence (already time-integrated), the ion dose cannot be summed with them without a mission duration, so the column is still written — honestly labelled rad/day — but marked [not in total] and left out of the sum.

The min energy [MeV/u] cut is set on the Environment tab (§4) beside the environment itself, so this curve and a raytrace of the same environment can never disagree about which ions exist. The chip here shows the value in force.

Ion transport is stopping power plus Tripathi/Kox attenuation — fragments are not produced, so ion flux at depth is a lower bound and the light ions built up from fragmenting heavies are missing. LET is unrestricted (δ-rays counted at the track), an upper bound for thin sensitive volumes.

Standalone heavy-ion sweep (CLI / API)

The separate per-species CSV bundle is still available outside the GUI, for scripted use where flux_vs_depth.csv itself is the product:

srtc hi-depth-sweep --environment <file> [--format oltaris|creme96] \
  --species Fe56,O16,alpha --material AL --depths-g-cm2 0,1,5,10 --out <dir>
File Contents
flux_vs_depth.csv depth, species, z, a, bin_low_MeV, bin_high_MeV, fluence — kept per species so LET can later be recomputed in another target material without re-transporting
let_vs_depth.csv silicon LET per depth, differential and integral-above
summary_vs_depth.csv per depth and species: integral flux, dose, mean LET

The same numbers, from the same code path (raytrace::compute_hi_depth), as the sections the curve file carries.


5. Raytrace (Run panel)

Opened from 5 Run in the top menu (or the ▶ 5 Run step in the readiness stepper), then the Raytrace engine chip. The menu's function entries are numbered in workflow order — 1 Geometry · 2 Materials · 3 Detectors · 4 Environment · 5 Run — and the stepper under the menu mirrors them: each step shows a ✓ when complete or how many selections are missing (click for the list; items open the panel that fixes them).

Solid-angle dose from a detector point through the CSG (ray-trace sectoring):

Absolute normalization. The Environment spectra are absolute differential fluence (per_day_cm2_mev); deterministic doses are physical rad/day directly from them. In curve mode the OLTARIS curve carries the absolute dose-vs-depth instead. (An OLTARIS electron curve is not yet shipped — only the proton reference exists in-repo.)

Ray visualization (dose-entry directions)

The Rays (dose entry) panel casts rays over 4π from a point and draws them colored by traversed thickness (a damage heatmap): red = thin (least shielding → most dose/damage), blue = thick (well shielded). A color legend shows the g/cm² range. Trace computes them; the max-thickness slider filters which rays show (default = least-material 10% — the highest-dose directions). Added shielding shapes are reflected in the rays.


6. Adjoint MC (Run panel)

Opened from 5 Run in the top menu, then the Adjoint MC engine chip. Two modes, chosen with the toggle at the top:

scoring volume (surface coupling)

Volume-averaged flux and dose inside a part, obtained in two legs — the scheme Geant4, NOVICE and FASTRAD all offer. Protons, neutrons, photons and electrons transport together (the coupled multigroup group index carries the species).

   world sphere (environment)
        ▲                  │
 leg 1  │ adjoint          │ leg 2  forward
        │                  ▼
      ╔═╧══ scoring volume's own surface ══╗
      ║              scoring volume        ║
      ╚════════════════════════════════════╝

You pick one thing: the volume to score in. Its own boundary is the coupling surface — there is no second surface to choose, and no pair to get wrong.

Leg 1 (adjoint) emits from that surface out to the world sphere, where the histories are folded against the environment and normalized. That yields the flux arriving at the surface, with its energy and direction distributions. This is reciprocity used directly: an adjoint history born on the surface, folded against the environment where it arrives, estimates the forward flux there. Running it in reverse costs one pass over the geometry outside the part, rather than transporting the whole environment inward and keeping the small fraction that reaches it — which is why it pays off exactly when the part is small and deeply buried. The result is cached to boundary_flux.h5; the outside of the problem does not change when the inside does.

Leg 2 (forward) takes that distribution and transports it forward through the scoring volume, tallying track-length flux and dose. Unlike the point-detector mode, this gives you the flux spectrum inside the volume, not just a scalar dose.

Controls:

Run adjoint MC launches srtc mc3d. Results are absolute, in the environment's own units (per day for AP9/AE9), not per source particle.

Two things worth knowing. Leg 2 treats the surface as a black boundary: a particle that leaves through it is done, because its re-entry is already counted in the collected inward current. And the surface has to bound a cell — which a part's own boundary always does, but a hand-written deck can name a surface no cell references, and that one is geometrically inert: the tracker never reports crossing it, so the restart silently would not happen. The run refuses rather than returning a wrong number.

Validation. Two external codes and two closed forms.

Geant4 (tests/comparisons/surface_coupled_geant4/) — the same sphere geometry and IRENE environment, absolute rad/day on both sides. AP9 protons over 1–20 mm Al agree to 1.02–1.09; AE9 electrons show a depth trend that the direct adjoint shows equally, so it is SRTC's known electron gap rather than the coupling.

PHITS (tests/comparisons/surface_coupled_phits/) — isotropic AP9 protons above 50 MeV, compared as an Al/void ratio so each code's source normalisation cancels: 0.9508 against 0.9562, i.e. 0.6%.

Closed forms (tests/adjoint_surface_coupling.rs, fast tier, no ENDF) — these need no reference code at all, which is what lets them catch an error that scales everything equally: a Lambertian sphere of radius R carrying current I must produce a uniform interior field of exactly I/(πR²), and in vacuum an isotropic environment J across area A must be collected as A·J/4. On top of those: coupling on the scoring volume's own surface reproduces the direct adjoint, the answer does not depend on where the coupling surface sits, and a volume detector converges onto a point detector as it shrinks (4 cm → 1.394, 2 cm → 1.107, 1 cm → 1.042, 0.5 cm → 1.026).

Where it is weaker. A near-monoenergetic environment makes the adjoint fold heavy-tailed — seed to seed it moves ~50%, against 2.4% for a broad spectrum — so this is a solver for the broad environments it was built for (AP9, AE9, GCR), not for beam lines. And leg 2 transports only through the part you scored in; collecting on one surface and scoring somewhere deeper is reachable from a hand-written deck, not from here.

point detector (pure adjoint)

Dose to a point detector through a spherical shield (the default deck's setup). This is the validated Shield2Dose-spherical physics — pure adjoint born at the point detector, scored against the environment with a Silicon dose response and a spherical exit correction:


6b. Forward 3D tab

Its own top-level panel, not part of Run. Forward Monte Carlo on the CSG geometry, scoring volumetric cells and an optional 3D mesh.

It is separate because it shares nothing with the raytrace/adjoint forms: the source is a beam rather than an environment spectrum, the detectors are volumetric cells rather than points, and the main output is a .vti file opened in ParaView.

Source — pick a kind (point / pencil / surface / environment), a species (proton, electron, photon, neutron) and an energy. Pencil takes a direction; surface takes a deck surface id, a disc radius for planes, and a cosine or isotropic hemisphere. The three Gaussian sigmas (beam spot, energy width, divergence) are 0 = exact.

Where the beam may start — anywhere. A particle is only transported if some region claims the point it is born at; a point no cell contains is killed at birth, transporting nothing and scoring nothing (which_region in src/mc3d/track.rs), which reads as a clean run with zero dose and no tracks. GUI-authored geometry has no world cell at all, so for point and pencil sources the viewer appends a void space region sized to reach the source position. Two cases it cannot fix for you, both now reported by mc3d as warning: N particles were born outside every region: a hand-written deck whose own world does not reach the source, and a source placed inside a cell with imp=0.

Scoring cells — tick the shapes to score in. Each becomes one volumetric tally: MCNP F4 flux and F6 dose, both per source particle. Users normally add a cube or sphere for the purpose. The selection is held by shape, not by region id, so editing the geometry drops a stale pick rather than silently moving it to a different shape.

3D mesh — an axis-aligned box with its own origin, size and bin counts, independent of the geometry. Written as VTK ImageData for ParaView.

Scoring particles — leave all unchecked to score every species (the usual case); check some to isolate one component in the mesh.

Trace particle paths (debug) — records the first N paths of each species and draws them in the 3D view: electrons red, protons blue, photons green, neutrons yellow, with secondaries under their own species (proton/electron → bremsstrahlung photons → knock-on electrons). Recording runs on the serial transport path, so it is a small subset of the histories — a look at where particles go, never a tally. Off by default. The per-species checkboxes and the ✕ (discard) are in the track legend overlay; wireframe or hide the outer shell to see inside. The "escaping (dose-contributing) only" filter is adjoint-only and is not offered for forward tracks.

Outputs, in the run folder (Results tab): forward3d.toml, geometry.mcnp, tally.json, forward_summary.csv, <name>.vti, and tracks.json when path tracing is on. Runs are listed as Forward 3D.

Units are per source particle: flux 1/cm²/source, dose MeV/g/source — the PHITS convention, so a run compares directly against a mesh=reg PHITS tally.

7. How runs execute (the deck is the source of truth)

Each run assembles inputs into WORK_DIR/<job>/ and runs srtc:


8. HTTP API reference

Method Path Body / result
GET /api/health liveness {status:"ok"}
POST /api/geometry/load `{deck_text?
POST /api/geometry/save {…, out_path?}{deck_text, bytes, written_path}
POST /api/geometry/rays {…, origin?, n_rays?} → per-ray areal density
GET /api/materials SRTC material registry
GET /api/curves shipped dose-depth curve names
GET /api/curves/detail ?name={x_label, series[]} (main dose table only)
GET /api/curves/spectra ?name= → per-(particle, depth) spectrum slots
GET /api/curves/heavy-ions ?name={source, species[], let_spectra[]} from the curve's # HI / # LET sections; empty for a curve built without ions
GET /api/hi-environments shipped + uploaded heavy-ion environment files
GET /api/hi-environment ?file=&with_let= → parsed per-species spectra (format, flux_note)
POST /api/hi-environment/upload ?name= + raw body (.dat/.txt/.flx) → {file, bytes}
POST /api/jobs/dose_depth_curve {sources[], depths_*, hi_environment?, hi_species[]?, …}{job_id, curve}
GET /api/default-spectra {proton[], electron[]}
POST /api/jobs/raytrace {…, particle, curve, detector, spectrum[]}{job_id}
POST /api/jobs/adjoint {…, n_histories, surface_detector_id, volume_detector_id}{job_id}
GET /api/jobs/:id {status, log_tail, result, error}
GET /api/jobs job summaries

9. Troubleshooting