Combined Global / Local fit for pointing recovery

import astropy.units as u
import jax.numpy as jnp
import numpy as np
from astropy.coordinates import EarthLocation, SkyCoord
from astropy.time import Time

import nyx
from nyx import (
    Airglow,
    EffectiveApertureInstrument,
    Geometry,
    Moon,
    Observation,
    Optimizer,
    Scene,
    SingleScattering,
    Stars,
    ZodiacalLight,
    freeze_all,
    unfreeze,
)

nyx.configure()
JAX is not using 64-bit precision. Results will diverge from healpy even at moderate nside and can overflow for nside > 8192. See the README on numerical precision.

Building model:

geo = Geometry(
    wvls=jnp.linspace(300, 700, 40) * u.nm,  # wavelength grid
    nside=32,  # HEALPix resolution
    ngrid=2,  # FOV eval grid
    fov=3.5 * u.deg,  # field of view
)

hess_ct1 = EffectiveApertureInstrument.from_iactrace_table(geo, "CT1_aperture.npz")

atmosphere = SingleScattering.from_hg_ozone(geo)

sources = {
    "airglow": Airglow.from_eso_skycalc(geo, sfu=100.0, drift_order=2),
    "zodiacal": ZodiacalLight.from_leinert1998(geo),
    "GaiaDR3": Stars.from_gaia_dr3(geo, lim_mag=12),
    "moon": Moon.from_jones2013(geo),
}

Creating an example observation:

location = EarthLocation.from_geodetic(
    lon=16.5028 * u.deg,
    lat=-23.2728 * u.deg,
    height=1.8 * u.km,
)
times = Time("2021-06-12T02:12:16", scale="utc") + jnp.linspace(0, 30, 120) * 60 * u.s
target = SkyCoord(l=0 * u.deg, b=0 * u.deg, frame="galactic")

obs = Observation(location, times, target, geo)

Creating target images:

scene = Scene.build(hess_ct1, atmosphere, sources, obs)

# Add random pointing offsets:
nshift = np.deg2rad(np.random.uniform(-0.05, 0.05, size=(scene.nobs["instrument"], 2)))
scene = scene.set("instruments.instrument.shift", jnp.array(nshift))

# Add a dimished efficiency:
scene = scene.set("instruments.instrument.efficiency", 0.7)

# Add a random flatfielding offset:
pix_efficiency_shift = np.random.lognormal(
    mean=0, sigma=0.1, size=scene.instrument.pixel_efficiency.value.shape
)
scene = scene.set("instruments.instrument.pixel_efficiency", jnp.array(pix_efficiency_shift))
rates = scene.render()["instrument"]

rel_error = 0.05  # 5% relative error seems realistic from HMC of flatfielded data
N = 1 / (2 * rel_error**2)
truth_images = rates * np.sqrt(np.random.chisquare(df=N, size=rates.shape) / N)

Initializing clean scene again:

sources = {
    "airglow": Airglow.from_eso_skycalc(geo, sfu=100.0, drift_order=2),
    "zodiacal": ZodiacalLight.from_leinert1998(geo),
    "GaiaDR3": Stars.from_gaia_dr3(geo, lim_mag=12),
    "moon": Moon.from_jones2013(geo),
}

scene = Scene.build(hess_ct1, atmosphere, sources, obs)

Creating a loss function:

We first prefit using all instrument parameters unfrozen:

%%time
import optimistix as optx

scene = freeze_all(scene)
scene = unfreeze(scene, lambda s: s.instruments["instrument"].shift)
scene = unfreeze(scene, lambda s: s.instruments["instrument"].rotation)
scene = unfreeze(scene, lambda s: s.instruments["instrument"].efficiency)
scene = unfreeze(scene, lambda s: s.airglow.spectral_model.params)


def residuals_fn(scene):
    preds = scene.render()["instrument"]
    return (preds - truth_images) / (truth_images * rel_error)


opt = Optimizer(residuals_fn, optx.LBFGS(rtol=1e-6, atol=1e-6))
fitted, sol = opt.run(scene, max_steps=1024)
CPU times: user 26.1 s, sys: 1.29 s, total: 27.4 s
Wall time: 10 s
print(
    jnp.sum(residuals_fn(fitted) ** 2) / scene.nobs["instrument"],
    sol.result,
    int(sol.stats["num_steps"]),
)
4725.7314 optimistix._solution.RESULTS<> 175

Calculating the errors on our fitted parameters:

%%time
errors = opt.errors(fitted)
CPU times: user 30 s, sys: 924 ms, total: 30.9 s
Wall time: 35.7 s

We can see that we get decent results, with the standard deviation being relatively low

import matplotlib.pyplot as plt

dxdy = np.rad2deg(fitted.instrument.shift.value - nshift) * 3600

dx_err = np.rad2deg(errors.instrument.shift.value[:, 0]) * 3600
dy_err = np.rad2deg(errors.instrument.shift.value[:, 1]) * 3600
plt.errorbar(dxdy[:, 0], dxdy[:, 1], yerr=dy_err, xerr=dx_err, fmt=".")
plt.xlabel(r"$\Delta$x [arcsec]")
plt.ylabel(r"$\Delta$y [arcsec]")
Text(0, 0.5, '$\\Delta$y [arcsec]')
../_images/bf950c1c8b8eeea707391a7f0449dd10562502756ac909b26c1ed1359f26e7a8.png

The effect of the flatfielding (and us ignoring it in the fitting) leads to our error estimates not having the correct coverage! This shows how important it is to either include the pixel efficiencies in the fit or to have a good flatfielding estimate otherwise.

normed = np.concatenate([dxdy[:, 0] / dx_err, dxdy[:, 1] / dy_err])

within_1sig = np.mean(np.abs(normed) < 1)  # expect ~68%
within_2sig = np.mean(np.abs(normed) < 2)  # expect ~95%
print(f"1σ: {within_1sig:.0%}, 2σ: {within_2sig:.0%}")
1σ: 35%, 2σ: 63%