import requests
import time
import logging
import copy
import hashlib
import json
import shutil
from pathlib import Path
from math import ceil
from AFL.automation.APIServer.Driver import Driver
from AFL.automation.prepare.OT2DeckWebAppMixin import OT2DeckWebAppMixin
from AFL.automation.shared.utilities import listify
# Add this constant at the top of the file, after the imports
TIPRACK_WELLS = [f"{row}{col}" for col in range(1, 13) for row in "ABCDEFGH"]
FIXED_TRASH_ADDRESSABLE_AREA = "fixedTrash"
[docs]
class OT2HTTPDriver(OT2DeckWebAppMixin, Driver):
"""HTTP-backed Opentrons OT-2 driver.
This driver wraps the Opentrons HTTP API and persists deck state in the
AFL driver configuration so labware, modules, instruments, tip usage, and
preparation targets can survive run recreation.
Parameters
----------
overrides : dict, optional
Configuration overrides merged into :attr:`defaults` during driver
initialization.
Notes
-----
The driver recreates robot runs on demand and reloads previously configured
deck state when a run expires or is recreated.
Examples
--------
>>> driver = OT2HTTPDriver({"robot_ip": "192.168.1.50"})
>>> driver.load_labware("opentrons_96_tiprack_300ul", "1")
>>> driver.load_instrument("p300_single", "left", ["1"])
>>> driver.transfer("2A1", "3A1", 100)
"""
PIPETTE_NAME_ALIASES = {
"p10": "p10_single",
"p10_single": "p10_single",
"p10_single_gen1": "p10_single",
"p300": "p300_single",
"p300_single": "p300_single",
"p1000": "p1000_single",
"p1000_single": "p1000_single",
}
EXPECTED_TIPRACK_TOKEN = {
"p10_single": "10ul",
"p300_single": "300ul",
"p1000_single": "1000ul",
}
defaults = {}
defaults["robot_ip"] = "127.0.0.1" # Default to localhost, should be overridden
defaults["robot_port"] = "31950" # Default Opentrons HTTP API port
defaults["loaded_labware"] = {} # Persistent storage for loaded labware
defaults["loaded_instruments"] = {} # Persistent storage for loaded instruments
defaults["loaded_modules"] = {} # Persistent storage for loaded modules
defaults["available_tips"] = {} # Persistent storage for available tips, Format: {mount: [(tiprack_id, well_name), ...]}
defaults["stock_tip_locations"] = {} # Configured stock tip candidates, Format: {stock_name: ["6A4", "9A4"]}
defaults["stock_tip_reservations"] = {} # Activated stock tip reservations, Format: {stock_name: ["6A4"]}
defaults["reserved_stock_tips"] = [] # Tip locations reserved for stock pipetting, e.g. ["6A4"]
defaults["occupied_sample_locations"] = [] # Sample destinations already populated on deck
defaults["prep_targets"] = [] # Persistent storage for prep target well locations
defaults["tip_rack_offset"] = {"x": 0, "y": 0, "z": 0} # Default offset for tip pickup/return at tiprack wells
[docs]
def __init__(self, overrides=None):
"""Initialize the OT-2 HTTP driver.
Parameters
----------
overrides : dict, optional
Configuration values that override the class defaults.
Examples
--------
>>> driver = OT2HTTPDriver({"robot_ip": "127.0.0.1", "robot_port": "31950"})
>>> driver.base_url
'http://127.0.0.1:31950'
"""
self.app = None
Driver.__init__(
self,
name="OT2_HTTP_Driver",
defaults=self.gather_defaults(),
overrides=overrides,
)
self.name = "OT2_HTTP_Driver"
# Initialize state variables
self.session_id = None
self.protocol_id = None
self.max_transfer = None
self.min_transfer = None
self.has_tip = False
self.last_pipette = None
self.current_tip = None
self.modules = {}
self.pipette_info = {}
# Custom labware handling
self.custom_labware_files = {}
self.sent_custom_labware = {}
self.custom_labware_dir = self._get_custom_labware_dir()
self._load_custom_labware_defs()
# Base URL for HTTP requests
self.base_url = f"http://{self.config['robot_ip']}:{self.config['robot_port']}"
self.headers = {"Opentrons-Version": "2"}
# Initialize the robot connection
self._initialize_robot()
self.useful_links['View Deck'] = '/visualize_deck'
def _log(self, level, message):
"""Log a message safely with or without a Flask app.
Parameters
----------
level : str
Logger method name such as ``"info"`` or ``"error"``.
message : str
Message to emit.
"""
if self.app is not None and hasattr(self.app, "logger"):
log_method = getattr(self.app.logger, level, None)
if log_method:
log_method(message)
else:
print(f"[{level.upper()}] {message}")
[docs]
def log_info(self, message):
"""Log an informational message.
Parameters
----------
message : str
Message to emit.
"""
self._log("info", message)
[docs]
def log_error(self, message):
"""Log an error message.
Parameters
----------
message : str
Message to emit.
"""
self._log("error", message)
[docs]
def log_debug(self, message):
"""Log a debug message.
Parameters
----------
message : str
Message to emit.
"""
self._log("debug", message)
[docs]
def log_warning(self, message):
"""Log a warning message.
Parameters
----------
message : str
Message to emit.
"""
self._log("warning", message)
def _get_custom_labware_dir(self) -> Path:
"""Return the user-scoped custom labware directory.
Returns
-------
pathlib.Path
Directory used to persist custom labware JSON definitions.
"""
custom_labware_dir = Path.home() / ".afl" / "opentrons_labware"
self._bootstrap_custom_labware_dir(custom_labware_dir)
return custom_labware_dir
def _get_seed_custom_labware_dir(self):
"""Locate packaged labware definitions used for first-run seeding.
Returns
-------
pathlib.Path or None
Seed directory when found, otherwise ``None``.
"""
current = Path(__file__).resolve()
for parent in current.parents:
candidate = parent / "support" / "labware"
if candidate.is_dir():
return candidate
candidate = Path.cwd() / "support" / "labware"
if candidate.is_dir():
return candidate
return None
def _bootstrap_custom_labware_dir(self, custom_labware_dir: Path):
"""Create and seed the user custom-labware directory.
Parameters
----------
custom_labware_dir : pathlib.Path
Destination directory to create and seed.
"""
if custom_labware_dir.exists():
return
custom_labware_dir.mkdir(parents=True, exist_ok=True)
seed_dir = self._get_seed_custom_labware_dir()
if seed_dir is None:
self.log_warning(
f"Custom labware seed directory not found; leaving {custom_labware_dir} empty"
)
return
for json_file in sorted(seed_dir.glob("*.json")):
shutil.copy2(json_file, custom_labware_dir / json_file.name)
def _load_custom_labware_defs(self):
"""Index locally available custom labware definitions.
Notes
-----
Definitions are keyed by ``namespace/loadName`` and duplicate keys are
rejected to avoid ambiguous uploads.
"""
self.custom_labware_files = {}
duplicates = []
for json_file in self.custom_labware_dir.glob("*.json"):
try:
with open(json_file, "r") as f:
definition = json.load(f)
_, _, key = self._custom_labware_key(definition)
existing_path = self.custom_labware_files.get(key)
if existing_path is not None and existing_path != json_file:
duplicates.append((key, existing_path, json_file))
continue
self.custom_labware_files[key] = json_file
except Exception:
continue
if duplicates:
details = "; ".join(
f"{key}: {first} vs {second}"
for key, first, second in duplicates
)
raise ValueError(
f"Duplicate custom labware definitions found in {self.custom_labware_dir}: {details}"
)
def _custom_labware_key(self, labware_def):
"""Extract the canonical key for a labware definition.
Parameters
----------
labware_def : dict
Opentrons labware definition.
Returns
-------
tuple
``(namespace, load_name, key)`` where ``key`` is
``"namespace/load_name"``.
"""
namespace = labware_def.get("namespace", "custom_beta")
load_name = labware_def.get("parameters", {}).get("loadName")
if not load_name:
raise ValueError("labware_def missing parameters.loadName")
return namespace, load_name, f"{namespace}/{load_name}"
def _canonical_labware_def(self, labware_def):
"""Return a normalized copy of a labware definition for hashing.
Parameters
----------
labware_def : dict
Labware definition to normalize.
Returns
-------
dict
Deep-copied normalized definition.
"""
canonical = copy.deepcopy(labware_def)
canonical.pop("version", None)
return json.dumps(canonical, sort_keys=True, separators=(",", ":"))
def _hash_labware_def(self, labware_def):
"""Compute a stable content hash for a labware definition.
Parameters
----------
labware_def : dict
Labware definition to hash.
Returns
-------
str
SHA-256 hex digest.
"""
canonical = self._canonical_labware_def(labware_def)
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
def _labware_upload_info(self, key):
"""Return cached upload metadata for a custom labware key.
Parameters
----------
key : str
``namespace/load_name`` key.
Returns
-------
dict or None
Cached upload metadata when available.
"""
info = self.sent_custom_labware.get(key)
if isinstance(info, dict):
return info
return None
def _next_custom_labware_version(self, key, labware_def):
"""Choose the next upload version for a custom labware definition.
Parameters
----------
key : str
``namespace/load_name`` key.
labware_def : dict
Labware definition being uploaded.
Returns
-------
int
Version number to upload for the current run.
"""
requested_version = int(labware_def.get("version", 1) or 1)
if self._labware_upload_info(key) is not None:
return max(requested_version, int(self._labware_upload_info(key)["version"]) + 1)
return requested_version
def _custom_labware_file_path(self, labware_def):
"""Return the on-disk JSON path for a custom labware definition.
Parameters
----------
labware_def : dict
Labware definition.
Returns
-------
pathlib.Path
Destination JSON path in the user custom-labware directory.
"""
_, load_name, _ = self._custom_labware_key(labware_def)
return self.custom_labware_dir / f"{load_name}.json"
def _loaded_labware_key(self, labware_info):
"""Derive a ``namespace/load_name`` key from persisted labware metadata.
Parameters
----------
labware_info : tuple
Persisted labware tuple stored in ``config["loaded_labware"]``.
Returns
-------
str or None
Derived key when enough metadata is available.
"""
if not isinstance(labware_info, tuple) or len(labware_info) < 2:
return None
load_name = labware_info[1]
result = labware_info[2] if len(labware_info) >= 3 else {}
definition = result.get("definition", {}) if isinstance(result, dict) else {}
namespace = definition.get("namespace", "custom_beta")
return namespace, load_name
def _remap_tip_availability(self, old_uuid_to_slot, slot_to_new_tiprack_uuid):
"""Remap available-tip tracking after tiprack reload.
Parameters
----------
old_uuid_to_slot : dict
Mapping from old tiprack UUIDs to deck slots.
slot_to_new_tiprack_uuid : dict
Mapping from deck slots to newly loaded tiprack UUIDs.
"""
old_available_tips = self.config.get("available_tips", {})
new_available_tips = {}
for mount in self.config["loaded_instruments"].keys():
new_available_tips[mount] = []
for tiprack_uuid, well in old_available_tips.get(mount, []):
slot = old_uuid_to_slot.get(tiprack_uuid)
new_uuid = slot_to_new_tiprack_uuid.get(slot)
if new_uuid is not None:
new_available_tips[mount].append((new_uuid, well))
self.log_info(f"Remapped {len(new_available_tips[mount])} available tips for {mount} mount after reload.")
self.config["available_tips"] = new_available_tips
def _reload_matching_labware_definition(self, labware_def, run_id=None, check_run_status=True):
"""Reload active labware instances that match an updated definition.
Parameters
----------
labware_def : dict
Updated labware definition.
run_id : str, optional
Existing run identifier to reuse.
check_run_status : bool, default=True
If ``False``, skip the run-status GET check.
Returns
-------
bool
``True`` when reload succeeds or no reload is needed.
"""
namespace, load_name, key = self._custom_labware_key(labware_def)
matching_slots = []
original_labware = copy.deepcopy(self.config["loaded_labware"])
for slot, labware_info in original_labware.items():
loaded_key = self._loaded_labware_key(labware_info)
if loaded_key is None:
continue
loaded_namespace, loaded_name = loaded_key
if loaded_namespace == namespace and loaded_name == load_name:
matching_slots.append(str(slot))
if not matching_slots:
return False
if run_id is None:
run_id = self._ensure_run_exists(check_run_status=check_run_status)
original_instruments = copy.deepcopy(self.config["loaded_instruments"])
old_uuid_to_slot = {}
slot_to_new_tiprack_uuid = {}
affected_mounts = {}
for mount, instrument in original_instruments.items():
tiprack_slots = []
for tiprack_uuid in instrument.get("tip_racks", []):
slot = self._slot_by_labware_uuid(tiprack_uuid)
if slot is not None:
slot = str(slot)
old_uuid_to_slot[tiprack_uuid] = slot
tiprack_slots.append(slot)
if any(slot in matching_slots for slot in tiprack_slots):
affected_mounts[mount] = {
"name": instrument["name"],
"tiprack_slots": tiprack_slots,
}
for slot in matching_slots:
module_id = None
if slot in self.config["loaded_modules"]:
module_id = self.config["loaded_modules"][slot][0]
self.log_info(
f"Reloading active labware '{namespace}/{load_name}' in slot {slot}"
)
new_labware_id = self.load_labware(
f"{namespace}/{load_name}",
slot,
module=module_id,
labware_json=labware_def,
check_run_status=False,
)
slot_to_new_tiprack_uuid[str(slot)] = new_labware_id
for mount, instrument in affected_mounts.items():
self.log_info(
f"Reloading pipette '{instrument['name']}' on {mount} mount after tiprack update"
)
self.load_instrument(
instrument["name"],
mount,
instrument["tiprack_slots"],
reload=True,
check_run_status=False,
)
if slot_to_new_tiprack_uuid:
self._remap_tip_availability(old_uuid_to_slot, slot_to_new_tiprack_uuid)
if affected_mounts:
self.has_tip = False
self.last_pipette = None
self.current_tip = None
return True
def _initialize_robot(self):
"""Probe robot connectivity and refresh attached pipette metadata.
Raises
------
RuntimeError
If the robot cannot be reached or pipette metadata cannot be read.
"""
self.log_info("Initializing OT2 HTTP Driver")
try:
# Check if the robot is reachable
response = requests.get(url=f"{self.base_url}/health", headers=self.headers)
if response.status_code != 200:
raise ConnectionError(f"Failed to connect to robot at {self.base_url}")
# Get attached pipettes
self._update_pipettes()
except requests.exceptions.RequestException as e:
self.log_error(f"Error connecting to robot: {str(e)}")
raise ConnectionError(
f"Error connecting to robot at {self.base_url}: {str(e)}"
)
def _update_pipettes(self):
"""Get information about attached pipettes and their settings"""
try:
if self.app is not None:
self.log_info("Fetching pipette information from robot")
# Get basic pipette information
response = requests.get(
url=f"{self.base_url}/instruments", headers=self.headers
)
if response.status_code != 200:
raise RuntimeError(f"Failed to get pipettes: {response.text}")
pipettes_data = response.json()['data']
self.pipette_info = {}
# Update min/max transfer values based on attached pipettes
self.min_transfer = None
self.max_transfer = None
for pipette in pipettes_data:
mount = pipette['mount']
try:
pipette_id = self.config["loaded_instruments"][mount]["pipette_id"] # the id from this run
except KeyError:
pipette_id = None
# Store basic pipette info
self.pipette_info[mount] = {
"id": pipette_id,
"name": pipette["instrumentName"],
"model": pipette["instrumentModel"],
"serial": pipette["serialNumber"],
"mount": mount,
"min_volume": pipette.get("data",{}).get("min_volume", None),
"max_volume": pipette.get("data",{}).get("max_volume", None),
"aspirate_flow_rate": pipette.get("data",{}).get(
"aspirateFlowRate", {}
).get("value",150),
"dispense_flow_rate": pipette.get("data",{}).get(
"dispenseFlowRate", {}
).get("value",150),
"channels": pipette.get("data",{}).get("channels", 1),
}
if pipette_id is None:
continue
# Update global min/max transfer values
min_volume = self.pipette_info[mount]['min_volume']
max_volume = self.pipette_info[mount]['max_volume']
if (self.min_transfer is None) or (self.min_transfer > min_volume):
self.min_transfer = min_volume
if self.app is not None:
self.log_info(
f"Setting minimum transfer to {self.min_transfer}"
)
if (self.max_transfer is None) or (self.max_transfer < max_volume):
self.max_transfer = max_volume
if self.app is not None:
self.log_info(
f"Setting maximum transfer to {self.max_transfer}"
)
if self.app is not None:
self.log_info(f"Pipette information updated: {self.pipette_info}")
except Exception as e:
raise RuntimeError(f"Error getting pipettes: {str(e)}")
def _get_active_pipettes(self):
"""Return pipettes that are attached and loaded into the active run.
Returns
-------
dict
Mapping from mount name to pipette metadata.
"""
active_pipettes = {}
loaded_instruments = self.config.get("loaded_instruments", {})
for mount, info in self.pipette_info.items():
if not info:
continue
if mount not in loaded_instruments:
continue
if info.get("id") is None:
continue
active_pipettes[mount] = info
return active_pipettes
def _get_active_pipette_info(self, mount):
"""Return active pipette metadata for a mount.
Parameters
----------
mount : str
Pipette mount, typically ``"left"`` or ``"right"``.
Returns
-------
dict
Active pipette metadata.
Raises
------
ValueError
If no loaded pipette is available on the requested mount.
"""
mount = str(mount).strip().lower()
info = self._get_active_pipettes().get(mount)
if info is None:
raise ValueError(f"No loaded pipette available on {mount} mount")
return info
[docs]
def reset_prep_targets(self):
"""Clear queued preparation targets.
Examples
--------
>>> driver.reset_prep_targets()
"""
self.config["prep_targets"] = []
[docs]
def add_prep_targets(self, targets, reset=False):
"""Append preparation target locations.
Parameters
----------
targets : str or sequence of str
Target well locations to queue.
reset : bool, default=False
If ``True``, clear existing targets before appending.
Examples
--------
>>> driver.add_prep_targets(["4A1", "4A2"], reset=True)
"""
if reset:
self.reset_prep_targets()
self.config.setdefault("prep_targets", [])
self.config["prep_targets"].extend(listify(targets))
self.config._update_history()
[docs]
def get_prep_target(self):
"""Pop and return the next queued preparation target.
Returns
-------
str
Next queued target location.
"""
return self.config["prep_targets"].pop(0)
[docs]
def status(self):
"""Return human-readable OT-2 status lines.
Returns
-------
list of str
Status lines describing prep targets, tip state, session state,
active pipettes, and loaded labware.
"""
status = []
prep_targets = self.config.get("prep_targets", [])
if len(prep_targets) > 0:
status.append(f"Next prep target: {prep_targets[0]}")
status.append(f"Remaining prep targets: {len(prep_targets)}")
else:
status.append("No prep targets loaded")
status.append(self.get_tip_status())
# Get current session status if available
if self.session_id:
try:
response = requests.get(
url=f"{self.base_url}/sessions/{self.session_id}",
headers=self.headers,
)
if response.status_code == 200:
session_data = response.json().get("data", {})
current_state = session_data.get("details", {}).get(
"currentState", "unknown"
)
status.append(f"Session state: {current_state}")
except requests.exceptions.RequestException:
status.append("Unable to get session status")
# Get pipette information for mounts that are loaded into the active run
for mount, pipette in self._get_active_pipettes().items():
if pipette:
status.append(
f"Pipette on {mount} mount: {pipette.get('model', 'unknown')}"
)
# Get loaded labware information
try:
for slot, (labware_id, name, _) in self.config["loaded_labware"].items():
status.append(f"Labware in slot {slot}: {name}")
except Exception:
print(self.config["loaded_labware"])
return status
[docs]
@Driver.quickbar(
qb={
"button_text": "Refill Tipracks",
"params": {
"mount": {
"label": "Which Pipet left/right/both",
"type": "text",
"default": "both",
},
},
}
)
def reset_tipracks(self, mount="both"):
"""Reset available-tip tracking for one or more mounts.
Parameters
----------
mount : {"left", "right", "both"}, default="both"
Mount selection to reset.
"""
self.log_info(f"Resetting tipracks for {mount} mount")
mounts_to_reset = []
if mount == "both":
mounts_to_reset = list(self.config["loaded_instruments"].keys())
else:
mounts_to_reset = [mount]
for m in mounts_to_reset:
if m in self.config["loaded_instruments"]:
# Reinitialize available tips for this mount
self.config["available_tips"][m] = []
for tiprack in self.config["loaded_instruments"][m]["tip_racks"]:
for well in TIPRACK_WELLS:
self.config["available_tips"][m].append((tiprack, well))
self.log_info(f"Reset {len(self.config['available_tips'][m])} tips for {m} mount")
# Reset tip status
self.has_tip = False
self.current_tip = None
[docs]
def reset(self):
"""Reset the active OT-2 session, protocol, and persisted deck state."""
self.log_info("Resetting the protocol context")
# Delete any active session
if self.session_id:
try:
requests.delete(
url=f"{self.base_url}/sessions/{self.session_id}",
headers=self.headers,
)
except requests.exceptions.RequestException as e:
self.log_error(f"Error deleting session: {str(e)}")
# Delete any uploaded protocol
if self.protocol_id:
try:
requests.delete(
url=f"{self.base_url}/protocols/{self.protocol_id}",
headers=self.headers,
)
except requests.exceptions.RequestException as e:
self.log_error(f"Error deleting protocol: {str(e)}")
# Reset state variables
self.session_id = None
self.protocol_id = None
self.has_tip = False
self.last_pipette = None
self.current_tip = None
# Reset deck configuration too
self.reset_deck()
# Re-initialize robot connection
self._initialize_robot()
[docs]
def reset_deck(self):
"""Clear persisted deck configuration and related in-memory state."""
self.log_info("Resetting the deck configuration")
# Clear the deck configuration
self.config["loaded_labware"] = {}
self.config["loaded_instruments"] = {}
self.config["loaded_modules"] = {}
self.config["available_tips"] = {}
self.config["prep_targets"] = []
# Clear internal state variables
self.modules = {}
self.sent_custom_labware = {}
self.run_id = None
self.current_tip = None
[docs]
@Driver.quickbar(qb={"button_text": "Home"})
def home(self, **kwargs):
"""
Home the robot's axes using the dedicated /robot/home endpoint.
This endpoint is a direct control endpoint and doesn't require creating a run.
It can be used to home all axes at once or specific axes as needed.
"""
self.log_info("Homing the robot's axes")
try:
# Call the dedicated home endpoint
response = requests.post(
url=f"{self.base_url}/robot/home",
headers=self.headers,
json={
"target": "robot", # Home the entire robot
},
)
if response.status_code != 200:
self.log_error(f"Failed to home robot: {response.status_code}")
self.log_error(f"Response: {response.text}")
raise RuntimeError(f"Failed to home robot: {response.text}")
self.log_info("Robot homing completed successfully")
return True
except requests.exceptions.RequestException as e:
self.log_error(f"Error during homing: {str(e)}")
raise RuntimeError(f"Error during homing: {str(e)}")
[docs]
def parse_well(self, loc):
"""Split a deck location into slot and well components.
Parameters
----------
loc : str
Deck location such as ``"1A1"``.
Returns
-------
tuple
Two-item tuple ``(slot, well)``.
"""
# Default value in case no alphabetic character is found
i = 0
for i, loc_part in enumerate(list(loc)):
if loc_part.isalpha():
break
slot = loc[:i]
well = loc[i:]
return slot, well
[docs]
def get_wells(self, locs):
"""Convert deck locations into validated HTTP API well descriptors.
Parameters
----------
locs : str or sequence of str
Deck locations in ``slot+well`` form, for example ``"1A1"``.
Returns
-------
list of dict
Well descriptors containing ``labwareId`` and ``wellName``.
Raises
------
ValueError
If the slot has no loaded labware or the stored labware metadata is
malformed.
AssertionError
If the requested well is not valid for the loaded labware.
"""
self.log_debug(f"Converting locations to well objects: {locs}")
wells = []
for loc in listify(locs):
slot, well = self.parse_well(loc)
# Get labware info from the slot
labware_info = self.config['loaded_labware'].get(slot)
if not labware_info:
raise ValueError(f"No labware found in slot {slot}")
if not isinstance(labware_info, tuple) or len(labware_info) < 1:
raise ValueError(f"Invalid labware info format in slot {slot}")
labware_id = labware_info[0]
wells.append({"labwareId": labware_id, "wellName": well})
self.log_debug(f"Created well objects: {wells}")
# Check well validity here
assert slot in self.config["loaded_labware"].keys(), f"Slot {slot} does not have any loaded labware"
assert well in self.config["loaded_labware"][slot][2]['definition']['wells'].keys(), f"Well {well} is not a valid well for slot {slot}, {self.config['loaded_labware'][slot][2]['definition']['metadata']['displayName']}"
return wells
def _check_cmd_success(self, response):
"""Raise when an HTTP command response indicates failure.
Parameters
----------
response : requests.Response
Response returned by the robot server.
"""
if response.status_code != 201:
self.log_error(
f"Failed to execute command : {response.status_code}"
)
self.log_error(f"Response: {response.text}")
raise RuntimeError(
f"Failed to execute command: {response.text}"
)
if 'status' in response.json()['data'].keys():
if response.json()['data']['status'] == 'failed':
self.log_error(
f"Command returned error : {response.status_code}"
)
self.log_error(f"Response: {response.text}")
raise RuntimeError(
f"Command returned error: {response.text}"
)
[docs]
def send_labware(
self, labware_def, check_run_status=True, reload_loaded_labware=True
):
"""Persist and upload a custom labware definition.
Parameters
----------
labware_def : dict
Opentrons labware definition.
check_run_status : bool, default=True
If ``False``, skip the run-status GET check when ensuring a run.
reload_loaded_labware : bool, default=True
If ``True``, reload matching active labware after upload.
Returns
-------
dict
Upload metadata including definition URI, version, and content hash.
"""
self.log_debug(f"Sending custom labware definition: {labware_def}")
ns, load_name, key = self._custom_labware_key(labware_def)
content_hash = self._hash_labware_def(labware_def)
# Persist the definition for future use
self.custom_labware_dir.mkdir(parents=True, exist_ok=True)
file_path = self._custom_labware_file_path(labware_def)
file_path.parent.mkdir(parents=True, exist_ok=True)
with open(file_path, "w") as f:
json.dump(labware_def, f, indent=2)
self._load_custom_labware_defs()
existing = self._labware_upload_info(key)
if existing is not None and existing.get("content_hash") == content_hash:
self.log_debug(
f"Labware {key} already sent to robot as version {existing['version']}"
)
return copy.deepcopy(existing)
# Ensure we have a valid run
run_id = self._ensure_run_exists(check_run_status=check_run_status)
try:
upload_def = copy.deepcopy(labware_def)
upload_version = self._next_custom_labware_version(key, upload_def)
upload_def["version"] = upload_version
command_dict = {"data": upload_def}
response = requests.post(
url=f"{self.base_url}/runs/{run_id}/labware_definitions",
headers=self.headers,
params={"waitUntilComplete": True},
json=command_dict,
)
self._check_cmd_success(response)
response_data = response.json()
labware_name = response_data["data"]["definitionUri"]
upload_info = {
"definition_uri": labware_name,
"version": upload_version,
"content_hash": content_hash,
}
self.sent_custom_labware[key] = upload_info
if reload_loaded_labware:
self._reload_matching_labware_definition(
upload_def, run_id=run_id, check_run_status=False
)
self.log_info(
f"Successfully sent custom labware with name/URI {labware_name}"
)
return copy.deepcopy(upload_info)
except (requests.exceptions.RequestException, KeyError) as e:
self.log_error(f"Error sending custom labware: {str(e)}")
raise RuntimeError(f"Error sending custom labware: {str(e)}")
[docs]
def load_labware(self, name, slot, module=None, check_run_status=True, **kwargs):
"""Load labware into a deck slot or module.
Parameters
----------
name : str
Labware load name or ``namespace/load_name`` key.
slot : str
Deck slot identifier.
module : str, optional
Module identifier when loading onto a module.
check_run_status : bool, default=True
If ``False``, skip the run-status GET check when ensuring a run.
**kwargs
Additional options, including ``labware_json`` for custom labware.
Returns
-------
str
Loaded labware identifier returned by the robot.
"""
self.log_debug(f"Loading labware '{name}' into slot '{slot}'")
# Ensure we have a valid run
run_id = self._ensure_run_exists(check_run_status=check_run_status)
labware_json = kwargs.pop("labware_json", None)
version = 1
if labware_json is not None:
namespace = labware_json.get("namespace", "custom_beta")
load_name = labware_json.get("parameters", {}).get("loadName")
if not load_name:
raise ValueError("labware_json missing parameters.loadName")
name = load_name
version = int(labware_json.get("version", 1) or 1)
if namespace != "opentrons":
upload_info = self.send_labware(
labware_json,
check_run_status=check_run_status,
reload_loaded_labware=False,
)
version = int(upload_info["version"])
else:
if "/" in name:
namespace, load_name = name.split("/", 1)
name = load_name
else:
load_name = name
if f"custom_beta/{load_name}" in self.custom_labware_files:
namespace = "custom_beta"
elif f"opentrons/{load_name}" in self.custom_labware_files:
namespace = "opentrons"
else:
namespace = "opentrons"
key = f"{namespace}/{load_name}"
if namespace != "opentrons":
path = self.custom_labware_files.get(key)
if path and Path(path).exists():
with open(path, "r") as f:
definition = json.load(f)
upload_info = self.send_labware(
definition,
check_run_status=check_run_status,
reload_loaded_labware=False,
)
version = int(upload_info["version"])
else:
self.log_warning(f"Custom labware definition not found for {key}")
try:
# Check if there's existing labware in the slot
if slot in self.config["loaded_labware"]:
self.log_info(
f"Found existing labware in slot {slot}, moving it off-deck first"
)
existing_labware_id = self.config["loaded_labware"][slot][
0
] # Get the ID of existing labware
# Create command to move existing labware off-deck
move_command = {
"data": {
"commandType": "moveLabware",
"params": {
"labwareId": existing_labware_id,
"newLocation": "offDeck",
"strategy": "manualMoveWithoutPause", # Allow user to manually move the labware
},
"intent": "setup",
}
}
# Execute the move command
move_response = requests.post(
url=f"{self.base_url}/runs/{run_id}/commands",
headers=self.headers,
params={"waitUntilComplete": True},
json=move_command,
)
self._check_cmd_success(move_response)
# Remove from our tracking
del self.config["loaded_labware"][slot]
if str(slot) in self.config["loaded_modules"].keys():
# we need to load into a module, not a slot
location = {"moduleId": self.config["loaded_modules"][str(slot)][0]}
else:
location = {"slotName": str(slot)}
# Prepare the loadLabware command
command_dict = {
"data": {
"commandType": "loadLabware",
"params": {
"location": location,
"loadName": name,
"namespace": namespace,
"version": version,
},
"intent": "setup",
}
}
# If this is a module, we need to specify the moduleId
if module:
command_dict["data"]["params"]["moduleId"] = module
# Execute the command
response = requests.post(
url=f"{self.base_url}/runs/{run_id}/commands",
headers=self.headers,
params={"waitUntilComplete": True},
json=command_dict,
)
self._check_cmd_success(response)
# Get the labware ID from the response
response_data = response.json()
# Debug log the response structure
self.log_debug(f"Load labware response: {response_data}")
# Handle different response structures that might occur
try:
if "data" in response_data and "result" in response_data["data"]:
labware_id = response_data["data"]["result"]["labwareId"]
elif "data" in response_data and "labwareId" in response_data["data"]:
labware_id = response_data["data"]["labwareId"]
elif "data" in response_data and "id" in response_data["data"]:
labware_id = response_data["data"]["id"]
else:
# Try to find labware ID in any structure
self.log_warning(f"Unexpected response structure: {response_data}")
for key, value in response_data.items():
if isinstance(value, dict) and "labwareId" in value:
labware_id = value["labwareId"]
break
else:
raise KeyError("Could not find labwareId in response")
except KeyError as e:
self.log_error(f"Error extracting labware ID from response: {str(e)}")
self.log_error(f"Response data: {response_data}")
raise RuntimeError(
f"Failed to extract labware ID from response: {str(e)}"
)
result = response_data["data"]["result"]
# Store the labware information directly in config
self.config["loaded_labware"][slot] = (labware_id, name, result)
# If this is a module, store it
if module:
self.modules[slot] = module
self.log_info(
f"Successfully loaded labware '{name}' in slot {slot} with ID {labware_id}"
)
self.config._update_history()
return labware_id
except (requests.exceptions.RequestException, KeyError) as e:
self.log_error(f"Error loading labware: {str(e)}")
raise RuntimeError(f"Error loading labware: {str(e)}")
[docs]
def load_module(self, name, slot, check_run_status=True, **kwargs):
"""Load a module into a deck slot.
Parameters
----------
name : str
Module model name.
slot : str
Deck slot identifier.
check_run_status : bool, default=True
If ``False``, skip the run-status GET check when ensuring a run.
Returns
-------
str
Loaded module identifier returned by the robot.
"""
slot = str(slot)
self.log_debug(f"Loading module '{name}' into slot '{slot}'")
existing_module = self.config["loaded_modules"].get(slot)
if existing_module is not None:
try:
existing_module_id, existing_name = existing_module
except (TypeError, ValueError) as exc:
raise RuntimeError(
f"Cannot load module {name!r} in deck slot {slot!r}: the stored "
f"module record is invalid: {existing_module!r}"
) from exc
if existing_name == name:
self.log_info(
f"Module {name!r} is already loaded in deck slot {slot!r} "
f"with ID {existing_module_id!r}; reusing it."
)
return existing_module_id
raise RuntimeError(
f"Cannot load module {name!r} in deck slot {slot!r}: slot already "
f"contains module {existing_name!r} with ID {existing_module_id!r}. "
"Unload or reset the existing module before replacing it."
)
# Ensure we have a valid run
run_id = self._ensure_run_exists(check_run_status=check_run_status)
try:
# Prepare the loadLabware command
command_dict = {
"data": {
"commandType": "loadModule",
"params": {
"location": {"slotName": str(slot)},
"model": name,
},
"intent": "setup",
}
}
# Execute the command
response = requests.post(
url=f"{self.base_url}/runs/{run_id}/commands",
headers=self.headers,
params={"waitUntilComplete": True},
json=command_dict,
)
try:
self._check_cmd_success(response)
except RuntimeError:
message = self._module_load_failure_message(name, slot, response)
self.log_error(message)
raise RuntimeError(message) from None
# Get the labware ID from the response
response_data = response.json()
# Debug log the response structure
self.log_debug(f"Load labware response: {response_data}")
# Handle different response structures that might occur
try:
if "data" in response_data and "result" in response_data["data"]:
module_id = response_data["data"]["result"]["moduleId"]
elif "data" in response_data and "moduleId" in response_data["data"]:
module_id = response_data["data"]["moduleId"]
elif "data" in response_data and "id" in response_data["data"]:
module_id = response_data["data"]["id"]
else:
# Try to find labware ID in any structure
self.log_warning(f"Unexpected response structure: {response_data}")
for key, value in response_data.items():
if isinstance(value, dict) and "moduleId" in value:
module_id = value["moduleId"]
break
else:
raise KeyError("Could not find moduleId in response")
except KeyError as e:
self.log_error(f"Error extracting module ID from response: {str(e)}")
self.log_error(f"Response data: {response_data}")
raise RuntimeError(
f"Failed to extract module ID from response: {str(e)}"
)
# Store the module information directly in config
self.config["loaded_modules"][slot] = (module_id, name)
self.log_info(
f"Successfully loaded module '{name}' in slot {slot} with ID {module_id}"
)
self.config._update_history()
return module_id
except (requests.exceptions.RequestException, KeyError) as e:
self.log_error(f"Error loading module: {str(e)}")
raise RuntimeError(f"Error loading module: {str(e)}")
@staticmethod
def _module_load_failure_message(name, slot, response):
"""Create an actionable error message from an OT-2 load-module response."""
error_type = None
error_code = None
detail = None
try:
error = response.json().get("data", {}).get("error", {})
error_type = error.get("errorType")
error_code = error.get("errorCode")
detail = error.get("detail")
except (AttributeError, TypeError, ValueError):
pass
reported_error = ""
if error_type:
reported_error = f" Robot reported {error_type}"
if error_code:
reported_error += f" (code {error_code})"
if detail:
reported_error += f": {detail}"
reported_error += "."
elif detail:
reported_error = f" Robot reported: {detail}."
return (
f"Unable to load OT-2 module {name!r} in deck slot {str(slot)!r}."
f"{reported_error} Verify that the module is connected to the OT-2, "
"powered if required, matches the requested model, and is detected by "
"the Opentrons hardware server before retrying."
)
[docs]
def load_instrument(self, name, mount, tip_rack_slots, reload=False, check_run_status=True, update_pipettes=True, **kwargs):
"""Load a pipette and initialize tip tracking.
Parameters
----------
name : str
Pipette name or alias.
mount : {"left", "right"}
Mount on which to load the pipette.
tip_rack_slots : sequence of str
Slots containing compatible tipracks.
reload : bool, default=False
If ``True``, preserve existing tip availability during run reload.
check_run_status : bool, default=True
If ``False``, skip the run-status GET check when ensuring a run.
update_pipettes : bool, default=True
If ``False``, skip refreshing attached pipette metadata.
Returns
-------
str
Loaded pipette identifier returned by the robot.
"""
pipette_name = self._normalize_pipette_name(name)
mount = str(mount).strip().lower()
if mount not in {"left", "right"}:
raise ValueError(f"Mount must be 'left' or 'right'. Received: {mount!r}")
tip_rack_slots = [str(slot) for slot in listify(tip_rack_slots)]
if len(tip_rack_slots) == 0:
raise ValueError("At least one tip rack slot must be provided.")
for slot in tip_rack_slots:
if slot not in self.config["loaded_labware"]:
raise ValueError(
f"Tip rack slot {slot!r} is not loaded. Load a tiprack first."
)
labware_name = str(self.config["loaded_labware"][slot][1]).lower()
if "tiprack" not in labware_name:
self.log_warning(
f"Slot {slot} contains labware '{labware_name}', which may not be a tiprack."
)
self._warn_on_tiprack_mismatch(pipette_name, tip_rack_slots)
self.log_debug(
f"Loading pipette '{pipette_name}' on '{mount}' mount with tip_racks in slots {tip_rack_slots}"
)
# Ensure we have a valid run
run_id = self._ensure_run_exists(check_run_status=check_run_status)
try:
# First, load the pipette using the HTTP API
command_dict = {
"data": {
"commandType": "loadPipette",
"params": {
"pipetteName": pipette_name,
"mount": mount,
"tip_racks": [self.config["loaded_labware"][str(slot)][0] for slot in tip_rack_slots],
},
"intent": "setup",
}
}
# Execute the loadPipette command
response = requests.post(
url=f"{self.base_url}/runs/{run_id}/commands",
headers=self.headers,
params={"waitUntilComplete": True},
json=command_dict,
)
self._check_cmd_success(response)
# Get the pipette ID from the response
response_data = response.json()
logging.debug(f'loadPipette response: {response_data}')
pipette_id = response_data["data"]["result"]["pipetteId"]
# Make sure we have the latest pipette information (unless disabled for optimization)
if update_pipettes:
self._update_pipettes()
# Ensure pipette_info entry exists before patching
if mount not in self.pipette_info:
self.pipette_info[mount] = {}
self.pipette_info[mount][
"id"
] = pipette_id # patch the correct pipette id to the pipette_info dict
# Get the tip rack IDs - note that loaded_labware now stores tuples of (id, name)
tip_racks = []
for slot in listify(tip_rack_slots):
labware_info = self.config["loaded_labware"].get(slot)
if (
labware_info
and isinstance(labware_info, tuple)
and len(labware_info) >= 1
):
tip_racks.append(labware_info[0])
if not tip_racks:
self.log_warning(f"No valid tip racks found in slots {tip_rack_slots}")
# Store the instrument information
self.config["loaded_instruments"][mount] = {
"name": pipette_name,
"pipette_id": pipette_id,
"tip_racks": tip_racks,
}
# If not reloading, initialize available tips for this mount
if not reload:
self.config["available_tips"][mount] = []
for tiprack in tip_racks:
for well in TIPRACK_WELLS:
self.config["available_tips"][mount].append((tiprack, well))
# Verify that there's actually a pipette in this mount
if mount not in self.pipette_info or self.pipette_info[mount] is None:
self.log_warning(
f"No physical pipette detected in {mount} mount, but pipette information stored"
)
# Update min/max values for largest and smallest pipettes
self._update_pipette_ranges()
self.log_info(
f"Successfully loaded pipette '{pipette_name}' on {mount} mount with ID {pipette_id}"
)
self.config._update_history()
return pipette_id
except (requests.exceptions.RequestException, KeyError) as e:
self.log_error(f"Error loading pipette: {str(e)}")
raise RuntimeError(f"Error loading pipette: {str(e)}")
def _normalize_pipette_name(self, name):
"""Normalize a pipette alias to the canonical Opentrons name."""
key = str(name).strip().lower()
normalized = self.PIPETTE_NAME_ALIASES.get(key, key)
return normalized
def _warn_on_tiprack_mismatch(self, pipette_name, tip_rack_slots):
"""Warn when tiprack names appear incompatible with a pipette.
Parameters
----------
pipette_name : str
Canonical pipette name.
tip_rack_slots : sequence of str
Slots containing candidate tipracks.
"""
token = self.EXPECTED_TIPRACK_TOKEN.get(pipette_name)
if token is None:
return
mismatched_slots = []
for slot in tip_rack_slots:
labware_info = self.config["loaded_labware"].get(str(slot))
if labware_info is None:
continue
labware_name = str(labware_info[1]).lower()
if "tiprack" in labware_name and token not in labware_name:
mismatched_slots.append(slot)
if mismatched_slots:
self.log_warning(
f"Loaded pipette '{pipette_name}' with potentially mismatched tipracks in slots {mismatched_slots}. "
f"Expected tiprack names containing '{token}'."
)
def _update_pipette_ranges(self):
"""Update helper ranges used for transfer splitting across pipettes."""
self.min_largest_pipette = None
self.max_smallest_pipette = None
# Get all available pipettes with their volumes
available_pipettes = self._get_active_pipettes()
if available_pipettes:
# Get min and max volumes for each pipette
min_vols = {
mount: info.get("min_volume", float("inf"))
for mount, info in available_pipettes.items()
}
max_vols = {
mount: info.get("max_volume", 0)
for mount, info in available_pipettes.items()
}
# Find the smallest and largest pipettes
if max_vols:
# Use list and regular max/min functions with a key function
mounts = list(max_vols.keys())
if mounts:
largest_pipette_mount = max(
mounts, key=lambda m: max_vols.get(m, 0)
)
smallest_pipette_mount = min(
mounts, key=lambda m: max_vols.get(m, float("inf"))
)
# Set global min/max values
if min_vols and largest_pipette_mount in min_vols:
self.min_largest_pipette = min_vols[largest_pipette_mount]
self.log_info(
f"Setting min_largest_pipette to {self.min_largest_pipette}"
)
if max_vols and smallest_pipette_mount in max_vols:
self.max_smallest_pipette = max_vols[smallest_pipette_mount]
self.log_info(
f"Setting max_smallest_pipette to {self.max_smallest_pipette}"
)
[docs]
def mix(self, volume, location, repetitions=1, **kwargs):
"""Mix liquid in place by repeated aspirate/dispense cycles.
Parameters
----------
volume : float
Mix volume in microliters.
location : str
Deck location to mix.
repetitions : int, default=1
Number of aspirate/dispense cycles.
**kwargs
Reserved for future compatibility.
"""
self.log_info(f"Mixing {volume}uL {repetitions} times at {location}")
# Verify run exists once at the start, then skip checks for all atomic commands
self._ensure_run_exists()
# Get pipette based on volume
pipette = self.get_pipette(volume)
pipette_mount = pipette["mount"] # Get the mount from the pipette object
# Get the pipette ID
pipette_id = None
for mount, data in self.pipette_info.items():
if mount == pipette_mount and data:
pipette_id = data.get("id")
break
if not pipette_id:
raise ValueError(f"Could not find ID for pipette on {pipette_mount} mount")
# Get well location
wells = self.get_wells(location)
if not wells:
raise ValueError("Invalid location")
well = wells[0]
# Pick up tip if needed
if not self.has_tip:
self._execute_atomic_command(
"pickUpTip",
{
"pipetteId": pipette_id,
"pipetteMount": pipette_mount,
"wellLocation": None,
},
check_run_status=False,
)
self.has_tip = True
# Execute mix by performing repetitions of aspirate/dispense
for _ in range(repetitions):
self._execute_atomic_command(
"aspirate",
{
"pipetteId": pipette_id,
"volume": volume,
"labwareId": well["labwareId"],
"wellName": well["wellName"],
"wellLocation": {
"origin": "bottom",
"offset": {"x": 0, "y": 0, "z": 0},
},
},
check_run_status=False,
)
self._execute_atomic_command(
"dispense",
{
"pipetteId": pipette_id,
"volume": volume,
"labwareId": well["labwareId"],
"wellName": well["wellName"],
"wellLocation": {
"origin": "bottom",
"offset": {"x": 0, "y": 0, "z": 0},
},
},
check_run_status=False,
)
[docs]
def transfer(
self,
source,
dest,
volume,
mix_before=None,
mix_after=None,
air_gap=0,
aspirate_rate=None,
dispense_rate=None,
mix_aspirate_rate=None,
mix_dispense_rate=None,
blow_out=False,
post_aspirate_delay=0.0,
aspirate_equilibration_delay=0.0,
post_dispense_delay=0.0,
drop_tip=True,
return_tip=False,
force_new_tip=False,
to_top=True,
to_center=False,
to_top_z_offset=0,
source_z_offset=0,
tip_rack_offset=None,
return_tip_z_offset=None,
fast_mixing=False,
touch_tip=False,
tip_location=None,
**kwargs,
):
"""Transfer liquid between two deck locations.
Parameters
----------
source : str
Source deck location such as ``"2A1"``.
dest : str
Destination deck location such as ``"3B1"``.
volume : float
Requested transfer volume in microliters.
mix_before : sequence of int and float, optional
Two-item sequence ``(repetitions, volume_ul)`` applied before the
aspirate step.
mix_after : sequence of int and float, optional
Two-item sequence ``(repetitions, volume_ul)`` applied after the
dispense step.
air_gap : float, default=0
Air gap volume in microliters.
aspirate_rate : float, optional
Aspirate flow rate in microliters per second.
dispense_rate : float, optional
Dispense flow rate in microliters per second.
mix_aspirate_rate : float, optional
Aspirate flow rate used during mix cycles.
mix_dispense_rate : float, optional
Dispense flow rate used during mix cycles.
blow_out : bool, default=False
If ``True``, perform a blow-out after dispensing.
post_aspirate_delay : float, default=0.0
Delay in seconds after moving above the source well.
aspirate_equilibration_delay : float, default=0.0
Delay in seconds while the tip remains in the source liquid after
aspirating.
post_dispense_delay : float, default=0.0
Delay in seconds after dispensing.
drop_tip : bool, default=True
If ``True``, discard the tip after the transfer.
return_tip : bool, default=False
If ``True``, return the tip to its origin instead of discarding it.
force_new_tip : bool, default=False
If ``True``, force a fresh tip between split sub-transfers.
to_top : bool, default=True
Dispense at the top of the destination well.
to_center : bool, default=False
Dispense at the center of the destination well.
to_top_z_offset : float, default=0
Additional z-offset applied when dispensing to the top.
source_z_offset : float, default=0
Additional z-offset applied when aspirating from the source.
tip_rack_offset : dict, optional
Offset mapping with ``x``, ``y``, and ``z`` keys used for tip pickup
and tip return.
return_tip_z_offset : float, optional
Return-only z-offset applied when returning a tip to its origin.
If omitted, the existing ``tip_rack_offset`` z value is used.
fast_mixing : bool, default=False
Reserved flag for higher-level callers.
touch_tip : bool, default=False
If ``True``, touch the tip to the destination well after dispense.
tip_location : str, optional
Explicit tip location to use, for example ``"1A1"``.
**kwargs
Additional compatibility aliases such as ``blowout`` and
``touchTip``.
Returns
-------
dict
Structured transfer metadata including selected pipette, subtransfer
volumes, source and destination well metadata, and applied options.
Raises
------
ValueError
If the transfer request is invalid or no suitable pipette is loaded.
RuntimeError
If the underlying robot command fails.
Examples
--------
>>> driver.transfer("2A1", "3A1", 150)
>>> driver.transfer(
... "2A1",
... "3A1",
... 50,
... mix_before=(3, 40),
... return_tip=True,
... tip_rack_offset={"x": 0, "y": 0, "z": -1},
... )
"""
self.log_info(f"Transferring {volume}uL from {source} to {dest}")
if drop_tip and return_tip:
raise ValueError("Only one of drop_tip and return_tip can be True")
if "blowout" in kwargs and not blow_out:
blow_out = bool(kwargs["blowout"])
if "touchTip" in kwargs and not touch_tip:
touch_tip = bool(kwargs["touchTip"])
volume_ul = float(volume)
if volume_ul <= 0:
self.log_info(f"Skipping transfer with nonpositive volume {volume_ul}uL from {source} to {dest}")
return {
"source": source,
"dest": dest,
"requested_volume_ul": volume_ul,
"subtransfers_ul": [],
"status": "skipped_nonpositive_volume",
}
self._ensure_run_exists()
if aspirate_rate is not None:
self.set_aspirate_rate(aspirate_rate)
if dispense_rate is not None:
self.set_dispense_rate(dispense_rate)
pipette = self.get_pipette(volume_ul)
pipette_mount = pipette["mount"]
resolve_tip_rack_offset = getattr(self, "_resolve_tip_rack_offset", None)
if resolve_tip_rack_offset is not None:
resolved_tip_rack_offset = resolve_tip_rack_offset(
tip_rack_offset,
mount=pipette_mount,
)
elif tip_rack_offset is None:
resolved_tip_rack_offset = dict(self.config.get("tip_rack_offset", {"x": 0, "y": 0, "z": 0}))
else:
resolved_tip_rack_offset = dict(tip_rack_offset)
requested_tip = None
if tip_location is not None:
tip_location_str = str(tip_location).strip().upper()
resolve_tip_location = getattr(self, "_resolve_tip_location", None)
if resolve_tip_location is not None:
requested_tip = resolve_tip_location(pipette_mount, tip_location_str)
else:
requested_tip = {
"labware_id": self.config["loaded_labware"][str(tip_location_str[0])][0],
"well_name": tip_location_str[1:],
}
requested_tip["location"] = tip_location_str
pipette_id = None
for mount, data in self.pipette_info.items():
if mount == pipette_mount and data:
pipette_id = data.get("id")
break
if not pipette_id:
raise ValueError(f"Could not find ID for pipette on {pipette_mount} mount")
source_wells = self.get_wells(source)
if len(source_wells) > 1:
raise ValueError("Transfer only accepts one source well at a time!")
source_well = source_wells[0]
dest_wells = self.get_wells(dest)
if len(dest_wells) > 1:
raise ValueError("Transfer only accepts one dest well at a time!")
dest_well = dest_wells[0]
source_position = "bottom"
dest_position = "bottom"
if to_top and to_center:
raise ValueError("Cannot dispense to_top and to_center simultaneously")
elif to_top:
dest_position = "top"
elif to_center:
dest_position = "center"
split_up_transfers = getattr(self, "_split_up_transfers", None)
if split_up_transfers is not None:
transfers = split_up_transfers(volume_ul)
else:
transfers = [volume_ul]
transfer_record = {
"source": source,
"dest": dest,
"requested_volume_ul": volume_ul,
"subtransfers_ul": [],
"subtransfer_count": 0,
"pipette_mount": pipette_mount,
"pipette_name": pipette.get("name"),
"pipette_id": pipette_id,
"source_well": {
"labware_id": source_well["labwareId"],
"well_name": source_well["wellName"],
"position": source_position,
},
"dest_well": {
"labware_id": dest_well["labwareId"],
"well_name": dest_well["wellName"],
"position": dest_position,
"offset": {"x": 0, "y": 0, "z": to_top_z_offset if dest_position == "top" else 0},
},
"options": {
"mix_before": list(mix_before) if mix_before is not None else None,
"mix_after": list(mix_after) if mix_after is not None else None,
"air_gap": air_gap,
"aspirate_rate": aspirate_rate,
"dispense_rate": dispense_rate,
"mix_aspirate_rate": mix_aspirate_rate,
"mix_dispense_rate": mix_dispense_rate,
"blow_out": blow_out,
"post_aspirate_delay": post_aspirate_delay,
"aspirate_equilibration_delay": aspirate_equilibration_delay,
"post_dispense_delay": post_dispense_delay,
"drop_tip": drop_tip,
"return_tip": return_tip,
"force_new_tip": force_new_tip,
"to_top": to_top,
"to_center": to_center,
"to_top_z_offset": to_top_z_offset,
"tip_rack_offset": dict(resolved_tip_rack_offset),
"fast_mixing": fast_mixing,
"touch_tip": touch_tip,
"tip_location": tip_location,
},
"status": "executed",
}
if requested_tip is not None:
transfer_record["requested_tip"] = requested_tip.copy()
for i, sub_volume in enumerate(transfers):
if sub_volume <= 0:
self.log_warning(
f"Skipping nonpositive sub-transfer volume {sub_volume}uL from {source} to {dest}"
)
continue
transfer_record["subtransfers_ul"].append(float(sub_volume))
# Final tip disposal is controlled by drop_tip / return_tip alone.
# force_new_tip only controls whether a used tip is cleared before
# the next subtransfer picks up a fresh one.
is_last_subtransfer = i == (len(transfers) - 1)
effective_drop_tip = bool(drop_tip and is_last_subtransfer)
effective_return_tip = bool(return_tip and is_last_subtransfer)
# Keep tip handling consistent with the non-HTTP driver:
# reuse the current tip across split transfers unless force_new_tip is set.
if force_new_tip and self.has_tip:
tip_mount = self.last_pipette if self.last_pipette is not None else pipette_mount
tip_pipette_id = self.pipette_info.get(tip_mount, {}).get("id", pipette_id)
if return_tip:
self._return_tip_to_origin(
tip_pipette_id,
mount=tip_mount,
tip_rack_offset=resolved_tip_rack_offset,
return_tip_z_offset=return_tip_z_offset,
)
else:
self._drop_tip_to_trash(tip_pipette_id)
# If a tip is on a different mount, drop it before switching mounts.
if self.has_tip and self.last_pipette not in (None, pipette_mount):
tip_pipette_id = self.pipette_info.get(self.last_pipette, {}).get("id", pipette_id)
if return_tip:
self._return_tip_to_origin(
tip_pipette_id,
mount=self.last_pipette,
tip_rack_offset=resolved_tip_rack_offset,
return_tip_z_offset=return_tip_z_offset,
)
else:
self._drop_tip_to_trash(tip_pipette_id)
if (
requested_tip is None
and self.has_tip
and self.last_pipette == pipette_mount
and self._current_tip_is_reserved_stock_tip()
):
if return_tip:
self._return_tip_to_origin(
pipette_id,
mount=pipette_mount,
tip_rack_offset=resolved_tip_rack_offset,
return_tip_z_offset=return_tip_z_offset,
)
else:
self._drop_tip_to_trash(pipette_id)
needs_requested_tip = (
requested_tip is not None
and (
not self.has_tip
or self.last_pipette != pipette_mount
or self.current_tip is None
or self.current_tip.get("labware_id") != requested_tip["labware_id"]
or self.current_tip.get("well_name") != requested_tip["well_name"]
)
)
if needs_requested_tip and self.has_tip:
if return_tip:
self.return_tip(
tip_location=self.current_tip.get("location") if self.current_tip is not None else None,
tip_rack_offset=resolved_tip_rack_offset,
return_tip_z_offset=return_tip_z_offset,
)
else:
self._drop_tip_to_trash(pipette_id)
if not self.has_tip:
if requested_tip is not None:
requested_tip_location = (
f"{self._slot_by_labware_uuid(requested_tip['labware_id'])}{requested_tip['well_name']}"
)
self.pickup_tip(
requested_tip_location,
tip_rack_offset=resolved_tip_rack_offset,
)
else:
self._execute_atomic_command(
"pickUpTip",
{
"pipetteId": pipette_id,
"pipetteMount": pipette_mount,
"tipRackOffset": dict(resolved_tip_rack_offset),
},
check_run_status=False,
)
self.has_tip = True
self.last_pipette = pipette_mount
# 1a. If destination is on a heater-shaker, stop the shaking and latch the latch pre-flight
was_shaking = False
dest_well_slot = self._slot_by_labware_uuid(dest_well['labwareId'])
source_well_slot = self._slot_by_labware_uuid(source_well['labwareId'])
heater_shaker_slots = [slot for (slot,(uuid,name)) in self.config["loaded_modules"].items() if "heaterShaker" in name]
if dest_well_slot in heater_shaker_slots or source_well_slot in heater_shaker_slots:
# latch heater-shaker
# this is contextual, maybe - seems to not cause trouble to run without conditional
#if 'closed' not in self.get_shake_latch_status():
self.latch_shaker()
# store current shake rpm and stop shake
if self.get_shake_rpm()[0] != 'idle':
shake_rpm = self.get_shake_rpm()[2]
was_shaking = True
self.stop_shake()
# 2. Mix before if specified
if mix_before is not None:
n_mixes, mix_volume = mix_before
# Set mix aspirate rate if specified
if mix_aspirate_rate is not None:
self.set_aspirate_rate(mix_aspirate_rate, pipette_mount)
# Set mix dispense rate if specified
if mix_dispense_rate is not None:
self.set_dispense_rate(mix_dispense_rate, pipette_mount)
# Mix before transfer - implement by executing multiple aspirate/dispense
for _ in range(n_mixes):
self._execute_atomic_command(
"aspirate",
{
"pipetteId": pipette_id,
"volume": mix_volume,
"labwareId": source_well["labwareId"],
"wellName": source_well["wellName"],
"wellLocation": {
"origin": source_position,
"offset": {"x": 0, "y": 0, "z": 0},
},
"flowRate": self.pipette_info[pipette_mount]['aspirate_flow_rate'],
},
check_run_status=False,
)
self._execute_atomic_command(
"dispense",
{
"pipetteId": pipette_id,
"volume": mix_volume,
"labwareId": source_well["labwareId"],
"wellName": source_well["wellName"],
"wellLocation": {
"origin": source_position,
"offset": {"x": 0, "y": 0, "z": 0},
},
"flowRate": self.pipette_info[pipette_mount]['dispense_flow_rate'],
},
check_run_status=False,
)
# Restore original rates
if mix_aspirate_rate is not None or mix_dispense_rate is not None:
# Reset rates to default or specified rates
if aspirate_rate is not None:
self.set_aspirate_rate(aspirate_rate, pipette_mount)
if dispense_rate is not None:
self.set_dispense_rate(dispense_rate, pipette_mount)
# 3. Aspirate
self._execute_atomic_command(
"aspirate",
{
"pipetteId": pipette_id,
"volume": sub_volume,
"labwareId": source_well["labwareId"],
"wellName": source_well["wellName"],
"wellLocation": {
"origin": source_position,
"offset": {"x": 0, "y": 0, "z": source_z_offset},
},
"flowRate": self.pipette_info[pipette_mount]['aspirate_flow_rate'],
},
check_run_status=False,
)
# 4. Aspirate equilibration delay (while tip is in liquid)
if aspirate_equilibration_delay > 0:
time.sleep(aspirate_equilibration_delay)
# self._execute_atomic_command("delay", {"seconds": aspirate_equilibration_delay})
# 5. Move tip above liquid and post-aspirate delay (tip above liquid)
self._execute_atomic_command(
"moveToWell",
{
"pipetteId": pipette_id,
"labwareId": source_well["labwareId"],
"wellName": source_well["wellName"],
"wellLocation": {
"origin": "top",
"offset": {"x": 0, "y": 0, "z": 0},
},
},
check_run_status=False,
)
if post_aspirate_delay > 0:
time.sleep(post_aspirate_delay)
# self._execute_atomic_command("delay", {"seconds": post_aspirate_delay})
# 6. Air gap if specified
if air_gap > 0:
# Air gap is implemented as aspirate at the top of the source well
self._execute_atomic_command(
"aspirate",
{
"pipetteId": pipette_id,
"volume": air_gap,
"labwareId": source_well["labwareId"],
"wellName": source_well["wellName"],
"wellLocation": {
"origin": "top",
"offset": {"x": 0, "y": 0, "z": 0},
},
"flowRate": self.pipette_info[pipette_mount]['aspirate_flow_rate'],
},
check_run_status=False,
)
# 7. Dispense
offset = {
"x": 0,
"y": 0,
"z": (
to_top_z_offset
if dest_position == "top" and to_top_z_offset != 0
else 0
),
}
self._execute_atomic_command(
"dispense",
{
"pipetteId": pipette_id,
"volume": sub_volume
+ air_gap, # Include air gap in dispense volume
"labwareId": dest_well["labwareId"],
"wellName": dest_well["wellName"],
"wellLocation": {"origin": dest_position, "offset": offset},
"flowRate": self.pipette_info[pipette_mount]['dispense_flow_rate'],
},
check_run_status=False,
)
# 8. Post-dispense delay
if post_dispense_delay > 0:
time.sleep(post_dispense_delay)
# self._execute_atomic_command("delay", {"seconds": post_dispense_delay})
# 9. Mix after if specified
if mix_after is not None:
n_mixes, mix_volume = mix_after
# Set mix aspirate rate if specified
if mix_aspirate_rate is not None:
self.set_aspirate_rate(mix_aspirate_rate, pipette_mount)
# Set mix dispense rate if specified
if mix_dispense_rate is not None:
self.set_dispense_rate(mix_dispense_rate, pipette_mount)
# Mix after transfer should be performed from the bottom of the destination well
mix_well_location = {
"origin": "bottom",
"offset": {"x": 0, "y": 0, "z": 0},
}
# Mix after transfer - implement by executing multiple aspirate/dispense
for _ in range(n_mixes):
self._execute_atomic_command(
"aspirate",
{
"pipetteId": pipette_id,
"volume": mix_volume,
"labwareId": dest_well["labwareId"],
"wellName": dest_well["wellName"],
"wellLocation": mix_well_location,
"flowRate": self.pipette_info[pipette_mount]['aspirate_flow_rate'],
},
check_run_status=False,
)
self._execute_atomic_command(
"dispense",
{
"pipetteId": pipette_id,
"volume": mix_volume,
"labwareId": dest_well["labwareId"],
"wellName": dest_well["wellName"],
"wellLocation": mix_well_location,
"flowRate": self.pipette_info[pipette_mount]['dispense_flow_rate'],
},
check_run_status=False,
)
# Restore original rates
if mix_aspirate_rate is not None or mix_dispense_rate is not None:
# Reset rates to default or specified rates
if aspirate_rate is not None:
self.set_aspirate_rate(aspirate_rate, pipette_mount)
if dispense_rate is not None:
self.set_dispense_rate(dispense_rate, pipette_mount)
# 10. Blow out if specified
if blow_out:
flow_rate = self.pipette_info.get(pipette_mount, {}).get(
"dispense_flow_rate", 300
)
self._execute_atomic_command(
"blowout",
{
"pipetteId": pipette_id,
"labwareId": dest_well["labwareId"],
"wellName": dest_well["wellName"],
"wellLocation": {"origin": dest_position, "offset": offset},
"flowRate": flow_rate,
},
check_run_status=False,
)
# 10b. Optionally touch the tip to destination well edge.
if touch_tip:
self._touch_tip_well(pipette_id=pipette_id, well=dest_well)
if was_shaking:
self.set_shake(shake_rpm)
# back to running :)
# 11. Drop tip if specified
if effective_drop_tip:
self._drop_tip_to_trash(pipette_id)
elif effective_return_tip:
self._return_tip_to_origin(
pipette_id,
mount=pipette_mount,
tip_rack_offset=resolved_tip_rack_offset,
return_tip_z_offset=return_tip_z_offset,
)
# Update last pipette
self.last_pipette = pipette_mount
transfer_record["subtransfer_count"] = len(transfer_record["subtransfers_ul"])
return transfer_record
def _split_up_transfers(self, volume):
"""Split a requested transfer volume into pipette-safe subtransfers.
Parameters
----------
volume : float
Requested transfer volume in microliters.
Returns
-------
list of float
One or more subtransfer volumes.
"""
volume_ul = float(volume)
if volume_ul <= 0:
return []
if self.max_transfer is None or volume_ul <= self.max_transfer:
return [volume_ul]
transfers = []
remaining = volume_ul
while remaining > 0:
sub_volume = min(self.max_transfer, remaining)
transfers.append(float(sub_volume))
remaining -= sub_volume
return transfers
def _resolve_tip_rack_offset(self, tip_rack_offset=None, mount=None):
"""Resolve the configured tip-rack offset mapping.
Parameters
----------
tip_rack_offset : dict, optional
Explicit offset override.
mount : str, optional
Pipette mount used when the configured offsets are stored per mount.
Returns
-------
dict
Offset mapping with ``x``, ``y``, and ``z`` keys.
"""
offset = self.config.get("tip_rack_offset", {"x": 0, "y": 0, "z": 0})
if tip_rack_offset is not None:
offset = tip_rack_offset
if (
mount is not None
and isinstance(offset, dict)
and "x" not in offset
and "y" not in offset
and "z" not in offset
and mount in offset
):
offset = offset[mount]
resolved = copy.deepcopy(offset)
resolved.setdefault("x", 0)
resolved.setdefault("y", 0)
resolved.setdefault("z", 0)
return resolved
def _resolve_tip_location(self, mount, tip_location):
"""Resolve a deck tip location into a tracked tiprack/well pair.
Parameters
----------
mount : str
Pipette mount.
tip_location : str
Deck location such as ``"1A2"``.
Returns
-------
dict
Mapping with ``labware_id`` and ``well_name``.
Raises
------
ValueError
If the requested tip location is not available for the mount.
"""
normalized = str(tip_location).strip().upper()
if len(normalized) < 3:
raise ValueError(f"Requested tip location {tip_location} is invalid")
slot = normalized[0]
well_name = normalized[1:]
labware_info = self.config.get("loaded_labware", {}).get(str(slot))
if labware_info is None:
raise ValueError(f"Requested tip location {normalized} is not available")
labware_id = labware_info[0]
available = self.config.get("available_tips", {}).get(mount, [])
current_tip_matches = (
self.has_tip
and self.last_pipette == mount
and self.current_tip is not None
and self.current_tip.get("labware_id") == labware_id
and self.current_tip.get("well_name") == well_name
)
if (labware_id, well_name) not in available and not current_tip_matches:
raise ValueError(f"Requested tip location {normalized} is not available")
return {"labware_id": labware_id, "well_name": well_name}
def _resolve_tip_mount(self, tip_location):
"""Resolve a tip location to the unique loaded mount and pipette.
Parameters
----------
tip_location : str
Deck tip location such as ``"1A2"``.
Returns
-------
dict
Mapping with ``mount``, ``pipette_id``, ``tip_location``,
``labware_id``, and ``well_name``.
Raises
------
ValueError
If the tip location is invalid, unavailable, or maps to zero or
multiple loaded mounts.
"""
normalized = str(tip_location).strip().upper()
if len(normalized) < 3:
raise ValueError(f"Requested tip location {tip_location} is invalid")
slot = normalized[0]
labware_info = self.config.get("loaded_labware", {}).get(str(slot))
if labware_info is None:
raise ValueError(f"Requested tip location {normalized} is not available")
labware_id = labware_info[0]
matches = []
for mount, instrument in self.config.get("loaded_instruments", {}).items():
if labware_id not in instrument.get("tip_racks", []):
continue
pipette_id = self.pipette_info.get(mount, {}).get("id")
if pipette_id is None:
pipette_id = instrument.get("pipette_id")
requested_tip = self._resolve_tip_location(mount, normalized)
matches.append(
{
"mount": mount,
"pipette_id": pipette_id,
"tip_location": normalized,
"labware_id": requested_tip["labware_id"],
"well_name": requested_tip["well_name"],
}
)
if not matches:
raise ValueError(
f"No loaded instrument is configured for tip location {normalized}"
)
if len(matches) > 1:
mounts = ", ".join(sorted(match["mount"] for match in matches))
raise ValueError(
f"Tip location {normalized} is ambiguous across loaded mounts: {mounts}"
)
if not matches[0]["pipette_id"]:
raise ValueError(
f"Could not find pipette ID for mount {matches[0]['mount']}"
)
return matches[0]
[docs]
def pickup_tip(self, tip_location, tip_rack_offset=None):
"""Pick up a specific tip from a deck tip location.
Parameters
----------
tip_location : str
Deck tip location such as ``"1A2"``.
tip_rack_offset : dict, optional
Offset mapping with ``x``, ``y``, and ``z`` keys applied during
pickup.
Returns
-------
dict
Pickup metadata including mount, pipette, and requested tip
location.
Raises
------
ValueError
If the requested tip location is invalid or unavailable.
RuntimeError
If another tip is already attached.
"""
tip_target = self._resolve_tip_mount(tip_location)
current_tip_matches = (
self.has_tip
and self.last_pipette == tip_target["mount"]
and self.current_tip is not None
and self.current_tip.get("labware_id") == tip_target["labware_id"]
and self.current_tip.get("well_name") == tip_target["well_name"]
)
if current_tip_matches:
return {
"mount": tip_target["mount"],
"pipette_id": tip_target["pipette_id"],
"tip_location": tip_target["tip_location"],
"labware_id": tip_target["labware_id"],
"well_name": tip_target["well_name"],
"status": "already_attached",
}
if self.has_tip:
raise RuntimeError(
f"Cannot pick up tip {tip_target['tip_location']} while a tip is already attached on "
f"{self.last_pipette} mount"
)
self._execute_atomic_command(
"pickUpTip",
{
"pipetteId": tip_target["pipette_id"],
"pipetteMount": tip_target["mount"],
"labwareId": tip_target["labware_id"],
"wellName": tip_target["well_name"],
"tipRackOffset": self._resolve_tip_rack_offset(
tip_rack_offset,
mount=tip_target["mount"],
),
},
check_run_status=False,
)
self.has_tip = True
self.last_pipette = tip_target["mount"]
return {
"mount": tip_target["mount"],
"pipette_id": tip_target["pipette_id"],
"tip_location": tip_target["tip_location"],
"labware_id": tip_target["labware_id"],
"well_name": tip_target["well_name"],
"status": "picked_up",
}
[docs]
def return_tip(
self,
tip_location=None,
tip_rack_offset=None,
return_tip_z_offset=None,
):
"""Return the currently attached tip to its original tiprack well.
Parameters
----------
tip_location : str, optional
Expected current tip location such as ``"1A2"``. When provided,
this is validated against the attached tip before returning it.
tip_rack_offset : dict, optional
Offset mapping with ``x``, ``y``, and ``z`` keys applied while
moving to the return location.
return_tip_z_offset : float, optional
Return-only z-offset applied during the return operation.
Returns
-------
dict
Return metadata including the tip origin and status.
Raises
------
ValueError
If the requested tip location does not match the attached tip.
"""
if not self.has_tip or self.current_tip is None:
status = {"status": "no_tip_attached"}
if tip_location is not None:
status["tip_location"] = str(tip_location).strip().upper()
return status
attached_location = self.current_tip.get("location")
if attached_location is None:
slot = self.current_tip.get("slot")
well_name = self.current_tip.get("well_name")
if slot is not None and well_name is not None:
attached_location = f"{slot}{well_name}"
expected_tip_target = None
if tip_location is not None:
expected_tip_target = self._resolve_tip_mount(tip_location)
if (
self.current_tip.get("mount") != expected_tip_target["mount"]
or self.current_tip.get("labware_id") != expected_tip_target["labware_id"]
or self.current_tip.get("well_name") != expected_tip_target["well_name"]
):
raise ValueError(
f"Attached tip does not match requested return location {expected_tip_target['tip_location']}"
)
else:
mount = self.current_tip.get("mount")
expected_tip_target = {
"mount": mount,
"pipette_id": self.pipette_info.get(mount, {}).get("id"),
"tip_location": attached_location,
"labware_id": self.current_tip.get("labware_id"),
"well_name": self.current_tip.get("well_name"),
}
if expected_tip_target["pipette_id"] is None and mount in self.config.get(
"loaded_instruments", {}
):
expected_tip_target["pipette_id"] = self.config["loaded_instruments"][mount].get(
"pipette_id"
)
if not expected_tip_target.get("pipette_id"):
raise ValueError(
f"Could not find pipette ID for mount {expected_tip_target['mount']}"
)
self._return_tip_to_origin(
expected_tip_target["pipette_id"],
mount=expected_tip_target["mount"],
tip_rack_offset=tip_rack_offset,
return_tip_z_offset=return_tip_z_offset,
)
return {
"mount": expected_tip_target["mount"],
"pipette_id": expected_tip_target["pipette_id"],
"tip_location": expected_tip_target["tip_location"],
"labware_id": expected_tip_target["labware_id"],
"well_name": expected_tip_target["well_name"],
"status": "returned",
"offset": tip_rack_offset,
"z_offset" : return_tip_z_offset
}
def _reserve_tip(self, mount, labware_id, well_name):
"""Reserve a specific available tip for pickup.
Parameters
----------
mount : str
Pipette mount.
labware_id : str
Tiprack identifier.
well_name : str
Tip well name.
Returns
-------
tuple
Reserved ``(labware_id, well_name)`` pair.
Raises
------
ValueError
If the requested tip is not available.
"""
available = list(self.config.get("available_tips", {}).get(mount, []))
requested = (labware_id, well_name)
if requested not in available:
raise ValueError(
f"Requested tip location {well_name} in {labware_id} is not available for {mount} mount"
)
available.remove(requested)
self.config.setdefault("available_tips", {})[mount] = available
return requested
def _touch_tip_well(self, pipette_id, well):
"""Touch the tip to a well edge or fall back to a top-edge move.
Parameters
----------
pipette_id : str
Pipette identifier.
well : dict
Well descriptor containing ``labwareId`` and ``wellName``.
"""
params = {
"pipetteId": pipette_id,
"labwareId": well["labwareId"],
"wellName": well["wellName"],
"wellLocation": {"origin": "top", "offset": {"x": 0, "y": 0, "z": 0}},
"mmFromEdge": 1,
}
try:
self._execute_atomic_command("touchTip", params, check_run_status=False)
except RuntimeError as exc:
self.log_warning(
f"touchTip command unavailable; using moveToWell fallback. Error: {exc}"
)
self._execute_atomic_command(
"moveToWell",
{
"pipetteId": pipette_id,
"labwareId": well["labwareId"],
"wellName": well["wellName"],
"wellLocation": {"origin": "top", "offset": {"x": 0, "y": 0, "z": -2}},
},
check_run_status=False,
)
def _move_above_tiprack(
self,
pipette_id,
labware_id,
well_name,
tip_rack_offset,
approach_z_offset = 40.0,
check_run_status=True,
):
"""Move above a target tip before issuing the pickup command."""
approach_offset = dict(tip_rack_offset)
approach_offset["z"] = approach_z_offset
self._execute_atomic_command(
"moveToWell",
{
"pipetteId": pipette_id,
"labwareId": labware_id,
"wellName": well_name,
"wellLocation": {
"origin": "top",
"offset": approach_offset,
},
},
check_run_status=check_run_status,
)
def _execute_atomic_command(
self, command_type, params=None, wait_until_complete=True, timeout=None, check_run_status=True
):
"""Execute one atomic HTTP API command.
Parameters
----------
command_type : str
Opentrons command type.
params : dict, optional
Command parameters.
wait_until_complete : bool, default=True
If ``True``, wait for command completion before returning.
timeout : float, optional
Command timeout forwarded to the robot server.
check_run_status : bool, default=True
If ``False``, skip the run-status GET check when ensuring a run.
Returns
-------
bool or str
``True`` when a waited command succeeds, otherwise the command ID for
asynchronous tracking.
"""
if params is None:
params = {}
# Track tip usage for pick up and drop commands
if command_type == "pickUpTip":
mount = params.get("pipetteMount")
if not mount:
raise RuntimeError("pickUpTip requires pipetteMount for tip tracking")
if "labwareId" in params and "wellName" in params:
tiprack_id, well = self._reserve_tip(
mount, params["labwareId"], params["wellName"]
)
elif mount in self.config["available_tips"] and self.config["available_tips"][mount]:
tiprack_id, well = self.get_tip(mount)
else:
raise RuntimeError(f"No tips available for {mount} mount")
self.log_debug(
f"Using tip from {tiprack_id} well {well} for {mount} mount"
)
tip_rack_offset = self._resolve_tip_rack_offset(
params.get("tipRackOffset"),
mount=mount,
)
self._move_above_tiprack(
pipette_id=params["pipetteId"],
labware_id=tiprack_id,
well_name=well,
tip_rack_offset=tip_rack_offset,
approach_z_offset= 40.0,
check_run_status=check_run_status,
)
params["labwareId"] = tiprack_id
params["wellName"] = well
params["wellLocation"] = {
"origin": "top",
"offset": tip_rack_offset,
}
params.pop("tipRackOffset", None)
slot = self._slot_by_labware_uuid(tiprack_id)
self.current_tip = {
"mount": mount,
"labware_id": tiprack_id,
"well_name": well,
"slot": slot,
"location": f"{slot}{well}" if slot is not None else None,
}
del params["pipetteMount"]
self.log_debug(
f"Executing atomic command: {command_type} with params: {params}"
)
# Ensure we have a valid run
run_id = self._ensure_run_exists(check_run_status=check_run_status)
# Build the query parameters
query_params = {"waitUntilComplete": wait_until_complete}
if timeout is not None:
query_params["timeout"] = timeout
try:
# Send the command
command_response = requests.post(
url=f"{self.base_url}/runs/{run_id}/commands",
params=query_params,
headers=self.headers,
json={
"data": {
"commandType": command_type,
"params": params,
"intent": "setup",
}
},
)
self._check_cmd_success(command_response)
command_data = command_response.json()["data"]
command_id = command_data["id"]
self.log_debug(
f"Command {command_id} executed with status: {command_data['status']}"
)
# If wait_until_complete is True, the command has already completed
if wait_until_complete:
if command_data["status"] == "succeeded":
return True
elif command_data["status"] in ["failed", "error"]:
error_info = command_data.get("error", "Unknown error")
self.log_error(f"Command failed: {error_info}")
raise RuntimeError(f"Command failed: {error_info}")
# If we're not waiting or the command is still running, return the command ID for tracking
return command_id
except requests.exceptions.RequestException as e:
self.log_error(f"Error executing command: {str(e)}")
raise RuntimeError(f"Error executing command: {str(e)}")
[docs]
def set_aspirate_rate(self, rate=150, pipette=None):
"""Set stored aspirate flow rate for one or more active pipettes."""
self.log_info(f"Setting aspirate rate to {rate} uL/s")
if pipette is None:
active_pipettes = self._get_active_pipettes()
if not active_pipettes:
self.log_warning("No loaded pipettes available to update aspirate rate")
return
for info in active_pipettes.values():
info["aspirate_flow_rate"] = rate
return
self._get_active_pipette_info(pipette)["aspirate_flow_rate"] = rate
[docs]
def set_dispense_rate(self, rate=300, pipette=None):
"""Set stored dispense flow rate for one or more active pipettes."""
self.log_info(f"Setting dispense rate to {rate} uL/s")
if pipette is None:
active_pipettes = self._get_active_pipettes()
if not active_pipettes:
self.log_warning("No loaded pipettes available to update dispense rate")
return
for info in active_pipettes.values():
info["dispense_flow_rate"] = rate
return
self._get_active_pipette_info(pipette)["dispense_flow_rate"] = rate
[docs]
def set_gantry_speed(self, speed=400):
"""Record a requested gantry speed change.
Notes
-----
The HTTP driver currently logs the request but does not apply it through
the robot server.
"""
self.log_info(f"Setting gantry speed to {speed} mm/s")
[docs]
def get_pipette(self, volume, method="min_transfers"):
"""Select the best loaded pipette for a requested volume.
Parameters
----------
volume : float
Requested transfer volume in microliters.
method : {"min_transfers", "uncertainty"}, default="min_transfers"
Selection strategy.
Returns
-------
dict
Selected pipette metadata including mount, volume range, and number
of required transfers.
"""
self.log_debug(f"Looking for a pipette for volume {volume}")
# Make sure we have the latest pipette information
self._update_pipettes()
pipettes = []
for mount, pipette_data in self._get_active_pipettes().items():
if not pipette_data:
continue
min_volume = pipette_data.get("min_volume", 1)
max_volume = pipette_data.get("max_volume", 300)
if volume >= min_volume:
pipettes.append(
{
"mount": mount, # Use mount as the identifier
"min_volume": min_volume,
"max_volume": max_volume,
"name": pipette_data.get("name"),
"model": pipette_data.get("model"),
"channels": pipette_data.get("channels", 1),
"pipette_id": pipette_data.get("id"),
}
)
if not pipettes:
raise ValueError("No suitable loaded pipettes found!\n")
# Calculate transfers and uncertainties
for pipette in pipettes:
max_volume = pipette["max_volume"]
ntransfers = ceil(volume / max_volume)
vol_per_transfer = volume / ntransfers
pipette["ntransfers"] = ntransfers
# Calculate uncertainty (simplified from original)
pipette["uncertainty"] = (
ntransfers * 0.1
) # Simplified uncertainty calculation
if self.data is not None:
self.data["transfer_method"] = method
self.data["pipette_options"] = str(pipettes)
# Choose pipette based on method
if method == "uncertainty":
pipette = min(pipettes, key=lambda x: x["uncertainty"])
elif method == "min_transfers":
min_xfers = min(pipettes, key=lambda x: x["ntransfers"])["ntransfers"]
acceptable_pipettes = [p for p in pipettes if p["ntransfers"] == min_xfers]
pipette = min(acceptable_pipettes, key=lambda x: x["max_volume"])
else:
raise ValueError(f"Pipette selection method {method} was not recognized.")
self.log_debug(f"Chosen pipette: {pipette}")
if self.data is not None:
self.data["chosen_pipette"] = str(pipette)
return pipette
[docs]
def get_aspirate_rate(self, pipette=None):
"""Return the stored aspirate flow rate for a pipette."""
active_pipettes = self._get_active_pipettes()
if pipette is None:
# Return the rate of the first pipette found
for mount, pipette_data in active_pipettes.items():
if pipette_data:
pipette = mount
break
if pipette is None:
return None
try:
for mount, pipette_data in active_pipettes.items():
if mount == pipette and pipette_data:
return pipette_data.get("aspirate_flow_rate", 150)
except requests.exceptions.RequestException:
pass
return 150 # Default value
[docs]
def get_dispense_rate(self, pipette=None):
"""Return the stored dispense flow rate for a pipette."""
active_pipettes = self._get_active_pipettes()
if pipette is None:
# Return the rate of the first pipette found
for mount, pipette_data in active_pipettes.items():
if pipette_data:
pipette = mount
break
if pipette is None:
return None
try:
for mount, pipette_data in active_pipettes.items():
if mount == pipette and pipette_data:
return pipette_data.get("dispense_flow_rate", 300)
except requests.exceptions.RequestException:
pass
return 300 # Default value
# HTTP API communication with heater-shaker module
[docs]
def set_shake(self, rpm, module_id = None):
"""Set heater-shaker speed and wait for the target RPM."""
self.log_info(f"Setting heater-shaker speed to {rpm} RPM")
if module_id is None:
module_id = self._find_module_by_type("heaterShaker")
self._execute_atomic_command("heaterShaker/setAndWaitForShakeSpeed",
params= {
"moduleId": module_id,
"rpm": rpm,
},
)
[docs]
def stop_shake(self, module_id = None):
"""Stop heater-shaker motion."""
self.log_info("Stopping heater-shaker")
if module_id is None:
module_id = self._find_module_by_type("heaterShaker")
self._execute_atomic_command("heaterShaker/deactivateShaker",
params= {
"moduleId": module_id,
},
)
[docs]
def set_shaker_temp(self, temp, module_id = None):
"""Set heater-shaker target temperature."""
self.log_info(f"Setting heater-shaker temperature to {temp}°C")
if module_id is None:
module_id = self._find_module_by_type("heaterShaker")
self._execute_atomic_command("heaterShaker/setTargetTemperature",
params= {
"moduleId": module_id,
"celsius": temp,
},
)
[docs]
def stop_shaker_heat(self, module_id = None):
"""Deactivate heater-shaker heating."""
self.log_info(f"Deactivating heater-shaker heating")
if module_id is None:
module_id = self._find_module_by_type("heaterShaker")
self._execute_atomic_command("heaterShaker/deactivateHeater",
params= {
"moduleId": module_id,
},
)
[docs]
def unlatch_shaker(self, module_id = None):
"""Open the heater-shaker labware latch."""
self.log_info("Unlatching heater-shaker")
if module_id is None:
module_id = self._find_module_by_type("heaterShaker")
self._execute_atomic_command("heaterShaker/openLabwareLatch",
params= {
"moduleId": module_id,
},
)
[docs]
def latch_shaker(self, module_id = None):
"""Close the heater-shaker labware latch."""
self.log_info("Latching heater-shaker")
if module_id is None:
module_id = self._find_module_by_type("heaterShaker")
self._execute_atomic_command("heaterShaker/closeLabwareLatch",
params= {
"moduleId": module_id,
},
)
def _find_module_by_type(self,partial_name):
"""Return the first loaded module ID whose name contains a token."""
module_id = None
for module in self.config["loaded_modules"].values():
if partial_name in module[1]:
module_id = module[0]
return module_id
[docs]
def get_shaker_temp(self):
"""Return current and target heater-shaker temperatures."""
self.log_info("Getting heater-shaker temperature")
# For get operations, we still need to use the modules API directly
try:
# Get modules to find the heater-shaker module
modules_response = requests.get(
url=f"{self.base_url}/modules", headers=self.headers
)
if modules_response.status_code != 200:
self.log_error(f"Failed to get modules: {modules_response.status_code}")
return f"Error getting modules: {modules_response.status_code}"
modules = modules_response.json().get("modules", [])
heater_shaker_module = next(
(m for m in modules if "heaterShaker" in m.get("moduleModel")),
None,
)
if not heater_shaker_module:
self.log_error("No heater-shaker module found")
return "No heater-shaker module found"
logging.debug(heater_shaker_module)
current_temp = heater_shaker_module.get("data", {}).get("currentTemp")
target_temp = heater_shaker_module.get("data", {}).get("targetTemp")
self.log_info(
f"Heater-shaker temperature - Current: {current_temp}°C, Target: {target_temp}°C"
)
return (current_temp,target_temp)
except Exception as e:
self.log_error(f"Error getting temperature: {str(e)}")
return f"Error: {str(e)}"
[docs]
def get_shake_rpm(self):
"""Return heater-shaker speed status, current RPM, and target RPM."""
# For get operations, we just use the modules API
try:
# Get modules to find the heater-shaker module
modules_response = requests.get(
url=f"{self.base_url}/modules", headers=self.headers
)
if modules_response.status_code != 200:
self.log_error(f"Failed to get modules: {modules_response.status_code}")
return f"Error getting modules: {modules_response.status_code}"
modules = modules_response.json().get("modules", [])
heater_shaker_module = next(
(m for m in modules if "heaterShaker" in m.get("moduleModel")),
None,
)
if not heater_shaker_module:
self.log_error("No heater-shaker module found")
return "No heater-shaker module found"
current_rpm = heater_shaker_module.get("data", {}).get("currentSpeed")
target_rpm = heater_shaker_module.get("data", {}).get("targetSpeed")
status = heater_shaker_module.get("data", {}).get("speedStatus")
return (status,current_rpm,target_rpm)
except Exception as e:
self.log_error(f"Error getting RPM: {str(e)}")
return f"Error: {str(e)}"
[docs]
def get_shake_latch_status(self):
"""Return the heater-shaker latch status string."""
# For get operations, we just use the modules API
try:
# Get modules to find the heater-shaker module
modules_response = requests.get(
url=f"{self.base_url}/modules", headers=self.headers
)
if modules_response.status_code != 200:
self.log_error(f"Failed to get modules: {modules_response.status_code}")
return f"Error getting modules: {modules_response.status_code}"
modules = modules_response.json().get("modules", [])
heater_shaker_module = next(
(m for m in modules if "heaterShaker" in m.get("moduleModel")),
None,
)
if not heater_shaker_module:
self.log_error("No heater-shaker module found")
return "No heater-shaker module found"
status = heater_shaker_module.get("data", {}).get("labwareLatchStatus")
return status
except Exception as e:
self.log_error(f"Error getting RPM: {str(e)}")
return f"Error: {str(e)}"
[docs]
def set_tempmodule_temperature(
self,
module_id,
temperature_c,
hold_time = 0.0,
wait = True,
):
"""Set a temperature module target and optionally wait to stabilize.
Returns
-------
tuple
Current and target temperatures after stabilization.
"""
self.log_info(f"Setting temperature module to {temperature_c}°C")
if module_id is None:
module_id = self._find_module_by_type("tempdeck")
self._execute_atomic_command(
"temperatureModule/setTargetTemperature",
params={"moduleId": module_id, "celsius": float(temperature_c)},
wait_until_complete=wait,
)
time.sleep(60) # wait for a minute before querying status
data = self.get_tempmodule_status(log=False)
# Some OT-2 API versions report no target temperature immediately
# after setting it. Return in that case so callers can perform their
# own equilibration hold instead of crashing on float - None.
current_temp = data.get("currentTemp")
target_temp = data.get("targetTemp")
if current_temp is None or target_temp is None:
self.log_warning(
"Temperature module did not report both currentTemp and targetTemp; "
"skipping driver-side stabilization wait."
)
else:
while abs(current_temp - target_temp) > 1.0:
time.sleep(5)
data = self.get_tempmodule_status(log=False)
current_temp = data.get("currentTemp")
target_temp = data.get("targetTemp")
if current_temp is None or target_temp is None:
self.log_warning(
"Temperature module stopped reporting currentTemp or targetTemp; "
"skipping driver-side stabilization wait."
)
return current_temp, target_temp
self.log_debug(f"Waiting for temperature to stabilize... "
f"(Current: {current_temp}°C, Target: {target_temp}°C)")
if hold_time > 0:
self.log_info(f"Holding the command exceution for {hold_time}")
time.sleep(hold_time)
return current_temp, target_temp
[docs]
def deactivate_tempmodule(self, module_id, timeout_s=120, wait=True):
"""Deactivate a temperature module.
Returns
-------
bool or str
Result returned by :meth:`_execute_atomic_command`.
"""
if module_id is None:
module_id = self._find_module_by_type("tempdeck")
return self._execute_atomic_command(
"temperatureModule/deactivate",
params={"moduleId": module_id},
wait_until_complete=wait,
timeout=timeout_s,
)
[docs]
def get_tempmodule_status(self, log=True):
"""Return raw tempdeck status data from the modules endpoint."""
response = requests.get(
url=f"{self.base_url}/modules",
headers=self.headers,
timeout=30,
)
response.raise_for_status()
modules = response.json().get("modules", [])
for m in modules:
if m.get("name") == "tempdeck":
temp_module = m
data = temp_module.get("data", {})
status = temp_module.get("status", "unknown")
current_temp = data.get("currentTemp")
target_temp = data.get("targetTemp")
current_temp_str = f"{current_temp:g}°C" if current_temp is not None else "None"
target_temp_str = f"{target_temp:g}°C" if target_temp is not None else "None"
if log:
self.log_info(
f"Current status: {status} "
f"(Current temperature : {current_temp_str},"
f" Target temperature: {target_temp_str})"
)
else:
data = {}
return data
def _create_run(self):
"""Create a new robot run and reload persisted deck state.
Returns
-------
str
Newly created run identifier.
"""
self.log_info("Creating a new run for commands")
try:
# Clear custom labware tracking so definitions are re-uploaded for the new run
self.sent_custom_labware = {}
# Create a run
import datetime
run_response = requests.post(
url=f"{self.base_url}/runs",
headers=self.headers,
)
if run_response.status_code != 201:
self.log_error(f"Failed to create run: {run_response.status_code}")
self.log_error(f"Response: {run_response.text}")
raise RuntimeError(f"Failed to create run: {run_response.text}")
self.run_id = run_response.json()["data"]["id"]
self.log_debug(f"Created run: {self.run_id}")
# Reload previously configured labware, instruments, and modules
self._reload_deck_configuration()
return self.run_id
except requests.exceptions.RequestException as e:
self.log_error(f"Error creating run: {str(e)}")
raise RuntimeError(f"Error creating run: {str(e)}")
def _reload_deck_configuration(self):
"""Reload persisted modules, labware, instruments, and tip state.
Returns
-------
bool
``True`` on success, otherwise ``False`` after restoring the prior
persisted configuration.
"""
self.log_info("Reloading previously configured deck setup")
# Store original configuration for recovery if needed
original_modules = self.config["loaded_modules"].copy()
original_labware = self.config["loaded_labware"].copy()
original_instruments = self.config["loaded_instruments"].copy()
old_uuid_to_slot = {}
tiprack_slots = {}
for (mount,instrument) in original_instruments.items():
tiprack_slots[mount] = [self._slot_by_labware_uuid(uuid) for uuid in instrument['tip_racks']]
old_uuid_to_slot.update({uuid:self._slot_by_labware_uuid(uuid) for uuid in instrument['tip_racks']})
# Clear current state for reloading
self.config["loaded_modules"] = {}
self.config["loaded_labware"] = {}
self.config["loaded_instruments"] = {}
try:
# Step 1: Load modules first
# We know the run exists because _create_run just created it, so skip status checks
self.log_info("Reloading modules")
for slot, (_, module_name) in original_modules.items():
try:
self.log_info(f"Reloading module {module_name} in slot {slot}")
self.load_module(module_name, slot, check_run_status=False)
# New module ID will be stored in config["loaded_modules"]
except Exception as e:
self.log_error(f"Error reloading module {module_name} in slot {slot}: {str(e)}")
raise
# Step 2: Load labware
# We know the run exists because _create_run just created it, so skip status checks
self.log_info("Reloading labware")
for slot, (_, labware_name, labware_data) in original_labware.items():
# Check if this labware is on a module
module_id = None
if str(slot) in self.config["loaded_modules"]:
module_id = self.config["loaded_modules"][str(slot)][0] # Get new module ID
try:
self.log_info(f"Reloading labware {labware_name} in slot {slot}")
self.load_labware(labware_name, slot, module=module_id, check_run_status=False)
# New labware ID will be stored in config["loaded_labware"]
except Exception as e:
self.log_error(f"Error reloading labware {labware_name} in slot {slot}: {str(e)}")
raise
# Step 3: Load instruments
# We know the run exists because _create_run just created it, so skip status checks
# Also skip pipette updates since _initialize_robot already fetched pipette info
self.log_info("Reloading instruments")
for mount, instrument_data in original_instruments.items():
instrument_name = instrument_data['name']
try:
self.log_info(f"Reloading instrument {instrument_name} on {mount} mount")
self.load_instrument(instrument_name, mount, tiprack_slots[mount], reload=True, check_run_status=False, update_pipettes=False)
# New instrument ID will be stored in config["loaded_instruments"]
except Exception as e:
self.log_error(f"Error reloading instrument {instrument_name} on {mount} mount: {str(e)}")
raise
self.log_info("Deck configuration successfully reloaded")
# Update tiprack lists
# Build slot->new_uuid mapping from new loaded_instruments
slot_to_new_tiprack_uuid = {}
for instrument in self.config["loaded_instruments"].values():
for new_uuid in instrument.get('tip_racks', []):
slot = self._slot_by_labware_uuid(new_uuid)
slot_to_new_tiprack_uuid[slot] = new_uuid
# Remap available tips
old_available_tips = self.config.get("available_tips", {})
new_available_tips = {}
for mount in self.config["loaded_instruments"].keys():
new_available_tips[mount] = []
for tiprack_uuid, well in old_available_tips.get(mount, []):
slot = old_uuid_to_slot.get(tiprack_uuid)
new_uuid = slot_to_new_tiprack_uuid.get(slot)
if new_uuid is not None:
new_available_tips[mount].append((new_uuid, well))
self.log_info(f"Remapped {len(new_available_tips[mount])} available tips for {mount} mount after reload.")
self.config["available_tips"] = new_available_tips
return True
except Exception as e:
self.log_error(f"Failed to reload deck configuration: {str(e)}")
# Restore original configuration in config
self.config["loaded_modules"] = original_modules
self.config["loaded_labware"] = original_labware
self.config["loaded_instruments"] = original_instruments
return False
def _ensure_run_exists(self, check_run_status=True):
"""Return a valid run identifier, creating a run when needed.
Parameters
----------
check_run_status : bool, default=True
If ``False``, trust the cached run ID without a GET request.
Returns
-------
str
Valid run identifier.
"""
if not hasattr(self, "run_id") or not self.run_id:
return self._create_run()
# Skip status check if requested (optimization for bulk operations)
if not check_run_status:
return self.run_id
# Check if the run is still valid
try:
response = requests.get(
url=f"{self.base_url}/runs/{self.run_id}", headers=self.headers
)
if response.status_code != 200:
# Run doesn't exist, create a new one
return self._create_run()
# Check run state
run_data = response.json()["data"]
current_state = run_data.get("status")
if current_state in ["failed", "error", "succeeded", "stopped"]:
# Run is in a terminal state, create a new one
return self._create_run()
return self.run_id
except requests.exceptions.RequestException:
# Error checking run, create a new one
return self._create_run()
def _slot_by_labware_uuid(self, labware_id):
"""Return the deck slot for a loaded labware identifier."""
for slot, labware_info in self.config.get("loaded_labware", {}).items():
if labware_info and labware_info[0] == labware_id:
return str(slot)
return None
def _current_tip_is_reserved_stock_tip(self):
"""Return whether the currently attached tip is reserved for stock use."""
if not self.current_tip:
return False
location = self.current_tip.get("location")
if location is None:
slot = self.current_tip.get("slot")
well_name = self.current_tip.get("well_name")
if slot is not None and well_name is not None:
location = f"{slot}{well_name}"
return location in set(self.config.get("reserved_stock_tips", []))
def _drop_tip_to_trash(self, pipette_id):
"""Drop the current tip into trash and clear local tracking."""
try:
self._execute_atomic_command(
"moveToAddressableAreaForDropTip",
{
"pipetteId": pipette_id,
"addressableAreaName": FIXED_TRASH_ADDRESSABLE_AREA,
"alternateDropLocation": False,
},
check_run_status=False,
)
except Exception as exc:
self.log_warning(
"Explicit fixed-trash move unavailable; falling back to "
f"dropTipInPlace only. Error: {exc}"
)
self._execute_atomic_command(
"dropTipInPlace",
{"pipetteId": pipette_id},
check_run_status=False,
)
self.has_tip = False
self.current_tip = None
def _return_tip_to_origin(
self,
pipette_id,
mount=None,
tip_rack_offset=None,
return_tip_z_offset=None,
):
"""Return the current tip to its original tiprack location."""
if not self.current_tip:
return
tip_mount = mount or self.current_tip.get("mount")
labware_id = self.current_tip.get("labware_id")
well_name = self.current_tip.get("well_name")
if labware_id is None or well_name is None:
self._drop_tip_to_trash(pipette_id)
return
offset = self._resolve_tip_rack_offset(tip_rack_offset, mount=tip_mount)
return_offset = copy.deepcopy(offset)
# approch the tiprack safely
self._move_above_tiprack(
pipette_id=pipette_id,
labware_id=labware_id,
well_name=well_name,
tip_rack_offset=offset,
approach_z_offset=50.0,
check_run_status=False,
)
# Apply any return-only z adjustment without mutating the configured or
# caller-provided offset mapping used for future pickups.
if return_tip_z_offset is not None:
return_offset["z"] += return_tip_z_offset
# Move to the base well location before issuing the return/drop command.
self._execute_atomic_command(
"moveToWell",
{
"pipetteId": pipette_id,
"labwareId": labware_id,
"wellName": well_name,
"wellLocation": {
"origin": "center",
"offset": dict(offset),
},
},
check_run_status=False,
)
# drop the tip
self._execute_atomic_command(
"dropTipInPlace",
{
"pipetteId": pipette_id,
"labwareId": labware_id,
"wellName": well_name,
"wellLocation": {
"origin": "center",
"offset": dict(return_offset),
},
},
check_run_status=False,
)
available = list(self.config.get("available_tips", {}).get(tip_mount, []))
tip_entry = (labware_id, well_name)
if tip_entry not in available:
available.insert(0, tip_entry)
self.config.setdefault("available_tips", {})[tip_mount] = available
self.has_tip = False
self.current_tip = None
[docs]
def get_tip(self, mount):
"""Reserve and return the next available tip for a mount."""
available = list(self.config.get("available_tips", {}).get(mount, []))
if not available:
raise ValueError(f"No tips available for mount {mount}")
reserved_locations = {
str(location).strip().upper()
for location in self.config.get("reserved_stock_tips", [])
}
selected_index = None
for index, (tiprack_id, well_name) in enumerate(available):
slot = self._slot_by_labware_uuid(tiprack_id)
normalized_location = None if slot is None else f"{slot}{well_name}".upper()
if normalized_location in reserved_locations:
continue
selected_index = index
break
if selected_index is None:
raise RuntimeError(f"No unreserved tips available for {mount} mount")
tiprack_id, well_name = available.pop(selected_index)
self.config.setdefault("available_tips", {})[mount] = available
return tiprack_id, well_name
def _tip_status_counts(self, mount):
"""Summarize general and reserved tip availability for a mount.
Parameters
----------
mount : str
Pipette mount name such as ``"left"`` or ``"right"``.
Returns
-------
dict
Counts keyed by ``general_available`` and ``reserved_available``.
"""
available = list(self.config.get("available_tips", {}).get(mount, []))
reserved_locations = {
str(location).strip().upper()
for location in self.config.get("reserved_stock_tips", [])
}
reserved_available = 0
general_available = 0
for tiprack_id, well_name in available:
slot = self._slot_by_labware_uuid(tiprack_id)
normalized_location = None if slot is None else f"{slot}{well_name}".upper()
if normalized_location in reserved_locations:
reserved_available += 1
else:
general_available += 1
return {
"general_available": general_available,
"reserved_available": reserved_available,
}
[docs]
def get_tip_status(self, mount=None):
"""Return human-readable tip availability status.
Parameters
----------
mount : str, optional
Specific mount to report. If omitted, report all mounts.
Returns
-------
str
Tip availability summary.
"""
if mount:
if mount not in self.config["available_tips"]:
return f"No tipracks loaded for {mount} mount"
if mount not in self.config["loaded_instruments"]:
return f"No instrument defined for {mount} mount"
total_tips = len(TIPRACK_WELLS) * len(
self.config["loaded_instruments"][mount]["tip_racks"]
)
counts = self._tip_status_counts(mount)
return (
f"{counts['general_available']}/{total_tips} general tips available on {mount} mount "
f"({counts['reserved_available']} reserved for stock pipetting)"
)
# Return status for all mounts
status = []
for m in self.config["available_tips"]:
status.append(self.get_tip_status(m))
return "\n".join(status)
[docs]
def make_align_script(self, filename: str):
"""
Generate an Opentrons Python Protocol API script to verify alignment.
Parameters
----------
filename : str
Output path for the generated protocol script.
Notes
-----
The generated script recreates the current deck state and moves each
loaded pipette to the top of well ``A1`` for each non-tiprack labware.
Examples
--------
>>> driver.make_align_script("align_check.py")
"""
script = []
# Header
script.append("from opentrons import protocol_api")
script.append("")
script.append("metadata = {")
script.append(" 'protocolName': 'Alignment Check',")
script.append(" 'author': 'AFL Auto-Generated',")
script.append(" 'description': 'Script for aligning and testing deck configuration',")
script.append(" 'apiLevel': '2.13'")
script.append("}")
script.append("")
script.append("def run(protocol: protocol_api.ProtocolContext):")
indent = " "
# Track labware variable names by ID
labware_var_by_id = {}
# 1. Modules
loaded_modules = self.config.get("loaded_modules", {})
if loaded_modules:
script.append(f"{indent}# Modules")
# Sort by slot
for slot in sorted(loaded_modules.keys(), key=lambda x: int(x) if x.isdigit() else 99):
module_id, module_name = loaded_modules[slot]
script.append(f"{indent}module_{slot} = protocol.load_module('{module_name}', '{slot}')")
script.append("")
# 2. Labware
loaded_labware = self.config.get("loaded_labware", {})
regular_labware_vars = []
if loaded_labware:
script.append(f"{indent}# Labware")
# Sort by slot
for slot in sorted(loaded_labware.keys(), key=lambda x: int(x) if x.isdigit() else 99):
labware_id, _, labware_data = loaded_labware[slot]
# Get precise load info from definition if available
definition = labware_data.get('definition', {})
params = definition.get('parameters', {})
load_name = params.get('loadName', 'unknown_labware')
namespace = definition.get('namespace', 'opentrons')
version = definition.get('version', 1)
# Determine if tiprack
labware_type = definition.get('metadata', {}).get('displayCategory', 'default')
is_tiprack = (
params.get('isTiprack') or
labware_type == 'tipRack' or
'tiprack' in load_name.lower()
)
var_name = f"labware_{slot}"
labware_var_by_id[labware_id] = var_name
if not is_tiprack:
regular_labware_vars.append(var_name)
# Check if on module
if str(slot) in loaded_modules:
parent = f"module_{slot}"
script.append(f"{indent}{var_name} = {parent}.load_labware('{load_name}', namespace='{namespace}', version={version})")
else:
script.append(f"{indent}{var_name} = protocol.load_labware('{load_name}', '{slot}', namespace='{namespace}', version={version})")
script.append("")
# 3. Pipettes
loaded_instruments = self.config.get("loaded_instruments", {})
pipette_vars = []
if loaded_instruments:
script.append(f"{indent}# Pipettes")
for mount, instrument_data in loaded_instruments.items():
name = instrument_data['name']
tip_rack_ids = instrument_data.get('tip_racks', [])
# Resolve tip rack variables
tip_rack_vars = [labware_var_by_id[tid] for tid in tip_rack_ids if tid in labware_var_by_id]
tip_racks_arg = f"[{', '.join(tip_rack_vars)}]"
var_name = f"pipette_{mount}"
pipette_vars.append(var_name)
script.append(f"{indent}{var_name} = protocol.load_instrument('{name}', '{mount}', tip_racks={tip_racks_arg})")
script.append("")
# 4. Alignment Moves
if pipette_vars and regular_labware_vars:
script.append(f"{indent}# Alignment Verification")
script.append(f"{indent}# Move to top of A1 for each labware")
for pip in pipette_vars:
for lab in regular_labware_vars:
script.append(f"{indent}protocol.comment(f'Checking {lab} with {pip}')")
script.append(f"{indent}{pip}.move_to({lab}['A1'].top())")
script.append(f"{indent}protocol.delay(seconds=0.5)")
# Write to file
with open(filename, 'w') as f:
f.write('\n'.join(script))
self.log_info(f"Generated alignment script at {filename}")
if __name__ == "__main__":
from AFL.automation.shared.launcher import *