Skip to content
Open
8 changes: 6 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@ classifiers = [
"Programming Language :: Python :: 3.13",
]
description = "fastcs interface for xspress detector"
dependencies = ["aiohttp", "typer", "fastcs-odin>=0.8.0"]
dependencies = [
"aiohttp",
"typer",
"fastcs-odin>=0.9.0",
]
dynamic = ["version"]
license.file = "LICENSE"
readme = "README.md"
Expand All @@ -39,7 +43,7 @@ dev = [
]

[project.scripts]
fastcs-xspress = "fastcs_xspress.__main__:main"
fastcs-xspress = "fastcs_xspress.__main__:app"

[project.urls]
GitHub = "https://github.com/DiamondLightSource/fastcs-xspress"
Expand Down
35 changes: 35 additions & 0 deletions src/fastcs_xspress/xspress_controller.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,45 @@
from fastcs.attributes import AttrR, AttrRW
from fastcs.controllers import BaseController
from fastcs.datatypes import Bool, String
from fastcs_odin.controllers import 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_xspress.xspress_adapter_controller import XspressAdapterController
from fastcs_xspress.xspress_fp_adapter_controller import XspressFPAdapterController


class XspressController(OdinController):
"""A root ``Controller`` for an xspress control server."""

FP: XspressFPAdapterController
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.FP.acquisition_id,
self.MW.acquisition_id,
self.FP.acq_id,
]
),
)

