Loading dev/refactor/PLAN.md +54 −10 Original line number Diff line number Diff line Loading @@ -243,16 +243,60 @@ during the devices phase (no hardware needed); CCD-TEMP is still open. atik.py` wrapper (which happened with `self._looping` presumably true) — here, single-threaded, no loop, state is `-1` from the start and stays that way. `test_atik.py` was extended with a `wait_idle()` poll (up to 3s) to rule out a simple post-connect settle-time race before concluding it's a persistent error — not yet re-tested after this change. If it's still `-1` even after the poll, this points to a real hardware/driver/USB fault on this specific camera or its connection on `fork`, independent of and possibly underlying several other open Atik items above (the `CCD-TEMP` sentinel corruption, and perhaps the subframe/cooling behavior too) — needs `dmesg`/`lsusb` around the connect, and checking the SDK's Linux driver/udev installation (see the SDK guide's Section 4.2) hasn't drifted, not a code fix. poll (up to 3s, later manually extended to 30s while testing). **Root cause found on `fork` (2026-07-22), and it's an environment problem, not hardware and not a `noctua` code bug: the wrong Python interpreter.** Chased through several red herrings before landing on it, each ruled out in turn: - *USB extender*: `lsusb` showed two Cypress (`04b4:...`) devices — `04b4:6506 "CY4603"` (a genuine 4-port USB hub, confirmed part of the extender, disappears when it's unplugged) and `04b4:df28` (the Atik camera itself, with zero Mfr/Product/SerialNumber descriptor strings — looked like an unprogrammed Cypress FX2/FX3 chip stuck in bootloader mode, unlike the SBIG STL sharing the same hub, which correctly shows `"SBIG Astronomy Camera (with firmware)"`). It worked once right after power-cycling camera+extender together, then stopped — looked exactly like a marginal firmware-upload link. **Ruled out**: see below, same device/topology still fails or succeeds purely based on which Python runs the script. - *Session/permissions (`uaccess`/logind ACL)*: `sudo` from a fresh plain SSH session (no tmux) made it work reliably — looked like a privilege issue. `ls -l`/`getfacl` on the device node showed plain `crw-rw-rw-` (0666) with no extra ACL, so file permissions were never actually the blocker. **Ruled out**: `strace -f -e trace=ioctl,openat` comparing a failing (no sudo) and succeeding (sudo) run showed the *identical* set of ioctls (`USBDEVFS_SUBMITURB`/`REAPURBNDELAY`/etc.), no `EPERM`/`EACCES` anywhere — no privileged operation was ever attempted or denied. - **Actual cause, found in the same strace comparison**: the failing run's very first `openat` was `/home/antola/.pyenv/shims/python3` (pyenv's shim); the succeeding (`sudo`) run's was the system Python 3.10 (`/usr/lib/python3.10/...`, no `pyenv`/`pyvenv.cfg` anywhere). `sudo` resets `PATH` (`secure_path` in `/etc/sudoers`), which incidentally bypasses `~/.pyenv/shims` — that's *why* `sudo` "fixed" it, nothing to do with privilege. **Confirmed directly** by the user: running `test_atik.py` with `/usr/bin/env python3` → system Python works every time, in tmux *and* plain SSH; running it explicitly with pyenv's Python (currently pinned to **3.14**, very new) fails every time, in both session types. Root cause is Python 3.14 (or something specific to this pyenv build of it) interacting unreliably with the Artemis SDK's shared library over `ctypes` + `usbfs` — the exact mechanism (GIL/threading/signal handling difference between the two Python builds?) wasn't pinned down further; not needed once the interpreter swap reliably fixes it. **Not a code fix, not a hardware fix** — none of the Python-level fixes above (subframe-while-looping guard, setpoint telemetry) were wrong, they remain correct; the extender and USB permissions were both innocent. **Action needed**: verify what Python interpreter `noctua-api` actually runs under in production on `fork` — if it's also resolving to the pyenv 3.14 shim, that alone could explain *every* previously-tracked Atik flakiness in this document (`CCD-TEMP` sentinel corruption, the subframe full-frame bug, the cooling oddities) as one root cause rather than several unrelated hardware bugs. Pin `noctua-api`'s entry point to the system Python (or whichever interpreter is confirmed reliable) if so — not done yet, waiting on the user's check. ## Regressions found on `fork` post-refactor (2026-07-22) Loading noctua/devices/atik.py +96 −7 Original line number Diff line number Diff line Loading @@ -88,14 +88,98 @@ class Camera(BaseDevice): try: self._lib = ctypes.CDLL("/usr/lib/libatikcameras.so") self._lib.ArtemisConnect.restype = ctypes.c_void_p self._lib.ArtemisImageBuffer.restype = ctypes.c_void_p self._lib.ArtemisExposureTimeRemaining.restype = ctypes.c_float self._lib.ArtemisLastExposureDuration.restype = ctypes.c_float self._lib.ArtemisLastStartTime.restype = ctypes.c_char_p self._declare_signatures() except Exception as e: log.error(f"Atik SDK load failed: {e}") def _declare_signatures(self): """Declare argtypes/restype for every Artemis SDK function called from this file. Without these, ctypes falls back to its own implicit argument marshaling, which isn't part of ctypes' stable ABI and was found to behave differently across Python versions — the same calls that worked reliably under Python 3.10 returned corrupt/failed results under 3.14 (see PLAN.md). Types transcribed from the vendor's own ctypesgen-generated Python SDK (external-software/AtikPythonSDK_v1.5.1), which declares all of these explicitly and doesn't show the same instability. """ h = ctypes.c_void_p i = ctypes.c_int p_i = ctypes.POINTER(ctypes.c_int) lib = self._lib lib.ArtemisDeviceCount.argtypes = [] lib.ArtemisDeviceCount.restype = i lib.ArtemisConnect.argtypes = [i] lib.ArtemisConnect.restype = h lib.ArtemisDisconnect.argtypes = [h] lib.ArtemisDisconnect.restype = i lib.ArtemisIsConnected.argtypes = [h] lib.ArtemisIsConnected.restype = i lib.ArtemisProperties.argtypes = [h, ctypes.POINTER(ArtemisProperties)] lib.ArtemisProperties.restype = i lib.ArtemisCoolingInfo.argtypes = [h, p_i, p_i, p_i, p_i, p_i] lib.ArtemisCoolingInfo.restype = i lib.ArtemisSetCooling.argtypes = [h, i] lib.ArtemisSetCooling.restype = i lib.ArtemisCoolerWarmUp.argtypes = [h] lib.ArtemisCoolerWarmUp.restype = i lib.ArtemisTemperatureSensorInfo.argtypes = [h, i, p_i] lib.ArtemisTemperatureSensorInfo.restype = i lib.ArtemisAbortExposure.argtypes = [h] lib.ArtemisAbortExposure.restype = i lib.ArtemisSetDarkMode.argtypes = [h, ctypes.c_bool] lib.ArtemisSetDarkMode.restype = i lib.ArtemisStartExposure.argtypes = [h, ctypes.c_float] lib.ArtemisStartExposure.restype = i lib.ArtemisImageReady.argtypes = [h] lib.ArtemisImageReady.restype = i lib.ArtemisGetImageData.argtypes = [h, p_i, p_i, p_i, p_i, p_i, p_i] lib.ArtemisGetImageData.restype = i lib.ArtemisImageBuffer.argtypes = [h] lib.ArtemisImageBuffer.restype = ctypes.c_void_p lib.ArtemisLastExposureDuration.argtypes = [h] lib.ArtemisLastExposureDuration.restype = ctypes.c_float lib.ArtemisLastStartTime.argtypes = [h] lib.ArtemisLastStartTime.restype = ctypes.c_char_p lib.ArtemisLastStartTimeMilliseconds.argtypes = [h] lib.ArtemisLastStartTimeMilliseconds.restype = i lib.ArtemisExposureTimeRemaining.argtypes = [h] lib.ArtemisExposureTimeRemaining.restype = ctypes.c_float lib.ArtemisSubframe.argtypes = [h, i, i, i, i] lib.ArtemisSubframe.restype = i lib.ArtemisCameraState.argtypes = [h] lib.ArtemisCameraState.restype = i lib.ArtemisGetBin.argtypes = [h, p_i, p_i] lib.ArtemisGetBin.restype = i lib.ArtemisBin.argtypes = [h, i, i] lib.ArtemisBin.restype = i def _check_connection(self): """Internal method to manage the persistent camera handle. Loading Loading @@ -328,10 +412,15 @@ class Camera(BaseDevice): from ..config.constants import frame_type as _frame_type Path(filepath).parent.mkdir(parents=True, exist_ok=True) size = w.value * h_img.value buffer = (ctypes.c_uint16 * size).from_address(buf_ptr) data = np.frombuffer(buffer, dtype=np.uint16).reshape(h_img.value, w.value) # np.array(...): an owned copy, not a read-only view straight over # the SDK's own buffer — without it astropy silently wrote no # BZERO/BSCALE for this uint16 data (fixed once already on main in # e662296, lost when this file was rewritten for the devices phase # without that branch's fix; see PLAN.md). raw = np.frombuffer(buffer, dtype=np.uint16).reshape(h_img.value, w.value) data = np.array(raw) hdu = fits.PrimaryHDU(data) hdr = hdu.header Loading noctua/devices/atik2.py 0 → 100644 +781 −0 File added.Preview size limit exceeded, changes collapsed. Show changes pyproject.toml +1 −0 Original line number Diff line number Diff line Loading @@ -54,6 +54,7 @@ dependencies = [ # "Fourth" party :) "PIPython", # for PI - Physik Instrumente controllers "vmbpy @ file:lib/vmbpy-1.2.1-py3-none-manylinux_2_27_x86_64.whl", "AtikSDK @ file:lib/Atik_Python_SDK-1.5.1-py3-none-any.whl", # used by devices/atik2.py, still requires libatikcameras.so on the system ] [project.urls] Loading Loading
dev/refactor/PLAN.md +54 −10 Original line number Diff line number Diff line Loading @@ -243,16 +243,60 @@ during the devices phase (no hardware needed); CCD-TEMP is still open. atik.py` wrapper (which happened with `self._looping` presumably true) — here, single-threaded, no loop, state is `-1` from the start and stays that way. `test_atik.py` was extended with a `wait_idle()` poll (up to 3s) to rule out a simple post-connect settle-time race before concluding it's a persistent error — not yet re-tested after this change. If it's still `-1` even after the poll, this points to a real hardware/driver/USB fault on this specific camera or its connection on `fork`, independent of and possibly underlying several other open Atik items above (the `CCD-TEMP` sentinel corruption, and perhaps the subframe/cooling behavior too) — needs `dmesg`/`lsusb` around the connect, and checking the SDK's Linux driver/udev installation (see the SDK guide's Section 4.2) hasn't drifted, not a code fix. poll (up to 3s, later manually extended to 30s while testing). **Root cause found on `fork` (2026-07-22), and it's an environment problem, not hardware and not a `noctua` code bug: the wrong Python interpreter.** Chased through several red herrings before landing on it, each ruled out in turn: - *USB extender*: `lsusb` showed two Cypress (`04b4:...`) devices — `04b4:6506 "CY4603"` (a genuine 4-port USB hub, confirmed part of the extender, disappears when it's unplugged) and `04b4:df28` (the Atik camera itself, with zero Mfr/Product/SerialNumber descriptor strings — looked like an unprogrammed Cypress FX2/FX3 chip stuck in bootloader mode, unlike the SBIG STL sharing the same hub, which correctly shows `"SBIG Astronomy Camera (with firmware)"`). It worked once right after power-cycling camera+extender together, then stopped — looked exactly like a marginal firmware-upload link. **Ruled out**: see below, same device/topology still fails or succeeds purely based on which Python runs the script. - *Session/permissions (`uaccess`/logind ACL)*: `sudo` from a fresh plain SSH session (no tmux) made it work reliably — looked like a privilege issue. `ls -l`/`getfacl` on the device node showed plain `crw-rw-rw-` (0666) with no extra ACL, so file permissions were never actually the blocker. **Ruled out**: `strace -f -e trace=ioctl,openat` comparing a failing (no sudo) and succeeding (sudo) run showed the *identical* set of ioctls (`USBDEVFS_SUBMITURB`/`REAPURBNDELAY`/etc.), no `EPERM`/`EACCES` anywhere — no privileged operation was ever attempted or denied. - **Actual cause, found in the same strace comparison**: the failing run's very first `openat` was `/home/antola/.pyenv/shims/python3` (pyenv's shim); the succeeding (`sudo`) run's was the system Python 3.10 (`/usr/lib/python3.10/...`, no `pyenv`/`pyvenv.cfg` anywhere). `sudo` resets `PATH` (`secure_path` in `/etc/sudoers`), which incidentally bypasses `~/.pyenv/shims` — that's *why* `sudo` "fixed" it, nothing to do with privilege. **Confirmed directly** by the user: running `test_atik.py` with `/usr/bin/env python3` → system Python works every time, in tmux *and* plain SSH; running it explicitly with pyenv's Python (currently pinned to **3.14**, very new) fails every time, in both session types. Root cause is Python 3.14 (or something specific to this pyenv build of it) interacting unreliably with the Artemis SDK's shared library over `ctypes` + `usbfs` — the exact mechanism (GIL/threading/signal handling difference between the two Python builds?) wasn't pinned down further; not needed once the interpreter swap reliably fixes it. **Not a code fix, not a hardware fix** — none of the Python-level fixes above (subframe-while-looping guard, setpoint telemetry) were wrong, they remain correct; the extender and USB permissions were both innocent. **Action needed**: verify what Python interpreter `noctua-api` actually runs under in production on `fork` — if it's also resolving to the pyenv 3.14 shim, that alone could explain *every* previously-tracked Atik flakiness in this document (`CCD-TEMP` sentinel corruption, the subframe full-frame bug, the cooling oddities) as one root cause rather than several unrelated hardware bugs. Pin `noctua-api`'s entry point to the system Python (or whichever interpreter is confirmed reliable) if so — not done yet, waiting on the user's check. ## Regressions found on `fork` post-refactor (2026-07-22) Loading
noctua/devices/atik.py +96 −7 Original line number Diff line number Diff line Loading @@ -88,14 +88,98 @@ class Camera(BaseDevice): try: self._lib = ctypes.CDLL("/usr/lib/libatikcameras.so") self._lib.ArtemisConnect.restype = ctypes.c_void_p self._lib.ArtemisImageBuffer.restype = ctypes.c_void_p self._lib.ArtemisExposureTimeRemaining.restype = ctypes.c_float self._lib.ArtemisLastExposureDuration.restype = ctypes.c_float self._lib.ArtemisLastStartTime.restype = ctypes.c_char_p self._declare_signatures() except Exception as e: log.error(f"Atik SDK load failed: {e}") def _declare_signatures(self): """Declare argtypes/restype for every Artemis SDK function called from this file. Without these, ctypes falls back to its own implicit argument marshaling, which isn't part of ctypes' stable ABI and was found to behave differently across Python versions — the same calls that worked reliably under Python 3.10 returned corrupt/failed results under 3.14 (see PLAN.md). Types transcribed from the vendor's own ctypesgen-generated Python SDK (external-software/AtikPythonSDK_v1.5.1), which declares all of these explicitly and doesn't show the same instability. """ h = ctypes.c_void_p i = ctypes.c_int p_i = ctypes.POINTER(ctypes.c_int) lib = self._lib lib.ArtemisDeviceCount.argtypes = [] lib.ArtemisDeviceCount.restype = i lib.ArtemisConnect.argtypes = [i] lib.ArtemisConnect.restype = h lib.ArtemisDisconnect.argtypes = [h] lib.ArtemisDisconnect.restype = i lib.ArtemisIsConnected.argtypes = [h] lib.ArtemisIsConnected.restype = i lib.ArtemisProperties.argtypes = [h, ctypes.POINTER(ArtemisProperties)] lib.ArtemisProperties.restype = i lib.ArtemisCoolingInfo.argtypes = [h, p_i, p_i, p_i, p_i, p_i] lib.ArtemisCoolingInfo.restype = i lib.ArtemisSetCooling.argtypes = [h, i] lib.ArtemisSetCooling.restype = i lib.ArtemisCoolerWarmUp.argtypes = [h] lib.ArtemisCoolerWarmUp.restype = i lib.ArtemisTemperatureSensorInfo.argtypes = [h, i, p_i] lib.ArtemisTemperatureSensorInfo.restype = i lib.ArtemisAbortExposure.argtypes = [h] lib.ArtemisAbortExposure.restype = i lib.ArtemisSetDarkMode.argtypes = [h, ctypes.c_bool] lib.ArtemisSetDarkMode.restype = i lib.ArtemisStartExposure.argtypes = [h, ctypes.c_float] lib.ArtemisStartExposure.restype = i lib.ArtemisImageReady.argtypes = [h] lib.ArtemisImageReady.restype = i lib.ArtemisGetImageData.argtypes = [h, p_i, p_i, p_i, p_i, p_i, p_i] lib.ArtemisGetImageData.restype = i lib.ArtemisImageBuffer.argtypes = [h] lib.ArtemisImageBuffer.restype = ctypes.c_void_p lib.ArtemisLastExposureDuration.argtypes = [h] lib.ArtemisLastExposureDuration.restype = ctypes.c_float lib.ArtemisLastStartTime.argtypes = [h] lib.ArtemisLastStartTime.restype = ctypes.c_char_p lib.ArtemisLastStartTimeMilliseconds.argtypes = [h] lib.ArtemisLastStartTimeMilliseconds.restype = i lib.ArtemisExposureTimeRemaining.argtypes = [h] lib.ArtemisExposureTimeRemaining.restype = ctypes.c_float lib.ArtemisSubframe.argtypes = [h, i, i, i, i] lib.ArtemisSubframe.restype = i lib.ArtemisCameraState.argtypes = [h] lib.ArtemisCameraState.restype = i lib.ArtemisGetBin.argtypes = [h, p_i, p_i] lib.ArtemisGetBin.restype = i lib.ArtemisBin.argtypes = [h, i, i] lib.ArtemisBin.restype = i def _check_connection(self): """Internal method to manage the persistent camera handle. Loading Loading @@ -328,10 +412,15 @@ class Camera(BaseDevice): from ..config.constants import frame_type as _frame_type Path(filepath).parent.mkdir(parents=True, exist_ok=True) size = w.value * h_img.value buffer = (ctypes.c_uint16 * size).from_address(buf_ptr) data = np.frombuffer(buffer, dtype=np.uint16).reshape(h_img.value, w.value) # np.array(...): an owned copy, not a read-only view straight over # the SDK's own buffer — without it astropy silently wrote no # BZERO/BSCALE for this uint16 data (fixed once already on main in # e662296, lost when this file was rewritten for the devices phase # without that branch's fix; see PLAN.md). raw = np.frombuffer(buffer, dtype=np.uint16).reshape(h_img.value, w.value) data = np.array(raw) hdu = fits.PrimaryHDU(data) hdr = hdu.header Loading
noctua/devices/atik2.py 0 → 100644 +781 −0 File added.Preview size limit exceeded, changes collapsed. Show changes
pyproject.toml +1 −0 Original line number Diff line number Diff line Loading @@ -54,6 +54,7 @@ dependencies = [ # "Fourth" party :) "PIPython", # for PI - Physik Instrumente controllers "vmbpy @ file:lib/vmbpy-1.2.1-py3-none-manylinux_2_27_x86_64.whl", "AtikSDK @ file:lib/Atik_Python_SDK-1.5.1-py3-none-any.whl", # used by devices/atik2.py, still requires libatikcameras.so on the system ] [project.urls] Loading