Source code for biohit_pipettor_plus.deck_structure.deck


from biohit_pipettor_plus.deck_structure.slot import Slot
from biohit_pipettor_plus.deck_structure.labware_classes import *
from biohit_pipettor_plus.deck_structure.serializable import Serializable, register_class

from typing import Optional


[docs] @register_class class Deck(Serializable): """ Represents the Deck of a Pipettor. The Deck can contain multiple slots, each of which can hold multiple Labware objects stacked vertically. Attributes --------- deck_id : str Unique identifier for the deck instance. range_x : tuple[int, int] Minimum and maximum x coordinates of the deck. range_y : tuple[int, int] Minimum and maximum y coordinates of the deck. slots : dict[str, Slot] Dictionary mapping slot IDs to Slot objects. labware : dict[str, Labware] Dictionary mapping labware IDs to Labware objects. range_z : float, optional Maximum vertical range of the deck (mm). This is the total Z-axis travel from top (pipettor home) to bottom (deck surface). """ def __init__(self, range_x: tuple[int, int], range_y: tuple[int, int], deck_id: str, range_z : float = 500): self.deck_id = deck_id self.range_x = range_x self.range_y = range_y self.range_z = range_z self.slots: dict[str, Slot] = {} # store Slot objects self.labware: dict[str, Labware] = {} # global access to Labware objects
[docs] def add_slots(self, slots: list[Slot]): """ Add a Slot to the Deck after validating range and overlap. Parameters ---------- slots : list of slots The slot to add to the deck. Raises ------ ValueError If slot ID already exists, is out of deck range, or overlaps an existing slot. """ for slot in slots: if not isinstance(slot, Slot): raise TypeError(f"Object {slot} is not a Slot instance.") slot_id = slot.slot_id if slot_id in self.slots: raise ValueError(f"Slot-ID '{slot_id}' already exists in this Deck.") if not self._is_within_range(slot): raise ValueError( f"Slot '{slot_id}' is outside the deck range " f"x={self.range_x}, y={self.range_y}" ) for existing_id, existing_slot in self.slots.items(): if self._overlaps(slot, existing_slot): raise ValueError( f"Slot '{slot_id}' overlaps with existing slot: '{existing_id}'." ) self.slots[slot_id] = slot
[docs] def remove_slot(self, slot_id: str, unplace_labware: bool = False): """ Remove a Slot from the Deck. Parameters ---------- slot_id : str The ID of the slot to remove. unplace_labware : bool If True, unplace (remove) all labware contained within the slot first. If False (default), raise an error if the slot contains labware. Returns ------- list[Labware] A list of Labware objects that were unplaced (if unplace_labware is True). """ # Check if slot exists if slot_id not in self.slots: raise ValueError(f"Slot '{slot_id}' does not exist in the deck.") slot = self.slots[slot_id] unplaced_labware_list = [] # Check if slot has any labware stacked if slot.labware_stack: if not unplace_labware: labware_ids = list(slot.labware_stack.keys()) raise ValueError( f"Cannot remove slot '{slot_id}' because it still contains labware: {labware_ids}. " f"Use remove_slot(..., unplace_labware=True) to proceed." ) else: # UNPLACE ALL CONTAINED LABWARE lw_ids_to_remove = list(slot.labware_stack.keys()) # Must copy lw_ids_to_remove.reverse() #ensures sequential removal for lw_id in lw_ids_to_remove: # Use the new remove_labware function labware = self.remove_labware(lw_id) unplaced_labware_list.append(labware) # The slot's labware_stack is now guaranteed to be empty due to remove_labware calls. # Remove slot from deck's registry del self.slots[slot_id] # Reset slot position slot.position = None print(f"✓ Removed slot '{slot_id}' from deck.") return slot, unplaced_labware_list # Return the slot and any unplaced labware
[docs] def add_labware(self, labware: Labware, slot_id: str, min_z: float): """ Add a Labware to a specific Slot at a specific Z position. Parameters ---------- labware : Labware Labware object to place. slot_id : str ID of the slot where the labware will be placed. min_z : float Starting Z coordinate within the slot. Raises ------ TypeError If the object is not a Labware instance. ValueError If the slot does not exist, labware ID already exists, or labware does not fit in the slot. """ if not isinstance(labware, Labware): raise TypeError(f"Object {labware} is not a Labware instance.") if slot_id not in self.slots: raise ValueError(f"Slot '{slot_id}' does not exist.") # CHECK FOR DUPLICATE LABWARE ID if labware.labware_id in self.labware: existing_slot = self.get_slot_for_labware(labware.labware_id) raise ValueError( f"Labware ID '{labware.labware_id}' already exists in the deck " f"(in slot '{existing_slot}'). Each labware must have a unique ID." ) slot: Slot = self.slots[slot_id] # Place labware in the slot stack slot._place_labware(lw=labware, min_z=min_z) # allocation position to labware on deck. slot._allocate_position(labware) # store in deck's global labware dict self.labware[labware.labware_id] = labware
[docs] def remove_labware(self, labware_id: str) -> Labware: """ Remove a Labware from its Slot and the Deck. Only the topmost labware in a slot's stack can be removed. Returns the Labware object for reuse or deletion. """ if labware_id not in self.labware: raise ValueError( f"Labware '{labware_id}' not found in deck. " f"Cannot remove labware that was never added." ) labware = self.labware[labware_id] # Find the actual slot containing the labware slot_id = self.get_slot_for_labware(labware_id) if not slot_id: raise ValueError(f"Internal error: Labware '{labware_id}' is placed but has no slot association.") slot = self.slots[slot_id] #topmost level removal stack_keys = list(slot.labware_stack.keys()) if not stack_keys: # This should not happen assuming get_slot_for_labware works, but safe to check raise ValueError(f"Internal error: Slot '{slot_id}' is unexpectedly empty.") topmost_lw_id = stack_keys[-1] if labware_id != topmost_lw_id: # If the requested labware is NOT the topmost item, raise an error. raise ValueError( f"Cannot remove labware '{labware_id}'. It is not the topmost item in slot '{slot_id}'. " f"The topmost labware is '{topmost_lw_id}', which must be removed first." ) # 1. Call the slot's internal removal method and Remove from global labware registry slot._remove_labware(labware_id) del self.labware[labware_id] # 2. Reset labware state labware.position = None if isinstance(labware, Plate): for well in labware.get_wells().values(): if well: well.position = None elif isinstance(labware, ReservoirHolder): for reservoir in labware.get_reservoirs(): if reservoir: reservoir.position = None elif isinstance(labware, PipetteHolder): for holder in labware.get_individual_holders().values(): if holder: holder.position = None print(f"✓ Removed '{labware_id}' from slot '{slot_id}'") return labware # Return the object to the caller (gui)
def _is_within_range(self, slot: Slot) -> bool: return ( self.range_x[0] <= slot.range_x[0] and slot.range_x[1] <= self.range_x[1] and self.range_y[0] <= slot.range_y[0] and slot.range_y[1] <= self.range_y[1] ) def _overlaps(self, s1: Slot, s2: Slot) -> bool: return not ( s1.range_x[1] <= s2.range_x[0] or s2.range_x[1] <= s1.range_x[0] or s1.range_y[1] <= s2.range_y[0] or s2.range_y[1] <= s1.range_y[0] )
[docs] def to_dict(self) -> dict: """ Serialize the Deck to a dictionary for JSON export, including slots with stacked labware. Returns ------- dict Dictionary containing deck attributes, slots, and labware. """ return { "class": self.__class__.__name__, "deck_id": self.deck_id, "range_x": list(self.range_x), "range_y": list(self.range_y), "range_z": self.range_z, "slots": {sid: slot.to_dict() for sid, slot in self.slots.items()}, "labware": {lid: lw.to_dict() for lid, lw in self.labware.items()}, }
@classmethod def _from_dict(cls, data: dict) -> "Deck": deck = cls( range_x=tuple(data["range_x"]), range_y=tuple(data["range_y"]), range_z=data["range_z"], deck_id=data["deck_id"] ) # ✅ STEP 1: Create all labware FIRST for lid, lwdata in data.get("labware", {}).items(): deck.labware[lid] = Serializable.from_dict(lwdata) # ✅ STEP 2: Create slots (without labware) for sid, sdata in data.get("slots", {}).items(): deck.slots[sid] = Serializable.from_dict(sdata) # ✅ STEP 3: Populate slot labware_stack with existing labware objects for slot_id, slot in deck.slots.items(): if hasattr(slot, '_pending_labware_data'): for lw_id, (lw_data, z_range) in slot._pending_labware_data.items(): if lw_id in deck.labware: slot.labware_stack[lw_id] = (deck.labware[lw_id], tuple(z_range)) else: print(f"⚠️ Warning: {lw_id} in slot but not in deck.labware") # Clean up temporary data delattr(slot, '_pending_labware_data') return deck
[docs] def get_slot_for_labware(self, labware_id: str) -> Optional[str]: """ Find the slot ID containing the given labware_id. Parameters --------- labware_id : str The ID of the labware to locate. Returns ------- Optional[str] The slot ID if found, else None. """ for slot_id, slot in self.slots.items(): if labware_id in slot.labware_stack: return slot_id return None