def _create_adapter_controller(
self,
connection: HTTPConnection,
Expand All @@ -21,6 +52,10 @@ def _create_adapter_controller(
return XspressAdapterController(
connection, parameters, f"{self.API_PREFIX}/{adapter}", self._ios
)
case "FrameProcessorAdapter":
return XspressFPAdapterController(
connection, parameters, f"{self.API_PREFIX}/{adapter}", self._ios
)

return super()._create_adapter_controller(
connection, parameters, adapter, module
Expand Down
42 changes: 42 additions & 0 deletions src/fastcs_xspress/xspress_fp_adapter_controller.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import logging

from fastcs.attributes import AttrRW
from fastcs_odin.controllers import (
FrameProcessorAdapterController,
)
from fastcs_odin.controllers.odin_subcontroller import OdinSubController
from fastcs_odin.util import get_all_sub_controllers


class XspressFPAdapterController(FrameProcessorAdapterController):
chunks: AttrRW[int]
acq_id: AttrRW[str]

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

# Construct a list with all the MCA dataset chunks
mca_list = []
for sub_controller in get_all_sub_controllers(self):
match sub_controller:
case OdinSubController():
for parameter in sub_controller.parameters:
if "chunks" in parameter.uri and "0" in parameter.uri:
# Parameter name will be in the form of mca_X_chunks_0
# sub_controller path will be in the form of ["FP", "X",...]
mca_list.append(
(sub_controller.path[1], parameter.name.split("_")[1])
)
case _:
logging.warning(
f"Subcontroller {sub_controller} not an OdinAdapterController"
)

async def set_chunk_fp(value: int):
for sub_controller, mca_num in mca_list:
await self.connection.put(
f"api/0.1/fp/{sub_controller}/config/hdf/dataset/mca_{mca_num}/chunks",
[value, 1, 4096], # pyright: ignore[reportArgumentType]
)

self.chunks.add_on_update_callback(callback=set_chunk_fp)
Copy link
Contributor

@GDYendell GDYendell Mar 13, 2026

Choose a reason for hiding this comment

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

To add a test for this you could:

  • create a controller XspressFPAdapterController

  • mock super initialise

    mocker.patch.object(
        FrameProcessorAdapterController,
        "initialise",
        new_callable=mocker.AsyncMock,
    )
  • mock get_all_sub_controllers to return a list of, say, 4 odin parameters {idx}/chunks
  • mock controller.connection
  • manually create controller.chunks (because we aren't calling initialise)
  • call controller.initialise to create and add the callback
  • do chunks.set
  • Check that the connection mock has 4 calls to the correct uri with the correct value

120 changes: 87 additions & 33 deletions tests/test_xspress_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,23 @@
from pathlib import Path

import pytest
from fastcs.attributes import AttrRW
from fastcs.connections.ip_connection import IPConnectionSettings
from fastcs.datatypes import Int
from fastcs_odin.controllers.odin_data.frame_processor import (
FrameProcessorAdapterController,
)
from fastcs_odin.controllers.odin_subcontroller import OdinSubController
from fastcs_odin.util import (
OdinParameter,
OdinParameterMetadata,
create_odin_parameters,
)
from pytest_mock import MockerFixture

from fastcs_xspress.xspress_adapter_controller import XspressAdapterController
from fastcs_xspress.xspress_controller import XspressController
from fastcs_xspress.xspress_fp_adapter_controller import XspressFPAdapterController

_lock = asyncio.Lock()
HERE = Path(__file__).parent
Expand All @@ -25,26 +29,29 @@
async def test_xspress_controller_creates_xspress_adapter(mocker: MockerFixture):
xsp_controller = XspressController(IPConnectionSettings("127.0.0.1", 80))

connection = mocker.patch.object(xsp_controller, "connection")
connection.get = mocker.AsyncMock()
connection.get.side_effect = [
{"adapters": ["XSPRESS"]},
{"module": {"value": "XspressAdapter"}},
{"allowed": ["command_1", "command_2"]},
parameters = [
OdinParameter(
["dtc"],
metadata=OdinParameterMetadata(value=0, type="int", writeable=False),
),
OdinParameter(
["scalar"],
metadata=OdinParameterMetadata(value=0, type="int", writeable=False),
),
]
ctrl = xsp_controller._create_adapter_controller(
xsp_controller.connection, parameters, "xspress", "XspressAdapter"
)

await xsp_controller.initialise()
assert isinstance(ctrl, XspressAdapterController)

assert list(xsp_controller.sub_controllers.keys()) == ["XSPRESS"]
await ctrl.initialise()
assert isinstance(
xsp_controller.sub_controllers["XSPRESS"], XspressAdapterController
)
assert isinstance(
xsp_controller.sub_controllers["XSPRESS"].sub_controllers["dtc_controller"],
ctrl.sub_controllers["dtc_controller"],
OdinSubController,
)
assert isinstance(
xsp_controller.sub_controllers["XSPRESS"].sub_controllers["scalar_controller"],
ctrl.sub_controllers["scalar_controller"],
OdinSubController,
)

Expand All @@ -64,7 +71,7 @@ async def test_xspress_controller_creates_fp_adapter(mocker: MockerFixture):
xsp_controller.connection, parameters, "fp", "FrameProcessorAdapter"
)

assert isinstance(ctrl, FrameProcessorAdapterController)
assert isinstance(ctrl, XspressFPAdapterController)


@pytest.mark.asyncio
Expand All @@ -77,28 +84,75 @@ async def test_xspress_attribute_creation(mocker: MockerFixture):
connection = mocker.patch.object(xsp_controller, "connection")
connection.get = mocker.AsyncMock()
connection.get.side_effect = [
{"adapters": ["XSPRESS"]},
response,
{"allowed": response["command"]["allowed"]},
]
ctrl = xsp_controller._create_adapter_controller(
xsp_controller.connection,
create_odin_parameters(response),
"xspress",
"XspressAdapter",
)

await xsp_controller.initialise()
await ctrl.initialise()

assert (
len(
xsp_controller.sub_controllers["XSPRESS"]
.sub_controllers["dtc_controller"]
.attributes
)
== 81
assert len(ctrl.sub_controllers["dtc_controller"].attributes) == 81
assert len(ctrl.sub_controllers["scalar_controller"].attributes) == 120
assert len(ctrl.attributes) == 53
assert len(ctrl.command_methods) == 4


@pytest.mark.asyncio
async def test_xspress_chunk_set(mocker: MockerFixture):
xsp_fp = XspressFPAdapterController(mocker.AsyncMock(), [], "api/0.1", [])
mocker.patch.object(
FrameProcessorAdapterController,
"initialise",
new_callable=mocker.AsyncMock,
)
assert (
len(
xsp_controller.sub_controllers["XSPRESS"]
.sub_controllers["scalar_controller"]
.attributes
)
== 120
get_sub_controllers = mocker.patch(
"fastcs_xspress.xspress_fp_adapter_controller.get_all_sub_controllers"
)

parameters = [
OdinParameter(
["mca_0", "chunks", "0"],
metadata=OdinParameterMetadata(value=0, type="int", writeable=False),
),
OdinParameter(
["mca_1", "chunks", "0"],
metadata=OdinParameterMetadata(value=0, type="int", writeable=False),
),
OdinParameter(
["mca_2", "chunks", "0"],
metadata=OdinParameterMetadata(value=0, type="int", writeable=False),
),
OdinParameter(
["mca_3", "chunks", "0"],
metadata=OdinParameterMetadata(value=0, type="int", writeable=False),
),
]
subcontroller = OdinSubController(mocker.Mock(), parameters, "", [])
subcontroller.set_path(["FP", "0", "config", "HDF", "dataset"])
get_sub_controllers.return_value = [subcontroller]

connection = mocker.patch.object(xsp_fp, "connection")
connection.get = mocker.AsyncMock()
xsp_fp.chunks = AttrRW(Int(), initial_value=0)

await xsp_fp.initialise()

await xsp_fp.chunks.update(1)

connection.put.assert_any_await(
"api/0.1/fp/0/config/hdf/dataset/mca_0/chunks", [1, 1, 4096]
)
connection.put.assert_any_await(
"api/0.1/fp/0/config/hdf/dataset/mca_1/chunks", [1, 1, 4096]
)
connection.put.assert_any_await(
"api/0.1/fp/0/config/hdf/dataset/mca_2/chunks", [1, 1, 4096]
)
connection.put.assert_any_await(
"api/0.1/fp/0/config/hdf/dataset/mca_3/chunks", [1, 1, 4096]
)
assert len(xsp_controller.sub_controllers["XSPRESS"].attributes) == 53
assert len(xsp_controller.sub_controllers["XSPRESS"].command_methods) == 4
assert connection.put.await_count == len(parameters)
14 changes: 7 additions & 7 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading