Skip to content

feat(api, shared-data): Add labware containment and vacuum module dock support.#20802

Open
vegano1 wants to merge 75 commits intoedgefrom
vacuum-module-labware-containment
Open

feat(api, shared-data): Add labware containment and vacuum module dock support.#20802
vegano1 wants to merge 75 commits intoedgefrom
vacuum-module-labware-containment

Conversation

@vegano1
Copy link
Copy Markdown
Contributor

@vegano1 vegano1 commented Feb 6, 2026

Overview

This PR introduces labware containment support and adds the necessary API and hardware-control functionality to enable workflows with the Vacuum Module — specifically the SLAS 2026 demo protocol that uses the Millipore vacuum manifold collar.

Key Features Added

1. Labware Containment Model

  • Added a new optional top-level field containedSpace to the labware definition schema.
    • Defines the internal empty space (x/y/z dimensions + volume) that can house other labware.
  • Introduced geometry validation (_is_fully_contained) to check whether a child labware fits completely inside a parent.
  • Updated ensure_location_not_occupied to respect containment when determining occupancy for nested labware.
  • Added get_parent_from_location() helper to recursively resolve the root location of deeply nested labware.

This enables true nested labware workflows (e.g., manifold collar → filter plate) while maintaining safety and correctness in the engine.

2. Vacuum Module Dock Support

  • Added vacuumModuleV1Dock as an addressable area on the Vacuum Module fixture.
  • Extended load_adapter() to accept AddressableAreaLocation (so it can target the dock).
  • Added two new convenient methods on VacuumModuleContext:
    • load_adapter_to_dock(load_name: str) — loads a compatible adapter (e.g., manifold collar) directly onto the private staging dock.
    • move_to_dock(labware: Labware, use_gripper: bool = True) — moves a labware/adapter to the dock using the gripper.
  • Added support for isMovableAdapter labware definitions so the collar can be moved with ctx.move_labware(..., use_gripper=True).
  • Special-cased deck conflict checking to allow the dock to be used even when the module body is occupied.

3. Vacuum Module Improvements

  • Allow creation of a simulating Vacuum Module even when running on real Flex hardware (create_simulating_module).
  • Added start_set_vacuum(), pressure/pump state getters, and proper live data handling.
  • Fixed module definition keys and OT-3 standard deck configuration for the vacuum module.

4. Demo Protocol

  • Added vacuum_module_slas_2026_demo.py as a working example of the full workflow (collar loading, gripper moves between dock and module, vacuum cycles, absorbance reading, etc.).
  • Added millipore_vacuum_manifold_collar_tall with containedSpace
  • Added millipore_vacuum_manifold_collar_standard with containedSpace
  • Added invitroven_filter_plate to work inside the collar via containedSpace

Test Plan and Hands on Testing

  • All new protocols (including the SLAS demo) simulate and run successfully on the Flex.
  • Containment validation correctly rejects oversized labware and accepts properly sized ones.
  • Gripper moves of nested adapters (collar + filter plate) work as expected.
  • Deck conflict logic and location resolution handle the dock correctly.
  • Make sure the following protocol can be simulated and runs on the flex
from opentrons.hardware_control.modules.types import VacuumModuleModel
from opentrons.protocol_api import ProtocolContext, ParameterContext, VacuumModuleContext


metadata = {
    "protocolName": "Vacuum Module SLAS 2026 Demo",
    "author": "Opentrons <protocols@opentrons.com>",
}
requirements = {
    "robotType": "Flex",
    "apiLevel": "2.28",
}


def add_parameters(parameters: ParameterContext) -> None:
    """Runtime parameters."""
    parameters.add_int(
        display_name="Cycles",
        variable_name="cycles",
        description="The number of cycles to perform.",
        default=1,
        minimum=1,
        maximum=1000,
    )


