diff --git a/src/useq/_grid.py b/src/useq/_grid.py index 6d91bf4..87c4ab1 100644 --- a/src/useq/_grid.py +++ b/src/useq/_grid.py @@ -61,10 +61,19 @@ 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 @@ -137,7 +146,7 @@ def iter_grid_positions( y=y0 - r * dy, grid_row=r, grid_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] @@ -518,7 +527,11 @@ def iter_grid_positions( pos = [] for idx, (x, y, r, c) in enumerate(pos): yield AbsolutePosition( - x=x, y=y, grid_row=r, grid_col=c, name=f"{str(idx).zfill(4)}" + x=x, + y=y, + grid_row=r, + grid_col=c, + name=self.name_pattern.format(row=r, col=c, idx=idx), ) def _cached_tiles( diff --git a/src/useq/_iter_sequence.py b/src/useq/_iter_sequence.py index 3c3ece5..a521db2 100644 --- a/src/useq/_iter_sequence.py +++ b/src/useq/_iter_sequence.py @@ -174,7 +174,10 @@ 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 bea30bc..8b2c084 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,15 +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 6757dcd..c25421d 100644 --- a/src/useq/_position.py +++ b/src/useq/_position.py @@ -16,6 +16,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. @@ -32,13 +41,23 @@ class PositionBase(MutableModel): z : float | None Z position in microns. name : str | None - Optional name for the position. + 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. sequence : MDASequence | None Optional MDASequence relative this position. - 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. + 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"). grid_row : int | None Optional row index, when used in a grid. grid_col : int | None @@ -51,8 +70,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 grid_row: int | None = None grid_col: int | None = None @@ -108,6 +127,34 @@ 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 e0c8309..6c1393c 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 @@ -94,6 +95,7 @@ "fov_height": null, "fov_width": null, "mode": "row_wise_snake", + "name_pattern": "{idx:04d}", "overlap": [ 0.0, 0.0 diff --git a/tests/fixtures/mda.yaml b/tests/fixtures/mda.yaml index 5fea5c9..31d9195 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 diff --git a/tests/test_plate_positions.py b/tests/test_plate_positions.py index 0983b99..82268f7 100644 --- a/tests/test_plate_positions.py +++ b/tests/test_plate_positions.py @@ -1,4 +1,4 @@ -"""Tests for plate_row/plate_col on Position.""" +"""Tests for plate_row/plate_col, name_pattern, and composite pos_name.""" from __future__ import annotations @@ -9,36 +9,115 @@ import useq +# --- plate_row / plate_col name generation ----------------------------------- -def test_plate_row_col() -> None: +_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 + + +# --- 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) - assert (pos.plate_row, pos.plate_col) == (0, 1) - assert useq.Position(x=100).plate_row is None + 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" -@pytest.mark.parametrize("fmt", ["json", "yaml"]) -def test_plate_round_trip(fmt: str) -> None: +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=1)] + stage_positions=[useq.Position(x=1000, y=1000, plate_row=0, plate_col=0)] ) - if fmt == "json": - data = json.loads(seq.model_dump_json()) - else: - data = yaml.safe_load(seq.yaml()) - seq2 = useq.MDASequence.model_validate(data) + 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 == 1 + 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: - 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) + 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 -@pytest.mark.parametrize("attr", ["selected_well_positions", "image_positions"]) -def test_well_plate_plan_sets_plate_coords(attr: str) -> None: + +def test_well_plate_plan_image_positions_carry_plate_coords() -> None: pp = useq.WellPlatePlan( plate="24-well", a1_center_xy=(0, 0), @@ -47,6 +126,106 @@ def test_well_plate_plan_sets_plate_coords(attr: str) -> None: 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) + 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"}