diff --git a/pyproject.toml b/pyproject.toml index 8005408..3cf443c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -182,3 +182,6 @@ ignore = [ "tox.ini", "setup.py", ] + +[tool.pyright] +reportArgumentType = false diff --git a/src/useq/_grid.py b/src/useq/_grid.py index 5441842..6d91bf4 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/_plate.py b/src/useq/_plate.py index f4ccdc3..bea30bc 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 808df3c..6757dcd 100644 --- a/src/useq/_position.py +++ b/src/useq/_position.py @@ -3,7 +3,8 @@ 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 typing_extensions import deprecated from useq._base_model import FrozenModel, MutableModel from useq._mda_event import PropertyTuple @@ -34,9 +35,13 @@ class PositionBase(MutableModel): Optional name for the position. sequence : MDASequence | None Optional MDASequence relative this position. - row : int | None + 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. + 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. """ @@ -46,10 +51,28 @@ 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 + grid_row: int | None = None + grid_col: int | None = None - # excluded from serialization - row: int | None = Field(default=None, exclude=True) - col: int | None = Field(default=None, exclude=True) + @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.""" @@ -88,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 b2f35b2..e0c8309 100644 --- a/tests/fixtures/mda.json +++ b/tests/fixtures/mda.json @@ -61,16 +61,24 @@ "setup": null, "stage_positions": [ { + "grid_col": null, "name": null, + "plate_col": null, + "plate_row": null, "properties": null, + "grid_row": null, "sequence": null, "x": 10.0, "y": 20.0, "z": null }, { + "grid_col": null, "name": "test_name", + "plate_col": null, + "plate_row": null, "properties": null, + "grid_row": null, "sequence": { "autofocus_plan": null, "axis_order": [ @@ -105,7 +113,6 @@ "step": 1.0 } }, - "properties": null, "x": 10.0, "y": 20.0, "z": 50.0 diff --git a/tests/test_plate_positions.py b/tests/test_plate_positions.py new file mode 100644 index 0000000..0983b99 --- /dev/null +++ b/tests/test_plate_positions.py @@ -0,0 +1,52 @@ +"""Tests for plate_row/plate_col on Position.""" + +from __future__ import annotations + +import json + +import pytest +import yaml + +import useq + + +def test_plate_row_col() -> None: + pos = useq.Position(x=1000, y=2000, plate_row=0, plate_col=1) + assert (pos.plate_row, pos.plate_col) == (0, 1) + assert useq.Position(x=100).plate_row is 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=1)] + ) + 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 == 1 + + +def test_plate_coords_propagate_through_add() -> None: + 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) + + +@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), + selected_wells=([0], [0]), + well_points_plan=useq.GridRowsColumns( + rows=1, columns=2, fov_width=1, fov_height=1 + ), + ) + for pos in getattr(pp, attr): + assert isinstance(pos.plate_row, int) + assert isinstance(pos.plate_col, int)