Concepts

This page introduces the moving parts. For the precise contract — coordinates, units, photometry, differentiability — see Physics and Conventions.

Light Sources

IACTrace’s render path supports two source types:

Parallel sources (source_type='parallel')

Light from astronomical sources at effectively infinite distance. The input is a direction vector [dx, dy, dz] pointing from the source. For on-axis light coming straight down onto the telescope:

direction = jnp.array([[0.0, 0.0, -1.0]])  # Light traveling in -Z direction

For an off-axis source at 1 degree in X:

angle_rad = 1.0 * jnp.pi / 180
direction = jnp.array([[jnp.sin(angle_rad), 0.0, -jnp.cos(angle_rad)]])

Point sources (source_type='point')

Light from finite-distance calibration sources (LEDs, flashers). The input is a position [x, y, z] in the telescope coordinate system:

# LED at 200m distance, slightly off-axis
position = jnp.array([[0.5, 0.3, 200.0]])

The accompanying values are radiant intensities: each primary sample point at distance d from the source receives value / d^2, so moving a source further away dims it as it physically should. For a parallel source there is no distance and values are the irradiance on the aperture directly. See Physics and Conventions for the full chain.

For anything else — extended sources, Cherenkov shower input, custom calibration ray patterns — sample the rays yourself and use Telescope.trace() (see Physics and Conventions).

Monte Carlo Integration

For computational efficiency, IACTrace uses Monte-Carlo sampling for the primary optical group. This avoids expensive intersection computations on the first surface, at the cost of having to weight rays by an effective ray aperture.

The number of samples per mirror facet is set at load time via the n_samples argument to Telescope.from_yaml():

import jax
from iactrace import Telescope

key = jax.random.key(42)  # Fixed seed for reproducibility
telescope = Telescope.from_yaml(
    "telescope.yaml", n_samples=1024, key=key,
)

Higher values give more accurate results but increase compute and memory. Pass a deterministic key to make the sampling reproducible.

Telescope Structure

A telescope in IACTrace consists of:

Mirror groups

Collections of mirror facets at the same optical stage. A single-mirror telescope has one group (the primary). A Cassegrain has two groups (primary and secondary). Each facet in a group has:

  • Position: [x, y, z] centre location

  • Orientation: [rx, ry, rz] Euler angles (degrees), applied as Rz @ Ry @ Rx

  • Aperture: shape and size (circular, hexagonal)

  • Surface: curvature, conic constant, aspheric terms

Obstructions

Mechanical structures that block rays (support struts, camera housing). Currently supports cylinders, boxes, and spheres.

Camera frame

Each telescope carries the position and Euler-angle orientation of the camera (the camera_position / camera_rotation fields in the telescope YAML). After tracing, rays are transformed into this local camera frame so that the Camera works in its own coordinate system. Sensors, pixel layout and the photodetector model live on the Camera (loaded from a separate camera YAML), not on the telescope.

Operations on a Telescope return new instances rather than mutating in place; see Telescope Operations for the full set and the rationale.

Rendering vs Tracing

Both methods return a RayBundle (or LazyRayBundle) in the camera-local frame; pass to Camera.image() for a binned pixel image, or Camera.collect() for per-ray output.

render() — high-level path for point and parallel sources. Handles source-to-ray sampling internally and fuses the per-mirror-element scan downstream:

ray_bundle = telescope.render(sources, values, source_type='parallel')
image = camera.image(ray_bundle)

trace() — general path. You provide ray origins and directions directly. Use this for any custom ray distribution that render doesn’t cover (extended sources, Cherenkov shower output, calibration patterns):

ray_bundle, trajectory = telescope.trace(ray_origins, ray_directions, ray_values)
image = camera.image(ray_bundle)

trace does not apply effective-aperture weighting because you supplied the rays; it just propagates them. It returns a TraceResult, which is a NamedTuple with .rays being the ray bundle, and .trajectory holding the recorded path when called with record_trajectory=True.

Raw ray output

For debugging and custom analysis, call Camera.collect() instead of Camera.image() to get raw per-ray output without pixel binning:

ray_bundle = telescope.render(sources, values, source_type='parallel')
pe_vals, pe_times, pix_id, detected = camera.collect(ray_bundle)

detected is the final per-ray mask: True only for rays that stayed alive through the optics, hit a sensor tile, landed on the photodetector surface, and fell inside a real pixel. pix_id and pe_times are meaningful only where detected is True (pe_vals is already zeroed elsewhere). This is useful for:

  • visualising the raw spot diagram

  • computing custom statistics on ray distributions

  • debugging optical alignment