from typing import Dict, List
from biohit_pipettor_plus.deck_structure.labware_classes import *
from biohit_pipettor_plus.deck_structure.serializable import Serializable, register_class
from biohit_pipettor_plus.deck_structure.position import PositionAllocator
[docs]
@register_class
class Slot(Serializable):
"""
Represents a single slot on a Deck. A slot can hold multiple Labware objects stacked
vertically with specified Z-ranges
Attributes
---------
range_x : tuple[float, float]
Minimum and maximum x coordinates of the slot.
range_y : tuple[float, float]
Minimum and maximum y coordinates of the slot.
range_z : float
Maximum height of the slot.
slot_id : str
Unique identifier for the slot.
labware_stack : dict[str, list[Labware, tuple[float, float]]]
Dictionary mapping Labware IDs to a list containing the Labware object and its Z-range
(min_z, max_z) within the slot.
"""
def __init__(self, range_x: tuple[float, float], range_y: tuple[float, float],
range_z: float, slot_id: str) -> None:
self.range_x = range_x
self.range_y = range_y
self.range_z = range_z
self.slot_id = slot_id
# Dictionary storing stacked labware: {labware_id: [Labware, (min_z, max_z)]}
self.labware_stack: Dict[str, List[Labware, tuple[float, float]]] = {}
def _place_labware(self, lw: Labware, min_z: float):
"""
Add a Labware object to the slot at a specific Z-range.
Parameters
----------
lw : Labware
Labware object to place.
min_z : float
Minimum Z coordinate within the slot for this Labware.
Raises
------
ValueError
If the Labware exceeds the slot's Z range.
"""
self.is_compatible_labware(lw=lw, min_z=min_z)
max_z = min_z + lw.size_z
self.labware_stack[lw.labware_id] = [lw, (min_z, max_z)]
def _remove_labware(self, labware_id: str):
"""
Remove a specific Labware from the stack.
Parameters
----------
labware_id : str
ID of the Labware to remove.
"""
if labware_id in self.labware_stack:
del self.labware_stack[labware_id]
def _allocate_position(
self,
lw: Labware,
):
"""
if labware is placed in slot, x & y coordinates of the labware + offset is slot.
"""
if lw.labware_id not in self.labware_stack:
raise ValueError("Labware not in labware stack")
offset_x, offset_y = lw.offset
x_corner = max(self.range_x[0], self.range_x[1]) - offset_x
y_corner = min(self.range_y[0], self.range_y[1]) + offset_y
#position is slot corner + offset of the labware.
lw.position = (x_corner, y_corner)
#if not none, then labware contains labware within them. Like ReservoirHolder - reservoirs, Plate - wells, pipetteHolder -IndividualPipetteHolder
if hasattr(lw, "_rows") and hasattr(lw, "_columns"):
if not isinstance(lw, (Plate, ReservoirHolder, PipetteHolder)):
raise ValueError("Only (plate, reservoir, pipetteHolder) contain labware within them (wells, reservoirs, zone). Update the code for your labware")
else:
position_allocator = PositionAllocator()
position_allocator.calculate_multi(
lw,
lw.position[0],
lw.position[1],
lw._rows,
lw._columns,
)
[docs]
def is_compatible_labware(self, lw: Labware, min_z: float) -> None:
"""
Checks if a Labware object can be placed at the given Z position, raising
a ValueError if any constraint (XY fit, Z range, stacking, or overlap) is violated.
"""
slot_width = abs(self.range_x[1] - self.range_x[0])
slot_depth = abs(self.range_y[1] - self.range_y[0])
max_z_new = min_z + lw.size_z
# --- 1 & 2. Basic XY Fit & Z-Range Check (Remains the same) ---
if lw.size_x > slot_width or lw.size_y > slot_depth:
raise ValueError(
f"Labware '{lw.labware_id}' (Size X:{lw.size_x}, Y:{lw.size_y}) "
f"is too large for slot '{self.slot_id}' (Size X:{slot_width}, Y:{slot_depth})."
)
if max_z_new > self.range_z:
raise ValueError(
f"Placement of Labware '{lw.labware_id}' at min_z={min_z} "
f"exceeds the slot's total height capacity ({self.range_z})."
)
# --- 3. Stacking Constraint Check (UPDATED LOGIC) ---
if self.labware_stack:
top_lw = None
max_z_seen = -1.0
# Find the existing labware that occupies the highest Z-level.
for existing_lw, (min_z_exist, max_z_exist) in self.labware_stack.values():
if max_z_exist > max_z_seen:
max_z_seen = max_z_exist
top_lw = existing_lw
# Use getattr to safely check the property, defaulting to False
# if the attribute is missing (which matches the default behavior of blocking stacking).
can_stack_upon = getattr(top_lw, 'can_be_stacked_upon', False)
if not can_stack_upon:
# If can_be_stacked_upon is False, raise an error.
raise ValueError(
f"Stacking error in slot '{self.slot_id}': Labware '{top_lw.labware_id}' "
f"is not designed to have other labware placed on top of it (can_be_stacked_upon=False)."
)
# --- 4. Z-Overlap Check (Remains the same) ---
for existing_lw, (min_z_exist, max_z_exist) in self.labware_stack.values():
if max_z_exist > min_z and min_z_exist < max_z_new:
raise ValueError(
f"Z-Overlap error in slot '{self.slot_id}': Placement range Z:[{min_z}, {max_z_new}] "
f"overlaps with existing labware '{existing_lw.labware_id}' Z-range:[{min_z_exist}, {max_z_exist}]."
)
# If all checks pass, the function returns silently (None).
return
[docs]
def get_highest_z(self) -> float:
"""Returns the maximum Z coordinate currently occupied in this slot."""
if not self.labware_stack:
return 0.0
# Extract the max_z (index 1 of the tuple) from all items in the stack
return max(zr[1] for _, zr in self.labware_stack.values())
[docs]
def to_dict(self) -> dict:
"""
Serialize the Slot instance to a dictionary, including stacked labware and their Z-ranges.
Returns
-------
dict
Dictionary representation of the slot.
"""
return {
"class": self.__class__.__name__,
"slot_id": self.slot_id,
"range_x": list(self.range_x),
"range_y": list(self.range_y),
"range_z": self.range_z,
"labware_stack": {
lw_id: [lw.to_dict(), zr] for lw_id, (lw, zr) in self.labware_stack.items()
}
}
@classmethod
def _from_dict(cls, data: dict) -> "Slot":
"""
Deserialize a Slot instance from a dictionary.
Note: Labware objects should be populated by Deck after all labware is created.
"""
slot = cls(
range_x=tuple(data["range_x"]),
range_y=tuple(data["range_y"]),
range_z=data["range_z"],
slot_id=data["slot_id"]
)
# ✅ Don't create labware here - just store the raw data temporarily
slot._pending_labware_data = data.get("labware_stack", {})
return slot