From 17daaf0644fd8f3574e3a0f4ebdee6f97054133b Mon Sep 17 00:00:00 2001 From: Mike Langmayr <1809691+mikelangmayr@users.noreply.github.com> Date: Wed, 23 Sep 2026 13:17:13 -0700 Subject: [PATCH 1/3] Add a generic camera daemon with a per-instrument keyword seam --- config/hsfei/hsfei_atcam.yaml | 14 ++ daemons/generic/camera | 221 ++++++++++++++++++ src/hispec/driver/tracking_camera/keywords.py | 63 +++++ 3 files changed, 298 insertions(+) create mode 100644 config/hsfei/hsfei_atcam.yaml create mode 100755 daemons/generic/camera create mode 100644 src/hispec/driver/tracking_camera/keywords.py diff --git a/config/hsfei/hsfei_atcam.yaml b/config/hsfei/hsfei_atcam.yaml new file mode 100644 index 0000000..8dd0df9 --- /dev/null +++ b/config/hsfei/hsfei_atcam.yaml @@ -0,0 +1,14 @@ +# Acquisition and tracking camera — H2RG on an Archon, driven in process +# through the camera_interface extension +# +# Usage: +# daemons/generic/camera -c config/hsfei/hsfei_atcam.yaml + +peer_id: atcam +group_id: hsfei + +camera: + # camerad .cfg describing the Archon, the ACF and the frame outputs + config_file: /usr/local/etc/hispecatc.cfg + instrument: hispec.driver.tracking_camera.keywords.Instrument + nframes: 1 diff --git a/daemons/generic/camera b/daemons/generic/camera new file mode 100755 index 0000000..9ef7101 --- /dev/null +++ b/daemons/generic/camera @@ -0,0 +1,221 @@ +#!/usr/bin/env python3 +""" +Camera daemon for camerad instruments. + +Serves the keywords every camerad camera has. Anything instrument-specific +comes from an instrument object named in config, which registers its own +keywords, so one script runs every camera. +""" + +import argparse +import importlib +import sys +import threading +from typing import Any, List, Optional + +from hispec.daemon import HispecDaemon # pylint: disable=E0611 +from hispec.driver.pycamerad import Camerad, ModuleNotAvailable # pylint: disable=E0611 + +# An exposure blocks the RPC, so the keyword needs a timeout longer than one +EXPOSE_TIMEOUT_S = 3600.0 + +STATE_NOCAMERA = "nocamera" +STATE_IDLE = "idle" +STATE_EXPOSING = "exposing" +STATE_ERROR = "error" + + +class CameraDaemon(HispecDaemon): # pylint: disable=W0223 + """Daemon for one camerad camera, generic across instruments.""" + + def __init__(self): + super().__init__() + + self.camera: Optional[Camerad] = None + self.instrument = None + self.config_file = None + self.nframes = 1 + self._state = STATE_NOCAMERA + self._isopen = False + self._command_lock = threading.Lock() + + ### lifecycle + + def on_start(self, _libby): + """Register keywords, then try to reach the camera.""" + self.config_file = self.get_config("camera.config_file") + self.nframes = int(self.get_config("camera.nframes", 1)) + + self.instrument = self._load_instrument() + self._register_keywords() + if self.instrument is not None: + self.instrument.register_keywords(self.keyword_registry) + + self.logger.info("Starting %s", self.peer_id) + + if not self.config_file: + self.logger.error("camera.config_file is not set") + return + + try: + self._build_camera() + self.logger.info("Camera built from %s", self.config_file) + except (ModuleNotAvailable, RuntimeError, OSError) as exc: + self.logger.error("Camera unavailable: %s", exc) + + def on_stop(self, _libby=None): + """Abort anything running, then drop the connection.""" + self.logger.info("Shutting down %s", self.peer_id) + if self.camera is None: + return + try: + if self._state == STATE_EXPOSING: + self.camera.abort() + self.camera.close() + except (RuntimeError, OSError) as exc: + self.logger.error("Error releasing the camera: %s", exc) + + def _load_instrument(self): + """Import the instrument object named in config, if there is one.""" + path = self.get_config("camera.instrument") + if not path: + self.logger.info("No instrument object configured; generic keywords only") + return None + module_name, _, attribute = path.rpartition(".") + try: + module = importlib.import_module(module_name) + return getattr(module, attribute)(self) + except (ImportError, AttributeError, TypeError) as exc: + self.logger.error("Instrument object %s unusable: %s", path, exc) + return None + + def _build_camera(self) -> None: + camera_class = getattr(self.instrument, "camera_class", Camerad) + self.camera = camera_class.from_config(self.config_file) + self._state = STATE_IDLE + + ### generic keywords + + def _register_keywords(self) -> None: + self.keyword_registry.string("state", + getter=lambda: self._state, + description="nocamera, idle, exposing or error.") + self.keyword_registry.bool("isconnected", + getter=lambda: self.camera is not None, + description="Camera object exists for this config.") + self.keyword_registry.bool("isopen", + getter=lambda: self._isopen, + description="Controller connection has been opened.") + self.keyword_registry.bool("ispowered", + getter=self._is_powered, + setter=self._set_powered, + description="Detector power is on.") + self.keyword_registry.trigger("initialize", + action=self._initialize, + timeout_s=EXPOSE_TIMEOUT_S, + description="Open, load firmware and power on.") + self.keyword_registry.float("exptime", + getter=self._get_exptime, + setter=self._set_exptime, + units="s", + description="Exposure time.") + self.keyword_registry.int("nframes", + getter=lambda: self.nframes, + setter=self._set_nframes, + validator=self._check_nframes, + description="Frame count used by expose.") + self.keyword_registry.trigger("expose", + action=self._expose, + timeout_s=EXPOSE_TIMEOUT_S, + description="Take nframes exposures.") + self.keyword_registry.trigger("abort", + action=self._abort, + description="Abort the running exposure.") + self.keyword_registry.string("lastframe", + getter=self._last_frame, + description="Path of the last file written.") + + def _require_camera(self) -> Camerad: + if self.camera is None: + raise RuntimeError("no camera; check camera.config_file and the installed module") + return self.camera + + def _is_powered(self) -> bool: + return bool(self._require_camera().power()) + + def _set_powered(self, value: bool) -> None: + self._require_camera().power(bool(value)) + + def _initialize(self) -> None: + """Run the camera's startup sequence, then the instrument's own.""" + camera = self._require_camera() + with self._command_lock: + camera.initialize() + self._isopen = True + self._state = STATE_IDLE + + def _get_exptime(self) -> float: + return float(self._require_camera().exptime()) + + def _set_exptime(self, value: float) -> None: + self._require_camera().exptime(float(value)) + + def _check_nframes(self, value: Any) -> Optional[str]: + try: + if int(value) < 1: + return "nframes must be at least 1" + except (TypeError, ValueError): + return "nframes must be an integer" + return None + + def _set_nframes(self, value: int) -> None: + self.nframes = int(value) + + def _expose(self) -> None: + camera = self._require_camera() + with self._command_lock: + self._state = STATE_EXPOSING + try: + camera.expose(self.nframes) + except RuntimeError: + self._state = STATE_ERROR + raise + finally: + if self._state == STATE_EXPOSING: + self._state = STATE_IDLE + + def _abort(self) -> None: + self._require_camera().abort() + + def _last_frame(self) -> str: + written: List[str] = [output.last_written + for output in self._require_camera().output_status() + if output.last_written] + return written[-1] if written else "" + + +def main(): + """Main entry point for the daemon.""" + parser = argparse.ArgumentParser(description="Camera daemon for camerad instruments") + parser.add_argument("-c", "--config", type=str, + help="Path to config file (YAML or JSON)") + + args = parser.parse_args() + + if not args.config: + print("--config is required", file=sys.stderr) + sys.exit(2) + + try: + daemon = CameraDaemon.from_config_file(args.config) + daemon.serve() + except KeyboardInterrupt: + print("\nDaemon interrupted by user") + sys.exit(0) + except Exception as exc: # pylint: disable=W0718 + print(f"Error running daemon: {exc}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/src/hispec/driver/tracking_camera/keywords.py b/src/hispec/driver/tracking_camera/keywords.py new file mode 100644 index 0000000..75f8229 --- /dev/null +++ b/src/hispec/driver/tracking_camera/keywords.py @@ -0,0 +1,63 @@ +"""Tracking camera keywords, for the generic camera daemon to register. + +Kept apart from `camera.py` so the driver itself stays usable without libby. +""" + +from __future__ import annotations + +from typing import Any, Optional + +from libby import KeywordRegistry + +from .camera import ReadMode, TrackingCamera + + +class Instrument: + """The tracking camera's own keywords, over the generic camera ones.""" + + camera_class = TrackingCamera + + def __init__(self, daemon: Any) -> None: + self.daemon = daemon + + @property + def camera(self) -> TrackingCamera: + """Return the daemon's camera, or explain that there is not one.""" + if self.daemon.camera is None: + raise RuntimeError("no camera; check camera.config_file and the installed module") + return self.daemon.camera + + def register_keywords(self, registry: KeywordRegistry) -> None: + """Add the tracking camera's keywords to the given registry.""" + registry.string("readmode", + getter=self._get_readmode, + setter=self._set_readmode, + validator=self._check_readmode, + description="Detector readout mode: " + f"{', '.join(m.value for m in ReadMode)}.") + registry.bool("debug", + setter=self._set_debug, + description="Per-frame debug logging.") + registry.bool("takestats", + setter=self._set_take_stats, + description="Per-frame timing statistics.") + + def _get_readmode(self) -> str: + mode = self.camera.readmode() + return mode.value if mode else "" + + def _set_readmode(self, value: str) -> None: + self.camera.set_readmode(ReadMode(value)) + + def _set_debug(self, value: bool) -> None: + self.camera.set_debug(value) + + def _set_take_stats(self, value: bool) -> None: + self.camera.set_take_stats(value) + + def _check_readmode(self, value: Any) -> Optional[str]: + try: + ReadMode(value) + except ValueError: + return f"readmode must be one of {', '.join(m.value for m in ReadMode)}" + return None From b029393bfaf9d356bbb201a4b25db47bd83f660e Mon Sep 17 00:00:00 2001 From: Mike Langmayr <1809691+mikelangmayr@users.noreply.github.com> Date: Wed, 23 Sep 2026 16:26:24 -0700 Subject: [PATCH 2/3] Add subframe mode and geometry keywords to the tracking camera --- src/hispec/driver/tracking_camera/keywords.py | 119 +++++++++++++++++- 1 file changed, 117 insertions(+), 2 deletions(-) diff --git a/src/hispec/driver/tracking_camera/keywords.py b/src/hispec/driver/tracking_camera/keywords.py index 75f8229..6c832a8 100644 --- a/src/hispec/driver/tracking_camera/keywords.py +++ b/src/hispec/driver/tracking_camera/keywords.py @@ -5,12 +5,22 @@ from __future__ import annotations -from typing import Any, Optional +import threading +from typing import Any, Optional, Tuple from libby import KeywordRegistry from .camera import ReadMode, TrackingCamera +ROI = "roi" +GUIDING = "guiding" +SUBFRAME_MODES = (ROI, GUIDING) + +# The centred-ROI command caps width at half the detector: the horizontal +# argument is a per-tap pixel count, not a detector-wide one +MAX_ROI_HEIGHT = 2048 +MAX_ROI_WIDTH = 1024 + class Instrument: """The tracking camera's own keywords, over the generic camera ones.""" @@ -19,6 +29,13 @@ class Instrument: def __init__(self, daemon: Any) -> None: self.daemon = daemon + self._subframemode = ROI + self._readmode = ReadMode.RX + self._size: Optional[Tuple[int, int]] = None + self._bounds: Optional[Tuple[int, int, int, int]] = None + # subframemode and readmode both re-derive the ACF mode, so the whole + # sequence is applied under one lock rather than interleaved + self._apply_lock = threading.Lock() @property def camera(self) -> TrackingCamera: @@ -35,6 +52,31 @@ def register_keywords(self, registry: KeywordRegistry) -> None: validator=self._check_readmode, description="Detector readout mode: " f"{', '.join(m.value for m in ReadMode)}.") + registry.string("subframemode", + getter=lambda: self._subframemode, + setter=self._set_subframemode, + validator=self._check_subframemode, + description=f"Subframe mode: {' or '.join(SUBFRAME_MODES)}.") + for name in ("y0", "y1", "x0", "x1"): + registry.int(name, + getter=self._bound_getter(name), + setter=self._bound_setter(name), + units="pixel", + description=f"Guiding ROI {name}, inclusive; " + f"writable in {GUIDING} mode.") + registry.int("subframeheight", + getter=lambda: self._geometry().height, + setter=self._size_setter("height"), + units="pixel", + description=f"Centred ROI height; writable in {ROI} mode.") + registry.int("subframewidth", + getter=lambda: self._geometry().width, + setter=self._size_setter("width"), + units="pixel", + description=f"Centred ROI width; writable in {ROI} mode.") + registry.string("geometrystatus", + getter=self._geometry_status, + description="How the geometry in force was derived.") registry.bool("debug", setter=self._set_debug, description="Per-frame debug logging.") @@ -47,7 +89,80 @@ def _get_readmode(self) -> str: return mode.value if mode else "" def _set_readmode(self, value: str) -> None: - self.camera.set_readmode(ReadMode(value)) + self._readmode = ReadMode(value) + self._apply() + + ### subframe mode and geometry + + def _apply(self) -> None: + """Re-derive the ACF mode and apply the whole sequence. + + The camera mode goes first: it reloads the mode's parameters from the + ACF, which would otherwise undo the readout mode and the ROI. + """ + camera = self.camera + with self._apply_lock: + if self._subframemode == GUIDING: + camera.set_camera_mode("GUIDING") + camera.set_readmode(self._readmode) + camera.set_window(True) + if self._bounds is not None: + camera.set_guiding_roi(*self._bounds) + else: + camera.set_camera_mode(self._readmode.value.upper()) + camera.set_window(False) + if self._size is not None: + camera.set_centred_roi(*self._size) + + def _check_subframemode(self, value: Any) -> Optional[str]: + if str(value) not in SUBFRAME_MODES: + return f"subframemode must be one of {', '.join(SUBFRAME_MODES)}" + return None + + def _set_subframemode(self, value: str) -> None: + self._subframemode = str(value) + self._apply() + + def _geometry(self): + return self.camera.geometry() + + def _geometry_status(self) -> str: + if self._subframemode == GUIDING: + return "windowed bounds as written" if self._bounds else "windowed, bounds not set" + return "centred on the detector from the size" if self._size else "mode default" + + def _bound_getter(self, name: str): + return lambda: getattr(self._geometry(), name) + + def _bound_setter(self, name: str): + def setter(value: int) -> None: + if self._subframemode != GUIDING: + raise RuntimeError( + f"{name} is writable in {GUIDING} mode only; " + f"in {self._subframemode} mode set subframeheight and subframewidth") + geometry = self._geometry() + bounds = {axis: getattr(geometry, axis) for axis in ("y0", "y1", "x0", "x1")} + bounds[name] = int(value) + self._bounds = (bounds["y0"], bounds["y1"], bounds["x0"], bounds["x1"]) + self._apply() + return setter + + def _size_setter(self, name: str): + def setter(value: int) -> None: + if self._subframemode != ROI: + raise RuntimeError( + f"subframe{name} is writable in {ROI} mode only; " + f"in {self._subframemode} mode set y0, y1, x0 and x1") + # The unwritten dimension comes from the last request, not from + # geometry(), whose width is detector-wide and so out of range here + height, width = self._size or (MAX_ROI_HEIGHT, MAX_ROI_WIDTH) + size = {"height": height, "width": width} + size[name] = int(value) + self._size = (size["height"], size["width"]) + self._apply() + return setter + + ### diagnostics def _set_debug(self, value: bool) -> None: self.camera.set_debug(value) From bd318537607808d40499afe87e76088412be0103 Mon Sep 17 00:00:00 2001 From: Mike Langmayr <1809691+mikelangmayr@users.noreply.github.com> Date: Thu, 24 Sep 2026 11:42:54 -0700 Subject: [PATCH 3/3] Run counted exposures on a worker thread with abort and state tracking --- daemons/generic/camera | 101 +++++++++++++++++++++++++++++------------ 1 file changed, 73 insertions(+), 28 deletions(-) diff --git a/daemons/generic/camera b/daemons/generic/camera index 9ef7101..49835da 100755 --- a/daemons/generic/camera +++ b/daemons/generic/camera @@ -11,20 +11,37 @@ import argparse import importlib import sys import threading +import time +from dataclasses import dataclass from typing import Any, List, Optional from hispec.daemon import HispecDaemon # pylint: disable=E0611 from hispec.driver.pycamerad import Camerad, ModuleNotAvailable # pylint: disable=E0611 -# An exposure blocks the RPC, so the keyword needs a timeout longer than one -EXPOSE_TIMEOUT_S = 3600.0 +# initialize talks to the controller, so it needs longer than an RPC default +INITIALIZE_TIMEOUT_S = 300.0 + +# The writer is asynchronous, so the path appears shortly after the run ends +LASTFRAME_WAIT_S = 5.0 +LASTFRAME_POLL_S = 0.05 STATE_NOCAMERA = "nocamera" STATE_IDLE = "idle" STATE_EXPOSING = "exposing" +STATE_ABORTING = "aborting" STATE_ERROR = "error" +@dataclass +class _Run: + """What the current or most recent exposure run is doing.""" + + state: str = STATE_NOCAMERA + worker: Optional[threading.Thread] = None + lastframe: str = "" + aborting: bool = False + + class CameraDaemon(HispecDaemon): # pylint: disable=W0223 """Daemon for one camerad camera, generic across instruments.""" @@ -35,9 +52,9 @@ class CameraDaemon(HispecDaemon): # pylint: disable=W0223 self.instrument = None self.config_file = None self.nframes = 1 - self._state = STATE_NOCAMERA self._isopen = False self._command_lock = threading.Lock() + self._run = _Run() ### lifecycle @@ -69,7 +86,7 @@ class CameraDaemon(HispecDaemon): # pylint: disable=W0223 if self.camera is None: return try: - if self._state == STATE_EXPOSING: + if self._run.state == STATE_EXPOSING: self.camera.abort() self.camera.close() except (RuntimeError, OSError) as exc: @@ -92,14 +109,14 @@ class CameraDaemon(HispecDaemon): # pylint: disable=W0223 def _build_camera(self) -> None: camera_class = getattr(self.instrument, "camera_class", Camerad) self.camera = camera_class.from_config(self.config_file) - self._state = STATE_IDLE + self._run.state = STATE_IDLE ### generic keywords def _register_keywords(self) -> None: self.keyword_registry.string("state", - getter=lambda: self._state, - description="nocamera, idle, exposing or error.") + getter=lambda: self._run.state, + description="nocamera, idle, exposing, aborting or error.") self.keyword_registry.bool("isconnected", getter=lambda: self.camera is not None, description="Camera object exists for this config.") @@ -112,7 +129,7 @@ class CameraDaemon(HispecDaemon): # pylint: disable=W0223 description="Detector power is on.") self.keyword_registry.trigger("initialize", action=self._initialize, - timeout_s=EXPOSE_TIMEOUT_S, + timeout_s=INITIALIZE_TIMEOUT_S, description="Open, load firmware and power on.") self.keyword_registry.float("exptime", getter=self._get_exptime, @@ -126,13 +143,12 @@ class CameraDaemon(HispecDaemon): # pylint: disable=W0223 description="Frame count used by expose.") self.keyword_registry.trigger("expose", action=self._expose, - timeout_s=EXPOSE_TIMEOUT_S, - description="Take nframes exposures.") + description="Start nframes exposures; watch state.") self.keyword_registry.trigger("abort", action=self._abort, description="Abort the running exposure.") self.keyword_registry.string("lastframe", - getter=self._last_frame, + getter=lambda: self._run.lastframe, description="Path of the last file written.") def _require_camera(self) -> Camerad: @@ -152,7 +168,7 @@ class CameraDaemon(HispecDaemon): # pylint: disable=W0223 with self._command_lock: camera.initialize() self._isopen = True - self._state = STATE_IDLE + self._run.state = STATE_IDLE def _get_exptime(self) -> float: return float(self._require_camera().exptime()) @@ -172,27 +188,56 @@ class CameraDaemon(HispecDaemon): # pylint: disable=W0223 self.nframes = int(value) def _expose(self) -> None: - camera = self._require_camera() + """Start a counted run and return; progress shows in state.""" + self._require_camera() with self._command_lock: - self._state = STATE_EXPOSING - try: - camera.expose(self.nframes) - except RuntimeError: - self._state = STATE_ERROR - raise - finally: - if self._state == STATE_EXPOSING: - self._state = STATE_IDLE - - def _abort(self) -> None: - self._require_camera().abort() - - def _last_frame(self) -> str: + if self._run.worker is not None and self._run.worker.is_alive(): + raise RuntimeError(f"already {self._run.state}; abort first") + self._run.aborting = False + self._run.state = STATE_EXPOSING + self._run.worker = threading.Thread(target=self._run_exposure, + args=(self.nframes,), + name="expose", daemon=True) + self._run.worker.start() + + def _run_exposure(self, count: int) -> None: + try: + self.camera.expose(count) + except RuntimeError as exc: + # An aborted run fails too, but on request, so it is not an error + if self._run.aborting: + self.logger.info("Exposure aborted") + self._run.state = STATE_IDLE + return + self.logger.error("Exposure failed: %s", exc) + self._run.state = STATE_ERROR + return + self._run.lastframe = self._await_written(self._run.lastframe) + self._run.state = STATE_IDLE + + def _await_written(self, previous: str) -> str: + """Wait briefly for the writer to report a new path.""" + deadline = time.monotonic() + LASTFRAME_WAIT_S + while time.monotonic() < deadline: + current = self._written_path() + if current and current != previous: + return current + time.sleep(LASTFRAME_POLL_S) + return previous + + def _written_path(self) -> str: written: List[str] = [output.last_written - for output in self._require_camera().output_status() + for output in self.camera.output_status() if output.last_written] return written[-1] if written else "" + def _abort(self) -> None: + camera = self._require_camera() + if self._run.state == STATE_EXPOSING: + self._run.aborting = True + self._run.state = STATE_ABORTING + camera.abort() + def main(): """Main entry point for the daemon."""