def run(ctx: ProtocolContext) -> None:
    """Protocol."""

    # Create a virtual vm if one does not exist
    vm = [
        m
        for m in ctx._hw_manager.hardware.attached_modules
        if m.serial_number == "VMA1020250119002"
    ]
    if not vm:
        ctx._hw_manager.hardware.create_simulating_module(
            VacuumModuleModel.VACUUM_MODULE_V1, "VMA1020250119002"
        )

    # Load Modules
    vm_mod: VacuumModuleContext = ctx.load_module(module_name="vacuumModuleV1", location="A3")
    abs_mod = ctx.load_module(module_name="absorbanceReaderV1", location="D3")
    abs_mod.open_lid()

    # Load Tipracks
    tiprack_1000 = ctx.load_labware(
        "opentrons_flex_96_tiprack_1000ul",
        "C1",
        adapter="opentrons_flex_96_tiprack_adapter",
    )
    tiprack_200 = ctx.load_labware(
        "opentrons_flex_96_tiprack_200ul",
        "D1",
        adapter="opentrons_flex_96_tiprack_adapter",
    )

    # Load Labware
    manifold_collar =  ctx.load_adapter('millipore_vacuum_manifold_collar_tall', vm_mod.manifold_dock)
    white_filter_plate = manifold_collar.load_labware("invitroven_filter_plate")
    black_flat_plate = ctx.load_labware(
        "corning_96_wellplate_360ul_flat", "B2", lid="opentrons_tough_universal_lid"
    )
    deep_well_plate = ctx.load_labware(
        "nest_96_wellplate_2ml_deep", "C4", lid="opentrons_tough_universal_lid"
    )
    reservoir_1 = ctx.load_labware("opentrons_tough_1_reservoir_300ml", "C2")
    reservoir_2 = ctx.load_labware("opentrons_tough_1_reservoir_300ml", "C3")
    riser = ctx.load_adapter("opentrons_flex_deck_riser", "D2")
    lid_stack = riser.load_lid_stack("opentrons_tough_universal_lid", quantity=2)

    # Load Instruments + Trash
    pip = ctx.load_instrument(
        "flex_96channel_1000", tip_racks=[tiprack_200, tiprack_1000]
    )
    trash = ctx.load_trash_bin("A1")

    # Run Time Parameters
    cycles = ctx.params.cycles  # type: ignore[attr-defined]

    # Protocol Start
    # ------------------------------------------------------
    for cycle in range(cycles):
        ctx.comment(f"Cycle #{cycle}")
        ctx.home()
        ctx.move_labware(manifold_collar, vm_mod, use_gripper=True)

        # Aspirate 500ul with 1000ul tips from reservoir2 onto filter plate
        pip.pick_up_tip(tiprack_1000)
        pip.aspirate(500, reservoir_2["A1"].bottom(z=5))
        pip.dispense(500, white_filter_plate.wells()[0].top())
        pip.return_tip()
        tiprack_1000.reset()

        # Turn on vacuum for 10s and vent after
        vm_mod.start_set_vacuum(pressure=-400, duration=10, vent_after=True)
        ctx.delay(10, msg="Start Vacuum -400 mbar for 10s")

        # Move the collar with filter plate to the dock
        ctx.move_labware(manifold_collar, vm_mod.manifold_dock, use_gripper=True)

        # Remove the lids from the flat and deep well plates
        ctx.move_lid(black_flat_plate, lid_stack, use_gripper=True)
        ctx.move_lid(deep_well_plate, lid_stack, use_gripper=True)
        # Move the deep well plate into the vacuum module
        ctx.move_labware(deep_well_plate, vm_mod, use_gripper=True)

        # Move collar with filter plate back to the vacuum module base
        ctx.move_labware(manifold_collar, vm_mod, use_gripper=True)

        # Aspirate 150ul with 200ul tips from reservoir1 onto filter plate
        pip.pick_up_tip(tiprack_200)
        pip.aspirate(150, reservoir_1["A1"].bottom(z=5))
        pip.dispense(150, white_filter_plate.wells()[0].top())
        pip.return_tip()
        tiprack_200.reset()

        # Turn on vacuum for 10s and vent after
        vm_mod.start_set_vacuum(pressure=-400, duration=10, vent_after=True)
        ctx.delay(10, msg="Start Vacuum -400 mbar for 10s")

        # Move the collar with filter plate to the dock
        ctx.move_labware(manifold_collar, "A2", use_gripper=True)

        # Aspirate 150ml from the deep well onto the flat plate
        pip.pick_up_tip(tiprack_200)
        pip.aspirate(150, deep_well_plate["A1"].bottom(z=5))
        pip.dispense(150, black_flat_plate.wells()[0].top())
        pip.return_tip()
        tiprack_200.reset()

        # Move the flat plate to the absorbance reader
        ctx.move_labware(black_flat_plate, abs_mod, use_gripper=True)
        abs_mod.close_lid()
        ctx.delay(10, msg="Taking Absorbance Reading...")
        abs_mod.open_lid()

        # Move the flat plate from the absorbance reader to B2 and add lid
        ctx.move_labware(black_flat_plate, "B2", use_gripper=True)
        ctx.move_lid(lid_stack, black_flat_plate, use_gripper=True)

        # Move the deep well plate to C4 and add lid
        ctx.move_labware(deep_well_plate, "C4", use_gripper=True)
        ctx.move_lid(lid_stack, deep_well_plate, use_gripper=True)

