Commit 5745a788 authored by vertighel's avatar vertighel
Browse files

atik.py: guard subframe change while looping; fix setpoint telemetry...


atik.py: guard subframe change while looping; fix setpoint telemetry off-cooling; script diagnostico standalone

Causa reale trovata su hardware per il bug half_frame()/small_frame()
full-frame: ArtemisSubframe falliva con ARTEMIS_INVALID_PARAMETER
(codice 1) perche' mancava il guard "non idle" che mako.py/stl.py
hanno gia' per lo stesso vincolo hardware (can't-change-binning/window-
while-looping). La guida SDK del vendor conferma il pattern: il suo
stesso esempio controlla ArtemisCameraState(hcam)==0 prima di
ArtemisSubframe. _apply_subframe() ora rifiuta subito se self._looping
e' vero, oltre al controllo del codice di ritorno gia' aggiunto prima.

Cooling: trovata la causa per cui "Set" non aggiornava la telemetria a
cooling spento. all()'s "setpoint" leggeva il registro SDK live
(ArtemisCoolingInfo) invece di self._setpoint — ma ArtemisSetCooling e'
l'unica chiamata che comunica un setpoint all'hardware e attiva sempre
anche il cooling, quindi col cooling spento il registro non viene mai
aggiornato. Ora all() riporta self._setpoint, come gia' fa stl.py per
lo stesso motivo. La lentezza del bottone Cooling Off resta
comportamento SDK/firmware documentato (slow ramp down), non un bug.

Aggiunto test_atik.py nella root: script diagnostico standalone che
chiama la SDK Artemis direttamente via ctypes, senza passare dal
wrapper (niente loop thread/guard/cache), per isolare ulteriormente il
bug subframe. Scatta un'esposizione Light di 1s sulla subframe passata
da riga di comando, stampa stato macchina/codice di ritorno/read-back
SDK, scrive un FITS.

