UniteLabs

Simulation

Run scripts against a mock liquid handler that drops in for real hardware and tracks liquid in every well.

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.

The code on this tab targets unitelabs-liquid-handling 0.39 with unitelabs-labware 0.37. If an import fails, check your installed versions first: Versions shows how.

Mock liquid handler

The following mock classes are available, all imported from unitelabs.liquid_handling.testing:

  • MicrolabSTARMock: Hamilton Microlab STAR
  • VantageMock: Hamilton Vantage
  • BravoMock: Agilent Bravo
  • TecanFreedomEvoMock: Tecan Freedom EVO
  • BiomekMock: Beckman Biomek
import asyncio

from unitelabs.labware import PredefinedLiquids, Standard96Plate
from unitelabs.labware.hamilton import (
    PLT_CAR_L5MD_A00,
    TIP_CAR_480_A00,
    HamiltonTip_300,
    HamiltonTipRack_300,
)
from unitelabs.liquid_handling.testing import MicrolabSTARMock

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(PredefinedLiquids.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
    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("Run completed in simulation.")

asyncio.run(main())

Configuring the mock

The mock ships with a default configuration based on a Hamilton 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 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 decimal import Decimal

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 import Stage

lh.configuration['iswap'] = 'left'
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 script's 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

Last updated