Changelog

  • add ability to create a simulated module even when running on an actual robot
  • allow the vacuum module dock area to be loaded onto the deck
  • add AddressableAreaLocation to protocol.load_adapter
  • add load_adapter_to_dock for the VacuumModuleContext to let us load the collar onto the staging area
  • add move_to_dock for the VacuumModuleContext to let you move a labware to the staging area
  • add ability to move adapters that have the moveableAdapter parameter in the def
  • add get_parent_from_location function to resolve a _LabwareLocation down to its root
  • add containedSpace property to labware definition to model the space inside a labware.
  • add _is_fully_contained to check if a labware is fully enclosed by another one
  • add containedSpace logic to ensure_location_not_occupied to not just check
    if a location is occupied, but to make sure we can geometrically fit plates inside
    each other to determine occupancy.
  • add VacuumModuleSubState and corresponding getters
  • add vacuum module slas 2026 demo
  • update the ot3_standard.json keys for the vacuum module to the correct values
  • add vacuumModuleMilliporeV1Dock addressable area to vacuumModuleV1 fixture
  • add tall/standard Millipore collar labware definitions
  • fix the vacuumModuleMilliporeV1 module definition to include the correct values

Review requests

  • Feedback on the vacuumModuleV1Dock addressable area, it needs to be loaded, but we do have to extend load_adapter to accept AddressableAreaLocation. Is this a good idea?
  • Thoughts on VacuumModuleContext.load_adapter_to_dock and VacuumModuleContext.move_to_dock functions for the Vacuum Module?
  • Thoughts on the containedSpace implementation so far in ensure_location_not_occupied function
  • General thoughts/concerns with implementation?

Risk assessment

Low, unreleased

TODO

  • Add unit tests

vegano1 and others added 30 commits November 16, 2025 21:11
…-api snapshots (#20547)

This PR was requested on the PR
#20501

Co-authored-by: vegano1 <12740774+vegano1@users.noreply.github.com>
…-api snapshots (#20604)

This PR was requested on the PR
#20501

Co-authored-by: vegano1 <12740774+vegano1@users.noreply.github.com>
@vegano1 vegano1 changed the title feat(api, shared-data): Add labware containment to enable vacuum module. feat(api, shared-data): Add labware containment and vacuum module dock support. Apr 9, 2026
@vegano1 vegano1 marked this pull request as ready for review April 9, 2026 15:36
@vegano1 vegano1 requested review from a team as code owners April 9, 2026 15:36
@vegano1 vegano1 requested review from mjhuff and removed request for a team April 9, 2026 15:36
@vegano1 vegano1 requested a review from ncdiehl11 April 9, 2026 17:07
Copy link
Copy Markdown
Member

@sfoster1 sfoster1 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is there any way you can split this up so there's one pr per large section?

@mjhuff
Copy link
Copy Markdown
Contributor

mjhuff commented Apr 9, 2026

Pulling edge should fix the lint issue 👍

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants