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.

argos.core.imaging.imx662.full_well_capacity(gain_setting: int) → int[source]
argos.core.imaging.imx662.lookup_egain(gain_setting: int) → float[source]
argos.core.imaging.imx662.lookup_read_noise(gain_setting: int) → float[source]

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.

argos.core.imaging.imx462.full_well_capacity(gain_setting: int) → int[source]
argos.core.imaging.imx462.lookup_egain(gain_setting: int) → float[source]
argos.core.imaging.imx462.lookup_read_noise(gain_setting: int) → float[source]

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.is_known(sensor_name: str) → bool[source]
argos.core.imaging.sensor_models.lookup_egain(sensor_name: str, gain_setting: int) → float[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: object

Published reference anchors for one sensor / camera electronics pair.

anchors: tuple[tuple[int, float, float], ...]
property calibration_path: Path
full_well_capacity(gain_setting: int) → int[source]
full_well_e: int
hcg_full_well_fraction: float = 0.25
hcg_threshold: int
lookup_egain(gain_setting: int) → float[source]
lookup_read_noise(gain_setting: int) → float[source]
name: str
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_rgb and 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 input arr is 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)/2 green 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 as argos.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 arr through black/white + transfer + midtones to a uint8 display array.

Works on 2-D (grayscale) or 3-D (RGB) input. midtones is 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. bins is 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) = 16 ADU (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 in q (a multiple of 32 is also a multiple of 16), so we climb q = 2, 4, 8, … and stop at the first grid fewer than min_fraction of 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_fraction gate 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: object

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

eccentricity: float
flux: float
fwhm: float
x: float
y: float
class argos.core.imaging.metrics.FrameMetrics(hfd: float | None, star_count: int, sky_adu: float, peak_adu: int)[source]

Bases: object

Quality summary of a single frame (display/QA only).

hfd: float | None
peak_adu: int
sky_adu: float
star_count: int
class argos.core.imaging.metrics.StarField(stars: tuple[DetectedStar, ...], sky_adu: float, mean_fwhm: float | None, mean_eccentricity: float | None)[source]

Bases: object

Detected stars + frame-level focus/shape summary (display/QA, §5/§7).

property count: int
mean_eccentricity: float | None
mean_fwhm: float | None
sky_adu: float
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: object

Full measurement of one star (the one the user clicked).

Coordinates are green-plane px; radius is the aperture used. The caller converts FWHM/HFD to arcsec with the plate scale.

eccentricity: float | None
flux: float
fwhm: float | None
hfd: float | None
peak_adu: int
radius: int
sky_adu: float
snr: float
x: float
y: float

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

  1. robust sky + MAD sigma → threshold at sky + 5σ;

  2. 3×3 local maxima above threshold, brightest first;

  3. reject hot pixels / cosmics — a real star’s 4 neighbours must also be elevated (a lone bright pixel has none);

  4. deduplicate — skip a peak within radius px of an accepted star, so one star isn’t counted several times;

  5. 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) yields mean_* of None.

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 search px of (x, y), then measures centroid/FWHM/eccentricity/HFD/SNR in an aperture of radius. This is the “click a star → read its FWHM” path (§5). Returns None if 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_SEARCH and is independent of radius so 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: object

Writes 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.json lives).

Structure: {base_dir}/{RUN}_{OBJECT}/ — the parent of lower-case Siril image-type folders produced by session_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:
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: object

Mount/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() accepts None and falls back to defaults so callers in tests can stay minimal.

altitude: float | None = None
annotation: str = ''
azimuth: float | None = None
ccd_temp: float | None = None
dec: float | None = None
eccentricity: float | None = None
egain_driver: float | None = None
filter_name: str = 'LRGB'
fwhm: float | None = None
hfd: float | None = None
object_name: str = ''
observer: str = ''
offset: int | None = None
pier_side: str | None = None
ra: float | None = None
readout_mode: str | None = None
sensor_meta: dict
site_elev: float | None = None
site_lat: float | None = None
site_lon: float | None = None
sky_adu: float | None = None
software: str = 'Argos'
star_count: int | None = None
target_dec: float | None = None
target_ra: float | None = None

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: object

A single frame to shoot, produced by expand_plan().

cadence_s: float | None
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 Dark slot: 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: object

An 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:

str

repeat

Number of times the whole step list is replayed.

Type:

int

autofocus_every_n

Trigger autofocus every N frames (0 = off).

Type:

int

autofocus_on_filter_change

Trigger autofocus whenever the filter changes.

Type:

bool

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:

str

base_dir

Output root (defaults to Config.sessions_path when None).

Type:

pathlib.Path | None

metadata

Optional JSON-safe science-planning hand-off written beside a sequence’s raw FITS.

Type:

dict[str, str | int | float | bool | None]

autofocus_every_n: int = 0
autofocus_on_filter_change: bool = False
base_dir: Path | None = None
metadata: dict[str, str | int | float | bool | None]
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: object

One block of identical frames in a sequence plan.

enabled

Whether this step is included when the plan runs.

Type:

bool

frame_type

One of FRAME_TYPES (maps to a FITS IMAGETYP).

Type:

str

filter_name

Filter to use (ignored for Dark/Bias frames).

Type:

str

exposure_s

Exposure time in seconds.

