argos.core.photometry

Differential-photometry preview core (notes/photometry_plan.md §6).

Qt-free, unit-tested. This is the preview pipeline (raw subs, no dark/flat/bias); the publishable light curve is produced in post-processing.

Modules

Aperture photometry on the green plane (notes/photometry_plan.md §6 C1).

A circular aperture + a sky annulus → background-subtracted flux, instrumental magnitude and a CCD-equation uncertainty. Pure numpy, Qt-free; coordinates are green-plane px (the same grid as measure_star_at and the WCS).

class argos.core.photometry.aperture.AperturePhot(flux_adu: float, sky_adu: float, n_pix: int, peak_adu: float, snr: float, saturated: bool, inst_mag: float | None, inst_mag_err: float | None, suspect: bool = False)[source]

Bases: object

One aperture measurement (instrumental, uncalibrated).

flux_adu: float
inst_mag: float | None
inst_mag_err: float | None
n_pix: int
peak_adu: float
saturated: bool
sky_adu: float
snr: float
suspect: bool = False
argos.core.photometry.aperture.measure_aperture(green: ndarray, x: float, y: float, r_ap: float, r_in: float, r_out: float, *, egain: float = 1.0, read_noise_e: float = 1.5, sat_adu: float = 60000.0) → AperturePhot | None[source]

Measure a star at (x, y) green px with a circular aperture + sky annulus.

Parameters:
  • green – 2-D green plane (float-able).

  • x – Centre, green px.

  • y – Centre, green px.

  • r_ap – Aperture radius (green px).

  • r_in – Sky-annulus inner/outer radii (green px).

  • r_out – Sky-annulus inner/outer radii (green px).

  • egain – e-/ADU for the CCD-equation noise (≤0 → treated as 1).

  • read_noise_e – Read noise (e- RMS) per pixel.

  • sat_adu – Linearity/full-well threshold for the saturation flag.

Returns:

An AperturePhot, or None if the aperture falls off the frame.

Ensemble differential photometry (notes/photometry_plan.md §6 C2).

Zero-point from a comparison ensemble (mean of catalog − instrumental in the chosen band), then the target magnitude + an honest uncertainty that combines the target’s photon error with the ensemble scatter (the realistic field error).

class argos.core.photometry.differential.DiffResult(mag: float | None, mag_err: float | None, zero_point: float | None, comps_used: int, note: str = '', zp_rms: float | None = None, formal_mag_err: float | None = None)[source]

Bases: object

A single differential-magnitude estimate.

comps_used: int
formal_mag_err: float | None = None
mag: float | None
mag_err: float | None
note: str = ''
zero_point: float | None
zp_rms: float | None = None
class argos.core.photometry.differential.RelativeFluxResult(flux_ratio: float | None, flux_ratio_err: float | None, comps_used: int, note: str = '')[source]

Bases: object

Unnormalised target/comparison ensemble flux ratio.

flux_ratio is F_target / sum(F_comparisons). It needs no catalogue magnitudes, unlike DiffResult, and is intended for the live transit preview. Normalising to an out-of-transit baseline and detrending remain post-processing operations.

comps_used: int
flux_ratio: float | None
flux_ratio_err: float | None
note: str = ''
argos.core.photometry.differential.differential_mag(target_inst_mag: float | None, target_inst_err: float | None, comps: Iterable[tuple[float | None, float | None]], *, min_comps: int = 2) → DiffResult[source]

Calibrate the target against the comparison ensemble.

comps are the comparisons’ (inst_mag, cat_mag) in the same band. The error combines the target’s photon error with the ensemble standard error rms/√n. note flags a provisional result (no flux / too few comps) and reports clipped outliers; comps_used counts the kept comps.

argos.core.photometry.differential.ensemble_zero_point(comps: Iterable[tuple[float | None, float | None]]) → tuple[float | None, float | None, int, int][source]

Zero-point from (inst_mag, cat_mag) pairs → (zp, zp_rms, n, n_rejected).

zp = mean(cat − inst) over the kept pairs; zp_rms is the sample RMS about it (0 for a single comp). Pairs with a missing magnitude are skipped.

Outlier rejection (P3): with ≥3 pairs, pairs deviating from the median by more than max(2.5 · 1.4826 · MAD, 10 mmag) are dropped (≤2 iterations). Median+MAD rather than mean+RMS because a single bad comp in a small ensemble inflates the RMS enough to hide itself.

