Skip to content

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: OpticalDepthGridOperatorSightlineOperator

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:

\[ P(x,y) = \int_0^L q(s) \cdot w(s) \, ds \]

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: requires center (point to orbit around)
  • traverse: requires travel_distance (total distance to travel along direction)

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:

  1. What ends a segment — the close rule.
  2. What is accumulated within a segment — the accumulator.
  3. How the ray itself propagates — the propagation mode (straight, or field-aligned / curved).
  4. 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 velocityVelocityX/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:

\[ \tau(\text{ray}, \lambda_i) = \sum_{\text{lines}} \sum_{\text{cells}} \rho_c \cdot dl_c \cdot \sigma_{\text{Voigt}}(\lambda_i;\, T_c,\, v_{\text{LOS},c}) \]

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
Field names resolve through the wildcard/recipe system (not just the static enum), so any loaded dedicated or Cloudy ion field works. A routed field that isn't loaded (or has no per-cell array) is a hard error naming the field. Without a Cloudy table, route to whatever per-cell ion densities the snapshot already carries (e.g. 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 1548tau_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 generated ray_source:, the always-on core fields + extra fields:, optional tau: integration, and output.cells. v_LOS / redshift_eff are 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_path with per-ray dist_max = |end − start|); τ is integrated on each rank's local per-cell data, then distributed_merge gathers the per-rank dl/field arrays to rank 0 and reconstructs every ray in depth order along its own direction (zero-dl boundary 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 (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

x, y, z ~ Uniform([0, 1))

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 (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 metadata
    • cell_offsets (nray+1,) — CSR index; ray i's cells are [cell_offsets[i], cell_offsets[i+1]). Written when output.cells: true
    • dl and one array per emitted field (density, temperature, velocity_x/y/z, extras) — flat per-cell arrays (length = total cells); slice with cell_offsets. Written when output.cells: true
    • tau (nray, nbins) — integrated optical-depth spectra; written when tau: is configured
    • tau_<line> (nray, window_bins) — per-species spectra, compact (each on its ion's own window, a slice of lam); written when tau.per_species: true. Placement is in the group attr per_species[<dataset>] = {bin_offset, nbins, lam_min, lam_max}; stitch with full[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, and grid_shape/nside for 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.