Response matrix (effective aperture)

For some workflows it is convenient to precompute the per-pixel response of a telescope+camera pair as a function of stellar position. Camera.response_matrix does this in a single render pass: it traces all sources at once and scatters the resulting rays into a (n_sources, n_sensors, *pixel_shape) array.

import jax
import jax.numpy as jnp
import matplotlib.pyplot as plt

from iactrace import Camera, Telescope

Load HESS CT3

We only load 1 sample per mirror to be fast enough to execute on CPU, but generally you should choose higher values.

telescope = Telescope.from_yaml(
    "../../configs/HESS/CT3.yaml",
    n_samples=1,
    key=jax.random.key(42),
)
telescope = telescope.apply_roughness(0, 24)

camera = Camera.from_yaml("../../configs/HESS/HESS1U.yaml")
E0804 11:11:46.499473 3123608 numa_hwloc.cc:121] Call to hwloc_set_cpubind() failed: Invalid argument [22]
E0804 11:11:46.499605 3123609 numa_hwloc.cc:121] Call to hwloc_set_cpubind() failed: Invalid argument [22]
E0804 11:11:46.499656 3123610 numa_hwloc.cc:121] Call to hwloc_set_cpubind() failed: Invalid argument [22]

Sample a grid of stellar directions

We tile a square in the field of view; each row of the response matrix will be the per-pixel response to a single direction.

fov_deg = 5.5
fov_rad = fov_deg * jnp.pi / 180

n_side = 128
x1d = jnp.linspace(-fov_rad / 2, fov_rad / 2, n_side)
y1d = jnp.linspace(-fov_rad / 2, fov_rad / 2, n_side)
X, Y = jnp.meshgrid(x1d, y1d, indexing="xy")
z = -jnp.ones(n_side**2)

sources = jnp.stack([X.ravel(), Y.ravel(), z], axis=1)
sources = sources / jnp.linalg.norm(sources, axis=1, keepdims=True)

Compute the response matrix

Output shape is (n_sources, n_sensors, n_pixels). CT3 has a single hexagonal sensor group with 960 pixels.

rm = camera.response_matrix(
    telescope.render(sources, jnp.ones(sources.shape[0]), source_type="parallel"),
    sensor_idx=0,
)
rm.shape
(16384, 1, 960)

Total effective aperture per source

Sum across pixels and reshape onto the source grid:

weights = rm.sum(axis=(1, 2)).reshape(n_side, n_side)

fig, ax = plt.subplots(figsize=(8, 7))
im = ax.imshow(
    weights,
    origin="lower",
    extent=[x1d[0], x1d[-1], y1d[0], y1d[-1]],
)
fig.colorbar(im, ax=ax, label="Effective aperture [arb. units]")
ax.set_xlabel("x [rad]")
ax.set_ylabel("y [rad]")
Text(0, 0.5, 'y [rad]')
../_images/989b3dc05e4ca54311696026ef67da91ec4836949204152764cd10d8ccc4a850.png

Per-pixel effective aperture

Pick a single pixel (e.g. pixel 300) and visualise where on the sky an incoming photon ends up in that pixel.

pixel_id = 300
weights_pix = rm[:, 0, pixel_id].reshape(n_side, n_side)

fig, ax = plt.subplots(figsize=(8, 7))
im = ax.imshow(
    weights_pix,
    origin="lower",
    extent=[x1d[0], x1d[-1], y1d[0], y1d[-1]],
)
fig.colorbar(im, ax=ax, label="Effective aperture [arb. units]")
ax.set_xlabel("x [rad]")
ax.set_ylabel("y [rad]")
Text(0, 0.5, 'y [rad]')
../_images/1536df91a0deb8409db4b220092bb20c893af76f94e3aba6c4d57a17ced4316b.png

Re-rendering after misalignment

response_matrix is a thin wrapper around the regular render path, so functional updates to the telescope (e.g. random misalignment) compose naturally.

perturbed = (
    telescope.apply_misalignment(0, 15, 10, jax.random.key(0))
    .apply_displacement(0, 0.02, jax.random.key(1))
    .apply_focal_error(0, 0.07, jax.random.key(2))
)
rm_p = camera.response_matrix(
    perturbed.render(sources, jnp.ones(sources.shape[0]), source_type="parallel"),
    sensor_idx=0,
)
weights_p = rm_p.sum(axis=(1, 2)).reshape(n_side, n_side)

fig, ax = plt.subplots(figsize=(8, 7))
im = ax.imshow(
    weights_p,
    origin="lower",
    extent=[x1d[0], x1d[-1], y1d[0], y1d[-1]],
)
fig.colorbar(im, ax=ax, label="Effective aperture [arb. units]")
ax.set_title("Misaligned + displaced + focal-error CT3")
ax.set_xlabel("x [rad]")
ax.set_ylabel("y [rad]")
Text(0, 0.5, 'y [rad]')
../_images/3211111269e58c95f4ac9d7012c5aba3fb024f532c8302a583e77f63a83d1689.png