import warnings
from AFL.automation.APIServer.Driver import Driver
from AFL.automation.prepare.OT2HTTPDriver import OT2HTTPDriver
from AFL.automation.prepare.PrepareDriver import PrepareDriver
from AFL.automation.shared.utilities import listify
from AFL.automation.shared.units import enforce_units
[docs]
class OT2Prepare(OT2HTTPDriver, PrepareDriver):
"""Preparation-oriented OT-2 driver.
This class combines :class:`OT2HTTPDriver` transport primitives with the
higher-level preparation workflow implemented by :class:`PrepareDriver`.
It adds stock-aware tip reservation, destination occupancy tracking, and
execution helpers for preparation plans.
Parameters
----------
overrides : dict, optional
Configuration overrides merged into the inherited defaults.
Examples
--------
>>> driver = OT2Prepare({"robot_ip": "192.168.1.50"})
>>> driver.add_prep_targets(["4A1", "4A2"])
>>> driver.resolve_destination(None)
'4A1'
"""
defaults = {
"prep_targets": [],
"prepare_volume": "900 ul",
"catch_volume": "900 ul",
"deck": {},
"stocks": [],
"stock_mix_order": [],
"fixed_compositions": {},
"stock_locations": {}, # Maps stock names to deck positions: {'stockH2O': '3A2'}
"stock_transfer_params": {}, # Per-stock transfer parameters: {'stockH2O': {'mix_after': True}}
"catch_protocol": {}, # PipetteAction-formatted dict for catch transfer parameters
}
[docs]
def __init__(self, overrides=None):
"""Initialize the preparation driver.
Parameters
----------
overrides : dict, optional
Configuration values applied on top of the inherited defaults.
Examples
--------
>>> driver = OT2Prepare({"prepare_volume": "500 ul"})
>>> driver.last_target_location is None
True
"""
OT2HTTPDriver.__init__(self, overrides=overrides)
PrepareDriver.__init__(self, driver_name="OT2Prepare", overrides=overrides)
self.last_target_location = None
self.stock_sources_by_id = {}
self.stock_sources_by_group = {}
self.stock_sources_by_location = {}
self.useful_links["View Deck"] = "/visualize_deck"
[docs]
def status(self):
"""Return combined preparation and robot status lines.
Returns
-------
list of str
Human-readable status lines from both parent driver layers.
Examples
--------
>>> isinstance(driver.status(), list)
True
"""
return PrepareDriver.status(self) + OT2HTTPDriver.status(self)
def _status_lines(self):
"""Build preparation-specific status lines.
Returns
-------
list of str
Summary lines describing configured stocks, reserved stock tips,
occupied sample locations, and queued preparation targets.
Examples
--------
>>> lines = driver._status_lines()
>>> isinstance(lines, list)
True
"""
status = []
status.append(f"Stocks: {len(self.stocks)} configured")
status.append(f"Stock locations: {self.config['stock_locations']}")
stock_inventory = self._stock_inventory_snapshot(include_sources=False)
if stock_inventory:
remaining_by_stock = {}
for stock_name, entry in stock_inventory.items():
remaining_volume_ul = entry.get("remaining_volume_ul")
remaining_by_stock[stock_name] = (
f"{remaining_volume_ul} uL" if remaining_volume_ul is not None else "unknown"
)
status.append(f"Stock inventory remaining: {remaining_by_stock}")
status.append(
f"Stock-reserved tips: {len(self.config.get('reserved_stock_tips', []))}"
)
status.append(
f"Occupied sample locations: {len(self.config.get('occupied_sample_locations', []))}"
)
status.append(f"{len(self.config['prep_targets'])} preparation targets available")
return status
def _validate_pipette_action_plan(self, protocol):
"""Validate planned transfer volumes against loaded OT-2 pipettes."""
split_up_transfers = getattr(self, "_split_up_transfers", None)
can_split = split_up_transfers is not None and hasattr(self, "max_transfer")
for action in protocol:
volume_ul = float(action.volume)
if volume_ul <= 0:
continue
subtransfers = [volume_ul]
if can_split:
subtransfers = split_up_transfers(volume_ul)
if not subtransfers:
continue
for subtransfer_ul in subtransfers:
try:
self.get_pipette(subtransfer_ul)
except ValueError as exc:
raise ValueError(
f"Planned transfer from {action.source} to {action.dest} with volume "
f"{volume_ul} uL is not executable with the loaded pipettes. "
f"Subtransfer {subtransfer_ul} uL failed: {exc}"
) from exc
@staticmethod
def _infer_pipette_min_volume(pipette_name):
"""Infer a pipette minimum transfer volume from its model name."""
if pipette_name is None:
return None
normalized = str(pipette_name).strip().lower()
known_minima = {
"p10": 1.0,
"p10_single": 1.0,
"p10_single_gen1": 1.0,
"p20": 1.0,
"p20_single": 1.0,
"p20_single_gen2": 1.0,
"p50": 5.0,
"p50_single": 5.0,
"p100": 10.0,
"p100_single": 10.0,
"p300": 20.0,
"p300_single": 20.0,
"p1000": 100.0,
"p1000_single": 100.0,
}
return known_minima.get(normalized)
def _loaded_pipette_minimum_volumes(self):
"""Return candidate positive minimum transfer volumes for active pipettes."""
minima = []
update_pipettes = getattr(self, "_update_pipettes", None)
if update_pipettes is not None:
try:
update_pipettes()
except Exception:
pass
get_active_pipettes = getattr(self, "_get_active_pipettes", None)
if get_active_pipettes is not None:
try:
active_pipettes = get_active_pipettes()
except Exception:
active_pipettes = {}
for info in active_pipettes.values():
min_volume = info.get("min_volume")
if min_volume is None:
min_volume = self._infer_pipette_min_volume(info.get("name"))
if min_volume is not None and float(min_volume) > 0:
minima.append(float(min_volume))
if not minima:
for instrument in self.config.get("loaded_instruments", {}).values():
min_volume = self._infer_pipette_min_volume(instrument.get("name"))
if min_volume is not None and float(min_volume) > 0:
minima.append(float(min_volume))
min_transfer = getattr(self, "min_transfer", None)
if min_transfer is not None and float(min_transfer) > 0:
minima.append(float(min_transfer))
unique_minima = []
for min_volume in sorted(minima):
if min_volume not in unique_minima:
unique_minima.append(min_volume)
return unique_minima
def _closest_feasible_transfer_volume(self, requested_volume_ul):
"""Return the nearest OT-2-executable transfer volume for a request."""
requested_volume_ul = float(requested_volume_ul)
if requested_volume_ul <= 0:
return 0.0
try:
self.get_pipette(requested_volume_ul)
return requested_volume_ul
except ValueError:
pass
candidate_minima = []
for candidate_volume_ul in self._loaded_pipette_minimum_volumes():
try:
self.get_pipette(candidate_volume_ul)
except ValueError:
continue
candidate_minima.append(candidate_volume_ul)
if candidate_minima:
nearest_positive_volume_ul = candidate_minima[0]
if requested_volume_ul < (nearest_positive_volume_ul / 2.0):
return 0.0
for candidate_volume_ul in candidate_minima:
if candidate_volume_ul < requested_volume_ul:
continue
try:
self.get_pipette(candidate_volume_ul)
return candidate_volume_ul
except ValueError:
continue
raise ValueError(
f"No feasible OT-2 transfer volume found for requested aliquot {requested_volume_ul} uL"
)
def _condition_preparation_target(self, balanced_target):
"""Adjust undersized stock-fraction transfers to executable OT-2 aliquots."""
if not getattr(balanced_target, "stock_volume_fractions", None):
return balanced_target
adjusted_transfer_volumes = {}
adjusted_protocol = []
adjusted_any_transfer = False
for action in balanced_target.protocol:
adjusted_volume_ul = round(
self._closest_feasible_transfer_volume(action.volume),
6,
)
if abs(adjusted_volume_ul - float(action.volume)) > 1e-9:
adjusted_any_transfer = True
adjusted_action = action
adjusted_action.kwargs["volume"] = adjusted_volume_ul
adjusted_protocol.append(adjusted_action)
stock_name = self.stocks_by_location(action.source).name
adjusted_transfer_volumes[stock_name] = round(
adjusted_transfer_volumes.get(stock_name, 0.0) + adjusted_volume_ul,
6,
)
if not adjusted_any_transfer:
return balanced_target
actual_total_volume_ul = round(sum(adjusted_transfer_volumes.values()), 6)
if actual_total_volume_ul <= 0:
raise ValueError("Adjusted stock-fraction target has no executable transfer volume")
balanced_target.protocol = adjusted_protocol
balanced_target.stock_transfer_volumes = adjusted_transfer_volumes
balanced_target.stock_volume_fractions = {
stock_name: adjusted_volume_ul / actual_total_volume_ul
for stock_name, adjusted_volume_ul in adjusted_transfer_volumes.items()
}
balanced_target.requested_total_volume = enforce_units(
f"{actual_total_volume_ul} ul", "volume"
)
return balanced_target
def _normalize_locations(self, locations):
"""Normalize and deduplicate deck locations.
Parameters
----------
locations : iterable of str
Deck locations such as ``"4A1"`` or ``"6B3"``.
Returns
-------
list of str
Uppercase normalized locations in first-seen order.
Examples
--------
>>> driver._normalize_locations(["4a1", "4A1", "4b1"])
['4A1', '4B1']
"""
normalize_location = getattr(self, "_normalize_deck_location", None)
normalized = []
for location in locations:
if normalize_location is not None:
normalized_location = normalize_location(location)
else:
if location is None:
normalized_location = None
elif not isinstance(location, str):
raise TypeError(
f"Deck location must be a string, got {type(location).__name__}"
)
else:
normalized_location = location.strip().upper()
if normalized_location not in normalized:
normalized.append(normalized_location)
return normalized
def _sync_stock_tip_tracking(self):
"""Rebuild stock-tip configuration and active reservations.
Notes
-----
This method inspects configured stock objects for ``tip_location`` or
legacy ``tip`` attributes and synchronizes the persistent reservation
state stored in the driver config.
Examples
--------
>>> driver._sync_stock_tip_tracking()
>>> isinstance(driver.config.get("stock_tip_locations", {}), dict)
True
"""
stock_tip_locations = {}
for stock in self.config.get("stocks", []):
stock_name = str(stock.get("name", "")).strip()
if not stock_name:
continue
tip_location = stock.get("tip_location", stock.get("tip"))
if tip_location is None:
continue
stock_tip_locations[stock_name] = self._normalize_locations(listify(tip_location))
existing_reservations = self.config.get("stock_tip_reservations", {})
normalized_reservations = {}
for stock_name, tip_locations in existing_reservations.items():
configured_tips = stock_tip_locations.get(stock_name, [])
if not configured_tips:
continue
active = [
location
for location in self._normalize_locations(listify(tip_locations))
if location in configured_tips
]
if active:
normalized_reservations[stock_name] = active
active_reserved = []
for tip_locations in normalized_reservations.values():
active_reserved.extend(tip_locations)
self.config["stock_tip_locations"] = stock_tip_locations
self.config["stock_tip_reservations"] = normalized_reservations
self.config["reserved_stock_tips"] = self._normalize_locations(active_reserved)
def _rebuild_stock_source_indexes(self):
"""Rebuild runtime lookup tables for physical stock sources."""
self.stock_sources_by_id = {}
self.stock_sources_by_group = {}
self.stock_sources_by_location = {}
for stock in getattr(self, "stocks", []):
stock_id = getattr(stock, "stock_id", None)
stock_group = getattr(stock, "stock_group", stock.name)
if stock_id is not None:
self.stock_sources_by_id[stock_id] = stock
self.stock_sources_by_group.setdefault(stock_group, []).append(stock)
if stock.location is not None:
self.stock_sources_by_location[stock.location] = stock
def _resolve_stock_sources(self, stock_name):
stock_name = str(stock_name).strip()
if stock_name in getattr(self, "stock_sources_by_group", {}):
return list(self.stock_sources_by_group[stock_name])
return super()._resolve_stock_sources(stock_name)
def _stock_inventory_snapshot(self, stock_name=None, include_sources=True):
snapshot = {}
if not getattr(self, "stocks", None):
return snapshot
grouped_sources = {}
for stock in self.stocks:
grouped_sources.setdefault(
getattr(stock, "stock_group", stock.name),
[],
).append(stock)
requested_name = None if stock_name is None else str(stock_name).strip()
for group_name, sources in grouped_sources.items():
if requested_name is not None and group_name != requested_name:
continue
total_remaining_ul = 0.0
total_known = False
source_entries = []
for source in sources:
remaining_ul = None
if getattr(source, "volume", None) is not None:
try:
remaining_ul = round(float(source.volume.to("ul").magnitude), 6)
except Exception:
remaining_ul = None
if remaining_ul is not None:
total_remaining_ul += remaining_ul
total_known = True
if include_sources:
source_entries.append(
{
"stock_id": getattr(source, "stock_id", None),
"location": source.location,
"remaining_volume_ul": remaining_ul,
"tip_location": getattr(source, "tip_location", None),
}
)
entry = {
"remaining_volume_ul": round(total_remaining_ul, 6) if total_known else None,
}
if include_sources:
entry["sources"] = source_entries
snapshot[group_name] = entry
return snapshot
def _consume_stock_volume(self, source_location, consumed_volume_ul):
"""Deplete tracked stock volume for a physical source well."""
try:
stock = self.stocks_by_location(source_location)
except ValueError:
return {
"stock_id": None,
"source_stock_group": self.config.get("deck", {}).get(source_location),
"remaining_before_ul": None,
"remaining_after_ul": None,
"consumed_volume_ul": round(float(consumed_volume_ul), 6),
}
before_ul = None
after_ul = None
if getattr(stock, "volume", None) is not None:
before_ul = round(float(stock.volume.to("ul").magnitude), 6)
stock.measure_out(f"{float(consumed_volume_ul)} ul", deplete=True)
after_ul = round(float(stock.volume.to("ul").magnitude), 6)
inventory = dict(self.config.get("stock_inventory", {}))
inventory[getattr(stock, "stock_id", f"{stock.name}@{stock.location}")] = {
"remaining_volume": f"{after_ul} ul"
}
self.config["stock_inventory"] = inventory
return {
"stock_id": getattr(stock, "stock_id", None),
"source_stock_group": getattr(stock, "stock_group", stock.name),
"remaining_before_ul": before_ul,
"remaining_after_ul": after_ul,
"consumed_volume_ul": round(float(consumed_volume_ul), 6),
}
def _ordered_stock_tip_candidates(self, stock_name, step_tip_location=None):
"""Return prioritized tip candidates for a stock transfer.
Parameters
----------
stock_name : str
Stock identifier used in the preparation configuration.
step_tip_location : str or sequence of str, optional
Explicit tip location override from a protocol step.
Returns
-------
list of str
Normalized tip locations with active reservations ordered before
configured fallback candidates.
Examples
--------
>>> driver._ordered_stock_tip_candidates("stockH2O")
['6A1']
"""
configured = self.config.get("stock_tip_locations", {}).get(stock_name, [])
if step_tip_location is None:
candidates = list(configured)
else:
candidates = self._normalize_locations(listify(step_tip_location))
active = self.config.get("stock_tip_reservations", {}).get(stock_name, [])
ordered = []
for normalized in self._normalize_locations(list(active) + list(candidates)):
if normalized not in ordered:
ordered.append(normalized)
return ordered
def _select_stock_tip_location(self, stock_name, volume_ul, step_tip_location=None):
"""Choose an available stock-reserved tip location.
Parameters
----------
stock_name : str
Stock identifier.
volume_ul : float
Transfer volume used to determine the pipette mount.
step_tip_location : str or sequence of str, optional
Explicit tip location override from the planned transfer step.
Returns
-------
str or None
Selected normalized tip location, or ``None`` when no stock tip is
configured.
Raises
------
ValueError
If configured stock tips exist but none are currently available for
the required mount.
Examples
--------
>>> driver._select_stock_tip_location("stockH2O", 50)
'6A1'
"""
candidates = self._ordered_stock_tip_candidates(stock_name, step_tip_location)
if not candidates:
return None
pipette_mount = self.get_pipette(float(volume_ul))["mount"]
match_tip_location = getattr(self, "_tip_location_matches_mount", None)
resolve_tip_location = getattr(self, "_resolve_tip_location", None)
compatible = []
for location in candidates:
if match_tip_location is not None:
try:
matches_mount = match_tip_location(pipette_mount, location)
except ValueError:
continue
if not matches_mount:
continue
compatible.append(location)
if resolve_tip_location is None:
return location
try:
resolve_tip_location(pipette_mount, location)
return location
except ValueError:
continue
if compatible:
raise ValueError(
f"No configured tip locations for stock '{stock_name}' are currently available "
f"on {pipette_mount} mount: {', '.join(compatible)}"
)
raise ValueError(
f"No configured tip locations for stock '{stock_name}' match the {pipette_mount} mount: "
f"{', '.join(candidates)}"
)
def _activate_stock_tip_reservation(self, stock_name, tip_location):
"""Mark a stock tip as actively reserved.
Parameters
----------
stock_name : str
Stock identifier.
tip_location : str or None
Tip location to reserve. ``None`` leaves the reservation state
unchanged.
Examples
--------
>>> driver._activate_stock_tip_reservation("stockH2O", "6A1")
"""
if stock_name is None or tip_location is None:
return
tip_location = self._normalize_locations([tip_location])[0]
configured = self.config.get("stock_tip_locations", {}).get(stock_name, [])
if tip_location not in configured:
return
reservations = {
name: self._normalize_locations(listify(locations))
for name, locations in self.config.get("stock_tip_reservations", {}).items()
}
for other_stock, locations in reservations.items():
if other_stock != stock_name and tip_location in locations:
raise ValueError(
f"Tip location {tip_location} is already reserved for stock '{other_stock}' "
f"and cannot also be reserved for stock '{stock_name}'."
)
stock_reservations = reservations.get(stock_name, [])
if tip_location not in stock_reservations:
stock_reservations.append(tip_location)
reservations[stock_name] = self._normalize_locations(stock_reservations)
active_reserved = []
for locations in reservations.values():
active_reserved.extend(locations)
self.config["stock_tip_reservations"] = reservations
self.config["reserved_stock_tips"] = self._normalize_locations(active_reserved)
def _build_stock_transfer_params(self, stock_name, volume_ul, step_tip_location=None):
"""Build transfer keyword arguments for a stock step.
Parameters
----------
stock_name : str
Stock identifier.
volume_ul : float
Requested transfer volume in microliters.
step_tip_location : str or sequence of str, optional
Explicit tip location override from the protocol step.
Returns
-------
tuple
Two-item tuple ``(transfer_params, selected_tip_location)``.
Examples
--------
>>> params, tip = driver._build_stock_transfer_params("stockH2O", 100)
>>> isinstance(params, dict)
True
"""
transfer_params = self.get_transfer_params(stock_name)
selected_tip_location = self._select_stock_tip_location(
stock_name=stock_name,
volume_ul=volume_ul,
step_tip_location=step_tip_location,
)
if selected_tip_location is not None:
transfer_params["tip_location"] = selected_tip_location
return transfer_params, selected_tip_location
def _occupied_sample_locations(self):
"""Return occupied sample destinations as a normalized set.
Returns
-------
set of str
Occupied destination locations currently tracked in config.
Examples
--------
>>> isinstance(driver._occupied_sample_locations(), set)
True
"""
return set(self._normalize_locations(self.config.get("occupied_sample_locations", [])))
def _assert_destination_locations_available(self, destinations):
"""Validate that destination locations are not already occupied.
Parameters
----------
locations : iterable of str
Destination locations to validate.
Raises
------
ValueError
If duplicate destinations are requested or any destination is
already marked occupied.
Examples
--------
>>> driver._assert_destination_locations_available(["4A1", "4A2"])
"""
normalized = self._normalize_locations(listify(destinations))
duplicates = sorted({location for location in normalized if normalized.count(location) > 1})
if duplicates:
raise ValueError(
"Preparation requested the same destination location more than once: "
+ ", ".join(duplicates)
)
occupied = self._occupied_sample_locations()
conflicts = [location for location in normalized if location in occupied]
if conflicts:
raise ValueError(
"Destination location(s) already contain a prepared sample: "
+ ", ".join(conflicts)
+ ". Clear or change those sample destinations before preparing again."
)
def _mark_sample_locations_occupied(self, locations):
"""Record destination locations as occupied.
Parameters
----------
locations : iterable of str
Locations to add to the occupied-sample tracking list.
Examples
--------
>>> driver._mark_sample_locations_occupied(["4A1"])
"""
occupied = self._normalize_locations(self.config.get("occupied_sample_locations", []))
for location in self._normalize_locations(listify(locations)):
if location not in occupied:
occupied.append(location)
self.config["occupied_sample_locations"] = occupied
[docs]
@Driver.queued()
def clear_sample_locations(self, locations=None):
"""Clear occupied sample destination tracking.
Parameters
----------
locations : str or sequence of str, optional
Specific occupied locations to clear. If omitted, all occupied
sample locations are cleared.
Returns
-------
list of str
Normalized locations that were cleared.
Examples
--------
>>> driver.clear_sample_locations(["4A1"])
['4A1']
>>> driver.clear_sample_locations()
[]
"""
occupied = self._occupied_sample_locations()
if locations is None:
self.config["occupied_sample_locations"] = []
self.config._update_history()
return []
to_clear = set(self._normalize_locations(listify(locations)))
remaining = [location for location in occupied if location not in to_clear]
cleared = [location for location in occupied if location in to_clear]
self.config["occupied_sample_locations"] = remaining
self.config._update_history()
return cleared
[docs]
def resolve_destination(self, destination):
"""Resolve the destination well for a preparation.
Parameters
----------
destination : str or None
Explicit destination location. If ``None``, the next queued
preparation target is consumed.
Returns
-------
str
Normalized destination location.
Raises
------
ValueError
If no destination is available or the destination is already
occupied.
Examples
--------
>>> driver.resolve_destination("4A1")
'4A1'
"""
if destination is None:
if not self.config.get("prep_targets"):
raise ValueError("No preparation targets configured. Cannot select a destination target.")
prep_targets = list(self.config["prep_targets"])
destination = self._normalize_locations([prep_targets[0]])[0]
self._assert_destination_locations_available([destination])
prep_targets.pop(0)
self.config["prep_targets"] = prep_targets
return destination
destination = self._normalize_locations([destination])[0]
self._assert_destination_locations_available([destination])
return destination
def _reserve_destinations(
self,
dest=None,
required_intermediate_targets=0,
intermediate_destinations=None,
destination=None,
):
"""Reserve final and intermediate destinations for a preparation plan.
Parameters
----------
dest : str, optional
Final destination location.
required_intermediate_targets : int, default=0
Number of intermediate destinations required by the preparation
plan. Used by the base prepare flow.
intermediate_destinations : sequence of str, optional
Intermediate destination locations used by staged plans.
destination : str, optional
Backward-compatible alias for ``dest``.
Returns
-------
tuple
Normalized ``(destination, intermediate_destinations)``.
Raises
------
ValueError
If any requested destination is already occupied.
Examples
--------
>>> driver._reserve_destinations("4A1", ["5A1"])
('4A1', ['5A1'])
"""
requested_destination = dest if dest is not None else destination
requested_intermediate_destinations = list(intermediate_destinations or [])
if requested_intermediate_destinations:
required_intermediate_targets = len(requested_intermediate_destinations)
destination, intermediate_destinations, consumed, queue_key = super()._reserve_destinations(
dest=requested_destination,
required_intermediate_targets=required_intermediate_targets,
)
all_destinations = list(intermediate_destinations) + [destination]
normalized_destinations = self._normalize_locations(all_destinations)
normalized_intermediates = normalized_destinations[: len(intermediate_destinations)]
normalized_destination = normalized_destinations[-1]
try:
self._assert_destination_locations_available(normalized_destinations)
except Exception:
if required_intermediate_targets > 0:
self._restore_reserved_destinations(queue_key=queue_key, consumed=consumed)
elif requested_destination is None:
queue = list(self.config.get("prep_targets", []))
self.config["prep_targets"] = [normalized_destination] + queue
raise
return normalized_destination, normalized_intermediates, consumed, queue_key
[docs]
def execute_preparation(self, target, balanced_target, destination):
"""Execute a simple preparation protocol into one destination.
Parameters
----------
target : object
Original target specification from the preparation workflow.
balanced_target : object
Balanced target object containing a generated ``protocol``.
destination : str
Destination deck location.
Returns
-------
bool
``True`` when all transfers succeed, otherwise ``False``.
Raises
------
ValueError
If no protocol is available or a stock location cannot be resolved.
Examples
--------
>>> driver.execute_preparation(target, balanced_target, "4A1")
True
"""
if not hasattr(balanced_target, "protocol") or not balanced_target.protocol:
raise ValueError("No protocol generated for the target solution")
protocol = self.reorder_protocol(balanced_target.protocol)
for step in protocol:
source = step.source
volume_ul = step.volume
if float(volume_ul) <= 0:
continue
stock_name = self.config.get("deck", {}).get(source)
if stock_name is None:
raise ValueError(f"No stock name found for deck location: {source}")
transfer_params, selected_tip_location = self._build_stock_transfer_params(
stock_name=stock_name,
volume_ul=volume_ul,
step_tip_location=getattr(step, "tip_location", None),
)
try:
self.log_debug(
"Pipette action: "
f"stock={stock_name!r}, source={source!r}, dest={destination!r}, "
f"volume_ul={float(volume_ul)}"
)
transfer_result = self.transfer(
source=source,
dest=destination,
volume=volume_ul,
**transfer_params,
)
depletion_info = self._consume_stock_volume(
source,
sum(transfer_result.get("subtransfers_ul", [])) or float(volume_ul),
)
self._record_prepare_transfer(
stage_type="single",
source=source,
dest=destination,
requested_volume_ul=float(volume_ul),
source_stock_name=stock_name,
transfer_params=transfer_params,
transfer_result=transfer_result,
planned_transfer={
"source": source,
"dest": destination,
"source_stock_name": stock_name,
},
extra=depletion_info,
)
self._activate_stock_tip_reservation(stock_name, selected_tip_location)
except Exception as e:
warnings.warn(f"Transfer failed from {source} to {destination}: {str(e)}", stacklevel=2)
return False
self.last_target_location = destination
self._mark_sample_locations_occupied([destination])
return True
def _resolve_stage_source(self, source_location, intermediate_map):
"""Resolve a staged source token to a concrete deck location.
Parameters
----------
source_location : str
Source location or ``@intermediate:<id>`` token.
intermediate_map : dict
Mapping from intermediate identifiers to deck locations.
Returns
-------
str
Concrete deck location.
Raises
------
ValueError
If an intermediate token cannot be resolved.
"""
if isinstance(source_location, str) and source_location.startswith("@intermediate:"):
key = source_location.split(":", 1)[1]
if key not in intermediate_map:
raise ValueError(f"Unknown intermediate source token: {source_location}")
return intermediate_map[key]
return source_location
def _record_prepare_transfer(
self,
stage_type,
source,
dest,
requested_volume_ul,
source_stock_name,
transfer_params,
transfer_result,
planned_transfer=None,
extra=None,
):
"""Append a structured preparation transfer record.
Parameters
----------
stage_type : str
Preparation stage label such as ``"single"`` or ``"final_mix"``.
source, dest : str
Source and destination deck locations.
requested_volume_ul : float
Requested transfer volume in microliters.
source_stock_name : str or None
Stock name associated with the source location.
transfer_params : dict
Transfer keyword arguments used for execution.
transfer_result : dict
Result returned by :meth:`transfer`.
planned_transfer : dict, optional
Original planned transfer metadata.
extra : dict, optional
Additional fields merged into the stored record.
"""
entry = {
"stage_type": stage_type,
"source_location": source,
"dest_location": dest,
"source_stock_name": source_stock_name,
"requested_volume_ul": float(requested_volume_ul),
"transfer_params": dict(transfer_params or {}),
"transfer_result": transfer_result,
}
if planned_transfer is not None:
entry["planned_transfer"] = planned_transfer
if extra:
entry.update(extra)
self._append_prepare_transfer(entry)
def _transfer_stage(
self,
source,
dest,
volume_ul,
stage_type,
source_stock_name=None,
planned_transfer=None,
extra=None,
):
"""Execute and record one staged preparation transfer.
Parameters
----------
source, dest : str
Source and destination deck locations.
volume_ul : float
Transfer volume in microliters.
stage_type : str
Stage label used in transfer bookkeeping.
source_stock_name : str, optional
Stock name associated with the source.
planned_transfer : dict, optional
Planned transfer metadata.
extra : dict, optional
Additional bookkeeping fields.
"""
if float(volume_ul) <= 0:
return
stock_name = source_stock_name
if stock_name is None:
stock_name = self.config.get("deck", {}).get(source)
selected_tip_location = None
if stock_name is not None:
transfer_params, selected_tip_location = self._build_stock_transfer_params(
stock_name=stock_name,
volume_ul=volume_ul,
)
else:
transfer_params = self.get_transfer_params("default")
self.log_debug(
"Pipette action: "
f"stage={stage_type!r}, stock={stock_name!r}, source={source!r}, "
f"dest={dest!r}, volume_ul={float(volume_ul)}"
)
transfer_result = self.transfer(source=source, dest=dest, volume=volume_ul, **transfer_params)
self._activate_stock_tip_reservation(stock_name, selected_tip_location)
depletion_info = {}
if stock_name is not None:
depletion_info = self._consume_stock_volume(
source,
sum(transfer_result.get("subtransfers_ul", [])) or float(volume_ul),
)
self._record_prepare_transfer(
stage_type=stage_type,
source=source,
dest=dest,
requested_volume_ul=float(volume_ul),
source_stock_name=stock_name,
transfer_params=transfer_params,
transfer_result=transfer_result,
planned_transfer=planned_transfer,
extra=dict((extra or {}), **depletion_info),
)
[docs]
def execute_preparation_plan(self, target, balanced_target, destination, procedure_plan, intermediate_destinations):
"""Execute a staged preparation plan with intermediates.
Parameters
----------
target : object
Original target specification.
balanced_target : object
Balanced target object associated with the plan.
destination : str
Final destination location.
procedure_plan : dict
Staged plan containing dilution and final-mix steps.
intermediate_destinations : sequence of str
Concrete deck locations assigned to intermediate stages.
Returns
-------
bool
``True`` when the plan completes successfully.
Raises
------
ValueError
If the intermediate mapping is inconsistent or a stage type is
unknown.
Examples
--------
>>> driver.execute_pre preparation_plan(target, balanced_target, "4A1", plan, ["5A1"])
True
"""
intermediate_ids = procedure_plan.get("intermediate_ids", [])
if len(intermediate_ids) != len(intermediate_destinations):
raise ValueError(
f"Intermediate destination mismatch. Need {len(intermediate_ids)}, got {len(intermediate_destinations)}."
)
intermediate_map = {
intermediate_id: intermediate_destinations[i]
for i, intermediate_id in enumerate(intermediate_ids)
}
stages = procedure_plan.get("stages", [])
for stage in stages:
stage_type = stage.get("stage_type")
if stage_type == "dilution":
dest_token = stage.get("destination_token")
if not isinstance(dest_token, str) or not dest_token.startswith("@intermediate:"):
raise ValueError(f"Invalid dilution destination token: {dest_token}")
intermediate_id = dest_token.split(":", 1)[1]
if intermediate_id not in intermediate_map:
raise ValueError(f"No destination assigned for intermediate '{intermediate_id}'")
stage_dest = intermediate_map[intermediate_id]
source_loc = self._resolve_stage_source(stage.get("source_location"), intermediate_map)
diluent_loc = self._resolve_stage_source(stage.get("diluent_location"), intermediate_map)
source_mass_g = float(stage.get("total_source_mass_g", 0.0))
diluent_mass_g = float(stage.get("total_diluent_mass_g", 0.0))
dilution_actions = []
if source_mass_g > 0:
source_stock = self.stocks_by_location(source_loc)
source_volume = source_stock.measure_out(f"{source_mass_g} g").volume.to("ul").magnitude
dilution_actions.append(
{
"source_location": source_loc,
"source_stock_name": stage.get("source_stock_name"),
"volume_ul": source_volume,
"planned_transfer": {
"required_mass_g": source_mass_g,
"source_location": source_loc,
"destination_token": dest_token,
},
"extra": {
"intermediate_id": intermediate_id,
"destination_token": dest_token,
"dilution_factor": stage.get("dilution_factor"),
"batches": stage.get("batches"),
"transfer_role": "source",
"intermediate_location": stage_dest,
},
}
)
if diluent_mass_g > 0:
diluent_stock = self.stocks_by_location(diluent_loc)
diluent_volume = diluent_stock.measure_out(f"{diluent_mass_g} g").volume.to("ul").magnitude
dilution_actions.append(
{
"source_location": diluent_loc,
"source_stock_name": stage.get("diluent_stock_name"),
"volume_ul": diluent_volume,
"planned_transfer": {
"required_mass_g": diluent_mass_g,
"source_location": diluent_loc,
"destination_token": dest_token,
},
"extra": {
"intermediate_id": intermediate_id,
"destination_token": dest_token,
"dilution_factor": stage.get("dilution_factor"),
"batches": stage.get("batches"),
"transfer_role": "diluent",
"intermediate_location": stage_dest,
},
}
)
for action in self.reorder_protocol(dilution_actions):
self._transfer_stage(
action["source_location"],
stage_dest,
action["volume_ul"],
stage_type="dilution",
source_stock_name=action["source_stock_name"],
planned_transfer=action["planned_transfer"],
extra=action["extra"],
)
elif stage_type == "final_mix":
for transfer in self.reorder_protocol(stage.get("transfers", [])):
source_loc = self._resolve_stage_source(transfer.get("source_location"), intermediate_map)
vol_ul = float(transfer.get("required_volume_ul", 0.0))
if vol_ul <= 0:
continue
self._transfer_stage(
source_loc,
destination,
vol_ul,
stage_type="final_mix",
source_stock_name=transfer.get("source_stock_name"),
planned_transfer=transfer,
extra={
"destination_location": destination,
},
)
else:
raise ValueError(f"Unknown stage type '{stage_type}' in procedure plan")
self.last_target_location = destination
self._mark_sample_locations_occupied(list(intermediate_destinations) + [destination])
return True
[docs]
def stocks_by_location(self, location):
"""Return the configured stock object at a deck location.
Parameters
----------
location : str
Deck location associated with a configured stock.
Returns
-------
object
Matching stock object.
Raises
------
ValueError
If no stock is configured at the requested location.
"""
normalized = self._normalize_locations([location])[0]
if normalized in getattr(self, "stock_sources_by_location", {}):
return self.stock_sources_by_location[normalized]
for stock in self.stocks:
if stock.location == normalized:
return stock
raise ValueError(f"No stock configured at location '{location}'")
[docs]
def build_prepare_result(self, feasible_result, balanced_target):
"""Build the serialized result payload for a preparation.
Parameters
----------
feasible_result : object
Feasibility result from the preparation workflow.
balanced_target : object
Balanced target object to serialize.
Returns
-------
dict
Serialized target data with total volume included when available.
"""
result_dict = balanced_target.to_dict()
total_volume = getattr(balanced_target, "requested_total_volume", None)
if total_volume is None and hasattr(balanced_target, "volume"):
total_volume = balanced_target.volume
if total_volume is not None:
total_volume_ul = round(float(total_volume.to("ul").magnitude), 6)
result_dict["total_volume"] = f"{total_volume_ul} ul"
result_dict["stock_inventory_after"] = self._stock_inventory_snapshot()
return result_dict
[docs]
def process_stocks(self):
"""Process stocks and refresh deck-derived preparation state.
Notes
-----
This extends :class:`PrepareDriver` stock processing by rebuilding the
reverse deck map and stock-tip reservation state.
"""
PrepareDriver.process_stocks(self)
self._rebuild_stock_source_indexes()
self._update_deck_config()
self._sync_stock_tip_tracking()
def _update_deck_config(self):
"""Rebuild the reverse deck map from stock locations.
Examples
--------
>>> driver._update_deck_config()
>>> isinstance(driver.config.get("deck", {}), dict)
True
"""
deck_config = {}
for stock in getattr(self, "stocks", []):
if stock.location is None:
continue
deck_config[stock.location] = getattr(stock, "stock_group", stock.name)
self.config["deck"] = deck_config
[docs]
@Driver.unqueued()
def get_stock_inventory(self, stock_name=None, include_sources=True):
self.process_stocks()
return self._stock_inventory_snapshot(
stock_name=stock_name,
include_sources=bool(include_sources),
)
[docs]
def get_transfer_params(self, stock_name):
"""Return merged transfer parameters for a stock.
Parameters
----------
stock_name : str
Stock identifier.
Returns
-------
dict
Default transfer parameters overlaid with stock-specific overrides.
Examples
--------
>>> isinstance(driver.get_transfer_params("default"), dict)
True
"""
stock_params = self.config.get("stock_transfer_params", {}).get(stock_name, {})
default_params = self.config.get("stock_transfer_params", {}).get("default", {})
params = default_params.copy()
params.update(stock_params)
return params
[docs]
def reorder_protocol(self, protocol):
"""Reorder protocol steps according to configured stock-name order.
Parameters
----------
protocol : sequence
Protocol steps with a ``source`` attribute (or procedure-plan
transfer dictionaries with ``source_location``). A stock may
have several source wells; all of those wells are ordered using
the stock's configured name.
Returns
-------
list
Reordered protocol steps.
"""
stock_mix_order = self.config.get("stock_mix_order", [])
if not stock_mix_order:
return protocol
configured_stock_names = {str(stock_name) for stock_name in stock_mix_order}
steps_by_stock_name = {}
unordered_steps = []
for step in protocol:
if isinstance(step, dict):
source = step.get("source_location", step.get("source"))
stock_name = step.get("source_stock_name")
else:
source = step.source
stock_name = None
# ``stock_mix_order`` is intentionally expressed in logical
# stock names, rather than physical source locations. Resolve a
# source well through the deck map so split-stock transfers stay
# together and obey the same ordering.
if stock_name is None:
stock_name = self.config.get("deck", {}).get(source)
if stock_name in configured_stock_names:
steps_by_stock_name.setdefault(stock_name, []).append(step)
else:
unordered_steps.append(step)
reordered = []
emitted_stock_names = set()
for stock_name in stock_mix_order:
normalized_stock_name = str(stock_name)
if normalized_stock_name not in emitted_stock_names:
reordered.extend(steps_by_stock_name.get(normalized_stock_name, []))
emitted_stock_names.add(normalized_stock_name)
# Keep all stocks omitted from stock_mix_order in their original
# protocol order.
return reordered + unordered_steps
[docs]
def transfer_to_catch(self, source=None, dest=None, **kwargs):
"""Transfer a prepared sample into the configured catch destination.
Parameters
----------
source : str, optional
Source location. Defaults to the last preparation destination.
dest : str, optional
Destination override for the catch transfer.
**kwargs
Additional transfer keyword arguments merged into the configured
catch protocol.
Returns
-------
None
The method raises on failure and records the transfer on success.
Raises
------
ValueError
If no source or destination can be resolved.
RuntimeError
If the underlying transfer fails.
"""
catch_params = self.config.get("catch_protocol", {}).copy()
if source is None:
if self.last_target_location is None:
raise ValueError(
"No source specified and no last target location available. "
"Call prepare() first or specify source."
)
source = self.last_target_location
kwargs["source"] = source
if dest is not None:
kwargs["dest"] = dest
catch_params.update(kwargs)
if "dest" not in catch_params:
raise ValueError("Destination 'dest' must be specified in catch_protocol config or as an argument.")
try:
transfer_result = self.transfer(**catch_params)
self._record_prepare_transfer(
stage_type="catch",
source=catch_params["source"],
dest=catch_params["dest"],
requested_volume_ul=float(catch_params.get("volume", 0.0)),
source_stock_name=self.config.get("deck", {}).get(catch_params["source"]),
transfer_params={k: v for k, v in catch_params.items() if k not in ("source", "dest", "volume")},
transfer_result=transfer_result,
)
except Exception as e:
dest_val = catch_params.get("dest", "unknown")
warnings.warn(
f"Transfer to catch failed from {source} to {dest_val} using {catch_params}: {str(e)}",
stacklevel=2,
)
raise
[docs]
def load_gen1_p10(self, mount, tip_rack_slots, **kwargs):
"""Load a GEN1 P10 single-channel pipette.
Parameters
----------
mount : {"left", "right"}
Mount on which to load the pipette.
tip_rack_slots : sequence of str
Tiprack slots associated with the pipette.
**kwargs
Additional keyword arguments forwarded to :meth:`load_instrument`.
Returns
-------
str
Loaded pipette identifier returned by the robot.
"""
return self.load_instrument(
name="p10_single",
mount=mount,
tip_rack_slots=tip_rack_slots,
**kwargs,
)
[docs]
def reset(self):
"""Reset preparation targets and stock state.
Notes
-----
This reset is preparation-focused and delegates to
:class:`PrepareDriver` helpers rather than resetting the OT-2 run.
"""
self.reset_targets()
self.reset_stocks()
self._reset_prepare_state()
_DEFAULT_PORT = 5002
if __name__ == "__main__":
from AFL.automation.shared.launcher import *