From 1e32b9a5289980a8877ca3fce72775c414e9a3de Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 24 Feb 2025 11:18:27 -0500 Subject: [PATCH 1/4] feat: add non-dim coords --- src/useq/_iter_sequence.py | 19 ++++++++++++++++--- src/useq/_mda_event.py | 7 +++++++ src/useq/_plate.py | 8 +++++--- src/useq/_position.py | 13 +++++++++++-- 4 files changed, 39 insertions(+), 8 deletions(-) diff --git a/src/useq/_iter_sequence.py b/src/useq/_iter_sequence.py index e2fd68bc..d5840aea 100644 --- a/src/useq/_iter_sequence.py +++ b/src/useq/_iter_sequence.py @@ -21,6 +21,7 @@ class MDAEventDict(TypedDict, total=False): index: ReadOnlyDict + non_dimension_coords: ReadOnlyDict channel: EventChannel | None exposure: float | None min_start_time: float | None @@ -152,7 +153,6 @@ def _iter_sequence( continue # pragma: no cover # get axes objects for this event index, time, position, grid, channel, z_pos = _parse_axes(zip(order, item)) - # skip if necessary if _should_skip(position, channel, index, sequence.z_plan): continue @@ -166,8 +166,21 @@ def _iter_sequence( ) # determine x, y, z positions event_kwargs.update(_xyzpos(position, channel, sequence.z_plan, grid, z_pos)) - if position and position.name: - event_kwargs["pos_name"] = position.name + if position: + if position.name: + event_kwargs["pos_name"] = position.name + + pr = position.row + pc = position.col + if pr is not None or pc is not None: + nd_coords = event_kwargs.setdefault( + "non_dimension_coords", ReadOnlyDict() + ) + if pr is not None: + nd_coords.data["r"] = pr + if pc is not None: + nd_coords.data["c"] = pc + if channel: event_kwargs["channel"] = EventChannel.model_construct( config=channel.config, group=channel.group diff --git a/src/useq/_mda_event.py b/src/useq/_mda_event.py index ce0cfa84..75cd00f8 100644 --- a/src/useq/_mda_event.py +++ b/src/useq/_mda_event.py @@ -158,6 +158,12 @@ class MDAEvent(UseqModel): index : dict[str, int] Index of this event in the sequence. This is a mapping of axis name to index. For example: `{'t': 4, 'c': 0, 'z': 5},` + non_dimension_coords : dict[str, int] + Additional coordinates to associate with this event. This is a mapping of + coordinate name to index. For example: `{'r': 2, 'c': 3}` for a row/column + declaration. In that case, there will likely be a 1-dimensional axis for + position (e.g. `{'p': 0}`, `{'p': 1}`) in `index`, while the row/column + provides additional context (but is not a true axis of the sequence). channel : Channel | None Channel to use for this event. If `None`, implies use current channel. By default, `None`. `Channel` is a simple pydantic object with two attributes: @@ -220,6 +226,7 @@ class MDAEvent(UseqModel): """ index: ReadOnlyDict = Field(default_factory=ReadOnlyDict) + non_dimension_coords: ReadOnlyDict = Field(default_factory=ReadOnlyDict) channel: Optional[Channel] = None exposure: Optional[float] = Field(default=None, gt=0.0) min_start_time: Optional[float] = None # time in sec diff --git a/src/useq/_plate.py b/src/useq/_plate.py index 2f74acae..2e222b88 100644 --- a/src/useq/_plate.py +++ b/src/useq/_plate.py @@ -345,9 +345,11 @@ def all_well_positions(self) -> Sequence[Position]: def selected_well_positions(self) -> Sequence[Position]: """Return selected wells (centers) as Position objects.""" return [ - Position(x=x * 1000, y=y * 1000, name=name) # convert to µm - for (y, x), name in zip( - self.selected_well_coordinates, self.selected_well_names + Position(x=x * 1000, y=y * 1000, name=name, row=r, col=c) # convert to µm + for (y, x), name, (r, c) in zip( + self.selected_well_coordinates, + self.selected_well_names, + zip(*self.selected_wells or ()), ) ] diff --git a/src/useq/_position.py b/src/useq/_position.py index 3137e99c..21ac8b93 100644 --- a/src/useq/_position.py +++ b/src/useq/_position.py @@ -30,7 +30,7 @@ class PositionBase(MutableModel): Optional name for the position. sequence : MDASequence | None Optional MDASequence relative this position. - row : int | None + row : int | str | None Optional row index, when used in a grid. col : int | None Optional column index, when used in a grid. @@ -58,7 +58,16 @@ def __add__(self, other: "RelativePosition") -> "Self": z += other.z if (name := self.name) and other.name: name = f"{name}_{other.name}" - kwargs = {**self.model_dump(), "x": x, "y": y, "z": z, "name": name} + kwargs = { + **self.model_dump(), + "x": x, + "y": y, + "z": z, + "name": name, + # need to explicitly include these due to the exclude=True + "row": self.row, + "col": self.col, + } return type(self).model_construct(**kwargs) # type: ignore [return-value] def __round__(self, ndigits: "SupportsIndex | None" = None) -> "Self": From 21e5b43a8115d02c2cd3d4fcb65908cbe4895cd2 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 24 Feb 2025 11:20:12 -0500 Subject: [PATCH 2/4] update type hint --- src/useq/_position.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/useq/_position.py b/src/useq/_position.py index 21ac8b93..956b2440 100644 --- a/src/useq/_position.py +++ b/src/useq/_position.py @@ -30,7 +30,7 @@ class PositionBase(MutableModel): Optional name for the position. sequence : MDASequence | None Optional MDASequence relative this position. - row : int | str | None + row : int | None Optional row index, when used in a grid. col : int | None Optional column index, when used in a grid. From 77ac9df3f012e67a939a9177366c9afa344337b8 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 24 Feb 2025 11:25:35 -0500 Subject: [PATCH 3/4] fix serialization --- src/useq/_mda_event.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/useq/_mda_event.py b/src/useq/_mda_event.py index 75cd00f8..a3bc951f 100644 --- a/src/useq/_mda_event.py +++ b/src/useq/_mda_event.py @@ -143,7 +143,9 @@ def __get_pydantic_core_schema__( cls, source: type[Any], handler: GetCoreSchemaHandler ) -> core_schema.CoreSchema: return core_schema.dict_schema( - keys_schema=core_schema.str_schema(), values_schema=core_schema.int_schema() + keys_schema=core_schema.str_schema(), + values_schema=core_schema.int_schema(), + serialization=core_schema.plain_serializer_function_ser_schema(dict), ) @@ -247,7 +249,6 @@ def _validate_channel(cls, val: Any) -> Any: return Channel(config=val) if isinstance(val, str) else val if field_serializer is not None: - _si = field_serializer("index", mode="plain")(lambda v: dict(v)) _sx = field_serializer("x_pos", mode="plain")(_float_or_none) _sy = field_serializer("y_pos", mode="plain")(_float_or_none) _sz = field_serializer("z_pos", mode="plain")(_float_or_none) From 77c601a783ced9acd792220c394ab7aefd72538f Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 24 Feb 2025 14:50:59 -0500 Subject: [PATCH 4/4] fix serialization issues --- src/useq/_position.py | 10 +-- tests/conftest.py | 2 +- tests/fixtures/mda.json | 132 ++++++++++-------------------------- tests/fixtures/mda.yaml | 2 +- tests/test_serialization.py | 7 +- 5 files changed, 46 insertions(+), 107 deletions(-) diff --git a/src/useq/_position.py b/src/useq/_position.py index 956b2440..8f48760d 100644 --- a/src/useq/_position.py +++ b/src/useq/_position.py @@ -1,8 +1,6 @@ from collections.abc import Iterator from typing import TYPE_CHECKING, Generic, Optional, SupportsIndex, TypeVar -from pydantic import Field - from useq._base_model import FrozenModel, MutableModel if TYPE_CHECKING: @@ -42,9 +40,8 @@ class PositionBase(MutableModel): name: Optional[str] = None sequence: Optional["MDASequence"] = None - # excluded from serialization - row: Optional[int] = Field(default=None, exclude=True) - col: Optional[int] = Field(default=None, exclude=True) + row: Optional[int] = None + col: Optional[int] = None def __add__(self, other: "RelativePosition") -> "Self": """Add two positions together to create a new position.""" @@ -64,9 +61,6 @@ def __add__(self, other: "RelativePosition") -> "Self": "y": y, "z": z, "name": name, - # need to explicitly include these due to the exclude=True - "row": self.row, - "col": self.col, } return type(self).model_construct(**kwargs) # type: ignore [return-value] diff --git a/tests/conftest.py b/tests/conftest.py index 7ed488d8..871325fc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,7 +7,7 @@ def mda1() -> MDASequence: return MDASequence( axis_order="tpgcz", - metadata={"some info": "something"}, + metadata={"some_info": "something"}, stage_positions=[ (10, 20), { diff --git a/tests/fixtures/mda.json b/tests/fixtures/mda.json index 858585b6..9460a8f8 100644 --- a/tests/fixtures/mda.json +++ b/tests/fixtures/mda.json @@ -1,129 +1,71 @@ { - "autofocus_plan": { - "autofocus_device_name": "Z", - "autofocus_motor_offset": 50.0, - "axes": [ - "c" - ] - }, - "axis_order": [ - "t", - "p", - "g", - "c", - "z" - ], - "channels": [ - { - "acquire_every": 1, - "camera": null, - "config": "Cy5", - "do_stack": true, - "exposure": 50.0, - "group": "Channel", - "z_offset": 0.0 - }, - { - "acquire_every": 1, - "camera": null, - "config": "FITC", - "do_stack": true, - "exposure": 100.0, - "group": "Channel", - "z_offset": 0.0 - }, - { - "acquire_every": 3, - "camera": null, - "config": "DAPI", - "do_stack": false, - "exposure": null, - "group": "Channel", - "z_offset": 0.0 - } - ], - "grid_plan": { - "columns": 1, - "fov_height": null, - "fov_width": null, - "mode": "row_wise_snake", - "overlap": [ - 0.0, - 0.0 - ], - "relative_to": "center", - "rows": 2 - }, - "keep_shutter_open_across": [], "metadata": { - "some info": "something" + "some_info": "something" }, + "axis_order": ["t", "p", "g", "c", "z"], "stage_positions": [ { - "name": null, - "sequence": null, "x": 10.0, - "y": 20.0, - "z": null + "y": 20.0 }, { + "x": 10.0, + "y": 20.0, + "z": 50.0, "name": "test_name", "sequence": { - "autofocus_plan": null, - "axis_order": [ - "t", - "p", - "g", - "c", - "z" - ], - "channels": [], + "axis_order": ["t", "p", "g", "c", "z"], "grid_plan": { - "columns": 3, - "fov_height": null, - "fov_width": null, "mode": "row_wise_snake", - "overlap": [ - 0.0, - 0.0 - ], - "relative_to": "center", - "rows": 2 + "rows": 2, + "columns": 3 }, - "keep_shutter_open_across": [], - "metadata": {}, - "stage_positions": [], - "time_plan": null, "z_plan": { "above": 10.0, "below": 0.0, - "go_up": true, "step": 1.0 } - }, - "x": 10.0, - "y": 20.0, - "z": 50.0 + } + } + ], + "grid_plan": { + "rows": 2, + "columns": 1 + }, + "channels": [ + { + "config": "Cy5", + "exposure": 50.0 + }, + { + "config": "FITC", + "exposure": 100.0 + }, + { + "config": "DAPI", + "do_stack": false, + "acquire_every": 3 } ], "time_plan": { "phases": [ { "interval": 3.0, - "loops": 3, - "prioritize_duration": false + "loops": 3 }, { - "duration": 2400.0, "interval": 10.0, - "prioritize_duration": true + "duration": 2400.0 } - ], - "prioritize_duration": false + ] }, "z_plan": { - "go_up": true, "range": 1.0, "step": 0.5 + }, + "autofocus_plan": { + "autofocus_device_name": "Z", + "autofocus_motor_offset": 50.0, + "axes": ["c"] } } diff --git a/tests/fixtures/mda.yaml b/tests/fixtures/mda.yaml index 5fea5c92..63593f58 100644 --- a/tests/fixtures/mda.yaml +++ b/tests/fixtures/mda.yaml @@ -21,7 +21,7 @@ grid_plan: columns: 1 rows: 2 metadata: - some info: something + some_info: something stage_positions: - x: 10.0 y: 20.0 diff --git a/tests/test_serialization.py b/tests/test_serialization.py index a5b41aaf..6b453bef 100644 --- a/tests/test_serialization.py +++ b/tests/test_serialization.py @@ -1,4 +1,3 @@ -import json from pathlib import Path import pytest @@ -13,7 +12,11 @@ def test_serialization(mda1: MDASequence, ext: str) -> None: mda = MDASequence.from_file(str(FILE)) assert mda == mda1 if ext == "json": - assert json.loads(mda.model_dump_json(exclude={"uid"})) == json.loads(text) + # NOTE: this is extremely sensitive to the format of the JSON fixture file + # things MUST be in the same order there... so if you get errors here, double + # check the order of the fields in the fixture file. + dump = mda.model_dump_json(exclude_unset=True) + assert dump == text.replace("\n", "").replace(" ", "") else: assert mda.yaml() == text