DiamondLightSource / DiamondLightSource/blueapi
Support deserialization of generic plan argument types before execution
- Dominant language
- Python
- Stars
- 13
- Forks
- 13
- Avg merge
- 2d 11h
- Merged PRs (30d)
- 25
Description
When testing with Blueapi, I have a pydantic model loaded and ready to give to my plan / device client side.
```Python
>>> region1
VGScientaRegion[LensMode, PassEnergy](name='New_Region', enabled=True, slices=1, iterations=1, excitation_energy_source=, lens_mode=, pass_energy=, acquisition_mode=, low_energy=149.0, centre_energy=150.0, high_energy=151.0, acquire_time=1.0, energy_step=0.2, energy_mode=, 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=)
>>> plans.set_absolute(devs.ew4000, region1)
```
When trying to set my device with these settings
```Python
>>> 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
2026-03-26 15:05:25,832 INFO bluesky.RE.state Change state on 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 from 'running' -> 'idle'
2026-03-26 15:05:25,837 INFO bluesky Cleaned up from plan
2026-03-26 15:05:25,837 INFO blueapi.worker.task_worker Task ran successfully - returned: .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: .wait_with_error_message at 0x7fbbf3c30a40>, errored: AttributeError("'dict' object has no attribute 'excitation_energy_source'")>`
So my device expects the pydantic model
```Python
@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.
Contributor guide
Assessment
This issue has not been assessed yet.