Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 73 additions & 28 deletions daemons/generic/camera
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand All @@ -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

Expand Down Expand Up @@ -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:
Expand All @@ -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.")
Expand All @@ -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,
Expand All @@ -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:
Expand All @@ -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())
Expand All @@ -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."""
Expand Down
119 changes: 117 additions & 2 deletions src/hispec/driver/tracking_camera/keywords.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand All @@ -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:
Expand All @@ -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.")
Expand All @@ -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)
Expand Down
Loading