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:
objectOne aperture measurement (instrumental, uncalibrated).
- 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, orNoneif 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:
objectA single differential-magnitude estimate.
- class argos.core.photometry.differential.RelativeFluxResult(flux_ratio: float | None, flux_ratio_err: float | None, comps_used: int, note: str = '')[source]¶
Bases:
objectUnnormalised target/comparison ensemble flux ratio.
flux_ratioisF_target / sum(F_comparisons). It needs no catalogue magnitudes, unlikeDiffResult, and is intended for the live transit preview. Normalising to an out-of-transit baseline and detrending remain post-processing operations.
- 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.
compsare the comparisons’(inst_mag, cat_mag)in the same band. The error combines the target’s photon error with the ensemble standard errorrms/√n.noteflags a provisional result (no flux / too few comps) and reports clipped outliers;comps_usedcounts 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_rmsis 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:
objectOne light-curve point (exposure-midpoint JD_UTC; BJD_TDB when site known).
- class argos.core.photometry.lightcurve.LightCurve(auid: str = '', name: str = '', points: list[LcPoint] = <factory>, role: str = 'target')[source]¶
Bases:
objectA star’s differential light curve (preview).
rolemirrors 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).- 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.
- 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
LightCurveto 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 FITSAIRMASSheader uses), so a frame’s header and its light-curve point can never disagree. ReturnsNoneat 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
Noneif 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.
Nonemeans 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:
objectA 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.
wcsonly needsworld_to_pixel_deg(ra_deg, dec_deg) -> (x, y)(green px). Comparisons without a catalog magnitude inband/V are skipped from the ensemble. Returns oneTargetResultper target and per check star (distinguish them byresult.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:
objectConfig-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().- aperture_px(fwhm: float | None) float[source]¶
FWHM-adaptive aperture radius, floored at
aperture_min_px.
- 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.
- 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 fromparams— 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.
stablemeans 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:
objectFollow 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:
object2D rotation by
theta_radabout(cx, cy)followed by(tx, ty).- classmethod identity(cx: float, cy: float) RigidTransform[source]¶
- class argos.core.photometry.tracking.TrackedWCS(wcs, tracker: ApertureTracker)[source]¶
Bases:
objectReference solve + live rigid correction, quacking like a FrameWCS.
Only
world_to_pixel_degis provided — that is all the measurement core (measure_targets) uses.
- 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
rof(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:
objectMeasured systematic contribution for one target/filter/run.
- 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 variance6*sigma². Second differences remove a locally linear variable-star trend, matching the uncertainty model used bystar_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
Noneuntil at least ten valid measurements are available for an automatic estimate.