argos.core.photometry.differential.relative_flux(target_flux_adu: float | None, target_snr: float | None, comparisons: Iterable[tuple[float | None, float | None]], *, min_comps: int = 2) → RelativeFluxResult[source]

Return a raw flux ratio and propagated photon-noise uncertainty.

Each comparison is (flux_adu, snr). Invalid/non-positive fluxes and non-positive SNR values are excluded. The ensemble is deliberately the sum of comparison fluxes: it has the correct ratio uncertainty and does not require catalogue magnitudes. It is not an out-of-transit-normalised or detrended science product.

Light-curve accumulator + CSV export (notes/photometry_plan.md §6 C3).

One LightCurve per target; points are appended as subs are measured and written to photometry.csv (the hand-off to post-processing). Qt-free.

class argos.core.photometry.lightcurve.LcPoint(jd_utc: float, mag: float, mag_err: float, bjd_tdb: float | None = None, airmass: float | None = None, fwhm: float | None = None, sky_adu: float | None = None, comps_used: int = 0, saturated: bool = False, formal_mag_err: float | None = None, sigma_syst: float | None = None, relative_flux: float | None = None, relative_flux_err: float | None = None)[source]

Bases: object

One light-curve point (exposure-midpoint JD_UTC; BJD_TDB when site known).

airmass: float | None = None
bjd_tdb: float | None = None
comps_used: int = 0
formal_mag_err: float | None = None
fwhm: float | None = None
jd_utc: float
mag: float
mag_err: float
relative_flux: float | None = None
relative_flux_err: float | None = None
saturated: bool = False
sigma_syst: float | None = None
sky_adu: float | None = None
class argos.core.photometry.lightcurve.LightCurve(auid: str = '', name: str = '', points: list[LcPoint] = <factory>, role: str = 'target')[source]

Bases: object

A star’s differential light curve (preview).

role mirrors the TargetStar role (“target” / “check” / “comparison”) so display surfaces can group series; reloaded CSVs default to “target” (the historical behaviour — role is a display hint, not science data).

append(point: LcPoint) → None[source]
auid: str = ''
classmethod from_csv(path, auid: str = '', name: str = '') → LightCurve[source]

Reload a curve written by to_csv() (round-trips it).

Lets a finished session be reopened for review/export without re-running the night. Unknown/blank optional columns become None; unparseable rows are skipped rather than raising, so a partial file still loads.

name: str = ''
points: list[LcPoint]
role: str = 'target'
to_aavso(path, **kwargs) → None[source]

Write this curve in AAVSO Extended File Format (ensemble photometry).

to_csv(path) → None[source]

Write the curve to path (parent dirs created).

argos.core.photometry.lightcurve.read_curves_csv(path) → dict[str, LightCurve][source]

Restore every curve in a session export, preserving role and identity.

Single-curve and legacy files remain supported; they become one target curve keyed by the supplied name or filename stem.

argos.core.photometry.lightcurve.write_aavso(path, curves, *, obscode: str = 'XXX', filt: str = 'TG', software: str = 'Argos') → None[source]

Write one or more LightCurve to an AAVSO Extended File.

A preview export — DATE is the JD_UTC midpoint, MTYPE=STD, comparison is the ensemble (CNAME=ENSEMBLE). Calibrated mags + BJD_TDB come from post-processing.

argos.core.photometry.lightcurve.write_curves_csv(path, curves) → None[source]

Write several curves to one lossless, session-scoped CSV export.

Airmass + Julian date helpers (notes/photometry_plan.md §6 C4).

Qt-free, dependency-light. JD is the standard calendar→Julian-date conversion for an exposure-midpoint UTC datetime. (BJD_TDB, the publishable standard, is a post-processing step.)

argos.core.photometry.airmass.airmass_from_altitude(alt_deg: float | None) → float | None[source]

Airmass for a target at altitude alt_deg.

One formula project-wide (P4): delegates to Pickering 2002 (sky_geometry.compute_airmass — what the FITS AIRMASS header uses), so a frame’s header and its light-curve point can never disagree. Returns None at or below the horizon.

argos.core.photometry.airmass.bjd_tdb(jd_utc: float, ra_deg: float, dec_deg: float, lat_deg: float, lon_deg: float, elev_m: float = 0.0) → float | None[source]

