Example 2: Testing fastcs-gatan over EPICS with aioca#

aioca is a Python (asyncio) EPICS Channel Access client: caget, caput and camonitor as Python functions, so it can drive fastcs-gatan’s PVs where the EPICS command-line tools are not available. It is not a fastcs-gatan dependency; install it with uv pip install aioca. PV names are explained in Example 1.

Setup#

Start the mock server and the driver with - epicsca: {} in the transport: list of gatan.yaml, then point the client at the local IOC:

export EPICS_CA_ADDR_LIST=127.0.0.1 EPICS_CA_AUTO_ADDR_LIST=NO

Interactive#

python -m asyncio gives a Python prompt where await works directly:

>>> from aioca import caget, caput, camonitor, DBR_CHAR_STR, FORMAT_CTRL, FORMAT_TIME
>>> P = "GATAN:Acquisition:"
>>> await caget("GATAN:DmVersion")
50302

The main calls#

# Numbers: write the setting PV, read back the _RBV PV
await caput(P + "DoseFracNumFrames", 20, wait=True)   # wait=True: wait until processed
await caget(P + "DoseFracNumFrames_RBV")               # 20

# Text PVs are character arrays: use DBR_CHAR_STR both ways (like caput -S)
await caput(P + "SaveDir", "/tmp/frames", datatype=DBR_CHAR_STR, wait=True)
await caget(P + "SavedPath", datatype=DBR_CHAR_STR)

# Choice PVs: the value is an index; FORMAT_CTRL adds the option names
st = await caget(P + "DoseFracState", format=FORMAT_CTRL)
st.enums[int(st)]                                      # 'DONE'

# Alarm status and timestamp: FORMAT_TIME
cmd = await caget(P + "AcquireDoseFractionated", format=FORMAT_TIME)
cmd.severity, cmd.timestamp                            # 2 = MAJOR alarm (last command failed)

# Several PVs at once: pass a list, get a list back
await caget([P + "FramesSaved", P + "SaveError"])

# Commands: write 1 to the command PV
await caput(P + "AcquireDoseFractionated", 1, wait=True)

# Watch changes: the callback runs on every update; close when finished
m = camonitor(P + "DoseFracState", lambda v: print(v.enums[int(v)]), format=FORMAT_CTRL)
m.close()

A command PV is never reset to 0 after a write, but every write of 1 runs the command again. Watch the status PVs (DoseFracBusy, DoseFracState), not the command PV.

A dose-fractionated exposure on the Talos K3#

examples/dose_fractionation_aioca.py is the EPICS version of examples/dose_fractionation.py. Its defaults are the parameters of GatanDetectorClient’s session_runner.sh step 26, which worked on the real K3 (counting mode, binned frames, 5760 × 4092, gain-normalised, 0.1 s at 0.013316 s per frame = 8 frames, saved to X:\DoseFractions\kwd30084 as fmt_mrc).

python -m fastcs_gatan run Talos_K3_ca.yaml          # terminal 1: driver with EPICS CA
export EPICS_CA_ADDR_LIST=127.0.0.1 EPICS_CA_AUTO_ADDR_LIST=NO
python examples/dose_fractionation_aioca.py          # terminal 2

The script prints the settings and asks for ACQUIRE (--yes skips this). It then writes the setting PVs, runs the Camera:Select and Acquisition:ApplyReadMode commands, starts Acquisition:AcquireDoseFractionated, and watches Acquisition:DoseFracState with camonitor until it reports DONE or FAILED. A command that fails (its PV goes into MAJOR alarm) aborts the script before the exposure starts.

On a recording mock server it sends the plugin the same requests as step 26, byte for byte, from SetCurrentCamera to GetFileSaveResult; it then also sends SetK2Parameters2 to switch frame saving off again.

A single exposure on the Talos K3#

examples/single_exposure_aioca.py takes one exposure and has the driver save the transferred frame as an MRC file on the driver’s machine. Its defaults are the parameters of GatanDetectorClient’s session_runner.sh step 6, the first frame that worked on the real K3 (K3 linear, scaling 0.25, gain-normalised, 1024 × 1024, 0.1 s, beam-blanked).

python -m fastcs_gatan run Talos_K3_ca.yaml          # terminal 1: driver with EPICS CA
export EPICS_CA_ADDR_LIST=127.0.0.1 EPICS_CA_AUTO_ADDR_LIST=NO
python examples/single_exposure_aioca.py             # terminal 2

The script asks for ACQUIRE (--yes skips this), writes the setting PVs and runs Camera:Select, Acquisition:ApplyReadMode and Acquisition:ApplyK2Parameters. It then writes 1 to Acquisition:AcquireImage and waits for the put to complete, which happens once the frame is transferred and saved (up to --timeout, default 120 s).

The frame goes to <LocalSaveDir>/<LocalSaveRoot>_NNNN.mrc (default frames/single_0001.mrc, then _0002, …; never overwritten). The driver writes the bytes from the socket unchanged after an MRC header, marked as 16-bit signed like GatanDetectorClient’s MRC files. The script makes --save-dir absolute, since a relative path would be resolved in the driver’s working directory, and prints Acquisition:LocalSavedPath at the end.

On a recording mock server it sends the plugin the same requests as step 6, byte for byte: SetCurrentCamera, SetReadMode, SetK2Parameters2, GetAcquiredImage.

A pytest system test#

This test starts the mock server and the driver as subprocesses and talks to them only through PVs. It checks the status PVs, runs a full dose-fractionated exposure (watched with camonitor), and checks that a failing command puts the command PV into MAJOR alarm. Save it as e.g. tests/system/test_ca_system.py.

