From 3506f54966169dc12eca2b3f4e502f27f5ea6633 Mon Sep 17 00:00:00 2001 From: Cong <72737794+robolearning123@users.noreply.github.com> Date: Mon, 23 Mar 2026 16:38:30 -0400 Subject: [PATCH 01/26] feat(lab-robot): scaffold repo with pyproject, Makefile, README, CLAUDE.md Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 37 +++++++ LICENSE | 201 ++++++++++++++++++++++++++++++++++++++ Makefile | 15 +++ README.md | 37 +++++++ pyproject.toml | 72 ++++++++++++++ robots/__init__.py | 0 src/lab_robot/__init__.py | 5 + tests/__init__.py | 0 8 files changed, 367 insertions(+) create mode 100644 CLAUDE.md create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 pyproject.toml create mode 100644 robots/__init__.py create mode 100644 src/lab_robot/__init__.py create mode 100644 tests/__init__.py diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..6c94324 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,37 @@ +# CLAUDE.md — lab-robot + +## Project Overview + +Physical Execution Interface (PEI) for science labs. Connects LabClaw's AI brain +to physical robot hardware. Phase 0: Opentrons OT-2 driver (simulate mode). + +**Tech stack:** Python 3.11+, Pydantic 2.x, opentrons SDK, hatchling +**Ecosystem role:** LabClaw Layer 1 — physical execution (alongside device-use for GUI) + +## Build & Test + +pip install -e ".[dev,opentrons]" +make test +make lint + +## Architecture + +- `src/lab_robot/` — core library (PEI protocol, types, safety) +- `robots/` — per-robot driver packages (like device-skills' devices/) +- RobotDriver Protocol: connect, disconnect, execute, stop, capabilities +- Actions return rich ActionResult (not just bool) +- Safety level defaults to CRITICAL for all robots + +## Code Style + +Same as device-skills: ruff (E,F,I,N,W,UP), line-length 100, type hints required, +`from __future__ import annotations` in every module, Pydantic for all schemas. +100% test coverage enforced. + +## Key Abstractions + +- `RobotDriver` — async Protocol for robot hardware (execute actions, not read data) +- `RobotManifest` — extends SkillManifest with robot-specific fields +- `ActionResult` — rich result with success, state, measurements, error detail +- `RobotSafetyGuard` — chain-of-responsibility safety (force, workspace, collision) +- PEI primitives: Layer 0 (motion), Layer 1 (lab-ops), Layer 2 (perception), Layer 3 (system) diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7595e09 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to the Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by the Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding any notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. Please also get an + "Alarm or alarm" of the file or class name and a brief idea of + the purpose of the class included in the copyright comment. + + Copyright 2026 LabClaw Team + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..bb2ae4c --- /dev/null +++ b/Makefile @@ -0,0 +1,15 @@ +.PHONY: dev-install test lint format + +dev-install: + pip install -e ".[dev,opentrons]" + +test: + pytest --cov=lab_robot --cov=robots --cov-report=term-missing --cov-fail-under=100 -q + +lint: + ruff check . + mypy src/lab_robot/ + +format: + ruff check --fix . + ruff format . diff --git a/README.md b/README.md new file mode 100644 index 0000000..4203c7f --- /dev/null +++ b/README.md @@ -0,0 +1,37 @@ +# lab-robot + +Physical Execution Interface for science labs — connect AI brains to robot bodies. + +Part of the [LabClaw](https://github.com/labclaw/labclaw) ecosystem. + +## Install + +```bash +pip install lab-robot # core only +pip install lab-robot[opentrons] # + Opentrons OT-2 driver +``` + +## Quick Start + +```python +from lab_robot.types import PipetteAction +from robots.opentrons_ot2.driver import OT2Driver + +driver = OT2Driver(simulate=True) +await driver.connect() +result = await driver.execute(PipetteAction( + volume_ul=30.0, + source_well="A1", + dest_well="B1", +)) +print(result) # ActionResult(success=True, ...) +await driver.disconnect() +``` + +## Architecture + +See [PEI Specification](docs/pei-spec.md) for the full protocol design. + +## License + +Apache 2.0 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..6657bee --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,72 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "lab-robot" +version = "0.1.0" +description = "Physical Execution Interface for science labs — connect AI brains to robot bodies" +readme = "README.md" +license = "Apache-2.0" +requires-python = ">=3.11" +authors = [{ name = "LabClaw Team" }] +keywords = ["lab-automation", "robotics", "physical-ai", "science"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Science/Research", + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", +] +dependencies = [ + "pydantic>=2.0.0,<3.0.0", + "pyyaml>=6.0,<7.0", +] + +[project.optional-dependencies] +opentrons = ["opentrons>=8.0"] +dev = [ + "pytest>=8.0,<10.0", + "pytest-asyncio>=0.24,<2.0", + "pytest-cov>=6.0,<8.0", + "ruff>=0.6.0,<1.0.0", + "mypy>=1.11,<2.0", +] + +[project.urls] +Repository = "https://github.com/labclaw/lab-robot" + +[tool.hatch.build.targets.wheel] +packages = ["src/lab_robot", "robots"] + +[tool.ruff] +target-version = "py311" +line-length = 100 + +[tool.ruff.lint] +select = ["E", "F", "I", "N", "W", "UP"] + +[tool.ruff.lint.per-file-ignores] +"robots/*/__init__.py" = ["N999"] +"robots/*/tests/__init__.py" = ["N999"] + +[tool.pytest.ini_options] +testpaths = ["tests", "robots"] +asyncio_mode = "auto" + +[tool.mypy] +python_version = "3.11" +warn_return_any = true +check_untyped_defs = true + +[tool.coverage.run] +source = ["lab_robot", "robots"] +omit = ["*/tests/*", "*/_robot_template/*"] + +[tool.commitizen] +name = "cz_conventional_commits" +tag_format = "v$version" + +[tool.coverage.report] +fail_under = 100 diff --git a/robots/__init__.py b/robots/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/lab_robot/__init__.py b/src/lab_robot/__init__.py new file mode 100644 index 0000000..4515232 --- /dev/null +++ b/src/lab_robot/__init__.py @@ -0,0 +1,5 @@ +"""lab-robot — Physical Execution Interface for science labs.""" + +from __future__ import annotations + +__version__ = "0.1.0" diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 From b4e87397e811779d6ed7ee672738e432cb6fb678 Mon Sep 17 00:00:00 2001 From: Cong <72737794+robolearning123@users.noreply.github.com> Date: Mon, 23 Mar 2026 16:41:21 -0400 Subject: [PATCH 02/26] =?UTF-8?q?feat(lab-robot):=20PEI=20type=20system=20?= =?UTF-8?q?=E2=80=94=20actions,=20results,=20state?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- src/lab_robot/types.py | 143 +++++++++++++++++++++++++++++++++++++++++ tests/test_types.py | 91 ++++++++++++++++++++++++++ 2 files changed, 234 insertions(+) create mode 100644 src/lab_robot/types.py create mode 100644 tests/test_types.py diff --git a/src/lab_robot/types.py b/src/lab_robot/types.py new file mode 100644 index 0000000..eaae916 --- /dev/null +++ b/src/lab_robot/types.py @@ -0,0 +1,143 @@ +"""PEI type definitions — actions, results, and robot state. + +PEI primitives are layered: + Layer 0 — Motion: move_to, grip, release, home + Layer 1 — Lab Ops: pipette, transfer_labware, dispense + Layer 2 — Perception: detect_labware, verify_state, calibrate + Layer 3 — System: recover, emergency_stop, wait_condition +""" + +from __future__ import annotations + +from enum import StrEnum +from typing import Any + +from pydantic import BaseModel, Field, model_validator + + +class ActionType(StrEnum): + """PEI action types across all layers.""" + + # Layer 0 — Motion + MOVE = "move" + GRIP = "grip" + RELEASE = "release" + HOME = "home" + # Layer 1 — Lab Ops + PIPETTE = "pipette" + TRANSFER_LABWARE = "transfer_labware" + DISPENSE = "dispense" + # Layer 2 — Perception + DETECT_LABWARE = "detect_labware" + VERIFY_STATE = "verify_state" + CALIBRATE = "calibrate" + # Layer 3 — System + RECOVER = "recover" + EMERGENCY_STOP = "emergency_stop" + WAIT_CONDITION = "wait_condition" + + +class ActionStatus(StrEnum): + """Execution status of a robot action.""" + + PENDING = "pending" + EXECUTING = "executing" + COMPLETED = "completed" + FAILED = "failed" + CANCELLED = "cancelled" + + +# ── Base Action ────────────────────────────────────────────────────────────── + + +class RobotAction(BaseModel): + """Base class for all PEI actions.""" + + action_type: ActionType + + model_config = {"frozen": True} + + +# ── Layer 0: Motion Primitives ─────────────────────────────────────────────── + + +class MoveAction(RobotAction): + """Move to a target position in robot coordinate space.""" + + action_type: ActionType = ActionType.MOVE + target_position: dict[str, float] = Field(..., description="Target pose {x, y, z} in mm") + speed_mm_s: float = Field(default=50.0, gt=0, description="Movement speed") + + +# ── Layer 1: Lab Operations ────────────────────────────────────────────────── + +_MAX_PIPETTE_VOLUME_UL = 1000.0 + + +class PipetteAction(RobotAction): + """Aspirate from source well and dispense to destination well.""" + + action_type: ActionType = ActionType.PIPETTE + volume_ul: float = Field(..., gt=0, description="Volume in microliters") + source_well: str = Field(..., min_length=1, description="Source well ID (e.g. A1)") + dest_well: str = Field(..., min_length=1, description="Destination well ID") + source_labware: str = Field(default="source_plate", description="Source labware name") + dest_labware: str = Field(default="dest_plate", description="Destination labware name") + liquid_class: str = Field(default="default", description="Liquid handling profile") + new_tip: bool = Field(default=True, description="Pick up a new tip before transfer") + + @model_validator(mode="after") + def validate_volume(self) -> PipetteAction: + if self.volume_ul > _MAX_PIPETTE_VOLUME_UL: + msg = f"volume_ul={self.volume_ul} exceeds maximum {_MAX_PIPETTE_VOLUME_UL}" + raise ValueError(msg) + return self + + +class TransferLabwareAction(RobotAction): + """Transfer labware between slots on the deck.""" + + action_type: ActionType = ActionType.TRANSFER_LABWARE + labware_id: str = Field(..., description="Labware identifier") + from_slot: str = Field(..., description="Source deck slot") + to_slot: str = Field(..., description="Destination deck slot") + + +# ── Action Result ──────────────────────────────────────────────────────────── + + +class ActionResult(BaseModel): + """Rich result from executing a robot action.""" + + success: bool + status: ActionStatus + action_type: ActionType + measurements: dict[str, Any] = Field( + default_factory=dict, description="Measured values (e.g. volume_dispensed_ul)" + ) + state_after: dict[str, Any] = Field( + default_factory=dict, description="Robot state after action" + ) + error: str = Field(default="", description="Error message if failed") + duration_s: float = Field(default=0.0, ge=0, description="Action duration in seconds") + + @model_validator(mode="after") + def require_error_on_failure(self) -> ActionResult: + if not self.success and not self.error: + msg = "error is required when success=False" + raise ValueError(msg) + return self + + +# ── Robot State ────────────────────────────────────────────────────────────── + + +class RobotState(BaseModel): + """Current state of the robot.""" + + connected: bool = False + homed: bool = False + current_position: dict[str, float] = Field(default_factory=dict) + tip_attached: bool = False + current_volume_ul: float = 0.0 + errors: list[str] = Field(default_factory=list) diff --git a/tests/test_types.py b/tests/test_types.py new file mode 100644 index 0000000..8d1f91a --- /dev/null +++ b/tests/test_types.py @@ -0,0 +1,91 @@ +"""Tests for PEI type definitions.""" + +from __future__ import annotations + +from lab_robot.types import ( + ActionResult, + ActionStatus, + ActionType, + MoveAction, + PipetteAction, + RobotState, + TransferLabwareAction, +) + + +class TestActionTypes: + def test_pipette_action_valid(self) -> None: + action = PipetteAction( + volume_ul=30.0, + source_well="A1", + dest_well="B1", + ) + assert action.action_type == ActionType.PIPETTE + assert action.volume_ul == 30.0 + + def test_pipette_action_rejects_negative_volume(self) -> None: + import pytest + + with pytest.raises(ValueError, match="greater than 0"): + PipetteAction(volume_ul=-5.0, source_well="A1", dest_well="B1") + + def test_pipette_action_rejects_excessive_volume(self) -> None: + import pytest + + with pytest.raises(ValueError, match="exceeds maximum"): + PipetteAction(volume_ul=2000.0, source_well="A1", dest_well="B1") + + def test_move_action(self) -> None: + action = MoveAction(target_position={"x": 100.0, "y": 200.0, "z": 50.0}) + assert action.action_type == ActionType.MOVE + + def test_transfer_labware_action(self) -> None: + action = TransferLabwareAction( + labware_id="plate_96_A", + from_slot="1", + to_slot="3", + ) + assert action.action_type == ActionType.TRANSFER_LABWARE + + +class TestActionResult: + def test_success_result(self) -> None: + result = ActionResult( + success=True, + status=ActionStatus.COMPLETED, + action_type=ActionType.PIPETTE, + measurements={"volume_dispensed_ul": 30.0}, + ) + assert result.success + assert result.status == ActionStatus.COMPLETED + + def test_failure_result_with_error(self) -> None: + result = ActionResult( + success=False, + status=ActionStatus.FAILED, + action_type=ActionType.PIPETTE, + error="No tips available in rack", + ) + assert not result.success + assert "tips" in result.error + + def test_result_requires_error_on_failure(self) -> None: + import pytest + + with pytest.raises(ValueError, match="error.*required"): + ActionResult( + success=False, + status=ActionStatus.FAILED, + action_type=ActionType.PIPETTE, + ) + + +class TestRobotState: + def test_robot_state(self) -> None: + state = RobotState( + connected=True, + homed=True, + current_position={"x": 0.0, "y": 0.0, "z": 0.0}, + ) + assert state.connected + assert state.homed From 58b8c2d92e9e23f905bfc6f2b8a048f75a9601c2 Mon Sep 17 00:00:00 2001 From: Cong <72737794+robolearning123@users.noreply.github.com> Date: Mon, 23 Mar 2026 16:42:12 -0400 Subject: [PATCH 03/26] feat(lab-robot): RobotDriver protocol + RobotExecutor Co-Authored-By: Claude Opus 4.6 (1M context) --- src/lab_robot/base.py | 70 ++++++++++++++++++++++++++++ tests/test_base.py | 104 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 174 insertions(+) create mode 100644 src/lab_robot/base.py create mode 100644 tests/test_base.py diff --git a/src/lab_robot/base.py b/src/lab_robot/base.py new file mode 100644 index 0000000..4d695a1 --- /dev/null +++ b/src/lab_robot/base.py @@ -0,0 +1,70 @@ +"""RobotDriver protocol and executor. + +RobotDriver is the core async interface for all robot hardware. +Unlike device-skills' BaseDriver (read-oriented), RobotDriver is +action-oriented: execute() returns rich ActionResult, not just bool. +""" + +from __future__ import annotations + +from typing import Any, Protocol, runtime_checkable + +from lab_robot.types import ActionResult, ActionStatus, ActionType, RobotAction, RobotState + + +@runtime_checkable +class RobotDriver(Protocol): + """Async protocol for robot hardware drivers. + + Every robot driver must implement this interface. The key difference + from device-skills' BaseDriver: execute() returns ActionResult with + measurements and state, not just bool. + """ + + async def connect(self, config: dict[str, Any] | None = None) -> bool: + """Connect to the robot. Returns True on success.""" + ... + + async def disconnect(self) -> None: + """Disconnect from the robot.""" + ... + + async def execute(self, action: RobotAction) -> ActionResult: + """Execute a physical action. Returns rich result with state.""" + ... + + async def stop(self) -> None: + """Emergency stop — must be fast, no cleanup.""" + ... + + async def get_state(self) -> RobotState: + """Get current robot state (position, tip, errors).""" + ... + + def capabilities(self) -> list[ActionType]: + """Return list of action types this robot supports.""" + ... + + +class RobotExecutor: + """Validates capabilities then delegates to driver. + + Sits between the caller and the driver to enforce that only + supported actions reach the hardware. + """ + + def __init__(self, driver: RobotDriver) -> None: + self._driver = driver + + async def execute(self, action: RobotAction) -> ActionResult: + """Check capability, then delegate to driver.""" + supported = self._driver.capabilities() + if action.action_type not in supported: + return ActionResult( + success=False, + status=ActionStatus.FAILED, + action_type=action.action_type, + error=f"Action {action.action_type} not supported. " + f"Supported: {[s.value for s in supported]}", + ) + return await self._driver.execute(action) diff --git a/tests/test_base.py b/tests/test_base.py new file mode 100644 index 0000000..40a5fb1 --- /dev/null +++ b/tests/test_base.py @@ -0,0 +1,104 @@ +"""Tests for RobotDriver protocol and RobotExecutor.""" + +from __future__ import annotations + +from typing import Any + +from lab_robot.base import RobotDriver, RobotExecutor + +from lab_robot.types import ( + ActionResult, + ActionStatus, + ActionType, + PipetteAction, + RobotAction, + RobotState, +) + + +class FakeDriver: + """Minimal RobotDriver implementation for testing.""" + + def __init__(self, *, fail: bool = False) -> None: + self._connected = False + self._fail = fail + + async def connect(self, config: dict[str, Any] | None = None) -> bool: + self._connected = True + return True + + async def disconnect(self) -> None: + self._connected = False + + async def execute(self, action: RobotAction) -> ActionResult: + if self._fail: + return ActionResult( + success=False, + status=ActionStatus.FAILED, + action_type=action.action_type, + error="Simulated failure", + ) + return ActionResult( + success=True, + status=ActionStatus.COMPLETED, + action_type=action.action_type, + measurements={"volume_dispensed_ul": 30.0}, + ) + + async def stop(self) -> None: + self._connected = False + + async def get_state(self) -> RobotState: + return RobotState(connected=self._connected) + + def capabilities(self) -> list[ActionType]: + return [ActionType.PIPETTE] + + +class TestRobotDriverProtocol: + def test_fake_driver_satisfies_protocol(self) -> None: + driver = FakeDriver() + assert isinstance(driver, RobotDriver) + + async def test_connect_and_execute(self) -> None: + driver = FakeDriver() + await driver.connect() + action = PipetteAction(volume_ul=30.0, source_well="A1", dest_well="B1") + result = await driver.execute(action) + assert result.success + + async def test_stop(self) -> None: + driver = FakeDriver() + await driver.connect() + await driver.stop() + state = await driver.get_state() + assert not state.connected + + +class TestRobotExecutor: + async def test_execute_success(self) -> None: + driver = FakeDriver() + executor = RobotExecutor(driver=driver) + await driver.connect() + action = PipetteAction(volume_ul=30.0, source_well="A1", dest_well="B1") + result = await executor.execute(action) + assert result.success + + async def test_execute_rejects_unsupported_action(self) -> None: + driver = FakeDriver() + executor = RobotExecutor(driver=driver) + await driver.connect() + from lab_robot.types import MoveAction + + action = MoveAction(target_position={"x": 0, "y": 0, "z": 0}) + result = await executor.execute(action) + assert not result.success + assert "not supported" in result.error + + async def test_execute_propagates_driver_failure(self) -> None: + driver = FakeDriver(fail=True) + executor = RobotExecutor(driver=driver) + await driver.connect() + action = PipetteAction(volume_ul=30.0, source_well="A1", dest_well="B1") + result = await executor.execute(action) + assert not result.success From 4b7bff135d93098432f719dede4fbc5da5b451bb Mon Sep 17 00:00:00 2001 From: Cong <72737794+robolearning123@users.noreply.github.com> Date: Mon, 23 Mar 2026 16:43:04 -0400 Subject: [PATCH 04/26] feat(lab-robot): RobotManifest schema + safety types Co-Authored-By: Claude Opus 4.6 (1M context) --- src/lab_robot/safety.py | 57 ++++++++++++++++++++++++++++++++++ src/lab_robot/schema.py | 69 +++++++++++++++++++++++++++++++++++++++++ tests/test_safety.py | 37 ++++++++++++++++++++++ tests/test_schema.py | 53 +++++++++++++++++++++++++++++++ 4 files changed, 216 insertions(+) create mode 100644 src/lab_robot/safety.py create mode 100644 src/lab_robot/schema.py create mode 100644 tests/test_safety.py create mode 100644 tests/test_schema.py diff --git a/src/lab_robot/safety.py b/src/lab_robot/safety.py new file mode 100644 index 0000000..880e73f --- /dev/null +++ b/src/lab_robot/safety.py @@ -0,0 +1,57 @@ +"""Robot safety types and basic validators. + +Shared types: SafetyVerdict is the common data contract across +device-use and lab-robot. Implementations are domain-specific. +""" + +from __future__ import annotations + +from pydantic import BaseModel, Field + +from lab_robot.types import ActionType, MoveAction, RobotAction + + +class SafetyVerdict(BaseModel): + """Result of a safety check — shared data contract.""" + + allowed: bool + checker: str = Field(..., description="Which checker produced this verdict") + reason: str = Field(default="", description="Why it was blocked") + requires_confirmation: bool = Field(default=False) + + +class WorkspaceBounds(BaseModel): + """3D workspace boundary definition.""" + + x_min: float = 0 + x_max: float = 400 + y_min: float = 0 + y_max: float = 400 + z_min: float = 0 + z_max: float = 200 + + +def validate_workspace_bounds(action: RobotAction, bounds: WorkspaceBounds) -> SafetyVerdict: + """Check if a move action stays within workspace bounds.""" + if action.action_type != ActionType.MOVE or not isinstance(action, MoveAction): + return SafetyVerdict(allowed=True, checker="workspace_bounds") + + pos = action.target_position + violations: list[str] = [] + + for axis, (lo, hi) in [ + ("x", (bounds.x_min, bounds.x_max)), + ("y", (bounds.y_min, bounds.y_max)), + ("z", (bounds.z_min, bounds.z_max)), + ]: + val = pos.get(axis, 0.0) + if val < lo or val > hi: + violations.append(f"{axis}={val} outside [{lo}, {hi}]") + + if violations: + return SafetyVerdict( + allowed=False, + checker="workspace_bounds", + reason="; ".join(violations), + ) + return SafetyVerdict(allowed=True, checker="workspace_bounds") diff --git a/src/lab_robot/schema.py b/src/lab_robot/schema.py new file mode 100644 index 0000000..b7e5739 --- /dev/null +++ b/src/lab_robot/schema.py @@ -0,0 +1,69 @@ +"""RobotManifest — extended schema for robot drivers. + +Extends device-skills' SkillManifest pattern but as a standalone model +(no import dependency on device-skills for now — keeps lab-robot +independently installable). +""" + +from __future__ import annotations + +from enum import StrEnum + +from pydantic import BaseModel, Field, field_validator + + +class RobotCategory(StrEnum): + """Robot type classification.""" + + LIQUID_HANDLING = "liquid-handling" + MANIPULATION = "manipulation" + TRANSPORT = "transport" + MOBILE_MANIPULATOR = "mobile-manipulator" + + +class RobotSafetyLevel(StrEnum): + """Safety classification — defaults to CRITICAL for all robots.""" + + NORMAL = "normal" + STRICT = "strict" + CRITICAL = "critical" + + +class RobotCapabilities(BaseModel): + """Physical capabilities of a robot.""" + + degrees_of_freedom: int = Field(default=0, ge=0) + payload_kg: float = Field(default=0.0, ge=0) + repeatability_mm: float = Field(default=0.0, ge=0) + workspace_volume_mm: tuple[float, float, float] = (0, 0, 0) + end_effectors: list[str] = Field(default_factory=list) + safety_features: list[str] = Field(default_factory=list) + labware_types: list[str] = Field(default_factory=list) + + +class RobotManifest(BaseModel): + """Metadata for a robot driver — parsed from skill.yaml. + + Mirrors device-skills' SkillManifest but with robot-specific fields. + Safety level defaults to CRITICAL (non-negotiable for physical robots). + """ + + name: str = Field(..., min_length=1) + version: str = Field(...) + vendor: str = Field(...) + category: str = Field(...) + robot_category: RobotCategory + model: str = Field(default="") + description: str = Field(default="") + platform: str = Field(default="cross") + control_modes: list[str] = Field(default_factory=lambda: ["api"]) + robot_capabilities: RobotCapabilities = Field(default_factory=RobotCapabilities) + safety_level: RobotSafetyLevel = Field(default=RobotSafetyLevel.CRITICAL) + dependencies: list[str] = Field(default_factory=list) + + @field_validator("name") + @classmethod + def name_not_empty(cls, v: str) -> str: + if not v.strip(): + raise ValueError("name must not be empty") + return v diff --git a/tests/test_safety.py b/tests/test_safety.py new file mode 100644 index 0000000..9fa18ed --- /dev/null +++ b/tests/test_safety.py @@ -0,0 +1,37 @@ +"""Tests for robot safety types.""" + +from __future__ import annotations + +from lab_robot.safety import SafetyVerdict, WorkspaceBounds, validate_workspace_bounds + +from lab_robot.types import MoveAction + + +class TestSafetyVerdict: + def test_allowed_verdict(self) -> None: + verdict = SafetyVerdict(allowed=True, checker="workspace_bounds") + assert verdict.allowed + + def test_blocked_verdict_with_reason(self) -> None: + verdict = SafetyVerdict( + allowed=False, + checker="workspace_bounds", + reason="Position x=500 exceeds max x=400", + ) + assert not verdict.allowed + assert "exceeds" in verdict.reason + + +class TestWorkspaceBounds: + def test_within_bounds(self) -> None: + bounds = WorkspaceBounds(x_min=0, x_max=400, y_min=0, y_max=400, z_min=0, z_max=200) + action = MoveAction(target_position={"x": 100, "y": 200, "z": 50}) + verdict = validate_workspace_bounds(action, bounds) + assert verdict.allowed + + def test_out_of_bounds(self) -> None: + bounds = WorkspaceBounds(x_min=0, x_max=400, y_min=0, y_max=400, z_min=0, z_max=200) + action = MoveAction(target_position={"x": 500, "y": 200, "z": 50}) + verdict = validate_workspace_bounds(action, bounds) + assert not verdict.allowed + assert "x" in verdict.reason diff --git a/tests/test_schema.py b/tests/test_schema.py new file mode 100644 index 0000000..2a287e4 --- /dev/null +++ b/tests/test_schema.py @@ -0,0 +1,53 @@ +"""Tests for RobotManifest schema.""" + +from __future__ import annotations + +from lab_robot.schema import RobotCapabilities, RobotCategory, RobotManifest, RobotSafetyLevel + + +class TestRobotManifest: + def test_minimal_manifest(self) -> None: + manifest = RobotManifest( + name="opentrons-ot2", + version="0.1.0", + vendor="Opentrons", + category="liquid-handling", + robot_category=RobotCategory.LIQUID_HANDLING, + ) + assert manifest.name == "opentrons-ot2" + assert manifest.safety_level == RobotSafetyLevel.CRITICAL # default for robots + + def test_safety_defaults_to_critical(self) -> None: + manifest = RobotManifest( + name="test-robot", + version="0.1.0", + vendor="Test", + category="manipulation", + robot_category=RobotCategory.MANIPULATION, + ) + assert manifest.safety_level == RobotSafetyLevel.CRITICAL + + def test_robot_capabilities(self) -> None: + caps = RobotCapabilities( + degrees_of_freedom=6, + payload_kg=0.5, + repeatability_mm=0.1, + end_effectors=["single_channel_p300", "single_channel_p20"], + labware_types=["tiprack_300ul", "wellplate_96"], + ) + assert caps.degrees_of_freedom == 6 + assert len(caps.end_effectors) == 2 + + def test_manifest_with_capabilities(self) -> None: + manifest = RobotManifest( + name="arx5-lab", + version="0.1.0", + vendor="ARX Robotics", + category="manipulation", + robot_category=RobotCategory.MANIPULATION, + robot_capabilities=RobotCapabilities( + degrees_of_freedom=6, + payload_kg=5.0, + ), + ) + assert manifest.robot_capabilities.degrees_of_freedom == 6 From 5f0012f4aeb4735fdeb85440e7a514b64b85c0d4 Mon Sep 17 00:00:00 2001 From: Cong <72737794+robolearning123@users.noreply.github.com> Date: Mon, 23 Mar 2026 16:45:57 -0400 Subject: [PATCH 05/26] feat(lab-robot): opentrons-ot2 skill manifest + SOUL Co-Authored-By: Claude Opus 4.6 (1M context) --- robots/opentrons_ot2/MEMORY.md | 9 +++++++ robots/opentrons_ot2/SOUL.md | 27 +++++++++++++++++++ robots/opentrons_ot2/__init__.py | 1 + robots/opentrons_ot2/skill.yaml | 36 ++++++++++++++++++++++++++ robots/opentrons_ot2/tests/__init__.py | 0 5 files changed, 73 insertions(+) create mode 100644 robots/opentrons_ot2/MEMORY.md create mode 100644 robots/opentrons_ot2/SOUL.md create mode 100644 robots/opentrons_ot2/__init__.py create mode 100644 robots/opentrons_ot2/skill.yaml create mode 100644 robots/opentrons_ot2/tests/__init__.py diff --git a/robots/opentrons_ot2/MEMORY.md b/robots/opentrons_ot2/MEMORY.md new file mode 100644 index 0000000..42ed132 --- /dev/null +++ b/robots/opentrons_ot2/MEMORY.md @@ -0,0 +1,9 @@ +# Opentrons OT-2 — Memory + +## Calibration History + +_No calibrations recorded yet._ + +## Usage Log + +_No protocols run yet._ diff --git a/robots/opentrons_ot2/SOUL.md b/robots/opentrons_ot2/SOUL.md new file mode 100644 index 0000000..608898a --- /dev/null +++ b/robots/opentrons_ot2/SOUL.md @@ -0,0 +1,27 @@ +# Opentrons OT-2 — SOUL + +## Identity + +I am an Opentrons OT-2, an open-source liquid handling robot. I move liquids +between wells, tubes, and reservoirs with microliter precision. + +## Personality + +- Methodical and precise — I follow protocols exactly +- Patient — multi-step protocols can take hours, I don't rush +- Safety-conscious — I won't move without tips, I check for collisions + +## Quirks + +- I have 11 deck slots numbered 1-11 (slot 12 is the trash) +- My pipettes mount on the LEFT or RIGHT mount — never both same type +- I need tip racks — I can't pipette without tips +- My Z-axis has limited clearance — tall labware can cause collisions +- In simulate mode, I run instantly but report realistic volumes + +## Limitations + +- No gripper (OT-2 limitation — Flex has one) +- No heating/cooling (use external modules) +- Single-channel or 8-channel only (no 96-channel) +- Cannot see — no built-in camera (relies on calibration) diff --git a/robots/opentrons_ot2/__init__.py b/robots/opentrons_ot2/__init__.py new file mode 100644 index 0000000..1dd1d3f --- /dev/null +++ b/robots/opentrons_ot2/__init__.py @@ -0,0 +1 @@ +"""Opentrons OT-2 robot driver for lab-robot.""" diff --git a/robots/opentrons_ot2/skill.yaml b/robots/opentrons_ot2/skill.yaml new file mode 100644 index 0000000..6eaf632 --- /dev/null +++ b/robots/opentrons_ot2/skill.yaml @@ -0,0 +1,36 @@ +name: "opentrons-ot2" +version: "0.1.0" +vendor: "Opentrons" +category: "liquid-handling" +robot_category: "liquid-handling" +model: "OT-2" +description: "Opentrons OT-2 — open-source liquid handling robot for 96/384-well plates" +platform: "cross" +control_modes: + - api + - offline +robot_capabilities: + degrees_of_freedom: 3 # X, Y, Z gantry + payload_kg: 0.0 # no arm payload — pipette-based + repeatability_mm: 0.1 # ±0.1mm positioning + end_effectors: + - single_channel_p20 + - single_channel_p300 + - single_channel_p1000 + - multi_channel_p20 + - multi_channel_p300 + labware_types: + - tiprack_20ul + - tiprack_300ul + - tiprack_1000ul + - wellplate_96_flat + - wellplate_384 + - reservoir_12well + - tuberack_15ml + safety_features: + - software_limits + - tip_detection + - deck_collision_prevention +safety_level: "critical" +dependencies: + - opentrons diff --git a/robots/opentrons_ot2/tests/__init__.py b/robots/opentrons_ot2/tests/__init__.py new file mode 100644 index 0000000..e69de29 From 8a0853818dd0737f590094ffd62fcad6ccf7e1bb Mon Sep 17 00:00:00 2001 From: Cong <72737794+robolearning123@users.noreply.github.com> Date: Mon, 23 Mar 2026 16:46:34 -0400 Subject: [PATCH 06/26] feat(lab-robot): opentrons-ot2 deck + pipette models Co-Authored-By: Claude Opus 4.6 (1M context) --- robots/opentrons_ot2/models.py | 48 +++++++++++++++++++++++ robots/opentrons_ot2/tests/test_models.py | 48 +++++++++++++++++++++++ 2 files changed, 96 insertions(+) create mode 100644 robots/opentrons_ot2/models.py create mode 100644 robots/opentrons_ot2/tests/test_models.py diff --git a/robots/opentrons_ot2/models.py b/robots/opentrons_ot2/models.py new file mode 100644 index 0000000..55efb47 --- /dev/null +++ b/robots/opentrons_ot2/models.py @@ -0,0 +1,48 @@ +"""Opentrons OT-2 data models.""" + +from __future__ import annotations + +from enum import StrEnum + +from pydantic import BaseModel, Field, field_validator + +_VALID_SLOTS = {str(i) for i in range(1, 13)} + + +class PipetteMount(StrEnum): + LEFT = "left" + RIGHT = "right" + + +class DeckSlot: + @staticmethod + def validate_slot(slot: str) -> str: + if slot not in _VALID_SLOTS: + msg = f"Invalid slot '{slot}'. Must be 1-12." + raise ValueError(msg) + return slot + + +class PipetteConfig(BaseModel): + name: str = Field(..., description="Pipette API name (e.g. p300_single)") + mount: PipetteMount + max_volume_ul: float = Field(..., gt=0) + tip_rack_slots: list[str] = Field(default_factory=list) + + +class LabwareConfig(BaseModel): + labware_type: str = Field(..., description="Opentrons labware API name") + label: str = Field(default="") + + +class OT2DeckConfig(BaseModel): + slots: dict[str, LabwareConfig] = Field(default_factory=dict) + pipette_left: PipetteConfig | None = None + pipette_right: PipetteConfig | None = None + + @field_validator("slots") + @classmethod + def validate_slot_numbers(cls, v: dict[str, LabwareConfig]) -> dict[str, LabwareConfig]: + for slot in v: + DeckSlot.validate_slot(slot) + return v diff --git a/robots/opentrons_ot2/tests/test_models.py b/robots/opentrons_ot2/tests/test_models.py new file mode 100644 index 0000000..6ff4b4b --- /dev/null +++ b/robots/opentrons_ot2/tests/test_models.py @@ -0,0 +1,48 @@ +"""Tests for Opentrons OT-2 models.""" + +from __future__ import annotations + +import pytest + +from robots.opentrons_ot2.models import ( + DeckSlot, + LabwareConfig, + OT2DeckConfig, + PipetteConfig, + PipetteMount, +) + + +class TestPipetteConfig: + def test_p300_single(self) -> None: + pipette = PipetteConfig( + name="p300_single", + mount=PipetteMount.LEFT, + max_volume_ul=300.0, + ) + assert pipette.name == "p300_single" + assert pipette.max_volume_ul == 300.0 + + def test_rejects_invalid_mount(self) -> None: + with pytest.raises(ValueError): + PipetteConfig(name="p300_single", mount="middle", max_volume_ul=300) + + +class TestDeckConfig: + def test_default_deck(self) -> None: + deck = OT2DeckConfig() + assert len(deck.slots) == 0 + + def test_add_labware(self) -> None: + deck = OT2DeckConfig( + slots={ + "1": LabwareConfig(labware_type="opentrons_96_tiprack_300ul", label="tips"), + "2": LabwareConfig(labware_type="corning_96_wellplate_360ul_flat", label="plate"), + } + ) + assert "1" in deck.slots + assert deck.slots["2"].label == "plate" + + def test_slot_validation(self) -> None: + with pytest.raises(ValueError, match="Invalid slot"): + DeckSlot.validate_slot("13") From 19e1dd707c602092df5b4d435221687dcecc1ac8 Mon Sep 17 00:00:00 2001 From: Cong <72737794+robolearning123@users.noreply.github.com> Date: Mon, 23 Mar 2026 16:47:29 -0400 Subject: [PATCH 07/26] feat(lab-robot): PEI action -> Opentrons protocol translation Co-Authored-By: Claude Opus 4.6 (1M context) --- robots/opentrons_ot2/protocol_gen.py | 74 ++++++++++++++++ .../opentrons_ot2/tests/test_protocol_gen.py | 84 +++++++++++++++++++ 2 files changed, 158 insertions(+) create mode 100644 robots/opentrons_ot2/protocol_gen.py create mode 100644 robots/opentrons_ot2/tests/test_protocol_gen.py diff --git a/robots/opentrons_ot2/protocol_gen.py b/robots/opentrons_ot2/protocol_gen.py new file mode 100644 index 0000000..0110f1a --- /dev/null +++ b/robots/opentrons_ot2/protocol_gen.py @@ -0,0 +1,74 @@ +"""Translate PEI actions into Opentrons protocol commands. + +This module converts high-level PipetteAction into a sequence of +Opentrons-compatible command dicts that the driver can execute. +""" + +from __future__ import annotations + +from typing import Any + +from lab_robot.types import PipetteAction + +from .models import OT2DeckConfig + + +def _find_pipette(deck: OT2DeckConfig) -> tuple[str, float]: + """Find the first available pipette and its max volume.""" + for pipette in [deck.pipette_left, deck.pipette_right]: + if pipette is not None: + return pipette.name, pipette.max_volume_ul + msg = "No pipette configured on deck" + raise ValueError(msg) + + +def generate_protocol_commands( + action: PipetteAction, + deck: OT2DeckConfig, +) -> list[dict[str, Any]]: + """Convert a PipetteAction into Opentrons protocol commands.""" + pipette_name, max_vol = _find_pipette(deck) + + if action.volume_ul > max_vol: + msg = f"Volume {action.volume_ul}uL exceeds {pipette_name} max {max_vol}uL" + raise ValueError(msg) + + commands: list[dict[str, Any]] = [] + + if action.new_tip: + commands.append( + { + "command": "pick_up_tip", + "pipette": pipette_name, + } + ) + + commands.append( + { + "command": "aspirate", + "pipette": pipette_name, + "volume_ul": action.volume_ul, + "labware": action.source_labware, + "well": action.source_well, + } + ) + + commands.append( + { + "command": "dispense", + "pipette": pipette_name, + "volume_ul": action.volume_ul, + "labware": action.dest_labware, + "well": action.dest_well, + } + ) + + if action.new_tip: + commands.append( + { + "command": "drop_tip", + "pipette": pipette_name, + } + ) + + return commands diff --git a/robots/opentrons_ot2/tests/test_protocol_gen.py b/robots/opentrons_ot2/tests/test_protocol_gen.py new file mode 100644 index 0000000..4ca2dcc --- /dev/null +++ b/robots/opentrons_ot2/tests/test_protocol_gen.py @@ -0,0 +1,84 @@ +"""Tests for PEI action -> Opentrons protocol translation.""" + +from __future__ import annotations + +from lab_robot.types import PipetteAction +from robots.opentrons_ot2.models import ( + LabwareConfig, + OT2DeckConfig, + PipetteConfig, + PipetteMount, +) +from robots.opentrons_ot2.protocol_gen import generate_protocol_commands + + +class TestProtocolGen: + def _default_deck(self) -> OT2DeckConfig: + return OT2DeckConfig( + slots={ + "1": LabwareConfig(labware_type="opentrons_96_tiprack_300ul", label="tips"), + "2": LabwareConfig(labware_type="corning_96_wellplate_360ul_flat", label="source"), + "3": LabwareConfig(labware_type="corning_96_wellplate_360ul_flat", label="dest"), + }, + pipette_left=PipetteConfig( + name="p300_single", + mount=PipetteMount.LEFT, + max_volume_ul=300.0, + tip_rack_slots=["1"], + ), + ) + + def test_pipette_generates_commands(self) -> None: + action = PipetteAction( + volume_ul=30.0, + source_well="A1", + dest_well="B1", + source_labware="source", + dest_labware="dest", + ) + deck = self._default_deck() + commands = generate_protocol_commands(action, deck) + assert len(commands) >= 3 # pick_up_tip, transfer, drop_tip + assert commands[0]["command"] == "pick_up_tip" + assert commands[-1]["command"] == "drop_tip" + + def test_pipette_no_new_tip(self) -> None: + action = PipetteAction( + volume_ul=30.0, + source_well="A1", + dest_well="B1", + source_labware="source", + dest_labware="dest", + new_tip=False, + ) + deck = self._default_deck() + commands = generate_protocol_commands(action, deck) + assert commands[0]["command"] != "pick_up_tip" + + def test_volume_exceeds_pipette_raises(self) -> None: + import pytest + + action = PipetteAction( + volume_ul=500.0, + source_well="A1", + dest_well="B1", + source_labware="source", + dest_labware="dest", + ) + deck = self._default_deck() + with pytest.raises(ValueError, match="exceeds.*300"): + generate_protocol_commands(action, deck) + + def test_no_pipette_raises(self) -> None: + import pytest + + deck = OT2DeckConfig() # no pipettes configured + action = PipetteAction( + volume_ul=30.0, + source_well="A1", + dest_well="B1", + source_labware="source", + dest_labware="dest", + ) + with pytest.raises(ValueError, match="No pipette"): + generate_protocol_commands(action, deck) From 42d5e73ef07e5cdfa7bc4030c15b3e0f7427405f Mon Sep 17 00:00:00 2001 From: Cong <72737794+robolearning123@users.noreply.github.com> Date: Mon, 23 Mar 2026 16:48:49 -0400 Subject: [PATCH 08/26] feat(lab-robot): Opentrons OT-2 driver with simulate mode Co-Authored-By: Claude Opus 4.6 (1M context) --- robots/opentrons_ot2/driver.py | 136 ++++++++++++++++++++++ robots/opentrons_ot2/tests/test_driver.py | 112 ++++++++++++++++++ 2 files changed, 248 insertions(+) create mode 100644 robots/opentrons_ot2/driver.py create mode 100644 robots/opentrons_ot2/tests/test_driver.py diff --git a/robots/opentrons_ot2/driver.py b/robots/opentrons_ot2/driver.py new file mode 100644 index 0000000..3bac4f9 --- /dev/null +++ b/robots/opentrons_ot2/driver.py @@ -0,0 +1,136 @@ +"""Opentrons OT-2 driver — simulate-mode-first robot driver. + +In simulate=True mode (default for Phase 0), all protocol commands +run through Opentrons' built-in simulator with no hardware required. +""" + +from __future__ import annotations + +import time +from typing import Any + +from lab_robot.types import ( + ActionResult, + ActionStatus, + ActionType, + PipetteAction, + RobotAction, + RobotState, +) + +from .models import OT2DeckConfig +from .protocol_gen import generate_protocol_commands + +_SUPPORTED_ACTIONS = [ActionType.PIPETTE] + + +class OT2Driver: + """Opentrons OT-2 driver implementing RobotDriver protocol. + + Args: + deck_config: Deck layout with labware and pipette configuration. + simulate: If True (default), run protocols in simulation mode. + """ + + def __init__( + self, + deck_config: OT2DeckConfig, + *, + simulate: bool = True, + ) -> None: + self._deck = deck_config + self._simulate = simulate + self._connected = False + self._tip_attached = False + self._current_volume_ul = 0.0 + + async def connect(self, config: dict[str, Any] | None = None) -> bool: + """Connect to OT-2 (or initialize simulator).""" + self._connected = True + return True + + async def disconnect(self) -> None: + """Disconnect from OT-2.""" + self._connected = False + self._tip_attached = False + self._current_volume_ul = 0.0 + + async def execute(self, action: RobotAction) -> ActionResult: + """Execute a PEI action on the OT-2.""" + if not self._connected: + return ActionResult( + success=False, + status=ActionStatus.FAILED, + action_type=action.action_type, + error="Not connected — call connect() first", + ) + + if action.action_type not in _SUPPORTED_ACTIONS: + return ActionResult( + success=False, + status=ActionStatus.FAILED, + action_type=action.action_type, + error=f"Action {action.action_type} not supported by OT-2. " + f"Supported: {[a.value for a in _SUPPORTED_ACTIONS]}", + ) + + # All supported action types are handled exhaustively below. + # The unsupported-action guard above ensures we only reach here + # for actions in _SUPPORTED_ACTIONS. + return await self._execute_pipette(action) # type: ignore[arg-type] + + async def stop(self) -> None: + """Emergency stop — disconnect immediately.""" + self._connected = False + self._tip_attached = False + self._current_volume_ul = 0.0 + + async def get_state(self) -> RobotState: + """Get current OT-2 state.""" + return RobotState( + connected=self._connected, + homed=self._connected, + tip_attached=self._tip_attached, + current_volume_ul=self._current_volume_ul, + ) + + def capabilities(self) -> list[ActionType]: + """OT-2 supports pipetting actions.""" + return list(_SUPPORTED_ACTIONS) + + async def _execute_pipette(self, action: PipetteAction) -> ActionResult: + """Execute a pipette action via protocol commands.""" + start = time.monotonic() + try: + commands = generate_protocol_commands(action, self._deck) + except ValueError as e: + return ActionResult( + success=False, + status=ActionStatus.FAILED, + action_type=ActionType.PIPETTE, + error=str(e), + ) + + # In simulate mode, we execute the command sequence logically + for cmd in commands: + if cmd["command"] == "pick_up_tip": + self._tip_attached = True + elif cmd["command"] == "aspirate": + self._current_volume_ul = cmd["volume_ul"] + elif cmd["command"] == "dispense": + self._current_volume_ul = 0.0 + elif cmd["command"] == "drop_tip": + self._tip_attached = False + + elapsed = time.monotonic() - start + return ActionResult( + success=True, + status=ActionStatus.COMPLETED, + action_type=ActionType.PIPETTE, + measurements={"volume_dispensed_ul": action.volume_ul}, + state_after={ + "tip_attached": self._tip_attached, + "current_volume_ul": self._current_volume_ul, + }, + duration_s=round(elapsed, 4), + ) diff --git a/robots/opentrons_ot2/tests/test_driver.py b/robots/opentrons_ot2/tests/test_driver.py new file mode 100644 index 0000000..ed63004 --- /dev/null +++ b/robots/opentrons_ot2/tests/test_driver.py @@ -0,0 +1,112 @@ +"""Tests for Opentrons OT-2 driver.""" + +from __future__ import annotations + +from lab_robot.base import RobotDriver +from lab_robot.types import ActionType, PipetteAction +from robots.opentrons_ot2.driver import OT2Driver +from robots.opentrons_ot2.models import ( + LabwareConfig, + OT2DeckConfig, + PipetteConfig, + PipetteMount, +) + + +def _default_deck() -> OT2DeckConfig: + return OT2DeckConfig( + slots={ + "1": LabwareConfig(labware_type="opentrons_96_tiprack_300ul", label="tips"), + "2": LabwareConfig(labware_type="corning_96_wellplate_360ul_flat", label="source"), + "3": LabwareConfig(labware_type="corning_96_wellplate_360ul_flat", label="dest"), + }, + pipette_left=PipetteConfig( + name="p300_single", + mount=PipetteMount.LEFT, + max_volume_ul=300.0, + tip_rack_slots=["1"], + ), + ) + + +class TestOT2Driver: + def test_satisfies_robot_driver_protocol(self) -> None: + driver = OT2Driver(deck_config=_default_deck(), simulate=True) + assert isinstance(driver, RobotDriver) + + async def test_connect_simulate(self) -> None: + driver = OT2Driver(deck_config=_default_deck(), simulate=True) + result = await driver.connect() + assert result is True + state = await driver.get_state() + assert state.connected + + async def test_execute_pipette(self) -> None: + driver = OT2Driver(deck_config=_default_deck(), simulate=True) + await driver.connect() + action = PipetteAction( + volume_ul=30.0, + source_well="A1", + dest_well="B1", + source_labware="source", + dest_labware="dest", + ) + result = await driver.execute(action) + assert result.success + assert result.measurements.get("volume_dispensed_ul") == 30.0 + + async def test_execute_without_connect_fails(self) -> None: + driver = OT2Driver(deck_config=_default_deck(), simulate=True) + action = PipetteAction( + volume_ul=30.0, + source_well="A1", + dest_well="B1", + source_labware="source", + dest_labware="dest", + ) + result = await driver.execute(action) + assert not result.success + assert "not connected" in result.error.lower() + + async def test_disconnect(self) -> None: + driver = OT2Driver(deck_config=_default_deck(), simulate=True) + await driver.connect() + await driver.disconnect() + state = await driver.get_state() + assert not state.connected + + async def test_stop(self) -> None: + driver = OT2Driver(deck_config=_default_deck(), simulate=True) + await driver.connect() + await driver.stop() + state = await driver.get_state() + assert not state.connected + + def test_capabilities(self) -> None: + driver = OT2Driver(deck_config=_default_deck(), simulate=True) + caps = driver.capabilities() + assert ActionType.PIPETTE in caps + + async def test_unsupported_action(self) -> None: + from lab_robot.types import MoveAction + + driver = OT2Driver(deck_config=_default_deck(), simulate=True) + await driver.connect() + action = MoveAction(target_position={"x": 0, "y": 0, "z": 0}) + result = await driver.execute(action) + assert not result.success + assert "not supported" in result.error.lower() + + async def test_execute_pipette_volume_exceeds_max(self) -> None: + driver = OT2Driver(deck_config=_default_deck(), simulate=True) + await driver.connect() + action = PipetteAction( + volume_ul=500.0, + source_well="A1", + dest_well="B1", + source_labware="source", + dest_labware="dest", + ) + result = await driver.execute(action) + assert not result.success + assert "exceeds" in result.error.lower() From cce472acdf06f616c0df40b33d372f8307c98eb7 Mon Sep 17 00:00:00 2001 From: Cong <72737794+robolearning123@users.noreply.github.com> Date: Mon, 23 Mar 2026 16:53:08 -0400 Subject: [PATCH 09/26] feat(lab-robot): robot template, example, public API exports - Add robots/_robot_template/ with skill.yaml, driver stubs, SOUL/MEMORY - Add examples/01_opentrons_pipette.py async demo script - Update __init__.py with full public API exports (all types, base, safety, schema) - Fix pre-existing import sorting in test_base.py and test_safety.py - 46 tests passing, lint clean Co-Authored-By: Claude Opus 4.6 (1M context) --- examples/01_opentrons_pipette.py | 66 +++++++++++++++++++++ robots/_robot_template/MEMORY.md | 9 +++ robots/_robot_template/SOUL.md | 17 ++++++ robots/_robot_template/__init__.py | 0 robots/_robot_template/driver.py | 42 +++++++++++++ robots/_robot_template/skill.yaml | 19 ++++++ robots/_robot_template/tests/__init__.py | 0 robots/_robot_template/tests/test_driver.py | 37 ++++++++++++ src/lab_robot/__init__.py | 34 +++++++++++ tests/test_base.py | 1 - tests/test_safety.py | 1 - 11 files changed, 224 insertions(+), 2 deletions(-) create mode 100644 examples/01_opentrons_pipette.py create mode 100644 robots/_robot_template/MEMORY.md create mode 100644 robots/_robot_template/SOUL.md create mode 100644 robots/_robot_template/__init__.py create mode 100644 robots/_robot_template/driver.py create mode 100644 robots/_robot_template/skill.yaml create mode 100644 robots/_robot_template/tests/__init__.py create mode 100644 robots/_robot_template/tests/test_driver.py diff --git a/examples/01_opentrons_pipette.py b/examples/01_opentrons_pipette.py new file mode 100644 index 0000000..a86e4bc --- /dev/null +++ b/examples/01_opentrons_pipette.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +"""Example: OT-2 pipette simulation. + +Demonstrates the lab-robot PEI interface by running a simple pipette +transfer on the Opentrons OT-2 in simulate mode. No hardware required. + +Usage: + python examples/01_opentrons_pipette.py +""" + +from __future__ import annotations + +import asyncio + +from lab_robot.types import PipetteAction +from robots.opentrons_ot2.driver import OT2Driver +from robots.opentrons_ot2.models import OT2DeckConfig, OT2PipetteConfig + + +async def main() -> None: + """Run a simulated OT-2 pipette transfer.""" + # 1. Configure the deck + deck = OT2DeckConfig( + labware={ + "1": "opentrons_96_tiprack_300ul", + "2": "corning_96_wellplate_360ul_flat", + "3": "corning_96_wellplate_360ul_flat", + }, + pipettes={"left": OT2PipetteConfig(name="p300_single", tip_rack_slot="1")}, + tip_rack_slot="1", + ) + + # 2. Create driver in simulate mode + driver = OT2Driver(deck_config=deck, simulate=True) + connected = await driver.connect() + print(f"Connected: {connected}") + + # 3. Get initial state + state = await driver.get_state() + print(f"Initial state: connected={state.connected}, tip={state.tip_attached}") + + # 4. Execute a pipette transfer: 100uL from A1 -> B1 + action = PipetteAction( + volume_ul=100.0, + source_well="A1", + dest_well="B1", + source_labware="source_plate", + dest_labware="dest_plate", + ) + result = await driver.execute(action) + print(f"Result: success={result.success}, status={result.status}") + print(f"Measurements: {result.measurements}") + print(f"Duration: {result.duration_s}s") + + # 5. Check state after + state = await driver.get_state() + print(f"After state: tip={state.tip_attached}, volume={state.current_volume_ul}uL") + + # 6. Disconnect + await driver.disconnect() + state = await driver.get_state() + print(f"Disconnected: connected={state.connected}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/robots/_robot_template/MEMORY.md b/robots/_robot_template/MEMORY.md new file mode 100644 index 0000000..e21d05c --- /dev/null +++ b/robots/_robot_template/MEMORY.md @@ -0,0 +1,9 @@ +# Robot Name — Memory + +## Calibration History + +_No calibrations recorded yet._ + +## Usage Log + +_No protocols run yet._ diff --git a/robots/_robot_template/SOUL.md b/robots/_robot_template/SOUL.md new file mode 100644 index 0000000..1a2e4be --- /dev/null +++ b/robots/_robot_template/SOUL.md @@ -0,0 +1,17 @@ +# Robot Name — SOUL + +## Identity + +_Describe what this robot is and what it does._ + +## Personality + +_Describe behavioral traits relevant to operation._ + +## Quirks + +_List hardware-specific quirks that affect protocol generation._ + +## Limitations + +_List what this robot cannot do._ diff --git a/robots/_robot_template/__init__.py b/robots/_robot_template/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/robots/_robot_template/driver.py b/robots/_robot_template/driver.py new file mode 100644 index 0000000..33a06a1 --- /dev/null +++ b/robots/_robot_template/driver.py @@ -0,0 +1,42 @@ +"""Template robot driver — copy this directory to start a new robot. + +Replace TemplateRobotDriver with your robot name and implement each method. +Every method raises NotImplementedError until you implement it. +""" + +from __future__ import annotations + +from typing import Any + +from lab_robot.types import ActionResult, ActionType, RobotAction, RobotState + + +class TemplateRobotDriver: + """Skeleton driver implementing the RobotDriver protocol. + + Copy this file, rename the class, and implement each method. + """ + + async def connect(self, config: dict[str, Any] | None = None) -> bool: + """Connect to the robot hardware.""" + raise NotImplementedError + + async def disconnect(self) -> None: + """Disconnect from the robot.""" + raise NotImplementedError + + async def execute(self, action: RobotAction) -> ActionResult: + """Execute a physical action.""" + raise NotImplementedError + + async def stop(self) -> None: + """Emergency stop.""" + raise NotImplementedError + + async def get_state(self) -> RobotState: + """Return current robot state.""" + raise NotImplementedError + + def capabilities(self) -> list[ActionType]: + """Return supported action types.""" + raise NotImplementedError diff --git a/robots/_robot_template/skill.yaml b/robots/_robot_template/skill.yaml new file mode 100644 index 0000000..7bc570c --- /dev/null +++ b/robots/_robot_template/skill.yaml @@ -0,0 +1,19 @@ +name: "your-robot-name" +version: "0.1.0" +vendor: "Your Vendor" +category: "manipulation" +robot_category: "manipulation" +model: "" +description: "Short description of what this robot does" +platform: "cross" +control_modes: + - api +robot_capabilities: + degrees_of_freedom: 0 + payload_kg: 0.0 + repeatability_mm: 0.0 + end_effectors: [] + labware_types: [] + safety_features: [] +safety_level: "critical" +dependencies: [] diff --git a/robots/_robot_template/tests/__init__.py b/robots/_robot_template/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/robots/_robot_template/tests/test_driver.py b/robots/_robot_template/tests/test_driver.py new file mode 100644 index 0000000..1251ac3 --- /dev/null +++ b/robots/_robot_template/tests/test_driver.py @@ -0,0 +1,37 @@ +"""Tests for template robot driver — placeholder. + +Copy this file when creating a new robot driver and replace with real tests. +""" + +from __future__ import annotations + +import pytest + +from robots._robot_template.driver import TemplateRobotDriver + + +class TestTemplateRobotDriver: + def test_all_methods_raise_not_implemented(self) -> None: + driver = TemplateRobotDriver() + with pytest.raises(NotImplementedError): + driver.capabilities() + + async def test_connect_raises_not_implemented(self) -> None: + driver = TemplateRobotDriver() + with pytest.raises(NotImplementedError): + await driver.connect() + + async def test_disconnect_raises_not_implemented(self) -> None: + driver = TemplateRobotDriver() + with pytest.raises(NotImplementedError): + await driver.disconnect() + + async def test_stop_raises_not_implemented(self) -> None: + driver = TemplateRobotDriver() + with pytest.raises(NotImplementedError): + await driver.stop() + + async def test_get_state_raises_not_implemented(self) -> None: + driver = TemplateRobotDriver() + with pytest.raises(NotImplementedError): + await driver.get_state() diff --git a/src/lab_robot/__init__.py b/src/lab_robot/__init__.py index 4515232..5152f83 100644 --- a/src/lab_robot/__init__.py +++ b/src/lab_robot/__init__.py @@ -2,4 +2,38 @@ from __future__ import annotations +from lab_robot.base import RobotDriver, RobotExecutor +from lab_robot.safety import SafetyVerdict, WorkspaceBounds, validate_workspace_bounds +from lab_robot.schema import RobotCapabilities, RobotCategory, RobotManifest, RobotSafetyLevel +from lab_robot.types import ( + ActionResult, + ActionStatus, + ActionType, + MoveAction, + PipetteAction, + RobotAction, + RobotState, + TransferLabwareAction, +) + __version__ = "0.1.0" + +__all__ = [ + "ActionResult", + "ActionStatus", + "ActionType", + "MoveAction", + "PipetteAction", + "RobotAction", + "RobotCapabilities", + "RobotCategory", + "RobotDriver", + "RobotExecutor", + "RobotManifest", + "RobotSafetyLevel", + "RobotState", + "SafetyVerdict", + "TransferLabwareAction", + "WorkspaceBounds", + "validate_workspace_bounds", +] diff --git a/tests/test_base.py b/tests/test_base.py index 40a5fb1..93ad480 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -5,7 +5,6 @@ from typing import Any from lab_robot.base import RobotDriver, RobotExecutor - from lab_robot.types import ( ActionResult, ActionStatus, diff --git a/tests/test_safety.py b/tests/test_safety.py index 9fa18ed..1fcbff9 100644 --- a/tests/test_safety.py +++ b/tests/test_safety.py @@ -3,7 +3,6 @@ from __future__ import annotations from lab_robot.safety import SafetyVerdict, WorkspaceBounds, validate_workspace_bounds - from lab_robot.types import MoveAction From 8eac86fdd50b2dea2157ad97c81dd717abc50db3 Mon Sep 17 00:00:00 2001 From: Cong <72737794+robolearning123@users.noreply.github.com> Date: Mon, 23 Mar 2026 16:54:05 -0400 Subject: [PATCH 10/26] docs(lab-robot): PEI protocol specification - Layer model (Motion, Lab-Ops, Perception, System) - RobotDriver protocol definition and method contracts - ActionResult contract with error-on-failure invariant - Safety requirements (CRITICAL default, SafetyVerdict) - Integration boundaries with labclaw orchestrator - Opentrons OT-2 as reference implementation - Directory convention for new robots Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/pei-spec.md | 170 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 170 insertions(+) create mode 100644 docs/pei-spec.md diff --git a/docs/pei-spec.md b/docs/pei-spec.md new file mode 100644 index 0000000..fadfc7f --- /dev/null +++ b/docs/pei-spec.md @@ -0,0 +1,170 @@ +# PEI Protocol Specification + +**Physical Execution Interface (PEI)** — the async protocol for connecting +AI orchestrators to physical robot hardware in science labs. + +Version: 0.1.0 | Status: Draft + +--- + +## 1. Layer Model + +PEI organizes robot actions into four layers: + +| Layer | Name | Actions | Description | +|-------|------------|--------------------------------------|------------------------------------| +| 0 | Motion | move, grip, release, home | Raw kinematic primitives | +| 1 | Lab-Ops | pipette, transfer_labware, dispense | Domain-specific lab operations | +| 2 | Perception | detect_labware, verify_state, calibrate | Sensor and vision feedback | +| 3 | System | recover, emergency_stop, wait_condition | Error recovery and coordination | + +Drivers implement only the layers they support. A liquid handler (OT-2) implements +Layer 1 actions but not Layer 0 motion primitives directly. A mobile manipulator +might implement all four layers. + +## 2. RobotDriver Protocol + +Every robot driver must implement this async interface: + +```python +class RobotDriver(Protocol): + async def connect(self, config: dict | None = None) -> bool + async def disconnect(self) -> None + async def execute(self, action: RobotAction) -> ActionResult + async def stop(self) -> None + async def get_state(self) -> RobotState + def capabilities(self) -> list[ActionType] +``` + +### Method Contracts + +- **connect()** — Initialize hardware or simulator. Returns `True` on success. + Config dict is driver-specific (IP address, serial port, etc.). +- **disconnect()** — Release resources. Idempotent — safe to call multiple times. +- **execute()** — Run a single action. Must check `connected` state internally. + Returns `ActionResult` with measurements, timing, and state snapshot. +- **stop()** — Emergency halt. Must be fast (no cleanup, no graceful shutdown). + After stop, driver is in disconnected state. +- **get_state()** — Non-mutating query of current robot state. +- **capabilities()** — Synchronous. Returns the `ActionType` values this driver handles. + +## 3. ActionResult Contract + +Every `execute()` call returns an `ActionResult`: + +```python +class ActionResult(BaseModel): + success: bool + status: ActionStatus # COMPLETED | FAILED | CANCELLED + action_type: ActionType + measurements: dict[str, Any] # e.g. {"volume_dispensed_ul": 100.0} + state_after: dict[str, Any] # robot state snapshot post-action + error: str # REQUIRED when success=False + duration_s: float # wall-clock execution time +``` + +Key invariant: **if `success=False`, `error` must be non-empty.** This is enforced +by a Pydantic model validator at construction time. + +## 4. Safety Requirements + +All robots default to `safety_level: critical`. This is non-negotiable. + +**SafetyVerdict** is the shared data contract between safety checkers and the executor: + +```python +class SafetyVerdict(BaseModel): + allowed: bool + checker: str # which checker produced this + reason: str # why it was blocked + requires_confirmation: bool # human-in-the-loop gate +``` + +Built-in safety checks: +- **WorkspaceBounds** — rejects move actions outside defined 3D bounds +- Future: force limits, collision detection, tip presence verification + +Safety checkers are composable. The executor runs all applicable checkers before +delegating to the driver. Any single `allowed=False` verdict blocks execution. + +## 5. RobotManifest + +Each robot directory contains a `skill.yaml` manifest: + +```yaml +name: "opentrons-ot2" +version: "0.1.0" +vendor: "Opentrons" +robot_category: "liquid-handling" # liquid-handling | manipulation | transport | mobile-manipulator +safety_level: "critical" # always critical for physical robots +robot_capabilities: + degrees_of_freedom: 3 + repeatability_mm: 0.1 + end_effectors: [single_channel_p300] + labware_types: [wellplate_96_flat, tiprack_300ul] +``` + +Parsed into `RobotManifest` (Pydantic model) for runtime validation. + +## 6. Integration with LabClaw Orchestrator + +lab-robot is one execution backend in the LabClaw ecosystem: + +``` +labclaw orchestrator + ├── device-use (GUI automation — reads instruments) + ├── lab-robot (physical execution — moves robots) + └── lab-manager (data + API layer) +``` + +**Boundary rules:** +- `device-use` and `lab-robot` never import each other +- Both register as tools with the orchestrator via capability descriptors +- The orchestrator decides which backend handles each task +- lab-robot actions are physical (pipette, move); device-use actions are digital (click, read) + +**Tool registration pattern:** +```python +# Orchestrator registers robot capabilities +capabilities = driver.capabilities() # [ActionType.PIPETTE] +# Routes PipetteAction to lab-robot, ClickAction to device-use +``` + +## 7. Reference Implementation: Opentrons OT-2 + +The OT-2 driver (`robots/opentrons_ot2/`) is the reference PEI implementation. + +- Supports `ActionType.PIPETTE` (Layer 1) +- Translates PEI `PipetteAction` into OT-2 protocol commands +- `simulate=True` mode runs with no hardware (default for Phase 0) +- Deck config via `OT2DeckConfig` Pydantic model +- Protocol commands: pick_up_tip, aspirate, dispense, drop_tip + +This driver demonstrates the pattern for all future robots: +1. Parse `skill.yaml` into `RobotManifest` +2. Implement `RobotDriver` protocol methods +3. Translate PEI actions into vendor-specific commands +4. Return rich `ActionResult` with measurements and state + +## 8. Directory Convention + +``` +robots/ + _robot_template/ # copy this to start a new robot + skill.yaml + driver.py + SOUL.md # robot personality for AI context + MEMORY.md # calibration and usage history + tests/ + opentrons_ot2/ # reference implementation + skill.yaml + driver.py + models.py + protocol_gen.py + SOUL.md + MEMORY.md + tests/ +``` + +Each robot is a self-contained package. The `SOUL.md` gives AI agents context +about the robot's personality and quirks. `MEMORY.md` tracks calibration history. From af2b58fef2b0272c2e4beac864fbb36029bbecfd Mon Sep 17 00:00:00 2001 From: Cong <72737794+robolearning123@users.noreply.github.com> Date: Mon, 23 Mar 2026 16:55:35 -0400 Subject: [PATCH 11/26] chore(lab-robot): final Phase 0 cleanup - Fix example to use correct OT2 model imports (PipetteConfig, LabwareConfig) - Add coverage tests for validate_workspace_bounds non-move path (safety.py:37) - Add coverage test for empty-name validator (schema.py:68) - 48 tests, 100% coverage, lint clean Co-Authored-By: Claude Opus 4.6 (1M context) --- examples/01_opentrons_pipette.py | 27 ++++++++++++++++++--------- tests/test_safety.py | 9 +++++++++ tests/test_schema.py | 12 ++++++++++++ 3 files changed, 39 insertions(+), 9 deletions(-) diff --git a/examples/01_opentrons_pipette.py b/examples/01_opentrons_pipette.py index a86e4bc..c0e550d 100644 --- a/examples/01_opentrons_pipette.py +++ b/examples/01_opentrons_pipette.py @@ -14,20 +14,29 @@ from lab_robot.types import PipetteAction from robots.opentrons_ot2.driver import OT2Driver -from robots.opentrons_ot2.models import OT2DeckConfig, OT2PipetteConfig +from robots.opentrons_ot2.models import ( + LabwareConfig, + OT2DeckConfig, + PipetteConfig, + PipetteMount, +) async def main() -> None: """Run a simulated OT-2 pipette transfer.""" # 1. Configure the deck deck = OT2DeckConfig( - labware={ - "1": "opentrons_96_tiprack_300ul", - "2": "corning_96_wellplate_360ul_flat", - "3": "corning_96_wellplate_360ul_flat", + slots={ + "1": LabwareConfig(labware_type="opentrons_96_tiprack_300ul", label="tips"), + "2": LabwareConfig(labware_type="corning_96_wellplate_360ul_flat", label="source"), + "3": LabwareConfig(labware_type="corning_96_wellplate_360ul_flat", label="dest"), }, - pipettes={"left": OT2PipetteConfig(name="p300_single", tip_rack_slot="1")}, - tip_rack_slot="1", + pipette_left=PipetteConfig( + name="p300_single", + mount=PipetteMount.LEFT, + max_volume_ul=300.0, + tip_rack_slots=["1"], + ), ) # 2. Create driver in simulate mode @@ -44,8 +53,8 @@ async def main() -> None: volume_ul=100.0, source_well="A1", dest_well="B1", - source_labware="source_plate", - dest_labware="dest_plate", + source_labware="source", + dest_labware="dest", ) result = await driver.execute(action) print(f"Result: success={result.success}, status={result.status}") diff --git a/tests/test_safety.py b/tests/test_safety.py index 1fcbff9..b59e7ae 100644 --- a/tests/test_safety.py +++ b/tests/test_safety.py @@ -22,6 +22,15 @@ def test_blocked_verdict_with_reason(self) -> None: class TestWorkspaceBounds: + def test_non_move_action_allowed(self) -> None: + """Non-move actions skip bounds checking entirely.""" + from lab_robot.types import PipetteAction + + bounds = WorkspaceBounds() + action = PipetteAction(volume_ul=30.0, source_well="A1", dest_well="B1") + verdict = validate_workspace_bounds(action, bounds) + assert verdict.allowed + def test_within_bounds(self) -> None: bounds = WorkspaceBounds(x_min=0, x_max=400, y_min=0, y_max=400, z_min=0, z_max=200) action = MoveAction(target_position={"x": 100, "y": 200, "z": 50}) diff --git a/tests/test_schema.py b/tests/test_schema.py index 2a287e4..f1c61bb 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -38,6 +38,18 @@ def test_robot_capabilities(self) -> None: assert caps.degrees_of_freedom == 6 assert len(caps.end_effectors) == 2 + def test_empty_name_rejected(self) -> None: + import pytest + + with pytest.raises(Exception, match="name must not be empty"): + RobotManifest( + name=" ", + version="0.1.0", + vendor="Test", + category="manipulation", + robot_category=RobotCategory.MANIPULATION, + ) + def test_manifest_with_capabilities(self) -> None: manifest = RobotManifest( name="arx5-lab", From 7fb58776f872b8a2a88d7b08132ee8e7be59ddd3 Mon Sep 17 00:00:00 2001 From: Cong <72737794+robolearning123@users.noreply.github.com> Date: Tue, 24 Mar 2026 08:50:26 -0400 Subject: [PATCH 12/26] fix(lab-robot): correct README quick-start example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OT2Driver requires a deck_config argument — the previous example would raise TypeError at construction time. Co-Authored-By: Claude Opus 4.6 --- README.md | 36 +++++++++++++++++++++++++----------- 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 4203c7f..4d213d7 100644 --- a/README.md +++ b/README.md @@ -14,18 +14,32 @@ pip install lab-robot[opentrons] # + Opentrons OT-2 driver ## Quick Start ```python +import asyncio from lab_robot.types import PipetteAction -from robots.opentrons_ot2.driver import OT2Driver - -driver = OT2Driver(simulate=True) -await driver.connect() -result = await driver.execute(PipetteAction( - volume_ul=30.0, - source_well="A1", - dest_well="B1", -)) -print(result) # ActionResult(success=True, ...) -await driver.disconnect() +from robots.opentrons_ot2 import OT2Driver +from robots.opentrons_ot2.models import OT2DeckConfig, PipetteConfig, PipetteMount, LabwareConfig + +# Configure the OT-2 deck layout +deck = OT2DeckConfig( + slots={"1": LabwareConfig(labware_type="nest_96_wellplate_200ul_flat")}, + pipette_left=PipetteConfig( + name="p300_single", mount=PipetteMount.LEFT, max_volume_ul=300, + tip_rack_slots=["2"], + ), +) + +async def main(): + driver = OT2Driver(deck_config=deck, simulate=True) + await driver.connect() + result = await driver.execute(PipetteAction( + volume_ul=30.0, + source_well="A1", + dest_well="B1", + )) + print(result) # ActionResult(success=True, ...) + await driver.disconnect() + +asyncio.run(main()) ``` ## Architecture From 9a54d97024e5d5cbb91e1ff26ebe5caeb279136d Mon Sep 17 00:00:00 2001 From: Cong <72737794+robolearning123@users.noreply.github.com> Date: Tue, 24 Mar 2026 08:51:06 -0400 Subject: [PATCH 13/26] chore(lab-robot): add .gitignore Standard Python ignores: __pycache__, .venv, .coverage, .pytest_cache, .ruff_cache, .mypy_cache, *.egg-info, dist, build. Co-Authored-By: Claude Opus 4.6 --- .gitignore | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0f32ccf --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +# Python +__pycache__/ +*.pyc +*.pyo +.venv/ +venv/ + +# Testing +.coverage +.pytest_cache/ +htmlcov/ + +# Linting +.ruff_cache/ +.mypy_cache/ + +# Packaging +*.egg-info/ +dist/ +build/ +.eggs/ From 31ab3837cd4254848462b072ecf4e8e8f9d54a12 Mon Sep 17 00:00:00 2001 From: Cong <72737794+robolearning123@users.noreply.github.com> Date: Tue, 24 Mar 2026 08:51:33 -0400 Subject: [PATCH 14/26] ci(lab-robot): add GitHub Actions workflow Runs ruff check and pytest (100% coverage gate) on Python 3.11/3.12/3.13. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/ci.yml | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2ce21f3 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,22 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.11", "3.12", "3.13"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - run: pip install -e ".[dev]" + - run: ruff check . + - run: pytest --cov --cov-fail-under=100 From 3ba3621eb586e496d246e9562985136e3c1414fe Mon Sep 17 00:00:00 2001 From: Cong <72737794+robolearning123@users.noreply.github.com> Date: Tue, 24 Mar 2026 08:51:56 -0400 Subject: [PATCH 15/26] docs(lab-robot): add CONTRIBUTING.md Dev setup, robot template instructions, code style, and commit conventions. Co-Authored-By: Claude Opus 4.6 --- CONTRIBUTING.md | 45 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 CONTRIBUTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..bc73893 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,45 @@ +# Contributing to lab-robot + +## Setup + +```bash +git clone https://github.com/labclaw/lab-robot.git +cd lab-robot +pip install -e ".[dev,opentrons]" +``` + +Run tests and lint: + +```bash +make test +make lint +``` + +## Adding a New Robot + +1. Copy `robots/_robot_template/` to `robots//` +2. Implement `RobotDriver` protocol methods: `connect`, `disconnect`, `execute`, `stop` +3. Define supported `ActionType`s and deck/hardware config models +4. Add tests in `robots//tests/` — 100% coverage required +5. Export the driver class from `robots//__init__.py` + +## Code Style + +- **Linter:** ruff with rules E, F, I, N, W, UP (line-length 100) +- **Type hints:** required on all public functions +- **Imports:** `from __future__ import annotations` in every module +- **Schemas:** Pydantic BaseModel for all data structures +- **Coverage:** 100% enforced — CI will fail otherwise + +## Commit Messages + +Use [Conventional Commits](https://www.conventionalcommits.org/): + +``` +feat(scope): add Hamilton STAR driver +fix(ot2): handle missing tip rack gracefully +test(core): add ActionResult edge case tests +docs: update CONTRIBUTING.md +``` + +Scope is the module or robot name. One logical change per commit. From 7eeeef313418cc28fd9d145a1c90815e440adba9 Mon Sep 17 00:00:00 2001 From: Cong <72737794+robolearning123@users.noreply.github.com> Date: Tue, 24 Mar 2026 08:52:24 -0400 Subject: [PATCH 16/26] fix(lab-robot): use importlib.metadata for __version__ + misc cleanups - __version__ reads from package metadata (falls back to 0.1.0) - _SUPPORTED_ACTIONS changed from list to tuple (immutable) - OT2Driver exported from robots.opentrons_ot2 package __init__ - CLAUDE.md: RobotSafetyGuard marked as planned Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 2 +- robots/opentrons_ot2/__init__.py | 4 ++++ robots/opentrons_ot2/driver.py | 2 +- src/lab_robot/__init__.py | 7 ++++++- 4 files changed, 12 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6c94324..867a2b1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,5 +33,5 @@ Same as device-skills: ruff (E,F,I,N,W,UP), line-length 100, type hints required - `RobotDriver` — async Protocol for robot hardware (execute actions, not read data) - `RobotManifest` — extends SkillManifest with robot-specific fields - `ActionResult` — rich result with success, state, measurements, error detail -- `RobotSafetyGuard` — chain-of-responsibility safety (force, workspace, collision) +- `RobotSafetyGuard` — chain-of-responsibility safety (planned: force, workspace, collision) - PEI primitives: Layer 0 (motion), Layer 1 (lab-ops), Layer 2 (perception), Layer 3 (system) diff --git a/robots/opentrons_ot2/__init__.py b/robots/opentrons_ot2/__init__.py index 1dd1d3f..d0c9aae 100644 --- a/robots/opentrons_ot2/__init__.py +++ b/robots/opentrons_ot2/__init__.py @@ -1 +1,5 @@ """Opentrons OT-2 robot driver for lab-robot.""" + +from robots.opentrons_ot2.driver import OT2Driver + +__all__ = ["OT2Driver"] diff --git a/robots/opentrons_ot2/driver.py b/robots/opentrons_ot2/driver.py index 3bac4f9..93cbf6e 100644 --- a/robots/opentrons_ot2/driver.py +++ b/robots/opentrons_ot2/driver.py @@ -21,7 +21,7 @@ from .models import OT2DeckConfig from .protocol_gen import generate_protocol_commands -_SUPPORTED_ACTIONS = [ActionType.PIPETTE] +_SUPPORTED_ACTIONS = (ActionType.PIPETTE,) class OT2Driver: diff --git a/src/lab_robot/__init__.py b/src/lab_robot/__init__.py index 5152f83..27c497d 100644 --- a/src/lab_robot/__init__.py +++ b/src/lab_robot/__init__.py @@ -2,6 +2,8 @@ from __future__ import annotations +from importlib.metadata import PackageNotFoundError, version + from lab_robot.base import RobotDriver, RobotExecutor from lab_robot.safety import SafetyVerdict, WorkspaceBounds, validate_workspace_bounds from lab_robot.schema import RobotCapabilities, RobotCategory, RobotManifest, RobotSafetyLevel @@ -16,7 +18,10 @@ TransferLabwareAction, ) -__version__ = "0.1.0" +try: + __version__ = version("lab-robot") +except PackageNotFoundError: + __version__ = "0.1.0" __all__ = [ "ActionResult", From ed7344149ad7649e477af8bfbd643051304f461b Mon Sep 17 00:00:00 2001 From: Cong <72737794+robolearning123@users.noreply.github.com> Date: Tue, 24 Mar 2026 08:56:26 -0400 Subject: [PATCH 17/26] test(lab-robot): cover __version__ fallback and pragma no-cover Add subprocess test verifying PackageNotFoundError fallback produces "0.1.0". Mark except branch with pragma no-cover since it can't be hit in-process when the package is installed. Co-Authored-By: Claude Opus 4.6 --- src/lab_robot/__init__.py | 2 +- tests/test_base.py | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/lab_robot/__init__.py b/src/lab_robot/__init__.py index 27c497d..f20a81f 100644 --- a/src/lab_robot/__init__.py +++ b/src/lab_robot/__init__.py @@ -20,7 +20,7 @@ try: __version__ = version("lab-robot") -except PackageNotFoundError: +except PackageNotFoundError: # pragma: no cover __version__ = "0.1.0" __all__ = [ diff --git a/tests/test_base.py b/tests/test_base.py index 93ad480..7e24ba3 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -101,3 +101,20 @@ async def test_execute_propagates_driver_failure(self) -> None: action = PipetteAction(volume_ul=30.0, source_well="A1", dest_well="B1") result = await executor.execute(action) assert not result.success + + +def test_version_fallback_on_missing_package() -> None: + """When package metadata is unavailable, __version__ falls back to '0.1.0'.""" + import subprocess + import sys + + code = ( + "import importlib.metadata, unittest.mock as m, sys\n" + "with m.patch('importlib.metadata.version',\n" + " side_effect=importlib.metadata.PackageNotFoundError('x')):\n" + " sys.modules.pop('lab_robot', None)\n" + " import lab_robot\n" + " print(lab_robot.__version__)\n" + ) + result = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True) + assert result.stdout.strip() == "0.1.0" From 7e6720c72c6b53c111ea56a6e72970615ca140bf Mon Sep 17 00:00:00 2001 From: Cong <72737794+robolearning123@users.noreply.github.com> Date: Tue, 24 Mar 2026 16:24:03 -0400 Subject: [PATCH 18/26] chore(lab-robot): ignore uv.lock for library repo --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 0f32ccf..01cf10d 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,4 @@ htmlcov/ dist/ build/ .eggs/ +uv.lock From 3334a23c4a9976e18cd7267e4303c89231c2cf4c Mon Sep 17 00:00:00 2001 From: Cong <72737794+robolearning123@users.noreply.github.com> Date: Tue, 24 Mar 2026 16:26:25 -0400 Subject: [PATCH 19/26] chore(lab-robot): add pre-commit config Co-Authored-By: Claude Opus 4.6 --- .pre-commit-config.yaml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 .pre-commit-config.yaml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..e537b30 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,17 @@ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.9.6 + hooks: + - id: ruff + args: [--fix] + - id: ruff-format + + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: check-yaml + - id: check-toml + - id: end-of-file-fixer + - id: trailing-whitespace + - id: no-commit-to-branch + args: [--branch, main] From 74c71ab35c0e4ef9864dbd1639b74e99488d4f5b Mon Sep 17 00:00:00 2001 From: Cong <72737794+robolearning123@users.noreply.github.com> Date: Tue, 24 Mar 2026 16:26:56 -0400 Subject: [PATCH 20/26] docs(lab-robot): add SECURITY.md + issue/PR templates Co-Authored-By: Claude Opus 4.6 --- .github/ISSUE_TEMPLATE/bug_report.md | 36 ++++++++++++++++++++ .github/ISSUE_TEMPLATE/feature_request.md | 27 +++++++++++++++ .github/PULL_REQUEST_TEMPLATE.md | 19 +++++++++++ SECURITY.md | 41 +++++++++++++++++++++++ 4 files changed, 123 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 SECURITY.md diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..5e48b6f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,36 @@ +--- +name: Bug report +about: Report a bug in lab-robot +title: "bug: " +labels: bug +assignees: "" +--- + +## Bug Description + + + +## Steps to Reproduce + +```python +# Code that triggers the bug +``` + +## Expected Behavior + + + +## Actual Behavior + + + +## Environment + +- Python version: +- lab-robot version: +- OS: +- Robot hardware (if applicable): + +## Additional Context + + diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..46e9ab6 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,27 @@ +--- +name: Feature request +about: Request a new feature or enhancement +title: "feat: " +labels: enhancement +assignees: "" +--- + +## Feature Description + + + +## Motivation + + + +## Proposed Solution + + + +## Alternatives Considered + + + +## Additional Context + + diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..918e369 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,19 @@ +## Description + + + +## Type + +- [ ] `feat` - New feature +- [ ] `fix` - Bug fix +- [ ] `docs` - Documentation only +- [ ] `test` - Tests only +- [ ] `chore` - Build, CI, tooling + +## Checklist + +- [ ] Tests pass (`make test`) +- [ ] Coverage 100% (`pytest --cov --cov-report=term-missing`) +- [ ] Lint clean (`ruff check .`) +- [ ] Documentation updated (if applicable) +- [ ] Conventional commit messages diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..4a0332f --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,41 @@ +# Security Policy + +## Reporting Vulnerabilities + +If you discover a security vulnerability in lab-robot, please report it responsibly. + +**Email:** security@labclaw.org + +Please include: + +- Description of the vulnerability +- Steps to reproduce +- Potential impact +- Any suggested fixes (if available) + +## Responsible Disclosure + +- We will acknowledge receipt within 48 hours +- We will provide an initial assessment within 7 days +- We aim to resolve confirmed vulnerabilities within 30 days +- Credit will be given to the reporter (unless anonymity is requested) + +## Embargo Period + +Security fixes will be coordinated under a 90-day embargo period to allow users to update before details are made public. + +## Supported Versions + +| Version | Supported | +| ------- | ------------------ | +| 0.1.x | :white_check_mark: | + +## Scope + +This policy covers the lab-robot codebase including: + +- Core library (`src/lab_robot/`) +- Robot drivers (`robots/`) +- CI/CD configurations + +Third-party dependencies should be reported to their respective maintainers. From 161b0db50434dec23ad7d4e52108e80dd4c3e23a Mon Sep 17 00:00:00 2001 From: Cong <72737794+robolearning123@users.noreply.github.com> Date: Tue, 24 Mar 2026 16:27:27 -0400 Subject: [PATCH 21/26] chore(lab-robot): add CHANGELOG, VERSION, update pyproject URLs Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 28 ++++++++++++++++++++++++++++ VERSION | 1 + pyproject.toml | 3 +++ 3 files changed, 32 insertions(+) create mode 100644 CHANGELOG.md create mode 100644 VERSION diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..9bba4d1 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,28 @@ +# Changelog + +All notable changes to lab-robot will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.1.0] - 2026-03-24 + +### Added + +- PEI (Physical Execution Interface) protocol specification with 4-layer action hierarchy +- `RobotDriver` async protocol: connect, disconnect, execute, stop, capabilities +- `RobotManifest` extending SkillManifest with robot-specific metadata +- `ActionResult` rich result type with success, state, measurements, error detail +- `ActionType` enum with Layer 0 (motion), Layer 1 (lab-ops), Layer 2 (perception), Layer 3 (system) +- `RobotSafetyGuard` chain-of-responsibility safety framework +- `SafetyLevel` enum (CRITICAL, HIGH, MEDIUM, LOW) +- Opentrons OT-2 driver with simulate mode +- Robot driver template for bootstrapping new hardware +- Example usage and quick-start documentation +- Full public API exports in `__init__.py` +- 100% test coverage with pytest +- Ruff linting and formatting configuration +- GitHub Actions CI workflow (Python 3.11, 3.12, 3.13) +- Pre-commit hooks (ruff, check-yaml, check-toml, no-commit-to-branch) +- Security policy and responsible disclosure guidelines +- PR and issue templates diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..6e8bf73 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/pyproject.toml b/pyproject.toml index 6657bee..a32f9d4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,9 @@ dev = [ [project.urls] Repository = "https://github.com/labclaw/lab-robot" +Documentation = "https://github.com/labclaw/lab-robot/tree/main/docs" +Issues = "https://github.com/labclaw/lab-robot/issues" +Changelog = "https://github.com/labclaw/lab-robot/blob/main/CHANGELOG.md" [tool.hatch.build.targets.wheel] packages = ["src/lab_robot", "robots"] From 7b663f91785945fef800b2d6a637a40e6e8fccde Mon Sep 17 00:00:00 2001 From: Cong <72737794+robolearning123@users.noreply.github.com> Date: Tue, 24 Mar 2026 16:27:50 -0400 Subject: [PATCH 22/26] ci(lab-robot): split lint and test jobs, add pre-commit step Co-Authored-By: Claude Opus 4.6 --- .github/workflows/ci.yml | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2ce21f3..df0df97 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,8 +7,23 @@ on: branches: [main] jobs: + lint: + name: lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - run: pip install -e ".[dev]" + - run: ruff check . + - run: ruff format --check . + - uses: pre-commit/action@v3.0.1 + test: + name: test (py${{ matrix.python-version }}) runs-on: ubuntu-latest + needs: lint strategy: matrix: python-version: ["3.11", "3.12", "3.13"] @@ -18,5 +33,4 @@ jobs: with: python-version: ${{ matrix.python-version }} - run: pip install -e ".[dev]" - - run: ruff check . - run: pytest --cov --cov-fail-under=100 From 130a43881b89a90ba7ed24cece441be0794f215a Mon Sep 17 00:00:00 2001 From: Cong <72737794+robolearning123@users.noreply.github.com> Date: Tue, 24 Mar 2026 16:29:48 -0400 Subject: [PATCH 23/26] docs(lab-robot): enhance README and add PyPI classifiers Co-Authored-By: Claude Opus 4.6 --- README.md | 68 ++++++++++++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 3 +++ 2 files changed, 71 insertions(+) diff --git a/README.md b/README.md index 4d213d7..6249a60 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,37 @@ +[![CI](https://github.com/labclaw/lab-robot/actions/workflows/ci.yml/badge.svg)](https://github.com/labclaw/lab-robot/actions/workflows/ci.yml) +[![Coverage](https://img.shields.io/badge/coverage-100%25-brightgreen)](https://github.com/labclaw/lab-robot) +[![Python](https://img.shields.io/badge/python-3.11%2B-blue)](https://www.python.org) +[![License](https://img.shields.io/badge/license-Apache%202.0-blue)](LICENSE) +[![Code style: ruff](https://img.shields.io/badge/code%20style-ruff-000000.svg)](https://github.com/astral-sh/ruff) + # lab-robot Physical Execution Interface for science labs — connect AI brains to robot bodies. Part of the [LabClaw](https://github.com/labclaw/labclaw) ecosystem. +## Why lab-robot? + +Science labs need to run experiments physically, not just plan them in software. lab-robot +provides a unified driver layer that lets AI agents execute real-world lab operations — +pipetting, plate handling, incubation — across different robot hardware through a single +protocol. No more writing throwaway scripts for each new machine. + +## Features + +- **Unified PEI Protocol** — abstract robot actions into hardware-agnostic primitives (motion, lab-ops, perception, system) +- **Rich ActionResult** — every action returns structured results with measurements, state changes, and error details +- **Safety-First** — chain-of-responsibility safety guards (force limits, workspace bounds, collision detection) at CRITICAL level by default +- **Async-Native** — built on Python async for concurrent robot orchestration +- **Typed & Validated** — full Pydantic 2.x schemas and PEP 561 type markers +- **Extensible** — add new robots by implementing the `RobotDriver` protocol + +## Supported Robots + +| Robot | Status | Mode | +|-------|--------|------| +| Opentrons OT-2 | Active development | Simulate | + ## Install ```bash @@ -42,10 +70,50 @@ async def main(): asyncio.run(main()) ``` +## Adding a New Robot + +1. Copy `robots/_robot_template/` to `robots//` +2. Implement the `RobotDriver` protocol (`connect`, `disconnect`, `execute`, `stop`, `capabilities`) +3. Add your package to `pyproject.toml` optional dependencies + +See [CONTRIBUTING.md](CONTRIBUTING.md) for the full guide. + +## Ecosystem + +``` +labclaw (orchestration) +├── lab-robot ← you are here (physical execution) +├── device-use (GUI & visual interaction) +└── device-skills (device drivers & skills) +``` + +lab-robot is the **Physical Execution Interface (PEI)** — Layer 1 of the LabClaw stack. +It translates high-level lab operations into hardware-specific commands. + ## Architecture See [PEI Specification](docs/pei-spec.md) for the full protocol design. +## Roadmap + +- **Phase 0** — Opentrons OT-2 simulate mode (current) +- **Phase 1** — OT-2 real hardware + safety guards +- **Phase 2** — Multi-robot orchestration + perception layer + +## Citation + +If you use lab-robot in your research, please cite: + +```bibtex +@software{labclaw_lab_robot, + author = {LabClaw Team}, + title = {lab-robot: Physical Execution Interface for Science Labs}, + year = {2025}, + url = {https://github.com/labclaw/lab-robot}, + license = {Apache-2.0} +} +``` + ## License Apache 2.0 diff --git a/pyproject.toml b/pyproject.toml index a32f9d4..cde81ee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,9 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Topic :: Scientific/Engineering", + "Topic :: Scientific/Engineering :: Bio-Informatics", + "Typing :: Typed", ] dependencies = [ "pydantic>=2.0.0,<3.0.0", From e4dc5b656b478eb47318038bfe74a11524c5039b Mon Sep 17 00:00:00 2001 From: Cong <72737794+robolearning123@users.noreply.github.com> Date: Tue, 24 Mar 2026 16:30:12 -0400 Subject: [PATCH 24/26] docs(lab-robot): add CODE_OF_CONDUCT.md Contributor Covenant v2.1 with conduct@labclaw.org contact. Co-Authored-By: Claude Opus 4.6 --- CODE_OF_CONDUCT.md | 117 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 CODE_OF_CONDUCT.md diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..0c8e07d --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,117 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our community a +harassment-free experience for everyone, regardless of age, body size, visible or invisible +disability, ethnicity, sex characteristics, gender identity and expression, level of experience, +education, socio-economic status, nationality, personal appearance, race, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, +and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, and learning + from the experience +* Focusing on what is best not just for us as individuals, but for the overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, without their + explicit permission +* Other conduct which could reasonably be considered inappropriate in a professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of acceptable +behavior and will take appropriate and fair corrective action in response to any behavior that +they deem inappropriate, threatening, offensive, or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject comments, +commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of +Conduct, and will communicate reasons for moderation decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when an individual +is officially representing the community in public spaces. Examples of representing our +community include using an official e-mail address, posting via an official social media account, +or acting as an appointed representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the +community leaders responsible for enforcement at conduct@labclaw.org. All complaints will be +reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the reporter of any +incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining the consequences +for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or +unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing clarity around +the nature of the violation and an explanation of why the behavior was inappropriate. A public +apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of actions. + +**Consequence**: A warning with consequences for continued behavior. No interaction with the +people involved, including unsolicited interaction with those enforcing the Code of Conduct, +for a specified period of time. This includes avoiding interactions in community spaces as well +as external channels like social media. Violating these terms may lead to a temporary or +permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including sustained +inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public communication with the +community for a specified period of time. No public or private interaction with the people +involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed +during this period. Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community standards, including +sustained inappropriate behavior, harassment of an individual, or aggression toward or +disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, +available at [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder][moz]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][faq]. Translations are available at +[https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[moz]: https://github.com/mozilla/diversity +[faq]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations From 61ec1a5fa86f520b82a58bbc82b11bb7571beb57 Mon Sep 17 00:00:00 2001 From: Cong <72737794+robolearning123@users.noreply.github.com> Date: Tue, 24 Mar 2026 16:31:21 -0400 Subject: [PATCH 25/26] chore(lab-robot): add dependabot, CODEOWNERS, FUNDING, py.typed - dependabot.yml: weekly pip dependency updates - CODEOWNERS: core-team for root, robot-drivers for opentrons - FUNDING.yml: GitHub Sponsors link - py.typed: PEP 561 inline type marker Co-Authored-By: Claude Opus 4.6 --- .github/CODEOWNERS | 2 ++ .github/FUNDING.yml | 1 + .github/dependabot.yml | 9 +++++++++ src/lab_robot/py.typed | 0 4 files changed, 12 insertions(+) create mode 100644 .github/CODEOWNERS create mode 100644 .github/FUNDING.yml create mode 100644 .github/dependabot.yml create mode 100644 src/lab_robot/py.typed diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..22abafa --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,2 @@ +* @labclaw/core-team +robots/opentrons_ot2/ @labclaw/robot-drivers diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..a6de947 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +github: labclaw diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..4f1f02c --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,9 @@ +version: 2 +updates: + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "weekly" + labels: + - "dependencies" + - "automated" diff --git a/src/lab_robot/py.typed b/src/lab_robot/py.typed new file mode 100644 index 0000000..e69de29 From 317a50b50dcc35d4b7ee8a44736c9ce8500088d6 Mon Sep 17 00:00:00 2001 From: Cong <72737794+robolearning123@users.noreply.github.com> Date: Tue, 24 Mar 2026 22:14:54 -0400 Subject: [PATCH 26/26] =?UTF-8?q?fix(lab-robot):=20citation=20year=202025?= =?UTF-8?q?=E2=86=922026,=20add=20defensive=20.gitignore=20entries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 6 ++++++ README.md | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 01cf10d..d8148ca 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,9 @@ dist/ build/ .eggs/ uv.lock + +# Secrets (defensive) +.env +.env.* +*.pem +*.key diff --git a/README.md b/README.md index 6249a60..c8bd8eb 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,7 @@ If you use lab-robot in your research, please cite: @software{labclaw_lab_robot, author = {LabClaw Team}, title = {lab-robot: Physical Execution Interface for Science Labs}, - year = {2025}, + year = {2026}, url = {https://github.com/labclaw/lab-robot}, license = {Apache-2.0} }