Barycentric Julian Date (TDB) for an exposure-midpoint jd_utc.

The publishable time standard: UTC→TDB plus the barycentric light-travel-time correction for the target’s direction from the observing site. Uses astropy (imported lazily). Returns None if astropy can’t do the conversion.

argos.core.photometry.airmass.julian_date(dt: datetime) → float[source]

Julian date of a UTC datetime (Fliegel–Van Flandern day number).

The input should be timezone-aware UTC (the exposure midpoint); the tz is not re-converted here — pass UTC.

argos.core.photometry.airmass.utc_from_bjd_tdb(bjd: float, ra_deg: float, dec_deg: float, lat_deg: float, lon_deg: float, elev_m: float = 0.0) → datetime | None[source]

Invert bjd_tdb() to a topocentric UTC instant.

A transit catalogue epoch is a barycentric arrival time, while the observer needs a clock time at their site. Starting from astropy’s TDB → UTC conversion, a few light-travel-time iterations solve that difference to well below the precision appropriate for planning a sequence. None means that astropy/site data could not produce a trustworthy conversion.

Measure a target set on one solved frame (notes/photometry_plan.md §6 C4).

Glue between the catalog (TargetSet), the WCS and the aperture/differential primitives: project each saved star to green px, aperture-measure it, then calibrate every target against the comparison ensemble. Pure + Qt-free; the per-frame cost is a handful of small aperture sums, so it runs synchronously.

class argos.core.photometry.session.TargetResult(star: TargetStar, diff: DiffResult | None, phot: AperturePhot | None, relative: RelativeFluxResult | None = None)[source]

Bases: object

A target star’s per-frame outcome.

diff: DiffResult | None
phot: AperturePhot | None
relative: RelativeFluxResult | None = None
star: TargetStar
argos.core.photometry.session.measure_targets(green: ndarray, wcs, target_set: TargetSet, *, r_ap: float, r_in: float, r_out: float, egain: float = 1.0, read_noise_e: float = 1.5, sat_adu: float = 60000.0, band: str = 'V', min_comps: int = 2, on_star: Callable[[TargetStar, AperturePhot | None, float, float], None] | None = None) → list[TargetResult][source]

Aperture-measure every saved star, then calibrate targets vs comparisons.

wcs only needs world_to_pixel_deg(ra_deg, dec_deg) -> (x, y) (green px). Comparisons without a catalog magnitude in band/V are skipped from the ensemble. Returns one TargetResult per target and per check star (distinguish them by result.star.role).

on_star (diagnostics tap, P11) is called once per star of any role with (star, phot, x, y) — the raw measurement the calibrated output discards for comparisons.

Check stars (P2) are calibrated exactly like targets — against the comparison ensemble, never as part of it — so a flat check curve can certify the night and a wandering one condemn it.

Comparisons get a leave-one-out result (each calibrated against the ensemble minus itself, needs ≥2 usable comps) — the standard way to vet an ensemble member: a comp whose leave-one-out curve wanders is variable or blended and should be pruned. Purely a display/vetting aid; the target ensemble is untouched.

The single source of truth for differential-photometry parameters (WS7).

Both the live path (AcquisitionEngine._measure_photometry) and the offline batch worker (PhotometryBatchWorker) build their measurement inputs here, so the physics is provably identical: config keys photometry.* / camera.* are the only knobs, and the FWHM-adaptive aperture is computed the same way in both. Qt-free.

argos.core.photometry.params.DEFAULT_FWHM = 3.0

Fallback FWHM (green px) when a frame’s FWHM is unknown (batch re-run of a saved sub, which carries no measured FWHM on disk). The aperture then floors to aperture_min_px.

class argos.core.photometry.params.PhotometryParams(aperture_min_px: float, aperture_fwhm_mult: float, annulus_in_px: float, annulus_out_px: float, egain: float, read_noise_e: float, sat_adu: float, band: str, min_comps: int, track_apertures: bool = True)[source]

Bases: object

Config-derived aperture/annulus/band/noise inputs to measure_targets.

Built once from config; the FWHM-adaptive aperture radius is resolved per-frame by for_fwhm().

annulus_in_px: float
annulus_out_px: float
aperture_fwhm_mult: float
aperture_min_px: float
aperture_px(fwhm: float | None) → float[source]

