Ray Tracer Driver
The RayTracer driver follows deterministic ray paths through the simulation domain (vs. the MCRT driver's stochastic photon transport). Use it for:
- Projections: column density maps, temperature-weighted projections
- Volume rendering: 3D visualization with transfer functions
- Line tracers: quantities along specific sight lines
Deprecation: OpticalDepthGridOperator → SightlineOperator
OpticalDepthGridOperator (the absorptiongrids operator) is soft-deprecated
and will be reconciled into SightlineOperator, which is the long-term home for
absorption τ (per-line density routing, per-species output, Hubble modes, MPI). It
emits a one-time runtime warning. For new work prefer sightlines with a tau:
block. The one case still better served by absorptiongrids is a dense
image/IFU grid: it writes a compact (npy, npx, nbins) cube, whereas
SightlineOperator writes a flat/dense layout — a shared lam (nbins) plus a dense
tau (nray, nbins), with optional flat per-cell field arrays indexed by a
cell_offsets CSR table. Once SightlineOperator gains a grid/τ-only output mode,
absorptiongrids will be removed.
Operator overlap
TracerOperator, SightlineOperator, and OpticalDepthGridOperator all walk
rays and record per-cell or integrated quantities; expect further consolidation,
so some configuration keys and output layouts may change.
Architecture
flowchart TD
subgraph RayTracer["RayTracer Driver"]
init[Initialize] --> run[run]
run --> proj[run_projections]
run --> trace[run_tracers]
run --> abs[run_absorptiongrids]
run --> sl[run_sightlines]
end
subgraph Operators["Operators"]
proj --> ProjOp[ProjectionOperator]
proj --> VolOp[VolumeRenderOperator]
trace --> TracerOp[TracerOperator]
abs --> AbsOp[OpticalDepthGridOperator]
sl --> SlOp[SightlineOperator]
end
subgraph Kernel["SYCL Kernel"]
ProjOp --> kernel[evolve_step]
VolOp --> kernel
TracerOp --> kernel
AbsOp --> kernel
SlOp --> kernel
kernel --> field[Field Access]
end
field --> smart[SmartFieldAccessor]
field --> dynamic[DynamicFieldAccessor]
Ray Tracer Parameters
These top-level keys in the raytracer: block are driver-level and apply across all operators:
| Parameter | Type | Default | Description |
|---|---|---|---|
dist_max |
float | - | Maximum ray distance (in box widths) |
max_step |
float | 1.0 |
Maximum step length along the ray |
min_step |
float | 0 |
Minimum step length |
outputpath |
string | required | Output file path |
overwrite |
bool | false |
Overwrite existing output |
linename |
string | - | Spectral line name (required for line-specific fields) |
populate |
dict | - | Maps derived field names to dataset field names |
kernel_stats |
bool | false |
Compute kernel execution statistics |
Operators
ProjectionOperator
Computes 2D projections by integrating field values along rays:
Where:
- \(q(s)\) is the quantity being projected (e.g., Temperature)
- \(w(s)\) is the weight field (typically Density)
- \(L\) is the path length through the domain
Configuration example:
raytracer:
dist_max: 0.7 # maximum ray distance (box widths)
max_step: 1e-2 # maximum step length
outputpath: output.zr
overwrite: true
linename: FeXXV # required for line-specific fields
populate: # map derived fields to loaded dataset fields
Emissivity: "cloudyemission_Fe25 1.85040A"
operators:
projections:
mode: manual
view: orthogonal # orthogonal, perspective, or equirectangular
position: [0.5, 0.5, 0.0]
direction: [0.0, 0.0, 1.0]
up: [0.0, 1.0, 0.0]
npixels: [512, 512] # [width, height] in pixels
widths: [1.0, 1.0] # field of view [x, y] in box units
fields:
- Temperature
- Density
use_weighting: true # weight projections by density
perform_averaging: true # normalize by total weight (density-weighted average)
Projection Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
mode |
string | required | Camera mode (see below) |
view |
string | "orthogonal" |
Projection type (see below) |
fields |
list | required | Fields to project |
npixels |
list | [128, 128] |
Resolution as [width, height] |
widths |
list | - | Field of view [x, y] in box units |
position |
list | [0.5, 0.5, 0.5] |
Camera position [x, y, z] in normalized coords |
direction |
list | - | View direction [x, y, z] (unit vector) |
up |
list | [0, 1, 0] |
Up vector [x, y, z] (unit vector) |
nframes |
int | 1 |
Number of frames (for rotate/traverse modes) |
use_weighting |
bool | true |
Weight projections by density field |
perform_averaging |
bool | true |
Normalize by total weight (density-weighted average) |
invert |
bool | false |
Invert ray direction |
start_dist |
float | mode-dependent | Offset each ray forward from the camera by this distance (normalized box units), so integration skips an inner sphere of radius start_dist. Default is 0.001 for orthogonal/perspective/equirectangular (legacy epsilon) and 0 for healpix. Useful for healpix-from-galaxy-center maps where the central SFR cell would otherwise dominate every line of sight. |
View Types
| Value | Description |
|---|---|
"orthogonal" |
Parallel projection (default) |
"perspective" |
Perspective projection with field of view |
"equirectangular" |
360° equirectangular projection |
"healpix" |
Equal-area all-sky map in HEALPix RING ordering |
For perspective view, an additional fov_up parameter (in degrees, default 90) controls the vertical field of view.
For healpix, a single camera position emits one ray per HEALPix pixel; the
output is a 1D zarr array of length 12 * nside² (RING ordering). Required
extra keys:
| Parameter | Type | Default | Description |
|---|---|---|---|
nside |
int | required | HEALPix resolution; must be a positive power of two. |
ordering |
string | "ring" |
RING ordering only (NESTED not yet supported). |
The angular frame is box z-up: theta is measured from +z, phi
increases from +x toward +y, regardless of camera basis. The dataset
carries a healpix attribute block (nside, ordering, frame, npix)
in its zarr attributes; downstream Python code can call
healpy.pix2ang(nside, ipix) directly on the index. Example:
projections:
mode: manual
view: healpix
nside: 16 # 12 * 16² = 3072 pixels (Smith+19 convention)
position: [0.5, 0.5, 0.5]
start_dist: 0.005 # optional: skip inner 0.005 box-units (e.g. central SFR core)
fields: [Density, Temperature]
stereoscopic is not supported in healpix mode.
Camera Modes
The mode parameter controls how rays are generated across frames:
| Mode | Description |
|---|---|
manual |
Explicit position, direction, and up vectors |
rotate |
Rotate camera around a center point over nframes |
traverse |
Move camera along direction by travel_distance over nframes |
Mode-specific parameters:
rotate: requirescenter(point to orbit around)traverse: requirestravel_distance(total distance to travel alongdirection)
VolumeRenderOperator
Performs volume rendering with density-weighted field values for 3D visualization.
TracerOperator
Records a per-ray profile along each sight line: instead of collapsing the
ray to a single pixel (as ProjectionOperator does), it stores an ordered list
of segments with, for each, the segment's path length and an accumulated
quantity (e.g. the mean field value over that segment). Output is therefore a
ragged, per-ray structure ("skewers"), not a 2D image — so it accepts any ray
layout (Grid2D, Healpix, or Scatter ray sources).
The operator is built from four orthogonal, JIT-specialized choices:
- What ends a segment — the close rule.
- What is accumulated within a segment — the accumulator.
- How the ray itself propagates — the propagation mode (straight, or field-aligned / curved).
- What ends the whole ray — the stop rule (in addition to the always-on
dist_max/ domain-exit / stuck-ray terminators).
Segment model: close rule + accumulator
close_rule |
A new segment begins when… |
|---|---|
every_cell (default) |
every cell crossing — one segment per cell (the classic tracer/skewer) |
angle_threshold |
the propagation/field direction has rotated past angle_threshold from the segment's seed direction — one segment per coherent run (the coherence-length segmentation, now carrying a quantity) |
never |
never — the whole ray is a single segment, committed exactly once when the ray ends (domain exit or dist_max) |
accumulator |
Stored per segment (contributions) |
|---|---|
weighted_mean (default) |
weight-weighted mean of the traced fields[0] over the segment. weight: density (default) → mass-weighted; weight: none → path-length-weighted (spatial) mean |
length_only |
nothing — only the segment length is recorded (reproduces CoherenceLengthOperator output) |
column |
column integral Σ Δs · q of each traced field over the segment (path lengths in normalized box units) |
Only these (close_rule, accumulator) pairs are supported; any other
combination terminates at startup:
close_rule |
accumulator |
Result |
|---|---|---|
every_cell |
weighted_mean |
per-cell mean quantity (default tracer) |
angle_threshold |
length_only |
per-coherence-segment length (≡ coherence length) |
angle_threshold |
weighted_mean |
per-coherence-segment length and mean quantity (e.g. mean |B| per coherence segment) |
never |
column |
one entry per ray: the column integral Σ Δs · q from the seed to the ray end (per-star obscuration columns) |
The (never, column) pair makes the tracer a column integrator: each ray
commits a single entry — the integral Σ Δs · q of each traced field from the
seed to the ray's end (domain exit or raytracer.dist_max). Output naming
matches weighted_mean (one contributions dataset, or contributions_<field>
per field), and lengths holds the ray's total path length. A committed column
is in normalized box units × field units; multiply by length_unit_cm from the
output store for a physical column. This is the pairing behind the
source_points ray source — one obscuration column per star (see
Source points).
Curved-ray propagation
By default rays travel straight. Setting propagation_mode: field_aligned makes
each ray follow a vector field (direction_field) cell-by-cell — it becomes
a streamline. The ray source then only seeds the field-line entry points; the
path is determined by the field. This is the same DirectionFunctor abstraction
the CoherenceLengthOperator uses, so a field-aligned tracer measures, e.g., the
coherence length of a magnetic field line and the mean |B| along it in one pass.
field_aligned requires raytracer.dist_max
A field line need not ever leave the box — with PBC, or on a closed/winding
streamline, it can trace forever without a cap. Set raytracer.dist_max to the
longest arc length you want to follow (a path-length budget, not the sqrt(3)
straight-line diagonal). Near-null cells (|direction_field| <= min_field_magnitude)
don't steer the ray — it coasts through with its previous direction — and a
stuck-ray guard ends any zero-progress ray, logging a count.
The Coherence Length cookbook page
illustrates this segmentation cell by cell (schematics for both
angle_reference modes) and applies it to a real snapshot.
Ray-stop rule
The close rule decides where a segment ends; the stop rule decides where
the whole ray ends. They are orthogonal. By default (stop_rule: never) a ray
runs until one of the always-on terminators fires — raytracer.dist_max, leaving
the domain, or the stuck-ray guard. stop_rule: angle_threshold adds an early
termination: the ray ends when direction_field bends past stop_angle_threshold.
stop_rule |
The ray terminates when… |
|---|---|
never (default) |
only the standard terminators fire (dist_max / domain exit / stuck guard) |
angle_threshold |
the direction_field direction rotates past stop_angle_threshold (the breaking cell is not logged — before-add semantics) |
stop_rule: angle_threshold requires close_rule: every_cell
The angle stop is only allowed with the per-cell close rule — that is its sole
intended pairing ("log every cell until the field bends"). With close_rule:
angle_threshold the ray already segments at each bend, so an angle stop on top
would just truncate at the first segment (redundant); THOR rejects that combo at
startup. It also keeps the kernel-instantiation count down (the StopAtAngleThreshold
variant is compiled only for every_cell).
stop_angle_reference chooses what the rotation is measured against, exactly like
angle_reference does for the close rule:
continuous(default) — compares each cell to the previous one, so the ray stops at the first adjacent cell-to-cell bend exceeding the threshold.cumulative— compares each cell to the ray's seed direction, so the ray stops once the field has drifted past the threshold relative to where it started (there is no re-seed — a stop ends the ray).
The angle is measured on the raw field direction — the stop does not apply
flip_anti_parallel. A ~180° reversal of the field between cells therefore exceeds
any sub-180° threshold and terminates the ray (a field reversal is treated as a
genuine coherence break). This matches the close rule's angle convention; note it
differs from propagation_mode: field_aligned, which does flip so the ray keeps
heading forward through a reversal — so a field-aligned ray can coast through a
reversal while the stop rule ends it there.
The canonical pairing is close_rule: every_cell + stop_rule: angle_threshold:
it logs every cell (full per-cell fidelity) but only until the field bends,
giving a bounded, physically meaningful per-ray length. That bound is what keeps a
per-cell trace tractable at full resolution — an unbounded every_cell trace runs
to dist_max and emits orders of magnitude more entries.
Angle stop vs. angle close — when to use which
To measure the coherence-length distribution, use close_rule:
angle_threshold (it emits one length per coherent run, many per ray). To log
the per-cell trajectory up to the first bend, use close_rule: every_cell +
stop_rule: angle_threshold. A cumulative angle stop paired with a
reduction is largely redundant with the angle close — the stop's value is
realized when paired with per-cell logging.
Tracer Parameters
The camera-grid keys (mode, view, position, direction, up, npixels,
widths, …) are shared with the projection operators — see
Projection Parameters, View Types, and
Camera Modes. The tracer-specific keys:
| Parameter | Type | Default | Description |
|---|---|---|---|
fields |
list | [Density] |
Quantities to accumulate per segment, in one pass (up to MAX_TRACE_FIELDS, default 8; see accumulators.h). fields[0] is the primary (routed through the field accessor; may be specialized/JIT); fields[1..] are extra array-backed fields sampled directly from the dataset. Recipe/wildcard fields (e.g. MagneticFieldMagnitude) resolve here. Single-field output is the contributions dataset (unchanged); multi-field writes one contributions_<field> dataset per field. Multi-field requires accumulator: weighted_mean or column and a single MPI rank. |
close_rule |
string | every_cell |
Segment boundary rule (see above). |
accumulator |
string | weighted_mean |
Per-segment accumulation (see above). |
weight |
string | density |
weighted_mean weighting: density (mass-weighted) or none (path-length / spatial); column ignores the weight. Note: with none and a specialized/computed primary field, Density must still be loaded (used as a placeholder weight, then ignored). |
angle_threshold |
float (deg) | 30.0 |
angle_threshold close rule: segment ends past this deviation. |
angle_reference |
string | cumulative |
cumulative = deviation from the segment seed direction; continuous = deviation between consecutive cells. |
min_segment_length |
float | 0.0 |
Discard segments shorter than this (box units). Suppresses sub-resolution grazing-crossing micro-segments on unstructured meshes. 0 = off. Rejected with close_rule: never (there the whole ray is a single segment). |
stop_rule |
string | never |
Whole-ray termination rule (see above). never or angle_threshold. |
stop_angle_threshold |
float (deg) | 30.0 |
angle_threshold stop rule: the ray ends when direction_field bends past this. |
stop_angle_reference |
string | continuous |
continuous = bend between consecutive cells (stop at first sharp kink); cumulative = bend from the ray's seed direction. |
propagation_mode |
string | straight |
straight or field_aligned. |
direction_field |
string | velocity |
Vector field the ray follows (field_aligned) and/or the close rule / stop rule measures (angle_threshold). Resolves <name>X/Y/Z (or velocity → VelocityX/Y/Z). |
flip_anti_parallel |
bool | false |
field_aligned: flip the new direction on a negative dot with the prior one. Set true for magnetic fields (a field line has no inherent +/− sign); leave false for directional fields like velocity. |
min_field_magnitude |
float | 1.0e-10 |
Cells with |direction_field| below this are treated as nulls — the ray coasts straight through with frozen direction (no segment contribution). |
max_segments_per_ray |
int | 100 |
Initial per-ray segment buffer; grows dynamically if a ray exceeds it. |
initial_entries |
int | 100 |
Initial per-skewer buffer capacity before any realloc. |
write_tracer_origins |
bool | true |
Write each ray's origin position to the output. |
Tracer Output
Written under the tracers/<field>/ zarr group as flat arrays indexed by a
per-ray offsets table. The written arrays are compacted: ray i's
entries occupy [offsets[i], offsets[i+1]) exactly (last ray runs to the end
of the array), so its segment count is offsets[i+1] - offsets[i] — no
padding. Naming note: the array called lengths is not a segment count:
under weighted_mean it holds the per-segment path lengths (in normalized
box units); under length_only it stays zero and the segment length is
written to contributions instead (see the table); under column there is a
single segment per ray, so it holds each ray's total path length.
| Array | dtype | Layout | Meaning |
|---|---|---|---|
offsets |
uint32 | one per ray | start index of ray i's slice in the flat arrays |
lengths |
float | flat (segments) | per-segment path length, normalized box units (weighted_mean; zeros under length_only; the ray's total path length under column) |
contributions |
float | flat (segments) | per-segment accumulated quantity (weighted_mean: the mean; length_only: the segment length itself; column: the column integral Σ Δs · q) |
origins |
float | one per ray | the ray's post-trace position — where it ended up (domain exit / stop point), not the seed (when write_tracer_origins) |
Tracer MPI support
TracerOperator has MPI support for the legacy/default tracer mode:
close_rule: every_cell, accumulator: weighted_mean,
propagation_mode: straight, and stop_rule: never. This path is covered by
the tracer_mpi, tracer_mpi_pbc, and tracer_mpi_pbc_offaxis regression
tests, which compare multi-rank output against a one-rank baseline.
The newer tracer modes are single-rank only. propagation_mode:
field_aligned, close_rule: angle_threshold, close_rule: never, and
stop_rule: angle_threshold terminate at startup under MPI: field-aligned rays
bend away from the precomputed per-rank segment table, angle-threshold
segmentation would over-split at every rank boundary, a never-close column would
fragment across ranks instead of committing once per ray, and the angle stop's
per-ray reference cannot follow a ray across a rank handoff. Scatter ray sources (e.g. random_uniform) are
likewise single-rank because they have no meaningful camera depth axis to merge.
Tracer Examples
Classic per-cell skewer (default), Density along an orthographic grid:
raytracer:
operators:
tracers:
mode: manual
view: orthogonal
position: [0.01, 0.5, 0.5]
direction: [1.0, 0.0, 0.0]
npixels: [256, 256]
widths: [0.98, 0.98]
fields: [Density]
# close_rule: every_cell, accumulator: weighted_mean, weight: density (defaults)
Magnetic field-line coherence length and mean |B| per segment (curved ray):
raytracer:
dist_max: 1.1 # REQUIRED for field_aligned; max path length to trace
operators:
tracers:
mode: manual
view: orthogonal
position: [0.01, 0.5, 0.5]
direction: [1.0, 0.0, 0.0] # seeds entry pixels only; path follows B
npixels: [640, 640]
widths: [0.98, 0.98]
fields: [MagneticFieldMagnitude]
close_rule: angle_threshold
accumulator: weighted_mean
weight: none # path-length-weighted (spatial) mean |B|
angle_threshold: 90.0
min_segment_length: 2.71e-8 # ~1 pc: drop sub-resolution micro-segments
propagation_mode: field_aligned
direction_field: MagneticField
flip_anti_parallel: true # required for magnetic (sign-agnostic) fields
min_field_magnitude: 1.0e-20
SightlineOperator
Traces a user-supplied list of straight rays — each defined by an explicit
start and end point — and dumps the state of every cell each ray
intersects. The output schema mirrors Trident's
lightray.h5 model: one entry per traversed cell, carrying the path length
dl plus per-cell field values. Always-on per-cell fields are density,
temperature, and the three velocity components (velocity_x/y/z); extra
fields (e.g. ion densities) are requested via fields:.
Optionally, a tau: block enables in-engine optical-depth integration: after
the cell-field passes, a Voigt-deposition kernel computes a per-ray
optical-depth spectrum τ(λ) directly from the collected per-cell data — no
extra mesh queries:
Where:
- \(\rho_c\) is the (per-line) density in cell \(c\), \(dl_c\) the path length through it
- \(\sigma_{\text{Voigt}}\) is the Voigt profile cross-section, with thermal + (optional) turbulent broadening
- \(v_{\text{LOS},c}\) is the line-of-sight velocity, including peculiar motion and optional Hubble flow
Frame convention
For a ray, start = source, end = observer, so the propagation
direction (end − start)/L points source → observer. A cell approaching
the observer (d·v > 0) is blue-shifted; Hubble flow far from the
observer is red-shifted. v_LOS and redshift_eff are deliberately
not written by the engine — they are derived in the Python
lightray-converter step (see below).
Configuration example (cell dump):
raytracer:
outputpath: output.zr
overwrite: true
operators:
sightlines:
rays:
- start: [0.0, 0.5, 0.5] # box / code units
end: [1.0, 0.5, 0.5]
id: 0 # optional; defaults to list index
- start: [0.5, 0.0, 0.5]
end: [0.5, 1.0, 0.5]
fields: # extra per-cell fields (optional)
- HIDensity
output:
cells: true # write per-cell datasets (default true)
Sightline Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
rays |
list | required¹ | Non-empty list of rays; each needs start and end. ¹Required unless a ray_source: block is given |
rays[].start |
list | required | Ray start [x, y, z] in box/code units (the source) |
rays[].end |
list | required | Ray end [x, y, z] (the observer); must differ from start |
rays[].id |
int | list index | Per-ray identifier; used as the output subgroup name |
fields |
list | [] |
Extra per-cell fields beyond the always-on core |
output.cells |
bool | true |
Write per-cell datasets (dl + fields). Set false to keep only tau/lam |
Generated rays (ray_source:):
Instead of listing rays explicitly, you can generate them programmatically with
the shared ray_source: factory (the same one the other raytracer operators
use). This is the way to produce large ray sets — a grid of parallel sightlines
for tomography, an all-sky set from an observer, or a Monte-Carlo sample.
Because the generators yield a (position, direction) per ray but no endpoint,
sightlines attach a fixed length: (box units): each ray runs from its
generated position to position + length · direction.
sightlines:
ray_source:
type: camera_plane # grid of sightlines across a plane
npixels: [256, 256] # -> 65536 rays
position: [0.5, 0.5, 0.0]
direction: [0.0, 0.0, 1.0]
up: [0.0, 1.0, 0.0]
widths: [1.0, 1.0]
length: 1.0 # ray length in box units (required)
# tau:, fields:, output: all work the same as with an explicit rays: list
| Parameter | Type | Default | Description |
|---|---|---|---|
ray_source.type |
string | required | camera_plane, camera_healpix, or random_uniform |
ray_source.length |
float | required | Ray length in box units (start → start + length·direction) |
The per-type knobs (npixels/widths/position/direction/up for
camera_plane; nside for camera_healpix; n_rays/direction_distribution/seed
for random_uniform) match the Projection Parameters
and the other operators' ray_source: blocks. rays: and ray_source: are
mutually exclusive — provide exactly one. Generated rays are auto-assigned
sequential ids (their index).
Optical-depth integration (tau:):
Add a tau: block to compute τ(λ) spectra. Two mutually exclusive modes,
auto-detected by the presence of a lines: sequence:
sightlines:
rays: [...]
tau:
# --- single-line mode (window relative to line centre) ---
linename: OVI # line identifier
edge_left: -5.0 # Δλ (Å) blue edge from line centre
edge_right: 5.0 # Δλ (Å) red edge
nbins: 200 # number of wavelength bins
# --- OR multi-line mode (absolute window) ---
# lines: [MgII, MgII_2] # e.g. the MgII 2796/2803 doublet
# lambda_min: 2790.0 # absolute Å
# lambda_max: 2810.0
# nbins: 400
include_lyman_continuum: false # add HI Lyman-continuum opacity
use_vturb: false # add turbulent broadening (TurbVelocity)
line_density_fields: # optional per-line density routing
OVI: density # map a line to its per-cell density field
ll_density_field: HIDensity # density field for the LL term
Tau Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
nbins |
int | required | Number of wavelength bins |
linename |
string | required (single-line) | Line name; or line.name |
edge_left |
float | required (single-line) | Δλ (Å) from line centre, blue edge |
edge_right |
float | required (single-line) | Δλ (Å) from line centre, red edge |
lines |
list | required (multi-line) | List of line names |
lambda_min |
float | required (multi-line) | Absolute window start (Å) |
lambda_max |
float | required (multi-line) | Absolute window end (Å) |
include_lyman_continuum |
bool | false |
Add HI Lyman-continuum opacity for λ < 912 Å |
use_vturb |
bool | auto | Turbulent broadening from TurbVelocity; auto-on if the field exists |
line_density_fields |
dict | - | Map each line to its per-cell density field (default: shared density) |
ll_density_field |
string | first routed field | Density field the Lyman-continuum term attaches to |
per_species |
bool | false |
Also write each density-field group's τ separately as tau_<line> (compact per-ion window; see below) |
per_species_window_rtol |
float | 1e-6 |
Per-species window = bins where the ion's τ exceeds this × its peak (data-driven; smaller → wider window, less wing truncation) |
per_species_window_kms |
float | 1500 |
Fallback per-species window half-width (km/s) when a group has no absorption above threshold |
Available line names
linename / lines accept either a curated name (Lya, OVI, CIV,
SiIV, MgII/MgII_2, OVII, FeXXV — authoritative, cross-checked
constants) or any of the ~293 NIST labels from the bundled UV/optical
line list (e.g. "CIV 1548", "OVI 1032", "CII 1334", "NV 1239").
Labels are <ion> <rounded-λ_Å>; look them up in
python/thor-rt/src/thor/absorption/data/line_lists/uv_optical.tsv. The
curated name wins when both exist. The C++ fallback table is generated from
that TSV by scripts/gen_emissionline_header.py (regenerate after updating
the list). Fine-structure components within one label are merged into one
effective line (Σf, f-weighted λ).
Per-ion densities (line_density_fields)
A physically correct multi-ion τ needs each line to use its ion's per-cell
number density, not the total gas density. Route them with line_density_fields,
pointing each line at a loaded per-cell field. With a Cloudy ion table enabled,
THOR auto-registers wildcard fields cloudydensity_<ion> (e.g. cloudydensity_HI,
cloudydensity_CIV, cloudydensity_OVI) = ionization-fraction × element-abundance
× n_H. List those in the dataset's field list (so they're computed by the loader)
and route — the operator auto-collects any routed target per cell, so you do not
repeat them in sightlines.fields:
gadget: # (or unigrid:, …) — the dataset loader computes the ion fields
fields: [..., cloudydensity_HI, cloudydensity_CIV, cloudydensity_OVI]
...
sightlines:
tau:
lines: ["Lya", "CIV 1548", "OVI 1032"]
line_density_fields: # auto-collected — no separate sightlines.fields needed
"Lya": cloudydensity_HI
"CIV 1548": cloudydensity_CIV
"OVI 1032": cloudydensity_OVI
HIDensity). (sightlines.fields is still honoured if you want extra fields
dumped per cell beyond what routing needs.)
Per-species τ (per_species: true). By default all lines are summed into a
single tau. With per_species: true, each density-field group (an ion — lines
sharing a density field, so a doublet like MgII/MgII_2 stays together) also
gets its own tau_<label> dataset, labelled by the group's first line (spaces in
NIST labels become underscores, e.g. CIV 1548 → tau_CIV_1548). The combined
tau is still written. Unlike Trident — which emits only the summed τ and needs
a separate run per species — THOR produces all per-species spectra in one pass.
Each tau_<label> is stored compactly: only over that ion's wavelength window,
as a contiguous slice of the shared lam grid. The window is data-driven — the
deposited τ already encodes peculiar velocity, Hubble flow (per hubble_mode), and
thermal+turbulent broadening, so the window is just its support: the bins where
the ion's τ (max over rays) exceeds per_species_window_rtol × peak (default 1e-6),
always including the line centres, with a small pad. (A group with no absorption falls
back to its lines ± per_species_window_kms.) Placement is recorded in the group attr
per_species[<dataset>] = {bin_offset, nbins, lam_min, lam_max}, so a consumer stitches
it back with no interpolation: full[bin_offset : bin_offset+nbins] = tau_<label>.
This adapts per ion (a narrow line gets a tight window; a doublet or high-velocity
absorber a wider one) and conserves the combined τ's equivalent width to the wing
level set by per_species_window_rtol; at the default rtol of 1e-6 the measured EW
closure is ≈1e-5. On the OVI/CIV/SiIV
AGORA run it is ~1.9× tighter than a fixed ±1500 km/s window and ~2% of a full-grid copy.
tau:
lines: [Lya, MgII, MgII_2, OVI]
per_species: true
line_density_fields: { Lya: HIDensity, MgII: MgIIDensity, MgII_2: MgIIDensity, OVI: density }
# writes: tau (combined) + tau_Lya, tau_MgII (doublet summed), tau_OVI
The Hubble flow and cosmology used by the τ kernel are read from the
driver-level raytracer: block (not under operators):
| Parameter | Type | Default | Description |
|---|---|---|---|
hubble_flow |
float | 0 |
Hubble flow [km/s/Mpc] imprinted along the LOS |
hubble_mode |
string | "constant" |
constant/constant_slope or integrated/integrated_z |
hubble_flow_at_z |
float | 0 |
Snapshot redshift for the E(z)·(1+z) correction |
omega_matter |
float | 0.272 |
Ωₘ (used by both Hubble modes) |
omega_lambda |
float | 0.728 |
Ω_Λ |
Stages
The operator is built in stages. Both are available; the difference is how many MPI ranks the run uses — the YAML and outputs are identical either way.
-
Stage 1 — single-rank. Each ray is traced on one rank and its intersected cells emitted directly. Covers the full feature set: inline
rays:or generatedray_source:, the always-on core fields + extrafields:, optionaltau:integration, andoutput.cells.v_LOS/redshift_effare deferred to the Python lightray converter, not written here. -
Stage 2 — MPI multi-rank. When run on >1 rank, each ray is split into per-rank segments (
compute_ray_rank_pathwith per-raydist_max = |end − start|); τ is integrated on each rank's local per-cell data, thendistributed_mergegathers the per-rankdl/field arrays to rank 0 and reconstructs every ray in depth order along its own direction (zero-dlboundary cells are dropped so the MPI trace matches the single-rank one). Rank 0 writes the output.
Periodic boundaries not supported
Sightlines have explicit endpoints, so PBC is always off (pbc=false) — a
ray is not wrapped across the box. Place start/end (or the generated
ray + length) within the domain.
Ray Sources
Most raytracer examples put camera keys (mode, view, position,
direction, npixels, ...) directly under an operator block. Operators can
also use a ray_source: block, which selects a named ray generator and makes
the ray layout explicit. (SightlineOperator uses this same factory — see its
Generated rays note for the sightline-specific length:.)
Supported ray_source.type values:
| Type | Layout | Description |
|---|---|---|
camera_plane |
Grid2D |
Manual orthographic or perspective camera plane. Uses camera keys such as position, direction or center, up, npixels, widths, and fov_up. |
camera_equirectangular |
Grid2D |
Manual full-sky equirectangular camera. Uses position, npixels, and the camera orientation keys. |
camera_healpix |
Healpix |
Manual HEALPix camera. Requires nside; ordering defaults to ring. |
random_uniform |
Scatter |
Unordered rays with positions sampled uniformly in the normalized unit box. Directions are either fixed or sampled isotropically. |
grid_centers |
Scatter |
One ray per cell of a uniform N³ (or [nx,ny,nz]) lattice — volume-uniform seeding. |
dataset_points |
Scatter |
One ray per dataset cell center (Voronoi sites), optionally a random count subsample. Particle/Voronoi datasets only. |
source_points |
Scatter |
One ray per stellar particle, positions loaded through an MCRT-style source loader (sources: block). Tracer-only, single MPI rank. |
The camera_* sources are the ray_source: spelling of the manual camera path:
type determines the camera view, so mode/view are not needed inside the
block. Camera animation modes (rotate, traverse, nframes, and explicit
frame lists) remain on the legacy inline camera configuration. stereoscopic
is rejected under ray_source: because non-image operators size their buffers
from the mono ray layout.
Random uniform rays
random_uniform emits n_rays rays with start positions
in normalized box coordinates. It sets each ray origin to the sampled position.
The output layout is Scatter, so rays have no pixel, image, or HEALPix
ordering.
| Parameter | Type | Default | Description |
|---|---|---|---|
type |
string | required | Must be random_uniform. |
n_rays |
int | required | Number of rays to emit; must be greater than zero. |
direction_distribution |
string | fixed |
fixed uses one direction for every ray; isotropic samples each direction uniformly on the unit sphere; toward_position (source_points only) aims every ray at the world-space target: point — e.g. a perspective camera pinhole. |
target |
list | required for toward_position |
World-space point (propagation frame) each ray aims at. The tracer still integrates to the domain boundary; to cut each star's column at the target distance, use close_rule: every_cell and cumulative-sum the per-cell output up to |star − target| in post-processing. |
direction |
list | required for fixed |
Direction vector for direction_distribution: fixed; it is normalized before use and must be non-zero. |
seed |
int | 42 |
32-bit RNG seed. The same seed gives the same ray list. |
Example with random positions and isotropic random directions:
raytracer:
operators:
tracers:
ray_source:
type: random_uniform
n_rays: 10000
direction_distribution: isotropic
seed: 12345
fields: [Density]
Example with random positions and a shared fixed direction:
raytracer:
operators:
tracers:
ray_source:
type: random_uniform
n_rays: 10000
direction_distribution: fixed
direction: [0.0, 0.0, 1.0]
seed: 12345
fields: [Density]
Uniform-grid cell centers
grid_centers puts one ray at the center of each cell of a uniform N³ (or
[nx, ny, nz]) lattice — a repeatable, volume-uniform seeding of the box.
| Parameter | Type | Default | Description |
|---|---|---|---|
type |
string | required | grid_centers. |
ngrid |
int or list | required | N for an N×N×N lattice, or [nx, ny, nz]. |
direction, direction_distribution, seed |
As for random_uniform. |
Dataset point centers
dataset_points seeds one ray per dataset cell — for a Voronoi mesh, the
cell-generating sites — or a random count subsample (0 = all, reproducible
from seed). Where the grid is volume-uniform, this samples per cell, so rays
concentrate where the cells are smallest, i.e. in the dense gas. Needs a dataset
with per-cell positions (Voronoi/particle); others report no points and stop.
| Parameter | Type | Default | Description |
|---|---|---|---|
type |
string | required | dataset_points. |
count |
int | 0 |
Cells to subsample (0 = all). |
direction, direction_distribution, seed |
As for random_uniform. |
Scatter layout is currently useful for TracerOperator on one MPI rank.
Multi-rank tracer runs reject Scatter sources because distributed skewer
merging needs a meaningful camera depth order. Structured-output operators also
reject Scatter: ProjectionOperator requires Grid2D or Healpix, while
VolumeRenderOperator, OpticalDepthGridOperator, and
CoherenceLengthOperator require Grid2D.
Source points (stellar particles)
source_points seeds one ray per stellar particle, loading the particle list
through the same source machinery the MCRT driver uses. The sources: block
is a self-contained listsource-shaped body — a loader: key (gadget,
tipsy_stars, ramses_part, art_stars, enzo_particles) plus that
loader's config section, with the same schema as emissionmodel.listsource
(see Configuration → List Source). The
gadget loader requires a luminosity_field; the others default it to the
Lya_StellarParticle_Luminosity recipe, so set it explicitly to be sure. Stars
with non-positive luminosity are dropped before any ray is created.
If the gas dataset uses a zoom_box, the sources: block inherits it
automatically so star seeds land in the same frame; set zoom_box inside
sources: to override.
| Parameter | Type | Default | Description |
|---|---|---|---|
type |
string | required | source_points. |
sources |
map | required | Listsource-shaped body: loader: plus the loader's config section (path, particle type, luminosity_field, …). |
direction, direction_distribution, seed |
As for random_uniform (seed matters only for isotropic). |
Ray order, emitter order, and output row order all match. There is no count
subsample (a count key is rejected) — it would break the row ↔ star identity.
To keep rows self-identifying, the driver also writes these datasets into the
tracers group, in emitter order:
| Dataset | Shape | Meaning |
|---|---|---|
tracers/source_positions |
(n, 3) | box-normalized emitter positions (the ray seeds) |
tracers/source_luminosities |
(n,) | emitter lum_total (the loader's luminosity_field) |
tracers/source_particle_ids |
(n,) uint64 |
source snapshot ParticleIDs (gadget only; omitted if the loader has no IDs) |
(The tracer's own origins array records where each ray ended up after
tracing, not the seeds — use source_positions for the stars.)
source_particle_ids is the stable join key for post-processing — it ties each
column back to its snapshot particle regardless of order or filtering, e.g. to
write a ParticleID-keyed sidecar field for THOR to re-load and project. It is
present only when the loader supplies real IDs (the gadget loader reads
PartType<X>/ParticleIDs).
source_points is single-rank only (an MPI run terminates at startup), and
pbc should stay at its default false — a column runs to the domain boundary.
The intended use is per-star obscuration for visualization: pair it with
close_rule: never + accumulator: column, and point the rays at the observer
(a fixed direction of minus the camera direction), so each ray commits one
value — the density column between the star and the domain boundary:
raytracer:
outputpath: star_columns.zr
overwrite: true
# pbc defaults to false: each column runs star -> domain boundary.
# pbc: true would require a finite dist_max.
operators:
tracers:
ray_source:
type: source_points
direction_distribution: fixed # default; isotropic also allowed
direction: [0.0, 0.0, -1.0] # toward the observer = minus the camera direction
sources: # listsource-shaped body
loader: gadget
gadget:
path: ./snap_099
particle_type: "PartType4"
luminosity_field: Masses # required; lum <= 0 emitters are dropped
fields: [Density]
close_rule: never
accumulator: column
Recovering per-star columns. A star outside the mesh commits no entry, so
read the columns through offsets (row i has offsets[i+1] - offsets[i]
entries — 0 or 1 here):
import numpy as np
import zarr
store = zarr.open("star_columns.zr", mode="r")
grp = store["tracers"]
offsets = grp["density/offsets"][:]
contribs = grp["density/contributions"][:] # 0 or 1 entry per star
counts = np.diff(np.append(offsets, len(contribs))) # 0 = no column for this star
columns = np.full(offsets.size, np.nan) # NaN where no column
columns[counts == 1] = contribs[offsets[counts == 1]]
positions = grp["source_positions"][:] # (n, 3), same row order
luminosities = grp["source_luminosities"][:] # (n,)
# physical column: box-unit path length -> cm
length_unit_cm = grp["density/contributions"].attrs["Parameters"]["length_unit_cm"]
columns_physical = columns * length_unit_cm # field units x cm
Field Access
Fields are selected at runtime; see JIT Field Access for the dispatch mechanism.
Output
Results are written to a Zarr store (.zr directory):
- Projections: 2D arrays of shape
(npixels_y, npixels_x) - Volume renders: RGB(A) images
- Tracers: per-ray field profiles
- Sightlines: a single flat group
sightlines/default/(one dataset per quantity, indexed by ray — not one group per ray), containing:ray_id(nray,),start/end(nray, 3) — per-ray metadatacell_offsets(nray+1,) — CSR index; ray i's cells are[cell_offsets[i], cell_offsets[i+1]). Written whenoutput.cells: truedland one array per emitted field (density,temperature,velocity_x/y/z, extras) — flat per-cell arrays (length = total cells); slice withcell_offsets. Written whenoutput.cells: truetau(nray, nbins) — integrated optical-depth spectra; written whentau:is configuredtau_<line>(nray, window_bins) — per-species spectra, compact (each on its ion's own window, a slice oflam); written whentau.per_species: true. Placement is in the group attrper_species[<dataset>] = {bin_offset, nbins, lam_min, lam_max}; stitch withfull[bin_offset:bin_offset+nbins] = tau_<line>(no interpolation)lam(nbins,) — bin-centred wavelengths in Å, written once (shared by all rays)- group attrs:
layout(scatter/grid2d/healpix),nrays,nbins, andgrid_shape/nsidefor structured ray sources
For a grid2d (camera_plane) source, tau reshapes to (ny, nx, nbins) via
grid_shape; for healpix, to (npix, nbins) via nside. The per-cell data
converts to a Trident-compatible LightRay HDF5 via
thor.absorption.sightline.Sightline (slices each ray by cell_offsets,
computes velocity_los/redshift_eff); integrated spectra load with
thor.absorption.spectrum.Spectrum.from_sightline.
See Raytracer Postprocessing for reading the output.