Commit 9935a975 authored by vertighel's avatar vertighel
Browse files

stl.py: windowing reale; half/small_frame configurabili da cameras.ini



stl.py: set_window()/full_frame()/half_frame()/small_frame() erano stub
(sempre full-frame). Ora self._subframe (px non-binnati) è tenuto in
memoria come il binning e applicato a ogni esposizione/readout:
top/left/height/width di StartExposureParams2 derivati dal subframe,
_read_frame() prende anche left/top (passato pure come pixelStart di
ReadoutLineParams, dato che SBIG non ha un ROI persistente). Corretto
anche xystart/xyend (prima sempre = full frame) e "size" in all()
(prima = max_range invece di xyend-xystart).

half_frame()/small_frame() in stx.py, atik.py e stl.py leggono ora le
coordinate xstart,ystart,xend,yend da cameras.ini (nuova camera_frame()
in constants.py), con fallback alla formula hardcoded precedente se la
chiave manca. Valori calcolati per scicam1/2/3 e validati contro i FITS
reali (scicam1.fits: XORGSUBF/YORGSUBF coincidono esattamente col
small_frame STX calcolato). scicam2 (Atik) pilota lo spettrografo
long-slit di stazione 2, non un teccam — il crop resta solo in Y.

Co-Authored-By: default avatarClaude Sonnet 5 <noreply@anthropic.com>
parent a65c7bf2
Loading
Loading
Loading
Loading
+14 −0
Original line number Diff line number Diff line
@@ -14,6 +14,10 @@
# has_loop     — true if the viewer can run a hw-driven live loop
# pixel_scale  — [arcsec/px] at binning 1
# orientation  — [deg] camera rotation angle
# half_frame   — [px] xstart,ystart,xend,yend at binning 1, used by
#                half_frame(); omit to use the device's built-in default
# small_frame  — [px] xstart,ystart,xend,yend at binning 1, used by
#                small_frame(); omit to use the device's built-in default
# #########################################################

##############
@@ -27,6 +31,8 @@ power_api = /scicam1/power
fits_path   = fits/scicam1.fits
pixel_scale = 0.283
orientation = -60
half_frame  = 1036, 1031, 3108, 3094
small_frame = 1865, 1856, 2279, 2268

[scicam2]
role        = sci
@@ -35,6 +41,10 @@ power_api = /scicam2/power
fits_path   = fits/scicam2.fits
pixel_scale = 0.283
orientation = -60
# spettrografo long-slit: la finestra ritaglia solo in Y, tutta la
# larghezza del sensore (KAI-11002, 4008x2672) resta leggibile.
half_frame  = 0, 668, 4008, 2004
small_frame = 0, 1500, 4008, 2000

[scicam3]
role        = sci
@@ -42,6 +52,10 @@ station = 3
fits_path   = fits/scicam3.fits
pixel_scale = 0.283
orientation = -60
# stesso sensore KAI-11002 di scicam2 (4008x2672), ma qui centrato su
# entrambi gli assi come STX: camera scientifica, non slit.
half_frame  = 1002, 668, 3006, 2004
small_frame = 1803, 1202, 2203, 1469

##############
# Technical (guider) cameras
+14 −0
Original line number Diff line number Diff line
@@ -41,6 +41,20 @@ def camera_optics(viewer_key):
    orientation = cfg.getfloat(viewer_key, 'orientation', fallback=rotangle)
    return scale, orientation


def camera_frame(viewer_key, name):
    """Return (x0, y0, x1, y1) unbinned px for 'half_frame'/'small_frame'
    from cameras.ini, or None if the key is absent or not configured.
    """
    if not viewer_key:
        return None
    cfg = configparser.ConfigParser()
    cfg.read(_CAMERAS_INI)
    raw = cfg.get(viewer_key, name, fallback=None)
    if raw is None:
        return None
    return tuple(int(v) for v in raw.split(','))

# Telescope