FWHM-adaptive aperture radius, floored at aperture_min_px.

band: str
egain: float
classmethod from_config(cfg: Callable[[str, object], object], egain: float = 1.0) → PhotometryParams[source]

Build from a cfg(key, default) accessor (the engine’s _cfg).

egain (e-/ADU) is resolved by the caller — live from the connected driver/gain table, batch from the config table — since only the caller knows the frame’s gain. Everything else is config-keyed.

min_comps: int
read_noise_e: float
sat_adu: float
track_apertures: bool = True
argos.core.photometry.params.measure_frame(green, wcs, target_set: TargetSet, params: PhotometryParams, *, fwhm: float | None = None, on_star=None) → list[TargetResult][source]

Aperture-measure one solved green frame with the shared parameter set.

Thin wrapper over measure_targets() that resolves the FWHM-adaptive aperture and the annulus ordering constraints from params — the one place live and batch agree on geometry.

Live, auditable quality assessment for a comparison-star ensemble.

The acquisition preview is deliberately not a substitute for calibrated Siril photometry. It can nevertheless catch a bad reference before it poisons a night: every comparison already has a leave-one-out differential curve, so its own stability can be assessed against the other comparisons.

argos.core.photometry.quality.comparison_quality_report(target_set: TargetSet, curves: dict, *, min_points: int = 10, max_scatter_mag: float = 0.1, max_formal_error_mag: float = 0.1) → dict[source]

Assess every comparison’s leave-one-out curve without changing it.

stable means only that the live preview has enough internally consistent samples. It is not a calibrated-photometry certification; that belongs to the registered and calibrated Siril sequence.

argos.core.photometry.quality.save_comparison_quality_report(path, report: dict) → None[source]

Atomically persist the live assessment beside the observing session.

Aperture tracking across saved frames — field-rotation / drift correction.

A single reference plate solve is only valid at the instant it was made: on an alt-az mount the field rotates around the frame centre for the rest of the session, and any mount drifts. Rather than model the rotation from time + site (parity and clock pitfalls), measure it: each frame, re-centroid the anchor stars in a small window around their predicted positions, fit the rigid transform (rotation + translation, no scale) that maps the reference positions onto the measured ones, and use it to place every aperture. The transform is re-fit from the reference geometry on every frame — prediction only seeds the search windows — so centroid noise never accumulates, while consecutive subs rotate by well under a pixel so the windows never lose their star.

measure_targets() only needs world_to_pixel_deg from its wcs argument, so TrackedWCS wraps the reference solve and applies the current transform after projection — the measurement core stays untouched.

class argos.core.photometry.tracking.ApertureTracker(anchors_xy: list[tuple[float, float]], frame_center: tuple[float, float], search_r: float = 8.0)[source]

Bases: object

Follow a fixed star geometry through a time-ordered frame sequence.

Parameters:
  • anchors_xy – Reference pixel positions (green px) of the anchor stars — comparisons/checks: bright and astrometrically stable. (A variable’s position is stable too, but its centroid gets noisy at minimum.)

  • frame_center – Pivot the transform is expressed about, (cx, cy).

  • search_r – Centroid search radius in px. Must exceed the worst-case per-frame motion (sub-pixel) plus prediction error; the default is generous without risking a lock onto a neighbouring star.

MIN_SIGNAL_FRACTION = 0.2

A real guide star can dim in cloud, but not by an order of magnitude between consecutive science frames. Below this fraction of its first measured signal, a centroid is almost certainly a noise/hot-pixel lock.

REJECT_IMPROVEMENT = 0.3

…and it is dropped only when refitting without it shrinks the median residual below this fraction of the original. A residuals-vs-threshold test can’t work here: a least-squares rigid fit smears a lone outlier’s error over all anchors, so with 3-4 anchors the outlier’s own residual never stands out. The leave-worst-out improvement is decisive instead.

RESIDUAL_FLOOR_PX = 2.0

The worst anchor is only examined when its residual exceeds this floor (px) — centroid noise on a faint anchor must never trigger a rejection.

update(green: ndarray) → int[source]

Re-fit the transform on one frame; returns anchors matched.

