Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,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:
Expand Down Expand Up @@ -195,4 +197,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.
8 changes: 5 additions & 3 deletions integrations/labop/export.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
}
Expand Down
14 changes: 11 additions & 3 deletions src/lab/documents.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,19 +33,27 @@ 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
else ""
)
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 _:
Expand Down
8 changes: 2 additions & 6 deletions src/lab/experiments/cloning/stages/assembly.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
7 changes: 6 additions & 1 deletion src/lab/protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""
Expand All @@ -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 != (
Expand Down
36 changes: 27 additions & 9 deletions src/lab/targets/opentrons.py
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand All @@ -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()",
)
)
Expand Down
4 changes: 4 additions & 0 deletions src/lab/targets/star.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down
33 changes: 32 additions & 1 deletion tests/test_ot2_equivalence.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -100,6 +109,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", ""))
Expand Down Expand Up @@ -192,6 +212,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()}
)
Expand Down Expand Up @@ -243,6 +264,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)


Expand Down Expand Up @@ -280,12 +302,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 {
Expand Down Expand Up @@ -329,12 +355,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 {
Expand Down Expand Up @@ -374,3 +404,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]
1 change: 1 addition & 0 deletions tests/test_protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
57 changes: 57 additions & 0 deletions tests/test_targets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Loading