From f12b1e611d006549893140fe7d170d8e1b946be7 Mon Sep 17 00:00:00 2001 From: Mike Langmayr <1809691+mikelangmayr@users.noreply.github.com> Date: Wed, 23 Sep 2026 15:58:32 -0700 Subject: [PATCH] Write the architecture, emulator and Python bindings chapters --- docs/architecture/index.md | 134 +++++++++++++++++++++++++------------ docs/emulator/index.md | 76 +++++++++++++++------ docs/python/index.md | 105 ++++++++++++++++++++++------- 3 files changed, 229 insertions(+), 86 deletions(-) diff --git a/docs/architecture/index.md b/docs/architecture/index.md index 885fc4e..91c0918 100644 --- a/docs/architecture/index.md +++ b/docs/architecture/index.md @@ -1,69 +1,121 @@ # Architecture -:::{note} -Expands in M3 with the thread model, the frame path and the instrument plugin contract in full. The -layering below is accurate now. -::: - -The C++ reference here is narrative and links to source rather than reproducing signatures. +How the pieces fit together, and why acquisition is kept separate from output. The C++ reference +here is narrative and links to source rather than reproducing signatures. ## Layers ``` - client (socket, socksend, or Python module) + client (socket, camerad-socksend, or the Python module) | - Camera::Server ........|...... command dispatch, ports, logging + Camera::Server ....................... one TCP port, command dispatch, logging | - Camera::Interface .....|...... the camera, as commands - | | - | +------ Camera::ExposureMode ..... how one exposure runs + Camera::Interface .................... the camera, expressed as commands + | | | + | | +----- FrameOutput .... FITS file, shared memory + | | + | +-------------------- Camera::ExposureMode .... how one exposure runs | - Camera::Controller ....|...... the wire to the hardware + Camera::Controller ................... the wire to the hardware | Archon (TCP) or ARC (PCIe) ``` `Camera::Server` ({source}`camerad/camera_server.h`) -: Owns the ports and the command dispatch loop. Parses a command line, calls the matching - `Interface` method, and formats the reply. Knows nothing about detectors. +: Owns the listening socket and the command dispatch chain. Parses a command line, calls the + matching `Interface` method, appends `DONE` or `ERROR`, writes the reply back. Knows nothing about + detectors. Each client connection is served on its own thread. `Camera::Interface` ({source}`camerad/camera_interface.h`) -: The abstract camera. Declares one pure virtual per command: `expose`, `exptime`, `bias`, `bin`, - `load_firmware`, `power` and the rest. `ArchonInterface` and `AstroCamInterface` implement it for - the two controller families, and an instrument module subclasses one of those. +: The abstract camera. Declares one pure virtual per command, and owns the config, the + `Camera::Information` for the current exposure, and the list of frame outputs. + `ArchonInterface` and `AstroCamInterface` implement it for the two controller families; an + instrument module subclasses one of those. `Camera::Controller` -: The transport to the hardware, separate from `Interface` so that the command semantics and the - wire protocol can vary independently. +: The transport to the hardware, kept separate from `Interface` so command semantics and wire + protocol vary independently. `Camera::ExposureMode` ({source}`camerad/exposure_modes.h`) -: One way of running an exposure, as a producer/consumer pair: an acquisition thread pulls frames - from the controller, a processing thread turns them into images. A single `expose` command means - different things for a slow CCD readout and a continuously reading H2RG, and this is where that - difference lives. `Interface::select_expose_mode()` picks one. +: One way of running an exposure. `ExposureModeTemplate` is the base each concrete + mode derives from; the template parameter gives a mode typed access to its interface. Each mode + owns its own `Camera::Information` for processed and unprocessed images, and an `ImageProcessor` + for deinterlacing. + +A single `expose` means different things for a slow CCD readout and a continuously reading H2RG, +which is exactly what the exposure mode abstracts. + +## Acquisition: producer and consumer + +An exposure mode is a producer/consumer pair over a bounded queue: + +`image_acquisition_thread()` +: The producer. Pulls frames off the controller as fast as the hardware delivers them and enqueues + them. + +`image_processing_thread()` +: The consumer. Dequeues a frame, runs it through the deinterlacer, and hands the result to the + frame outputs. + +The base class carries the synchronization (`queue_mutex`, `queue_cv`, and the +`is_producer_finished` / `is_producer_error` / `is_consumer_error` flags), so a concrete mode +implements only the parts that differ. In the tracking camera module, for instance, a shared base +owns the queue and the consumer and each subclass implements only the producer. + +Two lifetimes exist. For a counted exposure the consumer is one-shot: `do_expose()` spawns it, +and it terminates once the producer is finished and the queue is drained. In freerun, one producer +and one consumer run for the whole session and `expose` returns immediately, leaving them going +until an abort or a producer error. + +## The frame path + +A processed frame is fanned out synchronously to every configured output by +`Interface::dispatch_frame()`, which simply calls `write()` on each in turn. When the whole exposure +command finishes, `end_exposure()` tells each output, so a multi-frame output such as a data cube +can finalize its file. + +Outputs are built once at startup by `Interface::configure_frame_outputs()`, called from +{source}`camerad/camerad.cpp` for every instrument. It is deliberately not virtual, so no derived +class can silently skip wiring its outputs. + +### Why outputs never block + +`dispatch_frame()` runs on the consumer thread, so anything slow inside an output would stall +acquisition. Each output is therefore required to return promptly, and absorbs the mismatch itself. + +The FITS writer is the clearest case ({source}`utils/fits_writer.h`): `write()` copies the pixels +into a bounded queue and returns, never touching CCfits. A dedicated worker thread drains that queue +to disk. When the queue is full the oldest frame is dropped. Disk is slower than acquisition can be, +and the design chooses to lose a frame rather than apply backpressure to the detector. + +This is also why `FrameOutput::status()` is documented as a snapshot and never a barrier: letting a +caller wait on an output would serialize acquisition behind disk I/O, reintroducing the coupling the +queue exists to remove. + +The shared-memory output makes the opposite trade ({source}`utils/shared_memory_writer.cpp`): it +copies the frame into the stream buffer and calls `ImageStreamIO_UpdateIm`, which posts the +semaphores. Nothing it does depends on a reader, so a reader that cannot keep up misses frames but +never delays the writer. Anything that must be woken per frame should attach there rather than watch +for files. + +See [frame output keys](../configuration/frame-outputs.md) for configuring them. ## Instrument modules -An instrument is a separate git repository, checked out as a submodule under -`camerad/Instruments/` and selected with `-DINSTRUMENT=`. It contributes four things: +An instrument is a separate repository, checked out as a submodule under +`camerad/Instruments/` and selected with `-DINSTRUMENT=`. It contributes: -1. A `.cmake` fragment setting `INSTRUMENT_SOURCES`, which is the whole of the build - integration. -2. An `Interface` subclass, deriving from `ArchonInterface` or `AstroCamInterface`, overriding what - the detector needs and adding instrument commands via `instrument_cmd()` and - `is_instrument_command()`. -3. Its own `ExposureMode` implementations, if the stock ones do not fit. +1. A `.cmake` fragment setting `INSTRUMENT_SOURCES`. That is the whole build integration. +2. An `Interface` subclass deriving from `ArchonInterface` or `AstroCamInterface`, overriding what + the detector needs and adding commands through `instrument_cmd()` and `is_instrument_command()`. +3. Its own `ExposureMode` implementations, where the stock ones do not fit. 4. Optionally a FITS header dictionary and shipped `.cfg` and `.acf` files. -An interface factory function ties it together, so the core builds against the base class and never -names a concrete instrument. +An interface factory ties it together, so the core builds against the base class and never names a +concrete instrument. -See [instruments](../instruments/index.md) for the four that exist, and -{source}`camerad/Instruments/hispec_tracking_camera` for the most complete example. +Instrument commands are checked *before* the base command chain, so an instrument can add commands +and also override a base one of the same name. -## Frame path - -Acquisition is deliberately decoupled from output. The exposure mode's processing thread publishes a -completed frame to every configured [frame output](../configuration/frame-outputs.md); each output -owns its own queue and thread. Nothing an output does can block acquisition, which is why the FITS -writer drops frames instead of applying backpressure. +See [instruments](../instruments/index.md) for the modules that exist, and +{source}`camerad/Instruments/hispec_tracking_camera` for the most complete example. diff --git a/docs/emulator/index.md b/docs/emulator/index.md index dc7ed4d..987216e 100644 --- a/docs/emulator/index.md +++ b/docs/emulator/index.md @@ -1,11 +1,7 @@ # Emulator -`camerad-emulator` stands in for an Archon controller so the server, an instrument module and a -client can all be exercised with no hardware. CI uses it for the end-to-end tests. - -:::{note} -Expands in M3 with the emulated command list and the limits of the emulation. -::: +`camerad-emulator` impersonates an Archon controller over TCP, so the server, an instrument module +and a client can all be exercised with no hardware. CI uses it for the end-to-end tests. There is no ARC emulator. `-DINTERFACE_TYPE=AstroCam` therefore builds none, and the default `-DINTERFACE_TYPE=Archon` builds this one regardless of which `CONTROLLER` was selected. @@ -16,24 +12,60 @@ There is no ARC emulator. `-DINTERFACE_TYPE=AstroCam` therefore builds none, and camerad-emulator -i ``` -It reads `EMULATOR_PORT` and `EMULATOR_SYSTEM` from the same `.cfg` the server uses, so the only -thing that makes a config point at the emulator rather than hardware is `ARCHON_IP` and -`ARCHON_PORT`. `-i generic` suits the shipped test configs. +It reads its keys from the same `.cfg` the server uses, so the only thing separating a test rig from +real hardware is where `ARCHON_IP` and `ARCHON_PORT` point. `-i generic` suits the shipped test +configs. + +```{eval-rst} +.. camerad-config-keys:: Emulator + :widths: 30 70 +``` `EMULATOR_SYSTEM` names a `.system` file describing the module complement to report, which is what -lets the emulator answer `SYSTEM` convincingly for a given instrument. +lets the emulator answer `SYSTEM` convincingly for a given instrument. `config/demo/demo.system` is +the shipped example. + +## What it emulates + +The emulator answers the Archon command set the server actually uses: + +Configuration +: `WCONFIG`, `RCONFIG`, `CLEARCONFIG`, `APPLYALL`, `APPLYMOD`, `APPLYDIO` + +Parameters +: `LOADPARAM`, `PREPPARAM`, `FASTLOADPARAM`, `FASTPREPPARAM` -## What it models +Timing and power +: `RESETTIMING`, `HOLDTIMING`, `RELEASETIMING`, `POWERON`, `POWEROFF` -The emulator answers the Archon command set the server uses: configuration load, parameter writes, -frame status, and pixel delivery on the timing the exposure implies. +Status and data +: `STATUS`, `SYSTEM`, `FRAME`, `TIMER`, `FETCH`, `LOCK`, `POLLON`, `POLLOFF` -Frames carry synthetic pixel data by default, so it validates plumbing, geometry, timing and headers -rather than image quality. Point `EMULATOR_DATADIR` at a directory of real frames to have it serve +It tracks configuration memory and parameters across `WCONFIG` and `LOADPARAM`, so a timing script +loaded by `load` reads back the way the server expects, and it delivers pixels on timing derived +from the exposure and readout parameters rather than instantly. + +## Pixel data + +Frames carry synthetic pixel data by default, which validates plumbing, geometry, timing and headers +rather than image quality. Setting `EMULATOR_DATADIR` to a directory of real frames makes it serve those instead, which is what makes it useful for exercising downstream processing. -Sources are in {source}`emulator`, with the per-detector frame sources alongside -(`generic.h`, `nirc2.h`). +Per-detector frame sources live alongside the emulator ({source}`emulator/generic.h`, +{source}`emulator/nirc2.h`) and are selected by `-i`. They also parse their own keys from the +config, including `READOUT_TIME` and the pixel and row timings that set how fast a readout appears +to proceed. + +:::{note} +Those detector-description keys are a separate namespace from the server configuration keys in the +[configuration reference](../configuration/index.md), even though they are read from the same file. +::: + +## Limits + +The emulator models the command and data protocol, not the controller. It does not reproduce +electrical behaviour, real detector noise, or the failure modes of a misconfigured ACF. A timing +script that is wrong in a way the Archon would reject may still appear to work here. ## In CI @@ -43,5 +75,11 @@ Two workflow jobs run against it on every push and pull request: validates the written header and reads the shared-memory segment back. - A Python module test that drives the same exposure through the `camera_interface` bindings. -Both are in `.github/workflows/emulator-integration.yml` and are the closest thing to a -regression suite for the acquisition path. +Both are in `.github/workflows/emulator-integration.yml` and are the closest thing the project has +to a regression suite for the acquisition path. + +:::{tip} +The frame outputs job is also the most complete worked example of a full setup: it builds with +shared memory enabled, starts the emulator, starts `camerad` against it, drives an exposure with +`camerad-socksend`, then checks both outputs. Read it when a local setup misbehaves. +::: diff --git a/docs/python/index.md b/docs/python/index.md index 7fcd7a0..9543146 100644 --- a/docs/python/index.md +++ b/docs/python/index.md @@ -1,8 +1,8 @@ # Python bindings `camera_interface` lets a Python process own a camera directly: no `camerad` process, no socket, no -text protocol. It performs the same startup the server does, then exposes the command set as -methods. +text protocol. Constructing a `Camera` performs the same one-time setup `camerad` does at startup, +then the command set is available as methods. ```python import camera_interface @@ -15,21 +15,26 @@ camera.exptime("1.5") camera.expose("1") ``` +Use it when the caller *is* the control system, so the text protocol would only be overhead. Keep +`camerad` when several clients share one camera, or when the camera must outlive the client. + ## Installing -`pip install` compiles `camerad` and the module and puts both in the environment, so `import -camera_interface` needs no `PYTHONPATH` and `camerad` is on `PATH`: +`pip install` compiles `camerad` and the module together and puts both in the environment, so +`import camera_interface` needs no `PYTHONPATH` and `camerad` is on `PATH`: ```bash pip install ./camera-interface \ --config-settings=cmake.define.INSTRUMENT=hispec_tracking_camera ``` -Any CMake option can be passed the same way. `CONTROLLER` defaults to `archon` and -`BUILD_PYTHON_MODULE` is forced on. pybind11 comes from the build requirements into an isolated -build environment, and is never installed into the target environment. +Any CMake option can be passed the same way, so +`--config-settings=cmake.define.ENABLE_SHM_OUTPUT=ON` works too. `CONTROLLER` defaults to `archon` +and `BUILD_PYTHON_MODULE` is forced on. pybind11 comes from the build requirements into an isolated +build environment and is never installed into the target environment. -A compiler and the full C++ dependency set must be present wherever `pip install` runs. +A compiler and the full C++ dependency set must be present wherever `pip install` runs, since it +builds from source. :::{important} The controller and instrument are fixed when the wheel is built, and the module is always named @@ -41,32 +46,79 @@ assert camera_interface.instrument_name() == "hispec_tracking_camera" ``` ::: -Alternatively, `cmake -DBUILD_PYTHON_MODULE=ON` builds it in the source tree, where importing it -means putting the build's `lib` directory on `PYTHONPATH`. +Alternatively `cmake -DBUILD_PYTHON_MODULE=ON` builds it in the source tree, where importing means +putting the build's `lib` directory on `PYTHONPATH`. + +## Command coverage + +Every command `camerad` accepts is reachable, through three routes: + +Base commands +: Bound as methods: `open`, `close`, `load`, `power`, `exptime`, `expose`, `abort`, `key`, + `datacube` and the rest. + +Instrument commands +: `instrument_cmd(command, args)`, a deliberate passthrough rather than one binding each, so a build + whose instrument gains a command exposes it with no change to the module. Enumerate with + `instrument_commands()`, or test one with `is_instrument_command()`. + +Controller commands +: `controller_cmd(command, args)` for `mode`, `raw`, `readacf`, `loadtiming`, `heater`, `sensor` and + the other Archon-only commands. + +Only `exit` is missing, since the process belongs to the caller. + +## Errors + +A command that fails raises `RuntimeError`, carrying the server's own error detail where there is +one: + +```python +try: + camera.expose("1") +except RuntimeError as error: + log.error("exposure failed: %s", error) +``` + +Commands that succeed return the command's return string, which is often empty. + +## Concurrency -## Behaviour worth knowing +Blocking commands release the GIL while they run, so a long `expose()` leaves the rest of the +process responsive. For a daemon that is the difference between one exposure stalling and its whole +RPC loop stalling. -Commands release the GIL while they run, so a blocking `expose()` leaves the rest of the process -responsive. For a daemon, that is the difference between one exposure and its whole RPC loop -stalling. +This does not make the object thread-safe. The underlying interface serializes hardware access, but +issuing conflicting commands from several threads is still a logic error. -A failed command raises `RuntimeError`. +## Frame outputs -Every command `camerad` accepts is reachable. Base commands are bound as methods; instrument -commands go through `instrument_cmd()`, enumerable with `instrument_commands()`; controller -commands such as `mode`, `raw`, `readacf`, `loadtiming`, `heater` and `sensor` go through -`controller_cmd()`. Only `exit` is missing, since the process belongs to the caller. +The frame outputs are configured from the `.cfg` exactly as they are for `camerad`, and +`output_status()` reports on them. It returns a list of dicts, one per configured output: -Logging follows `LOG_STDERR` from the `.cfg`, overridable per session with `log_to_stderr=`. The C++ -log always goes to its daily file under `LOGPATH`. +```python +for output in camera.output_status(): + print(output["name"], output["frames_written"], + output["frames_dropped"], output["last_written"]) +``` :::{warning} `output_status()` is a snapshot, never a barrier. The FITS writer queues and drops frames by design, -and nothing in this API lets a caller stall acquisition by waiting on an output. Anything that must -be woken per frame should attach to the -[shared-memory segment](../configuration/frame-outputs.md), which posts semaphores. +and nothing in this API lets a caller stall acquisition by waiting on an output, because that would +serialize acquisition behind disk I/O. Anything that must be woken per frame should attach to the +[shared-memory segment](../configuration/frame-outputs.md), which posts a semaphore per frame. ::: +## Logging + +Logging follows `LOG_STDERR` from the `.cfg`, overridable per session with `log_to_stderr=`: + +```python +camera = camera_interface.Camera("hispecatc.cfg", log_to_stderr=True) +``` + +The C++ log always goes to its daily file under `LOGPATH` regardless. + ## API ```{eval-rst} @@ -88,5 +140,6 @@ be woken per frame should attach to the ## Examples -`python/examples/shm_read_frames.py` is a streaming shared-memory consumer; see -[frame outputs](../configuration/frame-outputs.md). +`python/examples/shm_read_frames.py` is a streaming shared-memory consumer that blocks on the +stream's semaphore and flags frames it missed. See +[frame outputs](../configuration/frame-outputs.md) for what it needs.