from biohit_pipettor_plus.deck_structure.labware_classes.labware import Labware
from biohit_pipettor_plus.deck_structure.serializable import register_class, Serializable
from biohit_pipettor_plus.deck_structure.labware_classes.well import Well
from typing import Optional
import copy
import warnings
[docs]
@register_class
class Plate(Labware):
def __init__(self, size_x: float,
size_y: float,
size_z: float,
wells_x: int,
wells_y: int,
well: Well,
add_height: float = -3,
remove_height: float = -10,
offset: tuple[float, float] = (0, 0),
x_spacing: float = None,
y_spacing: float = None,
labware_id: str = None,
position: tuple[float, float] = None,
can_be_stacked_upon: bool = False):
"""
Initialize a Plate instance.
Parameters
----------
size_x : float
Width of the plate.
size_y : float
Depth of the plate.
size_z : float
Height of the plate.
wells_x : int
Number of wells in X direction.
wells_y : int
Number of wells in Y direction.
add_height : float
Height above the well bottom used when adding liquid (in mm).
remove_height : float
Height above the well bottom used when removing liquid (in mm).
well : Well
Template well to use for all wells in the plate.
offset : tuple[float, float], optional
Offset of the plate.
x_spacing : float, optional
Distance along x-axis between hooks in millimeters.
y_spacing : float, optional
Distance along y-axis between hooks in millimeters.
labware_id : str, optional
Unique ID for the plate.
position : tuple[float, float], optional
(x, y) position coordinates of the plate in millimeters.
"""
super().__init__(size_x=size_x, size_y=size_y, size_z=size_z, offset=offset, labware_id=labware_id,
position=position, can_be_stacked_upon=can_be_stacked_upon)
if wells_x <= 0 or wells_y <= 0:
raise ValueError("wells_x and wells_y cannot be negative or 0")
self._columns = wells_x
self._rows = wells_y
self.add_height = add_height
self.remove_height = remove_height
self.__wells: dict[tuple[int, int], Well] = {}
self.well = well
self.x_spacing = x_spacing
self.y_spacing = y_spacing
min_required_x = round((wells_x * well.size_x) + (2*abs(offset[0])),2)
min_required_y = round((wells_y * well.size_y) + (2*abs(offset[1])),2)
if size_x < min_required_x:
warnings.warn(
f"Plate width ({size_x}mm) is too small for {wells_x} wells of width {well.size_x}mm. "
f"Minimum required: {min_required_x:.1f}mm (including offsets)",
UserWarning,
stacklevel=2
)
if size_y < min_required_y:
warnings.warn(
f"Plate height ({size_y}mm) is too small for {wells_y} wells of height {well.size_y}mm. "
f"Minimum required: {min_required_y:.1f}mm (including offsets)",
UserWarning,
stacklevel=2
)
if well.size_z > size_z:
raise ValueError(
f"Well height ({well.size_z}mm) exceeds plate height ({size_z}mm)"
)
self.place_wells()
[docs]
def place_wells(self):
"""Create wells across the grid using the template well."""
for x in range(self._columns):
for y in range(self._rows):
well = copy.deepcopy(self.well)
well.labware_id = f'{self.labware_id}_{x}:{y}'
well.row = y
well.column = x
self.__wells[(x, y)] = well
[docs]
def get_wells(self) -> dict[tuple[int, int], Well]: # ✅ Correct type
"""Get all wells in the plate."""
return self.__wells
[docs]
def get_all_children(self) -> list[Well]:
return list(self.__wells.values())
[docs]
def get_child_at(self, column: int, row: int) -> Optional[Well]:
return self.__wells.get((column, row))
[docs]
def get_well_at(self, column: int, row: int) -> Optional[Well]:
"""
Get the well at a specific position.
Parameters
----------
column : int
Column index
row : int
Row index
Returns
-------
Optional[Well]
The well at the position, or None if not found
"""
return self.__wells.get((column, row))
[docs]
def get_wells_in_column(self, column: int) -> list[Well]:
"""Get all wells in a specific column."""
wells = []
for row in range(self._rows):
well = self.get_well_at(column, row)
if well:
wells.append(well)
return wells
[docs]
def get_wells_in_row(self, row: int) -> list[Well]:
"""Get all wells in a specific row."""
wells = []
for col in range(self._columns):
well = self.get_well_at(col, row)
if well:
wells.append(well)
return wells
[docs]
def get_state_snapshot(self) -> dict:
"""Return deep copy of all wells' state"""
return {
'wells': {pos: well.get_state_snapshot()
for pos, well in self._Plate__wells.items()}
}
[docs]
def restore_state_snapshot(self, snapshot: dict) -> None:
"""Restore all wells' state from snapshot"""
for pos, well_snapshot in snapshot['wells'].items():
self._Plate__wells[pos].restore_state_snapshot(well_snapshot)
[docs]
def to_dict(self):
"""Serialize the Plate instance to a dictionary."""
base = super().to_dict()
base.update({
"add_height": self.add_height,
"remove_height": self.remove_height,
"x_spacing": self.x_spacing,
"y_spacing": self.y_spacing,
"wells_x": self.wells_x,
"wells_y": self.wells_y,
"wells": {
f"{col}:{row}": well.to_dict()
for (col, row), well in self.__wells.items()
}
})
return base
@classmethod
def _from_dict(cls, data: dict) -> "Plate":
position = tuple(data["position"]) if data.get("position") else None
wells_data = data.get("wells", {})
if not wells_data:
raise ValueError("Cannot deserialize Plate without wells data")
# Get the first well as a template
first_well_data = next(iter(wells_data.values()))
template_well = Serializable.from_dict(first_well_data)
plate = cls(
size_x=data["size_x"],
size_y=data["size_y"],
size_z=data["size_z"],
offset=data["offset"],
add_height=data["add_height"],
remove_height=data["remove_height"],
labware_id=data["labware_id"],
x_spacing=data.get("x_spacing", None),
y_spacing=data.get("y_spacing", None),
wells_x=data["wells_x"],
wells_y=data["wells_y"],
can_be_stacked_upon=data.get("can_be_stacked_upon", False),
well=template_well,
position=position,
)
# Restore wells with their actual state
for wid, wdata in wells_data.items():
col, row = map(int, wid.split(':'))
restored_well = Serializable.from_dict(wdata)
plate._Plate__wells[(col, row)] = restored_well
return plate
@property
def wells_x(self) -> int:
"""Number of wells in X direction (columns)"""
return self._columns
@property
def wells_y(self) -> int:
"""Number of wells in Y direction (rows)"""
return self._rows
@property
def grid_x(self) -> int:
"""Standard grid dimension (columns)"""
return self._columns
@property
def grid_y(self) -> int:
"""Standard grid dimension (rows)"""
return self._rows