Skip to content

Commit 0787a89

Browse files
authored
feat: add CustomAction (#214)
1 parent c95b176 commit 0787a89

File tree

4 files changed

+84
-7
lines changed

4 files changed

+84
-7
lines changed

src/useq/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import warnings
44
from typing import Any
55

6-
from useq._actions import AcquireImage, Action, HardwareAutofocus
6+
from useq._actions import AcquireImage, Action, CustomAction, HardwareAutofocus
77
from useq._channel import Channel
88
from useq._grid import (
99
GridFromEdges,
@@ -50,6 +50,7 @@
5050
"AxesBasedAF",
5151
"Axis",
5252
"Channel",
53+
"CustomAction",
5354
"EventChannel",
5455
"GridFromEdges",
5556
"GridRelative",

src/useq/_actions.py

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,24 @@
11
from typing import Optional, Union
22

3+
from pydantic import ConfigDict, Field, TypeAdapter, field_validator
4+
from pydantic_core import PydanticSerializationError
35
from typing_extensions import Literal
46

57
from useq._base_model import FrozenModel
68

9+
_dict_adapter = TypeAdapter(dict, config=ConfigDict(defer_build=True))
10+
711

812
class Action(FrozenModel):
913
"""Base class for a [`useq.MDAEvent`][] action.
1014
1115
An `Action` specifies what task should be performed during a
1216
[`useq.MDAEvent`][]. An `Action` can be for example used to acquire an
1317
image ([`useq.AcquireImage`][]) or to perform a hardware autofocus
14-
([`useq.HardwareAutofocus`][]).
18+
([`useq.HardwareAutofocus`][]). An action of `None` implies `AcquireImage`.
19+
20+
You may use `CustomAction` to indicate any custom action, with the `data` attribute
21+
containing any data required to perform the custom action.
1522
1623
Attributes
1724
----------
@@ -61,4 +68,42 @@ class HardwareAutofocus(Action):
6168
max_retries: int = 3
6269

6370

64-
AnyAction = Union[HardwareAutofocus, AcquireImage]
71+
class CustomAction(Action):
72+
"""[`useq.Action`][] to perform a custom action.
73+
74+
This is a generic user action that can be used to represent anything that is not
75+
covered by the other action types, such as a microfluidic event, or a
76+
photostimulation, etc...
77+
78+
The `data` attribute is a dictionary that can contain any data that is needed to
79+
perform the custom action. It *must* be serializable to JSON by `pydantic`.
80+
81+
Attributes
82+
----------
83+
type : Literal["custom"]
84+
This action can be used to perform a custom action.
85+
name : str, optional
86+
A name for the custom action (not to be confused with the `type` attribute,
87+
which must always be `"custom"`).
88+
data : dict, optional
89+
Custom data associated with the action.
90+
"""
91+
92+
type: Literal["custom"] = "custom"
93+
name: str = ""
94+
data: dict = Field(default_factory=dict)
95+
96+
@field_validator("data", mode="after")
97+
@classmethod
98+
def _ensure_serializable(cls, data: dict) -> dict:
99+
try:
100+
_dict_adapter.serializer.to_json(data)
101+
except PydanticSerializationError as e:
102+
raise ValueError(
103+
f"`CustomAction.data` must be JSON serializable, but is not:\n {e}.\n"
104+
" (You may use a pydantic object for custom serialization).\n "
105+
) from e
106+
return data
107+
108+
109+
AnyAction = Union[HardwareAutofocus, AcquireImage, CustomAction]

src/useq/_mda_event.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,10 @@ class MDAEvent(UseqModel):
174174
action : Action
175175
The action to perform for this event. By default, [`useq.AcquireImage`][].
176176
Example of another action is [`useq.HardwareAutofocus`][] which could be used
177-
to perform a hardware autofocus.
177+
to perform a hardware autofocus. For backwards compatibility, an `action` of
178+
`None` implies `AcquireImage`. You may use `CustomAction` to indicate any
179+
custom action, with the `data` attribute containing any data required to
180+
perform the custom action.
178181
keep_shutter_open : bool
179182
If `True`, the illumination shutter should be left open after the event has
180183
been executed, otherwise it should be closed. By default, `False`."
@@ -199,7 +202,7 @@ class MDAEvent(UseqModel):
199202
sequence: Optional["MDASequence"] = Field(default=None, repr=False)
200203
properties: Optional[list[PropertyTuple]] = None
201204
metadata: dict[str, Any] = Field(default_factory=dict)
202-
action: AnyAction = Field(default_factory=AcquireImage)
205+
action: AnyAction = Field(default_factory=AcquireImage, discriminator="type")
203206
keep_shutter_open: bool = False
204207
reset_event_timer: bool = False
205208

tests/test_sequence.py

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
import numpy as np
77
import pytest
8-
from pydantic import BaseModel
8+
from pydantic import BaseModel, ValidationError
99

1010
from useq import (
1111
Channel,
@@ -23,6 +23,7 @@
2323
ZRangeAround,
2424
ZRelativePositions,
2525
)
26+
from useq._actions import CustomAction, HardwareAutofocus
2627
from useq._mda_event import SLMImage
2728
from useq._position import RelativePosition
2829

@@ -354,7 +355,34 @@ def test_skip_channel_do_stack_no_zplan() -> None:
354355

355356
def test_event_action_union() -> None:
356357
# test that action unions work
357-
MDAEvent(action={"autofocus_device_name": "Z", "autofocus_motor_offset": 25})
358+
event = MDAEvent(
359+
action={
360+
"type": "hardware_autofocus",
361+
"autofocus_device_name": "Z",
362+
"autofocus_motor_offset": 25,
363+
}
364+
)
365+
assert isinstance(event.action, HardwareAutofocus)
366+
367+
368+
def test_custom_action() -> None:
369+
event = MDAEvent(action={"type": "custom"})
370+
assert isinstance(event.action, CustomAction)
371+
372+
event2 = MDAEvent(
373+
action=CustomAction(
374+
data={
375+
"foo": "bar",
376+
"alist": [1, 2, 3],
377+
"nested": {"a": 1, "b": 2},
378+
"nested_list": [{"a": 1}, {"b": 2}],
379+
}
380+
)
381+
)
382+
assert isinstance(event2.action, CustomAction)
383+
384+
with pytest.raises(ValidationError, match="must be JSON serializable"):
385+
CustomAction(data={"not-serializable": lambda x: x})
358386

359387

360388
def test_keep_shutter_open() -> None:

0 commit comments

Comments
 (0)