Co-Authored-By: default avatarClaude Sonnet 5 <noreply@anthropic.com>
parent 3cd3fd0c
Loading
Loading
Loading
Loading
Loading
+57 −31
Original line number Diff line number Diff line
@@ -165,42 +165,68 @@ during the devices phase (no hardware needed); CCD-TEMP is still open.
     (no alternative "warm up now" function exists in the SDK), and a
     slow ramp is expected vendor behavior, not obviously a bug. No
     code path re-enables cooling afterward (checked — only one
     `ArtemisSetCooling` call site, in the True branch). Still
     genuinely unresolved: is what was observed just "hasn't finished
     ramping yet" (needs a longer observation window than STX/STL) or
     a real stuck state? Needs watching `ArtemisCoolingInfo`'s
     `level`/`flags` over a longer period on `fork`, not a code change
     — no fix applied.
  Temperature-setter fix applied; cooler-off half still open per (2).
     `ArtemisSetCooling` call site, in the True branch). Confirmed on
     `fork` (2026-07-22): the Cooling-Off button is genuinely slow to
     respond, consistent with this documented ramp-down — the physical
     temperature lagging is expected. No fix applied/needed for this
     part specifically; not a code bug.
  3. *(found on `fork` 2026-07-22, alongside the above)* **Setpoint
     telemetry doesn't update on "Set" while cooling is off** (works
     correctly while cooling is on, matching fix (1) above). Root
     cause: `all()`'s `"setpoint"` field (~line 812) read
     `ArtemisCoolingInfo`'s live `setp` output — the SDK's own hardware
     register — instead of `self._setpoint`. But `ArtemisSetCooling` is
     the *only* SDK call that communicates a setpoint to the hardware,
     and it also unconditionally activates cooling (no "store a target
     without engaging cooling" function exists in this SDK) — so while
     cooling is off, the hardware register is never told about the new
     value, and reading it live shows the stale one. Fixed: `all()` now
     reports `self._setpoint` (the Python-side value, updated
     immediately by the `temperature` setter regardless of cooling
     state) instead of the live SDK register — matches `stl.py`'s
     `all()`, which already does the same for the identical reason.
  4. *(found on `fork` 2026-07-22)* **The Cooling-Off *state* itself
     (the boolean flag, not the temperature) should flip immediately in
     telemetry but is slow.** Same underlying cause as (2) — the SDK's
     `flags & 64` bit apparently isn't cleared until the ramp-down
     itself progresses/completes, which is vendor firmware behavior
     `atik.py` has no control over. Not fixed, not clearly fixable from
     the host side.
  (1) and (3) fixed and believed correct; (2)/(4) are vendor firmware
  behavior, not bugs in this codebase — no further action proposed
  unless hardware testing shows the ramp genuinely never completes.
- **`half_frame()`/`small_frame()` returned a full-frame image
  (correctly binned) instead of the intended crop, though
  `XORGSUBF`/`YORGSUBF` correctly matched the requested read mode.**
  Found on `fork` (2026-07-22); user confirms these worked correctly on
  `fork` in the past.
  **Not a confirmed root-cause fix — a diagnostic gap closed, the
  actual cause is still unknown.** Found that `put()`'s `ctypes_errors`
  decorator deliberately never interprets the SDK's own numeric return
  code ("each call site's job", see its docstring in `utils/check.py`)
  — and `set_window()`/`full_frame()`/`half_frame()`/`small_frame()`
  were never checking it. A rejected/failed `ArtemisSubframe` call
  would have been silently ignored, with `self._subframe` (the source
  for `XORGSUBF`/`YORGSUBF`) still updated to the intended crop
  regardless — a plausible match for the symptom, *if* the call is
  in fact failing, which is not yet established. Confirmed
  `ARTEMIS_OK == 0` in the vendor SDK (`AtikDefs.h`). Changed: the 4
  methods' duplicated `self.put('ArtemisSubframe', ...)` +
  cache-update pairs factored into one `_apply_subframe(x, y, w, h)`
  helper that checks the return code, logs + `self.error.append(...)`
  and leaves `self._subframe` untouched on failure, instead of blindly
  trusting success. Verified with `py_compile` only — no hardware
  access to confirm. This only surfaces the failure-return-code
  scenario; it does *not* cover the other plausible scenario where
  `ArtemisSubframe` itself returns `ARTEMIS_OK` but the sensor/SDK
  still delivers a full frame anyway (e.g. a firmware/SDK quirk not
  honoring the subframe under some condition) — that scenario would
  need comparing `ArtemisGetSubframe`'s own read-back against
  `ArtemisGetImageData`'s reported size, not attempted here. Next real
  exposure on `fork` will show which of the two it actually is.
  **Root cause found and confirmed on hardware (2026-07-22):** the
  diagnostic fix below (checking `ArtemisSubframe`'s return code, which
  was never checked before) immediately surfaced the real error on
  `fork` — every `half_frame()`/`small_frame()` attempt while testing
  returned `ARTEMIS_INVALID_PARAMETER` (code 1), for both `(0,668,4008,
  1336)` (half_frame) and `(0,1500,4008,500)` (small_frame) — both
  arithmetically correct against `cameras.ini`'s configured coordinates
  for `scicam2`, ruling out a coordinate-computation bug. Checked the
  vendor SDK guide's own `ArtemisSetPreview` example: it explicitly
  checks `ArtemisCameraState(hcam) == 0` (idle) *before* calling
  `ArtemisSubframe` — strongly implying the SDK rejects subframe
  changes while the camera isn't idle (e.g. mid-loop). `atik.py` had no
  such guard anywhere, unlike `mako.py`/`stl.py`, which both already
  refuse binning/window changes while looping for exactly this class of
  hardware constraint (see `dev/conventions/python.md`'s intro: "the
  can't-change-gain/binning-while-looping guards exist because of a
  real bug found on hardware, not indecision").
  Fixed: `_apply_subframe()` now refuses outright with
  `self.error.append(...)` if `self._looping` is true, mirroring
  `mako.py`'s/`stl.py`'s exact guard pattern, on top of the return-code
  check (kept — still useful for any *other* reason `ArtemisSubframe`
  might fail while idle). Needs confirming on `fork` with Loop off
  during a frame change to close this out completely; if it still
  fails while genuinely idle, a live `ArtemisCameraState` check (not
  just the `_looping` flag) would be the next step, since a single
  non-looping exposure could plausibly leave the camera briefly
  downloading/flushing (states 4/5) too.

## Regressions found on `fork` post-refactor (2026-07-22)

+24 −1
Original line number Diff line number Diff line
@@ -472,8 +472,22 @@ class Camera(BaseDevice):
        used to be silently ignored, leaving ``self._subframe`` (and the
        ``XORGSUBF``/``YORGSUBF`` headers derived from it) claiming a crop
        that was never actually applied on the sensor.

        Also refuses outright while looping: the vendor SDK guide's own
        ``ArtemisSetPreview`` example checks ``ArtemisCameraState(hcam)
        == 0`` (idle) before calling ``ArtemisSubframe``, and confirmed
        on hardware — ``ArtemisSubframe`` returns
        ``ARTEMIS_INVALID_PARAMETER`` (code 1) when the camera isn't
        idle. Same guard as ``mako.py``'s/``stl.py``'s "cannot change
        binning/window while looping".
        """

        if self._looping:
            msg = "Atik: cannot change subframe while looping."
            log.error(msg)
            self.error.append(msg)
            return False

        ret = self.put('ArtemisSubframe', x, y, w, h)
        if ret != 0:
            msg = f"Atik: ArtemisSubframe({x},{y},{w},{h}) failed (code {ret})"
@@ -795,7 +809,16 @@ class Camera(BaseDevice):

        return {
            "ambient": None,
            "setpoint": setp.value / 100.0,
            # self._setpoint, not the SDK's own ArtemisCoolingInfo setp:
            # ArtemisSetCooling both sets *and* activates cooling in one
            # call, with no equivalent "store a target without engaging
            # cooling" function in this SDK — so while cooling is off,
            # self._setpoint (updated immediately by the temperature
            # setter) is the only place the just-requested setpoint is
            # recorded; the hardware register isn't told about it until
            # cooling is actually turned on. Matches stl.py's "setpoint":
            # self._setpoint for the same reason.
            "setpoint": self._setpoint,
            "temperature": self.temperature,
            "cooler": bool(flags.value & 64),
            "fan": fan_power,

test_atik.py

0 → 100644
+149 −0
Original line number Diff line number Diff line
#!/usr/bin/env python3
"""
Acquisizione singola da camera Atik (Artemis SDK), diretta, senza
passare da noctua.devices.atik — per isolare il bug ArtemisSubframe
(vedi dev/refactor/PLAN.md, sezione Atik) da eventuali interferenze
del wrapper (loop thread, guard, cache).

Uso:
    python test_atik.py <xstart> <ystart> <xend> <yend> [-o output.fits]

Scatta un'esposizione Light di 1 secondo sulla sub-frame richiesta e
salva il risultato in FITS. Stampa a video anche lo stato macchina
prima della subframe e il read-back di ArtemisGetSubframe, cosi' da
vedere subito se la richiesta e' stata accettata dall'SDK.
"""

import argparse
import ctypes
import sys
import time
from pathlib import Path

import numpy as np
from astropy.io import fits

LIB_PATH = "/usr/lib/libatikcameras.so"
EXPTIME = 1.0


class AtikTest:
    """Wrapper minimale per una singola esposizione Light su Atik."""

    def __init__(self):
        self.lib = ctypes.CDLL(LIB_PATH)
        self.lib.ArtemisConnect.restype = ctypes.c_void_p
        self.lib.ArtemisImageBuffer.restype = ctypes.c_void_p
        self.lib.ArtemisExposureTimeRemaining.restype = ctypes.c_float
        self.handle = None

    def connect(self):
        """Connette alla prima camera Atik disponibile su USB."""

        count = self.lib.ArtemisDeviceCount()
        print(f"SDK: {count} device rilevati")
        if count <= 0:
            raise RuntimeError("Nessuna camera Atik rilevata su USB")

        self.handle = self.lib.ArtemisConnect(0)
        if not self.handle:
            raise RuntimeError("ArtemisConnect non ha restituito un handle valido")
        print(f"Connesso, handle={self.handle}")

    def disconnect(self):
        if self.handle:
            self.lib.ArtemisDisconnect(self.handle)
            print("Disconnesso")

    def set_subframe(self, xstart, ystart, width, height):
        """Imposta la sub-frame e stampa stato macchina + read-back SDK."""

        state = self.lib.ArtemisCameraState(self.handle)
        print(f"Stato camera prima della subframe: {state} "
              f"(0=idle, 1=waiting, 2=exposing, 4=downloading, 5=flushing, -1=error)")

        ret = self.lib.ArtemisSubframe(self.handle, xstart, ystart, width, height)
        print(f"ArtemisSubframe({xstart}, {ystart}, {width}, {height}) -> {ret} "
              f"(0=ok, 1=invalid parameter)")
        if ret != 0:
            raise RuntimeError(f"ArtemisSubframe fallita, codice {ret}")

        gx, gy, gw, gh = (ctypes.c_int() for _ in range(4))
        self.lib.ArtemisGetSubframe(self.handle, ctypes.byref(gx), ctypes.byref(gy),
                                    ctypes.byref(gw), ctypes.byref(gh))
        print(f"Read-back ArtemisGetSubframe: x={gx.value} y={gy.value} "
              f"w={gw.value} h={gh.value}")

    def expose(self, exptime_s):
        """Scatta un'esposizione Light e restituisce (array, binx, biny)."""

        ret = self.lib.ArtemisStartExposure(self.handle, ctypes.c_float(exptime_s))
        print(f"ArtemisStartExposure({exptime_s}) -> {ret}")
        if ret != 0:
            raise RuntimeError(f"ArtemisStartExposure fallita, codice {ret}")

        deadline = time.time() + exptime_s + 10.0
        while not self.lib.ArtemisImageReady(self.handle):
            if time.time() > deadline:
                raise RuntimeError("Timeout in attesa di ArtemisImageReady")
            time.sleep(0.1)

        x, y, w, h, binx, biny = (ctypes.c_int() for _ in range(6))
        self.lib.ArtemisGetImageData(self.handle, ctypes.byref(x), ctypes.byref(y),
                                     ctypes.byref(w), ctypes.byref(h),
                                     ctypes.byref(binx), ctypes.byref(biny))
        print(f"ArtemisGetImageData: x={x.value} y={y.value} w={w.value} h={h.value} "
              f"binx={binx.value} biny={biny.value}")

        buf_ptr = self.lib.ArtemisImageBuffer(self.handle)
        if not buf_ptr:
            raise RuntimeError("ArtemisImageBuffer ha restituito un puntatore nullo")

        size = w.value * h.value
        buffer = (ctypes.c_uint16 * size).from_address(buf_ptr)
        array = np.frombuffer(buffer, dtype=np.uint16).reshape(h.value, w.value).copy()

        return array, binx.value, biny.value

    def save_fits(self, array, binx, biny, xstart, ystart, path):
        """Salva l'array come FITS Light frame."""

        hdu = fits.PrimaryHDU(array)
        hdu.header['IMAGETYP'] = ('Light', 'Image type')
        hdu.header['EXPTIME'] = (EXPTIME, '[s] Exposure duration')
        hdu.header['XBINNING'] = (binx, 'X binning factor')
        hdu.header['YBINNING'] = (biny, 'Y binning factor')
        hdu.header['XORGSUBF'] = (xstart, '[px] Subframe X origin (unbinned, richiesta)')
        hdu.header['YORGSUBF'] = (ystart, '[px] Subframe Y origin (unbinned, richiesta)')
        hdu.writeto(str(path), overwrite=True)
        print(f"Salvato FITS: {path}  shape={array.shape[1]}x{array.shape[0]} "
              f"min={array.min()} max={array.max()}")


def main():
    parser = argparse.ArgumentParser(description='Esposizione Light singola su Atik, con subframe')
    parser.add_argument('xstart', type=int, help='X origine subframe (px, unbinned)')
    parser.add_argument('ystart', type=int, help='Y origine subframe (px, unbinned)')
    parser.add_argument('xend',   type=int, help='X fine subframe (px, unbinned)')
    parser.add_argument('yend',   type=int, help='Y fine subframe (px, unbinned)')
    parser.add_argument('-o', '--output', default='test_atik.fits',
                         help='Percorso file FITS di output (default: test_atik.fits)')
    args = parser.parse_args()

    width = args.xend - args.xstart
    height = args.yend - args.ystart
    if width <= 0 or height <= 0:
        sys.exit(f"xend/yend devono essere maggiori di xstart/ystart (larghezza={width}, altezza={height})")

    cam = AtikTest()
    try:
        cam.connect()
        cam.set_subframe(args.xstart, args.ystart, width, height)
        array, binx, biny = cam.expose(EXPTIME)
        cam.save_fits(array, binx, biny, args.xstart, args.ystart, Path(args.output))
    finally:
        cam.disconnect()


if __name__ == '__main__':
    main()