Source code for argos.core.imaging.imx585

"""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…``), :func:`lookup_egain` will prefer that JSON
file at ``~/.argos/camera_calibration.json``.
"""

from __future__ import annotations

import json
import logging
import math
from pathlib import Path
from typing import Optional

logger = logging.getLogger(__name__)


# Sensor-intrinsic constants (datasheet)
FULL_WELL_E = 38_000  # electrons at saturation (low-gain mode)
HCG_THRESHOLD = 252  # gain setting at which HCG mode engages
PIXEL_SIZE_UM = 2.9
SENSOR_NAME = "IMX585"


# Reference points: (gain_setting, EGAIN [e-/ADU], read_noise [e-]).
# Derived from ZWO ASI585MC published characterisation; piecewise log-linear
# fit between anchors. Validated against forum bias-frame measurements.
_ANCHORS: list[tuple[int, float, float]] = [
    (0, 3.00, 3.60),
    (100, 1.50, 2.60),
    (252, 0.55, 1.05),  # HCG mode kicks in
    (300, 0.40, 0.95),
    (400, 0.18, 0.92),
    (500, 0.07, 0.90),
    (600, 0.04, 0.90),
]


_USER_CALIB_PATH = Path.home() / ".argos" / "camera_calibration.json"


[docs] def configure_calibration_directory(path: Path | str) -> None: """Set the folder searched for the legacy IMX585 PTC calibration.""" global _USER_CALIB_PATH _USER_CALIB_PATH = Path(path).expanduser() / "camera_calibration.json"
[docs] def lookup_egain(gain_setting: int) -> float: """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. """ g = _clamp_gain(gain_setting) user_value = _load_user_calibration(g, "egain") if user_value is not None: return user_value return _interpolate_log(g, index=1)
[docs] def lookup_read_noise(gain_setting: int) -> float: """Return read noise in electrons for the given gain setting (0–600).""" g = _clamp_gain(gain_setting) user_value = _load_user_calibration(g, "rdnoise") if user_value is not None: return user_value return _interpolate_linear(g, index=2)
[docs] def full_well_capacity(gain_setting: int) -> int: """Return effective full-well capacity in electrons. Drops sharply once HCG mode engages — the on-sensor amplifier trades dynamic range for read noise. """ if gain_setting < HCG_THRESHOLD: return FULL_WELL_E # HCG roughly quarters the well return int(FULL_WELL_E * 0.25)
# --------------------------------------------------------------------------- # # Internals # # --------------------------------------------------------------------------- # def _clamp_gain(g: int) -> int: return max(_ANCHORS[0][0], min(_ANCHORS[-1][0], int(g))) def _bracket(g: int) -> tuple[tuple[int, float, float], tuple[int, float, float]]: """Return the two anchor points bracketing ``g``.""" g = _clamp_gain(g) for i in range(len(_ANCHORS) - 1): lo, hi = _ANCHORS[i], _ANCHORS[i + 1] if lo[0] <= g <= hi[0]: return lo, hi return _ANCHORS[-2], _ANCHORS[-1] def _interpolate_log(g: int, index: int) -> float: """Interpolate column ``index`` of the anchor table in log-space.""" lo, hi = _bracket(g) if lo[0] == hi[0]: return lo[index] t = (g - lo[0]) / (hi[0] - lo[0]) log_value = math.log(lo[index]) + t * (math.log(hi[index]) - math.log(lo[index])) return round(math.exp(log_value), 4) def _interpolate_linear(g: int, index: int) -> float: lo, hi = _bracket(g) if lo[0] == hi[0]: return lo[index] t = (g - lo[0]) / (hi[0] - lo[0]) return round(lo[index] + t * (hi[index] - lo[index]), 3) def _load_user_calibration(gain_setting: int, key: str) -> Optional[float]: """Return a user-calibrated value for ``gain_setting`` if available. Expected JSON structure:: { "100": {"egain": 1.42, "rdnoise": 2.7}, "252": {"egain": 0.52, "rdnoise": 1.05} } """ if not _USER_CALIB_PATH.is_file(): return None try: data = json.loads(_USER_CALIB_PATH.read_text()) except (OSError, json.JSONDecodeError) as exc: logger.warning("Could not read user calibration %s: %s", _USER_CALIB_PATH, exc) return None entry = data.get(str(gain_setting)) if entry is None: return None value = entry.get(key) if value is None: return None try: return float(value) except (TypeError, ValueError): return None