Skip to content

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).