From 90c7e45092ba1ace4745f707ef2d61e39932a6af Mon Sep 17 00:00:00 2001 From: Mike Arpaia Date: Fri, 9 Oct 2026 19:27:20 -0600 Subject: [PATCH 1/2] Match thermocycler lid targets to block holds --- README.md | 2 + integrations/labop/export.py | 8 ++- src/lab/documents.py | 14 ++++- .../experiments/cloning/stages/assembly.py | 8 +-- src/lab/protocol.py | 7 ++- src/lab/targets/opentrons.py | 36 +++++++++--- src/lab/targets/star.py | 4 ++ tests/test_ot2_equivalence.py | 22 +++++++ tests/test_protocol.py | 1 + tests/test_targets.py | 57 +++++++++++++++++++ 10 files changed, 137 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 2409f4d..08a15e6 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,8 @@ The compiler rejects missing layouts, conflicting placements, invalid holder ref For Opentrons, place thermal labware on a supported `Module`. STAR layouts currently support one independent `Channel` and external thermal integration. Declare thermal containers in `DeckLayout.external_thermal_resources` and supply the async `thermocycle` callback when the generated `run()` executes. The callback is responsible for controlling the device and returning the plate to its original position. Compilation checks the declaration; the software preview only reports the request. On-deck `Module` models are not yet supported by the STAR backend. +By default, thermocycling matches the lid target to each block target within the device's heated lid range and turns lid heating off below that range. On Opentrons, the lid follows block targets at 37 °C and above; colder holds turn off the lid heater. Persistent thermocycler holds use the same rule, including setup and plating. An explicit `lid_temperature` keeps the lid at a fixed target throughout a profile. Successful profiles keep their final temperature settings; a failed Opentrons profile shuts down both controllers. External STAR thermal callbacks follow the same contract when `lid_temperature=None`. + ## Try it The examples live in the source repository. With [uv](https://docs.astral.sh/uv/) installed, set up Python 3.12 and both optional SDKs: diff --git a/integrations/labop/export.py b/integrations/labop/export.py index d253a32..1b48379 100644 --- a/integrations/labop/export.py +++ b/integrations/labop/export.py @@ -39,9 +39,11 @@ DESCRIPTIONS = { "Wait": "Wait for the duration without changing material.", "OperatorPause": "Pause for the operator instruction; no material change is modeled.", - "SetTemperature": "Set a persistent temperature; the hold continues after this action.", - "Thermocycle": "Repeat the ordered JSON profile of {celsius, seconds} holds, " - "then release temperature control.", + "SetTemperature": "Set a persistent temperature; the hold continues after this action. " + "Match a heated lid to the block within its heated range; turn lid heating off below it.", + "Thermocycle": "Repeat the ordered JSON profile of {celsius, seconds} holds. " + "When lidTemperature is omitted, match lid to block within the device's heated lid range, " + "with lid heating off below that range. Keep the final temperature settings.", "Distribute": "Transfer equal amounts to ordered destinations, reusing one tip. " "Air gap is air; a target may split aspirations within its capacity.", } diff --git a/src/lab/documents.py b/src/lab/documents.py index 8ef51ea..b4aceb1 100644 --- a/src/lab/documents.py +++ b/src/lab/documents.py @@ -33,7 +33,11 @@ def describe(step: Step) -> str: holds = "; ".join( f"{number(hold.celsius)} °C for {number(hold.seconds)} s" for hold in profile ) - lid_text = "unheated lid" if lid is None else f"lid at {number(lid)} °C" + lid_text = ( + "lid follows block; lid heating off below the device's heated lid range" + if lid is None + else f"lid at {number(lid)} °C" + ) block = ( f" Reported block volume {number(block_volume)} µL." if block_volume is not None @@ -41,11 +45,15 @@ def describe(step: Step) -> str: ) return ( f"Thermocycle all of {resource}: {holds}; {cycles} cycle(s); {lid_text}. " - "Wait for completion, turn off temperature control, and release the plate." + "Wait for completion, keep the final temperature settings, and release the plate." f"{block}" ) case SetTemperature(resource, celsius, _): - return f"Hold {resource} at {number(celsius)} °C." + return ( + f"Hold {resource} at {number(celsius)} °C. " + "For a device with a heated lid, match its target within the heated lid range; " + "turn lid heating off below that range." + ) case ManualInstruction(text, _): return f"Operator: {text}" case _: diff --git a/src/lab/experiments/cloning/stages/assembly.py b/src/lab/experiments/cloning/stages/assembly.py index 1b3e168..d38d720 100644 --- a/src/lab/experiments/cloning/stages/assembly.py +++ b/src/lab/experiments/cloning/stages/assembly.py @@ -128,12 +128,8 @@ def record_assembly( protocol.mix(destination, volume=min(layout.total, _PIPETTE_MAX) * uL, cycles=cycles) profile = [(celsius(42), 2 * minutes), (celsius(16), 5 * minutes)] inactivation = [(celsius(60), 10 * minutes), (celsius(80), 10 * minutes)] - protocol.thermocycle( - products, profile, cycles=75, lid_temperature=celsius(42), block_volume=30 * uL - ) - protocol.thermocycle( - products, inactivation, cycles=1, lid_temperature=celsius(42), block_volume=30 * uL - ) + protocol.thermocycle(products, profile, cycles=75, block_volume=30 * uL) + protocol.thermocycle(products, inactivation, block_volume=30 * uL) protocol.set_temperature(products, celsius(4)) return { key: tuple(well_at(products, index) for index in indexes) diff --git a/src/lab/protocol.py b/src/lab/protocol.py index 6ba2fe2..b0a93b7 100644 --- a/src/lab/protocol.py +++ b/src/lab/protocol.py @@ -236,6 +236,11 @@ def thermocycle( ) -> None: """Apply (temperature, duration) holds to a whole plate, then release it. + Without ``lid_temperature``, the lid follows each block temperature within + the device's heated lid range; below that range, lid heating is off. A supplied + lid temperature stays fixed throughout the profile. Temperature control remains + at the final hold after the plate is released. + ``block_volume`` overrides the block's reported liquid volume for instruments that schedule ramps from that number. The ledger still uses the modeled volumes. """ @@ -256,7 +261,7 @@ def thermocycle( self._steps.append(Thermocycle(plate.name, holds, cycles, lid, _origin(), block)) def set_temperature(self, plate: Plate, value: Any) -> None: - """Hold a plate at one temperature without ending the hold when the call returns.""" + """Hold a plate at one temperature; match its lid within the device's heated range.""" if not isinstance(plate, Plate) or plate._owner is not self._owner: raise ValueError("A plate must belong to this protocol") if plate.name not in self._resources or plate.shape != ( diff --git a/src/lab/targets/opentrons.py b/src/lab/targets/opentrons.py index 2f9291e..dcfeb17 100644 --- a/src/lab/targets/opentrons.py +++ b/src/lab/targets/opentrons.py @@ -302,6 +302,12 @@ def select_pipette(volume: Decimal) -> _Pipette: if not 4 <= step.celsius <= upper: raise step_error(index, step, f"Module temperature range is 4–{upper} °C") setter = "set_block_temperature" if module == "thermocycler" else "set_temperature" + if module == "thermocycler": + commands.append( + "thermocycler.deactivate_lid()" + if step.celsius < 37 + else f"thermocycler.set_lid_temperature({number(step.celsius)})" + ) commands.append(f"{module}.{setter}({number(step.celsius)})") elif isinstance(step, Thermocycle): if self.thermocycler is None or self.labware[step.resource].slot != "thermocycler": @@ -328,20 +334,32 @@ def select_pipette(volume: Decimal) -> _Pipette: f"'hold_time_seconds': {number(hold.seconds)}}}" for hold in step.profile ) - lid = ( - "thermocycler.deactivate_lid()" - if step.lid_celsius is None - else f"thermocycler.set_lid_temperature({number(step.lid_celsius)})" - ) + thermal_commands: tuple[str, ...] + if step.lid_celsius is None: + thermal_commands = ( + f"for _cycle in range({step.cycles}):", + f" for _hold in [{profile}]:", + " if _hold['temperature'] < 37:", + " thermocycler.deactivate_lid()", + " else:", + " thermocycler.set_lid_temperature(_hold['temperature'])", + " thermocycler.execute_profile([_hold], repetitions=1, " + f"block_max_volume={number(maximum_volume)})", + ) + else: + thermal_commands = ( + f"thermocycler.set_lid_temperature({number(step.lid_celsius)})", + f"thermocycler.execute_profile([{profile}], repetitions={step.cycles}, " + f"block_max_volume={number(maximum_volume)})", + ) commands.extend( ( "thermocycler.close_lid()", "try:", - f" {lid}", - f" thermocycler.execute_profile([{profile}], repetitions={step.cycles}, " - f"block_max_volume={number(maximum_volume)})", - "finally:", + *(f" {command}" for command in thermal_commands), + "except BaseException:", " thermocycler.deactivate()", + " raise", "thermocycler.open_lid()", ) ) diff --git a/src/lab/targets/star.py b/src/lab/targets/star.py index 44c7b46..bc965e8 100644 --- a/src/lab/targets/star.py +++ b/src/lab/targets/star.py @@ -286,6 +286,10 @@ def on_deck(resource: Any) -> None: f"DECK_JSON = {serialized_deck!r}\n" f"SDK_VERSION = {version('pylabrobot')!r}\n\n" "async def run(backend, *, confirm=None, thermocycle=None, sleep=asyncio.sleep):\n" + ' """The thermal callback matches lid to block when lid_temperature is None;\n' + " Turn lid heating off below the device's heated lid range;\n" + " keep the final temperature settings.\n" + ' """\n' " if version('pylabrobot') != SDK_VERSION:\n" " raise RuntimeError(f'This artifact requires pylabrobot=={SDK_VERSION}')\n" ) diff --git a/tests/test_ot2_equivalence.py b/tests/test_ot2_equivalence.py index 0a36f9a..256a322 100644 --- a/tests/test_ot2_equivalence.py +++ b/tests/test_ot2_equivalence.py @@ -100,6 +100,17 @@ def _profiles(log: Sequence[Mapping]) -> list[tuple[tuple[tuple[float, float], . return found +def _lid_settings(log: Sequence[Mapping]) -> list[float | None]: + found: list[float | None] = [] + for event in log: + text = str(event.get("payload", {}).get("text", "")) + if text.startswith("Setting Thermocycler lid temperature to "): + found.append(float(text.split(" to ", 1)[1].split(" ", 1)[0])) + elif text == "Deactivating Thermocycler lid heating": + found.append(None) + return found + + def _handoff(log: Sequence[Mapping]) -> dict: for event in log: text = str(event.get("payload", {}).get("text", "")) @@ -192,6 +203,7 @@ def run(protocol: protocol_api.ProtocolContext): pudu = _log(source, tmp_path) assert _transfers(ours) == _transfers(pudu) assert _profiles(ours) == _profiles(pudu) + assert _lid_settings(ours) == _lid_settings(pudu) _assert_product_locations( protocol, {uri.removesuffix("/1"): wells for uri, wells in _handoff(pudu).items()} ) @@ -243,6 +255,7 @@ def run(protocol: protocol_api.ProtocolContext): pudu = _log(source, tmp_path) assert _transfers(ours) == _transfers(pudu) assert _profiles(ours) == _profiles(pudu) + assert _lid_settings(ours) == _lid_settings(pudu) assert {key: [well.name for well in wells] for key, wells in outputs.items()} == _handoff(pudu) @@ -280,12 +293,16 @@ def run(protocol: protocol_api.ProtocolContext): engine = HeatShockTransformation( transformation_data=strains, plasmid_locations=locations, replicates=2 ) + # Exercise thermal control in the simulator instead of PUDU's water-testing shortcut. + protocol.is_simulating = lambda: False engine.run(protocol) protocol.comment('HANDOFF ' + json.dumps(engine.dict_of_parts_in_thermocycler)) """ ) pudu = _log(source, tmp_path) assert _transfers(ours) == _transfers(pudu) + assert _profiles(ours) == _profiles(pudu) + assert _lid_settings(ours) == _lid_settings(pudu) outputs = protocol.snapshot().output_manifest() locations = {placement.sample_id: placement.location for placement in outputs.placements} assert { @@ -329,12 +346,16 @@ def run(protocol: protocol_api.ProtocolContext): tube_volume_competent_cell={int(magnitude(CELL_ALIQUOT, "microliter"))}, tiprack_p200_labware={tiprack!r}, ) + # Exercise thermal control in the simulator instead of PUDU's water-testing shortcut. + protocol.is_simulating = lambda: False engine.run(protocol) protocol.comment('HANDOFF ' + json.dumps(engine.dict_of_parts_in_thermocycler)) """ ) pudu = _log(source, tmp_path) assert _transfers(ours) == _transfers(pudu) + assert _profiles(ours) == _profiles(pudu) + assert _lid_settings(ours) == _lid_settings(pudu) outputs = protocol.snapshot().output_manifest() locations = {placement.sample_id: placement.location for placement in outputs.placements} assert { @@ -374,3 +395,4 @@ def run(protocol: protocol_api.ProtocolContext): ) pudu = _log(source, tmp_path) assert _transfers(ours) == _transfers(pudu) + assert _lid_settings(ours) == _lid_settings(pudu) == [None] diff --git a/tests/test_protocol.py b/tests/test_protocol.py index 4b54e37..ac0e258 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -214,6 +214,7 @@ def test_stage_handoffs_share_wells_and_preserve_volume(): {"celsius": "30", "seconds": "1"}, ] assert "Thermocycle all of aliquots" in bundle.files["protocol.html"] + assert "lid follows block" in bundle.files["protocol.html"] def test_thermal_units_and_snapshot(): diff --git a/tests/test_targets.py b/tests/test_targets.py index 0e33f2d..4a16271 100644 --- a/tests/test_targets.py +++ b/tests/test_targets.py @@ -554,6 +554,63 @@ def test_thermal_binding_and_limits(): compiled(p, target("ot2", thermal=True)) +@pytest.mark.integration +@requires_opentrons +@pytest.mark.parametrize("name", ["ot2", "flex"]) +def test_opentrons_lid_follows_each_hold_and_persistent_temperature(name, monkeypatch): + lid_settings = [] + profiles = [] + shutdowns = [] + original_lid = ThermocyclerContext.set_lid_temperature + original_off = ThermocyclerContext.deactivate_lid + original_profile = ThermocyclerContext.execute_profile + original_shutdown = ThermocyclerContext.deactivate + + def set_lid(self, temperature): + lid_settings.append(temperature) + return original_lid(self, temperature) + + def lid_off(self): + lid_settings.append(None) + return original_off(self) + + def execute_profile(self, steps, repetitions, block_max_volume): + profiles.append((steps, repetitions, block_max_volume, lid_settings[-1])) + return original_profile(self, steps, repetitions, block_max_volume) + + def shutdown(self): + shutdowns.append(True) + return original_shutdown(self) + + monkeypatch.setattr(ThermocyclerContext, "set_lid_temperature", set_lid) + monkeypatch.setattr(ThermocyclerContext, "deactivate_lid", lid_off) + monkeypatch.setattr(ThermocyclerContext, "execute_profile", execute_profile) + monkeypatch.setattr(ThermocyclerContext, "deactivate", shutdown) + p = Protocol("Lid tracking") + p.container("water", capacity=300 * uL) + plate = p.plate("aliquots", capacity=100 * uL) + p.load(plate["A1"], "water", volume=50 * uL) + p.set_temperature(plate, celsius(4)) + p.thermocycle(plate, [(celsius(42), 1 * seconds), (celsius(16), 2 * seconds)], cycles=2) + p.thermocycle(plate, [(celsius(60), 3 * seconds), (celsius(80), 4 * seconds)]) + p.set_temperature(plate, celsius(37)) + bundle = compiled(p, target(name, thermal=True)) + simulate(StringIO(bundle.files["protocol.py"])) + assert lid_settings == [None, 42, None, 42, None, 60, 80, 37] + assert profiles == [ + ([{"temperature": temperature, "hold_time_seconds": duration}], 1, 50, lid) + for temperature, duration, lid in ( + (42, 1, 42), + (16, 2, None), + (42, 1, 42), + (16, 2, None), + (60, 3, 60), + (80, 4, 80), + ) + ] + assert not shutdowns + + @pytest.mark.integration @requires_opentrons def test_opentrons_deactivates_after_thermal_failure(monkeypatch): From 732b0d1792ac11f82e8b71299f37b62d09cdaffa Mon Sep 17 00:00:00 2001 From: Mike Arpaia Date: Mon, 12 Oct 2026 14:47:24 -0600 Subject: [PATCH 2/2] Run pinned PUDU equivalence checks in CI --- .github/workflows/ci.yml | 26 +++++++++++++++++++++++++- README.md | 6 ++++++ tests/test_ot2_equivalence.py | 11 ++++++++++- 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b803f45..36f16aa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,6 +39,30 @@ jobs: - name: Run tests run: uv run --no-sync pytest -q + pudu-equivalence: + name: PUDU OT-2 equivalence + runs-on: ubuntu-latest + env: + PUDU_SOURCE: ${{ github.workspace }}/build/pudu-reference/src + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + - name: Check out the PUDU reference + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + repository: MyersResearchGroup/PUDU + ref: dc61f82e4f5c8f9e2ebfa0eae8ea75ef2c7f5cdf # Merged lid-control fix (#22) + path: build/pudu-reference + persist-credentials: false + - uses: astral-sh/setup-uv@94527f2e458b27549849d47d273a16bec83a01e9 # v7 + with: + python-version: "3.12" + - name: Install Opentrons and test dependencies + run: uv sync --locked --extra opentrons + - name: Compare assembly, transformation, and plating with PUDU + run: uv run --no-sync pytest -q tests/test_ot2_equivalence.py + labop: name: Optional upstream LabOP integration runs-on: ubuntu-latest @@ -64,7 +88,7 @@ jobs: build: name: Build and check distributions - needs: test + needs: [test, pudu-equivalence] runs-on: ubuntu-latest steps: - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 diff --git a/README.md b/README.md index 08a15e6..0d1bc28 100644 --- a/README.md +++ b/README.md @@ -178,4 +178,10 @@ uv run --no-sync mypy uv run --no-sync pytest ``` +CI runs the OT-2 equivalence tests against a pinned PUDU revision. To compare against another checkout, set `PUDU_SOURCE` to its `src` directory. When this variable is set, missing PUDU source or the Opentrons SDK fails the run. + +```sh +PUDU_SOURCE=/path/to/PUDU/src uv run --no-sync pytest -q tests/test_ot2_equivalence.py +``` + See the [release guide](https://github.com/MyersResearchGroup/LabCompiler/blob/master/docs/releasing.md) for package validation and PyPI publishing, and the [release notes](https://github.com/MyersResearchGroup/LabCompiler/blob/master/CHANGELOG.md) for changes and migration guidance. diff --git a/tests/test_ot2_equivalence.py b/tests/test_ot2_equivalence.py index 256a322..c8a37b4 100644 --- a/tests/test_ot2_equivalence.py +++ b/tests/test_ot2_equivalence.py @@ -45,7 +45,16 @@ except ImportError: simulate = None -PUDU = Path.home() / "git/MyersResearchGroup/PUDU/src" +PUDU = ( + Path(os.environ.get("PUDU_SOURCE", Path.home() / "git/MyersResearchGroup/PUDU/src")) + .expanduser() + .resolve() +) +if "PUDU_SOURCE" in os.environ: + if simulate is None: + raise pytest.UsageError("PUDU equivalence requires the Opentrons SDK; install .[opentrons]") + if not (PUDU / "pudu" / "__init__.py").is_file(): + raise pytest.UsageError(f"PUDU_SOURCE does not contain the pudu package: {PUDU}") requires_robots = pytest.mark.skipif( simulate is None or not PUDU.is_dir(), reason="Opentrons SDK or PUDU checkout missing",