Time series & observations¶
A single Frame is a snapshot. Many real programs ---
transit photometry, adaptive-optics wavefront sensing, persistence
characterisation --- take a sequence of frames of a scene that changes in time.
Camera.observe_series produces such a
sequence and returns an Observation: the
frames, their timestamps, the realised pointing offsets, and a ground-truth light
curve to validate against.
scene + time model ──► Camera.observe_series(...) ──► Observation
(LightCurve, per-frame render frames + times +
Pointing) offsets + truth
A variable source¶
Variability is owned by the source: a PointSource
may carry a LightCurve in its brightness
field, and observe_series samples it at each frame's timestamp
(t_i = i * cadence, the start of the exposure).
import getframes as gf
# A 1% box transit between t = 2000 s and t = 4000 s.
transit = gf.LightCurve.box(depth=0.01, t0=2000, t1=4000)
scene = gf.Scene(
shape=(256, 256),
optics=gf.Telescope(
aperture_diameter_m=0.2,
throughput=0.5,
plate_scale_arcsec_per_pixel=5.0,
band=gf.Bandpass.johnson("R"),
),
psf=gf.GaussianPSF(fwhm_arcsec=8.0),
sources=[
gf.PointSource(x=64, y=64, magnitude=12.0, name="target", brightness=transit),
gf.PointSource(x=180, y=180, magnitude=11.5, name="ref"),
],
sky=gf.Sky(surface_brightness_mag_arcsec2=20.0),
)
cam = gf.Camera.from_preset("zwo_asi2600mm").with_config(resolution=(256, 256))
obs = cam.observe_series(
scene, exposure=20.0, n_frames=300, cadence=20.0, jitter_arcsec=2.0, seed=0
)
LightCurve ships box, sinusoidal, constant, and from_function (wrap any
t -> multiplier callable). The multiplier scales the source's baseline rate, so
0.99 dims it by 1%. A plain Camera.observe
(no time) renders the baseline and ignores the light curve.
Validating against the truth light curve¶
The observation carries the injected, noise-free signal of each named source:
measured = [
gf.analysis.aperture_sum(f, (64, 64), r=12) / gf.analysis.aperture_sum(f, (180, 180), r=12)
for f in obs
]
truth = obs.truth.light_curve["target"] # injected photons/frame, shape (n_frames,)
times = obs.truth.times_s
obs is iterable and indexable over its frames, and exposes obs.times_s and
obs.offsets_pixels (the realised pointing path).
Pointing: jitter, drift, dither¶
A Pointing model offsets the whole field per
frame. Offsets are given in arcseconds and converted with the scene's plate scale.
pointing = gf.Pointing(
jitter_arcsec=2.0, # per-frame Gaussian (also tip-tilt / image motion)
drift_arcsec_per_s=(0.01, 0.0), # slow linear creep
dither_arcsec=[(0, 0), (5, 0), (0, 5)], # programmed pattern, cycled by frame
)
obs = cam.observe_series(scene, exposure=20.0, n_frames=300, pointing=pointing, seed=0)
For the common case, the jitter_arcsec= shortcut builds a jitter-only model. With
a seed, the pointing path is reproducible and drawn from a stream independent of
the per-frame shot/read noise.
Persistence (latent images)¶
IR arrays (eAPD/SAPHIRA) retain a ghost of a bright exposure in subsequent frames.
Set persistence_fraction (the fraction of a
frame's charge trapped) and persistence_decay (the fraction released each later
frame); observe_series carries the trapped charge across the series:
cam = gf.Camera.from_preset("leonardo_saphira").with_config(
resolution=(256, 256),
persistence_fraction=0.01,
persistence_decay=0.5,
)
obs = cam.observe_series(scene, exposure=2.0, n_frames=20, seed=0)
The latent charge is real charge in the well, so it picks up shot noise and any
EM/avalanche gain. Persistence is off by default (persistence_fraction = 0).
Global-reset nondestructive reads¶
SAPHIRA-class arrays can be sampled repeatedly without clearing the charge well.
Use nondestructive_series to accumulate charge between global resets while
drawing fresh read noise on every sample:
import numpy as np
import getframes as gf
cam = gf.Camera.from_preset("first_light_imaging_cred_one")
reads = list(
cam.nondestructive_series(
photon_rate=0.0,
background=630.0, # example warm-cap photons/s/pixel at QE ~0.7
read_interval=1 / 35,
n_frames=250,
reads_per_reset=42,
temperature=-188.55, # 84.6 K
seed=0,
)
)
# A correlated-double-sampling difference within one ramp.
cds = np.asarray(reads[1], dtype=float) - np.asarray(reads[0], dtype=float)
Photo-electrons and dark charge arrive as independent Poisson increments, then
remain in the well until the next reset. The reset-noise realization is shared by
all reads in a ramp, while read noise is redrawn each time. The CRED One preset
also includes its measured interval/gain-dependent pedestal and common-mode
scale. The example reproduces the 42-read strong-reset cadence observed in the
local 35 Hz capped data; acquisition metadata requested 250 reads, so callers
should use the cadence their camera actually produces. Each frame records
ramp_index, read_index, reads_per_reset, and
time_since_reset_s in its metadata.
Correlated double sampling¶
CDS is the standard low-noise operating mode for these arrays, and a two-read
ramp taken on its own. correlated_double_sample runs that ramp and returns the
difference directly, which is what a camera in CDS mode delivers:
import getframes as gf
cam = gf.Camera.from_preset("first_light_imaging_cred_one")
frame = cam.correlated_double_sample(
photon_rate=5.0e4,
exposure=1 / 1750, # read-to-read integration time
seed=0,
)
The returned frame is a signed int32 difference in ADU — bias-subtracted by
construction, and free to go negative on a dark pixel. exposure is the
read-to-read integration time, so it is the charge the difference measures.
What differencing does to each noise term follows from whether that term is common to the two reads:
| Term | Effect of CDS |
|---|---|
| kTC / reset noise, fixed bias structure | removed — one realization per ramp |
| Amplifier read noise, eAPD input noise | $\sqrt{2}\times$ — redrawn per read |
| Readout common mode | partly removed; AR(1) leaves $\sqrt{2(1-\rho)}$ |
| Reset settling | leaves the pedestal-to-signal residual |
| Interval-proportional bias rate | not removed — it scales with integration time, not with the read |
That last row is the one worth planning for: a CDS frame still sits on a small exposure-dependent pedestal (about +50 ADU for this preset at 1750 Hz, against −8 ADU of settling residual). Remove it with a dark CDS frame taken at the same exposure and gain, exactly as you would on the real camera.