Module pipettin-piper.piper.plugins.tools.pipette
Classes
class PipetteTool (tool_data: dict, gcode_builder: GcodeBuilder)-
Expand source code
class PipetteTool(Tool): """Tool object for pipettes Inheriting from Tool and TrackedDict (which wraps UserDict really) allows getting data with the usual dictionary syntax and methods. """ def __init__(self, tool_data: dict, gcode_builder: GcodeBuilder): # Initialize the parent Tool class. super().__init__(tool_data, gcode_builder) # Initialize empty state. self.state = deepcopy(self.default_state) homed: bool = False """Indicates if the pipette tool is homed or not.""" # TODO: Replace the key in the state dict with this property. lastdir = None """To track the direction of the last pipette move (for compensation).""" default_state = { # pipette axis position in mm (s) and microliters (vol). "s": 0, "vol": 0, "tip_vol": 0, "lastdir": None, "x": None, "y": None, "z": None, "platform": None, "tube": None, "paused": False, "alarm": False } """State tracking variables.""" # Use a different feedrate for the extrusion/pipetting moves. tool_velocity_mm_s = 200 tool_feedrate = tool_velocity_mm_s * 60 # Convert from mm/s to mm/min tip_loaded: dict = None """Either None or a dict with the currently placed tip's data.""" @property def max_volume(self) -> float: """Current maximum volume limit.""" # Get the maximum volume from the current stage. if self.tip_loaded: # Return the smallest limit, between the tip and the pipette. tip_container = self.get_tip_container(self.tip_loaded) return self.get_max_volume(tip_container) # If no tip is loaded, get the default tip-stage parameters. default_tip_stage = self.get_default_tip_stage() return default_tip_stage["vol_max"] def get_max_volume(self, tip_container: dict = None): """Return the maximum volume limit, between the tip and the pipette.""" # Get the max volume of the pipette stage. tip_stage = self.get_tip_stage(tip_container) tip_stage_max_vol = tip_stage["vol_max"] # Get the max volume of the tip. tip_max_vol = tip_container["maxVolume"] # Return the smallest value. return min(tip_stage_max_vol, tip_max_vol) @property def flowrate(self) -> float: """Get the flowrate for the current tip, or get it from the default stage. TODO: Get the flowrate from the tip instead. This requires adding the property to the tip container data. """ # Get the flowrate from the current stage. if self.tip_loaded: tip_stage = self.get_tip_stage() return tip_stage["flowrate"] # Get the default tip-stage parameters. default_tip_stage = self.get_default_tip_stage() return default_tip_stage["flowrate"] @property def shaft_diameter(self): """Physical diameter of the pipette's shaft This should be measured precisely with a micrometer, at several points along its length to ensure accuracy. """ return float(self["shaft_diameter"]) @property def scaler(self): """Ideal conversion factor from displacement (mm) to volume (uL) Divide the target volume by this factor to obtain the corresponding linear displacement of a cyllinder with diameter set by `shaft_diameter`. Expression for a cyllinder's volume: pi * ((shaft_diameter/2)**2) Note: the scaler is ideal, and does not contemplate the corrections of the "scaler_adjust" property. """ return math.pi * ((self.shaft_diameter/2)**2) @property def scaler_adjust(self): """Linear adjustment factor for the ideal scaler property This is meant to correct sistematic linear deviations from the ideal scaler due to, for example, the weight of the water column. """ return float(self["scaler_adjust"]) @property def feedrate(self) -> float: """Get the linear feedrate for the current tip-stage, or get the default Convert the volumetric flowrate (in microliters/second) to the speed (in mm/min) of a cylindrical shaft that displaces the volume. flowrate (float): Volumetric flowrate in microliters/second (µL/s). diameter (float): Diameter of the shaft in millimeters (mm). Returns: float: Speed of the shaft in millimeters per minute (mm/min). """ # Convert flowrate from microliters/second to cubic millimeters/second. flowrate_mm3_per_s = self.flowrate # 1 microliter = 1 cubic millimeter # Calculate the cross-sectional area of the cylindrical shaft. radius_mm = self.shaft_diameter / 2 area_mm2 = math.pi * (radius_mm ** 2) # Calculate the speed in millimeters per second (mm/s). speed_mm_per_s = flowrate_mm3_per_s / area_mm2 # Convert the speed to millimeters per minute (mm/min) speed_mm_per_min = speed_mm_per_s * 60 # In CNC-friendly units. return speed_mm_per_min @property def can_probe(self) -> bool: """Check if the pipette can do 'tip probing' during placement.""" return bool(self["can_probe"]) @property def probe_extra_dist(self): """Extra (downwards) probing distance, beyond the fitting depth. Harmless if the probe works. Requires a tip to be loaded. """ return self.get_probe_extra_dist() # Default container. @property def bare_z_offset(self): """Z offset for the tool, with no tips.""" z = float(self.tool_offset["z"]) return z @property def z_offset(self): """Vertical offset for the tool, as a computed property. Defined as the height at which the machine touches the baseplate when the tool is attached. This value may be context-dependent. For example, it may depend on wether a tip is placed on a (pipette) tool or not. It is used by GcodeBuilder to compute vertical clearance and the safe height. """ # Start value for the calculation. z = self.bare_z_offset # Add tip offsets, only if a tip is loaded. if self.tip_loaded: # Get the associated container. tip_container = self.get_tip_container(self.tip_loaded) # Add the total length of the tip. z += tip_container["length"] # Subtract tip-stage z-offset. z -= self.get_tip_stage_offset(tip_container) # Subtract the fit distance. z -= self.get_tip_fit_distance(tip_container) # Done! return z def get_tip_stage_offset(self, tip_container: dict = None): """Calculate tip-stage z-offset for a target tip container and tool.""" # Adjust by tip-stage offset, if needed. tip_stage = self.get_tip_stage(tip_container=tip_container) # NOTE: The value is subtracted because a "deeper" tip stage # will make the Z-height at which the tool touches ground # a bit lower. return tip_stage.get("z_offset") def get_tip_fit_distance(self, tip_container: dict = None): """Calculate 'tip_fit_distance' for a target tip container and tool.""" # Adjust by tip-stage offset, if needed. tip_stage = self.get_tip_stage(tip_container=tip_container) stage_fit_distances = tip_stage.get("tip_fit_distance") tip_fit_distance = stage_fit_distances[tip_container["name"]] return tip_fit_distance def get_probe_extra_dist(self, tip_container: dict = None): """Calculate 'probe_extra_dist' for a target tip container and tool.""" # Adjust by tip-stage offset, if needed. tip_stage = self.get_tip_stage(tip_container=tip_container) return tip_stage.get("probe_extra_dist") @property def tension_correction(self) -> dict: """Parameters to correct for surface tension effects at volumes lower than the tip's limits. This value may be dynamic. For example, it may depend on which tip is placed. Must contain keys: { "start_vol": 10, "max_correction": 0.1 } Requires a tip to be loaded. """ tip_stage = self.get_tip_stage() return tip_stage.get("tension_correction") def get_tip_container(self, tip: dict) -> dict: """Get the container data of a tip from the database.""" container_name = tip.get("container") container = self.controller.database_tools.getContainerByName(container_name) return container def get_default_tip_stage(self) -> dict: """Get the default tip-stage's parameters""" default_tip_stage_name = self.tip_stages["default"] default_tip_stage = self.tip_stages[default_tip_stage_name] return default_tip_stage @property def tip_map(self) -> dict: """Correspondence of tip container names to tip stages. Example: "tip_map": { "20 uL Tip": "p20", "200 uL Tip": "p200", "200 uL Tip Tarsons": "p200", "1000 uL Tip": "p1000" } """ return deepcopy(self["tip_map"]) @property def tip_stages(self) -> dict: """Definition of tip-fitting stages on the pipette's tip holder. Example: "tip_stages": { "default": "p1000", "p200": { "vol_max": 200, "flowrate": 100, "z_offset": 5, "tip_fit_distance": { ... }, "probe_extra_dist": 2.7, "eject_post": { ... }, "tension_correction": { ... } } } """ return deepcopy(self["tip_stages"]) def get_tip_stage(self, tip_container: dict=None) -> dict: """Get the tip stage parameters for a particular tip.""" # Default tip_container to curent tip if not provided. if tip_container is None: # A tip is needed to inferr the relevant tip stage. if not self.tip_loaded: raise ValueError("No tip has been loaded yet, and no default container was provided.") # Get the currently loaded tip. tip_container = self.get_tip_container(self.tip_loaded) # Get the tip's "model" name. tip_container_name = tip_container["name"] # Get tip-stage parameters from the tool definition. tip_map = self.tip_map tip_stages = self.tip_stages default_tip_stage_name = tip_stages["default"] # Get the name of the tip stage. stage_name = tip_map.get(tip_container_name, default_tip_stage_name) # Get the tip stage. tip_stage = tip_stages[stage_name] return tip_stage def is_home(self): """Infer if the pipette is in the 'home' position from the state tracker.""" return ( self.homed and (self.state["vol"] == self.state["s"] == 0) and self.state["lastdir"] == self.builder.sign(-1) ) def set_home(self): # Zero the volumes self.state["vol"] = 0 self.state["s"] = 0 # Update direction with the current move self.state["lastdir"] = self.builder.sign(-1) # Set the homedflag. self.homed = True #### GCODE GENERATORS #### def home(self) -> list: """Generate GCODE commands needed to home this tool. Then update the internal state accordingly. """ logging.info(f"Homing tool '{self.name}'.") # List for homing GCODE commands. homing_commands = [] # Activate tool. homing_commands += self.gcode_activate() # TODO: Fix this. It caused errors when the machine was idle for too long, or if the steppers were disabled. # if self.is_home(): # # Skip homing if the pipette is already homed. # return [self.gcode.comment("Skipped homing. The tool's state indicates that it already is.")] # Build the command if not self.controller.machine.dry: # Add homing commands. homing_commands += self.homing['commands'] # Add priming distance. travel_distance = self.homing['travel_distance'] # TODO: Set priming move feedrate from a config parameter. homing_commands += self.gcode.G1(e=travel_distance, absolute=True, absolute_e=True) # Debug log. logging.debug(f"Homing commands for tool {self.name}: {homing_commands}") else: # Dry mode: msg = f"Simulating homed state for tool '{self.name}'. Dry mode enabled." logging.info(msg) homing_commands += [self.gcode.comment(msg)] # Update internal state. self.set_home() return homing_commands @property def eject_post(self): """Coordinates and parameters to eject tips using a fixed post. Minimal contents: eject_post: post_x: 185 post_y: 301 post_z_pre: 64 post_z: 73 eject_feed_rate: 600 Notes: - post_x: X-axis coordinate of the carriage when the pipette's eject button is under the ejection post. - post_y: Y-axis coordinate of the carriage when the pipette's eject button is under the ejection post. - post_z_pre: Z-axis coordinate of the carriage when the pipette's eject button is under the ejection post, but not in contact with it. - post_z: Z-axis coordinate of the carriage when the pipette's eject button is fully pressed the ejection post, and a tip is ejected. - eject_feed_rate: Speed for the ejection motion. Should be "slow". Requires a tip to be loaded. """ tip_stage = self.get_tip_stage() eject_post = tip_stage["eject_post"] return eject_post @property def eject_post_init_coords(self) -> dict: """Initial coordinates for the post-based tip ejection sequence.""" x = self.eject_post["post_x"] safe_y = self.builder.getSafeY() z_pre = self.eject_post["post_z_pre"] return x, safe_y, z_pre def eject_tip_with_post(self, eject_feed_rate=None) -> list: """Make GCODE to eject the tip by elegantly crashing with a "post". This assumes that the tool is safely positioned such that it may move freely towards the eject-post, and then use it.""" # Get tip ejector parameters. eject_post = self.eject_post y, z_pre, z = eject_post["post_y"], eject_post["post_z_pre"], eject_post["post_z"] # Set ejection move speed. if eject_feed_rate is None: eject_feed_rate = eject_post["eject_feed_rate"] # Start the command list. commands = [] # Align to the ejector on Z (without tool-offsets). commands += self.gcode.G0(z=z_pre, absolute=True, add_comment="Approach the eject-post on Z.") # Align to the ejector on Y (without tool-offsets), inserting the tip holder in the ejector. commands += self.gcode.G0(y=y, absolute=True, add_comment="Approach the eject-post on Y.") # Move upwards to eject the tip (without tool-offsets). commands += self.gcode.G1(z=z, f=eject_feed_rate, absolute=True, add_comment="Eject the tip.") # BUG: The tip-probe is "erratic" in the reverse direction. # A regular mechanical endstop would be better, because # it has a small amount of hysteresis. # Use a regular upwards move instead. # Compute clearance values. safe_y = self.builder.getSafeY() # Move to safe Y. commands += self.gcode.G0( y=safe_y, absolute=True, add_comment="Move away from the ejection post up to safe Y (avoid parked tools).") # The safe_z distance will actually change during this GCODE. # Let the "macroDiscardTip" function handle this. commands += [self.gcode.comment("END tip-ejection macro.")] return commands def eject_tip(self) -> list: """Generate GCODE for the ejection, and update the pipette's state.""" # Get the current tip stage. tip_stage = self.get_tip_stage() # Make the GCODE. if self.can_eject and "tip_ejector" in tip_stage: # Use the integrated ejector. commands = self.eject_with_ejector() elif "eject_post" in tip_stage: # Use the fixed ejection post. commands = self.eject_tip_with_post() else: raise ProtocolError("Neither a tip ejector or a tip-ejection post are configured.") # Register no tip in the volume tracker self.tip_loaded = False self.state["tip_vol"] = None return commands @property def can_eject(self) -> bool: return bool(self["can_eject"]) @property def tip_ejector(self) -> bool: """Coordinates and parameters to eject tips using a built-in tip ejector. The tip ejector is associated to a particular tip stage and container. This property uses the stage that matches the current tip. Example: >>> "tip_ejector": { >>> "e_start": 22, >>> "e_end": 27, >>> "eject_feedrate": 200 >>> } """ tip_stage = self.get_tip_stage() eject_post = tip_stage["tip_ejector"] return deepcopy(eject_post) def eject_with_ejector(self) -> list: """Generate GCODE to eject a tip using the integrated tip-ejector. { "tip_ejector": { "e_start": 200, "e_end": 250, "eject_feedrate": 200 }, } """ if not self.can_eject: raise ProtocolError("This pipette does not have a tip ejector.") commands = [self.gcode.comment("Ejecting tip move.")] commands += self.gcode.G1( e=self.tip_ejector["e_start"], f=self.tool_feedrate) commands += self.gcode.G1( e=self.tip_ejector["e_end"], f=self.tip_ejector["eject_feedrate"]) return commands def pickup(self, old_tool, new_tool) -> list: """Pickup a tool. Assumes an empty toolhead, and a tool in its parking post. """ commands = [self.gcode.comment(f"Picking up {self.name}.")] # Home tool axis commands += self.home() # Home the toolchanger remote. # TODO: "gcodeHomeP" is largely deprecated. Replace it. commands += self.gcode.gcodeHomeP(cmd_prefix="HOME_", which_axis="E0") # Generate commands for the pickup. commands += self.gcode_dock() # Mark the tool as not parked. self.parked = False # Mark a toolchange / activate the new tool. commands += self.gcode_activate() return commands def gcode_activate(self) -> list: """Signal the firmware to activate this tool. This usually means sending gcode commands equivalent to T0, T1, etc. which may switch the active extruder. The need for it is really up to the firmware and the machine. Example: - "activate": "T1" """ return deepcopy(self["activate"]) def gcode_deactivate(self) -> list: """Signal the firmware to deactivate this tool. Example: - "deactivate": "; dummy deactivate." """ return deepcopy(self["deactivate"]) def park(self, old_tool, new_tool) -> list: """Park a tool. Assumes that the tool is mounted, and that its parking post is empty. """ # Generate commands for the pickup. parking_commands = self.gcode_park() # Mark the tool as parked. self.parked = True # TODO: Is a "deactivate" function needed for the pipettes? parking_commands += self.gcode_deactivate() return parking_commands @property def docking_commands(self): return deepcopy(self["docking_commands"]) # Custom GCODE section def gcode_dock(self, commands: list = None) -> list: """Generate GCODE to dock a tool (assuming none is loaded). Required tool parameters: - tool_post: coordinates and offsets of the tool post. - docking_commands: list of GCODE commands. - parking_commands: list of GCODE commands. Returns: list: List of GCODES for docking. """ if commands is None: commands = [] # Initialize commands list. commands += [self.gcode.comment("START tool-change pickup macro.")] # Clearance moves. commands += self.gcode_safe_zy() # Parked tool coordinates. x, y, z = self.tool_post["x"], self.tool_post["y"], self.tool_post["z"] # Extra Y-axis distance needed to approach and touch the tool. y_docking_closeup = self.tool_post["y_docking_closeup"] # Extra Z-axis distance used to help align the tool. z_offset = self.tool_post["z_docking_offset"] # TODO: This clearance move may be trivial. Consider removing. commands += self.gcode_approach(x, self.safe_y_parked, z+z_offset) # Initial approach. # NOTE: Corrected for axis inversion. commands += self.gcode_approach(x, y+y_docking_closeup, z+z_offset) # Final approach moves. # NOTE: Corrected for axis inversion. commands += self.gcode_align(y=y, z=z+z_offset) # Tool-changer commands to lock the tool to the toolhead. commands += [self.gcode.comment("Locking moves.")] commands += self.docking_commands # End sequence. commands += self.gcode.G0( y=self.safe_y_loaded, absolute=True, add_comment="Move back to the Y coordinate clear of other parked tools.") # Done. commands += [self.gcode.comment("END tool-change macro.")] return commands @property def parking_commands(self): return deepcopy(self["parking_commands"]) def gcode_park(self, commands: list = None) -> list: """Generate GCODE to park a tool (assuming none is loaded). TODO: Document this function. See 'gcode_dock' for deatils for now. """ if commands is None: commands = [] # Initialize commands list. commands += [self.gcode.comment("START tool-change parking macro.")] # Clearance moves. commands += self.gcode_safe_zy() # Parking post coordinates. x, y, z = self.tool_post["x"], self.tool_post["y"], self.tool_post["z"] # Extra Y-axis distance needed to approach the post and hang the tool. y_parking_closeup = self.tool_post["y_parking_closeup"] # Extra Z-axis distance used to help align the tool. z_offset = self.tool_post["z_parking_offset"] # TODO: This clearance move may be trivial. Consider removing. commands += self.gcode_approach(x, self.safe_y_loaded, z+z_offset) # Initial approach. commands += self.gcode_approach(x, y, z+z_offset) # Final approach moves. # NOTE: Corrected for axis inversion. commands += self.gcode_align(y=y-y_parking_closeup, z=z+z_offset) # Tool-changer commands to release the tool from the toolhead. commands += [self.gcode.comment("Unlocking moves.")] commands += self.parking_commands # End sequence. commands += self.gcode.G0( y=self.safe_y_parked, absolute=True, add_comment="Move back to the Y coordinate clear of other parked tools.") # Done. commands += [self.gcode.comment("END tool-change macro.")] return commands def gcode_safe_zy(self, commands: list = None) -> list: """Make G1 GCODE commands to move to safe Z and Y coordinates, in that order.""" # Use a new command list if none was provided. if commands is None: commands = [] # Get the absolute clearance coordinates safe_z = self.builder.getSafeHeight() safe_y = self.builder.getSafeY() # Clearance moves. commands += [self.gcode.comment("Safety clearance moves."), "G90" + self.gcode.comment("Set absolute motion mode.")] commands += self.gcode.gcodeClearance( z=safe_z, f=self.builder.feedrate, add_comment="Move to the safe Z height.") commands += self.gcode.G0( y=safe_y, add_comment="Move to the Y coordinate clear of other parked tools.") return commands def gcode_approach(self, x, y, z) -> list: """Make G1 GCODE to move in front of a tool-post. Moves first in X, then in YZ. """ # Start sequence: approach the tool. commands = [self.gcode.comment("Alignment moves.")] commands += self.gcode.G0( x=x, add_comment="Move to the X position in front of the parking post.") commands += self.gcode.G0( y=y, z=z, add_comment="Move to the Y-Z position in front of the parking post/tool.") return commands def gcode_align(self, y, z) -> list: """Make G1 GCODE for fine alignment to a tool-post. Moves first in Y (fine and slow approach) and then in Z (wobble compensation). """ # Slow feedrates for the moves. tc_feedrate = self.tool_post["feedrate"] # Initial docking moves. commands = [self.gcode.comment("Alignment moves.")] commands += self.gcode.G1( y=y, f=tc_feedrate, add_comment="Partial approach for initial alignment.") commands += self.gcode.G1( z=z, f=tc_feedrate, add_comment="Compensate Z for looseness in the parking post.") return commandsTool object for pipettes Inheriting from Tool and TrackedDict (which wraps UserDict really) allows getting data with the usual dictionary syntax and methods.
Initializes the TrackedDict instance.
Args
file_path:str- The path to the YAML file.
data:dict- Altertative (and sufficient) data source. Used to update the YAML file if any.
Ancestors
- Tool
- TrackedDict
- YAMLReader
- collections.UserDict
- collections.abc.MutableMapping
- collections.abc.Mapping
- collections.abc.Collection
- collections.abc.Sized
- collections.abc.Iterable
- collections.abc.Container
Class variables
var default_state-
State tracking variables.
var homed : bool-
Indicates if the pipette tool is homed or not.
var lastdir-
To track the direction of the last pipette move (for compensation).
var tip_loaded : dict-
Either None or a dict with the currently placed tip's data.
var tool_feedratevar tool_velocity_mm_s
Instance variables
prop bare_z_offset-
Expand source code
@property def bare_z_offset(self): """Z offset for the tool, with no tips.""" z = float(self.tool_offset["z"]) return zZ offset for the tool, with no tips.
prop can_eject : bool-
Expand source code
@property def can_eject(self) -> bool: return bool(self["can_eject"]) prop can_probe : bool-
Expand source code
@property def can_probe(self) -> bool: """Check if the pipette can do 'tip probing' during placement.""" return bool(self["can_probe"])Check if the pipette can do 'tip probing' during placement.
prop eject_post-
Expand source code
@property def eject_post(self): """Coordinates and parameters to eject tips using a fixed post. Minimal contents: eject_post: post_x: 185 post_y: 301 post_z_pre: 64 post_z: 73 eject_feed_rate: 600 Notes: - post_x: X-axis coordinate of the carriage when the pipette's eject button is under the ejection post. - post_y: Y-axis coordinate of the carriage when the pipette's eject button is under the ejection post. - post_z_pre: Z-axis coordinate of the carriage when the pipette's eject button is under the ejection post, but not in contact with it. - post_z: Z-axis coordinate of the carriage when the pipette's eject button is fully pressed the ejection post, and a tip is ejected. - eject_feed_rate: Speed for the ejection motion. Should be "slow". Requires a tip to be loaded. """ tip_stage = self.get_tip_stage() eject_post = tip_stage["eject_post"] return eject_postCoordinates and parameters to eject tips using a fixed post. Minimal contents: eject_post: post_x: 185 post_y: 301 post_z_pre: 64 post_z: 73 eject_feed_rate: 600 Notes: - post_x: X-axis coordinate of the carriage when the pipette's eject button is under the ejection post. - post_y: Y-axis coordinate of the carriage when the pipette's eject button is under the ejection post. - post_z_pre: Z-axis coordinate of the carriage when the pipette's eject button is under the ejection post, but not in contact with it. - post_z: Z-axis coordinate of the carriage when the pipette's eject button is fully pressed the ejection post, and a tip is ejected. - eject_feed_rate: Speed for the ejection motion. Should be "slow".
Requires a tip to be loaded.
prop eject_post_init_coords : dict-
Expand source code
@property def eject_post_init_coords(self) -> dict: """Initial coordinates for the post-based tip ejection sequence.""" x = self.eject_post["post_x"] safe_y = self.builder.getSafeY() z_pre = self.eject_post["post_z_pre"] return x, safe_y, z_preInitial coordinates for the post-based tip ejection sequence.
prop feedrate : float-
Expand source code
@property def feedrate(self) -> float: """Get the linear feedrate for the current tip-stage, or get the default Convert the volumetric flowrate (in microliters/second) to the speed (in mm/min) of a cylindrical shaft that displaces the volume. flowrate (float): Volumetric flowrate in microliters/second (µL/s). diameter (float): Diameter of the shaft in millimeters (mm). Returns: float: Speed of the shaft in millimeters per minute (mm/min). """ # Convert flowrate from microliters/second to cubic millimeters/second. flowrate_mm3_per_s = self.flowrate # 1 microliter = 1 cubic millimeter # Calculate the cross-sectional area of the cylindrical shaft. radius_mm = self.shaft_diameter / 2 area_mm2 = math.pi * (radius_mm ** 2) # Calculate the speed in millimeters per second (mm/s). speed_mm_per_s = flowrate_mm3_per_s / area_mm2 # Convert the speed to millimeters per minute (mm/min) speed_mm_per_min = speed_mm_per_s * 60 # In CNC-friendly units. return speed_mm_per_minGet the linear feedrate for the current tip-stage, or get the default Convert the volumetric flowrate (in microliters/second) to the speed (in mm/min) of a cylindrical shaft that displaces the volume.
flowrate (float): Volumetric flowrate in microliters/second (µL/s). diameter (float): Diameter of the shaft in millimeters (mm).
Returns: float: Speed of the shaft in millimeters per minute (mm/min).
prop flowrate : float-
Expand source code
@property def flowrate(self) -> float: """Get the flowrate for the current tip, or get it from the default stage. TODO: Get the flowrate from the tip instead. This requires adding the property to the tip container data. """ # Get the flowrate from the current stage. if self.tip_loaded: tip_stage = self.get_tip_stage() return tip_stage["flowrate"] # Get the default tip-stage parameters. default_tip_stage = self.get_default_tip_stage() return default_tip_stage["flowrate"]Get the flowrate for the current tip, or get it from the default stage. TODO: Get the flowrate from the tip instead. This requires adding the property to the tip container data.
prop max_volume : float-
Expand source code
@property def max_volume(self) -> float: """Current maximum volume limit.""" # Get the maximum volume from the current stage. if self.tip_loaded: # Return the smallest limit, between the tip and the pipette. tip_container = self.get_tip_container(self.tip_loaded) return self.get_max_volume(tip_container) # If no tip is loaded, get the default tip-stage parameters. default_tip_stage = self.get_default_tip_stage() return default_tip_stage["vol_max"]Current maximum volume limit.
prop probe_extra_dist-
Expand source code
@property def probe_extra_dist(self): """Extra (downwards) probing distance, beyond the fitting depth. Harmless if the probe works. Requires a tip to be loaded. """ return self.get_probe_extra_dist() # Default container.Extra (downwards) probing distance, beyond the fitting depth. Harmless if the probe works. Requires a tip to be loaded.
prop scaler-
Expand source code
@property def scaler(self): """Ideal conversion factor from displacement (mm) to volume (uL) Divide the target volume by this factor to obtain the corresponding linear displacement of a cyllinder with diameter set by `shaft_diameter`. Expression for a cyllinder's volume: pi * ((shaft_diameter/2)**2) Note: the scaler is ideal, and does not contemplate the corrections of the "scaler_adjust" property. """ return math.pi * ((self.shaft_diameter/2)**2)Ideal conversion factor from displacement (mm) to volume (uL)
Divide the target volume by this factor to obtain the corresponding linear displacement of a cyllinder with diameter set by
shaft_diameter.Expression for a cyllinder's volume: pi * ((shaft_diameter/2)**2)
Note: the scaler is ideal, and does not contemplate the corrections of the "scaler_adjust" property.
prop scaler_adjust-
Expand source code
@property def scaler_adjust(self): """Linear adjustment factor for the ideal scaler property This is meant to correct sistematic linear deviations from the ideal scaler due to, for example, the weight of the water column. """ return float(self["scaler_adjust"])Linear adjustment factor for the ideal scaler property This is meant to correct sistematic linear deviations from the ideal scaler due to, for example, the weight of the water column.
prop shaft_diameter-
Expand source code
@property def shaft_diameter(self): """Physical diameter of the pipette's shaft This should be measured precisely with a micrometer, at several points along its length to ensure accuracy. """ return float(self["shaft_diameter"])Physical diameter of the pipette's shaft This should be measured precisely with a micrometer, at several points along its length to ensure accuracy.
prop tension_correction : dict-
Expand source code
@property def tension_correction(self) -> dict: """Parameters to correct for surface tension effects at volumes lower than the tip's limits. This value may be dynamic. For example, it may depend on which tip is placed. Must contain keys: { "start_vol": 10, "max_correction": 0.1 } Requires a tip to be loaded. """ tip_stage = self.get_tip_stage() return tip_stage.get("tension_correction")Parameters to correct for surface tension effects at volumes lower than the tip's limits. This value may be dynamic. For example, it may depend on which tip is placed. Must contain keys: { "start_vol": 10, "max_correction": 0.1 } Requires a tip to be loaded.
prop tip_ejector : bool-
Expand source code
@property def tip_ejector(self) -> bool: """Coordinates and parameters to eject tips using a built-in tip ejector. The tip ejector is associated to a particular tip stage and container. This property uses the stage that matches the current tip. Example: >>> "tip_ejector": { >>> "e_start": 22, >>> "e_end": 27, >>> "eject_feedrate": 200 >>> } """ tip_stage = self.get_tip_stage() eject_post = tip_stage["tip_ejector"] return deepcopy(eject_post)Coordinates and parameters to eject tips using a built-in tip ejector. The tip ejector is associated to a particular tip stage and container. This property uses the stage that matches the current tip.
Example
>>> "tip_ejector": { >>> "e_start": 22, >>> "e_end": 27, >>> "eject_feedrate": 200 >>> } prop tip_map : dict-
Expand source code
@property def tip_map(self) -> dict: """Correspondence of tip container names to tip stages. Example: "tip_map": { "20 uL Tip": "p20", "200 uL Tip": "p200", "200 uL Tip Tarsons": "p200", "1000 uL Tip": "p1000" } """ return deepcopy(self["tip_map"])Correspondence of tip container names to tip stages.
Example
"tip_map": { "20 uL Tip": "p20", "200 uL Tip": "p200", "200 uL Tip Tarsons": "p200", "1000 uL Tip": "p1000" }
prop tip_stages : dict-
Expand source code
@property def tip_stages(self) -> dict: """Definition of tip-fitting stages on the pipette's tip holder. Example: "tip_stages": { "default": "p1000", "p200": { "vol_max": 200, "flowrate": 100, "z_offset": 5, "tip_fit_distance": { ... }, "probe_extra_dist": 2.7, "eject_post": { ... }, "tension_correction": { ... } } } """ return deepcopy(self["tip_stages"])Definition of tip-fitting stages on the pipette's tip holder.
Example
"tip_stages": { "default": "p1000", "p200": { "vol_max": 200, "flowrate": 100, "z_offset": 5, "tip_fit_distance": { … }, "probe_extra_dist": 2.7, "eject_post": { … }, "tension_correction": { … } } }
prop z_offset-
Expand source code
@property def z_offset(self): """Vertical offset for the tool, as a computed property. Defined as the height at which the machine touches the baseplate when the tool is attached. This value may be context-dependent. For example, it may depend on wether a tip is placed on a (pipette) tool or not. It is used by GcodeBuilder to compute vertical clearance and the safe height. """ # Start value for the calculation. z = self.bare_z_offset # Add tip offsets, only if a tip is loaded. if self.tip_loaded: # Get the associated container. tip_container = self.get_tip_container(self.tip_loaded) # Add the total length of the tip. z += tip_container["length"] # Subtract tip-stage z-offset. z -= self.get_tip_stage_offset(tip_container) # Subtract the fit distance. z -= self.get_tip_fit_distance(tip_container) # Done! return zVertical offset for the tool, as a computed property. Defined as the height at which the machine touches the baseplate when the tool is attached. This value may be context-dependent. For example, it may depend on wether a tip is placed on a (pipette) tool or not. It is used by GcodeBuilder to compute vertical clearance and the safe height.
Methods
def eject_tip(self) ‑> list-
Expand source code
def eject_tip(self) -> list: """Generate GCODE for the ejection, and update the pipette's state.""" # Get the current tip stage. tip_stage = self.get_tip_stage() # Make the GCODE. if self.can_eject and "tip_ejector" in tip_stage: # Use the integrated ejector. commands = self.eject_with_ejector() elif "eject_post" in tip_stage: # Use the fixed ejection post. commands = self.eject_tip_with_post() else: raise ProtocolError("Neither a tip ejector or a tip-ejection post are configured.") # Register no tip in the volume tracker self.tip_loaded = False self.state["tip_vol"] = None return commandsGenerate GCODE for the ejection, and update the pipette's state.
def eject_tip_with_post(self, eject_feed_rate=None) ‑> list-
Expand source code
def eject_tip_with_post(self, eject_feed_rate=None) -> list: """Make GCODE to eject the tip by elegantly crashing with a "post". This assumes that the tool is safely positioned such that it may move freely towards the eject-post, and then use it.""" # Get tip ejector parameters. eject_post = self.eject_post y, z_pre, z = eject_post["post_y"], eject_post["post_z_pre"], eject_post["post_z"] # Set ejection move speed. if eject_feed_rate is None: eject_feed_rate = eject_post["eject_feed_rate"] # Start the command list. commands = [] # Align to the ejector on Z (without tool-offsets). commands += self.gcode.G0(z=z_pre, absolute=True, add_comment="Approach the eject-post on Z.") # Align to the ejector on Y (without tool-offsets), inserting the tip holder in the ejector. commands += self.gcode.G0(y=y, absolute=True, add_comment="Approach the eject-post on Y.") # Move upwards to eject the tip (without tool-offsets). commands += self.gcode.G1(z=z, f=eject_feed_rate, absolute=True, add_comment="Eject the tip.") # BUG: The tip-probe is "erratic" in the reverse direction. # A regular mechanical endstop would be better, because # it has a small amount of hysteresis. # Use a regular upwards move instead. # Compute clearance values. safe_y = self.builder.getSafeY() # Move to safe Y. commands += self.gcode.G0( y=safe_y, absolute=True, add_comment="Move away from the ejection post up to safe Y (avoid parked tools).") # The safe_z distance will actually change during this GCODE. # Let the "macroDiscardTip" function handle this. commands += [self.gcode.comment("END tip-ejection macro.")] return commandsMake GCODE to eject the tip by elegantly crashing with a "post". This assumes that the tool is safely positioned such that it may move freely towards the eject-post, and then use it.
def eject_with_ejector(self) ‑> list-
Expand source code
def eject_with_ejector(self) -> list: """Generate GCODE to eject a tip using the integrated tip-ejector. { "tip_ejector": { "e_start": 200, "e_end": 250, "eject_feedrate": 200 }, } """ if not self.can_eject: raise ProtocolError("This pipette does not have a tip ejector.") commands = [self.gcode.comment("Ejecting tip move.")] commands += self.gcode.G1( e=self.tip_ejector["e_start"], f=self.tool_feedrate) commands += self.gcode.G1( e=self.tip_ejector["e_end"], f=self.tip_ejector["eject_feedrate"]) return commandsGenerate GCODE to eject a tip using the integrated tip-ejector. { "tip_ejector": { "e_start": 200, "e_end": 250, "eject_feedrate": 200 }, }
def get_default_tip_stage(self) ‑> dict-
Expand source code
def get_default_tip_stage(self) -> dict: """Get the default tip-stage's parameters""" default_tip_stage_name = self.tip_stages["default"] default_tip_stage = self.tip_stages[default_tip_stage_name] return default_tip_stageGet the default tip-stage's parameters
def get_max_volume(self, tip_container: dict = None)-
Expand source code
def get_max_volume(self, tip_container: dict = None): """Return the maximum volume limit, between the tip and the pipette.""" # Get the max volume of the pipette stage. tip_stage = self.get_tip_stage(tip_container) tip_stage_max_vol = tip_stage["vol_max"] # Get the max volume of the tip. tip_max_vol = tip_container["maxVolume"] # Return the smallest value. return min(tip_stage_max_vol, tip_max_vol)Return the maximum volume limit, between the tip and the pipette.
def get_probe_extra_dist(self, tip_container: dict = None)-
Expand source code
def get_probe_extra_dist(self, tip_container: dict = None): """Calculate 'probe_extra_dist' for a target tip container and tool.""" # Adjust by tip-stage offset, if needed. tip_stage = self.get_tip_stage(tip_container=tip_container) return tip_stage.get("probe_extra_dist")Calculate 'probe_extra_dist' for a target tip container and tool.
def get_tip_container(self, tip: dict) ‑> dict-
Expand source code
def get_tip_container(self, tip: dict) -> dict: """Get the container data of a tip from the database.""" container_name = tip.get("container") container = self.controller.database_tools.getContainerByName(container_name) return containerGet the container data of a tip from the database.
def get_tip_fit_distance(self, tip_container: dict = None)-
Expand source code
def get_tip_fit_distance(self, tip_container: dict = None): """Calculate 'tip_fit_distance' for a target tip container and tool.""" # Adjust by tip-stage offset, if needed. tip_stage = self.get_tip_stage(tip_container=tip_container) stage_fit_distances = tip_stage.get("tip_fit_distance") tip_fit_distance = stage_fit_distances[tip_container["name"]] return tip_fit_distanceCalculate 'tip_fit_distance' for a target tip container and tool.
def get_tip_stage(self, tip_container: dict = None) ‑> dict-
Expand source code
def get_tip_stage(self, tip_container: dict=None) -> dict: """Get the tip stage parameters for a particular tip.""" # Default tip_container to curent tip if not provided. if tip_container is None: # A tip is needed to inferr the relevant tip stage. if not self.tip_loaded: raise ValueError("No tip has been loaded yet, and no default container was provided.") # Get the currently loaded tip. tip_container = self.get_tip_container(self.tip_loaded) # Get the tip's "model" name. tip_container_name = tip_container["name"] # Get tip-stage parameters from the tool definition. tip_map = self.tip_map tip_stages = self.tip_stages default_tip_stage_name = tip_stages["default"] # Get the name of the tip stage. stage_name = tip_map.get(tip_container_name, default_tip_stage_name) # Get the tip stage. tip_stage = tip_stages[stage_name] return tip_stageGet the tip stage parameters for a particular tip.
def get_tip_stage_offset(self, tip_container: dict = None)-
Expand source code
def get_tip_stage_offset(self, tip_container: dict = None): """Calculate tip-stage z-offset for a target tip container and tool.""" # Adjust by tip-stage offset, if needed. tip_stage = self.get_tip_stage(tip_container=tip_container) # NOTE: The value is subtracted because a "deeper" tip stage # will make the Z-height at which the tool touches ground # a bit lower. return tip_stage.get("z_offset")Calculate tip-stage z-offset for a target tip container and tool.
def is_home(self)-
Expand source code
def is_home(self): """Infer if the pipette is in the 'home' position from the state tracker.""" return ( self.homed and (self.state["vol"] == self.state["s"] == 0) and self.state["lastdir"] == self.builder.sign(-1) )Infer if the pipette is in the 'home' position from the state tracker.
def set_home(self)-
Expand source code
def set_home(self): # Zero the volumes self.state["vol"] = 0 self.state["s"] = 0 # Update direction with the current move self.state["lastdir"] = self.builder.sign(-1) # Set the homedflag. self.homed = True
Inherited members
Tool:clearclearancedocking_commandsgcode_activategcode_aligngcode_approachgcode_deactivategcode_dockgcode_parkgcode_post_frontgcode_safe_zygetget_accessed_keysget_non_accessed_keyshomehomingnameparametersparkparkedparking_commandspickupsafe_xsafe_ysafe_y_loadedsafe_y_parkedsafe_y_posttool_offsettool_postupdatex_offsety_offset