On zero matches (clouds, slew glitch) the previous transform is kept — the next frame’s windows still sit where the stars were last seen. With ≥3 matches, the worst-fitting anchor is dropped (P8) when its residual exceeds RESIDUAL_FLOOR_PX and refitting without it improves the median residual by REJECT_IMPROVEMENT — one wrong lock (hot pixel, neighbouring star) must not skew every aperture of the frame.

class argos.core.photometry.tracking.RigidTransform(theta_rad: float, tx: float, ty: float, cx: float, cy: float)[source]

Bases: object

2D rotation by theta_rad about (cx, cy) followed by (tx, ty).

apply(x: float, y: float) → tuple[float, float][source]
cx: float
cy: float
classmethod identity(cx: float, cy: float) → RigidTransform[source]
property rotation_deg: float
property shift_px: float
theta_rad: float
tx: float
ty: float
class argos.core.photometry.tracking.TrackedWCS(wcs, tracker: ApertureTracker)[source]

Bases: object

Reference solve + live rigid correction, quacking like a FrameWCS.

Only world_to_pixel_deg is provided — that is all the measurement core (measure_targets) uses.

world_to_pixel_deg(ra_deg: float, dec_deg: float) → tuple[float, float][source]
argos.core.photometry.tracking.fit_rigid(src: list[tuple[float, float]], dst: list[tuple[float, float]], cx: float, cy: float) → RigidTransform[source]

Least-squares rotation+translation mapping src → dst (no scale).

Closed-form orthogonal Procrustes on the centred point sets. With a single pair the rotation is indeterminate, so it degrades to pure translation. (cx, cy) is only the pivot the result is expressed about (any rigid transform can be written about any pivot); it does not affect the fit.

argos.core.photometry.tracking.refine_centroid(green: ndarray, x: float, y: float, r: float) → tuple[float, float] | None[source]

Flux-weighted centroid within radius r of (x, y), or None.

A local background (median of the window’s outer ring) is subtracted first so sky level and gradients don’t pull the centroid. Returns None when the window falls outside the frame, carries no flux, or the centroid lands outside the search radius (no star there — clouds, or the prediction is already lost).

argos.core.photometry.tracking.select_tracking_anchors(green, wcs, candidates, params, *, min_snr: float = 10.0, max_anchors: int = 6)[source]

Return only bright, clean stars suitable for geometric tracking.

The tracking constellation is deliberately distinct from the photometric ensemble. A reference can be meaningful after offline calibration yet be too weak to centroid in a raw live sub; such a source must never steer the WCS transform.

Run-level photometric uncertainty model.

The formal error for an individual image is incomplete: scintillation, flat-field residuals and small tracking/calibration effects are shared by a run but do not appear in the CCD photon budget. This module implements the same robust systematic-floor method used by star_var_script so live and post-processed Argos measurements have one uncertainty contract.

class argos.core.photometry.uncertainty.SystematicErrorFloor(sigma_real: float, median_formal: float, sigma_systematic: float, points_used: int)[source]

Bases: object

Measured systematic contribution for one target/filter/run.

median_formal: float
points_used: int
sigma_real: float
sigma_systematic: float
argos.core.photometry.uncertainty.apply_systematic_floor(formal_error: float, floor: SystematicErrorFloor | None) → float[source]

Return the final uncertainty, preserving the point’s formal error.

argos.core.photometry.uncertainty.estimate_lightcurve_scatter(jd_mag: Iterable[tuple[float, float]]) → float | None[source]

Estimate the per-point scatter using robust second differences.

For independent errors, m[i-1] - 2*m[i] + m[i+1] has variance 6*sigma². Second differences remove a locally linear variable-star trend, matching the uncertainty model used by star_var_script.

argos.core.photometry.uncertainty.estimate_systematic_floor(points: Iterable[tuple[float, float, float]], *, manual_floor: float | None = None) → SystematicErrorFloor | None[source]

Measure the systematic uncertainty floor for a light-curve run.

Parameters:
  • points – (JD_UTC, magnitude, formal_error_mag) triples.

  • manual_floor – Explicit systematic floor in magnitudes. If omitted, it is inferred from the robust second-difference scatter.

Returns:

The derived floor, or None until at least ten valid measurements are available for an automatic estimate.

argos.core.photometry.uncertainty.robust_sigma(values: Iterable[float]) → float[source]

Return the MAD-based robust standard deviation of finite values.