Simulation
See Simulation for what the mock validates (and what it doesn't), and why. This guide covers the procedural how-to: instantiating mocks, configuring them, and switching between mock and real hardware.
Mock liquid handler
The following mock classes are available, all imported from unitelabs.liquid_handling.testing:
MicrolabSTARMock: mock for the Hamilton Microlab STARBravoMock: mock for the Agilent Bravo
import asyncio
import typing
from unitelabs.labware import Liquid, Standard96Plate
from unitelabs.labware.hamilton import (
HamiltonTip_300,
HamiltonTipRack_300
)
from unitelabs.liquid_handling.testing import MicrolabSTARMock
from unitelabs.labware.hamilton import PLT_CAR_L5MD_A00, TIP_CAR_480_A00
async def main():
lh = MicrolabSTARMock()
await lh.configure()
await lh.initialize()
# Build your deck exactly as you would with real hardware
tip_carrier = TIP_CAR_480_A00(identifier="tip_carrier")
tip_rack = HamiltonTipRack_300(filled_with=HamiltonTip_300, identifier="tip_rack")
tip_carrier[0] = tip_rack
plate_carrier = PLT_CAR_L5MD_A00(identifier="plate_carrier")
plate = Standard96Plate(identifier="plate")
for well in plate["A1":"H1"]:
well.container.add_liquid(Liquid.WATER, 100)
plate_carrier[0] = plate
lh.deck.add(tip_carrier, track=1)
lh.deck.add(plate_carrier, track=10)
# All pipetting operations work normally
tips = lh.deck.get("tip_rack") # or just use tip_rack
tips = typing.cast(TIP_CAR_480_A00, tips)
await lh.pipettes.pick_up_tips(channels=range(8), spots=tip_rack["A1":"H1"])
await lh.pipettes.aspirate(plate["A1":"H1"], volume=[100] * 8)
await lh.pipettes.dispense(plate["A1":"H1"], volume=[100] * 8)
await lh.pipettes.drop_tips()
print("Protocol completed in simulation.")
asyncio.run(main())
Configuring the mock
The mock ships with a default configuration based on a Microlab STARlet with all supported modules enabled. For accurate deck dimension checks and module availability, override this with your device's actual configuration.
You can read and modify the configuration object, then call configure() to apply it:
from decimal import Decimal
lh = MicrolabSTARMock()
configuration = await lh.get_configuration()
# Set STAR deck dimensions
configuration.deck_track_count = 54
configuration.autoload_track_count = 54
configuration.waste_x = Decimal('1340.0')
configuration.max_x = Decimal('1140.0')
# Enable installed modules
configuration.iswap = 'left'
configuration.core96 = 'left'
configuration.autoload = 'Scanner'
await lh.configure()
Alternatively, pass a pre-built configuration object directly to the constructor:
from decimal import Decimal
from unitelabs.liquid_handling.hamilton.interfaces import MicrolabSTARConfiguration
from unitelabs.liquid_handling.testing import MicrolabSTARMock
configuration = MicrolabSTARConfiguration(
deck_track_count=54,
autoload_track_count=54,
waste_x=Decimal('1340.0'),
max_x=Decimal('1140.0'),
iswap='left',
core96='left',
autoload='Scanner',
)
lh = MicrolabSTARMock(configuration=configuration)
await lh.configure()
You can verify any attribute directly after configuring:
print(lh.configuration.deck_track_count)
print(lh.configuration.iswap)
# 54
# 'left'
Initialization and movement
Call initialize() before issuing movement or pipetting commands. After initialization, you can inspect current channel positions:
await lh.initialize()
await lh.pipettes.initialize()
print(await lh.pipettes.current_locations())
# [Vector(x=..., y=..., z=...), ...]
Movement commands to locations within the configured deck bounds execute without error:
from unitelabs.labware import Vector
await lh.pipettes[0].move_to(Vector(x=Decimal('100'), y=Decimal('100'), z=Decimal('300')))
Note: await lh.is_initialized() does not reflect the expected initialization status in the mock. Check initialization state by attempting a movement instead.
Activating modules
Any module present in the configuration can be activated:
await lh.iswap.activate()
To simulate initialization errors or recover a module that fails to activate, set its stage manually before initializing:
from unitelabs.liquid_handling.modules import Stage
lh.configuration['iswap'] = True
lh.iswap._stage = Stage.CONFIGURED
await lh.iswap.initialize()
await lh.iswap.activate()
Checking liquid state
One of the most useful properties of simulation is that the liquid model runs in full. You can inspect volumes after each operation to verify your protocol logic:
await lh.pipettes.pick_up_tips(channels=range(8), spots=tip_rack["A2":"H2"])
await lh.pipettes.aspirate(plate["A1"], channels=[0], volume=[50])
print(plate["A1"].container.volume) # 50 µL removed from the well
await lh.pipettes.dispense(plate["B1"], channels=[0], volume=[50])
print(plate["B1"].container.volume) # 50 µL added to B1
This catches off-by-one errors, wrong well indexing, and volume miscalculations before any liquid is touched.
Switching to real hardware
The mock is a drop-in replacement for the real device class. Structure your code with a flag to toggle between them:
from unitelabs.liquid_handling.testing import MicrolabSTARMock
mock_run = True
if mock_run:
lh = MicrolabSTARMock()
else:
from unitelabs.liquid_handling.hamilton import MicrolabSTAR
from unitelabs.sdk import AsyncApiClient
client = AsyncApiClient()
lh = MicrolabSTAR(name="Microlab STAR", client=client)
All downstream code (pipetting commands, movement, module use) works identically with either object.