def plan_arc( origin_point: Point, dest_point: Point, z_height: float, origin_cp: CriticalPoint = None, dest_cp: CriticalPoint = None, extra_waypoints: List[Tuple[float, float]] = None)\ -> List[Tuple[Point, Optional[CriticalPoint]]]: assert z_height >= max(origin_point.z, dest_point.z) checked_wp = extra_waypoints or [] return [(origin_point._replace(z=z_height), origin_cp)]\ + [(Point(x=wp[0], y=wp[1], z=z_height), dest_cp) for wp in checked_wp]\ + [(dest_point._replace(z=z_height), dest_cp), (dest_point, dest_cp)]
def check_arc_basic(arc: List[Point], from_pt: Point, to_pt: Point): """ Check the tests that should always be true for different-well moves - we should always go only up, then only xy, then only down - we should have three moves """ # first move should move only up assert arc[0]._replace(z=0) == from_pt._replace(z=0) # second move should move only in xy assert arc[0].z == arc[1].z # second-to-last move should always end at the dest in xy # so the last move is z-only assert arc[-2]._replace(z=0) == to_pt._replace(z=0) # final move should arrive precisely at the dest assert arc[-1] == to_pt # first move should be up assert arc[0].z >= from_pt.z # last move should be down assert arc[-2].z >= to_pt.z
async def move(request): """ Moves the robot to the specified position as provided by the `control.info` endpoint response Post body must include the following keys: - 'target': either 'mount' or 'pipette' - 'point': a tuple of 3 floats for x, y, z - 'mount': must be 'left' or 'right' If 'target' is 'pipette', body must also contain: - 'model': must be a valid pipette model (as defined in `pipette_config`) """ hw = hw_from_req(request) req = await request.text() data = json.loads(req) target, point, mount, model, message, error = _validate_move_data(data) if error: status = 400 else: status = 200 if ff.use_protocol_api_v2(): await hw.cache_instruments() if target == 'mount': critical_point: Optional[CriticalPoint] = CriticalPoint.MOUNT else: critical_point = None mount = Mount[mount.upper()] target = Point(*point) await hw.home_z() pos = await hw.gantry_position(mount, critical_point) await hw.move_to(mount, target._replace(z=pos.z), critical_point=critical_point) await hw.move_to(mount, target, critical_point=critical_point) pos = await hw.gantry_position(mount) message = 'Move complete. New position: {}'.format(pos) else: if target == 'mount': message = _move_mount(hw, mount, point) elif target == 'pipette': message = _move_pipette(hw, mount, model, point) return web.json_response({"message": message}, status=status)
def test_critical_points(model): loaded = pipette_config.load(model) pip = pipette.Pipette(loaded, { 'single': [0, 0, 0], 'multi': [0, 0, 0] }, 'testID') mod_offset = Point(*loaded.model_offset) assert pip.critical_point() == mod_offset assert pip.critical_point(types.CriticalPoint.NOZZLE) == mod_offset assert pip.critical_point(types.CriticalPoint.TIP) == mod_offset tip_length = 25.0 pip.add_tip(tip_length) new = mod_offset._replace(z=mod_offset.z - tip_length) assert pip.critical_point() == new assert pip.critical_point(types.CriticalPoint.NOZZLE) == mod_offset assert pip.critical_point(types.CriticalPoint.TIP) == new pip.remove_tip() assert pip.critical_point() == mod_offset assert pip.critical_point(types.CriticalPoint.NOZZLE) == mod_offset assert pip.critical_point(types.CriticalPoint.TIP) == mod_offset
class Pipette: """ A class to gather and track pipette state and configs. This class should not touch hardware or call back out to the hardware control API. Its only purpose is to gather state. """ DictType = Dict[str, Union[str, float, bool]] #: The type of this data class as a dict def __init__(self, model: str, inst_offset_config: Dict[str, Tuple[float, float, float]], pipette_id: str = None) -> None: self._config = pipette_config.load(model, pipette_id) self._name = pipette_config.name_for_model(model) self._model = model self._model_offset = self._config.model_offset self._current_volume = 0.0 self._working_volume = self._config.max_volume self._current_tip_length = 0.0 self._current_tiprack_diameter = 0.0 self._fallback_tip_length = self._config.tip_length self._tip_overlap_map = self._config.tip_overlap self._has_tip = False self._pipette_id = pipette_id pip_type = 'multi' if self._config.channels > 1 else 'single' self._instrument_offset = Point(*inst_offset_config[pip_type]) self._log = mod_log.getChild( self._pipette_id if self._pipette_id else '<unknown>') self._log.info("loaded: {}, instr offset {}".format( model, self._instrument_offset)) self.ready_to_aspirate = False #: True if ready to aspirate def update_instrument_offset(self, new_offset: Point): self._log.info("updated instrument offset to {}".format(new_offset)) self._instrument_offset = new_offset @property def config(self) -> pipette_config.pipette_config: return self._config @property def model_offset(self): return self._model_offset def update_config_item(self, elem_name: str, elem_val: Any): self._log.info("updated config: {}={}".format(elem_name, elem_val)) self._config = self._config._replace(**{elem_name: elem_val}) @property def name(self) -> str: return self._name @property def model(self) -> str: return self._model @property def pipette_id(self) -> Optional[str]: return self._pipette_id def critical_point(self, cp_override: CriticalPoint = None) -> Point: """ The vector from the pipette's origin to its critical point. The critical point for a pipette is the end of the nozzle if no tip is attached, or the end of the tip if a tip is attached. If `cp_override` is specified and valid - so is either :py:attr:`CriticalPoint.NOZZLE` or :py:attr:`CriticalPoint.TIP` when we have a tip, or :py:attr:`CriticalPoint.XY_CENTER` - the specified critical point will be used. """ if not self.has_tip or cp_override == CriticalPoint.NOZZLE: cp_type = CriticalPoint.NOZZLE tip_length = 0.0 else: cp_type = CriticalPoint.TIP tip_length = self.current_tip_length if cp_override == CriticalPoint.XY_CENTER: mod_offset_xy = [0, 0, self.model_offset[2]] cp_type = CriticalPoint.XY_CENTER elif cp_override == CriticalPoint.FRONT_NOZZLE: mod_offset_xy = [0, -self.model_offset[1], self.model_offset[2]] cp_type = CriticalPoint.FRONT_NOZZLE else: mod_offset_xy = self.model_offset mod_and_tip = Point(mod_offset_xy[0], mod_offset_xy[1], mod_offset_xy[2] - tip_length) cp = mod_and_tip + self._instrument_offset._replace(z=0) if self._log.isEnabledFor(logging.DEBUG): mo = 'model offset: {} + '.format(self.model_offset)\ if cp_type != CriticalPoint.XY_CENTER else '' info_str = 'cp: {}{}: {}=({}instr offset xy: {}'\ .format(cp_type, '(from override)' if cp_override else '', cp, mo, self._instrument_offset._replace(z=0)) if cp_type == CriticalPoint.TIP: info_str += '- current_tip_length: {}=(true tip length: {}'\ ' - inst z: {}) (z only)'.format( self.current_tip_length, self._current_tip_length, self._instrument_offset.z) info_str += ')' self._log.debug(info_str) return cp @property def current_volume(self) -> float: """ The amount of liquid currently aspirated """ return self._current_volume @property def current_tip_length(self) -> float: """ The length of the current tip attached (0.0 if no tip) """ return (self._current_tip_length - self._instrument_offset.z) @property def current_tiprack_diameter(self) -> float: """ The diameter of the current tip rack (0.0 if no tip) """ return self._current_tiprack_diameter @current_tiprack_diameter.setter def current_tiprack_diameter(self, diameter: float): self._current_tiprack_diameter = diameter @property def working_volume(self) -> float: """ The working volume of the pipette """ return self._working_volume @working_volume.setter def working_volume(self, tip_volume: float): """ The working volume is the current tip max volume """ self._working_volume = min(self.config.max_volume, tip_volume) @property def available_volume(self) -> float: """ The amount of liquid possible to aspirate """ return self.working_volume - self.current_volume def set_current_volume(self, new_volume: float): assert new_volume >= 0 assert new_volume <= self.working_volume self._current_volume = new_volume def add_current_volume(self, volume_incr: float): assert self.ok_to_add_volume(volume_incr) self._current_volume += volume_incr def remove_current_volume(self, volume_incr: float): assert self._current_volume >= volume_incr self._current_volume -= volume_incr def ok_to_add_volume(self, volume_incr: float) -> bool: return self.current_volume + volume_incr <= self.working_volume def add_tip(self, tip_length: float) -> None: """ Add a tip to the pipette for position tracking and validation (effectively updates the pipette's critical point) :param tip_length: a positive, non-zero float presenting the distance in Z from the end of the pipette nozzle to the end of the tip :return: """ assert tip_length > 0.0, "tip_length must be greater than 0" assert not self.has_tip self._has_tip = True self._current_tip_length = tip_length def remove_tip(self) -> None: """ Remove the tip from the pipette (effectively updates the pipette's critical point) """ assert self.has_tip self._has_tip = False self._current_tip_length = 0.0 @property def has_tip(self) -> bool: return self._has_tip def ul_per_mm(self, ul: float, action: str) -> float: sequence = self._config.ul_per_mm[action] return pipette_config.piecewise_volume_conversion(ul, sequence) def __str__(self) -> str: return '{} current volume {}ul critical point: {} at {}'\ .format(self._config.display_name, self.current_volume, 'tip end' if self.has_tip else 'nozzle end', 0) def __repr__(self) -> str: return '<{}: {} {}>'.format(self.__class__.__name__, self._config.display_name, id(self)) def as_dict(self) -> 'Pipette.DictType': config_dict = self.config._asdict() config_dict.update({ 'current_volume': self.current_volume, 'available_volume': self.available_volume, 'name': self.name, 'model': self.model, 'pipette_id': self.pipette_id, 'has_tip': self.has_tip, 'working_volume': self.working_volume }) return config_dict
async def test_moves_to_hotspot(hardware_api, monkeypatch, mount, pipette_model): move_calls = [] rel_calls = [] probe_calls = [] old_move_to = hardware_api.move_to old_move_rel = hardware_api.move_rel old_probe = hardware_api._backend.probe async def fake_move_to(which_mount, point): move_calls.append((which_mount, point)) return await old_move_to(which_mount, point) async def fake_move_rel(which_mount, delta): rel_calls.append((which_mount, delta)) return await old_move_rel(which_mount, delta) def fake_probe(ax, dist): probe_calls.append((ax, dist)) return old_probe(ax, dist) monkeypatch.setattr(hardware_api, 'move_to', fake_move_to) monkeypatch.setattr(hardware_api, 'move_rel', fake_move_rel) monkeypatch.setattr(hardware_api._backend, 'probe', fake_probe) await hardware_api.cache_instruments( {mount: name_for_model(pipette_model)}) await hardware_api.home() center = await hardware_api.locate_tip_probe_center(mount, 30) hotspots = robot_configs.calculate_tip_probe_hotspots( 30, hardware_api._config.tip_probe) assert len(move_calls) == len(hotspots) * 4 assert len(rel_calls) == len(hotspots) move_iter = iter(move_calls) rel_iter = iter(rel_calls) probe_iter = iter(probe_calls) bounce_base = hardware_api._config.tip_probe.bounce_distance old_center = hardware_api._config.tip_probe.center for hs in hotspots: x0 = old_center[0] + hs.x_start_offs y0 = old_center[1] + hs.y_start_offs z0 = hs.z_start_abs next(move_iter) next(move_iter) rel = next(rel_iter) if hs.probe_distance < 0: bounce = bounce_base else: bounce = -bounce_base assert rel[1] == Point(**{hs.axis: bounce}) prep_point = next(move_iter) assert prep_point[1] == Point(x0, y0, z0) probe = next(probe_iter) assert probe[0] == hs.axis if hs.axis != 'z' else 'a' assert probe[1] == hs.probe_distance next(move_iter) targ = Point(*hardware_api._config.tip_probe.center) # The x and y are the same because the offsets from our naive mock of # probe() should cancel out, but the z only has one side so we need # to figure out what it will be targ = targ._replace(z=hotspots[-1][3] + hotspots[-1][4]) assert list(center) == pytest.approx(targ)
class Pipette: """ A class to gather and track pipette state and configs. This class should not touch hardware or call back out to the hardware control API. Its only purpose is to gather state. """ DictType = Dict[str, Union[str, float, bool]] #: The type of this data class as a dict def __init__(self, config: pipette_config.PipetteConfig, inst_offset_config: Union[InstrumentOffsetConfig, Point], pipette_id: str = None) -> None: self._config = config self._acting_as = self._config.name self._name = self._config.name self._model = self._config.model self._model_offset = self._config.model_offset self._current_volume = 0.0 self._working_volume = self._config.max_volume self._current_tip_length = 0.0 self._current_tiprack_diameter = 0.0 self._fallback_tip_length = self._config.tip_length self._tip_overlap_map = self._config.tip_overlap self._has_tip = False self._pipette_id = pipette_id if isinstance(inst_offset_config, dict): pip_type = 'multi' if self._config.channels == 8 else 'single' self._instrument_offset = Point(*inst_offset_config[pip_type]) else: self._instrument_offset = inst_offset_config self._log = mod_log.getChild( self._pipette_id if self._pipette_id else '<unknown>') self._log.info("loaded: {}, instr offset {}".format( config.model, self._instrument_offset)) self.ready_to_aspirate = False #: True if ready to aspirate self._aspirate_flow_rate\ = self._config.default_aspirate_flow_rates['2.0'] self._dispense_flow_rate\ = self._config.default_dispense_flow_rates['2.0'] self._blow_out_flow_rate\ = self._config.default_blow_out_flow_rates['2.0'] def act_as(self, name: PipetteName): """ Reconfigure to act as ``name``. ``name`` must be either the actual name of the pipette, or a name in its back-compatibility config. """ if name == self._acting_as: return assert name in self._config.back_compat_names + [self.name],\ f'{self._name} is not back-compatible with {name}' name_conf = pipette_config.name_config() bc_conf = name_conf[name] self.working_volume = bc_conf['maxVolume'] self.update_config_item('min_volume', bc_conf['minVolume']) self.update_config_item('max_volume', bc_conf['maxVolume']) @property def acting_as(self) -> PipetteName: return self._acting_as def update_instrument_offset(self, new_offset: Point): self._log.info("updated instrument offset to {}".format(new_offset)) self._instrument_offset = new_offset @property def config(self) -> pipette_config.PipetteConfig: return self._config @property def model_offset(self): return self._model_offset def update_config_item(self, elem_name: str, elem_val: Any): self._log.info("updated config: {}={}".format(elem_name, elem_val)) self._config = replace(self._config, **{elem_name: elem_val}) @property def name(self) -> PipetteName: return self._name @property def model(self) -> PipetteModel: return self._model @property def pipette_id(self) -> Optional[str]: return self._pipette_id def critical_point(self, cp_override: CriticalPoint = None) -> Point: """ The vector from the pipette's origin to its critical point. The critical point for a pipette is the end of the nozzle if no tip is attached, or the end of the tip if a tip is attached. If `cp_override` is specified and valid - so is either :py:attr:`CriticalPoint.NOZZLE` or :py:attr:`CriticalPoint.TIP` when we have a tip, or :py:attr:`CriticalPoint.XY_CENTER` - the specified critical point will be used. """ if not self.has_tip or cp_override == CriticalPoint.NOZZLE: cp_type = CriticalPoint.NOZZLE tip_length = 0.0 else: cp_type = CriticalPoint.TIP tip_length = self.current_tip_length if cp_override == CriticalPoint.XY_CENTER: mod_offset_xy = [0, 0, self.model_offset[2]] cp_type = CriticalPoint.XY_CENTER elif cp_override == CriticalPoint.FRONT_NOZZLE: mod_offset_xy = [0, -self.model_offset[1], self.model_offset[2]] cp_type = CriticalPoint.FRONT_NOZZLE else: mod_offset_xy = self.model_offset mod_and_tip = Point(mod_offset_xy[0], mod_offset_xy[1], mod_offset_xy[2] - tip_length) if enable_calibration_overhaul(): instr = self._instrument_offset else: instr = self._instrument_offset._replace(z=0) cp = mod_and_tip + instr if self._log.isEnabledFor(logging.DEBUG): info_str = 'cp: {}{}: {} (from: '\ .format(cp_type, ' (from override)' if cp_override else '', cp) info_str += 'model offset: {} + instrument offset: {}'\ .format(mod_offset_xy, instr) info_str += ' - tip_length: {}'.format(tip_length) info_str += ')' self._log.debug(info_str) return cp @property def current_volume(self) -> float: """ The amount of liquid currently aspirated """ return self._current_volume @property def current_tip_length(self) -> float: """ The length of the current tip attached (0.0 if no tip) """ if enable_calibration_overhaul(): return self._current_tip_length else: return (self._current_tip_length - self._instrument_offset.z) @property def current_tiprack_diameter(self) -> float: """ The diameter of the current tip rack (0.0 if no tip) """ return self._current_tiprack_diameter @current_tiprack_diameter.setter def current_tiprack_diameter(self, diameter: float): self._current_tiprack_diameter = diameter @property def aspirate_flow_rate(self) -> float: """ Current active flow rate (not config value)""" return self._aspirate_flow_rate @aspirate_flow_rate.setter def aspirate_flow_rate(self, new_flow_rate: float): assert new_flow_rate > 0 self._aspirate_flow_rate = new_flow_rate @property def dispense_flow_rate(self) -> float: """ Current active flow rate (not config value)""" return self._dispense_flow_rate @dispense_flow_rate.setter def dispense_flow_rate(self, new_flow_rate: float): assert new_flow_rate > 0 self._dispense_flow_rate = new_flow_rate @property def blow_out_flow_rate(self) -> float: """ Current active flow rate (not config value)""" return self._blow_out_flow_rate @blow_out_flow_rate.setter def blow_out_flow_rate(self, new_flow_rate: float): assert new_flow_rate > 0 self._blow_out_flow_rate = new_flow_rate @property def working_volume(self) -> float: """ The working volume of the pipette """ return self._working_volume @working_volume.setter def working_volume(self, tip_volume: float): """ The working volume is the current tip max volume """ self._working_volume = min(self.config.max_volume, tip_volume) @property def available_volume(self) -> float: """ The amount of liquid possible to aspirate """ return self.working_volume - self.current_volume def set_current_volume(self, new_volume: float): assert new_volume >= 0 assert new_volume <= self.working_volume self._current_volume = new_volume def add_current_volume(self, volume_incr: float): assert self.ok_to_add_volume(volume_incr) self._current_volume += volume_incr def remove_current_volume(self, volume_incr: float): assert self._current_volume >= volume_incr self._current_volume -= volume_incr def ok_to_add_volume(self, volume_incr: float) -> bool: return self.current_volume + volume_incr <= self.working_volume def add_tip(self, tip_length: float) -> None: """ Add a tip to the pipette for position tracking and validation (effectively updates the pipette's critical point) :param tip_length: a positive, non-zero float presenting the distance in Z from the end of the pipette nozzle to the end of the tip :return: """ assert tip_length > 0.0, "tip_length must be greater than 0" assert not self.has_tip self._has_tip = True self._current_tip_length = tip_length def remove_tip(self) -> None: """ Remove the tip from the pipette (effectively updates the pipette's critical point) """ assert self.has_tip self._has_tip = False self._current_tip_length = 0.0 @property def has_tip(self) -> bool: return self._has_tip def ul_per_mm(self, ul: float, action: UlPerMmAction) -> float: sequence = self._config.ul_per_mm[action] return pipette_config.piecewise_volume_conversion(ul, sequence) def __str__(self) -> str: return '{} current volume {}ul critical point: {} at {}'\ .format(self._config.display_name, self.current_volume, 'tip end' if self.has_tip else 'nozzle end', 0) def __repr__(self) -> str: return '<{}: {} {}>'.format(self.__class__.__name__, self._config.display_name, id(self)) def as_dict(self) -> 'Pipette.DictType': config_dict = asdict(self.config) config_dict.update({ 'current_volume': self.current_volume, 'available_volume': self.available_volume, 'name': self.name, 'model': self.model, 'pipette_id': self.pipette_id, 'has_tip': self.has_tip, 'working_volume': self.working_volume, 'aspirate_flow_rate': self.aspirate_flow_rate, 'dispense_flow_rate': self.dispense_flow_rate, 'blow_out_flow_rate': self.blow_out_flow_rate }) return config_dict