Type:

float

gain

Camera gain value.

Type:

int

count

Number of frames to shoot for this step.

Type:

int

interval_s

Idle delay inserted after each frame of this step.

Type:

float

cadence_s

Optional start-to-start cadence. When set, the sequence waits only for the remaining cadence after acquisition; it never adds interval_s on 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:

int

cadence_s: float | None = None
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, count and repeat. 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 FrameSpec per 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: object

Quality summary of one written sub (everything is JSON-safe).

eccentricity: float | None = None
exposure_s: float
filename: str
filter_name: str
fwhm: float | None = None
gain: int
hfd: float | None = None
image_type: str
peak_adu: int | None = None
sky_adu: float | None = None
star_count: int | None = None
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: object

Accumulates FrameRecord rows and serialises session.json.

object_name

Target name.

Type:

str

software

Acquisition software string.

Type:

str

started_utc

ISO-8601 UTC of session start.

Type:

str

observer

Observer name (optional).

Type:

str

frames

Per-frame records appended in shooting order.

Type:

list[argos.core.imaging.session_log.FrameRecord]

add(record: FrameRecord) → None[source]

Append a frame record.

frames: list[FrameRecord]
object_name: str = ''
observer: str = ''
software: str = 'Argos'
started_utc: str = ''
summary() → dict[source]

Aggregate stats across light frames (means ignore missing values).

to_dict() → dict[source]

Return the full JSON-safe document.

write(path: Path) → None[source]

Atomically write session.json to path.

Writes to a temp file in the same directory then os.replace — so a reader never sees a half-written file even if we crash mid-write.

Parameters:

path – Destination session.json path (parent dirs are created).

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: object

A 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 and measure_star_at use); celestial coordinates are RA in hours, Dec in degrees on the public methods, degrees on the *_deg ones.

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.

radec_to_pixel(ra_hours: float, dec_deg: float) → tuple[float, float][source]

(RA hours, Dec deg) → pixel (0-based, green px).

world_to_pixel_deg(ra_deg, dec_deg)[source]

(RA deg, Dec deg) → pixel (0-based, green px). 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: object

Outcome of a solve — solved plus the recovered WCS summary.

dec_deg: float | None = None
fields: dict
message: str = ''
mirrored: bool | None = None
ra_hours: float | None = None
rotation_deg: float | None = None
scale_arcsec: float | None = None
solved: bool
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: object

ASTAP invocation parameters (mostly from the Astrometry config panel).

allow_blind_retry: bool = True
astap_path: str = ''
database: str = ''
dec_hint_deg: float | None = None
downsample: int = 2
fov_hint_deg: float | None = None
ra_hint_hours: float | None = None
scale_hint_arcsec: float | None = None
search_radius_deg: float = 30.0
timeout_s: float = 120.0
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: object

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

center: tuple[float, float] | None
center_label: str
labels: list
lines: list
target: tuple[float, float] | None
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 FrameWCS from 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)). Returns None when the minimum keys are missing.

argos.core.imaging.platesolve.parse_astap_ini(text: str) → SolveResult[source]

Parse ASTAP’s key=value .ini output into a SolveResult.

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.

shape is 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_radec is (ra_hours, dec_deg) of the intended target (a framing reticle).

spacing_deg forces a fixed on-sky grid step (a finer grid than the target_lines auto-pick); None keeps the adaptive 1/2/5 step. The RA step is widened by 1/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_get is a callable(key, default) -> value (the page’s self._cfg), so this module never imports the UI Config.

  • mount_radec is (ra_hours, dec_deg) of the live pointing, or None.

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 SolveSettings from 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_radec is (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; None when 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 than margin green px.

argos.core.imaging.astrometry_session.wcs_from_result(result: SolveResult, green_shape: tuple[int, int] | None) → FrameWCS | None[source]

Build a FrameWCS from 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 None if 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 naive sec(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) and field_rotation (signed degrees/hour while the mount runs alt-az).

Args mirror compute_moon_info(); when_utc naive 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. datetime values 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: object

The outcome of fitting a focuser V-curve.

best_position

Estimated best focuser position (steps).

Type:

int

best_hfd

HFD at the best position, or None if unknown.

Type:

float | None

method

"parabola" (vertex of a reliable fit), "raw" (lowest measured sample — fallback) or "none" (no usable data).

Type:

str

coeffs

(a, b, c) of the fitted a x^2 + b x + c, present only when method == "parabola".

Type:

tuple[float, float, float] | None

samples

The valid (position, hfd) pairs the fit used, sorted by position.

Type:

tuple[tuple[int, float], …]

fit_curve(num: int = 100) → tuple[list[float], list[float]][source]

Return (positions, hfd) tracing the fitted parabola for plotting.

Spans the sampled position range. Empty if there is no parabola fit.

property is_reliable: bool

True when a real parabola minimum was found (not a raw fallback).

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_hfd saturates) 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. NaN HFDs (failed frames) are dropped.

Parameters:
  • measurements – (position, hfd) pairs; hfd may be NaN.

  • 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].

count evenly split offsets strictly inside ±``spacing`` (the coarse interval): the coarse sweep already measured center and its ±spacing neighbours, so the fine pass samples the V where compute_hfd is actually informative. Sorted, deduplicated, center excluded.

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