lat = 44.5912  # [°] Latitude North from Greenwich.
+24 −8
Original line number Diff line number Diff line
@@ -28,6 +28,7 @@ from astropy.io import fits

# Custom modules
from .basedevice import BaseDevice
from ..config.constants import camera_frame
from ..utils.image import make_png
from ..utils.logger import log

@@ -420,7 +421,8 @@ class Camera(BaseDevice):
        return [nx, ny]

    def half_frame(self):
        """Set the camera to use a centered 50% sub-frame.
        """Set the camera to use the sub-frame from cameras.ini's ``half_frame``
        (xstart,ystart,xend,yend), or a centered Y-crop (full width) if unconfigured.

        Returns
        -------
@@ -429,13 +431,21 @@ class Camera(BaseDevice):
        """
        h = self._check_connection()
        nx, ny = self._props.nPixelsX, self._props.nPixelsY
        coords = camera_frame(getattr(self, '_viewer_key', None), 'half_frame')
        if coords is None:
            x0, y0, x1, y1 = 0, ny // 4, nx, ny // 4 + ny // 2
        else:
            x0, y0, x1, y1 = coords
        w, hh = x1 - x0, y1 - y0
        if h:
            self._lib.ArtemisSubframe(h, 0, ny // 4, nx, ny // 2)
            self._subframe = [0, ny // 4, nx, ny // 2]
        return [nx, ny // 2]
            self._lib.ArtemisSubframe(h, x0, y0, w, hh)
            self._subframe = [x0, y0, w, hh]
        return [w, hh]

    def small_frame(self):
        """Set the camera to use a fixed 500-row strip (rows 1500-2000).
        """Set the camera to use the sub-frame from cameras.ini's ``small_frame``
        (xstart,ystart,xend,yend), or a fixed 500-row strip (rows 1500-2000)
        if unconfigured.

        Returns
        -------
@@ -444,10 +454,16 @@ class Camera(BaseDevice):
        """
        h = self._check_connection()
        nx = self._props.nPixelsX
        coords = camera_frame(getattr(self, '_viewer_key', None), 'small_frame')
        if coords is None:
            x0, y0, x1, y1 = 0, 1500, nx, 2000
        else:
            x0, y0, x1, y1 = coords
        w, hh = x1 - x0, y1 - y0
        if h:
            self._lib.ArtemisSubframe(h, 0, 1500, nx, 500)
            self._subframe = [0, 1500, nx, 500]
        return [nx, 500]
            self._lib.ArtemisSubframe(h, x0, y0, w, hh)
            self._subframe = [x0, y0, w, hh]
        return [w, hh]

    # --- Properties ---

