# Welcome to UniteLabs UniteLabs connects lab instruments to one common interface. Each instrument is paired with a connector that exposes its functions as typed actions that you can list, inspect, and call: from Python, over the REST API, or in the platform's web interface, with no vendor-specific code on your side. Workflows written in Python coordinate several instruments, execute on the platform, and record every run with its inputs, logs, and results. Calling a connected instrument from Python: ```python [first_script.py] import asyncio from unitelabs.sdk import AsyncApiClient from unitelabs.liquid_handling.hamilton import MicrolabSTAR async def main(): # Connect by the name the instrument carries in your workspace # AsyncApiClient() reads BASE_URL, AUTH_URL, and the client credentials from .env hamilton = MicrolabSTAR(name="Microlab STAR", client=AsyncApiClient()) await hamilton.initialize() # tip_rack and plate come from your deck layout await hamilton.pipettes.pick_up_tips_from(channels=[0], rack=tip_rack) await hamilton.pipettes.aspirate(plate["A1"], channels=[0], volume=[50]) await hamilton.pipettes.dispense(plate["B1"], channels=[0], volume=[50]) asyncio.run(main()) ``` The same pattern works for any connected instrument, a thermocycler, a plate reader, a balance, a liquid handler. The [use cases](https://docs.unitelabs.io/get-started/use-cases/low-level-instrument-control/) show where it leads, from a single device to workflows deployed across several workcells. ## The parts of the system | Part | What it is | What it does | | ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Connector** | A small server, one per instrument, that speaks the device's own language over serial, TCP, or USB. | Exposes the instrument's functions through SiLA 2, the open standard for calling instrument functions, so Python, the REST API, and the web interface all reach the instrument the same way. | | **GroundControl** | The UniteLabs application you install on each lab computer wired to instruments. | Downloads connectors from the UniteLabs registry, lets you configure them, then runs them and links them to your tenant. | | **UniteLabs Platform** | Your organization's cloud workspace, called a tenant, as a web application you reach at its own URL. | The control center: shows every connected device with its live status, runs single actions from a form without code, and tracks deployed workflows and their runs with logs, results, and sample lineage. Also holds secrets, stored files, and a built-in Jupyter notebook. | | **UniteLabs SDK** | Three Python packages: `unitelabs-sdk` with the API client and the tools for building workflows, `unitelabs-liquid-handling` with typed classes for Hamilton and Agilent liquid handlers, `unitelabs-labware` with plates, tips, and deck layouts. | What your code builds on, from a single instrument call up to a whole workflow. | | **REST API** | The HTTPS API of your tenant, with a browsable Swagger UI. | Reaches the same functions from any language: call an instrument, start a workflow run, poll its status, fetch its results, subscribe to its lineage events. | | **Connector Development Kit (CDK)** | An open-source Python framework. | Builds a connector for an instrument that has none yet. | These parts sit in three places: - **In the lab**: the connectors and GroundControl, on the computer wired to your instruments. - **On your machine**: the scripts and workflows you write in your own editor, and from which you deploy workflows. - **In the cloud**: your tenant, which the SDK, the REST API, and the web interface all talk to. The lab always connects outward with one encrypted connection from the lab computer to your tenant, so no inbound ports need to be opened. Without that connection a connector still works, but only from inside the lab network. See [How it works](https://docs.unitelabs.io/get-started/how-it-works/) and [Network requirements](https://docs.unitelabs.io/get-started/setup/network-requirements/). ## Set up your lab in four steps Your workspace, called a tenant, and the credentials for it come from UniteLabs. [Get access](https://docs.unitelabs.io/get-started/setup/get-access/) lists the five things you receive, where each one goes, and what to have ready on your side. 1. [Install GroundControl](https://docs.unitelabs.io/get-started/setup/groundcontrol/) on the lab computer wired to your instruments. 2. [Add the instrument in GroundControl](https://docs.unitelabs.io/integrate/connect-a-device/connector-configuration/) and pick its connector: browse the UniteLabs registry and download the one for your instrument, and GroundControl fetches the build matching your operating system. The [UniteLabs Hub](https://unitelabs.io/hub){rel=""nofollow,noopener""} shows publicly what exists. One configuration covers both how the connector reaches the instrument and [how it reaches your tenant](https://docs.unitelabs.io/get-started/setup/connect-to-platform/). 3. [Prepare your development machine](https://docs.unitelabs.io/get-started/setup/developer-machine/), then [install the SDK](https://docs.unitelabs.io/get-started/setup/sdk-installation/). The registry credentials fetch the packages, the client credentials let your code authenticate. 4. Run your first script: [Calling a connector](https://docs.unitelabs.io/integrate/control-with-code/) discovers what your instrument exposes and calls it. ## The documentation, section by section The four middle sections follow the life of your lab code: connect instruments, control them, turn scripts into workflows, work with the data. ### Integrate: connect your instruments Start with [What is a connector?](https://docs.unitelabs.io/integrate/what-is-a-connector/), or go straight to [Connect a device](https://docs.unitelabs.io/integrate/connect-a-device/connector-configuration/). This section covers what a connector is, the ways to run one, and how to call it from GroundControl, the web interface, or Python, including listing a connector's modules and actions, reading properties, and subscribing to sensor values that stream while an instrument runs. ### Operate: control instruments from Python Start with [Your first protocol](https://docs.unitelabs.io/operate/your-first-protocol/), which builds the deck layout the snippet above leaves out and runs a full transfer. The [Operate overview](https://docs.unitelabs.io/operate/overview/) lists the rest: labware definitions, deck layout, pipetting, tip handling, labware transport, and guides for Hamilton STAR and Vantage, Agilent Bravo, and Tecan Fluent. ### Automate: turn scripts into workflows Start with [What is a workflow?](https://docs.unitelabs.io/automate/what-is-a-workflow/) or [Your first workflow](https://docs.unitelabs.io/automate/your-first-workflow/). This section grows a script like the one above into a **workflow** that the workflow engine executes and tracks for you: structuring a process so it can resume from a safe point instead of starting over, pausing it where an operator confirms or fills in a form, and deploying the same Python file you ran on your machine to the platform. Every execution becomes a **run** you can follow live and look up later, with its inputs, logs, and results. ### Observe: work with your data Start with the [Data overview](https://docs.unitelabs.io/observe/overview/). Raw files land in object storage, processed results in a queryable data warehouse, credentials in the built-in secrets store. Query results with standard database tools or build pipelines that process data as it arrives. ### The remaining sections - **Get Started**, this section: [How it works](https://docs.unitelabs.io/get-started/how-it-works/), the [use cases](https://docs.unitelabs.io/get-started/use-cases/low-level-instrument-control/), [why lab methods belong in code](https://docs.unitelabs.io/get-started/why-code/), and the setup guides behind the four steps above. - **Reference**: the [REST API](https://docs.unitelabs.io/technical-reference/rest-api/) and the changelog of every UniteLabs package. - **CDK**: [building your own connector](https://docs.unitelabs.io/connector-development/getting-started/overview/) for an instrument that has none yet. ## Resources and help The [UniteLabs Hub](https://unitelabs.io/hub){rel=""nofollow,noopener""} lists the connectors that already exist, so check there before building one. For technical problems and for anything wrong in these docs, write to ; for access, credentials, and commercial questions, talk to your UniteLabs contact or . [Support](https://docs.unitelabs.io/support/) lists what each channel covers. # How it works The UniteLabs Platform is built around a clean separation of concerns. Rather than point-to-point integrations, every instrument is connected once and then available to everything else through a unified API — no vendor-specific code needed downstream. ![Platform architecture](https://docs.unitelabs.io/images/platform_architecture.webp) ## Connectors Every instrument connects to the platform exactly once through a **Connector** — a driver running on the local machine paired with an edge gateway connecting to the cloud. Once connected, the instrument is available via the SDK, REST API, or Platform UI. A Connector consists of a **driver** (running on a machine physically connected to the device) and an **edge gateway** (connecting to the cloud endpoint). A connector bundles these together into a standalone application that you can download and run with our edge application GroundControl. Connectors are available for multiple operating systems, including Windows, macOS, and Linux. The [Connector Development Kit (CDK)](https://docs.unitelabs.io/connector-development/getting-started/overview/) lets you build a connector for any instrument in pure Python. → [What is a connector?](https://docs.unitelabs.io/integrate/what-is-a-connector/) · [Build a connector (CDK)](https://docs.unitelabs.io/connector-development/getting-started/overview/) ## SDK The SDK is a suite of currently three packages for controlling connected instruments from Python. We are expanding the list as we add abstraction layers for other types of instruments: | Component | Package | Role | | ----------------------- | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **UniteLabs SDK** | `unitelabs-sdk` | Platform connectivity — programmatic access to all API endpoints including devices, workflows, runs, databases, secrets and more. | | **Liquid Handling SDK** | `unitelabs-liquid-handling` | Abstraction layer for liquid handling — unifies Hamilton, Bravo, and others behind one Python API. Uses the UniteLabs SDK under the hood and is enhanced by the labware library. | | **Labware Library** | `unitelabs-labware` | Predefined labware geometries (plates, tips, carriers) and utilities for custom labware | Install all three packages for liquid-handling workflows. For device control without liquid handling, install only `unitelabs-sdk`, following the [installation guide](https://docs.unitelabs.io/get-started/setup/sdk-installation/). → [SDK installation](https://docs.unitelabs.io/get-started/setup/sdk-installation/) · [Control with code](https://docs.unitelabs.io/integrate/control-with-code/) ## Orchestration For multi-step, multi-instrument experiments, workcells and AI-driven automation, the **Automate** layer provides a structured workflow engine. Workflows are defined in Python as a hierarchy: - **Workflow**: the top-level process, defined by the scientific result it produces. - **Phase**: a group of steps that ends in a stable state a run can resume from. - **Step**: a single action on one device. It completes or fails as a whole. The engine tracks every execution as a **run**, and handles scheduling, operator pauses (**human in the loop**), and parallel phase execution automatically. Workflows are versioned in Git and testable against simulated devices in CI. → [What is a workflow?](https://docs.unitelabs.io/automate/what-is-a-workflow/) · [Your first workflow](https://docs.unitelabs.io/automate/your-first-workflow/) ## Data Layer Raw instrument output feeds into a layered data infrastructure — no manual storage management required. Our File System Connector makes it possible to store and acquire data in a variety of locations, including local storage, cloud storage, and more: ```text Instrument output → File System Connector → Object Storage (S3/MinIO) ↓ ETL Workflow (Workflow Engine) ↓ Data Warehouse (PostgreSQL) ``` Each layer is independently accessible: analysts can query the warehouse directly from any Postgres client; raw files remain in object storage for reprocessing at any time. Credentials are managed through the built-in Secrets system. → [Data overview](https://docs.unitelabs.io/observe/overview/) · [Building an ETL](https://docs.unitelabs.io/observe/guides/building-an-etl/) # Instrument Control Most lab instruments ship with proprietary software that ties you to a specific vendor's ecosystem. Scripting requires gaining access to closed APIs, fragile integrations, and endless vendor support tickets. UniteLabs replaces that with a clean Python interface to every connected device. We work together with the vendors to get there! ## What this unlocks - **Python-native control**: call any instrument command from a workflow, script, or Jupyter notebook - **No vendor GUI required**: automate instruments that normally require manual interaction via the vendor software - **Introspect available commands**: discover what a device can do at runtime. Claude knows what your lab can do. - **Async by default**: non-blocking calls so your script can do other work while an instrument is busy. Don't worry, we also support a Sync client if you prefer. ## How it works Every instrument in UniteLabs is exposed as a **connector**: a standardized software interface that maps the device's native protocol to a uniform homogeneous integration layer that we expose with our UniteLabs SDK and REST API. Once a connector is running and connected to your platform tenant, you can control the instrument from any Python script with network access: ```python [control.py] import asyncio from unitelabs.sdk import AsyncApiClient from unitelabs.liquid_handling.hamilton import MicrolabSTAR from unitelabs.labware import Standard96Plate from unitelabs.labware.hamilton import PLT_CAR_L5MD_A00, TIP_CAR_480_A00, HamiltonTipRack_300, HamiltonTip_300_Filter async def main(): client = AsyncApiClient() # Connect to the liquid handler by name hamilton = MicrolabSTAR(name="Hamilton STAR", client=client) await hamilton.initialize() # Define the deck layout tip_carrier = TIP_CAR_480_A00() tip_carrier[0] = tip_rack_0 = HamiltonTipRack_300(filled_with=HamiltonTip_300_Filter()) plate_carrier = PLT_CAR_L5MD_A00() plate_carrier[0] = plate_0 = Standard96Plate() hamilton.deck.add(tip_carrier, track=1) hamilton.deck.add(plate_carrier, track=3) # Pick up tips, aspirate from column 1, dispense into column 2 await hamilton.pipettes.pick_up_tips_from(tip_rack_0) await hamilton.pipettes.aspirate(plate_0["A1:H1"], volume=50) await hamilton.pipettes.dispense(plate_0["A2:H2"], volume=50) await hamilton.pipettes.discard_tips() asyncio.run(main()) ``` The SDK generates the code for your instruments dynamically — if a new device is connected to the platform, it appears in `list_services()` without any implementation effort on your part. ## When to use this Low-level control is the right starting point when you want to: - Automate a single instrument that currently requires manual operation - Write a quick script or notebook to collect data from a device - Explore what a connector exposes before building a larger workflow - Integrate an instrument into an existing Python data pipeline For coordinating multiple instruments together, see [Multi-device Control](https://docs.unitelabs.io/get-started/use-cases/multi-device-control/). For fully tracked and reproducible runs, see [Workflow Orchestration](https://docs.unitelabs.io/get-started/use-cases/workflow-orchestration/). ## Next steps - [Set up your environment](https://docs.unitelabs.io/get-started/setup/sdk-installation/): install the SDK and connect to your platform tenant - [Connect an instrument](https://docs.unitelabs.io/integrate/what-is-a-connector/): add a device to UniteLabs - [Call a connector](https://docs.unitelabs.io/integrate/control-with-code/): guide on using device actions and reading responses # Multi-device Control Real experiments rarely involve a single instrument. A typical assay might require a liquid handler, a thermocycler, a plate reader, and a shaker — each from different vendors, each with its own software. Coordinating them usually means manual handoffs, rigid schedules, or brittle point-to-point integrations. UniteLabs gives every instrument a uniform Python interface. Coordinating five devices looks the same as coordinating one. ## What this unlocks - **Unified API across vendors**: the same Python patterns regardless of instrument brand - **Concurrent execution**: run multiple instruments in parallel with `asyncio.gather` - **One workflow per workcell**: a single Python project coordinates your entire setup - **Consistent observability**: logging, status, and error handling work the same across all devices ## How it works Each connected instrument becomes a Python object. You can call them sequentially or concurrently — the SDK is fully async. A sync client is also available if you prefer. ```python [workcell.py] import asyncio from unitelabs.sdk import AsyncApiClient async def main(): client = AsyncApiClient() # Connect to each instrument by name liquid_handler = await client.get_service_by_name("Hamilton STAR") thermocycler = await client.get_service_by_name("Thermocycler") plate_reader = await client.get_service_by_name("Plate Reader") # Sequential steps await liquid_handler.iswap.transfer(source=plate_1, destination=thermocycler[0]) # Parallel steps — start thermocycler and open a plate reader stage at the same time await asyncio.gather( thermocycler.temperature_controller.set_target_temperature(target_temperature=37), plate_reader.stage_controller.open(), ) asyncio.run(main()) ``` Because all devices share the same connectivity layer, you get consistent error handling, logging, and observability across your whole workcell — not just per instrument. ## When to use this Multi-device control is the right approach when: - Your protocol involves handoffs between two or more instruments - You want to run instruments in parallel to reduce cycle time - You're replacing a scheduler or manual coordination with code For fully reproducible, tracked runs with versioned inputs and outputs, see [Workflow Orchestration](https://docs.unitelabs.io/get-started/use-cases/workflow-orchestration/). ## Next steps - [Set up your environment](https://docs.unitelabs.io/get-started/setup/sdk-installation/) - [Device guides](https://docs.unitelabs.io/device-guides/overview/): instrument-specific code patterns - [Error handling and recovery](https://docs.unitelabs.io/operate/concepts/error-handling/) # Workflow Orchestration A workflow written in a scheduler GUI, a spreadsheet, or an instrument script lives outside your normal software practices. It can't be version-controlled, reviewed in a PR, tested automatically, or deployed reliably across locations. UniteLabs lets you define workflows as Python code. They run on the UniteLabs automation engine, are tracked end-to-end, and behave exactly the same whether you trigger them once or a thousand times. ## What this unlocks - **Workflows in Git**: version-control your experiment logic alongside your analysis code - **CI/CD compatible**: test workflow logic against simulated devices in CI before running on real hardware - **Reproducible runs**: every run is tracked with inputs, logs, and output artifacts - **Workcell-aware scheduling (WIP)**: the engine tracks which instruments are in use; idle devices can be picked up by other workflows running in the same workcell, increasing throughput without manual scheduling - **Unattended operation**: reliable recovery and retry logic suitable for overnight or weekend runs ## How it works A workflow is a decorated Python function that calls phases. A phase is a group of steps that ends in a stable state a run can resume from. The orchestrator handles scheduling, device allocation, and run tracking. ```python [cell_viability.py] import asyncio from prefect import flow from unitelabs.sdk import AsyncApiClient @flow() # phase async def seed_plate(): client = AsyncApiClient() hamilton = await client.get_service_by_name("Hamilton STAR") await hamilton.initialize() ... # dispense cells into 96-well plate @flow() # phase async def add_reagent(): client = AsyncApiClient() hamilton = await client.get_service_by_name("Hamilton STAR") ... # dispense CCK-8 reagent @flow() # phase async def incubate(hours: float = 2.0): client = AsyncApiClient() incubator = await client.get_service_by_name("CO2 Incubator") ... # hold at 37 °C for the given duration @flow() # phase async def measure() -> dict: client = AsyncApiClient() reader = await client.get_service_by_name("Plate Reader") ... # read absorbance at 450 nm return results @flow(name="Cell Viability Assay") # workflow async def cell_viability_assay(): await seed_plate() await incubate(hours=24.0) # cells settle overnight await add_reagent() await incubate(hours=2.0) # CCK-8 reaction window return await measure() asyncio.run(cell_viability_assay()) ``` Workflows are registered with the platform and can be triggered via the [REST API](https://docs.unitelabs.io/automate/guides/workflows-api/), the [Platform UI](https://docs.unitelabs.io/automate/guides/run-a-workflow/), or on a schedule. ## Workcell scheduling The orchestrator knows which instruments each running workflow has claimed. When a device finishes its current phase and becomes idle, the engine can make it available to another workflow in the same workcell — no manual coordination needed. ::callout{icon="i-heroicons-information-circle"} Explicit resource constraints and conflict resolution are configured per workflow. Detailed documentation is coming soon. :: ## When to use this Workflow orchestration is the right approach when: - You need reproducible, auditable runs (GxP, SOPs, tech transfer) - You want to run a protocol repeatedly with different inputs - Your workcell is shared across multiple teams or experiments - You need reliable unattended or long-running operations ## Next steps - [What is a workflow?](https://docs.unitelabs.io/automate/what-is-a-workflow/): core concepts - [Set up a workflow in Python](https://docs.unitelabs.io/automate/your-first-workflow/) - [Run a workflow via the API](https://docs.unitelabs.io/automate/guides/workflows-api/) # Custom Apps and LIMS Integration Lab software rarely lives in a single system. Sample data is in your LIMS (Benchling, LabVantage, or similar), experimental requests come through internal portals, and results need to flow back automatically. UniteLabs exposes a full REST API so any external system can trigger automation and receive results — no manual handoff required. ## What this unlocks - **Trigger workflows from your LIMS**: submit a sample ID, get results back automatically - **Return data to external systems**: artifacts and outputs of runs are accessible via API - **Build custom scientist-facing UIs**: front your workcell with a purpose-built application - **Event-driven automation**: trigger runs in response to upstream events in your data pipeline or vice versa ## How it works The UniteLabs REST API exposes endpoints for managing devices, triggering workflow runs, and retrieving results. Any system that can make HTTP requests can integrate — no SDK required. ```python [lims_integration.py] import httpx UNITELABS_API = "https://{tenant_id}.unitelabs.io/v1" HEADERS = {"Authorization": f"Bearer {API_TOKEN}"} def fetch_workflow_by_name(name: str) -> str: response = httpx.get( f"{UNITELABS_API}/workflows", headers=HEADERS, params={"name[eq]": name}, ) return response.json()["data"][0]["id"] # Resolve workflow by name, then trigger a run with sample metadata from your LIMS workflow_id = fetch_workflow_by_name("cell-viability-assay") response = httpx.post( f"{UNITELABS_API}/workflows/{workflow_id}/runs", headers=HEADERS, json={ "parameters": { "sample_id": "SMP-00123", "plate_barcode": "PLT-8821", } }, ) run_id = response.json()["id"] # Poll for completion and retrieve artifacts result = httpx.get( f"{UNITELABS_API}/runs/{run_id}/artifacts", headers=HEADERS, ) print(result.json()) ``` For richer integrations — like subscribing to real-time instrument events or reading sensor streams — the UniteLabs SDK can also be embedded inside your application server. ## Common integration patterns **LIMS → UniteLabs → LIMS**: A sample registered in Benchling triggers a workflow run. Results (absorbance values, pass/fail, raw data) are written back to the Benchling entry automatically. **Custom scheduling portal → UniteLabs**: An internal tool queues runs based on team priority. UniteLabs executes them in order and returns status, making it available to scientists through your own UI. **Data pipeline trigger**: When a pre-processing step completes upstream, it triggers an assay run via the REST API and waits for the artifact before continuing downstream analysis. ## When to use this LIMS integration is the right approach when: - Scientists should not need to interact with UniteLabs directly - Sample context (IDs, metadata, plate maps) lives in another system - Results must be returned to a LIMS, ELN, or data warehouse automatically - You want a custom UI tailored to your team's workflow ## Next steps - [REST API reference](https://docs.unitelabs.io/technical-reference/rest-api/) - [Run a workflow via the API](https://docs.unitelabs.io/automate/guides/workflows-api/) # Scale and Fleet Management As automation scales — from one workcell to ten, from one site to three — the challenge shifts from "does this work?" to "how do we keep it consistent?" Manual deployment, per-workcell configuration drift, and undocumented site-specific tweaks compound into operational debt that makes scaling painful. UniteLabs treats automation like software: workflows are versioned, deployments are repeatable, and code is shared across workcells. ## What this unlocks - **One codebase, many workcells**: deploy the same workflow to multiple sites from a single repository - **Version-controlled deployments**: roll back a workflow the same way you roll back software - **CI/CD for lab automation**: test against simulated devices in CI before pushing to production - **Centralized observability**: all runs across all sites visible in one place - **Consistent rollouts**: deploy a connector or workflow update to every workcell in a single operation - **Sharing the work**: share labware definitions, liquid classes, and a library of reusable code with other teams ## When to use this Fleet management is the right approach when: - You operate automation across more than one workcell or location - You need consistent, auditable workflows across sites (tech transfer, multi-site studies) - You want CI/CD practices applied to lab automation - You need centralized visibility into what's running across your fleet ## Next steps - [Workflow Orchestration](https://docs.unitelabs.io/get-started/use-cases/workflow-orchestration/): how workflows are defined - [Run a workflow via the API](https://docs.unitelabs.io/automate/guides/workflows-api/) - [GroundControl: install and setup](https://docs.unitelabs.io/get-started/setup/groundcontrol/) # Lab as Code A standard operating procedure in a Word document drifts. Different operators paste it into different files, add handwritten edits, and run slightly different versions without realizing it. By the time something goes wrong, it is hard to know which version ran. A lab method written in Python has one authoritative version. It runs identically every time. It can be reviewed in a pull request and audited from a log file. ## What this unlocks - **The script is the documentation**: it shows exactly what happened, in what order, with what parameters - **Reproduce any run exactly**: check out the commit, run the script with the same inputs - **Catch errors before touching samples**: type checking and linting work on lab code just like any other Python script - **Integrate with anything**: LIMS, databases, dashboards, Slack — because it is just Python ## How it works Consider a single protocol step. As a written SOP it reads: ```text # Manual SOP — Step 4 Set incubator to 37°C. Wait for temperature to stabilize. Confirm visually. ``` In Python: ```python await incubator.heating_controller.set_target_temperature(target_temperature=37) await incubator.heating_controller.wait_for_temperature() status = await incubator.heating_controller.get_status() assert status.at_setpoint, f"Incubator not at setpoint: {status.current_temperature}°C" ``` The code version is unambiguous, executable, and verifiable. It does not depend on who is reading it or how carefully. ## The compounding benefits **Version control** Every change to your method is a commit. `git log` tells you which version ran on Tuesday. `git diff` shows exactly what changed between the run that worked and the one that didn't. For regulated environments, this is an audit trail you did not have to build separately. **Reuse** Write a dilution helper once, import it everywhere. Utility functions for common operations — normalizing OD readings, reformatting plate maps, calculating volumes — accumulate into a shared library your whole team uses. **Testing** You can write unit tests against your method logic using mock connectors before touching any hardware. Catch logic errors — wrong well positions, off-by-one loops, incorrect volume calculations — before a sample is in the machine. **Auditability** Because you control the output format, every run can write a structured log that satisfies traceability requirements. The log is not a PDF export from proprietary software — it is an event stream and a JSON file you own and can query. ## When to use this A GUI might be faster for a genuine one-off. If you will run a method more than a few times, or if reproducibility matters, the code approach pays for itself quickly. The threshold is lower than it looks — a method you run twice already benefits from having a version you can diff and replay. ## Next steps - [Operate](https://docs.unitelabs.io/operate/overview/): the four-stage structure of a lab method - [Error Handling](https://docs.unitelabs.io/operate/concepts/error-handling/): make your scripts resilient to device failures # Get access Your instruments, workflows, and data live in a workspace that belongs to your organization alone. UniteLabs calls it a **tenant**, sets it up for you, and hands over five things. There is no self-service signup yet: everything on this list comes from your UniteLabs contact. Most tenants run in the UniteLabs cloud. The platform can also run on your organization's own infrastructure, in your cloud or on your own servers. Ask your UniteLabs contact about hosting options. | What you receive | Where it goes | | -------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Tenant details**, the ID and the web address | Signing in: the web interface and [GroundControl's settings](https://docs.unitelabs.io/get-started/setup/groundcontrol/) ask for the tenant ID to find your workspace. | | **A user account** | Signing in to GroundControl and to the web interface. | | **API client credentials**, a client ID and secret | The `.env` file of your project, so your own code can authenticate against your tenant. First needed in [SDK installation](https://docs.unitelabs.io/get-started/setup/sdk-installation/). | | **A download password** | The [GroundControl download page](https://unitelabs.io/downloads){rel=""nofollow,noopener""}. | | **Registry credentials**, a username and password | Your `~/.netrc` file (uv, pip) or the Poetry config, so the UniteLabs Python packages install from the private registry. Also covered in [SDK installation](https://docs.unitelabs.io/get-started/setup/sdk-installation/). | ## What you need on your side - **A lab computer** wired to your instruments over serial, TCP, or USB. GroundControl runs here. A regular machine with macOS, Windows, or Linux works; for computers without a display there is a [headless install](https://docs.unitelabs.io/integrate/connect-a-device/headless-install/). - **A development machine** where you write code, with Python 3.10 or newer. Workflows need Python 3.12 or newer. Details in [Set up your development machine](https://docs.unitelabs.io/get-started/setup/developer-machine/). - **An outbound network path**: the lab computer connects out to your tenant over one encrypted connection; nothing from outside needs to reach into the lab network. Details in [Network requirements](https://docs.unitelabs.io/get-started/setup/network-requirements/). Not set up with UniteLabs yet, or missing one of the five? Your UniteLabs contact is the fastest route, otherwise write to . Once everything is at hand, start with [Install GroundControl](https://docs.unitelabs.io/get-started/setup/groundcontrol/). # GroundControl GroundControl is a desktop application that runs on a local machine in your lab. It acts as the secure edge gateway between your physical instruments and the UniteLabs cloud, hosting and managing connector executables, executing real-time commands, and streaming telemetry back to the platform. You need one GroundControl installation per edge machine (i.e., per machine that is physically connected to lab instruments). ::callout{icon="i-heroicons-computer-desktop"} GroundControl is the GUI path, best for workstations and lab PCs. For headless Linux servers, Raspberry Pi, or embedded devices, see [Headless install](https://docs.unitelabs.io/integrate/connect-a-device/headless-install/) instead. :: ## System Requirements | Platform | Requirement | | ----------- | ----------------------------------------------------------------------------------------------------- | | **macOS** | macOS 10.15 (Catalina) or later — Intel and Apple Silicon | | **Windows** | Windows 10 (64-bit) or later | | **Linux** | Ubuntu 22.04 AMD64, Raspberry Pi OS 64-bit (RPi 4/5), or any modern distro with a desktop environment | ::callout{icon="i-heroicons-information-circle"} Some connectors rely on Windows-based components of the instrument software. These connectors are only available for download in the Windows version of GroundControl. We are working hard on porting all components to become fully OS-agnostic. :: ## Download ::callout{icon="i-heroicons-lock-closed"} The download page is password protected. Ask your UniteLabs contact for the current password. :: Download the latest release of GroundControl for Windows, macOS, and Linux from our download page: [Download GroundControl](https://unitelabs.io/downloads){rel=""nofollow,noopener""} ## Install ::tabs :::div{icon="i-simple-icons-apple" label="macOS"} Download and open the universal DMG (supports both Intel and Apple Silicon). Drag the app to your Applications folder and open it. ::: :::div{icon="i-simple-icons-windows" label="Windows"} Download and run the MSI installer. Follow the installer prompts; administrator rights are required. ::: :::div{icon="i-simple-icons-linux" label="Linux"} Download the installer script from the download page, make it executable, then run it. The installer downloads GroundControl and moves it into the correct location for you: ```bash chmod a+x install_unitelabs_linux.sh ./install_unitelabs_linux.sh ``` Full Raspberry Pi 4/5 support is included in the ARM64 build. ::: :: ### Auto-Updates GroundControl checks for updates automatically. When a new version is available, you will be prompted inside the application to download and install it. Updates are signed and delivered securely. ## Initial Configuration On first launch, open **Settings**. The settings serve two purposes: they define the tenant that GroundControl authenticates with, and the registry it downloads connectors from. 1. **Tenant ID**: your UniteLabs tenant identifier, a UUID. You will find it in your welcome email or by asking your UniteLabs contact. 2. **Authentication URL**: the authentication server used for sign-in. Keep the default unless instructed otherwise by UniteLabs. 3. **Registry URL**: the connector registry that GroundControl downloads connectors from. Keep the default unless instructed otherwise by UniteLabs. 4. **Default Cloud Server URL** (GroundControl 1.44 and later): your tenant's cloud endpoint. GroundControl derives a suggestion from your Tenant ID and shows it under the field as `https://.unitelabs.io`. Click the suggestion to accept it. If your tenant is deployed differently, your UniteLabs contact gives you the correct endpoint. GroundControl prefills the Cloud Connectivity section of every connector you add with this value. Without a working cloud connection a connector stays local: invisible in the platform, unreachable for the SDK and for workflows. Enter the URL without a port; `http://` is upgraded to `https://` automatically. 5. **Default Cloud Server Port** (GroundControl 1.44 and later): optional, defaults to **443**. Keep it unless instructed otherwise. ![GroundControl settings with tenant, authentication server, and connector registry](https://docs.unitelabs.io/images/connect/groundcontrol/gc-settings-tenant.webp) Both cloud fields arrived in GroundControl 1.44. On older versions, and for connectors the prefill does not reach, set the endpoint per connector in its [Cloud Connectivity section](https://docs.unitelabs.io/integrate/connect-a-device/connector-configuration/#cloud-connectivity). ![Cloud Connectivity settings with the suggested cloud server URL and port 443](https://docs.unitelabs.io/images/connect/groundcontrol/gc-settings-cloud.webp) ## Sign In Click **Sign In** in the bottom left navigation bar. Your browser opens the sign-in page of the configured authentication server. Sign in with your UniteLabs account credentials (username or email and password, or Google). Afterwards, the browser returns you to GroundControl. Once signed in, GroundControl can download connectors from the registry. Continue with [Connector Configuration](https://docs.unitelabs.io/integrate/connect-a-device/connector-configuration/) to add a device and connector. ::callout{icon="i-lucide-triangle-alert"} GroundControl stores connector executables, configurations, and logs under `.unitelabs` in the current user's home directory. Install and run it under the shared lab account that operates the instruments. Connectors configured by another operating-system user are not visible or manageable from this account. :: # Set up your development machine Set up the development machine you use to write automation code. This is separate from the lab computer running GroundControl. You need the **registry credentials** from [Get access](https://docs.unitelabs.io/get-started/setup/get-access/). Allow about 20 minutes. ## Install an editor We recommend [Visual Studio Code](https://code.visualstudio.com/){rel=""nofollow,noopener""} with the **Python extension** by Microsoft, installed from the Extensions sidebar. Any editor you know well works too; where these docs show an editor, it is VS Code. ## Install Git Check whether Git is already installed: ```bash [Terminal] git --version ``` If a version number prints, continue with the next section. Otherwise: ::tabs :::div{icon="i-simple-icons-apple" label="macOS"} macOS may open a popup offering to install the Xcode Command Line Tools. That works, but downloads about 1 GB; the standalone installer is much smaller. - **Standalone installer** (recommended): download from [git-scm.com/download/mac](https://git-scm.com/download/mac){rel=""nofollow,noopener""}, open the `.dmg`, and run the `.pkg` inside. - **Xcode Command Line Tools**: run `xcode-select --install` and confirm the prompt. This also installs Apple's C compiler, which this setup does not need. - **Homebrew**: `brew install git`, only if you already use Homebrew. Do not install Homebrew just for this. Close and reopen the terminal, then verify with `git --version`. ::: :::div{icon="i-simple-icons-windows" label="Windows"} 1. Check which build you need: ```powershell \[PowerShell] systeminfo | findstr "System Type" ``` :brAn x64-based PC needs the x64 setup, an ARM-based PC the ARM64 setup. 2. Download the matching installer from [git-scm.com/downloads/win](https://git-scm.com/downloads/win){rel=""nofollow,noopener""} and keep the default settings. The installation includes Git Bash. 3. Close and reopen the terminal, then verify with `git --version`. ::: :::div{icon="i-simple-icons-linux" label="Linux"} Install Git through your distribution's package manager, for example `sudo apt install git` on Debian and Ubuntu or `sudo dnf install git` on Fedora. Verify with `git --version`. ::: :: ## Install uv uv manages Python versions, virtual environments, and dependencies, and it is what the UniteLabs guides and trainings use. Check whether it is already installed with `uv --version`. If not: ::tabs :::div{icon="i-simple-icons-apple" label="macOS and Linux"} ```bash [Terminal] curl -LsSf https://astral.sh/uv/install.sh | sh ``` ::: :::div{icon="i-simple-icons-windows" label="Windows"} ```powershell [PowerShell] powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex" ``` ::: :: Close and reopen the terminal, then verify with `uv --version`. Other options are listed in the [official uv installation instructions](https://docs.astral.sh/uv/getting-started/installation/){rel=""nofollow,noopener""}. ## Install Python The UniteLabs packages require Python 3.10 or newer; 3.13 is what the guides and trainings use. Install it through uv: ```bash [Terminal] uv python install 3.13 ``` Verify with `uv python list --only-installed`; Python 3.13 should appear in the list. ::callout{icon="i-heroicons-information-circle"} uv installs and selects project-specific Python versions independently of the Python installations already on the machine. :: ## Set up registry access The UniteLabs Python packages are not on public PyPI; they install from a private registry. uv and pip read the credentials for it from a `.netrc` file in your home directory: ```bash [~/.netrc] machine gitlab.com login password ``` `` and `` are the registry credentials from [Get access](https://docs.unitelabs.io/get-started/setup/get-access/). If UniteLabs sent you a prepared `netrc.txt`, move it into place as shown below. ::tabs :::div{icon="i-simple-icons-apple" label="macOS and Linux"} Place the file at `~/.netrc` and restrict its permissions: ```bash [Terminal] mv ~/Downloads/netrc.txt ~/.netrc chmod 600 ~/.netrc ``` The `chmod 600` step is required: most tools silently ignore a `.netrc` that other users can read. Verify: ```bash [Terminal] ls -la ~/.netrc ``` The output should start with `-rw-------`. Files starting with a dot are hidden in Finder; press `Cmd+Shift+.` to toggle visibility. ::: :::div{icon="i-simple-icons-windows" label="Windows"} Place the file at `C:\Users\\.netrc` and point the `NETRC` environment variable at it so every tool finds it: ```powershell [PowerShell] Move-Item "$env:USERPROFILE\Downloads\netrc.txt" "$env:USERPROFILE\.netrc" [System.Environment]::SetEnvironmentVariable("NETRC", "$env:USERPROFILE\.netrc", "User") ``` Close and reopen the terminal, then verify with `notepad "$HOME\.netrc"`. The [Windows specifics](https://docs.unitelabs.io/#windows-specifics) below cover file name extensions, pip, and profiles on a network drive. ::: :: Registry access is exercised the first time you install packages in [SDK installation](https://docs.unitelabs.io/get-started/setup/sdk-installation/). If installs download from `pypi.org` instead of GitLab, or fail with a 302 or 401 error, the `.netrc` is not being picked up: check the location, the permissions, and that the name carries no hidden `.txt` extension. ## Verify ```bash [Terminal] git --version uv --version uv python list --only-installed ``` ## Troubleshooting ### Windows specifics - If you rename the file in File Explorer instead of moving it with the command above, enable **View, Show, File name extensions** first, so no hidden `.txt` extension survives. - Pip looks for `%USERPROFILE%\_netrc` when the `NETRC` variable is not set; if you use pip directly, keep a copy under that name. - If your Windows profile lives on a network drive, check where the terminal resolves your home directory with `echo $HOME`, and either copy the file there or point the `NETRC` variable at the actual location. ### Corporate networks and SSL If your organization inspects TLS traffic with its own certificate authority, Python may reject connections that your browser accepts. Add the [truststore](https://truststore.readthedocs.io/){rel=""nofollow,noopener""} package to your project (`uv add truststore`) and activate it at program start with `import truststore; truststore.inject_into_ssl()`, so Python uses the operating system's trust store. If uv itself fails with certificate errors while downloading packages, run it with `--native-tls` or set `UV_NATIVE_TLS=1`, so uv uses the system trust store too. If certificate errors persist, ask your IT to confirm the root CA is present in the system trust store. ## Next steps [SDK installation](https://docs.unitelabs.io/get-started/setup/sdk-installation/) creates a first project, installs the UniteLabs packages through the registry access you just set up, and sends a first request to your tenant. # SDK installation The UniteLabs SDK provides Python access to connected devices and platform APIs. This guide creates a project, installs the SDK packages, configures client credentials, and sends a first API request. It assumes you have completed [Set up your development machine](https://docs.unitelabs.io/get-started/setup/developer-machine/). For endpoint details, see the [REST API reference](https://docs.unitelabs.io/technical-reference/rest-api/). ## Prerequisites UniteLabs requires Python 3.10 or newer. It is platform independent and works equally on Linux, macOS and Windows. Some steps require authentication, therefore you need: - **UniteLabs Artifactory Access**A username and password to install UniteLabs Python packages. UniteLabs packages are not published to PyPI — they are hosted in a private GitLab package registry. This is separate from GroundControl and the Platform; it is simply where the Python packages live. - Username: 32 characters separated by dashes. :br Example: `56ae938f-484c-41cc-ae54-e4f9b8814fb7` - Password: 22 characters separated by dashes. :br Example: `ahjt-Lk-e1swOPZcWYYGh-mKJ` - **UniteLabs Client Credentials**Client ID and secret to authenticate connections made from your local SDK installation (your code/client) to the UniteLabs API (the platform). - Client ID: A custom username with variable length. - Client Secret: 32 characters without separators. :br Example: `YT1a2HFEoi8tiAwl6bv127Tqp0ZXDSru` - Tenant Base URL: the web address of your tenant's API. :br Example: `https://api..unitelabs.io/` - Tenant Auth URL: the sign-in endpoint of your tenant, including the realm ID. :br Example: `https://auth..unitelabs.io/realms//protocol/openid-connect/` All of these come from UniteLabs. If you are missing one, the [Get access](https://docs.unitelabs.io/get-started/setup/get-access/) page lists who to ask. ## Install Packages We recommend installing the UniteLabs SDK using a Python virtual environment manager such as uv, Poetry, or venv. All packages are provided in a private package registry which requires configuration and authentication. The examples below install `unitelabs-liquid-handling`. This package includes the Liquid Handling SDK, while the labware library `unitelabs-labware` and core UniteLabs SDK (`unitelabs-sdk`) typically need to be installed as well. See [the parts of the system](https://docs.unitelabs.io/get-started/welcome/#the-parts-of-the-system) for the full breakdown. ::tabs :::div{icon="i-simple-icons-uv" label="uv (recommended)"} Uv is a tool that manages dependencies, maintains a lockfile for reproducible installations, manages your virtual environment and can build packages for distribution. If you have not used uv before, you can follow any of the options for installation in the [official uv installation instructions](https://docs.astral.sh/uv/getting-started/installation/){rel=""nofollow,noopener""}. **1. Prepare uv** Uv reads the UniteLabs Artifactory Access from a user's `.netrc` file. Set it up as described in [Set up registry access](https://docs.unitelabs.io/get-started/setup/developer-machine/#set-up-registry-access). See the [official uv documentation](https://docs.astral.sh/uv/configuration/authentication/#http-authentication){rel=""nofollow,noopener""} for alternatives. **2. Setup Project** ```bash [Terminal] uv init --package my-app cd my-app ``` **3. Install UniteLabs** ```bash [Terminal] uv add unitelabs-liquid-handling \ --index unitelabs=https://gitlab.com/api/v4/groups/1009252/-/packages/pypi/simple ``` The example below also uses `python-dotenv`. It lives on public PyPI, so it must not go through the private index: ```bash [Terminal] uv add python-dotenv ``` ::: :::div{icon="i-simple-icons-poetry" label="Poetry"} Poetry is a tool for dependency management and packaging in Python. It allows you to declare the libraries your project depends on and it will manage (install/update) them for you. Poetry offers a lockfile to ensure repeatable installs, and can build your project for distribution. If you have not used `poetry` before, you can follow any of the options in the [official Poetry installation instructions](https://python-poetry.org/docs/#installation){rel=""nofollow,noopener""}. **1. Prepare Poetry** When `poetry` is available (try `poetry -V` in your terminal), you need to provide the UniteLabs Artifactory Access for poetry to be able to install the required dependencies. Optionally, we suggest to configure poetry to create the Python virtual environment within your project folder. ```bash [Terminal] poetry config http-basic.unitelabs poetry config virtualenvs.in-project true ``` **2. Setup Project** ```bash [Terminal] poetry new my-app --src cd my-app ``` **3. Install UniteLabs** ```bash [Terminal] poetry source add --priority=supplemental unitelabs \ https://gitlab.com/api/v4/groups/1009252/-/packages/pypi/simple poetry add --source unitelabs unitelabs-liquid-handling ``` The example below also uses `python-dotenv`. It lives on public PyPI, so it must not go through the private index: ```bash [Terminal] poetry add python-dotenv ``` ::: :::div{icon="i-simple-icons-python" label="venv"} Venv allows you to manage separate package installations for different projects. It creates a "virtual" isolated Python installation. **1. Prepare pip** Pip reads the UniteLabs Artifactory Access from a user's `.netrc` file. Set it up as described in [Set up registry access](https://docs.unitelabs.io/get-started/setup/developer-machine/#set-up-registry-access). See the [official pip documentation](https://pip.pypa.io/en/stable/topics/authentication){rel=""nofollow,noopener""} for alternatives. **2. Setup Project** ```bash [Terminal] mkdir my-app cd my-app python -m venv .venv .venv/bin/pip install -U pip ``` **3. Install UniteLabs** ```bash [Terminal] .venv/bin/pip install unitelabs-liquid-handling \ --index-url https://gitlab.com/api/v4/groups/1009252/-/packages/pypi/simple ``` The example below also uses `python-dotenv`. It lives on public PyPI, so it must not go through the private index: ```bash [Terminal] .venv/bin/pip install python-dotenv ``` ::: :: ## Authenticate Client You need to authenticate your UniteLabs client to access the UniteLabs API on your behalf. We suggest to use [python-dotenv](https://saurabh-kumar.com/python-dotenv/){rel=""nofollow,noopener""} to load the UniteLabs Client Credentials from a `.env` file in the root of your project. ```dotenv [.env] BASE_URL=https://api..unitelabs.io/ AUTH_URL=https://auth..unitelabs.io/realms//protocol/openid-connect/ CLIENT_ID= CLIENT_SECRET= ``` ::callout{icon="i-heroicons-exclamation-triangle"} `BASE_URL` contains neither the tenant UUID nor a `/v1` suffix; the SDK appends `/v1` itself. A 404 on `/v1/secrets` or `/v1/workflows` means the path is doubled. :: > Security note: :br > When using version control, make sure to exclude the .env file by e.g. adding it to the .gitignore file. ## Send Your First Request The SDK provides both asynchronous and synchronous clients. Choose based on your application's needs and personal preference: - **AsyncApiClient** (recommended): For modern async/await code, better performance with concurrent operations - **SyncApiClient**: For simple scripts or blocking code where async is not needed ::tabs :::div{icon="i-heroicons-bolt" label="Async (Recommended)"} ```python [src/my_app/__main__.py] import asyncio from unitelabs.sdk import AsyncApiClient from dotenv import load_dotenv async def main(): # Async example (recommended) client = AsyncApiClient() connectors = await client.list_services() for connector in connectors: print(connector.name) if __name__ == "__main__": load_dotenv() asyncio.run(main()) ``` ::: :::div{icon="i-heroicons-arrow-path" label="Sync"} ```python [src/my_app/__main__.py] from unitelabs.sdk import SyncApiClient from dotenv import load_dotenv def main(): # Synchronous example client = SyncApiClient() connectors = client.list_services() for connector in connectors: print(connector.name) if __name__ == "__main__": load_dotenv() main() ``` ::: :: Run the script to verify your credentials and confirm the SDK can reach the platform. It should print the names of your connected instruments. This is just a smoke test; in real projects your entry point will vary. Run the package's `__main__.py` file with: ::tabs :::div{icon="i-simple-icons-uv" label="uv"} ```bash [Terminal] uv run python -m my_app ``` ::: :::div{icon="i-simple-icons-poetry" label="Poetry"} ```bash [Terminal] poetry run python -m my_app ``` ::: :::div{icon="i-simple-icons-python" label="venv"} ```bash [Terminal] .venv/bin/python -m my_app ``` ::: :: ## Troubleshooting - Packages download from public PyPI instead of the private registry, or the install fails with 302 or 401: the `.netrc` file is not being picked up. Check the file permissions (`chmod 600`), the file location, and that it has no Windows line endings. - 404 on `/v1/secrets` or `/v1/workflows`: the `BASE_URL` contains the tenant UUID or a `/v1` suffix. Remove both; the SDK appends `/v1` itself. - 401 when running the first request: `CLIENT_ID` or `CLIENT_SECRET` is wrong, or the `AUTH_URL` misses the realm part. ## Versions The installation defaults to the latest version. Updates on versions and the corresponding changelogs are posted in the [Technical Reference Section](https://docs.unitelabs.io/technical-reference/python-sdk/changelog/). If a defined version is required or needs to be locked, it must be explicitly defined using the [PEP 440 Version Specifier](https://peps.python.org/pep-0440/#version-specifiers){rel=""nofollow,noopener""}(==, !=, <, >, <=, >, >=) in the pyproject.toml. ```.toml [pyproject.toml] [project] requires-python = ">=3.10,<4.0" dependencies = [ "unitelabs-liquid-handling~=0.11.0", "python-dotenv", ] ``` We recommend pinning explicit versions for workflows you plan on running in production. The installed version can be checked using `__version__`: ```python [__main__.py] from unitelabs import labware, liquid_handling, sdk if __name__ == "__main__": print(sdk.__version__) print(labware.__version__) print(liquid_handling.__version__) ``` Run the file as explained above. **Note on client types:** - `Client` from `unitelabs.sdk` is **deprecated** - Use `AsyncApiClient` (recommended) for async/await patterns - Use `SyncApiClient` for synchronous/blocking code - Both clients provide the same methods - the only difference is async vs sync execution ## IDE Integration ### VSCode ```json [.vscode/extensions.json] { "recommendations": ["ms-python.python"] } ``` ```json [.vscode/settings.json] { "python.defaultInterpreterPath": ".venv/bin/python", "python.terminal.activateEnvironment": false } ``` ### Jupyter Notebooks ::callout{color="info" icon="i-heroicons-light-bulb"} **Prefer Jupyter for exploration?** Install `ipykernel` as a dev dependency and run SDK calls interactively in notebook cells — all async methods work via `await` in Jupyter without an explicit `asyncio.run()` wrapper. ```bash [Terminal] uv add --dev ipykernel ``` All SDK calls work identically in Jupyter. This is useful for prototyping and exploring connectors interactively before writing a script. :: ## Next steps Your first request already listed the connected instruments. [Calling a connector](https://docs.unitelabs.io/integrate/control-with-code/) shows how to inspect one and call its actions. # Connect to Platform GroundControl and connectors work entirely locally without an internet connection. The server-initiated connection (Cloud connectivity) is what makes connectors accessible from the UniteLabs Platform, the UniteLabs SDK, and the REST API — from anywhere, not just the local network. ## What platform connectivity enables | Without platform connection | With platform connection | | -------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | | Connectors are reachable only from the local network | Connectors are reachable from the UniteLabs platform and SDK from anywhere | | Connector interface available, but cannot be used with UniteLabs SDKs. No workflow execution | Workflows written with the UniteLabs SDK can connect to the connectors. enables workflow execution | | No visibility in the platform UI | Connectors appear in the platform Connectors page | If you want to use the UniteLabs platform, build workflows, or access instruments remotely, enable it. > We are working towards making more platform features available via GroundControl, e.g. hybrid execution. ## How it works When cloud is enabled, GroundControl opens a single persistent **outbound** TLS connection to: ```text .unitelabs.io:443 ``` The connector's SiLA 2 API is relayed through this connection. No inbound ports are required — GroundControl initiates the connection, not the cloud. > In the current state, each connector initiates a connection to the platform API endpoint and communication is not yet channeled through GroundControl yet. ## Enable cloud connectivity Cloud connectivity is configured in GroundControl settings, which you fill in during initial setup: 1. Open GroundControl → **Settings** 2. Enter your **Tenant ID** (from your welcome email or your UniteLabs contact) 3. Click **Save** GroundControl uses the tenant ID to derive the cloud endpoint (`.unitelabs.io`) and establish the relay connection automatically after sign-in. Alternatively, if the connector should make the connection directly to the Unitelabs Platform, the cloud endpoint can be configured in the connector configuration menu. For connectors run as standalone executables (without GroundControl), add the cloud endpoint to the connector's `config.json`: ```json { "cloud_server_endpoint": { "hostname": ".unitelabs.io", "port": 443, "tls": true } } ``` See [Headless install](https://docs.unitelabs.io/integrate/connect-a-device/headless-install/) for the full setup flow. See [Cloud Connectivity](https://docs.unitelabs.io/integrate/connect-a-device/connector-configuration/#cloud-connectivity) to configure these fields in GroundControl. ## Verify the connection Once GroundControl is signed in and your connectors are running, open the [UniteLabs platform](https://app.unitelabs.io/connectors){rel=""nofollow,noopener""}. Your connectors should appear as **online** within a few seconds. If a connector shows as offline: - Confirm the connector process is `running` in GroundControl - The connector can be accessed and commands can be send to the connector via GroundControl - Review the Activity tab in GroundControl for error messages - Check the connector logs and ensure that outbound TCP port 443 to `*.unitelabs.io` is not blocked by your network - Verify the TLS setting and contact your network administrator about potential requirements for certificates. ::callout{icon="i-heroicons-information-circle"} See [Network requirements](https://docs.unitelabs.io/get-started/setup/network-requirements/) for the full list of ports, firewall rules, and security aspects. :: # Multi-device Networking GroundControl is designed to scale from a single instrument on one machine to dozens of instruments spread across multiple lab locations. Here is how the topology works in each scenario. ## One edge machine, multiple instruments The simplest multi-device setup: one GroundControl installation managing several instruments connected to the same machine. ```text Connector A ──┐ Connector B ──┤── GroundControl ── UniteLabs Platform Connector C ──┘ ``` Each instrument gets its own connector. All connectors run as separate processes under one GroundControl instance. Add each device through **Devices → Add Device** and assign it a connector as usual. There is no hard limit on the number of connectors per GroundControl. In practice, the limit is the resources available on the edge machine. > The above architecture is still a work in progress. In the current state, while managed by GroundControl, each connector establishes an independent, direct connection to the cloud as sole communication channel. A second channel via the GroundControl relay will be rolled out soon. > > ```text > ┌── GroundControl > Connector A ──┤ > Connector B ──┤─────────────────── UniteLabs Platform > Connector C ──┘ > ``` ## Multiple edge machines If instruments are spread across multiple benches or rooms — each with a dedicated computer — run one GroundControl per edge machine. All GroundControl instances in the same tenant share the same connector registry and appear in the same platform workspace. ```text Lab bench A Lab bench B ┌──────────────┐ ┌──────────────┐ │ Connector 1 │ │ Connector 3 │ │ Connector 2 │ │ Connector 4 │ │ GroundControl│ │ GroundControl│ └──────┬───────┘ └──────┬───────┘ └──────────┬───────────────┘ UniteLabs Platform ``` Each machine needs its own GroundControl installation. Sign in with the same tenant credentials on each. ## Instruments on separate network segments If the edge machine cannot directly reach an instrument (e.g., the instrument is on a restricted VLAN), you have two options: **Option 1 — Relocate GroundControl.** Run GroundControl on a machine that is already on the same network segment as the instrument. This is the simplest solution and avoids network routing complexity. **Option 2 — Remote connector.** Run the connector on a machine co-located with the instrument and add it to GroundControl as a remote. The connector runs independently and GroundControl connects to it over the network: - **Discovered (mDNS)**: if the connector broadcasts on the local network, GroundControl finds it automatically - **Manual remote**: enter the connector's hostname and port directly in the Add Device wizard See [Manual Remote Connector](https://docs.unitelabs.io/integrate/connect-a-device/connector-configuration/#manual-remote-connector) for the setup steps. ## Multiple lab locations For connectors in physically separate locations (different buildings or sites), the recommended setup is: 1. Install GroundControl at each location 2. Enable cloud connectivity at each location ([Connect to UniteLabs Platform](https://docs.unitelabs.io/get-started/setup/connect-to-platform/)) 3. All connectors appear in the central platform workspace, accessible from anywhere Without cloud connectivity, connectors are only reachable from within their local network. ::callout{icon="i-heroicons-information-circle"} If your instruments are on an isolated network with no internet access, you can still use GroundControl locally — automation workflows running on the same network can reach connectors directly. Cloud connectivity is not required. :: # UniteLabs Desktop UniteLabs Desktop is a self-contained, Docker-based distribution of the UniteLabs Platform for a single machine on your premises. The bundle ships with all Docker images included: the API, frontend, Web IDE, workflow engine, as well as secrets, SQL, S3-compatible storage and all Python SDK dependencies. The platform itself installs and runs without access to the UniteLabs registry or other external sources. Connectors are delivered separately: GroundControl downloads them when the connector host has internet access. In restricted environments you upload the connector executable as a file instead. Use it for: - Largely offline or airgapped environments - IT restrictions that do not allow connections to external domains (for example unitelabs.io or gitlab.com) - Data-residency-restricted deployments - Trials and evaluations on a single workstation For cloud tenants, no installation is needed; see [Connect to Platform](https://docs.unitelabs.io/get-started/setup/connect-to-platform/). For private cloud deployments, we coordinate the setup with your IT team. ## Prerequisites - A Linux host (Ubuntu 22.04 or newer recommended). Linux is the tested and supported target; if you need to run on a Windows or macOS host, talk to your UniteLabs contact first. - **Docker CE with the Compose plugin** (Docker Engine 27 or newer), installed from the official Docker repository following the [official Docker install guide for Ubuntu](https://docs.docker.com/engine/install/ubuntu/){rel=""nofollow,noopener""}. Distribution packages such as Ubuntu's `docker.io` package or the Docker snap ship an outdated Compose that breaks the installer. - The installing user must be in the `docker` group. Group membership takes effect after logging out and back in. - `make` and [`mkcert`](https://github.com/FiloSottile/mkcert#installation){rel=""nofollow,noopener""} must be installed via your package manager. For browsers to trust the local certificate, `libnss3-tools` (certutil) must be present as well. On Ubuntu: `sudo apt install make mkcert libnss3-tools`. - If other machines on the network need to reach the platform: clients address the platform by hostname, not by IP, so the platform hostname must resolve to the host's IP from every client machine. That works either through an internal DNS record or through hosts-file entries on the clients. With hosts-file entries the host needs a static IP (or a DHCP reservation), because the entries hardcode the IP. - The UniteLabs Desktop bundle (a single `.tar.gz`); contact your UniteLabs representative for access. ## Install The bundle's `INSTALL.md` and `PREREQUISITES.md` contain the exact commands for that bundle version. The generic flow is: 1. Copy the bundle onto the target machine and unpack it. This creates a `dist/` folder with a `Makefile`, `docker-compose.yaml`, environment files, the bundled images archive, and the install docs. 2. Create the local certificates: `make setup-certificates` (uses mkcert to create a local CA and a certificate for the platform hostname). 3. Load the bundled images into Docker as described in `INSTALL.md`. 4. Initialize the stack: `make init` (runs database migrations and creates the object storage bucket for your tenant). 5. Add the platform hostnames to `/etc/hosts`, mapping the platform host and the API host to localhost. The hostnames are pre-configured per deployment and named in `INSTALL.md`; the entry looks like `127.0.0.1 unitelabs.platform api.unitelabs.platform`. 6. Start the stack: `make up` and wait until all containers report `Healthy` or `Started`. Then open the platform hostname in a browser on the machine, for example `https://unitelabs.platform`. The hostname is pre-configured for your deployment and named in the bundle's `INSTALL.md` (it is the same name you added to the hosts file in step 5). The default admin credentials are handed over by your UniteLabs representative together with the bundle; log in with them and change the password on first login. ## Verify the installation - `docker compose ps` in the `dist/` folder shows all containers as `Healthy` or `Started`. - The API answers: `curl https://unitelabs.platform/v1/health` returns HTTP 200. Replace `unitelabs.platform` with your platform hostname; the API is served under the platform hostname at `/v1`. - The platform login page loads in the browser and login works. - The Web IDE opens from the platform navigation: create a notebook and run this cell: ```python import os, urllib.request print(urllib.request.urlopen(os.environ["BASE_URL"] + "/health").status) ``` :brIt prints `200`. The notebook gets the platform URL and the certificate trust from the installation, so this also shows that code in the Web IDE reaches the API. - The workflow engine runs a workflow: open the Workflows page in the platform and trigger a run. A fresh install has no workflows deployed yet; set up the SDK first (next section) and deploy the `hello_world` workflow from the [workflow template](https://docs.unitelabs.io/automate/workflow-template/). A successful run reaches the state `COMPLETED` and logs the SDK version. - If other machines on the network need access, for example a GroundControl host connected to an instrument: the platform URL loads on those machines as well. This requires the name resolution from the prerequisites (DNS record or hosts-file entry on the client). ## Connect with the Python SDK To control the platform from your own code, install the SDK as described in [SDK installation](https://docs.unitelabs.io/get-started/setup/sdk-installation/). The SDK client reads `BASE_URL`, `AUTH_URL`, `CLIENT_ID` and `CLIENT_SECRET` from the environment or an `.env` file. For UniteLabs Desktop these values are deployment-specific; your UniteLabs representative hands them over together with the bundle. The [workflow template](https://docs.unitelabs.io/automate/workflow-template/) contains ready-made scripts to test the connection, for example `scripts/get-all-workflows-from-api.py`, which authenticates with your `.env` and lists the workflows on the platform. ## Connect an instrument Install GroundControl on the machine connected to your instrument and register the connector against the platform URL of your UniteLabs Desktop installation. See [GroundControl](https://docs.unitelabs.io/get-started/setup/groundcontrol/). When the connector host has internet access, GroundControl downloads connectors directly. In restricted environments, get the GroundControl installer and the connector executable as files from your UniteLabs representative and use GroundControl's Upload Connector option instead. ## Troubleshooting - **`docker: command not found` or `make init` fails on an env-file flag**: the distribution Docker package is installed or Docker is missing. Remove distro packages and install Docker CE with the Compose plugin from the official repository. - **Permission denied on the Docker socket**: the user is not in the `docker` group yet, or the group change has not taken effect. Add the user and log out and back in. - **Browser shows 404 although all containers are healthy**: check the hosts-file entry for the platform hostname, and check for remnants of previous deployments. An old router or ingress (for example from an earlier k3s install) can intercept requests and answer 404 while the new stack is healthy. Take an inventory and stop old components before installing. - **Browser shows "Not secure"**: the mkcert CA is not trusted. Install `libnss3-tools` and rerun the mkcert install step. - **`make up` fails after an interrupted first run**: run `make up` again. ## Limitations and notes - All Python packages must be baked into the bundle upfront; there are no dynamic library downloads at run time. - Component versions are pinned per bundle in its `versions.env`. Updates ship as a new bundle. # Network requirements GroundControl and connectors involve three distinct network paths. Most are local and require no firewall changes. Cloud connectivity requires one outbound port. ## Network paths | Path | Protocol | Port | Direction | Required? | | ----------------------------------------- | ---------------------------------- | --------------------- | -------------- | --------- | | Edge machine → Lab instrument. | Device-specific (TCP, serial, USB) | Varies per instrument | Local | Yes | | GroundControl ↔ Connector | gRPC (SiLA 2) | 50000 - 60000. | Local loopback | Yes | | GroundControl/Connector → UniteLabs Cloud | gRPC over TLS (HTTPS) | 443 (default) | Outbound only | Yes | ### Instrument connectivity The edge machine must be able to reach the instrument directly. This is typically: - **Serial (RS-232/USB)**: physical cable, no network configuration needed - **TCP/IP**: the instrument's IP address must be reachable from the edge machine. If instruments are on a separate VLAN or subnet, ensure routing is in place. The specific address and port are entered in the connector's configuration form in GroundControl. ### Local SiLA 2 port Connectors service **port 0** by default for local gRPC communication with GroundControl and should be changed in the connector config. A port in the range of 50000 - 60000 is typically used. This port is bound to the loopback interface and does not need to be opened in any external firewall. If you run multiple connectors on the same machine, make sure that each uses a different port (50052, 50053, 50054, …). ### Cloud relay When cloud connectivity is enabled, GroundControl opens a single **outbound** TLS connection to: ```text .unitelabs.io:443 ``` This is a persistent gRPC stream over HTTPS — default port 443, outbound only. No inbound firewall rules are needed. If your network enforces egress filtering, allow outbound TCP on port 443 to `*.unitelabs.io`. ## mDNS discovery (optional) GroundControl uses mDNS (Multicast DNS) to auto-discover SiLA 2 connectors running elsewhere on the local network. mDNS uses **UDP port 5353** on the `224.0.0.251` multicast address. This is only relevant if you're using the [Discovered connector mode](https://docs.unitelabs.io/integrate/connect-a-device/connector-configuration/#add-a-connector). Local-only and cloud-only setups don't require mDNS. ## Instruments on separate network segments If lab instruments are on a different subnet or VLAN from the GroundControl machine: - Ensure the edge machine has a route to the instrument's subnet - Or place GroundControl on a machine that is already on the same network segment as the instruments - Alternatively, run the connector on a machine co-located with the instruments and add it to GroundControl as a [remote connector](https://docs.unitelabs.io/integrate/connect-a-device/connector-configuration/#manual-remote-connector) See [Multiple devices networks](https://docs.unitelabs.io/get-started/setup/multiple-devices/) for multi-site topology options. # What is a connector? A **connector** is a small, standalone software application that sits between a lab instrument and the rest of your automation stack. It speaks the instrument's native language on one side (serial, TCP, USB — whatever the device requires) and exposes a clean, standardized API on the other. Everything above the connector — GroundControl, the UniteLabs platform, the UniteLabs SDK — talks to that API without needing to know anything about the underlying instrument protocol. ## How it fits together ```text Lab Instrument │ (device protocol: serial, TCP, USB…) ▼ Connector ◄──── manages ──── GroundControl │ (SiLA 2 / gRPC) ▼ UniteLabs Platform & REST API (Academic, Cloud, Enterprise, On-Prem) │ ▼ Platform UI · UniteLabs SDK · 3rd Party Applications ``` The connector runs as a local process on the edge machine — the computer physically connected to the instrument. Connectors are distributed as self-contained executables that you can run in two ways: - **[GroundControl](https://docs.unitelabs.io/get-started/setup/groundcontrol/)**: a desktop application that downloads, configures, and manages connector processes through a GUI. The recommended starting point for most labs. - **Direct executable**: drop the binary on any Linux or Windows server, Raspberry Pi, or embedded device and manage it as a system service. No GUI required. See [Headless install](https://docs.unitelabs.io/integrate/connect-a-device/headless-install/). ## What a connector exposes Every connector is organized into [modules](https://docs.unitelabs.io/integrate/concepts/module/) — logical groupings of related capabilities. A temperature controller connector might have a `TemperatureController` module and a `DoorController` module. Each module contains [actions](https://docs.unitelabs.io/integrate/concepts/action/), the things you can call to read values or trigger behavior on the instrument. Some actions return a single value and complete; others stream updates over time and are consumed as [subscriptions](https://docs.unitelabs.io/integrate/concepts/subscription/). Every action has a type — **Property** (scalar read), **Sensor** (stream), or **Control** (trigger). The platform UI groups actions into three sections by type; the SDK and REST API expose the type as a field on each action. See the [Terminology table](https://docs.unitelabs.io/integrate/concepts/connector#terminology) for the full model. Every connector also ships with built-in modules for lock control, simulation mode, and service metadata — consistent behavior across all instruments. For the full technical model see the [Integrate concepts](https://docs.unitelabs.io/integrate/concepts/connector/) — connectors, modules, actions, subscriptions, and [devices](https://docs.unitelabs.io/integrate/concepts/device/). ## Where connectors come from **Connector registry**: UniteLabs maintains a catalogue of versioned connectors for common lab instruments. GroundControl can browse and download connectors from the registry with one click. **Custom connectors**: For instruments not in the registry, you can build your own using the [Connector Development Kit (CDK)](https://docs.unitelabs.io/connector-development/). The CDK lets you write a connector in pure Python using simple decorators — no knowledge of network protocols required. ## Next steps Both paths below install and run connector processes on your edge machine — the computer physically connected to your instruments. - [Install GroundControl](https://docs.unitelabs.io/get-started/setup/groundcontrol/): the recommended path for workstations and lab PCs - [Headless install](https://docs.unitelabs.io/integrate/connect-a-device/headless-install/): for servers, Raspberry Pi, and other embedded Linux devices - [Connector concepts](https://docs.unitelabs.io/integrate/concepts/connector/) to understand the model in more depth # Calling a Connector **Pre-requisite:** Follow the steps in [Importing a connector](https://docs.unitelabs.io/integrate/use-a-connector/python/) to install the SDK and authenticate against the platform. This tutorial uses an Agilent Bravo to show how to discover what a connector exposes and then call real actions on it. The pattern is the same for every connector (thermocyclers, balances, readers, liquid handlers); only the module and action names change. ## Understanding Actions Actions are the callable methods on a module: the things you invoke to read a value or trigger behavior on the instrument. Every action has typed parameters and responses, and either returns a single result or streams updates over time. See [Action](https://docs.unitelabs.io/integrate/concepts/action/) for the full model. ## Connect to the Bravo ```python from unitelabs.sdk import AsyncApiClient from unitelabs.liquid_handling.agilent import Bravo client = AsyncApiClient() bravo = Bravo(name="Bravo", client=client) await bravo.configure() await bravo.initialize() await bravo.activate() ``` Three calls bring the Bravo online: `configure` reads the deck and module configuration from the connector, `initialize` opens the control loop to the physical device, and `activate` arms the pipette head for motion. ## Discover available actions The Bravo's pipette head exposes every liquid-handling action you need. Ask the module what actions it has: ```python print(bravo.pipette_head.actions.keys()) ``` The same callables appear in the platform UI under the pipette head module, grouped by type into Properties, Sensors, and Controls. See the [Terminology table](https://docs.unitelabs.io/integrate/concepts/connector#terminology) for the cross-surface mapping. ## Inspect an action's parameters Before calling an action you do not already know, ask it what arguments it expects: ```python print(await bravo.pipette_head.aspirate.parameters) ``` The result is a typed schema: parameter names, data types, and constraints (units, min / max, enumerations). The SDK uses this to serialize your arguments correctly; the platform UI uses the same schema to render a parameter form. ## Place a plate on the deck Before any liquid moves, the SDK needs to know what is on the deck. For a minimal example we place one 96-well plate and pre-fill it with water, plus a rack of tips the pipette head will use: ```python from unitelabs.labware.agilent.tips import AgilentTip_250, AgilentTipRack_250 from unitelabs.labware.plates import Standard96Plate from unitelabs.labware.liquids import Liquid, PredefinedLiquids # Tips tip_rack = AgilentTipRack_250(identifier="TipRack") tip_rack.fill(AgilentTip_250) # A plate pre-filled with water plate = Standard96Plate(identifier="DemoPlate") plate.container.add_liquid(PredefinedLiquids.WATER, 20_000.0) # Place both on the Bravo deck bravo.deck.add(tip_rack, location=1) bravo.deck.add(plate, location=5) ``` See [Labware](https://docs.unitelabs.io/operate/concepts/labware/) for how the plate's wells are modelled and how the SDK tracks liquid volume through every subsequent action. ## Aspirate and dispense Pick up tips, aspirate 100 µL from the plate, and dispense back into it: ```python # Pick up tips await bravo.pipette_head.pick_up_tips_from(rack=tip_rack, press_depth=5.8) # Aspirate 100 µL await bravo.pipette_head.aspirate(plate=plate, volume=100) # Dispense 100 µL await bravo.pipette_head.dispense(plate=plate, volume=100) # Return the tips await bravo.pipette_head.discard_tips(bravo.deck[3]) ``` These are the two actions you introspected in the previous section. Now you have discovered them on the connector, asked for their parameter schemas, and executed them against a real instrument. ## Next steps This tutorial stays focused on calling actions on one module. For a full end-to-end protocol that wires deck building, labware, device connection, and error handling into a single runnable script, see [Your First Protocol](https://docs.unitelabs.io/operate/your-first-protocol/). For deeper pipetting patterns and vendor-specific options (Hamilton, Bravo), see [Basic Pipetting](https://docs.unitelabs.io/operate/guides/pipetting/basic-pipetting/). # Connector A connector is a standalone application that represents a single lab instrument in UniteLabs. It runs on the edge machine — the computer physically connected to the instrument — and exposes the instrument's capabilities through a structured, typed, and introspectable API that GroundControl, the UniteLabs SDK, and the REST API can all call. ## Terminology UniteLabs uses one set of labels for connector capabilities across every surface — the platform UI, the Python SDK, the REST API, and GroundControl. | Docs concept | UI label | Python SDK | REST API | | ----------------------------- | --------------------------- | --------------------------- | ----------------------------------- | | Module | Module | `.modules` | `/modules` | | Property action (scalar read) | Property | action with `type=PROPERTY` | `/actions` (filter `type=PROPERTY`) | | Sensor action (stream) | Sensor | action with `type=SENSOR` | `/actions` (filter `type=SENSOR`) | | Control action (trigger) | Control | action with `type=CONTROL` | `/actions` (filter `type=CONTROL`) | | Subscription | live-stream panel on Sensor | `async for` | `/subscriptions` | Every action has a type: **Property** (read a scalar once), **Sensor** (subscribe to a stream), or **Control** (trigger an action). The UI groups actions into three sections by type; the SDK and REST API expose the type as a field on each action. In the REST API a connector itself is called a **service** (endpoint: `/v1/services`). In this documentation we use "connector" because it is the term the product is built around — the Connector Development Kit, the connector registry, and GroundControl's Connectors tab all use it. Mentally: `service = connector instance`. ## Identity Every connector has four pieces of identity: | Field | Description | | ----------- | ------------------------------------------------------------------------- | | **Name** | Human-readable label, e.g. `Hamilton STAR (Bench 3)` | | **UUID** | Stable unique identifier — assigned on first start and never changes | | **Type** | The instrument category, e.g. `liquid_handler`, `balance`, `thermocycler` | | **Version** | The connector software version | The UUID is how the UniteLabs SDK and REST API reference a specific connector instance. The name is what appears in GroundControl and the platform UI. ## Modules A connector groups its capabilities into [modules](https://docs.unitelabs.io/integrate/concepts/module/). Each module represents one functional aspect of the instrument. For example, a balance connector might expose: - `WeighingService` — tare, zero, read weight - `LockController` — exclusive device locking to prevent concurrent access Modules are the unit of organization within a connector's API. Expanding a module in the UI reveals its [actions](https://docs.unitelabs.io/integrate/concepts/action/), grouped by type into Properties, Sensors, and Controls. ## Built-in modules Every connector — regardless of instrument — ships with these modules automatically: | Module | Purpose | | ---------------------- | ------------------------------------------------------------------------------------------------------------- | | Service metadata | Lists the connector's other modules so clients can introspect capabilities. Required for discovery. | | `LockController` | Lets clients acquire exclusive access to the instrument | | `SimulationController` | Toggle simulation mode — the connector responds with realistic fake values instead of talking to the hardware | Simulation mode is useful for developing workflows before the instrument is available, or for testing without consuming physical resources. ## How connectors talk to instruments Under the hood, connectors implement one of several integration protocols — **SiLA 2** (the UniteLabs default, used by any connector built with the CDK), **OPC-UA LADS** (for vendors shipping LADS-compliant instruments), and adapters for **LIMS / ELN** systems. From a consumer's perspective this is invisible: every connector exposes the same Module / Action / Subscription model regardless of what it is speaking to the instrument. If you are building a connector, read [SiLA](https://docs.unitelabs.io/connector-development/concepts/sila/) in the CDK docs for the protocol-layer detail. ## Lifecycle Regardless of how a connector is managed, its lifecycle is the same: 1. **Start**: the connector executable launches with a `config.json` file. It connects to the instrument and starts its local server. 2. **Running**: the connector is reachable locally. If cloud is configured, it maintains an outbound relay to the UniteLabs cloud. 3. **Stop / Restart**: the connector receives a shutdown signal, closes its instrument connection, and exits cleanly. **Via GroundControl**: the GUI manages the process directly and shows status, logs, and restart controls in the interface. Connectors can be registered for auto-restart. **Via systemd (or another process supervisor)**: the connector runs as a system service that starts automatically on boot and restarts on failure. This is the typical setup for headless Linux servers and Raspberry Pi devices. ## Configuration Each connector has a configuration file that captures the instrument-specific settings — typically the instrument's hostname or IP address, port, and any authentication details. GroundControl generates a configuration form from the connector's own schema when you add a device, so you fill in fields specific to that instrument rather than editing a file manually. For connectors run as standalone executables, configuration is stored in a `config.json` file alongside the binary. Run the connector once with the `config create` command to generate the default file, then edit it before starting the connector for real. # Module A **module** groups related [actions](https://docs.unitelabs.io/integrate/concepts/action/) on a connector. If a connector represents an entire instrument, a module represents one functional aspect of that instrument — the temperature controller, the door controller, the weighing service. A balance connector typically has a weighing module and a lock module; a thermocycler has a temperature controller, a lid controller, and a block controller. The module layer is where most of your code targets. You rarely call actions directly on the connector itself — you find the right module first, then call actions on it. In the platform UI modules appear as their own column — click one to reveal its actions, grouped by type into Properties, Sensors, and Controls. See the [Terminology table](https://docs.unitelabs.io/integrate/concepts/connector#terminology) for the cross-surface mapping. ## Structure Every module has: - An **identifier** — the name you use to reach it from code (`temperature_controller`, `weighing_service`) - A set of **actions** — things you can call (read temperature, tare balance, set target) - A reference to its parent **service** (the connector instance it belongs to) ## Discovering modules on a connector From the Python SDK, modules are attributes of a connector instance and also addressable through the `.modules` mapping: ```python from unitelabs.sdk import AsyncApiClient async with AsyncApiClient() as client: balance = await client.get_service_by_name("Balance") # Iterate print(balance.modules.keys()) # dict_keys(['weighing_service', 'lock_controller', 'simulation_controller']) # Attribute access weight = await balance.weighing_service.get_stable_weight() ``` From the REST API: ```bash # All modules across all connected services curl "$BASE_URL/v1/modules" \ -H "Authorization: Bearer $TOKEN" # Modules on a specific service curl "$BASE_URL/v1/services/$SERVICE_ID/modules" \ -H "Authorization: Bearer $TOKEN" # Detail for one module curl "$BASE_URL/v1/modules/$MODULE_ID" \ -H "Authorization: Bearer $TOKEN" ``` The SDK uses attribute access; the REST API returns JSON you can filter server-side. Both return the same set of modules for a given connector. ## Built-in modules on every connector Three modules ship on every connector regardless of instrument — service metadata for introspection, `LockController` for exclusive access, and `SimulationController` for offline testing. See [Connector: Built-in modules](https://docs.unitelabs.io/integrate/concepts/connector#built-in-modules) for detail. ## What's inside a module Everything callable inside a module is an [action](https://docs.unitelabs.io/integrate/concepts/action/). Some actions return a single value and complete; others stream updates over time (see [Subscription](https://docs.unitelabs.io/integrate/concepts/subscription/)). Both are actions — the distinction is how long they live and how you consume them, not how you reach them in code. # Action An **action** is anything you can call on a [module](https://docs.unitelabs.io/integrate/concepts/module/). Reading the current temperature, subscribing to a file-system change stream, triggering a centrifuge spin — these are all actions. Every action has a **type** that tells you how to consume it: - **Property** — read a single scalar value and complete - **Sensor** — subscribe to a stream of values - **Control** — trigger a change, optionally with parameters, and get a result The platform UI groups actions into three sections by type. The SDK and REST API expose the type as a field on each action. See the [Terminology table](https://docs.unitelabs.io/integrate/concepts/connector#terminology) for the cross-surface mapping. ## Property actions A **Property** reads a single value and returns it right away. Use one when you need to read something once, not follow it over time. Properties take no parameters. Examples: - The connector's server name or version, metadata that rarely changes - The target temperature a thermocycler is currently set to - Whether a heater-shaker is actively holding a temperature, a boolean status (`True` or `False`) ```python # Read the connector's server name name = await file_system.sila_service.get_server_name() print(name) # "FileSystem" # Read a structured value: the folder whitelist whitelist = await file_system.folder_service.get_folder_whitelist() print(whitelist) # ['/opt/fs_demo'] ``` In the REST API, a Property is read via `POST /v1/data/{actionId}`, which returns the value once and completes. ## Sensor actions A **Sensor** exposes a stream of values that change over time. Use one when you want to follow a value, not just read it once. A sensor keeps emitting until you stop it; it does not complete on its own. Examples: - A live temperature feed from a thermocycler - The current shaking speed of a heater-shaker while it runs - A file-system watcher that emits an event whenever a folder's contents change ```python # Subscribe to file-system changes subscription = await file_system.folder_service.subscribe_changes() async with subscription: async for change in subscription: print(change) ``` Consuming a Sensor holds open a long-lived stream. See [Subscription](https://docs.unitelabs.io/integrate/concepts/subscription/) for lifecycle, reliability, and cancellation. In the REST API, a Sensor is consumed by creating a subscription (`POST /v1/subscriptions`) and reading its stream of `value` events. ## Control actions A **Control** triggers a change on the instrument, optionally with parameters. Unlike a property, it makes something happen rather than reading a value. Controls come in two kinds, named for whether you can watch them execute. An **unobservable** Control offers no view into its execution: you call it, and the call returns once the command has completed. An **observable** Control tells you while it runs how the execution is going (its state, progress, and estimated remaining time) and can send partial results along the way. **Unobservable Controls.** The call returns once the instrument confirms the change; there are no updates in between. Examples: - Set a target temperature: the setpoint is stored and the call returns; you watch the actual temperature climb through the sensor - Tare or zero a balance - Delete a file, or set the server name ```python # Trigger a file deletion await file_system.file_service.delete_file(path="/opt/fs_demo/old.txt") # Set a parameter the server remembers afterward await file_system.sila_service.set_server_name(server_name="FileSystem (Bench 3)") ``` When the call returns, the command succeeded; if something goes wrong, the connector raises a defined error instead. Many Controls return `None`, but some hand back a value. Taring a balance, for instance, returns the new tare weight and its unit: ```python tare_weight, unit, is_stable = await balance.weighing_service.tare() print(tare_weight, unit) # e.g. 0.0021 'g' ``` **Observable Controls.** These run for a while and report on their execution until the task finishes. While the command runs, it reports its **Status** (the execution state, e.g. `running`), its **Estimated Time Remaining** (in seconds), and its **Progress** (in percent). On top of these generic updates, a command can emit **intermediate responses**: typed partial results, delivered as the command produces them. Unlike a sensor, the stream does end: namely when the command completes. For example, starting a read on a plate reader returns a Status (`running`), an estimated time remaining, and a progress value, plus intermediate responses with the read data per well as it is created. The final response then returns the complete data object for the full read. Consuming these updates is optional. If you only want the final result, call the Control and await it like any other. Observable Controls can also be cancelled while they run. Observable Controls are consumed as [subscriptions](https://docs.unitelabs.io/integrate/concepts/subscription/); the stream delivers the execution updates and optional intermediate responses until the command completes. Whether a given change is modeled as an unobservable Control plus a Sensor, or as a single observable Control, is a choice the connector makes. Reaching a target temperature, for example, is often a `set_target_temperature` Control paired with a `subscribe_current_temperature` Sensor, rather than one command that blocks until the setpoint is reached. ## How SiLA maps to action types Connectors built on the UniteLabs CDK expose their functionality as SiLA features. Each SiLA primitive surfaces as one of the three action types: | SiLA primitive | Action type | SDK method | | --------------------- | ----------- | -------------------- | | Unobservable Property | `PROPERTY` | `get_()` | | Observable Property | `SENSOR` | `subscribe_()` | | Unobservable Command | `CONTROL` | `(...)` | | Observable Command | `CONTROL` | `(...)` | Both command kinds surface as `CONTROL`. The difference is that an observable command runs long enough to report progress and intermediate responses while it executes. Those updates arrive as a [subscription](https://docs.unitelabs.io/integrate/concepts/subscription/), and the final result is returned when the command completes. ## Discovering actions and their types Ask a module what actions it has, then read each action's type: ```python module = file_system.folder_service print(module.actions.keys()) # dict_keys(['get_folder_whitelist', 'subscribe_changes', 'get_folder_contents']) for name, action in module.actions.items(): print(name, action.type) # get_folder_whitelist PROPERTY # subscribe_changes SENSOR # get_folder_contents CONTROL ``` From the REST API: ```bash # All actions on a module — each record carries its type curl "$BASE_URL/v1/modules/$MODULE_ID/actions" \ -H "Authorization: Bearer $TOKEN" # One action in detail — type, parameters, responses, source (SiLA URI) curl "$BASE_URL/v1/actions/$ACTION_ID" \ -H "Authorization: Bearer $TOKEN" ``` Each action record includes its `type` (one of `PROPERTY`, `SENSOR`, `CONTROL`), a human-readable `name`, typed `parameters` and `responses` schemas, and a `source` field that identifies the underlying SiLA URI for connector developers who need it. ## Introspection: parameters and responses Every action carries typed schemas describing what it expects and what it returns. The schema is programmatically accessible so you can build UIs, validate payloads before sending them, or generate client code: ```python # What parameters does this Control accept? print(await file_system.sila_service.set_server_name.parameters) # {'ServerName': Parameter(name='Server Name', schema={...})} # What does it return? print(await file_system.sila_service.set_server_name.responses) # None ``` Parameters come with names, data types, and constraints — units, min / max ranges, enumerations. Responses have the same shape. The SDK uses this schema to serialize your arguments correctly; the platform UI uses it to render a parameter form on Controls and a value view on Properties. # Subscription A **subscription** is a long-lived consumption of a streaming action — primarily a [Sensor](https://docs.unitelabs.io/integrate/concepts/action#sensor-actions), and in some cases a long-running [Control](https://docs.unitelabs.io/integrate/concepts/action#control-actions) that emits progress updates before it completes. When you subscribe to a Sensor the connector pushes values as they change (or on a fixed interval). When you consume a long-running Control as a stream you get status updates — "spinning up", "at target speed", "complete" — followed by the final result. In the UI, a Sensor opens a live-stream panel when you click Subscribe. In the SDK the same thing is handled with an `async for` loop. See the [Terminology table](https://docs.unitelabs.io/integrate/concepts/connector#terminology). ## Creating a subscription From Python, subscribing is the same as calling any streaming action: ```python async for change in file_system.folder_service.changes(): print(change) ``` The `async for` loop stays open as long as you want to consume the stream. Break out to cancel it. From the REST API, create a subscription explicitly with the action ID and a polling interval: ```bash curl -X POST "$BASE_URL/v1/subscriptions" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "action": "", "parameters": {}, "interval": 1000 }' ``` And cancel it explicitly: ```bash curl -X DELETE "$BASE_URL/v1/subscriptions/$SUBSCRIPTION_ID" \ -H "Authorization: Bearer $TOKEN" ``` ## When you subscribe Two situations produce subscriptions: - **Sensor actions** — the primary case. A Sensor exists specifically to stream values over time (file-system changes, live sensor feeds, monitoring readings). You subscribe to receive each new value as it arrives. - **Streaming Control actions** — the secondary case. A Control that takes time to complete can report progress as it runs (centrifuge spin, PCR cycle, plate move). You subscribe to receive status updates and intermediate results, concluding with the Control's final return value. You subscribe to both the same way. The difference is what comes down the pipe. ## Lifecycle and reliability An active subscription holds open a streaming connection between the platform and the connector. Platform-side we invest heavily in keeping these connections alive across temporary network failures — reconnecting automatically when the connector becomes reachable again. But subscriptions do not live forever. They end when: - You cancel them explicitly (breaking out of `async for`, or `DELETE /v1/subscriptions/{id}`) - The connection closes and cannot be re-established - The run or workflow they belong to finishes — workflow-scoped subscriptions are cleaned up automatically when the workflow completes Do not leak subscriptions. Long-running scripts that fan out subscriptions and never close them can exhaust connector resources and confuse downstream consumers that see stale streams. ## Listing active subscriptions ```bash curl "$BASE_URL/v1/subscriptions" \ -H "Authorization: Bearer $TOKEN" ``` Useful when debugging leaked streams from a previous run, or when verifying that a new subscription registered before a dependent component starts consuming it. # Device A connector is the software. A **device** is the physical thing the connector controls. UniteLabs keeps these two concepts distinct because a connector's identity (install, version, running process) evolves differently from a device's identity (serial number, location, calibration history, ownership). The platform tracks two related but separate entities: - **Device Metadata** — the catalog of known device *types*. "Hamilton Microlab STAR" as a product model, with manufacturer, device category, and part number. Read-only from a consumer's perspective; managed by UniteLabs. - **Device** — your registered *instance*. "Our Hamilton STAR on Bench 3, serial SN-1234, owned by Lab Team A." You create, update, and delete devices. Each device links back to a Device Metadata entry to declare what type it is. ## Why the split A workflow that targets "any Hamilton STAR" is a different thing from a workflow that targets "the Hamilton STAR on Bench 3." Device Metadata gives you the former (the type system); Device gives you the latter (the instance). Audit trails, location filters, and ownership live on Device, not Device Metadata. ## Registering a device ```bash curl -X POST "$BASE_URL/v1/devices" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "Hamilton STAR (Bench 3)", "metadataId": 618, "serialNumber": "SN-1234", "location": "Lab 2 / Bench A", "owner": "team-automation", "tags": ["liquid-handler", "production"] }' ``` `metadataId` is the numeric ID from `/v1/device-metadata` — it tells the platform what *kind* of device this is. All other fields describe your specific instance. ## Browsing the catalog and the registry ```bash # What types of devices does UniteLabs know about? curl "$BASE_URL/v1/device-metadata?deviceCategory[like]=Liquid%20Handler" \ -H "Authorization: Bearer $TOKEN" # Which physical devices do we have registered? curl "$BASE_URL/v1/devices?tags=liquid-handler&location=Lab%202" \ -H "Authorization: Bearer $TOKEN" ``` Device Metadata is searchable by category, manufacturer, and name. Device is searchable by any combination of its fields — serial number, location, owner, tags, or creation / update dates. ## How devices relate to connectors A connector runs on the edge machine and controls one device. The link between them is created during connector setup: when you add a connector via GroundControl, you pick which registered Device it controls. The platform maintains this mapping so workflows can ask "which connector serves Device X?" and get a single, stable answer. This indirection is why the [service / connector / device](https://docs.unitelabs.io/integrate/concepts/connector#terminology) trio exists as three distinct concepts rather than one — they have independent life cycles. A device can outlive any specific connector version; a connector process can be restarted without re-registering the device. ## Soft delete Devices are not hard-deleted by default. `DELETE /v1/devices/{id}` sets a `deletedAt` timestamp so the audit trail stays intact and historical runs that referenced the device remain queryable. Filter deleted devices out of list queries with the `deletedAt` field when you do not want to see decommissioned instruments. # Connector Configuration A connector configuration defines how the connector reaches the instrument, where its local SiLA 2 server listens, and how it connects to your tenant. ::callout{icon="i-heroicons-light-bulb"} **No instrument at hand?** Follow along with the **File System Connector**. It exposes folders on the lab computer the way other connectors expose instruments, needs no hardware, and every step below applies unchanged. The examples on this page use it. :: ::callout{icon="i-heroicons-information-circle"} Make sure GroundControl is installed and you are signed in before continuing. See the [Setup guide](https://docs.unitelabs.io/get-started/setup/groundcontrol/) if you haven't done that yet. :: ## Choose how to run the connector GroundControl is the default way to run and manage connectors, and the rest of this page follows it. Three alternatives exist for setups where GroundControl does not fit: | Method | When | | -------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **GroundControl** (this page) | The default: a lab computer with a display and a supported operating system. | | [As an executable](https://docs.unitelabs.io/integrate/connect-a-device/with-an-executable/) | GroundControl is blocked or unavailable: no display on the lab computer, an unsupported Linux distribution, or IT policy. For a permanent setup without a display, see the [headless install](https://docs.unitelabs.io/integrate/connect-a-device/headless-install/). | | [From source](https://docs.unitelabs.io/integrate/connect-a-device/from-source/) | You develop or modify a connector and run it from a checkout. | | [Build your own](https://docs.unitelabs.io/integrate/connect-a-device/build-yourself/) | No connector exists for your instrument yet. | ## Add a Device A *device* in GroundControl represents a physical instrument. Connectors are then attached to devices to provide the communication layer. A device can hold several connectors, but only one connector that controls the instrument should run at a time; running two in parallel is not supported. Combinations that do work are complementary connectors that do not claim the instrument itself, for example a file system connector for instruments that create large files, such as sequencers or microscopes, or a power connector to switch the device on and off remotely. You can also attach several versions of the same connector for testing, with only one of them running. 1. Click **Add Device** in the Devices page. This opens a three step dialog: Device Info, Connector, and Configure. :br![Devices page with the Add Device button](https://docs.unitelabs.io/images/connect/groundcontrol/gc-devices-overview.webp) 2. Fill in the device metadata: - **Name**: a human-readable label (e.g., `Hamilton STAR`) - **Manufacturer**, **Model**, **Serial Number**: optional but useful for auditing - **Description**: any notes about this instrument or its location :br![Add Device dialog with device metadata](https://docs.unitelabs.io/images/connect/groundcontrol/gc-add-device-metadata.webp) 3. Click **Next** to proceed to connector selection. For the File System Connector there is no instrument; the device stands for the computer whose folders you expose. Name it after the machine or the bench, for example `Bench 3 file drop`. ## Add a Connector A *connector* is an executable that enables communication with your instrument. Connectors use SiLA 2 for communication with GroundControl and the UniteLabs Platform. You can add a connector in four ways: ![Connector selection modes in the Add Device dialog](https://docs.unitelabs.io/images/connect/groundcontrol/gc-add-connector-modes.webp) ### Download Connector Choose **Download Connector** and search the connector catalogue for your instrument; the examples below continue with the File System Connector, so search for **File System**. Pick a version from the dropdown and click the download button next to it. GroundControl fetches the correct binary for your operating system and architecture automatically, and saves it to `~/.unitelabs/connectors//`. Wait until the download completes, then click **Next**. Pick the newest stable version. Versions tagged `rc` are release candidates for testing; they can fail to start or validate. ![Connector registry catalogue with version picker and download button](https://docs.unitelabs.io/images/connect/groundcontrol/gc-connector-registry.webp) ### Discovered Connectors If a connector is already running on your machine or a machine elsewhere in your network and broadcasting via mDNS, GroundControl will list it on the Home page and in the Connector step of the dialog. Typical cases are connectors running on the instrument's own control PC next to the vendor software, headless deployments on a server or Raspberry Pi, and third party SiLA 2 servers. 1. Choose **Discovered Connectors**. 2. Select the connector from the list of auto-detected SiLA servers. 3. No local installation is needed. GroundControl connects to the remote process directly. The Configure step of the dialog is skipped and the device is created right away. The connector is attached as a *remote connector*; its configuration is managed on the remote server and cannot be edited in GroundControl. ### Upload Connector If you are deploying in an air-gapped environment or do not have access to our registry, reach out to your UniteLabs contact or the responsible IT contact in your organization to get the respective connector executable for manual upload: 1. Choose **Upload Connector**. 2. Browse to your connector executable (or drag-and-drop it). 3. GroundControl reads the connector's type and version automatically from the binary metadata. ### Manual Remote Connector The same cases as above, for networks where mDNS does not reach you, for example when it is blocked by IT policy or the connector runs in a different subnet. If the connector is running on a known host but not broadcasting via mDNS: 1. Choose **Manual Remote Connector**. 2. Enter the hostname or IP address and port. 3. GroundControl queries that address and lists the available SiLA services to connect to. As with discovered connectors, the Configure step is skipped and the configuration is managed on the remote server. ## Configure a Connector After selecting a connector (Download or Upload), the dialog shows a configuration form generated from the connector's own schema, grouped into four collapsible sections. Some connectors add a fifth, connector specific section. Expand each section and fill in the relevant fields: ![Connector configuration form with collapsible sections](https://docs.unitelabs.io/images/connect/groundcontrol/gc-connector-config.webp) ### General Configuration Options the connector itself exposes for how it runs. Example: Simulation Mode (start with a simulated device connection instead of the physical instrument). Check what Simulation Mode is set to before you start: a few connectors ship with it switched on. The connector then comes up, answers every call, and the instrument never moves. This section is also where the connection to the physical instrument is configured when the connector needs it, for example a serial port such as `COM3` on Windows or `/dev/ttyUSB0` on Linux, or the instrument's IP address. Connectors that detect their instrument automatically do not show such fields. The available options come from the connector's schema and differ per connector. See [What is a connector](https://docs.unitelabs.io/integrate/what-is-a-connector/) for how connectors talk to instruments. ### Local Network The configuration of the local SiLA 2 server, in other words where the connector listens on this machine. Three fields matter in most setups: - **Hostname**: the local address the server binds to. Keep the default `0.0.0.0` to listen on all interfaces, or use `localhost` to make the connector reachable only from this machine. Do not enter a remote address such as a `unitelabs.io` domain here. The connector does not start if the local SiLA server fails to come up, and without it the cloud connection fails as well. - **Port**: GroundControl fills in a free port when it builds the form; keep it so the address stays stable across restarts. If the field shows **0**, set a port yourself: **0** means a new random port on every start. We typically use five digit ports in the range 50000 to 59999. - **Name**: the human readable name of the connector. This name is displayed on the UniteLabs platform. For the running example: `File System (Bench 3)`. ::callout{icon="i-heroicons-information-circle"} The **Name** set here is what your tenant's Connectors page shows. The device name from the Add Device step appears only in GroundControl's own lists. When GroundControl and the platform seem to disagree about names, they are showing these two different things; nothing was renamed. :: The remaining fields can usually keep their defaults, and the certificate fields stay empty. The **Uuid** is the only field the form marks as required. GroundControl generates it, and it must stay the same across restarts: change it and your tenant sees an unknown new connector. ### Cloud Connectivity The outbound gRPC connection from the connector to the UniteLabs Platform. The connector dials out and keeps the connection open, so no inbound firewall rules are required. **Enabled** stays ticked. Unticked, the connector never connects to any tenant. **Endpoints** is a list, and on a fresh configuration it is often empty. Click **Add Entry** to create entry **#1**, then fill it in: - **Hostname**: your tenant's cloud endpoint. GroundControl 1.44 and later derive it from your Tenant ID, see below. Otherwise you receive it individually from your UniteLabs contact, since its exact form depends on how your tenant is deployed, for example dedicated or shared. - **Port**: **443**. A new entry starts at **0**, so set the port yourself. - **Tls**: enable it. A new entry starts with the box unticked. The rest of the entry (certificates, reconnect delay, options, name) keeps its defaults. Use one entry per tenant. A second entry means a second connection, and the connector then talks to both tenants at once. An empty field or a `localhost` value is a placeholder, not a configuration. If these values are wrong, the connector runs locally but never appears in the platform. GroundControl 1.44 and later prefill these values from the **Default Cloud Server URL** and **Default Cloud Server Port** settings ([set during installation](https://docs.unitelabs.io/get-started/setup/groundcontrol/)) when the connector is added. The prefill reaches only connectors that declare a single cloud server endpoint. Connectors built with CDK 0.13 or later carry a list of endpoints and start empty. Whenever a field stays empty or shows the placeholder, enter the value by hand. Check this section again after updating a connector to a new version. We have seen updates drop the cloud settings. ### Network Auto-Discovery Controls how the connector announces itself on the local network via mDNS (IP version and network interfaces). The defaults are fine for most setups; only change this if discovery is not working, for example when the machine has multiple network interfaces. ### ConnectorConfig Only shown for connectors that define additional connector specific parameters. The fields come from the connector's schema and differ per connector; most connectors do not have this section. The File System Connector uses this section for its access control. Each **Access** entry pairs a list of directories with the permissions granted on them: read, write, delete. Add the folder you want to expose, for example the directory your instrument writes result files into, and grant at least read. The directories must exist when the connector starts. Older connector versions offered a read-only folder whitelist instead; it is deprecated, and existing entries are treated as read-only access rules. Expand each section, fill in the fields, and click **Create Device**. GroundControl writes a `config.json` file alongside the connector executable. The form renders what the connector's schema declares, and older GroundControl versions leave out parts of it. If a setting you expect is missing, logging for example, it still exists in that `config.json` under `~/.unitelabs/connectors//`, where support can adjust it. ## Start and Stop Connectors ::callout{icon="i-heroicons-information-circle"} The following sections apply only to connectors installed through Download Connector or Upload Connector. GroundControl cannot start, stop, configure, or display live logs for discovered or manually added remote connectors. :: Open **Devices**, click your device, then click **Configure**. The Configure view shows the device details and every connector attached to this device. Each connector row shows the connector name, its version, and a status light: green when the connector process is running, red when it is stopped or in error. Click **Start** to launch the connector. While it is running, the row shows **Stop** and **Restart** instead. The connector must be running for workflows to reach your instrument via the UniteLabs platform. ![Connector status and controls](https://docs.unitelabs.io/images/connect/groundcontrol/gc-connector-status.webp) ## Logs, Auto-Start, and Configuration Changes Expand a connector row to manage everything about this connector in one place: - **Logs**: the live log output of the connector process. This is the first place to look when a connector does not start or does not connect. - **Start automatically on login**: enable this toggle to start the connector automatically when you log in to your computer. The connector must be configured before the toggle can be enabled. - **Configuration sections**: the same sections as in the Add Device dialog (General Configuration, Local Network, Cloud Connectivity, Network Auto-Discovery), pre-filled with the current values. To change the configuration later: adjust the fields, click **Save Configuration**, and **Restart** the connector. Changes only take effect after a restart. ![Expanded connector row with logs, auto-start, and configuration](https://docs.unitelabs.io/images/connect/groundcontrol/gc-edit-config.webp) ### Log Files Everything GroundControl manages lives in the `.unitelabs` folder of your user directory. GroundControl writes its own application log there, and each locally managed connector writes a log file next to its executable and `config.json`: | Platform | GroundControl application log | Connector logs | | ------------------- | ------------------------------------------------- | ------------------------------------------------------------------ | | **Windows** | `%USERPROFILE%\.unitelabs\logs\groundcontrol.log` | `%USERPROFILE%\.unitelabs\connectors\\connector.log` | | **macOS and Linux** | `~/.unitelabs/logs/groundcontrol.log` | `~/.unitelabs/connectors//connector.log` | The application log rotates; older files carry a timestamp suffix. Check the connector log when a connector fails to start or connect; check the application log for problems with GroundControl itself, such as sign-in or registry access. ## Remove a Connector or Device - To take a connector out of service temporarily, click **Stop**. It stays configured and can be started again at any time. - To remove a connector from the device, click the trash icon in the connector row. - To delete the whole device including its connectors, open the three dot menu next to the device name and select **Delete**. ::callout{icon="i-lucide-triangle-alert"} Removing and re-adding a connector is not a repair step. The reinstall starts from a fresh configuration (Cloud Connectivity falls back to the placeholder unless the Settings prefill fills it) and a new server UUID, so your tenant sees an unknown new connector while everything that referenced the old one keeps failing. Fix the configuration in place instead. If you do reinstall, re-enter the Cloud Connectivity values and verify the connector afterwards. :: ## Verify the connector works Verify the connector locally and through your tenant. A green status light confirms only that the connector process is running. **In GroundControl**: open **Devices**, click your device, then the **Operations tab**, expand a module and run a harmless read operation. On an instrument connector, a good first call is **Get Firmware Version** in the device service module. On the File System Connector, expand **Folder Service** and run **List Files** with one of the configured directories as the path; the response lists the files in that folder. If this works, the connector reaches its instrument. **In your tenant**: open the Connectors page in the web interface. The connector appears as online within a few seconds. Open it and run the same read operation from there. This confirms both the platform-to-connector and connector-to-instrument connections. For other operations, see [Use a connector via GroundControl](https://docs.unitelabs.io/integrate/use-a-connector/groundcontrol/). ## Troubleshooting and common mistakes **A run or script fails with `ServiceUnavailableException`, status 503, "SiLA server with serverUUID ... is not connected".** The platform holds no open connection to that connector. The message names a UUID, not a connector; the Connectors page in your tenant tells you which one it is. Then work through this order on the lab computer: 1. Is the connector running at all? Check its status light in GroundControl and read the connector logs (expand the connector row). 2. Does a second instance hold the same identity? A twin entry named `... (2)` on the Connectors page means the connector was re-added and now runs under a new UUID, while everything that referenced the old one keeps failing. 3. Are the Cloud Connectivity values right? See the next item. 4. Restart the connector last. If **Restart** has no effect, use **Stop** and then **Start**. A restart alone rarely fixes this error. **Several connectors fail with 503 at the same time.** Then it is usually not your lab. Platform maintenance and outages look exactly like a local connection problem. Ask support before you change anything on the lab computer. **The connector runs and responds in GroundControl but never appears in your tenant, or stays offline there.** The Cloud Connectivity values are wrong or still on the placeholder. Typical after adding a connector without the Settings prefill, or after removing and re-adding one: a reinstall resets the configuration and the server UUID, see the callout in [Remove a Connector or Device](https://docs.unitelabs.io/#remove-a-connector-or-device). Enter your tenant endpoint, port 443, TLS enabled; save, restart, and run the verification above. **The connector shows online in your tenant but the instrument does not respond.** Online means the connector process is connected to your tenant, nothing more. Run a read operation to test the instrument link; if it fails, check the physical connection, whether Simulation Mode is switched on, and the instrument fields in General Configuration. **Starting or restarting fails with "uuid already exists".** A running connector already claims this server UUID: usually a second instance of the same connector on this or another machine, or a copied configuration. Find and stop the duplicate instead of changing UUIDs by hand; that multiplies the confusion. The Connectors page in your tenant lists everything currently registered. **Your tenant shows a connector as online that is long dead.** A stale registration can block the connector from registering again. Delete the entry on the tenant's Connectors page, then restart the connector so it registers fresh. **The connector downloads but does not start on Windows.** Three known causes: the Microsoft Visual C++ Redistributable is missing or broken (install or repair it), Windows quarantines the downloaded executable (right click the file, Properties, Unblock, or `Unblock-File` in PowerShell), or the instrument's USB driver is missing (many serial-over-USB instruments need the vendor's VCP driver). **Only a red status light and empty logs.** Run the same connector once from a terminal, see [As an executable](https://docs.unitelabs.io/integrate/connect-a-device/with-an-executable/); the console output usually names the problem, for example a directory in the File System Connector's access list that does not exist. **GroundControl shows no connectors although they were set up on this machine.** GroundControl stores connectors, configurations, and logs per user under `~/.unitelabs`. Signed in to the computer as a different user, you see nothing. Use one shared lab account, see the [Setup guide](https://docs.unitelabs.io/get-started/setup/groundcontrol/). ## Next steps Your connector is online. See it from your tenant with [Use a connector from the platform](https://docs.unitelabs.io/integrate/use-a-connector/platform/), or reach it from Python with [Calling a connector](https://docs.unitelabs.io/integrate/control-with-code/). # With an executable Connectors are distributed as self-contained executables that bundle their own Python runtime, so you can run one on any machine without installing GroundControl or Python. This page covers how to start the executable and the commands you can run through it. ::callout{icon="i-heroicons-information-circle"} For a complete production setup, placing the binary, generating a config, and registering it as a `systemd` service, follow the [Headless install guide](https://docs.unitelabs.io/integrate/connect-a-device/headless-install/). :: ## Pick the right executable Executables are built per device and per platform, following the naming scheme `unitelabs---`: | Platform | `` | Example | | -------------------------------- | --------------------------- | ---------------------------------------------------------- | | Windows (x86\_64) | `x86_64-pc-windows-msvc` | `unitelabs-microlab-star-x86_64-pc-windows-msvc-0.5.0.exe` | | Linux (x86\_64) | `x86_64-unknown-linux-gnu` | `unitelabs-microlab-star-x86_64-unknown-linux-gnu-0.5.0` | | Linux (ARM64, e.g. Raspberry Pi) | `aarch64-unknown-linux-gnu` | `unitelabs-microlab-star-aarch64-unknown-linux-gnu-0.5.0` | | macOS (Apple Silicon) | `aarch64-apple-darwin` | `unitelabs-microlab-star-aarch64-apple-darwin-0.5.0` | ::callout{icon="i-heroicons-information-circle"} On Windows, every command run through `--start-cmd` additionally requires the `--app` option pointing at the connector's app factory, e.g. `--start-cmd "config create --app unitelabs.:create_app"`. Note that `` uses underscores here, e.g. `unitelabs.microlab_star:create_app` for the Microlab STAR. :: ## Start the connector Run the executable directly to start the connector with its default configuration (`config.json` in the current directory): ::code-group ```bash [Linux / macOS] # Make the file executable once after downloading chmod +x unitelabs--- ./unitelabs--- ``` ```powershell [Windows] .\unitelabs--x86_64-pc-windows-msvc-.exe --start-cmd "connector start --app unitelabs.:create_app" ``` :: The connector reads `config.json` from the working directory. To generate one first, or to point at a different file, use the start commands below. ## Choose a start command By default the executable runs `connector start`. The `--start-cmd` flag lets you run any of the connector's CLI commands through the same binary. For example automatically generating a config.json, inspecting the config documentation or starting the connector. ::code-group ```bash [Linux / macOS] # Generate a default config.json in the current folder ./unitelabs--- --start-cmd "config create" # Show the resolved configuration ./unitelabs--- --start-cmd "config show" # Start the connector explicitly (the default) ./unitelabs--- --start-cmd "connector start" ``` ```powershell [Windows] # Generate a default config.json in the current folder .\unitelabs--x86_64-pc-windows-msvc-.exe --start-cmd "config create --app unitelabs.:create_app" # Show the resolved configuration .\unitelabs--x86_64-pc-windows-msvc-.exe --start-cmd "config show --app unitelabs.:create_app" # Start the connector explicitly (the default) .\unitelabs--x86_64-pc-windows-msvc-.exe --start-cmd "connector start --app unitelabs.:create_app" ``` :: These are the same `connector` and `config` commands you would run from source (see [From source](https://docs.unitelabs.io/integrate/connect-a-device/from-source/)) for what each command does and how to configure logging, the SiLA server, and the cloud connection. ::callout{icon="i-lucide-triangle-alert"} You may also see `--start-cmd "connector start -vvv"` in older guides. The `-v`/`--verbose` flag is **deprecated** and will be removed in a future version. Configure the log level through the `logging` section of your config instead (see [Logging](https://docs.unitelabs.io/integrate/connect-a-device/from-source#logging)). :: ## Use the connector With your connector running, you can interact with your instrument in three ways: - **[GroundControl](https://docs.unitelabs.io/integrate/use-a-connector/groundcontrol/)** — read values, trigger actions, and monitor activity directly from the edge app. - **[UniteLabs Platform](https://docs.unitelabs.io/integrate/use-a-connector/platform/)** — view, inspect, and operate the connector from the Platform UI, and add it to workflows. - **[SDK & REST API](https://docs.unitelabs.io/integrate/use-a-connector/python/)** — control the connector programmatically: discover services, explore modules and actions, and subscribe to live data. # From source If you have a connector's Python project, cloned from source or scaffolded with the [`connector-factory`{style="color: green;"}](https://gitlab.com/unitelabs/cdk/connector-factory){rel=""nofollow,noopener""}, you can configure and run it directly with `uv`, `hatch`, or `poetry`. This is the typical workflow during connector development and for advanced deployments where you run the connector from its project rather than as a packaged executable. ::callout{icon="i-heroicons-light-bulb"} Want to build your own connector? See [Build your own](https://docs.unitelabs.io/integrate/connect-a-device/build-yourself/). :: ## Create a configuration file A connector is configured through a `config.json` (or `config.yaml`) file. Generate one with the `config create` command: ::code-group ```bash [uv] uv run config create --app unitelabs.connector_starter:create_app ``` ```bash [hatch] hatch run config create --app unitelabs.connector_starter:create_app ``` ```bash [poetry] poetry run config create --app unitelabs.connector_starter:create_app ``` :: ::callout{icon="i-heroicons-light-bulb"} The `--app` option is required for Windows. On MacOS and Linux, commands like `connector start` and `config` will generally work without providing this option. On these operating systems, needing to provide `--app` often points to an error in the connector package. :: The `create` command by default will create a file called `config.json` in the current working directory. We support `yaml` and `json` file-types and allow users to specify their own path and file type by adding the `--path` argument: ::code-group ```bash [uv] uv run config create --app unitelabs.connector_starter:create_app --path /path/to/config.prod.json ``` ```bash [hatch] hatch run config create --app unitelabs.connector_starter:create_app --path /path/to/config.prod.json ``` ```bash [poetry] poetry run config create --app unitelabs.connector_starter:create_app --path /path/to/config.prod.json ``` :: ## Run the connector Starting the connector named `connector-starter` with a configuration file at the default path is as simple as: ::code-group ```bash [uv] uv run connector start --app unitelabs.connector_starter:create_app ``` ```bash [hatch] hatch run connector start --app unitelabs.connector_starter:create_app ``` ```bash [poetry] poetry run connector start --app unitelabs.connector_starter:create_app ``` :: Both `connector start` and `certificate generate` CLIs have a `--config-path` or `-cfg` argument that allows one to pass in a path to their configuration file, enabling users to create and apply multiple configurations for their connectors. ::code-group ```bash [uv] uv run connector start --app unitelabs.connector_starter:create_app -cfg /path/to/config.prod.json ``` ```bash [hatch] hatch run connector start --app unitelabs.connector_starter:create_app -cfg /path/to/config.prod.json ``` ```bash [poetry] poetry run connector start --app unitelabs.connector_starter:create_app -cfg /path/to/config.prod.json ``` :: ::callout{color="amber" icon="i-heroicons-exclamation-triangle"} **Deprecated:** The `-v`/`--verbose` CLI flag on `start` and `dev` is **deprecated** and will be removed in future versions. Until then it overrides the root config level. Configure the log level through the [`logging`](https://docs.unitelabs.io/#logging) section instead. :: For more information about the `config` CLI, check out the help documentation: ::code-group ```bash [uv] uv run config --help ``` ```bash [hatch] hatch run config --help ``` ```bash [poetry] poetry run config --help ``` :: or for a sub-command, e.g. `create`: ::code-group ```bash [uv] uv run config create --help ``` ```bash [hatch] hatch run config create --help ``` ```bash [poetry] poetry run config create --help ``` :: ## Logging A connector logs its activity so you can monitor it and diagnose problems. There is no need to set anything up to get useful logs. Leaving the `logging` field of your configuration unset (`null`, the default) applies sensible, command-specific presets automatically: | Command | Console (`stderr`) | JSON log file (`./logs/connector.log`) | | ----------------- | ----------------------- | -------------------------------------- | | `connector dev` | `DEBUG`, human-readable | — | | `connector start` | `INFO`, human-readable | `DEBUG`, structured JSON | In production (`connector start`) the console shows `INFO` and above for a readable live view, while the rotating file records everything from `DEBUG` up as structured JSON for troubleshooting. Once the active file reaches 5 MB it rolls over to `connector.log.1`, `connector.log.2`, and so on. By default the connector keeps the active file plus the 199 most recent rotated files (`backupCount`), deleting the oldest on each rollover: ```text ./logs/ ├── connector.log ← active file ├── connector.log.1 ← most recent rotation ├── connector.log.2 ← ... └── connector.log.199 ← oldest kept; deleted on the next rollover ``` ::callout{icon="i-heroicons-exclamation-triangle"} The `backupCount` default is a **safety cap to stop logs from filling the disk**. It bounds total usage to roughly 1 GB. It is **not** a retention or archival policy. It is the connector user's responsibility to manage log files by shipping them to a log aggregator, rotating them with an external tool, or backing them up before they are deleted. If you don't need a large local buffer, or don't care about the logs except for error debugging in case of crashes, you can lower `backupCount` (or `maxBytes`) in your [logging config](https://docs.unitelabs.io/#logging); setting `backupCount` to `0` keeps only the active file. For more information how to configure the file handler read the respective [`logging.handlers.RotatingFileHandler`{style="color: green;"}](https://docs.python.org/3/library/logging.handlers.html#rotatingfilehandler){rel=""nofollow,noopener""} documentation. :: ### Customizing the logging configuration To change the defaults, set the `logging` field to a standard Python [logging configuration dictionary](https://docs.python.org/3/library/logging.config.html#configuration-dictionary-schema){rel=""nofollow,noopener""}. Once set, your configuration is used for *every* command, overwriting the defaults. To scaffold a new config file pre-populated with a preset, use the `--explicit-logging`/`-x` flag of `config create`. The flag defaults to `prod`: ::code-group ```bash [uv] uv run config create --app unitelabs.connector_starter:create_app -x prod ``` ```bash [hatch] hatch run config create --app unitelabs.connector_starter:create_app -x prod ``` ```bash [poetry] poetry run config create --app unitelabs.connector_starter:create_app -x prod ``` :: Alternatively, copy one of the presets below into the `logging` section of your config file and adapt it: ::code-group ```json [prod] "logging": { "version": 1, "disable_existing_loggers": false, "handlers": { "file": { "class": "unitelabs.cdk.logging.RotatingStructlogHandler", "filename": "./logs/connector.log", "maxBytes": 5242880, "backupCount": 200, "encoding": "utf-8", "level": "DEBUG" }, "console": { "class": "unitelabs.cdk.logging.ConsoleHandler", "level": "INFO" } }, "root": { "level": "DEBUG", "handlers": ["file", "console"] } } ``` ```json [dev] "logging": { "version": 1, "disable_existing_loggers": false, "handlers": { "console": { "class": "unitelabs.cdk.logging.ConsoleHandler" } }, "root": { "level": "DEBUG", "handlers": ["console"] } } ``` :: ### Adjusting log levels per module Loggers are organized hierarchically by dotted path, so any module can be tuned independently by adding a `loggers` entry with its own `level`, while `root` sets the default for everything else: ```json "logging": { ... "loggers": { "sila.server.cloud_server": { "level": "INFO" }, "unitelabs.cdk": { "level": "WARNING" }, "connector_name": { "level": "DEBUG" } }, ... } ``` In the example above: - `sila.server.cloud_server` — the cloud server module emits `INFO` and above, dropping its `DEBUG` records. - `unitelabs.cdk` — everything within the CDK emits `WARNING` and above only. - `connector_name` — everything that specific connector logs, down to `DEBUG`, is captured. ::callout{icon="i-heroicons-light-bulb"} Building a connector yourself and want to emit logs from your own code? See the developer [Logging guide](https://docs.unitelabs.io/connector-development/guides/logging/). :: ## Use the connector With your connector running, you can interact with your instrument in three ways: - **[GroundControl](https://docs.unitelabs.io/integrate/use-a-connector/groundcontrol/)** — read values, trigger actions, and monitor activity directly from the edge app. - **[UniteLabs Platform](https://docs.unitelabs.io/integrate/use-a-connector/platform/)** — view, inspect, and operate the connector from the Platform UI, and add it to workflows. - **[SDK & REST API](https://docs.unitelabs.io/integrate/use-a-connector/python/)** — control the connector programmatically: discover services, explore modules and actions, and subscribe to live data. # Build your own Can't find a connector for your instrument in the registry? You can build your own using the UniteLabs Connector Development Kit (CDK). UniteLabs provides a [`connector-factory`{style="color: green;"}](https://gitlab.com/unitelabs/cdk/connector-factory){rel=""nofollow,noopener""} project scaffold, a SiLA 2 server, and everything needed to expose your device to GroundControl and the UniteLabs Platform, the same way UniteLabs connectors are built. Once built, your connector runs exactly like any other: [from source](https://docs.unitelabs.io/integrate/connect-a-device/from-source/). ::callout{icon="i-heroicons-arrow-right-circle"} Get started with the [Connector Development overview](https://docs.unitelabs.io/connector-development/getting-started/overview/). :: # Headless install Connectors are distributed as self-contained executables that bundle their own Python runtime. You can run them directly on any Linux machine without installing GroundControl or Python. This is the typical setup for: - Raspberry Pi devices co-located with instruments - Headless Linux servers in the lab network - Environments where a GUI is not practical For workstations and lab PCs with a display, [GroundControl](https://docs.unitelabs.io/get-started/setup/groundcontrol/) is the easier path. ## Get the connector executable Contact your UniteLabs representative or download the connector from the UniteLabs connector registry. Executables are named: ```text unitelabs--- ``` For example, on a Raspberry Pi 4 (64-bit): ```text unitelabs-microlab-star-aarch64-unknown-linux-gnu-0.5.0 ``` Common architecture suffixes: | Architecture | `` | | --------------------- | --------------------------- | | x86\_64 Linux | `x86_64-unknown-linux-gnu` | | ARM64 Linux (RPi 4/5) | `aarch64-unknown-linux-gnu` | The commands below use `unitelabs---` as a placeholder — replace it with the file name of the executable you downloaded, and `` with your device, e.g. `microlab_star`. ## Install and configure ### 1. Place the binary Create a folder for the connector under `/opt/connectors/`: ```bash sudo mkdir -p /opt/connectors/ sudo cp unitelabs--- /opt/connectors// sudo chmod +x /opt/connectors//unitelabs--- ``` ### 2. Generate the default config Run the connector once with the `config create` command to generate a `config.json` with all available fields: ```bash cd /opt/connectors/ ./unitelabs--- --start-cmd "config create" ``` This writes `config.json` to the current folder. ### 3. Edit config.json Open `config.json` and fill in the instrument-specific settings — typically the connector's hostname or IP address and port it should run on or instrument specifics such as the serial port (This is not required for the Mircolab STAR as it auto-detects the instrument): ```json { "serial_port": "", "sila_server": { "hostname": "0.0.0.0", "port": 50052, "tls": true, "name": "Hamilton Microlab STAR", ... } } ``` To connect to the UniteLabs cloud, edit the `cloud_server_endpoint` block: ```json { "cloud_server_endpoint": { "hostname": ".unitelabs.io", "port": 443, "tls": true } } ``` ### 4. Test run Run the connector directly to verify it starts and connects to the instrument: ```bash ./unitelabs--- ``` Check the logs for any connection errors. Press `Ctrl+C` to stop. The default logging config writes a log file in the specified folder in the `config.json`. For troubleshooting during a headless install, we recommend starting a connector in a more verbose mode that prints the logs to the terminal ```bash ./unitelabs--- --start-cmd "connector start -vvv" ``` ::callout{icon="i-lucide-triangle-alert"} The `-v`/`--verbose` CLI flag is **deprecated** and will be removed in future versions. Until then it overrides the root logger config level. For more information on the new logger configuration read the [logging section](https://docs.unitelabs.io/integrate/connect-a-device/from-source#logging) of the "from source" guide. :: ## Register as a systemd service For production use, register the connector as a systemd service so it starts automatically on boot and restarts on failure. ### Create the service file Create `/etc/systemd/system/ul-.service`: ```ini [Unit] Description=UniteLabs After=network.target Wants=network-online.target [Service] User= WorkingDirectory=/opt/connectors/ ExecStart=/opt/connectors//unitelabs--- Restart=on-failure RestartSec=10 [Install] WantedBy=multi-user.target ``` ::callout{icon="i-heroicons-information-circle"} Name all UniteLabs services with the `ul-` prefix — it makes them easy to list and manage together. :: ### Enable and start ```bash sudo systemctl daemon-reload sudo systemctl enable ul- sudo systemctl start ul- sudo systemctl status ul- ``` ### Common service commands ```bash # List all UniteLabs services systemctl list-units | grep ul- # Check logs journalctl -u ul- -f # Restart after a config change sudo systemctl restart ul- ``` ### Back up the service file Store a copy of the service file alongside the connector binary so the setup can be fully reconstructed from `/opt/connectors/` alone: ```bash sudo cp /etc/systemd/system/ul-.service /opt/connectors// ``` ## Notes - The connector binary unpacks a Python runtime into a `.cache/` directory on first run. This is expected behavior shared across all connectors. - Each connector must use a **unique port** for its local SiLA 2 server. By convention, assign ports incrementally (50052, 50053, 50054, …) or use a site-specific range. - See [Network requirements](https://docs.unitelabs.io/get-started/setup/network-requirements/) for firewall rules and other security settings. ## Example: multi-connector Raspberry Pi setup For a complete example of multiple connectors running on a single Raspberry Pi, including folder layout, active services, and troubleshooting, see the [Raspberry Pi deployment example](https://docs.unitelabs.io/integrate/connect-a-device/raspberry-pi/). # Raspberry Pi This example shows a typical multi-connector deployment on a Raspberry Pi 4 running Raspberry Pi OS (64-bit). All connectors run as systemd services and start automatically on boot. For the step-by-step setup guide, see [Headless install](https://docs.unitelabs.io/integrate/connect-a-device/headless-install/). --- ## Folder structure All connectors live under `/opt/connectors/`, one folder per connector: ```text /opt/connectors/ ├── microlab_star/ │ ├── unitelabs-microlab-star-aarch64-unknown-linux-gnu- │ ├── config.json │ └── ul-microlab-star.service # backup of the systemd unit ├── bioshake_q1/ │ ├── unitelabs-bioshake-q1-aarch64-unknown-linux-gnu- │ ├── config.json │ └── ul-bioshake-q1.service └── filesystem/ ├── unitelabs-filesystem-aarch64-unknown-linux-gnu- ├── config.json └── ul-filesystem.service ``` Each connector folder contains: - The connector **binary**: self-contained executable with a bundled Python runtime - **`config.json`**: instrument connection settings and cloud relay config - A **backup of the systemd unit file**: so the full setup can be reconstructed from `/opt/connectors/` alone --- ## Example active services | Service name | Connector | Port | | ------------------- | ---------------------- | ----- | | `ul-microlab-star` | Hamilton Microlab STAR | 50052 | | `ul-bioshake-q1` | Bioshake Q1 | 50053 | | `ul-filesystem` | File System | 50054 | | `ul-tapo-camera-01` | Tapo Camera | 50055 | Each service uses a unique port for its local SiLA 2 server. Assign ports incrementally to avoid conflicts. --- ## Common operations ### List all UniteLabs services ```bash systemctl list-units | grep ul- ``` ### Check the status of a service ```bash sudo systemctl status ul-microlab-star ``` ### Start / stop / restart ```bash sudo systemctl start ul-microlab-star sudo systemctl stop ul-microlab-star sudo systemctl restart ul-microlab-star ``` ### View live logs ```bash journalctl -u ul-microlab-star -f ``` --- ## Adding a new connector ### 1. Create the folder and place the binary ```bash sudo mkdir -p /opt/connectors/ sudo cp /opt/connectors// sudo chmod +x /opt/connectors// ``` ### 2. Generate and edit the config ```bash cd /opt/connectors/ ./ --start-cmd "config create" # edit config.json with instrument settings and the next available port ``` ### 3. Create the systemd service file Create `/etc/systemd/system/ul-.service`: ```ini [Unit] Description=UniteLabs After=network.target Wants=network-online.target [Service] User= WorkingDirectory=/opt/connectors/ ExecStart=/opt/connectors// Restart=on-failure RestartSec=10 [Install] WantedBy=multi-user.target ``` ### 4. Enable and start ```bash sudo systemctl daemon-reload sudo systemctl enable ul- sudo systemctl start ul- sudo systemctl status ul- ``` ### 5. Back up the service file ```bash sudo cp /etc/systemd/system/ul-.service /opt/connectors// ``` --- ## Removing a connector ```bash sudo systemctl stop ul- sudo systemctl disable ul- sudo rm /etc/systemd/system/ul-.service sudo systemctl daemon-reload sudo systemctl reset-failed sudo rm -rf /opt/connectors/ ``` --- ## Notes - The connector binary unpacks a Python runtime into `/opt/connectors/.cache/` on first run. This is expected and shared across all connectors on the machine. - Service names use the `ul-` prefix so they group together when listing services. - Camera connectors may log raw binary data (encoded video stream): this is normal. # GroundControl GroundControl is our edge application that helps you with the local setup on each machine. From providing you access to the UniteLabs registry and with that our connector library, installing and configuring connectors, to communication with the connectors and the platform. It has three main areas: **Home** (auto-detected connectors and 3rd party SiLA servers on your local network), the **Devices** list (your configured instruments), and **Settings**. In its current state, GroundControl is not fully integrated into the UniteLabs Platform yet. Future iterations will enable full remote control of GroundControl via the platform to enable deployment and management of all edge applications with IaC. This guide covers working with a configured connector: reading values, triggering actions, and following what happened. To add and configure a connector first, see [Connector Configuration](https://docs.unitelabs.io/integrate/connect-a-device/connector-configuration/). ::callout{icon="i-heroicons-information-circle"} Make sure GroundControl is installed and you are signed in before continuing. See the [Setup guide](https://docs.unitelabs.io/get-started/setup/groundcontrol/) if you haven't done that yet. :: ## Interact with a Connector Open **Devices** → **[your device]** → **Operations tab** to directly read values and trigger actions on your instrument. If a connector does not show up in the platform yet, check here if you can control the instrument. In that case, the configurations cloud endpoint may need to be adjusted. ![Operations tab with an expanded module showing properties and commands](https://docs.unitelabs.io/images/connect/groundcontrol/gc-operations-tab.webp) ### Modules and Operations The Operations tab lists the connector's [modules](https://docs.unitelabs.io/integrate/concepts/module/), for example a SiLA Service module and the instrument specific modules. Expand a module to see its operations. Each operation is one of two types: - **Property**: a value you can read. Observable properties update live while you watch them. - **Command**: an operation you can execute, some with a parameter form. Long-running commands report their execution status while they run and can stream intermediate results, useful for operations like a centrifuge spin or a PCR cycle. See [Action](https://docs.unitelabs.io/integrate/concepts/action/) for the full model. ## Activity Tab **Devices** → **[your device]** → **Activity** shows a timestamped log of every operation executed on the device: which action was called, the parameters sent, and the result returned. The default logging behavior also stores this activity log to file; see the [log file locations](https://docs.unitelabs.io/integrate/connect-a-device/connector-configuration/#logs-auto-start-and-configuration-changes). Use this to confirm that actions triggered from a workflow actually reached the instrument, or to debug unexpected behavior. ## Next steps [Use a connector from the platform](https://docs.unitelabs.io/integrate/use-a-connector/platform/) shows the same device in the web interface; [Python](https://docs.unitelabs.io/integrate/use-a-connector/python/) reaches it from code. # Platform The [Connectors page](https://app.unitelabs.io/connectors){rel=""nofollow,noopener""} in the UniteLabs Platform gives you a central view of every connector registered to your tenant — regardless of which edge machine it runs on. ::callout{icon="i-heroicons-information-circle"} A connector must be running in GroundControl and have cloud connectivity enabled to appear here. See the [Connector Configuration troubleshooting](https://docs.unitelabs.io/integrate/connect-a-device/connector-configuration/#troubleshooting-and-common-mistakes) if your connector isn't showing up. :: ## Connector status Each connector card shows a status badge: | Status | Meaning | | ----------- | -------------------------------------------------------- | | **Online** | Connector is running and reachable via the cloud relay | | **Offline** | Connector process has stopped or lost cloud connectivity | Click a connector card to open its detail view. ## Browsing modules, properties, sensors, and controls The connector detail view has a **Modules** column listing every module the connector exposes. Click a module to reveal its actions, grouped into three sections by type: - **Properties** — scalar values you can read (server name, whitelist entries, current sensor readings) - **Sensors** — live streams you can subscribe to (file-system change feeds, continuous temperature, weight over time) - **Controls** — triggerable commands, some with parameters (delete a file, set a target, start a run) This is useful for: - Verifying a connector is working before building a workflow - Discovering what an unfamiliar connector is capable of - Quickly reading a value, watching a stream, or triggering a control without writing any code See [Action](https://docs.unitelabs.io/integrate/concepts/action/) for the full model behind the three types. ## Running an action from the UI You can execute any of the three action types directly from the platform without writing code: 1. Navigate to the connector's detail page 2. Select a module to reveal its Properties, Sensors, and Controls 3. Click the action you want to interact with: - **Read** for a Property — returns the current value - **Subscribe** for a Sensor — opens a live-stream panel that updates in real time until you close it or navigate away - **Execute** for a Control — shows a parameter form if the Control takes parameters, fill it in and confirm ::callout{icon="i-heroicons-light-bulb"} This is the fastest way to verify an instrument is responding correctly after initial setup. :: ## Adding a connector to a workflow Once you've confirmed a connector is online and working, you can use it in automation workflows. From the connector detail page, click **Add to workflow** or navigate to the Workflows section and select the connector when defining an instrument step. For programmatic access, use the [UniteLabs SDK & REST API](https://docs.unitelabs.io/integrate/use-a-connector/python/). ## Adding a new connector Adding a connector is done through GroundControl — the platform shows connectors that are already running, it doesn't install or configure them (yet). See the [GroundControl setup guide](https://docs.unitelabs.io/get-started/setup/groundcontrol/) for how to add and configure a new device. # UniteLabs SDK & REST API The UniteLabs SDK and REST API expose the same connector model: **services** (connectors), **modules** (features), and **actions** (properties, sensors, and controls). Each section below shows both approaches side by side. ::callout{icon="i-heroicons-information-circle"} This guide shows how to discover connectors and call each type of action. For a complete automation-script walkthrough, including deck setup and liquid handling, continue with [Calling a Connector](https://docs.unitelabs.io/integrate/control-with-code). :: ## Prerequisites ::tabs :::div{icon="i-simple-icons-python" label="UniteLabs SDK"} 1. [Access to the UniteLabs platform](https://docs.unitelabs.io/get-started/welcome/) 2. An environment with an installed SDK — see [SDK installation](https://docs.unitelabs.io/get-started/setup/sdk-installation) 3. A connector deployed and connected to your tenant (the [thermocycler demo](https://gitlab.com/unitelabs/connectors/thermocycler){rel=""nofollow,noopener""} works for these examples) ::: :::div{icon="i-heroicons-command-line" label="REST API"} 1. [Access to the UniteLabs platform](https://docs.unitelabs.io/get-started/welcome) 2. API credentials: your **tenant ID**, **client ID**, and **client secret**: ask your UniteLabs contact if you don't have these 3. A connector deployed and connected to your tenant ::: :: ## Authentication The UniteLabs SDK handles authentication automatically using environment variables. For the REST API you need a Bearer token. ::tabs :::div{icon="i-simple-icons-python" label="UniteLabs SDK"} The SDK reads credentials from environment variables. Set them in your `.env` file: ```bash [.env] BASE_URL=your-tenant-base-url AUTH_URL=your-authentication-url CLIENT_ID=your-client-id CLIENT_SECRET=your-client-secret ``` Then instantiate the client — no token handling required: ```python from unitelabs.sdk import AsyncApiClient client = AsyncApiClient() ``` ::: :::div{icon="i-heroicons-command-line" label="curl"} Request a Bearer token via OAuth2 client credentials: ```bash [Terminal] curl -X POST "https://auth.unitelabs.io/realms/{tenant-id}/protocol/openid-connect/token" \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "grant_type=client_credentials&client_id={client-id}&client_secret={client-secret}" ``` Store the token and set your base URL for subsequent requests: ```bash [Terminal] TOKEN="" BASE_URL="https://api.unitelabs.io/{tenant-id}/v1" ``` ::: :: --- ## List connectors Retrieve all connectors connected to your tenant. ::tabs :::div{icon="i-simple-icons-python" label="UniteLabs SDK"} ```python services = await client.list_services() print(services) ``` ```bash [Terminal] (Thermocycler(client=..., id='daa46515-49bc-4a7d-944f-369732edde2e', name='Thermocycler'),) ``` ::: :::div{icon="i-heroicons-command-line" label="curl"} ```bash [Terminal] curl "$BASE_URL/services" \ -H "Authorization: Bearer $TOKEN" ``` ```json [Response] [ { "id": "daa46515-49bc-4a7d-944f-369732edde2e", "name": "Thermocycler", "category": "thermocycler" } ] ``` ::: :: --- ## Get a specific connector Look up a connector by name or ID. ::tabs :::div{icon="i-simple-icons-python" label="UniteLabs SDK"} By name: ```python thermocycler = await client.get_service_by_name(name="Thermocycler") print(thermocycler.id) print(thermocycler.name) ``` By ID: ```python thermocycler = await client.get_service(service_id="daa46515-49bc-4a7d-944f-369732edde2e") ``` ::: :::div{icon="i-heroicons-command-line" label="curl"} ```bash [Terminal] curl "$BASE_URL/services/daa46515-49bc-4a7d-944f-369732edde2e" \ -H "Authorization: Bearer $TOKEN" ``` ::: :: --- ## Explore modules (features) A connector's **modules** are its features — logical groupings of related actions. ::tabs :::div{icon="i-simple-icons-python" label="UniteLabs SDK"} ```python print(thermocycler.modules.keys()) ``` ```bash [Terminal] dict_keys(['sila_service', 'temperature_controller', 'door_controller']) ``` ::: :::div{icon="i-heroicons-command-line" label="curl"} ```bash [Terminal] curl "$BASE_URL/services/daa46515-49bc-4a7d-944f-369732edde2e/modules" \ -H "Authorization: Bearer $TOKEN" ``` ```json [Response] [ { "id": "...", "name": "temperature_controller" }, { "id": "...", "name": "door_controller" } ] ``` ::: :: --- ## Explore actions Each module exposes **actions**: the individual properties, sensors, and controls you can call. ::tabs :::div{icon="i-simple-icons-python" label="UniteLabs SDK"} ```python print(thermocycler.temperature_controller.actions.keys()) ``` ```bash [Terminal] dict_keys(['get_target_temperature', 'subscribe_current_temperature', 'set_target_temperature']) ``` Check the type of an action (`PROPERTY`, `SENSOR`, or `CONTROL`): ```python print(thermocycler.temperature_controller.get_target_temperature.type) # PROPERTY ``` ::: :::div{icon="i-heroicons-command-line" label="curl"} List all actions for a module: ```bash [Terminal] curl "$BASE_URL/modules/{moduleId}/actions" \ -H "Authorization: Bearer $TOKEN" ``` Get detailed info (parameters, response schema) for a specific action: ```bash [Terminal] curl "$BASE_URL/actions/{actionId}" \ -H "Authorization: Bearer $TOKEN" ``` ::: :: Every action has one of three types. The type determines how you consume it, and each maps to an underlying SiLA interaction: | Type | What it does | How you consume it | SiLA interaction | | ---------- | ---------------------------------------------------- | ------------------------------------------------------------------ | --------------------- | | `PROPERTY` | Reads a single value and completes | Call it and `await` the value | Unobservable Property | | `SENSOR` | Streams values as they change | Subscribe and read each value | Observable Property | | `CONTROL` | Triggers a change, with optional parameters | Call it and `await` the result | Unobservable Command | | `CONTROL` | Triggers a change and reports progress while it runs | Call it and `await` the result, or subscribe to follow its updates | Observable Command | The three sections below show how to consume each type. For the concept behind action types see [Action](https://docs.unitelabs.io/integrate/concepts/action/), and for the cross-surface mapping see the [Terminology table](https://docs.unitelabs.io/integrate/concepts/connector#terminology). --- ## Read a property A **property** (`PROPERTY`) reads a single value and completes. Properties take no parameters, and the SDK method name is prefixed with `get_`. ::tabs :::div{icon="i-simple-icons-python" label="UniteLabs SDK"} ```python target = await thermocycler.temperature_controller.get_target_temperature() print(target) # e.g. 42 ``` ::: :::div{icon="i-heroicons-command-line" label="curl"} In the REST API, a property is implemented as a one-off read that returns a single value only: ```bash [Terminal] curl -X POST "$BASE_URL/data/{actionId}" \ -H "Authorization: Bearer $TOKEN" \ -H "Accept: application/json" ``` ```json [Response] { "data": 42 } ``` ::: :: --- ## Read a sensor A **sensor** (`SENSOR`) streams values that change over time. Subscribing returns a subscription object: `await` the call to get it, then open it as an async context manager. The SDK method name is prefixed with `subscribe_`. ::tabs :::div{icon="i-simple-icons-python" label="UniteLabs SDK"} Subscribe and read each value as it changes: ```python subscription = await thermocycler.temperature_controller.subscribe_current_temperature() async with subscription: async for temperature in subscription: print(temperature) # prints each new reading as it arrives ``` A sensor emits only when its value changes, so iteration blocks until the next update. Leaving the `async with` block closes the stream. You can also break out of the loop or use `asyncio` cancellation. If you only need a single snapshot, take the first value and stop. This is how you read a live value that has no property of its own: the actual current temperature, for instance, since the property only holds the target. ```python subscription = await thermocycler.temperature_controller.subscribe_current_temperature() async with subscription: temperature = await anext(subscription) print(temperature) # the next reading, e.g. 41.7 ``` ::: :::div{icon="i-heroicons-command-line" label="curl"} Create a subscription by passing the action ID and an optional polling interval (milliseconds): ```bash [Terminal] curl -X POST "$BASE_URL/subscriptions" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "action": "{actionId}", "parameters": {}, "interval": 1000 }' ``` ```json [Response] { "id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "action": "{actionId}", "source": "..." } ``` To stream values directly instead, request the event stream and keep the connection open: ```bash [Terminal] curl -N -X POST "$BASE_URL/subscriptions" \ -H "Authorization: Bearer $TOKEN" \ -H "Accept: text/event-stream" \ -H "Content-Type: application/json" \ -d '{"action": "{actionId}", "parameters": {}}' ``` Cancel a polling subscription when done: ```bash [Terminal] curl -X DELETE "$BASE_URL/subscriptions/f47ac10b-58cc-4372-a567-0e02b2c3d479" \ -H "Authorization: Bearer $TOKEN" ``` ::: :: See [Subscription](https://docs.unitelabs.io/integrate/concepts/subscription/) for lifecycle, reliability, and cancellation. --- ## Execute a control A **control** (`CONTROL`) triggers a change on the instrument, optionally with parameters, and returns its result once the execution finishes. Unlike properties and sensors, a control's method carries no prefix: it is the action name itself. ::tabs :::div{icon="i-simple-icons-python" label="UniteLabs SDK"} Call the control and `await` its result. Pass parameters as keyword arguments: ```python await thermocycler.temperature_controller.set_target_temperature(target_temperature=42) ``` The call resolves when the instrument reports the command finished, and returns the final response (or `None` if the control has no response). ::: :::div{icon="i-heroicons-command-line" label="curl"} Invoke a control by creating a subscription with its parameters. The final `response` frame carries the result: ```bash [Terminal] curl -N -X POST "$BASE_URL/subscriptions" \ -H "Authorization: Bearer $TOKEN" \ -H "Accept: text/event-stream" \ -H "Content-Type: application/json" \ -d '{ "action": "{actionId}", "parameters": {"TargetTemperature": 42} }' ``` ```text [Response stream] event: response data: {...} ``` ::: :: ### Observe a long-running control Short-running and long-running controls are called the same way; the distinction only matters when you want to watch a long one while it runs. Some controls run long enough to report progress and intermediate responses before they finish. These are observable commands, for example a door that streams its opening status. The generated action method returns only the final result and discards those updates. To observe them, create the subscription yourself and iterate the raw events: ::tabs :::div{icon="i-simple-icons-python" label="UniteLabs SDK"} ```python open_door = thermocycler.door_controller.open_door subscription = await open_door.client.create_subscription(action_id=open_door.id, parameters={}) async with subscription: async for event, value in subscription: if event == "error": raise value if event == "response": print("done:", value) # final result else: print("update:", value) # progress or intermediate result ``` This is a low-level escape hatch. For a plain call-and-wait, use the action method directly as shown above. For controls that take parameters, pass them under the names defined in the action's [parameter schema](https://docs.unitelabs.io/integrate/concepts/action#introspection-parameters-and-responses). ::: :::div{icon="i-heroicons-command-line" label="curl"} Keep the event stream open and read each frame as it arrives. Intermediate updates arrive as `value` frames; the final result arrives as a `response` frame: ```bash [Terminal] curl -N -X POST "$BASE_URL/subscriptions" \ -H "Authorization: Bearer $TOKEN" \ -H "Accept: text/event-stream" \ -H "Content-Type: application/json" \ -d '{"action": "{actionId}", "parameters": {}}' ``` ```text [Response stream] event: value data: {"progress": 0.5} event: response data: {...} ``` ::: :: --- ## Next steps - **Execute commands**: read properties and call commands on the instrument from a script: [Calling a Connector](https://docs.unitelabs.io/integrate/control-with-code/) - **Liquid handling & robots**: additional SDK packages for deck building and liquid transfers: [Operate](https://docs.unitelabs.io/operate/overview/) # Operate Before any instrument can receive a command, the SDK needs a precise model of your physical setup. An `aspirate()` call needs to know exactly where well A1 is in 3D space. A gripper move needs the precise dimensions of the plate it will pick up. That model is what this section teaches you to build. The dependency is strict and the order matters: ```text Labware definitions → Deck layout → Device connection → Instrument commands what exists where it is who can act what happens ``` New here? Start with **[Your First Protocol](https://docs.unitelabs.io/operate/your-first-protocol/)**. It walks the entire chain in one runnable simulator script, so every concept and guide below has a concrete example to anchor against. The [Automate](https://docs.unitelabs.io/automate/what-is-a-workflow/) section explains the workflow engine layer: how workflows, phases, steps, and human-in-the-loop work from a platform perspective. Operate is the physical layer those abstractions run on top of. ## Concepts ::u-page-grid{cols="3"} :::u-page-card --- description: "The domain: aspirate and dispense as primitives, and how vendor differences are flattened behind one API." icon: i-lucide-droplets title: Liquid Handling to: https://docs.unitelabs.io/operate/concepts/liquid-handling/ --- ::: :::u-page-card --- description: Pipettes, grippers, and autoload as independent units of hardware capability, each with its own lifecycle. icon: i-lucide-cpu title: Modules to: https://docs.unitelabs.io/operate/concepts/modules/ --- ::: :::u-page-card --- description: The coordinate frame and resource tree that place labware in 3D space. icon: i-lucide-layout-grid title: Deck to: https://docs.unitelabs.io/operate/concepts/deck/ --- ::: :::u-page-card --- description: Plates, tips, tubes, and carriers modelled as Python objects with positions, dimensions, and liquid state. icon: i-lucide-box title: Labware to: https://docs.unitelabs.io/operate/concepts/labware/ --- ::: :::u-page-card --- description: Three ways to represent a liquid — predefined types, traceable samples, and user-defined custom liquids. icon: i-lucide-flask-conical title: Liquids to: https://docs.unitelabs.io/operate/concepts/liquids/ --- ::: :::u-page-card --- description: Parameters that tune one pipetting cycle to the fluid being moved — flow rates, timing, volume correction. icon: i-lucide-sliders-horizontal title: Liquid Classes to: https://docs.unitelabs.io/operate/concepts/liquid-classes/ --- ::: :::u-page-card --- description: Lifecycle-managed pipette tips, tracked inventory, and the Hamilton-channel vs. Bravo-96-head difference. icon: i-lucide-pipette title: Tips to: https://docs.unitelabs.io/operate/concepts/tips/ --- ::: :::u-page-card --- description: Drop-in mock for every handler — what it validates, what it doesn't, and why the boundary matters. icon: i-lucide-play-circle title: Simulation to: https://docs.unitelabs.io/operate/concepts/simulation/ --- ::: :::u-page-card --- description: Leave every instrument in a safe, known state when a step fails, before re-raising or continuing. icon: i-lucide-shield-alert title: Error Handling to: https://docs.unitelabs.io/operate/concepts/error-handling/ --- ::: :: ## Guides ::u-page-grid{cols="3"} :::u-page-card --- description: Import standard labware, or define custom plates, tips, tubes, and carriers. icon: i-lucide-box title: Labware to: https://docs.unitelabs.io/operate/guides/labware/standard-labware/ --- ::: :::u-page-card --- description: Arrange labware on a liquid handler deck, save the layout to JSON, and reload it across runs. icon: i-lucide-layout-grid title: Deck Setup to: https://docs.unitelabs.io/operate/guides/deck/building-a-deck/ --- ::: :::u-page-card --- description: Aspirate, dispense, handle tips, transport labware, and work with liquid classes across Hamilton and Bravo. icon: i-lucide-droplets title: Pipetting to: https://docs.unitelabs.io/operate/guides/pipetting/basic-pipetting/ --- ::: :::u-page-card --- description: Run and validate full protocols against a mock liquid handler. icon: i-lucide-play-circle title: Simulation to: https://docs.unitelabs.io/operate/guides/simulation/ --- ::: :::u-page-card --- description: Device-specific setup and usage for Hamilton STAR, Vantage, and Agilent Bravo. icon: i-lucide-cog title: Liquid Handler Guides to: https://docs.unitelabs.io/operate/devices/hamilton-star/positioning/ --- ::: :::u-page-card --- description: Setup and usage guides for incubators, shakers, readers, and other non-liquid-handler instruments. icon: i-lucide-flask-conical title: Other Devices to: https://docs.unitelabs.io/device-guides/overview/ --- ::: :: # Your First Protocol This tutorial walks the full physical-layer dependency chain in one runnable script. By the end you will have executed a pipette transfer against the Hamilton simulator and, more importantly, you will understand **why the order of the chain matters**. Every protocol you write from now on will follow the same shape. ## Why this matters The [Operate overview](https://docs.unitelabs.io/operate/overview/) sets out the dependency chain every protocol has to satisfy: labware → deck → device → commands. Writing protocols feels difficult when people jump straight to the last stage without building the first three. This tutorial wires all four together in the simplest possible end-to-end example so the chain becomes muscle memory. You will use the Hamilton simulator throughout, so no hardware is required. **Prerequisites** - Python 3.10 or newer - [`uv`](https://docs.astral.sh/uv/){rel=""nofollow,noopener""} installed - The UniteLabs SDK and liquid handling packages (installed below) - Familiarity with the [simulator](https://docs.unitelabs.io/operate/guides/simulation/) is helpful but not required ## Set up the project Create a new directory and initialize the project: ```bash mkdir first-protocol && cd first-protocol uv init --no-workspace uv add unitelabs-labware unitelabs-liquid-handling ``` Create a single script file: ```bash touch protocol.py ``` The rest of the tutorial lives in `protocol.py`. Build it up step by step; each step below adds to the same file. ## Step 1 — Define your labware Before anything moves, the SDK needs to know what physical objects exist. In this step you create Python objects for every piece of labware the protocol will touch. You are not placing them anywhere yet; you are just defining them. Open `protocol.py` and add: ```python from unitelabs.labware import PredefinedLiquids, Standard96Plate, StandardTrough from unitelabs.labware.hamilton import ( PLT_CAR_L5MD_A00, TIP_CAR_480_A00, HamiltonTip_300, HamiltonTipRack_300, ) # A 96-well destination plate plate = Standard96Plate(identifier="destination_plate") # A reservoir of water as the source trough = StandardTrough(identifier="water_source") trough.containers[0].add_liquid(PredefinedLiquids.WATER, 150_000) # 150 mL # A rack of filter tips tip_rack = HamiltonTipRack_300(filled_with=HamiltonTip_300) # Two carriers — one for tips, one for the plate + trough tip_carrier = TIP_CAR_480_A00(identifier="tip_carrier") plate_carrier = PLT_CAR_L5MD_A00(identifier="plate_carrier") ``` A few things worth noticing: - Every labware gets a stable `identifier=`. This is what you will reference later when you save or reload a deck. Auto-generated identifiers change every run and make saved layouts non-reproducible. - The `trough` has a `Container` that tracks liquid volume. You can add, remove, and inspect volumes on any `Fillable` labware; this is what lets the simulator catch overfill and underflow errors before they reach real samples. - Tips are "in" a tip rack, plates are "on" a carrier. The tree structure (carrier → slot → plate → well) is how the SDK computes absolute positions for every well. For the full model see [Labware](https://docs.unitelabs.io/operate/concepts/labware/) and [Standard Labware](https://docs.unitelabs.io/operate/guides/labware/standard-labware/). ## Step 2 — Start the simulator and place labware on its deck The deck is an attribute of the liquid handler, not a standalone object. You bring up the simulator (which gives you a device context with its own deck), then place your carriers on that deck at specific tracks. Add to `protocol.py`: ```python import asyncio from unitelabs.liquid_handling.testing import MicrolabSTARMock async def run(): lh = MicrolabSTARMock() await lh.configure() await lh.initialize() # Wire labware into carriers tip_carrier[0] = tip_rack plate_carrier[0] = plate plate_carrier[1] = trough # Place the carriers on the deck at physical tracks lh.deck.add(tip_carrier, track=7) lh.deck.add(plate_carrier, track=1) print(lh.deck.summary()) ``` Run the script so far to confirm the deck prints cleanly: ```bash uv run python -c "import asyncio; from protocol import run; asyncio.run(run())" ``` You should see a summary showing the plate carrier on tracks 1–6 (plate in slot 0, trough in slot 1) and the tip carrier on tracks 7–12 (tip rack in slot 0). If a resource collides with another, or if you place something outside the deck bounds, the simulator raises immediately. That is the point of this step. Deck problems are cheap to fix before any liquid moves. For advanced patterns like saving the deck to JSON and reloading it across runs, see [Building a Deck](https://docs.unitelabs.io/operate/guides/deck/building-a-deck/). ## Step 3 — Run your first command Now that the SDK knows what exists and where it is, you can issue a command. The dependency chain is satisfied: `plate["A1"]` has a resolvable absolute position because the plate is in a carrier which is on track 1 of a known deck. Extend `run()`: ```python from unitelabs.labware.hamilton import LiquidClass liquid_class = LiquidClass.HamiltonTip_300_Water_DispenseJet_Empty() # Pick up 8 tips from column 1 of the tip rack await lh.pipettes.pick_up_tips_from(channels=range(8), rack=tip_rack) # Aspirate 100 µL of water from the trough await lh.pipettes.aspirate( source=trough, channels=range(8), volume=100, liquid_class=liquid_class, ) # Dispense into column 1 of the destination plate await lh.pipettes.dispense( target=plate["A1":"H1"], channels=range(8), volume=100, liquid_class=liquid_class, ) # Return the tips await lh.pipettes.discard_tips(channels=range(8)) # Inspect the result print("Trough volume:", trough.containers[0].volume) print("Well A1 volume:", plate["A1"].container.volume) ``` Run the full script again. The simulator's liquid model tracks volumes through every operation, so you can verify your protocol logic before touching real samples: the trough should be 800 µL lighter (8 channels × 100 µL), and each well in column 1 should now contain 100 µL. This is the hello-world of liquid handling. Every real protocol is a longer version of this shape: pick up tips, aspirate from some source, dispense into some destination, discard tips. What changes between protocols is the labware, the volumes, the liquid classes, and the loops. ## Step 4 — Handle errors A protocol that works only on the happy path is not a protocol; it is a demo. On real hardware, timeouts happen, tips fall off, and samples get left in unexpected states. The goal of error handling in lab automation is not graceful failure; it is **leaving every instrument in a safe, known state** before the script exits or retries. Add `logging` to your imports at the top of `protocol.py`: ```python import logging logging.basicConfig(level=logging.INFO) ``` Then wrap the commands in `try / except / finally`: ```python async def run(): lh = MicrolabSTARMock() await lh.configure() await lh.initialize() tip_carrier[0] = tip_rack plate_carrier[0] = plate plate_carrier[1] = trough lh.deck.add(tip_carrier, track=7) lh.deck.add(plate_carrier, track=1) try: await lh.pipettes.pick_up_tips_from(channels=range(8), rack=tip_rack) await lh.pipettes.aspirate( source=trough, channels=range(8), volume=100, liquid_class=liquid_class, ) await lh.pipettes.dispense( target=plate["A1":"H1"], channels=range(8), volume=100, liquid_class=liquid_class, ) except Exception: logging.exception("Protocol step failed — attempting safe state") raise finally: # Always return tips, whether or not the protocol succeeded await lh.pipettes.discard_tips(channels=range(8)) ``` The `finally` block runs regardless of success or failure. Here it guarantees that the channels never end up holding tips. A real run that crashes mid-protocol should not leave the pipette armed and blocking the next run. The pattern generalizes. For a full protocol, every step that changes an instrument's physical state (door open, plate picked up, tips held) should have a corresponding safe-state action in `finally` or in a wider `except` block. See [Error Handling](https://docs.unitelabs.io/operate/concepts/error-handling/) for common failure patterns (timeout, unexpected state, partial completion, connection loss) and a safe-state checklist. ## Where to go next You have walked the full chain: labware → deck → device → command → error. Everything from here is a variation on this shape. - [Basic Pipetting](https://docs.unitelabs.io/operate/guides/pipetting/basic-pipetting/): same pattern against Hamilton or Bravo, with finer control over liquid classes and tip handling. - [Labware](https://docs.unitelabs.io/operate/concepts/labware/) and [Standard Labware](https://docs.unitelabs.io/operate/guides/labware/standard-labware/): the model behind the Python objects you instantiated in step 1. - [Building a Deck](https://docs.unitelabs.io/operate/guides/deck/building-a-deck/): save this deck layout to JSON and reload it across runs so team members and CI share the same physical setup. - [Your First Workflow](https://docs.unitelabs.io/automate/your-first-workflow/): wrap this protocol in a Prefect flow so it can be scheduled, retried, and run unattended from the platform. # Liquid Handling Liquid handling is the part of lab automation that moves liquids between containers. At its core, the SDK models this as a small number of primitives — aspirate, dispense, pick up tips, discard tips, transport labware — applied to a resource tree that describes your physical setup. The SDK provides a high-level Python interface for controlling liquid handlers, covering aspirating, dispensing, tip handling, and labware transport. It abstracts vendor-specific differences so protocol code stays clean and portable across Hamilton, Agilent Bravo, and other supported handlers. ## What the model captures To command any liquid handler from Python, the SDK needs four things: 1. **Labware definitions** — what plates, tips, tubes, and carriers exist ([Labware](https://docs.unitelabs.io/operate/concepts/labware/)) 2. **Deck layout** — where each piece of labware sits ([Deck](https://docs.unitelabs.io/operate/concepts/deck/)) 3. **A device connection** — which physical handler receives commands 4. **A set of instrument commands** — what should happen, expressed as `aspirate`, `dispense`, `pick_up_tips`, etc. The dependency is strict and the order matters: ```text Labware definitions → Deck layout → Device connection → Instrument commands what exists where it is who can act what happens ``` Every liquid-handling call traces through this chain. `aspirate(plate["A1"], volume=100)` only works because `plate["A1"]` already has a known `absolute_location` computed from the deck, and because the device knows which pipetting module to use. ## Vendor abstraction Different liquid handlers work differently. Hamilton uses independent channels; Bravo uses a monolithic 96-nozzle head. Hamilton controls motion with flow rates in µL/s; Bravo uses velocity in mm/s. Hamilton requires an explicit liquid class; Bravo auto-selects one. The SDK flattens these differences behind a shared set of concepts — [Modules](https://docs.unitelabs.io/operate/concepts/modules/), [Liquid Classes](https://docs.unitelabs.io/operate/concepts/liquid-classes/), [Tips](https://docs.unitelabs.io/operate/concepts/tips/) — so the same protocol shape works across vendors. Vendor-specific details show up as different parameters on the same methods, not as separate APIs. ## Dry-running without hardware Every device has a matching mock you can drop in without touching hardware. The mock runs the full liquid model — volume tracking, deck conflicts, tip state — locally, so you can validate a protocol end-to-end before a run. See [Simulation](https://docs.unitelabs.io/operate/concepts/simulation/). ## Where to go next - [Your First Protocol](https://docs.unitelabs.io/operate/your-first-protocol/) — a runnable example that walks the full chain - [Labware](https://docs.unitelabs.io/operate/concepts/labware/), [Deck](https://docs.unitelabs.io/operate/concepts/deck/), [Modules](https://docs.unitelabs.io/operate/concepts/modules/) — the building blocks of the model - [Basic Pipetting](https://docs.unitelabs.io/operate/guides/pipetting/basic-pipetting/) — the procedural how-to once the concepts are in place # Modules A liquid handler is not a monolith. It is a collection of **modules** — the pipetting channels, the 96-nozzle head, the iSWAP arm, the CO-RE gripper, the autoload, the Bravo gripper — each with its own capabilities and its own lifecycle. Your protocol commands modules, not the device as a whole. A module is the unit of capability you can command: - **Pipetting channels / heads** (`hamilton.pipettes`, `hamilton.core96`, `bravo.pipette_head`) — aspirate and dispense liquid - **Grippers and transport arms** (`hamilton.core_gripper`, `hamilton.iswap`, `bravo.gripper`) — move labware between deck positions - **Autoload** — load and unload carriers from the front of the deck - **Readers, heaters, shakers** — integrated accessories exposed through their own module interfaces This mirrors the connector-side vocabulary: in the CDK, a module is an addressable capability exposing properties, sensors, and controls. Consumer-side, you see the same unit. ## Transport modules at a glance Three modules move labware, each with a different mechanism and reach. Pick one based on your device and the kind of transport you need: | | Hamilton CO-RE | Hamilton iSWAP / IPG | Bravo | | --------------------- | ------------------------------------ | -------------------------------- | ------------------------ | | **Device** | Hamilton STAR / Vantage | Hamilton STAR / Vantage | Agilent Bravo | | **Mechanism** | Two pipette channels pick up paddles | Dedicated rotatable gripper | Integrated gripper arm | | **On-deck transport** | Yes | Yes | Yes | | **Off-deck reach** | No | Yes | No | | **Rotation support** | No | Yes | No | | **Module setup** | Configure paddle locations | None | None | | **Best for** | Quick on-deck moves | Off-deck integrations, rotations | Bravo deck repositioning | The same pattern applies across module types: different mechanisms, a shared conceptual shape. ## Module lifecycle Every module has a small state machine that determines when it can receive commands: ```text INSTALLED → CONFIGURED → INITIALIZED → ACTIVE ``` - **Installed**: the device knows the module exists (from the device configuration). - **Configured**: any setup that depends on deck geometry is complete — for example, the CO-RE gripper has had its paddle pickup locations set. - **Initialized**: the module has homed and reported ready. - **Active**: the module is currently the tool in use. On Hamilton, modules that share the pipetting arm are mutually exclusive — activating one deactivates the others. Most protocols only call `activate()` before using a module and move on. Configuration is a one-time setup step on the modules that need it (primarily the CO-RE gripper's paddle locations); initialization happens at the device level when you first connect. ## Mutual exclusion On Hamilton, the pipetting channels, the CO-RE gripper, and the iSWAP share the pipetting arm. Only one can be active at a time. Activating another module automatically deactivates the current one — which is how you "park" a gripper by activating the channels again. On Bravo, the pipette head and the gripper are mutually exclusive for a different reason: the gripper cannot pick up plates while tips are mounted. Drop tips before calling a gripper operation. ## Where to go next - [Labware Transport](https://docs.unitelabs.io/operate/guides/pipetting/labware-transport/) — procedural how-to for each transport module - [Basic Pipetting](https://docs.unitelabs.io/operate/guides/pipetting/basic-pipetting/) — commanding the pipetting modules - Device-specific references: [Hamilton STAR](https://docs.unitelabs.io/operate/devices/hamilton-star/positioning/), [Hamilton Vantage](https://docs.unitelabs.io/operate/devices/hamilton-vantage/waste-configuration/), [Agilent Bravo](https://docs.unitelabs.io/operate/devices/agilent-bravo/tip-handling/) # Deck The deck is the spatial model every liquid-handling command resolves against. When you call `aspirate(plate["A1"])`, the SDK needs to know where well A1 sits in 3D space — and it knows because the plate is placed in a carrier site, which is placed in a carrier, which is placed on the deck at a known track. The deck is the root of that resource tree, and the origin of the coordinate frame. ## Coordinate system The deck is a 3-dimensional space with x, y, z coordinates in millimeters: - **x**: left → right (track direction) - **y**: front → back (depth) - **z**: bottom → top (height) When standing in front of the device, the origin is in the bottom-left corner at the front. Depending on the device, the origin can be below the physical deck surface and beyond the marked tracks — device-specific details are documented in each device guide. Every resource has two locations: | Property | What it returns | | ---------------------------- | ------------------------------------------------ | | `resource.location` | Position relative to parent (None if not placed) | | `resource.absolute_location` | Absolute position from the deck origin | The absolute location is computed by walking the resource tree and summing each parent's offset. ## The resource tree Labware on a deck is always a tree. The deck is the root; carriers are children of the deck; carrier sites hold plates; plates hold wells. Children inherit position from their parent: ```text Deck └── Carrier (track 15) └── CarrierSite [slot 0] └── Plate "wellplate" ├── Well A1 ├── Well A2 └── ... ``` When you call `lh.deck.find(identifier="wellplate")`, the SDK walks this tree to find the plate by its identifier. Positions are always stored relative to the parent; the absolute location is computed by summing up the chain. This structure is what makes the labware model composable. Swap a carrier at track 7 for a different one, and every well inside it gets a new absolute location automatically. Save the tree to a file and reload it verbatim in the next run. ## Deck bounds Decks have dimensions constrained by the physical handler. The default bounds prevent placing a carrier outside the reachable area, but some workflows legitimately need to override this — for instance, placing a tip carrier below the minimum track so it is only reachable by the CO-RE96 head and not individual channels. Bounds can be relaxed per deck (`deck.configure(min_track=None, max_track=None)`), but positions outside the standard area may not be reachable by every module. ## Layout as data A deck layout is data. Once built, it can be serialized to JSON and reloaded in any future run — no need to reconstruct it from scratch each time. The JSON captures every carrier, every position, and every nested piece of labware, including identifiers and state. The deck JSON file is intended to be committed alongside the workflow script that uses it: - It becomes a full audit trail of which physical layout was used for each experiment. - Deck changes produce reviewable diffs. - The workflow and its deck travel together when deployed or shared. For the conventions on when to use the JSON form vs. a Python-script form of a deck, see [Save/Load a Deck](https://docs.unitelabs.io/operate/guides/deck/save-load-deck/). ## Where to go next - [Building a Deck](https://docs.unitelabs.io/operate/guides/deck/building-a-deck/) — compose a deck from carriers and labware - [Save/Load a Deck](https://docs.unitelabs.io/operate/guides/deck/save-load-deck/) — persist and reload layouts - [Labware](https://docs.unitelabs.io/operate/concepts/labware/) — the physical objects that live inside a deck # Labware Labware is any physical object that holds, transfers, or interacts with samples in the lab: plates, tips, tubes, reservoirs, and the carriers that position them on the deck. In UniteLabs, every piece of labware is a Python object with a precise position, physical dimensions, and an optional liquid model. This page explains the conceptual building blocks. ## Everything is a Resource The foundation of the labware model is the `Resource` class. Every physical object (a single tip, a well, a plate, a carrier) is a `Resource` with: - A unique **identifier** (auto-generated UUID unless you supply one) - **Dimensions** (width × depth × height in mm as a `Vector`) - A **location** relative to its parent resource - An optional **parent** and a list of **children** All concrete labware inherits from `Resource` via `Labware`: ```text Resource └── Labware ├── Plate (holds Wells) ├── Tip ├── TipRack (holds TipSpots → Tips) ├── Tube ├── TubeRack (holds TubeSpots → Tubes) ├── Trough └── Carrier (holds CarrierSites → Plates / Troughs) ``` Anything that can contain other resources is a **`Group`**, which gives it parent-child management and serialization. Labware always sits inside a deck's resource tree — a plate inside a carrier site, a carrier site inside a carrier, a carrier on the deck. That tree, and how absolute positions are computed from it, is covered in [Deck](https://docs.unitelabs.io/operate/concepts/deck/). ## Labware Types | Type | What it is | Key property | | ------------ | ---------------------------------- | --------------------------------------------- | | **Plate** | 96/384/1536-well microplate | `rows`, `cols`, wells indexed `A1`→`H12` | | **Well** | A single well within a plate | Has a `Container` for tracking liquid volume | | **Tip** | A single pipette tip | Tracks air volumes, fitting depth, max volume | | **TipRack** | A rack holding tips | `next_tips()` returns available tips | | **Tube** | A standalone tube (e.g. Eppendorf) | Has a `Container`, optional `Cap` | | **TubeRack** | A rack holding tubes | Grid of `TubeSpot` children | | **Trough** | A large reagent reservoir | Single `Container` with multiple access holes | | **Carrier** | Holds plates/tubes on the deck | `rows` of `CarrierSite` slots | ### Plates and wells A `Plate` is a `Group` of `Well` objects. Wells are auto-created when the plate is instantiated and indexed column-major (A1 = index 0, B1 = index 1, … H1 = index 7, A2 = index 8): ```python from unitelabs.labware import Standard96Plate plate = Standard96Plate(identifier="my_plate") well_a1 = plate["A1"] # by label well_a1 = plate[0] # by index (column-major) ``` ### Carriers A `Carrier` is a `Rack` of `CarrierSite` slots. Each site has a defined position and can hold one piece of labware. Hamilton carrier names encode their geometry: - `PLT_CAR_L5MD_A00` → Plate carrier, Landscape, 5 slots, Medium Density, revision A00 - `TIP_CAR_480_A00` → Tip carrier, 480 tips, revision A00 ```python from unitelabs.labware.hamilton import PLT_CAR_L5MD_A00, TIP_CAR_480_A00 from unitelabs.labware.corning_costar import Cos_96_FB carrier = PLT_CAR_L5MD_A00(identifier="plate_carrier") carrier[0] = Cos_96_FB(identifier="plate_1") # slot 0 (front) ``` ### Readable child identifiers Since labware 0.29.0, auto-built children of a labeled group (plate wells, tip-rack and tube-rack spots, and other pipettables) get a readable `:` identifier instead of an opaque UUID. The key is the chess label where the group supports it, and the numeric slot index otherwise (carriers are addressed by index): ```python plate = Standard96Plate(identifier="source_plt") plate["A1"].identifier # "source_plt:A1" plate["H12"].identifier # "source_plt:H12" # Readable identifiers resolve through find() plate.find("source_plt:A1") is plate["A1"] # True ``` A child you name explicitly is never overwritten, and copying a group relabels its children from the new identifier (`plate.copy(identifier="dest_plt")` gives `"dest_plt:A1"`), so saved layouts stay reproducible. Use `select()` to resolve an identifier to a **list** of resources. It accepts a single readable identifier, a bare group identifier (expands to all children), or a `:::` range mirroring slice access: ```python plate.select("source_plt:A1") # [plate["A1"]] plate.select("source_plt") # every well, as a list plate.select("source_plt::A1:H1") # list(plate["A1":"H1"]) — the first column ``` A missing or malformed identifier raises `MissingError` rather than leaking an index error. ## Positions and Dimensions All positions use a `Vector(x, y, z)` with coordinates in millimeters. The coordinate origin is the bottom-left front corner of the deck: - **x**: left → right (track direction) - **y**: front → back (depth) - **z**: bottom → top (height) ```python from unitelabs.labware.math import Vector # A carrier placed at track 15 has an x-offset of (15-1) × 22.5 mm location = Vector(x=315.0, y=63.0, z=0.0) ``` Every resource has two location properties: | Property | What it returns | | ---------------------------- | ------------------------------------------------ | | `resource.location` | Position relative to parent (None if not placed) | | `resource.absolute_location` | Absolute position from deck origin | | `resource.dimensions` | Width × depth × height as a Vector | ## Liquid Modelling Any labware that can hold liquid (wells, tubes, troughs, tips) inherits from `Fillable`. Fillable resources have a `Container` that tracks volume and composition: ```text Fillable (Well, Tube, Trough, Tip) └── Container ├── max_volume (µL) ├── volume → current total volume └── mixture → Mixture {Liquid: volume_µL, ...} ``` ### Container The `Container` tracks the total liquid volume in a well or tube and enforces physical limits: ```python from unitelabs.labware import PredefinedLiquids well = plate["A1"] well.container.add_liquid(PredefinedLiquids.WATER, 100) # add 100 µL water well.container.add_liquid(PredefinedLiquids.DIMETHYL_SULFOXIDE, 10) # add 10 µL DMSO print(well.container.volume) # Decimal('110') print(well.container.liquid_level) # height in mm (shape-dependent) well.container.remove_liquid(50) # remove 50 µL ``` Overflow and underflow raise `LiquidOverflowError` / `LiquidUnderflowError`. ### Liquid and Mixture A `Mixture` maps liquid types to volumes. Predefined liquids accessed via `PredefinedLiquids` cover common lab reagents: ```python from unitelabs.labware import PredefinedLiquids # Predefined liquids PredefinedLiquids.WATER PredefinedLiquids.DIMETHYL_SULFOXIDE PredefinedLiquids.ETHANOL PredefinedLiquids.PBS_BUFFER PredefinedLiquids.BLOOD # ... and others ``` For traceable user-defined samples, use `Sample` instead of `Liquid`. Samples carry an identifier that can be linked to external systems (e.g. a LIMS barcode). ### Liquid level calculation The container calculates the liquid level in mm using the labware's physical shape model (`Shape` objects: `Cylinder`, `Cone`, `ConicalFrustum`, etc.). This is used for liquid level detection (LLD) during pipetting, so the SDK can aspirate exactly at the current surface height rather than a fixed z-offset. ## Identifiers Every resource gets a UUID identifier on construction. You can supply your own: ```python plate = Standard96Plate(identifier="wellplate") reservoir = StandardTrough(identifier="reservoir_ice") ``` Identifiers are stable across serialization; they are the key used in deck JSON files and in `lh.deck.find()`: ```python # Find by the identifier you assigned plate = lh.deck.find(identifier="wellplate") # Or by the UUID assigned at creation plate = lh.deck.find(identifier="a5ecb299") ``` ::callout{color="amber" icon="i-heroicons-light-bulb"} Use stable, descriptive identifiers (like `"reservoir_ice"`) when defining deck layouts that will be saved to JSON. Auto-generated UUIDs change each time a resource is instantiated, which makes saved deck files non-reproducible. :: ## Next Steps - [Standard Labware](https://docs.unitelabs.io/operate/guides/labware/standard-labware/): browse and import pre-built labware types - [Custom Labware](https://docs.unitelabs.io/operate/guides/labware/): define your own plates, tubes, tips, and carriers - [Building a Deck](https://docs.unitelabs.io/operate/guides/deck/building-a-deck/): compose labware into a full deck layout # Liquids Every container in the labware model can hold liquid. How you represent that liquid determines how it behaves: whether it carries physical properties that influence pipetting, whether it carries a unique identifier that can be traced back to a sample, and how it homogenizes inside a mixture. The SDK provides three representations, each with distinct semantics: - **Predefined liquids** — common lab reagents accessed via `PredefinedLiquids` (water, ethanol, DMSO, etc.) - **Custom `Liquid` instances** — user-defined reagents with optional physical metadata, created with `Liquid("name")` - **`Sample` class** — traceable biological specimens with unique identifiers --- ## Predefined Liquids Predefined liquids are accessed through the `PredefinedLiquids` registry: ```python from unitelabs.labware import Liquid, PredefinedLiquids # Add predefined liquid to a container container.add_liquid(PredefinedLiquids.WATER, volume=50_000) container.add_liquid(PredefinedLiquids.ETHANOL, volume=10_000) ``` ::callout{color="amber" icon="i-heroicons-exclamation-triangle"} `Liquid.WATER` still works but is **deprecated** and will be removed in a future version. Use `PredefinedLiquids.WATER` instead. :: ### Available Liquids | Constant | Display name | Built-in aliases | | -------------------- | ------------------ | ---------------------------------------------------------------------------- | | `ACETONITRILE` | Acetonitrile | — | | `BLOOD` | Blood | — | | `BRAIN_HOMOGENATE` | Brain-Homogenate | `Brain Homogenate`, `brain_homogenate`, `BrainHomogenate` | | `CHLOROFORM` | Chloroform | — | | `DIMETHYL_SULFOXIDE` | Dimethyl sulfoxide | `DMSO`, `dimethyl_sulfoxide`, `DimethylSulfoxide` | | `ETHANOL` | Ethanol | `EtOH`, `ethanol`, `Ethyl Alcohol`, `ethyl_alcohol` | | `GLYCERIN` | Glycerin | — | | `METHANOL` | Methanol | `MeOH` | | `OCTANOL` | Octanol | `1-Octanol`, `octanol` | | `PBS_BUFFER` | PBS Buffer | — | | `PLASMA` | Plasma | — | | `SERUM` | Serum | — | | `TE_BUFFER` | Tris-EDTA buffer | `TE Buffer`, `te_buffer`, `TEBuffer`, `Tris EDTA buffer`, `tris_edta_buffer` | | `WATER` | Water | `H2O`, `h_2_o`, `H_2_O`, `water`, `Aqua` | All name and alias lookups are **case-insensitive**. To see the full table at runtime (including any custom aliases you have added): ```python PredefinedLiquids.show() ``` ### Liquid Properties Liquids can optionally carry properties that influence pipetting behavior: - **Viscosity** — viscosity of the liquid in mPa·s - **Density** — density of the liquid in g/mL These properties can be used to select appropriate pipetting parameters (flow rates, mixing speeds, etc.) for optimal liquid transfer. --- ## Custom Liquids For substances that are not in the predefined list, create a `Liquid` instance directly. You can optionally supply physical metadata. ### Creating a Custom Liquid ```python from unitelabs.labware import Liquid # Minimal — name only tris_buffer = Liquid("Tris buffer") # With physical metadata lysis_buffer = Liquid("Lysis buffer", viscosity=1.2, density=1.05) ``` ::callout{color="amber" icon="i-heroicons-exclamation-triangle"} Creating a `Liquid` with a name that matches a predefined liquid or its aliases raises `ValueError`. Use the `PredefinedLiquids` constant instead. :: ```python # Raises ValueError — "Water" is a predefined liquid water = Liquid("Water") # ValueError: Liquid named 'Water' conflicts with predefined liquid 'Water'. # Use PredefinedLiquids.WATER instead. # Correct approach water = PredefinedLiquids.WATER ``` ### Equality and Homogenization Two `Liquid` instances are **equal** when their `name`, `viscosity`, and `density` all match. This means the same custom liquid defined in two places will homogenize correctly inside a `Mixture`: ```python from unitelabs.labware import Liquid, Mixture buf_a = Liquid("Lysis buffer", viscosity=1.2, density=1.05) buf_b = Liquid("Lysis buffer", viscosity=1.2, density=1.05) mixture = Mixture() mixture.add(buf_a, 100) mixture.add(buf_b, 50) print(len(mixture.ingredients)) # 1 — homogenized into one entry print(mixture.volume) # 150 ``` Each `Liquid` instance also carries a short unique `identifier` (8-character hex string) that is **excluded from equality and hashing** — it exists solely for tracking purposes: ```python liq = Liquid("My Buffer") print(liq.identifier) # e.g. "3f8a1c2b" ``` --- ## Custom Aliases If your lab uses a different name for a predefined liquid (e.g. your LIMS stores water as `"AquaDest"`), you can register an alias so that name resolves to the correct predefined instance and homogenizes transparently: ```python from unitelabs.labware import PredefinedLiquids, Mixture # Register the alias once at startup PredefinedLiquids.add_alias(PredefinedLiquids.WATER, "AquaDest") # From that point on, the alias is interchangeable with the predefined liquid mixture = Mixture() mixture.add("AquaDest", 100) mixture.add("Water", 50) print(len(mixture.ingredients)) # 1 — both resolve to the same entry print(mixture.volume) # 150 ``` ### Rules for Aliases - An alias can only be added to a **predefined** liquid; custom `Liquid` instances cannot have aliases. - Aliases must not conflict with any existing predefined name or existing alias (comparison is case-insensitive). - Multiple aliases for the same liquid are allowed. ```python # Multiple aliases for the same liquid — OK PredefinedLiquids.add_alias(PredefinedLiquids.DIMETHYL_SULFOXIDE, "Dimethylsulfoxide") PredefinedLiquids.add_alias(PredefinedLiquids.DIMETHYL_SULFOXIDE, "Me2SO") # Raises ValueError — alias conflicts with the predefined name "Water" PredefinedLiquids.add_alias(PredefinedLiquids.ETHANOL, "Water") # Raises ValueError — alias targets a custom liquid, not a predefined one custom = Liquid("My Buffer") PredefinedLiquids.add_alias(custom, "MB") ``` --- ## Samples `Sample` represents traceable biological specimens with unique identifiers: ```python from unitelabs.labware import Sample # Create a sample with tracking information sample = Sample( name="MySample", identifier="SAMPLE-001", ) # Add to container container.add_liquid(sample, volume=100) ``` Samples are appropriate when you need: - **Traceability** — unique identification and tracking of biological specimens - **Isolation** — each sample is distinct, even if the names match --- ## When to Use Each Type | Type | Use Case | Key Features | | ----------------------------- | ---------------------------------------- | ------------------------------------------ | | **Predefined liquids** | Standard reagents (water, ethanol, etc.) | Registry-based, aliases, no construction | | **Custom `Liquid` instances** | Workflow-specific buffers and reagents | Optional viscosity/density, homogenization | | **`Sample` class** | Traceable biological specimens | Unique IDs, tracking, never homogenized | Use predefined liquids for standard reagents, custom `Liquid` instances for workflow-specific reagents, and `Sample` for traceable biological specimens. --- ## Pre-filling Labware Adding liquid one container at a time is useful in many cases as volumes or liquids vary between the wells and granural control is needed. ```python plate = Standard96Plate(identifier="plate") for well in plate: well.container.add_liquid(PredefinedLiquids.WATER, 100) ``` If a whole plate is filled with same volume of a liquid, a simpler method on labware construction can be preferable. Pipettable labware — plates and troughs — accept a `filled_with` argument that does this at construction, mirroring the way a `TipRack` is pre-filled with tips: ```python from unitelabs.labware import PredefinedLiquids from unitelabs.labware.plates import Standard96Plate plate = Standard96Plate(identifier="plate", filled_with=(PredefinedLiquids.WATER, 100)) ``` `filled_with` takes a single `(liquid, volume)` pair, or a list of pairs to pre-fill each container with a mixture. The volume is the amount in microliters added to **every** container of the labware: ```python plate = Standard96Plate( identifier="plate", filled_with=[(PredefinedLiquids.WATER, 80), (PredefinedLiquids.ETHANOL, 20)], ) ``` "Every container" is what keeps the behavior uniform across labware shapes — the argument fills whatever the labware's containers are, whether that is 96 wells on a plate, the twelve independent channels of a column trough, or the single reservoir of a simple trough: ```python from unitelabs.labware.troughs import StandardTrough12Column # Each of the twelve columns is filled with the same mixture reservoir = StandardTrough12Column( identifier="reservoir", filled_with=[(PredefinedLiquids.WATER, 80), (PredefinedLiquids.ETHANOL, 20)], ) ``` ::callout{color="amber" icon="i-heroicons-exclamation-triangle"} A `filled_with` volume that exceeds a container's `max_volume` fails immediately at construction with `LiquidOverflowError`, rather than silently overfilling. :: To fill only some containers, leave `filled_with` off and add liquid to the wells you want — slicing works with chess-key ranges: ```python plate = Standard96Plate(identifier="plate") for well in plate["A1":"H1"]: well.container.add_liquid(PredefinedLiquids.WATER, 100) ``` --- ## Migration Guide The `Liquid` type was redesigned from a `str` enum into a **frozen dataclass**. Predefined substances are no longer members of the `Liquid` class itself; they live in the `PredefinedLiquids` registry. Custom liquids are now plain `Liquid` instances. ### Import changes ```python # Before from unitelabs.labware import Liquid # After from unitelabs.labware import Liquid, PredefinedLiquids ``` ### Accessing predefined liquids ```python # Before — Liquid was a str(enum.Enum) container.add_liquid(Liquid.WATER, volume=50_000) container.add_liquid(Liquid.DMSO, volume=10_000) # After — predefined liquids live in PredefinedLiquids container.add_liquid(PredefinedLiquids.WATER, volume=50_000) container.add_liquid(PredefinedLiquids.DIMETHYL_SULFOXIDE, volume=10_000) ``` `Liquid.WATER` is deprecated and will be removed in a future version — use `PredefinedLiquids.WATER` instead. ### String comparison no longer works directly ```python # Before — Liquid was a str enum, so this was True Liquid.WATER == "Water" # True # After — Liquid is a frozen dataclass PredefinedLiquids.WATER == "Water" # False str(PredefinedLiquids.WATER) == "Water" # True — compare via str() if needed ``` ### Constructing a Liquid with a predefined name is now forbidden ```python # Before — silently created a duplicate water = Liquid("Water") # After — raises ValueError water = Liquid("Water") # ValueError: Liquid named 'Water' conflicts with predefined liquid 'Water'. # Use PredefinedLiquids.WATER instead. ``` The same guard applies to all built-in aliases (`"H2O"`, `"DMSO"`, `"EtOH"`, etc.) and is case-insensitive. --- ## PredefinedLiquids API Reference ```python from unitelabs.labware import PredefinedLiquids ``` | Method / attribute | Description | | -------------------------------------------- | -------------------------------------------------------------------------- | | `PredefinedLiquids.WATER` | Direct attribute access to any predefined liquid | | `PredefinedLiquids.get_by_name(name)` | Case-insensitive lookup by name or alias; returns `Liquid` or `None` | | `PredefinedLiquids.get_by_identifier(id)` | Lookup by the `identifier` field; returns `Liquid` or `None` | | `PredefinedLiquids.add_alias(liquid, alias)` | Register a custom alias for a predefined liquid | | `PredefinedLiquids.find(**kwargs)` | Filter predefined liquids by `name`, `viscosity`, and/or `density` | | `PredefinedLiquids.show()` | Print a formatted table of all predefined liquids, aliases, and properties | --- ## Integration with Liquid Classes Custom liquids with viscosity and density properties can influence liquid-class selection for pipetting parameters. See [Liquid Classes](https://docs.unitelabs.io/operate/concepts/liquid-classes/). # Liquid Classes A liquid class encapsulates all the parameters needed for one pipetting cycle, from aspiration through dispense. This includes flow or velocity rates, timing, and a volume correction curve that ensures the commanded volume matches what is actually transferred. Water, ethanol, DMSO, and blood all behave differently under a pipette. A fixed set of flow rates and dwell times optimized for water will over-aspirate ethanol and under-aspirate glycerol. A liquid class is the SDK's way of naming "the full pipetting recipe for this fluid," so the same `aspirate(volume=100)` call can produce the correct physical result regardless of what the well contains. ## Two device models, one concept Hamilton and Bravo both use liquid classes, but the internal models differ significantly. Knowing the shape of each model is important — protocols written for one do not trivially port to the other. | | Hamilton | Bravo | | --------------------- | ------------------------------------------------ | ----------------------------------------------- | | **Motion control** | Flow rates (µL/s) + dispense mode | Velocity + acceleration per phase (mm/s, mm/s²) | | **Volume correction** | Correction curve dict (target → corrected) | Polynomial coefficients | | **Auto-selection** | Must be passed explicitly | Auto-selected by tip type + volume | | **Dispense modes** | Jet empty, jet part, surface empty, surface part | `PipetteMode` enum (SURFACE / BOTTOM) | **Hamilton** treats pipetting as plunger flow: flow rate controls the plunger, a correction-curve dictionary maps commanded volume to corrected volume, and the dispense mode (jet vs. surface, empty vs. part) changes how the tip positions itself relative to the liquid. Every call must be passed an explicit liquid class. **Bravo** treats pipetting as motion: velocity and acceleration govern the plunger per phase, a polynomial corrects the commanded volume, and the pipette mode distinguishes above-liquid vs. in-liquid dispense. When no liquid class is passed, the SDK auto-selects one based on the mounted tip type and the requested volume. ## A liquid class is a dataclass Regardless of vendor, liquid classes are Python dataclasses. Predefined classes ship with the SDK (Hamilton's `LiquidClass` enum, Bravo's `BravoLiquidClasses` factory); custom classes are defined by subclassing the vendor's base class and overriding only the fields you need to change. All other fields inherit their defaults. This shape — dataclass, override-what-changes, serializable to dict — means liquid classes can be versioned in code, shared between protocols, and generated from calibration data. A calibration curve can fit a polynomial for Bravo or a correction-curve dict for Hamilton, and the result is a liquid class you commit to your repo alongside the protocol that uses it. ## Serialization keeps instance overrides Every liquid class serializes to a plain JSON-compatible dict via `.serialize()`, and comes back through `.deserialize()`. The dict names its subclass under `type` and carries **every** parameter of that instance, so a class you tweaked at runtime — a custom curve, a raised flow rate, a different tip — survives the round trip. Recording only the class name would silently discard those overrides and rehydrate the shipped defaults instead. The capability lives on the shared `LiquidClass` base, so it works identically for `HamiltonLiquidClass`, `BravoLiquidClass`, and any class you define yourself, with nothing to implement per device. This is what lets a liquid class travel: persist it next to the run that used it, hand it to another process, or store it as part of a parameter set. `HamiltonParameterSet.liquid_class` and `BravoParameterSet.liquid_class` serialize through the same mechanism, so dumping a parameter set preserves the full liquid class rather than just its name. See [Liquid Classes](https://docs.unitelabs.io/operate/guides/pipetting/liquid-classes/) for the serialization API in practice. ## Using them in practice - On Hamilton: pick a predefined `LiquidClass` member and pass it to every pipetting call, or subclass `HamiltonLiquidClass` and define your own. - On Bravo: usually rely on auto-selection; pass a specific class explicitly when you need to override the default, or subclass `BravoLiquidClass` to encode your reagent. See [Liquid Classes](https://docs.unitelabs.io/operate/guides/pipetting/liquid-classes/) for the procedural how-to, including predefined-class browsing, parameter reference tables, and custom-class examples. # Tips and Tip Tracking Tips are consumables, but the SDK models them as tracked resources with a lifecycle. Every tip has a state — sitting in a rack, mounted on a channel, holding liquid, discarded — and every pipetting call validates that state before executing. Aspirating without a tip raises; dropping a tip that was never picked up raises. This is not a convenience, it is what makes simulation meaningful and what catches protocol errors before any liquid moves. ## Two head models Pipetting heads come in two kinds, and which kind you have shapes every tip-handling call: - **Independent channels** — the Hamilton channel head. Each channel holds one tip independently. You specify which channels pick up from which spots, and channels can have different tips (or no tip) at the same time. - **Monolithic 96-heads** — the Bravo head and the Hamilton CO-RE 96 head. All active nozzles act as a unit. Partial pickups work by aligning the head's A1 nozzle with a specific rack position via `well_offset=(row, col)`; only nozzles that overlap a filled spot actually pick up tips, so an offset that overlaps already-consumed spots skips them rather than failing. Partial pickup on the Hamilton CO-RE 96 head is currently unsupported, as it requires a special adapter. This difference cascades through aspirate, dispense, and mix. Channel-based heads specify `channels=[0, 1, 2, 3]`; 96-heads specify `well_offset`. The same logical operation — "pick up four tips" — has different call shapes because the hardware has different primitives. ## The tip state machine Every tip moves through a small number of states: ```text in rack → picked up → aspirating / dispensing → dropped / discarded ``` - **In rack**: a tip sits in a `TipSpot` on a `TipRack`. The rack tracks which spots are filled. - **Picked up**: a channel or nozzle holds the tip. The SDK records which tip is where. - **Aspirating / dispensing**: the tip contains liquid. Its container tracks the current volume. - **Dropped / discarded**: the tip is returned to a rack spot (`put_down_tips()` or `return_tips()`) or sent to the trash (`discard_tips()`). The channel is empty again. Running against a mock catches violations of this machine before they reach hardware: trying to aspirate with no tip, dropping to a rack spot that is already occupied, discarding a tip that is not held. See [Simulation](https://docs.unitelabs.io/operate/concepts/simulation/) for the full validation scope. ## Tip racks as inventory A `TipRack` is an inventory, not just a grid. It tracks which spots are filled and exposes a `next_tips()` method to return the next available tips in column-major order — so you do not have to remember which spots you have already consumed. This pairs with the tip state machine: once picked up, a tip is no longer "available" from the rack until it is returned. Similarly, `first_spots()` returns the next empty spots in column-major order. ### Inventory operations Tips presence in a rack can be modified programmatically, for example in order to represent physical instrument state at the beginning of a workflow. - `TipRack(filled_with=TipType, filled_at=indices)` initializes rack filled with tips on specified spots (or all spots if filled\_at omitted) - `tip_rack.fill(labware, spots)` adds tips to empty spots - `tip_rack.clear(spots)` removes tips from tip rack spots ## Tip properties matter A tip is not generic. Each tip type declares: - A **maximum volume** (how much liquid it can hold) - An **air volume model** (transport air, blowout air) used by the liquid class - A **fitting depth** (how far it seats onto the nozzle) Liquid classes are typed by tip — a Bravo `AgilentTip_250` class will not work on a different tip type. When protocols move between tip types, liquid class selection has to move with them. ::callout{icon="i-heroicons-information-circle"} **Version note**: Tip names changed in Labware SDK v0.22.0 / LHSDK v0.22.0. Previous names such as `StandardTip`, `LT250Tip`, and `HighVolumeTip` are deprecated but still work with a warning. See the [Tips and Tip Racks](https://docs.unitelabs.io/operate/guides/labware/tips-and-racks/) guide for the current naming. :: ## Where to go next - [Tip Handling](https://docs.unitelabs.io/operate/guides/pipetting/tip-handling/) — the procedural how-to across Hamilton and Bravo - [Liquid Classes](https://docs.unitelabs.io/operate/concepts/liquid-classes/) — how tip type enters the liquid-class model - [Simulation](https://docs.unitelabs.io/operate/concepts/simulation/) — what the mock catches about tip state # Simulation Every liquid handler class has a matching mock. The mock has the same interface as the real device (same methods, same deck API, same liquid tracking) but executes locally, without network calls and without touching hardware. You can run a full protocol against a mock and inspect every well's volume when it is done. Mocks are available for each supported handler — `MicrolabSTARMock`, `BravoMock`, and so on — all importable from `unitelabs.liquid_handling.testing`. They are drop-in replacements: swap `MicrolabSTAR` for `MicrolabSTARMock` and the rest of your protocol code is unchanged. ```python from unitelabs.liquid_handling.testing import MicrolabSTARMock hamilton = MicrolabSTARMock() # The same setup/build-deck/pipetting code that runs against real hardware. ``` ## What the mock validates The mock is a functional model of the device, not a full physics simulation. It is good at catching structural and state errors: - **Deck conflicts** — placing two resources at the same position - **Volume errors** — aspirating more than a well contains, overfilling a well - **Tip state errors** — aspirating without tips, dropping tips that were never picked up - **Wrong resource types** — placing a plate in a tip carrier slot - **Liquid tracking** — every aspirate and dispense updates volumes, so you can assert the final state This is enough to find off-by-one errors, wrong well indexing, mis-ordered pickups, and volume arithmetic mistakes before any liquid is touched. ## What the mock does not validate The mock deliberately stops short of simulating physics. It will not catch: - **Physical reachability or collision detection** — whether the arm can actually move to a position without hitting something - **Timing and speed parameters** — flow rates, velocities, and dwell times do not produce physical effects - **Hardware-specific errors** — pressure, clot detection, optical verifications - **Channel movement state** — channel positions between commands are not tracked Use real hardware for these. ## Why this boundary matters The boundary is deliberate. A simulator that tries to model physics produces false confidence when the model is wrong and false alarms when the model is too conservative. A simulator that only models the state machine — what the SDK knows — is honest about what it covers. Running protocols against the mock first catches the class of bugs that are cheapest to fix: arithmetic, indexing, sequencing. The bugs that remain are the ones that need hardware anyway. ## Switching between mock and real The two objects are interface-compatible. Most protocols gate the choice behind a flag: ```python if mock_run: hamilton = MicrolabSTARMock() else: hamilton = MicrolabSTAR(name="Microlab STAR", client=client) ``` All downstream code — pipetting commands, movement, module activation — works identically against either. ## Where to go next - [Simulation](https://docs.unitelabs.io/operate/guides/simulation/) — the procedural how-to, including mock configuration, module activation in simulation, and inspecting liquid state - [Your First Protocol](https://docs.unitelabs.io/operate/your-first-protocol/) — a runnable mock-first example # Error Handling In most software, an unhandled exception means a bad user experience. In lab automation, it can mean a plate left in an incubator overnight, a gripper holding a tube, or reagents dispensed twice. The goal of error handling here is not just to recover gracefully; it is to leave every instrument in a **safe, known state** before the script can safely continue or exits. Every error handler should ask: *is the instrument in a safe state?* before re-raising or continuing. On this page you write error handlers yourself. When you're ready to have the workflow engine retry, pause, and resume on your behalf, see [Error handling](https://docs.unitelabs.io/automate/concepts/error-handling/) in Automate — same idea, moved from reactive code to declared policy. ## Basic structure Wrap your method steps with `try / except / finally`. The `finally` block runs regardless of success or failure, so use it for cleanup that must always happen. ```python import logging from unitelabs.sdk import AsyncApiClient async def run_method(): async with AsyncApiClient() as client: incubator = await client.get_service_by_name("Incubator") gripper = await client.get_service_by_name("Gripper") try: await incubator.door_controller.open() await gripper.arm_controller.move_plate(source="A1", destination="B1") await incubator.door_controller.close() except Exception as e: logging.exception("Step failed — attempting safe state") await incubator.door_controller.close() # always close the door raise finally: await gripper.arm_controller.home() # runs whether or not an exception occurred ``` Use `logging.exception` rather than `print`; it captures the full traceback and writes it to your log file. Keep `finally` blocks simple. If safe-state actions can themselves raise, wrap them in their own `try / except` to prevent masking the original error. ## Common failure patterns ### Device timeout The instrument did not respond in time. Retry once with a short delay, then raise: ```python for attempt in range(2): try: result = await reader.measurement.read_absorbance(well="A1") break except TimeoutError: if attempt == 1: logging.error("Reader timed out after 2 attempts") raise logging.warning("Reader timeout on attempt %d — retrying", attempt + 1) await asyncio.sleep(5) ``` ### Unexpected device state Never silently continue when the instrument reports a state your script did not expect. Raise immediately with enough context to diagnose: ```python status = await incubator.heating.get_status() if not status.door_closed: raise RuntimeError( f"Incubator door is open before step — expected closed. " f"Check that no plates were left inside." ) ``` Silence here turns a detectable misconfiguration into an unexplained result. ### Partial completion If the method fails halfway through, write whatever results you have before re-raising. Data from completed samples is valuable even when the run did not finish: ```python results = [] try: for sample in samples: measurement = await reader.measurement.read_absorbance(well=sample["position"]) results.append({"id": sample["id"], "absorbance": measurement.value}) except Exception: logging.error("Run aborted — saving %d partial results", len(results)) with open("results_partial.json", "w") as f: json.dump(results, f) raise ``` Writing results incrementally during the loop makes this automatic. Your method accumulates partial data as it runs rather than buffering everything until the end. ### Connection loss Catch connection errors separately from device-logic errors, since the recovery is different: ```python try: await thermocycler.temperature_controller.set_target_temperature(target_temperature=37) except ConnectionError as e: logging.error("Lost connection to thermocycler: %s", e) # Connectors handle reconnection to the instrument automatically # Automatic retries with a delay or exponential backoff may be sufficient raise except Exception: logging.exception("Thermocycler command failed") raise ``` ## Marking labware as errored After a failed tip pickup or transport step you often cannot trust what is physically in a spot — a tube may have been dropped, a tip half-mounted. Since labware 0.29.0, you can record that uncertainty on the labware itself instead of guessing later. `Spot.mark_errored()` (available on `TipSpot` and `TubeSpot`) clears whatever the spot held and replaces it with an `Unknown` placeholder, and `spot.errored` reports the state: ```python from unitelabs.labware import Unknown spot = tip_rack["A1"] try: await hamilton.pipettes.pick_up_tips(channels=[0], spots=[spot]) except Exception: spot.mark_errored() # contents are now Unknown raise spot.errored # True isinstance(spot.get(), Unknown) # True ``` `mark_errored()` works whether the spot was occupied or empty; the previous occupant is detached. Clearing the spot with `spot.remove()` discards the `Unknown` placeholder and restores the empty, non-errored state. Marking spots as errored keeps the deck model honest so downstream steps — and anyone reloading the saved deck — know not to rely on those positions. ## Logging for traceability Set up logging once at the top of your script, before any instrument connections: ```python import logging logging.basicConfig( level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s — %(message)s", handlers=[ logging.StreamHandler(), logging.FileHandler("run.log"), ], ) ``` Log before and after every state-changing operation, not for debugging, but for audit and post-run analysis: ```python logging.info("Opening incubator door") await incubator.door.open() logging.info("Incubator door open") logging.info("Moving plate: %s → %s", source_well, dest_well) await gripper.arm.move_plate(source=source_well, destination=dest_well) logging.info("Plate move complete") ``` The `run.log` file gives you a timestamped record of every physical action your script took. Many logs are automatically written and stored on the platform (API logs), others are available but not surfaced unless logging is explicitly set up (inbuilt SDK logs). ## Safe-state checklist Before finishing an error handler, check: 1. **Connections**: are open connections closed or returned to their pool? 2. **Held labware**: is the gripper parked? Is a pipette tip ejected? 3. **Instrument doors**: are incubator and centrifuge doors closed? 4. **Partial results**: have you written whatever data you collected so far? 5. **Notification**: if the method was running unattended, does someone know it failed? 6. **`finally` safety**: does your `finally` block avoid raising its own exception? ## Next steps - [Operate](https://docs.unitelabs.io/operate/overview/): define deck positions your error handlers can reference - [Basic Pipetting](https://docs.unitelabs.io/operate/guides/pipetting/basic-pipetting/): tip ejection and liquid-handling-specific error patterns - [Automate → Error handling](https://docs.unitelabs.io/automate/concepts/error-handling/): declare retry policy and checkpoint-based resumption instead of writing it yourself # Standard Labware The labware library contains many pre-defined labware types and base classes to create custom labware. Before building your own labware definition, check if what you need already exists; it likely does for common Hamilton carriers, Corning-Costar plates, and standard tips. ## What's available Standard labware is organized by vendor sub-package: | Sub-package | Contents | | ---------------------------------- | --------------------------------------------------------------------------------- | | `unitelabs.labware.hamilton` | Hamilton plate carriers, tip carriers, trough carriers, tip types | | `unitelabs.labware.corning_costar` | Corning-Costar 96/384-well plates | | `unitelabs.labware` | Generic base types: `Standard96Plate`, `StandardTrough`, `Standard50mLTube`, etc. | To see the full list of what is available in any sub-package at runtime: ```python from unitelabs.labware import hamilton, corning_costar print(hamilton.__all__) # all Hamilton carriers and tip types print(corning_costar.__all__) # all Corning-Costar plates ``` ## Hamilton carriers ```python from unitelabs.labware.hamilton import ( # Plate carriers (landscape, 5-slot) PLT_CAR_L5MD_A00, # Medium density PLT_CAR_L5AC_A00, # Anti-condensation PLT_CAR_L5FLEX_MD_A00, PLT_CAR_L5FLEX_AC_A00, PLT_CAR_L5PCR_A00, # PCR plates # Plate carriers (portrait, 3-slot) PLT_CAR_P3MD_A00, PLT_CAR_P3AC_A00, # Tip carriers TIP_CAR_288_A00, TIP_CAR_480_A00, ) ``` ## Corning-Costar plates ```python from unitelabs.labware.corning_costar import ( Cos_96_FB, # 96-well flat bottom Cos_96_RD, # 96-well round bottom Cos_384_Sq, # 384-well square bottom ) ``` ## Generic standard types These are available directly from `unitelabs.labware` and work across vendors: ```python from unitelabs.labware import ( Standard96Plate, # ANSI/SLAS standard 96-well plate StandardTrough, # 300 mL reagent trough (96-hole access) Standard50mLTube, # 50 mL conical tube ) ``` Importing a labware class, i.e. the tip carrier *TIP\_CAR\_480\_A00* from Hamilton is done with the following code: ```python from unitelabs.labware.hamilton import TIP_CAR_480_A00 ``` In the same way, importing the 96-well flat bottom microplates from Corning-Costar looks like this: ```python from unitelabs.labware.corning_costar import Cos_96_FB ``` ## Inspecting labware properties After importing a labware class, you can instantiate it. Using the `dir( ... )` built-in function, all available properties and methods can be viewed. A selection is shown below: ```python from unitelabs.labware.hamilton import PLT_CAR_L5MD_A00 plate_carrier_1 = PLT_CAR_L5MD_A00() print(plate_carrier_1.dimensions) print(plate_carrier_1.height, plate_carrier_1.depth, plate_carrier_1.width) print(plate_carrier_1.location) print(plate_carrier_1.absolute_location) print(plate_carrier_1.rotation) print(plate_carrier_1.tracks) print(plate_carrier_1.cols) print(plate_carrier_1.rows) print(plate_carrier_1.children) ``` This yields the following output: ```bash [Terminal] Vector(x=Decimal('135'), y=Decimal('497'), z=Decimal('130')) # .dimension 130 497 135 # .height, .depth, .width None # .location Vector(x=Decimal('0'), y=Decimal('0'), z=Decimal('0')) # .absolute_location 0 # .rotation 6 # .tracks 1 # .cols 5 # .rows [ CarrierSite(identifier='81625615', rotation=0, dimensions=Vector(x=Decimal('127'), y=Decimal('86'), z=Decimal('0')), location=Vector(x=Decimal('4'), y=Decimal('392.5'), z=Decimal('111.75')), orientation=), CarrierSite(identifier='90e13a32', rotation=0, dimensions=Vector(x=Decimal('127'), y=Decimal('86'), z=Decimal('0')), location=Vector(x=Decimal('4'), y=Decimal('296.5'), z=Decimal('111.75')), orientation=), CarrierSite(identifier='0e66b837', rotation=0, dimensions=Vector(x=Decimal('127'), y=Decimal('86'), z=Decimal('0')), location=Vector(x=Decimal('4'), y=Decimal('200.5'), z=Decimal('111.75')), orientation=), CarrierSite(identifier='d16d85e1', rotation=0, dimensions=Vector(x=Decimal('127'), y=Decimal('86'), z=Decimal('0')), location=Vector(x=Decimal('4'), y=Decimal('104.5'), z=Decimal('111.75')), orientation=), CarrierSite(identifier='b2b1ced8', rotation=0, dimensions=Vector(x=Decimal('127'), y=Decimal('86'), z=Decimal('0')), location=Vector(x=Decimal('4'), y=Decimal('8.5'), z=Decimal('111.75')), orientation=) ] ``` When instantiated, labware does not have a location yet. Labware can be added to a deck or other labware. The location will then be calculated relative to its parent. More on deck building is explained in [Building a Deck](https://docs.unitelabs.io/operate/guides/deck/building-a-deck/). Labware can be custom-made using the base classes provided. More on custom labware is explained in the [custom labware guide](https://docs.unitelabs.io/operate/guides/labware/). # Plates To define a new plate, a technical drawing is essential. A standard plate class is defined based on the [ANSI/SLAS Microplate Standards](https://www.slas.org/education/ansi-slas-microplate-standards/){rel=""nofollow,noopener""}. A typical technical drawing from a vendor has the following information: ![Technical drawing of a microtiter plate](https://docs.unitelabs.io/images/automate/labware/plate-technical-drawing.webp){.max-w-md.mx-auto.mb-2}[© by Perkin Elmer]{.block.max-w-md.mx-auto.text-sm} We need the length, width, and height which are the base dimensions (x, y, z) of the plate respectively. Furthermore, we need the offsets in x-, y-, and z-direction starting from well A1. To define the wells, it may be necessary to measure the well width, length, and depth. The plate in this example follows the standard definition of a 96 well plate and has the following dimensions: - Cols, rows: 12, 8 - Length, width, height (mm): 127.76 mm, 85.48 mm, 14.35 mm By using the `place_standardized(count=96)` method, the offsets and spacing are inferred from the standard specification. If the plate deviates from the specification, the `place()` method can be used to define custom offsets, well spacings, or alignments. In the code below, we create a 96-well plate with a cylindrical form and a round bottom. ```python import collections.abc import dataclasses from unitelabs.labware import ( Container, Cylinder, Decimal, Plate, StandardMicroplateDimensions, Well, place_standardized, ) @dataclasses.dataclass class Standard96Plate(Plate, StandardMicroplateDimensions): """Standard 96-well plate.""" cols: int = 12 rows: int = 8 grab_height: Decimal = dataclasses.field(default=Decimal(default=13.2)) children: collections.abc.Sequence[Well] = dataclasses.field( repr=False, default_factory=lambda: [ Well( container=Container(max_volume=300, sections=[Cylinder(radius=3.48, height=10.67)]), dimensions=dimension, ).copy(location=location) for location, dimension in place_standardized(count=96) ], ) ``` ## Ad-hoc building in your workflow You can find the plate that is defined below in the Thermofisher webshop under the model number [3959](https://www.thermofisher.com/document-connect/document-connect.html?url=https://assets.thermofisher.com/TFS-Assets%2FLSG%2Fmanuals%2Fcms_042421.pdf){rel=""nofollow,noopener""}. Corning has published a table of the dimensions for all their plates [here](https://www.corning.com/catalog/cls/documents/drawings/MicroplateDimensions96-384-1536.pdf){rel=""nofollow,noopener""}. They have a separate table for [cell-culture plates](https://www.corning.com/catalog/cls/documents/selection-guides/CLS-CC-010.pdf){rel=""nofollow,noopener""}. In this example, we will define the plate "on the fly" and not as a dataclass. The total height of the plate is 23.24 mm. The well bottom is offset from the ground by 0.74 mm and the boundary box at which the well ends is 2.5 mm below the height of the plate. The spacing between the wells is 9.025 mm and thus deviates from the standard specification by 0.025 mm. The volume of the well is calculated with the approximation of a conical frustum. To yield more accurate results, the well could be divided into two sections: a conical frustum and a spherical segment at the bottom. However, the required measurements are not provided by the technical data sheet. A maximum volume is defined for this well. The maximum volume corresponds to the recommended working volume. ```python from unitelabs.labware import ( ConicalFrustum, Container, Plate, Vector, Well, place, ) # MicroAmp_Optical_96_Well_Reaction_Plate approximated with a conical frustum shape microamp_optical_96_well = Plate( dimensions=Vector(x=125.98, y=85.85, z=23.24), # base dimension in mm children=[ Well( container=Container( max_volume=200, sections=[ ConicalFrustum( radius_lower=1, radius_upper=2.747, height=23.24 - 0.74 - 2.5, ) ], ), dimensions=dimensions, ).copy(location=location) for location, dimensions in place( rows=8, cols=12, boundary=Vector(x=125.98, y=85.85, z=23.24 - 2.5), item=Vector(x=9.025, y=9.025, z=23.24 - 0.74 - 2.5), ) ], ) ``` The plate and well object properties can be accessed to double-check their definition: ```python print(f'Absolute location of the plate: {microamp_optical_96_well.absolute_location}') print(f'Base dimensions of the plate: {microamp_optical_96_well.dimensions}') print(f'The relative location of well A1: {microamp_optical_96_well[0].location}') print(f'The absolute location of well A1: {microamp_optical_96_well[0].absolute_location}') print(f'The height of well A1: {microamp_optical_96_well[0].height}') ``` Since the plate is not assigned to a deck, its absolute location is the origin. The well is a part of the plate and therefore its absolute location is relative to the location of the plate. As shown in the well z-location, the well bottom sits 0.74 mm above the plate bottom, accounting for the material of the well bottom/plate base. ```text Absolute location of the plate: Vector(x=Decimal('0'), y=Decimal('0'), z=Decimal('0')) Base dimensions of the pate: Vector(x=Decimal('125.98'), y=Decimal('85.85'), z=Decimal('23.24')) The relative location of well A1: Vector(x=Decimal('8.840'), y=Decimal('70.000'), z=Decimal('0.74')) The absolute location of well A1: Vector(x=Decimal('8.840'), y=Decimal('70.000'), z=Decimal('0.74')) The height of well A1: 20.0 ``` The wells on the plate have containers. These containers are defined by their shape by which volume and liquid level calculations are performed. While these are used heavily in the background, they can also be directly accessed. Here are some useful properties and methods of the general container object: ```python from unitelabs.labware import PredefinedLiquids microamp_optical_96_well[0].container.add_liquid(liquid=PredefinedLiquids.WATER, volume=50) print(f'The max volume defined by the creator: {microamp_optical_96_well[0].container.max_volume} µL') print(f'The max calculated volume derived from the dimensions: {round(microamp_optical_96_well[0].container.max_fitting_volume, 3)} µL') print(f'The current volume inside of this container: {round(microamp_optical_96_well[0].container.volume, 3)} µL') print(f'The current liquid level inside of the container: {round(microamp_optical_96_well[0].container.liquid_level, 3)} mm') print(f'Calculating the volume inside the container for a height of 10 mm: {round(microamp_optical_96_well[0].container.volume_for_height(height=10), 3)} µL') print(f'Calculating the liquid level height for volume of 100 µL: {round(microamp_optical_96_well[0].container.height_for_volume(volume=100), 3)} mm') ``` To pre-fill every well at construction, pass `filled_with` to the plate — this works for both predefined plates and the ad-hoc `Plate(...)` shown above: ```python microamp_optical_96_well = Plate( dimensions=Vector(x=125.98, y=85.85, z=23.24), children=[...], # as above filled_with=(PredefinedLiquids.WATER, 50), ) ``` See [Pre-filling Labware](https://docs.unitelabs.io/operate/concepts/liquids#pre-filling-labware) for mixtures, troughs, and the full behavior. These functions can be used to verify the labware definition. ```text The max volume defined by the creator: 200 µL The max calculated volume derived from the dimensions: 236.520 µL The current volume inside of this container: 50.000 µL The current liquid level inside of the container: 8.348 mm Calculating the volume inside the container for a height of 10 mm: 66.848 µL Calculating the liquid level height for volume of 100 µL: 12.662 mm ``` ## Shape Dimensions at Height Labware containers can be made up of different shape types such as `Cylinder`, `Cuboid`, `Cone`, `Pyramid`, and others. When an operation needs to know the width of a container at a specific height — for example, to move a pipetting channel to the side of a well — there is no single shared property for this across all shapes. Without a universal interface, logic that depends on the cross-sectional width or area at a given height must handle each shape type separately, which is error-prone and hard to maintain. To solve this, three methods are available on all concrete shape classes: - `width_at_height(height)` — Returns the x-axis extent of the cross-section at a given height. - `depth_at_height(height)` — Returns the y-axis extent of the cross-section at a given height. For circular shapes this equals the width. - `area_at_height(height)` — Returns the cross-sectional area at a given height. For stacked containers (any `Container` with multiple sections), an additional method is available: - `section_at_height(height)` — Returns a `tuple[Shape, Decimal]` containing the shape section and the local height within that section. `Container` automatically delegates `width_at_height`, `depth_at_height`, and `area_at_height` through this method. ### Usage Example Given the `Standard50mLTube` defined in [Tubes and Tube Racks](https://docs.unitelabs.io/operate/guides/labware/tubes-and-tube-racks/) — a tube with a cylindrical upper section and a conical frustum at the bottom — you can query the width at any height without caring which section you are in: ```python from unitelabs.labware import Decimal tube = Standard50mLTube() # Width at the middle of the cylindrical section (e.g. 50 mm from bottom) width_mid = tube.container.width_at_height(Decimal("50")) print(f"Width at 50 mm: {width_mid} mm") # 27.78 mm (2 × 13.89) # Width near the tip of the conical frustum (e.g. 5 mm from bottom) width_tip = tube.container.width_at_height(Decimal("5")) print(f"Width at 5 mm: {width_tip} mm") # interpolated between 7.2 mm and 27.78 mm # Cross-sectional area at 50 mm area = tube.container.area_at_height(Decimal("50")) print(f"Area at 50 mm: {round(area, 3)} mm²") # π × 13.89² # Inspect which section and local offset a given height falls into section, local_height = tube.container.section_at_height(Decimal("5")) print(f"Section: {section}") # ConicalFrustum(...) print(f"Local height: {local_height} mm") ``` ### Shape Reference The table below shows how each concrete shape implements the three methods, where `h` is the queried height and `H` is the total height of the shape: | Shape | `width_at_height(h)` | `depth_at_height(h)` | `area_at_height(h)` | | -------------------- | -------------------------------------------------------------------------- | ------------------------------------------------------------ | ---------------------------- | | **Cuboid** | `width` (constant) | `depth` (constant) | `width × depth` | | **Pyramid** | `(h/H) × width` | `(h/H) × depth` | `(h/H)² × width × depth` | | **PyramidalFrustum** | linear interpolation between `width_lower` and `width_upper` | linear interpolation between `depth_lower` and `depth_upper` | `w(h) × d(h)` | | **Cylinder** | `2 × radius` (constant) | `2 × radius` (constant) | `π × r²` | | **Cone** | `2 × (h/H) × radius` | same as width | `π × ((h/H) × r)²` | | **ConicalFrustum** | `2 × r(h)`, linear interpolation between `radius_lower` and `radius_upper` | same as width | `π × r(h)²` | | **SphericalCap** | `2 × √(2Rh − h²)` | same as width | `π × (2Rh − h²)` | | **HalfSphere** | inherits from `SphericalCap` | inherits from `SphericalCap` | inherits from `SphericalCap` | | **SphericalSegment** | `2 × √(2R·h' − h'²)` where `h'` is height from sphere bottom | same as width | `π × (2R·h' − h'²)` | ::callout{icon="i-heroicons-light-bulb"} **Stacked containers**: When a `Container` has multiple sections, `width_at_height`, `depth_at_height`, and `area_at_height` automatically resolve the correct section via `section_at_height`. The height passed is always measured from the bottom of the container, not from the bottom of the individual section. :: ## Standard Dimensions As mentioned in the plate section, most microplates follow the [ANSI/SLAS Microplate Standards](https://www.slas.org/education/ansi-slas-microplate-standards/){rel=""nofollow,noopener""}. To facilitate the creation and usage of labware, the library stores standard dimensions in two classes: ```python from unitelabs.labware.dimensions import StandardMicroplateDimensions ``` ### StandardMicroplateDimensions `StandardMicroplateDimensions` holds the standard footprint (`x` and `y` dimensions) of microplates. This is used for a large variety of labware on liquid handling stations. Any subclass can define a `dimensions` property (see the `Lid` example below) to update the z-dimension (defaults to a standard microplate height of 14.35mm). One should only set the z-dimension (height) for any subclasses of `StandardMicroplateDimensions`, since the x and y dimensions are fixed to the ANSI/SLAS standard (127.76 mm x 85.48 mm). This is useful for checking if specific labware can fit on carriers or adapters. ```python @dataclasses.dataclass class StandardLid(StandardMicroplateDimensions, Labware): """ Standard lid for plates. Attributes: fitting_depth: The overlap between the lid and the plate, in mm. grab_height: The distance in mm from the top of the labware where to grab it. """ dimensions: Vector = dataclasses.field(default_factory=lambda: Vector(z=10)) fitting_depth: Decimal = dataclasses.field(default=Decimal(default=8)) grab_height: Decimal = dataclasses.field(default=Decimal(default=5.7)) ``` # Tips and Tip Racks ## Tips In this example we will use the [Hamilton standard 300 μL conductive tips](https://www.hamiltoncompany.com/automated-liquid-handling/disposable-tips/300-%CE%BCl-conductive-non-sterile-non-filter-tips){rel=""nofollow,noopener""}. We need the length, width, and height of the tip (x, y, z), as well as the fitting-depth in millimeters. Fitting depth, length and width are constant for all conventional Hamilton tips. Furthermore, we need the collar type to check for compatibility with the pipetting heads. Only the height and collar type can be extracted from the technical specification. The dimensions of the regular channels are 9x9 mm. The tip in this example has the following parameters: - Length, width, height (mm): 9.0 mm, 9.0 mm, 59.9 mm - Fitting depth (mm): 8 mm - Collar type: STANDARD Other collar types: `LOW_VOLUME`, `HIGH_VOLUME`, `CORE_384_AXYGEN`, `XL`, `CORE_384_HAMILTON`. ::callout{icon="i-heroicons-light-bulb"} **Finding tip parameters**: Most tip parameters are stored in the liquid handler vendor software. For Hamilton Venus, look in the Labware folder. You can also reach out to UniteLabs if you can't find the right dimensions. :: ::callout{icon="i-heroicons-information-circle"} **Version note**: Tip names changed in Labware SDK v0.22.0 / LHSDK v0.22.0. Previous names such as `StandardTip`, `LT250Tip`, and `HighVolumeTip` are deprecated but still work with a warning. Update your imports to the new names below to avoid deprecation warnings. :: ::callout{color="amber" icon="i-heroicons-exclamation-triangle"} **Volume limits**: Make sure maximum volumes are correct. Over-aspirating may damage the pipetting channel. The `max_volume` field limits the liquid volume to be aspirated — it does not account for transport air or blowout air, which can cause the channel to exceed this limit if set incorrectly. :: ```python import dataclasses import typing from unitelabs.labware import ComplexShape, Container, Decimal, Vector from unitelabs.labware.hamilton import CollarType, HamiltonTip @dataclasses.dataclass class CustomHamiltonTip(HamiltonTip): tip_type: typing.ClassVar[int] = 20 model: str = "235902" dimensions: Vector = dataclasses.field(default_factory=lambda: Vector(x=9, y=9, z=59.9)) container: Container = dataclasses.field( default_factory=lambda: Container( max_volume=400, sections=[ComplexShape(factor=36.3168, height=8), ComplexShape(factor=12.8596, height=52)], ) ) has_filter: bool = False fitting_depth: Decimal = dataclasses.field(default=Decimal(default=8)) collar_type: CollarType = CollarType.STANDARD ``` To use the newly defined tip on a Hamilton liquid handler, it must be registered first: ```python hamilton = MicrolabSTAR( ... ) tip = CustomHamiltonTip() await hamilton.api.define_tip(tip.tip_type, tip, pick_up_method=PickUpTipMethod.NORMAL) ``` ## Tip Racks A peculiarity about the tip rack is the negative z-offset of the `TipSpot` children. Instead of modeling the tips hanging in the tip rack, it is assumed that the tips stand on the virtual bottom of the tip rack. **How the z-offset is calculated:** The height of the tip rack in this example is 20 mm. The height of a `HamiltonTip_1000` is 95.1 mm. The approach height for tip pick-up is 8.4 mm above the tip — placing the `TipSpot` at 103.5 mm. Assuming the top of the tip levels out with the tip rack (20 mm high), the tip is 103.5 mm − 20 mm = **−83.5 mm** below the top of the rack. The `TipSpot` dimensions match the dimensions of the tip type the rack is designed for. Since the tip spot is 2-dimensional, the z-component can be set to 0 or omitted. The following parameters were used in the example below: - Cols, rows: 12, 8 - Length, width, height (mm): 122.4 mm, 82.6 mm, 20 mm - Tip spot A1 offsets (mm): 7.2 mm, 5.3 mm, −83.5 mm - Tip spot width, length, depth (mm): 9 mm, 9 mm, 0 mm For Hamilton: All parameters except the x/y offsets of the tip spot can be found in the Venus labware file. ```python import collections.abc import dataclasses from unitelabs.labware import TipRack, TipSpot, Vector, place @dataclasses.dataclass class HamiltonTipRack_1000(TipRack): dimensions: Vector = dataclasses.field(default_factory=lambda: Vector(x=122.4, y=82.6, z=20.0)) rows: int = 8 cols: int = 12 children: collections.abc.Sequence[TipSpot] = dataclasses.field( repr=False, default_factory=lambda: [ TipSpot(dimensions=dimensions).copy(location=location) for location, dimensions in place( HamiltonTipRack_1000.cols, HamiltonTipRack_1000.rows, item=Vector(x=9.0, y=9.0, z=0), boundary=Vector(x=122.4, y=82.6, z=0), offset=Vector(z=-83.5), ) ], ) ``` # Tubes and Tube Racks ## Tubes Tubes can be defined using the `Tube` base class. A tube has a base dimension equal to the maximum outer diameter in x- and y-direction and the maximum total height. A fillable tube has a container made up of one or more sections. Sections are defined **top-to-bottom** — the first section in the list is the uppermost part of the tube, and the last section is the bottom. It is possible to define one or more `Hole` objects to model pipetting channel access points. In the example below, a 50 mL conical tube is defined with a cylindrical upper section and a conical frustum at the bottom. A single 9 mm × 9 mm access hole is defined with a depth of 113.65 mm. ```python import collections.abc import dataclasses from unitelabs.labware import ( ConicalFrustum, Container, Cylinder, Hole, Tube, Vector, place, ) @dataclasses.dataclass class Standard50mLTube(Tube): dimensions: Vector = dataclasses.field(default_factory=lambda: Vector(x=29.36, y=29.36, z=114.65)) container: Container = dataclasses.field( default_factory=lambda: Container( max_volume=50_000, sections=[ Cylinder(radius=13.89, height=98.77), # upper cylindrical section (top) ConicalFrustum(radius_lower=3.6, radius_upper=13.89, height=14.88), # conical bottom ], ) ) children: collections.abc.Sequence[Hole] = dataclasses.field( repr=False, default_factory=lambda: [ Hole(dimensions=dimensions).copy(location=location) for location, dimensions in place( cols=1, rows=1, item=Vector(x=9, y=9, z=113.65), boundary=Vector(x=29.36, y=29.36, z=114.65) ) ], ) ``` A simpler tube can be defined by omitting explicit container sections — in this case the container geometry is automatically derived from the tube's dimensions and shape: ```python import dataclasses from unitelabs.labware import Tube, Vector from unitelabs.labware.liquids import Container @dataclasses.dataclass class SimpleTube15mL(Tube): dimensions: Vector = dataclasses.field(default_factory=lambda: Vector(x=17, y=17, z=120)) container: Container = dataclasses.field(default_factory=Container) # The above would be identical to: @dataclasses.dataclass class SimpleTube15mL(Tube): dimensions: Vector = dataclasses.field(default_factory=lambda: Vector(x=17, y=17, z=120)) ``` Here the container sections, height, and maximum volume are computed automatically from dimensions during initialization. ## Tube Caps Tubes can have caps which are `Cap` objects placed on top of the tube: ```python from unitelabs.labware.tubes import Cap from unitelabs.labware.math import Decimal # Add a cap with custom dimensions @dataclasses.dataclass class CustomCap(Cap): dimensions: Vector = dataclasses.field(default_factory=lambda: Vector(x=8, y=8, z=8)) fitting_depth: Decimal = dataclasses.field(default=Decimal("5")) # 5mm overlap into tube ``` One can assign a cap to a tube in the following ways: ```python # Initialize a lid with a cap directly (always defaults to None) tube = Tube(lid=CustomCap()) # Or assign a cap to a tube's lid attribute tube.lid = CustomCap(fitting_depth=Decimal("5")) # Or use tube.close() to assign a cap to a tube tube.close(CustomCap(fitting_depth=Decimal("5"))) ``` One can manage and reassign caps on tubes in the following ways: ```python # Remove the cap from the tube and return it cap = tube.open() # Place a cap back onto the tube tube.close(cap) # Move a cap from one tube to another tube.reassign(to=tube2, lid=cap) ``` ## Tube Racks `TubeRack`s consist of a grid of `TubeSpot`s arranged into rows and columns. These spots can either contain tubes or be empty. For non-standard grid sizes, create a subclass inheriting from `TubeRack`. The base class defaults to a 6x4 grid (24 tubes), but you can specify any `cols` and `rows` configuration: ```python import dataclasses import decimal import collections.abc from unitelabs.labware import TubeRack, Tube, TubeSpot, Vector from unitelabs.labware.liquids import Container from unitelabs.labware.math import place @dataclasses.dataclass class CustomTubeRack(TubeRack): """A 6x4 tube rack for 15 mL conical tubes.""" cols: int = 6 rows: int = 4 dimensions: Vector = dataclasses.field(default_factory=lambda: Vector(x=120, y=80, z=30)) fitting_depth: decimal.Decimal = dataclasses.field(default_factory=lambda: decimal.Decimal("25")) # Define tube spot positions using `place` children: collections.abc.Sequence[TubeSpot] = dataclasses.field( repr=False, default_factory=lambda: [ TubeSpot().copy(location=loc) for loc in place( cols=6, rows=4, item=Vector(x=10, y=10), # tube footprint boundary=Vector(x=120, y=80), # rack footprint ) ], ) @dataclasses.dataclass class Tube15mL(Tube): """15 mL conical tube.""" dimensions: Vector = dataclasses.field(default_factory=lambda: Vector(x=17, y=17, z=120)) container: Container = dataclasses.field(default_factory=Container) # Create and fill the rack rack = CustomTubeRack(filled_with=Tube15mL) ``` A `Standard96TubeRack` (of standard ANSI-SLAS footprint) is implemented and easily customizable with the `spot_offset` and `fitting_depth` parameters: - `spot_offset`: the offset of the A1 tube spot center from the A1 corner of the rack - `fitting_depth`: how far into the rack (from its top plane) that tubes sit in the rack ```python import dataclasses import decimal from unitelabs.labware import Tube, Standard96TubeRack, Vector from unitelabs.labware.liquids import Container @dataclasses.dataclass class CustomTube(Tube): """A 2 mL Eppendorf tube.""" dimensions: Vector = dataclasses.field(default_factory=lambda: Vector(7.8, 7.8, 42.5)) container: Container = dataclasses.field(default_factory=Container) @dataclasses.dataclass class Custom96TubeRack(Standard96TubeRack): """96-position tube rack for 2 mL tubes.""" dimensions: Vector = dataclasses.field(default_factory=lambda: Vector(z=30.4)) spot_offset: Vector = dataclasses.field(default_factory=lambda: Vector(x=14.7, y=11.2)) fitting_depth: decimal.Decimal = dataclasses.field(default_factory=lambda: decimal.Decimal("28.1")) # Create a filled rack tube_rack = Custom96TubeRack(filled_with=CustomTube) ``` ### Accessing Tubes Any `TubeRack` provides a convenient way to access tubes in the rack using `.tubes`: ```python tubes = tube_rack.tubes for tube in tubes: print(tube.container.volume) # Volume of the tube print(tube.lid) # Lid of the tube, if any ``` One can access tubes by their well position or numerical (column-major) index: ```python # Access tubes by well position tube = tube_rack.tubes["A1"] tube = tube_rack.tubes["A2":"H2"] tube = tube_rack.tubes["A7":] # Access tubes by numerical index tube = tube_rack.tubes[0] # First tube (A1) tube = tube_rack.tubes[0:8] # First column (A1:H1) ``` It is **essential** to note that since `TubeSpot`s can be empty, calling `.tubes` over a slice of `TubeSpot`s may return fewer tubes than the number of spots searched! When necessary, one can enforce that tubes are present in all spots by using `.tubes.strict`, following the same pattern as `.tubes`: ```python tubes = tube_rack.tubes.strict # Raises IndexError if any spot is empty on the rack # Access tubes by well position as with .tubes tube = tube_rack.tubes.strict["A2":"H2"] # Raises IndexError if any spot is empty tube = tube_rack.tubes.strict[9:16] # Raises IndexError if any spot is empty ``` ### Convenience Methods `.filled_spots`, `.empty_spots`: quickly find filled or empty spots in a tube rack: ```python filled_spots = tube_rack.filled_spots empty_spots = tube_rack.empty_spots # Print filled spot labels for spot in filled_spots: print(spot.well_label) # Print empty spot labels for spot in empty_spots: print(spot.well_label) ``` `.any_caps`: quickly find if a tube rack has any caps: ```python if tube_rack.any_caps: print("The tube rack has at least one cap.") ``` `.height`: get the current effective height of the rack, accounting for: - Presence of tubes in the rack (adjusted by the rack's fitting depth) - Presence of caps on tubes (adjusted by the cap's fitting depth) ```python empty_rack = Standard96TubeRack(dimensions=Vector(z=50.0)) print(empty_rack.height) # empty_rack.dimensions.z filled_rack = Standard96TubeRack( filled_with=Tube(dimensions=Vector(x=9.0, y=9.0, z=30.0)), fitting_depth=25.0 ) print(filled_rack.height) # filled_rack.dimensions.z + Tube.dimensions.z - filled_rack.fitting_depth cap = Cap( dimensions=Vector(x=10.0, y=10.0, z=5.0), fitting_depth=3.0 ) filled_rack.tubes["A1"].close(cap) print(filled_rack.height) # filled_rack.dimensions.z + Tube.dimensions.z - filled_rack.fitting_depth + cap.dimensions.z - cap.fitting_depth ``` ### Liquid Handling One can flexibly provide `TubeRack` objects to liquid handling operations: ```python lhs.pipettes.aspirate(tube_rack) # Targets the first present tubes in the rack with column-major searching lhs.pipettes.dispense(tube_rack.tubes["A6":"H6"]) # Target tubes present from A6-H6 on the rack lhs.pipettes.aspirate(tube_rack.tubes.strict["A6":"H6"]) # Target tubes present from A6-H6 on the rack, raising an error if any spot is empty lhs.core96.aspirate(tube_rack) # Target all tubes in the rack. Note that for a 96-channel head, tubes must be arranged in a compatible layout on the rack! ``` See [Basic Pipetting](https://docs.unitelabs.io/operate/guides/pipetting/basic-pipetting/) for more information on liquid handling operations. # Troughs ## Troughs Troughs are used to store large amounts of liquid such as water, ethanol, or buffer solutions. In contrast to a plate, aspiration from or to a trough with multiple channels work from the same shared container volume. Conceptually, a trough is a collection of Fillables with a defined number of access points (`Hole` objects) for pipetting channels. These Fillables and holes can be arranged in any layout. In the example below, a grid of 96 holes is generated — similar to a 96-well plate layout — so that multi-channel heads can access the trough at each column position. `cols` and `rows` are hints for the arrangement of the Holes. ```python import collections.abc import dataclasses from unitelabs.labware import ( Container, Cuboid, Fillable, Hole, StandardMicroplateDimensions, Trough, Vector, place_standardized, ) @dataclasses.dataclass class StandardTrough(Trough, StandardMicroplateDimensions): """A standard trough.""" cols: int = 12 rows: int = 8 dimensions: Vector = dataclasses.field(default_factory=lambda: Vector(z=44)) children: collections.abc.Sequence[Fillable] = dataclasses.field( repr=False, default_factory=lambda: [ Fillable( container=Container(max_volume=300_000, sections=[Cuboid(width=108, depth=72, height=40)]), children=[ Hole(dimensions=dimensions).copy(location=location) for location, dimensions in place_standardized(count=96, boundary_height=44, item_height=40) ], ) ], ) ``` More complex troughs can have multiple containers, as with "12-column troughs" or "8-row troughs". For consistency across these labware types, troughs' containers are accessed like `trough.containers[0]`, even if there is only one container (i.e. the standard trough above, the most common case). ## Lids Since labware 0.29.0, troughs are liddable — they share the same lid API as plates. A `Lid` can be placed at construction, queried, removed, and put back: ```python from unitelabs.labware import StandardTrough from unitelabs.labware.plates import StandardLid # Place a lid at construction trough = StandardTrough(lid=StandardLid()) trough.has_lid # True trough.get_lid() # the StandardLid instance # Remove the lid (returns it) and put it back lid = trough.open() trough.has_lid # False trough.close(lid) trough.has_lid # True ``` A trough has no lid by default (`StandardTrough().has_lid` is `False`). When a lid is on, the trough's `height` accounts for it: `dimensions.z + lid.dimensions.z - lid.fitting_depth` (the `fitting_depth` is the overlap between lid and trough). Because `StandardTrough` and standard plates both use `StandardMicroplateDimensions`, the same `StandardLid` fits either, and a single lid instance can be moved between them: ```python from unitelabs.labware.plates import Standard96Plate lid = StandardLid() plate = Standard96Plate(lid=lid) trough = StandardTrough() # Reassign the lid from the plate to the trough plate.reassign(to=trough, lid=lid) plate.has_lid # False trough.has_lid # True ``` # Carriers and Adapters ## Adapters Sometimes labware is not placed directly into a carrier site, but onto an adapter. An adapter always modifies the position of the labware it holds, and can provide additional functions like magnetic separation or temperature control. In this example, we build a magnetic plate adapter — specifically the [Magnum FLX® adapter](https://www.alpaqua.com/product/magnum-flx/){rel=""nofollow,noopener""} from Alpaqua: ```python import collections.abc import dataclasses from unitelabs.labware import Adapter, Decimal, Labware, Spot, StandardMicroplateDimensions, Vector @dataclasses.dataclass class PLT_ADP_MAG_MAGNUM_FLX( Adapter[Spot[StandardMicroplateDimensions], StandardMicroplateDimensions], StandardMicroplateDimensions, Labware ): """Magnum FLX® with Solid-Core™ Technology magnetic plate by Alpaqua.""" height: Decimal = dataclasses.field(default=Decimal(default=28.55)) children: collections.abc.Sequence[Spot[StandardMicroplateDimensions]] = dataclasses.field( repr=False, default_factory=lambda: [Spot(dimensions=Vector(x=127.76, y=85.48), location=Vector(x=0, y=0, z=28.55))], ) ``` The `Adapter` class is generic. We parameterize it to specify two things: 1. **The Spot type**: the kind of spot the adapter itself fits into (e.g., `Spot[StandardMicroplateDimensions]`) 2. **The placeable type**: what the adapter can hold (e.g., `StandardMicroplateDimensions`) Multiple inheritance also applies: - `StandardMicroplateDimensions`: defines it as having a standard footprint, so it can be placed on standard carriers - `Labware`: marks it as a physical resource managed by the library ## Carriers Carriers are resources that hold other labware on the deck. They are defined with a set of `CarrierSite` slots, each at a specific position relative to the carrier origin. ::callout{icon="i-heroicons-information-circle"} The dimension examples below are specific to **Hamilton carriers**. If building for a different vendor, measure your carrier directly or consult its technical specification. :: ### Plate Carriers A plate carrier holds labware of the `Plate` type (anything with `StandardMicroplateDimensions`). The carrier sites are 2-dimensional planes — they define x and y position only; z is the height of the carrier. The plate carrier in this example has the following dimensions: - Rows: 5 - Length, width, height (mm): 157.5 mm, 497.0 mm, 130.0 mm - Carrier site dimensions (mm): 127.0 mm, 86.0 mm - Distance between carrier sites (mm): 96 mm ```python import collections.abc import dataclasses from unitelabs.labware import CarrierSite, Orientation, StandardMicroplateDimensions, Vector from unitelabs.labware.hamilton import HamiltonCarrier, LabwareType @dataclasses.dataclass class PLT_CAR_L5FLEX_MD_A00(HamiltonCarrier[StandardMicroplateDimensions]): rows: int = 5 dimensions: Vector = dataclasses.field(default_factory=lambda: Vector(x=157.5, y=497.0, z=130.0)) labware: LabwareType = LabwareType.PLATES orientation: Orientation = Orientation.LANDSCAPE children: collections.abc.Sequence[CarrierSite] = dataclasses.field( repr=False, default_factory=lambda: [ CarrierSite(dimensions=Vector(x=127.0, y=86.0)).copy(location=Vector(x=15.25, y=y, z=115.8)) for y in [392.5, 296.5, 200.5, 104.5, 8.5] ], ) ``` ### Tip Carriers Tip carriers are built analogously to plate carriers, except that they can only hold labware of the type `TipRack`. The tip carrier in this example has the following dimensions: - Rows: 5 - Length, width, height (mm): 135.0 mm, 497.0 mm, 130.0 mm - Carrier site dimensions (mm): 122.4 mm, 82.6 mm - Carrier site 1 offsets (mm): 6.2 mm, 10.0 mm, 114.95 mm - Distance between carrier sites (mm): 96 mm ```python import collections.abc import dataclasses from unitelabs.labware import CarrierSite, Orientation, TipRack, Vector from unitelabs.labware.hamilton import HamiltonCarrier, LabwareType @dataclasses.dataclass class TIP_CAR_480_A00(HamiltonCarrier[TipRack]): dimensions: Vector = dataclasses.field(default_factory=lambda: Vector(x=135.0, y=497.0, z=130.0)) labware: LabwareType = LabwareType.TIPS orientation: Orientation = Orientation.LANDSCAPE children: collections.abc.Sequence[CarrierSite] = dataclasses.field( repr=False, default_factory=lambda: [ CarrierSite(dimensions=Vector(x=122.4, y=82.6)).copy(location=Vector(x=6.2, y=y, z=114.95)) for y in [394.0, 298.0, 202.0, 106.0, 10.0] ], ) ``` ### Trough Carriers Trough carriers are built analogously to plate carriers, except that they can only hold labware of the type `Trough`. The trough carrier in this example has the following dimensions: - Rows: 5 - Length, width, height (mm): 22.5 mm, 497.0 mm, 93.0 mm - Carrier site dimensions (mm): 20.0 mm, 89.9 mm - Carrier site 1 offsets (mm): 1.2 mm, 6.0 mm, 63.2 mm - Distance between carrier sites (mm): 96 mm ```python import collections.abc import dataclasses from unitelabs.labware import CarrierSite, Orientation, Vector from unitelabs.labware.hamilton import HamiltonCarrier, LabwareType, RGT_CONT_50ml @dataclasses.dataclass class RGT_CAR_5R_A00(HamiltonCarrier[RGT_CONT_50ml]): dimensions: Vector = dataclasses.field(default_factory=lambda: Vector(x=22.5, y=497, z=82)) labware: LabwareType = LabwareType.REAGENT orientation: Orientation = Orientation.PORTRAIT children: collections.abc.Sequence[CarrierSite] = dataclasses.field( repr=False, default_factory=lambda: [ CarrierSite( dimensions=Vector(x=20, y=90), location=Vector(x=1.25, y=390.5, z=18.5), ) for y in [390.5, 294.5, 198.5, 102.5, 6.5] ], ) ``` # Labware This guide explains how to import standard labware and how to use the labware library to define custom labware. UniteLabs Labware library provides a set of standard labware that can be used in your protocols. When the standard labware library does not cover your specific equipment, you can define your own. Custom labware is defined as Python dataclasses using base classes from `unitelabs.labware`; the result plugs directly into the deck and liquid handler API with no special handling. Before building custom labware, check [Standard Labware](https://docs.unitelabs.io/operate/guides/labware/standard-labware/). Your plate, carrier, or tip type may already exist. ## What you can define | Type | Base class | Use for | | -------------- | ----------------- | --------------------------------------------------- | | **Plates** | `Plate` | Microplates of any well count or geometry | | **Tips** | `HamiltonTip` | Custom tip types for Hamilton channels | | **Tip Racks** | `TipRack` | Racks holding custom tips | | **Tubes** | `Tube` | Standalone tubes (Eppendorf, Falcon, etc.) | | **Tube Racks** | `TubeRack` | Racks holding tubes | | **Troughs** | `Trough` | Reagent reservoirs | | **Carriers** | `HamiltonCarrier` | Plate, tip, or trough carriers for the deck | | **Adapters** | `Adapter` | Position-modifying adapters (magnetic, temperature) | ## Guides - [Plates](https://docs.unitelabs.io/operate/guides/labware/plates/): standard and non-standard microplates - [Tips and Tip Racks](https://docs.unitelabs.io/operate/guides/labware/tips-and-racks/): custom tip types and tip racks - [Tubes and Tube Racks](https://docs.unitelabs.io/operate/guides/labware/tubes-and-tube-racks/): tubes and tube racks - [Troughs](https://docs.unitelabs.io/operate/guides/labware/troughs/): reagent reservoirs - [Carriers and Adapters](https://docs.unitelabs.io/operate/guides/labware/carriers-and-adapters/): deck carriers and plate adapters - [Standard Labware](https://docs.unitelabs.io/operate/guides/labware/standard-labware/): browse and import pre-built labware # Building a Deck See [Deck](https://docs.unitelabs.io/operate/concepts/deck/) for the coordinate model and resource tree. This guide covers the procedural building blocks. For device-specific origin details (where the zero point falls relative to the physical deck), see the device guides — e.g. [Positioning and movement](https://docs.unitelabs.io/operate/devices/hamilton-star/positioning/). ## Defining a Deck Decks have a dimension that is constrained by the liquid handler space. When importing the deck directly as shown below, default constraints are applied. ```python from unitelabs.liquid_handling.hamilton import HamiltonDeck deck = HamiltonDeck() print(deck.summary()) ``` Which returns: ```text Tracks Name ID Capacity Content X-Range Height ====================================================================================== 1 - 30 ── ``` However, if the deck is created automatically using the specific liquid handler class, the deck dimensions are pre-configured for that particular liquid handler model. ```python from unitelabs.liquid_handling.hamilton import MicrolabSTAR # Initialize the Hamilton Microlab STAR hamilton = MicrolabSTAR(name="Microlab STAR") await hamilton.initialize() print(hamilton.deck.summary()) ``` ## Adding Labware Now we can start and add our first carrier. We will use a landscape tip carrier with 5 slots for tips racks. The location of the carrier on the deck is provided in absolute coordinates and passed using the Vector class. We place the carrier starting on rail 16. ```python from unitelabs.liquid_handling.hamilton import HamiltonDeck from unitelabs.labware import Vector from unitelabs.labware.hamilton import TIP_CAR_480_A00 deck = HamiltonDeck() tip_carrier = TIP_CAR_480_A00(identifier="tip_carrier") deck.add(tip_carrier, track=16) # Alternatively the exact location can be set # deck.add(tip_carrier, location=Vector(x=(16 - 1) * 22.5, y=63, z=0)) print(deck.summary()) ``` Now our deck looks like this: ```text Tracks Name ID Capacity Content X-Range Height ====================================================================================== 1 - 15 ┌─ 16 - 21 ├─ TIP_CAR_480_A00 tip_carrier 0 tips 0 tips 337.5 - 472.5 130.0 │ ├─ │ ├─ │ ├─ │ ├─ │ └─ 22 - 30 ``` ::callout{color="amber" icon="i-heroicons-light-bulb" target="_blank"} **Dynamic max deck size and deck dimension**: When using a dedicated liquid handler class, like the `MicrolabSTAR` class, a deck is autogenerated and dynamically configured during the initialization process. :: We can add more labware to the carrier like a pre-defined Hamilton tip rack and Hamilton standard filter tips. In the following code we first instantiate the *HamiltonTipRack\_300*, then fill it with *HamiltonTip\_300\_Filter* and then set the tip rack into the second slot (from behind) of the tip carrier we created earlier. The *tip\_carrier* object is linked to the deck. ```python from unitelabs.labware.hamilton import HamiltonTip_300_Filter, HamiltonTipRack_300 tip_rack = HamiltonTipRack_300(filled_with=HamiltonTip_300_Filter) tip_carrier[1] = tip_rack print(deck.summary()) ``` Printing the deck summary shows that the tip rack and the tips were added successfully. ```text Tracks Name ID Capacity Content X-Range Height ====================================================================================== 1 - 15 ┌─ 16 - 21 ├─ TIP_CAR_480_A00 tip_carrier 96 tips 96 tips 337.5 - 472.5 135.0 │ ├─ │ ├─ HamiltonTipRack_300 e3e3a74b 96 tips 96 tips │ ├─ │ ├─ │ └─ 22 - 30 └─ ``` Adding plates can be done in the same way as adding racks. We will import a plate carrier and add a standard plate to it. We place the carrier starting on rail 10: ```python from unitelabs.labware.hamilton import TIP_CAR_480_A00, PLT_CAR_L5MD_A00 from unitelabs.labware.corning_costar import Cos_96_FB plate_1 = Cos_96_FB() plate_carrier = PLT_CAR_L5MD_A00(identifier="plate_carrier") plate_carrier[1] = plate_1 deck.add(plate_carrier, track=10) print(deck.summary()) ``` ```text Tracks Name ID Capacity Content X-Range Height ====================================================================================== 1 - 9 ┌─ 10 - 15 ├─ PLT_CAR_L5MD_A00 plate_carrier 5 plates 1 plates 202.5 - 337.5 130.0 │ ├─ │ ├─ Cos_96_FB a9e7efb0 34560 µl 0 µl │ ├─ │ ├─ │ └─ 16 - 21 ├─ TIP_CAR_480_A00 tip_carrier 96 tips 96 tips 337.5 - 472.5 135.0 │ ├─ │ ├─ HamiltonTipRack_300 e3e3a74b 96 tips 96 tips │ ├─ │ ├─ │ └─ 22 - 30 └─ ``` It is often required to create custom labware. See the [custom labware guide](https://docs.unitelabs.io/operate/guides/labware/) for details. ## Hamilton Deck Bounds By default, the deck enforces track boundaries that match the physical deck size of your liquid handler. However, some workflows require placing carriers on negative tracks or beyond the maximum track, for example, storing tips below the minimum track of a Hamilton STAR CO-RE 96 access. Attempting to add a carrier outside the default track bounds will raise an error. To allow out-of-bounds placement, remove the deck limitation by configuring the minimum and/or maximum track to `None`: ```python await hamilton.deck.configure(min_track=None, max_track=None) ``` You can also remove only one limit. For example, to allow only negative tracks: ```python await hamilton.deck.configure(min_track=None) ``` ::callout{color="red" icon="i-heroicons-shield-exclamation"} Carriers placed outside of the standard deck bounds may define positions that are not reachable by all modules. For instance, pipetting channels may not be able to reach positions outside the track area, while the CO-RE 96 can. Always verify that the intended module can physically reach the carrier positions you define. :: A common use case is placing a tip carrier in the negative track area of a Hamilton STAR, where tips are accessible by the CO-RE 96 but not by individual pipetting channels: ```python from unitelabs.liquid_handling.hamilton import MicrolabSTAR from unitelabs.labware.hamilton import TIP_CAR_480_A00 hamilton = MicrolabSTAR(name="Microlab STAR") await hamilton.initialize() # Remove the minimum track limit to allow negative tracks await hamilton.deck.configure(min_track=None) # Place the tip carrier starting at track -3 hamilton.deck.add(TIP_CAR_480_A00(), track=-3) print(hamilton.deck.summary()) ``` ### Custom Carrier at an Absolute Position Another use case is placing a custom carrier at specific x, y, z coordinates that lie outside the standard deck bounds. These coordinates are typically measured or taught, for example using the iSWAP (see [Training a Custom Deck Position](https://docs.unitelabs.io/operate/devices/hamilton-star/custom-deck-position/)): ```python from unitelabs.labware import Vector # Remove deck track limits await hamilton.deck.configure(min_track=None, max_track=None) # Carrier coordinates, measured or taught (e.g. by iSWAP) CARRIER_X = -53.7 CARRIER_Y = 89.0 CARRIER_Z = 209.3 custom_carrier = CustomCarrier(identifier="custom_carrier") # Add the carrier at an absolute position, offset by the deck origin deck_location = hamilton.deck.location or Vector(x=0, y=0, z=0) hamilton.deck.add( labware=custom_carrier, location=Vector(x=CARRIER_X, y=CARRIER_Y, z=CARRIER_Z) - deck_location, ) ``` ## Serializing and Deserializing Decks Deck serialization allows you to save and restore deck configurations, enabling several useful workflows: - **Sharing deck layouts** across different workflows and team members - **Persisting deck state** between sessions for reproducibility - **Version control** for deck configurations using JSON files - **Creating templates** for common deck setups The SDK provides four methods for working with deck serialization: converting decks to/from JSON-compatible Python dictionaries and saving/loading decks to/from JSON files. ### Converting Deck to Dictionary Use the `to_json()` method to convert a deck configuration to a JSON-compatible Python dictionary: ```python from unitelabs.liquid_handling.hamilton import MicrolabSTAR hamilton = MicrolabSTAR(name="Microlab STAR") await hamilton.initialize() # Configure your deck with carriers and labware # ... (add carriers, tip racks, plates, etc.) # Convert deck to JSON-compatible dictionary deck_dict = hamilton.deck.to_json() ``` The returned dictionary contains all deck information including: - Deck dimensions and constraints - All installed carriers and their positions - Nested labware structures (carriers → racks → tips/plates) - Module lifecycle stages and configuration ### Creating Deck from Dictionary Use the `from_json()` class method to create a deck from a JSON-compatible Python dictionary: ```python from unitelabs.liquid_handling.hamilton.modules import HamiltonDeck # Load deck from dictionary restored_deck = HamiltonDeck.from_json(deck_dict) print(restored_deck.summary()) ``` This creates a fully configured deck instance that preserves: - Track index mapping - Module lifecycle stages (INSTALLED, CONFIGURED, INITIALIZED, ACTIVE) - All nested labware structures with complete fidelity ### Saving Deck to File Use the `save()` method to persist a deck configuration to a JSON file: ```python from pathlib import Path # Save to file (accepts str or pathlib.Path) hamilton.deck.save("my_deck_layout.json") # Or using pathlib deck_path = Path("deck_layouts/standard_setup.json") hamilton.deck.save(deck_path) ``` The JSON file is saved with indentation for readability and can be committed to version control systems. ### Loading Deck from File Use the `load()` class method to restore a deck from a JSON file: ```python from unitelabs.liquid_handling.hamilton.modules import HamiltonDeck # Load deck from file loaded_deck = HamiltonDeck.load("my_deck_layout.json") print(loaded_deck.summary()) ``` # Save/Load a Deck The rationale for saving deck layouts — audit trail, reproducibility, version control — is covered in [Deck](https://docs.unitelabs.io/operate/concepts/deck/). This guide covers the procedural API. ## Saving a Deck ### Save to a JSON file Call `save()` on any deck object. It writes an indented JSON file at the path you specify, accepting either a string or a `pathlib.Path`. ```python from unitelabs.liquid_handling.hamilton import MicrolabSTAR hamilton = MicrolabSTAR(name="Microlab STAR") await hamilton.initialize() # ... add carriers and labware ... hamilton.deck.save("deck_layouts/my_deck.json") ``` The resulting JSON file captures the full deck configuration: every carrier, its track position, and all nested labware with their identifiers and state. The file is human-readable and safe to commit to version control. ::callout{color="amber" icon="i-heroicons-light-bulb"} Commit your deck JSON files alongside your workflow scripts. This gives you a full audit trail of which physical deck layout was used for each experiment. :: ### What the JSON looks like A saved deck file has this structure: ```json { "description": "STARlet deck for Plate Painter Pro", "carriers": [ { "id": "tip_carrier", "model": "TIP_CAR_480_A00", "track": 15, "labware": { "0": { "model": "HamiltonTipRack_300", "id": "tip_rack", "filled_with": "HamiltonTip_300_Filter" } } }, { "id": "reservoir_carrier", "model": "PLT_CAR_L5AC_A00", "track": 11, "labware": { "0": { "model": "StandardTrough", "id": "reservoir_ice", "volume_ul": 200000 }, "1": { "model": "StandardTrough", "id": "reservoir_orange", "volume_ul": 200000 } } }, { "id": "plate_carrier", "model": "PLT_CAR_L5AC_A00", "track": 7, "labware": { "0": { "model": "Standard96Plate", "id": "wellplate" } } } ] } ``` ### Export to a Python script If you prefer a deck definition that is readable as code rather than a data file, you can represent the same layout as a Python script that builds it programmatically. This is also the format shown in [Building a Deck](https://docs.unitelabs.io/operate/guides/deck/building-a-deck/). ```python from unitelabs.liquid_handling.hamilton import HamiltonDeck from unitelabs.labware.hamilton import ( TIP_CAR_480_A00, PLT_CAR_L5AC_A00, HamiltonTipRack_300, HamiltonTip_300_Filter, StandardTrough, Standard96Plate, ) deck = HamiltonDeck() tip_carrier = TIP_CAR_480_A00(identifier="tip_carrier") tip_carrier[0] = HamiltonTipRack_300(identifier="tip_rack", filled_with=HamiltonTip_300_Filter) deck.add(tip_carrier, track=15) reservoir_carrier = PLT_CAR_L5AC_A00(identifier="reservoir_carrier") reservoir_carrier[0] = StandardTrough(identifier="reservoir_ice", volume_ul=200_000) reservoir_carrier[1] = StandardTrough(identifier="reservoir_orange", volume_ul=200_000) deck.add(reservoir_carrier, track=11) plate_carrier = PLT_CAR_L5AC_A00(identifier="plate_carrier") plate_carrier[0] = Standard96Plate(identifier="wellplate") deck.add(plate_carrier, track=7) ``` The Python script form is useful when you want deck changes to produce meaningful diffs in code review. The JSON form is more convenient for loading at runtime. Both work well together: use the Python script as the authoritative source and regenerate the JSON from it when needed. ## Loading a Deck ### Load into a new deck object Use `HamiltonDeck.load()` to create a fully configured deck object from a saved JSON file. This is useful when you want to inspect or manipulate the deck before attaching it to a liquid handler. ```python from unitelabs.liquid_handling.hamilton.modules import HamiltonDeck deck = HamiltonDeck.load("deck_layouts/my_deck.json") print(deck.summary()) ``` ### Load in-place into a liquid handler Use `lh.deck.load()` to load a deck configuration directly into an already-configured liquid handler. This is the most common pattern in workflow scripts: configure the instrument, load the saved deck layout, then initialize. ```python from unitelabs.liquid_handling.hamilton import MicrolabSTARMock hamilton = MicrolabSTARMock(name="Microlab STAR") await hamilton.configure() hamilton.deck.load("deck_layouts/my_deck.json") await hamilton.initialize() print(hamilton.deck.summary()) ``` ::callout{icon="i-heroicons-information-circle"} Call `lh.deck.load()` after `configure()` but before `initialize()`. This ensures the instrument initializes with the correct layout already applied. :: ### Reconstructing from a dictionary If you have a deck serialized as a Python dictionary (for example, fetched from a database or returned by an API) use `HamiltonDeck.from_json()`: ```python from unitelabs.liquid_handling.hamilton.modules import HamiltonDeck # deck_dict could come from a database, an API response, etc. deck = HamiltonDeck.from_json(deck_dict) ``` To get the dictionary from a live deck, use `deck.to_json()`: ```python deck_dict = hamilton.deck.to_json() ``` ## Bundling Deck Configs with Workflow Code For production workflows, keep your deck JSON files inside your workflow package rather than relying on filesystem paths that may differ between machines. A simple helper resolves the path relative to the package itself. **Project layout:** ```text my_workflow/ ├── __init__.py ├── library/ │ ├── __init__.py ← path helper lives here │ └── standard_deck.json ← deck config bundled with the code ├── phase_01_initialization.py └── phase_02_run.py ``` **`my_workflow/library/__init__.py`:** ```python from pathlib import Path _LIBRARY_DIR = Path(__file__).parent def get_library_path(filename: str) -> str: path = _LIBRARY_DIR / filename if not path.exists(): raise FileNotFoundError(f"Library file not found: {path}") return str(path) ``` **`my_workflow/phase_01_initialization.py`:** ```python from unitelabs.liquid_handling.hamilton import MicrolabSTARMock from my_workflow.library import get_library_path async def initialize_workcell() -> MicrolabSTARMock: hamilton = MicrolabSTARMock(name="Microlab STAR") await hamilton.configure() deck_config_path = get_library_path("standard_deck.json") hamilton.deck.load(deck_config_path) await hamilton.initialize() return hamilton ``` ::callout{color="amber" icon="i-heroicons-light-bulb"} Bundling deck configs inside your Python package means the correct layout is always co-located with the workflow that uses it. When you deploy or share the workflow, the deck definition travels with it. :: ## Quick Reference | Method | What it does | | -------------------------------- | -------------------------------------------------------- | | `deck.save("path.json")` | Write the current deck to a JSON file | | `deck.to_json()` | Return the deck as a Python dictionary | | `HamiltonDeck.load("path.json")` | Create a new deck object from a JSON file | | `HamiltonDeck.from_json(dict)` | Create a new deck object from a dictionary | | `lh.deck.load("path.json")` | Load a deck configuration in-place into a liquid handler | ## Next Steps - [Building a Deck](https://docs.unitelabs.io/operate/guides/deck/building-a-deck/): define a deck layout from scratch - [Basic Pipetting](https://docs.unitelabs.io/operate/guides/pipetting/basic-pipetting/): attach a deck to a liquid handler and run operations # Basic Pipetting In this guide a source reservoir, a destination plate, and a tip rack are set up on the deck. Tips are picked up, liquid is aspirated from the source, dispensed into the destination, and tips are discarded. **Prerequisites** - A running liquid handler connector connected to the UniteLabs platform - A tip rack, a source container (trough or reservoir), and a destination plate - Basic understanding of tip handling (See [Tip Handling](https://docs.unitelabs.io/operate/guides/pipetting/tip-handling/)) ## Connect to the Device ::tabs :::div{label="Hamilton"} ```python from unitelabs.sdk import AsyncApiClient from unitelabs.liquid_handling.hamilton import MicrolabSTAR client = AsyncApiClient() hamilton = MicrolabSTAR( name="Microlab STAR", client=client, ) await hamilton.initialize() ``` ::: :::div{label="Bravo"} ```python from unitelabs.sdk import AsyncApiClient from unitelabs.liquid_handling.agilent import Bravo client = AsyncApiClient() bravo = Bravo( name="Bravo", client=client, ) await bravo.configure() await bravo.initialize() await bravo.activate() ``` ::: :: ## Arrange the Deck ::tabs :::div{label="Hamilton"} Hamilton uses carriers placed at track positions. This example uses a tip carrier, a plate carrier with a 96-well plate, and a trough filled with water. ```python from unitelabs.labware import PredefinedLiquids, Standard96Plate, StandardTrough from unitelabs.labware.hamilton import ( PLT_CAR_L5MD_A00, TIP_CAR_480_A00, HamiltonTip_300, HamiltonTipRack_300, ) # Tips tip_carrier = TIP_CAR_480_A00() tip_rack = HamiltonTipRack_300(filled_with=HamiltonTip_300) tip_carrier[0] = tip_rack hamilton.deck.add(tip_carrier, track=7) # Source and destination plate_carrier = PLT_CAR_L5MD_A00() plate = Standard96Plate() trough = StandardTrough() trough.containers[0].add_liquid(PredefinedLiquids.WATER, 150_000) plate_carrier[0] = plate plate_carrier[1] = trough hamilton.deck.add(plate_carrier, track=1) ``` ::: :::div{label="Bravo"} The Bravo deck has nine numbered locations. Place each labware type at a location. ```python from unitelabs.labware.agilent.tips import AgilentTipRack_250, AgilentTip_250 from unitelabs.labware.plates import Standard96Plate from unitelabs.labware.agilent.troughs import Agilent_DW_Reservoir from unitelabs.labware.liquids import PredefinedLiquids tip_rack = AgilentTipRack_250(identifier="TipRack_96LT_250uL") tip_rack.fill(AgilentTip_250) source_reservoir = Agilent_DW_Reservoir(identifier="SourceReservoir_300mL") source_reservoir.container.add_liquid(PredefinedLiquids.WATER, 100_000.0) dest_plate = Standard96Plate(identifier="DestinationPlate_96Well") bravo.deck.add(tip_rack, location=1) bravo.deck.add(source_reservoir, location=4) bravo.deck.add(dest_plate, location=5) ``` ::: :: ## Pick Up Tips ::tabs :::div{label="Hamilton"} ```python await hamilton.pipettes.pick_up_tips_from(channels=range(8), rack=tip_rack) ``` ::: :::div{label="Bravo"} ```python await bravo.pipette_head.pick_up_tips_from(rack=tip_rack, press_depth=5.8) ``` ::: :: ## Aspirate ::tabs :::div{label="Hamilton"} Aspirate using a predefined liquid class that controls flow rates and volume correction. ```python from unitelabs.labware.hamilton import LiquidClass liquid_class = LiquidClass.HamiltonTip_300_Water_DispenseJet_Empty() await hamilton.pipettes.aspirate( source=trough, channels=range(8), volume=100, liquid_class=liquid_class, ) ``` ::::callout{icon="i-heroicons-light-bulb"} Check the current volume held in a channel after aspiration: ```python current_volume = await hamilton.pipettes[0].current_volume() print(f"{current_volume} µl") # e.g. 104.9 µl ``` :::: ::: :::div{label="Bravo"} Aspirate without specifying a liquid class; the SDK auto-selects one based on tip type and volume. ```python await bravo.pipette_head.aspirate( plate=source_reservoir, volume=100, ) ``` To use a specific liquid class, pass it explicitly: ```python from unitelabs.labware.agilent import BravoLiquidClasses liquid_classes = BravoLiquidClasses() await bravo.pipette_head.aspirate( plate=source_reservoir, volume=100, liquid_class=liquid_classes.OQ_96LT_water_highVol, ) ``` ::: :: ## Dispense ::tabs :::div{label="Hamilton"} ```python await hamilton.pipettes.dispense( target=plate["A1":"H1"], channels=range(8), volume=100, liquid_class=liquid_class, ) ``` ::: :::div{label="Bravo"} ```python await bravo.pipette_head.dispense( plate=dest_plate, volume=100, ) ``` ::: :: ## Discard Tips ::tabs :::div{label="Hamilton"} ```python await hamilton.pipettes.discard_tips(channels=range(8)) ``` ::: :::div{label="Bravo"} ```python # Discard to empty location 3 await bravo.pipette_head.discard_tips(bravo.deck[3]) ``` ::: :: # Advanced Pipetting Parameter sets let you group, validate, and reuse pipetting parameters as Python dataclasses. Instead of passing many keyword arguments to every `aspirate` or `dispense` call, you define the parameters once, optionally validate them before running, and pass the object to the pipetting method. Both Hamilton and Bravo support parameter sets, though the feature depth differs. Hamilton's CO-RE 96 parameter sets include a full context/validation system and nested sub-parameter sets. Bravo's are simpler, covering the most common options. **Prerequisites** - Basic understanding of pipetting (See [Basic Pipetting](https://docs.unitelabs.io/operate/guides/pipetting/basic-pipetting/)) ## Creating and Using a Parameter Set ::tabs :::div{label="Hamilton CoRe96"} ```python from unitelabs.labware.hamilton.liquids import LiquidClass from unitelabs.liquid_handling.hamilton.interfaces import LLDMode from unitelabs.liquid_handling.modules import PipetteMode from unitelabs.liquid_handling.hamilton.modules.core96 import ( CoRe96AspirateParameterSet, CoRe96DispenseParameterSet, ) # Build a parameter set explicitly aspirate_params = CoRe96AspirateParameterSet( volume=150, lld_mode=LLDMode.OFF, pipette_mode=PipetteMode.BOTTOM, liquid_offset=1.5, ) # Or use a factory method aspirate_params = CoRe96AspirateParameterSet.simple_bottom_mode( volume=150, liquid_offset=1.5, ) # Pass to the pipetting method await hamilton.core96.aspirate(trough, aspirate_params) await hamilton.core96.dispense(plate, CoRe96DispenseParameterSet(volume=150)) ``` ::: :::div{label="Bravo"} ```python from unitelabs.liquid_handling.agilent.interfaces.parameters import ( BravoAspirateParameterSet, BravoDispenseParameterSet, ) from unitelabs.liquid_handling.modules import PipetteMode aspirate_params = BravoAspirateParameterSet( volume=100, pipette_mode=PipetteMode.SURFACE, liquid_offset=0.0, ) dispense_params = BravoDispenseParameterSet( volume=100, pipette_mode=PipetteMode.BOTTOM, liquid_offset=3.0, ) await bravo.pipette_head.aspirate(source_reservoir, aspirate_params) await bravo.pipette_head.dispense(dest_plate, dispense_params) ``` For the full Bravo parameter reference see [Bravo Basic Pipetting](https://docs.unitelabs.io/operate/devices/agilent-bravo/basic-pipetting/). ::: :: ## Device-Independent Parameter Sets You do not have to build a device-specific parameter set. The base `AspirateParameterSet` and `DispenseParameterSet` can be passed to any device's `aspirate` or `dispense`; the set is adopted onto that device's class, carrying over equivalent fields. A field the device cannot represent is rejected with a `ValueError` rather than silently dropped, so the same set can be reused across instruments. ```python from unitelabs.liquid_handling.modules.parameters import ( AspirateParameterSet, DispenseParameterSet, ) from unitelabs.liquid_handling.modules import AspiratePipetteMode # Define once, device-independent aspirate_params = AspirateParameterSet( volume=100, pipette_mode=AspiratePipetteMode.SURFACE, liquid_offset=1.0, ) # Pass the same set to either device — each adopts it onto its own class await hamilton.core96.aspirate(trough, aspirate_params) await bravo.pipette_head.aspirate(source_reservoir, aspirate_params) ``` ## Serialization Parameter sets can be saved to and loaded from plain dictionaries, making them easy to store in configuration files or databases. ```python # Dump to a dictionary saved = aspirate_params.dumps() print(saved) # {'volume': 150, 'lld_mode': 'OFF', 'pipette_mode': 'BOTTOM', 'liquid_offset': 1.5} # Load from a dictionary (string values for enum fields) loaded = CoRe96AspirateParameterSet.loads(saved) assert loaded == aspirate_params ``` ## Copy and Update ```python # Copy copy = aspirate_params.copy() # Update (modifies in place, also returns the updated set) aspirate_params.update( CoRe96AspirateParameterSet(liquid_offset=2.0) ) # Update only missing fields aspirate_params.update( CoRe96AspirateParameterSet(liquid_offset=2.0), missing_only=True, ) ``` --- ## Hamilton: Context & Validation ::callout{icon="i-heroicons-information-circle"} This section is Hamilton-specific. The context system validates parameter sets against live liquid handler state (tip volume, container fill level, module configuration) before a run. :: A context captures the current state of the module and target labware. `validate` checks the parameter set against the context without running; `compile` resolves all `MISSING` fields into concrete values. ```python from unitelabs.liquid_handling.hamilton.modules.core96 import CoRe96Context context = CoRe96Context(module=hamilton.core96, target=plate) # Explicit validate + compile (optional — done automatically inside aspirate/dispense) assert aspirate_params.validate(context) compiled = aspirate_params.compile(context) ``` ### Missing Fields Omit any field to let the context fill it in at compile time. For example, `volume` defaults to the minimum of the tip's free volume and the container's available volume. ```python # Fully default parameter set — all fields resolved from context default_aspiration = CoRe96AspirateParameterSet() await hamilton.core96.aspirate(trough, default_aspiration) ``` Always provide explicit values for fields that must stay constant across different deck states. ### Previewing Resolved Defaults `compile` returns the low-level dictionary sent to the instrument. To see how your inputs resolve *as a parameter set* — without running anything — use `show_defaults`. It returns a copy with every `MISSING` field replaced by the value it would default to for the given context, and passes fields you set explicitly through their resolver so you see the effective value. ```python context = CoRe96Context(module=hamilton.core96, target=plate) params = CoRe96AspirateParameterSet(volume=150) resolved = params.show_defaults(context) # `volume` was set explicitly, so it comes back unchanged. assert resolved.volume == 150 # `liquid_offset` was MISSING; the result shows what the context inferred. print(resolved.liquid_offset) ``` Compare a field between the original and the result to tell whether a value was inferred: a field that is `MISSING` before but populated after was defaulted. Resolution recurses into nested parameter sets, so `mixing` and `tadm` are resolved too. For example, a `MixingParameterSet` with no `cycles` reports the effective `cycles` (`0`, i.e. no mixing) rather than `MISSING`: ```python from unitelabs.labware import MISSING aspiration = CoRe96AspirateParameterSet(mixing=MixingParameterSet(volume=50)) # Before: cycles is unset. assert aspiration.mixing.cycles is MISSING # After: cycles reports its effective value (0 = no mixing). resolved = aspiration.show_defaults(context) assert resolved.mixing.cycles == 0 ``` `show_defaults` emits the same warnings as `compile`; capture them with `warnings.catch_warnings` if you want to inspect them. The original set is never mutated — the result is always a fresh copy of the same type. ### Nested Parameter Sets Mixing and TADM settings are nested inside aspirate/dispense parameter sets. ```python from unitelabs.liquid_handling.hamilton.modules.core96 import ( MixingParameterSet, TADMParameterSet, TADMMode, ) mix_3x50 = MixingParameterSet(volume=50, cycles=3) aspiration = CoRe96AspirateParameterSet( volume=150, mixing=mix_3x50, ) dispense = CoRe96DispenseParameterSet( volume=150, mixing=mix_3x50, tadm=TADMParameterSet(tadm_mode=TADMMode.ON, tadm_limit_curve=1), ) await hamilton.core96.aspirate(trough, aspiration) await hamilton.core96.dispense(plate, dispense) ``` --- ## Hamilton: Channels Parameter Sets ::callout{icon="i-heroicons-information-circle"} This section is Hamilton-specific. Channels parameter sets represent operations across multiple independent pipetting channels simultaneously. :: Each channel can have a different volume, liquid offset, or mixing step. Think of a channels parameter set as a list of per-channel sub-parameter sets. ```python from unitelabs.liquid_handling.hamilton.modules.pipettes import ChannelsAspirateParameterSet aspirate_channels = ChannelsAspirateParameterSet( channels=[0, 2, 4, 6], # zero-based channel indices volume=[100, 200, 300, 400], # one value per channel liquid_offset=1.5, # single value → applied to all channels lld_mode=False, mixing=[mix_3x50, MixingParameterSet()] * 2, ) ``` Scalar fields are broadcast to all channels; list fields must match the length of `channels`. ### Channels Context ```python from unitelabs.liquid_handling.hamilton.modules.pipettes import ChannelsContext context = ChannelsContext( channels=[0, 2, 4, 6], module=hamilton.pipettes, target=[plate["A1"], plate["C1"], plate["E1"], plate["G1"]], ) assert aspirate_channels.validate(context) ``` ### Building from Sub-Parameters ```python from unitelabs.liquid_handling.hamilton.modules.pipettes import ChannelAspirateParameterSet sub_params = [ChannelAspirateParameterSet(liquid_offset=i, volume=100/i) for i in range(1, 5)] channels_aspirate = ChannelsAspirateParameterSet.from_sub_parameters( channels=[3, 4, 5, 6], sub_parameters=sub_params, ) ``` --- ## Compiled Parameters Glossary The tables below describe the dictionary keys returned by `compile()` for `CoRe96AspirateParameterSet` and `CoRe96DispenseParameterSet`. Fields marked *from liquid class* should be set on the liquid class before passing it to the parameter set. ### `CoRe96AspirateParameterSet` | Compiled key | Source | Description | | ---------------------- | ------------- | ------------------------------------- | | `volume` | parameter set | Volume in µL to aspirate | | `lld_mode` | parameter set | Liquid level detection mode | | `pipette_mode` | parameter set | `SURFACE` or `BOTTOM` positioning | | `liquid_offset` | parameter set | Z-offset from reference point (mm) | | `flow_rate` | liquid class | Plunger speed (µL/s) | | `swap_speed` | liquid class | Retract speed after aspiration (mm/s) | | `settling_time` | liquid class | Dwell time in liquid (s) | | `over_aspirate_volume` | liquid class | Pre-wetting extra volume (µL) | | `transport_air_volume` | liquid class | Air drawn after aspiration (µL) | | `blowout_air_volume` | liquid class | Pre-dispense blowout air (µL) | ### `CoRe96DispenseParameterSet` | Compiled key | Source | Description | | ---------------------- | ------------- | ------------------------------------------- | | `volume` | parameter set | Volume in µL to dispense | | `pipette_mode` | parameter set | `SURFACE` or `BOTTOM` positioning | | `liquid_offset` | parameter set | Z-offset from reference point (mm) | | `flow_rate` | liquid class | Plunger speed (µL/s) | | `swap_speed` | liquid class | Retract speed after dispense (mm/s) | | `stop_flow_rate` | liquid class | Flow rate at end of dispense (µL/s) | | `stop_back_volume` | liquid class | Air volume re-aspirated after dispense (µL) | | `transport_air_volume` | liquid class | Air drawn at end of dispense step (µL) | | `blowout_air_volume` | liquid class | Blowout air dispensed before liquid (µL) | # Tip Handling See [Tips and Tip Tracking](https://docs.unitelabs.io/operate/concepts/tips/) for the conceptual model (state machine, channel vs. 96-head, inventory tracking). This guide covers the procedural API for picking up, returning, and discarding tips across both supported liquid handlers. For deeper vendor detail see the [Hamilton](https://docs.unitelabs.io/operate/devices/hamilton-star/positioning/) or [Bravo](https://docs.unitelabs.io/operate/devices/agilent-bravo/tip-handling/) guides. **Prerequisites** - A running liquid handler connector connected to the UniteLabs platform - A tip rack loaded with tips ## Connect to the Device ::tabs :::div{label="Hamilton"} ```python from unitelabs.sdk import AsyncApiClient from unitelabs.liquid_handling.hamilton import MicrolabSTAR client = AsyncApiClient() hamilton = MicrolabSTAR(name="Microlab STAR", client=client) await hamilton.initialize() ``` ::: :::div{label="Bravo"} ```python from unitelabs.sdk import AsyncApiClient from unitelabs.liquid_handling.agilent import Bravo client = AsyncApiClient() bravo = Bravo(name="Bravo", client=client) await bravo.configure() await bravo.initialize() await bravo.activate() ``` ::: :: ## Arrange the Deck ::tabs :::div{label="Hamilton"} Hamilton uses a track-based deck. Tip racks sit inside carriers which are added at specific tracks. ```python from unitelabs.labware.hamilton import TIP_CAR_480_A00, HamiltonTip_300, HamiltonTipRack_300 tip_carrier = TIP_CAR_480_A00() tip_rack = HamiltonTipRack_300(filled_with=HamiltonTip_300) tip_carrier[0] = tip_rack hamilton.deck.add(tip_carrier, track=7) print(hamilton.deck.summary()) ``` ::: :::div{label="Bravo"} The Bravo has a 3×3 grid of nine numbered deck locations. Labware is added directly to a location. ```python from unitelabs.labware.agilent.tips import AgilentTipRack_250, AgilentTip_250 tip_rack = AgilentTipRack_250(identifier="TipRack_96LT_250uL") tip_rack.fill(AgilentTip_250) bravo.deck.add(tip_rack, location=1) ``` ::: :: ## Pick Up Tips ::tabs :::div{label="Hamilton"} To pick up next available tips from a tip rack, use the `pick_up_tips_from` method and specifying the channels to use.Channels are zero-indexed. ```python await hamilton.pipettes.pick_up_tips_from(channels=range(4), rack=tip_rack) ``` ::::callout{icon="i-heroicons-light-bulb"} If not enough tips are available, the method will raise an error: `MissingError: No next tip available. The tip rack is empty.` :::: Alternatively, specify the tip spots to use defined tip locations, for example when reusing the tips. ```python spots = tip_rack["B2":"C2"] await hamilton.pipettes.pick_up_tips(channels=[1, 2], spots=spots) ``` ::::callout{icon="i-heroicons-light-bulb"} `tip_rack["A1"]` and `tip_rack[0]` are equivalent. Inspect `tip_rack[pos].get()` to check dynamically whether a spot has a tip. :::: ::: :::div{label="Bravo"} The 96-nozzle head picks up tips as a unit. By default all 96 nozzles are active. ```python await bravo.pipette_head.pick_up_tips_from( rack=tip_rack, press_depth=5.8, ) ``` ::: :: ## Pick Up a Partial Set of Tips ::tabs :::div{label="Bravo"} Use `well_offset=(row, col)` to shift which part of the rack the head's A1 nozzle aligns with. Only nozzles that overlap with the rack pick up tips. ```python # Pick up column 12 only (head A1-H1 aligns with rack A12-H12) await bravo.pipette_head.pick_up_tips_from( rack=tip_rack, well_offset=(0, 11), press_depth=5.8, ) # Pick up a single tip from H12 (head A1 aligns with rack H12) await bravo.pipette_head.pick_up_tips_from( rack=tip_rack, well_offset=(7, 11), press_depth=5.8, ) ``` ::: :: ## Put Down Tips Return tips to the rack they were picked up from. ::tabs :::div{label="Hamilton"} Use `await hamilton.pipettes.return_tips()` to return tips to the exact location they were picked up from. ```python await hamilton.pipettes.return_tips() ``` Alternatively, specify you can return tips to the first free positions in the specific tip rack or define exact tip spots to return tips to. ```python # return tips to first empty positions in tip rack await hamilton.pipettes.put_down_tips_to(rack=tip_rack, channels=range(4)) # return tips to specific spots spots = tip_rack["A1":"E1"] await hamilton.pipettes.put_down_tips(channels=[0, 1, 2, 3], spots=spots) ``` ::: :::div{label="Bravo"} Use the same `well_offset` that was used during pickup. ```python await bravo.pipette_head.put_down_tips_to( rack=tip_rack, well_offset=(7, 11), ) ``` ::: :: ## Discard Tips ::tabs :::div{label="Hamilton"} The tip waste location is stored in the device configuration and varies between models of Hamilton robots. `discard_tips()` discards all tips to the configured waste location. The command can also be used to discard tips from specific channels. ```python # Discard all present tips await hamilton.pipettes.discard_tips() # Discard tips from specific channels await hamilton.pipettes.discard_tips(channels=[0, 1]) ``` ::::callout{icon="i-heroicons-light-bulb"} For more information on Hamilton STAR waste see [Waste block](https://docs.unitelabs.io/operate/devices/hamilton-star/waste-block/). :::: ::: :::div{label="Bravo"} Pass an empty deck location as the discard target. ```python # Discard to empty location 3 await bravo.pipette_head.discard_tips(bravo.deck[3]) ``` ::::callout{icon="i-heroicons-shield-exclamation"} The target location must be empty. The SDK raises an error if labware is assigned to the discard location. :::: ::: :: # Liquid Classes See [Liquid Classes](https://docs.unitelabs.io/operate/concepts/liquid-classes/) for the conceptual model — what a liquid class encapsulates, how the Hamilton and Bravo parameter models differ, and why liquid classes are dataclasses. This guide covers the procedural API for instantiating, browsing, and subclassing predefined classes. ## Using Predefined Classes ::tabs :::div{label="Hamilton"} Hamilton ships a library of named liquid classes. List available classes by printing the enum, then instantiate by name. ```python from unitelabs.labware.hamilton import LiquidClass print(LiquidClass) # HamiltonTip_300_Water_DispenseJet_Empty # HamiltonTip_300_Water_DispenseJet_Part # HamiltonTip_300_Water_DispenseSurface_Empty # HamiltonTip_300_Water_DispenseSurface_Part liquid_class = LiquidClass.HamiltonTip_300_Water_DispenseJet_Empty() # HamiltonTip_300_Water_DispenseJet_Empty [Water (100%)] # aspirate_flow_rate: 100 # dispense_flow_rate: 180 # ... ``` Any parameter can be overridden after instantiation: ```python liquid_class.aspirate_flow_rate = 200 ``` ::: :::div{label="Bravo"} The `BravoLiquidClasses` factory provides access to predefined classes. Use `find()` to search by tip type or volume. ```python from unitelabs.labware.agilent import BravoLiquidClasses from unitelabs.labware.agilent.tips import AgilentTip_250 liquid_classes = BravoLiquidClasses() # Access by name lc = liquid_classes.OQ_96LT_water_highVol # Find matching classes for a given tip and volume results = liquid_classes.find(tip=AgilentTip_250, volume=100) ``` **Available predefined classes (96LT head):** | Liquid Class | Tip | Volume Range | Description | | ----------------------- | --------------- | ------------ | ------------------ | | `OQ_96LT_water_lowVol` | AgilentTip\_200 | 0–50 µL | Water, low volume | | `OQ_96LT_water_highVol` | AgilentTip\_250 | 51–250 µL | Water, high volume | When no liquid class is passed to `aspirate` or `dispense`, the SDK auto-selects based on the mounted tip type and requested volume. ::: :: ## Liquid Class Parameters ### Hamilton Key parameters on a Hamilton liquid class: | Parameter | Description | | ------------------------------- | ------------------------------------------------------- | | `aspirate_flow_rate` | Plunger speed during aspiration (µL/s) | | `aspirate_mix_flow_rate` | Plunger speed during mixing aspiration (µL/s) | | `aspirate_transport_air_volume` | Air drawn after aspiration to prevent dripping (µL) | | `aspirate_blowout_air_volume` | Pre-conditioning blowout air (µL) | | `aspirate_swap_speed` | Retract speed after aspiration (mm/s) | | `aspirate_settling_time` | Dwell time in liquid after aspiration (s) | | `aspirate_over_aspirate_volume` | Pre-wetting extra volume (µL) | | `dispense_mode` | Jet empty / jet part / surface empty / surface part | | `dispense_flow_rate` | Plunger speed during dispense (µL/s) | | `dispense_stop_flow_rate` | Flow rate at end of dispense step (µL/s) | | `dispense_stop_back_volume` | Air re-aspirated immediately after dispense (µL) | | `curve` | Volume correction map: `{target_µL: corrected_µL, ...}` | ### Bravo Key parameters on a Bravo liquid class: | Parameter | Default | Description | | ------------------------------ | ---------- | ------------------------------------------------------------------- | | `aspirate_velocity` | 5.0 mm/s | Plunger speed during aspiration | | `aspirate_acceleration` | 10.0 mm/s² | Plunger acceleration | | `aspirate_velocity_into_wells` | 50.0 mm/s | Z descent velocity | | `aspirate_post_delay_ms` | 250 ms | Dwell after aspiration | | `dispense_velocity` | 5.0 mm/s | Plunger speed during dispense | | `dispense_acceleration` | 10.0 mm/s² | Plunger acceleration | | `dispense_post_delay_ms` | 250 ms | Dwell after dispense | | `coefficients` | [0.0, 1.0] | Polynomial volume correction: `corrected = c₀ + c₁·v + c₂·v² + ...` | ## Serializing a Liquid Class `.serialize()` returns a JSON-compatible dict holding the subclass name plus every parameter of the instance; `.deserialize()` rebuilds an equal instance of that subclass. Instance overrides are preserved — you get back the class you had, not the shipped defaults. ::tabs :::div{label="Hamilton"} ```python import json from unitelabs.labware.hamilton import HamiltonLiquidClass, LiquidClass liquid_class = LiquidClass.HamiltonTip_300_Water_DispenseJet_Empty() liquid_class.aspirate_flow_rate = 200 data = liquid_class.serialize() # { # "type": "HamiltonTip_300_Water_DispenseJet_Empty", # "liquid": {"Water": "1"}, # "tip": "HamiltonTip_300", # "curve": {"0.0": 0.0, "20.0": 23.2, ...}, # "aspirate_flow_rate": "200", # ... # } restored = HamiltonLiquidClass.deserialize(json.loads(json.dumps(data))) assert restored == liquid_class assert restored.aspirate_flow_rate == 200 # the override survived ``` ::: :::div{label="Bravo"} ```python from unitelabs.labware.agilent import BravoLiquidClasses from unitelabs.labware.agilent.liquids import BravoLiquidClass liquid_class = BravoLiquidClasses.OQ_96LT_water_highVol() data = liquid_class.serialize() # { # "type": "OQ_96LT_water_highVol", # "liquid": {"Water": "1.0"}, # "tip": "AgilentTip_250", # "coefficients": ["0", "1.007400"], # ... # } restored = BravoLiquidClass.deserialize(data) assert restored == liquid_class ``` ::: :: Notes: - **Numbers are strings.** Decimal parameters serialize as strings so no precision is lost; `deserialize` parses them back to `Decimal`. - **Classes are names.** `tip` (and any other class-valued field) serializes as the class name and resolves back to the class on load. - **Deserialize from the right base.** `deserialize` only resolves the class it is called on or a subclass of it, so `BravoLiquidClass.deserialize` rejects a Hamilton liquid class instead of loading it. ### Inside a parameter set Parameter sets carry their liquid class through `dumps()` / `loads()` the same way, so the full liquid class is preserved rather than reduced to its name: ```python from unitelabs.labware.hamilton import LiquidClass from unitelabs.liquid_handling.hamilton.modules.core96 import CoRe96AspirateParameterSet parameters = CoRe96AspirateParameterSet( volume=100, liquid_class=LiquidClass.HamiltonTip_300_Water_DispenseJet_Empty(aspirate_flow_rate=200), ) restored = CoRe96AspirateParameterSet.loads(parameters.dumps()) assert restored.liquid_class == parameters.liquid_class ``` Passing the liquid class *type* instead of an instance dumps as `{"type": ""}` and loads back as a default-constructed instance of it — the same value the type would have resolved to at pipetting time. ## Creating a Custom Liquid Class ::tabs :::div{label="Hamilton"} Subclass `HamiltonLiquidClass` and override the fields you want to change. All other fields inherit their default values. ```python import dataclasses from unitelabs.labware import Decimal, Ingredient, Liquid, Mixture, PredefinedLiquids from unitelabs.labware.hamilton import DispenseMode, HamiltonLiquidClass, StandardTip @dataclasses.dataclass class EthanolJetEmpty(HamiltonLiquidClass): liquid: Mixture = dataclasses.field( default_factory=lambda: Mixture([Ingredient(PredefinedLiquids.ETHANOL, 1)]) ) tip: type = HamiltonTip_300 aspirate_flow_rate: Decimal = Decimal(default="80") aspirate_settling_time: Decimal = Decimal(default="1.5") dispense_mode: int = DispenseMode.JET_EMPTY dispense_flow_rate: Decimal = Decimal(default="150") curve: dict[float, float] = dataclasses.field( default_factory=lambda: { 0.0: 0.0, 50.0: 52.1, 100.0: 103.8, 200.0: 207.0, 300.0: 311.2, } ) ``` ::: :::div{label="Bravo"} Subclass `BravoLiquidClass` and override fields as needed. Provide `coefficients` directly or fit them from calibration data. **With direct coefficients:** ```python import dataclasses import decimal from unitelabs.labware.agilent.liquids.bravo_liquid_class import BravoLiquidClass from unitelabs.labware.agilent.tips import LT250Tip, AgilentTip from unitelabs.labware.liquids import Liquid, Mixture, PredefinedLiquids from unitelabs.labware.math import Decimal def _ethanol_mixture() -> Mixture: return Mixture({PredefinedLiquids.ETHANOL: 1}) @dataclasses.dataclass class EthanolLiquidClass(BravoLiquidClass): liquid: Mixture = dataclasses.field(default_factory=_ethanol_mixture) tip: type[AgilentTip] = AgilentTip_250 min_volume: Decimal = dataclasses.field(default=Decimal(default="0")) max_volume: Decimal = dataclasses.field(default=Decimal(default="250")) coefficients: list[decimal.Decimal] = dataclasses.field( default_factory=lambda: [decimal.Decimal("0.05"), decimal.Decimal("1.02")] ) aspirate_velocity: Decimal = dataclasses.field(default=Decimal(default="35.0")) dispense_velocity: Decimal = dataclasses.field(default=Decimal(default="40.0")) ``` **With calibration curve (SDK fits the polynomial automatically):** ```python @dataclasses.dataclass class CalibratedEthanol(BravoLiquidClass): tip: type[AgilentTip] = AgilentTip_250 coefficients: list[decimal.Decimal] | int = 2 # fit a 2nd-order polynomial curve: dict[float, float] | None = dataclasses.field( default_factory=lambda: { 0: 0.0, 50: 51.8, 100: 102.5, 150: 153.4, 200: 204.6, 250: 255.9 } ) ``` For full parameter details see [Bravo Liquid Classes](https://docs.unitelabs.io/operate/devices/agilent-bravo/liquid-classes/). ::: :: # Labware Transport See [Modules](https://docs.unitelabs.io/operate/concepts/modules/) for the module model and the comparison of the three transport mechanisms (reach, rotation, lifecycle). This guide covers the procedural API for each. For deep-dive vendor detail: [CO-RE Gripper](https://docs.unitelabs.io/operate/devices/hamilton-star/core-gripper/), [iSWAP](https://docs.unitelabs.io/operate/devices/hamilton-star/iswap/), [Bravo Gripper](https://docs.unitelabs.io/operate/devices/agilent-bravo/using-the-gripper/), [IPG](https://docs.unitelabs.io/operate/devices/hamilton-vantage/ipg/). **Prerequisites** - A running liquid handler connector connected to the UniteLabs platform - A plate or other labware on the deck ## Connect to the Device ::tabs :::div{label="CO-RE Gripper"} ```python from unitelabs.liquid_handling.hamilton import MicrolabSTAR hamilton = MicrolabSTAR( name="Microlab STAR", ) await hamilton.initialize() ``` ::: :::div{label="iSWAP"} ```python from unitelabs.liquid_handling.hamilton import MicrolabSTAR hamilton = MicrolabSTAR( name="Microlab STAR", ) await hamilton.initialize() ``` ::: :::div{label="Bravo"} ```python from unitelabs.liquid_handling.agilent import Bravo bravo = Bravo( name="Bravo", ) await bravo.configure() await bravo.initialize() await bravo.activate() ``` ::: :: ## Arrange the Deck ::tabs :::div{label="CO-RE Gripper"} ```python from unitelabs.labware import Standard96Plate from unitelabs.labware.hamilton import PLT_CAR_L5MD_A00 plate_carrier = PLT_CAR_L5MD_A00() plate = Standard96Plate() plate_carrier[0] = plate hamilton.deck.add(plate_carrier, track=1) ``` ::::callout{icon="i-heroicons-shield-exclamation"} For testing you can leave the physical deck empty; only the CO-RE gripper paddles are required to run this guide without hardware errors. :::: ::: :::div{label="iSWAP"} ```python from unitelabs.labware import Standard96Plate from unitelabs.labware.hamilton import PLT_CAR_L5MD_A00 plate_carrier = PLT_CAR_L5MD_A00() plate = Standard96Plate() plate_carrier[0] = plate hamilton.deck.add(plate_carrier, track=1) ``` ::: :::div{label="Bravo"} ```python from unitelabs.labware.plates import Standard96Plate plate = Standard96Plate(identifier="DestinationPlate_96Well") bravo.deck.add(plate, location=5) ``` ::: :: ## Set Up the Transport Module ::tabs :::div{label="CO-RE Gripper"} The CO-RE gripper paddle storage location varies by setup. Configure the locations and channel indices that correspond to your instrument, then pick up the paddles. Two consecutive channels must be used to pick up the paddles. ```python from unitelabs.labware import Vector core_gripper_locations = ( Vector(x=796, y=105, z=225), Vector(x=796, y=79, z=225), ) await hamilton.core_gripper.configure( park_location=core_gripper_locations, channels=(6, 7), ) await hamilton.core_gripper.pick_up_gripper() ``` ::::callout{icon="i-heroicons-information-circle"} Use a teaching needle to find the exact pickup coordinates for your setup. :::: ::: :::div{label="iSWAP"} The iSWAP module deactivates other tools automatically before any operation. An explicit `activate()` is optional since SDK ≥ 0.2.1. ```python await hamilton.iswap.activate() # optional ``` ::: :::div{label="Bravo"} No setup required; the Bravo gripper is always available. ::::callout{icon="i-heroicons-shield-exclamation"} The gripper and pipette head are mutually exclusive. Tips must be discarded before using the gripper. The SDK raises an error if you attempt to activate the gripper while tips are mounted. :::: ::: :: ## Pick Up Labware ::tabs :::div{label="CO-RE Gripper"} ```python await hamilton.core_gripper.pick_up_from( plate_carrier[0], strength=30, # grip pressure 0–99 move_speed=128.7, # mm/s on z-axis close_speed=277.8, # mm/s closing gripper ) ``` Further parameters such as grip width and grip height are determined based on the definition of the labware that is being picked up. See the [CO-RE gripper guide](https://docs.unitelabs.io/operate/devices/hamilton-star/core-gripper/) for a full list of available parameters. Verify the carrier site is now empty and the gripper holds the plate: ```python assert plate_carrier[0].get() == None assert hamilton.core_gripper.get() == plate ``` ::: :::div{label="iSWAP"} ```python from unitelabs.liquid_handling.hamilton.modules.iswap.interfaces import Direction await hamilton.iswap.pick_up_from( plate_carrier[0], pick_up_direction=Direction.LEFT, strength=30, ) ``` Further parameters such as grip width and grip height are determined based on the definition of the labware that is being picked up. See the [iSWAP guide](https://docs.unitelabs.io/operate/devices/hamilton-star/iswap/) for a full list of available parameters. Verify: ```python assert plate_carrier[0].get() == None assert hamilton.iswap.get() == plate ``` ::::callout{icon="i-heroicons-light-bulb"} `Direction` defines which side the iSWAP arm approaches from: `FRONT`, `RIGHT`, `BACK`, or `LEFT`. When omitted, the iSWAP uses its current orientation. See the [iSWAP vendor guide](https://docs.unitelabs.io/operate/devices/hamilton-star/iswap/) for details on the positional reference frame. :::: ::: :::div{label="Bravo"} Pass the labware object directly. The gripper moves to the plate's current deck location. ```python await bravo.gripper.pick_up(plate) ``` Verify: ```python assert bravo.deck.is_location_empty(5) assert bravo.gripper.get() == plate ``` Optional parameters: `grip_gap`, `width_offset`, `movement_velocity`, `gripper_velocity`, `pick_up_offset`. ::: :: ## Put Down Labware ::tabs :::div{label="CO-RE Gripper"} Labware can only be placed on carrier sites. ```python await hamilton.core_gripper.put_down( carrier_site=plate_carrier[4], pressure=0, # pressure on bottom move_speed=128.7, # mm/s on z-axis ) ``` ```python assert hamilton.core_gripper.get() == None assert plate_carrier[4].get() == plate ``` ::: :::div{label="iSWAP"} ```python await hamilton.iswap.put_down( carrier_site=plate_carrier[4], drop_direction=Direction.LEFT, ) ``` ```python assert hamilton.iswap.get() == None assert plate_carrier[4].get() == plate ``` ::: :::div{label="Bravo"} Pass the target deck site. ```python await bravo.gripper.put_down(bravo.deck[7]) ``` ```python assert bravo.gripper.get() is None assert bravo.deck.get_labware_at_location(7) == plate ``` ::: :: ## Transfer Shorthand Combine pick-up and put-down in a single call. ::tabs :::div{label="CO-RE Gripper"} The CO-RE gripper does not have a dedicated `transfer` method. Use `pick_up_from` followed by `put_down`. ::: :::div{label="iSWAP"} ```python # Transfer labware to a carrier site await hamilton.iswap.transfer( plate, target=plate_carrier[4], pick_up_direction=Direction.LEFT, drop_direction=Direction.LEFT, ) # Transfer between two sites without a labware reference await hamilton.iswap.transfer_from( source=plate_carrier[0], target=plate_carrier[4], ) ``` ::: :::div{label="Bravo"} ```python # Transfer labware to another deck location await bravo.gripper.transfer(plate, target=bravo.deck[1]) # Transfer between deck sites without a labware reference await bravo.gripper.transfer_from( source=bravo.deck[1], target=bravo.deck[9], ) ``` ::: :: ## Deactivate / Return Module ::tabs :::div{label="CO-RE Gripper"} In order to return the CO-RE gripper to its storing location, use `put_down_gripper` command: ```python await hamilton.core_gripper.put_down_gripper() ``` The `put_down_gripper` command will be executed automatically if another module is used. ::: :::div{label="iSWAP"} To park the iSWAP on the back of the instrument, use home command or activate any other tool: ```python await hamilton.iswap.home() ``` ::: :::div{label="Bravo"} The Bravo gripper is always ready, so no explicit deactivation is needed. It is deactivated automatically when a pipetting operation begins. ::: :: ## Troubleshooting ::u-accordion --- items: - label: "CO-RE: channel was not able to pick up the gripper paddles" slot: core-pickup-fail - label: "CO-RE: gripper dropped the plate unexpectedly" slot: core-drop - label: "iSWAP: wrong pick-up or put-down position" slot: iswap-position - label: "Bravo: cannot activate the gripper" slot: bravo-activate --- #core-pickup-fail Use a teaching needle to verify the exact `park_location` vectors for your setup. Check that you are using the correct CO-RE gripper size for your pipetting channels (1000 µL vs 5 mL, not yet supported). #core-drop Increase the `strength` parameter in `pick_up_from` (range 0–99), or provide a `pick_up_offset` vector to adjust where the gripper grabs the plate. #iswap-position The iSWAP uses the center of the labware as its reference frame, not the front-left corner. Use `labware.location + labware.center` when computing absolute positions for `move_to`. See the [iSWAP vendor guide](https://docs.unitelabs.io/operate/devices/hamilton-star/iswap/) for the full explanation. #bravo-activate Ensure the pipette head has no tips mounted before using the gripper. Drop or discard all tips first. The SDK will raise an error if tips are present when the gripper activates. :: # Simulation See [Simulation](https://docs.unitelabs.io/operate/concepts/simulation/) for what the mock validates (and what it doesn't), and why. This guide covers the procedural how-to: instantiating mocks, configuring them, and switching between mock and real hardware. ## Mock liquid handler The following mock classes are available, all imported from `unitelabs.liquid_handling.testing`: - `MicrolabSTARMock`: mock for the Hamilton Microlab STAR - `BravoMock`: mock for the Agilent Bravo ```python import asyncio import typing from unitelabs.labware import Liquid, Standard96Plate from unitelabs.labware.hamilton import ( HamiltonTip_300, HamiltonTipRack_300 ) from unitelabs.liquid_handling.testing import MicrolabSTARMock from unitelabs.labware.hamilton import PLT_CAR_L5MD_A00, TIP_CAR_480_A00 async def main(): lh = MicrolabSTARMock() await lh.configure() await lh.initialize() # Build your deck exactly as you would with real hardware tip_carrier = TIP_CAR_480_A00(identifier="tip_carrier") tip_rack = HamiltonTipRack_300(filled_with=HamiltonTip_300, identifier="tip_rack") tip_carrier[0] = tip_rack plate_carrier = PLT_CAR_L5MD_A00(identifier="plate_carrier") plate = Standard96Plate(identifier="plate") for well in plate["A1":"H1"]: well.container.add_liquid(Liquid.WATER, 100) plate_carrier[0] = plate lh.deck.add(tip_carrier, track=1) lh.deck.add(plate_carrier, track=10) # All pipetting operations work normally tips = lh.deck.get("tip_rack") # or just use tip_rack tips = typing.cast(TIP_CAR_480_A00, tips) await lh.pipettes.pick_up_tips(channels=range(8), spots=tip_rack["A1":"H1"]) await lh.pipettes.aspirate(plate["A1":"H1"], volume=[100] * 8) await lh.pipettes.dispense(plate["A1":"H1"], volume=[100] * 8) await lh.pipettes.drop_tips() print("Protocol completed in simulation.") asyncio.run(main()) ``` ## Configuring the mock The mock ships with a default configuration based on a Hamilton STARlet with all supported modules enabled. For accurate deck dimension checks and module availability, override this with your device's actual configuration. You can read and modify the configuration object, then call `configure()` to apply it: ```python from decimal import Decimal lh = MicrolabSTARMock() configuration = await lh.get_configuration() # Set STAR deck dimensions configuration.deck_track_count = 54 configuration.autoload_track_count = 54 configuration.waste_x = Decimal('1340.0') configuration.max_x = Decimal('1140.0') # Enable installed modules configuration.iswap = 'left' configuration.core96 = 'left' configuration.autoload = 'Scanner' await lh.configure() ``` Alternatively, pass a pre-built configuration object directly to the constructor: ```python from decimal import Decimal from unitelabs.liquid_handling.hamilton.interfaces import MicrolabSTARConfiguration from unitelabs.liquid_handling.testing import MicrolabSTARMock configuration = MicrolabSTARConfiguration( deck_track_count=54, autoload_track_count=54, waste_x=Decimal('1340.0'), max_x=Decimal('1140.0'), iswap='left', core96='left', autoload='Scanner', ) lh = MicrolabSTARMock(configuration=configuration) await lh.configure() ``` You can verify any attribute directly after configuring: ```python print(lh.configuration.deck_track_count) print(lh.configuration.iswap) # 54 # 'left' ``` ## Initialization and movement Call `initialize()` before issuing movement or pipetting commands. After initialization, you can inspect current channel positions: ```python await lh.initialize() await lh.pipettes.initialize() print(await lh.pipettes.current_locations()) # [Vector(x=..., y=..., z=...), ...] ``` Movement commands to locations within the configured deck bounds execute without error: ```python from unitelabs.labware import Vector await lh.pipettes[0].move_to(Vector(x=Decimal('100'), y=Decimal('100'), z=Decimal('300'))) ``` Note: `await lh.is_initialized()` does not reflect the expected initialization status in the mock. Check initialization state by attempting a movement instead. ## Activating modules Any module present in the configuration can be activated: ```python await lh.iswap.activate() ``` To simulate initialization errors or recover a module that fails to activate, set its stage manually before initializing: ```python from unitelabs.liquid_handling.modules import Stage lh.configuration['iswap'] = True lh.iswap._stage = Stage.CONFIGURED await lh.iswap.initialize() await lh.iswap.activate() ``` ## Checking liquid state One of the most useful properties of simulation is that the liquid model runs in full. You can inspect volumes after each operation to verify your protocol logic: ```python await lh.pipettes.pick_up_tips(channels=range(8), spots=tip_rack["A2":"H2"]) await lh.pipettes.aspirate(plate["A1"], channels=[0], volume=[50]) print(plate["A1"].container.volume) # 50 µL removed from the well await lh.pipettes.dispense(plate["B1"], channels=[0], volume=[50]) print(plate["B1"].container.volume) # 50 µL added to B1 ``` This catches off-by-one errors, wrong well indexing, and volume miscalculations before any liquid is touched. ## Switching to real hardware The mock is a drop-in replacement for the real device class. Structure your code with a flag to toggle between them: ```python from unitelabs.liquid_handling.testing import MicrolabSTARMock mock_run = True if mock_run: lh = MicrolabSTARMock() else: from unitelabs.liquid_handling.hamilton import MicrolabSTAR from unitelabs.sdk import AsyncApiClient client = AsyncApiClient() lh = MicrolabSTAR(name="Microlab STAR", client=client) ``` All downstream code (pipetting commands, movement, module use) works identically with either object. ## Next steps - [Tip Handling](https://docs.unitelabs.io/operate/guides/pipetting/tip-handling/) - [Basic Pipetting](https://docs.unitelabs.io/operate/guides/pipetting/basic-pipetting/) - [Getting Started with Hamilton STAR](https://docs.unitelabs.io/operate/devices/hamilton-star/positioning/) # Positioning & Movement In this guide the positioning system of the liquid handler is explained. Positions of the pipetting channels with and without attachments will be read out and moved to. **Prerequisites** - A switched on Hamilton STAR device - A running Hamilton STAR connector - Basic understanding of the liquid handler class (See the [liquid handler tutorial](https://docs.unitelabs.io/operate/devices/hamilton-star/positioning/)) ## Coordinate System & Origin All measurements are in millimeters. The deck origin (x=0, y=0, z=0) is in the front, left, bottom corner when standing at the cover of the robot. The x-axis is the width, i.e. the axis the arm is moving along from left to right. The y-axis is the depth from the front to the back, i.e. the direction a carrier is sliding in and that the channels can move along back- and forwards. The z-axis is the height and thus the axis the pipetting channels can move up and down on. However, the deck origin does not start at the metal deck, but 100 mm below it. A carrier or instrument placed on the deck will thus be placed at z=100. The first rail also starts 100 mm to the right of the deck, where the first rails is indicated. A carrier position starting on rail 1 therefore has x=100. It is possible to move within the first 100 mm along the x-axis, but the range is defined by the actual hardware setup. The y-range is also constraint, depending on the hardware setup and pipetting channels installed. A carrier in the first position will have it's origin at y=63. :br The figure below shows the constraints for various pipetting channel configurations. Always check the device configuration using the `hamilton.get_configuration()` command. The response will provide information of the x-, y-, and z-ranges (See the [liquid handler tutorial](https://docs.unitelabs.io/operate/devices/hamilton-star/positioning/)). ![Illustration of the deck origin](https://docs.unitelabs.io/images/automate/liquid-handling/deck-origin.webp){.max-w-md.mx-auto.mb-2}[© by Hamilton Company]{.block.max-w-md.mx-auto.text-sm} ## Positioning Different models have different ranges in the x-direction depending on the deck size. The range of the liquid handler arm can be requested with: ```python configuration = await hamilton.get_configuration() print(configuration.min_x, configuration.max_x) # 350, 1140 ``` The position of the pipetting channels can be accessed through the pipetting head "pipettes" module. The x position is identical for all pipetting channels, as they are mounted on the same arm. ```python locations = await hamilton.pipettes.current_locations() print(locations) # [ # Vector(x=415, y=70, z=245), # Vector(x=415, y=79, z=245), # ... # ] ``` The location for an individual pipette can be requested with: ```python locations = await hamilton.pipettes[0].current_location() print(locations) # Vector(x=415, y=70, z=245) ``` ::callout{color="amber" icon="i-heroicons-light-bulb" target="_blank"} The z-position is the position of the end of the pipetting channel. However, if tips or other tools are attached, the returned z-position is that of the channel including the tool, i.e. the tip of the needle! :: When moving the arm on the x-axis, the robot will always move up to the safe traverse height, when using the commands below. When using low-level commands for movement, it is recommended to always set the channels to a safe traverse height. ```python await hamilton.pipettes.api.set_z_positions_to_safety() ``` Regular movement in the x-, y-, and z-direction for a single or multiple channels at once: ```python # move all pipettes to an absolute location await hamilton.pipettes.move_to(locations=[ Vector(x=415, y=70, z=245), Vector(x=415, y=61, z=245), ... ]) # move a single pipette to an absolute location await hamilton.pipettes[0].move_to(location=Vector(x=415, y=70, z=245)) ``` ::callout{color="amber" icon="i-heroicons-light-bulb" target="_blank"} Pipetting channels have a defined width, so there is a limit on how close they can move together. Using the `hamilton.get_configuration()` command returns the `pip_channel_gap`, which is - in most configurations - 9 mm. So always keep that 9 mm gap in mind when designing new labware or when moving channels around. :: Movement is also possible in an unsafe way. You can move your channels relatively to their current location. With this method, the channels do not move to the safe traverse height first. It might be helpful during development but should not be used in automated workflows due to safety reasons. ```python await hamilton.pipettes[0].move_by(offset=Vector(y=9)) ``` # Gripper Module During liquid handling workflows, the need often arises to reposition labware across the deck. Whether you want to cover microplates or transport them to and from positions on the deck, e.g. into a reader, an efficient method to achieve this is through the integrated CO-RE gripper. Comprising two paddles, which are picked up by two pipetting channels during operation, this gripper streamlines the task. Instruments with an Fluid Motion arm and X1 channels also support the **Quad CO-RE gripper**, whose paddles are each picked up by two channels (four channels total) — see [Quad CO-RE Gripper](https://docs.unitelabs.io/#quad-co-re-gripper) below. For more complex scenarios, such as labware rotation or transferring into stacked plate hotels, exploring the capabilities of the iSWAP gripper module is recommended. This guide serves as a comprehensive walkthrough for utilizing the integrated CO-RE gripper on the Hamilton Microlab STAR. By adhering to these instructions, users can effortlessly and precisely maneuver labware around the deck with assurance. **Prerequisites** - A switched on Hamilton STAR device - A plate carrier and a plate - A running Hamilton STAR connector - Basic understanding of the liquid handler class (See the [liquid handler tutorial](https://docs.unitelabs.io/operate/devices/hamilton-star/core-gripper/)) - Basic understanding of how labware is used (See the [using standard labware tutorial](https://docs.unitelabs.io/operate/guides/labware/standard-labware/)) ## Connect and Initialize Ensure that the Hamilton STAR is powered on and ready for operation. Verify that the connector is running and connected to the UniteLabs platform. ```python from unitelabs.liquid_handling.hamilton import MicrolabSTAR # Initialize the Hamilton Microlab STAR hamilton = MicrolabSTAR(name="Microlab STAR") await hamilton.initialize() ``` ## Arrange the Deck Arrange the deck layout using the components from the labware library. This guide uses a plate carrier with one standard 96 well microtiter plate that we move from one carrier site to another. ```python from unitelabs.labware import Standard96Plate, Vector from unitelabs.labware.hamilton import PLT_CAR_L5MD_A00 plate_carrier = PLT_CAR_L5MD_A00() plate = Standard96Plate() plate_carrier[0] = plate hamilton.deck.add(plate_carrier, track=1) ``` ::callout{icon="i-heroicons-shield-exclamation"} **Safety tip**: For testing purposes, the actual deck can be left empty. Only the CO-RE gripper is required to run this guide without hardware errors. :: ## Configure the Gripper The storage location of the CO-RE gripper may vary in different environments. Therefore, configure the gripper module first by providing the locations for your setup. Learn how to teach the gripper paddle positions in the [Teaching Gripper Paddle Positions](https://docs.unitelabs.io/operate/devices/hamilton-star/gripper-positions/) guide. ```python from unitelabs.labware import Vector core_gripper_locations = ( Vector(x=796, y=105, z=225), Vector(x=796, y=79, z=225), ) await hamilton.core_gripper.configure( park_location=core_gripper_locations, channels=(6, 7), ) ``` ## Pick up the Gripper Pick up the gripper from its configured location. ```python await hamilton.core_gripper.pick_up_gripper() ``` ## Pick up the Labware Labware can be picked up from carrier sites. Make sure that the plate is placed on the site and pass it to the gripper's `pick_up` method. The gripper moves on a safe traverse height to the site and picks up the plate. Grip strength, move speed, and close speed are set to defaults in this example and can be left out. ```python await hamilton.core_gripper.pick_up_from( plate_carrier[0], # The pressure to apply on the plate ranging from 0 (low) to 99 (high). strength=30, # The speed in mm/s when moving along the z-axis. move_speed=128.7, # The speed in mm/s along the y-axis when closing the gripper. close_speed=277.8, ) ``` The parameters such as grip width and grip height are determined based on the [definition of the labware](https://docs.unitelabs.io/operate/guides/labware/standard-labware/) that is being picked up while others have default values. All parameters can be specified explicitly if desired: - `strength`: Grip pressure ranging from 0 (low) to 99 (high). Default: `60`. - `move_speed`: Speed in mm/s when moving along the z-axis. Default: `128.7`. - `close_speed`: Speed in mm/s along the y-axis when closing the gripper. Default: `277.8`. - `pick_up_offset`: A `Vector(dx, dy)` offset in mm to adjust the pick up location. Default: `None`. - `width_offset`: Offset in mm to adjust the labware size for grip width calculation. Default: `0`. - `grip_gap`: Initial gap in mm between the gripper and the labware, i.e. how wide the gripper opens before reaching for the labware. Default: `6`. - `min_traverse_height`: Minimum traverse height in mm. Default: `None` (uses global setting). ::callout{icon="i-heroicons-information-circle"} `grab_height` is read from the labware definition and is not exposed as a pick-up parameter. To adjust the grip height, either edit the `grab_height` field on your labware definition or use `pick_up_offset` for fine-tuning. :: The plate is now accessible on the gripper and the carrier site should be empty. ```python assert plate_carrier[0].get() == None assert hamilton.core_gripper.get() == plate ``` ## Move to Position Now that the plate is picked up, it can be moved to another position. The plate's center will be placed at the provided location. ```python # Location 20 mm above the 5th carrier site's center target_location = plate_carrier[4].absolute_location \ + plate_carrier[4].center.update(z=20) await hamilton.core_gripper.move_to(location=target_location) ``` ::callout{icon="i-heroicons-information-circle"} **Note**: The gripper can be moved to any location even without carrying labware. :: ## Release the Labware Labware can only be placed on carrier sites. Make sure that the site is empty and pass it to the gripper's `put_down` method. The method automatically transfers the plate from the gripper module to the site. Gripper pressure and move speed are set to defaults in this example and can be left out. ```python await hamilton.core_gripper.put_down( # The carrier's 5th site carrier_site=plate_carrier[4], # The pressure in mm to apply on the bottom. pressure=0, # The speed in mm/s when moving along the z-axis. move_speed=128.7, ) ``` The plate is now accessible on the carrier site and the gripper should be empty again. ```python assert hamilton.core_gripper.get() == None assert plate_carrier[4].get() == plate ``` ## Put Down the Gripper Repeat the above steps as necessary to manipulate additional labware. To return the CO-RE gripper to its storing location, ```python await hamilton.core_gripper.put_down_gripper() ``` ## Quad CO-RE Gripper Instruments equipped with an X1 channels support the Quad CO-RE gripper. Instead of two single-channel paddles, each paddle is picked up by two channels, so the gripper occupies **four channels** in total. This provides a more robust grip for heavier or taller labware. Once picked up, plate handling (`pick_up_from`, `move_to`, `put_down`) works exactly as with the standard gripper. To use it, pass the gripper tip type to `configure()`. Two variants are available from the labware library: - `QuadCoReGripperTip` — the standard gripper where the paddles sit closer to the outermost channels. - `DeepQuadCoReGripperTip` — the Deep Grasp Quad CO-RE Gripper where the paddles sit closer to the innermost channels, used for gripping hight labware like Tip Boxes. ```python from unitelabs.labware import Vector from unitelabs.labware.hamilton import QuadCoReGripperTip await hamilton.core_gripper.configure( park_location=( Vector(x=796, y=105, z=225), Vector(x=796, y=105, z=225), ), # The lowest and highest of the four channels used to pick up the gripper. channels=(4, 7), gripper_type=QuadCoReGripperTip, ) ``` ::callout{icon="i-heroicons-exclamation-triangle"} For the Quad CO-RE gripper, only the **first** `Vector` of `park_location` is used. It defines the **tool center** — the midpoint of the paddle set as it sits parked on the deck — from which the firmware derives each channel's pick-up and put-down position. The second `Vector` is ignored, so pass a copy of the first (as shown above). :: The `channels` tuple gives the **outermost** channel indices — for a quad gripper this must span four channels (e.g. `(4, 7)`). If `channels` is omitted, it defaults to the last four channels of the instrument. Configuring a quad gripper on an instrument whose firmware does not report an NGC arm raises a `ValueError`. When picking up the gripper, you can optionally verify plate presence during the pick-up. This feature is supported for Quad CO-RE grippers only. ```python await hamilton.core_gripper.pick_up_gripper(check_plate_presence=True) ``` ## Troubleshooting ::u-accordion --- items: - label: The channel was not able to pick up the CO-RE gripper slot: initialize-failed - label: The CO-RE gripper is unable to pick up the plate slot: pick-up-failed - label: The CO-RE gripper dropped the plate unexpectedly slot: unexpected-drop --- #initialize-failed 1. There are different sizes of CO-RE grippers. One for the 1000 μl pipetting channels and one for the 5 ml pipetting channels. Make sure to use the appropriate CO-RE gripper for your pipetting channel size. 2. The locations of the CO-RE grippers may differ on your setup. Use a teaching needle to find the correct pick up location for the configuration step. #pick-up-failed Some labware might have slightly different dimensions than expected. You can provide an offset vector to almost every method to adjust the used locations. #unexpected-drop Either the CO-RE gripper should grab the plate on another position or you could increase the strength applied to holding the plate in the `pick_up` method. :: ## Conclusion You should now be able to utilize the integrated CO-RE gripper as an efficient solution for transporting labware during liquid handling workflows. # iSWAP Module In addition to the CO-RE gripper, the iSWAP module provides advanced plate handling capabilities for the Hamilton Microlab STAR. This module enables precise manipulation of labware across the deck, including tasks like plate covering, transportation to readers, and complex positioning scenarios that go beyond standard pipetting operations. The iSWAP module offers a rotatable arm that can pick up and place labware at any position on the deck, providing greater flexibility for complex workflows. In addition, it has a better reach beyond the standard deck envelope, allowing for better access to off-deck integrations such as readers, incubators, and other laboratory equipment. **Prerequisites** - A switched on Hamilton STAR device - A plate carrier and a plate - A running Hamilton STAR connector - Basic understanding of the liquid handler class (See the [liquid handler tutorial](https://docs.unitelabs.io/operate/devices/hamilton-star/iswap/)) - Basic understanding of how labware is used (See the [using standard labware tutorial](https://docs.unitelabs.io/operate/guides/labware/standard-labware/)) ## Key iSWAP Concepts Before getting started, let's review a few key concepts when working with the iSWAP module. ### Positional Reference Frame Like the CO-RE gripper, the iSWAP's location is defined as the *center point* between its two gripper fingers. This center point serves as the reference for all its positioning operations, and is crucial to account for when using the module. However, this can easily cause confusion when working with other deck resources, since the default reference point for other labware is typically defined as its origin (front-left corner). The UniteLabs labware library provides utilities to help with coordinate transformations and positioning calculations when working with the iSWAP module, in particular for converting between these two coordinate base frames (iSWAP center point vs. labware front-left origin). The key utility is the resource `.center` property. The `.center` property returns a `Vector` instance representing the center point of the resource *relative to **that resource's** own origin* (the front-left corner). This `Vector` will have only x- and y-components. It does **not** represent the absolute location of the labware's center on the deck, therefore you must sum the plate's location and center to get the absolute position of the center point, as illustrated below. Additionally, the `.location` property of a resource gives its absolute position on the deck, and when combined with `.center`, you can calculate the absolute center position of any labware. ::callout{icon="i-heroicons-information-circle"} **Note**: This utility is only necessary when using iSWAP methods that expect absolute coordinates. The only one that does so is `move_to`, while the rest (`pick_up`, `pick_up_from`, `put_down`, `transfer`, and `transfer_from`) all perform the center calculation internally, inferring the center point from the labware arguments passed to those methods. This distinction is particularly important to remember when training new deck locations with the iSWAP, as described in the guide on [training a custom deck position](https://docs.unitelabs.io/operate/devices/hamilton-star/custom-deck-position/). :: ```ascii Deck View - Illustrating the Reference Frames ┌──────────────────────────────────────────────────────────┐ │ │ │ ┌─────────┐ │ │ └─────────┘ │ │ ● ← iswap.current_location │ │ ┌─────────┐ │ │ └─────────┘ │ │ │ │ ┌─────────────────┐ │ │ │ │ │ │ │ ● ← my_plate.location + my_plate.center │ │ │ │ │ │ ●─────────────────┘ │ y │ ▲ │ ↑ │ └─ my_plate.location │ | │ │ └ - → x └──────────────────────────────────────────────────────────┘ ↙ z ``` ```ascii Deck View - iSWAP Correctly Aligned to Plate Center ┌──────────────────────────────────────────────────────────┐ │ │ │ ┌─────────┐ │ │ ┌───└─────────┘───┐ │ │ │ │ │ │ │ ● ← │ iswap.current_location == │ │ │ │ my_plate.location | y │ ●───┌─────────┐───┘ + my_plate.center │ ↑ │ └─────────┘ │ | │ │ └ - → x └──────────────────────────────────────────────────────────┘ ↙ z ``` ```ascii Deck View - iSWAP Incorrectly Aligned to Plate Origin ┌──────────────────────────────────────────────────────────┐ │ │ │ ┌─────────────────┐ │ │ │ │ │ │ ┌─────────┐ ● │ │ │ └─────────┘ │ │ │ → ●─────────────────┘ iswap.current_location == | y │ ┌─────────┐ my_plate.location │ ↑ | └─────────┘ │ | │ │ └ - → x └──────────────────────────────────────────────────────────┘ ↙ z ``` The `.center` utility helps simplify coordinate transformations when working with the iSWAP module, making it easier to specify absolute coordinates that account for the different reference frames between the iSWAP and other deck resources. With a plate on the deck, one would use these properties with the `move_to` method like so: ```python # Move to a plate location # Calculate absolute center position plate_center_absolute = my_plate.location + my_plate.center # Use with iSWAP move_to method await hamilton.iswap.move_to(location=plate_center_absolute) ``` ```python # Move to a carrier site location # Calculate absolute center position carrier_center_absolute = my_carrier[0].location + my_carrier[0].center # Use with iSWAP move_to method await hamilton.iswap.move_to(location=carrier_center_absolute) ``` ::callout{icon="i-heroicons-shield-exclamation"} **Warning**: To avoid collision, make sure to either open the grippers before moving to a plate location, or add a z offset to the move command. :: ### Rotation and Direction The iSWAP has a unique rotational element which allows it to pick and place labware in multiple orientations. The UniteLabs liquid handling SDK provides support for specifying rotation direction when handling labware with the iSWAP module in the form of the `Direction` enum: ```python class Direction(enum.IntEnum): FRONT = 1 RIGHT = 2 BACK = 3 LEFT = 4 ``` This enum defines the four cardinal directions from which the iSWAP can approach and handle labware. It can be imported directly from the liquid handling SDK like so: ```python from unitelabs.liquid_handling.hamilton.modules.iswap import Direction ``` All of the iSWAP's pick and place methods contain a `direction` parameter that accepts values from this enum. This parameter determines from which side the iSWAP *approaches* the labware for the operation. It is possible to use the corresponding integer values directly (1-4), but we recommend importing and using the enum values for better code readability. ## Basic Setup Here we describe the necessary steps to initialize and configure the iSWAP module for operation. ### Power On the System Ensure that the Hamilton STAR is powered on and ready for operation. Verify that the connector is running and connected to the UniteLabs platform. ```python from unitelabs.sdk import AsyncApiClient from unitelabs.liquid_handling.hamilton import MicrolabSTAR client = AsyncApiClient() # Initialize the Hamilton Microlab STAR hamilton = MicrolabSTAR( name="Microlab STAR", # Or your connector's name on the UniteLabs platform client=client, ) await hamilton.initialize() ``` ### Arrange the Deck Arrange the deck layout using the components from the labware library. This guide uses a plate carrier with one standard 96 well microtiter plate that we move from one carrier site to another. ```python from unitelabs.labware import Standard96Plate, Vector from unitelabs.labware.hamilton import PLT_CAR_L5MD_A00 plate_carrier = PLT_CAR_L5MD_A00() plate = Standard96Plate() plate_carrier[0] = plate hamilton.deck.add(plate_carrier, track=1) ``` ## Using the iSWAP ### Pick up Labware Training locations should be done with labware in the iSWAP to ensure accuracy. This ensures that the positioning is valid for the specific labware to be transferred. Make sure that the plate is placed on the site and pass it to the iSWAP's `pick_up_from` method. The iSWAP moves on a safe traverse height to the site and picks up the plate. The method automatically transfers the plate from the carrier site to the iSWAP module. When the `pick_up_direction` argument is omitted, the iSWAP will approach from whichever orientation it is currently in. Here the `strength` parameter is set to the default value, which is used if not specified. ```python from unitelabs.liquid_handling.hamilton.modules.iswap.interfaces import Direction await hamilton.iswap.pick_up_from( plate_carrier[0], # The direction from which the iSWAP arm approaches the labware to pick up. pick_up_direction=Direction.LEFT, # The pressure to apply on the plate ranging from 0 (low) to 99 (high). strength=30, ) ``` The parameters such as grip width and grip height are determined based on the [definition of the labware](https://docs.unitelabs.io/operate/guides/labware/standard-labware/) that is being picked up while others have default values. All parameters can be specified explicitly if desired: - `pick_up_direction`: Approach direction (`Direction.FRONT`, `RIGHT`, `BACK`, or `LEFT`). Default: `None` (uses current orientation). - `strength`: Grip pressure ranging from 0 (low) to 99 (high). Default: `30`. - `pick_up_offset`: A `Vector(dx, dy)` offset in mm to adjust the pick up location. Default: `None`. - `width_offset`: Offset in mm to adjust the labware size for grip width calculation. Default: `0`. - `grip_gap`: Initial gap in mm between the gripper and the labware. Default: `6`. ::callout{icon="i-heroicons-information-circle"} `grab_height` is read from the labware definition and is not exposed as a pick-up parameter. To adjust the grip height, either edit the `grab_height` field on your labware definition or use `pick_up_offset` for fine-tuning. :: The plate is now accessible on the iSWAP and the carrier site should be empty. ```python assert plate_carrier[0].get() == None assert hamilton.iswap.get() == plate ``` ### Move Labware Now that the plate is picked up, it can be moved to another position. The plate's center will be placed at the provided location. In `move_to`, the `direction` parameter specifies the approach direction for the iSWAP arm. ```python # Location 20 mm above the 5th carrier site's center target_location = plate_carrier[4].absolute_location \ + plate_carrier[4].center.update(z=20) await hamilton.iswap.move_to( location=target_location, direction=Direction.RIGHT, ) ``` ::callout{icon="i-heroicons-information-circle"} **Note**: The iSWAP can be moved to any location even without carrying labware. :: ### Release Labware Labware can only be placed on carrier sites. Make sure that the site is empty and pass it to the iSWAP's `put_down` method. The method automatically transfers the plate from the iSWAP module to the site. The `drop_direction` parameter specifies the approach direction for the iSWAP arm when putting down the labware. ```python await hamilton.iswap.put_down( # The carrier's 5th site carrier_site=plate_carrier[4], # The direction from which the iSWAP arm approaches the labware to put it down. drop_direction=Direction.LEFT ) ``` The plate is now accessible on the carrier site and the iSWAP should be empty again. ```python assert hamilton.iswap.get() == None assert plate_carrier[4].get() == plate ``` ### Home iSWAP Repeat the above steps as necessary to manipulate additional labware. When you are done, using any other tool (e.g. the CoRe96 pipetting head) will return the iSWAP back to its home position. You can also home iSWAP explicitly: ```python await hamilton.iswap.home() ``` ## Conclusion You should now be able to utilize the iSWAP gripper as an efficient solution for transporting labware during liquid handling workflows. # Using TADM Total Air Displacement Monitoring (TADM) is an advanced feature designed to enhance the reliability of pipetting processes in liquid handling systems. TADM uses pressure sensors to monitor the pressure inside each pipetting channel during both aspiration and dispensing, allowing for real-time detection of pipetting errors. This guide will help you integrate and utilize TADM within your application, ensuring precise and error-free liquid transfers. ::callout{icon="i-heroicons-exclamation-triangle"} For correct usage, limitations, and detailed specifications of TADM, refer to the manufacturer's documentation provided with your Hamilton Microlab STAR, STAR V or Vantage instrument. :: ## Benefits of TADM - **Increased Safety:** By monitoring pressure changes, TADM can detect and prevent potential pipetting errors. - **Enhanced Robustness:** Ensures consistent pipetting performance across various liquids and volumes. - **Real-time Monitoring:** Provides immediate feedback on the pipetting process, enabling prompt correction of issues. ## When to Use TADM TADM is ideal for: - Single aspirations and dispenses for volumes above 10 μL. - Multiple dispenses with individual aliquot volumes above 10 μL. - Transfers of various liquid types, not limited to aqueous solutions. TADM is especially beneficial in regulated environments and for development purposes, as it records every transfer step, allowing for detailed review and comparison of pressure profiles during and after method runs. This capability is invaluable for troubleshooting and ensuring stable transfers. ## When Not to Use TADM Avoid using TADM if: - The volume to pipette is less than 10 μL, as pressure profiles may be inconsistent. - You need to determine the exact pipetted volume or the amount of volume missing from a sample. - The method involves variable volumes, making it impractical to create tolerance bands for each volume. - You need to confirm the precision and trueness of the transfer, as TADM cannot provide these metrics. ## Modes of TADM 1. **Recording Mode:** The pressure inside the pipetting channel is recorded and stored. 2. **Monitoring Mode:** The recorded pressure is compared to a user-defined tolerance band in real time. If the measured value deviates from the tolerance band, the plunger movement stops, and software-dependent error handling is executed. ## Reading TADM Curves ### Using TADM Guardbands Guardbands are essential for active monitoring: - Collect consistent pressure profiles for each volume transferred. - Set upper and lower tolerance bands based on these profiles. - Monitor for exceptions during runtime; deviations indicate potential errors. ![Dispense TADM Curve](https://docs.unitelabs.io/images/guides/tadm/tadm-guardbands.webp){.max-w-md.mx-auto} **Step 1: Create TADM Guardbands** ```python from unitelabs.liquid_handling.hamilton import LimitCurve # sequence of time (ms) and pressure (Pa) value pairs upper_limit_curve = [(0, 2000), (500, 2000)] lower_limit_curve = [(0, -2000), (500, -2000)] tadm_guardband = await hamilton.pipettes[0].add_tadm_guardband(upper_limit_curve, lower_limit_curve) ``` **Step 2: Read TADM Guardbands** ```python tadm_guardband_uuids = await hamilton.pipettes[0].get_tadm_guardbands() tadm_guardband = await hamilton.pipettes[0].get_tadm_guardband(identifier=tadm_guardband_uuids[0]) print(tadm_guardband) # TADMGuardband( # index = 0, # identifier = "40e21e0f-bd91-4dd7-89e0-bce233e18fc3" # upper_limit_curve = [(0, 2000), (500, 2000)] # lower_limit_curve = [(0, -2000), (500, -2000)] # ) ``` ### Aspiration TADM Curve **Step 1: Enable TADM Recording** TADM is disabled by default. To enable TADM recording for an aspiration step, set the `tadm_mode` property either to `TADMMode.ERRORS` or `TADMMode.ALL`. ```python await hamilton.pipettes.aspirate(..., tadm_mode=hamilton.pipettes.TADMMode.ALL) # Or, with a parameter set: from unitelabs.liquid_handling.hamilton.interfaces.parameters import TADMParameterSet from unitelabs.liquid_handling.hamilton.modules.core96 import CoRe96AspirateParameterSet tadm_on = TADMParameterSet(tadm_mode=TADMMode.ALL) parameters = CoRe96AspirateParameterSet(tadm_parameter_set=tadm_on) await hamilton.core96.aspirate(target, parameters) ``` ::callout{icon="i-heroicons-exclamation-triangle"} It might be necessary to have a guardband in place to even monitor the liquid handling step. Use the parameter `tadm_limit_curve: int = 0` on the `aspirate` method to choose an appropriate guardband by its index. :: **Step 2: Read TADM Recordings** Each individual channel stores its TADM recordings. Read the data on each channel with `get_tadm_data`, e.g. for the first channel, use: ```python tadm_data = await hamilton.pipettes[0].get_tadm_data() ``` The returned data contains a measured pressure value in Pa for every 10 ms. ![Aspiration TADM Curve](https://docs.unitelabs.io/images/guides/tadm/tadm-aspirate.svg){.max-w-md.mx-auto.bg-white} 1. Plunger Movement Starts 2. Pressure is High Enough to Overcome the Surface Tension 3. The Liquid Starts to Flow into the Tip 4. The Speed of the Rising Liquid Matches the Plunger Speed 5. Plunger Movement Stops 6. Liquid Continues to Flow 7. Liquid Flow Stops ### Dispense TADM Curve **Step 1: Enable TADM Recording** TADM is disabled by default. To enable TADM recording for a dispense step, set the `tadm_mode` property either to `TADMMode.ERRORS` or `TADMMode.ALL`. ```python await hamilton.pipettes.dispense(..., tadm_mode=hamilton.pipettes.TADMMode.ALL) ``` ::callout{icon="i-heroicons-exclamation-triangle"} It might be necessary to have a guardband in place to even monitor the liquid handling step. Use the parameter `tadm_limit_curve: int = 0` on the `dispense` method to choose an appropriate guardband by its index. :: **Step 2: Read TADM Recordings** Each individual channel stores its TADM recordings. Read the data on each channel with `get_tadm_data`, e.g. for the first channel, use: ```python tadm_data = await hamilton.pipettes[0].get_tadm_data() ``` The returned data contains a measured pressure value in Pa for every 10 ms. ![Dispense TADM Curve](https://docs.unitelabs.io/images/guides/tadm/tadm-dispense.svg){.max-w-md.mx-auto.bg-white} 1. Plunger Movement Starts 2. Pressure is High Enough to Overcome the Surface Tension and Static Friction 3. The Liquid Starts to Flow out of Tip 4. The Speed of the Falling Liquid Matches the Plunger Speed 5. Plunger Movement Stops 6. Liquid Continues to Flow 7. Liquid Flow Stops ## Conclusion Incorporating TADM into your liquid handling processes enhances reliability and accuracy, crucial for high-precision applications. By following this guide, you can effectively utilize TADM within your application, ensuring optimal performance in liquid handling tasks. # Autoload Module In this guide the basic deck is extended with a loading tray and the Autoload is used to move carriers on and off the deck. The barcode scanner of the Autoload is used to scan barcodes of the carrier and plate placed on it. **Prerequisites** - A switched on Hamilton STAR device with the Autoload module - A plate carrier with barcode, plate with a barcode - A running Hamilton STAR connector - Basic understanding of how labware is used (See the [using standard labware tutorial](https://docs.unitelabs.io/operate/guides/labware/standard-labware/)) - Basic understanding of the liquid handler class (See the [liquid handler tutorial](https://docs.unitelabs.io/operate/devices/hamilton-star/autoload/)) ## Power On the System Ensure that the Hamilton STAR is powered on and ready for operation. Verify that the connector is running and connected to the UniteLabs platform. ```python from unitelabs.sdk import AsyncApiClient from unitelabs.liquid_handling.hamilton import MicrolabSTAR client = AsyncApiClient() # Initialize the Hamilton Microlab STAR hamilton = MicrolabSTAR( name="Microlab STAR", client=client, ) await hamilton.initialize() ``` ## Arrange the Deck Arrange the deck layout using the components from the labware library. This guide uses - a plate carrier with one barcoded standard 96 well microtiter plate. - a sample carrier filled with barcoded tubes. If the liquid handler is outfitted with a loading tray, this loading tray is configured during initialization and extends the regular main deck. The loading tray part of the deck can be accessed through the autoload module. ```python from unitelabs.labware import Standard96Plate, Vector from unitelabs.labware.falcon import Standard15mLTube from unitelabs.labware.hamilton import PLT_CAR_L5MD_A00, SMP_CAR_12_A00 # Sample carrier sample_carrier = SMP_CAR_12_A00() sample_carrier.fill(Standard15mLTube()) hamilton.autoload.loading_tray.add(sample_carrier, track=1) # Plate carrier plate_carrier = PLT_CAR_L5MD_A00() plate = Standard96Plate() plate_carrier[0] = plate hamilton.autoload.loading_tray.add(plate_carrier, track=7) ``` ## Positioning & Movement Before diving into loading and unloading, the current position of the autoload relative to the tracks is queried. ```python await hamilton.autoload.current_track() ``` The autoload carriage can also be moved to a defined track position or moved to safety using the `move_to()` and the `park()` method. ```python await hamilton.autoload.move_to(track=30) await hamilton.autoload.park() ``` ## Loading The `load_carrier()` method will drive the autoload carriage to the defined track and move the carrier onto the deck space. The in-built barcode reader can read barcodes of tubes and plates that are placed on the carrier. The following line of code loads the plate carrier and reads the barcodes of all plates placed on it. Passing `read_barcode=True` scans using the labware's default symbologies. ```python barcodes = await hamilton.autoload.load_carrier(carrier=plate_carrier, read_barcode=True, is_last=False) ``` In this example, the carriers are loaded one after another and thus the autoload carriage should remain in position after loading the first carrier. For this, the `is_last=False` is used. Otherwise, the carriage would return to its parking position after each loading procedure. The method returns a list of strings with the decoded barcode information. The position in the list corresponds to the position of the labware on the carrier. The `load_carrier()` method infers the orientation of the barcode on the labware and will turn the reader into a vertical or horizontal orientation respectively. The following line loads the sample carrier and reads the barcodes in a vertical orientation. ```python barcodes = await hamilton.autoload.load_carrier(carrier=sample_carrier) ``` ### Barcode Types There is a variety of barcodes in use and in some cases it may be necessary to specify which symbologies the reader should scan for. Pass one or more `BarcodeType` flags to `read_barcode`. Because `BarcodeType` is a `Flag` enum, multiple symbologies can be combined with the bitwise `|` operator: ```python from unitelabs.liquid_handling.hamilton.modules.autoload import BarcodeType barcodes = await hamilton.autoload.load_carrier( carrier=plate_carrier, read_barcode=BarcodeType.ISBT | BarcodeType.CODE_128, ) ``` The `read_barcode` parameter accepts: - `True` — scan using the carrier's/labware's default symbologies (all 1D types except Code 93). - A `BarcodeType` (or combination) — scan only for the given symbologies. - `False` / `None` — do not read barcodes. The following symbologies are supported. **2D symbologies require an instrument with a 2D autoload module** (see [2D Barcode Reader](https://docs.unitelabs.io/#2d-barcode-reader) below). | 1D symbologies | 2D symbologies | | ------------------------------- | --------------- | | `ISBT` (ISBT Standard) | `DATA_MATRIX` | | `CODE_128` (subset B and C) | `QR_CODE` | | `CODE_39` | `MAXI_CODE` | | `CODABAR` | `AZTEC` | | `ITF` (Code 2 of 5 Interleaved) | `PDF_417` | | `UPC_A` (UPC A/E) | `MICRO_PDF_417` | | `EAN_8` | `GS1_DATABAR` | | `CODE_93` | `EAN_UCC_COMP` | ::callout{icon="i-heroicons-information-circle"} Passing barcode types as plain integers (e.g. `read_barcode=1`) is no longer supported starting from liqid-handling version sdk 0.31.0 — use the `BarcodeType` flags or `True`. :: ## Unloading Unloading is performed with the `unload_carrier()` method. In the following snippet, the last unload method returns the autoload carriage to its home position after completing the unload process using the `is_last` parameter. ```python await hamilton.autoload.unload_carrier(carrier=sample_carrier) await hamilton.autoload.unload_carrier(carrier=plate_carrier, is_last=True) ``` ## 2D Barcode Reader Instruments fitted with a 2D autoload module can read 2D symbologies (Data Matrix, QR, etc.) and expose additional controls over the reading region and illumination. When reading a configuration from the instrument, this capability is detected directly, however the user can also explicitly enable the 2D features by configuring the autoload with `two_d=True`: ```python await hamilton.autoload.configure(two_d=True) ``` All 2D-only methods raise a `ModuleError` if the autoload was not configured as a 2D reader. ### Region of Interest & Illumination When loading a carrier on a 2D reader, you can constrain the scan to a Region of Interest (ROI) and override the illumination. Passing either `roi` or `illumination` automatically enables the free-definable grid and requires a 2D reader. ```python barcodes = await hamilton.autoload.load_carrier( carrier=plate_carrier, read_barcode=BarcodeType.DATA_MATRIX, # ROI in mm: (YR0, ZR, ΔYR, ΔZR) roi=(0.0, 0.0, 20.0, 10.0), # 7 illumination values: internal 1-4, external on, gain, exposure time illumination=(0, 0, 0, 0, 1, 5, 100), ) ``` ### Free Definable Carrier For non-standard carriers, the reading position, ROI, direction and illumination can be defined per labware position. Configure each position, then reset when done. ```python await hamilton.autoload.set_free_definable_carrier( position=1, # labware position (0 is reserved for the carrier ID) reading_position=12.5, # code reading position in mm roi=(0.0, 0.0, 20.0, 10.0), direction=2, # 0=vertical, 1=horizontal, 2=free orientation illumination=(0, 0, 0, 0, 1, 5, 100), ) # Reset all free definable carrier settings await hamilton.autoload.reset_free_definable_carrier() ``` ### Reading Codes by Position After a `load_carrier()` call, individual codes and their lengths can be queried by labware position: ```python # Barcode at a specific position (0 = carrier ID, 1 = first labware position) code = await hamilton.autoload.request_code_by_position(position=1) # Code lengths for all positions from the last load (0 = unread or > 255 chars) lengths = await hamilton.autoload.request_code_lengths() ``` ## Selected Low-level Methods More useful methods can be accessed through the low-level api using `hamilton.autoload.api`. These methods only check basic parameter constraint and not any logical or collision-related constraints. The can be invoked as follows: ```python await hamilton.autoload.api.get_deck_presences() ``` Useful low-level methods include, among others: - `get_deck_presences()`:br Check the presence of carriers on the deck without movement. Presence sensors can only check the highest track number that is occupied by the carrier, not its width. - `get_loading_tray_presences()`:br Check the presence of carriers on the loading tray by moving along the tracks. Presence sensors can only check the highest track number that is occupied by the carrier, not its width. - `get_carrier_presence(track)`:br Check the presence of a single carrier on the loading tray by moving to the specified tracks. - `set_loading_indicators(status)`:br Set the loading indicators (LED's) of the autoload unit. - `get_module_type()`:br Request the installed autoload module type (`"Microlab STAR"`, `"Microlab STAR 2D"`, `"XRP Lite"`, or `None`). # Waste Block ## Overview On Hamilton STAR line, the waste block is a specialized, always present labware component that provides: - **Teaching needle spots** - Storage and access points for teaching needles - **Waste positions** - Tip discard positions for tip disposal during workflows Waste block on Hamilton Microlab STARlet robots is located just right of the track 30 and on STAR robots just right of the track 54. In Unitelabs, the waste block is automatically configured when using `initialize()` or `configure()` command on STAR liquid handler. The waste block is created with the correct number of spots based on the number of channels the liquid handler possesses. ## Using the Waste Block ### Discarding Tips To discard tips to the waste block, use the `discard_tips()` method. The SDK automatically targets the correct waste positions: ```python # Discard tips from all channels that have tips await hamilton.pipettes.discard_tips() # Discard tips from specific channels await hamilton.pipettes.discard_tips(channels=[0, 1, 2]) # Discard with custom offset from unitelabs.labware import Vector await hamilton.pipettes.discard_tips(drop_offset=Vector(x=0, y=0, z=5)) ``` To learn more about tip handling, see the [Tips Handling](https://docs.unitelabs.io/operate/guides/pipetting/tip-handling/) page. ### Using Teaching Needles To pick up teaching needles from the waste block, use the `pick_up_teaching_needles()` method on the pipettes module. ```python # Pick up and put down teaching needle on the first pipetting channel (default) await hamilton.pipettes.pick_up_teaching_needles() await hamilton.pipettes.put_down_teaching_needles() # Pick up and put down teaching needles on specific channels await hamilton.pipettes.pick_up_teaching_needles(channels=[0, 2, 4]) await hamilton.pipettes.put_down_teaching_needles(channels=[0, 2, 4]) ``` ### Accessing Waste Spots Waste spots (discard positions) are stored as children of the waste block: ```python from unitelabs.liquid_handling.hamilton import MicrolabSTAR star = MicrolabSTAR("Hamilton Microlab STAR") await hamilton.deck.configure(waste_block=True, teaching_needles=True) # Get all waste spots waste_spots = star.deck.waste_block.children # List of WasteSpot (one per channel) # Access a specific spot by index spot = waste_spots[0] print(f"Waste spot location: {spot.absolute_location}") # Iterate over waste spots for i, spot in enumerate(waste_spots): print(f"Channel {i} waste spot: {spot.absolute_location}") ``` ### Accessing Teaching Needles If the waste block has teaching needles configured, access them through the teaching needle block: ```python # Get the teaching needle block teaching_block = star.deck.waste_block.teaching_needle_block # Returns TeachingNeedleBlock or None if teaching_block: # Access teaching needles for needle in teaching_block.children: print(f"Teaching needle at: {needle.absolute_location}") ``` --- # Complex Mixing This section explains the `complex_mix` methods for the CO-RE 96 and Channels modules. **Prerequisites** A thorough understanding of advanced pipetting operations is required, as this guide builds upon the concepts introduced in the [advanced pipetting](https://docs.unitelabs.io/operate/guides/pipetting/advanced-pipetting/) guide. The prerequisites are the same as for that guide, and the basic setup will be the same (sections [Power on the System](https://docs.unitelabs.io/operate/guides/pipetting/basic-pipetting#connect-to-the-device) and [Arrange the Deck](https://docs.unitelabs.io/operate/guides/pipetting/basic-pipetting#arrange-the-deck)). ## Parameter Sets Complex mixing requires the use of parameter sets. There are three parameter sets of note: 1. `CoRe96ComplexMixParameterSet` - Contains a single field: `steps`. This is a list of `CoRe96AspirateParameterSet` and `CoRe96DispenseParameterSet` objects in the order they are to be performed by the CO-RE96 head. 2. `ChannelComplexMixParameterSet` - Contains a single field: `steps`. This is a list of `ChannelAspirateParameterSet` and `ChannelDispenseParameterSet` objects in the order they are to be performed by the channel. 3. `ChannelsComplexMixParameterSet` - Contains *two*fields: - `parameters`: This is a list of `ChannelComplexMixParameterSet` objects. - `channels`: This is a list of channel indices to be used for the mixing operation. - Parameters and channels must be the same length, since each parameter set corresponds to a channel (in order). Note the difference between `ChannelComplexMixParameterSet` (*singular Channel*) and `ChannelsComplexMixParameterSet` (*plural Channels*). The user has complete freedom to construct their aspiration and dispense parameter sets as they would for an ordinary aspiration or dispense. However, complex mixing parameter sets do enforce some additional constraints, not on the individual parameter sets, but on the list as a whole. ### Constraints The following constraints apply to complex mixing parameter sets: - `steps` must be non-empty and of even length. - `steps`must alternate between the appropriate aspiration and dispense parameter sets. - In each pair, the `volume` field of the aspiration and dispense parameter sets must be equal. However, the `volume` can vary *across* pairs in the list. ### Features Here we describe the key features of complex mixing parameter sets. #### Validation As with other parameter sets, complex mixing parameter sets can be validated against various liquid handler states ("contexts") before being passed into a pipetting method, greatly reducing the risk of errors. Uniquely, since complex mixing parameter sets contain multiple independent operations, all of them are validated, allowing the user to evaluate the entire operation before execution: ```python from unitelabs.liquid_handling.hamilton.modules.core96 import CoRe96ComplexMixParameterSet, CoRe96AspirateParameterSet, CoRe96DispenseParameterSet, CoRe96Context # Construct a complex mixing parameter set with 3 cycles of 10uL aspiration and dispensing aspirate = CoRe96AspirateParameterSet(volume=10.0, liquid_offset=5.0, end_z_offset=0) dispense = CoRe96DispenseParameterSet(volume=10.0, end_z_offset=0) steps = [aspirate, dispense] * 3 complex_mix = CoRe96ComplexMixParameterSet(steps=steps) # Construct context for validation context = CoRe96Context(module=lhs.core96, target=some_labware) # Validate the complex mix complex_mix.validate(context) ``` The validation will either succeed (return `True`) or raise an exception with a descriptive error message, including which steps failed validation and why, for example: ```text ValueError: Parameter set CoRe96ComplexMixParameterSet failed validation due to the following warnings: [STEP 2 (DISPENSE)]: Parameter set CoRe96DispenseParameterSet failed validation due to the following warnings: - Liquid offset must be a positive number for LLD mode OFF, received -8. [STEP 4 (DISPENSE)]: Parameter set CoRe96DispenseParameterSet failed validation due to the following warnings: - Liquid offset must be a positive number for LLD mode OFF, received -8. ``` #### Factory Methods Complex mix parameter sets each provide factory methods to help users create common complex mixing parameter sets more easily. Each of them creates a single aspirate, dispense pair of the corresponding type with the specified parameters. Those pairs can then be combined into the `steps` list of a complex mix parameter set. ```python from unitelabs.liquid_handling.hamilton.modules.pipettes import ChannelComplexMixParameterSet # Create `ChannelAspirateParameterSet` and `ChannelDispenseParameterSet` objects aspirate_1, dispense_1 = ChannelComplexMixParameterSet.surface_to_bottom( volume=100, aspirate_surface_offset=5, dispense_bottom_offset=9, ) aspirate_2, dispense_2 = ChannelComplexMixParameterSet.bottom_to_surface( volume=100, aspirate_bottom_offset=9, dispense_surface_offset=5, ) # Combine the steps into a `ChannelComplexMixParameterSet` steps = [aspirate_1, dispense_1, aspirate_2, dispense_2] complex_mix = ChannelComplexMixParameterSet(steps=steps) ``` Available factory methods are: - `surface_to_bottom`: Creates a surface mode aspiration and bottom mode dispense. - `bottom_to_surface`: Creates a bottom mode aspiration and surface mode dispense. - `surface_to_surface`: Creates a surface mode aspiration and surface mode dispense. - `bottom_to_bottom`: Creates a bottom mode aspiration and bottom mode dispense. ## With the CO-RE 96 Here is a simple example of how to use the complex mixing method with the CO-RE 96: ```python from unitelabs.liquid_handling.hamilton.modules.core96 import CoRe96ComplexMixParameterSet, CoRe96AspirateParameterSet, CoRe96DispenseParameterSet, CoRe96Context from unitelabs.labware import Vector # Construct a complex mixing parameter set with 3 cycles of 10uL aspiration and dispensing aspirate = CoRe96AspirateParameterSet(volume=10.0, liquid_offset=5.0, end_z_offset=0) dispense = CoRe96DispenseParameterSet(volume=10.0, offset=Vector(1, 1, 3)) steps = [aspirate, dispense] * 3 complex_mix = CoRe96ComplexMixParameterSet(steps=steps) # Perform the complex mix await lhs.core96.complex_mix(parameters=complex_mix, target=some_labware) ``` ## With the Channels Users have two options when using the `complex_mix` method with the Channels module: - Pass a single or list of `ChannelComplexMixParameterSet` objects to `parameters`, and corresponding channel indices to the `channels`parameter. - If a single value is received in `parameters`, it will be broadcast to each channel in `channels`. If a list is passed, it must match the length of `channels` exactly, and each parameter set will be applied to the corresponding channel. - Pass a complete `ChannelsComplexMixParameterSet` to `parameters`. This object contains the `channels` information, so it is not necessary to pass the `channels` parameter separately and will be ignored if this is done. Here is an example of how to use the complex mixing method with the Channels: ```python from unitelabs.liquid_handling.hamilton.modules.pipettes import ChannelComplexMixParameterSet # Create `ChannelAspirateParameterSet` and `ChannelDispenseParameterSet` objects aspirate_1, dispense_1 = ChannelComplexMixParameterSet.surface_to_bottom( volume=100, aspirate_surface_offset=5, dispense_bottom_offset=9, ) aspirate_2, dispense_2 = ChannelComplexMixParameterSet.bottom_to_surface( volume=100, aspirate_bottom_offset=9, dispense_surface_offset=5, ) # Combine the steps into a `ChannelComplexMixParameterSet` steps = [aspirate_1, dispense_1, aspirate_2, dispense_2] complex_mix = ChannelComplexMixParameterSet(steps=steps) ``` At this point, one could simply use this parameter set to perform the same complex mix on each channel: ```python await lhs.pipettes.complex_mix(target=some_labware, parameters=complex_mix, channels=[0, 1, 2, 3]) ``` Alternatively, one could use it as a starting point for creating different complex mixes for different channels: ```python per_channel_complex_mix = [complex_mix for _ in range(4)] for i, complex_mix in enumerate(per_channel_complex_mix): if i % 2 == 0: # Set volume on all steps to 50 for even indices in per-channel list for step in complex_mix.steps: step.volume = 50 else: # Set volume on all steps to 150 for odd indices in per-channel list for step in complex_mix.steps: step.volume = 150 await lhs.pipettes.complex_mix(target=some_labware, parameters=per_channel_complex_mix, channels=[4, 5, 6, 7]) ``` Furthermore, one could use it as a basis for creating a `ChannelsComplexMixParameterSet`, and use that in the method: ```python from unitelabs.liquid_handling.hamilton.modules.pipettes import ChannelsComplexMixParameterSet channels_complex_mix = ChannelsComplexMixParameterSet( channels=[0, 1, 2, 3], parameters=per_channel_complex_mix, ) await lhs.pipettes.complex_mix(target=some_labware, parameters=channels_complex_mix) ``` ## Tips & Tricks Since the `complex_mix` methods offer complete control over each individual step, virtually any behavior that would be possible from individual aspirate and dispense steps is possible to execute with a complex mix. However, there are a few common patterns to be aware of. ### Maintaining Fixed Height Over an increasing number of mixing steps, the z movement of the pipettor to and from the instrument's configured minimum traverse height takes an increasing amount of time. Therefore the most common use case is to maintain a constant z-height of the pipettor between consecutive steps. This is possible through the use of two parameters that are present on all Hamilton parameter sets: - `min_traverse_height`: the minimum z-height at which the pipettor travels to the operation's target location before performing liquid handling. This defaults to the configured minimum traverse height of the module, however it can be overridden on individual commands with the use of this parameter. - `end_z_offset`: the z-height that the pipettor moves to when the operation is complete. As an offset, this is relative to the top of the target labware. The common pattern to use these parameters to maintain a fixed height would look like this: ```python import decimal plate_z_top = my_plate.absolute_location.z + my_plate.dimensions.z # Top of my_plate plate_z_offset = decimal.Decimal("-5") # Do not override min_traverse_height on first step to allow free z movement to target aspirate_1 = CoRe96AspirateParameterSet( end_z_offset = plate_z_offset # End operation 5mm below plate top ) dispense_1 = CoRe96DispenseParameterSet( min_traverse_height = plate_z_top + plate_z_offset, # Move to operation 5mm below plate top end_z_offset = plate_z_offset ) aspirate_2 = CoRe96AspirateParameterSet( min_traverse_height = plate_z_top + plate_z_offset, end_z_offset = plate_z_offset ) dispense_2 = CoRe96DispenseParameterSet( min_traverse_height = plate_z_top + plate_z_offset, end_z_offset = 10 # End mix cycle above plate ) mix = CoRe96ComplexMixParameterSet(steps=[aspirate_1, dispense_1, aspirate_2, dispense_2]) ``` ### Error Handling `complex_mix` methods wrap all runtime exceptions in `ComplexMixError`s to provide more context about the error. It contains the original `message`, as well as the `step` and `step_type` fields that indicate which step failed: ```python from unitelabs.liquid_handling.modules.parameters import ComplexMixError try: lhs.core96.complex_mix(target=...,parameters=...) except ComplexMixError as e: print(f"Complex mixing failed: {e.message}") print(f"Failed during step {e.step} of type {e.step_type}") ``` One can therefore recover errors more gracefully by interpreting the remaining operations in their parameter set's `steps` field and proceeding with the remaining operations. # Teaching Gripper Paddle Positions This guide explains the teaching process of the CO-RE gripper positions on the MFX CO-RE gripper carrier and how the gripper module is configured. **Prerequisites** - A switched on Hamilton STAR device - A running Hamilton STAR connector - An MFX CO-RE gripper carrier with two gripper paddles - At least two installed pipetting channels - Understanding of basic movement of the pipetting channels as described in the [Positioning and movement](https://docs.unitelabs.io/operate/devices/hamilton-star/positioning/) guide ## Get Deck Dimensions Instantiate a liquid handler instance and initialize the device. This will load the device configuration from the device's firmware. The configuration is set up during the initial hardware installation of the device. In case the configuration does not match the actual device state, it can be adjusted with the UniteLabs SDK, but we strongly advise to consult with the vendor. The configuration can be viewed via the `await hamilton.get_configuration()` method. To quickly hone in on the x-coordinate of the gripper position, we check the following entries: ```python >>> await hamilton.get_configuration() { ... 'waste_x': Decimal('1340.0'), 'waste_direction': 'right', 'min_x': Decimal('360.0'), 'max_x': Decimal('1140.0'), ... } ``` In most cases, the (fixed) carrier is located between the last track and the trash position, i.e. the `max_x` and the `waste_x` respectively. The following table provides some references of some device instances: | Model | Gripper Position 1 | Gripper Position 2 | | :---------------------- | :---------------------: | :----------------------: | | Microlab STARlet | x=796, y=105, z=225 | x=796, y=79, z=225 | | Microlab STARlet (2009) | x=797, y=124.3, z=234.7 | x=797, y=106.3, z=234.7 | | Microlab STAR | x=1338.1, y=125, z=235 | x=1338.1, y=106.5, z=235 | We can check that the gripper is not configured by running `hamilton.core_gripper.park_location`, resulting in: ```text (Vector(x=Decimal('0'), y=Decimal('0'), z=Decimal('0')), Vector(x=Decimal('0'), y=Decimal('0'), z=Decimal('0'))) ``` ::callout{icon="i-heroicons-shield-exclamation"} **Warning**: Never use the gripper without configuring the positions correctly. This may crash the pipetting channels! :: ## Channel Selection & Teaching Typically, the two channels closest to the cover are used, which correspond to the last two channels with the highest indices, which may vary depending on the number of channels installed. Before we start the teaching process, we clear the y-range to move the other channels out of the way and start with the foremost channel. For an 8-channel setup, we use the following: ```python hamilton.api.min_traverse_height = 282.5 await hamilton.api.disable_cover_control() await hamilton.pipettes.clear_y_range(channels=[7]) await hamilton.pipettes.api.set_z_positions_to_safety() ``` We can always check the current location of the channels by using: - `await hamilton.pipettes.current_locations()` for all locations at once or - `await hamilton.pipettes.api.get_z_positions()` - `await hamilton.pipettes.api.get_x_position()` - `await hamilton.pipettes.api.get_y_positions()` for the individual directions. After checking that the deck is clear of any obstructions, the arm is moved to the approximate location. In this case the waste\_x-coordinate of a STAR model is used. ```python await hamilton.pipettes.api.set_x_position(x_position=1340.0) ``` Alternatively, the `move_to` method can be used to specify a new location in x-, y-, z-coordinates: ```python from unitelabs.labware import Vector await hamilton.pipettes[7].move_to(Vector(x=1340.0, y=125, z=334.7)) ``` We can use the `move_by` method to hone in on the exact x-, y-, and z-location just above the gripper paddles. ```python from unitelabs.labware import Vector await hamilton.pipettes[7].move_by(Vector(y=10)) await hamilton.pipettes.api.get_y_positions() ``` for z: ```python await hamilton.pipettes[7].move_by(Vector(z=-10)) await hamilton.pipettes.api.get_z_positions() ``` and for x respectively: ```python await hamilton.pipettes[3].move_by(Vector(x=0.1)) await hamilton.pipettes.api.get_x_position() ``` Repeat this process until all axes are aligned. The x- and y-position must align with the center of the paddle hole, whereas the bottom of the channel must align with the upper brim of the paddle hole. Repeat this for position 2. The same channel is used for teaching although for configuration, the second gripper channel is used. ## Configuration & Usage ```python await hamilton.core_gripper.configure(park_location=(Vector(x=1338.1, y=125, z=235), Vector(x=1338.1, y=106.5, z=235)), channels=(6, 7)) ``` ::callout{icon="i-heroicons-shield-exclamation"} **Warning**: The coordinates used for configuration must be adjusted and validated for each device individually. Don't use the coordinates above. Use the coordinates identified during the above teaching procedure. If not specified correctly, the pipetting channel may crash during pick-up! :: When picking up the gripper, the configured channels will move to the pickup location to pick up the gripper paddles. ```python await hamilton.core_gripper.pick_up_gripper() ``` In case the pick-up failed on the first try, adjust the z-direction to allow for a bit more space between the upper brim of the hole and the channel bottom. To reset the state, drop the paddles using: ```python await hamilton.core_gripper.drop_gripper() ``` Once finished, `await star.core_gripper.put_down_gripper()` will put the paddles back into the currently configured position. # Training a Custom Deck Position With the iSWAP When configuring a liquid handler, one may need to define custom deck positions for specific experimental or operational setups. This can include positions that are not part of a standard deck configuration, for example, a custom 3D printed plate carrier, or an off-deck integrated plate reader position. This guide will explain the process of creating and validating custom deck positions. In this example, we use a custom plate reader position that will be physically validated with the iSWAP and then added to the deck for use in future workflows. While it is also possible to perform with the CO-RE grippers, the iSWAP is more common for reaching beyond the standard deck range. ::callout{color="amber" icon="i-heroicons-exclamation-triangle"} **Teaching with teaching needles is not supported.** Use a plate held by the iSWAP (as described in this guide) to physically validate custom positions. :: **Prerequisites** - A switched on Hamilton STAR device - A plate carrier, a plate, and a plate reader or other custom position to validate - A running Hamilton STAR connector - Basic understanding of the liquid handler class (See the [liquid handler tutorial](https://docs.unitelabs.io/operate/devices/hamilton-star/custom-deck-position/)) - Basic understanding of the iSWAP module (See [Using the iSWAP](https://docs.unitelabs.io/operate/devices/hamilton-star/iswap/)) - Basic understanding of how custom labware is created (See the [creating custom labware tutorial](https://docs.unitelabs.io/operate/guides/labware/)) ## Basic Setup Before beginning the training, we need to ensure that the system is properly configured and the necessary components are initialized. This includes powering on the device, verifying the connector is running and connected, arranging the deck with the required labware, and picking up the labware with the iSWAP. ### Power On the System Ensure that the Hamilton STAR is powered on and ready for operation. Verify that the connector is running and connected to the UniteLabs platform. ```python from unitelabs.liquid_handling.hamilton import MicrolabSTAR # Initialize the Hamilton Microlab STAR hamilton = MicrolabSTAR(name="Microlab STAR") # Or your connector's name on the UniteLabs platform await hamilton.initialize() ``` ### Arrange the Deck Arrange the deck layout using the components from the labware library. This guide uses a plate carrier with one standard 96 well microtiter plate that we move from one carrier site to the custom position. ```python from unitelabs.labware import Standard96Plate, Vector from unitelabs.labware.hamilton import PLT_CAR_L5MD_A00 plate_carrier = PLT_CAR_L5MD_A00() plate = Standard96Plate() plate_carrier[0] = plate hamilton.deck.add(plate_carrier, track=1) ``` ### Pick up Labware Training locations should be done with labware in the iSWAP to ensure accuracy. This ensures that the positioning is valid for the specific labware to be transferred. Make sure that the plate is placed on the site and pass it to the iSWAP's `pick_up_from` method. The iSWAP moves on a safe traverse height to the site and picks up the plate. The method automatically transfers the plate from the carrier site to the iSWAP module. When the `pick_up_direction` argument is omitted, the iSWAP will approach from whichever orientation it is currently in. Here the `strength` parameter is set to the default value, which is used if not specified. ```python from unitelabs.liquid_handling.hamilton.modules.iswap.interfaces import Direction await hamilton.iswap.pick_up_from( plate_carrier[0], # The direction from which the iSWAP arm approaches the labware to pick up. pick_up_direction=Direction.LEFT, # The pressure to apply on the plate ranging from 0 (low) to 99 (high). strength=30, ) ``` The plate is now accessible on the iSWAP and the carrier site should be empty. ```python assert plate_carrier[0].get() == None assert hamilton.iswap.get() == plate ``` ## Get & Validate Coordinates To find the absolute coordinates of the target deck position, we will use the iSWAP's `move_to` and `move_by` methods while checking the physical position after each movement until we reach the desired location. This iterative approach ensures safe and precise positioning. ### Move to Starting Point Now that the plate is picked up, it can be moved to the target training position with the iSWAP's `move_to` method. The plate's *center* will be placed at the provided location (this will become an important distinction in later steps). Here we demonstrate use of a `Vector` to specify the target deck coordinates. This represents a "best estimate" of the desired position that will need to be fine-tuned in subsequent steps. The z-height should be set to a safe distance above the deck to avoid collisions. ```python from unitelabs.labware import Vector # Location at specific deck coordinates (in mm) target_location = Vector(x=200.0, y=100.0, z=250.0) await hamilton.iswap.move_to(location=target_location, direction=Direction.RIGHT) ``` It may also be desirable to estimate the target location relative to existing deck positions or labware. In this case it is useful to get and update the existing location before using it in the `move_to` command. **Important**: When using relative positioning, keep in mind that `.location` properties are relative to the labware or resource's *origin* (the bottom-left corner), so we need to adjust the coordinates to properly center the plate relative to the iSWAP's center reference point. Conveniently, plates have a `.center` property that returns the center offset as a Vector. ```python # Estimated target location is 100mm to the right of the carrier position, with a safe z-height target_location = plate_carrier[4].location.update(z=250.0) # Location is relative to the CarrierSite's origin, so add the plate's center to center the iSWAP properly target_location = target_location + plate.center # Use the estimated target for the move await hamilton.iswap.move_to(location=target_location, direction=Direction.RIGHT) ``` ### Fine-tuning After moving to the estimated position, we will need to make small adjustments to achieve precise alignment with the actual physical position. This can be done by making small incremental movements using the `move_by` method with provided offset vectors. These offset vectors are relative to the *current position of the iSWAP*. It will likely be necessary to repeat this step a few times. Generally speaking, each adjustment should be small (typically 1-5mm) and the process should be repeated until the position is visually confirmed to be correct (that is, until the plate held by the iSWAP is physically seated in the desired location). The z height should gradually be reduced until the plate is properly seated in the target position. **Important**: The `current_location` method returns the current position of the iSWAP's *center*. ```python from unitelabs.labware import Vector # For example: move 3mm right, 2mm forward, and 5mm down await hamilton.iswap.move_by(offset=Vector(x=3.0, y=2.0, z=-5.0)) validated_position = await hamilton.iswap.current_location() print(f"Validated position: {validated_position}") ``` ## Save the Coordinates After fine-tuning the plate to the desired position and obtaining that position's precise coordinates, we need to integrate this validated position into a deck configuration for future use. We will do this by creating a custom `HamiltonCarrier` and adding it to the deck at the `validated_position`. This step ensures that the position is available for methods that interact with a `CarrierSite`, such as `pick_up_from` and `put_down` gripper commands; furthermore, it is much safer to save the position in the deck configuration this way than to directly reuse the `validated_position` `Vector` in future workflows. ### Create a Custom Carrier To add a single position to the deck, we will create a custom carrier with one carrier site. The carrier site will not be offset from the carrier, and will have the exact dimensions of the plate we are training. It is strongly recommended to review the [creating custom labware tutorial](https://docs.unitelabs.io/operate/guides/labware/) for more information on creating custom labware. ```python import collections.abc import dataclasses from unitelabs.labware import CarrierSite, Orientation, StandardMicroplateDimensions, Vector from unitelabs.labware.hamilton import HamiltonCarrier, LabwareType @dataclasses.dataclass class PlateReaderCarrier(HamiltonCarrier[StandardMicroplateDimensions]): rows: int = 1 dimensions: Vector = dataclasses.field(default_factory=lambda: Vector(x=127.0, y=86.0, z=0.0)) labware: LabwareType = LabwareType.PLATES orientation: Orientation = Orientation.LANDSCAPE children: collections.abc.Sequence[CarrierSite] = dataclasses.field( repr=False, default_factory=lambda: [ CarrierSite(dimensions=Vector(x=127.0, y=86.0)) ], ) ``` This custom carrier can then be used in the deck configuration to define our custom position for the plate reader. ### Use the Carrier After creating a custom carrier, we must add it to the deck at the validated position. **Importantly**, the `validated_position` from `current_location` refers to the iSWAP's *center*, while the `add` method refers to a carrier's *origin*. Thus we must offset the position we use in the `add` call by half the plate's dimensions to align the carrier's center with the validated position. We will once again take advantage of the plate's `center` property to get these values. **Note**: Here we subtract 100mm from the z-coordinate, since `add` will place the carrier on the deck relative to the deck origin, which is always 100mm above the absolute origin (what is referenced in `current_location`). ```python from . import PlateReaderCarrier reader_carrier = PlateReaderCarrier(identifier="reader_carrier") # Obtained from above process, for illustration only. validated_position = Vector(x=243.2, y=181.0, z=146.8) # Subtract plate center to get origin validated_position_origin = validated_position - plate.center # Subtract 100mm from z to account for deck origin carrier_origin = validated_position_origin - Vector(z=100.0) hamilton.deck.add( reader_carrier, location=carrier_origin ) ``` ### Verify the Carrier Position Now that the carrier is added to the deck, we can verify that the carrier's position is correctly saved by simply attempting to put the plate down at that location. ```python await hamilton.iswap.pick_up_from( source=plate_carrier[0], pick_up_direction=Direction.LEFT ) await hamilton.iswap.put_down( target=reader_carrier[0], drop_direction=Direction.RIGHT ) ``` ## Conclusion You should now have a solid understanding of how to train custom deck positions using the iSWAP gripper and validate them for precise labware placement. # Waste Configuration ## Overview Hamilton Vantage liquid handlers support two types of tip waste: - **Universal Waste** (`VantageUniversalWaste`) - This large waste can accommodate tips from multiple vantage modules (1000 µL pipettes, 5 mL XL channels, CO-RE 96 head etc.). It takes up 8 tracks of deck space and is similar in construction to STAR waste block. The Universal Waste uses a **SHIFT** discard method where pipetting channels shift sideways after ejecting tips. - **2T Waste** (`Vantage2TWaste`) - This slim waste takes 2 tracks of deck space and can be used with channels (1000 µL pipettes, 5 mL XL channels) only. Correct ejection plate must be used to accommodate different channel configurations. The 2T Waste uses a **DROP** discard method where tips drop straight down without channel shifting. ## Waste Configuration Information about the waste system is not stored in the instrument firmware, therefore waste has to be manually configured before initializing the Vantage liquid handler. If you are not sure about waste configuration of your system, consult with the manufacturer. Vantage Waste can only be placed on certain deck positions, depending on your instrument type: | **Waste Type** | **Waste Type Enum** | **1.3m Instrument** | **2.0m Instrument** | | -------------- | ------------------- | ------------------- | ------------------- | | Universal | WasteType.UNIVERSAL | 20, 30, 40 | 34, 50, 60, 70 | | 2T | WasteType.TWO\_T | 25, 35, 45 | 39, 55, 65, 75 | ### Universal Waste Configuration To configure universal waste, you need to specify the waste type and track location in the vantage configuration and then call `configure()` method: ```python from unitelabs.liquid_handling.hamilton import Vantage from unitelabs.liquid_handling.hamilton.interfaces import WasteType vantage = Vantage("My Vantage") # Configure Universal Waste at track 40 config = await vantage.get_configuration() config.waste_type = WasteType.UNIVERSAL config.waste_track = 40 await vantage.configure() ``` ### 2T Waste Configuration For 2T waste, the ejection plate must be specified additionally based on the instrument hardware. Following ejection plates are available for Hamilton Vantage: `PIP_1000UL`, `PIP_1000UL_MAGPIP`. ```python from unitelabs.labware.hamilton.waste import Hamilton2TEjectionPlate from unitelabs.liquid_handling.hamilton.interfaces import WasteType # Configure 2T Waste with PIP_1000UL ejection plate config = await vantage.get_configuration() config.waste_type = WasteType.TWO_T config.waste_track = 35 config.ejection_plate = Hamilton2TEjectionPlate.PIP_1000UL await vantage.configure() ``` ## Discarding Tips To use waste to discard tips it is enough to call `discard_tips()` method from the used pipetting channels. The SDK automatically uses the correct discard location and method based on your configured waste type. ```python # Discard tips from all channels that have tips await vantage.pipettes.discard_tips() # Discard from specific channels await vantage.xl_channels.discard_tips(channels=[0, 1]) ``` ## Accessing Waste Spots Locations Access waste spots by channel type: ```python # Get the waste block waste_block = vantage.deck.waste_block # Access spots by type pip_spots = waste_block.spots.get("pip", []) # Standard pipetting xl_spots = waste_block.spots.get("xl", []) # XL channels core96_spots = waste_block.spots.get("core96", []) # CoRe 96 head magpip_spots = waste_block.spots.get("magpip", []) # Magnetic pipetting # Iterate over spots for spot in pip_spots: print(f"Waste spot at: {spot.absolute_location}") ``` ## Defining Custom Waste Because Vantage Wastes are defined as labware dataclasses, any type of custom waste can be defined and used by the user by creating a custom labware dataclass and passing it to the Vantage configuration. In the following example, a custom universal waste with limited ejection space (only front half of the waste is used for tip ejection) is defined and used: ```python from decimal import Decimal import dataclasses import typing from unitelabs.geometry.vector import Vector from unitelabs.labware.hamilton.waste import VantageUniversalWaste, UNIVERSAL_DIMENSIONS from unitelabs.liquid_handling.hamilton import Vantage from unitelabs.liquid_handling.hamilton.interfaces import WasteType vantage = Vantage("My Vantage") @dataclasses.dataclass class CustomUniversalWaste(VantageUniversalWaste): y_min: typing.ClassVar[Decimal] = Decimal("201.6") y_max: typing.ClassVar[Decimal] = Decimal("290") dimensions: Vector = dataclasses.field( default_factory=lambda: Vector( x=UNIVERSAL_DIMENSIONS.x, y=Decimal("290") - Decimal("201.6"), z=UNIVERSAL_DIMENSIONS.z ) ) config = await vantage.get_configuration() config.waste_track = 40 config.waste_type = CustomUniversalWaste await vantage.configure() ``` # Covers and Lights Hamilton Microlab Vantage has covers that can be locked and unlocked programmatically to prevent unauthorized access and guarantee safety during operation. If covers are not locked, the liquid handler will operate at reduced speed. **Prerequisites** - A switched on Hamilton Vantage liquid handler - A running Hamilton Vantage connector - Basic understanding of the liquid handler class (See [liquid handling](https://docs.unitelabs.io/operate/concepts/liquid-handling/)) ## Basic Setup ```python from unitelabs.liquid_handling.hamilton import Vantage vantage = Vantage(name="Hamilton Microlab Vantage") await vantage.configure() await vantage.initialize() ``` ## Deck lights The deck lights can be controlled to indicate different states or to provide visual feedback during operation. RGB colors can be set in the range of 0-100. For example `set_deck_light(red=100, blue=100)` sets red and blue channel at maximum brightness, resulting in pink light, while `set_deck_light(red=0, blue=0, green=0)` turns the lights off. `set_deck_light(white=50)` sets white light at 50% brightness. ```python # Turn on the deck lights await vantage.set_deck_light(red=100, blue=100) # Turn off the deck lights await vantage.set_deck_light(red=0, blue=0, green=0) # Turn on the white light await vantage.set_deck_light(white=50) ``` Additionally, blinking mode can be enabled with `blink` parameter, with specified period in milliseconds (0 disables blink). ```python # Blinking red light with 200ms period await vantage.set_deck_light(red=100, blink=200) ``` A cycling rainbow pattern using `set_deck_light(rainbow=True)`. ## Get cover status Vantage can have up to three lockable covers and loading trays all equipped with sensors, but most benchtop instruments have one cover and one loading tray (`cover1` and `tray1`). You can check the cover configuration of your device by calling `vantage.api.get_cover_config()` and the status of cover sensors and locks by calling `vantage.get_cover_status()`. ```python covers = await vantage.api.get_cover_config() print(covers) # {'Cover1Present': True, 'Cover2Present': False, 'Cover3Present': False, 'Tray1Present': True, 'Tray2Present': False, 'Tray3Present': False} cover_status = await vantage.get_cover_status() print(cover_status) # {'Cover1Sensor': True, 'Cover2Sensor': False, 'Cover3Sensor': False, 'Tray1Sensor': True, 'Tray2Sensor': False, 'Tray3Sensor': False, 'Cover1Locked': False, 'Cover2Locked': False, 'Cover3Locked': False, 'Tray1Locked': False, 'Tray2Locked': False, 'Tray3Locked': False, 'Cover1Mask': False, 'Cover2Mask': False, 'Cover3Mask': False, 'Tray1Mask': False, 'Tray2Mask': False, 'Tray3Mask': False} ``` ## Cover locks ::callout{icon="i-heroicons-light-bulb"} If covers are not locked, Vantage will operate at reduced speed. :: `vantage.lock_covers()` and `vantage.unlock_covers()` can be used to unlock and lock all covers present on the instrument. ```python await vantage.lock_covers() await vantage.unlock_covers() ``` By passing parameters to `vantage.lock_cover("cover1")`, each lock can be controlled separately. The following example only locks cover 1: ```python await vantage.lock_cover("cover1") print(await vantage.get_cover_status()) # {'Cover1Sensor': True, 'Cover2Sensor': False, 'Cover3Sensor': False, 'Tray1Sensor': True, 'Tray2Sensor': False, 'Tray3Sensor': False, 'Cover1Locked': True, 'Cover2Locked': False, 'Cover3Locked': False, 'Tray1Locked': False, 'Tray2Locked': False, 'Tray3Locked': False, 'Cover1Mask': False, 'Cover2Mask': False, 'Cover3Mask': False, 'Tray1Mask': False, 'Tray2Mask': False, 'Tray3Mask': False} ``` Notice that all trays and covers not explicitly set to `True` are per default set to `False` and therefore are being left unlocked even if they were previously locked. It is good practice to either use `vantage.lock_covers()` to address all covers present on an instrument explicitly. ```python await vantage.unlock_cover("cover1") print(await vantage.get_cover_status()) # {'Cover1Sensor': True, 'Cover2Sensor': False, 'Cover3Sensor': False, 'Tray1Sensor': True, 'Tray2Sensor': False, 'Tray3Sensor': False, 'Cover1Locked': False, 'Cover2Locked': False, 'Cover3Locked': False, 'Tray1Locked': False, 'Tray2Locked': False, 'Tray3Locked': False, 'Cover1Mask': False, 'Cover2Mask': False, 'Cover3Mask': False, 'Tray1Mask': False, 'Tray2Mask': False, 'Tray3Mask': False} ``` ::callout{icon="i-heroicons-light-bulb"} Remember to manually close the front cover of the instrument before locking covers. Trying to lock an open cover will result in the following error: ```python ProcessError[82]: Cover Is Open (command: LL) ``` :: ::callout{icon="i-heroicons-light-bulb"} Forcefully opening a locked cover during a run disturbs the safety circuit of the Vantage and results in an immediate cut out of power to motors and unrecoverable hardware error. :: # IDL Module The IDL (ID Loader) is the automated carrier loading module on the Hamilton Microlab Vantage. It moves carriers between the deck and a loading tray, reading carrier and container barcodes during the cycle. ::callout{icon="i-heroicons-light-bulb"} The IDL is the Vantage equivalent of the Autoload on the Hamilton STAR. See [Using the Autoload](https://docs.unitelabs.io/operate/devices/hamilton-star/autoload/) for background on carrier loading concepts. :: **Prerequisites** - A switched on Hamilton Vantage device with IDL installed - A Hamilton carrier assigned to the deck - A running Hamilton Vantage connector - Basic understanding of the liquid handler class (See [liquid handling](https://docs.unitelabs.io/operate/concepts/liquid-handling/)) - Basic understanding of liquid handler modules, deck and labware (See the [liquid handler modules](https://docs.unitelabs.io/operate/concepts/modules/), [deck concept](https://docs.unitelabs.io/operate/concepts/deck/) and [labware concept](https://docs.unitelabs.io/operate/concepts/labware/)) ## Basic Setup ```python from unitelabs.liquid_handling.hamilton import Vantage from unitelabs.labware.hamilton import TIP_CAR_480_A00 vantage = Vantage(name="Microlab Vantage") await vantage.configure() await vantage.initialize() ``` ## Open and Close the Loading Tray The loading tray can be opened and closed manually, for example to load or unload carriers by hand. Loading tray needs to be unlocked before opening. ```python await vantage.unlock_cover() await vantage.idl.open_loading_tray() # Load or unload a carrier manually... await vantage.idl.close_loading_tray() ``` ## Identify a Carrier ::callout{icon="i-heroicons-exclamation-triangle"} The IDL is used for carrier identification and barcode scanning only. All carriers must already be on the deck before starting; loading carriers from the loading tray mid-run is not supported. :: `identify_carrier()` runs a full IDL cycle for a single carrier: moves it from the deck to the loading tray, reads the carrier barcode, then returns the carrier to the deck. ```python tip_carrier = TIP_CAR_480_A00() vantage.deck.add(tip_carrier, track=10) carrier_barcode = await vantage.idl.identify_carrier(tip_carrier) print(carrier_barcode) # "TIP_CAR_001" ``` ## Scan Barcodes `scan_barcodes()` runs the same cycle but also reads the barcodes of all containers (tubes, tip racks, plates) loaded on the carrier. ```python carrier_barcode, container_barcodes = await vantage.idl.scan_barcodes(tip_carrier) print(carrier_barcode) # "TIP_CAR_001" print(container_barcodes) # ["RACK_001", "RACK_002", ...] ``` ::callout{icon="i-heroicons-light-bulb"} Both `identify_carrier()` and `scan_barcodes()` accept an `is_last` parameter (default `True`). Set `is_last=False` when loading multiple carriers in sequence to skip the park step between cycles and improve throughput. :: ## Move IDL The IDL can be moved to different tracks using the `move_to()`. Current position can be checked using `current_track()`. `home()` moves the IDL to the home position on the right side of the deck. ```python await vantage.idl.move_to(track=20) current_track = await vantage.idl.current_track() print(f"Current track: {current_track}") # Current track: 20 await vantage.idl.home() ``` ## Get deck presence It is possible to check the presence of carriers on the deck without any movement. Presence sensors on the back of the deck detect the presence of carriers via small magnets that hamilton carriers are equipped with. The sensors can only check the highest track number that is occupied by the carrier, not its width. For example a tip carrier with width of 6 tracks loaded on track 10 will be detected as present on track 15. ```python await vantage.get_deck_presences() ``` # IPG Module The IPG (Internal Plate Gripper) is the plate transport arm on the Hamilton Microlab Vantage. It picks up and places microplates between positions on the deck. ::callout{icon="i-heroicons-light-bulb"} The IPG is the Vantage equivalent of the iSWAP on the Hamilton STAR. See [Using the iSWAP](https://docs.unitelabs.io/operate/devices/hamilton-star/iswap/) for background on plate gripper concepts such as positional reference frames and grip directions. :: **Prerequisites** - A switched on Hamilton Vantage with IPG installed - A plate carrier and a plate on the deck - A running Vantage connector - Basic understanding of how labware is used (See the [using standard labware tutorial](https://docs.unitelabs.io/operate/guides/labware/standard-labware/)) - Basic understanding of labware transports (See the [labware transport guide](https://docs.unitelabs.io/operate/guides/pipetting/labware-transport/)) ## Grip Parameters ### Grip Height and Width Grip height and grip width are read from the labware definition — `grab_height` sets the vertical grip position (distance from the top of the plate), and grip width is taken from `labware.dimensions.x` or `.y` depending on the gripper orientation. See the [plates guide](https://docs.unitelabs.io/operate/guides/labware/plates/) for how these fields are defined on a labware class. ### Grip Direction The IPG arm position and gripper orientation are encoded together in the `GripDirection` enum, accessible via `IPG.GripDirection`. The first letter is the arm position (R=Right, F=Front, L=Left, B=Back) and the second is the gripper orientation. ```python from unitelabs.liquid_handling.hamilton import Vantage # GripDirection is accessible as a class attribute on the IPG # e.g. vantage.ipg.GripDirection.LR (arm Left, gripper facing Right) ``` When only an arm position matters (gripper orientation preserved from current hardware state), use `IPG.ArmPosition`: ```python # vantage.ipg.ArmPosition.LEFT ``` Passing `None` for a direction uses the IPG's current orientation. These values are passed to the `pick_up_direction` and `drop_direction` parameters of `transfer()`, `pick_up_from()`, and `put_down()` described in the sections below. For example, to approach from the right when picking up and drop from the front: ```python await vantage.ipg.transfer( labware=plate, target=plate_carrier[1], pick_up_direction=vantage.ipg.GripDirection.RL, # arm Right, gripper facing Left drop_direction=vantage.ipg.GripDirection.FF, # arm Front, gripper facing Front ) ``` ::callout{color="amber" icon="i-heroicons-exclamation-triangle"} The IPG on Vantage has no collision detection with deck elements. Choose `pick_up_direction` and `drop_direction` carefully to avoid collision of arm and gripper with carriers, labware, and other deck components. Always verify directions against your deck layout before running a protocol. Make sure there are no deck components higher than `traverse_height` on your instrument. :: ## Basic Setup ```python from unitelabs.liquid_handling.hamilton import Vantage from unitelabs.labware import Standard96Plate from unitelabs.labware.hamilton import PLT_CAR_L5MD_A00 vantage = Vantage(name="Hamilton Microlab Vantage") await vantage.configure() await vantage.initialize() plate_carrier = PLT_CAR_L5MD_A00() plate = Standard96Plate() plate_carrier[0] = plate vantage.deck.add(plate_carrier, track=10) ``` ## Labware transport ### Transfer Labware `transfer()` is the most common IPG operation; it picks up labware from its current location and places it at a target carrier site in a single call. Minimally, only `labware` and `target` parameters are required: ```python await vantage.ipg.transfer( labware=plate, target=plate_carrier[1], ) ``` To control approach and drop directions, the optional parameters `pick_up_direction` and `drop_direction` can be used (see [Grip Direction](https://docs.unitelabs.io/#grip-direction)). This is important to avoid collisions with other deck components. ```python await vantage.ipg.transfer( labware=plate, target=plate_carrier[1], pick_up_direction=vantage.ipg.GripDirection.LR, drop_direction=vantage.ipg.ArmPosition.LEFT, ) ``` `transfer_from()` is a variant that takes a `source` carrier site instead of a labware reference — useful when you want to move whatever plate occupies a slot without holding a Python reference to it. ```python await vantage.ipg.transfer_from( source=plate_carrier[0], target=plate_carrier[1], ) ``` ### Pick Up and Put Down Separately For more control, `pick_up()` and `put_down()` can be called independently. Use `pick_up_from()` when working with a carrier site, or `pick_up()` when you already hold a reference to the labware object. ```python # Pick up from a carrier site await vantage.ipg.pick_up_from( source=plate_carrier[0], pick_up_direction=vantage.ipg.GripDirection.RL, ) # Pick up by labware reference await vantage.ipg.pick_up( labware=plate, pick_up_direction=vantage.ipg.ArmPosition.RL, ) # Put down onto a carrier site await vantage.ipg.put_down( target=plate_carrier[2], drop_direction=vantage.ipg.GripDirection.RR, ) ``` ### Transfer parameters Parameters for `transfer()` and `transfer_from()` methods. `pick_up()`, `pick_up_from()` and `put_down()` use the relevant subset of these parameters. | Parameter | Default | Description | | --------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `labware` / `source` | — | The labware object (`transfer`) or source carrier site (`transfer_from`) to pick up. | | `target` | — | The carrier site or plate where to put the labware down. | | `pick_up_direction` | `None` | Approach direction for pick-up. Accepts `GripDirection` (arm + gripper orientation), `ArmPosition` (arm only, gripper orientation preserved from hardware state), or `None` (current direction used for both pick-up and drop). | | `drop_direction` | `None` | Approach direction for drop. Same options as `pick_up_direction`. Defaults to the resolved `pick_up_direction` when `None`. | | `strength` | `100` | Grip strength in 0.1 N units (range 0–160, i.e. 0–16 N). | | `pick_up_offset` | `None` | `Vector(dx, dy, dz)` offset in mm applied to the pick-up position. | | `drop_offset` | `None` | `Vector(dx, dy, dz)` offset in mm applied to the drop position. | | `width_offset` | `0` | Offset in mm added to the grip width derived from the labware dimensions. | | `grip_gap` | `6` | Initial and final gap in mm between gripper in open state and labware. | | `min_traverse_height` | `None` | Minimum safe traverse height in mm. Defaults to the system value. | ## IPG Operations ### Recovery with `drop_labware` `drop_labware()` was introduced in LHSDK version 0.30.0 and allows the user to discard the currently held labware at the current gripper location. It opens the gripper fingers by a small amount at a time and does not require the IPG to be initialized. Use to recover after a run abort that left a plate gripped in the IPG fingers. You might need to run the command multiple times, as it only opens gripper fingers a small amount at a time. ```python dropped = await vantage.ipg.drop_labware() ``` ::callout{color="warning" icon="i-heroicons-exclamation-triangle"} `drop_labware()` releases the plate at whatever position the IPG is currently in and can result in spillage or sample loss. Only use it as a recovery action, not as a substitute for `put_down()`. :: ### Home IPG When you are done, using any other tool (e.g. the pipettes) will return the IPG back to its home position. You can also home IPG explicitly: ```python await vantage.ipg.home() ``` ### IPG State Query the current hardware state of the IPG: ```python await vantage.ipg.has_labware() # True if the gripper is holding a plate await vantage.ipg.current_location() # Vector with the gripper center's absolute position await vantage.ipg.current_direction() # GripDirection the gripper is currently facing await vantage.ipg.current_grip_width() # Current distance between gripper fingers in mm ``` ### IPG Positioning `move_to()` and `move_by()` give direct control over the gripper position. The IPG must be active (i.e. holding labware or explicitly activated). ```python from unitelabs.labware import Vector await vantage.ipg.move_to(location=Vector(x=200, y=200, z=300)) await vantage.ipg.move_by(offset=Vector(x=10, y=0, z=0)) ``` ### Manual Gripper Width Control Sometimes it is necessary to manually control the gripper fingers width, for example for teaching. `open_to()` and `close_to()` move the gripper fingers to an absolute width. `open_by()` and `close_by()` move them relative to the current width. ```python # Move fingers to an absolute width in mm await vantage.ipg.open_to(110) await vantage.ipg.close_to(100) # Move fingers relative to the current width await vantage.ipg.open_by(10) await vantage.ipg.close_by(10) ``` `open_to()` releases any labware currently held by the gripper. `close_to()` raises a `RuntimeError` if labware is currently held by the IPG. This prevents accidental damage to the IPG as the command performs an unconditional finger move without gripping logic. The optional `strength` parameter is accepted for API parity with the iSWAP, but is not used by the IPG implementation. # XL Pipettes The Vantage can be equipped with XL pipetting channels alongside the standard 1000 µL pipetting channels. XL channels are 5 mL high-volume pipettes used for larger volume transfers. ## Overview XL channels work the same way as the standard pipetting channels — the same aspirate, dispense, tip handling, and complex mix APIs apply. The key differences are: - **Volume range**: XL channels handle volumes up to 5000 µL (vs. 1000 µL for standard pipettes) - **Tip types**: XL channels use larger tip types (e.g. `HamiltonTip_5000`, `HamiltonTip_4000_Filter`) - **Waste spots**: Tips are ejected to dedicated XL waste spots — see [Waste Configuration](https://docs.unitelabs.io/operate/devices/hamilton-vantage/waste-configuration/) - **Module exclusivity**: XL pipettes are inactivated when using other modules such as IDL (ID Loader) or IPG (Internal Plate Gripper) ## Basic Pipetting Use `vantage.pipettes_xl` instead of `vantage.pipettes`. The API is identical. ```python from unitelabs.liquid_handling.hamilton import Vantage from unitelabs.labware.hamilton import HamiltonTip_5000, HamiltonTipRack_5000, LiquidClass vantage = Vantage(name="Microlab Vantage") await vantage.configure() await vantage.initialize() # Pick up tips tip_rack = HamiltonTipRack_5000(filled_with=HamiltonTip_5000) await vantage.pipettes_xl.pick_up_tips_from(channels=range(8), rack=tip_rack) # Aspirate and dispense liquid_class = LiquidClass.HamiltonTip_5000_Water_DispenseJet_Empty() await vantage.pipettes_xl.aspirate( source=trough, channels=range(8), volume=2000, liquid_class=liquid_class, ) await vantage.pipettes_xl.dispense( target=plate["A1":"H1"], channels=range(8), volume=2000, liquid_class=liquid_class, ) # Discard tips await vantage.pipettes_xl.discard_tips() ``` For full details on pipetting operations, see the shared guides: - [Basic Pipetting](https://docs.unitelabs.io/operate/guides/pipetting/basic-pipetting/) - [Advanced Pipetting](https://docs.unitelabs.io/operate/guides/pipetting/advanced-pipetting/) - [Tip Handling](https://docs.unitelabs.io/operate/guides/pipetting/tip-handling/) # Tip Handling In this guide a tip rack is placed on the Bravo deck. Tips can be picked up using different patterns (full 96, single column/row, and single tip) and then put back or discarded. **Prerequisites** - A switched on Agilent Bravo device - A tip rack and Agilent 250 µL tips - A running Agilent Bravo connector - Basic understanding of the liquid handler class (See the [Agilent Bravo tutorial](https://docs.unitelabs.io/operate/devices/agilent-bravo/basic-pipetting/)) ## Power On the System Ensure that the Agilent Bravo is powered on and ready for operation. Verify that the connector is running and connected to the UniteLabs platform. ```python from unitelabs.sdk import AsyncApiClient from unitelabs.liquid_handling.agilent import Bravo client = AsyncApiClient() # Initialize the Agilent Bravo bravo = Bravo( name="Bravo", client=client, ) await bravo.configure() await bravo.initialize() await bravo.activate() ``` ## Arrange the Deck Place a tip rack filled with LT250 tips on deck location 1. ```python from unitelabs.labware.agilent.tips import AgilentTipRack_250, AgilentTip_250 tip_rack = AgilentTipRack_250(identifier="TipRack_96LT_250uL") tip_rack.fill(AgilentTip_250) bravo.deck.add(tip_rack, location=1) ``` ## Pick Up All 96 Tips By default, `pick_up_tips_from()` with no `well_offset` picks up all 96 tips at once. ```python await bravo.pipette_head.pick_up_tips_from( rack=tip_rack, press_depth=5.8, ) ``` ## Pick Up a Single Column / Row Use `well_offset=(row, col)` to shift where on the rack the head's A1 nozzle aligns with. Only nozzles that overlap with the rack will pick up tips and be available for pipetting use. ```python # Pick up column 12 (head A1-H1 aligns with rack A12-H12) await bravo.pipette_head.pick_up_tips_from( rack=tip_rack, well_offset=(0, 11), # offset A1 of head 0 rows, +11 cols from A1 of rack press_depth=5.8, ) # Pick up row A (head A12-H12 aligns with rack A1-H1) await bravo.pipette_head.pick_up_tips_from( rack=tip_rack, well_offset=(-7, 0), # offset A1 of head -7 rows, 0 cols from A1 of rack press_depth=5.8, ) ``` ::callout{icon="i-heroicons-light-bulb"} The Bravo has a 96-nozzle head arranged in an 8×12 grid. The `well_offset` parameter shifts the head relative to the rack so that only the desired nozzles overlap with tip positions. For a single column, offset the column index so that one column of the head aligns with one column of the rack. :: ## Pick Up a Single Tip Offset both row and column so that only the head's A1 nozzle overlaps with a single rack position. ```python # Pick up 1 tip from H12 (head A1 at rack position H12) await bravo.pipette_head.pick_up_tips_from( rack=tip_rack, well_offset=(7, 11), # offset A1 of head +7 rows, +11 cols from A1 of rack press_depth=5.8, ) ``` ## Put Down Tips Put tips back into the rack at the same position they were picked up from. Make sure to use the same `well_offset` that was used during pickup. ```python await bravo.pipette_head.put_down_tips_to( rack=tip_rack, well_offset=(7, 11), # offset A1 of head +7 rows, +11 cols from A1 of rack ) ``` ## Discard Tips To discard tips into a waste location, use `discard_tips()` with an empty deck site as the target. ```python # Discard to empty location 3 await bravo.pipette_head.discard_tips(bravo.deck[3]) ``` ::callout{icon="i-heroicons-shield-exclamation"} **Safety tip**: The target location must be empty. The SDK will raise an error if labware is assigned to the discard location. Make sure no labware is placed at the target deck site before discarding. :: # Basic Pipetting In this guide a deck with a plate, tips, and a trough is created. Tips are picked up to aspirate liquid from the trough and dispense into a plate. Both direct keyword arguments and parameter sets are demonstrated. **Prerequisites** - A switched on Agilent Bravo device - A tip rack and Agilent 250 µL tips, a plate, and a trough - A running Agilent Bravo connector - Basic understanding of the liquid handler class (See the [Agilent Bravo tutorial](https://docs.unitelabs.io/operate/devices/agilent-bravo/basic-pipetting/)) - Basic understanding of tip handling (See [Tip handling](https://docs.unitelabs.io/operate/devices/agilent-bravo/tip-handling/)) ## Power On the System Ensure that the Agilent Bravo is powered on and ready for operation. Verify that the connector is running and connected to the UniteLabs platform. ```python from unitelabs.sdk import AsyncApiClient from unitelabs.liquid_handling.agilent import Bravo client = AsyncApiClient() # Initialize the Agilent Bravo bravo = Bravo( name="Bravo", client=client, ) await bravo.configure() await bravo.initialize() await bravo.activate() ``` ## Arrange the Deck Arrange the deck layout using the components from the labware library. This guide uses: - a tip rack filled with LT250 tips, - a deep well reservoir pre-filled with water, and - a standard 96 well plate as the destination. ```python from unitelabs.labware.agilent.tips import AgilentTipRack_250, AgilentTip_250 from unitelabs.labware.plates import Standard96Plate from unitelabs.labware.agilent.troughs import Agilent_DW_Reservoir from unitelabs.labware.liquids import PredefinedLiquids tip_rack = AgilentTipRack_250(identifier="TipRack_96LT_250uL") tip_rack.fill(AgilentTip_250) source_reservoir = Agilent_DW_Reservoir(identifier="SourceReservoir_300mL") source_reservoir[0].container.add_liquid(PredefinedLiquids.WATER, 100_000.0) dest_plate = Standard96Plate(identifier="DestinationPlate_96Well") bravo.deck.add(tip_rack, location=1) bravo.deck.add(source_reservoir, location=4) bravo.deck.add(dest_plate, location=5) ``` ## Aspirate Make sure to pick up tips first: ```python await bravo.pipette_head.pick_up_tips_from(rack=tip_rack, press_depth=5.8) ``` Aspirate 100 µL of water from the source reservoir. ```python await bravo.pipette_head.aspirate( plate=source_reservoir, volume=100, ) ``` ## Dispense Dispense the water from the tips into the destination plate. ```python await bravo.pipette_head.dispense( plate=dest_plate, volume=100, ) ``` ## Using Parameter Sets For more control over pipetting operations, use `BravoAspirateParameterSet` and `BravoDispenseParameterSet`. Parameter sets allow you to configure pipette mode, liquid offset, and other advanced options. ```python from unitelabs.liquid_handling.agilent.interfaces.parameters import ( BravoAspirateParameterSet, BravoDispenseParameterSet, ) from unitelabs.liquid_handling.modules import PipetteMode aspirate_params = BravoAspirateParameterSet(volume=51.0) await bravo.pipette_head.aspirate(source_reservoir, aspirate_params) dispense_params = BravoDispenseParameterSet( volume=51.0, pipette_mode=PipetteMode.BOTTOM, liquid_offset=3.0, ) await bravo.pipette_head.dispense(dest_plate, dispense_params) ``` ## Dispensing to Specific Wells with Partial Tips When using a single tip or column, use `well_offset` in the parameter set to target specific wells on the destination plate. The offset is relative to the active nozzle positions on the head. ```python dispense_params = BravoDispenseParameterSet( volume=51.0, well_offset=(2, 5), # Target wells at row C, column 6 from head A1 pipette_mode=PipetteMode.BOTTOM, liquid_offset=3.0, ) await bravo.pipette_head.dispense(dest_plate, dispense_params) ``` ::callout{icon="i-heroicons-light-bulb"} The `well_offset` parameter in dispense works the same way as in tip pickup; it shifts which wells the active nozzles target. This is especially useful when pipetting with a single tip or column to address different areas of the plate across multiple dispense steps. :: ## Touch-Side Dispense Setting `touch_side=True` causes the tips to touch the well wall after dispensing. The touch distance and direction are automatically calculated from the container geometry, which helps reduce droplet retention on the tip. ```python dispense_params = BravoDispenseParameterSet( volume=51.0, touch_side=True, ) await bravo.pipette_head.dispense(dest_plate, dispense_params) ``` ## Aspirate Parameter Reference The following parameters are accepted by `pipette_head.aspirate()` as keyword arguments or via `BravoAspirateParameterSet`: | Parameter | Type | Default | Description | | --------------------- | ------------------- | ---------------- | ----------------------------------------------------------------------------------------------------------------- | | `volume` | Number | auto | Volume to aspirate in µL. Defaults to the minimum of free tip volume and container volume. | | `liquid_class` | BravoLiquidClass | auto | Liquid class for motion parameters and volume correction. Auto-selected by volume and tip type when not provided. | | `velocity` | Number | 50.0 | General movement velocity in mm/s. | | `pipette_mode` | AspiratePipetteMode | SURFACE | `SURFACE` positions the tip relative to the liquid surface. `BOTTOM` positions relative to the well bottom. | | `liquid_offset` | Number | 0.0 | Z offset from the reference point in mm. | | `start_z_position` | Number | container top | Approach Z before descent in mm. | | `min_z_position` | Number | container bottom | Floor Z (actual aspirate depth) in mm. | | `well_offset` | tuple | (0, 0) | Grid offset (row, col) for partial-tip operations. | | `offset` | Vector | (0, 0, 0) | Fine XY tuning offset in mm on top of well-positioned movement. | | `min_traverse_height` | Number | auto | Override safe traverse Z in mm. Defaults to connector value. | | `mixing` | MixingParameterSet | None | Optional mixing to perform during aspiration. | | `follow_liquid` | bool or Number | auto | Not supported on Bravo; parameter is ignored. | | `pull_out_distance` | Number | None | Not supported on Bravo; parameter is ignored. | ## Dispense Parameter Reference The following parameters are accepted by `pipette_head.dispense()` as keyword arguments or via `BravoDispenseParameterSet`: | Parameter | Type | Default | Description | | ---------------------------- | ------------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------- | | `volume` | Number | auto | Volume to dispense in µL. Defaults to the minimum of tip volume and container free volume. | | `liquid_class` | BravoLiquidClass | auto | Liquid class for motion parameters and volume correction. Auto-selected when not provided. | | `velocity` | Number | 50.0 | General movement velocity in mm/s. | | `pipette_mode` | DispensePipetteMode | BOTTOM | `SURFACE` positions relative to liquid surface. `BOTTOM` positions relative to well bottom. | | `liquid_offset` | Number | 0.0 | Z offset from the reference point in mm. | | `start_z_position` | Number | container top | Approach Z before descent in mm. | | `min_z_position` | Number | container bottom | Floor Z (actual dispense depth) in mm. | | `touch_side` | bool or Number | False | `True` auto-calculates wall touch distance from container geometry. A Number specifies the exact distance in mm. | | `touch_tip_direction` | TouchTipDirection | auto | Explicit touch-off direction override. Auto-calculated from well position when not provided. | | `touch_tip_retract_distance` | Number | 0 | Z retract before touch in mm. | | `touch_tip_distance` | Number | auto | Lateral XY move toward wall in mm. Auto-calculated from tip and well diameter when not provided. | | `well_offset` | tuple | (0, 0) | Grid offset (row, col) for partial-tip operations. | | `offset` | Vector | (0, 0, 0) | Fine XY tuning offset in mm on top of well-positioned movement. | | `min_traverse_height` | Number | auto | Override safe traverse Z in mm. Defaults to connector value. | | `mixing` | MixingParameterSet | None | Optional mixing to perform during dispense. | | `follow_liquid` | bool or Number | auto | Not supported on Bravo; parameter is ignored. | | `pull_out_distance` | Number | None | Not supported on Bravo; parameter is ignored. | # Liquid Classes Liquid classes define the motion parameters and volume correction curves used during aspirate and dispense operations. The SDK provides predefined liquid classes for common configurations and supports creating custom liquid classes for specialized protocols. **Prerequisites** - Basic understanding of the Bravo pipetting operations (See [Basic Pipetting](https://docs.unitelabs.io/operate/devices/agilent-bravo/basic-pipetting/)) ## Predefined Liquid Classes The SDK includes predefined liquid classes for different head types, tip sizes, and volume ranges. Use the `BravoLiquidClasses` factory to access them. ```python from unitelabs.labware.agilent import BravoLiquidClasses liquid_classes = BravoLiquidClasses # Access a predefined class by name liquid_class = liquid_classes.OQ_96LT_water_highVol # Use with aspirate await bravo.pipette_head.aspirate( plate=source_reservoir, volume=100, liquid_class=liquid_class, ) ``` ### Available Liquid Classes #### 96LT Head | Liquid Class | Tip | Volume Range | Description | | ----------------------- | --------------- | ------------ | ------------------ | | `OQ_96LT_water_lowVol` | AgilentTip\_200 | 0–50 µL | Water, low volume | | `OQ_96LT_water_highVol` | AgilentTip\_250 | 51–250 µL | Water, high volume | ### Finding Liquid Classes Use the `find()` method to discover liquid classes matching specific criteria. ```python from unitelabs.labware.agilent.tips import AgilentTip_250 results = liquid_classes.find(tip=AgilentTip_250, volume=100) ``` ::callout{icon="i-heroicons-light-bulb"} When no `liquid_class` is provided to aspirate or dispense, the SDK automatically selects a matching liquid class based on the mounted tip type and the requested volume. :: ## Volume Correction Liquid classes define polynomial coefficients that correct for systematic pipetting errors. The corrected volume determines the actual plunger (W-axis) position during aspirate and dispense operations. The correction formula is a polynomial: ```text corrected = c₀ + c₁ × volume + c₂ × volume² + ... ``` For example, a liquid class with coefficients `[0.0, 1.0074]` applies a linear correction: the plunger moves slightly further than the nominal volume to compensate for dead volume or compression effects. ## Liquid Class Parameters Every liquid class defines the following parameters that control motion during pipetting: | Parameter | Default | Description | | ------------------------------------ | --------------- | ----------------------------------------- | | `liquid` | Mixture() | Liquid type (e.g., water, ethanol) | | `tip` | AgilentTip\_200 | Compatible tip type | | `min_volume` / `max_volume` | 0 / 250 µL | Valid volume range | | `coefficients` | [0.0, 1.0] | Polynomial volume correction coefficients | | `aspirate_velocity` | 5.0 mm/s | Plunger velocity during aspirate | | `aspirate_acceleration` | 10.0 mm/s² | Plunger acceleration during aspirate | | `aspirate_velocity_into_wells` | 50.0 mm/s | Z descent velocity into wells | | `aspirate_velocity_out_of_wells` | 50.0 mm/s | Z retract velocity out of wells | | `aspirate_acceleration_into_wells` | 100.0 mm/s² | Z descent acceleration | | `aspirate_acceleration_out_of_wells` | 100.0 mm/s² | Z retract acceleration | | `aspirate_post_delay_ms` | 250 ms | Delay after aspirate completes | | `dispense_velocity` | 5.0 mm/s | Plunger velocity during dispense | | `dispense_acceleration` | 10.0 mm/s² | Plunger acceleration during dispense | | `dispense_velocity_into_wells` | 50.0 mm/s | Z descent velocity into wells | | `dispense_velocity_out_of_wells` | 50.0 mm/s | Z retract velocity out of wells | | `dispense_acceleration_into_wells` | 100.0 mm/s² | Z descent acceleration | | `dispense_acceleration_out_of_wells` | 100.0 mm/s² | Z retract acceleration | | `dispense_post_delay_ms` | 250 ms | Delay after dispense completes | ## Custom Liquid Class with Coefficients Create a custom liquid class by subclassing `BravoLiquidClass` and providing direct polynomial coefficients. This approach is best when you already know the correction formula. ```python import dataclasses import decimal from unitelabs.labware.agilent.liquids.bravo_liquid_class import BravoLiquidClass from unitelabs.labware.agilent.tips import AgilentTip_250, AgilentTip from unitelabs.labware.liquids import Mixture, PredefinedLiquids from unitelabs.labware.math import Decimal def _ethanol_mixture() -> Mixture: return Mixture({PredefinedLiquids.ETHANOL: 1}) @dataclasses.dataclass class EthanolLiquidClass(BravoLiquidClass): liquid: Mixture = dataclasses.field(default_factory=lambda: Mixture({Liquid.ETHANOL: 1})) tip: type[AgilentTip] = AgilentTip_250 min_volume: Decimal = dataclasses.field(default=Decimal(default="0")) max_volume: Decimal = dataclasses.field(default=Decimal(default="250")) coefficients: list[decimal.Decimal] = dataclasses.field( default_factory=lambda: [decimal.Decimal("0.05"), decimal.Decimal("1.02")] ) aspirate_velocity: Decimal = dataclasses.field(default=Decimal(default="35.0")) dispense_velocity: Decimal = dataclasses.field(default=Decimal(default="40.0")) ``` The coefficients `[0.05, 1.02]` define a linear correction: `corrected = 0.05 + 1.02 × volume`. Only the parameters you want to override need to be specified; all others inherit from the default values. ```python await bravo.pipette_head.aspirate( plate=source_reservoir, volume=100, liquid_class=EthanolLiquidClass, ) ``` ## Custom Liquid Class with Calibration Curve When you have measured calibration data but don't know the exact correction formula, you can provide a `curve` dictionary and let the SDK fit a polynomial automatically. Set `coefficients` to an integer specifying the polynomial order. ```python @dataclasses.dataclass class CalibratedEthanol(BravoLiquidClass): tip: type[AgilentTip] = AgilentTip_250 min_volume: Decimal = dataclasses.field(default=Decimal(default="51")) max_volume: Decimal = dataclasses.field(default=Decimal(default="250")) # Set coefficients to an int to specify polynomial order for fitting coefficients: list[decimal.Decimal] | int = 2 # Measured calibration points: target µL → corrected plunger µL curve: dict[float, float] | None = dataclasses.field( default_factory=lambda: { 0: 0.0, 50: 51.8, 100: 102.5, 150: 153.4, 200: 204.6, 250: 255.9, } ) aspirate_velocity: Decimal = dataclasses.field(default=Decimal(default="35.0")) aspirate_acceleration: Decimal = dataclasses.field(default=Decimal(default="75.0")) aspirate_velocity_into_wells: Decimal = dataclasses.field(default=Decimal(default="45.0")) aspirate_velocity_out_of_wells: Decimal = dataclasses.field(default=Decimal(default="55.0")) aspirate_acceleration_into_wells: Decimal = dataclasses.field(default=Decimal(default="90.0")) aspirate_acceleration_out_of_wells: Decimal = dataclasses.field(default=Decimal(default="110.0")) aspirate_post_delay_ms: int = 500 dispense_velocity: Decimal = dataclasses.field(default=Decimal(default="40.0")) dispense_acceleration: Decimal = dataclasses.field(default=Decimal(default="80.0")) dispense_velocity_into_wells: Decimal = dataclasses.field(default=Decimal(default="55.0")) dispense_velocity_out_of_wells: Decimal = dataclasses.field(default=Decimal(default="45.0")) dispense_acceleration_into_wells: Decimal = dataclasses.field(default=Decimal(default="110.0")) dispense_acceleration_out_of_wells: Decimal = dataclasses.field(default=Decimal(default="90.0")) dispense_post_delay_ms: int = 500 ``` - `coefficients: int = 2` means "fit a quadratic polynomial (2 coefficients) to the calibration data" - The `curve` dictionary maps target volumes (µL) to measured corrected plunger volumes (µL) - The SDK uses least-squares fitting to derive the polynomial coefficients automatically at initialization time ::callout{icon="i-heroicons-light-bulb"} The **curve** approach is recommended when you have measured calibration data from gravimetric testing. Use direct **coefficients** when you already know the correction formula or want a simple linear correction. :: # Gripper Module During liquid handling workflows, the need often arises to reposition labware across the deck. The Bravo's integrated gripper can pick up, move, and place plates between the 9 deck locations. For workflows involving an external robot, the Bravo can also move to a safe position to allow deck access. This guide serves as a comprehensive walkthrough for utilizing the integrated gripper on the Agilent Bravo. By adhering to these instructions, users can effortlessly and precisely maneuver labware around the deck. **Prerequisites** - A switched on Agilent Bravo device - A plate - A running Agilent Bravo connector - Basic understanding of the liquid handler class (See the [Agilent Bravo tutorial](https://docs.unitelabs.io/operate/devices/agilent-bravo/basic-pipetting/)) ## Power On the System Ensure that the Agilent Bravo is powered on and ready for operation. Verify that the connector is running and connected to the UniteLabs platform. ```python from unitelabs.sdk import AsyncApiClient from unitelabs.liquid_handling.agilent import Bravo client = AsyncApiClient() # Initialize the Agilent Bravo bravo = Bravo( name="Bravo", client=client, ) await bravo.configure() await bravo.initialize() await bravo.activate() ``` ## Arrange the Deck Place a standard 96 well plate on deck location 5. ```python from unitelabs.labware.plates import Standard96Plate plate = Standard96Plate(identifier="DestinationPlate_96Well") bravo.deck.add(plate, location=5) ``` ::callout{icon="i-heroicons-shield-exclamation"} **Important**: The gripper and pipette head are mutually exclusive. The pipette head must have no tips mounted before the gripper can activate. The SDK handles activation and deactivation automatically, but tips must be dropped first. Attempting to activate the gripper while tips are mounted will raise an error. :: ## Pick Up Labware Labware can be picked up by passing the labware object directly to the gripper's `pick_up` method. The gripper moves to the labware's deck location and picks it up. ```python await bravo.gripper.pick_up(plate) ``` The plate is now held by the gripper and the deck location should be empty. ```python assert bravo.deck.is_location_empty(5) assert bravo.gripper.get() == plate ``` The `pick_up` method accepts optional parameters to fine-tune the operation: - `grip_gap`: Gripper close position in mm. - `width_offset`: Gripper open position in mm before closing. - `movement_velocity`: XYZ and Zg movement velocity in mm/s. - `gripper_velocity`: Gripper jaw close velocity in mm/s. - `pick_up_offset`: An offset `Vector(dx, dy, dz)` in mm to adjust the pick up location. ::callout{color="amber" icon="i-heroicons-shield-exclamation"} **Offset alignment**: If you use `pick_up_offset` during pick up, you must also pass `drop_offset` to `put_down` with the **same value**. When an offset is omitted it falls back to the labware definition; a mismatch between an explicit offset and the default labware value can mis-align the gripper and crash into the deck. :: ::callout{icon="i-heroicons-light-bulb"} `grab_height` is read from the labware definition. See the [Plates guide](https://docs.unitelabs.io/operate/guides/labware/plates/) for how to set it when creating custom labware. :: ## Put Down Labware Labware can be placed at any empty deck site. Pass the target deck site to the gripper's `put_down` method. ```python await bravo.gripper.put_down(bravo.deck[7]) ``` The plate is now on the deck and the gripper should be empty. ```python assert bravo.gripper.get() is None assert bravo.deck.get_labware_at_location(7) == plate ``` ## Pick Up from Deck Site Alternatively, you can pick up labware by referencing the deck site instead of the labware object. This is useful when you know the location but not the labware reference. ```python await bravo.gripper.pick_up_from(bravo.deck[7]) ``` ## Transfer The `transfer` method combines pick up and put down into a single call. Pass the labware and the target deck site. ```python await bravo.gripper.transfer(plate, target=bravo.deck[1]) ``` ## Transfer From To transfer labware between two deck sites without needing a reference to the labware object, use `transfer_from`. ```python await bravo.gripper.transfer_from( source=bravo.deck[1], target=bravo.deck[9], ) ``` ## External Robot Access When an external robot needs to access the Bravo deck, the Bravo can be moved to a safe position. The status light updates to indicate it is safe for external interaction. ```python # Move Bravo out of the way, status light signals safe access await bravo.move_to_safe_robot_position(x=5.0, y=5.0, z=0.0) # ... external robot operates ... # Re-enable Bravo, resume normal LED operation await bravo.robot_finished() ``` ::callout{icon="i-heroicons-light-bulb"} The `move_to_safe_robot_position` method moves the pipette head to the specified coordinates and homes the gripper and plunger axes. The `robot_finished` method re-enables all motor axes and returns the status light to normal operation so the Bravo can resume. :: ## Troubleshooting ::u-accordion --- items: - label: Cannot activate the gripper slot: activate-failed - label: The gripper dropped the plate unexpectedly slot: unexpected-drop - label: Cannot pick up from a deck location slot: pick-up-failed --- #activate-failed Ensure the pipette head has no tips mounted. The gripper and pipette head are mutually exclusive, so drop or discard all tips before activating the gripper. #unexpected-drop Adjust the `grip_gap` parameter to apply more grip force, or use `pick_up_offset` to fine-tune the pick up position so the gripper grabs the plate more securely. #pick-up-failed Ensure the deck location is not empty. The SDK will raise an error if you attempt to pick up from a location that has no labware assigned. :: # Connect & Run a Method ::callout{icon="i-heroicons-information-circle"} **Different integration model**: Unlike Hamilton STAR/Vantage and Agilent Bravo — which expose instrument commands directly through the SDK's typed deck and labware model — FluentControl is driven through a method defined inside the FluentControl software. The SDK prepares and starts that method; FluentControl handles the actual instrument control. If the method is fully configured in FluentControl, no further interaction is needed — the SDK simply triggers it and waits. For dynamic control, the method can include an XML execution channel step, which lets the SDK stream commands into the running method to configure the worktable, register labware, and trigger liquid handling at runtime. :: The FluentControl connector exposes two controller interfaces on the `Service` object. The `runtime_controller` manages the lifecycle of a named method in the FluentControl software — preparing, starting, and closing it. The `execution_channel_controller` is an XML message bus that becomes available while the method is running, through which commands are streamed to the instrument. Commands can only be sent through the XML channel while a method is actively executing. **Prerequisites** - A running Tecan FluentControl instrument with the FluentControl software open - A named method configured in the FluentControl software - A running Tecan FluentControl connector registered in the UniteLabs platform - The UniteLabs SDK installed (see the [SDK getting started guide](https://docs.unitelabs.io/automate/what-is-a-workflow/)) ## Connect to the Connector Retrieve the FluentControl service by the connector name registered in the platform. The name must match exactly. ```python from unitelabs.sdk import AsyncApiClient client = AsyncApiClient() service = await client.get_service_by_name("Tecan FluentControl") ``` The returned `service` object exposes both `execution_channel_controller` and `runtime_controller`. ## Check and Reset the Channel Before starting a new session, verify that no XML channel is already open from a previous run. If one is, close it before proceeding. ```python import asyncio await asyncio.sleep(2) # let the system state settle before reading channel status is_alive = await service.execution_channel_controller.get_is_alive() if is_alive: channel = await service.execution_channel_controller.get_channel() print(f"Closing open channel: {channel}") await service.execution_channel_controller.finish_command() ``` ::callout{color="amber" icon="i-heroicons-exclamation-triangle"} **Important**: If `get_is_alive()` returns `True`, FluentControl is still in *Advanced Worklist Execution* mode from a prior run. Calling `prepare_method()` in this state will raise an exception — FluentControl cannot accept a new method preparation while the previous execution channel is still open. Call `finish_command()` first to return it to EditMode. :: ## List Available Methods Use `get_runnable_methods()` to retrieve the method names available in the connected FluentControl software. Method names are case-sensitive. ```python methods = await service.runtime_controller.get_runnable_methods() print(methods) # ["plate_prep", "dilution_series", "demo"] ``` ## Prepare and Start the Method Prepare the method by name, then start it. After calling `run_method()`, wait for the XML channel to become ready before sending any commands. ```python await service.runtime_controller.prepare_method(method="plate_prep") await service.runtime_controller.run_method() # Simple: fixed wait for the XML channel to open (used in the reference implementation) await asyncio.sleep(3) # Robust alternative: poll until the channel is ready, with a timeout # for _ in range(20): # if await service.execution_channel_controller.get_is_alive(): # break # await asyncio.sleep(0.5) # else: # raise TimeoutError("XML channel did not open within 10 seconds") ``` ::callout{color="amber" icon="i-heroicons-exclamation-triangle"} **Why the wait?**: After `run_method()`, FluentControl transitions from *Idle* to *Advanced Worklist Execution* mode and opens the XML channel. This state transition takes a few seconds on the FluentControl side and is not signalled back to the SDK. The fixed `asyncio.sleep(3)` works in most environments — increase it to 5 seconds on slower hardware, or replace it with the polling loop above, which waits until `get_is_alive()` confirms the channel is actually ready. :: ## Close the Method Closing the session requires two distinct steps, in order. **Step 1 — `finish_command()`** sends the finish signal through the XML execution channel. FluentControl receives it, closes the channel on its side, and returns to *EditMode*. The instrument is no longer accepting XML commands after this call. **Step 2 — `close_method()`** closes the SDK-level method session. After `finish_command()` the method has completed on the instrument, but the connector still holds a reference to it; `close_method()` releases that reference and marks the runtime as idle and ready for the next run. ```python await service.execution_channel_controller.finish_command() # close XML channel → EditMode await service.runtime_controller.close_method() # release SDK method reference ``` ::callout{color="amber" icon="i-heroicons-exclamation-triangle"} **Important**: `finish_command()` must always be called before `close_method()`. Calling `close_method()` while the XML channel is still open leaves FluentControl with an orphaned execution channel that requires a manual restart of the FluentControl software to recover. :: # Sending XML Commands Once a method is running and the XML channel is open, the FluentControl connector acts as an XML message bus. Each call to `execute_command()` sends one command string and awaits acknowledgement from FluentControl before returning — commands are processed sequentially. **Prerequisites** - A running method with the XML channel open (see [Connect & Run a Method](https://docs.unitelabs.io/operate/devices/tecan-fluentcontrol/connect-and-run/)) - Knowledge of the carrier names and site numbers in your FluentControl worktable definition ## Checking the Active Channel During development, use `get_channel()` to confirm the XML channel is open before sending commands. ```python channel = await service.execution_channel_controller.get_channel() print(f"XML channel active on: {channel}") ``` ## Sending a Command Pass any XML command string to `execute_command()`. The call blocks until FluentControl acknowledges the command. ```python await service.execution_channel_controller.execute_command(command=xml_command_string) ``` ## The ScriptGroup Envelope Every command sent to FluentControl must be wrapped in a `ScriptGroup` XML envelope: ```xml False False 0 ``` The inner command XML uses Tecan's scripting namespace (`Tecan.Core.Scripting.*`). Build helper functions to assemble and wrap commands for the types used in your workflow. ::callout{icon="i-heroicons-information-circle"} **Tecan terminology**: FluentControl calls the physical surface of the instrument the *worktable* — this is the same concept as the *deck* in Hamilton and Agilent instruments. Carrier names and site numbers referenced in XML commands must match the worktable configuration defined in your FluentControl installation. :: ## Discovering XML Command Structures FluentControl stores methods as XML internally. To find the correct XML structure for any command type — including operator prompts, labware registration, and pipetting (LiHa) commands — open the FluentControl script editor, add the desired step visually, then inspect or export the resulting `.fcs` method file. The XML can be read directly from there and adapted for use in `execute_command()`. # Full Workflow Example This example brings together the steps from the previous guides into a single script. It uses the reusable step functions from the `shared.steps.fluent_control` module included in the [UniteLabs workflow template](https://docs.unitelabs.io/automate/workflow-template/), and extends the run phase with an XML command sent through the execution channel. **Prerequisites** - A Tecan FluentControl connector connected to the UniteLabs platform - A FluentControl method available on the instrument - The `shared` package from the [UniteLabs workflow template](https://docs.unitelabs.io/automate/workflow-template/) - Familiarity with the XML command format (see [Sending XML Commands](https://docs.unitelabs.io/operate/devices/tecan-fluentcontrol/sending-xml-commands/)) ## Full Script ```python import asyncio from shared.config.fluent_control import CONNECTOR_NAME, DEFAULT_METHOD_NAME from shared.steps.fluent_control import ( check_execution_channel, finish_command, get_fluentcontrol_service, prepare_method, run_method, ) async def run_workflow( connector_name: str = CONNECTOR_NAME, method_name: str = DEFAULT_METHOD_NAME, ): # Phase 01: Connect and reset any open channel from a previous session service = await get_fluentcontrol_service(connector_name=connector_name) await check_execution_channel(service=service) # Phase 02: Prepare and start the method, then wait for the XML channel to open await prepare_method(service=service, method_name=method_name) await run_method(service=service) await asyncio.sleep(3) # wait for FluentControl to open the XML channel # Phase 03: Send an XML command through the execution channel. # This example sends a UserPromptStatement — FluentControl blocks until # the operator presses OK on the touchscreen. # Build XML command strings following the ScriptGroup envelope format # described in Sending XML Commands. user_prompt = ( "" '' "" "Verify the deck layout, then press OK to continue" "False1" "False" "False" "#FFFFFF00" "False" "False" "1" "" "" "False" "False" "0" ) await service.execution_channel_controller.execute_command(command=user_prompt) # Phase 04: Close the XML channel and return FluentControl to EditMode await finish_command(service=service) asyncio.run(run_workflow()) ``` ## Phase Summary | Phase | Step functions used | Purpose | | ------------------ | ------------------------------------------------------ | ---------------------------------------------- | | 01 Connect & reset | `get_fluentcontrol_service`, `check_execution_channel` | Locate the service; close any leftover channel | | 02 Start method | `prepare_method`, `run_method` | Load and start the named FluentControl method | | 03 Send commands | `execute_command()` (direct SDK call) | Stream XML commands into the running method | | 04 Finalise | `finish_command` | Close the XML channel; return to EditMode | ## Simulation mode The `shared.steps.fluent_control` module includes `FluentControlMock`, a drop-in replacement for the live service that no-ops all calls. Use it during development to test the workflow structure without a connected instrument: ```python from shared.steps.fluent_control import FluentControlMock service = FluentControlMock() ``` Pass `FluentControlMock()` wherever the live `service` is expected. All `execute_command()`, `prepare_method()`, `run_method()`, and `finish_command()` calls succeed silently. ## Troubleshooting ::u-accordion --- items: - label: prepare_method() raises a "method not found" error slot: method-not-found - label: execute_command() raises a connection error slot: connection-error - label: FluentControl is stuck in RunMode after an error slot: stuck-in-run-mode --- #method-not-found Use `get_runnable_methods()` to retrieve the exact method names registered in the connected FluentControl software: ```python methods = await service.runtime_controller.get_runnable_methods() print(methods) ``` Method names are case-sensitive and must match the script file name in FluentControl exactly. Ensure that the method has been saved and is not currently open for editing in the FluentControl software. #connection-error The XML channel was not yet open when the command was sent. The `asyncio.sleep(3)` after `run_method()` is the minimum wait — in slower environments, increase it to 5 seconds. You can also replace the fixed sleep with a polling loop that calls `get_is_alive()` until it returns `True`. See [Connect & Run a Method](https://docs.unitelabs.io/operate/devices/tecan-fluentcontrol/connect-and-run/) for the polling pattern. #stuck-in-run-mode Call `finish_command` to return the instrument to EditMode: ```python await finish_command(service=service) ``` If the connector is unresponsive, restart the connector service from the UniteLabs platform and then call `finish_command` again before attempting a new run. :: # Overview New systems can be added by deploying connectors for these systems. If the systems already ship with a SiLA 2 interface natively, only a UniteLabs edge gateway is required. If the system does not have a standardized interface, a new connector must be created. The Cloud Connector and the Edge Gateway require the connection details of the tenant's cloud endpoint to establish a connection with their respective UniteLabs instance. Ask your system administrator or reach out to UniteLabs customer service. For further information on the deployment process please refer to the [deployment guide](https://docs.unitelabs.io/connector-development/guides/deployment/). ::callout{:to='null' icon="i-heroicons-light-bulb" target="_blank"} **Tenant Cloud Endpoint UUID**: A standardized Universally Unique IDentifier consisting of 32 characters separated by hyphens. An example UniteLabs Tenant Cloud Endpoint ID can look like this: `00000000-1111-2222-3333-444444444444`. Note: This is not the same as the Tenant UUID! :: ## Connector Development Connectors distributed by UniteLabs are typically in the form of an executable (.exe, .dmg, .pkg) via the UniteLabs Artifactory. A dedicated deployment and fleet management software, UniteLabs Ground Control, is provided to customers for streamlined deployment and life cycle management. In some cases, connectors can be made available as a Docker image or a Python application as well. Connectors distributed by UniteLabs will have detailed information regarding deployment in their respective documentation. New Connectors can be developed with our open-source [UniteLabs Connector Development Kit](https://gitlab.com/unitelabs/cdk/python-cdk){rel=""nofollow,noopener""} (Python) that is based on the SiLA 2 standard or any other SiLA 2 reference implementation. Development is performed by UniteLabs but can also be done by any third party or internally. We gather standard-compliant connectors on our [UniteLabs Hub](https://unitelabs.io/hub){rel=""nofollow,noopener""}. For new developments, we recommend using our [Connector Development Kit](https://gitlab.com/unitelabs/cdk/python-cdk){rel=""nofollow,noopener""} (Python, SiLA 2 1.1). Other basic SiLA 2 frameworks include: - [SiLA Tecan](https://gitlab.com/SiLA2/vendors/sila_tecan){rel=""nofollow,noopener""} (C#, by Tecan, SiLA 2 1.1) - [SiLA Java](https://gitlab.com/SiLA2/sila_java){rel=""nofollow,noopener""} (Java, by SiLA, SiLA 2.1.1) - [SiLA C#](https://gitlab.com/SiLA2/sila_csharp){rel=""nofollow,noopener""} (C#, by Inheco, SiLA 2 1.0) - [SiLA C++](https://gitlab.com/SiLA2/sila_cpp){rel=""nofollow,noopener""} (C++, by Cetoni, SiLA 2 1.0) ## Connector Development Kit (CDK) You can find the source code in the [UniteLabs Connector Development Kit repository](https://gitlab.com/unitelabs/cdk/python-cdk){rel=""nofollow,noopener""}. The documentation includes a [step-by-step tutorial](https://docs.unitelabs.io/connector-development/tutorial/walkthrough/) on how to develop a connector starting with the [Connector Factory](https://gitlab.com/unitelabs/cdk/connector-factory){rel=""nofollow,noopener""} cookiecutter template. All code examples from the tutorial are contained in the [Tutorial Connector](https://gitlab.com/unitelabs/examples/tutorial){rel=""nofollow,noopener""} which you can download and run side-by-side with the tutorial. ### SiLA Browser The open-source [UniteLabs SiLA Browser](https://gitlab.com/unitelabs/integrations/sila2/sila-browser){rel=""nofollow,noopener""} is a lightweight web application that can be used to auto-discover and test SiLA-compliant servers in the local network. Once a connection is established, the server can be tested interactively. The SiLA Browser repository contains detailed [documentation](https://gitlab.com/unitelabs/integrations/sila2/sila-browser/-/blob/main/README.md?ref_type=heads){rel=""nofollow,noopener""} on the installation procedure including a user manual. ### SiLA Python This [SiLA 2 Python repository](https://gitlab.com/unitelabs/sila2/sila-python){rel=""nofollow,noopener""} is an un-opinionated SiLA 2 library written in Python. It is a core dependency of the Connector Framework. Usage of the Connector Framework does not require familiarity with the SiLA Python implementation. Taking a deep-dive into this repository is only recommended for advanced connector developers. ### SiLA JS This [SiLA 2 JS repository](https://gitlab.com/unitelabs/sila2/sila-js){rel=""nofollow,noopener""} is an un-opinionated SiLA 2 library written in JavaScript. This is one of our developer libraries that only implements the client side of the SiLA communication. SiLA JS has not been released yet. ::callout{:to='null' icon="i-heroicons-light-bulb" target="_blank"} We are working towards a comprehensive Connector Development Kit (CDK) that includes many more tools for e.g. a SiLA XML to SiLA Python converter, a library of feature templates, a Python implementation of OPC-UA LADS, a data model builder for OPC-UA servers, an in-built OPC-UA universal client. :: The UniteLabs Connector Development Kit ([CDK on GitLab](https://gitlab.com/unitelabs/cdk/python-cdk){rel=""nofollow,noopener""}) is a free and open-source kit that enables you to build connectors for laboratory hard- and software services with interfaces that are based on industry standards like [SiLA 2](https://sila-standard.com){rel=""nofollow,noopener""}. Use this framework to build connectors with a SiLA interface as a wrapper around proprietary interfaces or to build an interface that runs natively on your device. This framework allows quick development with our intuitive, code-first approach. There is no need for a deep-dive into the standard specifications as we did that for you. The benefits of our framework are: - SiLA 2 1.1 compliant - Cloud-connectivity - Library of standard, core, and custom features - A code-first approach (No interface definition in XML!) - Stable releases with good test coverage - Maintenance and updates - An open-source project with a growing community and a battle-tested implementation At UniteLabs, we believe that the adoption of standards in the field requires excellent tooling that is aimed at developers. Our opinionated kit aims at reducing the friction of developing hard- and software interfaces in the lab space. The UniteLabs CDK relies on our own [sila-python](https://gitlab.com/unitelabs/sila2/sila-python){rel=""nofollow,noopener""} library that abstracts and implements all SiLA-related logic. Within our framework, we add some magic that makes building a new connector straightforward. You can take a look at our [tutorials](https://docs.unitelabs.io/connector-development/tutorial/walkthrough/) for more information on specific topics. ### About nomenclature Connectors, SiLA servers, servers, and drivers are terms that are often used interchangeably by the community, which can be confusing as there are subtle differences in what they refer to. The term "**server**" is a general computing concept referring to a computer or a software application that provides services or resources to other devices or software applications, often over a network. **SiLA Server** specifically refers to a software implementations that conform to the SiLA standard. A **connector**, on the other hand, is a software application that provides an interface and establishes a connection to other systems. Its underlying logic is not restricted to a single standard such as SiLA, but may also implement other standards, such as OPC-UA. Therefore, a UniteLabs connector contains a server, which could be either a SiLA server or an OPC-UA server (in the future), as well as additional features that go beyond the standard specification. In the realm of laboratory automation, the term "**driver**" typically refers to software that enables communication and control of specific laboratory instruments or devices. However, this is a very general term and we try to avoid using it altogether. For a quick start on how to build a basic connector, simply read on! # What's new April 2026 ## UniteLabs CDK v0.9.0 Release Notes ### New Features - Added support for python Enum types, which can now be used as parameters and responses for SiLA endpoints. Enums are a powerful way to constrain inputs and outputs to a specific set of values and can help improve the readability and usability of your connector. See our [Enum Tutorial](https://docs.unitelabs.io/connector-development/tutorial/data-types#enumerations) for more information about using Enums in your connectors. --- December 2025 ## UniteLabs CDK v0.5.0 Release Notes ### New Features - Modernized connector documentation utilities to enable [google-style docstrings](https://google.github.io/styleguide/pyguide.html){rel=""nofollow,noopener""}. See our [SiLA Feature Documentation Transition Guide](https://docs.unitelabs.io/connector-development/tutorial/sila-endpoints#transitioning-to-v050) for more information about upgrading your connectors. - Completely reworked configuration system, see our [Configuration Guide](https://docs.unitelabs.io/connector-development/guides/configuration) for more details. - Updated `connector start` CLI to accept a config file. - Updated `certificate generate` CLI to accept a config file, see our updated [Security Guide](https://docs.unitelabs.io/connector-development/guides/security). ### Deprecations - `sila.Parameter`, `sila.Response`, and `sila.IntermediateResponse` decorators deprecated in favor of [google-style docstring](https://google.github.io/styleguide/pyguide.html){rel=""nofollow,noopener""} annotations. - `.. parameter::`, `.. return::` and `.. yields::` docstring directives deprecated in favor of [google-style docstring](https://google.github.io/styleguide/pyguide.html){rel=""nofollow,noopener""} annotations. - `.env`-based configuration system using `cdk.Config` deprecated in favor of `cdk.ConnectorBaseConfig`, see our [Configuration Transition Guide](https://docs.unitelabs.io/connector-development/guides/configuration#transitioning-to-v050) for more information about upgrading your connectors. - `connector start` CLI options `--tls/--no-tls`, `--cert`, `--key`, and `--log-config` deprecated in favor of `cdk.ConnectorBaseConfig`. ## New Tutorial Connector Check out our new [Tutorial Connector](https://gitlab.com/unitelabs/examples/tutorial){rel=""nofollow,noopener""}, which is designed to help you get started quickly with the UniteLabs CDK by demonstrating best practices and common patterns and includes all example code used in the [Tutorial Walkthrough](https://docs.unitelabs.io/connector-development/tutorial/walkthrough/). --- April 2025 Check out our latest update to the [connector-factory](https://gitlab.com/unitelabs/cdk/connector-factory){rel=""nofollow,noopener""} template project. - Expanded supported package managers to include: `poetry`, `uv`, and `hatch`. - Loosened python version requirements such that new projects are created with whatever python version is globally available. - Improved automated testing with python version matrixing. - Expanded `ruff` linting and formatting rulesets. - Established `pre-commit` config to apply standard checks like preventing secrets from being uploaded and linting, as well as some highly-opinionated hooks, such as preventing commits to `main`-branch and enforcing [conventional-commit](https://www.conventionalcommits.org/en/v1.0.0/){rel=""nofollow,noopener""} message style. - Updated VSCode `settings.json` to enable integrated test-coverage reporting and linting, and improve editor auto-completions. - Added a VSCode `launch.json` for test debugging and `connector start`. # Installation We recommend starting with our [Connector Factory](https://gitlab.com/unitelabs/cdk/connector-factory){rel=""nofollow,noopener""} cookiecutter. To verify your installation, you can run the connector and start the SiLA Browser locally. Refer to the [SiLA Browser guide on GitLab](https://gitlab.com/unitelabs/sila2/sila-browser){rel=""nofollow,noopener""} for further instructions. ## Prerequisites Before getting started your first connector, ensure you have the following prerequisites installed on your system: - **Python 3.10+**: Ensure that Python 3.10 or later is installed. We suggest the most recently released version of CPython for the best feature coverage and highest security. You can download Python from [python.org](https://www.python.org/downloads/){rel=""nofollow,noopener""}. - **Git**: Ensure that Git is installed for version control. You can download the latest version from [git-scm.com](https://git-scm.com/install){rel=""nofollow,noopener""}. - **pipx**: Ensure that `pipx` is installed to manage your global python environment and dependencies. This can be installed using `pip` after successfully installing Python: ```bash \[Terminal] pip install pipx pipx ensurepath ``` ::callout{color="amber" icon="i-heroicons-exclamation-triangle"} Calling `pipx ensurepath` will add the `pipx` binary to your system's PATH. This is required for `pipx` to be available in your terminal. :: - **Cruft**: Ensure that `cruft` is installed to create boilerplate for new connector projects. This can be installed using `pipx` to make it available system-wide: ```bash \[Terminal] pipx install cruft ``` :callout[**Using uv?** You can skip installing pipx entirely and run cruft directly with uv: `uvx cruft create https://gitlab.com/unitelabs/cdk/connector-factory.git`. Jump straight to [Setup your Environment Manager](https://docs.unitelabs.io/#0-setup-your-environment-manager).]{color="info" icon="i-heroicons-light-bulb"} ## 0. Setup your Environment Manager Our `connector-factory` supports three popular environment/package managers: `uv`, `hatch`, and `poetry`. Check out our [Library Comparison](https://docs.unitelabs.io/connector-development/getting-started/installation/) of the three tools to learn more about the differences and decide which tool is right for you. ::code-group ```bash [uv] pipx install uv ``` ```bash [hatch] pipx install hatch hatch config set dirs.env.virtual .venv ``` ```bash [poetry] pipx install poetry poetry config virtualenvs.in-project true ``` :: If you are using `uv` or `hatch` and plan to use the Omnibus for the device communication layer of your connector, you will need to set up a `.netrc` file in your home directory with your GitLab credentials. This can be done by running the following command: - On Linux or macOS: ```bash \[Terminal] cat < ~/.netrc machine gitlab.com login password EOF ``` - On Windows: ```powershell \[Terminal] @" machine gitlab.com login password "@ | Out-File -FilePath "$env:USERPROFILE\.netrc" -Encoding ASCII ``` Note that the `` placeholder should be replaced with your GitLab username and `` placeholder should be replaced with your GitLab Personal Access Token (PAT). ## 1. Generate a New Connector Project Use Cruft to create a new connector project: ```bash cruft create https://gitlab.com/unitelabs/cdk/connector-factory.git ``` It prompts you to enter some basic information about your project, such as: - **Connector Name:**: The name of your instrument to connect. - **Description**: A description of the instrument's main purpose. - **Communication Type**: The hardware communication protocol (e.g., none, Serial, USB, TCP, UDP). ::callout{color="amber" icon="i-heroicons-exclamation-triangle"} The “No communication required” option is the only one that lets you run the connector without additional configuration, so choose this option for now. :: This creates a structured project directory. The exact layout depends on the communication type you chose: ::code-group ```text [No communication] ├── project-name ├── src/unitelabs/package_name ├── __init__.py └── __main__.py ├── tests ├── __init__.py ├── conftest.py └── test_version.py ``` ```text [Serial / USB / TCP / UDP] ├── project-name ├── src/unitelabs/package_name ├── __init__.py ├── __main__.py └── io ├── __init__.py └── package_name_protocol.py ├── tests ├── __init__.py ├── conftest.py └── test_version.py ``` :: ## 2. Generate the Configuration File Before starting the connector, you must generate a configuration file. Navigate into your project directory and run the `config create` command: ::code-group ```bash [uv] cd project-name uv run config create --app unitelabs.${your_package_name_here}:create_app ``` ```bash [hatch] cd project-name hatch run config create --app unitelabs.${your_package_name_here}:create_app ``` ```bash [poetry] cd project-name poetry run config create --app unitelabs.${your_package_name_here}:create_app ``` :: ::callout{color="info" icon="i-heroicons-light-bulb"} **Naming convention:** The project directory uses hyphens (e.g., `awesome-instrument`), while the Python package uses underscores (e.g., `awesome_instrument`). When you see `package_name` in commands below, replace it with your package name using underscores. For example, if you named your connector "Awesome Instrument", use `unitelabs.awesome_instrument:create_app`. :: This creates a `config.json` file in the current directory with default settings. See the [Configuration Guide](https://docs.unitelabs.io/connector-development/guides/configuration/) for more details on customizing your configuration. ## 3. Verify the Installation Run the connector to ensure everything is working: ::code-group ```bash [uv] uv run connector start --app unitelabs.${your_package_name_here}:create_app ``` ```bash [hatch] hatch run connector start --app unitelabs.${your_package_name_here}:create_app ``` ```bash [poetry] poetry run connector start --app unitelabs.${your_package_name_here}:create_app ``` :: After starting the connector, you should see output similar to this in your terminal: ```bash [Terminal] INFO: Starting SiLA 2 server... INFO: Server listening on 0.0.0.0:50052 INFO: Registered feature: de.unitelabs.feature.AwesomeInstrument (1 command, 0 properties) INFO: Connector ready ``` The exact feature name and port will match your connector's configuration. If you see `Connector ready`, your environment is set up correctly. You can then verify it in the SiLA Browser — it should appear as a discovered device on the local network. ## Next Steps With your environment set up, you can proceed to modify and enhance the existing connector. Check out our [Walkthrough](https://docs.unitelabs.io/connector-development/tutorial/walkthrough/) for a comprehensive guide to connector development. # Contributing ## You want to contribute to the CDK? The CDK is a open-source project maintained by UniteLabs. If you encounter any bugs in our code or have suggestions for improvements or new features let us know by [creating an issue on GitLab](https://gitlab.com/unitelabs/cdk/python-cdk/-/issues){rel=""nofollow,noopener""}! # Walkthrough Once you have installed the framework and created your first project with the `connector-factory`, you're ready to go. You may wish to start with installing our [Tutorial Connector](https://gitlab.com/unitelabs/examples/tutorial){rel=""nofollow,noopener""} which contains all example code shown throughout the tutorial. ## Connector-factory Output For this example, we will use `uv` as our environment manager and `serial` as our communication type. First we can check out the starter protocol generated by the `connector-factory`, located in the `io` directory. ```text └── awesome-instrument └── src/unitelabs/awesome_instrument └── io └── awesome_instrument_protocol.py ``` ::card{icon="i-heroicons-light-bulb" title="Note"} The file and directory names will be based on the inputs provided to `connector-factory` and will not look *exactly* as above, but will still be in the same location. :: The file contents should look as follows: ```python [awesome-instrument/src/unitelabs/awesome_instrument/io/awesome_instrument_protocol.py] from unitelabs.bus import Protocol, create_serial_connection class AwesomeInstrumentProtocol(Protocol): def __init__(self, **kwargs): kwargs["port"] = "/dev/ttyUSB0" # FIXME: set device port super().__init__(create_serial_connection, **kwargs) ``` ::card{icon="i-heroicons-code-bracket-square" title="Code explanation"} - Here we see that a transport factory function `create_serial_connection` has been passed into the `Protocol` constructor. - A transport factory creates a `Transport` that establishes a connection to the device when `Protocol.open()` is called and requires arguments to be passed in via `**kwargs` in the `Protocol` constructor. For serial communication the `create_serial_connection` transport factory requires `port` to be specified. :: ## Protocol Configuration As mentioned above, each transport factory requires that certain arguments be provided in the `Protocol.__init__`. Before running the connector, ensure you have generated a configuration file (see the [Configuration Guide](https://docs.unitelabs.io/connector-development/guides/configuration/) for details): ```bash uv run config create --app unitelabs.awesome_instrument:create_app ``` At this point if we run ::code-group ```bash [MacOS/Linux] uv run connector start ``` ```bash [Windows] uv run connector start --app unitelabs.awesome_instrument ``` :: A `SerialException` is raised informing us that connection to the specified serial device could not be established. For serial and USB communication we can use the `DeviceManager` to see a listing of all the available devices by calling ::callout{color="amber" icon="i-heroicons-exclamation-triangle"} This method has been thoroughly validated only for serial devices on unix-based machines. Users may experience issues finding USB devices, i.e. not all USB devices will show up in the `DeviceManager`. Not supported on Windows machines. :: ```bash [Terminal] uv run python -m unitelabs.bus.utils.device_manager_cli ``` This returns information about connected devices. Here the connected device allows both serial and USB communication. ```json [ { "device": "/dev/cu.usbmodem142401", "name": "cu.usbmodem142401", "description": "Board in FS mode", "hwid": "USB VID:PID=CAFE:0001 SER=e66180109f6f8025 LOCATION=20-2.4", "vid": 51966, "pid": 1, "serial_number": "e66180109f6f8025", "location": "20-2.4", "manufacturer": "MicroPython", "product": "Board in FS mode" } ] ``` The `device` here is the serial port value we need in order to configure our transport factory. ```python [awesome-instrument/src/unitelabs/awesome_instrument/io/awesome_instrument_protocol.py] from unitelabs.bus import Protocol, create_serial_connection class AwesomeInstrumentProtocol(Protocol): def __init__(self, **kwargs): kwargs["port"] = "/dev/cu.usbmodem142401" super().__init__(create_serial_connection, **kwargs) ``` With the port value set we should be able to successfully run the `connector start` command and start our SiLA server. It should be noted, however, that serial ports are likely to change (unless you have set up some udev rules on your computer), meaning that this will not be a stable connection. We can now test our basic set up by starting the connector: ::code-group ```bash [MacOS/Linux] uv run connector start ``` ```bash [Windows] uv run connector start --app unitelabs.awesome_instrument ``` :: The server is now running and listening for SiLA client connections on port 50052. With our Protocol configured, it's time to start communicating with the device. You can read more about the [Basics of Hardware Communication Here](https://docs.unitelabs.io/connector-development/tutorial/hardware-communication/). ## Protocol Method Definition To get started we'll create our first Protocol method using a basic `ByteCommand`. Let's assume a device that sends either a response of `b"ok"` or `b"not ok"` to the request `b"status"`. First, we'll create an exception, which is displayed in the SiLA client with a description based on the class's docstring. ```python [awesome-instrument/src/unitelabs/awesome_instrument/io/errors.py] class NotOkException(Exception): """The device is not ok.""" ``` Then we'll use the exception in our Protocol to ensure that all calls to device which respond with `b"not ok"` give direct feedback of the device's status. [Read More about error handling here!](https://docs.unitelabs.io/connector-development/guides/error-handling/) ```python [awesome-instrument/src/unitelabs/awesome_instrument/io/awesome_instrument_protocol.py] import typing from unitelabs.bus import ByteCommand, Protocol, create_serial_connection from .errors import NotOkException class AwesomeInstrumentProtocol(Protocol): def __init__(self, **kwargs): kwargs["port"] = "/dev/cu.usbmodem142401" super().__init__(create_serial_connection, **kwargs) async def get_status(self, timeout: typing.Optional[float] = None) -> bytes: """ Get the device's status. Args: timeout: Amount of time in seconds to wait for a response, or indefinitely if None. Raises: NotOkException: If the device responds with b"not ok". Returns: b"ok" if everything is ok """ command = ByteCommand(b"status", timeout) response = await self.execute(command) self.logger.info(f"status: {response}") if response == b"not ok": raise NotOkException("Everything is not ok.") return response ``` With this method added to our Protocol we can now call `get_status()` and respond to the data received from the device. ::card{icon="i-heroicons-light-bulb" title="Note"} If the device does not recognize a request bytestring, no response will be sent. By default if no `timeout` for a `Command` is set, the `Protocol` will wait *indefinitely* for a response. :: ## Creating a SiLA Feature This next step is to create a SiLA Feature with a method for accessing our status information. ```python [awesome-instrument/src/unitelabs/awesome_instrument/features/device_controller/device_controller.py] import typing from unitelabs.awesome_instrument.io.errors import NotOkException from unitelabs.cdk import sila if typing.TYPE_CHECKING: from ...io.awesome_instrument_protocol import AwesomeInstrumentProtocol class DeviceController(sila.Feature): """Control the device and monitor its state.""" def __init__(self, protocol: "AwesomeInstrumentProtocol"): super().__init__( originator="org.silastandard", category="examples", version="0.1", maturity_level="Draft", ) self.protocol = protocol ``` The class docstring of `DeviceController` will become the feature description. Originator, category, version, and maturity level are all required arguments that must be passed to the `super().__init__(...)` call. This basic information is defined by the SiLA 2 standard and provides SiLA clients the basic feature information. Next we will add an `UnobservableProperty` to the Feature which calls the underlying `AwesomeInstrumentProtocol.get_status` method from our Protocol. ```python [device_controller.py] @sila.UnobservableProperty() async def get_status(self) -> bytes: """ Description of the property, viewable from the SiLA client. Raises: NotOkException: Conditions under which it is raised and user feedback about how to fix and/or avoid the error. """ return await self.protocol.get_status(timeout=5) ``` ::card{icon="i-heroicons-code-bracket-square" title="Code explanation"} Generally speaking, most of the business logic of a connector will be contained within the Protocol. When writing a SiLA Feature, the primary concern is improving the input parameterization and output structures for users, e.g. with [Structures](https://docs.unitelabs.io/connector-development/tutorial/data-types#structures) to humanize data inputs and outputs from the device. :: ## Integrate SiLA Feature With a new feature defined, we'll need to register this in on our SiLA server in the project `__init__.py` ```python [awesome-instrument/src/unitelabs/awesome_instrument/__init__.py] import collections.abc from importlib.metadata import version from .features.device_controller.device_controller import DeviceController from .io.awesome_instrument_protocol import AwesomeInstrumentProtocol from .config import AwesomeInstrumentConfig __version__ = version("unitelabs-awesome-instrument") async def create_app(config: AwesomeInstrumentConfig) -> collections.abc.AsyncGenerator[Connector]: """Creates the connector application""" app = Connector(config) protocol = AwesomeInstrumentProtocol() await protocol.open() app.register(DeviceController(protocol)) yield app protocol.close() ``` Calling `app.register` with an instance of our new SiLA Feature, we should now be able to see our feature and interact with it on the SiLA Browser. ## Simulation Mode Best practices include the development of a Simulation Mode that mocks device communication and allows the testing of the UI without access to a physical device. ::callout{icon="i-heroicons-wrench-screwdriver"} **Coming Soon**: We are already working on a more comprehensive guide to writing device simulations with the CDK `SimulationControllerBase`. :: # SiLA Endpoints SiLA makes the distinction between [Commands](https://docs.google.com/document/d/1nGGEwbx45ZpKeKYH18VnNysREbr1EXH6FqlCo03yASM/edit?tab=t.0#heading=h.fx3j5r3j2sc8){rel=""nofollow,noopener""} and [Properties](https://docs.google.com/document/d/1nGGEwbx45ZpKeKYH18VnNysREbr1EXH6FqlCo03yASM/edit?tab=t.0#heading=h.s151a1gock2p){rel=""nofollow,noopener""} that commands may be parameterized, but properties may not. At UniteLabs we take a different philosophical approach to grouping functionality that distinguishes between [Data Endpoints](https://docs.unitelabs.io/connector-development/tutorial/data-endpoints/), which are used to return data to the user and [Controls](https://docs.unitelabs.io/connector-development/tutorial/controls/) which have real-world physical device interactions associated with them. Data endpoints may be represented by observable or unobservable properties as well as unobservable commands, whereas Controls may only be represented by Commands. Before we dive into the differences between these four groups, let's first learn about the shared attributes of SiLA Endpoints. ## Shared Attributes The UniteLabs CDK offers function decorators to create SiLA Commands and Properties which auto-generate SiLA identifiers, display names, and descriptions for methods, their parameters, and their returns based on a function's typing and its docstring representation, ensuring that our SiLA client is strongly typed with a human-readable interface. ```python from unitelabs.cdk import sila class CustomException(Exception): """Custom error message.""" @sila.SiLAMethod() async def method_name(self, arg: arg_type, ...) -> return_type: """ Top-level description of the method. Args: ArgIdentifier: Description of `arg`, which can extend over multiple lines. ... Returns: ReturnIdentifier: Description of return value. Raises: CustomException: Description of the conditions under which the error is raised, and how to fix and/or avoid it. """ ``` **Listing 1**: A general template for declaring Commands and Properties, where `SiLAMethod` represents one of `ObservableCommand`, `ObservableProperty`, `UnobservableCommand`, `UnobservableProperty`. ### Identifiers and Display Names Identifiers are auto-generated based on the method name. The method name `get_server_name` is turned into the property identifier "ServerName". Several prefixes are auto-detected and infer certain aspects of the method: - "get"-prefix is subtracted for unobservable properties - "subscribe"-prefix is subtracted for observable properties When allowing the CDK to derive its own identifiers and display names, acronyms and abbreviations that are part of the identifier and display name will be turned into lowercase words. A method named "get\_server\_uuid" is transformed into "ServerUuid" (identifier) and "Server Uuid" (display name) respectively. However, in some cases, like the SiLA Service feature, identifier and display name are defined as "ServerUUID" and "Server UUID". If an identifier is specified explicitly in the SiLA decorator, the display name is derived from it, and vice-versa. ```python @sila.UnobservableProperty(name="Server UUID") async def get_server_uuid(self) -> str: """Get the UUID for the server.""" ``` **Listing 2**: Explicitly specifying the display name of a `sila.UnobservableProperty`. All UniteLabs CDK SiLA decorators, i.e. `sila.ObservableCommand`, `sila.ObservableProperty`, `sila.UnobservableCommand`, `sila.UnobservableProperty`, accept `identifier`, `name`, and `errors` as parameters for manually setting these values, rather than having them be derived. These decorator arguments serve as an alternative to inference based on the function signature and docstrings, which follow a slightly modified google-doc style: ```python from unitelabs.cdk import sila class ConversionError(Exception): """Unable to convert value.""" @sila.ObservableCommand(name="Display Name Override") async def get_complex_value( self, param_a: str, param_b: str, *, status: sila.Status, intermediate: sila.Intermediate[int], ) -> tuple[str, int]: """ Create a complex value from two input values. Examples: Example for how to use this function: >>> complex = await self.get_complex_value("a", "2") >>> print(complex) ("a", 2) Args: ParamA: Description of `param_a`, where the identifier matches what would be inferred. SecondParam: Description of `param_b`, with an overridden display name of 'Second Param'. Yields: NamedIntermediateValue: Description of intermediate integer value. Returns: ReturnValueA: Description of the returned string value from `param_a`, with the display name 'Return Value A'. IntValue: Description of the returned integer value from `param_b`, with the display name 'Int Value'. Raises: ConversionError: If `param_b` is not convertible into an integer. """ ... ``` **Listing 3**: Maximal example of docstring functionality, with parameters, responses, and intermediate responses, as well as examples and error descriptions. ### Parameters Parameters can be inferred based on the type-hints provided in the function declaration. These type-hints must be combined with annotation of the parameters' human-readable name and description, which can be set using the `Args` docstring section. Parameters must be declared in the order that they are declared on the function. ```python [src/unitelabs/tutorial/features/sila_endpoints.py] @sila.UnobservableCommand() async def parameter_example(self, seed: int) -> float: """ Stably generate a random number. Args: Seed: Number used to initialize the pseudorandom number generator. Returns: RandomNumber: A randomly generated number. """ import random random.seed(seed) return random.random() ``` **Listing 4**: Declaring user input parameters for a SiLA Command. Parameter types must be valid [SiLA Data Types](https://docs.unitelabs.io/connector-development/tutorial/data-types). ### Responses Responses are inferred by the specified return type. Responses can be annotated within the `Returns` section of a docstring. While properties must only have one response, commands may have multiple responses. Multiple responses can be described simply by explicitly declaring the identifiers and descriptions in the `Returns` section. In this case, the method should return a tuple of all responses and the return statement looks like this: ```python [src/unitelabs/tutorial/features/sila_endpoints.py] @sila.UnobservableCommand() async def responses_example(self) -> tuple[int, str]: """ Create random stuff. Returns: Answer: The answer to the Ultimate Question of Life, the Universe, and Everything. RandomFact: Some random fact to always keep in mind. """ return 42, "Don't Panic!" ``` **Listing 5**: Declaring response values for a SiLA method. Return types must be valid [SiLA Data Types](https://docs.unitelabs.io/connector-development/tutorial/data-types). ::callout{color="green" icon="i-heroicons-magnifying-glass"} Technical Hint: While SiLA Properties may only return a single response, a dataclass allows you to return a single response with multiple named values. In comparison, SiLA Commands may use the `tuple` type annotation in combination with display names declared in the docstring to return multiple responses without a dataclass. :: ## Observable Properties An observable property is a property that can be read at any time and that offers a subscription mechanism to observe any change of its value. ::callout{color="green" icon="i-heroicons-magnifying-glass"} For more information about how to create and manage observable data sources, check out our [Subscriptions Guideline](https://docs.unitelabs.io/connector-development/guides/subscriptions/). :: Add the `ObservableProperty` decorator to a method to turn it into an observable property. ```python [src/unitelabs/tutorial/features/sila_endpoints.py] @sila.ObservableProperty() async def subscribe_random_numbers(self) -> sila.Stream[int]: """Stream of random numbers.""" import asyncio import random while True: yield random.randint(0, 42) await asyncio.sleep(1) ``` **Listing 6**: A basic SiLA ObservableProperty, where display names and identifiers are inferred. ::callout{color="green" icon="i-heroicons-magnifying-glass"} Technical Hint: The `sila.Stream` annotation is an alias for `collections.abc.AsyncGenerator`. Python does not allow `return` statements in async generators, therefore only `yield` is allowed. :: ## Unobservable Properties An unobservable property is a property that can be read at any time, but no subscription mechanism is provided to observe its changes. Add the `UnobservableProperty` decorator to a method to turn it into an unobservable property. ```python [src/unitelabs/tutorial/features/sila_endpoints.py] @sila.UnobservableProperty() async def get_random_number(self) -> int: """A single random number.""" import random return random.randint(0, 42) ``` **Listing 7**: A basic SiLA UnobservableProperty, where display names and identifiers are inferred. Unlike the observable property from the previous section, this unobservable property does not provide a stream of constantly updating values but only returns `get_random_number` once. Refinements to the identifier for SiLA Properties requires setting the `name` or `identifier` in the sila decorator: ```python [src/unitelabs/tutorial/features/sila_endpoints.py] @sila.UnobservableProperty(name="Number") async def get_random_number(self) -> int: """Get a single random number.""" ``` **Listing 8**: Declaring a SiLA Property, with an explicit named response which differs from the method name, i.e. without inference. ## Unobservable Commands An unobservable command is a command that triggers an action on the device and returns at most a single response. UnobservableCommands may act as both Data Endpoints and Controls, depending on their functionality. Unobservable commands are best used for parameterized data accession and changing settings on the server, i.e. passing data from Client to Server or vis-versa, as these actions are short-lived and respond immediately. Add the `UnobservableCommand` decorator to a method to turn it into an unobservable command. ```python [src/unitelabs/tutorial/features/sila_endpoints.py] @sila.UnobservableCommand() async def perform_action(self, name: str) -> tuple[bool, datetime.datetime]: """ Perform the action on the device identified by `name`. Args: ActionName: The name of the action to be performed. Returns: Success: Whether or not the action was successful. SucceedTime: The timestamp for when the action succeeded. """ ... ``` **Listing 9**: Declaring a SiLA UnobservableCommand. ::callout{color="green" icon="i-heroicons-magnifying-glass"} Technical Hint: Remember that here a single response means only one time. The type of the response may be a complex, inferred data structure (with `tuple`) or a dataclass, thus allowing multiple values to be sent at once. :: Here we attempt to perform a named action and report whether or not that named action was successfully performed. The action does not report back any intermediary values and thus is unobservable. As noted, unobservable commands must not necessarily act as controls, but may also be functions which have input parameters (which are not allowed for SiLA properties) and use those parameters to selectively get information from the device, i.e. extracting a single value from a dictionary based on its key. ```python [src/unitelabs/tutorial/features/sila_endpoints.py] @sila.UnobservableCommand() async def get_variable_value(self, variable: str) -> str: """ Read out the variable value for the currently active configuration. Args: VariableName: The name of the variable in the active configuration to get the value for. Returns: Value: The variable value. """ return self.active_config.get(variable, "") ``` **Listing 10**: Declaring a SiLA UnobservableCommand which acts as a Data Endpoint. ## Observable Commands An observable command is a command that triggers and action on the device and can be subscribed. During the subscription, intermediate results can be returned that contain information on the command execution status, such as remaining execution time, progress and any other arbitrary data useful for consumers of the method. Any action which does not return an immediate result is best made observable to ensure completion of the command execution and prevent loss of data that could result from a SiLA Client timing out or disconnecting. Add the `ObservableCommand` decorator to a method to turn it into an observable command. ```python [src/unitelabs/tutorial/features/sila_endpoints.py] @sila.ObservableCommand() async def iterative_method( self, seed: int, iterations: int, *, status: sila.Status, intermediate: sila.Intermediate[int], ) -> float: """ Creates a random number based on a `seed` after `iterations`. Args: Seed: Number used to initialize the pseudorandom number generator. NumberOfIterations: How many times to iterate before number generation. Yields: CurrentIteration: The current iteration. Returns: RandomNumber: A random number. """ import asyncio import random random.seed(seed) total = iterations + 1 for x in range(1, total): intermediate.send(x) status.update(progress=(x / total)) await asyncio.sleep(1) return random.random() ``` **Listing 11**: Declaring a SiLA ObservableCommand. Observable command functions may take the following keyword arguments: - `status: sila.Status` - `intermediate: sila.Intermediate[T]` where `T` is a valid SiLA data type If the function signature contains these two arguments, one may then use the objects passed in to send status updates with `status.update()` or to send `IntermediateResponse` values via `intermediate.send()`. ::callout{color="green" icon="i-heroicons-magnifying-glass"} For more information about how to create and manage observable data sources, check out our [Subscriptions Guideline](https://docs.unitelabs.io/connector-development/guides/subscriptions/). :: ## Transitioning to V0.5.0 Prior to V0.5.0, the CDK support a directive-style docstring declaration, which has been deprecated and will raise a `DeprecationWarning` for all usages. Pre-V0.5.0 also supported a decorator-based declaration for parameters, responses, and intermediate responses, which have been completely removed, and will cause an `ImportError`. All directives and decorators have been replaced with our googleDoc-style docstring inference system. For example instead of `sila.Parameter` or the Parameter `parameter` directive ::code-group ```python [Parameter decorator (pre-V0.5.0)] @sila.Parameter(identifier="Human-ReadableName", name="Human-Readable Name", description="Description of the parameter.") @sila.UnobservableProperty(description="Description of the method.") async def get_value(self, arg: arg_type) -> return_type: ... ``` ```python [parameter directive (pre-V0.5.0)] @sila.UnobservableProperty() async def get_value(self, arg: arg_type) -> return_type: """ Description of the method. .. parameter:: Description of the parameter. :name: Human-Readable Name :identifier: Human-ReadableName """ ``` ```python [docstring equivalent (V0.5.0)] @sila.UnobservableProperty() async def get_value(self, arg: arg_type) -> return_type: """ Description of the method. Args: Human-ReadableName: Description of the parameter. """ ``` :: Similarly for `sila.Response` and the `return` directive: ::code-group ```python [Response decorator (pre-V0.5.0)] @sila.Response(name="Return Value Display Name", description="Description of the response.") @sila.UnobservableProperty(description="Description of the method.") async def get_value(self) -> return_type: ... ``` ```python [return directive (pre-V0.5.0)] @sila.UnobservableProperty() async def get_value(self) -> return_type: """ Description of the method. .. return:: Description of the response. :name: Return Value Display Name """ ``` ```python [docstring equivalent (V0.5.0)] @sila.UnobservableProperty() async def get_value(self) -> return_type: """ Description of the method. Returns: ReturnValueDisplayName: Description of the response. """ ``` :: And `sila.IntermediateResponse` and the `yields` directive: ::code-group ```python [IntermediateResponse decorator (pre-V0.5.0)] @sila.IntermediateResponse(name="Intermediate Value Display Name", description="Description of yielded intermediate response.") @sila.ObservableCommand(description="Description of the method.") async def get_value(self, intermediate: sila.Intermediate[intermediate_type]) -> return_type: ... ``` ```python [yields directive (pre-V0.5.0)] @sila.ObservableCommand() async def get_value(self, intermediate: sila.Intermediate[intermediate_type]) -> return_type: """ Description of the method. .. yields:: Description of yielded intermediate response. :name: Intermediate Value Display Name """ ``` ```python [docstring equivalent (V0.5.0)] @sila.ObservableCommand() async def get_value(self, intermediate: sila.Intermediate[intermediate_type]) -> return_type: """ Description of the method. Yields: IntermediateValueDisplayName: Description of yielded intermediate response. """ ``` :: ::callout{color="amber" icon="i-heroicons-exclamation-triangle"} Types should never be included in docstrings, as these are already documented in the function signature. The UniteLabs docstring format follows the [Google StyleGuide](https://google.github.io/styleguide/pyguide.html){rel=""nofollow,noopener""}. Our system relies on the presence of types in the function signature to determine the SiLA data types for parameters, intermediate responses, and response values. :: \:: # Data Endpoints Data endpoints represent the subset of SiLA endpoints which return data to the user. [Observable Properties](https://docs.unitelabs.io/connector-development/tutorial/sila-endpoints#observable-properties) and [Unobservable Properties](https://docs.unitelabs.io/connector-development/tutorial/sila-endpoints#unobservable-properties) as well as [Unobservable Commands](https://docs.unitelabs.io/connector-development/tutorial/sila-endpoints#unobservable-commands) may all be used to create Data Endpoints depending on the function being wrapped. In this context, properties are relatively clear in their data extraction functionality. Unobservable commands, however, could be functions which have input parameters (which are not allowed for SiLA properties) and use those parameters to selectively get information from the device, i.e. extracting a single value from a dictionary based on its key. The following use case exemplifies the distinction we want to make between data endpoints and controls. ```python [src/unitelabs/tutorial/features/sila_endpoints.py] @sila.UnobservableCommand() async def get_variable_value(self, variable: str) -> str: """ Read out the variable value for the currently active configuration. Args: VariableName: The name of the variable in the active configuration to get the value for. Returns: Value: The variable value. """ return self.active_config.get(variable, "") ``` The above function does not fit into the SiLA definition of a property because it is parameterized, however its functionality more closely matches that of properties conceptually as static information providers. With this distinction in mind, we will proceed to a real world example and implement some data endpoints. ## Implementation Throughout this and the next tutorial we will use the example of a clock to understand the differences between different SiLA methods. We will implement a SiLA Clock Feature and then create data endpoints for each of the three types. ### The feature class For our feature class we will start by declaring methods that we will later implement on the feature. ```python [src/unitelabs/tutorial/features/clock_controller.py] import asyncio import typing from unitelabs.cdk import sila from unitelabs.cdk.sila import datetime class ClockController(sila.Feature): def __init__(self): super().__init__( originator="io.unitelabs", category="clock", version="1.0", maturity_level="Draft", ) self._tz: typing.Optional[datetime.tzinfo] = None @sila.UnobservableProperty() async def get_current_timestamp(self) -> datetime.datetime: """Get the current time.""" @sila.ObservableProperty(name="Running Clock") async def subscribe_current_timestamp(self) -> sila.Stream[datetime.datetime]: """Subscribe to the current time.""" @sila.UnobservableCommand() async def get_time_difference(self, future_time: datetime.time) -> int: """ Get the difference in seconds between given `future_time` and the current time. Args: FutureTime: The future time to compare the current time with. Returns: TimeDifference: The time difference in seconds. """ ``` For each method, we have so far defined: - name - method type (Observable vs. Unobservable, Property vs. Command) - parameters, if any - return values, if any - type hints (type of the parameters & return variables) - documentation (docstring used to specify functionality in a human-readable way) ### UnobservableProperty The first method we will implement is the unobservable property `get_current_timestamp`. Here is the stub we produced above. ```python [src/unitelabs/tutorial/features/clock_controller.py] @sila.UnobservableProperty() async def get_current_timestamp(self) -> datetime.datetime: """Get the current time.""" ``` In this case, the structure that has been defined is: - name: get\_current\_timestamp - method type: unobservable property - parameters: only the required self (properties don't allow input parameters) - returns: a single return value named "Current Timestamp" (based on the name of the function - "get") - type hints: the return value is a `datetime.datetime`. - documentation: everything within the """ ... """. Additional documentation is inferred from the method name and type hints. Now to add the logic to make this functional: ```python [src/unitelabs/tutorial/features/clock_controller.py] @sila.UnobservableProperty() async def get_current_timestamp(self) -> datetime.datetime: """Get the current time.""" return datetime.datetime.now(self._tz) ``` We use the CDK's SiLA data types to convert our `datetime` into a SiLA-compatible data type. Here we are using the class's `_tz` attribute to set the timezone for our timestamp. By default our clock feature has no timezone, but users will be able to set this with the UnobservableCommand `set_timezone` which we will define later in the Control tutorial, meaning that our feature holds a particular state which can only be modified through usage of the feature. ### ObservableProperty The observable property we will implement is a method to subscribe to the clock's state, i.e. the current time. Subscribing means retrieving the state and continually receiving the updated state as soon as it changes. ```python [src/unitelabs/tutorial/features/clock_controller.py] @sila.ObservableProperty(name="Running Clock") async def subscribe_current_timestamp(self) -> sila.Stream[datetime.datetime]: """Subscribe to the current time.""" ``` One important thing to note here is the use of `name` with the `sila.ObservableProperty` decorator. Without explicitly setting the `name` here, this function would have the same identifier as our UnobservableProperty `get_current_timestamp`. Remember that the "get\_" of UnobservableProperties and the "subscribe\_" of ObservableProperties are by default stripped from names when creating unique identifiers for methods in a feature. In the case of namespace overlaps such as this, the ObservableProperty would override the UnobservableProperty in the SiLA client. By setting the `name` explicitly we avoid this namespace collision. Our implementation might be as follows: ```python [src/unitelabs/tutorial/features/clock_controller.py] class ClockController(sila.Feature): def __init__(self): ... @sila.ObservableProperty(name="Running Clock") async def subscribe_current_timestamp(self) -> sila.Stream[datetime.datetime]: while True: # without any pause this will very quickly send a lot of timestamps yield await self.get_current_timestamp() ``` This example is highly simplified for the purpose of showcasing the structure of an `ObservableProperty`. For more information about how to create and manage observable data sources, check out our [Subscriptions Guideline](https://docs.unitelabs.io/connector-development/guides/subscriptions/). ### UnobservableCommand Unobservable commands differ from properties in that they may accept parameters. In this way, they can be used in data endpoints to retrieve information which is modified by the input parameter(s). ```python [src/unitelabs/tutorial/features/clock_controller.py] @sila.UnobservableCommand() async def get_time_difference(self, future_time: datetime.time) -> int: """ Get the difference in seconds between given `future_time` and the current time. Args: FutureTime: The future time to compare the current time with. Returns: TimeDifference: The time difference in in seconds. """ now = datetime.datetime.now(self._tz) if future_time.tzinfo != self._tz: print(f"Replacing timezone with currently configured timezone {self._tz}.") future_time = future_time.replace(tzinfo=self._tz) future_dt = now.replace( hour=future_time.hour, minute=future_time.minute, second=future_time.second, microsecond=future_time.microsecond, ) diff = future_dt - now return diff.seconds ``` Here we provide the command with a `datetime.time` and ask what the time difference would be in seconds between the given `future_time` and the current clock time. # Controls Controls are always SiLA Commands. They impact the state of the connector and the attached hardware or software by triggering a discrete action on the target system of the connector, e.g. to change configuration settings, or start a parameterized process. A control allows input parameters for execution and optionally either returns just a single response (UnobservableCommand) or a stream of responses consisting of intermediate responses and a final response (ObservableCommand). Control actions may also have additional metadata attached, such as progress information, time remaining, and execution state. Unlike properties, there are no prefixes for commands. ## Intermediate Responses During a long-running processes observable commands may wish to provide users with intermediate responses as well as status updates. The return of intermediate responses requires `status: sila.Status` and `intermediate: sila.Intermediate[ T ]` to be passed as parameters, where `T` is a valid SiLA data type. Intermediate Response types cannot be inferred and must be annotated in the `intermediate` parameter. The `IntermediateResponse`s of an observable command can be annotated with a `Yields` section in the docstring. ```python [src/unitelabs/tutorial/features/sila_endpoints.py] @sila.ObservableCommand() async def iterative_method( self, seed: int, iterations: int, *, status: sila.Status, intermediate: sila.Intermediate[int], ) -> float: """ Creates a random number based on a `seed` after `iterations`. Args: Seed: Number used to initialize the pseudorandom number generator. NumberOfIterations: How many times to iterate before number generation. Yields: CurrentIteration: The current iteration. Returns: RandomNumber: A random number. """ import asyncio import random random.seed(seed) total = iterations + 1 for x in range(1, total): intermediate.send(x) status.update(progress=(x / total)) await asyncio.sleep(1) return random.random() ``` Intermediate response inner types must be valid [SiLA Data Types](https://docs.google.com/document/d/1nGGEwbx45ZpKeKYH18VnNysREbr1EXH6FqlCo03yASM/edit?tab=t.0#heading=h.mnzohomp656){rel=""nofollow,noopener""}. ## Implementation Example Continuing with our example of a ClockController, let's implement some Controls. ### Unobservable Command Unobservable commands can be used to modify the state of a device or controller. For our `ClockController`, which is initialized with no default timezone, we can create a method that allows the user to update the currently set timezone. ```python [src/unitelabs/tutorial/features/clock_controller.py] @sila.UnobservableCommand() async def set_timezone(self, hours: int) -> None: """ Set the timezone of the current location. Args: Hours: The offset of the timezone in hours. """ self._tz = datetime.timezone(datetime.timedelta(hours=hours)) ``` Now calls to our [UnobservableProperty `get_current_timestamp`](https://docs.unitelabs.io/connector-development/tutorial/data-endpoints#unobservableproperty-implementation) returns a timestamp produced using the user-defined timezone. ### Observable Command An observable command for our `ClockController` could be a timer functionality. We will allow the user to set a timer with a desired timeout and give feedback to the user as the timer winds down before finally reporting that the timer is over. ```python [src/unitelabs/tutorial/features/clock_controller.py] @sila.ObservableCommand() async def set_timer( self, wait_time: datetime.time, *, status: sila.Status, intermediate: sila.Intermediate[int], ) -> None: """ Start a timer. Args: WaitTime: The amount of time for the timer to run. Yields: SecondsRemaining: The amount of seconds left before the alarm goes off. """ if wait_time.tzinfo != self._tz: wait_time = wait_time.replace(tzinfo=self._tz) td = datetime.timedelta( hours=wait_time.hour, minutes=wait_time.minute, seconds=wait_time.second, microseconds=wait_time.microsecond, ) total_time = td.total_seconds() self._end_time: datetime.datetime = await self.get_current_timestamp() + td while (current_time := await self.get_current_timestamp()) <= self._end_time: # once per second we check the time and report time remaining time_diff = self._end_time - current_time time_remaining = time_diff.seconds intermediate.send(time_remaining) status.update(progress=(total_time - time_remaining) / total_time) await asyncio.sleep(1) ``` # SiLA Data Types Typing is an essential part of any API design, and SiLA is no exception. The SiLA 2 Standard Part A defines a set of basic and derived data types which can be used to define the parameters and return values of SiLA endpoints. Data types are declared according to [PEP 484](https://peps.python.org/pep-0484/){rel=""nofollow,noopener""}. When using the UniteLabs CDK, our signature type hints are automatically converted to the respective SiLA data types by the SiLA decorators. The data types, as specified by the SiLA 2 Standard, and their respective counterparts are defined in the [data\_types module](https://gitlab.com/unitelabs/cdk/python-cdk/-/tree/main/src/unitelabs/cdk/sila/data_types?ref_type=heads){rel=""nofollow,noopener""} of the CDK and in the [SiLA Data Types](https://docs.google.com/document/d/1nGGEwbx45ZpKeKYH18VnNysREbr1EXH6FqlCo03yASM/edit?tab=t.0#heading=h.mnzohomp656){rel=""nofollow,noopener""} section of the SiLA2 Specification. ## Basic Data Types The CDK wraps python native types and converts them into their respective SiLA data types when used as type hints in SiLA endpoints. The data types defined by the SiLA 2 Standard Part A and their respective python counterparts are as follows: | SiLA Data Type | Python Type | | -------------- | ------------------- | | String | `str` | | Binary | `bytes` | | Integer | `int` | | Real | `float` | | Boolean | `bool` | | Time | `datetime.time` | | Date | `datetime.date` | | Timestamp | `datetime.datetime` | ## Derived Data Types Derived data types are those which combine multiple values or inner data types into a single type. The SiLA 2 Standard Part A defines two derived data types which we will focus on here: List and Structure. | SiLA Data Type | Python Type | | -------------- | ----------------------- | | List | `list` | | Structure | `dataclasses.dataclass` | Derived data types can contain any of the basic data types and additionally utilize constraints to further specify the data. ::callout{icon="i-heroicons-exclamation-triangle"} **Technical Hint**: Lists cannot be nested in SiLA, i.e. you cannot have a List of Lists. If you need to represent nested list data, you can utilize a Structure to wrap the inner list and then create a List of that Structure. :: ### Structures Up to this point in the tutorials all of our function signatures have utilized basic data types. It is, however, important that we be able to define or derive our own complex types to keep our data well structured and human-readable. Structures may be composed of any mixture of valid SiLA data types, including other Structures. Let's take as an example the [LabwareTransferFeature](https://gitlab.com/unitelabs/cdk/python-cdk/-/tree/main/src/unitelabs/cdk/features/robot/labware_transfer_manipulator_controller?ref_type=heads){rel=""nofollow,noopener""} in the feature library. In the `LabwareTransferManipulatorControllerBase` we see the following method: ```python @sila.UnobservableCommand() async def prepare_for_input( self, handover_position: HandoverPosition, internal_position: PositionIndex, labware_type: str, labware_unique_ID: str, ) -> str: ``` This method utilizes a Structure to specify how the complex data required by the method can be supplied by the user with `HandoverPosition` defined as follows: ```python @dataclasses.dataclass class HandoverPosition: """ Specifies one of the possible positions of a device where labware items can be handed over. Can contain a sub-position, e.g. for specifying a position in a rack. Attributes: Position: The position where the labware will be handed over. SubPosition: A sub-specification of the position for more precise locations, e.g. a position within a rack, where the rack's position is specified with `position`. """ position: str sub_position: PositionIndex ``` ## Command vs Property Data Types In addition to the basic data types, SiLA Commands may also return tuples of values. Per the SiLA 2 Standard, Commands may return multiple values, while Properties may only return a single value. This means that if you want to return multiple values from a Property, you will need to define a complex structure to wrap those values. When defining a Command that returns multiple values, you can simply return a tuple of those values. Each value returned should be annotated in the docstring of the method with a name and description to make it clear to the user what each value represents. For example: ```python @sila.UnobservableCommand() async def echo_string_and_integer(self, my_string: str, my_integer: int) -> tuple[str, int]: """ Echo a string and an integer value. Args: MyString: A string value. MyInteger: An integer value. Returns: EchoString: The string value that was provided as `my_string`. EchoInteger: The integer value that was provided as `my_integer`. """ return my_string, my_integer ``` ## Constraints Each of the basic data types can be further constrained by the addition of Constraints to the type hint. Constraints are a way to specify limitations on the values that can be accepted for a given parameter or return value. For example, you may want to specify that an integer parameter must be within a certain range, or that a string parameter must match a certain regex pattern. The basic data types and their respective constraints are as follows: | SiLA Data Type | Python Native Type | Constraints | | -------------- | ------------------- | --------------------------------------------------------------------------------------------- | | Binary | `bytes` | `ContentType`, `Length`, `MinimalLength`, `MaximalLength`, `Schema` | | String | `str` | `ContentType`, `Length`, `MinimalLength`, `MaximalLength`, `Schema`, `Pattern`, `Set` | | Integer | `int` | `MinimalInclusive`, `MaximalInclusive`, `MinimalExclusive`, `MaximalExclusive`, `Set`, `Unit` | | Real | `float` | `MinimalInclusive`, `MaximalInclusive`, `MinimalExclusive`, `MaximalExclusive`, `Set`, `Unit` | | Date | `datetime.date` | `MinimalInclusive`, `MaximalInclusive`, `MinimalExclusive`, `MaximalExclusive`, `Set` | | Time | `datetime.time` | `MinimalInclusive`, `MaximalInclusive`, `MinimalExclusive`, `MaximalExclusive`, `Set` | | Timestamp | `datetime.datetime` | `MinimalInclusive`, `MaximalInclusive`, `MinimalExclusive`, `MaximalExclusive`, `Set` | | List | `list` | `ElementCount`, `MinimalElementCount`,`MaximalElementCount` | We can also enforce limitations on standard data types through the addition of Constraint annotations to our type-hints. Let's say that we have a function that takes a string input: ```python def my_func(input: string) -> None: if input == "a": do_a() elif input == "b": do_b() ``` Here we only have two valid `input` values, so we might want to limit the inputs that a user can provide to this function. With constraints this is simply a matter of wrapping the current parameter type-hint in `typing.Annotated` and adding as many constraints as desired. ```python def my_func( input: typing.Annotated[str, sila.constraints.Set(["a", "b"])] ) -> None: ... ``` Now any value supplied which is not "a" or "b" will automatically raise a `ValidationError`. Constraints can be used in SiLA endpoints directly as part of the parameter type hinting or can be built into `Structures`s, like the `PositionIndex` from the `LabwareTransferManipulatorControllerBase`: ```python @dataclasses.dataclass class PositionIndex: """Specifies a position via an index number, starting at 1. Attributes: PositionIndex: A number indicating the position's index, starting at 1. """ position_index: typing.Annotated[int, sila.constraints.MinimalInclusive(value=1)] ``` Constraints come with pre-existing validations that are applied automatically before submission when data is provided by end-users. Read up on available constraints in the installed [Python SiLA library](https://gitlab.com/unitelabs/sila2/sila-python/-/tree/main/src/sila/framework/constraints){rel=""nofollow,noopener""} or in the [SiLA2 Core Specification](https://docs.google.com/document/d/1nGGEwbx45ZpKeKYH18VnNysREbr1EXH6FqlCo03yASM/edit?tab=t.0#heading=h.bqqrpk93qnmv){rel=""nofollow,noopener""}. ### Runtime Constraints Sometimes the valid constraints for a command parameter or return value depend on the specific device, and may vary across different configurations of the same device. Consider for example a robotic arm that comes in different configurations which have different reach ranges. Instead of writing different features or endpoints for each of these configurations, we can query an endpoint on the machine, and then dynamically set the constraints during the connector's startup. This can be achieved by using two methods of the `sila.Feature` class: the `on_before_start` hook and the `add_constraint` method: ```python from ..io import get_device_range # a helper method in your io layer that retrieves the range from the device class ArmMovementController(sila.Feature): @typing.override async def on_before_start(self) -> None: min_range, max_range = await get_device_range() self.add_constraint( self.move_absolute, "position", [ sila.constraints.MinimalInclusive(min_range), sila.constraints.MaximalInclusive(max_range), ] ) @sila.UnobservableCommand() async def move_absolute(self, position: int) -> None: """ Move the arm on the x-axis. Args: Position: Where to move the arm to """ ... ``` After startup, the `move_absolute` command will be visible to a SiLA client with the constraints of `min_range` and `max_range`. ## Enumerations For convenience, the CDK also supports the use of python `Enum` classes as a way to define a set of valid values for a given parameter or return value. This is particularly useful for parameters that can only take on a limited set of values, such as a parameter that specifies the mode of operation for a device. To use an `Enum` as a type hint, simply define your `Enum` class and use it as the type hint for your parameter or return value. For example: ```python from enum import IntEnum class OperationMode(IntEnum): MODE_A = 0 MODE_B = 1 MODE_C = 2 @sila.UnobservableCommand() async def set_operation_mode(self, mode: OperationMode) -> int: """ Set the operation mode for the device. Args: Mode: The operation mode to set for the device. """ return mode.value ``` Enumerations in the CDK are automatically converted to strings with a `Set` constraints. The names of the Enum members are lowercased and separated by the `_` character to generate the user-facing string values. In the above example, the valid input values for the `mode` parameter would be "mode a", "mode b", and "mode c". # Feature ## Generating a new feature ### Connector Directory Organization Create a folder in **src/unitelabs/package\_name/features/** with the name of the feature in lowercase without any special characters including ' ' and '-'. Use underscores to separate words if your feature name consists of multiple words. ```text ├── project-name ├── src/unitelabs/package_name ├── __init__.py ├── __main__.py └── io ├── __init__.py └── package_name_protocol.py └── features └── random_number_provider ├── __init__.py └── random_number_provider.py ``` Within the SiLA specification, it is best practice to specify the feature type with an appendix. Feature types are `service`, `controller`, and `provider`. Whereas controllers and providers mostly contain functionality for control operations and data or information provisioning respectively, the `service` type contains functionalities of both. Our example feature folder is `src/unitelabs/package_name/features/random_number_provider/` ### Creating and Naming a new Feature In the UniteLabs Connector Development Kit, the interface is designed following a code-first approach, hence the interface is written in Python and not using the XML-schema-based Feature Definition Language (FDL) as implemented in several SiLA reference implementations. Create your first feature by defining a class with your feature name in PascalCase. Add connector details such as the `originator`, `category`, `version`, and `maturity_level`. - The originator follows a reverse domain name notation, consisting of a domain name followed by your organization name. - The category defines the functional group that the feature is grouped in, e.g. `lhs`, `robot`, `weighing` etc. - The version consists of a major and minor version and starts at 1.0 for published connectors. - The maturity level can be one of `Draft`, `Verified`, or `Normative`. A maturity level of `Verified` requires a validation step via the SiLA community in which at least one SiLA member checks your feature definition. `Normative` is reserved for features that are part of the standard and located in the SiLA Base repository. So to start: ```python [src/unitelabs/package_name/features/random_number_provider/random_number_provider.py] class RandomNumberProvider(sila.Feature): """ This feature contains methods that provide a client with randomly generated numbers. """ def __init__(self): super().__init__( originator="io.unitelabs", category="example", version="1.0", maturity_level="Draft", ) ``` To add endpoints to the interface of this feature, you add methods to the class. Depending on the type of endpoint, a variety of decorators can be used. The framework uses decorators extensively to keep the feature definition and implementation files as lean as possible. The **@sila.UnobservableCommand**, **@sila.ObservableCommand** and **@sila.UnobservableProperty**, **@sila.ObservableProperty** decorators are used to wrap python methods. For more information about decorator usage see the [SiLA Data Endpoints Tutorial](https://docs.unitelabs.io/connector-development/tutorial/sila-endpoints/) ## Registering a feature Open the `__init__.py` file in the connector directory: ```text ├── project-name └── src/unitelabs/package_name └── __init__.py ``` Import the `RandomNumberProvider` feature and register it with the connector application using the `app.register()` method. Within this file you can also add and update the connector configuration, such as name, type, description, version, and the vendor url. The connector information is not just visible to the client, but also provides valuable information for auto-discovery as it is also part of the mDNS message that is broadcasted via zeroconf/bonjour in your local network. That makes it very easy to find, ultimately enabling plug 'n play! ```python [__init__.py] import collections.abc import dataclasses from importlib.metadata import version from unitelabs.cdk import Connector, ConnectorBaseConfig, SiLAServerConfig from .features.random_number_provider import RandomNumberProvider __version__ = version("unitelabs-package-name") @dataclasses.dataclass class CustomConfig(ConnectorBaseConfig): sila_server: SiLAServerConfig = dataclasses.field(default_factory=lambda: SiLAServerConfig( name="Thermocycler", type="Example", description="Control the temperature of samples.", version=str(__version__), vendor_url="https://unitelabs.io/", ) ) async def create_app(config: CustomConfig) -> collections.abc.AsyncGenerator[Connector]: """Creates the connector application""" app = Connector(config) app.register(RandomNumberProvider()) yield app # cleanup ``` For more information about connector configuration, check out our [Configuration Guide](https://docs.unitelabs.io/connector-development/guides/configuration/). ## Abstract Base Feature Classes It is sometimes the case that we have shared functionality across multiple connectors which we would like to encapsulate into a shared Base Feature. Some examples of [Base Features in the CDK](https://gitlab.com/unitelabs/cdk/python-cdk/-/tree/main/src/unitelabs/cdk/features/){rel=""nofollow,noopener""} include the `SimulationControllerBase` and `WeighingServiceBase`. Additionally, this features folder contains some fully-fledged Features, like the `LockController` which one can incorporate wholesale into their connectors. Broadly what these base features attempt to do is to create an abstraction for a process, such as weighing a thing, such that any connector for a device that is capable of weighing things, can build upon this shared base class to expose a consistent weighing API. # Hardware Communication ::callout{icon="i-heroicons-exclamation-triangle"} **Experimental API**: This API is still under development and therefore subject to change without notice. :: The [Omnibus package](https://gitlab.com/unitelabs/cdk/python-bus){rel=""nofollow,noopener""} is a Python library that simplifies hardware and software communication by providing an abstraction layer over various communication protocols (RS-232/485, USB, TCP/IP, and more). With the Omnibus, developers can seamlessly interact with devices without needing to delve into the complexities of specific communication methods. This tutorial will walk you through the process of setting up the Omnibus library, establishing a connection with your hardware device, and performing basic read/write operations. By the end of this tutorial, you'll be ready to integrate the Omnibus into your projects and start communicating with your hardware devices efficiently. **Prerequisites** - A hardware device capable of serial communication. - The communication specification of that device. - Python 3 installed on your system. ## Step 1: Installing the Omnibus Library First, you need to install the `Omnibus` library. You can use any Python package manager of your choice. ::tabs :::div{icon="i-simple-icons-uv" label="uv"} Open your terminal or command prompt and run the following commands. If you start from scratch, create a new project first. ```bash [Terminal] uv init --lib my-app cd my-app ``` Then setup and install the dependency in your current project. ```bash [Terminal] uv add unitelabs-bus[serial] --index unitelabs=https://gitlab.com/api/v4/groups/1009252/-/packages/pypi/simple ``` ::: :::div{icon="i-simple-icons-python" label="venv"} Open your terminal or command prompt and run the following commands. If you start from scratch, create a new project first. ```bash [Terminal] mkdir my-app cd my-app python -m venv .venv .venv/bin/pip install -U pip ``` Then setup and install the dependency in your current project. ```bash [Terminal] .venv/bin/pip install "unitelabs-bus[serial]" \ --index-url https://gitlab.com/api/v4/groups/1009252/-/packages/pypi/simple ``` ::: :: This will download and install the `Omnibus` library, making it available for use in your Python scripts. ::callout{icon="i-heroicons-light-bulb"} **Note**: If your device supports other protocols than serial, install the respective extra with the `Omnibus` library, e.g. `unitelabs-bus[usb]`. :: ::callout{icon="i-heroicons-wrench-screwdriver"} **Coming Soon**: Official PyPi distribution to make `unitelabs-bus` easier to install. :: ## Step 2: Connecting Your Hardware Connect your hardware device to your computer using the appropriate serial connection (e.g., USB-to-serial adapter). Note the COM port (on Windows) or the device path (on Linux/Mac) that the hardware is connected to. You'll need this information to establish a connection in your Python code. To read out all available ports, you can use our device manager with: ::code-group ```bash [uv] uv run python -m unitelabs.bus.utils.device_manager_cli ``` ```bash [venv] .venv/bin/python -m unitelabs.bus.utils.device_manager_cli ``` :: ::callout{icon="i-heroicons-wrench-screwdriver"} **Coming Soon**: We will provide a fully fledged Command Line Interface to easily identify connected devices and even interact with them. :: ## Step 3: Writing Your First Python Script Now, let's write a Python script to open a serial connection and communicate with your hardware device. 1. **Import the Omnibus Library**:br Start by importing the necessary classes and methods from the Omnibus library: ```python from unitelabs.bus import Protocol, create_serial_connection, ByteCommand ``` 2. **Initialize the Connection**:br Next, configure the serial connection. You need to specify the COM port (or device path), baud rate, and other parameters like timeout. ```python protocol = Protocol( create_serial_connection, port='/dev/ttyUSB0', # Replace with your port name, e.g. '/dev/cu.usbmodem3210' on macOS, 'COM3' on Windows baudrate=9600, # Set the baud rate to match your device timeout=1, # Set a timeout for read operations ) await protocol.open() ``` :brIn order for the protocol to establish a connection to the device, you need to call the `open()` method. 3. **Send Data to the Device**:br To transmit data to your hardware device, you can use the `execute()` method. ```python protocol.execute(ByteCommand(b"Hello, Device!\r\n")) ``` :brThis line sends the binary data b"Hello, Device!\r\n" to your serial device. 4. **Receive Data from the Device**:br The `execute()` method is also responsible for returning the corresponding response sent from the device. To read data sent from the device, await the return of the `execute()` method. This reads data from the serial port until a newline character is encountered: ```python response = await protocol.execute(ByteCommand(b"Hello, Device!\r\n")) print('Received:', response) ``` :brThe `execute()` method now waits until it receives a response or until it times out. If it reads a line of data from the device then it prints it to the console. 5. **Close the Connection**:br After communication is complete, it's good practice to close the connection to free the resources: ```python protocol.close() ``` ## Step 4: Running the Script In order to run asynchronous code, you need to wrap it in an `async` method and call that via `asyncio.run()`: ```python [src/my_app/__main__.py] import asyncio from unitelabs.bus import Protocol, create_serial_connection, ByteCommand async def main(): protocol = Protocol( create_serial_connection, port='/dev/ttyUSB0', # Replace with your port name, e.g. '/dev/cu.usbmodem3210' on macOS, 'COM3' on Windows baudrate=9600, # Set the baud rate to match your device timeout=1, # Set a timeout for read operations ) await protocol.open() response = await protocol.execute(ByteCommand(b"Hello, Device!\r\n")) print('Received:', response) protocol.close() if __name__ == "__main__": asyncio.run(main()) ``` Save your script (e.g., as `src/my_app/__main__.py`) and run it from your terminal: ::code-group ```bash [uv] uv run python -m my_app ``` ```bash [venv] .venv/bin/python -m my_app ``` :: If everything is set up correctly, the script will open the serial connection, send a message to your hardware device, receive a response, and print it to the console. ## Step 5: Troubleshooting Common Issues Here are a few tips if you run into issues: - **Port not found**: Double-check the port name and ensure your device is properly connected. - **Incorrect baud rate**: Ensure that the baud rate in your script matches the baud rate configured on your hardware. - **Timeouts**: If you're not receiving data, increase the timeout parameter or check the connection. To receive a more verbose output, you can increase the log level. Add the following at the top of your script: ```python import logging logging.basicConfig(level=logging.DEBUG) ``` ## Step 6: Expanding Your Communication Now that you've mastered the basics of using the Omnibus library to establish a connection and perform simple read/write operations, it's time to explore more advanced use cases. The Omnibus library is designed with flexibility in mind, allowing you to extend its functionality to suit specific communication requirements or device protocols. ### 1. Creating Custom Protocols In many cases, you want to create a custom protocol to encapsulate specific communication patterns or device commands. A protocol in Omnibus bundles all the functions your hardware device offers, presenting them in a human-readable and easy-to-use way. **Example: Creating a Custom Protocol** Let's start by creating a custom protocol class that inherits from Protocol. This class can encapsulate common commands and responses your device supports: ```python class MyCustomProtocol(Protocol): def __init__(self, port: str) -> None: super().__init__(create_serial_connection, port=port, baudrate=9600, timeout=1) async def hello(self) -> str: return await self.execute(SerialCommand("HELLO")) async def ping(self) -> str: return await self.execute(SerialCommand("PING")) ``` In this example, `MyCustomProtocol` defines two methods, `hello` and `ping`, that communicate with the device using specific commands. This setup makes it easy to call these methods without worrying about the underlying serial communication details. ### 2. Customizing Commands with CommandBuilder The Omnibus comes with some basic Command subclasses predefined: `ByteCommand` and `SerialCommand`. We can readily modify these classes to adjust their behavior to our needs using the `CommandBuilder`. Before we can get into customizing a Command, we first need to understand the basic parts of a command and how it interacts with the Protocol. - serializer - the function to convert the input type into bytes when generating a Request, which is sent through `Protocol.execute` to the device. - deserializer - the function to convert the Response bytes received from the device within the `Protocol.execute` call to the output type. - parser - one or more optional functions to serially apply to the deserialized data for further data processing before the result is returned from `Protocol.execute` The builder pattern allows us to build on a basic Command subclass to customize its functionality by making changes to the aforementioned parts as well as the initialization arguments of the Command. ```python from unitelabs.bus import CommandBuilder builder = CommandBuilder() cmd = builder.build(b"HELLO") cmd2 = builder.with_serializer(lambda x:x +b"\r\n").build(b"HELLO") ``` By default the `CommandBuilder` uses `ByteCommand` as its base. Calling build directly after initializing CommandBuilder is functionally equivalent to creating an instance of `ByteCommand`. The result here is that serialized payload of `cmd` is `b"HELLO"` and for `cmd2` the serialized payload is `b"HELLO\r\n"`. #### CommandBuilder Methods CommandBuilder offers methods which may be chained together or called individually to create intermediate builders, which we will group here based on their usage. - `with_serializer(serializer_function, **function_kwargs)` for changing the serialization function - `with_deserializer(deserializer_function, **function_kwargs)` for changing the deserialization function - `with_parser(parser_function, **function_kwargs)` for adding parsers functions This first set of methods accept functions as arguments and will preload additional arguments into the provided function: ```python def complex_serializer(message: str, sub_message: str, is_complex: bool) -> bytes: return message.encode("ascii") if not is_complex else (message + sub_message).encode("ascii") cmd = CommandBuilder().with_serializer(complex_serializer, sub_message=" world", is_complex=True).build("hello") ``` ::card{icon="i-heroicons-code-bracket-square" title="Code explanation"} If we inspect the contents of the request at `cmd.request.payload` we will see that our serialized payload is b"hello world"! :: These methods will alter the function signature to additionally bind the function to the Command instance: ```python def use_protocol_logger(self, data: bytes) -> None: if b"goodbye" in data: self.receiver.logger.info("Device says goodbye.") cmd = CommandBuilder().with_parser(use_protocol_logger).build(b"hello") ``` ::card{icon="i-heroicons-code-bracket-square" title="Code explanation"} Commands have access to the `Protocol` instance they are executed in through the `receiver` attribute, therefore we can use the instance-binding of the CommandBuilder to bind functions which interact with the Protocol. :: CommandBuilder also allows us to set global init args for our Commands: - `with_timeout(timeout)` for setting the timeout (i.e. how long the command can attempt to run through Protocol.execute before throwing an error) - `without_response()` for marking a Command which does not expect a response from the device These methods set the `timeout` and `is_void` args during `Command` initialization. The values can also be supplied when calling `build(message, timeout, is_void)`. Use of both `with_` and its associated arg in `build` will preferentially use the build arg. Finally we have the use-case dependent method `with_multiline` which is designed specifically for handling edge cases and bad behavior in serial communication. For more information, see the [Serial Troubleshooting Guide](https://docs.unitelabs.io/connector-development/guides/serial-troubleshooting/) ::callout{icon="i-heroicons-wrench-screwdriver"} **Coming Soon**: We are already working on guides about reconnect and retry behavior, and other edge cases. :: ## Conclusion This tutorial introduced you to the basics of using the Omnibus library for hardware communication in Python. With these foundational steps, you can start building sophisticated applications that interact with various serial-enabled devices. Whether you're working on IoT projects, embedded systems, or data acquisition, Omnibus provides a flexible and robust framework to meet your needs. Happy coding! # SiLA SiLA (**Standardization in Laboratory Automation**) is an standardized communication protocol designed specifically for laboratory instruments. UniteLabs uses SiLA 2 — the current version — as the foundation for how connectors expose instrument capabilities to the platform. ## What SiLA 2 is SiLA 2 defines a standard way for lab software to describe and call instrument functions. It uses **gRPC** (a high-performance remote procedure call framework) with **Protocol Buffers** as the serialization format. What this means in practice: - Every instrument capability is described in a typed, machine-readable schema — no ambiguity about what parameters a command expects or what type a property returns - Communication is binary and efficient, well-suited for real-time lab automation - The protocol is open and vendor-neutral, with a growing ecosystem of compatible instruments and software ## Why it matters to you Because UniteLabs connectors are built using SiLA 2, every connector follows the same structural model — **Features** containing **Commands** and **Properties**: regardless of the underlying instrument. Once you know how to work with one connector, you know how to work with all of them. It also means that SiLA 2-compliant instruments from third-party vendors can be connected to UniteLabs with minimal effort. If an instrument vendor ships a SiLA 2 server, it can be discovered and used via GroundControl directly. > Cloud-connectivity was introduced with SiLA 2 1.1. To connect a local SiLA 2 server to the UniteLabs platform, cloud connectivity is required. If a SiLA 2 server doesn't support this version yet, UniteLabs provides the 'Edge Gateway' connector, which can relay the connection to the platform and make it SILA 2 1.1. compatible! ## What you don't need to worry about You don't need to know anything about gRPC, Protocol Buffers, or SiLA feature definition files to use UniteLabs connectors. GroundControl, the UniteLabs SDK, and the REST API all handle the protocol layer for you. If you are building a connector with the CDK, the framework generates all the protocol-level boilerplate from your Python code automatically — you write plain Python methods and decorators, not XML or Protobuf schemas. See the [CDK documentation](https://docs.unitelabs.io/connector-development/getting-started/overview/) for details. ## SiLA 2 and network ports A connector's SiLA 2 gRPC server uses a local hostname and port, typically within the 50000-60000 range, on the edge machine and broadcasts its capability via mDNS for auto-discovery. These settings are only used for local communication, e.g. by GroundControl — these do not need to be exposed to the internet or opened in external firewalls. To establish a connection to the platform, a server-initiated communication is used that requires the client endpoint, the platform API url, and the port it is served on. The default port is *443*. See [Network requirements](https://docs.unitelabs.io/get-started/setup/network-requirements/) for the full picture, especially if you're working in a corporate network and need to use encrypted communication with certificates. # Parallel Processing Some devices allow multiple requests to be processed in parallel. It is common for such communication protocols to include a unique identifier which is shared between the Request-Response pair. This tutorial will go over the changes necessary to update a working protocol to allow parallel processing as well as the basic pattern of creating a custom `Command` to encapsulate the logic of identifying a Response's source. **Prerequisites** - A `Protocol` correctly configured to communicate with a device. - A device which permits parallel processing, i.e. not suitable for RS-232/485 communication. ## Step 1: Updating Protocol This must first be configured in the `Protocol` by setting the `max_parallel_commands` argument, which has a default value of 1. Adjust your protocol to pass this argument to `Protocol` during initialization. The value will be specific to the device and may require testing. ```python [parallel_protocol.py] from unitelabs.bus import Protocol class ParallelProtocol(Protocol): def __init__(self, *args, **kwargs): # configured protocol super().__init__( transport_factory, *args, max_parallel_commands=3, **kwargs ) ``` ## Step 2: Creating a Custom Command Let's take as our test case a device which expects messages to include a 4-digit identifier at the start and responds with the id prepended to the response value, i.e. - Request: b"1234 request" - Response: b"1234 response" We can encode this behavior into a custom `Command`: ```python [id_command.py] import random import typing from unitelabs.bus import Command class IdCommand(Command[str, str]): def __init__( self, message: str, encoding: str = "utf-8", **kwargs, ) -> None: super().__init__(message, timeout, **kwargs) self._encoding = encoding self._id = str(self.make_internal_tracking_id()) def make_internal_tracking_id(self) -> str: return f"{random.randrange(0000, 9999):04}" @property def id(self) -> str: return self._id def _serialize(self, message: str = None) -> bytes: message = message or self.message msg = str.encode(f"{self.id} {message}", encoding=self._encoding) return msg def _deserialize(self, response: bytes = None) -> str: resp = response.decode(encoding=self._encoding).lstrip(f"{self.id} ") return resp ``` `IdCommand` will prepend a unique 4-digit identifier to the string message and convert it to bytes to send to the device. Later it removes that identifier from the bytestring received from the device, returning a string. Now we can update our `Protocol` to create a method that uses `IdCommand` with `Protocol.execute`. ```python [parallel_protocol.py] import asyncio from .id_command import IdCommand class ParallelProtocol(Protocol): ... async def get_request(self) -> str cmd = IdCommand("request") res = await protocol.execute(cmd) print(res) # "response" return res ``` ## Step 3: Updating a Custom Command to Enable Parallel Processing It is not enough to have a unique identifier, `IdCommand` needs to be slightly modified to enable parallel processing behavior and to do this we need to customize the `Command.match_response` method. When the `Protocol` receives data from the device it will first call `match_response` to identify the `Command` that generated the request that the response belongs to. Because consecutive processing is the default behavior of the `Protocol`, the default `Command.match_response` always returns True. This is because without parallel processing there is never ambiguity as to which `Command` a device response belongs to. Let's override `match_response` in our custom command: ```python [parallel_command.py] from .id_command import IdCommand class ParallelCommand(IdCommand): def match_response(self, data: bytes) -> bool: msg = data.decode(encoding=self._encoding) if not msg.startswith(self.id): return False return super().match_response(data) ``` Here `match_response` will only return True in the case where the id on the device response matches the `ParallelCommand`'s id. The `Protocol` will then associated the response data with it's originating Command and return the results. ## Step 4: Making Parallel Calls to the Device Now to integrate this into Protocol use `asyncio.gather` to process multiple calls to `execute` in parallel. Here `return_exceptions=True` treats exceptions the same as successful results and stores them in the `responses` list. ```python [parallel_protocol.py] import asyncio from .parallel_command import ParallelCommand class ParallelProtocol(Protocol): ... async def get_request_many(self, number: int, request: str = "request") -> list[typing.Union[Exception, str]]: responses = await asyncio.gather( *[self.execute(ParallelCommand(request)) for _ in range(number)], return_exceptions=True, ) return responses ``` With this method we can test the behavior of the device when sending multiple requests in parallel, which can be helpful for configuring `max_parallel_commands` if the max is not specified by the device. ```python [test.py] import asyncio from .parallel_protocol import ParallelProtocol protocol = ParallelProtocol() await protocol.open() res = await protocol.get_request_many(2) # ["response", "response"] res = await protocol.get_request_many(3) # ["response", "response", "response"] res = await protocol.get_request_many(4) # ["response", "response", "response", CommandExecutionError("Cannot send request. Transport is currently processing maximum number of commands.")] protocol.close() ``` Remember that we set `max_parallel_commands=3`, so when we try to call `get_request_many(4)` the last value in the list of returned device responses is an Exception raised by Protocol. # Configuration Connectors commonly require some amount of user-facing configuration to get started. Whether this is something as simple as needing to know to which serial port a device is connected, or something as complex as allowing a single Connector to communicate with multiple related devices with different startup conditions, UniteLab's new configuration system, built on top of the popular [`pydantic`{style="color: green;"}](https://docs.pydantic.dev/latest/){rel=""nofollow,noopener""} library, is here to help. ## Prerequisites - CDK v0.5.0 or higher - A Connector, check out our [Installation Guide](https://docs.unitelabs.io/connector-development/getting-started/installation/) to create a new Connector project with our [`connector-factory`{style="color: green;"}](https://gitlab.com/unitelabs/cdk/connector-factory){rel=""nofollow,noopener""} cookiecutter and the [Connector Walkthrough](https://docs.unitelabs.io/connector-development/tutorial/walkthrough/) to get started writing your own Connector. ## The Base Config The easiest way to learn more about the configuration of a connector is with the `config show` command. After creating a new connector called `connector-starter` with the `connector-factory`, you would call `config show` as follows: ::code-group ```bash [uv] uv run config show --app unitelabs.connector_starter:create_app ``` ```bash [hatch] hatch run config show --app unitelabs.connector_starter:create_app ``` ```bash [poetry] poetry run config show --app unitelabs.connector_starter:create_app ``` :: This command will display a table of the configurable values and their associated type hints, descriptions, and default values. ::callout{icon="i-heroicons-light-bulb"} The `--app` option is required for Windows. On MacOS and Linux, commands like `connector start` and `config` will generally work without providing this option. On these operating systems, needing to provide `--app` often points to an error in the connector package. :: When creating a connector there are some subset of configurable values that are shared by all connectors. All of the required and optional configuration values for a SiLA server are embedded in the `ConnectorBaseConfig`. `ConnectorBaseConfig` contains four main sections: - **sila\_server** - All of the configuration values for the SiLA server, documented in the [SILA Server Config Reference](https://docs.unitelabs.io/#the-sila-server-config). - **cloud\_server\_endpoint** - All of the configuration values for the cloud server, which allows one to expose and interact with the Connector on the UniteLabs platform, documented in the [Cloud Server Config Reference](https://docs.unitelabs.io/#the-cloud-server-config). - **discovery** - All of the configuration values for multicast DNS (mDNS) and DNS-based Service Discovery(DNS-SD). - **logging** - Entrypoint for setting python logging configuration, accepts a python logging dict as defined in the python [logging documentation](https://docs.python.org/3/library/logging.config.html#configuration-dictionary-schema){rel=""nofollow,noopener""}. When left as `null`, the CDK applies a sensible default. See the dedicated [Logging Guide](https://docs.unitelabs.io/connector-development/guides/logging) for emitting logs from your connector code. All the specific parameters contained in these base config groups can be viewed with the `config show` command using the `--output` argument, e.g. `config show --output sila_server` or `config show --output cloud_server_endpoint`. ## The CLI config endpoint Connector configuration comes with its own CLI: learn about a the configuration by running `config show` or generate a `config.json` with `config create`. Use the `--help` flag on each command and subcommand to learn more. ::code-group ```bash [uv] uv run config --help ``` ```bash [hatch] hatch run config --help ``` ```bash [poetry] poetry run config --help ``` :: For the full written guide to creating configuration files, running a connector, configuring logging, and migrating from `.env`, see [Connect a device — From source](https://docs.unitelabs.io/integrate/connect-a-device/from-source/). ## Developer's Guide To extend the config with your device specific variables the following points need to be considered. Requirements: - The derived config class must be a [dataclass](https://docs.python.org/3/library/dataclasses.html#module-dataclasses){rel=""nofollow,noopener""}. - All configurable values **must** have a default; this allows the creation of a "default" config, which users can then fill in with their own configuration values. Limitations: - Does not allow for asynchronous default factories, e.g. if you need to fetch some data from a remote source to populate a default value. - Our `pydantic` integration is compatible with pydantic concepts which can be used with[`pydantic.dataclasses.dataclass`{style="color: green;"}](https://docs.pydantic.dev/latest/concepts/dataclasses/){rel=""nofollow,noopener""} or [`pydantic.TypeAdapter`{style="color: green;"}](https://docs.pydantic.dev/latest/concepts/type_adapter/){rel=""nofollow,noopener""}, but not those which are restricted to use with the [`pydantic.BaseModel`{style="color: green;"}](https://docs.pydantic.dev/latest/concepts/models/#basic-model-usage){rel=""nofollow,noopener""}. - We re-export a subset of `pydantic` methods as a utility, you must add `pydantic` as a dependency and import it directly to access additional `pydantic` methods that are not explicitly exported by the CDK. ```python [config.py] import dataclasses from unitelabs.cdk.config import ConnectorBaseConfig @dataclasses.dataclass class ConnectorConfig(ConnectorBaseConfig): number_of_pipettes: int = 8 """The number of pipettes connected to the device.""" ``` **Listing 1**: Basic `ConnectorBaseConfig` derivation with an additional parameter. In the Connector's `__init__.py`: ```python [__init__.py] from .config import ConnectorConfig from unitelabs.cdk import Connector async def create_app(config: ConnectorConfig): app = Connector(config) # use config values to adapt the connector yield app ``` **Listing 2**: Incorporation of a derived `ConnectorBaseConfig` into a `Connector`. ::callout{color="amber" icon="i-heroicons-exclamation-triangle"} You may put your config in the `__init__.py` file or in a separate file, as shown here. However, if your config is in a separate file, you **must** import it into your `__init__.py`. Our configuration system relies on the config class being present when the connector is loaded during `connector start` to validate the configuration values. :: If we were to now run `config create`, it would create the following default config file: ```json [config.json] { "sila_server": { "hostname": "0.0.0.0", "port": 0, "tls": false, "require_client_auth": false, "root_certificates": null, "certificate_chain": null, "private_key": null, "options": {}, "uuid": "adf054d6-4503-486a-82cc-7b3f4c159b46", "name": "SiLA Server", "type": "ExampleServer", "description": "", "version": "0.1", "vendor_url": "https://sila-standard.com" }, "cloud_server_endpoint": { "hostname": "localhost", "port": 50001, "tls": false, "root_certificates": null, "certificate_chain": null, "private_key": null, "reconnect_delay": 10000.0, "options": {} }, "discovery": { "network_interfaces": [], "ip_version": "ipv4" }, "logging": null, "number_of_pipettes": 8 } ``` **Listing 3**: Basic derived `ConnectorBaseConfig`'s `config.json` output. Running `connector start` would start our connector which would have the default name of "SiLA Server" as we have not yet overridden the default Connector configuration values. Let's modify our config to add override the default values for our SiLA server: ```python [config.py] import dataclasses from importlib.metadata import version from unitelabs.cdk.config import ConnectorBaseConfig, SiLAServerConfig @dataclasses.dataclass class MyConfig(ConnectorBaseConfig): number_of_pipettes: int = 8 """The number of pipettes connected to the device.""" sila_server: SiLAServerConfig = dataclasses.field(default_factory=lambda: SiLAServerConfig( name="My Server", type="Test", description="My Test Server written with the UniteLabs CDK.", version=str(version("unitelabs-my-connector")), vendor_url= "https://unitelabs.io/", )) ``` **Listing 4**: A derived `ConnectorBaseConfig` with `SiLAServerConfig` overrides. Now running `config create` we see the default value for `sila_server.name` is set to "My Server". ```json { "sila_server": { "hostname": "0.0.0.0", "port": 0, "tls": false, "require_client_auth": false, "root_certificates": null, "certificate_chain": null, "private_key": null, "options": {}, "uuid": "adf054d6-4503-486a-82cc-7b3f4c159b46", "name": "My Server", "type": "Test", "description": "My Test Server written with the UniteLabs CDK.", "version": "0.1.0", "vendor_url": "https://unitelabs.io/" }, "cloud_server_endpoint": { "hostname": "localhost", "port": 50001, "tls": false, "root_certificates": null, "certificate_chain": null, "private_key": null, "reconnect_delay": 10000.0, "options": {} }, "discovery": { "network_interfaces": [], "ip_version": "ipv4" }, "logging": null, "number_of_pipettes": 8 } ``` ### Delayed Defaults We may want to have some dynamically generated default values for our connector. We can use the `delayed_default` method to allow values to have a delayed evaluation. ```python [config.py] import dataclasses import typing from unitelabs.cdk.config import ConnectorBaseConfig, delayed_default @dataclasses.dataclass class MyConfig(ConnectorBaseConfig): type: typing.Literal["A", "B"] = "A" """The type of connector to run.""" name: str = dataclasses.field( default_factory=delayed_default(lambda self: f"{self.type} Connector") ) """The human readable name of the connector type.""" ``` **Listing 5**: Applying delayed default values to a derived `ConnectorBaseConfig`. Delayed defaults are evaluated on accession if no explicit override value has been provided so: ```python >>> config = MyConfig() >>> name = config.name # config.name is resolved now >>> print(name) "A Connector" >>> config = MyConfig(type="B", name="overridden") >>> name = config.name # no evaluation here as `name` is already set >>> print(name) "overridden" ``` **Listing 6**: Attribute setters with `delayed_default` are evaluated on accession. ## Validations Standard typing validations come baked in with no special declaration. All built-in validations come with informative error messages that point directly to the misconfigured field. Validations fall into two categories: field validations, which apply to one or more fields of the configuration, and config validations, which apply to the entire configuration, e.g. for cases of inter-dependent fields. These validations are applied when the configuration is loaded through the `ConnectorBaseConfig.validate` method, which is called automatically when the connector is started. The `validate` method returns a validated Configuration instance as a `pydantic` dataclass, which also applies field validations on assignment. It is important to note that `pydantic` relies on a `ValueError` being raised as a result of invalid configuration, we therefore suggest always using the CDK's `ConfigurationError` (our custom `ValueError`) as shown in the upcoming sections. All validation methods MUST throw some `ValueError` for failure cases in order to be caught by `pydantic`. ### Field Validation Field validations apply to single fields in the configuration, and can be used to further constrain allowed user-supplied values. Field-level validations are applied as property descriptors, meaning that they are applied both when the configuration is loaded via the `validate` method and when a field is set post-validation. ```text unitelabs.cdk.config.ConfigurationError: Invalid configuration for : 1 validation error for " Value error: ``` **Listing 7**: How field-level `ConfigurationError`s are displayed. ::tabs :::div{label="Annotated pattern"} ```python import dataclasses import typing from unitelabs.cdk.config import ConfigurationError, ConnectorBaseConfig, Field LargeInt: typing.TypeAlias = typing.Annotated[int, Field(gt=1_000_000)] @dataclasses.dataclass class ConnectorConfig(ConnectorBaseConfig): large_int: LargeInt = 1_000_001 """A large integer.""" another_large_int: LargeInt = 1_000_001 """Another large integer.""" ``` **Listing 8**: Using `typing.Annotated` field annotation for validating that numbers are greater than (`gt`) 1,000,000. Calling `connector start` with a configuration where `large_int=1` will result in the following error message: ```bash unitelabs.cdk.config.ConfigurationError: Invalid configuration for MyConfig: 1 validation error for MyConfig large_int Input should be greater than 1000000 [type=greater_than, input_value=1, input_type=int] For further information visit https://errors.pydantic.dev/2.11/v/greater_than ``` Additionally, `Field` annotations allow one to add metadata to fields, such as descriptions and examples, which are included in the config's JSONschema representation, accessible with the `config schema` CLI command. ```python [config.py] import uuid import pydantic from unitelabs.cdk.config import Field @dataclasses.dataclass class ConnectorConfig(ConnectorBaseConfig): large_int: typing.Annotated[int, Field(gt=1_000_000, description="A large integer.", example=5_000_000)] = 1_000_001 uuid: typing.Annotated[ str, pydantic.WithJsonSchema({"type": "string", "format": "uuid"}) ] = dataclasses.field(default_factory=lambda: str(uuid.uuid4())) """A unique identifier for the connector instance.""" ``` **Listing 9**: Using `typing.Annotated` field annotation for JSONschema specification. ::: :::div{label="Decorator pattern"} ```python import dataclasses import typing from unitelabs.cdk.config import ConfigurationError, ConnectorBaseConfig, validate_field @dataclasses.dataclass class MyConfig(ConnectorBaseConfig): large_int: int = -1 """A large integer.""" another_large_int: int = -1 """Another large integer.""" @validate_field("large_int", "another_large_int") @classmethod def must_be_large(cls, value: int) -> int: if value < 1_000_000: msg = "large_int must be larger than 1,000,000" raise ConfigurationError(msg) return value ``` **Listing 10**: Using `validate_field` classmethod decorator for validations. ::::callout{color="amber" icon="i-heroicons-exclamation-triangle"} The `validate_field` decorator is an alias for [`pydantic.field_validator`{style="color: green;"}](https://docs.pydantic.dev/latest/concepts/validators/#field-validators){rel=""nofollow,noopener""}. It takes as arguments one or more field names for which the wrapped `classmethod` should be called, here we apply `must_be_large` to the `large_int` and `another_large_int` fields. The method that `validate_field` wraps must be a classmethod and it must have a signature `(cls, value: Type) -> Type`, where the type matches the type of the field or fields being validated. Validation methods **must** return the value after validation is complete. :::: Calling `connector start` with a configuration where `large_int=1` will result in the following error message: ```bash unitelabs.cdk.config.ConfigurationError: Invalid configuration for MyConfig: 1 validation error for MyConfig large_int Value error, large_int must be larger than 1,000,000. [type=value_error, input_value=1, input_type=int] For further information visit https://errors.pydantic.dev/2.11/v/value_error ``` ::: :: --- Pydantic uses `Field` annotations and the `validate_field` decorator to create property descriptors for the dataclass field, thus ensuring that validations are always applied. When using the either of the above `MyConfig` classes: ```python config = MyConfig.validate({"large_int":5_000_000}) print(config.large_int) # 5000000 config.large_int = 500 # Raises pydantic.ValidationError ``` **Listing 11**: Field-level validations are also evaluated on assignment when using a validated config, i.e. one that has been created through the `validate` entrypoint. ### Field annotation arguments Using the Annotated pattern with the `Field` annotation additionally ensures that all validators are included in the Config's JSONschema representation, i.e. output of `config schema` will include [Structural Validations](https://json-schema.org/draft/2020-12/json-schema-validation#name-a-vocabulary-for-structural){rel=""nofollow,noopener""}. Number Validations: | Argument | Description | | ---------------- | -------------------------------------------------------------------------------------------------------------------- | | `gt` | input must be greater than value | | `ge` | input must be greater than or equal to value | | `lt` | input must be less than value | | `le` | input must be less than or equal value | | `multiple_of` | input must be a multiple of this value, e.g. for `multiple_of=2` valid values would include 2 and 4 but not 3 or 1.5 | | `decimal_places` | input's decimal precision must not exceed this value | String Validations: | Argument | Description | | ------------ | ------------------------------------------------------------- | | `pattern` | input must match the pattern of this regex value | | `max_digits` | input must contain a maximum of this number of allowed digits | | `min_length` | input must be at least of the given length | | `max_length` | input must not exceed the given length | List Validations: | Argument | Description | | ------------ | ---------------------------------------------------------- | | `min_length` | input list must contain a minimum of this number of values | | `max_length` | input list must contain a maximum of this number of values | ### Config Validation Config-level validations do not automatically offer feedback pointing to a specific field and are not re-evaluated when fields are set post-validation. ```text unitelabs.cdk.config.ConfigurationError: Invalid configuration for : 1 validation error for " Value error, ``` **Listing 12**: How config-level `ConfigurationError`s are displayed. This is because config-level configurations look to validate multiple inter-dependent fields and the validity of specific fields is determined in the context of the entire config. Let's take for example a setup where we have two modes for running something, one of which requires an additional configuration value: ::tabs :::div{label="post_init pattern"} Validations can be applied in the `__post_init__` of the configuration dataclass. ```python [dependent_fields_post_init.py] import dataclasses import typing_extensions as typing from unitelabs.cdk.config import ConfigurationError, ConnectorBaseConfig @dataclasses.dataclass class DependentFieldsConfig(ConnectorBaseConfig): mode: typing.Literal["A", "B"] = "A" b_number: int = -1 def __post_init__(self) -> None: if self.mode == "B" and self.b_number == -1: msg = "When mode=B, b_number must also be supplied." raise ConfigurationError(msg) ``` **Listing 13**: Using dataclasses `__post_init__` for validations. ::: :::div{label="Decorator pattern"} ```python [dependent_fields.py] import dataclasses import typing_extensions as typing from unitelabs.cdk.config import ConfigurationError, ConnectorBaseConfig, validate_config @dataclasses.dataclass class DependentFieldsConfig(ConnectorBaseConfig): mode: typing.Literal["A", "B"] = "A" """The mode to run the connector in.""" b_number: int = -1 """An integer required when mode is 'B'.""" @validate_config() def check_b_config(self) -> typing.Self: if self.mode == "B" and self.b_number == -1: msg = "When 'mode=B', 'b_number' must also be supplied." raise ConfigurationError(msg) return self ``` **Listing 14**: Using `validate_config` method decorator for validations. This decorator pattern is preferred over the post\_init approach when implementing multiple separate methods to validate different aspects of a configuration. ::::callout{color="amber" icon="i-heroicons-exclamation-triangle"} The `validate_config` decorator is an alias for [`pydantic.model_validator`{style="color: green;"}](https://docs.pydantic.dev/latest/concepts/validators/#model-validators){rel=""nofollow,noopener""} with`mode="after"`. The method that `validate_config` wraps must be an instance method and it must have a signature `(self) -> Self`, where `Self` is the type of the configuration class. Validation methods **must** return `self` after validation is complete. :::: ::: :: This setup will expose to the user a default configuration for mode "A". When the user adjusts the values in their config file and tries to run the connector they will receive clear feedback from the `ConfigurationError` about the changes that are necessary to make their configuration valid. ## Best Practices Do not change configuration values in the Connector `create_app` function; this is important in order to best respect user settings. Values which can be derived from the device itself should rather be retrieved from the device in the `create_app` function after establishing communication, especially in instances when the user would be penalized for incorrectly setting the configuration value. When creating a configuration value of this sort, design the `create_app` function to ensure the value is **not required** for starting the connector and clearly document the endpoint that users should call to retrieve the correct configuration values in the docstring for that value. Connector configuration should best be focused on: - establishing connection to the device - adjustment to communication behaviors, e.g. reconnection delays or timeouts - adjustment to connector reactivity behaviors, e.g. tolerance settings for sensors - establishing default start-up conditions ## Transitioning to V0.5.0 ```python [pre_v0.5.0_config.py] import collections.abc from importlib.metadata import version from unitelabs.cdk import Config, Connector __version__ = version("unitelabs-connector") class ConnectorConfig(Config): serial_port: str device: typing.Literal["DeviceTypeA", "DeviceTypeB"] async def create_app() -> collections.abc.AsyncGenerator[Connector]: config = CustomConfig() # here env was already modified by the start command and is now loaded in app = Connector( { "sila_server": { "name": f"{config.device}", "type": "Device Type", "description": f"{config.device} is a Device Type instrument.", "version": str(__version__), "vendor_url": "https://unitelabs.io/", } } ) # app.register() # register Features on the Connector yield app # cleanup ``` **Listing 15**: Usage of old `Config` for versions of the CDK < v0.5.0. All fields should have a default value; these default values should, when possible, allow for users to start the connector without input. ```python [v0.5.0_config.py] import collections.abc import dataclasses import typing from importlib.metadata import version from unitelabs.cdk import Connector from unitelabs.cdk.config import ConnectorBaseConfig, SiLAServerConfig, delayed_default __version__ = version("unitelabs-connector") @dataclasses.dataclass class ConnectorConfig(ConnectorBaseConfig): serial_port: str = "/dev/ttyUSB0" """The serial port to which the device is connected.""" device: typing.Literal["DeviceTypeA", "DeviceTypeB"] = "DeviceTypeA" """The type of device to which the connector should connect.""" sila_server: SiLAServerConfig = dataclasses.field( default_factory=delayed_default(lambda self: SiLAServerConfig( name=f"{self.device}", type="Device Type", description=f"Connector for the {self.device} instrument.", version=str(__version__), vendor_url= "https://unitelabs.io/", ))) async def create_app(config: CustomConfig) -> collections.abc.AsyncGenerator[Connector]: app = Connector(config) # app.register() # register Features on the Connector yield app # cleanup ``` **Listing 16**: Upgraded v0.5.0 configuration using `ConnectorBaseConfig`. ## The SILA Server Config ### Required fields - **hostname** - The name of the host which the server should bind to. - **port** - The port which the server should bind to. - **name** - The human readable name of the server. - **type** - A human readable identifier for the grouping or type of instrument the server connects to. - **description** - The use and purpose of the server. - **version** - The version for the server, following the [Semantic Version Specification](https://semver.org/){rel=""nofollow,noopener""}. - **vendor\_url** - The URL to the website of the vendor or product which the server connects to. ### Optional fields For TLS encryption configuration (Further information available in our [Security Guide](https://docs.unitelabs.io/connector-development/guides/security/).) - **tls** - Whether or not TLS encryption should be applied to the server. - **certificate\_chain** - A path to, or the contents of, the PEM-encoded certificate chain, or `None` if no certificate should be used. - **private\_key** - A path to, or the contents of, the PEM-encoded private key, or `None` if no private key should be used. For gRPC connection configuration - **require\_client\_auth** - Whether or not to require clients to be authenticated, used in combination with `root_certificates`. - **root\_certificates** - The PEM-encoded root certificates, or `None` which uses the gRPC default location. - **options** - A dictionary of values for configuring the underlying gRPC connection, advanced configuration. ## The Cloud Server Config Connecting to a cloud server is optional for connectors. In order to configure a Connector to interact with the UniteLabs platform, we must set the required values of the `CloudServerConfig`. ### Required fields - **hostname** - The target hostname to connect to. - **port** - The target port to connect to. ### Optional fields - **tls** - Whether or not TLS encryption should be applied to the server. - **root\_certificates** - The PEM-encoded root certificates, or `None` which uses the gRPC default location. - **certificate\_chain** - A path to, or the contents of, the PEM-encoded certificate chain, or `None` if no certificate should be used. - **private\_key** - A path to, or the contents of, the PEM-encoded private key, or `None` if no private key should be used. - **reconnect\_delay** - The time in ms to wait before attempting to reconnect the channel after an error occurs. - **options** - A dictionary of values for configuring the underlying gRPC connection, advanced configuration. # Logging ::callout{icon="i-heroicons-information-circle"} This guide focuses specifically on logging usage within the CDK. It should be enough for most people to get started. For a base-level introduction to Python logging, read the [Python standard library logging basic tutorial](https://docs.python.org/3/howto/logging.html#logging-basic-tutorial){rel=""nofollow,noopener""}. :: Logging is essential for understanding a connector's behavior while debugging and in production. The CDK provides a structured logging system built on top of [`structlog`{style="color: green;"}](https://www.structlog.org/en/stable/){rel=""nofollow,noopener""} and Python's standard `logging` module. Out of the box a connector emits human-readable logs to the terminal, and in production it additionally writes JSON-structured logs to a rotating file. This guide covers how to **emit logs from your own connector code**. For what a connector logs by default and how to customize its log output (levels, files, rotation), see [Connect a device — From source](https://docs.unitelabs.io/integrate/connect-a-device/from-source#logging). ::callout{icon="i-heroicons-light-bulb"} To scaffold a config file pre-populated with the default logging presets, run `config create` with the `--explicit-logging`/`-x` flag (defaults to `prod`), e.g. `uv run config create -x dev`. See [Customizing the logging configuration](https://docs.unitelabs.io/integrate/connect-a-device/from-source#customizing-the-logging-configuration) for the copyable available presets. :: ## Prerequisites - CDK v0.13.0 or higher - A Connector, check out our [Installation Guide](https://docs.unitelabs.io/connector-development/getting-started/installation/) to create a new Connector project with our [`connector-factory`{style="color: green;"}](https://gitlab.com/unitelabs/cdk/connector-factory/){rel=""nofollow,noopener""} cookiecutter and the [Connector Walkthrough](https://docs.unitelabs.io/connector-development/tutorial/walkthrough/) to get started writing your own Connector. ## Logging from your connector code Wherever logging is required in a module of a connector, a logger can be created using: ```python from unitelabs.cdk import get_logger logger = get_logger(__name__) ``` `get_logger` returns a named logger instance. `__name__` is recommended to keep the logger names standard and clearly assigned to a module hierarchically by dotted path. Note that we call `get_logger` imported from the CDK and not `logging.getLogger` from Python's standard `logging` module. ::callout{icon="i-lucide-wrench"} Using Python's standard `logging` still works without any additional setup and will be merged automatically with the CDK's internal structlog logs. We recommend the CDK's `get_logger` to get access to all structlog features (more details [below](https://docs.unitelabs.io/#structlog-features)) :: Whenever a module or class requires a lot of logging, consider exposing the logger as a property to make the code more readable. Consider for example a protocol class: ```python from unitelabs.bus import Protocol from unitelabs.cdk import get_logger, BoundLogger class DeviceProtocol(Protocol): def __init__(self, ...): ... @property def logger(self) -> BoundLogger: """A structured logger for this protocol.""" return get_logger(__name__) async def execute(self, command: str) -> None: ... async def make_device_do_something(self) -> str: """Make the device do science.""" try: await self.execute("start_science") except ValueError as exc: self.logger.error("Failed to run `start_science`.", exc_info=exc) raise self.logger.debug("Successfully ran `start_science`.") ``` ## Structlog features Using the `get_logger` method from the CDK exposes a [`structlog`](https://www.structlog.org/en/stable/){rel=""nofollow,noopener""} bound logger. This means the following features are available: **Structured key-value context.** Pass keywords alongside the message; they become first-class fields in the JSON output instead of being baked into a string: ```python logger.info("Command finished", command="start_science", duration_ms=42) # JSON: {"event": "Command finished", "command": "start_science", "duration_ms": 42, ...} ``` **Bound loggers.** Pre-bind context that should appear on every subsequent log line from that logger: ```python log = logger.bind(device_id="A1B2") log.info("connected") # includes device_id log.warning("timeout") # includes device_id ``` **Exceptions.** Pass the exception via `exc_info=` to attach a formatted traceback: ```python try: ... except ValueError as exc: logger.error("Validation failed", exc_info=exc) ``` **Standard-library-style formatting** also still works, so existing code needs no changes: ```python logger.info("Detected file change: %s", path) ``` # Deployment ::callout{color="amber" icon="i-heroicons-exclamation-triangle"} Parts of this guide predate the current configuration system. For up-to-date configuration, see the [Configuration guide](https://docs.unitelabs.io/connector-development/guides/configuration/). :: Connectors are the backbone of an automation setup and a robust deployment is essential for smooth operation. The optimal deployment process can vary depending on the connector and some may even require a specific operating system. UniteLabs is open for standard-compliant third party or open-source connectors which can have a deviating deployment process. All connectors developed by UniteLabs are based on the UniteLabs Connector Development Kit (CDK) which promotes connector containerization with Docker. If a UniteLabs connector requires a specific operating system caused by limitations incurred by the vendor hard- or software, a specific deployment instruction is provided that may deviate from the general deployment instructions below. ::tabs :::div{icon="i-simple-icons-docker" label="Docker"} **1. Prepare Docker** Deploying connectors via Docker presents an efficient solution for achieving operating system agnosticism, catering to a wide range of platforms including microcontrollers and computers. Leveraging Docker ensures consistent execution environments, mitigating compatibility challenges and facilitating a seamless deployment processes. Before delving into the Docker deployment specifics, it's essential to understand the fundamentals of Docker and establish necessary prerequisites. More information on installing Docker on various operating systems is found in the [Docker documentation](https://docs.docker.com){rel=""nofollow,noopener""}. These setup instructions are specific to a Docker installation under Unix. Set up Docker's apt repository: ```bash sudo apt install vim ca-certificates curl gnupg sudo install -m 0755 -d /etc/apt/keyrings curl -fsSL https://download.docker.com/linux/debian/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg sudo chmod a+r /etc/apt/keyrings/docker.gpg echo \ "deb [arch="$(dpkg --print-architecture)" signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian \ "$(. /etc/os-release && echo "$VERSION_CODENAME")" stable" | \ sudo tee /etc/apt/sources.list.d/docker.list > /dev/null sudo apt update ``` Install the additional Docker packages: ```bash sudo apt install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin ``` Grant the user (default: `unitelabs`) access to Docker: ```bash sudo usermod -a -G docker unitelabs ``` Configure Docker to start on boot with systemd: ```bash sudo systemctl enable docker.service sudo systemctl enable containerd.service ``` **2. Authenticate Docker** This step is required for UniteLabs connectors: To enable Docker to pull the connector image, Docker needs access to the UniteLabs Artifactory: A username (Tenant UUID) and password (GitLab deploy token) are required. - Username: 32 characters separated by dashes in UUID4 format, e.g. 56ae938f-484c-41cc-ae54-e4f9b8814fb7. - Password: 22 characters separated by dashes, e.g. ahjt-Lk-e1swOPZcWYYGh-mKJ [Authenticate Docker](https://docs.docker.com/reference/cli/docker/login/){rel=""nofollow,noopener""} using the command below. This allows the docker daemon to pull private Docker images (You may need to restart the machine!). The Docker daemon must be running for this! ```bash sudo reboot echo "" | docker login registry.gitlab.com --username --password-stdin ``` **3. Create a Docker Compose file** [Docker Compose](https://docs.docker.com/compose/){rel=""nofollow,noopener""} is used to manage deployments more efficiently, especially when dealing with the deployment of multiple connector applications on the same system. Problems that are tackled by using Docker Compose include automatic restarts on connector crashes or reboots as well as log rotation and memory management. When `docker` is available and installed correctly, create a docker-compose.yml file. This is typically done by a UniteLabs representative. The default path for this file is `/home/unitelabs/docker-compose.yml`. ```yaml [docker-compose.yml] services: hamilton-microlab-star: image: registry.gitlab.com/unitelabs/connectors/hamilton-star:2cd5f877 restart: always environment: ENVIRONMENT: production SILA_SERVER__HOSTNAME: 0.0.0.0 SILA_SERVER__PORT: 50051 SILA_SERVER__UUID: e1c7147d-a11f-4ac5-9518-df4f62ac6e1c CLOUD_SERVER_ENDPOINT__HOSTNAME: ca91c49c-ca1-40b7-9b93-46b69861f367.unitelabs.io CLOUD_SERVER_ENDPOINT__PORT: 443 CLOUD_SERVER_ENDPOINT__TLS: True SIMULATION: false privileged: true volumes: - /dev/bus/usb:/dev/bus/usb ``` > Depending on the connector, some resources of the host system must be made available such as serial and USB ports. > Apart from the Server and cloud-endpoint specific environmental variables, the connector may accept additional > environmental variables. These are generally documented in the connector documentation. The connector image is pulled from the UniteLabs GitLab container registry. A version can be specified using the trailing hash of the registry URL. The `latest` tag can be used to pull the most recent stable image. Multiple connectors (services) can be defined in the same docker-compose file. **4. Manage connector containers** Start the containers with: ```bash docker compose up -d ``` Running containers can be listed with `docker ps`. The logs of each container can be accessed with `docker logs `. Individual containers can be restarted and stopped using the basic Docker commands `docker stop ` and `docker restart `. ::: :::div{icon="i-simple-icons-windows" label="Windows Startup"} While Docker containers are the preferred option to deploy connectors, certain applications may require the installation of a connector on a machine with a Windows operating system. In these cases, it is often advantageous for the connector to start automatically upon user login, such as after a system restart. **Setup** This installation option requires a Python installation with the appropriate package manager (either poetry or uv). The connector repository can be cloned to the machine using Git or, if Git is unavailable, downloaded manually from GitLab. Access to connector repositories must be granted by UniteLabs and the deployment token and further credentials need to be set up accordingly. Reach out to the UniteLabs support for further help (). Projects which contain a `poetry.lock` are managed by Poetry: within the connector root directory, open a command prompt window and run `poetry install`. Projects which contain a `uv.lock` are managed by UV: within the connector root directory, open a command prompt window and run `uv sync`. **Register service with Windows Startup** If the connector is specifically designed for Windows, it may already include two .bat files in the root folder: `register_login_item.bat` and `gen5_service.bat`. Executing `register_login_item.bat` with administrative privileges will set up the connector to start upon login. *If this worked the rest of this tab can be ignored.* **Debug: The files are not present** Should these files be absent, they can be created manually. Create two files named `register_login_item.bat` and `gen5_service.bat`, ensuring they are saved with the .bat extension. Open these files in a text editor (e.g. VS Code, PyCharm, Notepad++) and enter the following content: ```bash [gen5_service.bat] call run connector start ``` where :runner[is the name of the environment management tool the project is using, e.g. `poetry` or `uv`.] And: ```bash [register_login_item.bat] @echo off setlocal :: Define variables set "BAT_FILE=gen5_service.bat" set "SHORTCUT_NAME=gen5_service.lnk" set "CURRENT_DIR=%~dp0" set "STARTUP_FOLDER=%APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup" :: Check if the batch file exists if not exist "%CURRENT_DIR%%BAT_FILE%" ( echo Error: %BAT_FILE% does not exist. exit /b 1 ) :: Create the shortcut set "VBS_SCRIPT=%TEMP%\create_shortcut.vbs" echo Set oWS = WScript.CreateObject("WScript.Shell") > "%VBS_SCRIPT%" echo sLinkFile = "%CURRENT_DIR%%SHORTCUT_NAME%" >> "%VBS_SCRIPT%" echo Set oLink = oWS.CreateShortcut(sLinkFile) >> "%VBS_SCRIPT%" echo oLink.TargetPath = "%CURRENT_DIR%%BAT_FILE%" >> "%VBS_SCRIPT%" echo oLink.WorkingDirectory = "%CURRENT_DIR%" >> "%VBS_SCRIPT%" echo oLink.WindowStyle = 7 >> "%VBS_SCRIPT%" echo oLink.Save >> "%VBS_SCRIPT%" cscript /nologo "%VBS_SCRIPT%" del "%VBS_SCRIPT%" :: Move the shortcut to the Startup folder if not exist "%STARTUP_FOLDER%" ( mkdir "%STARTUP_FOLDER%" ) move "%CURRENT_DIR%%SHORTCUT_NAME%" "%STARTUP_FOLDER%" echo Shortcut for %BAT_FILE% created and moved to startup folder. :: Display message and wait echo Registration complete timeout /t 10 endlocal exit /b 0 ``` **Debug: register\_login\_item.bat did not work** The `gen5_service.bat` file must still be created with the same script provided in the automatic setup. ```bash [gen5_service.bat] call run connector start ``` where :runner[is the name of the environment management tool the project is using, e.g. `poetry` or `uv`.] After creation, create a shortcut by right-clicking the gen5\_service.bat file and selecting ‘Create shortcut‘. On Windows 11 this can be found under ‘Show more options' > ‘Create shortcut'. Next, press Win + R, type `shell:startup`, and press enter to open the startup folder. Drag and drop the previously created shortcut into this folder to complete the setup. This process ensures the connector will start whenever the system logs in a user. ::: :: # Security ## TLS Encryption We encourage you to secure the communication between your connector and the client. For the underlying technology, this means that you need to provide a certificate which is then used to encrypt the transferred data. Additionally, a trusted certificate allows the client to verify the server's identity. ### Self-signed Certificates Anyone can make their own certificates without the help from a certificate authority (CA). The only difference is that certificates you make yourself won't be trusted by anyone else. For local development, that's fine. The simplest way to generate a private key and self-signed certificate for your connector is with these commands: ::code-group ```bash [uv] $ cd connector-starter $ uv add --optional dev cryptography $ uv run certificate generate ``` ```bash [hatch] $ cd connector-starter $ hatch run uv pip install cryptography $ hatch run certificate generate ``` ```bash [poetry] $ cd connector-starter $ poetry add --optional dev cryptography $ poetry run certificate generate ``` :: The certificate generation requires the cryptography package to be present. The command reads the server's uuid and host from your project's `config.json` file, if it exists, to configure the generated certificate. If you do not already have a configuration file, you can create a default "config.json" file in your working directory: ::code-group ```bash [uv] uv run config create ``` ```bash [hatch] hatch run config create ``` ```bash [poetry] poetry run config create ``` :: The `certificate generate` command will prompt whether or not you want to update your config file to activate TLS encryption. This prompt can be suppressed with the addition of the `-y` flag to the command. For more information about the available options, see: ::code-group ```bash [uv] uv run certificate generate --help ``` ```bash [hatch] hatch run certificate generate --help ``` ```bash [poetry] poetry run certificate generate --help ``` :: ### Enable Encryption To enable TLS encryption for your connector, you need to provide a certificate and its corresponding private key in the config, if you ran `certificate generate` with `--embed` or `-y` or you accepted updates to your configuration file, your config is already up-to-date and ready to use with the `connector start` command. ::code-group ```bash [uv] uv run connector start --app unitelabs.connector_starter:create_app ``` ```bash [hatch] hatch run connector start --app unitelabs.connector_starter:create_app ``` ```bash [poetry] poetry run connector start --app unitelabs.connector_starter:create_app ``` :: When using a self-signed certificate, it might be necessary to add the certificate to the client's trust store in order for it to trust your connector. Otherwise connections might be rejected. Also make sure to keep your private key safe. For more information about the connector start CLI options, see: ::code-group ```bash [uv] uv run connector start --help ``` ```bash [hatch] hatch run connector start --help ``` ```bash [poetry] poetry run connector start --help ``` :: # Subscriptions Tracking certain pieces of information and keeping them continuously available and up-to-date is a common requirement. When such data is needed in multiple parts of a connector, it often leads to complex and repetitive logic to send and retrieve that information when it is needed. To simplify this, we provide two classes within the subscription module - `Subject` and `Publisher` - which offer rich functionality for managing, manipulating, and distributing data across connectors. `Subject` and `Publisher` are both observables -- containers that notify subscribers when their values change. They each hold a single value, and when that value is updated, they inform their subscribers of the new value. The key difference is in how they get their values: - The `Subject` is reactive to events. You push values into it manually. - The `Publisher` polls (regularly requests updates from) a data source. It pulls values automatically into itself. This guide covers their core features and aims to make working with subscribable endpoints straightforward. ::callout{color="yellow" icon="i-heroicons-light-bulb"} **Note**: much of the focus in this guide will be aimed towards Feature development, `Subject` and `Publisher` are also well-suited for use in the Protocol layer of your connector, enabling re-use of data streams and pipelines throughout your Connector. :: ## Subject: Event-Driven Data Subjects are reactive to events, meaning we must actively send new values to the `Subject` that we want to make available elsewhere in our connector. Let us consider a feature where we want the ability to set a state and also allow users to actively monitor changes to that state. ```python [src/unitelabs/tutorial/features/subscriptions/tracked_feature.py] import asyncio import dataclasses import datetime from unitelabs.cdk import Subject, sila @dataclasses.dataclass class State: """ The state of the TrackedFeature. Attributes: CurrentState: The current state. UpdateTimestamp: When state changed. """ current: str when: datetime.datetime class TrackedFeature(sila.Feature): def __init__(self): super().__init__() self.state_subject = Subject[State](maxsize=10) @sila.ObservableProperty() async def subscribe_state(self) -> sila.Stream[State]: """Subscribe to changes in the current state.""" return self.state_subject.subscribe() @sila.UnobservableCommand() async def perform_action(self) -> None: """Perform an action.""" self.state_subject.update( State( current="action_started", when=datetime.datetime.now() ) ) # perform action await asyncio.sleep(1) self.state_subject.update( State( current="action_complete", when=datetime.datetime.now() )) ``` **Listing 1**: Implementing a basic `Subject` and using the `Subscription` as a `sila.Stream`. First, we define a dataclass to structure our state data and a `Subject[State]` to hold and distribute our `State` values. Then we define our `ObservableProperty` and use `Subject.subscribe` to create a `Subscription` that listens for new `State` values put into the `Subject`. We then create a method `perform_action` for which we would like to track the state as it is being run. It calls `Subject.update` with an instance of the `State` dataclass to set a new `State` value on the `Subject`. If the `Subject` has any subscribers, it will notify them of the new value. ::callout{color="green" icon="i-heroicons-magnifying-glass"} Technical Hint: In `Subject[State]`, the bracketed value `[State]` indicates the type of values that can be passed into `Subject.update`. Here `state_subject` has an implicit type of `Subject[State, State]`, meaning that `State` is also the type that it provides to subscribers. We will dig more into this later when we talk about pipes and filters :: ## Publisher: a Self-updating Subject The CDK additionally provides a subclass of `Subject`, called a `Publisher`, which will update its value by calling a provided `source` function at a given `interval`. Publishers are ideal for sensor data and other data sources which can be stably represented with a callable. ```python [src/unitelabs/tutorial/features/subscriptions/number_providers.py] import random from unitelabs.cdk import Publisher, sila def get_random_number() -> int: return random.randint(0, 42) class RandomNumberProvider(sila.Feature): def __init__(self): super().__init__() self.number_publisher = Publisher[int](source=get_random_number, interval=1) @sila.ObservableProperty() async def subscribe_random_number(self) -> sila.Stream[int]: """Subscribe to observe changes to the random number.""" return self.number_publisher.subscribe() ``` **Listing 3**: Implementing a self-updating observable with `Publisher`. When we subscribe to a `Publisher` it will create a background task that calls the `source` function every `interval` seconds. Publishers smartly manage resources such that only a single background task is created to notify multiple subscribers. Only when all subscribers, including subscribers on Publisher's children, i.e. those created from the `pipe` and `filter` methods, have unsubscribed from the `Publisher` will it cancel its background task to free resources. A new `Subscription` of a `Subject` will wait for the next call to `update` for its first value. In comparison, a `Publisher` assumes that its most recently seen value is still relevant information for its subscribers and immediately adds this value to the `Subscription` queue. ## Subscription Values We've seen that we can use a `Subscription` directly as a `sila.Stream`, but what if we want to perform additional operations on the values received from the `Subject`? Here we can use `Subject`'s built-in context-manager which will create a new `Subscription` that is automatically unsubscribed from the `Subject` when the property/method is terminated: ```python [src/unitelabs/tutorial/features/subscriptions/tracked_feature.py] class TrackedFeature(sila.Feature): ... @sila.ObservableProperty() async def subscribe_current_state(self) -> sila.Stream[str]: """Subscribe to observe changes to the tracked state.""" with self.state_subject as subscription: async for state in subscription: yield state.current ``` **Listing 2**: Using the `Subject`s context-manager to access `Subscription` values. From a user's perspective, when the client stops listening to further updates from the `subscribe_current_state` endpoint, i.e. the user cancels their subscription, the gRPC call will be cancelled and the `Subscription` gracefully cancelled. ::callout{color="green" icon="i-heroicons-magnifying-glass"} Technical Hint: Here the use of the `Subject` as a context-manager means that we do not have to unsubscribe our `Subscription` from the `Subject` as this is done by the context-manager as it closes. When usage with the context-manager is not desired, direct usage of the `Subscription` should be wrapped in a try/finally block to ensure that the `Subscription` is unsubscribed from the `Subject`/`Publisher` when the method is terminated by the client (see Listing 11 for an example using `Subscription` queues). :: ## Subscription Operations ### Transforming data A `Subject` or `Publisher` can produce children which receive updates from their parent and apply a static operation to all values received from the parent subject/publisher. A simplistic example of this: ```python [src/unitelabs/tutorial/features/subscriptions/number_providers.py] from unitelabs.cdk import Subject, sila class NumberProvider(sila.Feature): def __init__(self): super().__init__() self.single = Subject[int](maxsize=10) self.double = self.single.pipe(lambda x: x * 2) @sila.ObservableProperty() async def subscribe_single_value(self) -> sila.Stream[int]: """Subscribe to observe changes to the set value.""" return self.single.subscribe() @sila.ObservableProperty() async def subscribe_double_value(self) -> sila.Stream[int]: """Subscribe to observes changes to the doubled value.""" return self.double.subscribe() @sila.UnobservableCommand() async def set_value(self, value: int) -> None: """Update the value of any currently running subscriptions.""" self.single.update(value) ``` **Listing 4**: Creating and subscribing to `Subject`s with static data transformations via `Subject.pipe`. Here, we create a `Subject` that is updated by the user with the `set_value` endpoint. Calling `set_value` will update the value on `single`, which will update its child-subject, `double`, with that same value. Any value `x` received by `double` will automatically have the transformation `x * 2` applied to it before it is yielded to subscribers. Typing clarification: When declaring a `Subject` we generally must only declare a single type. The second type we see in the return signature is the type which is sent to `Subscription`s of the `Subject`, which defaults to the input value type. When a `Subject` is instantiated with a pipe operation, it is required to additionally declare this second type, which would be the return type of the pipe function. ```python def stringify(value: int) → str: return str(value) subject = Subject[int, str](maxsize=10, pipe=stringify) ``` **Listing 5**: Instantiating a `Subject` with a `pipe` function to immediately apply a data transformation. If the `pipe` is added to a `Subject[T]` after initiation, it will accept a callable with the signature `(T) -> Any`, e.g. a `Subject[int]` will accept a callable with the signature `(int) -> Any`. Finally, multiple pipes can be chained together, with the type of the return value from the previous pipe being the required parameter type for the subsequent pipe, i.e. ```python float_subject: Subject[float, float] = Subject[float](maxsize=10) str_subject: Subject[int, str] = dict_subject.pipe(lambda x: int(x)).pipe(lambda x: str(x)) ``` **Listing 6**: Chaining pipes together to apply multiple data transformations. ### Filtering data We can also apply filters to our `Subject` or `Publisher` such that its `Subscription`s and children are conditionally notified using the `filter` method. ```python numbers = Subject[int](maxsize=10) big_numbers = numbers.filter(lambda x: x > 100) big_numbers_made_small = big_numbers.pipe(lambda x: x / 100) ``` **Listing 7**: Filtering `Subject` data to prevent its propagation to `Subscription`s with `Subject.filter`. A filter is essentially a special pipe which only propagates data to `Subject`s subscribers when the condition of the filter function is met. Here calling `numbers.update(100)` would notify a subscriber of `numbers`, but not a subscriber of `big_numbers` or its child `big_numbers_made_small`. ## Advanced Usage ### Resource Management Whenever `pipe` or `filter` is called, a new child-subject is created. By design, `Subject`s and `Publisher`s notify all child-subjects in their hierarchy. This can mean that if we are creating many child-subjects via `pipe` and `filter` we may eventually begin to see our `Subscription`s slowing down due to unnecessary resource allocation. Consider the following scenario: ```python [src/unitelabs/tutorial/features/subscriptions/global_counter_provider.py] from unitelabs.cdk import Publisher, sila value = 0 def get_next_value() -> int: global value value += 1 return value class GlobalCounterProvider(sila.Feature): def __init__(self): super().__init__() self.counter = Publisher[int](source=get_next_value, interval=1) @sila.ObservableProperty() async def subscribe_even_numbers(self) -> sila.Stream[int]: """Subscribe to observe changes to the counter, when the new number is an even number.""" return self.counter.filter(lambda x: x % 2 == 0).subscribe() ``` **Listing 8**: Misuse of the `filter` and `pipe` methods resulting in unnecessary resource allocation. Now, if we were to call `subscribe_even_numbers` multiple times, every call would create a fresh child-subject. Even though the `Subscription` is properly unsubscribed, the child-subject remains in the `Subject`s known children. Subsequent calls to the endpoint will get slower as the `counter` propagates values to a growing list of child-subjects. This could be solved in the manner previously described, by creating a stable child-subject that lives in the `__init__` method of the `Feature`, as demonstrated in Listing 3. However, this is not always practical, especially when the child-subjects are created dynamically based on user input or other runtime conditions. A small change in how we use `pipe` and `filter` can ensure that these child-`Subject`s are removed from a `Subject` or `Publisher`s children when they are no longer being subscribed to. ```python [src/unitelabs/tutorial/features/subscriptions/global_counter_provider.py] ... class GlobalCounterProvider(sila.Feature): ... @sila.ObservableProperty() async def subscribe_even_numbers(self) -> sila.Stream[int]: """Subscribe to observe changes to the counter, when the new number is an even number.""" return self.counter.filter(lambda x: x % 2 == 0, temporary=True).subscribe() ``` **Listing 9**: Creating a temporary child-subject to ensure proper disposal after unsubscribe. By calling `pipe` and `filter` with `temporary=True` we mark these child-subjects for removal once their dependent `Subscription`s have been cancelled. Note: Trying to create a non-temporary child `Subject`s of a temporary `Subject` will raise an error at runtime. ```python temp = subject.pipe(method1, temporary=True).pipe(method2) # raises temp = subject.pipe(method1, temporary=True).pipe(method2, temporary=True) # ok ``` **Listing 10**: Creating a non-temporary `Subject` from a temporary `Subject` will raise a `RuntimeError`. ### Subscription Queue We can also use the `Subscription` as an `asyncio.Queue`. You may have noticed that the constructor of `Subject` and `Publisher` contain the argument `maxsize`. This refers to the maximum number of entries that can be held in any `Subscription` queues created with `Subject.subscribe`. We can operate on the underlying `asyncio.Queue` using the `Subject.get` method, and use this to manage execution flow in complex methods. ```python [src/unitelabs/tutorial/features/subscriptions/tracked_feature.py] from unitelabs.cdk import Subject, sila class SteppedActionError(Exception): """An error that occurs when an single step of a multi-step action does not update the state within the timeout.""" class TrackedFeature(sila.Feature): ... @sila.UnobservableCommand() async def execute_method(self) -> None: # execute method await asyncio.sleep(1) self.state_subject.update( State( current="method_executed", when=datetime.datetime.now(), )) @sila.UnobservableCommand() async def multi_step_action(self) -> None: """ Description of the multi-step action. Raises: SteppedActionError: Explanation for conditions under which it is raised, and how users can avoid and/or correct for the error. """ subscription = self.state_subject.subscribe() try: await self.perform_action() current_state = await subscription.get( predicate=lambda x: x.current == "action_complete", timeout=2.0, ) await self.execute_method() current_state = await subscription.get( predicate=lambda x: x.current == "method_executed", timeout=2.0, ) except TimeoutError as e: raise SteppedActionError() from e finally: self.state_subject.unsubscribe(subscription) ``` **Listing 11**: Using a `Subscription` as an `asyncio.Queue`. Here we have a method `multi_step_action` which expects certain `State` entries to be added to the `Subscription` and checks that those `State`s were observed before moving on. By calling `Subject.get` with timeout 1.0, we say that we expect a `State` entry matching our `predicate` within 1.0 seconds. This can be an important tool for controlling flow in complex functions. The call to `Subject.get` will block the code from moving forward until it gets a `State` that matches our condition, otherwise a `TimeoutError` will be thrown, preventing the function from moving on to the next step. ::callout{color="green" icon="i-heroicons-magnifying-glass"} Technical Hint: It is important here to wrap the use of the `Subscription` object in a try-finally loop. This is true not only when using the queue-functionality but also any time a `Subscription` object is used outside of the context-manager. This pattern ensures that the subscription is **always** properly disposed of, i.e. unsubscribed, when the subscription is cancelled. Improper handling of `Subscription`s can result in decreased performance for long-running processes. :: # Error Handling Error handling is a crucial aspect of developing reliable connectors for laboratory instruments. A connector must anticipate failures from various sources - whether due to invalid user input, communication failures, or hardware malfunctions - and handle them appropriately. This guideline explains how to manage these errors, document them effectively, and expose them via the SiLA 2 interface so that both client applications and users of the interface can understand and respond appropriately. Errors encountered in connector development typically fall into one of four categories: - **Communication Errors**: Failures in the underlying infrastructure. - **Framework Errors**: Violations of the SiLA 2 protocol or client misuse. - **Validation Errors**: Input that violates constraints, either defined at design-time or evaluated dynamically at runtime. - **Execution Errors**: Unexpected failures during the actual operation of a command or property. Each of these categories requires a different strategy for detection, reporting, and documentation. ## Communication Errors Communication failures occur at the system or transport layer and often originate from sources beyond your direct control, such as the operating system, network stack, or transport protocol. These failures may include issues like a loss of network connectivity or receiving corrupted or invalid protobuf messages. While they tend to be transient, they must still be reported to the client in a clear and user-friendly manner. Typically, communication failures are caught and handled by the underlying SiLA framework. However, you should still ensure your connector can recover from such interruptions gracefully and maintain a consistent internal state. For example, if the transport layer fails mid-command, the framework may abort execution, and your connector must avoid side effects from partial execution. ## Framework Errors Framework-level errors result from violations of the SiLA 2 protocol or incorrect client behavior. These may include scenarios where a client sends disallowed metadata or tries to execute a command after the server has explicitly disallowed further execution. In such cases, the SiLA framework automatically detects the violation and raises an appropriate error, requiring little or no intervention from connector developers. While you do not typically need to handle these errors yourself, understanding their origin can be helpful for diagnosing client-side integration issues. When debugging such problems, check whether the SiLA framework has rejected the request due to a protocol constraint or misuse of the interface. ## Validation Errors Connectors often need to enforce constraints on command input parameters. These constraints can be defined at two levels: 1. **Design-time**, using the SiLA framework's built-in constraint system. 2. **Runtime**, setting SiLA constraints based on device configuration at startup. If they are implemented using SiLA constraints as described in the constraints section of the [data types guide](https://docs.unitelabs.io/connector-development/tutorial/data-types#constraints), a `ValidationError` with details about the violation is raised automatically on invalid inputs. These errors are informative and predictable, making them ideal for user guidance. ## Execution Errors Execution errors are failures that occur at runtime - either during command execution or property evaluation. These may result from hardware malfunctions, device-reported exceptions, or internal logic issues within the connector. The following sections show how to document such errors clearly and how to include meaningful diagnostic information to help users and clients respond appropriately. ### SiLA Error Declarations When you know a specific error might occur during execution, you should define it explicitly in the SiLA interface using the `errors` argument or in the `Raises` section of the docstring. ::callout{color="green" icon="i-heroicons-magnifying-glass"} Technical Note: An exception listed in both the `errors` argument of the SiLA command/property and in the `Raises` section of the docstring will preferentially use the docstring representation of the error description. :: ::code-group ```python [python] from unitelabs.cdk import sila class MyError(Exception): """My error description.""" class MyFeature(sila.Feature): @sila.UnobservableProperty() async def get_my_property(self) -> float: """ My property value. Raises: ZeroDivisionError: Explanation for conditions under which it is raised, and how users can avoid and/or correct for the error. """ return 5 / 0 @sila.UnobservableCommand(errors=[MyError]) async def my_command(self) -> None: """ Execute my command. Raises: MyError: Explanation for conditions under which it is raised, and how users can avoid and/or correct for the error. """ raise MyError() ``` ```xml [xml] MyCommand My Command Execute my command. No MyError MyProperty My Property My property value. No Real ZeroDivisionError MyError My Error My error description. ZeroDivisionError Zero Division Error Second argument to a division or modulo operation was zero. ``` :: **Listing 3**: Declaring known execution errors in the SiLA interface so that clients can anticipate and handle them proactively. ::callout{icon="i-heroicons-light-bulb"} **Note**: Only **execution errors** should be included in a command or property's `errors` list or its docstring `Raises` section. **Validation errors**, even if raised at runtime, must **not** be listed - since they are not part of the SiLA feature definition and are not exposed in the interface at design time. :: **Defined Execution Errors** When an exception that is listed in the interface's `errors` declaration (or in `Raises` section of the method's docstring) is raised, the client receives a SiLA **Defined Execution Error**. This error includes a globally unique identifier along with an error message. The message is either the string passed when the exception was raised or, if no message was provided, the docstring of the exception class. Because the error is explicitly defined in the SiLA interface, clients and users can anticipate and handle it in advance. **Undefined Execution Errors** When an exception is raised that is not declared in the interface's `errors` list, the client receives a SiLA **Undefined Execution Error**. The message follows the same rules as for defined errors, but no unique identifier is included - only the message is transmitted. Since these errors are not part of the design-time interface, they cannot be anticipated by clients. ### Runtime Error Messages Design-time error descriptions should help users understand why an error might occur and how to avoid it. At runtime, always raise errors with specific and actionable messages. In the following example, the `MyError` exception is declared in the SiLA interface for `my_command`. This means that at design time, the client is already aware that `MyError` may occur and receives the description *"Docstring description."* as guidance for how to handle it. When the error is actually raised at runtime, a more detailed and situation-specific message is provided - "My error occurred and this is how you solve it: ..." - which is then passed to the client to aid in troubleshooting. ::code-group ```python [python] from unitelabs.cdk import sila class MyError(Exception): """My error description.""" class MyFeature(sila.Feature): @sila.UnobservableCommand() async def my_command(self) -> None: """ Description of my command. Raises: MyError: Docstring description. """ raise MyError("My error occurred and this is how you solve it: ...") ``` ```xml [xml] MyCommand My Command Execute my command. No MyError MyError My Error My error description. ``` :: **Listing 4**: Raising meaningful error messages at runtime to help users understand the cause and resolution of a failure. Providing clear, actionable error messages both in the interface definition and at runtime improves usability and reduces support overhead. # Serial Autodetect One common issue with serial-based Connectors is port-instability. When we initialize such a Connector we often provide it with the serial port that the device is connected to. In the long term the port is not a stable identifier; any time the device is connected it is possible that it will be given a different port value and that is liable to change if the device is disconnected and reconnected (unless we have some fancy udev rules set up). How do we ensure that our connector is not only connected to the correct device but that it holds that association? We can enable autodetect! Prerequisites: - A working `Protocol` configured to communicate with a device. - A data endpoint which provides a unique identifier for the device. ### How it works Autodetect relies on the fact that most devices have a unique identifier which we can programmatically request from the device. Let's take the following as an example: ```python import asyncio from unitelabs.bus import Protocol, create_serial_connection, SerialCommand class MyProtocol(Protocol): def __init__(self): port = "/dev/" super().__init__(create_serial_connection, port=port) async def get_serial_number(self) -> str: res = await self.execute(SerialCommand("sn")) return res ``` Autodetect will iterate through all connected devices and attempt to connect to it, only finalizing the connection only when the `identity` method returns True. ### Configuring Autodetect #### 1. Override the `identity` method Choose a function which extracts the identifier from the device and integrate it into identity: ```python [src/unitelabs/connector/io/protocol.py] class MyProtocol(Protocol): ... async def identity(self, **config_kwargs) -> bool: device_serial_number = await self.get_serial_number() return device_serial_number == config_kwargs["serial_number"] ``` #### 2. Update the Protocol init The Protocol init also needs to be adjusted to set `autodetect=True`. ```python [src/unitelabs/connector/io/protocol.py] class MyProtocol(Protocol): def __init__(self): super().__init__(create_serial_connection, autodetect=True) ``` We no longer need to provide `port` directly. #### 3. Update the Connector Finally we need to modify how we call `open` in our Connector to pass in the serial number from our `.env` config. In the top-level **init**: ```python [src/unitelabs/connector/__init__.py] from unitelabs.cdk import Connector, Config from .io.protocol import MyProtocol ... class MyConfig(Config): # extend Config to expect serial_number; will raise error if no value provided in .env serial_number: str async def create_app(): config = MyConfig() app = Connector( { "sila_server": { "name": "Autodetected Device", "type": "Example", "description": "A device with autodetect enabled.", "version": "0.1.0", "vendor_url": "https://unitelabs.io/", } } ) protocol = MyProtocol() await protocol.open(serial_number=config.serial_number) ... yield app protocol.close() `` ``` # Serial Troubleshooting ::callout{icon="i-heroicons-exclamation-triangle"} **Experimental API**: This API is still under development and therefore subject to change without notice. :: In serial communication, the message sender and the receiver structure their messages to each other with defined envelopes. Whenever either communication partner sends a message, the other partner listens until it hears the defined terminating sequence which tells the listener that the message is over, think "over" when using walkie-talkies. Because all messages sent must include the message terminator and all listening actions will continue until a message terminator is encountered, we can encapsulate this behavior into a reusable `Command` that ensures these rules are consistently followed. Take a look at `SerialCommand` to see what customizing this behavior looks like in action. `SerialCommand` allows the configuration of a `_read_terminator`, which defines the message envelope expected **from the device**, a `_write_terminator`, which defines the message envelope expected **by the device**, and an `encoding` which is used to convert between bytes and strings. ## Problem In an ideal world the `SerialCommand` works for all interactions with a serial device. The messages returned by the device, however were encoded by humans and it is very possible that in communicating with the device, messages are prematurely terminated by our `Protocol` because the full message from the device is malformed and contains multiple terminators. ## Solution To handle this behavior, the `Command` can be modified with the `multiline` decorator on the `_validate_response` method. ::tabs :::div{icon="i-heroicons-wrench" label="CommandBuilder"} ```python from unitelabs.bus import CommandBuilder builder = CommandBuilder(SerialCommand).with_multiline(0.001) cmd = builder.build("request") ``` `with_multiline` takes as an optional argument a callable function to replace the default super() call within `_validate_response`. ::: :::div{icon="i-heroicons-command-line" label="Command Subclassing"} ```python from unitelabs.bus import SerialCommand class MultilineSerialCommand(SerialCommand): @multiline(0.001) def _validate_response(self, data: bytes) -> bool: return super(SerialCommand, self)._validate_response(data) cmd = MultilineSerialCommand("request") ``` ::: :: `multiline` takes a timeout argument which sets the amount of time in seconds to wait for more data before calling the `_validate_response` method. The `timeout` should be as short a time as possible and is specific to the device's data transfer speeds. ::callout{color="amber" icon="i-heroicons-exclamation-triangle"} Calls to `super` from within the multiline wrapped method **must** use the old style super which specifies the base class, i.e. super(SelfClass, self) :: # Testing For this guide we will build upon the example `AwesomeInstrumentProtocol` that we declared in the [Walkthrough](https://docs.unitelabs.io/connector-development/tutorial/walkthrough/) to explore the topic of testing. ## Protocol Unit Testing Within the Omnibus package there are pre-configured fixtures designed to simplify the complexity of testing your protocol. To access Omnibus fixtures via pytest, simply add the following to your conftest file: ```python [tests/conftest.py] pytest_plugins = ["unitelabs.bus.testing.fixtures"] ``` One such fixture is `mk_stubbed_protocol`, which takes a `Protocol` subclass, a dictionary mapping between request bytes and return byte values, and the kwargs required for protocol initialization and returns an instance of your Protocol that mocks out device interaction and instead responds to commands sent via `execute` based on the provided dictionary mapping. In this way we can test that our protocol is handling all the different responses that the device can send. Here we will show how to use the fixture `mk_stubbed_protocol` to create an instance of the provided `Protocol` and mocks out the device responses based on a provided mapping between requests and responses. We can wrap this fixture into our own fixture as follows: ### 1. Protocol Test Fixture ```python [tests/conftest.py] import typing import pytest from unitelabs.awesome_instrument.io.awesome_instrument_protocol import AwesomeInstrumentProtocol pytest_plugins = ["unitelabs.bus.testing.fixtures"] @pytest.fixture def mk_my_protocol(mk_stubbed_protocol): def _mk_my_protocol(data: dict[bytes, typing.Union[bytes, list[bytes]]], **kwargs) -> AwesomeInstrumentProtocol: return mk_stubbed_protocol(AwesomeInstrumentProtocol, data, **kwargs) return _mk_my_protocol ``` By creating a fixture that returns a function, we are now able to call that function in all of our tests with a custom data dict and test our `Protocol`s behavior under different conditions and when different responses are sent from the device. (Alternatively we could also just use the `mk_stubbed_protocol` fixture directly, but creating a fixture will reduce repetition in our code). ### 2. Mock Device Communication ```python [tests/test_awesome_instrument_protocol.py] import pytest from unitelabs.awesome_instrument.io.awesome_instrument_protocol import AwesomeInstrumentProtocol from unitelabs.awesome_instrument.io.errors import NotOkException async def test_should_get_status(mk_my_protocol): protocol: AwesomeInstrumentProtocol = mk_my_protocol({b"status": b"ok"}) await protocol.open() response = await protocol.get_status() print(response) assert response == b"ok" async def test_should_raise_if_not_ok(mk_my_protocol): protocol: AwesomeInstrumentProtocol = mk_my_protocol({b"status": b"not ok"}) await protocol.open() with pytest.raises(NotOkException, match=r"Everything is not ok\."): print(await protocol.get_status()) ``` Here we use our `mk_my_protocol` fixture to test all possible return values from the device and ensure that the our protocol method is behaving as expected given the known responses from the device. # References The generated API references live in the reference section: [Python CDK reference](https://docs.unitelabs.io/reference/python-cdk/). # Connector Development (CDK) ::callout{icon="i-heroicons-light-bulb" target="_blank"} Everything you need to know about connectors, deployment, and the Connector Development Kit (CDK). :: All connectors developed by UniteLabs are cloud-native and support server-initiated connection. This way they can establish a secure, encrypted connection to the platform automatically without complicated network configurations or firewall issues. UniteLabs supports the industry-leading device interface standards SiLA 2 and OPC UA LADS. Devices and software systems from other vendors that support these standards can be connected seamlessly. ::callout --- target: _blank to: https://docs.unitelabs.io/connector-development/getting-started/overview --- Planing to **build** a connector? Read the CDK documentation. :: ::callout --- target: _blank to: https://docs.unitelabs.io/connector-development/guides/deployment --- Need to **deploy** an existing connector? Jump to the deployment guide. :: ::callout{target="_blank" to="https://docs.unitelabs.io/connector-development/"} Everything setup and ready to **automate**? Study the SDK guides. :: # What is a workflow? ::callout{icon="i-heroicons-code-bracket-square"} **Prefer to start from a working example?** Clone the [workflow template](https://docs.unitelabs.io/automate/workflow-template/): reference workflows (sanity check, liquid handling, HITL) you can run in simulation and deploy as-is. :: A **workflow** is the top-level executable process you write to achieve a scientific outcome. It coordinates timing, logic, data flow, and physical state across one or more instruments. ## The automation hierarchy UniteLabs workflows are built from four nested concepts: | Concept | Role | Example | | --------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | ---------------------------------------------------- | | [**Workflow**](https://docs.unitelabs.io/automate/concepts/workflow/) | The top-level process, defined by the scientific result it produces. | An ELISA assay, from sample preparation to detection | | [**Phase**](https://docs.unitelabs.io/automate/concepts/phase/) | A group of steps that ends in a stable state a run can resume from. | Sample preparation, washing, detection | | [**Step**](https://docs.unitelabs.io/automate/concepts/step/) | A single action on one device. It completes or fails as a whole. | Shake a plate, seal a plate, aspirate 50 µl | | **Action** | A device endpoint, generated from the device interface. Called inside steps, holds no logic. | `shaker.shake_controller.set_rpm(300)` | Once started, a workflow creates a [**Run**](https://docs.unitelabs.io/automate/concepts/runs/): a single execution of that workflow. Keep workflows, phases, and steps under version control in Git. To reuse phases and steps across workflows, collect them in a shared library inside your workflow repository. The [workflow template](https://docs.unitelabs.io/automate/workflow-template/) does this in its `shared/` package. ## A minimal example ```python [w01-hello-world/src/w01_hello_world/workflow.py] from unitelabs.sdk import __version__ as sdk_version from unitelabs.sdk import get_logger, workflow @workflow(name="Hello World") async def hello_world(recipient_name: str = "world") -> None: """ Log a greeting and SDK version info to confirm the environment works. Args: recipient_name: Name to greet (default: "world"). """ logger = get_logger() logger.info(f"Hello, {recipient_name}!") logger.info(f"UniteLabs SDK: {sdk_version}") ``` The `@workflow` decorator registers the function with the workflow engine. Phases are called like regular Python functions; the workflow engine handles scheduling, parallelism, and recovery. ## What a workflow is responsible for - **Scientific goal**: defined by the result it produces, not the hardware it uses - **Control flow**: supports loops, conditionals, and branching - **State ownership**: the workflow is the one place that keeps track of sample identity, lineage, and consumed resources across all phases The [Workflow concept page](https://docs.unitelabs.io/automate/concepts/workflow/) covers these properties in detail. For what happens at execution time, see [Runs](https://docs.unitelabs.io/automate/concepts/runs/). For pausing a run to collect manual input, see [Human in the Loop](https://docs.unitelabs.io/automate/concepts/human-in-the-loop/). ## Run locally or on the platform The same workflow file runs on your machine and on the platform, without code changes. During development, run it directly from your IDE or terminal. The `pyproject.toml` defines a script that calls the workflow, so you can run it like this: ```bash uv run workflow ``` When you're ready for scheduled, tracked, or team-accessible runs, deploy the same file to the platform, with no code changes required. See [Deploy a workflow](https://docs.unitelabs.io/automate/guides/deploy-a-workflow/) for the deployment steps. ## Next steps - [Workflow template](https://docs.unitelabs.io/automate/workflow-template/): clone the reference workflows and run one in simulation - [Your first workflow](https://docs.unitelabs.io/automate/your-first-workflow/): write and run a workflow end to end # Workflow template The [workflow template](https://gitlab.com/unitelabs/workflows/workflow-template){rel=""nofollow,noopener""} contains four standalone examples organized as [workflows](https://docs.unitelabs.io/automate/concepts/workflow/), [phases](https://docs.unitelabs.io/automate/concepts/phase/), and [steps](https://docs.unitelabs.io/automate/concepts/step/). W01 runs without platform credentials or hardware. Clone the repository to run the examples or use it as the starting point for a workflow project. ## Four reference workflows The template ships four examples of increasing complexity. Each lives in its own top-level directory as a **standalone Python package** with its own `pyproject.toml`, `uv.lock`, version, and `[tool.unitelabs.workflow]` metadata. Each workflow declares its own dependencies and releases on its own schedule, while shared code lives in the `shared/` package next to them. | # | Workflow | Demonstrates | | ------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | **W01** | `hello_world` | Minimal `@workflow` that logs the SDK version. Requires no hardware or platform connection. | | **W02** | `liquid_handling` | Eight-channel plate-to-plate transfer on a mocked Hamilton Microlab STAR. Demonstrates the workflow, phase, and step layers without hardware. | | **W03** | `plateloc_sealer` | Operator-guided plate sealing. Shows [human in the loop](https://docs.unitelabs.io/automate/concepts/human-in-the-loop/) with `operator_confirm()` inside a [phase](https://docs.unitelabs.io/automate/concepts/phase/). | | **W04** | `tecan_fluent_control` | Minimal demo for a Tecan Fluent: connects to FluentControl, verifies the channel, and optionally runs a named method. Shows a workflow driving vendor software through a connector. | Each workflow is a self-contained Python package at the repo root. The sibling `shared/` library holds the cross-workflow pieces (per-instrument config, custom labware, reusable `@step` steps, helpers) and is consumed by each workflow via `shared = { path = "../shared", editable = true }` in its `[tool.uv.sources]`. ## Project structure The directory layout maps directly onto the taxonomy. Reusable `@step` implementations live in `shared/src/shared/steps/`, grouped by component. Each workflow lives in its own top-level directory named `w-`: a two-digit number followed by a lowercase name with hyphens, for example `w02-liquid-handling`. ```text workflow-template/ # uv workspace root (monorepo) ├── shared/ # Shared library package. Add as many custom packages. │ ├── src/shared/ │ │ ├── config/ # Device configs (plateloc.py, microlab_star.py, …) │ │ ├── library/ # Custom labware definitions │ │ │ └── labware/ │ │ ├── steps/ # Custom step library: reusable @step functions by component │ │ │ ├── plateloc/ │ │ │ │ ├── _helpers.py # Plain async SDK wrappers (not tracked) │ │ │ │ └── _steps.py # @step public API │ │ │ └── liquid_handler/ │ │ │ ├── _helpers.py │ │ │ └── _steps.py │ │ └── utils/ # operator_input.py, etc. │ ├── tests/ │ ├── pyproject.toml │ └── uv.lock │ ├── w01-hello-world/ # Standalone workflow app │ ├── src/w01_hello_world/ │ │ ├── __main__.py # `uv run workflow` entrypoint │ │ └── workflow.py │ ├── tests/ │ ├── pyproject.toml # workflow specific pyproject.toml │ └── uv.lock │ ├── w02-liquid-handling/ # Standalone workflow app │ ├── src/w02_liquid_handling/ │ │ ├── __main__.py │ │ ├── workflow.py │ │ ├── phase_01_setup.py │ │ └── phase_02_transfer.py │ └── ... │ ├── w03-plateloc-sealer/ # Standalone workflow app │ └── ... │ ├── w04-tecan-fluent-control/ # Standalone workflow app │ ├── src/w04_tecan_fluent_control/ │ │ ├── __main__.py │ │ ├── workflow.py │ │ ├── phase_01_connect.py │ │ └── phase_02_run_method.py │ └── ... │ ├── scripts/ │ └── deploy.py # See usage in pyproject.toml [tool.unitelabs.workflow]) │ ├── .github/ # CI ├── .gitlab-ci.yml ├── .env / .env.example # BASE_URL, AUTH_URL, CLIENT_ID, CLIENT_SECRET ├── AGENTS.md # authoring rules: layers, retry policy, naming ├── pytest.ini ├── ruff.toml ├── workflow-template.code-workspace # VS Code multi-root workspace └── README.md ``` The repository's [`AGENTS.md`](https://gitlab.com/unitelabs/workflows/workflow-template/-/blob/main/AGENTS.md){rel=""nofollow,noopener""} is the authoritative authoring guide for retry policy, naming conventions, and what belongs where. ::callout{icon="i-heroicons-information-circle"} **Why is every workflow its own package?** Workflows often need different `unitelabs-*` SDK versions, for example when they target different tenants or instrument generations. Because each workflow has its own lockfile (`uv.lock`, where uv records the exact version of every dependency), you can upgrade one workflow without touching the others. :: ## Clone and deploy End-to-end: clone the repo, run the reference workflows in your IDE, then deploy a workflow to the platform. ### 1. Clone the repository ```bash git clone https://gitlab.com/unitelabs/workflows/workflow-template.git cd workflow-template ``` In your file explorer, navigate into the cloned `workflow-template/` directory and open `workflow-template.code-workspace` with VS Code. It opens directly into the pre-configured multi-root workspace, with each workflow and `shared/` loaded as a side-by-side root. After you sync dependencies in step 3, the per-folder `.venv` is recognized natively. ### 2. Configure credentials Copy `.env.example` to `.env` and fill in the four values. These credentials are required for the UniteLabs Python client object to establish a connection to the platform API for e.g. connector communication: ```bash cp .env.example .env ``` ```bash [.env] BASE_URL=https://api..unitelabs.io/ AUTH_URL=https://auth..unitelabs.io/realms//protocol/openid-connect/ CLIENT_ID= CLIENT_SECRET= ``` ::callout{icon="i-heroicons-information-circle"} Need help getting these values? See [SDK installation](https://docs.unitelabs.io/get-started/setup/sdk-installation/) for how to set the credentials. W02 and W03 will also require access to UniteLabs private packages. If you have not set up a `.netrc` file yet to get access, follow the instructions in the [SDK installation](https://docs.unitelabs.io/get-started/setup/sdk-installation/) section. :: ### 3. Install a workflow's dependencies Each workflow syncs independently. Sync `shared` first, then any workflow you want to run locally: ```bash uv sync --directory shared uv sync --directory w01-hello-world uv sync --directory w02-liquid-handling uv sync --directory w03-plateloc-sealer uv sync --directory w04-tecan-fluent-control ``` Each command creates the workflow's `.venv/` with its declared dependencies and the `shared` library installed editable. Edits to `shared/` are picked up without re-sync. ### 4. Run W01 Hello World If you can run this workflow, you have successfully installed the UniteLabs SDK and executed a workflow using the workflow hierarchy. You are one step closer to deploying to the platform! From the project root, run the following command: ```bash uv run --directory w01-hello-world workflow ``` You can also navigate into the workflow project and run it directly from there using ```bash uv run workflow ``` ### 5. Run W02 Liquid Handling locally against the mock W02 runs end-to-end against the liquid handling mock, with no hardware and no credentials. The mock lives in the LH SDK, is stateless, and only validates that the workflow will run (a compiler-style check that inputs are theoretically valid). It does not simulate a device, so you will not see realistic timing or device state, but it is the fastest way to verify your environment and catch configuration errors. ```bash uv run --directory w02-liquid-handling workflow ``` The `workflow` console script is declared in each workflow's `[project.scripts]`. You should see workflow engine logs for the setup phase followed by eight column-by-column transfers in the transfer phase. ::callout{icon="i-heroicons-light-bulb"} Pass extra args via the same command, e.g. `uv run --directory w02-liquid-handling workflow --hardware` to run against a real Hamilton Microlab STAR. That path requires `.env` credentials and a reachable device. :: ### 6. Deploy a workflow to the platform Requirements before the first deploy: - A populated `.env` at the repo root (`BASE_URL`, `AUTH_URL`, `CLIENT_ID`, `CLIENT_SECRET`). This is the same one you set up in step 2. `scripts/deploy.py` loads it automatically and uses it to authenticate against the platform. The deploy script does three things. It finds every workflow directory (any `w-/` folder containing a `pyproject.toml`), packs the workflow directory together with a copy of the `shared/` library into a zip bundle, and uploads that bundle to the platform. If the workflow already exists on the platform, it is updated in place; otherwise a new entry is created. ```bash # Deploy every workflow uv run scripts/deploy.py --all # Deploy a single workflow by slug uv run scripts/deploy.py w02-liquid-handling # Deploy as DEV (display name prefixed [DEV] on the platform) uv run scripts/deploy.py w02-liquid-handling --channel dev ``` See [Deploy a workflow](https://docs.unitelabs.io/automate/guides/deploy-a-workflow/) for the full CLI reference. Open the UniteLabs Workflows page to confirm the workflow appears with the correct name and description. ### 7. Run W03 PlateLoc Sealer against a simulated connector W03 drives a real connector instead of an in-SDK mock. From the workflow's perspective the simulation behaves 1:1 like the physical device, including state and transitions, because the simulation lives inside the **connector** itself, not inside the SDK. Requirements before the first run: - A running connector simulating a PlateLoc connected to the platform. Verify it appears on the Devices page. See how to [start a connector](https://docs.unitelabs.io/integrate/what-is-a-connector/). - `.env` to authenticate against the platform and reach the connector. Start the workflow: ```bash uv run --directory w03-plateloc-sealer workflow ``` The workflow runs two phases: 1. **Prepare** connects to the PlateLoc, moves the stage to the OUT position, and calls `operator_confirm()` so the operator can place a plate. 2. **Seal** resumes after operator confirmation and moves the stage to the IN position to seal the plate. While the workflow is paused, open the run in the UniteLabs Workflows page. An **Input required** banner appears at the top of the run with the message *"This workflow is waiting for user input to continue"*. Click **Provide Input**, tick the **Confirmed** checkbox in the dialog, and click **Submit Input** to resume the run. This is the canonical [human in the loop](https://docs.unitelabs.io/automate/concepts/human-in-the-loop/) pattern: a phase parks the run on an explicit operator checkpoint rather than polling or sleeping. ::callout{icon="i-heroicons-information-circle"} The device name is resolved from `shared/src/shared/config/plateloc.py` (`INSTRUMENT_NAME`). If your connector is registered under a different name on the platform, override it: `uv run --directory w03-plateloc-sealer workflow --device-name ""`. :: ## Releasing a workflow Each workflow is released on its own, with a git tag that combines two things: the workflow's **slug** and its version. The slug is the workflow's unique short name. It is set as `[project].name` in the workflow's `pyproject.toml` and matches the directory name, for example `w02-liquid-handling`. The tag `w02-liquid-handling/v1.2.0` therefore releases version 1.2.0 of that workflow. The version in the tag must match `[project].version`. To release `w02-liquid-handling v1.2.0`: ```bash # 1. Bump the workflow's version $EDITOR w02-liquid-handling/pyproject.toml # (set [project].version = "1.2.0") # 2. Commit git commit -am "release: w02-liquid-handling v1.2.0" # 3. Tag and push git tag w02-liquid-handling/v1.2.0 git push origin main --tags ``` CI matches the per-workflow tag pattern and runs `scripts/deploy.py --git-tag w02-liquid-handling/v1.2.0 --channel prd`. The script parses the tag, verifies the pyproject version matches `1.2.0` (refuses otherwise; the version in `pyproject.toml` is the one that counts), ships the bundle, and adds `v1.2.0` to the platform tags. See [CI/CD for workflows](https://docs.unitelabs.io/automate/guides/cicd-for-workflows/) for the three-channel deploy model. ## Adding a new workflow Mechanical checklist (see [`AGENTS.md`](https://gitlab.com/unitelabs/workflows/workflow-template/-/blob/main/AGENTS.md){rel=""nofollow,noopener""} for the full authoring rules): 1. Create a directory `w-/` at the repo root, for example `w05-dna-extraction/`. Pick the next free two-digit number and a lowercase name with hyphens. 2. Inside it, create: - `pyproject.toml` mirroring an existing workflow (set `[project].name`, version, description, dependencies, `[tool.unitelabs.workflow]`). - `src/w05_dna_extraction/workflow.py` with the `@workflow` function. The package directory uses the same name with underscores instead of hyphens. - One `phase__.py` file per phase, next to `workflow.py`. 3. Add new shared code to `shared/src/shared/{config,library,steps,utils}/` if needed. 4. Run `uv sync --directory w05-dna-extraction` to generate the workflow's lockfile. 5. Add the workflow's path to the `folders` array in `workflow-template.code-workspace`. No central registry to edit. `scripts/deploy.py` discovers workflows automatically. ## Next steps - [**Author your own workflow**](https://docs.unitelabs.io/automate/your-first-workflow/): copy W01 and build your first own workflow. The repo's [`AGENTS.md`](https://gitlab.com/unitelabs/workflows/workflow-template/-/blob/main/AGENTS.md){rel=""nofollow,noopener""} holds the detailed authoring rules (layers, retry policy, naming). - [**Deploy a workflow**](https://docs.unitelabs.io/automate/guides/deploy-a-workflow/): the full `scripts/deploy.py` CLI reference. - [**Set up CI/CD**](https://docs.unitelabs.io/automate/guides/cicd-for-workflows/): wire deploys to GitHub Actions or GitLab CI with the three-channel (dev/stg/prd) model. - [**Trigger runs via the API**](https://docs.unitelabs.io/automate/guides/workflows-api/): start a deployed workflow programmatically. - [**Add human-in-the-loop steps**](https://docs.unitelabs.io/automate/guides/basic-hitl/): use W03 as the reference pattern. # Your First Workflow In this guide you'll clone the [workflow template](https://docs.unitelabs.io/automate/workflow-template/), run its hello-world example locally to confirm your environment works, and copy it into a new workflow of your own. **Prerequisites** - Python 3.12 or newer - [`uv`](https://docs.astral.sh/uv/){rel=""nofollow,noopener""} installed (latest) - A VS Code based IDE - A UniteLabs account with API credentials (only needed when you deploy — not for local runs) - Registry credentials in `~/.netrc`, so uv can install the `unitelabs-*` packages (see [SDK installation](https://docs.unitelabs.io/get-started/setup/sdk-installation/)) ## Clone the template ```bash git clone https://gitlab.com/unitelabs/workflows/workflow-template.git cd workflow-template ``` The template ships four reference workflows side-by-side: ```text workflow-template/ ├── shared/ # shared library (config, custom labware, reusable @step steps) ├── w01-hello-world/ # the example we'll use here ├── w02-liquid-handling/ # real liquid handling demo (runs in simulation) ├── w03-plateloc-sealer/ # human-in-the-loop demo ├── w04-tecan-fluent-control/ # Tecan FluentControl demo └── scripts/deploy.py # the deploy CLI ``` Each workflow directory is a **standalone Python package** with its own `pyproject.toml`, its own lockfile (`uv.lock`, where uv records the exact version of every dependency), and its own `.venv`. The `shared/` library is consumed via a path dependency (a dependency that points at the local folder instead of a published package), so edits there are picked up by every workflow without a re-sync. ## Sync the dependencies Sync `shared` first, then the workflow you want to run: ```bash uv sync --directory shared uv sync --directory w01-hello-world ``` This creates `w01-hello-world/.venv/` with the UniteLabs workflow engine, the UniteLabs SDK pins from the workflow's `pyproject.toml`, and `shared` installed editable. ## Tour `w01-hello-world` Every workflow has the same three pieces. ### 1. The package metadata — `pyproject.toml` The interesting bit is `[tool.unitelabs.workflow]` — that's the contract with the deploy script: ```toml [w01-hello-world/pyproject.toml] [project] name = "w01-hello-world" # the workflow's slug version = "1.0.1" # bump this when you release description = "Simplest possible workflow. Logs SDK version info as a sanity check." dependencies = [ "shared", "unitelabs-sdk[automate]~=0.12.0", "unitelabs-labware", "unitelabs-liquid-handling", ] [project.scripts] workflow = "w01_hello_world.__main__:main" [tool.unitelabs.workflow] display_name = "[deploy-test] Hello World" # what shows on the platform entrypoint = "workflow.py:hello_world" # : inside the package tags = ["deploy-test"] [tool.uv.sources] shared = { path = "../shared", editable = true } ``` The deploy script reads `[project].name`, `[project].version`, and `[tool.unitelabs.workflow]` directly from this file. There's no central manifest to keep in sync. ### 2. The flow — `src/w01_hello_world/workflow.py` ```python [w01-hello-world/src/w01_hello_world/workflow.py] from unitelabs.sdk import __version__ as sdk_version from unitelabs.sdk import get_logger, workflow @workflow(name="Hello World") async def hello_world(recipient_name: str = "world") -> None: """Log a greeting and SDK version info to confirm the environment works.""" logger = get_logger() logger.info(f"Hello, {recipient_name}!") logger.info(f"UniteLabs SDK: {sdk_version}") ``` A workflow is an `async` function decorated with `@workflow`. Larger workflows split into **phases** (also `@phase`-decorated) and **steps** (`@step`-decorated, often coming from `shared/steps/`). See the [workflow, phase and step taxonomy](https://docs.unitelabs.io/automate/concepts/workflow/). ### 3. The entrypoint — `src/w01_hello_world/__main__.py` This is what `uv run --directory w01-hello-world workflow` calls — a thin wrapper that runs `hello_world` via `asyncio.run`. ## Run it locally ```bash uv run --directory w01-hello-world workflow ``` You should see the log output with the greeting and the SDK version. No credentials, no hardware. ::callout{icon="i-heroicons-light-bulb"} The `workflow` console script is declared in each workflow's `pyproject.toml` under `[project.scripts]` — every workflow exposes the same entry, so the run command is identical regardless of which workflow you point at. :: ## Create your own first workflow The mechanical recipe: copy `w01-hello-world` to a new directory named `w-` (the next free two-digit number plus a lowercase name with hyphens, here `w05-sample-quality`). Then rename every occurrence of the old name: 1. The package directory: `src/w01_hello_world/` becomes `src/w05_sample_quality/`. 2. In `pyproject.toml`: `[project].name`, the `[project.scripts]` module path, and the `[tool.unitelabs.workflow]` entrypoint (all shown below). 3. In `src/w05_sample_quality/__main__.py`: the import and the function it runs. 4. Add the new folder to the `folders` array in `workflow-template.code-workspace`. Then update the new workflow's `pyproject.toml`: ```toml [w05-sample-quality/pyproject.toml] [project] name = "w05-sample-quality" version = "0.1.0" description = "Fetch samples and evaluate them against a concentration threshold." [project.scripts] workflow = "w05_sample_quality.__main__:main" # match the new package name [tool.unitelabs.workflow] display_name = "Sample Quality" entrypoint = "workflow.py:sample_quality" tags = ["qc"] ``` And rewrite `workflow.py` with the flow of your own — for example, a quality check that fetches samples and applies a concentration threshold: ```python [w05-sample-quality/src/w05_sample_quality/workflow.py] from unitelabs.sdk import get_logger, phase, step, workflow @step(name="Fetch Samples") async def fetch_samples(source: str) -> list[dict]: logger = get_logger() logger.info(f"Fetching samples from {source}") # Replace with your actual data retrieval. return [ {"id": "sample_001", "concentration": 450.0}, {"id": "sample_002", "concentration": 210.0}, {"id": "sample_003", "concentration": 890.0}, ] @step(name="Evaluate Samples") async def evaluate_samples(samples: list[dict], min_concentration: float) -> dict: passed = [s for s in samples if s["concentration"] >= min_concentration] failed = [s for s in samples if s["concentration"] < min_concentration] return { "passed": passed, "failed": failed, "pass_rate": len(passed) / len(samples) if samples else 0.0, } @phase(name="Analyze Samples") async def analyze_samples(source: str, min_concentration: float) -> dict: samples = await fetch_samples(source=source) return await evaluate_samples(samples=samples, min_concentration=min_concentration) @workflow(name="Sample Quality") async def sample_quality( source: str = "data_warehouse", min_concentration: float = 300.0, ) -> dict: """Fetch samples and evaluate them against a concentration threshold.""" return await analyze_samples(source=source, min_concentration=min_concentration) ``` Sync and run: ```bash uv sync --directory w05-sample-quality uv run --directory w05-sample-quality workflow ``` ::callout{icon="i-heroicons-light-bulb"} Keep each `@step` focused on one responsibility — fetch, transform, persist, notify. Orchestrate them in the `@phase`, not inside the steps themselves. The UniteLabs workflow engine runs independent steps concurrently. :: ## Read workflow parameters from the context object Every argument `sample_quality_flow` is called with — `source` and `min_concentration` — is automatically published to the run's context by `@workflow`. Instead of passing `min_concentration` down through `evaluate_samples_task`'s own signature, the step can read it directly: ```python [w05-sample-quality/src/w05_sample_quality/workflow.py] from unitelabs.sdk import get_context, get_logger, step, workflow @step(name="Evaluate Samples") async def evaluate_samples_task(samples: list[dict]) -> dict: min_concentration = get_context().workflow_parameters["min_concentration"] passed = [s for s in samples if s["concentration"] >= min_concentration] failed = [s for s in samples if s["concentration"] < min_concentration] return { "passed": passed, "failed": failed, "pass_rate": len(passed) / len(samples) if samples else 0.0, } @workflow(name="Sample Quality") async def sample_quality_flow( source: str = "data_warehouse", min_concentration: float = 300.0, ) -> dict: """Fetch samples and evaluate them against a concentration threshold.""" samples = await fetch_samples_task(source=source) return await evaluate_samples_task(samples=samples) ``` This matters once a parameter needs to reach a step nested a few calls deep: without context, adding it means editing every intermediate phase's and step's signature along the way; with context, only the workflow's own signature changes. See [Run context](https://docs.unitelabs.io/automate/concepts/workflow/#run-context) and [Input](https://docs.unitelabs.io/automate/concepts/input/) for the full picture. ## Next steps - [**Deploy your workflow**](https://docs.unitelabs.io/automate/guides/deploy-a-workflow/) — bundle and ship with `scripts/deploy.py`. - [**Set up CI/CD**](https://docs.unitelabs.io/automate/guides/cicd-for-workflows/) — automate dev/stg/prd deploys on GitHub Actions or GitLab CI. - [**Add error handling**](https://docs.unitelabs.io/automate/guides/basic-error-handling/) — make failures surface cleanly. - [**Add human-in-the-loop steps**](https://docs.unitelabs.io/automate/guides/basic-hitl/) — use W03 as the reference pattern. # Workflow A **workflow** is the top-level process, defined by the scientific result it produces. It coordinates phases, manages the data flow between them, and owns the state of all samples and resources for the duration of the run. You define a workflow as a Python function decorated with `@workflow`. Inside it, you call phases like regular functions. The workflow engine handles scheduling, parallelism, error recovery, and checkpoints. ## Example ```python [workflows/elisa.py] from unitelabs.sdk.automate import workflow from .phases import sample_preparation, agitation, washing_cycle, detection @workflow(name="ELISA") async def main_workflow(): plate = await sample_preparation() await agitation(plate) for _ in range(3): await washing_cycle() await detection() ``` The workflow drives the scientific narrative. Phases represent the meaningful stages — `sample_preparation`, `agitation`, `detection` — while the workflow defines the order and control flow between them. ## Key properties - **Scientific goal**: a workflow is defined by *what result it produces* (e.g., "ELISA"), not the hardware it uses - **Control flow**: supports loops, conditionals, and branching; not limited to a linear sequence - **State ownership**: the workflow is the one place that keeps track of sample identity, lineage, and all consumed resources across phases - **Versioned**: workflows are deployed and versioned; each run is linked to a specific version ## Non-linear control flow Because a workflow is just a Python function, you can use standard control flow: ```python [workflows/repeat_until_clean.py] @workflow(name="Repeat Until Clean") async def repeat_workflow(): result = await initial_wash() while not result.is_clean: result = await additional_wash() await final_rinse() ``` ## Parallel phases Phases with no dependency between them can run in parallel. The workflow engine detects independence automatically — you only need to express the data dependency: ```python [workflows/parallel_example.py] @workflow(name="Parallel Preparation") async def parallel_workflow(): # These three phases have no shared inputs — the workflow engine runs them concurrently reagent_a = await prepare_reagent_a() reagent_b = await prepare_reagent_b() await wash_plate() # This phase depends on both reagents, so it waits for both to complete await combine(reagent_a, reagent_b) ``` ## Run context Every run carries a `context` object: the single place workflow-level state lives for the duration of the run. Rather than passing a run mode, a simulation flag, or a feature flag as an argument through every intermediate phase and step, you declare it once as a workflow input and read it from context wherever it's needed — no matter how deeply nested the phase or step is. ```python [workflows/elisa.py] from unitelabs.sdk import get_context, phase, workflow @workflow(name="ELISA") async def main_workflow(simulate: bool = False): await sample_preparation() await detection() @phase() async def detection(): simulate = get_context().workflow_parameters.get("simulate", False) ... ``` `detection` never declares `simulate` in its own signature, and neither does any step it calls in turn — it reads the value straight from context. Adding a new workflow-level parameter later needs no signature changes anywhere downstream. - `get_context()` returns the `RuntimeContext` active for the currently running workflow, phase, or step. - `context.workflow_parameters` is a read-only mapping, populated automatically from the arguments the top-level `@workflow` function is called with. Nothing needs to be called to fill it in. This is only available with SDK >= 0.15.0 or liquid handling SDK >= 0.34.0. - Only workflow-level arguments are published this way. A phase's or step's own arguments remain ordinary function arguments — they are not added to `workflow_parameters`. See [Input](https://docs.unitelabs.io/automate/concepts/input/) for the full workflow-vs-phase breakdown. - The context also carries the run across phase boundaries — it's what a resumed run reloads to pick up where it left off (see [Runs](https://docs.unitelabs.io/automate/concepts/runs/)). ## Related concepts - [Phase](https://docs.unitelabs.io/automate/concepts/phase/): the logical stages a workflow is composed of - [Runs](https://docs.unitelabs.io/automate/concepts/runs/): what happens when a workflow executes - [Input](https://docs.unitelabs.io/automate/concepts/input/): parameterize a workflow at run time # Phase A **phase** is a group of steps that ends in a stable state a run can resume from. That end state is the checkpoint the workflow engine restarts at when a run is interrupted. You define a phase as a Python function decorated with `@phase`. Inside it, you call steps to issue device commands. The workflow engine sequences steps, manages device locking, and surfaces errors to the phase level for user-defined recovery. ## Example ```python [workflows/elisa.py] from unitelabs.sdk.automate import phase @phase() async def agitation(target: Plate): await mix(target) await shake(target, rpm=300, duration=60) ``` ## Key properties - **Checkpoint-driven**: always ends in a state that is stable and externally comprehensible; if a run is interrupted, recovery starts at the last completed phase - **Recoverable**: errors within a phase are surfaced to the user for manual or automated recovery; see [Error Handling](https://docs.unitelabs.io/automate/concepts/error-handling/) for how to define recovery behavior - **Resource-scoped**: implicitly defines which devices are locked while it runs; the workflow engine acquires locks before the phase starts and releases them when it completes - **Composable**: phases are regular Python functions that can be reused across different workflows ## Parallel phases The workflow engine runs independent phases in parallel. If two phases share no data dependency, they execute concurrently — the workflow author does not need to do anything special: ```python [workflows/parallel.py] @workflow(name="Parallel Prep") async def main(): # No dependency between these — runs in parallel reagent_a = await prepare_reagent_a() reagent_b = await prepare_reagent_b() await combine(reagent_a, reagent_b) # waits for both ``` ## Error handling in phases Steps within a phase are retried automatically on technical failure. If a step exhausts its retries, the error propagates to the phase for user-defined recovery — see [Error Handling](https://docs.unitelabs.io/automate/concepts/error-handling/). ## Related concepts - [Step](https://docs.unitelabs.io/automate/concepts/step/): one action on exactly one device, called inside a phase - [Error Handling](https://docs.unitelabs.io/automate/concepts/error-handling/): how errors propagate through the hierarchy # Step A **step** is a single action on one device. It completes or fails as a whole, so a step never leaves a partial result at the scientific level. For example, a shaking step can lock a shaker, set its RPM, run it for a duration, and unlock it. The step succeeds only when the complete operation finishes. ## Example ```python [workflows/steps.py] from unitelabs.sdk.automate import step, phase @step() async def shake(shaker: ShakerDevice, rpm: int, duration: int): await shaker.elm_controller.lock(retry=3) await shaker.shake_controller.set_rpm(rpm) await shaker.shake_controller.shake(duration) await shaker.elm_controller.unlock() ``` Steps are called from inside a [phase](https://docs.unitelabs.io/automate/concepts/phase/): ```python [workflows/agitation.py] @phase() async def agitation(target: Plate, shaker: ShakerDevice): await mix(target) await shake(shaker=shaker, rpm=300, duration=60) await shake(shaker=shaker, rpm=150, duration=30) ``` ## Key properties - **No partial results**: raise and handle errors inside the step so it returns a clear success or failure. - **One device per step**: a step drives a single device. Coordinating two or more devices belongs in a phase. - **Automatically retryable**: the workflow engine retries technical errors (device timeouts, transient communication failures) automatically before propagating the error upward - **Sequenced within a phase**: steps within a phase always run sequentially, in the order they are called ## Retries The workflow engine retries steps on technical failures automatically. You can configure retry behavior per step: ```python [workflows/steps.py] @step(retries=5, retry_delay_seconds=2) async def aspirate(liquid_handler: LiquidHandler, volume: float): await liquid_handler.pipettes.aspirate(volume) ``` A **technical error** is a transient device-level failure: a timeout, a lost connection, a device that did not acknowledge a command. These are retried automatically. A **scientific error**: a sample gone missing, a volume that cannot be aspirated — is propagated to the phase for user-defined recovery. Steps do not handle scientific errors; phases do (see [Error Handling](https://docs.unitelabs.io/automate/concepts/error-handling/)). ::callout{icon="i-heroicons-exclamation-triangle"} Steps are always sequential within a phase. If you need parallelism, split your work into multiple phases — the workflow engine will run independent phases concurrently and evaluate constraints and transitions. :: ## Device typing Steps declare the device type they require as a function parameter. The workflow engine resolves the actual hardware instance at run time based on availability and capability — the step code never hardcodes a specific instrument: ```python [workflows/steps.py] @step() async def centrifuge_samples(centrifuge: CentrifugeDevice, rpm: int, duration: int): await centrifuge.centrifugation_controller.spin(rpm=rpm, duration=duration) ``` This means the same step can run on any compatible centrifuge in the lab. ## Related concepts - [Phase](https://docs.unitelabs.io/automate/concepts/phase/): the group of steps that ends in a stable state a run can resume from - [Error Handling](https://docs.unitelabs.io/automate/concepts/error-handling/): retry behavior and error propagation - [Workflow](https://docs.unitelabs.io/automate/concepts/workflow/): the top-level process, defined by the scientific result it produces # Runs A **run** is a single execution of a workflow. Every time a workflow is triggered — manually, via the API, the UI, or by an event — the platform creates a new run and tracks its progress from start to finish. Runs are the operational unit of a workflow: they carry the run-time inputs, the outputs of each phase, the execution logs, and the final status. ## Run states A run moves through a defined set of states during its lifetime: | State | Meaning | | ---------------- | ------------------------------------------------------------------------------------------------------------------------- | | `SCHEDULED` | The run will begin at a particular time in the future. | | `PENDING` | Created, waiting for resources or a scheduled trigger | | `RUNNING` | Actively executing | | `PAUSED` | Paused by user or system. Can be resumed. | | `AWAITING_INPUT` | Waiting for human/machine input (see [Human in the Loop](https://docs.unitelabs.io/automate/concepts/human-in-the-loop/)) | | `COMPLETED` | All phases finished successfully | | `FAILED` | A phase or step raised an unrecoverable error | | `CANCELLED` | Manually cancelled before completion | | `CRASHED` | The run crashed due to an infrastructure error | ## What a run tracks - **Inputs**: the parameter values provided when the run was started - **Phase results**: the output or status of each phase, in order - **Logs**: a structured execution log from every step (see [Logs](https://docs.unitelabs.io/automate/concepts/logs/)) - **Artifacts**: any data produced by phases (see [Artifacts](https://docs.unitelabs.io/automate/concepts/artifacts/)) - **Workflow version**: the exact version of the workflow code that was executed ## Starting a run You can start a run from the platform UI or the REST API. The workflow ID is visible in the platform UI on the workflow's detail page. ```bash [Terminal] curl -X POST https://api.unitelabs.io/v1/workflows/{workflow_id}/runs \ -H "Authorization: Bearer $API_TOKEN" \ -H "Content-Type: application/json" \ -d '{"inputs": {"sample_id": "S001"}}' ``` ## Phase-level execution Operators can run a workflow starting from a specific phase, or run a range of phases. This is useful for: - **Resuming** after a manual intervention - **Debugging** a specific stage in isolation - **Replaying** a phase with different inputs ## Campaigns A **campaign** is a collection of runs of the same workflow type — for example, running the same ELISA protocol across 50 samples. The platform groups these runs so you can track aggregate results, identify outliers, and compare outcomes across runs. ## Related concepts - [Workflow](https://docs.unitelabs.io/automate/concepts/workflow/): the process being executed - [Logs](https://docs.unitelabs.io/automate/concepts/logs/): execution output for each run - [Artifacts](https://docs.unitelabs.io/automate/concepts/artifacts/): data produced by a run - [Human in the Loop](https://docs.unitelabs.io/automate/concepts/human-in-the-loop/): pausing a run for manual input # Input **Inputs** are typed parameters you declare on a workflow or phase. They are provided at run time — when a run is started, or when a phase is triggered manually. This lets a single workflow definition handle different samples, concentrations, or experimental conditions without changing the code. This is particularly useful for presenting error recovery options to the human or machine user on the phase level for decision-making. ## Workflow inputs Declare inputs as typed function parameters on the `@workflow` function: ```python [workflows/elisa.py] from unitelabs.sdk.automate import workflow, phase @workflow(name="ELISA") async def main_workflow(sample_id: str, concentration: float = 1.0): plate = await sample_preparation(sample_id) await detection(plate, min_concentration=concentration) ``` When the run is started via the platform UI, the operator provides the `sample_id`. `concentration` is optional and defaults to `1.0`. If triggered through the API, these values are sent as JSON in the request body. ## Phase inputs Phases also accept typed parameters. They receive values passed by the workflow or from the output of a previous phase: ```python [workflows/phases.py] @phase() async def detection(target: Plate, min_concentration: float): result = await measure(target) assert result.concentration >= min_concentration ``` ## Reading inputs via the context object Only **workflow** inputs are published automatically to the run's context. The moment `main_workflow` above is called, `sample_id` and `concentration` become readable anywhere in the run via `get_context().workflow_parameters` — without adding them to any phase's or step's own signature: ```python [workflows/steps.py] from unitelabs.sdk import get_context, step @step() async def measure(target: Plate): concentration = get_context().workflow_parameters.get("concentration", 1.0) ... ``` This is the recommended way to thread run-wide settings — run mode, simulation flags, feature flags — through many phases and steps: declare them once as workflow inputs, read them from context wherever they're needed. Adding a new workflow input later never requires touching an existing phase's or step's signature, since nothing downstream needs to name it as an argument to see it. Phase inputs, like `target` and `min_concentration` above, work differently: they stay ordinary function arguments, passed explicitly by whatever calls the phase, and are not added to `workflow_parameters`. A phase can still read the parameters its enclosing workflow declared, via the same `get_context().workflow_parameters`. ::callout{icon="i-heroicons-light-bulb"} `workflow_parameters` is a read-only view (`types.MappingProxyType`), populated only from the top-level `@workflow` call — treat values read from it as immutable. It is only available with SDK >= 0.15.0 or liquid handling SDK >= 0.34.0. :: ## Input types Inputs support standard Python types and are validated before the run starts: | Type | Example | | ------------------ | ---------------------------------- | | `str` | Sample identifiers, protocol names | | `int`, `float` | Volumes, concentrations, durations | | `bool` | Feature flags, approval gates | | `list` | Lists of values | | `dict` | Key-value mappings | | Complex data types | Nested structures | | Pydantic models | Structured sample metadata | ## Providing inputs via the API ```bash [Terminal] curl -X POST https://api.unitelabs.io/v1/workflows/{workflow_id}/runs \ -H "Authorization: Bearer $API_TOKEN" \ -H "Content-Type: application/json" \ -d '{"inputs": {"sample_id": "S-042", "concentration": 2.5}}' ``` ## Related concepts - [Workflow](https://docs.unitelabs.io/automate/concepts/workflow/): where inputs are declared - [Human in the Loop](https://docs.unitelabs.io/automate/concepts/human-in-the-loop/): pausing for operator-provided inputs at phase execution time - [Runs](https://docs.unitelabs.io/automate/concepts/runs/): inputs are bound to a specific run and stored with the run record # Artifacts **Artifacts** are structured outputs attached to a run — reports, measurement summaries, annotated images, and links to external files. They appear in the platform UI under the run's Artifacts tab and are persisted for traceability and downstream use. Artifacts are distinct from [logs](https://docs.unitelabs.io/automate/concepts/logs/): logs capture the execution narrative, artifacts capture the scientific results. ## Producing artifacts Use the artifact functions from any phase or step. The most common type is a markdown artifact, which can embed tables, images, and rich text directly in the run view. **Measurement report** — summarize results as a markdown table: ```python [workflows/plate_read.py] from unitelabs.sdk import get_logger from unitelabs.sdk.automate import phase from prefect.artifacts import create_markdown_artifact @phase() async def report(plate_id: str, hits: list[str], hit_rate: float): logger = get_logger() rows = "\n".join(f"| {well} |" for well in hits) or "| — |" await create_markdown_artifact( key="plate-read-report", markdown=f"""## Plate Read Report **Plate:** `{plate_id}` **Hit rate:** {hit_rate:.1f}% | Hit well | |----------| {rows} """, description=f"{len(hits)} hits on {plate_id}", ) logger.info(f"Report artifact published — {len(hits)} hits") ``` **Annotated image** — embed a base64-encoded image inline: ```python [workflows/detection.py] import base64 from unitelabs.sdk.automate import phase from prefect.artifacts import create_markdown_artifact @phase() async def detection(camera_service_name: str) -> dict: image = await capture_snapshot(camera_service_name) result = detect_plate_in_roi(image) _, buf = cv2.imencode(".png", image) img_b64 = base64.b64encode(buf).decode() await create_markdown_artifact( key="detection-result", markdown=f"""## Plate Detection **Result:** {"✅ PLATE PRESENT" if result["plate_present"] else "❌ EMPTY"} ![Detection](data:image/png;base64,{img_b64}) """, description="Camera-based plate detection", ) return result ``` **External file link** — reference a file in object storage or a local path: ```python [workflows/imaging.py] from unitelabs.sdk.automate import phase from prefect.artifacts import create_link_artifact @phase() async def imaging(target: Plate): image_path = await microscope.capture(target) await create_link_artifact( key="plate-image", link=str(image_path), description="Captured plate image", ) ``` ## Accessing artifacts **Platform UI**: open a run and navigate to the Artifacts tab. Each artifact shows its name, type, and the phase that produced it. **API**: ```bash [Terminal] curl https://api.unitelabs.io/v1/runs/{run_id}/artifacts \ -H "Authorization: Bearer $API_TOKEN" ``` Download a specific artifact: ```bash [Terminal] curl https://api.unitelabs.io/v1/runs/{run_id}/artifacts/plate_image \ -H "Authorization: Bearer $API_TOKEN" \ -o plate_image.tiff ``` ## Artifact traceability Every artifact is linked to: - The **run** that produced it - The **workflow version** that was executing - The **phase** and **step** that created it - The **inputs** the run was started with This chain of custody makes it possible to reproduce any result or trace a measurement back to the exact protocol and reagent batch that generated it. ## Related concepts - [Runs](https://docs.unitelabs.io/automate/concepts/runs/): artifacts are scoped to a specific run - [Logs](https://docs.unitelabs.io/automate/concepts/logs/): execution narrative, separate from result data - [Phase](https://docs.unitelabs.io/automate/concepts/phase/): the level at which artifacts are typically produced and returned # Logs Every workflow execution produces structured logs. The platform captures output at each level of the hierarchy — workflow, phase, and step — and makes it available in the run detail view, via the API, and through the SDK. ## Log levels | Level | Emitted by | | ---------- | ---------------------------------------------------------------- | | `WORKFLOW` | Workflow start, end, and top-level workflow engine events | | `PHASE` | Phase start, end, constraint evaluations, device lock/unlock | | `STEP` | Step start, end, retry attempts, device commands, logical errors | | `ACTION` | API requests, responses, and errors | ## Writing logs from workflow code Use the SDK's `get_logger()` anywhere in a workflow, phase, or step: ```python [workflows/sample_prep.py] from unitelabs.sdk import get_logger, phase @phase() async def sample_preparation(plate: Plate, water_source: Plate) -> Plate: logger = get_logger() logger.info("Starting sample preparation") await transfer_liquid(source=water_source, target=plate, volume=200) logger.info("Transfer complete", extra={"volume_ul": 200, "target": plate.identifier}) return plate ``` Log entries are linked to the phase and step that emitted them, so you can filter the run log to a specific phase without custom tooling. ## Accessing logs **Platform UI**: open a run and navigate to the Logs tab. Filter by phase, step, or severity level. **API**: ```bash [Terminal] curl https://api.unitelabs.io/v1/runs/{run_id}/logs \ -H "Authorization: Bearer $API_TOKEN" ``` Filter to a specific phase: ```bash [Terminal] curl "https://api.unitelabs.io/v1/runs/{run_id}/logs?phase=sample_preparation" \ -H "Authorization: Bearer $API_TOKEN" ``` ## Automatic log entries The workflow engine automatically logs these events without any code changes: - Phase started / completed / failed - Step started / completed / retried / failed - Constraint evaluated (condition, outcome) - Device locked / unlocked - Transition applied (pre / post) - Human input requested / received - API requests and responses ## Related concepts - [Runs](https://docs.unitelabs.io/automate/concepts/runs/): logs are scoped to a specific run - [Artifacts](https://docs.unitelabs.io/automate/concepts/artifacts/): structured data produced by a run, separate from execution logs - [Error Handling](https://docs.unitelabs.io/automate/concepts/error-handling/): errors appear in logs with full stack traces and retry history # Human in the Loop **Human in the loop** (HITL) is a pattern for semi-automated workflows: the workflow engine pauses a run at a defined point, waits for an operator to confirm an action or provide input, then continues. The SDK provides two levels of API: - **`operator_confirm()`** — a one-line helper for the common case: the operator just needs to confirm a physical action was taken. - **`pause_flow_run()`** — the lower-level pause function, used directly when the operator has to enter structured data and not just confirm. ## Simple confirmation The most common case: pause and wait for an operator to confirm a physical action — no structured data needed. Use `operator_confirm()`: ```python [workflows/qc.py] from unitelabs.sdk import operator_confirm from unitelabs.sdk.automate import phase @phase() async def load_plate(): await operator_confirm("Load sample plate into slot 1, then click Resume.") ``` `operator_confirm()` logs the message with an `OPERATOR ACTION REQUIRED:` prefix (easy to spot in the run log), pauses the run until the operator confirms, then logs that the run resumed. Pass `timeout=` (seconds, default `3600`) to fail the run cleanly if no one responds. It requires the `automate` extra (`unitelabs-sdk[automate]`); without it the call raises `RuntimeError`. Internally it pauses with a single-field confirmation model (`OperatorConfirmation`), which is what puts the run into `AWAITING_INPUT` instead of a bare `PAUSED` state. ## Typed input When the operator needs to provide structured data, pass a `RunInput` subclass to `pause_flow_run()`. `RunInput` is a base class the SDK exports (`from unitelabs.sdk import RunInput`). Subclass it and declare one field per value you want back. Since it is a [`pydantic`](https://docs.pydantic.dev/latest/){rel=""nofollow,noopener""} model, the SDK sends its JSON Schema along with the pause: the platform picks a form control per field from that schema, and on submit pydantic validates the values and hands you back an instance of your class. ```python [workflows/qc.py] from unitelabs.sdk import pause_flow_run, RunInput from unitelabs.sdk.automate import phase class QCInput(RunInput): sample_id: str approved: bool notes: str = "" @phase() async def quality_check(): data = await pause_flow_run(wait_for_input=QCInput) if not data.approved: raise ValueError(f"Sample {data.sample_id} rejected at QC gate. Notes: {data.notes}") await log_approval(data.sample_id) ``` The run transitions to `AWAITING_INPUT`. The operator opens the run in the platform, fills in the form, and submits. Execution continues with the submitted values available as `data`. ### Instructions and pre-filled fields `operator_confirm()` takes a message. `pause_flow_run(wait_for_input=QCInput)` takes none, so on its own it leaves the operator with a form and no task. Pass the instruction through `with_initial_data()`: ```python [workflows/qc.py] data = await pause_flow_run( wait_for_input=QCInput.with_initial_data( description="Inspect the plate under the scope, then submit.", sample_id=current_sample, ) ) ``` `description` accepts Markdown and travels with the input request, next to the schema in the run status. Every other keyword argument pre-fills that field, so the operator only edits what is still open. ::callout{icon="i-heroicons-exclamation-triangle"} `with_initial_data()` rebuilds the fields it pre-fills and drops their `Field(...)` metadata. `sample_id: str = Field(title="Sample ID")` falls back to the generated `Sample Id` once pre-filled. :: The Python type annotation on each field determines which form control the platform renders: | Python type | Form control | | ------------------------ | ---------------------------- | | `str` | Text input | | `bool` | Toggle switch | | `int` | Number input (whole numbers) | | `float` | Decimal number input | | `Literal["a", "b", ...]` | Dropdown select | | `Enum` subclass | Dropdown select | Fields without a default value are required. Fields with a default value are optional and pre-filled. For constrained choices (e.g., selecting from a fixed list of protocols), use `Literal` or a Python `Enum` — both render as a dropdown. See [Typed operator inputs](https://docs.unitelabs.io/automate/guides/typed-operator-inputs/) for detailed examples. ## Run state during a pause | State | Produced by | Operator action | | ---------------- | ------------------------------------------- | ---------------------- | | `AWAITING_INPUT` | `operator_confirm(...)` | Confirm, then resume | | `AWAITING_INPUT` | `pause_flow_run(wait_for_input=MyRunInput)` | Fill form, then submit | | `PAUSED` | `pause_flow_run()` (no input model) | Click Resume | `AWAITING_INPUT` means the platform is holding a form open. `operator_confirm()` uses a single pre-filled confirmation field, so the form is effectively a confirm-and-resume button; a custom `RunInput` model renders one control per field. Bare `pause_flow_run()` carries no schema and the operator simply clicks Resume. ::callout{icon="i-heroicons-light-bulb"} Paused runs time out after a configurable period. `operator_confirm()` defaults to `timeout=3600` (1 hour); pass `timeout=600` to either `operator_confirm()` or `pause_flow_run()` to fail the run cleanly if the operator does not respond in time — rather than leaving it suspended indefinitely. :: ## Related concepts - [Input](https://docs.unitelabs.io/automate/concepts/input/): how workflow parameters become operator-facing fields - [Runs](https://docs.unitelabs.io/automate/concepts/runs/): the `AWAITING_INPUT` and `PAUSED` run states and resumption ## Guides - [Basic human in the loop](https://docs.unitelabs.io/automate/guides/basic-hitl/) - [Typed operator inputs](https://docs.unitelabs.io/automate/guides/typed-operator-inputs/) # Error Handling Error handling in UniteLabs is built into the workflow hierarchy. Different error types are handled at the level best suited to recover from them — automatic retries at the step level, and `try/except` with operator-assisted recovery at the phase level. ## Error types | Type | Examples | Where handled | | --------------- | ---------------------------------------------------------------- | ----------------------------------------------------------- | | **Technical** | Device timeout, lost connection, command not acknowledged | Step — automatic retry | | **Operational** | Phase time limit exceeded, device unavailable at scheduling time | Phase — `try/except` + pause or abort | | **Scientific** | Sample outside expected range, upstream result invalid | Phase — raise an exception, pause for operator intervention | ## Step level: automatic retry Steps are the first line of defense. The workflow engine retries technical errors automatically before surfacing them: ```python [workflows/steps.py] from unitelabs.sdk.automate import step @step(retries=3, retry_delay_seconds=2) async def aspirate(liquid_handler: LiquidHandlerDevice, volume: float): await liquid_handler.pipettes.aspirate(volume) ``` If the step fails after all retries, the error is propagated to the enclosing phase. The default retry count is configurable per step. A retry re-runs the step from the top, so a step that already did half its work does that half twice: retry a dispense that filled three wells before failing, and those three wells get filled again. Which steps are safe to retry is a design decision you make when you write them, and [Error recovery](https://docs.unitelabs.io/automate/guides/basic-error-handling/) walks through it. ## Phase level: try/except and operator recovery At the phase level, use `try/except` to catch errors and decide how to respond — retry, pause for operator intervention, or abort cleanly: ```python [workflows/detection.py] from unitelabs.sdk import get_logger, pause_flow_run from unitelabs.sdk.automate import phase class PlateNotDetectedError(Exception): pass @phase() async def detection_phase(lh, carrier_id: str) -> dict: logger = get_logger() try: result = await detect_plate(lh=lh, carrier_id=carrier_id) except PlateNotDetectedError: logger.warning( f"No plate detected at {carrier_id}. " "Seat the plate fully, then click Resume." ) await pause_flow_run() logger.info("Operator resumed — retrying detection.") result = await detect_plate(lh=lh, carrier_id=carrier_id) return {"lh": lh, "target_slot": result["slot"]} ``` Use `try/finally` to guarantee cleanup regardless of outcome: ```python [workflows/dispensing.py] @phase() async def dispensing_phase(lh) -> dict: try: lh = await distribute(lh=lh, volume=100.0) finally: lh = await return_tips(lh=lh) # always runs — success, error, or cancellation return {"lh": lh} ``` See [Error recovery](https://docs.unitelabs.io/automate/guides/basic-error-handling/) for more patterns. ## Workflow level: propagation and setting a checkpoint If a phase fails and no `try/except` handles the error, the failure propagates to the workflow. The run is marked as `FAILED` and the workflow engine records the last completed phase as a checkpoint. Because phases always end in stable states, a failed run can be resumed from the last successful phase — either automatically or by an operator. ## Summary ```text Step fails └── Technical error → retry (up to N times) └── Still failing → propagate to phase Phase receives error └── try/except → pause for operator, retry, or return error └── No handler → propagate to workflow Workflow receives error └── Run marked FAILED at last stable checkpoint └── Operator can resume from checkpoint ``` ## Related concepts - [Step](https://docs.unitelabs.io/automate/concepts/step/): where retries are configured - [Human in the Loop](https://docs.unitelabs.io/automate/concepts/human-in-the-loop/): using `pause_flow_run()` for operator-assisted recovery - [Runs](https://docs.unitelabs.io/automate/concepts/runs/): run states and checkpoint-based resumption ## Guides - [Basic error handling](https://docs.unitelabs.io/automate/guides/basic-error-handling/) - [Advanced error handling](https://docs.unitelabs.io/automate/guides/advanced-error-handling/) # Deploy a workflow In this guide you will use the `scripts/deploy.py` CLI shipped with the [workflow template](https://docs.unitelabs.io/automate/workflow-template/) to bundle one (or every) workflow in your repo and register it with the UniteLabs platform. **Prerequisites** - A repo cloned (or generated) from the [workflow template](https://docs.unitelabs.io/automate/workflow-template/). Each top-level workflow directory (`w-/`, for example `w02-liquid-handling/`) is a standalone workflow package with its own `pyproject.toml` declaring `[project].name`, `[project].version`, and `[tool.unitelabs.workflow]` metadata. - [`uv`](https://docs.astral.sh/uv/){rel=""nofollow,noopener""} (latest) and Python 3.12+. - UniteLabs API credentials: `BASE_URL`, `AUTH_URL`, `CLIENT_ID`, `CLIENT_SECRET`. ## Store your credentials Copy `.env.example` to `.env` at the repo root and fill in the four values: ```bash [.env] BASE_URL=https://api..unitelabs.io/ AUTH_URL=https://auth..unitelabs.io/realms//protocol/openid-connect/ CLIENT_ID= CLIENT_SECRET= ``` `scripts/deploy.py` loads `.env` automatically when run locally. CI/CD pipelines set the same four variables as platform secrets — see [CI/CD for workflows](https://docs.unitelabs.io/automate/guides/cicd-for-workflows/). ::callout{icon="i-heroicons-shield-exclamation"} `.env` is in the template's `.gitignore`. Never commit credentials. :: ## What the deploy script does `scripts/deploy.py` is a single, self-contained file. It declares its own dependencies (`requests`, `python-dotenv`) inline, so `uv run scripts/deploy.py` works from a fresh clone without an install step. There is no `pyproject.toml` at the repo root. For each workflow you deploy, the script: 1. **Finds the workflow.** It scans the repo for directories that match `w-/` and contain a `pyproject.toml`, then picks the one you asked for by its **slug**. The slug is the workflow's unique short name: the `[project].name` from its `pyproject.toml`, identical to the directory name (for example `w02-liquid-handling`). 2. **Builds a bundle.** It creates a zip containing the unchanged workflow directory, plus a copy of `shared/` at the top level of the zip. `shared/` is the repo's single library package: it sits next to the workflows and holds the code they all import. Because it travels inside the bundle, `import shared.steps...` works on the platform exactly as it does locally. 3. **Collects the dependencies.** The workflow and `shared/` each declare their dependencies in their own `pyproject.toml`. The script merges the two lists and drops duplicates. If both declare the same package, the workflow's version wins. The platform installs the runtime environment from this merged list. 4. **Authenticates** against the platform with the OAuth2 client credentials from your `.env`: a machine-to-machine login with client ID and secret, no browser involved. 5. **Creates or updates the platform record.** It looks up the workflow by its `display_name`. If a record exists, the script updates it; if not, it creates a new one. If several records share the same display name, the script stops instead of guessing, so it never overwrites the wrong workflow. 6. **Restores soft-deleted records.** Deleting a workflow in the UI only disables it. On redeploy the script re-enables the record and refreshes its description and tags, so you don't end up with a hidden duplicate. ## Commands All five forms are mutually exclusive; pick whichever fits your need. ### Deploy a single workflow ```bash uv run scripts/deploy.py w02-liquid-handling ``` The positional argument is the workflow's **slug** — `[project].name` in its `pyproject.toml`. ### Deploy every workflow ```bash uv run scripts/deploy.py --all ``` ### Deploy only what changed ```bash uv run scripts/deploy.py --changed-from origin/main ``` Runs `git diff --name-only ..HEAD` and deploys only the workflows whose directory was touched. Use it locally to preview what a merge would redeploy, or in CI on push-to-main (see [CI/CD for workflows](https://docs.unitelabs.io/automate/guides/cicd-for-workflows/)). ::callout{icon="i-heroicons-light-bulb"} **When everything counts as changed.** A change under `shared/` or to `scripts/deploy.py` itself marks every workflow as changed, because `shared/` is copied into every bundle and the script builds every bundle. This rule is implemented in one place, the helper function `affected_workflows()` inside the script. :: ### Deploy a tagged release ```bash uv run scripts/deploy.py --git-tag w02-liquid-handling/v1.2.0 ``` The tag combines the workflow's slug and its version, separated by `/v`. The script splits the tag, checks that the workflow's `[project].version` matches `1.2.0`, and adds `v1.2.0` to the platform tags. If the pyproject version and the tag disagree, the script refuses to deploy. The version in `pyproject.toml` is the one that counts. ### Print without deploying ```bash uv run scripts/deploy.py --list uv run scripts/deploy.py --list --changed-from origin/main ``` Prints the selected slugs (one per line) and exits **before** loading `.env` or authenticating. CI pipelines (or you, locally) can use this to sanity-check the selection without needing credentials. ## Channels: DEV / STG / PRD ```bash uv run scripts/deploy.py w02-liquid-handling --channel dev uv run scripts/deploy.py --all --channel stg uv run scripts/deploy.py --git-tag w02-liquid-handling/v1.2.0 --channel prd ``` `--channel` layers on top of any of the forms above. It prepends `[DEV] `/`[STG] `/`[PRD] `to the platform `display_name` and adds the channel name as a platform tag. The result: a single tenant can host **parallel DEV, STG, and PRD records of the same workflow** as distinct platform entries. Running a workflow against real instruments on DEV therefore cannot disturb the record production uses. The channel-to-trigger mapping is established by CI; locally you'll usually omit `--channel` (deploys land as untagged "main" records) or pass `--channel dev` when iterating against a shared DEV tenant. The full mapping is in [CI/CD for workflows](https://docs.unitelabs.io/automate/guides/cicd-for-workflows/). ## Extra platform tags ```bash uv run scripts/deploy.py w02-liquid-handling --tag prod --tag stable ``` `--tag/-t` is repeatable and stacks with `--git-tag`'s version label and `--channel`'s channel tag. Use it for ad-hoc labels (a feature flag, a customer name) without bumping the workflow's version. ## Release a workflow Each workflow ships independently. To release `w02-liquid-handling v1.2.0`: 1. **Bump the version.** Edit `w02-liquid-handling/pyproject.toml` and set `[project].version = "1.2.0"`. 2. **Commit:** ```bash git add w02-liquid-handling/pyproject.toml git commit -m "release: w02-liquid-handling v1.2.0" ``` 3. **Tag and push:** ```bash git tag w02-liquid-handling/v1.2.0 git push origin main --tags ``` The tag push triggers `deploy-prd` in CI — see [CI/CD for workflows](https://docs.unitelabs.io/automate/guides/cicd-for-workflows/). You can also run the release locally: ```bash uv run scripts/deploy.py --git-tag w02-liquid-handling/v1.2.0 --channel prd ``` ::callout{icon="i-heroicons-information-circle"} **The tag has to name the workflow.** CI only reacts to tags of the form `/v`, because the slug inside the tag is what tells the pipeline which of the repo's workflows to release: - `w02-liquid-handling/v1.2.0` deploys `w02-liquid-handling` at version 1.2.0 and leaves every other workflow in the repo untouched. - `v1.2.0` matches no rule. Nothing is deployed and no pipeline runs. :: ## Verify Open the UniteLabs Workflows page and confirm the workflow appears with the expected `[CHANNEL]` prefix, version, and tags. The script's terminal output shows what landed on the platform: display name, resolved entrypoint, bundle size, dependency count, and whether the record was created or updated. ## Next steps - [**Set up CI/CD**](https://docs.unitelabs.io/automate/guides/cicd-for-workflows/) — wire `scripts/deploy.py` into GitHub Actions or GitLab CI with the three-channel (dev/stg/prd) model. - [**Trigger a workflow run**](https://docs.unitelabs.io/automate/guides/run-a-workflow/) — confirm the deployment end-to-end. - [**`AGENTS.md`**](https://gitlab.com/unitelabs/workflows/workflow-template/-/blob/main/AGENTS.md){rel=""nofollow,noopener""} — authoring rules for adding new workflows to the template. # Run a workflow Once a workflow is [deployed](https://docs.unitelabs.io/automate/guides/deploy-a-workflow/), you can run it from the platform UI, a Python script, or directly via the REST API. **Prerequisites** - A deployed workflow (see [Deploy a workflow](https://docs.unitelabs.io/automate/guides/deploy-a-workflow/)) - API credentials if triggering programmatically ## From the platform UI 1. Go to the **Workflows** section and select the workflow you want to run. 2. Click **Run**. If the workflow has required inputs, a form appears — fill them in and confirm. 3. The run appears in the **Runs** list with status `SCHEDULED`, transitioning to `RUNNING` within seconds. 4. Click the run to see phase progress, step-level logs, and any artifacts produced. If the workflow reaches a phase that pauses for operator input, the run transitions to `AWAITING_INPUT`. Fill in the form that appears and click **Submit** to continue, or **Resume** for a simple pause with no form. ## From Python Use the `AsyncApiClient` from the SDK to trigger a run programmatically: ```python [trigger.py] import asyncio from unitelabs.sdk import AsyncApiClient async def main(): async with AsyncApiClient( base_url="https://api.unitelabs.io/{tenant-id}", auth_url="https://auth.unitelabs.io/realms/{tenant-id}/protocol/openid-connect", client_id="{client-id}", client_secret="{client-secret}", ) as client: response = await client.post( url="/workflows/{workflow-id}/runs", json={ "name": "Plate QC — Batch 42", "parameters": { "sample_id": "B42", "min_concentration": 250.0, }, }, ) run = response.json() print(f"Run started: {run['id']} ({run['status']})") asyncio.run(main()) ``` Replace `{tenant-id}`, `{client-id}`, `{client-secret}`, and `{workflow-id}` with your actual values. The workflow ID appears in the platform URL when you open a workflow. ## Watching run progress Poll the run status until it reaches a terminal state: ```python [wait_for_run.py] import asyncio from unitelabs.sdk import AsyncApiClient TERMINAL = {"COMPLETED", "FAILED", "CANCELLED"} async def wait_for_run(client, run_id: str, poll_interval: int = 5): while True: response = await client.get(f"/runs/{run_id}") run = response.json() print(f"[{run['statusSince']}] {run['status']}") if run["status"] in TERMINAL: return run await asyncio.sleep(poll_interval) async def main(): async with AsyncApiClient( base_url="https://api.unitelabs.io/{tenant-id}", auth_url="https://auth.unitelabs.io/realms/{tenant-id}/protocol/openid-connect", client_id="{client-id}", client_secret="{client-secret}", ) as client: run = await wait_for_run(client, run_id="{run-id}") print(f"Final status: {run['status']}") asyncio.run(main()) ``` ## Running specific phases You can start execution from a particular phase — useful when resuming after an interruption or replaying a stage with different inputs. From the platform UI, select the phase from the run detail view and click **Run from here**. ## Next steps - [Workflows and runs via REST API](https://docs.unitelabs.io/automate/guides/workflows-api/): full API reference for creating, listing, and monitoring runs - [Human in the loop](https://docs.unitelabs.io/automate/guides/basic-hitl/): how to handle paused runs waiting for operator input - [Basic error handling](https://docs.unitelabs.io/automate/guides/basic-error-handling/): what to do when a run fails # CI/CD for workflows The [workflow template](https://docs.unitelabs.io/automate/workflow-template/) ships ready-to-use CI/CD for both **GitHub Actions** and **GitLab CI**: pipelines your Git host runs automatically to check and deploy workflows on every push or release. Both wire `scripts/deploy.py` (see [Deploy a workflow](https://docs.unitelabs.io/automate/guides/deploy-a-workflow/)) into the same three-channel model. ## The three-channel model A **workflow record** is one entry on the platform's Workflows page: the thing an operator picks to start a run. It holds the uploaded bundle, the entrypoint, the resolved dependency list, and the tags. The platform itself has no channel or environment concept. It identifies a record by its display name, and that is what `--channel` works with: it prepends `[DEV] `, `[STG] `, or `[PRD] `to the name before deploying. Three channels therefore produce three differently named records of the same workflow in one tenant: | Channel | Trigger | Display-name prefix | Intent | | ------- | -------------------------------------------------- | ------------------- | ----------------------------------------------------------- | | **DEV** | Operator-triggered (web UI or API) | `[DEV]` | Manual one-off deploys while iterating on a feature branch. | | **STG** | Auto on push to `main` | `[STG]` | Every workflow affected by the merge is redeployed to STG. | | **PRD** | Auto on per-workflow release tag `/v` | `[PRD]` | One workflow ships at a verified version. | So `w02-liquid-handling`, whose display name is `Liquid Handling Demo` (from `[tool.unitelabs.workflow].display_name` in its `pyproject.toml`), ends up as three entries: `[DEV] Liquid Handling Demo`, `[STG] Liquid Handling Demo`, and `[PRD] Liquid Handling Demo`. Each carries its own bundle, version, and tags. A deploy only updates the record whose name matches, so a DEV deploy cannot touch PRD. That is the whole isolation mechanism: you can run against real instruments on DEV while PRD keeps serving production. STG always reflects `main`, and PRD shows what production runs. ## Required secrets Both platforms read the same four secrets: | Variable | Where to find it | | --------------- | ---------------------------------------------------------------------------------- | | `BASE_URL` | `https://api..unitelabs.io/` | | `AUTH_URL` | `https://auth..unitelabs.io/realms//protocol/openid-connect/` | | `CLIENT_ID` | OAuth2 client ID | | `CLIENT_SECRET` | OAuth2 client secret (mark as masked) | **GitHub**: Settings → Secrets and variables → Actions → New repository secret. **GitLab**: Settings → CI/CD → Variables. Mark `CLIENT_SECRET` as **Masked** so it never appears in logs; mark all four as **Protected** to restrict them to protected branches and tags. ## GitHub Actions The template ships three files under `.github/workflows/`: ### `deploy-dev.yml` — manual ```yaml name: Deploy DEV on: workflow_dispatch: inputs: workflow_slug: description: Workflow slug ([project].name from pyproject.toml) required: true type: string permissions: contents: read jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: astral-sh/setup-uv@v5 - name: Deploy to DEV env: BASE_URL: ${{ secrets.BASE_URL }} AUTH_URL: ${{ secrets.AUTH_URL }} CLIENT_ID: ${{ secrets.CLIENT_ID }} CLIENT_SECRET: ${{ secrets.CLIENT_SECRET }} run: | SHORT_SHA="${GITHUB_SHA::7}" uv run scripts/deploy.py "${{ inputs.workflow_slug }}" \ --channel dev --tag "$SHORT_SHA" ``` ::callout{icon="i-heroicons-light-bulb"} GitHub only renders the **Run workflow** button for `workflow_dispatch` jobs that exist on the repo's **default branch**. Merge the workflow file to `main` before expecting it to appear. :: ### `deploy-stg.yml` — push to main, affected workflows only ```yaml name: Deploy STG on: push: branches: [main] permissions: contents: read jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 # full history so --changed-from can diff - uses: astral-sh/setup-uv@v5 - name: Deploy affected workflows to STG env: BASE_URL: ${{ secrets.BASE_URL }} AUTH_URL: ${{ secrets.AUTH_URL }} CLIENT_ID: ${{ secrets.CLIENT_ID }} CLIENT_SECRET: ${{ secrets.CLIENT_SECRET }} run: | # First push to a new branch reports a null `before` ref; fall back # to HEAD~1 so we still compute a meaningful diff. BEFORE="${{ github.event.before }}" if [ "$BEFORE" = "0000000000000000000000000000000000000000" ]; then BEFORE="HEAD~1" fi SHORT_SHA="${GITHUB_SHA::7}" uv run scripts/deploy.py --changed-from "$BEFORE" \ --channel stg --tag "$SHORT_SHA" ``` ### `deploy-prd.yml` — per-workflow release tag ```yaml name: Deploy PRD on: push: tags: - '*/v*.*.*' permissions: contents: read jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: astral-sh/setup-uv@v5 - name: Deploy tagged release to PRD env: BASE_URL: ${{ secrets.BASE_URL }} AUTH_URL: ${{ secrets.AUTH_URL }} CLIENT_ID: ${{ secrets.CLIENT_ID }} CLIENT_SECRET: ${{ secrets.CLIENT_SECRET }} run: | uv run scripts/deploy.py --git-tag "$GITHUB_REF_NAME" --channel prd ``` ## GitLab CI The template's `.gitlab-ci.yml` defines a hidden `.deploy-base` template and three deploy jobs that extend it. ```yaml variables: SECRET_DETECTION_ENABLED: 'true' # WORKFLOW_SLUG renders as an input field on the "Run pipeline" web UI. # Pipeline-level (not job-level) so the `description:` form works. WORKFLOW_SLUG: value: '' description: 'Workflow slug for deploy-dev ([project].name from pyproject.toml)' stages: - secret-detection - lint - deploy # scripts/deploy.py needs only requests + python-dotenv + stdlib. We install # them directly to avoid pulling unitelabs-* SDK git sources (no SSH creds on # the runner). Image is `python:3.12` (NOT -slim) because deploy-stg's # --changed-from shells out to `git diff`, which slim lacks. .deploy-base: stage: deploy image: python:3.12 before_script: - pip install --quiet requests python-dotenv deploy-dev: extends: .deploy-base script: - test -n "$WORKFLOW_SLUG" || { echo "WORKFLOW_SLUG is required"; exit 1; } - python scripts/deploy.py "$WORKFLOW_SLUG" --channel dev --tag "$CI_COMMIT_SHORT_SHA" rules: # Operator-triggered pipelines only (web UI or `glab ci run`). - if: '$CI_PIPELINE_SOURCE == "web" || $CI_PIPELINE_SOURCE == "api"' when: manual deploy-stg: extends: .deploy-base # Full history so `git diff` against $CI_COMMIT_BEFORE_SHA works for any # merge depth (default GitLab clone depth is 50). variables: GIT_DEPTH: '0' script: - | BEFORE="$CI_COMMIT_BEFORE_SHA" if [ "$BEFORE" = "0000000000000000000000000000000000000000" ]; then BEFORE="HEAD~1" fi python scripts/deploy.py --changed-from "$BEFORE" \ --channel stg --tag "$CI_COMMIT_SHORT_SHA" rules: - if: '$CI_COMMIT_BRANCH == "main"' deploy-prd: extends: .deploy-base script: - python scripts/deploy.py --git-tag "$CI_COMMIT_TAG" --channel prd rules: # Per-workflow release tags only: `/v`. - if: '$CI_COMMIT_TAG =~ /^[a-z][a-z0-9-]*\/v\d+\.\d+\.\d+$/' ``` ::callout{icon="i-heroicons-exclamation-triangle"} **Three pitfalls we hit when wiring this up:** - **`description:` doesn't work on job-level `variables:`** — only at the top-level block. Put `WORKFLOW_SLUG` at the pipeline level (as shown) so the "Run pipeline" UI renders an input field. - **`python:3.12-slim` has no `git`**, and `--changed-from` shells out to `git diff`. Use the regular `python:3.12` image. - **The dev rule must include `api`**, not just `web` — `glab ci run` and other programmatic triggers report `$CI_PIPELINE_SOURCE == "api"`. :: ## The "everything affected" rule `deploy-stg` deploys only workflows whose directory was touched in the merge — **unless** the diff includes `shared/` or `scripts/deploy.py`. Those two are special: - `shared/` is copied into every workflow's bundle, so a change there reshapes every bundle. - `scripts/deploy.py` rewrites every bundle's contents. Touching either marks **every workflow as affected** and STG redeploys them all. The rule lives in Python (`affected_workflows()` in `scripts/deploy.py`) so it's testable and shared between local and CI runs — not duplicated across two CI dialects. ## End-to-end release walkthrough Releasing `w02-liquid-handling v1.2.0` through CI: 1. **Iterate on DEV**while developing on a feature branch: ```bash # Local uv run scripts/deploy.py w02-liquid-handling --channel dev # Or via CI: trigger deploy-dev with WORKFLOW_SLUG=w02-liquid-handling ``` 2. **Open an MR/PR** and merge to `main` — `deploy-stg` fires automatically. If your diff touched `shared/`, all three workflows redeploy to STG; otherwise just the workflows you changed. 3. **Bump the workflow's version** in a follow-up commit on `main`: ```bash $EDITOR w02-liquid-handling/pyproject.toml # bump [project].version → 1.2.0 git commit -am "release: w02-liquid-handling v1.2.0" git tag w02-liquid-handling/v1.2.0 git push origin main --tags ``` 4. **`deploy-prd` fires on the tag push.** `scripts/deploy.py --git-tag /v` parses the tag, verifies the pyproject version matches `1.2.0`, and refuses if they disagree. ## Verify a deployment Each CI job prints a line per deployed workflow: ```text Affected workflows since 'origin/main': w02-liquid-handling Authenticating... --- [STG] Liquid Handling Demo (v1.2.0) --- Building bundle... Updating workflow id=70634682-5178-4881-b355-fbcb87448102 resolved deps: 4 packages (101 chars) Deployed: [STG] Liquid Handling Demo v1.2.0 ``` Open the UniteLabs Workflows page and confirm the workflow appears with the expected `[CHANNEL]` prefix and version tag. ## Next steps - [**Deploy a workflow**](https://docs.unitelabs.io/automate/guides/deploy-a-workflow/) — the full `scripts/deploy.py` reference, useful for local one-offs and for understanding what CI is actually running. - [**Trigger a workflow run**](https://docs.unitelabs.io/automate/guides/run-a-workflow/) — confirm a deployment end-to-end. # HITL basics In this guide you will add human-in-the-loop (HITL) checkpoints to a workflow using the SDK's `operator_confirm()` helper. When called, the run suspends completely — the process is idle, no resources are held — and only continues when an operator confirms via the UI or API. **Prerequisites** - A working workflow (see [Build your first workflow](https://docs.unitelabs.io/automate/your-first-workflow/)) - `unitelabs-sdk[automate]` installed ## When to use HITL Use `operator_confirm()` when the workflow cannot safely proceed without a human action: - An operator needs to physically load a plate before liquid handling begins - A supervisor needs to visually confirm instrument state before dispensing - An automatic check fails and a human needs to intervene before retrying If you only need to notify someone without pausing execution, use [logging](https://docs.unitelabs.io/automate/concepts/logs/) instead. ## Pattern 1 — Unconditional checkpoint The simplest form: pause at a fixed point and wait for an operator to confirm before continuing. The message you pass is logged with an `OPERATOR ACTION REQUIRED:` prefix and shown to the operator — make it specific and actionable. ```python from unitelabs.sdk import operator_confirm from unitelabs.sdk.automate import phase @phase(name="Dispense Workflow") async def dispense_flow() -> dict: # ... initialization and setup ... # Checkpoint: operator confirms before dispensing begins await operator_confirm( "Confirm that plate_1 is seated at ByonoyCarrier slot 1 " "before dispensing, then click Resume." ) # ... dispensing logic ... return {"status": "success"} ``` When the workflow reaches `operator_confirm()`, the run transitions to `AWAITING_INPUT` and the platform shows a confirm-and-resume control. The operator resumes it from the platform UI or via the API. ## Pattern 2 — Conditional pause on failure A more powerful pattern: catch a failure, pause for the operator to fix it, then retry the failed step on resume. This is the standard approach for transient physical errors — a plate out of position, a sensor misfiring, a gripper not gripping cleanly. ```python from unitelabs.sdk import operator_confirm from unitelabs.sdk.automate import phase class PlateNotDetectedError(Exception): pass async def detect_plate(carrier_id: str) -> dict: """Returns detection result or raises PlateNotDetectedError.""" ... @phase(name="Detection with HITL Recovery") async def detection_flow(carrier_id: str) -> dict: try: result = await detect_plate(carrier_id=carrier_id) except PlateNotDetectedError: # Pause — operator physically fixes the plate and confirms; retry on resume await operator_confirm( f"No plate detected at {carrier_id} slot 1. " "Check that the plate is fully seated, then click Resume." ) result = await detect_plate(carrier_id=carrier_id) return {"status": "success", "target_slot": result["slot"]} ``` ::callout{icon="i-heroicons-exclamation-triangle"} `operator_confirm()` (like the `pause_flow_run()` it wraps) must be called from the **top-level `@phase`**, not from a `@phase` nested inside another one. The workflow engine cannot pause from within a nested phase. If your detection logic lives in a nested phase, catch the exception at the top level before pausing. :: ### Chaining multiple checkpoints For multi-step operator interactions, call `operator_confirm()` at each checkpoint in sequence: ```python @phase(name="Multi-Step HITL") async def multi_step_flow() -> dict: await operator_confirm("Step 1/2: Load tip boxes and reservoir. Click Resume when ready.") await operator_confirm("Step 2/2: Load sample plate at slot 1. Click Resume to start.") # ... instrument logic ... return {"status": "success"} ``` ## Pattern 3 — Resume via API An operator does not need to be watching the platform UI. Any system that can call the API can resume a paused run. `operator_confirm()` puts the run in `AWAITING_INPUT`, so resume it by submitting the confirmation (its `confirmed` field defaults to `true`) keyed by the waiting phase ID: ```bash curl -X POST "$BASE_URL/v1/runs/{runId}/status" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "status": "RUNNING", "data": { "phase-abc-123": { "confirmed": true } } }' ``` Look up the phase ID from the run's status response (the `input` field). See [Typed operator inputs](https://docs.unitelabs.io/automate/guides/typed-operator-inputs/) for the full resume-with-data flow. This lets you build operator-facing tools — a lab dashboard, a mobile approval screen, a Slack button — that resume the run when a human clicks confirm. ## Handling timeouts `operator_confirm()` defaults to a one-hour timeout. Pass `timeout=` (seconds) to fail the run cleanly if the operator never responds, rather than leaving it paused indefinitely: ```python await operator_confirm("Load plate at slot 1, then resume.", timeout=600) # fails after 10 minutes ``` A timed-out pause transitions the run to `FAILED` with a clear message in the logs — easier to debug than a run that is silently stuck. ## Next steps - [Typed operator inputs](https://docs.unitelabs.io/automate/guides/typed-operator-inputs/): collect structured data — not just a confirmation — with a `RunInput` model - [Error recovery](https://docs.unitelabs.io/automate/guides/basic-error-handling/): clean up hardware state and retry after failure # Typed operator inputs When a phase calls `pause_flow_run(wait_for_input=...)`, the platform renders a form for the operator based on the `RunInput` model's field annotations. A `bool` becomes a toggle switch, a `Literal[...]` becomes a dropdown, and so on. This guide shows how each type maps to a form control and how to submit input programmatically via the API. ::callout{icon="i-heroicons-information-circle"} If the operator only needs to confirm an action — with no data to enter — use [`operator_confirm()`](https://docs.unitelabs.io/automate/concepts/human-in-the-loop/) instead. This guide is for collecting structured input. :: **Prerequisites** - Familiarity with phases and HITL (see [Human in the loop](https://docs.unitelabs.io/automate/concepts/human-in-the-loop/)) - `unitelabs-sdk[automate]` installed ## Type → form field mapping | Python type | Form control | Notes | | ------------------------ | --------------- | -------------------------------------------------------------------------------------------------------------------------- | | `str` | Text input | | | `bool` | Toggle switch | | | `int` | Number input | Accepts whole numbers only | | `float` | Number input | Accepts decimals | | `Literal["a", "b", ...]` | Dropdown select | Values become the option list, inlined into the schema as `enum` | | `Enum` subclass | Dropdown select | Enum values become the option list, but emitted as a `$ref` into `$defs`. Prefer `Literal` if you read the schema yourself | Fields without a default are required — the operator cannot submit the form until they are filled. Fields with a default are optional and pre-filled with that value. ## Using enums for constrained choices When an operator must pick from a fixed set of options, use `Literal` or a Python `Enum`. Both render as a dropdown select. ### With `Literal` ```python [workflows/protocol_select.py] from typing import Literal from unitelabs.sdk import pause_flow_run, RunInput from unitelabs.sdk.automate import phase class ProtocolInput(RunInput): sample_id: str protocol: Literal["standard", "dilution_2x", "dilution_10x"] confirm: bool = False @phase() async def select_protocol(): data = await pause_flow_run(wait_for_input=ProtocolInput) run_protocol(data.sample_id, data.protocol) ``` ### With a Python `Enum` ```python [workflows/triage.py] from enum import Enum from unitelabs.sdk import pause_flow_run, RunInput from unitelabs.sdk.automate import phase class Priority(str, Enum): low = "low" medium = "medium" high = "high" class TriageInput(RunInput): sample_id: str priority: Priority = Priority.medium @phase() async def triage_sample(): data = await pause_flow_run(wait_for_input=TriageInput) register_triage(data.sample_id, data.priority) ``` Use `str, Enum` as the base class so the values serialize cleanly to JSON strings when submitted via the API. ## Optional fields and defaults Fields with default values appear in the form pre-filled but not required. Fields without defaults must be filled before the operator can submit. ```python [workflows/qc_gate.py] from unitelabs.sdk import pause_flow_run, RunInput from unitelabs.sdk.automate import phase class QCInput(RunInput): sample_id: str # required — text input, no default approved: bool # required — toggle, no default notes: str = "" # optional — text input, empty by default repeat_count: int = 1 # optional — number input, pre-filled with 1 @phase() async def quality_check(): data = await pause_flow_run(wait_for_input=QCInput) if not data.approved: raise ValueError(f"Sample {data.sample_id} rejected at QC gate. Notes: {data.notes}") log_approval(data.sample_id, data.repeat_count) ``` ## The `AWAITING_INPUT` run state When execution reaches a `pause_flow_run(wait_for_input=...)` call, the run transitions to `AWAITING_INPUT`. You can observe this by polling the run status: ```bash GET /v1/runs/{runId}/status ``` ```json { "status": "AWAITING_INPUT", "statusSince": "2025-06-01T14:22:00.000Z", "input": { "phase-abc-123": { "id": "phase-abc-123", "description": "Confirm sample before dispensing", "schema": { "properties": { "sample_id": { "type": "string", "title": "Sample Id" }, "approved": { "type": "boolean", "title": "Approved" }, "notes": { "type": "string", "title": "Notes", "default": "" } }, "required": ["sample_id", "approved"], "additionalProperties": false }, "values": null } } } ``` The `input` field is a map from phase ID to an input request object containing the JSON Schema for that phase's parameters. Use the schema to render your own form or validate values before submitting. `description` is what the workflow passed to `with_initial_data()` (see [Human in the loop](https://docs.unitelabs.io/automate/concepts/human-in-the-loop/)). In `schema`, `title` is derived from the field name, so `sample_id` becomes `Sample Id`; use `Field(title="Sample ID")` for a specific label. `additionalProperties` is `false`, so extra keys are rejected on submit. ::callout{icon="i-heroicons-information-circle"} `AWAITING_INPUT` is distinct from `PAUSED`. `PAUSED` is produced by `pause_flow_run()` with no input model and carries no input schema — the operator simply clicks resume. `AWAITING_INPUT` means the platform is holding a typed form open. :: ## Submitting input via API To resume a run in `AWAITING_INPUT`, POST to the run's status endpoint with `status: "RUNNING"` and a `data` object keyed by phase ID: ```bash curl -X POST "$BASE_URL/v1/runs/{runId}/status" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "status": "RUNNING", "data": { "phase-abc-123": { "sample_id": "S-001", "approved": true, "notes": "Checked visually — all good" } } }' ``` The keys in `data` are the phase IDs from the `input` field of the status response. The values must match the types in the schema — submit a JSON boolean for `bool` fields, a number for `int`/`float` fields, and a string for `str` and `Enum` fields. ## Multiple simultaneous inputs If two or more phases reach their `pause_flow_run(wait_for_input=...)` at the same time (e.g., parallel branches), the `input` field in the status response contains one key per waiting phase. Submit all of them in a single POST by including multiple phase IDs in `data`: ```bash curl -X POST "$BASE_URL/v1/runs/{runId}/status" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "status": "RUNNING", "data": { "phase-abc-123": { "approved": true, "sample_id": "S-001" }, "phase-def-456": { "priority": "high", "operator_id": "op-99" } } }' ``` Each phase resumes independently once its entry in `data` is received. ## Next steps - [Basic HITL](https://docs.unitelabs.io/automate/guides/basic-hitl/): simpler pause/resume without structured input using `operator_confirm()` - [Workflows API](https://docs.unitelabs.io/automate/guides/workflows-api/): full reference for run states, status transitions, and artifacts # Error recovery In lab automation, an error mid-run can leave hardware in an inconsistent state — tips are still loaded, a plate is mid-transfer, a gripper is holding something. Error recovery means: clean up first, then decide whether to retry or abort. This guide covers four practical patterns. They compose well — most real workflows use two or three of them together. **Prerequisites** - A working workflow (see [Build your first workflow](https://docs.unitelabs.io/automate/your-first-workflow/)) --- ## Pattern 1 — Guaranteed cleanup with `try/finally` Use `try/finally` to ensure hardware is left in a safe state even when an operation fails. The `finally` block always executes — whether the `try` body succeeds, raises, or is cancelled. ```python from unitelabs.sdk import get_logger from unitelabs.sdk.automate import phase @phase(log_prints=True, name="Dispensing Phase") async def dispensing_phase(lh) -> dict: logger = get_logger() try: lh = await distribute_colour(lh=lh, reservoir="ice", volume=100.0) lh = await distribute_colour(lh=lh, reservoir="orange", volume=100.0) finally: # Always return tips to the rack — even if distribute raises. # This runs on success, exception, and run cancellation. lh = await return_tips(lh=lh) logger.info("Tips returned.") return {"lh": lh, "status": "success"} ``` **Use `finally` for any action that must happen regardless of outcome:** returning tips, closing connections, unlocking an instrument, setting a status flag. Do not use it for logic that should only run on success — put that in the `try` body. ## Pattern 2 — Automatic retry with delay Add `retries` and `retry_delay_seconds` to a `@phase` or `@step` to have the SDK automatically re-run it when an unhandled exception escapes. This is ideal for transient hardware failures — a gripper that occasionally mis-grips, a sensor that times out under load. ```python from unitelabs.sdk.automate import phase @phase( log_prints=True, name="Return Plate Phase", retries=1, retry_delay_seconds=60, ) async def return_plate_phase(lh) -> dict: """ Move plate from working carrier back to storage. If the gripper motion fails, the workflow engine retries once after 60 seconds. The delay gives the instrument time to reset before the next attempt. """ lh = await gripper_move( lh=lh, source="working_carrier[1]", destination="storage_carrier[4]", ) return {"lh": lh} ``` The same decorator works on `@step`: ```python from unitelabs.sdk.automate import step @step( log_prints=True, name="Fetch Instrument Data", retries=3, retry_delay_seconds=10, ) async def fetch_instrument_data_task(endpoint: str) -> dict: # Retried up to 3 times on any exception, with 10s between attempts ... ``` ::callout{icon="i-heroicons-exclamation-triangle"} Only retry operations that are **safe to repeat**. Retrying a dispense that partially succeeded will double-dispense already-filled wells. Retrying a plate move when the plate was already moved will crash. When in doubt, use Pattern 3 instead: pause and let a human decide. :: ## Pattern 3 — Pause-and-retry (HITL recovery) When a failure requires human intervention before retrying — not just waiting — combine `try/except` with `pause_flow_run()`. The operator fixes the physical issue, clicks Resume, and the flow retries from that point. ```python from unitelabs.sdk import get_logger, pause_flow_run from unitelabs.sdk.automate import phase class PlateNotDetectedError(Exception): pass @phase(log_prints=True, name="Detection Phase with Recovery") async def detection_phase(lh, carrier_id: str) -> dict: logger = get_logger() try: result = await detect_plate(lh=lh, carrier_id=carrier_id) logger.info(f"Plate detected at slot {result['slot']}.") except PlateNotDetectedError: # 1. Log a clear, operator-readable message before pausing logger.warning( f"No plate detected at {carrier_id} slot 1. " "Check that the plate is fully seated, then click Resume." ) # 2. Suspend the run — no resources held while waiting await pause_flow_run() # 3. On resume, retry detection (plate is now in position) logger.info("Operator resumed — retrying detection.") result = await detect_plate(lh=lh, carrier_id=carrier_id) return {"lh": lh, "target_slot": result["slot"]} ``` The run stays `PAUSED` until the operator acts. They can resume from the platform's run view or [via the API](https://docs.unitelabs.io/automate/guides/basic-hitl/). See [HITL basics](https://docs.unitelabs.io/automate/guides/basic-hitl/) for more on the pause mechanism. ## Pattern 4 — Phase tracking for partial recovery Track which phases completed successfully in a list. Include it in every return value — success and error alike. This tells you exactly where a run failed, which phases are safe to skip on a re-run, and what to report to a LIMS. ```python from unitelabs.sdk import get_logger from unitelabs.sdk.automate import workflow @workflow(log_prints=True, name="Full Workflow") async def full_workflow(lh) -> dict: logger = get_logger() phases_completed: list[str] = [] # Phase 01 lh = await initialization_phase(lh=lh) phases_completed.append("01_initialization") # Phase 02 result = await detection_phase(lh=lh) lh = result["lh"] phases_completed.append("02_detection") # Phase 03 — dispensing can fail mid-run try: lh = await dispensing_phase(lh=lh) phases_completed.append("03_dispensing") except Exception as e: logger.error(f"Dispensing failed: {e}") return { "status": "error", "failed_phase": "03_dispensing", "phases_completed": phases_completed, "message": str(e), } phases_completed.append("04_finalization") return {"status": "success", "phases_completed": phases_completed} ``` Knowing `phases_completed = ["01_initialization", "02_detection"]` means Phase 03 was the one that failed, Phases 01 and 02 do not need to be repeated, and the instrument state after Phase 02 is the starting point for a manual recovery. --- ## Graceful vs fail-fast For workflows triggered by external systems (a UI, a webhook, a scheduler), the response format matters: | Pattern | Run status | Use when | | -------------------------------- | ----------- | ------------------------------------------ | | `try/except` → return error dict | `Completed` | Caller expects a structured response | | Let exception propagate | `Failed` | You want failure alerts and on-call paging | ```python # Graceful — caller sees {"status": "error", ...} instead of an exception @workflow(log_prints=True, name="Graceful Flow") async def graceful_flow(source: str) -> dict: try: result = await process(source=source) return {"status": "success", **result} except ValueError as e: return {"status": "error", "message": str(e)} ``` For a deeper treatment of structured error types and error codes, see [Advanced error handling](https://docs.unitelabs.io/automate/guides/advanced-error-handling/). ## Next steps - [HITL basics](https://docs.unitelabs.io/automate/guides/basic-hitl/): `pause_flow_run()` in depth - [Advanced error handling](https://docs.unitelabs.io/automate/guides/advanced-error-handling/): typed error taxonomy with user-facing messages and error codes # Advanced error handling In this guide you will replace generic exception handling with a typed error system that distinguishes who caused the error, what the user can do about it, and how the system should respond. **Prerequisites** - Familiarity with [Basic error handling](https://docs.unitelabs.io/automate/guides/basic-error-handling/) ## Why structured errors A raw exception like `ValueError("plate not found")` gives you a string. A structured error gives you: - **Who is responsible**: user mistake, system failure, config problem, or application bug - **What the user sees**: a clear title, explanation, and actionable steps - **What operations can do**: an error code to look up, and whether a retry is safe - **What developers see**: full technical details in the logs ## Define the error types Start with an enum that classifies every error by who caused it: ```python from enum import Enum class ErrorType(Enum): USER_ERROR = "user_error" # Bad input or invalid selection — user can fix SYSTEM_ERROR = "system_error" # Transient failure — retry might help CONFIG_ERROR = "config_error" # Missing credentials or misconfiguration — admin must fix BUG = "bug" # Unexpected application error — developer must fix ``` ## The StructuredError dataclass A `StructuredError` carries everything needed to handle an error consistently at every layer of the system: ```python from dataclasses import dataclass, field from typing import Optional @dataclass class StructuredError: error_type: ErrorType title: str # Short, human-readable title message: str # Explanation of what went wrong actions: list[str] = field(default_factory=list) # Steps the user can take error_code: Optional[str] = None # Unique code for lookup (e.g. "WF_101") can_retry: bool = False # Whether re-running the workflow may succeed contact_support: bool = False # Whether to escalate to support technical_details: Optional[str] = None # Stack trace / raw exception (omit from UI) def to_user_message(self) -> str: """Format the error for display to an end user.""" lines = [f"**{self.title}**", "", self.message] if self.actions: lines += ["", "**What you can do:**"] lines += [f"- {action}" for action in self.actions] if self.error_code: lines += ["", f"Error code: `{self.error_code}`"] if self.contact_support: lines += ["", "If the problem persists, contact support."] return "\n".join(lines) ``` ## Factory functions Define one factory function per error type so callers never need to import `ErrorType` directly: ```python def create_user_error( title: str, message: str, actions: list[str] | None = None, error_code: str | None = None, can_retry: bool = False, ) -> StructuredError: return StructuredError( error_type=ErrorType.USER_ERROR, title=title, message=message, actions=actions or [], error_code=error_code, can_retry=can_retry, ) def create_system_error( title: str, message: str, actions: list[str] | None = None, error_code: str | None = None, can_retry: bool = True, # System errors are often transient technical_details: str | None = None, ) -> StructuredError: return StructuredError( error_type=ErrorType.SYSTEM_ERROR, title=title, message=message, actions=actions or ["Wait a moment and try again", "Contact support if the problem persists"], error_code=error_code, can_retry=can_retry, contact_support=True, technical_details=technical_details, ) def create_config_error( title: str, message: str, actions: list[str] | None = None, error_code: str | None = None, ) -> StructuredError: return StructuredError( error_type=ErrorType.CONFIG_ERROR, title=title, message=message, actions=actions or ["Contact your administrator"], error_code=error_code, can_retry=False, contact_support=True, ) ``` ## Error codes Assign numeric codes to every distinct error so operators can look them up in your runbook without reading a full stack trace. A simple numbering convention: | Range | Category | | ------------------- | ---------------------------- | | `WF_100` – `WF_199` | User input errors | | `WF_200` – `WF_299` | Data validation errors | | `WF_300` – `WF_399` | External API errors | | `WF_400` – `WF_499` | Configuration errors | | `WF_500` – `WF_599` | Instrument / hardware errors | ```python class ErrorCode: # User input NO_SAMPLES_SELECTED = "WF_100" INVALID_SAMPLE_ID = "WF_101" INVALID_PARAMETER_VALUE = "WF_102" # Data NO_DATA_FOUND = "WF_200" MISSING_INSTRUMENT_DATA = "WF_201" # External APIs API_TIMEOUT = "WF_300" API_AUTH_FAILED = "WF_301" API_NOT_FOUND = "WF_302" # Configuration MISSING_CREDENTIALS = "WF_400" MISSING_ENV_VAR = "WF_401" # Instruments INSTRUMENT_UNREACHABLE = "WF_500" INSTRUMENT_RUN_FAILED = "WF_501" ``` ## Pre-built error constructors For each domain-specific error condition, write a named constructor. This keeps error definitions in one place and makes intent obvious at the call site. ```python def no_samples_error() -> StructuredError: return create_user_error( title="No Samples Selected", message="At least one sample must be selected to start the workflow.", actions=[ "Select one or more samples from the list", "Verify the samples exist in the system", ], error_code=ErrorCode.NO_SAMPLES_SELECTED, can_retry=True, ) def instrument_unreachable_error(instrument_name: str) -> StructuredError: return create_system_error( title=f"{instrument_name} Unreachable", message=f"Could not connect to {instrument_name}. The instrument may be offline or the connector may have stopped.", actions=[ f"Check that {instrument_name} is powered on", "Verify the connector service is running", "Restart the connector if needed", ], error_code=ErrorCode.INSTRUMENT_UNREACHABLE, can_retry=True, ) def missing_env_var_error(var_name: str) -> StructuredError: return create_config_error( title="Missing Configuration", message=f"Required environment variable '{var_name}' is not set.", actions=[ f"Set {var_name} in your .env file or CI/CD secrets", "Contact your administrator if you do not have access", ], error_code=ErrorCode.MISSING_ENV_VAR, ) ``` ## Raise structured errors from a workflow Wrap `StructuredError` in an exception class so it can be raised and caught naturally: ```python class WorkflowError(Exception): def __init__(self, structured_error: StructuredError) -> None: super().__init__(structured_error.title) self.structured_error = structured_error # Raising: sample_ids = [] if not sample_ids: raise WorkflowError(no_samples_error()) ``` Handle it in the workflow: ```python from unitelabs.sdk import get_logger from unitelabs.sdk.automate import workflow @workflow(log_prints=True, name="Structured Error Example") async def process_flow(sample_ids: list[str]) -> dict: logger = get_logger() try: if not sample_ids: raise WorkflowError(no_samples_error()) # ... actual processing return {"status": "success"} except WorkflowError as e: err = e.structured_error # Log full details for developers logger.error( f"[{err.error_code}] {err.error_type.value}: {err.title}\n" f"Message: {err.message}\n" f"Can retry: {err.can_retry}" ) # Return structured info for callers return { "status": "error", "error_type": err.error_type.value, "error_code": err.error_code, "title": err.title, "message": err.message, "actions": err.actions, "can_retry": err.can_retry, } ``` ## Automatic exception classification Add a `classify_exception` function to map common Python exceptions to the right `StructuredError` type. This means you catch and classify `Exception` once in the workflow rather than writing specific handlers for every exception type. ```python import httpx def classify_exception(exc: Exception, context: str = "") -> StructuredError: """Map a raw exception to a StructuredError based on its type.""" # HTTP errors from API calls if isinstance(exc, httpx.HTTPStatusError): status = exc.response.status_code if status in (401, 403): return create_config_error( title="Authentication Failed", message="API credentials are invalid or expired.", actions=["Contact your administrator to refresh the API credentials"], error_code=ErrorCode.API_AUTH_FAILED, ) if status == 404: return create_user_error( title="Resource Not Found", message=f"The requested resource does not exist{' while ' + context if context else ''}.", actions=["Verify the ID is correct", "Check that the resource has not been deleted"], error_code=ErrorCode.API_NOT_FOUND, ) if status >= 500: return create_system_error( title="API Server Error", message="The API returned an unexpected server error.", error_code=ErrorCode.API_TIMEOUT, technical_details=str(exc), ) if isinstance(exc, httpx.TimeoutException): return create_system_error( title="Request Timed Out", message="The API did not respond in time.", error_code=ErrorCode.API_TIMEOUT, can_retry=True, ) # Missing environment variables if isinstance(exc, (KeyError, RuntimeError)) and "not set" in str(exc).lower(): return create_config_error( title="Missing Configuration", message=str(exc), error_code=ErrorCode.MISSING_ENV_VAR, ) # Bad user input if isinstance(exc, ValueError): return create_user_error( title="Invalid Input", message=str(exc), actions=["Review your input and try again"], ) # Fallback — unexpected bug return StructuredError( error_type=ErrorType.BUG, title="Unexpected Error", message=f"An unexpected error occurred{' while ' + context if context else ''}.", contact_support=True, can_retry=False, technical_details=str(exc), ) ``` Use it in the workflow's catch-all: ```python @workflow(log_prints=True, name="Classified Error Flow") async def classified_flow(source: str) -> dict: logger = get_logger() try: data = await fetch_data_task(source=source) return {"status": "success", "count": len(data)} except WorkflowError as e: # Already structured — use as-is err = e.structured_error except Exception as e: # Classify automatically err = classify_exception(e, context="fetching data") logger.error(f"[{err.error_code}] {err.title}: {err.message}") return { "status": "error", "error_type": err.error_type.value, "error_code": err.error_code, "title": err.title, "message": err.message, "actions": err.actions, "can_retry": err.can_retry, } ``` ## What users see vs. what developers see The same `StructuredError` produces different outputs depending on context: **User-facing message** (e.g. displayed in a UI or returned to a caller): ```text **No Samples Selected** At least one sample must be selected to start the workflow. **What you can do:** - Select one or more samples from the list - Verify the samples exist in the system Error code: `WF_100` ``` **Developer log** (run logs): ```text [WF_100] user_error: No Samples Selected Message: At least one sample must be selected to start the workflow. Can retry: True ``` Keep `technical_details` (stack traces, raw API responses) out of user-facing messages. Pass them only to logs or an internal support system. # Workflows REST API The UniteLabs REST API lets you manage the full workflow lifecycle programmatically — create and deploy workflows, trigger runs, monitor progress, and interact with paused runs. **Prerequisites** - A UniteLabs account with a tenant ID and API credentials (client ID + secret) ## Authentication All API requests require a Bearer token obtained via OAuth2: ::tabs :::div{icon="i-heroicons-command-line" label="curl"} ```bash [Terminal] curl -X POST "https://auth.unitelabs.io/realms/{tenant-id}/protocol/openid-connect/token" \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "grant_type=client_credentials&client_id={client-id}&client_secret={client-secret}" ``` ::: :::div{icon="i-simple-icons-python" label="Python"} ```python [auth.py] import httpx token_response = httpx.post( "https://auth.unitelabs.io/realms/{tenant-id}/protocol/openid-connect/token", data={ "grant_type": "client_credentials", "client_id": "{client-id}", "client_secret": "{client-secret}", }, ) token = token_response.json()["access_token"] headers = {"Authorization": f"Bearer {token}"} ``` ::: :: All examples below assume `BASE_URL = "https://api.unitelabs.io/{tenant-id}/v1"` and `headers` set as above. --- ## Workflows ### Create a workflow Register a new workflow. Upload files separately or as a ZIP in one shot. ::tabs :::div{icon="i-heroicons-command-line" label="JSON body"} ```bash [Terminal] curl -X POST "$BASE_URL/workflows" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "plate-qc-pipeline", "description": "QC check before dispensing", "entrypoint": "workflow.py:entrypoint", "dependencies": "pandas,httpx", "tags": ["qc", "production"], "enabled": true }' ``` ::: :::div{icon="i-heroicons-command-line" label="ZIP upload"} ```bash [Terminal] curl -X POST "$BASE_URL/workflows" \ -H "Authorization: Bearer $TOKEN" \ -F "name=plate-qc-pipeline" \ -F "entrypoint=workflow.py:entrypoint" \ -F "file=@workflow.zip" ``` ::: :::div{icon="i-simple-icons-python" label="Python"} ```python [create_workflow.py] response = httpx.post( f"{BASE_URL}/workflows", headers=headers, json={ "name": "plate-qc-pipeline", "description": "QC check before dispensing", "entrypoint": "workflow.py:entrypoint", "dependencies": "pandas,httpx", "tags": ["qc", "production"], "enabled": True, }, ) workflow_id = response.json()["id"] ``` ::: :: Response `201`: ```json { "id": "d81023e8-87ab-4c9e-8394-0fc10ab562f3", "name": "plate-qc-pipeline", "entrypoint": "workflow.py:entrypoint", "dependencies": "pandas,httpx", "tags": ["qc", "production"], "enabled": true } ``` ### List workflows ```bash [Terminal] # Most recent 10, sorted by creation date curl "$BASE_URL/workflows?_take=10&_sort=-createdAt" \ -H "Authorization: Bearer $TOKEN" # Filter by name (partial match) curl "$BASE_URL/workflows?name[like]=plate" \ -H "Authorization: Bearer $TOKEN" # Include recent runs in the response curl "$BASE_URL/workflows?_include[]=recentRuns" \ -H "Authorization: Bearer $TOKEN" ``` Paginate using the cursor from the previous response: ```bash [Terminal] curl "$BASE_URL/workflows?_take=20&_cursor=eyJpZCI6Ii4uLiJ9" \ -H "Authorization: Bearer $TOKEN" ``` ### Get, update, delete ```bash [Terminal] # Get curl "$BASE_URL/workflows/{workflowId}" -H "Authorization: Bearer $TOKEN" # Update (partial — only include fields to change) curl -X PATCH "$BASE_URL/workflows/{workflowId}" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"enabled": false, "tags": ["qc", "staging"]}' # Delete curl -X DELETE "$BASE_URL/workflows/{workflowId}" -H "Authorization: Bearer $TOKEN" # Returns 204 No Content ``` ### Upload workflow files Add or update individual source files without redeploying the whole workflow: ```bash [Terminal] # Create a file curl -X POST "$BASE_URL/workflows/{workflowId}/files" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "workflow.py", "path": "/", "type": "file", "content": "from unitelabs.sdk.automate import workflow\n\n@workflow # workflow\nasync def entrypoint():\n pass\n" }' # Update a file curl -X PATCH "$BASE_URL/workflows/{workflowId}/files/workflow.py" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"content": "# updated content\n"}' # List all files curl "$BASE_URL/workflows/{workflowId}/files" -H "Authorization: Bearer $TOKEN" ``` --- ## Runs ### Create a run Start a workflow by its ID. Pass `parameters` to supply runtime values to the workflow function: ::tabs :::div{icon="i-heroicons-command-line" label="curl"} ```bash [Terminal] curl -X POST "$BASE_URL/workflows/{workflowId}/runs" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "QC Run — Plate B3", "parameters": { "sample_id": "B3", "min_concentration": 250.0 } }' ``` ::: :::div{icon="i-simple-icons-python" label="Python"} ```python [trigger_run.py] response = httpx.post( f"{BASE_URL}/workflows/{workflow_id}/runs", headers=headers, json={ "name": "QC Run — Plate B3", "parameters": { "sample_id": "B3", "min_concentration": 250.0, }, }, ) run_id = response.json()["id"] ``` ::: :: Response `201`: ```json { "id": "a3bb189e-8bf9-3888-9912-ace4e6543002", "workflowId": "d81023e8-87ab-4c9e-8394-0fc10ab562f3", "name": "QC Run — Plate B3", "status": "SCHEDULED", "statusSince": "2025-01-15T11:00:00.000Z", "parameters": { "sample_id": "B3", "min_concentration": 250.0 }, "createdAt": "2025-01-15T11:00:00.000Z" } ``` ### List and get runs ```bash [Terminal] # All runs for a workflow, newest first curl "$BASE_URL/workflows/{workflowId}/runs?_sort=-createdAt" \ -H "Authorization: Bearer $TOKEN" # Filter by date range curl "$BASE_URL/runs?created_at[gte]=2025-01-01T00:00:00Z&created_at[lte]=2025-01-31T23:59:59Z" \ -H "Authorization: Bearer $TOKEN" # Get a specific run curl "$BASE_URL/runs/{runId}" -H "Authorization: Bearer $TOKEN" ``` --- ## Monitoring a run ### Check status ```bash [Terminal] curl "$BASE_URL/runs/{runId}/status" -H "Authorization: Bearer $TOKEN" ``` | Status | Description | | ---------------- | --------------------------------------------- | | `SCHEDULED` | Queued, not yet started | | `RUNNING` | Actively executing | | `PAUSED` | Suspended — waiting for operator action | | `AWAITING_INPUT` | Suspended — waiting for structured data input | | `COMPLETED` | Finished successfully | | `FAILED` | Finished with an unhandled error | | `CANCELLED` | Cancelled before completion | ### Execution timeline and logs ```bash [Terminal] # Phase and step timeline with timestamps curl "$BASE_URL/runs/{runId}/timeline" -H "Authorization: Bearer $TOKEN" # Execution logs curl "$BASE_URL/runs/{runId}/logs" -H "Authorization: Bearer $TOKEN" # Artifacts produced by the run curl "$BASE_URL/runs/{runId}/artifacts" -H "Authorization: Bearer $TOKEN" ``` ### Poll until complete ```python [poll.py] import time import httpx def wait_for_run(run_id: str, headers: dict, base_url: str, poll_interval: int = 5) -> dict: terminal = {"COMPLETED", "FAILED", "CANCELLED"} while True: run = httpx.get(f"{base_url}/runs/{run_id}", headers=headers).json() print(f"[{run['statusSince']}] {run['status']}") if run["status"] in terminal: return run time.sleep(poll_interval) run = wait_for_run(run_id, headers, BASE_URL) print(f"Run finished: {run['status']}") ``` --- ## Interacting with run states Use `POST /v1/runs/{runId}/status` to drive a run externally — resume, provide input, or cancel. ### Resume a paused run ```bash [Terminal] curl -X POST "$BASE_URL/runs/{runId}/status" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"status": "RUNNING"}' ``` ### Provide input on resume When the run is in `AWAITING_INPUT`, include a `data` object keyed by phase ID: ```bash [Terminal] curl -X POST "$BASE_URL/runs/{runId}/status" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "status": "RUNNING", "data": { "quality_check": { "sample_id": "B3", "approved": true } } }' ``` ### Cancel a run ```bash [Terminal] curl -X POST "$BASE_URL/runs/{runId}/status" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"status": "CANCELLED"}' ``` ::callout{icon="i-heroicons-light-bulb"} For the workflow side — how to pause a run and wait for the resume signal from within your Python code — see [Human in the loop](https://docs.unitelabs.io/automate/guides/basic-hitl/). :: ## Next steps - [Run a workflow](https://docs.unitelabs.io/automate/guides/run-a-workflow/): trigger from the UI or UniteLabs SDK - [Deploy a workflow](https://docs.unitelabs.io/automate/guides/deploy-a-workflow/): package and register a workflow before running it # Overview ![UniteLabs Data Lake Navigation](https://docs.unitelabs.io/images/platform/platform_datalake_navigation.webp) UniteLabs provides a layered data infrastructure so workflows can ingest raw instrument files, persist structured records, and expose both to downstream analysis — without managing storage backends manually. Three components work together: Object Storage, the Data Warehouse, and File System Connectors. ## How the data layer fits together Data flows in one direction through the stack. Instruments write result files to local directories. File System Connectors detect new files via change subscriptions and forward them upstream. Object Storage (S3-compatible MinIO) holds every raw file as an immutable archive. ETL workflows — orchestrated by Prefect — pull files from Object Storage, parse them, and write structured records into the Data Warehouse (PostgreSQL). ```text Instrument output │ ▼ File System Connector ──────────────────► Object Storage (S3/MinIO) (subscribe_changes) │ ▼ ETL Workflow (Prefect) │ ▼ Data Warehouse (PostgreSQL) ``` Each layer is independently accessible — analysts can query the Warehouse directly with DBeaver, and raw files in Object Storage are always available for reprocessing. ## Components ::card{icon="i-heroicons-circle-stack" title="Object Storage"} S3-compatible file storage for raw instrument output and processing artifacts. Backed by MinIO with boto3-compatible APIs. [Learn more →](https://docs.unitelabs.io/observe/concepts/storage/) :: ::card{icon="i-heroicons-table-cells" title="Data Sources"} Managed PostgreSQL warehouse for structured experiment data. Define models with SQLModel and query from any Postgres client. [Learn more →](https://docs.unitelabs.io/observe/concepts/data-sources/) :: ::card{icon="i-heroicons-folder-open" title="File System Connector"} Monitor instrument directories in real time and trigger data pipelines on new files using the UniteLabs SDK. [Learn more →](https://docs.unitelabs.io/observe/guides/filesystem-connector/) :: ::card{icon="i-heroicons-key" title="Secrets"} Securely store and retrieve credentials used by workflows and connectors. All data components load credentials at runtime from the secrets manager. [Learn more →](https://docs.unitelabs.io/observe/concepts/secrets/) :: ## Credentials and secrets All data layer credentials are stored in the UniteLabs secrets manager — never hardcoded. Two key secrets the data layer depends on: - `datalake-admin` — MinIO access key, secret key, endpoint URL, and bucket name - `warehouse-admin` — PostgreSQL connection string for the managed warehouse See [Secrets](https://docs.unitelabs.io/observe/concepts/secrets/) for setup steps. ## A typical data pipeline Here is what happens end-to-end when a LabChip GXII Touch runs a protein analysis: 1. The instrument writes three CSV files (`PeakTable`, `SizeTable`, `WellTable`) to a local directory. 2. A File System Connector monitors that directory and emits a change event for each new file. 3. A Prefect ETL flow receives the event, uploads the raw file to Object Storage under `data/instruments/labchip/`, and records the file path in the Warehouse. 4. Once all three files for a plate are present, the flow parses them, maps wells to Benchling sample IDs, and writes structured results to the `labchip_results` table. 5. Downstream workflows and BI tools query the Warehouse directly. ::callout{icon="i-heroicons-light-bulb"} See the [Building an ETL](https://docs.unitelabs.io/observe/guides/building-an-etl/) guide for a complete working implementation of this pattern. :: # Object Storage UniteLabs provides an S3-compatible object store (MinIO) for persisting raw instrument files and processing artifacts. It is the primary landing zone for data before it is processed by ETL workflows — every raw file is archived here before anything is parsed or transformed. ## What you can do - Upload raw instrument files directly from a connector using the UniteLabs SDK - Retrieve and download files for downstream processing or reprocessing - Organize data by instrument type using path prefixes - Browse and download blobs from the UniteLabs platform UI - Access the store from any S3-compatible client or library (`boto3`, `s3cmd`, AWS CLI) ## Credentials Credentials are stored under the `datalake-admin` secret. Load them at runtime — never hardcode them in workflow code: ```python from unitelabs.sdk import AsyncApiClient async with AsyncApiClient() as client: secrets = await client.get("/secrets") datalake_secret = next(s for s in secrets if s["name"] == "datalake-admin") params = datalake_secret["parameters"] access_key = params["minio_root_user"] secret_key = params["minio_root_password"] endpoint_url = params["aws_client_parameters"]["endpoint_url"] bucket_name = params["aws_client_parameters"]["bucket_name"] ``` ::callout{icon="i-heroicons-exclamation-triangle"} Never hardcode `access_key` or `secret_key` in workflow code. Always load them from the secrets manager at runtime. :: See [Secrets](https://docs.unitelabs.io/observe/concepts/secrets/) for instructions on adding or rotating credentials. ## S3 URL format The UniteLabs SDK uses a custom `s3s://` scheme that embeds credentials directly in the URL. This is the format expected by `device.s3_service.upload_file()`: ```text s3s://ACCESS_KEY:SECRET_KEY@host:port/bucket/object-path ``` Canonical path prefixes by instrument type: | Instrument | S3 prefix | | --------------------- | ------------------------------- | | LabChip GXII Touch | `data/instruments/labchip/` | | Tecan Spark | `data/instruments/tecan_spark/` | | NanoTemper Prometheus | `data/instruments/nanotemper/` | ## Upload a file from a connector The most common write path: use the SDK's `s3_service` on a File System Connector device. The connector handles the transfer directly — no local intermediate copy needed. ```python from unitelabs.sdk import AsyncApiClient async with AsyncApiClient() as client: device = await client.get_service_by_name("File System (LabChip)") s3_url = ( f"s3s://{access_key}:{secret_key}" f"@{host}:{port}/{bucket}/data/instruments/labchip/run_001.csv" ) await device.s3_service.upload_file( path="/data/labchip/results/run_001.csv", s3_url=s3_url, ) ``` ::callout{icon="i-heroicons-light-bulb"} In production, construct the S3 URL dynamically from values loaded out of the `datalake-admin` secret. See [Building an ETL](https://docs.unitelabs.io/observe/guides/building-an-etl/) for the full pattern. :: ## Upload a file with boto3 Use this when uploading from a script or task that does not go through a connector (e.g., post-processing or reprocessing): ```python import boto3 s3 = boto3.client( "s3", endpoint_url="http://host:9000", aws_access_key_id=access_key, aws_secret_access_key=secret_key, ) s3.put_object( Bucket=bucket_name, Key="data/instruments/labchip/run_001.csv", Body=file_contents, # bytes ) ``` ## Download a file ```python response = s3.get_object( Bucket=bucket_name, Key="data/instruments/labchip/run_001.csv", ) file_contents: bytes = response["Body"].read() ``` ## Browse blobs in the UI Navigate to **Data → Object Storage** in the UniteLabs platform to browse, preview, and download stored files without writing any code. ![Open the file browser](https://docs.unitelabs.io/images/platform/platform_datalake_blob_openfilebrowser.webp) ![Browse and download blobs](https://docs.unitelabs.io/images/platform/platform_datalake_blob_browse_download.webp) ## Next steps - [File System Connector](https://docs.unitelabs.io/observe/guides/filesystem-connector/) — trigger uploads automatically when new instrument files appear - [Building an ETL](https://docs.unitelabs.io/observe/guides/building-an-etl/) — full example of upload → transform → warehouse pipeline # Secrets ![UniteLabs Secrets Management](https://docs.unitelabs.io/images/platform/platform_secrets.webp) # Overview Secrets in UniteLabs provide a secure way to store sensitive credentials—such as API keys, tokens, and passwords—that are essential for accessing external systems and instruments in your workflows. Instead of hardcoding credentials or sharing them manually, you can define and manage secrets in a centralized, encrypted environment. This helps ensure security, scalability, and repeatability when running lab automation. With UniteLabs Secrets, you can: - Store environment-specific credentials for APIs, devices, and services - Reuse secrets across multiple workflows without exposing sensitive values - Rotate or revoke credentials without changing workflow logic - Categorize secrets by type (e.g. string, JSON, connection) for structured access Secrets integrate seamlessly into the workflow engine, letting scientists and engineers focus on automation logic while maintaining compliance and security across labs. --- ## Authentication All API calls require a bearer token obtained from the UniteLabs identity provider. Replace `` with your organization's tenant ID: ::code-group ```bash [cURL] curl -X POST \ "https://auth.unitelabs.io/realms//protocol/openid-connect/token" \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "grant_type=client_credentials" \ -d "client_id=" \ -d "client_secret=" ``` ```python [Python] import httpx response = httpx.post( "https://auth.unitelabs.io/realms//protocol/openid-connect/token", data={ "grant_type": "client_credentials", "client_id": "", "client_secret": "", }, ) token = response.json()["access_token"] ``` :: Use the returned `access_token` as `Authorization: Bearer ` on every subsequent request. ::callout{icon="i-heroicons-light-bulb"} Inside workflows, use the UniteLabs SDK (`unitelabs-sdk`) — it handles token acquisition and refresh automatically. See the examples below. :: --- ## Endpoints Base URL: `http://unitelabs-api..svc` | Method | Path | Description | | -------- | ---------------------------- | ------------------------------------- | | `GET` | `/v1/secrets/types` | List available secret types | | `GET` | `/v1/secrets/schemas/{slug}` | Get the JSON schema for a secret type | | `GET` | `/v1/secrets` | List secrets (filter by name) | | `GET` | `/v1/secrets/{id}` | Get a secret by ID | | `POST` | `/v1/secrets` | Create a new secret | | `PATCH` | `/v1/secrets/{id}` | Update an existing secret | | `DELETE` | `/v1/secrets/{id}` | Delete a secret | --- ## List secret types Returns the available secret types (e.g. `string`, `json`, `aws-s3`, `postgres`). ::code-group ```bash [cURL] curl -X GET \ "http://unitelabs-api..svc/v1/secrets/types" \ -H "Authorization: Bearer " ``` ```python [Python (SDK)] from unitelabs.sdk import AsyncApiClient async with AsyncApiClient() as client: types = await client.get("/secrets/types") print(types) ``` :: --- ## Get schema for a secret type Returns the JSON schema describing the required fields for a given secret type `slug`. ::code-group ```bash [cURL] curl -X GET \ "http://unitelabs-api..svc/v1/secrets/schemas/aws-s3" \ -H "Authorization: Bearer " ``` ```python [Python (SDK)] from unitelabs.sdk import AsyncApiClient async with AsyncApiClient() as client: schema = await client.get("/secrets/schemas/aws-s3") print(schema) ``` :: --- ## Create a secret Creates a new secret. The `type` field must be a valid slug from `/v1/secrets/types`. The `parameters` object must match the schema for that type. ::code-group ```bash [cURL] curl -X POST \ "http://unitelabs-api..svc/v1/secrets" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "name": "datalake-admin", "type": "aws-s3", "parameters": { "minio_root_user": "my-access-key", "minio_root_password": "my-secret-key", "aws_client_parameters": { "endpoint_url": "http://minio.svc:9000", "bucket_name": "lab-data" } } }' ``` ```python [Python (SDK)] from unitelabs.sdk import AsyncApiClient async with AsyncApiClient() as client: secret = await client.post("/secrets", json={ "name": "datalake-admin", "type": "aws-s3", "parameters": { "minio_root_user": "my-access-key", "minio_root_password": "my-secret-key", "aws_client_parameters": { "endpoint_url": "http://minio.svc:9000", "bucket_name": "lab-data", }, }, }) print(secret["id"]) # store this ID for updates/deletes ``` :: **Response** `201 Created`: ```json { "id": "a3bb189e-8bf9-3888-9912-ace4e6543002", "name": "datalake-admin", "type": "aws-s3" } ``` --- ## List secrets Returns all secrets matching the `name` query parameter. Pass a partial name to search, or an exact name to fetch a specific secret. ::code-group ```bash [cURL] # List all secrets with a name starting with "datalake" curl -X GET \ "http://unitelabs-api..svc/v1/secrets?name=datalake" \ -H "Authorization: Bearer " ``` ```python [Python (SDK)] from unitelabs.sdk import AsyncApiClient async with AsyncApiClient() as client: secrets = await client.get("/secrets") # Find a specific secret by name datalake = next(s for s in secrets if s["name"] == "datalake-admin") params = datalake["parameters"] ``` :: **Response** `200 OK`: ```json [ { "id": "a3bb189e-8bf9-3888-9912-ace4e6543002", "name": "datalake-admin", "type": "aws-s3", "parameters": { "minio_root_user": "my-access-key", "minio_root_password": "my-secret-key", "aws_client_parameters": { "endpoint_url": "http://minio.svc:9000", "bucket_name": "lab-data" } } } ] ``` --- ## Get a secret by ID Fetch a single secret by its UUID. ::code-group ```bash [cURL] curl -X GET \ "http://unitelabs-api..svc/v1/secrets/a3bb189e-8bf9-3888-9912-ace4e6543002" \ -H "Authorization: Bearer " ``` ```python [Python (SDK)] from unitelabs.sdk import AsyncApiClient SECRET_ID = "a3bb189e-8bf9-3888-9912-ace4e6543002" async with AsyncApiClient() as client: secret = await client.get(f"/secrets/{SECRET_ID}") params = secret["parameters"] ``` :: --- ## Update a secret Partial update — only include fields you want to change. Useful for rotating credentials without recreating the secret or updating any workflow code that references it by name. ::code-group ```bash [cURL] curl -X PATCH \ "http://unitelabs-api..svc/v1/secrets/a3bb189e-8bf9-3888-9912-ace4e6543002" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "parameters": { "minio_root_password": "new-rotated-secret-key" } }' ``` ```python [Python (SDK)] from unitelabs.sdk import AsyncApiClient SECRET_ID = "a3bb189e-8bf9-3888-9912-ace4e6543002" async with AsyncApiClient() as client: updated = await client.patch(f"/secrets/{SECRET_ID}", json={ "parameters": { "minio_root_password": "new-rotated-secret-key", }, }) ``` :: **Response** `200 OK` — returns the full updated secret object. --- ## Delete a secret Permanently removes a secret. Workflows that reference it by name will fail until a replacement is created. ::code-group ```bash [cURL] curl -X DELETE \ "http://unitelabs-api..svc/v1/secrets/a3bb189e-8bf9-3888-9912-ace4e6543002" \ -H "Authorization: Bearer " ``` ```python [Python (SDK)] from unitelabs.sdk import AsyncApiClient SECRET_ID = "a3bb189e-8bf9-3888-9912-ace4e6543002" async with AsyncApiClient() as client: await client.delete(f"/secrets/{SECRET_ID}") ``` :: **Response** `200 OK`. --- ## Common use cases ### Load credentials in a workflow The most common pattern: load all secrets at the start of a Prefect task and extract the ones you need by name. ::code-group ```bash [cURL] curl -X GET \ "http://unitelabs-api..svc/v1/secrets?name=warehouse-admin" \ -H "Authorization: Bearer " ``` ```python [Python (SDK)] from unitelabs.sdk import AsyncApiClient async def load_credentials() -> dict: """Load all platform secrets and return by name.""" async with AsyncApiClient() as client: secrets = await client.get("/secrets") return {s["name"]: s["parameters"] for s in secrets} # In a Prefect task or flow async def my_task(): creds = await load_credentials() # S3/MinIO credentials s3 = creds["datalake-admin"] access_key = s3["minio_root_user"] secret_key = s3["minio_root_password"] endpoint = s3["aws_client_parameters"]["endpoint_url"] # Warehouse credentials db = creds["warehouse-admin"] db_url = ( f"postgresql+asyncpg://{db['username']}:{db['password']}" f"@{db['host']}:{db['port']}/{db['database']}" ) ``` :: ### Rotate a credential without touching workflow code When a key is compromised or expires, update only the secret value. Every workflow that loads credentials at runtime will pick up the new value on its next run — no code changes required. ::code-group ```bash [cURL] # 1. Find the secret ID curl -X GET \ "http://unitelabs-api..svc/v1/secrets?name=benchling-credentials" \ -H "Authorization: Bearer " # 2. Update only the API key field curl -X PATCH \ "http://unitelabs-api..svc/v1/secrets/" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{"parameters": {"api_key": ""}}' ``` ```python [Python (SDK)] from unitelabs.sdk import AsyncApiClient async def rotate_api_key(secret_name: str, new_key: str) -> None: async with AsyncApiClient() as client: # Find the secret by name secrets = await client.get("/secrets") target = next(s for s in secrets if s["name"] == secret_name) # Patch only the changed field await client.patch(f"/secrets/{target['id']}", json={ "parameters": {"api_key": new_key}, }) print(f"Rotated '{secret_name}' successfully") # asyncio.run(rotate_api_key("benchling-credentials", "sk-new-key-...")) ``` :: ### Bootstrap secrets for a new environment Create all required secrets for a fresh deployment in one script: ::code-group ```bash [cURL] curl -X POST \ "http://unitelabs-api..svc/v1/secrets" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "name": "warehouse-admin", "type": "postgres", "parameters": { "host": "postgres.svc", "port": 5432, "database": "unitelabs", "username": "warehouse_user", "password": "secure-password", "schema_name": "warehouse" } }' ``` ```python [Python (SDK)] from unitelabs.sdk import AsyncApiClient SECRETS = [ { "name": "datalake-admin", "type": "aws-s3", "parameters": { "minio_root_user": "access-key", "minio_root_password": "secret-key", "aws_client_parameters": { "endpoint_url": "http://minio.svc:9000", "bucket_name": "lab-data", }, }, }, { "name": "warehouse-admin", "type": "postgres", "parameters": { "host": "postgres.svc", "port": 5432, "database": "unitelabs", "username": "warehouse_user", "password": "secure-password", "schema_name": "warehouse", }, }, ] async def bootstrap_secrets() -> None: async with AsyncApiClient() as client: existing = await client.get("/secrets") existing_names = {s["name"] for s in existing} for secret in SECRETS: if secret["name"] in existing_names: print(f"Skipping '{secret['name']}' — already exists") continue await client.post("/secrets", json=secret) print(f"Created '{secret['name']}'") ``` :: --- ## Next steps - [Object Storage](https://docs.unitelabs.io/observe/concepts/storage/) — uses the `datalake-admin` secret for MinIO access - [Data Sources](https://docs.unitelabs.io/observe/concepts/data-sources/) — uses the `warehouse-admin` secret for PostgreSQL - [Building an ETL](https://docs.unitelabs.io/observe/guides/building-an-etl/) — loads both secrets at flow startup # Data sources The UniteLabs Data Warehouse is a managed PostgreSQL database for structured experiment data. Workflows write records using the `WarehouseClient` SDK — the same data is immediately queryable from external tools like DBeaver, Jupyter, or BI dashboards. ## What you can do - Define typed data models and create tables using SQLModel - Write individual records or bulk-insert DataFrames from workflow tasks - Query, filter, and upsert records by primary key or arbitrary field values - Connect any PostgreSQL-compatible client using the connection string from your secrets - Browse table contents in the platform UI or DBeaver without writing SQL ## Credentials The warehouse connection string is stored in the `warehouse-admin` secret: ```python from unitelabs.sdk import AsyncApiClient async with AsyncApiClient() as client: secrets = await client.get("/secrets") warehouse_secret = next(s for s in secrets if s["name"] == "warehouse-admin") params = warehouse_secret["parameters"] # postgresql+asyncpg://user:password@host:port/database async_url = ( f"postgresql+asyncpg://{params['username']}:{params['password']}" f"@{params['host']}:{params['port']}/{params['database']}" ) ``` All tables live in the `warehouse` schema. The `WarehouseClient` sets `search_path = warehouse` automatically per session. ## Define a data model Models are plain Python classes that extend `SQLModel` with `table=True`. Keep them in a shared `models.py` so they are importable by both flows and query scripts: ```python [models.py] from datetime import datetime from typing import Any import sqlalchemy as sa from sqlmodel import Field, SQLModel class SourceFiles(SQLModel, table=True): __tablename__ = "source_files" id: int | None = Field(default=None, primary_key=True) file_path: str s3_object_path: str connector_name: str instrument_type: str file_type: str | None = None group_identifier: str | None = None file_metadata: dict[str, Any] | None = Field( default=None, sa_type=sa.JSON ) updated_at: datetime ``` Call `await warehouse.ensure_tables_exist()` once on startup to create any missing tables. ## Write and query records `WarehouseClient` exposes four methods for common operations: ::code-group ```python [Create] from datetime import datetime, timezone record = SourceFiles( file_path="/data/labchip/results/run_001.csv", s3_object_path="data/instruments/labchip/run_001.csv", connector_name="File System (LabChip)", instrument_type="labchip", updated_at=datetime.now(tz=timezone.utc), ) created = await warehouse.create_record(record) ``` ```python [Query] # Fetch all LabChip files records: list[SourceFiles] = await warehouse.query_records( SourceFiles, instrument_type="labchip", ) # Fetch a single record by path record = await warehouse.get_record( SourceFiles, file_path="/data/labchip/results/run_001.csv", ) ``` ```python [Upsert] # Insert or update on file_path conflict await warehouse.upsert_record( SourceFiles( file_path="/data/labchip/results/run_001.csv", s3_object_path="data/instruments/labchip/run_001.csv", connector_name="File System (LabChip)", instrument_type="labchip", updated_at=datetime.now(tz=timezone.utc), ) ) ``` ```python [Bulk insert] import pandas as pd df = pd.read_csv("labchip_results.csv") await warehouse.bulk_insert_df( table_name="labchip_results", df=df, conflict_columns=["run_id", "plate_barcode", "well"], update_columns=["sample_id", "concentration", "purity_percent"], ) ``` :: ## Connect with DBeaver DBeaver is the recommended GUI for ad-hoc queries and data exploration. Use the connection string from the platform UI to set it up. **Step 1.** In the UniteLabs platform, go to **Data → Warehouse** and click **Copy connection string**: ![Copy the warehouse connection string](https://docs.unitelabs.io/images/platform/platform_datalake_dwh_copyconnection.webp) **Step 2.** In DBeaver, create a new PostgreSQL connection and paste the URL into the connection field: ![Paste the connection string in DBeaver](https://docs.unitelabs.io/images/platform/platform_datalake_dwh_pasteconnection.webp) **Step 3.** Configure the connection settings and click **Test Connection**: ![PostgreSQL connection settings in DBeaver](https://docs.unitelabs.io/images/platform/platform_datalake_dwh_dbeaver_postgresconnectionsettings.webp) **Step 4.** Expand the **warehouse** schema to browse tables and run SQL: ![View table data in DBeaver](https://docs.unitelabs.io/images/platform/platform_datalake_dwh_dbeaver_dataview.webp) ## Browse objects in the UI Navigate to **Data → Warehouse** in the platform to view all tables and record counts without leaving the browser: ![List warehouse objects in the platform](https://docs.unitelabs.io/images/platform/platform_datalake_dwh_listobjects.webp) ## Next steps - [Building an ETL](https://docs.unitelabs.io/observe/guides/building-an-etl/) — write to the Warehouse from a Prefect flow - [Secrets](https://docs.unitelabs.io/observe/concepts/secrets/) — manage warehouse credentials securely # File System Connector File System Connectors give workflows programmatic access to directories on lab instruments. Using the UniteLabs SDK, you can list files, stream real-time change notifications, retrieve file metadata, and upload files to Object Storage — all without touching the instrument OS directly. **Prerequisites** - A running File System Connector visible in the **Connectors** view of the platform. - A UniteLabs account with SDK access. - `unitelabs-sdk` installed in your Python environment. ## Connect to a connector ```python from unitelabs.sdk import AsyncApiClient async with AsyncApiClient() as client: device = await client.get_service_by_name("File System (LabChip)") ``` ::callout{icon="i-heroicons-light-bulb"} The connector name must match exactly what appears in the **Connectors** view. Names are case-sensitive. :: ## List available folders Connectors expose only pre-approved directories to prevent arbitrary filesystem access. Use `get_folder_whitelist()` to retrieve the allowed set: ```python whitelist = await device.folder_service.get_folder_whitelist() print(whitelist) # ['/data/labchip/results', '/data/labchip/archive'] ``` ## List files in a folder ```python files = await device.folder_service.get_folder_contents( path="/data/labchip/results" ) for f in files: print(f) # /data/labchip/results/run_001_PeakTable.csv # /data/labchip/results/run_001_SizeTable.csv # /data/labchip/results/run_001_WellTable.csv ``` ## Get file metadata Retrieve size, owner, and timestamps before deciding whether to process a file: ```python meta = await device.file_service.get_file_metadata( path="/data/labchip/results/run_001_PeakTable.csv" ) print(meta["Metadata"]["Size"]["value"]) # file size in bytes print(meta["Metadata"]["LastModified"]["value"]) # ISO timestamp print(meta["Metadata"]["Owner"]["value"]) # OS user ("unknown" on Windows) ``` ## Watch for new files `subscribe_changes()` is the key real-time capability. It returns an async context manager that yields the path of each new or modified file as it appears: ```python async with AsyncApiClient() as client: device = await client.get_service_by_name("File System (LabChip)") async with await device.folder_service.subscribe_changes() as subscription: async for changed_path in subscription: print(f"New or modified file: {changed_path}") # trigger processing here ``` ::callout{icon="i-heroicons-exclamation-triangle"} `subscribe_changes()` is a long-running async generator. Run it inside a Prefect `@task` or a dedicated async loop — do not call it in a blocking synchronous context. :: ## Upload a file to Object Storage Transfer a file from the instrument directory directly to S3 — no local intermediate copy: ```python s3_url = ( f"s3s://{access_key}:{secret_key}" f"@{host}:{port}/{bucket}/data/instruments/labchip/run_001_PeakTable.csv" ) await device.s3_service.upload_file( path="/data/labchip/results/run_001_PeakTable.csv", s3_url=s3_url, ) ``` Construct the S3 URL dynamically from values loaded out of the `datalake-admin` secret. See [Object Storage](https://docs.unitelabs.io/observe/concepts/storage/) for the URL format and credential loading pattern. ## Putting it together A complete async function that combines folder listing, change watching, and S3 upload: ```python from unitelabs.sdk import AsyncApiClient async def monitor_and_upload(connector_name: str, s3_base_url: str) -> None: """Watch a connector for new files and upload each one to Object Storage.""" async with AsyncApiClient() as client: device = await client.get_service_by_name(connector_name) whitelist = await device.folder_service.get_folder_whitelist() print(f"Watching {whitelist[0]} on '{connector_name}'") async with await device.folder_service.subscribe_changes() as sub: async for file_path in sub: meta = await device.file_service.get_file_metadata(path=file_path) size = meta["Metadata"]["Size"]["value"] print(f"Detected: {file_path} ({size} bytes)") filename = file_path.split("/")[-1] s3_url = f"{s3_base_url}/{filename}" await device.s3_service.upload_file(path=file_path, s3_url=s3_url) print(f"Uploaded to: {s3_url}") ``` ## Next steps - [Object Storage](https://docs.unitelabs.io/observe/concepts/storage/) — understand the S3 URL scheme, bucket layout, and credentials - [Building an ETL](https://docs.unitelabs.io/observe/guides/building-an-etl/) — embed this pattern in a production Prefect flow with startup reconciliation and Warehouse writes # Building an ETL This guide walks through building a production-ready ETL pipeline that monitors a LabChip GXII Touch instrument directory, archives raw files to Object Storage, and writes structured records to the Data Warehouse. The same pattern applies to any File System Connector and any instrument type. **Prerequisites** - A running File System Connector (e.g., `"File System (LabChip demo)"`). - `datalake-admin` and `warehouse-admin` secrets configured in UniteLabs. See [Secrets](https://docs.unitelabs.io/observe/concepts/secrets/). - Python packages: `prefect>=3.0`, `unitelabs-sdk`, `sqlmodel`, `boto3`, `pandas`, `asyncpg`. ## Architecture The pipeline uses three sequential stages inside a single Prefect `@flow`: 1. **Reconcile** — on startup, compare the connector's current file list against Warehouse records to detect files that were written while the pipeline was offline. 2. **Process backlog** — archive and register every reconciled file that has not yet been stored. 3. **Watch in real time** — subscribe to change events and process new files as they arrive. ::callout{icon="i-heroicons-light-bulb"} This three-stage pattern ensures no files are skipped even if the workflow was offline when the instrument wrote results. :: ## Define the data model Define a `SourceFiles` model to track every file the pipeline has seen. Keep it in a shared `models.py` so both the flow and any query scripts can import it: ```python [models.py] from datetime import datetime from typing import Any import sqlalchemy as sa from sqlmodel import Field, SQLModel class SourceFiles(SQLModel, table=True): __tablename__ = "source_files" id: int | None = Field(default=None, primary_key=True) file_path: str # Original path on the connector s3_object_path: str # Path inside the S3 bucket connector_name: str instrument_type: str file_metadata: dict[str, Any] | None = Field( default=None, sa_type=sa.JSON ) updated_at: datetime ``` ## Task: register a source file Write a single file record to the Warehouse. Using `upsert_record` with `file_path` as the conflict key makes the task safe to retry: ```python from datetime import datetime, timezone from prefect import task @task(name="Register Source File") async def register_source_file_task( file_path: str, s3_object_path: str, connector_name: str, instrument_type: str, warehouse, ) -> SourceFiles: record = SourceFiles( file_path=file_path, s3_object_path=s3_object_path, connector_name=connector_name, instrument_type=instrument_type, updated_at=datetime.now(tz=timezone.utc), ) await warehouse.upsert_record(record) return record ``` ## Task: archive to Object Storage Upload the raw file from the connector directly to S3. The transfer happens on the connector side — no data passes through the workflow host: ```python from unitelabs.sdk import AsyncApiClient @task(name="Archive to Object Storage") async def archive_file_task( connector_name: str, file_path: str, s3_url: str, ) -> str: async with AsyncApiClient() as client: device = await client.get_service_by_name(connector_name) await device.s3_service.upload_file(path=file_path, s3_url=s3_url) return s3_url ``` ## Task: reconcile missed files On startup, list all files currently visible to the connector and cross-reference against Warehouse records. Returns only files not yet registered: ```python @task(name="Reconcile Connector Files") async def reconcile_connector_files_task( connector_name: str, instrument_type: str, warehouse, ) -> dict: async with AsyncApiClient() as client: device = await client.get_service_by_name(connector_name) whitelist = await device.folder_service.get_folder_whitelist() all_files: list[str] = [] for folder in whitelist: files = await device.folder_service.get_folder_contents(path=folder) all_files.extend(files) existing = await warehouse.query_records( SourceFiles, instrument_type=instrument_type, ) existing_paths = {r.file_path for r in existing} reconciled = [f for f in all_files if f not in existing_paths] return { "reconciled_files": reconciled, "total_on_disk": len(all_files), } ``` ## Task: watch for new files A long-running task that yields each new file event as it arrives from the connector: ```python @task(name="Watch for New Files") async def watch_for_new_files_task(connector_name: str): async with AsyncApiClient() as client: device = await client.get_service_by_name(connector_name) async with await device.folder_service.subscribe_changes() as sub: async for file_path in sub: yield {"file_path": file_path} ``` ## The flow: wiring it together The `@flow` function runs all three stages in order. Stages 2 and 3 call the same archive + register tasks so the logic is never duplicated: ```python from unitelabs.sdk import AsyncApiClient from prefect import flow, get_run_logger @flow(log_prints=True, name="LabChip GXII Touch ETL Pipeline") async def labchip_etl_flow( connector_name: str = "File System (LabChip demo)", instrument_type: str = "labchip", s3_bucket_prefix: str = "data/instruments/labchip", ) -> None: logger = get_run_logger() # Load S3 credentials from secrets async with AsyncApiClient() as client: secrets = await client.get("/secrets") datalake = next(s for s in secrets if s["name"] == "datalake-admin") p = datalake["parameters"] s3_base = ( f"s3s://{p['minio_root_user']}:{p['minio_root_password']}" f"@{p['aws_client_parameters']['endpoint_url'].replace('http://', '')}" f"/{p['aws_client_parameters']['bucket_name']}/{s3_bucket_prefix}" ) warehouse = get_warehouse_client() await warehouse.ensure_tables_exist() # ── Stage 1: Reconcile files missed while offline ────────────────────── logger.info("Stage 1: Reconciling missed files") reconciliation = await reconcile_connector_files_task( connector_name=connector_name, instrument_type=instrument_type, warehouse=warehouse, ) logger.info( f"Found {len(reconciliation['reconciled_files'])} unregistered files " f"out of {reconciliation['total_on_disk']} on disk" ) # ── Stage 2: Process the backlog ─────────────────────────────────────── for file_path in reconciliation["reconciled_files"]: filename = file_path.split("/")[-1] s3_url = f"{s3_base}/{filename}" await archive_file_task( connector_name=connector_name, file_path=file_path, s3_url=s3_url, ) await register_source_file_task( file_path=file_path, s3_object_path=f"{s3_bucket_prefix}/{filename}", connector_name=connector_name, instrument_type=instrument_type, warehouse=warehouse, ) # ── Stage 3: Real-time monitoring ────────────────────────────────────── logger.info("Stage 3: Watching for new files in real time") async for change in watch_for_new_files_task(connector_name): file_path = change["file_path"] filename = file_path.split("/")[-1] s3_url = f"{s3_base}/{filename}" logger.info(f"New file detected: {file_path}") await archive_file_task( connector_name=connector_name, file_path=file_path, s3_url=s3_url, ) await register_source_file_task( file_path=file_path, s3_object_path=f"{s3_bucket_prefix}/{filename}", connector_name=connector_name, instrument_type=instrument_type, warehouse=warehouse, ) ``` ## Run the flow ```bash uv run python -c " import asyncio from my_etl.flows.labchip import labchip_etl_flow asyncio.run(labchip_etl_flow()) " ``` ## Deploy with Prefect Deploy the flow as a Prefect deployment so it restarts automatically on failure and appears in the Prefect UI for monitoring. See the [Deploy a workflow](https://docs.unitelabs.io/automate/guides/deploy-a-workflow/) guide for the manifest format and deployment commands. ## Verify in the Warehouse After running the flow, query the `source_files` table to confirm records were written: ```sql SELECT file_path, instrument_type, updated_at FROM warehouse.source_files WHERE instrument_type = 'labchip' ORDER BY updated_at DESC LIMIT 20; ``` ::callout{icon="i-heroicons-magnifying-glass"} See [Data Sources](https://docs.unitelabs.io/observe/concepts/data-sources/) for instructions on connecting DBeaver to the warehouse so you can browse tables without writing SQL. :: ## Next steps - [Object Storage](https://docs.unitelabs.io/observe/concepts/storage/) — understand how S3 URLs are constructed and how to browse raw files - [File System Connector](https://docs.unitelabs.io/observe/guides/filesystem-connector/) — full reference for all SDK connector operations - [Data Sources](https://docs.unitelabs.io/observe/concepts/data-sources/) — query the Warehouse from DBeaver or any PostgreSQL client