"""System test: fastcs-gatan over EPICS CA, against the mock GatanSocket server."""

import asyncio
import os
import subprocess
import sys
import time
from pathlib import Path

import pytest
import pytest_asyncio
from aioca import (
    DBR_CHAR_STR,
    FORMAT_CTRL,
    FORMAT_TIME,
    caget,
    camonitor,
    caput,
    purge_channel_caches,
)

MOCK_DIR = "/workspaces/GatanDetectorClient-0.1.0"
P = "GATAN:Acquisition:"

CONFIG = """\
controllers:
  - id: GATAN
    type: fastcs_gatan.GatanController
    ip: 127.0.0.1
    port: {port}
transport:
  - epicsca: {{}}
"""


@pytest.fixture(scope="module")
def ioc(tmp_path_factory):
    """Start the mock server and the driver (as an EPICS IOC) in subprocesses."""
    os.environ["EPICS_CA_ADDR_LIST"] = "127.0.0.1"
    os.environ["EPICS_CA_AUTO_ADDR_LIST"] = "NO"
    port = 48891  # not 48890, so it doesn't clash with a mock you started by hand
    config = tmp_path_factory.mktemp("cfg") / "gatan.yaml"
    config.write_text(CONFIG.format(port=port))

    mock = subprocess.Popen(
        [sys.executable, "-m", "gatan_client.mock_server", "--k3", "--port", str(port)],
        cwd=MOCK_DIR,
    )
    time.sleep(1)
    driver = subprocess.Popen(
        [sys.executable, "-m", "fastcs_gatan", "run", str(config)],
        stdin=subprocess.PIPE,  # keeps the IPython shell waiting instead of exiting
    )
    try:
        yield
    finally:
        driver.kill()
        mock.kill()
        assert driver.stdin is not None
        driver.stdin.close()
        driver.wait()
        mock.wait()


@pytest_asyncio.fixture(autouse=True)
async def close_ca_channels():
    """aioca caches channels per event loop, and each test gets its own loop.
    Drop them while this test's loop is still running, or they fire callbacks
    into a closed loop when the IOC stops."""
    yield
    purge_channel_caches()


async def wait_for_ioc(timeout: float = 20) -> None:
    deadline = time.monotonic() + timeout
    while True:
        try:
            await caget("GATAN:DmVersion", timeout=1)
            return
        except Exception:
            if time.monotonic() > deadline:
                raise
            await asyncio.sleep(0.5)


@pytest.mark.asyncio
async def test_status_pvs(ioc):
    await wait_for_ioc()
    assert await caget("GATAN:DmVersion") == 50302  # the mock's DM version
    assert await caget("GATAN:Camera:NumCameras") > 0


@pytest.mark.asyncio
async def test_dose_fractionated_exposure(ioc, tmp_path: Path):
    await wait_for_ioc()
    await caput(P + "ReadMode", 3, wait=True)  # K3 linear: mock allows small frames
    await caput(P + "Width", 512, wait=True)
    await caput(P + "Height", 512, wait=True)
    await caput(P + "SaveDir", str(tmp_path), datatype=DBR_CHAR_STR, wait=True)
    await caput(P + "SaveRootName", "movie", datatype=DBR_CHAR_STR, wait=True)
    await caput(P + "DoseFracNumFrames", 10, wait=True)
    await caput(P + "DoseFracExposure", 0.5, wait=True)
    assert await caget(P + "DoseFracNumFrames_RBV") == 10

    # Watch the state PV, then start the exposure and wait until it reports DONE.
    states: list[str] = []
    done = asyncio.Event()

    def on_state(value):
        name = value.enums[int(value)]
        states.append(name)
        if name in ("DONE", "FAILED"):
            done.set()

    monitor = camonitor(P + "DoseFracState", on_state, format=FORMAT_CTRL)
    try:
        await caput(P + "AcquireDoseFractionated", 1, wait=True)
        await asyncio.wait_for(done.wait(), timeout=10)
    finally:
        monitor.close()

    message = await caget(P + "DoseFracMessage", datatype=DBR_CHAR_STR)
    assert states[-1] == "DONE", message
    assert await caget(P + "DoseFracBusy") == 0
    assert await caget(P + "FramesSaved") == 10
    assert await caget(P + "SaveError") == 0
    saved = await caget(P + "SavedPath", datatype=DBR_CHAR_STR)
    assert saved.endswith("movie.mrc")


@pytest.mark.asyncio
async def test_failing_command_raises_alarm(ioc):
    await wait_for_ioc()
    await caput(P + "SaveDir", "", datatype=DBR_CHAR_STR, wait=True)  # invalid
    await caput(P + "AcquireDoseFractionated", 1, wait=True)
    cmd = await caget(P + "AcquireDoseFractionated", format=FORMAT_TIME)
    assert cmd.severity == 2  # MAJOR alarm: the command raised

Things to know, all caught by the repo’s filterwarnings = "error" pytest setting:

  • Drop aioca’s connections after each test. aioca caches connections per event loop, and pytest-asyncio gives each test its own loop; when the IOC stops, cached connections fire callbacks into closed loops (“Event loop is closed”). purge_channel_caches() in an async fixture fixes this.

  • Close the driver’s stdin pipe after the process stops, or pytest reports a ResourceWarning.

  • Start the driver with stdin=PIPE. Otherwise its IPython shell exits at once and takes the IOC with it.

  • Use a different mock port (48891 here), so the test does not clash with a mock server started by hand on 48890.