From 2a92639f4e76bb567e51129612d06df742d78f1b Mon Sep 17 00:00:00 2001 From: Ivan Ivanov Date: Tue, 24 Mar 2026 12:56:52 -0700 Subject: [PATCH 01/20] feat: add plate_row and plate_col fields to PositionBase Add serialized int fields to PositionBase for annotating which well a position belongs to, enabling HCS zarr output from plain stage positions without requiring WellPlatePlan. Also populate these fields in WellPlatePlan.all_well_positions and selected_well_positions. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/useq/_plate.py | 30 ++++++++++++++++++++++++------ src/useq/_position.py | 2 ++ tests/fixtures/mda.json | 3 --- tests/fixtures/mda.yaml | 8 +------- 4 files changed, 27 insertions(+), 16 deletions(-) diff --git a/src/useq/_plate.py b/src/useq/_plate.py index f4ccdc3b..bea30bca 100644 --- a/src/useq/_plate.py +++ b/src/useq/_plate.py @@ -335,9 +335,18 @@ def _transorm_coords(self, coords: np.ndarray) -> np.ndarray: def all_well_positions(self) -> Sequence[Position]: """Return all 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.all_well_coordinates, self.all_well_names.reshape(-1), strict=False + Position( + x=x * 1000, + y=y * 1000, + name=name, + plate_row=int(row), + plate_col=int(col), + ) + for (y, x), name, (row, col) in zip( + self.all_well_coordinates, + self.all_well_names.reshape(-1), + self.all_well_indices.reshape(-1, 2), + strict=False, ) ] @@ -345,9 +354,18 @@ 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, strict=False + Position( + x=x * 1000, + y=y * 1000, + name=name, + plate_row=int(row), + plate_col=int(col), + ) + for (y, x), name, (row, col) in zip( + self.selected_well_coordinates, + self.selected_well_names, + self.selected_well_indices, + strict=False, ) ] diff --git a/src/useq/_position.py b/src/useq/_position.py index 808df3c9..6e1067c5 100644 --- a/src/useq/_position.py +++ b/src/useq/_position.py @@ -46,6 +46,8 @@ class PositionBase(MutableModel): name: str | None = None sequence: Optional["MDASequence"] = None properties: list[PropertyTuple] | None = None + plate_row: int | None = None + plate_col: int | None = None # excluded from serialization row: int | None = Field(default=None, exclude=True) diff --git a/tests/fixtures/mda.json b/tests/fixtures/mda.json index b2f35b2b..d56b1d4b 100644 --- a/tests/fixtures/mda.json +++ b/tests/fixtures/mda.json @@ -62,7 +62,6 @@ "stage_positions": [ { "name": null, - "properties": null, "sequence": null, "x": 10.0, "y": 20.0, @@ -70,7 +69,6 @@ }, { "name": "test_name", - "properties": null, "sequence": { "autofocus_plan": null, "axis_order": [ @@ -105,7 +103,6 @@ "step": 1.0 } }, - "properties": null, "x": 10.0, "y": 20.0, "z": 50.0 diff --git a/tests/fixtures/mda.yaml b/tests/fixtures/mda.yaml index 5fea5c92..31d9195b 100644 --- a/tests/fixtures/mda.yaml +++ b/tests/fixtures/mda.yaml @@ -25,17 +25,11 @@ metadata: stage_positions: - x: 10.0 y: 20.0 + z: null - name: test_name sequence: - axis_order: - - t - - p - - g - - c - - z grid_plan: columns: 3 - mode: row_wise_snake rows: 2 z_plan: above: 10.0 From fa4f00911cc7fc1a7cd043fd73446fa2b4707fe6 Mon Sep 17 00:00:00 2001 From: Ivan Ivanov Date: Tue, 24 Mar 2026 12:56:52 -0700 Subject: [PATCH 02/20] feat: add plate_row and plate_col fields to PositionBase Add serialized int fields to PositionBase for annotating which well a position belongs to, enabling HCS zarr output from plain stage positions without requiring WellPlatePlan. Also populate these fields in WellPlatePlan.all_well_positions and selected_well_positions. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/useq/_plate.py | 30 ++++++++++++++++++++++++------ src/useq/_position.py | 2 ++ tests/fixtures/mda.json | 4 ++++ 3 files changed, 30 insertions(+), 6 deletions(-) diff --git a/src/useq/_plate.py b/src/useq/_plate.py index f4ccdc3b..bea30bca 100644 --- a/src/useq/_plate.py +++ b/src/useq/_plate.py @@ -335,9 +335,18 @@ def _transorm_coords(self, coords: np.ndarray) -> np.ndarray: def all_well_positions(self) -> Sequence[Position]: """Return all 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.all_well_coordinates, self.all_well_names.reshape(-1), strict=False + Position( + x=x * 1000, + y=y * 1000, + name=name, + plate_row=int(row), + plate_col=int(col), + ) + for (y, x), name, (row, col) in zip( + self.all_well_coordinates, + self.all_well_names.reshape(-1), + self.all_well_indices.reshape(-1, 2), + strict=False, ) ] @@ -345,9 +354,18 @@ 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, strict=False + Position( + x=x * 1000, + y=y * 1000, + name=name, + plate_row=int(row), + plate_col=int(col), + ) + for (y, x), name, (row, col) in zip( + self.selected_well_coordinates, + self.selected_well_names, + self.selected_well_indices, + strict=False, ) ] diff --git a/src/useq/_position.py b/src/useq/_position.py index 808df3c9..6e1067c5 100644 --- a/src/useq/_position.py +++ b/src/useq/_position.py @@ -46,6 +46,8 @@ class PositionBase(MutableModel): name: str | None = None sequence: Optional["MDASequence"] = None properties: list[PropertyTuple] | None = None + plate_row: int | None = None + plate_col: int | None = None # excluded from serialization row: int | None = Field(default=None, exclude=True) diff --git a/tests/fixtures/mda.json b/tests/fixtures/mda.json index b2f35b2b..80f4916a 100644 --- a/tests/fixtures/mda.json +++ b/tests/fixtures/mda.json @@ -62,6 +62,8 @@ "stage_positions": [ { "name": null, + "plate_col": null, + "plate_row": null, "properties": null, "sequence": null, "x": 10.0, @@ -70,6 +72,8 @@ }, { "name": "test_name", + "plate_col": null, + "plate_row": null, "properties": null, "sequence": { "autofocus_plan": null, From e1fdddce4a9a19355c9ed0b72908f6984ae7efda Mon Sep 17 00:00:00 2001 From: Ivan Ivanov Date: Tue, 24 Mar 2026 17:37:45 -0700 Subject: [PATCH 03/20] feat: add configurable name_pattern to grid plans Add a `name_pattern` field to `_GridPlan` that controls how grid positions are named. Supports {row}, {col}, {idx} format variables. Default is "{idx:04d}" (preserves backward compat). Names are now generated in useq-schema so downstream consumers don't need to override them. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/useq/_grid.py | 13 +++++++++++-- tests/fixtures/mda.json | 2 ++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/useq/_grid.py b/src/useq/_grid.py index 54418423..b0fba12b 100644 --- a/src/useq/_grid.py +++ b/src/useq/_grid.py @@ -65,6 +65,12 @@ class _GridPlan(_MultiPointPlan[PositionT]): overlap: tuple[float, float] = Field(default=(0.0, 0.0), frozen=True) mode: OrderMode = Field(default=OrderMode.row_wise_snake, frozen=True) + name_pattern: str = Field( + default="{idx:04d}", + frozen=True, + description="Format pattern for grid position names. " + "Supported variables: {row}, {col}, {idx}.", + ) @field_validator("overlap", mode="before") @classmethod @@ -137,7 +143,7 @@ def iter_grid_positions( y=y0 - r * dy, row=r, col=c, - name=f"{str(idx).zfill(4)}", + name=self.name_pattern.format(row=r, col=c, idx=idx), ) def __iter__(self) -> Iterator[PositionT]: # type: ignore [override] @@ -517,7 +523,10 @@ def iter_grid_positions( except ValueError: pos = [] for idx, (x, y, r, c) in enumerate(pos): - yield AbsolutePosition(x=x, y=y, row=r, col=c, name=f"{str(idx).zfill(4)}") + yield AbsolutePosition( + x=x, y=y, row=r, col=c, + name=self.name_pattern.format(row=r, col=c, idx=idx), + ) def _cached_tiles( self, diff --git a/tests/fixtures/mda.json b/tests/fixtures/mda.json index 80f4916a..1011e5b3 100644 --- a/tests/fixtures/mda.json +++ b/tests/fixtures/mda.json @@ -47,6 +47,7 @@ "fov_height": null, "fov_width": null, "mode": "row_wise_snake", + "name_pattern": "{idx:04d}", "overlap": [ 0.0, 0.0 @@ -90,6 +91,7 @@ "fov_height": null, "fov_width": null, "mode": "row_wise_snake", + "name_pattern": "{idx:04d}", "overlap": [ 0.0, 0.0 From 436d880d1bbc755245756d4211ea17fed24dec04 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 25 Mar 2026 00:38:07 +0000 Subject: [PATCH 04/20] style(pre-commit.ci): auto fixes [...] --- src/useq/_grid.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/useq/_grid.py b/src/useq/_grid.py index b0fba12b..891befbf 100644 --- a/src/useq/_grid.py +++ b/src/useq/_grid.py @@ -524,7 +524,10 @@ def iter_grid_positions( pos = [] for idx, (x, y, r, c) in enumerate(pos): yield AbsolutePosition( - x=x, y=y, row=r, col=c, + x=x, + y=y, + row=r, + col=c, name=self.name_pattern.format(row=r, col=c, idx=idx), ) From 3ad17428d28a99c68bb192706d442d562088e6ab Mon Sep 17 00:00:00 2001 From: Ivan Ivanov Date: Tue, 24 Mar 2026 18:06:20 -0700 Subject: [PATCH 05/20] feat: auto-generate position name from plate_row/plate_col - If name is None but plate_row/plate_col are set, auto-generate a well name using existing _index_to_row_name (e.g., 0,0 -> "A1") - When iterating with a grid plan, compose pos_name as "{position_name}_{grid_name}" for plate positions (e.g., "A1_0000") - Explicit names are never overwritten Co-Authored-By: Claude Opus 4.6 (1M context) --- src/useq/_iter_sequence.py | 9 ++++++++- src/useq/_plate.py | 10 +--------- src/useq/_position.py | 17 +++++++++++++++++ 3 files changed, 26 insertions(+), 10 deletions(-) diff --git a/src/useq/_iter_sequence.py b/src/useq/_iter_sequence.py index 3c3ece54..62387d89 100644 --- a/src/useq/_iter_sequence.py +++ b/src/useq/_iter_sequence.py @@ -174,7 +174,14 @@ 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 + pos_name = position.name + if ( + grid + and grid.name + and getattr(position, "plate_row", None) is not None + ): + pos_name = f"{pos_name}_{grid.name}" + event_kwargs["pos_name"] = pos_name # include position properties only when p-index changes p_idx = index.get(Axis.POSITION, -1) if position and position.properties and p_idx != _last_p_idx: diff --git a/src/useq/_plate.py b/src/useq/_plate.py index bea30bca..3b692a4e 100644 --- a/src/useq/_plate.py +++ b/src/useq/_plate.py @@ -24,7 +24,7 @@ from useq._enums import Shape from useq._grid import RandomPoints, RelativeMultiPointPlan from useq._plate_registry import _PLATE_REGISTRY -from useq._position import Position, PositionBase, RelativePosition +from useq._position import Position, PositionBase, RelativePosition, _index_to_row_name if TYPE_CHECKING: from pydantic_core import core_schema @@ -418,14 +418,6 @@ def plot(self, show_axis: bool = True) -> None: plot_plate(self, show_axis=show_axis) -def _index_to_row_name(index: int) -> str: - """Convert a zero-based column index to row name (A, B, ..., Z, AA, AB, ...).""" - name = "" - while index >= 0: - name = chr(index % 26 + 65) + name - index = index // 26 - 1 - return name - def _find_pattern(seq: Sequence[int]) -> tuple[list[int] | None, int | None]: n = len(seq) diff --git a/src/useq/_position.py b/src/useq/_position.py index 6e1067c5..e61b9736 100644 --- a/src/useq/_position.py +++ b/src/useq/_position.py @@ -15,6 +15,15 @@ from useq import MDASequence +def _index_to_row_name(index: int) -> str: + """Convert a zero-based row index to name (A, B, ..., Z, AA, AB, ...).""" + name = "" + while index >= 0: + name = chr(index % 26 + 65) + name + index = index // 26 - 1 + return name + + class PositionBase(MutableModel): """Define a position in 3D space. @@ -87,6 +96,14 @@ def __round__(self, ndigits: "SupportsIndex | None" = None) -> "Self": # not sure why these Self types are not working return type(self).model_construct(**kwargs) # type: ignore [return-value] + @model_validator(mode="after") + def _name_from_plate(self) -> "Self": + """Auto-generate name from plate_row/plate_col if not provided.""" + if self.name is None and self.plate_row is not None and self.plate_col is not None: + name = f"{_index_to_row_name(self.plate_row)}{self.plate_col + 1}" + object.__setattr__(self, "name", name) + return self + @model_validator(mode="before") @classmethod def _cast(cls, value: Any) -> Any: From e29b7800898edd2fe51e6aebf64599be8a1d45f7 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 25 Mar 2026 01:08:52 +0000 Subject: [PATCH 06/20] style(pre-commit.ci): auto fixes [...] --- src/useq/_iter_sequence.py | 6 +----- src/useq/_plate.py | 1 - src/useq/_position.py | 6 +++++- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/useq/_iter_sequence.py b/src/useq/_iter_sequence.py index 62387d89..a521db2a 100644 --- a/src/useq/_iter_sequence.py +++ b/src/useq/_iter_sequence.py @@ -175,11 +175,7 @@ def _iter_sequence( event_kwargs.update(_xyzpos(position, channel, sequence.z_plan, grid, z_pos)) if position and position.name: pos_name = position.name - if ( - grid - and grid.name - and getattr(position, "plate_row", None) is not None - ): + if grid and grid.name and getattr(position, "plate_row", None) is not None: pos_name = f"{pos_name}_{grid.name}" event_kwargs["pos_name"] = pos_name # include position properties only when p-index changes diff --git a/src/useq/_plate.py b/src/useq/_plate.py index 3b692a4e..8b2c0842 100644 --- a/src/useq/_plate.py +++ b/src/useq/_plate.py @@ -418,7 +418,6 @@ def plot(self, show_axis: bool = True) -> None: plot_plate(self, show_axis=show_axis) - def _find_pattern(seq: Sequence[int]) -> tuple[list[int] | None, int | None]: n = len(seq) diff --git a/src/useq/_position.py b/src/useq/_position.py index e61b9736..ce43b6a7 100644 --- a/src/useq/_position.py +++ b/src/useq/_position.py @@ -99,7 +99,11 @@ def __round__(self, ndigits: "SupportsIndex | None" = None) -> "Self": @model_validator(mode="after") def _name_from_plate(self) -> "Self": """Auto-generate name from plate_row/plate_col if not provided.""" - if self.name is None and self.plate_row is not None and self.plate_col is not None: + if ( + self.name is None + and self.plate_row is not None + and self.plate_col is not None + ): name = f"{_index_to_row_name(self.plate_row)}{self.plate_col + 1}" object.__setattr__(self, "name", name) return self From c44ebc916d1920f9536751b0fec3332809ea73b2 Mon Sep 17 00:00:00 2001 From: Ivan Ivanov Date: Tue, 24 Mar 2026 18:26:12 -0700 Subject: [PATCH 07/20] docs: update docstrings for plate_row, plate_col, and name_pattern Co-Authored-By: Claude Opus 4.6 (1M context) --- src/useq/_grid.py | 3 +++ src/useq/_position.py | 9 ++++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/useq/_grid.py b/src/useq/_grid.py index 891befbf..5d23a46f 100644 --- a/src/useq/_grid.py +++ b/src/useq/_grid.py @@ -61,6 +61,9 @@ class _GridPlan(_MultiPointPlan[PositionT]): Height of the field of view in microns. If not provided, acquisition engines should use current height of the FOV based on the current objective and camera. Engines MAY override this even if provided. + name_pattern : str + Format pattern for grid position names. Supported variables are + ``{row}``, ``{col}``, and ``{idx}``. By default, ``"{idx:04d}"``. """ overlap: tuple[float, float] = Field(default=(0.0, 0.0), frozen=True) diff --git a/src/useq/_position.py b/src/useq/_position.py index ce43b6a7..2de044f1 100644 --- a/src/useq/_position.py +++ b/src/useq/_position.py @@ -40,9 +40,16 @@ class PositionBase(MutableModel): z : float | None Z position in microns. name : str | None - Optional name for the position. + Optional name for the position. If not provided but `plate_row` and + `plate_col` are set, a well name is auto-generated (e.g., "A1"). sequence : MDASequence | None Optional MDASequence relative this position. + plate_row : int | None + Optional 0-based row index for well plate positions. Used to indicate + which well this position belongs to for HCS data storage. + plate_col : int | None + Optional 0-based column index for well plate positions. Used together + with `plate_row` to identify the well. row : int | None Optional row index, when used in a grid. col : int | None From 4161d7d79a42e2b96698d14fdbdb64d3991657b8 Mon Sep 17 00:00:00 2001 From: Ivan Ivanov Date: Tue, 24 Mar 2026 18:40:46 -0700 Subject: [PATCH 08/20] feat: enforce name matches plate_row/plate_col When plate_row and plate_col are set, the position name is always derived from them (e.g., plate_row=0, plate_col=0 -> "A1"). Providing an explicit name that doesn't match raises ValueError. This ensures the position name and zarr well path are always coupled. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/useq/_position.py | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/src/useq/_position.py b/src/useq/_position.py index 2de044f1..fe8cea1c 100644 --- a/src/useq/_position.py +++ b/src/useq/_position.py @@ -40,8 +40,9 @@ class PositionBase(MutableModel): z : float | None Z position in microns. name : str | None - Optional name for the position. If not provided but `plate_row` and - `plate_col` are set, a well name is auto-generated (e.g., "A1"). + Optional name for the position. When `plate_row` and `plate_col` are + set, the name is always derived from them (e.g., "A1"). Providing a + name that doesn't match the plate coordinates raises a ValueError. sequence : MDASequence | None Optional MDASequence relative this position. plate_row : int | None @@ -105,14 +106,17 @@ def __round__(self, ndigits: "SupportsIndex | None" = None) -> "Self": @model_validator(mode="after") def _name_from_plate(self) -> "Self": - """Auto-generate name from plate_row/plate_col if not provided.""" - if ( - self.name is None - and self.plate_row is not None - and self.plate_col is not None - ): - name = f"{_index_to_row_name(self.plate_row)}{self.plate_col + 1}" - object.__setattr__(self, "name", name) + """Set name from plate_row/plate_col. Errors if an explicit name conflicts.""" + if self.plate_row is not None and self.plate_col is not None: + well_name = f"{_index_to_row_name(self.plate_row)}{self.plate_col + 1}" + if self.name is not None and self.name != well_name: + raise ValueError( + f"Position name {self.name!r} does not match plate_row=" + f"{self.plate_row}, plate_col={self.plate_col} (expected " + f"{well_name!r}). Remove the name to auto-generate it from " + f"plate coordinates." + ) + object.__setattr__(self, "name", well_name) return self @model_validator(mode="before") From b8dde703a8d90969888abc047a396b49e6a77727 Mon Sep 17 00:00:00 2001 From: Ivan Ivanov Date: Tue, 24 Mar 2026 18:46:52 -0700 Subject: [PATCH 09/20] feat: allow plate_row and plate_col to be int or str plate_row accepts 0-based int (0 -> "A") or str ("A"). plate_col accepts 0-based int (0 -> "1") or str ("1"). The well name is derived accordingly and validated against any explicit name provided. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/useq/_position.py | 32 +++++++++++++++++++++++--------- 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/src/useq/_position.py b/src/useq/_position.py index fe8cea1c..147d7019 100644 --- a/src/useq/_position.py +++ b/src/useq/_position.py @@ -45,12 +45,16 @@ class PositionBase(MutableModel): name that doesn't match the plate coordinates raises a ValueError. sequence : MDASequence | None Optional MDASequence relative this position. - plate_row : int | None - Optional 0-based row index for well plate positions. Used to indicate - which well this position belongs to for HCS data storage. - plate_col : int | None - Optional 0-based column index for well plate positions. Used together - with `plate_row` to identify the well. + plate_row : int | str | None + Row for well plate positions. Can be a 0-based index (e.g., 0 → "A", + 1 → "B") or a string name (e.g., "A", "B") used as-is. In YAML, + unquoted letters are parsed as strings (``plate_row: A`` works). + plate_col : int | str | None + Column for well plate positions. Can be a 0-based index (e.g., 0 → "1", + 1 → "2") or a string name (e.g., "1", "2") used as-is. In YAML, + unquoted numbers are parsed as int, so use quotes for string columns + (``plate_col: "1"`` for column name "1", vs ``plate_col: 1`` for + 0-based index 1 → column name "2"). row : int | None Optional row index, when used in a grid. col : int | None @@ -63,8 +67,8 @@ class PositionBase(MutableModel): name: str | None = None sequence: Optional["MDASequence"] = None properties: list[PropertyTuple] | None = None - plate_row: int | None = None - plate_col: int | None = None + plate_row: int | str | None = None + plate_col: int | str | None = None # excluded from serialization row: int | None = Field(default=None, exclude=True) @@ -108,7 +112,17 @@ def __round__(self, ndigits: "SupportsIndex | None" = None) -> "Self": def _name_from_plate(self) -> "Self": """Set name from plate_row/plate_col. Errors if an explicit name conflicts.""" if self.plate_row is not None and self.plate_col is not None: - well_name = f"{_index_to_row_name(self.plate_row)}{self.plate_col + 1}" + row_str = ( + _index_to_row_name(self.plate_row) + if isinstance(self.plate_row, int) + else str(self.plate_row) + ) + col_str = ( + str(self.plate_col + 1) + if isinstance(self.plate_col, int) + else str(self.plate_col) + ) + well_name = f"{row_str}{col_str}" if self.name is not None and self.name != well_name: raise ValueError( f"Position name {self.name!r} does not match plate_row=" From 3e2de02fb24ea3eb301342e964c6284db89e2943 Mon Sep 17 00:00:00 2001 From: Ivan Ivanov Date: Tue, 24 Mar 2026 19:07:47 -0700 Subject: [PATCH 10/20] test: add tests for plate_row/plate_col, name_pattern, and composite pos_name 26 new tests covering: - Name auto-generation from int and str plate_row/plate_col - Mismatched name validation (ValueError) - JSON and YAML serialization round-trips - Propagation through Position.__add__ - WellPlatePlan setting plate_row/plate_col on positions - Grid name_pattern (default, custom, serialization) - Composite MDAEvent.pos_name for plate positions with grid Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/test_plate_positions.py | 208 ++++++++++++++++++++++++++++++++++ 1 file changed, 208 insertions(+) create mode 100644 tests/test_plate_positions.py diff --git a/tests/test_plate_positions.py b/tests/test_plate_positions.py new file mode 100644 index 00000000..8b98775b --- /dev/null +++ b/tests/test_plate_positions.py @@ -0,0 +1,208 @@ +"""Tests for plate_row/plate_col, name_pattern, and composite pos_name.""" + +from __future__ import annotations + +import json + +import pytest +import yaml + +import useq + + +# --- plate_row / plate_col name generation ----------------------------------- + +_NAME_FROM_PLATE_CASES = [ + pytest.param({"plate_row": 0, "plate_col": 0}, "A1", id="int_0_0"), + pytest.param({"plate_row": 0, "plate_col": 1}, "A2", id="int_0_1"), + pytest.param({"plate_row": 1, "plate_col": 0}, "B1", id="int_1_0"), + pytest.param({"plate_row": 25, "plate_col": 0}, "Z1", id="int_25_0"), + pytest.param({"plate_row": 26, "plate_col": 0}, "AA1", id="int_26_0"), + pytest.param({"plate_row": "A", "plate_col": "1"}, "A1", id="str_A_1"), + pytest.param({"plate_row": "B", "plate_col": 2}, "B3", id="mixed_B_2"), +] + + +@pytest.mark.parametrize("kwargs, expected_name", _NAME_FROM_PLATE_CASES) +def test_name_from_plate(kwargs: dict, expected_name: str) -> None: + pos = useq.Position(**kwargs) + assert pos.name == expected_name + + +def test_matching_explicit_name_accepted() -> None: + pos = useq.Position(name="A1", plate_row=0, plate_col=0) + assert pos.name == "A1" + + +def test_mismatched_name_raises() -> None: + with pytest.raises(ValueError, match="does not match"): + useq.Position(name="B9", plate_row=0, plate_col=0) + + +def test_no_plate_coords_preserves_name() -> None: + assert useq.Position(name="my_pos").name == "my_pos" + assert useq.Position(x=100).name is None + + +# --- plate_row / plate_col serialization ------------------------------------- + + +def test_plate_json_round_trip() -> None: + pos = useq.Position(x=1000, y=2000, plate_row=0, plate_col=1) + data = json.loads(pos.model_dump_json()) + assert data["plate_row"] == 0 + assert data["plate_col"] == 1 + pos2 = useq.Position.model_validate(data) + assert pos2.plate_row == 0 + assert pos2.plate_col == 1 + assert pos2.name == "A2" + + +def test_plate_json_round_trip_str() -> None: + pos = useq.Position(plate_row="B", plate_col="3") + data = json.loads(pos.model_dump_json()) + assert data["plate_row"] == "B" + assert data["plate_col"] == "3" + assert useq.Position.model_validate(data).name == "B3" + + +def test_plate_yaml_round_trip() -> None: + seq = useq.MDASequence( + stage_positions=[useq.Position(x=1000, y=1000, plate_row=0, plate_col=0)] + ) + data = yaml.safe_load(seq.yaml()) + seq2 = useq.MDASequence(**data) + assert seq2.stage_positions[0].plate_row == 0 + assert seq2.stage_positions[0].plate_col == 0 + assert seq2.stage_positions[0].name == "A1" + + +# --- plate_row / plate_col propagation through __add__ ----------------------- + + +def test_plate_coords_propagate_through_add() -> None: + well = useq.Position(x=1000, y=1000, plate_row=0, plate_col=0) + offset = useq.RelativePosition(x=50, y=50, name="0000") + result = well + offset + assert result.plate_row == 0 + assert result.plate_col == 0 + assert result.name == "A1_0000" + + +# --- WellPlatePlan sets plate_row / plate_col -------------------------------- + + +def test_well_plate_plan_sets_plate_coords() -> None: + pp = useq.WellPlatePlan( + plate="24-well", + a1_center_xy=(0, 0), + selected_wells=([0, 1], [0, 1]), + ) + for pos in pp.selected_well_positions: + assert pos.plate_row is not None + assert pos.plate_col is not None + + +def test_well_plate_plan_image_positions_carry_plate_coords() -> None: + pp = useq.WellPlatePlan( + plate="24-well", + a1_center_xy=(0, 0), + selected_wells=([0], [0]), + well_points_plan=useq.GridRowsColumns( + rows=1, columns=2, fov_width=1, fov_height=1 + ), + ) + for pos in pp.image_positions: + assert pos.plate_row is not None + assert pos.plate_col is not None + + +# --- name_pattern on grid plans ---------------------------------------------- + + +def test_grid_default_name_pattern() -> None: + names = [p.name for p in useq.GridRowsColumns(rows=2, columns=2)] + assert names == ["0000", "0001", "0002", "0003"] + + +_CUSTOM_PATTERN_CASES = [ + pytest.param( + "row_{row:03d}_col_{col:04d}", + {"row_000_col_0000", "row_000_col_0001", "row_001_col_0001", "row_001_col_0000"}, + id="row_col", + ), + pytest.param( + "fov{idx}", + {"fov0", "fov1", "fov2", "fov3"}, + id="fov_idx", + ), + pytest.param( + "r{row}c{col}", + {"r0c0", "r0c1", "r1c0", "r1c1"}, + id="compact", + ), +] + + +@pytest.mark.parametrize("pattern, expected_names", _CUSTOM_PATTERN_CASES) +def test_grid_custom_name_pattern(pattern: str, expected_names: set[str]) -> None: + grid = useq.GridRowsColumns(rows=2, columns=2, name_pattern=pattern) + names = {p.name for p in grid} + assert names == expected_names + + +def test_grid_name_pattern_serialization() -> None: + grid = useq.GridRowsColumns(rows=2, columns=2, name_pattern="site_{idx:04d}") + data = json.loads(grid.model_dump_json()) + assert data["name_pattern"] == "site_{idx:04d}" + grid2 = useq.GridRowsColumns.model_validate(data) + assert grid2.name_pattern == "site_{idx:04d}" + + +def test_grid_name_pattern_from_yaml() -> None: + data = yaml.safe_load('rows: 2\ncolumns: 2\nname_pattern: "r{row}c{col}"') + grid = useq.GridRowsColumns(**data) + assert {p.name for p in grid} == {"r0c0", "r0c1", "r1c0", "r1c1"} + + +# --- composite pos_name in MDAEvent ------------------------------------------ + + +def test_plate_position_with_grid_composite_pos_name() -> None: + """Plate positions with grid get composite pos_name: 'A1_0000'.""" + seq = useq.MDASequence( + stage_positions=[useq.Position(plate_row=0, plate_col=0)], + grid_plan=useq.GridRowsColumns(rows=1, columns=2, fov_width=180, fov_height=180), + ) + events = list(seq) + assert events[0].pos_name == "A1_0000" + assert events[1].pos_name == "A1_0001" + + +def test_regular_position_with_grid_no_composite() -> None: + """Non-plate positions should NOT get grid name appended.""" + seq = useq.MDASequence( + stage_positions=[useq.Position(x=1000, y=1000, name="MyPos")], + grid_plan=useq.GridRowsColumns(rows=1, columns=2, fov_width=180, fov_height=180), + ) + events = list(seq) + assert all(e.pos_name == "MyPos" for e in events) + + +def test_plate_position_without_grid_pos_name() -> None: + seq = useq.MDASequence( + stage_positions=[useq.Position(plate_row=0, plate_col=0)] + ) + assert list(seq)[0].pos_name == "A1" + + +def test_multiple_wells_with_grid_pos_names() -> None: + seq = useq.MDASequence( + stage_positions=[ + useq.Position(x=1000, y=1000, plate_row=0, plate_col=0), + useq.Position(x=2000, y=2000, plate_row=1, plate_col=1), + ], + grid_plan=useq.GridRowsColumns(rows=1, columns=2, fov_width=180, fov_height=180), + ) + pos_names = {e.pos_name for e in seq} + assert pos_names == {"A1_0000", "A1_0001", "B2_0000", "B2_0001"} From 0d8a19a85b714a0518130c0e3de6a1b0e1c88f05 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 25 Mar 2026 02:08:01 +0000 Subject: [PATCH 11/20] style(pre-commit.ci): auto fixes [...] --- tests/test_plate_positions.py | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/tests/test_plate_positions.py b/tests/test_plate_positions.py index 8b98775b..89b5d92b 100644 --- a/tests/test_plate_positions.py +++ b/tests/test_plate_positions.py @@ -9,7 +9,6 @@ import useq - # --- plate_row / plate_col name generation ----------------------------------- _NAME_FROM_PLATE_CASES = [ @@ -128,7 +127,12 @@ def test_grid_default_name_pattern() -> None: _CUSTOM_PATTERN_CASES = [ pytest.param( "row_{row:03d}_col_{col:04d}", - {"row_000_col_0000", "row_000_col_0001", "row_001_col_0001", "row_001_col_0000"}, + { + "row_000_col_0000", + "row_000_col_0001", + "row_001_col_0001", + "row_001_col_0000", + }, id="row_col", ), pytest.param( @@ -172,7 +176,9 @@ def test_plate_position_with_grid_composite_pos_name() -> None: """Plate positions with grid get composite pos_name: 'A1_0000'.""" seq = useq.MDASequence( stage_positions=[useq.Position(plate_row=0, plate_col=0)], - grid_plan=useq.GridRowsColumns(rows=1, columns=2, fov_width=180, fov_height=180), + grid_plan=useq.GridRowsColumns( + rows=1, columns=2, fov_width=180, fov_height=180 + ), ) events = list(seq) assert events[0].pos_name == "A1_0000" @@ -183,17 +189,17 @@ def test_regular_position_with_grid_no_composite() -> None: """Non-plate positions should NOT get grid name appended.""" seq = useq.MDASequence( stage_positions=[useq.Position(x=1000, y=1000, name="MyPos")], - grid_plan=useq.GridRowsColumns(rows=1, columns=2, fov_width=180, fov_height=180), + grid_plan=useq.GridRowsColumns( + rows=1, columns=2, fov_width=180, fov_height=180 + ), ) events = list(seq) assert all(e.pos_name == "MyPos" for e in events) def test_plate_position_without_grid_pos_name() -> None: - seq = useq.MDASequence( - stage_positions=[useq.Position(plate_row=0, plate_col=0)] - ) - assert list(seq)[0].pos_name == "A1" + seq = useq.MDASequence(stage_positions=[useq.Position(plate_row=0, plate_col=0)]) + assert next(iter(seq)).pos_name == "A1" def test_multiple_wells_with_grid_pos_names() -> None: @@ -202,7 +208,9 @@ def test_multiple_wells_with_grid_pos_names() -> None: useq.Position(x=1000, y=1000, plate_row=0, plate_col=0), useq.Position(x=2000, y=2000, plate_row=1, plate_col=1), ], - grid_plan=useq.GridRowsColumns(rows=1, columns=2, fov_width=180, fov_height=180), + grid_plan=useq.GridRowsColumns( + rows=1, columns=2, fov_width=180, fov_height=180 + ), ) pos_names = {e.pos_name for e in seq} assert pos_names == {"A1_0000", "A1_0001", "B2_0000", "B2_0001"} From 439849c76761987ab0b284c84e7b3419a9487ffc Mon Sep 17 00:00:00 2001 From: Ivan Ivanov Date: Tue, 24 Mar 2026 19:20:40 -0700 Subject: [PATCH 12/20] feat: relax name validation for string plate_row/plate_col When plate_row and plate_col are both int (standard well-plate indices), the name is strictly derived (e.g., 0,0 -> "A1") and mismatches raise ValueError. When either is a str (custom naming like "fish0", "neuromast0"), any explicit name is accepted, allowing free-form naming for non-standard plate layouts. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/useq/_position.py | 50 +++++++++++++++++++++-------------- tests/test_plate_positions.py | 19 +++++++++++-- 2 files changed, 47 insertions(+), 22 deletions(-) diff --git a/src/useq/_position.py b/src/useq/_position.py index 147d7019..6f54de57 100644 --- a/src/useq/_position.py +++ b/src/useq/_position.py @@ -41,8 +41,10 @@ class PositionBase(MutableModel): Z position in microns. name : str | None Optional name for the position. When `plate_row` and `plate_col` are - set, the name is always derived from them (e.g., "A1"). Providing a - name that doesn't match the plate coordinates raises a ValueError. + both int, the name is derived from them (e.g., "A1") and providing a + mismatched name raises ValueError. When either is a str (custom + naming), the name defaults to ``f"{plate_row}{plate_col}"`` but can + be freely overridden. sequence : MDASequence | None Optional MDASequence relative this position. plate_row : int | str | None @@ -110,27 +112,35 @@ def __round__(self, ndigits: "SupportsIndex | None" = None) -> "Self": @model_validator(mode="after") def _name_from_plate(self) -> "Self": - """Set name from plate_row/plate_col. Errors if an explicit name conflicts.""" + """Set or validate name from plate_row/plate_col. + + When both plate_row and plate_col are int (standard well-plate indices), + the name is derived as e.g. "A1" and an explicit mismatch raises + ValueError. When either is a str (custom naming), the name is only + auto-generated if not provided; explicit names are accepted as-is. + """ if self.plate_row is not None and self.plate_col is not None: - row_str = ( - _index_to_row_name(self.plate_row) - if isinstance(self.plate_row, int) - else str(self.plate_row) - ) - col_str = ( - str(self.plate_col + 1) - if isinstance(self.plate_col, int) - else str(self.plate_col) + both_int = isinstance(self.plate_row, int) and isinstance( + self.plate_col, int ) - well_name = f"{row_str}{col_str}" - if self.name is not None and self.name != well_name: - raise ValueError( - f"Position name {self.name!r} does not match plate_row=" - f"{self.plate_row}, plate_col={self.plate_col} (expected " - f"{well_name!r}). Remove the name to auto-generate it from " - f"plate coordinates." + if both_int: + well_name = ( + f"{_index_to_row_name(self.plate_row)}{self.plate_col + 1}" ) - object.__setattr__(self, "name", well_name) + if self.name is not None and self.name != well_name: + raise ValueError( + f"Position name {self.name!r} does not match " + f"plate_row={self.plate_row}, plate_col=" + f"{self.plate_col} (expected {well_name!r}). " + f"Remove the name to auto-generate it from " + f"plate coordinates." + ) + object.__setattr__(self, "name", well_name) + elif self.name is None: + # String plate coords: auto-generate only if no name provided + row_str = str(self.plate_row) + col_str = str(self.plate_col) + object.__setattr__(self, "name", f"{row_str}{col_str}") return self @model_validator(mode="before") diff --git a/tests/test_plate_positions.py b/tests/test_plate_positions.py index 89b5d92b..82268f76 100644 --- a/tests/test_plate_positions.py +++ b/tests/test_plate_positions.py @@ -18,7 +18,12 @@ pytest.param({"plate_row": 25, "plate_col": 0}, "Z1", id="int_25_0"), pytest.param({"plate_row": 26, "plate_col": 0}, "AA1", id="int_26_0"), pytest.param({"plate_row": "A", "plate_col": "1"}, "A1", id="str_A_1"), - pytest.param({"plate_row": "B", "plate_col": 2}, "B3", id="mixed_B_2"), + pytest.param({"plate_row": "B", "plate_col": 2}, "B2", id="mixed_B_2"), + pytest.param( + {"plate_row": "fish0", "plate_col": "neuromast0"}, + "fish0neuromast0", + id="str_custom", + ), ] @@ -33,11 +38,21 @@ def test_matching_explicit_name_accepted() -> None: assert pos.name == "A1" -def test_mismatched_name_raises() -> None: +def test_mismatched_name_raises_for_int_plate() -> None: + """Int plate coords enforce name == well name.""" with pytest.raises(ValueError, match="does not match"): useq.Position(name="B9", plate_row=0, plate_col=0) +def test_str_plate_coords_allow_custom_name() -> None: + """Str plate coords accept any explicit name.""" + pos = useq.Position(plate_row="fish0", plate_col="neuromast0", name="0") + assert pos.name == "0" + + pos2 = useq.Position(plate_row="A", plate_col=0, name="site1") + assert pos2.name == "site1" + + def test_no_plate_coords_preserves_name() -> None: assert useq.Position(name="my_pos").name == "my_pos" assert useq.Position(x=100).name is None From 013dfda7d9c988c9d6f7595f7f81cb56a77eda70 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 25 Mar 2026 02:21:33 +0000 Subject: [PATCH 13/20] style(pre-commit.ci): auto fixes [...] --- src/useq/_position.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/useq/_position.py b/src/useq/_position.py index 6f54de57..20332208 100644 --- a/src/useq/_position.py +++ b/src/useq/_position.py @@ -124,9 +124,7 @@ def _name_from_plate(self) -> "Self": self.plate_col, int ) if both_int: - well_name = ( - f"{_index_to_row_name(self.plate_row)}{self.plate_col + 1}" - ) + well_name = f"{_index_to_row_name(self.plate_row)}{self.plate_col + 1}" if self.name is not None and self.name != well_name: raise ValueError( f"Position name {self.name!r} does not match " From 5cbb6f1d5853014897882b1087124315025a0ee2 Mon Sep 17 00:00:00 2001 From: Ivan Ivanov Date: Tue, 24 Mar 2026 19:24:51 -0700 Subject: [PATCH 14/20] fix: resolve mypy type narrowing for plate_row/plate_col Use inline isinstance checks instead of a variable so mypy can narrow int|str to int within the branch. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/useq/_position.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/useq/_position.py b/src/useq/_position.py index 20332208..62441e5e 100644 --- a/src/useq/_position.py +++ b/src/useq/_position.py @@ -120,11 +120,10 @@ def _name_from_plate(self) -> "Self": auto-generated if not provided; explicit names are accepted as-is. """ if self.plate_row is not None and self.plate_col is not None: - both_int = isinstance(self.plate_row, int) and isinstance( - self.plate_col, int - ) - if both_int: - well_name = f"{_index_to_row_name(self.plate_row)}{self.plate_col + 1}" + if isinstance(self.plate_row, int) and isinstance(self.plate_col, int): + well_name = ( + f"{_index_to_row_name(self.plate_row)}{self.plate_col + 1}" + ) if self.name is not None and self.name != well_name: raise ValueError( f"Position name {self.name!r} does not match " From 710ee3cf38fb3a58c988978e51496f3515f8168a Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 25 Mar 2026 02:25:14 +0000 Subject: [PATCH 15/20] style(pre-commit.ci): auto fixes [...] --- src/useq/_position.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/useq/_position.py b/src/useq/_position.py index 62441e5e..e6a3f82b 100644 --- a/src/useq/_position.py +++ b/src/useq/_position.py @@ -121,9 +121,7 @@ def _name_from_plate(self) -> "Self": """ if self.plate_row is not None and self.plate_col is not None: if isinstance(self.plate_row, int) and isinstance(self.plate_col, int): - well_name = ( - f"{_index_to_row_name(self.plate_row)}{self.plate_col + 1}" - ) + well_name = f"{_index_to_row_name(self.plate_row)}{self.plate_col + 1}" if self.name is not None and self.name != well_name: raise ValueError( f"Position name {self.name!r} does not match " From 3fe9125ca21c6eb7e455957af12b69213aeb7f68 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 30 Mar 2026 08:09:21 -0400 Subject: [PATCH 16/20] Apply suggestion from @tlambert03 --- src/useq/_grid.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/useq/_grid.py b/src/useq/_grid.py index 5d23a46f..cd4ea248 100644 --- a/src/useq/_grid.py +++ b/src/useq/_grid.py @@ -63,7 +63,7 @@ class _GridPlan(_MultiPointPlan[PositionT]): Engines MAY override this even if provided. name_pattern : str Format pattern for grid position names. Supported variables are - ``{row}``, ``{col}``, and ``{idx}``. By default, ``"{idx:04d}"``. + `{row}`, `{col}`, and `{idx}`. By default, `"{idx:04d}"`. """ overlap: tuple[float, float] = Field(default=(0.0, 0.0), frozen=True) From e94b678d5cbfbc4a1dbe13f31189c9a16845af45 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 30 Mar 2026 08:46:51 -0400 Subject: [PATCH 17/20] minimal diff --- src/useq/_grid.py | 19 +--- src/useq/_iter_sequence.py | 5 +- src/useq/_plate.py | 11 ++- src/useq/_position.py | 61 ++---------- tests/fixtures/mda.json | 3 +- tests/fixtures/mda.yaml | 8 +- tests/test_plate_positions.py | 179 +++------------------------------- 7 files changed, 40 insertions(+), 246 deletions(-) diff --git a/src/useq/_grid.py b/src/useq/_grid.py index cd4ea248..54418423 100644 --- a/src/useq/_grid.py +++ b/src/useq/_grid.py @@ -61,19 +61,10 @@ class _GridPlan(_MultiPointPlan[PositionT]): Height of the field of view in microns. If not provided, acquisition engines should use current height of the FOV based on the current objective and camera. Engines MAY override this even if provided. - name_pattern : str - Format pattern for grid position names. Supported variables are - `{row}`, `{col}`, and `{idx}`. By default, `"{idx:04d}"`. """ overlap: tuple[float, float] = Field(default=(0.0, 0.0), frozen=True) mode: OrderMode = Field(default=OrderMode.row_wise_snake, frozen=True) - name_pattern: str = Field( - default="{idx:04d}", - frozen=True, - description="Format pattern for grid position names. " - "Supported variables: {row}, {col}, {idx}.", - ) @field_validator("overlap", mode="before") @classmethod @@ -146,7 +137,7 @@ def iter_grid_positions( y=y0 - r * dy, row=r, col=c, - name=self.name_pattern.format(row=r, col=c, idx=idx), + name=f"{str(idx).zfill(4)}", ) def __iter__(self) -> Iterator[PositionT]: # type: ignore [override] @@ -526,13 +517,7 @@ def iter_grid_positions( except ValueError: pos = [] for idx, (x, y, r, c) in enumerate(pos): - yield AbsolutePosition( - x=x, - y=y, - row=r, - col=c, - name=self.name_pattern.format(row=r, col=c, idx=idx), - ) + yield AbsolutePosition(x=x, y=y, row=r, col=c, name=f"{str(idx).zfill(4)}") def _cached_tiles( self, diff --git a/src/useq/_iter_sequence.py b/src/useq/_iter_sequence.py index a521db2a..3c3ece54 100644 --- a/src/useq/_iter_sequence.py +++ b/src/useq/_iter_sequence.py @@ -174,10 +174,7 @@ 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: - pos_name = position.name - if grid and grid.name and getattr(position, "plate_row", None) is not None: - pos_name = f"{pos_name}_{grid.name}" - event_kwargs["pos_name"] = pos_name + event_kwargs["pos_name"] = position.name # include position properties only when p-index changes p_idx = index.get(Axis.POSITION, -1) if position and position.properties and p_idx != _last_p_idx: diff --git a/src/useq/_plate.py b/src/useq/_plate.py index 8b2c0842..bea30bca 100644 --- a/src/useq/_plate.py +++ b/src/useq/_plate.py @@ -24,7 +24,7 @@ from useq._enums import Shape from useq._grid import RandomPoints, RelativeMultiPointPlan from useq._plate_registry import _PLATE_REGISTRY -from useq._position import Position, PositionBase, RelativePosition, _index_to_row_name +from useq._position import Position, PositionBase, RelativePosition if TYPE_CHECKING: from pydantic_core import core_schema @@ -418,6 +418,15 @@ def plot(self, show_axis: bool = True) -> None: plot_plate(self, show_axis=show_axis) +def _index_to_row_name(index: int) -> str: + """Convert a zero-based column index to row name (A, B, ..., Z, AA, AB, ...).""" + name = "" + while index >= 0: + name = chr(index % 26 + 65) + name + index = index // 26 - 1 + return name + + def _find_pattern(seq: Sequence[int]) -> tuple[list[int] | None, int | None]: n = len(seq) diff --git a/src/useq/_position.py b/src/useq/_position.py index e6a3f82b..c7c0b51b 100644 --- a/src/useq/_position.py +++ b/src/useq/_position.py @@ -15,15 +15,6 @@ from useq import MDASequence -def _index_to_row_name(index: int) -> str: - """Convert a zero-based row index to name (A, B, ..., Z, AA, AB, ...).""" - name = "" - while index >= 0: - name = chr(index % 26 + 65) + name - index = index // 26 - 1 - return name - - class PositionBase(MutableModel): """Define a position in 3D space. @@ -40,23 +31,13 @@ class PositionBase(MutableModel): z : float | None Z position in microns. name : str | None - Optional name for the position. When `plate_row` and `plate_col` are - both int, the name is derived from them (e.g., "A1") and providing a - mismatched name raises ValueError. When either is a str (custom - naming), the name defaults to ``f"{plate_row}{plate_col}"`` but can - be freely overridden. + Optional name for the position. sequence : MDASequence | None Optional MDASequence relative this position. - plate_row : int | str | None - Row for well plate positions. Can be a 0-based index (e.g., 0 → "A", - 1 → "B") or a string name (e.g., "A", "B") used as-is. In YAML, - unquoted letters are parsed as strings (``plate_row: A`` works). - plate_col : int | str | None - Column for well plate positions. Can be a 0-based index (e.g., 0 → "1", - 1 → "2") or a string name (e.g., "1", "2") used as-is. In YAML, - unquoted numbers are parsed as int, so use quotes for string columns - (``plate_col: "1"`` for column name "1", vs ``plate_col: 1`` for - 0-based index 1 → column name "2"). + plate_row : int | None + Optional 0-based row index for well plate positions. + plate_col : int | None + Optional 0-based column index for well plate positions. row : int | None Optional row index, when used in a grid. col : int | None @@ -69,8 +50,8 @@ class PositionBase(MutableModel): name: str | None = None sequence: Optional["MDASequence"] = None properties: list[PropertyTuple] | None = None - plate_row: int | str | None = None - plate_col: int | str | None = None + plate_row: int | None = None + plate_col: int | None = None # excluded from serialization row: int | None = Field(default=None, exclude=True) @@ -110,34 +91,6 @@ def __round__(self, ndigits: "SupportsIndex | None" = None) -> "Self": # not sure why these Self types are not working return type(self).model_construct(**kwargs) # type: ignore [return-value] - @model_validator(mode="after") - def _name_from_plate(self) -> "Self": - """Set or validate name from plate_row/plate_col. - - When both plate_row and plate_col are int (standard well-plate indices), - the name is derived as e.g. "A1" and an explicit mismatch raises - ValueError. When either is a str (custom naming), the name is only - auto-generated if not provided; explicit names are accepted as-is. - """ - if self.plate_row is not None and self.plate_col is not None: - if isinstance(self.plate_row, int) and isinstance(self.plate_col, int): - well_name = f"{_index_to_row_name(self.plate_row)}{self.plate_col + 1}" - if self.name is not None and self.name != well_name: - raise ValueError( - f"Position name {self.name!r} does not match " - f"plate_row={self.plate_row}, plate_col=" - f"{self.plate_col} (expected {well_name!r}). " - f"Remove the name to auto-generate it from " - f"plate coordinates." - ) - object.__setattr__(self, "name", well_name) - elif self.name is None: - # String plate coords: auto-generate only if no name provided - row_str = str(self.plate_row) - col_str = str(self.plate_col) - object.__setattr__(self, "name", f"{row_str}{col_str}") - return self - @model_validator(mode="before") @classmethod def _cast(cls, value: Any) -> Any: diff --git a/tests/fixtures/mda.json b/tests/fixtures/mda.json index 007a2e86..80f4916a 100644 --- a/tests/fixtures/mda.json +++ b/tests/fixtures/mda.json @@ -47,7 +47,6 @@ "fov_height": null, "fov_width": null, "mode": "row_wise_snake", - "name_pattern": "{idx:04d}", "overlap": [ 0.0, 0.0 @@ -91,7 +90,6 @@ "fov_height": null, "fov_width": null, "mode": "row_wise_snake", - "name_pattern": "{idx:04d}", "overlap": [ 0.0, 0.0 @@ -111,6 +109,7 @@ "step": 1.0 } }, + "properties": null, "x": 10.0, "y": 20.0, "z": 50.0 diff --git a/tests/fixtures/mda.yaml b/tests/fixtures/mda.yaml index 31d9195b..5fea5c92 100644 --- a/tests/fixtures/mda.yaml +++ b/tests/fixtures/mda.yaml @@ -25,11 +25,17 @@ metadata: stage_positions: - x: 10.0 y: 20.0 - z: null - name: test_name sequence: + axis_order: + - t + - p + - g + - c + - z grid_plan: columns: 3 + mode: row_wise_snake rows: 2 z_plan: above: 10.0 diff --git a/tests/test_plate_positions.py b/tests/test_plate_positions.py index 82268f76..005372db 100644 --- a/tests/test_plate_positions.py +++ b/tests/test_plate_positions.py @@ -1,64 +1,24 @@ -"""Tests for plate_row/plate_col, name_pattern, and composite pos_name.""" +"""Tests for plate_row/plate_col on Position.""" from __future__ import annotations import json -import pytest import yaml import useq -# --- plate_row / plate_col name generation ----------------------------------- -_NAME_FROM_PLATE_CASES = [ - pytest.param({"plate_row": 0, "plate_col": 0}, "A1", id="int_0_0"), - pytest.param({"plate_row": 0, "plate_col": 1}, "A2", id="int_0_1"), - pytest.param({"plate_row": 1, "plate_col": 0}, "B1", id="int_1_0"), - pytest.param({"plate_row": 25, "plate_col": 0}, "Z1", id="int_25_0"), - pytest.param({"plate_row": 26, "plate_col": 0}, "AA1", id="int_26_0"), - pytest.param({"plate_row": "A", "plate_col": "1"}, "A1", id="str_A_1"), - pytest.param({"plate_row": "B", "plate_col": 2}, "B2", id="mixed_B_2"), - pytest.param( - {"plate_row": "fish0", "plate_col": "neuromast0"}, - "fish0neuromast0", - id="str_custom", - ), -] - - -@pytest.mark.parametrize("kwargs, expected_name", _NAME_FROM_PLATE_CASES) -def test_name_from_plate(kwargs: dict, expected_name: str) -> None: - pos = useq.Position(**kwargs) - assert pos.name == expected_name - - -def test_matching_explicit_name_accepted() -> None: - pos = useq.Position(name="A1", plate_row=0, plate_col=0) - assert pos.name == "A1" - - -def test_mismatched_name_raises_for_int_plate() -> None: - """Int plate coords enforce name == well name.""" - with pytest.raises(ValueError, match="does not match"): - useq.Position(name="B9", plate_row=0, plate_col=0) - - -def test_str_plate_coords_allow_custom_name() -> None: - """Str plate coords accept any explicit name.""" - pos = useq.Position(plate_row="fish0", plate_col="neuromast0", name="0") - assert pos.name == "0" - - pos2 = useq.Position(plate_row="A", plate_col=0, name="site1") - assert pos2.name == "site1" - - -def test_no_plate_coords_preserves_name() -> None: - assert useq.Position(name="my_pos").name == "my_pos" - assert useq.Position(x=100).name is None +def test_position_plate_row_col() -> None: + pos = useq.Position(x=1000, y=2000, plate_row=0, plate_col=1) + assert pos.plate_row == 0 + assert pos.plate_col == 1 -# --- plate_row / plate_col serialization ------------------------------------- +def test_position_no_plate_coords() -> None: + pos = useq.Position(x=100) + assert pos.plate_row is None + assert pos.plate_col is None def test_plate_json_round_trip() -> None: @@ -69,15 +29,6 @@ def test_plate_json_round_trip() -> None: pos2 = useq.Position.model_validate(data) assert pos2.plate_row == 0 assert pos2.plate_col == 1 - assert pos2.name == "A2" - - -def test_plate_json_round_trip_str() -> None: - pos = useq.Position(plate_row="B", plate_col="3") - data = json.loads(pos.model_dump_json()) - assert data["plate_row"] == "B" - assert data["plate_col"] == "3" - assert useq.Position.model_validate(data).name == "B3" def test_plate_yaml_round_trip() -> None: @@ -88,22 +39,14 @@ def test_plate_yaml_round_trip() -> None: seq2 = useq.MDASequence(**data) assert seq2.stage_positions[0].plate_row == 0 assert seq2.stage_positions[0].plate_col == 0 - assert seq2.stage_positions[0].name == "A1" - - -# --- plate_row / plate_col propagation through __add__ ----------------------- def test_plate_coords_propagate_through_add() -> None: - well = useq.Position(x=1000, y=1000, plate_row=0, plate_col=0) + well = useq.Position(x=1000, y=1000, plate_row=0, plate_col=0, name="A1") offset = useq.RelativePosition(x=50, y=50, name="0000") result = well + offset assert result.plate_row == 0 assert result.plate_col == 0 - assert result.name == "A1_0000" - - -# --- WellPlatePlan sets plate_row / plate_col -------------------------------- def test_well_plate_plan_sets_plate_coords() -> None: @@ -115,6 +58,8 @@ def test_well_plate_plan_sets_plate_coords() -> None: for pos in pp.selected_well_positions: assert pos.plate_row is not None assert pos.plate_col is not None + assert isinstance(pos.plate_row, int) + assert isinstance(pos.plate_col, int) def test_well_plate_plan_image_positions_carry_plate_coords() -> None: @@ -129,103 +74,3 @@ def test_well_plate_plan_image_positions_carry_plate_coords() -> None: for pos in pp.image_positions: assert pos.plate_row is not None assert pos.plate_col is not None - - -# --- name_pattern on grid plans ---------------------------------------------- - - -def test_grid_default_name_pattern() -> None: - names = [p.name for p in useq.GridRowsColumns(rows=2, columns=2)] - assert names == ["0000", "0001", "0002", "0003"] - - -_CUSTOM_PATTERN_CASES = [ - pytest.param( - "row_{row:03d}_col_{col:04d}", - { - "row_000_col_0000", - "row_000_col_0001", - "row_001_col_0001", - "row_001_col_0000", - }, - id="row_col", - ), - pytest.param( - "fov{idx}", - {"fov0", "fov1", "fov2", "fov3"}, - id="fov_idx", - ), - pytest.param( - "r{row}c{col}", - {"r0c0", "r0c1", "r1c0", "r1c1"}, - id="compact", - ), -] - - -@pytest.mark.parametrize("pattern, expected_names", _CUSTOM_PATTERN_CASES) -def test_grid_custom_name_pattern(pattern: str, expected_names: set[str]) -> None: - grid = useq.GridRowsColumns(rows=2, columns=2, name_pattern=pattern) - names = {p.name for p in grid} - assert names == expected_names - - -def test_grid_name_pattern_serialization() -> None: - grid = useq.GridRowsColumns(rows=2, columns=2, name_pattern="site_{idx:04d}") - data = json.loads(grid.model_dump_json()) - assert data["name_pattern"] == "site_{idx:04d}" - grid2 = useq.GridRowsColumns.model_validate(data) - assert grid2.name_pattern == "site_{idx:04d}" - - -def test_grid_name_pattern_from_yaml() -> None: - data = yaml.safe_load('rows: 2\ncolumns: 2\nname_pattern: "r{row}c{col}"') - grid = useq.GridRowsColumns(**data) - assert {p.name for p in grid} == {"r0c0", "r0c1", "r1c0", "r1c1"} - - -# --- composite pos_name in MDAEvent ------------------------------------------ - - -def test_plate_position_with_grid_composite_pos_name() -> None: - """Plate positions with grid get composite pos_name: 'A1_0000'.""" - seq = useq.MDASequence( - stage_positions=[useq.Position(plate_row=0, plate_col=0)], - grid_plan=useq.GridRowsColumns( - rows=1, columns=2, fov_width=180, fov_height=180 - ), - ) - events = list(seq) - assert events[0].pos_name == "A1_0000" - assert events[1].pos_name == "A1_0001" - - -def test_regular_position_with_grid_no_composite() -> None: - """Non-plate positions should NOT get grid name appended.""" - seq = useq.MDASequence( - stage_positions=[useq.Position(x=1000, y=1000, name="MyPos")], - grid_plan=useq.GridRowsColumns( - rows=1, columns=2, fov_width=180, fov_height=180 - ), - ) - events = list(seq) - assert all(e.pos_name == "MyPos" for e in events) - - -def test_plate_position_without_grid_pos_name() -> None: - seq = useq.MDASequence(stage_positions=[useq.Position(plate_row=0, plate_col=0)]) - assert next(iter(seq)).pos_name == "A1" - - -def test_multiple_wells_with_grid_pos_names() -> None: - seq = useq.MDASequence( - stage_positions=[ - useq.Position(x=1000, y=1000, plate_row=0, plate_col=0), - useq.Position(x=2000, y=2000, plate_row=1, plate_col=1), - ], - grid_plan=useq.GridRowsColumns( - rows=1, columns=2, fov_width=180, fov_height=180 - ), - ) - pos_names = {e.pos_name for e in seq} - assert pos_names == {"A1_0000", "A1_0001", "B2_0000", "B2_0001"} From 4390d1cba093481d56731381765b991da4a97be9 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 30 Mar 2026 09:08:55 -0400 Subject: [PATCH 18/20] don't exclude fields from ser --- src/useq/_position.py | 8 +++----- tests/fixtures/mda.json | 4 ++++ 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/useq/_position.py b/src/useq/_position.py index c7c0b51b..b8a4a432 100644 --- a/src/useq/_position.py +++ b/src/useq/_position.py @@ -3,7 +3,7 @@ from typing import TYPE_CHECKING, Any, Generic, Optional, SupportsIndex, TypeVar import numpy as np -from pydantic import Field, model_validator +from pydantic import model_validator from useq._base_model import FrozenModel, MutableModel from useq._mda_event import PropertyTuple @@ -52,10 +52,8 @@ class PositionBase(MutableModel): properties: list[PropertyTuple] | None = None plate_row: int | None = None plate_col: int | None = None - - # excluded from serialization - row: int | None = Field(default=None, exclude=True) - col: int | None = Field(default=None, exclude=True) + row: int | None = None + col: int | None = None def __add__(self, other: "RelativePosition") -> "Self": """Add two positions together to create a new position.""" diff --git a/tests/fixtures/mda.json b/tests/fixtures/mda.json index 80f4916a..79a59ab2 100644 --- a/tests/fixtures/mda.json +++ b/tests/fixtures/mda.json @@ -61,20 +61,24 @@ "setup": null, "stage_positions": [ { + "col": null, "name": null, "plate_col": null, "plate_row": null, "properties": null, + "row": null, "sequence": null, "x": 10.0, "y": 20.0, "z": null }, { + "col": null, "name": "test_name", "plate_col": null, "plate_row": null, "properties": null, + "row": null, "sequence": { "autofocus_plan": null, "axis_order": [ From ead12dc64dacc19cdff8f1fd6b07141788717e5a Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 30 Mar 2026 09:13:50 -0400 Subject: [PATCH 19/20] Rename row and col attributes to grid_row and grid_col in PositionBase and related classes --- src/useq/_grid.py | 8 +++++--- src/useq/_position.py | 32 ++++++++++++++++++++++++++++---- tests/fixtures/mda.json | 9 ++++----- 3 files changed, 37 insertions(+), 12 deletions(-) diff --git a/src/useq/_grid.py b/src/useq/_grid.py index 54418423..6d91bf4a 100644 --- a/src/useq/_grid.py +++ b/src/useq/_grid.py @@ -135,8 +135,8 @@ def iter_grid_positions( yield pos_cls( # type: ignore [misc] x=x0 + c * dx, y=y0 - r * dy, - row=r, - col=c, + grid_row=r, + grid_col=c, name=f"{str(idx).zfill(4)}", ) @@ -517,7 +517,9 @@ def iter_grid_positions( except ValueError: pos = [] for idx, (x, y, r, c) in enumerate(pos): - yield AbsolutePosition(x=x, y=y, row=r, col=c, name=f"{str(idx).zfill(4)}") + yield AbsolutePosition( + x=x, y=y, grid_row=r, grid_col=c, name=f"{str(idx).zfill(4)}" + ) def _cached_tiles( self, diff --git a/src/useq/_position.py b/src/useq/_position.py index b8a4a432..6757dcdb 100644 --- a/src/useq/_position.py +++ b/src/useq/_position.py @@ -4,6 +4,7 @@ import numpy as np from pydantic import model_validator +from typing_extensions import deprecated from useq._base_model import FrozenModel, MutableModel from useq._mda_event import PropertyTuple @@ -38,9 +39,9 @@ class PositionBase(MutableModel): Optional 0-based row index for well plate positions. plate_col : int | None Optional 0-based column index for well plate positions. - row : int | None + grid_row : int | None Optional row index, when used in a grid. - col : int | None + grid_col : int | None Optional column index, when used in a grid. """ @@ -52,8 +53,26 @@ class PositionBase(MutableModel): properties: list[PropertyTuple] | None = None plate_row: int | None = None plate_col: int | None = None - row: int | None = None - col: int | None = None + grid_row: int | None = None + grid_col: int | None = None + + @property + @deprecated("Use 'grid_row' instead.") + def row(self) -> int | None: + return self.grid_row + + @row.setter + def row(self, value: int | None) -> None: + self.grid_row = value + + @property + @deprecated("Use 'grid_col' instead.") + def col(self) -> int | None: + return self.grid_col + + @col.setter + def col(self, value: int | None) -> None: + self.grid_col = value def __add__(self, other: "RelativePosition") -> "Self": """Add two positions together to create a new position.""" @@ -92,6 +111,11 @@ def __round__(self, ndigits: "SupportsIndex | None" = None) -> "Self": @model_validator(mode="before") @classmethod def _cast(cls, value: Any) -> Any: + if isinstance(value, dict): + if "row" in value and "grid_row" not in value: + value["grid_row"] = value.pop("row") + if "col" in value and "grid_col" not in value: + value["grid_col"] = value.pop("col") if isinstance(value, (np.ndarray, tuple)): x = y = z = None if len(value) > 0: diff --git a/tests/fixtures/mda.json b/tests/fixtures/mda.json index 79a59ab2..e0c8309b 100644 --- a/tests/fixtures/mda.json +++ b/tests/fixtures/mda.json @@ -61,24 +61,24 @@ "setup": null, "stage_positions": [ { - "col": null, + "grid_col": null, "name": null, "plate_col": null, "plate_row": null, "properties": null, - "row": null, + "grid_row": null, "sequence": null, "x": 10.0, "y": 20.0, "z": null }, { - "col": null, + "grid_col": null, "name": "test_name", "plate_col": null, "plate_row": null, "properties": null, - "row": null, + "grid_row": null, "sequence": { "autofocus_plan": null, "axis_order": [ @@ -113,7 +113,6 @@ "step": 1.0 } }, - "properties": null, "x": 10.0, "y": 20.0, "z": 50.0 From 1f30ea44fd0f1c8df1e31f5827a406ed7092b7c8 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 30 Mar 2026 09:23:05 -0400 Subject: [PATCH 20/20] Add Pyright configuration and refactor plate position tests for clarity --- pyproject.toml | 3 ++ tests/test_plate_positions.py | 68 ++++++++++++----------------------- 2 files changed, 25 insertions(+), 46 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 80054086..3cf443cd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -182,3 +182,6 @@ ignore = [ "tox.ini", "setup.py", ] + +[tool.pyright] +reportArgumentType = false diff --git a/tests/test_plate_positions.py b/tests/test_plate_positions.py index 005372db..0983b995 100644 --- a/tests/test_plate_positions.py +++ b/tests/test_plate_positions.py @@ -4,65 +4,41 @@ import json +import pytest import yaml import useq -def test_position_plate_row_col() -> None: +def test_plate_row_col() -> None: pos = useq.Position(x=1000, y=2000, plate_row=0, plate_col=1) - assert pos.plate_row == 0 - assert pos.plate_col == 1 + assert (pos.plate_row, pos.plate_col) == (0, 1) + assert useq.Position(x=100).plate_row is None -def test_position_no_plate_coords() -> None: - pos = useq.Position(x=100) - assert pos.plate_row is None - assert pos.plate_col is None - - -def test_plate_json_round_trip() -> None: - pos = useq.Position(x=1000, y=2000, plate_row=0, plate_col=1) - data = json.loads(pos.model_dump_json()) - assert data["plate_row"] == 0 - assert data["plate_col"] == 1 - pos2 = useq.Position.model_validate(data) - assert pos2.plate_row == 0 - assert pos2.plate_col == 1 - - -def test_plate_yaml_round_trip() -> None: +@pytest.mark.parametrize("fmt", ["json", "yaml"]) +def test_plate_round_trip(fmt: str) -> None: seq = useq.MDASequence( - stage_positions=[useq.Position(x=1000, y=1000, plate_row=0, plate_col=0)] + stage_positions=[useq.Position(x=1000, y=1000, plate_row=0, plate_col=1)] ) - data = yaml.safe_load(seq.yaml()) - seq2 = useq.MDASequence(**data) + if fmt == "json": + data = json.loads(seq.model_dump_json()) + else: + data = yaml.safe_load(seq.yaml()) + seq2 = useq.MDASequence.model_validate(data) assert seq2.stage_positions[0].plate_row == 0 - assert seq2.stage_positions[0].plate_col == 0 + assert seq2.stage_positions[0].plate_col == 1 def test_plate_coords_propagate_through_add() -> None: - well = useq.Position(x=1000, y=1000, plate_row=0, plate_col=0, name="A1") - offset = useq.RelativePosition(x=50, y=50, name="0000") - result = well + offset - assert result.plate_row == 0 - assert result.plate_col == 0 + p1 = useq.Position(x=1000, plate_row=0, plate_col=0, name="A1") + p2 = useq.RelativePosition(x=50, name="0000") + result = p1 + p2 + assert (result.plate_row, result.plate_col) == (0, 0) -def test_well_plate_plan_sets_plate_coords() -> None: - pp = useq.WellPlatePlan( - plate="24-well", - a1_center_xy=(0, 0), - selected_wells=([0, 1], [0, 1]), - ) - for pos in pp.selected_well_positions: - assert pos.plate_row is not None - assert pos.plate_col is not None - assert isinstance(pos.plate_row, int) - assert isinstance(pos.plate_col, int) - - -def test_well_plate_plan_image_positions_carry_plate_coords() -> None: +@pytest.mark.parametrize("attr", ["selected_well_positions", "image_positions"]) +def test_well_plate_plan_sets_plate_coords(attr: str) -> None: pp = useq.WellPlatePlan( plate="24-well", a1_center_xy=(0, 0), @@ -71,6 +47,6 @@ def test_well_plate_plan_image_positions_carry_plate_coords() -> None: rows=1, columns=2, fov_width=1, fov_height=1 ), ) - for pos in pp.image_positions: - assert pos.plate_row is not None - assert pos.plate_col is not None + for pos in getattr(pp, attr): + assert isinstance(pos.plate_row, int) + assert isinstance(pos.plate_col, int)