argos.core.imaging¶
Modules¶
Sony IMX585 calibration constants for the Seestar S30 Pro telephoto camera.
Provides EGAIN (e-/ADU) and read-noise lookups as a function of the gain setting reported by the Alpaca driver (0–600 on the Seestar S30 Pro).
When the Alpaca driver exposes ElectronsPerADU directly we prefer that
value; this module is the fallback so every FITS frame still carries a
sensible EGAIN header for the post-processing pipeline.
Anchor values are interpolated from the public ZWO ASI585MC characterisation (same sensor): EGAIN in log-space, read noise linearly. The Seestar may apply a small global offset depending on firmware, but the shape of the curve is sensor-intrinsic.
If the user has produced a personal calibration (photon-transfer-curve via
Tools → Calibrate Camera…), lookup_egain() will prefer that JSON
file at ~/.argos/camera_calibration.json.
- argos.core.imaging.imx585.configure_calibration_directory(path: Path | str) None[source]¶
Set the folder searched for the legacy IMX585 PTC calibration.
- argos.core.imaging.imx585.full_well_capacity(gain_setting: int) int[source]¶
Return effective full-well capacity in electrons.
Drops sharply once HCG mode engages — the on-sensor amplifier trades dynamic range for read noise.
- argos.core.imaging.imx585.lookup_egain(gain_setting: int) float[source]¶
Return e-/ADU for the given gain setting (0–600).
Prefers the user calibration file when available, otherwise interpolates between the built-in IMX585 anchor points. Values outside [0, 600] are clamped to the nearest endpoint — no extrapolation off the curve.
- argos.core.imaging.imx585.lookup_read_noise(gain_setting: int) float[source]¶
Return read noise in electrons for the given gain setting (0–600).
Sony IMX662 reference calibration for the Seestar S30 telephoto camera.
The S30 identifies its telephoto sensor as IMX662. The fallback anchors below
are a digitised, conservative reference to ZWO’s published ASI662MC camera
curve (0–600 gain units); they are not a replacement for a Seestar
photon-transfer calibration. Driver ElectronsPerADU is preferred by the
capture path and a local ~/.argos/camera_calibration_imx662.json overrides
these values point by point.
Sony IMX462 reference calibration for the Seestar S50 telephoto camera.
The reference follows ZWO’s published ASI462MC curve for the same sensor. It
is only the fallback if the Seestar Alpaca driver exposes no electron gain;
~/.argos/camera_calibration_imx462.json can hold a field PTC for exact
values on a specific telescope / firmware.
Select the correct sensor reference model from a telescope profile.
- argos.core.imaging.sensor_models.for_sensor(sensor_name: str) ModuleType[source]¶
Return a reference model, retaining IMX585 only for unknown legacy gear.
- argos.core.imaging.sensor_models.full_well_capacity(sensor_name: str, gain_setting: int) int[source]¶
- argos.core.imaging.sensor_models.lookup_read_noise(sensor_name: str, gain_setting: int) float[source]¶
Reusable reference gain/noise model for a Sony sensor family.
The Seestar firmware can scale its public gain control differently from an
ASI camera using the same Sony sensor. These curves are therefore only a
fallback for frames whose Alpaca driver does not provide ElectronsPerADU;
the FITS headers label that fact, and a per-sensor photon-transfer calibration
always wins.
- class argos.core.imaging.sensor_reference.SensorReference(name: str, full_well_e: int, hcg_threshold: int, anchors: tuple[tuple[int, float, float], ...], hcg_full_well_fraction: float = 0.25)[source]¶
Bases:
objectPublished reference anchors for one sensor / camera electronics pair.
- argos.core.imaging.sensor_reference.configure_calibration_directory(path: Path | str) None[source]¶
Set the folder searched for local photon-transfer calibrations.
Bayer demosaicing, CFA channel split and focus metrics for the IMX585 (GRBG).
Data pipeline ≠ display pipeline (see notes/capture_panel.md §0): every
function here takes the raw linear CFA array and returns a new display or
measurement array. The raw array is never mutated — it is what gets written to
FITS (linear, 16-bit, BAYERPAT='GRBG').
GRBG tile (2×2):
G R
B G
CFA pixel positions in a raw array (H, W) — these are real sensor pixels, no interpolation (the photometry / astrometry measurement primitive):
G1 = arr[0::2, 0::2] R = arr[0::2, 1::2]
B = arr[1::2, 0::2] G2 = arr[1::2, 1::2]
All split planes have shape (H//2, W//2).
- argos.core.imaging.debayer.MEASUREMENT_CHANNELS: tuple[str, ...] = ('R', 'G', 'B', 'G1', 'G2', 'Luminance')¶
Views that return a real-pixel single plane (valid for measurement).
- argos.core.imaging.debayer.VIEWS: tuple[str, ...] = ('Super-pixel', 'Raw CFA', 'R', 'G', 'B')¶
Ordered for the image toolbar. Every one is rendered onto the full sensor geometry, so changing display mode never changes the annotation coordinate frame or the apparent field size.
- argos.core.imaging.debayer.bilinear_rgb(arr: ndarray) ndarray[source]¶
Full-resolution bilinear demosaic — COSMETIC ONLY.
Interpolates each missing colour from its known neighbours, which correlates pixels and breaks photometric statistics. Use for framing/presentation, never for measurement. Returns (H, W, 3) uint16.
- argos.core.imaging.debayer.compute_hfd(arr: ndarray, search_radius: int = 32) float | None[source]¶
Compute the Half-Flux Diameter of the brightest star in the frame.
Uses the canonical green plane (densest sampling, best SNR for stars; see
core/imaging/green.py). Returns HFD in pixels (at the subsampled scale), or None if the frame is empty / no star.
- argos.core.imaging.debayer.downsample_cfa(arr: ndarray, factor: int = 2) ndarray[source]¶
Reduce a raw CFA frame while preserving its Bayer tile.
A plain
arr[::2, ::2]retains only one Bayer colour (for GRBG, G1), then wrongly presents those samples as a complete colour mosaic. Instead, downsample each like-colour plane and interleave the four results back into a smaller GRBG mosaic. This is display-only; saved FITS and photometry continue to use the untouched full-resolution frame.
- argos.core.imaging.debayer.expand_cfa_cells(plane: ndarray, sensor_shape: tuple[int, int]) ndarray[source]¶
Magnify a CFA-cell view onto the original sensor geometry.
superpixel_rgband individual CFA planes naturally live at half the sensor dimensions. Showing them at that size made the image jump whenever the observer switched to Raw CFA or back, and invited overlay-coordinate mistakes. Nearest-neighbour expansion is intentional: it introduces no synthetic image detail; each 2×2 sensor cell simply occupies its original on-screen footprint.
- argos.core.imaging.debayer.extract_plane(arr: ndarray, channel: str) ndarray[source]¶
Return a single real-pixel plane (uint16, H//2×W//2) for a CFA channel.
'G'averages the two green pixels (both real greens — no cross-colour interpolation), which is the OSC “TG” photometric channel.'Luminance'is a weighted grey of the super-pixel.- Parameters:
arr – Raw 2-D array (GRBG).
channel – One of R, G, B, G1, G2, Luminance.
- Raises:
ValueError – for an unknown channel.
- argos.core.imaging.debayer.neutralise_rgb_for_display(rgb: ndarray) ndarray[source]¶
Apply a background-neutral display balance to a linear RGB preview.
OSC sensors and light-pollution filters commonly leave the green background brighter than red/blue. The balance is deliberately confined to preview RGB values: raw data, per-channel histograms and photometry are unchanged.
- argos.core.imaging.debayer.render_view(arr: ndarray, view: str) ndarray[source]¶
Render the raw array for a given display view (see
VIEWS).Returns a linear array on the original sensor geometry — 2-D uint16 (raw / single channel) or 3-D uint16 RGB (colour modes). CFA-cell views are nearest-neighbour expanded rather than interpolated, preserving their native samples while keeping marker coordinates identical in every mode. The stretch stage (see
imaging.stretch) maps it to the screen; the inputarris never mutated.
- argos.core.imaging.debayer.split_cfa(arr: ndarray) tuple[ndarray, ndarray, ndarray, ndarray][source]¶
Split a GRBG raw array into
(r, g1, g2, b)real-pixel planes.Each plane has shape (H//2, W//2), same dtype — real sensor pixels, no interpolation, so noise/flux statistics are preserved. Odd dimensions are cropped by one row/column so the four planes line up.
- argos.core.imaging.debayer.superpixel_rgb(arr: ndarray) ndarray[source]¶
2×2 → 1 RGB pixel, no interpolation. Returns (H//2, W//2, 3) uint16.
The scientifically clean colour preview: every output pixel uses only real sensor samples, introducing no spatial correlation.
The canonical green plane — one definition for the whole science stack.
See notes/photometry_plan.md §1.4. Star detection, FWHM/HFD, measure_star_at
and the plate-solver all measure on the green half-res plane. Historically two
slightly different greens were used (metrics sampled G1 only, the solver used
debayer.extract_plane(VIEW_G) = (G1+G2)/2). They shared the same pixel grid but
not the same values. This module is the single source of truth so they can never
drift again.
Definition: the per-tile average of the two GRBG green samples, (G1+G2)/2, on
the G1 grid (top-left green of each 2×2 tile), shape (H//2, W//2), float32.
Why averaged (not G1 only): ≈√2 better SNR for the detector and the solver — the difference between a short live sub solving or not. The two greens are sampled one pixel apart on the diagonal, so the averaged plane is the G1 grid broadened by ≤0.5 green px (≈1 raw px ≈ 3.7″) — an accepted, documented systematic.
debayer.extract_plane(VIEW_G) is the uint16 sibling of green_plane()
(same grid, integer floor instead of float round); it is what the display “G”
channel and the solver’s FITS use. Keep the two in lock-step.
- argos.core.imaging.green.green_plane(raw: ndarray) ndarray[source]¶
Return the
(G1+G2)/2green plane as float32, shape(H//2, W//2).GRBG tile:
G1 = raw[0::2, 0::2],G2 = raw[1::2, 1::2]. Odd dimensions are cropped by one row/column so the two greens line up (same convention asargos.core.imaging.debayer.split_cfa()).
- argos.core.imaging.green.green_shape(raw: ndarray) tuple[int, int][source]¶
Return the green plane’s
(h, w)=(H//2, W//2).
Display stretch transforms + measurement stats — display/analysis only.
Pure numpy, Qt-free, unit-tested. None of this touches the raw data written to
FITS (see notes/capture_panel.md §0/§3): apply_stretch returns a new
uint8 array for the screen; the linear array is passed in unchanged.
- argos.core.imaging.stretch.apply_stretch(arr: ndarray, black: float, white: float, mode: str = 'Linear', midtones: float = 0.5) ndarray[source]¶
Map
arrthrough black/white + transfer + midtones to a uint8 display array.Works on 2-D (grayscale) or 3-D (RGB) input.
midtonesis a PixInsight-style MTF balance in (0, 1); 0.5 is neutral. The input array is not modified.
- argos.core.imaging.stretch.auto_levels(arr: ndarray, lo_pct: float = 1.0, hi_pct: float = 99.0) tuple[float, float][source]¶
Return (black, white) display levels from percentiles of
arr.
- argos.core.imaging.stretch.auto_stf(arr: ndarray, target_bg: float = 0.25, shadow_clip: float = 2.8) tuple[float, float, float][source]¶
PixInsight-style auto screen-transfer: returns (black, white, midtones).
Robust median/MAD shadow clip + a midtones value that maps the median to
target_bg— brings out faint signal without blowing the stars, so astro frames look right with no manual tweaking. Display only.
- argos.core.imaging.stretch.channel_histograms(raw: ndarray, bins: int = 128, lo: float = 0.0, hi: float = 65535.0) tuple[ndarray, ndarray, ndarray, ndarray][source]¶
Per-channel (R, G, B) histograms from the raw CFA, on real pixels.
Binned over
[lo, hi](pass the frame’s actual data range so the curves fill the plot instead of collapsing to the left edge). Edges are snapped to the sensor’s ADU quantization grid (see_histogram_edges()) so quantized frames render as a continuous curve rather than a comb of spikes.binsis the target resolution; the effective count may be lower when the data has fewer real ADU levels than that. Returns(centers, r_counts, g_counts, b_counts).
- argos.core.imaging.stretch.quantization_step(arr: ndarray, sample: int = 200000, min_fraction: float = 0.9) int[source]¶
Recover the ADU quantization step of a raw frame from the data itself.
The Seestar’s IMX-series sensor has a 12-bit ADC but the driver delivers frames left-shifted into 16-bit, so real pixel values land on a grid of
2**(16-12) = 16ADU (field frames: sky~1104, max=65520=4095<<4). If the histogram bins are narrower than that grid, every other bin is empty and the curve degenerates into a comb / “block of spikes” instead of a continuous line — exactly the field report this fixes.We derive the step from the pixels rather than hardcoding 16 (or blindly trusting
camera.adc_bits) so the histogram stays correct if the driver changes bit-depth or a different sensor is used. Because the quantization comes from a bit left-shift, the grid step is always a power of two, so we simply find the largest power-of-two grid that most pixels lie on:mean(pixels % q == 0)is monotonically non-increasing inq(a multiple of 32 is also a multiple of 16), so we climbq = 2, 4, 8, …and stop at the first grid fewer thanmin_fractionof pixels satisfy.Why this and not GCD / bit-mask / unique-value gaps: real frames carry ~0.1% off-grid stray pixels (Seestar’s own processing leaves a few non-multiples of 16). Those strays collapse GCD/OR-reduce to 1 and can drag a median-gap estimate off the true step when the frame has few distinct levels. Counting the pixel fraction on each grid is immune to a handful of strays, and the
min_fractiongate naturally returns 1 for genuinely continuous data (random frames: only ~50% are even), so those bin normally.Subsampled with a stride for speed (this runs per preview frame): a few ms on a 3840x2160 frame.
Returns the step in ADU (a power of two), or 1 when no dominant grid exists.
- argos.core.imaging.stretch.region_stats(plane: ndarray) dict[str, float][source]¶
Summary statistics of a region (for sky background / noise / object level).
Per-frame quality metrics — focus (§5) and acquisition QA (§7).
Pure numpy, Qt-free, unit-tested. Computed on the green CFA plane (densest sampling, best SNR for stars). These are analysis outputs — they never modify the raw frame.
Seestar context: every model is undersampled (the S30 Pro runs 3.74″/px), so
HFD/FWHM are coarse — treat the trend, not the absolute value. The plate scale
comes from the active telescope profile; see arcsec_per_full_px().
- argos.core.imaging.metrics.DEFAULT_STAR_RADIUS = 5¶
Public default aperture radius (green-plane px) for star measurement (§5).
- class argos.core.imaging.metrics.DetectedStar(x: float, y: float, flux: float, fwhm: float, eccentricity: float)[source]¶
Bases:
objectA single detected star, measured on the green half-res plane.
Coordinates are in green-plane pixels (raw // 2). The viewer scales them to whatever display view is active (super-pixel/channel = ×1, raw/interp = ×2). FWHM is in green-plane pixels; multiply by the plate scale for arcsec.
- class argos.core.imaging.metrics.FrameMetrics(hfd: float | None, star_count: int, sky_adu: float, peak_adu: int)[source]¶
Bases:
objectQuality summary of a single frame (display/QA only).
- class argos.core.imaging.metrics.StarField(stars: tuple[DetectedStar, ...], sky_adu: float, mean_fwhm: float | None, mean_eccentricity: float | None)[source]¶
Bases:
objectDetected stars + frame-level focus/shape summary (display/QA, §5/§7).
- stars: tuple[DetectedStar, ...]¶
- class argos.core.imaging.metrics.StarMeasurement(x: float, y: float, fwhm: float | None, eccentricity: float | None, hfd: float | None, peak_adu: int, flux: float, snr: float, sky_adu: float, radius: int)[source]¶
Bases:
objectFull measurement of one star (the one the user clicked).
Coordinates are green-plane px;
radiusis the aperture used. The caller converts FWHM/HFD to arcsec with the plate scale.
- argos.core.imaging.metrics.TRACK_SNAP_SEARCH = 2¶
Snap window when re-measuring an already-selected star (new frame / radius change): tight, so the centre stays put and only tracks small drift.
- argos.core.imaging.metrics.arcsec_per_full_px() float[source]¶
Plate scale in ″ per full-resolution pixel, for the active telescope.
Call this; do not bind it. It was a module constant baked at import time (
206.265 × 2.9 / 160, the S30 Pro’s 3.74″/px) and imported straight into two UI modules, which is precisely what made the plate scale impossible to change without an import-order hazard.
- argos.core.imaging.metrics.arcsec_per_green_px() float[source]¶
Plate scale per green-plane pixel — the plane is subsampled ×2.
- argos.core.imaging.metrics.detect_stars(raw: ndarray, max_stars: int = 120, radius: int = 5) StarField[source]¶
Detect stars on the green plane and measure FWHM + eccentricity each.
Detection pipeline (Qt-free, used for the live overlay §5 and the QA record §7):
robust sky + MAD sigma → threshold at
sky + 5σ;3×3 local maxima above threshold, brightest first;
reject hot pixels / cosmics — a real star’s 4 neighbours must also be elevated (a lone bright pixel has none);
deduplicate — skip a peak within
radiuspx of an accepted star, so one star isn’t counted several times;measure each survivor with
_measure_star()(circular aperture + local-background subtraction).
- Parameters:
raw – Raw 2-D GRBG frame.
max_stars – Cap on the number of stars kept (brightest first).
radius – Aperture radius (green-plane px) for profile measurement and the dedup separation.
- Returns:
A
StarField. Empty (no stars) yieldsmean_*ofNone.
- argos.core.imaging.metrics.frame_metrics(raw: ndarray) FrameMetrics[source]¶
Compute focus + quality metrics for a raw GRBG frame.
- argos.core.imaging.metrics.measure_star_at(raw: ndarray, x: float, y: float, radius: int = 5, search: int | None = None) StarMeasurement | None[source]¶
Measure the star nearest a clicked point, in green-plane coordinates.
Snaps to the brightest pixel within
searchpx of (x, y), then measures centroid/FWHM/eccentricity/HFD/SNR in an aperture ofradius. This is the “click a star → read its FWHM” path (§5). ReturnsNoneif there is no star-like signal near the click.- Parameters:
raw – Raw 2-D GRBG frame.
x – Click position in green-plane px.
y – Click position in green-plane px.
radius – Aperture radius (green-plane px).
search – Snap radius for the local peak. Defaults to
_SNAP_SEARCHand is independent ofradiusso changing the aperture radius doesn’t re-snap the centre onto a different star.
FITS file writer — science-grade compliant output for Seestar S30 Pro.
Writes 16-bit unsigned FITS files with a full set of standard headers compatible with Siril, PixInsight, AstroImageJ, and other astronomy tools.
Header set follows NINA’s production header list and the mandatory headers defined in CLAUDE.md, extended with DATE-LOC, DATE-AVG, MJD-OBS, MJD-AVG required for accurate photometric time analysis, plus EGAIN/CCD-TEMP/OFFSET/ AIRMASS/MOONSEP/TARGRA needed by the varstar postprod pipeline.
- class argos.core.imaging.fits_writer.FITSWriter[source]¶
Bases:
objectWrites science-grade FITS files for Seestar S30 Pro.
Usage:
FITSWriter.write( arr=numpy_uint16_array, path=Path("/path/to/output.fits"), exposure_start=datetime_utc, exposure_end=datetime_utc, exposure_time=10.0, gain=80, image_type="Light Frame", context=FrameContext(ra=..., dec=..., observer="JP", ...), )
- static build_filename(object_name: str, image_type: str, exposure_start: datetime, exposure_time: float, filter_name: str, frame_index: int, gain: int | None = None) str[source]¶
Build a Siril-recognisable filename for one homogeneous series.
Format:
{OBJECT}_{FILTER}_{TYPE}_e{EXPOSURE_MS}ms_g{GAIN}_{INDEX}.fit. The prefix is fixed for one physical frame series; only the final positive index changes, as required by Siril sequence discovery.- Parameters:
object_name – Target name (e.g. “M42”). Sanitized for filesystem.
image_type – FITS IMAGETYP string.
exposure_start – UTC datetime of exposure start.
exposure_time – Exposure duration in seconds.
filter_name – Filter name.
frame_index – 1-based frame counter.
- Returns:
Filename string (no directory component).
- static session_folder(base_dir: Path, object_name: str, exposure_start: datetime, image_type: str, filter_name: str, run_id: str = '') Path[source]¶
Return the Siril-compatible session folder for a given frame.
Structure:
{base_dir}/{RUN}_{OBJECT}/{lights|darks|flats|biases}/
- Parameters:
base_dir – Exact sessions directory selected in Settings.
object_name – Target name.
exposure_start – Frame timestamp.
image_type – FITS IMAGETYP string.
filter_name – Filter name.
- Returns:
Path to the output folder (not yet created).
- static session_root(base_dir: Path, object_name: str, exposure_start: datetime, run_id: str = '') Path[source]¶
Return the per-session root folder (where
session.jsonlives).Structure:
{base_dir}/{RUN}_{OBJECT}/— the parent of lower-case Siril image-type folders produced bysession_folder()for the same target/date.- Parameters:
base_dir – Root output directory (from config).
object_name – Target name.
exposure_start – Reference timestamp (typically the first frame).
- Returns:
Path to the session root folder (not yet created).
- static write(arr: ndarray, path: Path, exposure_start: datetime, exposure_end: datetime, exposure_time: float, gain: int, image_type: str = 'Light Frame', context: FrameContext | None = None, *, overwrite: bool = True) None[source]¶
Write a single FITS frame to disk.
- Parameters:
arr – 2-D numpy uint16 array, shape (height, width).
path – Output file path (parent directory will be created).
exposure_start – UTC datetime of exposure start.
exposure_end – UTC datetime when ImageReady became True.
exposure_time – Commanded exposure duration in seconds.
gain – Camera gain value used for this frame.
image_type – FITS IMAGETYP string (“Light Frame”, “Dark Frame”, …).
context – Mount/sky/camera/site state. Defaults to an empty FrameContext if not provided (frame still writes, just with fewer headers).
overwrite – Whether a caller deliberately permits replacement.
- Raises:
ValueError – If
arris not 2-D.OSError – If the file cannot be written.
- class argos.core.imaging.fits_writer.FrameContext(ra: float | None = None, dec: float | None = None, altitude: float | None = None, azimuth: float | None = None, pier_side: str | None = None, target_ra: float | None = None, target_dec: float | None = None, ccd_temp: float | None = None, egain_driver: float | None = None, offset: int | None = None, readout_mode: str | None = None, object_name: str = '', filter_name: str = 'LRGB', observer: str = '', site_lat: float | None = None, site_lon: float | None = None, site_elev: float | None = None, software: str = 'Argos', annotation: str = '', hfd: float | None = None, star_count: int | None = None, sky_adu: float | None = None, fwhm: float | None = None, eccentricity: float | None = None, sensor_meta: dict = <factory>)[source]¶
Bases:
objectMount/sky/camera/site state captured at exposure time.
Every field is optional — missing data results in a missing FITS header rather than a failure.
write()acceptsNoneand falls back to defaults so callers in tests can stay minimal.
Acquisition sequence model and expansion — pure logic, no Qt, no I/O.
Defines the multi-step sequence plan used by the advanced sequencer and the
expand_plan flattener that turns a plan into the ordered list of frames to
shoot. Kept free of PyQt and hardware so it stays unit-testable in isolation
(this is where the business logic of sequencing lives, per CLAUDE.md).
- argos.core.imaging.sequencer.DARK_FILTER_NAME = 'Dark'
Name of the wheel’s opaque slot (Seestar position 0) — matched case-insensitively by the worker; wheels without one skip the move.
- argos.core.imaging.sequencer.FRAME_TYPES: tuple[str, ...] = ('Light', 'Dark', 'Flat', 'Bias')
Frame types accepted in a step (UI-facing short names).
- class argos.core.imaging.sequencer.FrameSpec(frame_type: str, image_type: str, filter_name: str, exposure_s: float, gain: int, interval_s: float, cadence_s: float | None, dither_every: int, frame_index: int, step_index: int, is_light: bool)[source]
Bases:
objectA single frame to shoot, produced by
expand_plan().- dither_every: int
- exposure_s: float
- filter_name: str
- frame_index: int
- frame_type: str
- gain: int
- image_type: str
- interval_s: float
- is_light: bool
- property needs_filter: bool
True for frame types that require a filter selection.
- step_index: int
- property wheel_filter: str
Filter slot the wheel must sit on for this frame.
Light/Flat use the step’s filter. Dark/Bias go to the opaque
Darkslot: on a shutterless camera (Seestar) the wheel is the only shutter — leaving it on a light filter turns darks into lights (field bug 2026-07-11).
- argos.core.imaging.sequencer.ON_COMPLETE_CHOICES: tuple[str, ...] = ('Nothing', 'Stop tracking', 'Park mount')
End-of-sequence actions (UI-facing values, stored in the plan).
- class argos.core.imaging.sequencer.SequencePlan(steps: list[SequenceStep] = <factory>, object_name: str = '', repeat: int = 1, autofocus_every_n: int = 0, autofocus_on_filter_change: bool = False, on_complete: str = 'Nothing', base_dir: Path | None = None, metadata: dict[str, str | int | float | bool | None]=<factory>)[source]
Bases:
objectAn ordered list of steps plus sequence-level options.
- steps
Ordered acquisition steps.
- Type:
list[argos.core.imaging.sequencer.SequenceStep]
- object_name
Target name written to FITS headers/filenames.
- Type:
- repeat
Number of times the whole step list is replayed.
- Type:
- autofocus_every_n
Trigger autofocus every N frames (0 = off).
- Type:
- autofocus_on_filter_change
Trigger autofocus whenever the filter changes.
- Type:
- on_complete
One of
ON_COMPLETE_CHOICES— what to do with the mount when the plan finishes fully (an explicit user choice, logged when run).- Type:
- base_dir
Output root (defaults to
Config.sessions_pathwhenNone).- Type:
pathlib.Path | None
- metadata
Optional JSON-safe science-planning hand-off written beside a sequence’s raw FITS.
- autofocus_every_n: int = 0
- autofocus_on_filter_change: bool = False
- object_name: str = ''
- on_complete: str = 'Nothing'
- repeat: int = 1
- steps: list[SequenceStep]
- class argos.core.imaging.sequencer.SequenceStep(enabled: bool = True, frame_type: str = 'Light', filter_name: str = 'LRGB', exposure_s: float = 10.0, gain: int = 80, count: int = 10, interval_s: float = 0.0, cadence_s: float | None = None, dither_every: int = 0)[source]
Bases:
objectOne block of identical frames in a sequence plan.
- enabled
Whether this step is included when the plan runs.
- Type:
- frame_type
One of
FRAME_TYPES(maps to a FITSIMAGETYP).- Type:
- filter_name
Filter to use (ignored for Dark/Bias frames).
- Type:
- exposure_s
Exposure time in seconds.
- Type:
- gain
Camera gain value.
- Type:
- count
Number of frames to shoot for this step.
- Type:
- interval_s
Idle delay inserted after each frame of this step.
- Type:
- cadence_s
Optional start-to-start cadence. When set, the sequence waits only for the remaining cadence after acquisition; it never adds
interval_son top. Used for transit time series, where cadence is a scientific constraint.- Type:
float | None
- dither_every
Dither cadence (frames). 0 = off. Dithering is not supported on the Seestar (no guiding) — kept for forward compatibility only.
- Type:
- count: int = 10
- dither_every: int = 0
- enabled: bool = True
- exposure_s: float = 10.0
- filter_name: str = 'LRGB'
- frame_type: str = 'Light'
- gain: int = 80
- interval_s: float = 0.0
- argos.core.imaging.sequencer.estimated_duration_s(plan: SequencePlan) float[source]
Rough pre-run duration: exposures + intervals + per-frame overhead.
Autofocus passes and filter moves are not counted — the estimate is a floor, shown before the run so the user can sanity-check the night.
- argos.core.imaging.sequencer.expand_plan(plan: SequencePlan) Iterator[FrameSpec][source]
Flatten a plan into the ordered sequence of frames to shoot.
Honors
enabled,countandrepeat. The frame index restarts per physically homogeneous(type, filter, exposure, gain)series and increases monotonically across repeats, matching Siril’s fixed-basename sequence convention.- Parameters:
plan – The sequence plan to expand.
- Yields:
One
FrameSpecper frame, in shooting order.
- argos.core.imaging.sequencer.plan_from_dict(data: dict) SequencePlan[source]
Rebuild a plan from a dict produced by
plan_to_dict().
- argos.core.imaging.sequencer.plan_to_dict(plan: SequencePlan) dict[source]
Serialize a plan to a JSON-safe dict (for preset save).
- argos.core.imaging.sequencer.total_frames(plan: SequencePlan) int[source]
Return the total number of frames the plan will shoot.
Session log — per-frame QA records persisted to session.json (§7).
A session is one acquisition run for a target. As each sub is written, its quality metrics (HFD, FWHM, star count, sky level, peak ADU, eccentricity) are appended here and the JSON is rewritten atomically, so a crash mid-sequence still leaves a valid, up-to-date log next to the FITS frames.
Qt-free and I/O-light — unit-tested in isolation. The companion FITS headers
(HFD/NSTARS/SKYLEVEL/FWHM) are written by FITSWriter; this file is the
session-level roll-up used to cull subs and review a night later.
- class argos.core.imaging.session_log.FrameRecord(filename: str, image_type: str, filter_name: str, exposure_s: float, gain: int, timestamp: str, hfd: float | None = None, fwhm: float | None = None, star_count: int | None = None, sky_adu: float | None = None, peak_adu: int | None = None, eccentricity: float | None = None)[source]
Bases:
objectQuality summary of one written sub (everything is JSON-safe).
- exposure_s: float
- filename: str
- filter_name: str
- gain: int
- image_type: str
- timestamp: str
- argos.core.imaging.session_log.SESSION_FILENAME = 'session.json'
Conventional file name written inside the session folder.
- argos.core.imaging.session_log.SESSION_SCHEMA = 1
Schema version, so a later reader can migrate older logs.
- class argos.core.imaging.session_log.SessionLog(object_name: str = '', software: str = 'Argos', started_utc: str = '', observer: str = '', frames: list[FrameRecord] = <factory>)[source]
Bases:
objectAccumulates
FrameRecordrows and serialisessession.json.- object_name
Target name.
- Type:
- software
Acquisition software string.
- Type:
- started_utc
ISO-8601 UTC of session start.
- Type:
- observer
Observer name (optional).
- Type:
- frames
Per-frame records appended in shooting order.
- Type:
list[argos.core.imaging.session_log.FrameRecord]
- frames: list[FrameRecord]
- object_name: str = ''
- observer: str = ''
- software: str = 'Argos'
- started_utc: str = ''
Plate-solving via ASTAP — recover a WCS for a frame (§6).
We solve on the green plane (densest CFA sampling, best SNR for astrometry).
ASTAP is an external binary; if it isn’t installed, solving reports a clear
“not found” instead of crashing. This module stays Qt-free — it only builds the
command, runs the subprocess, and parses ASTAP’s .ini result. The worker
(argos.workers.solve_worker) drives it off the UI thread.
ASTAP writes <image>.ini next to the input with PLTSOLVD=T and the WCS
keywords (CRVAL1/2, CD matrix or CDELT/CROTA) on success.
- class argos.core.imaging.platesolve.FrameWCS(crval1: float, crval2: float, crpix1: float, crpix2: float, cd: tuple[float, float, float, float])[source]¶
Bases:
objectA solved WCS for the (green-plane) frame: pixel ↔ celestial mapping.
Backed by
astropy.wcs.WCS(TAN/gnomonic, the full FITS standard) so sign conventions, projection and the CD matrix are handled correctly. Pixel coordinates are 0-based green-plane px (the same grid the star detector andmeasure_star_atuse); celestial coordinates are RA in hours, Dec in degrees on the public methods, degrees on the*_degones.- pixel_to_radec(x: float, y: float) tuple[float, float][source]¶
Pixel (0-based, green px) → (RA hours, Dec deg).
- pixel_to_world_deg(x, y)[source]¶
Pixel (0-based, green px) → (RA deg, Dec deg). Accepts scalars/arrays.
- class argos.core.imaging.platesolve.SolveResult(solved: bool, message: str = '', ra_hours: float | None = None, dec_deg: float | None = None, scale_arcsec: float | None = None, rotation_deg: float | None = None, mirrored: bool | None = None, fields: dict = <factory>)[source]¶
Bases:
objectOutcome of a solve —
solvedplus the recovered WCS summary.
- class argos.core.imaging.platesolve.SolveSettings(astap_path: str = '', database: str = '', search_radius_deg: float = 30.0, downsample: int = 2, scale_hint_arcsec: float | None = None, fov_hint_deg: float | None = None, ra_hint_hours: float | None = None, dec_hint_deg: float | None = None, timeout_s: float = 120.0, allow_blind_retry: bool = True)[source]¶
Bases:
objectASTAP invocation parameters (mostly from the Astrometry config panel).
- class argos.core.imaging.platesolve.WCSOverlay(lines: list, center: tuple[float, float] | None, center_label: str, target: tuple[float, float] | None, labels: list = <factory>)[source]¶
Bases:
objectAstrometry overlay geometry in green-plane pixels (display-agnostic).
The viewer scales these to whatever display view is active (×1 super-pixel, ×2 raw/interp), exactly as it does for the star overlay.
- argos.core.imaging.platesolve.angular_separation_deg(ra1_h: float, dec1_d: float, ra2_h: float, dec2_d: float) float[source]¶
Great-circle separation (degrees) between two RA(h)/Dec(°) points.
- argos.core.imaging.platesolve.find_astap(explicit: str = '') str | None[source]¶
Locate the ASTAP binary: explicit path → PATH → common locations.
- argos.core.imaging.platesolve.find_astap_db(astap: str = '') str | None[source]¶
Locate a star-database directory: next to the binary → common locations.
- argos.core.imaging.platesolve.format_dec_dms(dec_deg: float) str[source]¶
Format Dec (degrees) as
±DD°MM'SS.S".
- argos.core.imaging.platesolve.format_ra_hms(ra_hours: float) str[source]¶
Format RA (hours) as
HHhMMmSS.Ss.
- argos.core.imaging.platesolve.frame_wcs(fields: dict, shape: tuple[int, int] | None = None) FrameWCS | None[source]¶
Build a
FrameWCSfrom parsed ASTAP keys (CRVAL/CD/…).Falls back to a CDELT+CROTA matrix if no CD is present, and to the frame centre for CRPIX when ASTAP omits it (
shape= green-plane(h, w)). ReturnsNonewhen the minimum keys are missing.
- argos.core.imaging.platesolve.parse_astap_ini(text: str) SolveResult[source]¶
Parse ASTAP’s
key=value.inioutput into aSolveResult.
- argos.core.imaging.platesolve.solve_array(green: ndarray, settings: SolveSettings) SolveResult[source]¶
Plate-solve a 2-D green-plane array. Writes a temp FITS and runs ASTAP.
Returns a
SolveResult;solved=False(with a message) when ASTAP is missing, times out, or fails to solve — never raises for those cases.
- argos.core.imaging.platesolve.wcs_grid(wcs: FrameWCS, shape: tuple[int, int], target_radec: tuple[float, float] | None = None, target_lines: int = 4, samples: int = 40, spacing_deg: float | None = None) WCSOverlay[source]¶
Build RA/Dec grid lines + centre marker (+ optional target) for the frame.
shapeis the green-plane(h, w). Lines are sampled in celestial coordinates and projected back to pixels; points outside the frame become NaN so the viewer breaks the polyline there.target_radecis(ra_hours, dec_deg)of the intended target (a framing reticle).spacing_degforces a fixed on-sky grid step (a finer grid than thetarget_linesauto-pick);Nonekeeps the adaptive 1/2/5 step. The RA step is widened by1/cos(dec)so meridians and parallels stay visually even, and the line count is capped so a tiny spacing can’t flood the view.
Shared astrometry helpers — one code path for the live page and Open-FITS.
See notes/photometry_plan.md §4 (Workstream A). Both the live acquisition page
and the floating analysis window used to carry their own copies of “build the
ASTAP settings”, “halve the green-plane scale to full-res”, “build the grid
overlay” and “project catalog RA/Dec to green pixels” — with subtle divergences
(different hints, grid spacing applied on one side only, the ÷2 convention
duplicated). This module is the single, Qt-free, unit-tested home for all of it.
Conventions (notes/photometry_plan.md §1):
pixel coordinates are green px
(H//2, W//2)everywhere;cfg_getis acallable(key, default) -> value(the page’sself._cfg), so this module never imports the UIConfig.mount_radecis(ra_hours, dec_deg)of the live pointing, orNone.
- argos.core.imaging.astrometry_session.build_solve_settings(cfg_get: Callable[[str, object], object], green_shape: tuple[int, int], *, live: bool, mount_radec: tuple[float, float] | None = None) SolveSettings[source]¶
Assemble
SolveSettingsfrom config — the only place that does it.Live solves (
live=True) with a mount hint use a bounded timeout and a configurable search radius, and allow a blind retry so a stale mount hint (common with the Seestar) doesn’t permanently break auto-solving. The blind retry removes the hint and searches the whole sky, so the cadence is only affected when the hinted solve misses.
- argos.core.imaging.astrometry_session.field_geometry(wcs: FrameWCS | None, green_shape: tuple[int, int] | None) tuple[float, float, float, float] | None[source]¶
(centre RA°, centre Dec°, cone radius°, FOV arcmin)from the WCS.The cone radius is the centre→corner separation; the FOV diameter is twice that. Used to drive a VSX/VSP catalog cone search.
- argos.core.imaging.astrometry_session.full_res_scale(result: SolveResult) float | None[source]¶
Full-res plate scale (″/px) from a solve.
ASTAP solves the green half-res plane, so its reported scale is per green pixel; one full-res sensor pixel spans half that on sky. The ÷2 lives here, once, instead of being re-typed at every call site.
- argos.core.imaging.astrometry_session.overlay_for(wcs: FrameWCS | None, green_shape: tuple[int, int] | None, cfg_get: Callable[[str, object], object], target_radec: tuple[float, float] | None = None) WCSOverlay | None[source]¶
Build the RA/Dec grid overlay, applying
astrometry.grid_spacing_arcmin.0(the default) keeps the adaptive 1/2/5 spacing; a positive value forces a finer fixed on-sky grid.target_radecis(ra_hours, dec_deg)of the intended target → a framing reticle.
- argos.core.imaging.astrometry_session.project_points(wcs: FrameWCS | None, green_shape: tuple[int, int] | None, radec_deg: Iterable[tuple[float, float]], margin: float = 2.0) list[tuple[float, float] | None][source]¶
Project
(ra_deg, dec_deg)pairs to green px;Nonewhen off-frame.The output list is parallel to the input (one entry per pair) so callers can keep a marker/positions array aligned with their catalog list; an entry is
None(and so not clickable / not measurable) when it falls outside the frame by more thanmargingreen px.
- argos.core.imaging.astrometry_session.wcs_from_result(result: SolveResult, green_shape: tuple[int, int] | None) FrameWCS | None[source]¶
Build a
FrameWCSfrom a solve (CRPIX defaults to the green centre).
Sky geometry helpers — airmass, Moon distance, phase.
Pure functions, no Qt, no network. Used by the FITS writer to fill the
photometry-relevant headers (AIRMASS, MOONSEP, MOONALT, MOONPHAS)
without ever blocking the UI thread.
The astropy calls are not cheap (~50 ms for the Moon position), so callers should invoke them from the exposure worker, not the main thread.
- argos.core.imaging.sky_geometry.altitude_at(jd_utc: float, ra_hours: float, dec_deg: float, lat_deg: float, lon_deg: float) float[source]¶
Target altitude (degrees) at a JD — fast spherical trig, no astropy.
GMST from the JD (IAU 1982 linear term), LST from the site longitude, then the standard HA→altitude formula. Accurate to ~0.1° (no nutation or refraction), which is far below what airmass needs; exists so a batch of hundreds of frames doesn’t pay astropy’s ~50 ms per transform.
- argos.core.imaging.sky_geometry.compute_airmass(altitude_deg: float) float | None[source]¶
Return Pickering (2002) airmass for
altitude_deg(degrees).Returns
Noneif the target is below the horizon. Pickering’s formula is accurate to better than 0.01 airmass down to 1° altitude and stays finite at the horizon, unlike the naivesec(z).
- argos.core.imaging.sky_geometry.compute_moon_info(when_utc: datetime, site_lat: float | None, site_lon: float | None, site_elev: float | None, target_ra_hours: float | None, target_dec_deg: float | None) dict[source]¶
Return Moon altitude, target separation, and illuminated fraction.
- Keys (any may be missing if astropy fails or inputs incomplete):
moon_alt: Moon altitude at the site in degrees. moon_sep: Angular separation target–Moon in degrees. moon_phase: Illuminated fraction 0.0 (new) .. 1.0 (full).
- Parameters:
when_utc – Observation time, naive treated as UTC.
site_lat – Site latitude in degrees (north positive).
site_lon – Site longitude in degrees (east positive).
site_elev – Site elevation in metres above sea level.
target_ra_hours – Target RA in decimal hours (J2000).
target_dec_deg – Target Dec in decimal degrees (J2000).
- argos.core.imaging.sky_geometry.compute_target_geometry(when_utc: datetime, site_lat: float | None, site_lon: float | None, site_elev: float | None, ra_hours: float | None, dec_deg: float | None) dict[source]¶
Return the observing geometry of a target as seen from a site, at a time.
Returns a mapping whose optional keys include
altitude(degrees),azimuth(degrees north through east),airmass,hour_angle(hours),transit_in(hours),transit_utc,moon_sep(degrees) andfield_rotation(signed degrees/hour while the mount runs alt-az).Args mirror
compute_moon_info();when_utcnaive is treated as UTC. Pure and network-free (astropy built-in ephemeris), but ~50 ms — call off the UI thread for tight cadences.
- argos.core.imaging.sky_geometry.field_rotation_rate(altitude_deg: float, azimuth_deg: float, latitude_deg: float) float | None[source]¶
Alt-az field rotation rate in degrees/hour (signed), or None near zenith.
On an alt-az mount the field rotates at the rate the parallactic angle changes:
15.041 · cos(lat) · cos(az) / cos(alt)(azimuth from north, eastward — the astropy AltAz convention). Zero for a target due east/west, divergent at the zenith (returns None above 89°, where the mount can’t track anyway). Irrelevant on a wedge (EQ mode) — the caller gates on the mount mode.
- argos.core.imaging.sky_geometry.upcoming_night_altitudes(ra_hours: float, dec_deg: float, lat_deg: float, lon_deg: float, *, now: datetime | None = None, interval_minutes: int = 10) list[tuple[datetime, float]][source]¶
Sample an object’s altitude over the next local observing night.
The planning view deliberately uses 18:00–06:00 local civil time: it is predictable, works without a network/ephemeris download, and is labelled as such in the UI.
datetimevalues retain the computer’s local time zone so axis labels agree with the observer’s clock.
Focus V-curve fitting — pure, Qt-free, network-free.
An autofocus sweep samples HFD (half-flux diameter) at a series of focuser positions; in focus the HFD reaches a minimum, so the samples trace a “V”. The canonical estimate of best focus is the vertex of a parabola fitted to that V.
This module holds that fit as a pure function so it can be unit-tested without
hardware and reused both by the AutofocusWorker (which feeds it live
samples) and by the Focus screen (which plots the curve + vertex). Keeping it
here, off the Qt thread, mirrors sky_geometry and the rest of core.
- class argos.core.imaging.focus.FocusResult(best_position: int, best_hfd: float | None, method: str, coeffs: tuple[float, float, float] | None, samples: tuple[tuple[int, float], ...])[source]¶
Bases:
objectThe outcome of fitting a focuser V-curve.
- method¶
"parabola"(vertex of a reliable fit),"raw"(lowest measured sample — fallback) or"none"(no usable data).- Type:
- coeffs¶
(a, b, c)of the fitteda x^2 + b x + c, present only whenmethod == "parabola".
- samples¶
The valid
(position, hfd)pairs the fit used, sorted by position.
- argos.core.imaging.focus.MAX_VERTEX_OVER_RAW = 1.2¶
A parabola vertex may sit slightly below the lowest sample (that is the point of fitting), but never meaningfully above it: a vertex worse than a measured HFD means the samples are not a parabola (e.g. the far-defocus plateau where
compute_hfdsaturates) and the fit landed on noise. Tolerance for measurement scatter before the fit is rejected.
- argos.core.imaging.focus.MIN_RELATIVE_SPAN = 0.15¶
Minimum relative HFD span (max−min vs min) for a sweep to count as a V-curve. Below this the sweep is flat — clouds, wrong step size, optically decoupled focuser — and any “best” is a fit to noise (P6).
- argos.core.imaging.focus.fit_v_curve(measurements: list[tuple[int, float]], low: int | None = None, high: int | None = None) FocusResult[source]¶
Estimate best focus from
(position, hfd)samples.Fits a 2nd-order polynomial and returns its vertex when the fit is sound — the parabola must open upward (
a > 0) and its vertex must fall within the scanned range. Otherwise (degenerate fit, vertex out of range, or fewer than three valid samples) it falls back to the lowest measured HFD.NaNHFDs (failed frames) are dropped.- Parameters:
measurements –
(position, hfd)pairs;hfdmay beNaN.low – Lower bound for an acceptable vertex. Defaults to the smallest sampled position.
high – Upper bound. Defaults to the largest sampled position.
- argos.core.imaging.focus.refine_positions(center: int, spacing: int, low: int, high: int, count: int = 4) list[int][source]¶
Fine-scan focuser positions bracketing
center, inside[low, high].countevenly split offsets strictly inside ±``spacing`` (the coarse interval): the coarse sweep already measuredcenterand its ±spacing neighbours, so the fine pass samples the V wherecompute_hfdis actually informative. Sorted, deduplicated,centerexcluded.
- argos.core.imaging.focus.sweep_is_degenerate(samples: tuple[tuple[int, float], ...]) str | None[source]¶
None when the sweep looks like a V-curve, else why it doesn’t (P6).
Degenerate cases: fewer than three valid samples, an HFD span under
MIN_RELATIVE_SPANof the minimum (flat curve), or the minimum sitting on a sweep edge (the true focus is outside the scanned range — re-centre and re-run rather than trust an extrapolation).