Validation¶
getframes promises accurate, auditable physics, so the library is checked not
only for internal consistency but against external references — analytic forms
and published characterisations. This page summarises what is validated and how you
can reproduce the key checks yourself. The assertions live in
tests/test_validation.py
and run as part of the test gate.
What is validated¶
| Claim | Reference | Check |
|---|---|---|
| Vega magnitudes follow Pogson's law | 5 mag = 100× flux | exact |
| AB zero points | flat $f_\nu = 3631$ Jy, $N_0 = \frac{f_{\nu,0}}{h}\int T\,\frac{d\lambda}{\lambda}$ | within 5% |
| Gain stage excess noise factor | $\mathrm{Var}=nG^2(F^2-1)$ for deterministic input | within 2% |
| CTI / IPC / blooming | charge conservation + documented displacement | exact |
| PSF kernels | flux conservation | within 0.2% |
| Synthetic PTC | recovers configured gain / read noise / full well | within 5–15% |
| Reduced frame | recovers Frame.truth to the noise floor |
mean residual < 2 ADU |
| All new (1.6) paths | deterministic for a fixed seed | bit-exact |
| Dark-only PTC | recovers configured gain / dark rate with no illumination | within 5–10% |
| sCMOS per-pixel read noise | repeatable through time (real detectors: split-half $r$ = 0.89–0.94) | $r > 0.8$ |
Recover the gain from a photon transfer curve¶
A PTC is the standard way to measure a detector's conversion gain. Synthesise one and confirm it returns the numbers you configured:
import numpy as np
import getframes as gf
config = gf.CameraConfig(
name="demo",
sensor_type="CMOS",
resolution=(96, 96),
pixel_size_um=5.0,
quantum_efficiency=1.0,
full_well_e=60_000.0,
bit_depth=16,
gain_e_per_adu=1.0,
bias_offset_adu=100.0,
read_noise_e=5.0,
dark_current_e_per_s=0.0,
)
cam = gf.Camera(config, default_temperature_c=-10.0)
ptc = gf.analysis.photon_transfer_curve(cam, np.linspace(200.0, 75_000.0, 16), exposure=1.0)
print(ptc.gain_e_per_adu, ptc.read_noise_e, ptc.full_well_adu) # ~1.0, ~5.0, ~60000
Reproduce the EMCCD excess noise factor¶
The stochastic gain stage is parameterised by the mean gain $G$ and the excess noise factor $F$. For deterministic input charge $n$, the output has mean $nG$ and variance $nG^2(F^2-1)$, so $F$ is recoverable from the moments — and an EMCCD at high gain should return the analytic $F=\sqrt 2$:
import numpy as np
from getframes import noise
rng = np.random.default_rng(0)
out = noise.apply_gain_stage(
np.full(400_000, 60.0), gain=250.0, excess_noise_factor=np.sqrt(2.0), rng=rng
)
recovered_F = np.sqrt(1.0 + out.var() / (60.0 * 250.0**2))
print(recovered_F) # ~1.414
Validating against your own detector¶
The checks above are internal or analytic. The strongest test is a real dark stack.
Three presets (princeton_instruments_kuro_1200b, photometrics_prime_95b,
andor_marana_4_2b_11) carry values characterised this way against real hardware —
that characterisation is not re-run in CI (it needs the raw frames), but the
estimator below is pinned against known truth by
test_dark_ptc_recovers_gain_without_any_illumination. Here is the method, which
needs nothing but darks.
Measure the conversion gain from darks alone¶
You do not need flats. Dark current is itself a Poisson process, so thermally generated charge works as the charge source for a photon transfer curve. For a dark frame,
$$\text{mean}\text{ADU}(t) = \text{bias} + \frac{Dt}{g}, \qquad \text{var}\text{ADU}(t) = \text{RN}_\text{ADU}^2 + \frac{Dt}{g^2}$$
so $\mathrm{d}\,\text{var}/\mathrm{d}\,\text{mean} = 1/g$ — the dark rate $D$ cancels. Working per pixel makes it immune to DSNU, and taking a slope across exposures removes the bias pedestal and read noise (they are the two intercepts):
import numpy as np
# stacks[t] is an (n_frames, h, w) array of darks at exposure t, in ADU
means = np.stack([s.mean(axis=0) for s in stacks.values()]) # (n_exp, h, w)
variances = np.stack([s.var(axis=0, ddof=1) for s in stacks.values()])
def slope(x, y): # least squares along axis 0, per pixel
xm, ym = x.mean(axis=0), y.mean(axis=0)
return ((x - xm) * (y - ym)).sum(axis=0) / ((x - xm) ** 2).sum(axis=0)
gain = float(np.nanmedian(1.0 / slope(means, variances))) # e-/ADU
The load-bearing assumption is that the dark charge is Poisson (Fano factor 1). Check it by confirming the recovered gain makes the electron statistics self-consistent: $\text{var}_e / \text{mean}_e$ should come out at 1. A wrong gain shows up as a Fano factor visibly away from unity.
Check that per-pixel read noise repeats¶
sCMOS read noise is a property of each pixel's own amplifier and column ADC, so a pixel's noise through time is repeatable. Split a dark stack into two halves, compute each half's per-pixel temporal variance, and correlate:
a = stack[0::2].var(axis=0, ddof=1)
b = stack[1::2].var(axis=0, ddof=1)
print(np.corrcoef(a.ravel(), b.ravel())[0, 1])
Real back-illuminated sCMOS gives $r$ = 0.89–0.94; a simulator that re-draws its
per-pixel sigma each frame gives $r \approx 0$. Run the same code against
Camera.dark_series and the two should agree. (This check is what caught a real bug
in getframes; it is now test_per_pixel_read_noise_is_a_fixed_sensor_property.)
The same split-half machinery separates fixed detector structure from sampling noise generally: the spatial variance of a variance map is $V_\text{fixed} + 2\langle v\rangle^2/(n-1)$, so anything left after subtracting the $\chi^2$ term is real structure.
Reproducibility¶
Every generation path is seeded through a numpy.random.Generator; a given config
+ inputs + seed reproduce the same frame on the same NumPy version (the float32
fast path, dataset generation, and vectorised catalog rendering included). Bit-for-
bit output is not guaranteed across NumPy releases that change their RNG internals —
see API stability.