Skip to content

Add support for pydantic model registration with BlueAPI #1458

@oliwenmandiamond

Description

@oliwenmandiamond

When testing with Blueapi, I have a pydantic model loaded and ready to give to my plan / device client side.

>>> region1
VGScientaRegion[LensMode, PassEnergy](name='New_Region', enabled=True, slices=1, iterations=1, excitation_energy_source=<SelectedSource.SOURCE1: 'source1'>, lens_mode=<LensMode.ANGULAR45: 'Angular45'>, pass_energy=<PassEnergy.E10: '10'>, acquisition_mode=<AcquisitionMode.SWEPT: 'Swept'>, low_energy=149.0, centre_energy=150.0, high_energy=151.0, acquire_time=1.0, energy_step=0.2, energy_mode=<EnergyMode.KINETIC: 'Kinetic'>, id='_bMnQkCkaEfG3xKiZ1VupOQ', total_steps=15.0, total_time=15.0, min_x=1, sensor_max_size_x=1000, min_y=101, sensor_max_size_y=800, detector_mode=<DetectorMode.ADC: 'ADC'>)
>>> plans.set_absolute(devs.ew4000, region1)

When trying to set my device with these settings

>>> plans.set_absolute(devs.ew4000, region1)

I can see these logs:

2026-03-26 15:05:25,816  WARNING blueapi.worker.task_worker Invalid correlation id detected: None
2026-03-26 15:05:25,817     INFO blueapi.service.main 10.36.0.26:49436 POST /tasks 201
2026-03-26 15:05:25,830     INFO blueapi.worker.task_worker Submitting: task_id='3ea512cf-4a5c-4349-9bcd-39a6ec357e3f' task=Task(name='set_absolute', params={'movable': 'ew4000', 'value': {'name': 'New_Region', 'enabled': True, 'slices': 1, 'iterations': 1, 'excitation_energy_source': 'source1', 'lens_mode': 'Angular45', 'pass_energy': '10', 'acquisition_mode': 'Swept', 'low_energy': 149.0, 'centre_energy': 150.0, 'high_energy': 151.0, 'acquire_time': 1.0, 'energy_step': 0.2, 'energy_mode': 'Kinetic', 'id': '_bMnQkCkaEfG3xKiZ1VupOQ', 'total_steps': 15.0, 'total_time': 15.0, 'min_x': 1, 'sensor_max_size_x': 1000, 'min_y': 101, 'sensor_max_size_y': 800, 'detector_mode': 'ADC'}}, metadata={'user': 'xol73553', 'instrument_session': 'cm12345-1'}) request_id=None is_complete=False is_pending=True errors=[] outcome=None
2026-03-26 15:05:25,831     INFO blueapi.worker.task_worker Got new task: task_id='3ea512cf-4a5c-4349-9bcd-39a6ec357e3f' task=Task(name='set_absolute', params={'movable': 'ew4000', 'value': {'name': 'New_Region', 'enabled': True, 'slices': 1, 'iterations': 1, 'excitation_energy_source': 'source1', 'lens_mode': 'Angular45', 'pass_energy': '10', 'acquisition_mode': 'Swept', 'low_energy': 149.0, 'centre_energy': 150.0, 'high_energy': 151.0, 'acquire_time': 1.0, 'energy_step': 0.2, 'energy_mode': 'Kinetic', 'id': '_bMnQkCkaEfG3xKiZ1VupOQ', 'total_steps': 15.0, 'total_time': 15.0, 'min_x': 1, 'sensor_max_size_x': 1000, 'min_y': 101, 'sensor_max_size_y': 800, 'detector_mode': 'ADC'}}, metadata={'user': 'xol73553', 'instrument_session': 'cm12345-1'}) request_id=None is_complete=False is_pending=True errors=[] outcome=None
2026-03-26 15:05:25,831     INFO blueapi.worker.task Asked to run plan set_absolute with {'movable': 'ew4000', 'value': {'name': 'New_Region', 'enabled': True, 'slices': 1, 'iterations': 1, 'excitation_energy_source': 'source1', 'lens_mode': 'Angular45', 'pass_energy': '10', 'acquisition_mode': 'Swept', 'low_energy': 149.0, 'centre_energy': 150.0, 'high_energy': 151.0, 'acquire_time': 1.0, 'energy_step': 0.2, 'energy_mode': 'Kinetic', 'id': '_bMnQkCkaEfG3xKiZ1VupOQ', 'total_steps': 15.0, 'total_time': 15.0, 'min_x': 1, 'sensor_max_size_x': 1000, 'min_y': 101, 'sensor_max_size_y': 800, 'detector_mode': 'ADC'}} and metadata {'user': 'xol73553', 'instrument_session': 'cm12345-1'} for all runs
2026-03-26 15:05:25,831     INFO bluesky Executing plan <generator object set_absolute at 0x7fbbf3c32240>
2026-03-26 15:05:25,832     INFO bluesky.RE.state Change state on <bluesky.run_engine.RunEngine object at 0x7fbbf993af50> from 'idle' -> 'running'
2026-03-26 15:05:25,836  WARNING opentelemetry.attributes Invalid type dict in attribute 'msg.args' value sequence. Expected one of ['bool', 'str', 'bytes', 'int', 'float'] or None
2026-03-26 15:05:25,836     INFO bluesky.RE.state Change state on <bluesky.run_engine.RunEngine object at 0x7fbbf993af50> from 'running' -> 'idle'
2026-03-26 15:05:25,837     INFO bluesky Cleaned up from plan <generator object set_absolute at 0x7fbbf3c32240>
2026-03-26 15:05:25,837     INFO blueapi.worker.task_worker Task ran successfully - returned: <AsyncStatus, device: ew4000, task: <coroutine object AsyncStatusBase.__init__.<locals>.wait_with_error_message at 0x7fbbf3c30a40>, errored: AttributeError("'dict' object has no attribute 'excitation_energy_source'")>
2026-03-26 15:05:25,838     INFO blueapi.worker.task_worker Awaiting task
2026-03-26 15:05:25,838     INFO blueapi.service.main 10.36.0.26:49436 PUT /worker/task 200With key bit being
2026-03-26 15:05:25,836  WARNING opentelemetry.attributes Invalid type dict in attribute 'msg.args' value sequence. 

With the key parts being

Expected one of ['bool', 'str', 'bytes', 'int', 'float'] or None

ew4000, task: <coroutine object AsyncStatusBase.__init__.<locals>.wait_with_error_message at 0x7fbbf3c30a40>, errored: AttributeError("'dict' object has no attribute 'excitation_energy_source'")>

So my device expects the pydantic model

    @AsyncStatus.wrap
    async def set(self, region: TAbstractBaseRegion) -> None:

So the issue is that Blueapi does the following

Python object -> JSON -> Python dic

So my device set now fails because it expects the model and not the python dict.

When creating devices in dodal, we were told to make it as strongly typed as possible but now were needing to regress as we either have to make the device handle a dict or create a custom set plan for this device to convert the dict into a pydantic model before we give it to the device.

It would be good if we could add Blueapi support to be able to register pydantic models in the blueapi service values.yaml. Then we retain the type information during serialisation, something like "__type__": "dodal.my.pydantic.model.Implementation" for example so that then on server side when executing the plan, it can attempt to look up registered models and convert back into the pydantic model so that we don't need to write wrapper plans for every pydantic object.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions