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.""" 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)