From 076ddead672f15e58541dd251a1e6fd91cc40a4c Mon Sep 17 00:00:00 2001 From: David Hale Date: Thu, 8 Oct 2026 13:09:46 -0700 Subject: [PATCH] Reference instrument exercises the instrument override points in CI reference_instrument/ builds its own camerad and module against the surrounding core through FetchContent, pinned to the #47 merge overrides the instrument command, configure_instrument and mode selection reference-instrument-test runs it against the emulator and checks command dispatch, not frame readout is_instrument_command override is temporary until the dispatch fix --- .github/workflows/emulator-integration.yml | 81 ++++++++++ reference_instrument/CMakeLists.txt | 73 +++++++++ reference_instrument/README.md | 55 +++++++ reference_instrument/reference.cfg | 41 +++++ .../reference_exposure_modes.h | 28 ++++ reference_instrument/reference_instrument.cpp | 146 ++++++++++++++++++ reference_instrument/reference_instrument.h | 36 +++++ .../reference_interface_factory.cpp | 20 +++ 8 files changed, 480 insertions(+) create mode 100644 reference_instrument/CMakeLists.txt create mode 100644 reference_instrument/README.md create mode 100644 reference_instrument/reference.cfg create mode 100644 reference_instrument/reference_exposure_modes.h create mode 100644 reference_instrument/reference_instrument.cpp create mode 100644 reference_instrument/reference_instrument.h create mode 100644 reference_instrument/reference_interface_factory.cpp diff --git a/.github/workflows/emulator-integration.yml b/.github/workflows/emulator-integration.yml index 602c761..f2ace2d 100644 --- a/.github/workflows/emulator-integration.yml +++ b/.github/workflows/emulator-integration.yml @@ -87,3 +87,84 @@ jobs: assert ci.instrument_name() == "none", ci.instrument_name() print("imported", ci.__file__) PY + + reference-instrument-test: + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - uses: actions/checkout@v4 + + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + libccfits-dev libcfitsio-dev libcurl4-openssl-dev \ + nlohmann-json3-dev libzmq3-dev \ + libboost-thread-dev libboost-chrono-dev \ + python3-dev python3-pip + python3 -m pip install --upgrade pybind11 + + - name: Build and install zmqpp from source + run: | + git clone --depth 1 https://github.com/zeromq/zmqpp.git /tmp/zmqpp + cd /tmp/zmqpp + mkdir build && cd build + cmake .. + make -j$(nproc) + sudo make install + sudo ldconfig + + - name: Build the emulator and socksend + run: | + mkdir -p ${{github.workspace}}/build + cd ${{github.workspace}}/build + cmake -DCONTROLLER=archon .. + make emulator socksend -j$(nproc) + + - name: Build the reference instrument + run: | + cmake -S reference_instrument -B build-reference + cmake --build build-reference -j$(nproc) + + - name: Reference instrument against the emulator + run: | + cfg=reference_instrument/reference.cfg + + bin/camerad-emulator $cfg -i generic & + emulator=$! + sleep 2 + build-reference/camerad --foreground --config $cfg & + camerad=$! + trap 'kill $camerad $emulator 2>/dev/null || true' EXIT + sleep 3 + + send() { bin/camerad-socksend -p 3231 -t 60 "$1"; } + + # fail unless the reply to $1 matches the pattern $2 + expect() { + resp=$(send "$1") + echo "$1 -> $resp" + echo "$resp" | grep -q "$2" || { echo "expected \"$2\""; exit 1; } + } + + expect "open" "DONE" + expect "load" "DONE" + expect "power on" "DONE" + expect "exptime 0.1" "DONE" + expect "expose 1" "DONE" + + # instrument command, replying with the value configure_instrument() read + expect "refvalue" "^reference-cfg-value DONE" + + # mode selection: the instrument's mode is listed and runs, and the + # parent's modes are still constructed by the parent + expect "exposuremode ?" "REFERENCE" + expect "exposuremode REFERENCE" "^REFERENCE DONE" + expect "expose 1" "DONE" + expect "exposuremode" "^REFERENCE DONE" + expect "exposuremode SINGLE" "^SINGLE DONE" + expect "expose 1" "DONE" + + - name: Reference module import test + run: ctest --test-dir build-reference --output-on-failure diff --git a/reference_instrument/CMakeLists.txt b/reference_instrument/CMakeLists.txt new file mode 100644 index 0000000..5711cbb --- /dev/null +++ b/reference_instrument/CMakeLists.txt @@ -0,0 +1,73 @@ +# ---------------------------------------------------------------------------- +# @file reference_instrument/CMakeLists.txt +# @brief builds the reference instrument's camerad and Python module +# @details Built the way an instrument repository builds: the core is a +# FetchContent dependency and this project declares the final targets +# ---------------------------------------------------------------------------- + +cmake_minimum_required(VERSION 3.24) + +project(reference_instrument LANGUAGES CXX) + +include(FetchContent) + +# A real instrument pins a released version of the core. This copy lives inside +# the core and builds the surrounding tree instead, so the source directory +# below overrides the fetch and the git URL is never contacted. A copy made for +# a new instrument removes this default +if (NOT DEFINED FETCHCONTENT_SOURCE_DIR_CAMERAD) + cmake_path(GET CMAKE_CURRENT_SOURCE_DIR PARENT_PATH FETCHCONTENT_SOURCE_DIR_CAMERAD) +endif() + +FetchContent_Declare(camerad + GIT_REPOSITORY https://github.com/CaltechOpticalObservatories/camera-interface + GIT_TAG d4815d100ad88a1c0773b5dc1dee086479b8d24d + ) + +set(CONTROLLER archon CACHE STRING "") +set(BUILD_PYTHON_MODULE ON CACHE BOOL "") +FetchContent_MakeAvailable(camerad) + +# each final target compiles exactly one interface factory definition +set(REFERENCE_SOURCES + reference_interface_factory.cpp + reference_instrument.cpp + ) + +add_executable(reference_camerad ${REFERENCE_SOURCES}) +target_link_libraries(reference_camerad camerad::daemon camerad::archon) +set_target_properties(reference_camerad PROPERTIES OUTPUT_NAME camerad) + +# pybind11 comes from the Python environment the module is built against, so +# the module and the interpreter that loads it always agree on the ABI +find_package(Python3 COMPONENTS Interpreter Development REQUIRED) +if (NOT pybind11_DIR) + execute_process( + COMMAND ${Python3_EXECUTABLE} -c "import pybind11; print(pybind11.get_cmake_dir())" + OUTPUT_VARIABLE PYBIND11_CMAKE_DIR + OUTPUT_STRIP_TRAILING_WHITESPACE + RESULT_VARIABLE PYBIND11_PROBE_RESULT + ERROR_QUIET + ) + if (NOT PYBIND11_PROBE_RESULT EQUAL 0) + message(FATAL_ERROR + "the module needs pybind11 in ${Python3_EXECUTABLE}. " + "Install it with: ${Python3_EXECUTABLE} -m pip install pybind11") + endif() + list(APPEND CMAKE_PREFIX_PATH ${PYBIND11_CMAKE_DIR}) +endif() +find_package(pybind11 CONFIG REQUIRED) + +pybind11_add_module(camera_interface ${REFERENCE_SOURCES}) +target_link_libraries(camera_interface PRIVATE camerad::python camerad::archon) +target_compile_definitions(camera_interface PRIVATE CAMERAD_INSTRUMENT_NAME="reference") + +# A shared library may leave symbols undefined, so a module missing its +# interface factory definition links and fails only when imported +enable_testing() +add_test(NAME python_import + COMMAND ${Python3_EXECUTABLE} -c "import camera_interface\nassert camera_interface.instrument_name() == 'reference', camera_interface.instrument_name()\nassert camera_interface.controller_name() == 'archon', camera_interface.controller_name()" +) +set_tests_properties(python_import PROPERTIES + ENVIRONMENT PYTHONPATH=$ +) diff --git a/reference_instrument/README.md b/reference_instrument/README.md new file mode 100644 index 0000000..b6c3a41 --- /dev/null +++ b/reference_instrument/README.md @@ -0,0 +1,55 @@ +# Reference instrument + +This is an instrument that belongs to no project. The core's CI builds it the way an instrument repository builds, +and runs it against the emulator, so that each point where an instrument specializes the core is exercised on every change. + +It derives from `ArchonInterface` and overrides the three specialization points, each observable over the socket: + +| Point | Override | Observed by | +|---|---|---| +| Instrument command | `instrument_commands`, `instrument_cmd` | `refvalue` replies with the configured value | +| Instrument configuration | `configure_instrument` | reads `REFERENCE_VALUE` from the cfg, which `refvalue` reports | +| Mode selection | `get_exposure_modes`, `set_exposure_mode` | `exposuremode ?` lists `REFERENCE`; `exposuremode REFERENCE` selects it | + +`is_instrument_command` is also overridden, only because the current server consults it before every command. That +is not the design, and the dispatch fix removes it. + +`REFERENCE` reads out as `SINGLE` and differs only in the type it reports. Any other mode is constructed by +`ArchonInterface`. + +The CI job checks command dispatch, not frame readout: `expose` replies DONE whatever `do_expose()` returns, so a +passing job does not show that a frame was read. + +## Building + +```bash +cmake -S reference_instrument -B build-reference +cmake --build build-reference +ctest --test-dir build-reference +``` + +This produces `camerad` and the `camera_interface` module, each compiled with `reference_interface_factory.cpp`. +`ctest` imports the module and checks `instrument_name()` and `controller_name()`. + +`CMakeLists.txt` declares the core with `FetchContent` at a pinned commit, but inside the core it defaults +`FETCHCONTENT_SOURCE_DIR_CAMERAD` to the enclosing checkout, so it always builds the surrounding tree and never +fetches. Passing `-DFETCHCONTENT_SOURCE_DIR_CAMERAD=` points it at another checkout. + +## Running + +Paths in `reference.cfg` are relative to the working directory, so run from the core's root. The emulator and +`camerad-socksend` come from a top-level build of the core: + +```bash +bin/camerad-emulator reference_instrument/reference.cfg -i generic & +build-reference/camerad --foreground --config reference_instrument/reference.cfg & +bin/camerad-socksend -p 3231 "refvalue" +``` + +A DONE reply to `expose` means the command was accepted, not that a frame was read; check the log for that. + +## Starting a new instrument from a copy + +- Remove the `FETCHCONTENT_SOURCE_DIR_CAMERAD` default, and set `GIT_TAG` to a released version of the core. +- Rename `reference`, `ReferenceInstrument` and `REFERENCE` throughout, including `CAMERAD_INSTRUMENT_NAME`. +- Bring your own ACF and system file; `reference.cfg` uses the core's `config/demo/` ones. diff --git a/reference_instrument/reference.cfg b/reference_instrument/reference.cfg new file mode 100644 index 0000000..1f50460 --- /dev/null +++ b/reference_instrument/reference.cfg @@ -0,0 +1,41 @@ +# configuration for the reference instrument against the emulator, used by +# the core's CI. Paths are relative to the working directory, which must be +# the core's root +# + +DAEMON=no +IMDIR=/tmp +LOGPATH=/tmp +BASENAME=reference +DIRMODE=0077 +TM_ZONE_LOG=local # time zone for log entries only, can be "UTC" or "local" +TM_ZONE=UTC # time zone for everything else, can be "UTC" or "local" +TZ_ENV=PST8PDT,M3.2.0/2,M11.1.0/2 # TZ ENV variable to use when TM_ZONE_* is "local" (follows POSIX standard) + +# +AUTODIR=no +LONGERROR=true +# +NBPORT=3230 # server non-blocking port +BLKPORT=3231 # blocking port +EMULATOR_PORT=3232 +EMULATOR_SYSTEM=config/demo/demo.system +# +# If you have a firewall, you must allow +# multicast address 239.192.13.165, ports 1304-1305. +# +ASYNCGROUP=239.1.1.234 # asynchronous broadcast group +ASYNCPORT=1234 # asynchronous message port + +# the following should not be changed +ARCHON_IP=localhost +ARCHON_PORT=3232 +DEFAULT_FIRMWARE=config/demo/demo.acf +ABORT_PARAM=abort # Archon parameter to trigger an abort +EXPOSE_PARAM=Expose # Archon parameter to trigger exposure +EXPTIME_MSEC_PARAM=exptime # Archon parameter for exposure time in msec +READOUT_TIME=5000 # Timeout waiting for new frame (ms) +WRITE_TAPINFO_TO_FITS=no # Tapinfo (gain, offset) be written to FITS headers {yes|no} + +# read by the reference instrument's configure_instrument() +REFERENCE_VALUE=reference-cfg-value diff --git a/reference_instrument/reference_exposure_modes.h b/reference_instrument/reference_exposure_modes.h new file mode 100644 index 0000000..07a22f3 --- /dev/null +++ b/reference_instrument/reference_exposure_modes.h @@ -0,0 +1,28 @@ +/** + * @file reference_exposure_modes.h + * @brief declares the exposure mode the reference instrument adds + * + */ + +#pragma once + +#include "archon_exposure_modes.h" + +namespace Camera { + + /** + * @namespace exposure modes added to those of ArchonExposureMode + */ + namespace ReferenceExposureMode { + constexpr const char* REFERENCE = "REFERENCE"; + }; + + // Reads out as SINGLE and differs only in the type it reports + class ExposureModeReference : public ExposureModeSingle { + public: + ExposureModeReference(Camera::ArchonInterface* iface) + : ExposureModeSingle(iface) { + type=ReferenceExposureMode::REFERENCE; + } + }; +} diff --git a/reference_instrument/reference_instrument.cpp b/reference_instrument/reference_instrument.cpp new file mode 100644 index 0000000..f5b2d9b --- /dev/null +++ b/reference_instrument/reference_instrument.cpp @@ -0,0 +1,146 @@ +/** + * @file reference_instrument.cpp + * @brief implementation of the reference instrument + * + */ + +#include "reference_instrument.h" +#include "reference_exposure_modes.h" +#include "utilities.h" + +#include + +namespace Camera { + + /***** Camera::ReferenceInstrument::configure_instrument ********************/ + /** + * @brief extract instrument parameters from the config file + * @details The config file has already been read into the Config class. + * + */ + void ReferenceInstrument::configure_instrument() { + const std::string function("Camera::ReferenceInstrument::configure_instrument"); + + for (int row=0; row < this->configfile.n_rows; row++) { + // REFERENCE_VALUE + if (this->configfile.param[row]=="REFERENCE_VALUE") { + this->reference_value = this->configfile.arg[row]; + logwrite(function, "config:"+this->configfile.param[row]+"="+this->configfile.arg[row]); + } + } + } + /***** Camera::ReferenceInstrument::configure_instrument ********************/ + + + /***** Camera::ReferenceInstrument::is_instrument_command *******************/ + /** + * @brief returns true if cmd is one of instrument_commands() + * @details The server consults this before every core command, so + * refvalue is reached only through it. That gate is not the + * design: a name the core does not recognize is meant to pass + * to the controller and then to the instrument with no gate. + * This override goes when the server does that. + * + */ + bool ReferenceInstrument::is_instrument_command(const std::string &cmd) { + const auto commands = this->instrument_commands(); + return std::find(commands.begin(), commands.end(), cmd) != commands.end(); + } + /***** Camera::ReferenceInstrument::is_instrument_command *******************/ + + + /***** Camera::ReferenceInstrument::instrument_commands *********************/ + /** + * @brief names of the commands this instrument handles + * + */ + std::vector ReferenceInstrument::instrument_commands() const { + return { REFERENCE_REFVALUE }; + } + /***** Camera::ReferenceInstrument::instrument_commands *********************/ + + + /***** Camera::ReferenceInstrument::instrument_cmd **************************/ + /** + * @brief dispatcher for reference instrument commands + * @param[in] cmd command + * @param[in] args argument list + * @param[out] retstring return string + * @return ERROR|NO_ERROR|HELP + * + */ + long ReferenceInstrument::instrument_cmd(const std::string &cmd, + const std::string &args, + std::string &retstring) { + if ( cmd == REFERENCE_REFVALUE ) { + return this->refvalue(args, retstring); + } + else { + retstring="unrecognized command"; + return ERROR; + } + } + /***** Camera::ReferenceInstrument::instrument_cmd **************************/ + + + /***** Camera::ReferenceInstrument::refvalue ********************************/ + /** + * @brief report the value configure_instrument() read + * @param[in] args not used + * @param[out] retstring REFERENCE_VALUE + * @return ERROR|NO_ERROR|HELP + * + */ + long ReferenceInstrument::refvalue(const std::string &args, std::string &retstring) { + const std::string function("Camera::ReferenceInstrument::refvalue"); + + // Help + if (args=="?" || args=="help") { + retstring = REFERENCE_REFVALUE; + retstring.append( "\n" ); + retstring.append( " report REFERENCE_VALUE from the config file\n" ); + return HELP; + } + + if (this->reference_value.empty()) { + return fail(function, retstring, "REFERENCE_VALUE not configured"); + } + + retstring = this->reference_value; + return NO_ERROR; + } + /***** Camera::ReferenceInstrument::refvalue ********************************/ + + + /***** Camera::ReferenceInstrument::get_exposure_modes **********************/ + /** + * @brief Archon's exposure modes and the one this instrument adds + * @return vector + * + */ + std::vector ReferenceInstrument::get_exposure_modes() { + auto modes = ArchonInterface::get_exposure_modes(); + modes.push_back(ReferenceExposureMode::REFERENCE); + return modes; + } + /***** Camera::ReferenceInstrument::get_exposure_modes **********************/ + + + /***** Camera::ReferenceInstrument::set_exposure_mode ***********************/ + /** + * @brief constructs the mode this instrument defines, else the parent's + * @param[in] modein string representing the exposure mode + * @param[in] modeargs optional mode-specific args + * @return ERROR|NO_ERROR + * + */ + long ReferenceInstrument::set_exposure_mode(const std::string &modein, const std::vector &modeargs) { + if (caseCompareString(modein, ReferenceExposureMode::REFERENCE)) { + this->exposuremode = std::make_shared(this); + return NO_ERROR; + } + return ArchonInterface::set_exposure_mode(modein, modeargs); + } + /***** Camera::ReferenceInstrument::set_exposure_mode ***********************/ + +} diff --git a/reference_instrument/reference_instrument.h b/reference_instrument/reference_instrument.h new file mode 100644 index 0000000..9655a5d --- /dev/null +++ b/reference_instrument/reference_instrument.h @@ -0,0 +1,36 @@ +/** + * @file reference_instrument.h + * @brief declares the reference instrument + * @details Belongs to no project. Overrides each instrument specialization + * point so that the core's CI exercises them. + * + */ + +#pragma once + +#include "archon_interface.h" + +const std::string REFERENCE_REFVALUE("refvalue"); + +namespace Camera { + + class ReferenceInstrument : public ArchonInterface { + public: + void configure_instrument() override; + + bool is_instrument_command(const std::string &cmd) override; + std::vector instrument_commands() const override; + long instrument_cmd(const std::string &cmd, + const std::string &args, + std::string &retstring) override; + + std::vector get_exposure_modes() override; + long set_exposure_mode(const std::string &modein, const std::vector &modeargs) override; + + private: + std::string reference_value; ///< REFERENCE_VALUE from the config file + + long refvalue(const std::string &args, std::string &retstring); + }; + +} diff --git a/reference_instrument/reference_interface_factory.cpp b/reference_instrument/reference_interface_factory.cpp new file mode 100644 index 0000000..6e7d2ab --- /dev/null +++ b/reference_instrument/reference_interface_factory.cpp @@ -0,0 +1,20 @@ +/** + * @file reference_interface_factory.cpp + * @brief Reference Instrument Interface Factory + * + */ +#include "reference_instrument.h" +#include "camera_interface.h" + +namespace Camera { + /***** Camera::Interface::create ********************************************/ + /** + * @brief factory function to create pointer to ReferenceInstrument + * @return unique_ptr + * + */ + std::unique_ptr Interface::create() { + return std::make_unique(); + } + /***** Camera::Interface::create ********************************************/ +}