Skip to content
Open
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
216 changes: 12 additions & 204 deletions daemons/generic/camera
Original file line number Diff line number Diff line change
Expand Up @@ -2,44 +2,18 @@
"""
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.
The generic camera keywords come from camera_daemon.CameraService. 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
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

# 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"

from camera_daemon import CameraService

@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
from hispec.daemon import HispecDaemon # pylint: disable=E0611


class CameraDaemon(HispecDaemon): # pylint: disable=W0223
Expand All @@ -48,49 +22,25 @@ class CameraDaemon(HispecDaemon): # pylint: disable=W0223
def __init__(self):
super().__init__()

self.camera: Optional[Camerad] = None
self.camera = None
self.instrument = None
self.config_file = None
self.nframes = 1
self._isopen = False
self._command_lock = threading.Lock()
self._run = _Run()

### lifecycle
self.service = CameraService(self)

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()

self.service.register_keywords(self.keyword_registry)
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)
self.service.connect()

def on_stop(self, _libby=None):
"""Abort anything running, then drop the connection."""
"""Release the camera."""
self.logger.info("Shutting down %s", self.peer_id)
if self.camera is None:
return
try:
if self._run.state == STATE_EXPOSING:
self.camera.abort()
self.camera.close()
except (RuntimeError, OSError) as exc:
self.logger.error("Error releasing the camera: %s", exc)
self.service.on_stop()

def _load_instrument(self):
"""Import the instrument object named in config, if there is one."""
Expand All @@ -106,148 +56,6 @@ class CameraDaemon(HispecDaemon): # pylint: disable=W0223
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._run.state = STATE_IDLE

### generic keywords

def _register_keywords(self) -> None:
self.keyword_registry.string("state",
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.")
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=INITIALIZE_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.bool("datacube",
getter=self._get_datacube,
setter=self._set_datacube,
description="Write a frame sequence as one FITS cube.")
self.keyword_registry.trigger("expose",
action=self._expose,
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=lambda: self._run.lastframe,
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._run.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 _get_datacube(self) -> bool:
return bool(self._require_camera().datacube())

def _set_datacube(self, value: bool) -> None:
self._require_camera().datacube(bool(value))

def _expose(self) -> None:
"""Start a counted run and return; progress shows in state."""
self._require_camera()
with self._command_lock:
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.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
4 changes: 3 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ dependencies = [
"pyyaml",
# influxdb extra: needed by daemons/generic/keygrabber
"libby[influxdb]@git+https://github.com/CaltechOpticalObservatories/libby.git",
"hardware_device_base@git+https://github.com/COO-Utilities/hardware_device_base"
"hardware_device_base@git+https://github.com/COO-Utilities/hardware_device_base",
"camera_daemon@git+https://github.com/COO-Utilities/camera-daemon.git"
]

[tool.pylint.main]
Expand All @@ -37,6 +38,7 @@ dependencies = [
ignored-modules = [
# Third-party dependencies, not installed in the lint environment
"astropy",
"camera_daemon",
"libby",
"matplotlib",
"numpy",
Expand Down
Loading