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
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ classifiers = [
description = "Eiger control system integration with FastCS"
dependencies = [
"aiohttp",
"fastcs[epicsca]~=0.11.3",
"fastcs-odin @ git+https://github.com/DiamondLightSource/fastcs-odin.git@0.7.0",
"fastcs[epicsca]",
"fastcs-odin @ git+https://github.com/DiamondLightSource/fastcs-odin.git@0.8.0a1",
"numpy",
"pillow",
"typer",
Expand Down
27 changes: 6 additions & 21 deletions run_acquisition.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,44 +31,29 @@ async def run_acquisition(

print("Configuring")
await asyncio.gather(
caput(f"{odin_prefix}:EF:BlockSize", 1),
caput_str(f"{odin_prefix}:EF:Acqid", file_name),
caput_str(f"{odin_prefix}:FP:FilePath", file_path),
caput_str(f"{odin_prefix}:FP:FilePrefix", file_name),
caput_str(f"{odin_prefix}:FP:AcquisitionId", file_name),
caput_str(f"{odin_prefix}:MW:Directory", file_path),
caput_str(f"{odin_prefix}:MW:FilePrefix", file_name),
caput_str(f"{odin_prefix}:MW:AcquisitionId", file_name),
caput(f"{odin_prefix}:BlockSize", 1),
caput_str(f"{odin_prefix}:FilePath", file_path),
caput_str(f"{odin_prefix}:AcquisitionId", file_name),
caput(f"{odin_prefix}:FP:Frames", frames),
caput_str(f"{odin_prefix}:FP:DataCompression", "BSLZ4"),
caput(f"{eiger_prefix}:Detector:Nimages", frames),
caput(f"{eiger_prefix}:Detector:Ntrigger", 1),
caput(f"{eiger_prefix}:Detector:FrameTime", exposure_time),
# caput(f"{eiger_prefix}:Detector:TriggerMode", "ints"), # for real detector
caput_str(f"{eiger_prefix}:Detector:TriggerMode", "ints"), # for tickit sim
)
await pv_equals(f"{eiger_prefix}:StaleParameters", 0)

print("Arming")
await caput(f"{eiger_prefix}:Detector:Arm", True)

datatype = f"uint{await aioca.caget(f'{eiger_prefix}:Detector:BitDepthImage')}"
await caput_str(f"{odin_prefix}:FP:DataDatatype", datatype)
await caput(f"{eiger_prefix}:ArmWhenReady", True)

print("Starting writing")
await caput(f"{odin_prefix}:FP:StartWriting", True)
await asyncio.sleep(1)
await asyncio.gather(
pv_equals(f"{odin_prefix}:FP:Writing", 1, timeout=5),
pv_equals(f"{odin_prefix}:EF:Ready", 1, timeout=5),
)
await caput(f"{eiger_prefix}:StartWriting", True)

print("Triggering")
await caput(f"{eiger_prefix}:Detector:Trigger", True, wait=False)

print("Waiting")
await pv_equals(
f"{odin_prefix}:FP:Writing",
f"{odin_prefix}:Writing",
0,
timeout=exposure_time * frames * 5, # tickit sim is much slower than requested
)
Expand Down
4 changes: 3 additions & 1 deletion src/fastcs_eiger/__main__.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
from pathlib import Path
from typing import Optional

import softioc.pvlog # noqa: F401
import typer
from fastcs.connections import IPConnectionSettings
from fastcs.launch import FastCS
from fastcs.logging import LogLevel, configure_logging
from fastcs.logging import LogLevel, configure_logging, intercept_std_logger
from fastcs.transports.epics import EpicsGUIOptions, EpicsIOCOptions
from fastcs.transports.epics.ca.transport import EpicsCATransport

Expand Down Expand Up @@ -54,6 +55,7 @@ def ioc(
ui_path = OPI_PATH if OPI_PATH.is_dir() else Path.cwd() / "opi"

configure_logging(log_level)
intercept_std_logger("root")

if odin_ip is None:
controller = EigerController(
Expand Down
19 changes: 17 additions & 2 deletions src/fastcs_eiger/controllers/eiger_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from fastcs.controllers import Controller
from fastcs.datatypes import Bool
from fastcs.logging import bind_logger
from fastcs.methods import scan
from fastcs.methods import command, scan

from fastcs_eiger.controllers.eiger_detector_controller import EigerDetectorController
from fastcs_eiger.controllers.eiger_monitor_controller import EigerMonitorController
Expand All @@ -24,6 +24,8 @@ class EigerController(Controller):
port: Port of Eiger detector
"""

detector: EigerDetectorController

# Internal Attribute
stale_parameters = AttrR(Bool())

Expand Down Expand Up @@ -77,7 +79,7 @@ async def initialise(self) -> None:
raise NotImplementedError(
f"No subcontroller implemented for subsystem {subsystem}"
)
self.add_sub_controller(subsystem.capitalize(), controller)
self.add_sub_controller(subsystem, controller)
await controller.initialise()

except HTTPRequestError:
Expand Down Expand Up @@ -114,3 +116,16 @@ async def queue_subsystem_update(self, coros: list[Coroutine]):
async with self._parameter_update_lock:
for coro in coros:
await self.queue.put(coro)

@command()
async def arm_when_ready(self):
"""Arm detector and return when ready to send triggers
Wait for parmeters to be synchronised before arming detector
Raises:
TimeoutError: If parameters are not synchronised or arm PUT request fails
"""
await self.stale_parameters.wait_for_value(False, timeout=1)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could: Now that we have wait_for_value with a timeout should we expose a DEFAULT_TIMEOUT in FastCS, such that we can ensure observation timeouts are consistent unless they explicitly shouldn't be?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not sure there is a widely useful default - what else would you use that default for?

I am actually not sure if 1 second here is sufficient. Perhaps it should be an Attribute so it can be changed at runtime?

await self.detector.arm()
5 changes: 4 additions & 1 deletion src/fastcs_eiger/controllers/eiger_detector_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,10 @@ class EigerDetectorController(EigerSubsystemController):

# Internal attribute to control triggers in `inte` mode
trigger_exposure = AttrRW(Float())
# Introspected attribute needed for trigger logic

# Introspected attributes needed for internal logic
bit_depth_image: AttrR[int]
compression: AttrRW[str]
trigger_mode: AttrR[str]

@detector_command
Expand Down
7 changes: 5 additions & 2 deletions src/fastcs_eiger/controllers/odin/eiger_fan.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from fastcs.attributes import AttrR
from fastcs.attributes import AttrR, AttrRW
from fastcs.datatypes import Bool
from fastcs_odin.controllers import OdinSubController
from fastcs_odin.io import StatusSummaryAttributeIORef
Expand All @@ -9,6 +9,9 @@ class EigerFanAdapterController(OdinSubController):
"""Controller for an EigerFan adapter in an odin control server"""

state: AttrR[str]
acqid: AttrRW[str]
block_size: AttrRW[int]
ready: AttrR[bool]

async def initialise(self):
for parameter in self.parameters:
Expand All @@ -22,7 +25,7 @@ async def initialise(self):
)

# Manually validate `state` to get a nicer error message if not introspected
self._validate_hinted_attributes()
self._validate_hinted_attribute("state")

self.ready = AttrR(
Bool(),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from fastcs.attributes import AttrRW
from fastcs_odin.controllers.odin_data.frame_processor import (
FrameProcessorAdapterController,
)


class EigerFrameProcessorAdapterController(FrameProcessorAdapterController):
data_compression: AttrRW[str]
data_datatype: AttrRW[str]
36 changes: 36 additions & 0 deletions src/fastcs_eiger/controllers/odin/eiger_odin_controller.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import asyncio

from fastcs.connections import IPConnectionSettings
from fastcs.methods import command

from fastcs_eiger.controllers.eiger_controller import EigerController
from fastcs_eiger.controllers.odin.odin_controller import OdinController
Expand All @@ -22,3 +23,38 @@ async def initialise(self) -> None:
"""Initialise eiger controller and odin controller"""

await asyncio.gather(super().initialise(), self.OD.initialise())

@command()
async def arm_when_ready(self):
"""Check eiger fan is ready before reporting arm as successful

Raises:
TimeoutError: If eiger fan is not ready

"""
await super().arm_when_ready()

try:
await self.OD.EF.ready.wait_for_value(True, timeout=1)
except TimeoutError as e:
raise TimeoutError("Eiger fan not ready") from e

@command()
async def start_writing(self):
"""Sync eiger parameters to file writers, start writing and return when ready

Raises:
TimeoutError: If file writers fail to start

"""
await asyncio.gather(
self.OD.FP.data_compression.put(self.detector.compression.get().upper()),
self.OD.FP.data_datatype.put(f"uint{self.detector.bit_depth_image.get()}"),
)

await self.OD.FP.start_writing()

try:
await self.OD.writing.wait_for_value(True, timeout=1)
except TimeoutError as e:
raise TimeoutError("File writers failed to start") from e
48 changes: 45 additions & 3 deletions src/fastcs_eiger/controllers/odin/odin_controller.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,59 @@
from fastcs.attributes import AttrR
from fastcs.attributes import AttrR, AttrRW
from fastcs.controllers import BaseController
from fastcs.datatypes import Bool
from fastcs.datatypes import Bool, Int, String
from fastcs_odin.controllers import OdinController as _OdinController
from fastcs_odin.controllers.odin_data.meta_writer import MetaWriterAdapterController
from fastcs_odin.http_connection import HTTPConnection
from fastcs_odin.io import StatusSummaryAttributeIORef
from fastcs_odin.io.config_fan_sender_attribute_io import ConfigFanAttributeIORef
from fastcs_odin.util import OdinParameter

from fastcs_eiger.controllers.odin.eiger_fan import EigerFanAdapterController
from fastcs_eiger.controllers.odin.eiger_fp_adapter_controller import (
EigerFrameProcessorAdapterController,
)


class OdinController(_OdinController):
"""Eiger-specific Odin controller"""

writing: AttrR = AttrR(
FP: EigerFrameProcessorAdapterController
EF: EigerFanAdapterController
MW: MetaWriterAdapterController

writing = AttrR(
Bool(), io_ref=StatusSummaryAttributeIORef([("MW", "FP")], "writing", any)
)

async def initialise(self):
await super().initialise()

self.file_path = AttrRW(
String(),
io_ref=ConfigFanAttributeIORef([self.FP.file_path, self.MW.directory]),
)
self.file_prefix = AttrRW(
String(),
io_ref=ConfigFanAttributeIORef([self.FP.file_prefix, self.MW.file_prefix]),
)
self.acquisition_id = AttrRW(
String(),
io_ref=ConfigFanAttributeIORef(
[
self.file_prefix,
self.FP.acquisition_id,
self.MW.acquisition_id,
self.EF.acqid,
]
),
)
Comment on lines +35 to +49
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm assuming the acquisition_id must always be the same as the FP and MW file_prefixes. With these changes, one could set self.file_prefix after self.acquisition_id with a different name. At the top level, should we not just expose:

Suggested change
self.file_prefix = AttrRW(
String(),
io_ref=ConfigFanAttributeIORef([self.FP.file_prefix, self.MW.file_prefix]),
)
self.acquisition_id = AttrRW(
String(),
io_ref=ConfigFanAttributeIORef(
[
self.file_prefix,
self.FP.acquisition_id,
self.MW.acquisition_id,
self.EF.acqid,
]
),
)
self.acquisition_id = AttrRW(
String(),
io_ref=ConfigFanAttributeIORef(
[
self.FP.file_prefix,
self.MW.file_prefix,
self.FP.acquisition_id,
self.MW.acquisition_id,
self.EF.acqid,
]
),
)

such that one should/could only poke one PV consistently?

Copy link
Contributor Author

@GDYendell GDYendell Jan 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is not any real reason to set them differently, but I think some do. We could check that with some DAQ people.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@DominicOram do you have any thoughts on this?

self.block_size = AttrRW(
Int(),
io_ref=ConfigFanAttributeIORef(
[self.FP.process_frames_per_block, self.EF.block_size]
),
)

def _create_adapter_controller(
self,
connection: HTTPConnection,
Expand All @@ -26,6 +64,10 @@ def _create_adapter_controller(
"""Create Eiger-specific adapter controllers."""

match module:
case "FrameProcessorAdapter":
return EigerFrameProcessorAdapterController(
connection, parameters, f"{self.API_PREFIX}/{adapter}", self._ios
)
case "EigerFanAdapter":
return EigerFanAdapterController(
connection, parameters, f"{self.API_PREFIX}/{adapter}", self._ios
Expand Down
12 changes: 6 additions & 6 deletions tests/system/test_eiger_introspection.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ async def test_controller_groups_and_parameters(sim_eiger):
await controller.initialise()

for subsystem in MISSING_KEYS:
subcontroller = controller.sub_controllers[subsystem.title()]
subcontroller = controller.sub_controllers[subsystem]
assert isinstance(subcontroller, EigerSubsystemController)
parameters = await subcontroller._introspect_detector_subsystem()
if subsystem == "detector":
Expand Down Expand Up @@ -129,7 +129,7 @@ async def test_threshold_mode_api_inconsistency_handled(
controller = EigerController(IPConnectionSettings("127.0.0.1", 8081))
await controller.initialise()

detector_controller = controller.sub_controllers["Detector"]
detector_controller = controller.sub_controllers["detector"]
assert isinstance(detector_controller, EigerDetectorController)

attr: AttrRW = detector_controller.attributes["threshold_1_energy"] # type: ignore
Expand Down Expand Up @@ -160,7 +160,7 @@ async def test_fetch_before_returning_parameters(sim_eiger, mocker: MockerFixtur
controller = EigerController(IPConnectionSettings("127.0.0.1", 8081))
await controller.initialise()

detector_controller = controller.sub_controllers["Detector"]
detector_controller = controller.sub_controllers["detector"]
assert isinstance(detector_controller, EigerDetectorController)

count_time_attr: AttrRW[float, EigerParameterRef] = (
Expand Down Expand Up @@ -212,7 +212,7 @@ async def test_stale_propagates_to_top_controller(
controller = EigerController(IPConnectionSettings("127.0.0.1", 8081))
await controller.initialise()

detector_controller = controller.sub_controllers["Detector"]
detector_controller = controller.sub_controllers["detector"]
assert isinstance(detector_controller, EigerDetectorController)
await detector_controller.queue_update(["threshold_energy"])
assert controller.stale_parameters.get() is True
Expand Down Expand Up @@ -271,7 +271,7 @@ async def test_eiger_controller_trigger_correctly_introspected(
controller = EigerController(IPConnectionSettings("127.0.0.1", 8081))
await controller.initialise()

detector_controller = controller.sub_controllers["Detector"]
detector_controller = controller.sub_controllers["detector"]
assert isinstance(detector_controller, EigerDetectorController)
detector_controller.connection = mocker.AsyncMock()

Expand Down Expand Up @@ -341,7 +341,7 @@ async def test_if_min_value_provided_then_prec_set_correctly(
):
await eiger_controller.initialise()

test_float_attr = eiger_controller.sub_controllers["Detector"].attributes.get(
test_float_attr = eiger_controller.sub_controllers["detector"].attributes.get(
"test_float_attr"
)

Expand Down
6 changes: 3 additions & 3 deletions tests/test_eiger_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,9 @@ async def test_eiger_controller_creates_subcontrollers(mock_connection):

await eiger_controller.initialise()
assert list(eiger_controller.sub_controllers.keys()) == [
"Detector",
"Stream",
"Monitor",
"detector",
"stream",
"monitor",
]
connection.get.assert_any_call("detector/api/1.8.0/status/state")
connection.get.assert_any_call("detector/api/1.8.0/status/keys")
Expand Down
1 change: 1 addition & 0 deletions tests/test_eiger_fan_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ async def test_ef_ready(mocker: MockerFixture):
"prefix",
[StatusSummaryAttributeIO(), ParameterTreeAttributeIO(mock_connection)],
)
mocker.patch.object(eiger_fan, "_validate_type_hints")
await eiger_fan.initialise()
eiger_fan.post_initialise()

Expand Down
8 changes: 8 additions & 0 deletions tests/test_odin_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
from pytest_mock import MockerFixture

from fastcs_eiger.controllers.odin.eiger_fan import EigerFanAdapterController
from fastcs_eiger.controllers.odin.eiger_fp_adapter_controller import (
EigerFrameProcessorAdapterController,
)
from fastcs_eiger.controllers.odin.odin_controller import OdinController


Expand All @@ -18,6 +21,11 @@ async def test_create_adapter_controller(mocker: MockerFixture):
)
]

ctrl = controller._create_adapter_controller(
controller.connection, parameters, "fp", "FrameProcessorAdapter"
)
assert isinstance(ctrl, EigerFrameProcessorAdapterController)

ctrl = controller._create_adapter_controller(
controller.connection, parameters, "ef", "EigerFanAdapter"
)
Expand Down
Loading