UniteLabs
Guides

Simulation

Run protocols against a mock liquid handler — drop-in replacement for real hardware, with full liquid tracking and state validation.

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 STAR
  • BravoMock: 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.

Next steps