+103 −37
Original line number Diff line number Diff line
@@ -30,6 +30,11 @@ NOT a persistent camera setting — it's a parameter passed to every Start
Exposure 2 / Start Readout / Readout Line call. The ``binning`` property
below therefore just stores the value locally and applies it on the next
exposure, it never queries or pushes it to the device on its own.
Likewise the imaging sub-frame (``set_window``/``full_frame``/...) is
just kept in ``self._subframe`` and applied to top/left/height/width on
the next Start Exposure 2 / Start Readout, and to pixelStart/pixelLength
on every Readout Line call (vertical windowing via top/height, horizontal
via pixelStart/pixelLength — SBIG has no single persistent ROI setting).
Likewise readout is line-by-line (one Readout Line call per row), not a
single "get frame" call.
"""
@@ -48,6 +53,7 @@ from astropy.io import fits

# Custom modules
from .basedevice import BaseDevice
from ..config.constants import camera_frame
from ..utils.image import make_png
from ..utils.logger import log

@@ -409,6 +415,7 @@ class Camera(STL):
        super().__init__(url)
        self._ccd_info = None       # GetCCDInfoResults0, cached after first connection
        self._binning = 1           # not a device state in the SBIG driver — applied per exposure
        self._subframe = None       # [start_x, start_y, width, height], unbinned; lazily set to full frame
        self._last_matrix = None
        self._last_exptime = None
        self._last_imagetype = None
@@ -445,6 +452,18 @@ class Camera(STL):
        entry = info.readoutInfo[0]  # mode 0 = RM_1X1
        return (entry.width, entry.height)

    def _ensure_subframe(self):
        """Return [start_x, start_y, width, height] (unbinned pixels), defaulting
        to the full sensor area — resolved lazily since that needs a live
        connection to read the native size."""
        if self._subframe is not None:
            return self._subframe
        width, height = self._native_size()
        if width is None:
            return None
        self._subframe = [0, 0, width, height]
        return self._subframe

    # --- Connection ---

    @property
@@ -533,11 +552,13 @@ class Camera(STL):
        if not self._check_connection():
            return

        width, height = self._native_size()
        if width is None:
        subframe = self._ensure_subframe()
        if subframe is None:
            return
        start_x, start_y, sub_width, sub_height = subframe
        b = self._binning
        width, height = width // b, height // b
        left, top = start_x // b, start_y // b
        width, height = sub_width // b, sub_height // b

        is_dark = imagetype in [0, 2, "Dark", "Bias"]
        params = StartExposureParams2(
@@ -546,7 +567,7 @@ class Camera(STL):
            abgState=0,
            openShutter=SC_CLOSE_SHUTTER if is_dark else SC_OPEN_SHUTTER,
            readoutMode=_READOUT_MODE.get(b, 0),
            top=0, left=0, height=height, width=width,
            top=top, left=left, height=height, width=width,
        )
        err = self.put(CC_START_EXPOSURE2, params)
        if err != 0:
@@ -580,7 +601,7 @@ class Camera(STL):
            return
        self._start(duration, imagetype, datetime)

    def _read_frame(self, width, height):
    def _read_frame(self, left, top, width, height):
        """Run Start Readout / Readout Line (x height) / End Readout, return a
        (height, width) float32 array. Blocking."""
        b = self._binning
@@ -588,11 +609,11 @@ class Camera(STL):

        self.put(CC_END_EXPOSURE, EndExposureParams(ccd=CCD_IMAGING))
        self.put(CC_START_READOUT, StartReadoutParams(
            ccd=CCD_IMAGING, readoutMode=mode, top=0, left=0, height=height, width=width))
            ccd=CCD_IMAGING, readoutMode=mode, top=top, left=left, height=height, width=width))

        data = np.empty((height, width), dtype=np.uint16)
        line_params = ReadoutLineParams(
            ccd=CCD_IMAGING, readoutMode=mode, pixelStart=0, pixelLength=width)
            ccd=CCD_IMAGING, readoutMode=mode, pixelStart=left, pixelLength=width)
        row_buf = (ctypes.c_ushort * width)()
        for row in range(height):
            err = self.get(CC_READOUT_LINE, line_params, row_buf)
@@ -615,12 +636,15 @@ class Camera(STL):
        """
        if self.ready != 1:
            return self._last_matrix
        width, height = self._native_size()
        if width is None:
        subframe = self._ensure_subframe()
        if subframe is None:
            return None
        start_x, start_y, sub_width, sub_height = subframe
        b = self._binning
        left, top = start_x // b, start_y // b
        width, height = sub_width // b, sub_height // b
        with self._lock:
            self._last_matrix = self._read_frame(width // b, height // b)
            self._last_matrix = self._read_frame(left, top, width, height)
        return self._last_matrix

    def image(self, vmin=None, vmax=None, color=True):
@@ -678,6 +702,9 @@ class Camera(STL):
            hdr['DATE-OBS'] = (str(self._last_datetime), "(UTC) Date the exposure was started")
        hdr['XBINNING'] = (self._binning, "X binning factor")
        hdr['YBINNING'] = (self._binning, "Y binning factor")
        if self._subframe is not None:
            hdr['XORGSUBF'] = (self._subframe[0], "[px] Subframe X origin (unbinned)")
            hdr['YORGSUBF'] = (self._subframe[1], "[px] Subframe Y origin (unbinned)")
        temp = self.temperature
        if temp is not None:
            hdr['CCD-TEMP'] = (temp, "[C] CCD temperature")
@@ -689,32 +716,59 @@ class Camera(STL):

    # --- Windowing ---
    # The SBIG driver windows the readout per-exposure (top/left/height/width
    # in StartExposureParams2), not as a persistent camera setting — track
    # the desired sub-frame here and apply it on the next start()/_start().
    # in StartExposureParams2/StartReadoutParams, pixelStart/pixelLength in
    # ReadoutLineParams), not as a persistent camera setting — track the
    # desired sub-frame here and apply it on the next start()/_start().

    def set_window(self, start_x, start_y, width, height):
        """Set the imaging sub-frame in unbinned pixel coordinates.

        Not yet applied to individual exposures — see the module note on
        SBIG's per-exposure windowing. Currently a placeholder; full-frame
        readout is always used (see _start()). Extend if windowed échelle
        readout is needed later.
        Applied to the next exposure/readout (see the module note on
        SBIG's per-exposure windowing).
        """
        log.warning("STL: set_window() not implemented — always reads full frame.")
        if self._looping:
            log.error("STL: cannot change window while looping.")
            self.error.append("Camera is looping")
            return
        self._subframe = [int(start_x), int(start_y), int(width), int(height)]

    def full_frame(self):
        """Set the camera to use the full sensor area."""
        return list(self._native_size())
        width, height = self._native_size()
        if width is None:
            return [None, None]
        self.set_window(0, 0, width, height)
        return [width, height]

    def half_frame(self):
        """Not implemented — see set_window()."""
        log.warning("STL: half_frame() not implemented — always reads full frame.")
        return self.full_frame()
        """Set the camera to use the sub-frame from cameras.ini's ``half_frame``
        (xstart,ystart,xend,yend), or a centered 50% sub-frame if unconfigured."""
        width, height = self._native_size()
        if width is None:
            return [None, None]
        coords = camera_frame(getattr(self, '_viewer_key', None), 'half_frame')
        if coords is None:
            x0, y0 = width // 4, height // 4
            x1, y1 = x0 + width // 2, y0 + height // 2
        else:
            x0, y0, x1, y1 = coords
        self.set_window(x0, y0, x1 - x0, y1 - y0)
        return [x1 - x0, y1 - y0]

    def small_frame(self):
        """Not implemented — see set_window()."""
        log.warning("STL: small_frame() not implemented — always reads full frame.")
        return self.full_frame()
        """Set the camera to use the sub-frame from cameras.ini's ``small_frame``
        (xstart,ystart,xend,yend), or a centered 10% sub-frame if unconfigured."""
        width, height = self._native_size()
        if width is None:
            return [None, None]
        coords = camera_frame(getattr(self, '_viewer_key', None), 'small_frame')
        if coords is None:
            x0, y0 = width * 9 // 20, height * 9 // 20
            x1, y1 = x0 + width // 10, y0 + height // 10
        else:
            x0, y0, x1, y1 = coords
        self.set_window(x0, y0, x1 - x0, y1 - y0)
        return [x1 - x0, y1 - y0]

    # --- Properties ---

@@ -842,17 +896,23 @@ class Camera(STL):

    @property
    def xystart(self):
        """list of int : Sub-frame [X, Y] origin — always [0, 0], full frame only."""
        return [0, 0]
        """list of int : Sub-frame [X, Y] origin in binned pixels."""
        subframe = self._ensure_subframe()
        if subframe is None:
            return [None, None]
        start_x, start_y, _, _ = subframe
        b = self._binning
        return [start_x // b, start_y // b]

    @property
    def xyend(self):
        """list of int : Sub-frame [X, Y] end corner in binned pixels."""
        width, height = self._native_size()
        if width is None:
        subframe = self._ensure_subframe()
        if subframe is None:
            return [None, None]
        start_x, start_y, width, height = subframe
        b = self._binning
        return [width // b, height // b]
        return [(start_x + width) // b, (start_y + height) // b]

    @property
    def xrange(self):
@@ -866,22 +926,28 @@ class Camera(STL):

    @property
    def center(self):
        """list of int : Sensor centre [X, Y] in binned pixels."""
        x_end, y_end = self.xyend
        if x_end is None:
        """list of int : Sensor centre [X, Y] in binned pixels (full sensor, not the sub-frame)."""
        x_max, y_max = self.max_range
        if x_max is None:
            return [None, None]
        return [x_end // 2, y_end // 2]
        return [x_max // 2, y_max // 2]

    @property
    def max_range(self):
        """list of int : Full sensor size [width, height] in binned pixels."""
        return self.xyend
        width, height = self._native_size()
        if width is None:
            return [None, None]
        b = self._binning
        return [width // b, height // b]

    @property
    def all(self):
        """dict : Comprehensive camera state matching the STX/Atik driver structure."""
        if not self._check_connection():
            return {}
        xystart, xyend = self.xystart, self.xyend
        size = [xyend[0] - xystart[0], xyend[1] - xystart[1]] if xystart[0] is not None else [None, None]
        return {
            "ambient":     self.ambient,
            "setpoint":    self._setpoint,
@@ -890,9 +956,9 @@ class Camera(STL):
            "fan":         self.fan,
            "binning":     self.binning,
            "max_range":   self.max_range,
            "size":        self.max_range,
            "xystart":     self.xystart,
            "xyend":       self.xyend,
            "size":        size,
            "xystart":     xystart,
            "xyend":       xyend,
            "xrange":      self.xrange,
            "yrange":      self.yrange,
            "center":      self.center,
+19 −10
Original line number Diff line number Diff line
@@ -19,6 +19,7 @@ import requests
from astropy.io import fits

# Other templates
from ..config.constants import camera_frame
from ..utils import check
from ..utils.image import make_png
from ..utils.logger import log
@@ -435,7 +436,8 @@ class Camera(STX):
        return [int(cam_x), int(cam_y)]

    def half_frame(self):
        """Sets the camera to use a centered 50% sub-frame."""
        """Sets the camera to use a sub-frame from cameras.ini's ``half_frame``
        (xstart,ystart,xend,yend), or a centered 50% sub-frame if unconfigured."""

        params = ["CameraXSize", "CameraYSize"]
        try:
@@ -448,16 +450,20 @@ class Camera(STX):
        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
        coords = camera_frame(getattr(self, '_viewer_key', None), 'half_frame')
        if coords is None:
            start_x, start_y = int(cam_x) // 4, int(cam_y) // 4
            x_end, y_end = start_x + int(cam_x) // 2, start_y + int(cam_y) // 2
        else:
            start_x, start_y, x_end, y_end = coords
        width, height = x_end - start_x, y_end - start_y

        self.set_window(start_x, start_y, width, height)
        return [width, height]

    def small_frame(self):
        """Sets the camera to use a centered 10% sub-frame."""
        """Sets the camera to use a sub-frame from cameras.ini's ``small_frame``
        (xstart,ystart,xend,yend), or a centered 10% sub-frame if unconfigured."""

        params = ["CameraXSize", "CameraYSize"]
        try:
@@ -470,10 +476,13 @@ class Camera(STX):
        if self.error:
            return [None, None]

        start_x = int(cam_x) * 9 // 20
        start_y = int(cam_y) * 9 // 20
        width = int(cam_x) // 10
        height = int(cam_y) // 10
        coords = camera_frame(getattr(self, '_viewer_key', None), 'small_frame')
        if coords is None:
            start_x, start_y = int(cam_x) * 9 // 20, int(cam_y) * 9 // 20
            x_end, y_end = start_x + int(cam_x) // 10, start_y + int(cam_y) // 10
        else:
            start_x, start_y, x_end, y_end = coords
        width, height = x_end - start_x, y_end - start_y

        self.set_window(start_x, start_y, width, height)
        return [width, height]