Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: few updates for WellPlatePlan #171

Merged
merged 5 commits into from
Jul 4, 2024
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 14 additions & 4 deletions src/useq/_grid.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ class GridPosition(NamedTuple):
row: int
col: int
is_relative: bool
name: str = ""


class _PointsPlan(FrozenModel):
Expand Down Expand Up @@ -247,8 +248,15 @@ def iter_grid_positions(
x0 = self._offset_x(dx)
y0 = self._offset_y(dy)

for r, c in _INDEX_GENERATORS[mode](rows, cols):
yield GridPosition(x0 + c * dx, y0 - r * dy, r, c, self.is_relative)
for idx, (r, c) in enumerate(_INDEX_GENERATORS[mode](rows, cols)):
yield GridPosition(
x0 + c * dx,
y0 - r * dy,
r,
c,
self.is_relative,
f"{str(idx).zfill(4)}",
)

def __iter__(self) -> Iterator[GridPosition]: # type: ignore
yield from self.iter_grid_positions()
Expand Down Expand Up @@ -492,14 +500,16 @@ def __iter__(self) -> Iterator[GridPosition]: # type: ignore
func = _POINTS_GENERATORS[self.shape]
n_points = max(self.num_points, MIN_RANDOM_POINTS)
points: list[Tuple[float, float]] = []
for x, y in func(seed, n_points, self.max_width, self.max_height):
for idx, (x, y) in enumerate(
func(seed, n_points, self.max_width, self.max_height)
):
if (
self.allow_overlap
or self.fov_width is None
or self.fov_height is None
or _is_a_valid_point(points, x, y, self.fov_width, self.fov_height)
):
yield GridPosition(x, y, 0, 0, True)
yield GridPosition(x, y, 0, 0, True, f"{str(idx).zfill(4)}")
points.append((x, y))
if len(points) >= self.num_points:
break
Expand Down
61 changes: 42 additions & 19 deletions src/useq/_plate.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,12 +63,30 @@ def _to_slice(value: Any) -> slice:


class WellPlate(FrozenModel):
"""A multi-well plate definition."""
"""A multi-well plate definition.

Parameters
----------
rows : int
The number of rows in the plate.
columns : int
The number of columns in the plate.
well_spacing : tuple[float, float] | float
The spacing between wells in the x and y directions.
If a single value is provided, it is used for both x and y.
well_size : tuple[float, float] | float
The size of each well in the x and y directions.
If a single value is provided, it is used for both x and y.
circular_wells : bool
Whether wells are circular (True) or rectangular (False).
name : str
A name for the plate.
"""

rows: int
columns: int
well_spacing: Tuple[float, float] # (x, y)
well_size: Union[Tuple[float, float], None] = None # (x, y)
well_size: Tuple[float, float] # (width, height)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

my reasoning for making this optional was that it's only necessary if you want to take a few images per well within some area. It's obviously pretty important in that case... but if you just wanted to take one image per well it's not necessary. I do guess in that case someone could just put in a fake number if they don't care about where the edge of the well is? (I do see that it makes life harder elsewhere if you can't rely on it)

fine with me to make it required though

circular_wells: bool = True
name: str = ""

Expand All @@ -84,18 +102,14 @@ def shape(self) -> tuple[int, int]:

@field_validator("well_spacing", "well_size", mode="before")
def _validate_well_spacing_and_size(cls, value: Any) -> Any:
if isinstance(value, (int, float)):
return value, value
return value
return (value, value) if isinstance(value, (int, float)) else value

@model_validator(mode="before")
@classmethod
def validate_plate(cls, value: Any) -> Any:
if isinstance(value, (int, float)):
value = f"{int(value)}-well"
if isinstance(value, str):
return cls.from_str(value)
return value
return cls.from_str(value) if isinstance(value, str) else value

@classmethod
def from_str(cls, name: str) -> WellPlate:
Expand Down Expand Up @@ -134,17 +148,24 @@ class WellPlatePlan(FrozenModel, Sequence[Position]):
"4 rad", "4.5deg").
If expressed as an arraylike, it is assumed to be a 2x2 rotation matrix
`[[cos, -sin], [sin, cos]]`, or a 4-tuple `(cos, -sin, sin, cos)`.
selected_wells : IndexExpression | None
Any <=2-dimensional index expression for selecting wells.
for example:
- None -> all wells are selected.
- 0 -> Selects the first row.
- [0, 1, 2] -> Selects the first three rows.
- slice(1, 5) -> selects wells from row 1 to row 4.
- (2, slice(1, 4)) -> select wells in the second row and only columns 1 to 3.
- ([1, 2], [3, 4]) -> select wells in (row, column): (1, 3) and (2, 4)
well_points_plan : GridRowsColumns | RandomPoints | Position
A plan for acquiring images within each well. This can be a single position
(for a single image per well), a GridRowsColumns (for a grid of images),
or RandomPoints (for random points within each well).
"""

plate: WellPlate
a1_center_xy: Tuple[float, float]
# if expressed as a single number, it is assumed to be the angle in degrees
# with anti-clockwise rotation
# if expressed as a string, rad/deg is inferred from the string
# if expressed as a tuple, it is assumed to be a 2x2 rotation matrix or a 4-tuple
rotation: Union[float, None] = None
# Any <2-dimensional index expression, where None means all wells
# and slice(0, 0) means no wells
selected_wells: Union[IndexExpression, None] = None
well_points_plan: Union[GridRowsColumns, RandomPoints, Position] = Field(
default_factory=lambda: Position(x=0, y=0)
Expand All @@ -165,7 +186,9 @@ def _validate_well_points_plan(
) -> Any:
value = handler(value)
if plate := info.data.get("plate"):
if isinstance(value, RandomPoints):
if isinstance(value, RandomPoints) and (
value.max_width == np.inf or value.max_height == np.inf
):
plate = cast(WellPlate, plate)
# use the well size and shape to bound the random points
kwargs = value.model_dump(mode="python")
Expand Down Expand Up @@ -362,11 +385,11 @@ def plot(self) -> None:

_, ax = plt.subplots()

# hide axes
ax.axis("off")

# ################ draw outline of all wells ################
if self.plate.well_size is None:
height, width = self.plate.well_spacing
else:
height, width = self.plate.well_size
height, width = self.plate.well_size

kwargs = {}
offset_x, offset_y = 0.0, 0.0
Expand Down
5 changes: 3 additions & 2 deletions src/useq/_position.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,13 @@ def __add__(self, other: "Position | GridPosition") -> "Position":
"""Add two positions together to create a new position."""
if isinstance(other, GridPosition) and not other.is_relative:
raise ValueError("Cannot add a non-relative GridPosition to a Position")
other_name = getattr(other, "name", None)
other_name = getattr(other, "name", "")
other_name = f"_{other_name}" if other_name else ""
other_z = getattr(other, "z", None)
return Position(
x=self.x + other.x if self.x is not None and other.x is not None else None,
y=self.y + other.y if self.y is not None and other.y is not None else None,
z=self.z + other_z if self.z is not None and other_z is not None else None,
name=f"{self.name}+{other_name}" if self.name and other_name else self.name,
name=f"{self.name}{other_name}" if self.name and other_name else self.name,
sequence=self.sequence,
)
17 changes: 15 additions & 2 deletions tests/test_well_plate.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,10 +73,10 @@ def test_custom_plate(monkeypatch: pytest.MonkeyPatch) -> None:
useq.register_well_plates(
{
"silly": useq.WellPlate(
rows=1, columns=1, well_spacing=1, circular_wells=False
rows=1, columns=1, well_spacing=1, circular_wells=False, well_size=1
)
},
myplate={"rows": 8, "columns": 8, "well_spacing": 9},
myplate={"rows": 8, "columns": 8, "well_spacing": 9, "well_size": 10},
)
assert "myplate" in plates
assert "silly" in useq.known_well_plate_keys()
Expand All @@ -89,3 +89,16 @@ def test_custom_plate(monkeypatch: pytest.MonkeyPatch) -> None:
assert not pp.plate.circular_wells
pp = useq.WellPlatePlan(plate="myplate", a1_center_xy=(0, 0))
assert pp.plate.rows == 8


def test_plate_plan_serialization() -> None:
pp = useq.WellPlatePlan(
plate=96,
a1_center_xy=(500, 200),
rotation=5,
selected_wells=np.s_[1:5:2, :6:3],
well_points_plan=useq.RandomPoints(num_points=10),
)
js = pp.model_dump_json()
pp2 = useq.WellPlatePlan.model_validate_json(js)
assert pp2 == pp
Loading