Physics and Conventions¶
This page is the contract: the coordinate frame, units, photometry model, and which gradients flow through the pipeline. Everything below applies uniformly across the library.
Coordinate frame¶
Coordinate frame like in sim_telarray. Assuming the telescope is first pointed towards magnetic north and then up to the zenith.
Z-axis along the optical axis, pointing from the primary mirror toward the sky (positive Z is “up”, toward incoming light).
X-axis along the north-south axis (positive x towards north – meaning down to the ground in normal operation)
Y-axis along the west-east axis (positive y towards west – meaning looking at the mirrors from the camera y points towards the right)
The origin is conventionally at the centre of the primary-mirror dish.
After optics tracing, rays are expressed in the camera-local frame
defined by Telescope.camera_position and Telescope.camera_rotation;
the Camera operates entirely in that frame.
Units¶
Quantity |
Unit |
|---|---|
Distance |
metres |
Angle (Euler) |
degrees, extrinsic XYZ (see Rotations) |
Small angles |
arcseconds (mirror roughness, mirror misalignment) |
Surface error |
metres RMS (Zernike figure errors) |
Focal error |
metres, or a dimensionless fraction with |
Time / path |
metres of optical path length ( |
There is no built-in unit system; the YAML configs and the runtime API agree by convention only. Mixing units silently produces wrong results.
Rotations¶
Every orientation in the library is a triple of Euler angles
(rx, ry, rz) in degrees. They all go through
euler_to_matrix(), which composes them as:
R = Rz(rz) @ Ry(ry) @ Rx(rx)
i.e. extrinsic X -> Y -> Z: rotate about the fixed x-axis first, then the fixed y-axis, then the fixed z-axis. Equivalently, this is the intrinsic z-y’-x’’ composition with the angles taken in reverse order.
The camera frame¶
Telescope.camera_position / camera_rotation place the camera in the
telescope frame, and every ray leaving render() or
trace() has already been re-expressed there
(RayBundle.to_frame applies R.T, the inverse of the rotation above).
Sensor positions in a camera YAML, Camera.collect output and
FocalSurface results are all in this frame.
The camera looks back at the optics, so its local +Z points towards the
last optic and rays arrive travelling along local -Z – which is what the
per-pixel detection chain assumes (light enters a pixel at z = 0 and runs
towards -z). Every shipped configuration therefore uses
camera_rotation: [180, 0, 0], a half turn about x, giving
Camera axis |
Telescope-frame direction |
|---|---|
+x |
+x |
+y |
-y |
+z |
-z |
So a camera-frame image has the same x as the telescope frame but a
flipped y: light arriving with a +y direction component lands at -y in
the image, while light arriving with a +x component lands at +x. Choosing a
different camera_rotation changes this mapping.
Photometry — monochromatic¶
IACTrace is monochromatic. Rays do not carry a wavelength. Every coefficient that would in principle depend on wavelength is treated as a single value:
Mirror reflectivity (
ReflectInteraction)Lens refractive index and bulk transmittance (
RefractInteraction,SlabInteraction)Photodetector quantum efficiency (
ConstantQE,PMT)
This means PSF, throughput, and effective aperture results are correctly differentiable and physically sensible for a single wavelength — typically the photon-detection-weighted mean of whatever spectrum you have in mind. Anything that requires a real spectrum (wavelength-dependent QE for PMTs/SiPMs, dispersion through refractive optics, reflectivity rolloff in the UV) is not modelled.
Wavelength tracking is planned for a future release.
The throughput-weighted values pipeline¶
RayBundle.values is a dimensionless scalar per ray that accumulates
every multiplicative factor along the optical path:
source irradiance at the sample point
× primary sampling weight
× reflectivity / refractivity
× aperture mask
× obstruction shadow
× concentrator throughput
× quantum efficiency
The first factor is where the two source types differ. A parallel
source is at infinity, so the values you pass to
Telescope.render() are the irradiance on the aperture and enter
unchanged. A point source is at a finite distance, so the values
are radiant intensities and each primary sample point at distance d
from the source receives value / d^2. Summing values over an
unobstructed render with values = 1 therefore gives the effective
collecting area in m² for a parallel source, and that area divided by
d^2 for a point source.
By the time a bundle reaches Camera.collect(), the entries of
values are photoelectrons (per source-photon, per Monte-Carlo
sample). Camera.image() and Camera.response_matrix() sum
those values into pixel bins, so their output has the same units.
Two ways to drive the optics¶
There are two entry points; pick based on whether your source has a closed-form ray sampler.
Telescope.render() is the fast path for point and parallel
sources. It samples rays backwards from the primary aperture toward
each source — the only two cases where this is closed-form — and fuses
the per-mirror-element scan, so Camera.image() and
Camera.response_matrix() can fold over the full ray buffer
without ever materialising it.
Telescope.trace() is the general path. You hand it raw
(origins, directions, values) arrays and it propagates them
through. Use it for anything that render doesn’t cover:
extended sources (Gaussian, disk, image-as-source)
arbitrary calibration ray patterns (collimated test beams, flashers with measured emission profiles)
There is no special source-type enum for these — sample whatever
distribution you want yourself, and call trace.
Differentiability¶
Ray tracing is end-to-end differentiable for continuous parameters.
Specifically, jax.grad flows through:
mirror surface parameters:
curvatures,conics,aspherics, positions, rotationsmirror reflectivity, lens refractive index and transmittance
photodetector QE
source positions / directions and source values
What does not currently flow:
gradients through pixel bin assignment.
SensorGroup.pixel_index_and_mask()uses integerfloorto assign each ray to a pixel, andSensorGroup.scatter()bins the values withsegment_sum. Gradients flow w.r.t. the ray values (sod(image)/d(reflectivity)etc. are fine), but not through the bin index itself. Optimisations that need a continuous response to pixel boundaries (e.g. sensor-position fitting via image gradients) would need a straight-through estimator, which IACTrace does not currently ship.
For gradient-based work, prefer objectives built from the ray values or from focal-surface spot statistics, both of which are fully differentiable, over ones that depend on which pixel a ray falls in.
Optical stages¶
A telescope is a sequence of OpticalElementGroup instances,
one per integer optical_stage. Stage 0 is the primary; the
renderer walks stages in ascending integer order. Per-stage operations
are addressed by stage:
telescope.stage(0) # OpticalElementGroup at stage 0
telescope.stage(0).kind # "mirror" | "lens" | "slab"
telescope.stage_indices() # sorted list of stages present
telescope.stages_of_kind("mirror")
The split between mirror_groups and lens_groups on the
Telescope is purely a storage layer that mirrors the
mirrors: / lenses: sections of the YAML config; it has no
runtime semantics. The renderer consumes the combined
optical_groups view sorted by stage.
The “one group per stage” rule is enforced at construction time. It is
the load-bearing invariant for stage(n).
Configuration files¶
Telescope and camera live in separate YAML files; see Installation for setup and Custom Telescopes for the schema.
Telescope.from_yamlreturns aTelescope. It carries the optics (mirrors, lenses, obstructions) plus the camera frame (position and orientation of the detector plane).Camera.from_yamlreturns aCamerawhose sensor positions are interpreted in the camera-local frame.
The pairing happens at runtime, so a shared camera (e.g.
FlashCAM.yaml) can sit on multiple telescopes.