Source code for noctua.devices.stx2

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

"""
Interface with a SBIG STX camera device
"""

# System modules
import time
from datetime import datetime
from urllib.parse import urlencode

# Third-party modules
import requests

# Other templates
from ..config.constants import temp_fits
from ..utils import check
from ..utils.logger import log
from .basedevice import BaseDevice


[docs] class STX(BaseDevice): """ Base wrapper class for SBIG STX cameras. Handles low-level communication and timing constraints. """ def __init__(self, url): """ Initializes the STX device interface. Parameters ---------- url : str The IP address or hostname of the camera. """ super().__init__(url) self.url = url self.addr = self.url self.timeout = 3 self._last_command_time = 0 self._command_interval = 0.05 # 50 milliseconds def _wait_if_needed(self): """ Ensures the 50ms command interval is respected between commands. """ elapsed = time.time() - self._last_command_time if elapsed < self._command_interval: time.sleep(self._command_interval - elapsed) self._last_command_time = time.time() @property def connection(self): """ Checks the connection to the camera. Returns ------- bool True if the camera responds to a version query, False otherwise. """ try: self._wait_if_needed() res = requests.get(f"{self.addr}/VersionNumbers.cgi", timeout=self.timeout) res.raise_for_status() return True except Exception: self.error = ["Camera not connected"] return False @check.request_errors def get(self, method, params=None): """ Sends a HTTP GET request to the device. Parameters ---------- method : str The API method name (e.g., "ImagerGetSettings"). params : list of str, optional A list of parameter names to query. Returns ------- str or list of str or float or list of float or None or list of None A single value or a list of values from the camera. Type conversion to float is attempted. Returns None on error. """ self._wait_if_needed() if params is None: params = [] res = requests.get(f"{self.addr}/{method}.cgi", params="&".join(params), timeout=self.timeout, verify=False) res.raise_for_status() values = res.text.split("\r\n") if not values[-1]: values = values[:-1] processed_values = [] for v in values: try: processed_values.append(float(v)) except (ValueError, TypeError): processed_values.append(v) if len(processed_values) == 1: return processed_values[0] return processed_values @check.request_errors def put(self, method, params=None): """ Sends a command (as a HTTP GET request) to the device. Parameters ---------- method : str The API method name (e.g., "ImagerSetSettings"). params : dict, optional A dictionary of parameter names and their values. Returns ------- str or list of str or None The response text from the camera, or None on error. """ self._wait_if_needed() if params is None: params = {} res = requests.get(f"{self.addr}/{method}.cgi", params=urlencode(params), timeout=self.timeout) res.raise_for_status() text = res.text.split("\r\n") if not text[-1]: text = text[:-1] if len(text) == 1: return text[0] return text
[docs] class Camera(STX): """ High-level interface for the SBIG STX camera imaging CCD. """
[docs] def abort(self): """Aborts the current exposure.""" self.put("ImagerAbortExposure")
[docs] def start(self, duration, frametype, dt=None): """ Starts an exposure if the camera is idle. Parameters ---------- duration : float Exposure time in seconds. frametype : int The type of frame (0=Dark, 1=Light, 2=Bias, 3=Flat). dt : str, optional ISO-formatted datetime string for the FITS header. If None, the current UTC time is used. """ if self.state != 0: log.error( f"Cannot start exposure, camera is not idle. State: { self.state}") self.error.append("Camera not idle") return if dt is None: dt = datetime.utcnow().isoformat(timespec='milliseconds') params = {"Duration": duration, "FrameType": frametype, "DateTime": dt} self.put("ImagerStartExposure", params=params)
[docs] def download(self, filepath=temp_fits): """ Downloads the last completed image from the camera buffer. Parameters ---------- filepath : str, optional The local path to save the FITS file to. """ self._wait_if_needed() res = requests.get(f"{self.addr}/Imager.FIT") with open(filepath, 'wb') as f: f.write(res.content)
[docs] def set_window(self, start_x, start_y, width, height): """ Sets the imaging sub-frame in a single, safe command. Coordinates are for a 1x1 binned frame. Parameters ---------- start_x : int The starting X pixel. start_y : int The starting Y pixel. width : int The width of the sub-frame in pixels. height : int The height of the sub-frame in pixels. """ if self.state != 0: log.error( f"Cannot set window, camera is not idle. State: { self.state}") self.error.append("Camera not idle") return params = { "StartX": start_x, "StartY": start_y, "NumX": width, "NumY": height } self.put("ImagerSetSettings", params=params)
[docs] def full_frame(self): """Sets the camera to use the full sensor area.""" params = ["CameraXSize", "CameraYSize"] cam_x, cam_y = self.get("ImagerGetSettings", params=params) if self.error: return [None, None] self.set_window(0, 0, int(cam_x), int(cam_y))
[docs] def half_frame(self): """Sets the camera to use a centered 50% sub-frame.""" params = ["CameraXSize", "CameraYSize"] cam_x, cam_y = self.get("ImagerGetSettings", params=params) if self.error: return [None, None] start_x = int(cam_x) // 4 start_y = int(cam_y) // 4 width = int(cam_x) // 2 height = int(cam_y) // 2 self.set_window(start_x, start_y, width, height)
[docs] def small_frame(self): """Sets the camera to use a centered 10% sub-frame.""" params = ["CameraXSize", "CameraYSize"] cam_x, cam_y = self.get("ImagerGetSettings", params=params) if self.error: return [None, None] cam_x, cam_y = res start_x = int(cam_x) * 9 // 20 start_y = int(cam_y) * 9 // 20 width = int(cam_x) // 10 height = int(cam_y) // 10 self.set_window(start_x, start_y, width, height)
@property def binning(self): """list of int: The current [X, Y] binning factor.""" params = ["BinX", "BinY"] binx, biny = self.get("ImagerGetSettings", params=params) if self.error: return [None, None] return [int(binx), int(biny)] @binning.setter def binning(self, b): if self.state != 0: log.error( f"Cannot change binning, camera is not idle. State: { self.state}") self.error.append("Camera not idle") return params = {"BinX": b[0], "BinY": b[1]} self.put("ImagerSetSettings", params=params) @property def filter(self): """int: The currently selected filter position (1-8).""" params = ["CurrentFilter"] res = self.get("GetFilterSetting", params=params) if self.error: return None return res @property def cooler(self): """bool: The current state of the CCD cooler (True=On, False=Off).""" params = ["CoolerState"] res = self.get("ImagerGetSettings", params=params) if self.error: return None return bool(res) @cooler.setter def cooler(self, b): if self.state != 0: log.error( f"Cannot change cooler state, camera is not idle. State: { self.state}") self.error.append("Camera not idle") return params = {"CoolerState": "1" if b else "0"} self.put("ImagerSetSettings", params=params) @filter.setter def filter(self, n): if self.is_moving != 0: log.error( f"Cannot change filter, filter wheel is moving. State: { self.is_moving}") self.error.append("Filter wheel busy") return params = {"NewPosition": n} self.put("ChangeFilter", params=params) @property def temperature(self): """float: The current CCD temperature in degrees Celsius.""" params = ["CCDTemperature"] res = self.get("ImagerGetSettings", params=params) if self.error: return None return round(res, 1) @temperature.setter def temperature(self, t): if self.state != 0: log.error( f"Cannot change temperature, camera is not idle. State: { self.state}") self.error.append("Camera not idle") return params = {"CCDTemperatureSetpoint": t} self.put("ImagerSetSettings", params=params) @property def all(self): """dict: A comprehensive dictionary of the current camera state.""" params = [ "AmbientTemperature", "CCDTemperatureSetpoint", "CCDTemperature", "CoolerState", "CoolerPower", "BinX", "BinY", "CameraXSize", "CameraYSize", "StartX", "StartY", "NumX", "NumY"] res = self.get("ImagerGetSettings", params=params) if self.error or not res or len(res) != len(params): return {} ambient, setpoint, temp, cool, fan, binx, biny, camx, camy, startx, starty, numx, numy = res b_x, b_y = int(binx), int(biny) x_start, y_start = int(startx) // b_x, int(starty) // b_y x_end = (int(startx) + int(numx)) // b_x y_end = (int(starty) + int(numy)) // b_y return {"ambient": round(ambient, 1), "setpoint": round(setpoint, 1), "temperature": round(temp, 1), "cooler": bool(cool), "fan": round(fan, 0), "binning": [b_x, b_y], "max_range": [int(camx) // b_x, int(camy) // b_y], "xystart": [x_start, y_start], "xyend": [x_end, y_end], "xrange": [x_start, x_end], "yrange": [y_start, y_end], "center": [int(camx) // b_x // 2, int(camy) // b_y // 2]} @property def ambient(self): """float: The ambient temperature in degrees Celsius.""" params = ["AmbientTemperature"] res = self.get("ImagerGetSettings", params=params) if self.error: return None return round(res, 1) @property def center(self): """list of int: The sensor's [X, Y] center coordinates for current binning.""" params = ["CameraXSize", "CameraYSize", "BinX", "BinY"] camx, camy, binx, biny = self.get("ImagerGetSettings", params=params) if self.error: return [None, None] return [int(camx) // int(binx) // 2, int(camy) // int(biny) // 2] @property def description(self): """str: The camera model description string.""" res = self.get("Description") if self.error: return None return res @property def fan(self): """int: The current cooler power level in percent.""" params = ["CoolerPower"] res = self.get("ImagerGetSettings", params=params) if self.error: return None return round(res) @property def is_moving(self): """int: The current state of the filter wheel. 0=Idle, 1=Moving, 2=Error. """ res = self.get("FilterState") if self.error: return None return res @property def max_range(self): """list of int: The maximum sensor dimensions [X, Y] for current binning.""" params = ["CameraXSize", "CameraYSize"] b_x, b_y = self.get("ImagerGetSettings", params=params) if self.error: return [None, None] b_x, b_y = self.binning return [int(res[0]) // b_x, int(res[1]) // b_y] @property def setpoint(self): """float: The current CCD temperature setpoint in degrees Celsius.""" params = ["CCDTemperatureSetpoint"] res = self.get("ImagerGetSettings", params=params) if self.error: return None return res @property def state(self): """int: The current state of the imaging CCD. 0=Idle, 2=Exposing, 3=Reading, 5=Error. """ res = self.get("ImagerState") if self.error: return None return res @property def ready(self): """int: The status of the image buffer. 1 if an image is ready for download, 0 otherwise. """ res = self.get("ImagerImageReady") if self.error: return None return res @property def version(self): """list of str: Camera firmware and API version numbers.""" res = self.get("VersionNumbers") if self.error: return None return res @property def xrange(self): """list of int: The current [start, end] X-axis window for current binning.""" params = ["StartX", "NumX", "BinX"] startx, numx, binx = self.get("ImagerGetSettings", params=params) if self.error: return [None, None] x_start = int(startx) // int(binx) x_end = (int(startx) + int(numx)) // int(binx) return [x_start, x_end] @property def yrange(self): """list of int: The current [start, end] Y-axis window for current binning.""" params = ["StartY", "NumY", "BinY"] starty, numy, biny = self.get("ImagerGetSettings", params=params) if self.error: return [None, None] y_start = int(starty) // int(biny) y_end = (int(starty) + int(numy)) // int(biny) return [y_start, y_end] @property def xystart(self): """list of int: The current [X, Y] start coordinates for current binning.""" params = ["StartX", "StartY", "BinX", "BinY"] startx, starty, binx, biny = self.get( "ImagerGetSettings", params=params) if self.error: return [None, None] return [int(startx) // int(binx), int(starty) // int(biny)] @property def xyend(self): """list of int: The current [X, Y] end coordinates for current binning.""" params = ["StartX", "StartY", "NumX", "NumY", "BinX", "BinY"] startx, starty, numx, numy, binx, biny = self.get( "ImagerGetSettings", params=params) if self.error: return [None, None] x_end = (int(startx) + int(numx)) // int(binx) y_end = (int(starty) + int(numy)) // int(biny) return [x_end, y_end]