diff --git a/docs/_static/devices.json b/docs/_static/devices.json index c6ec378ff72..16eade89a54 100644 --- a/docs/_static/devices.json +++ b/docs/_static/devices.json @@ -542,6 +542,22 @@ "manager": "https://discuss.pylabrobot.org/u/rickwierenga", "oem": "https://www.hettweb.com/products/sbs300robotic/" }, + { + "id": "highres-ambistore", + "vendor": "HighRes Biosolutions", + "name": "AmbiStore", + "kind": "storage", + "capabilities": [ + "storage" + ], + "status": "wip", + "api": "pylabrobot.high_res.sample_storage.AmbiStore", + "api_version": "v1", + "code_slug": "high_res/sample_storage", + "doc_slug": "high_res/sample-storage/ambistore/hello-world", + "manager": "https://discuss.pylabrobot.org/u/rickwierenga", + "oem": "https://www.highres.com/lab-instruments/sample-storage" + }, { "id": "highres-lid-valet", "vendor": "HighRes Biosolutions", @@ -573,6 +589,41 @@ "manager": "https://discuss.pylabrobot.org/u/rickwierenga", "oem": "https://www.highres.com/lab-instruments/microspin-centrifuge" }, + { + "id": "highres-steristore", + "vendor": "HighRes Biosolutions", + "name": "SteriStore", + "kind": "storage", + "capabilities": [ + "storage", + "heating", + "active cooling" + ], + "status": "full", + "api": "pylabrobot.high_res.sample_storage.SteriStore", + "api_version": "v1", + "code_slug": "high_res/sample_storage", + "doc_slug": "high_res/sample-storage/steristore/hello-world", + "manager": "https://discuss.pylabrobot.org/u/rickwierenga", + "oem": "https://www.highres.com/lab-instruments/sample-storage" + }, + { + "id": "highres-tundrastore", + "vendor": "HighRes Biosolutions", + "name": "TundraStore", + "kind": "storage", + "capabilities": [ + "storage", + "active cooling" + ], + "status": "wip", + "api": "pylabrobot.high_res.sample_storage.TundraStore", + "api_version": "v1", + "code_slug": "high_res/sample_storage", + "doc_slug": "high_res/sample-storage/tundrastore/hello-world", + "manager": "https://discuss.pylabrobot.org/u/rickwierenga", + "oem": "https://www.highres.com/lab-instruments/sample-storage" + }, { "id": "inheco-cpac", "vendor": "Inheco", diff --git a/docs/api/pylabrobot.high_res.rst b/docs/api/pylabrobot.high_res.rst index 9b66cf4eea1..7971a6c5443 100644 --- a/docs/api/pylabrobot.high_res.rst +++ b/docs/api/pylabrobot.high_res.rst @@ -12,3 +12,16 @@ pylabrobot.high_res package HighResLidValet HighResLidValetError + +.. currentmodule:: pylabrobot.high_res.sample_storage + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + + AmbiStore + EnvironmentControl + SteriStore + TundraStore + UnresolvedPlateTransfer + high_res_stacker diff --git a/docs/contributor_guide/event-schemas.md b/docs/contributor_guide/event-schemas.md index 337d48d9d52..ebb268644ae 100644 --- a/docs/contributor_guide/event-schemas.md +++ b/docs/contributor_guide/event-schemas.md @@ -82,6 +82,9 @@ operations. When emitted inside a semantic operation, they inherit its event con | `timeout` | `float` | Requested timeout in PLR's default time unit. | | `target_temperature` | `float` | Configured or requested controller target temperature. | | `current_temperature` | `float` | Controller sensor reading observed by the operation. It is not a resource-temperature measurement unless explicitly documented otherwise. | +| `target_humidity` | `float` | Requested relative-humidity setpoint as a fraction from 0 to 1. | +| `target_co2` | `float` | Requested CO2 concentration as a fraction from 0 to 1. | +| `target_o2` | `float` | Requested O2 concentration as a fraction from 0 to 1. | | `tolerance` | `float` | Allowed temperature difference in PLR's default temperature unit. | | `volume` | `float` | Requested liquid volume in PLR's default volume unit. | @@ -102,6 +105,9 @@ Use these names consistently across operation families: | Requested elapsed time | `duration` | `time`, `duration_s`, `duration_sec`, `seconds` | | Maximum wait | `timeout` | `timeout_s`, `wait_time` | | Requested thermal setpoint | `target_temperature` | `temperature_target`, `set_temperature`, `target_temperature_c` | +| Requested relative humidity | `target_humidity` | `humidity`, `humidity_pct`, `relative_humidity` | +| Requested CO2 concentration | `target_co2` | `co2`, `co2_pct`, `co2_fraction` | +| Requested O2 concentration | `target_o2` | `o2`, `o2_pct`, `o2_fraction` | | Observed controller temperature | `current_temperature` | `actual_temperature`, `measured_temperature`, `current_temperature_c` | | Temperature acceptance range | `tolerance` | `temperature_tolerance`, `tolerance_c` | | Relative centrifugal force | `relative_centrifugal_force` | `g`, `g_force`, `rcf` | @@ -141,6 +147,7 @@ These are state-transition records rather than semantic operation lifecycles. | --- | --- | --- | | `incubator.fetch_plate` | `device`, `resources`, `source`, `destination` | `resources` contains the directly moved plate; endpoints describe the storage site and loading tray when known. | | `incubator.take_in_plate` | `device`, `resources`, `source`, `destination` | Moves the loading-tray plate into storage. A requested selector such as `"random"` or `"smallest"` may identify an unresolved destination at invocation. | +| `incubator.transfer_plate` | `device`, `resources`, `source`, `destination` | Moves a plate directly between two transfer nests or other incubator endpoints. | ### Stackers @@ -201,7 +208,7 @@ record per channel with `channel` and direct `resource` fields. | `liquid_handler.tip_pickup_96` | `device`, `resources` | Direct resource is the operated `TipRack`. | | `liquid_handler.tip_drop_96` | `device`, `resources` | Direct resource is the destination `TipRack` or `Trash`. | -## Shaking and temperature control +## Shaking and environmental control Controllers that are `ResourceHolder`s include their directly loaded resource in `resources` when one is assigned at operation start. @@ -211,9 +218,19 @@ one is assigned at operation start. | `shaker.shake` | `device`, optional `resources`, `speed_rpm`, optional `duration` | Omitted `duration` means shaking continues after the call returns. | | `shaker.stop_shaking` | `device`, optional `resources` | Explicitly stops an indefinite shake. | | `temperature_controller.set_temperature` | `device`, optional `resources`, `target_temperature`, `passive` | Records the requested target and cooling policy. | +| `temperature_controller.activate` | `device`, optional `resources` | Starts active temperature control at the configured setpoint. | | `temperature_controller.wait_for_temperature` | `device`, optional `resources`, `target_temperature`, `timeout`, `tolerance`; **completed only:** `current_temperature` | `current_temperature` is the final controller reading that satisfied tolerance. | | `temperature_controller.hold_temperature` | `device`, optional `resources`, `duration`, optional `target_temperature` | Records a requested dwell without reissuing a setpoint or asserting that a resource reached temperature. | | `temperature_controller.deactivate` | `device`, optional `resources`, optional `target_temperature` | Stops active temperature control. | +| `humidity_controller.set_humidity` | `device`, optional `resources`, `target_humidity` | Records the requested relative-humidity fraction. | +| `humidity_controller.activate` | `device`, optional `resources` | Starts active humidity control at the configured setpoint. | +| `humidity_controller.deactivate` | `device`, optional `resources` | Stops active humidity control. | +| `co2_controller.set_co2` | `device`, optional `resources`, `target_co2` | Records the requested CO2 fraction. | +| `co2_controller.activate` | `device`, optional `resources` | Starts active CO2 control at the configured setpoint. | +| `co2_controller.deactivate` | `device`, optional `resources` | Stops active CO2 control. | +| `o2_controller.set_o2` | `device`, optional `resources`, `target_o2` | Records the requested O2 fraction. | +| `o2_controller.activate` | `device`, optional `resources` | Starts active O2 control at the configured setpoint. | +| `o2_controller.deactivate` | `device`, optional `resources` | Stops active O2 control. | ## Centrifugation diff --git a/docs/user_guide/high_res/index.md b/docs/user_guide/high_res/index.md index c2d180c7347..27033a36794 100644 --- a/docs/user_guide/high_res/index.md +++ b/docs/user_guide/high_res/index.md @@ -4,4 +4,5 @@ :maxdepth: 1 lid-valet/hello-world +sample-storage/index ``` diff --git a/docs/user_guide/high_res/sample-storage/ambistore/hello-world.ipynb b/docs/user_guide/high_res/sample-storage/ambistore/hello-world.ipynb new file mode 100644 index 00000000000..a6953c9dbf5 --- /dev/null +++ b/docs/user_guide/high_res/sample-storage/ambistore/hello-world.ipynb @@ -0,0 +1,340 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "intro", + "metadata": {}, + "source": [ + "# HighRes AmbiStore\n", + "\n", + "```{device-card} highres-ambistore\n", + "```\n", + "\n", + "| Property | Value |\n", + "| --- | --- |\n", + "| Model | AmbiStore |\n", + "| Transport | TCP, port 1000 |\n", + "| Environment | Ambient |\n", + "| Driver support | Work in progress; not hardware-verified |\n", + "\n", + "```{warning}\n", + "AmbiStore support has not been verified against AmbiStore hardware. Run motion only in a controlled setup and report verified behavior.\n", + "```" + ] + }, + { + "cell_type": "markdown", + "id": "protocol", + "metadata": {}, + "source": [ + "## How it talks\n", + "\n", + "The store exposes a line-oriented TCP service. Each command receives an acknowledgement, optional data, and one completion status. The driver validates the echoed command and command ID and closes the connection if a timeout or malformed response makes the stream unsafe to reuse." + ] + }, + { + "cell_type": "markdown", + "id": "physical-setup", + "metadata": {}, + "source": [ + "## Physical setup\n", + "\n", + "Connect the store to a dedicated Ethernet interface. Supply clean dry air above 80 psi before homing or moving pneumatic doors. The factory service is normally `192.168.127.60:1000` and also answers at `10.253.253.253:1000`. Give the host interface an address on the corresponding isolated subnet, without a default gateway.\n", + "\n", + "The example inventory below represents one known plate physically present in stacker 1, slot 1. It builds all 24 slots from example `getstackerdimensions` values. Replace the rack count, zero offsets, slot heights, and slot counts with the layout reported by the actual machine before running motion." + ] + }, + { + "cell_type": "markdown", + "id": "inventory-text", + "metadata": {}, + "source": [ + "## Describe the physical inventory\n", + "\n", + "`high_res_stacker` uses the measured HighRes stacker footprint and creates one holder per reported slot. The `racks` mapping keys are the physical stacker numbers reported by the device, which may be sparse. Carrier spots are zero-based in PLR and map to one-based device slots. The reported slot height is also the physical safety constraint used when selecting a destination." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "inventory-code", + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.high_res.sample_storage import AmbiStore, high_res_stacker\n", + "from pylabrobot.resources import Coordinate, Plate, Well\n", + "\n", + "rack = high_res_stacker(\n", + " name=\"stacker_1\",\n", + " zero_offset=0.0,\n", + " slot_height=22.867,\n", + " slot_count=24,\n", + ")\n", + "site = rack.sites[0]\n", + "well = Well(name=\"A1\", size_x=8, size_y=8, size_z=10)\n", + "well.location = Coordinate(10, 10, 2)\n", + "plate = Plate(\n", + " name=\"plate_1\",\n", + " size_x=127.76,\n", + " size_y=85.48,\n", + " size_z=14,\n", + " ordered_items={\"A1\": well},\n", + ")\n", + "site.assign_child_resource(plate)\n", + "racks = {1: rack}" + ] + }, + { + "cell_type": "markdown", + "id": "connect-text", + "metadata": {}, + "source": [ + "## Connect\n", + "\n", + "`setup()` connects, reads version and environmental information where applicable, and discovers the actual transfer nests. It does not home unless `home=True` is passed." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "connect-code", + "metadata": {}, + "outputs": [], + "source": [ + "store = AmbiStore(host=\"192.168.127.60\", name=\"ambistore\", racks=racks)\n", + "await store.setup()" + ] + }, + { + "cell_type": "markdown", + "id": "version-text", + "metadata": {}, + "source": [ + "## Read version information\n", + "\n", + "Confirm that the expected device answered." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "version-code", + "metadata": {}, + "outputs": [], + "source": [ + "version = await store.request_version()\n", + "print(version)" + ] + }, + { + "cell_type": "markdown", + "id": "nests-text", + "metadata": {}, + "source": [ + "## Inspect transfer nests\n", + "\n", + "Compare the live sensor report with `store.nests`. Assign a `Plate` resource to any physically occupied nest before transfer operations." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "nests-code", + "metadata": {}, + "outputs": [], + "source": [ + "nest_status = await store.request_nest_status()\n", + "print(nest_status)" + ] + }, + { + "cell_type": "markdown", + "id": "fetch-text", + "metadata": {}, + "source": [ + "## Fetch a plate\n", + "\n", + "This example moves the known plate from stacker 1, slot 1 to the first transfer nest. The driver requires that nest's live sensor to report clear." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "fetch-code", + "metadata": {}, + "outputs": [], + "source": [ + "plate = await store.fetch_plate_to_loading_tray(\"plate_1\", tray_index=0)" + ] + }, + { + "cell_type": "markdown", + "id": "nest-transfer-text", + "metadata": {}, + "source": [ + "## Transfer between nests\n", + "\n", + "If the machine reports at least two nests, move the plate from the first to the second. Both live sensors and PLR bookkeeping are validated before motion." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "nest-transfer-code", + "metadata": {}, + "outputs": [], + "source": [ + "plate = await store.transfer_plate_between_nests(\n", + " source_tray_index=0, destination_tray_index=1\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "take-in-text", + "metadata": {}, + "source": [ + "## Return the plate to storage\n", + "\n", + "Move the plate from the second nest back to the smallest free site that is tall enough for it." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "take-in-code", + "metadata": {}, + "outputs": [], + "source": [ + "plate = await store.take_in_plate(tray_index=1, site=\"smallest\")" + ] + }, + { + "cell_type": "markdown", + "id": "barcode-text", + "metadata": {}, + "source": [ + "## Scan a barcode\n", + "\n", + "Barcode scans require every transfer nest to be physically clear. `EMPTY` means no readable barcode, not necessarily no plate." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "barcode-code", + "metadata": {}, + "outputs": [], + "source": [ + "barcodes = await store.request_stacker_barcodes(1, slot=1)\n", + "print(barcodes)" + ] + }, + { + "cell_type": "markdown", + "id": "open-doors-text", + "metadata": {}, + "source": [ + "## Open robot doors\n", + "\n", + "Ensure the carousel and automation interface are clear. If firmware reports an error, the driver waits for every robot door to reach the final open state." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "open-doors-code", + "metadata": {}, + "outputs": [], + "source": [ + "await store.open_all_doors()" + ] + }, + { + "cell_type": "markdown", + "id": "close-doors-text", + "metadata": {}, + "source": [ + "## Close robot doors\n", + "\n", + "Close and reseal the robot-access doors." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "close-doors-code", + "metadata": {}, + "outputs": [], + "source": [ + "await store.close_all_doors()" + ] + }, + { + "cell_type": "markdown", + "id": "park-text", + "metadata": {}, + "source": [ + "## Verify or recover the parked state\n", + "\n", + "Recovery refuses to move while the spatula sensor reports a plate. Otherwise it retracts and homes only when the store is not safely parked. Recovery proves that the mechanism is parked, but it cannot prove where a plate ended up after a timeout, cancellation, abort, or motion fault. In that case `store.unresolved_transfer` remains set and further plate moves are blocked. Inspect its source and destination, inspect the machine, then call `await store.resolve_unresolved_transfer(\"source\")`, `await store.resolve_unresolved_transfer(\"destination\")`, or `await store.resolve_unresolved_transfer(\"unassigned\")`. Nest choices are checked against live sensors; selecting a stacker endpoint is operator confirmation because stacker slots have no non-destructive presence sensor." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "park-code", + "metadata": {}, + "outputs": [], + "source": [ + "if not await store.request_is_parked():\n", + " recovered = await store.recover()\n", + " if not recovered:\n", + " raise RuntimeError(\"Store did not recover to a parked state\")" + ] + }, + { + "cell_type": "markdown", + "id": "stop-text", + "metadata": {}, + "source": [ + "## Disconnect\n", + "\n", + "Close the TCP connection when the workflow is finished." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "stop-code", + "metadata": {}, + "outputs": [], + "source": [ + "await store.stop()" + ] + }, + { + "cell_type": "markdown", + "id": "reference", + "metadata": {}, + "source": [ + "## Reference\n", + "\n", + "See the [sample-storage overview](../index.md) and [event reference](../events.md) for the full API." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.10" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/user_guide/high_res/sample-storage/events.md b/docs/user_guide/high_res/sample-storage/events.md new file mode 100644 index 00000000000..6483d7ae8f1 --- /dev/null +++ b/docs/user_guide/high_res/sample-storage/events.md @@ -0,0 +1,132 @@ +# Sample-storage events + +HighRes sample stores emit structured events for plate transfers and changes to environmental +control. Event observation is optional and does not change device behavior. + +Every operation listed below emits one of these correlated lifecycle sequences when an active +EventBus has a subscriber: + +```text +.started -> .completed +.started -> .failed +``` + +The two records share `context.operation_id`. A failed record also contains `error_type` and +`error_message`. + +## Quickstart + +Subscribe after setting up the store, then wrap the operations that should be observed: + +```python +from pylabrobot.events import EventBus, use_event_bus + +event_bus = EventBus() +event_bus.subscribe(lambda event: print(event.as_dict())) + +await store.setup() + +with use_event_bus(event_bus): + await store.fetch_plate_to_loading_tray("plate_1", tray_index=0) + await store.environment.set_temperature(37) +``` + +Use `set_default_event_bus()` instead when one process-wide bus should observe every instrumented +operation. See the general [EventBus guide](../../machine-agnostic-features/event-bus.md) for event +shape, application context, and subscriber behavior. + +## Transfer events + +| Operation | Emitted by | Fields | +| --- | --- | --- | +| `incubator.fetch_plate` | `fetch_plate_to_loading_tray()` | `device`, `resources`, `source`, `destination` | +| `incubator.take_in_plate` | `take_in_plate()` and `store_plate()` | `device`, `resources`, `source`, `destination` | +| `incubator.transfer_plate` | `transfer_plate_between_nests()` | `device`, `resources`, `source`, `destination` | + +`resources` contains the plate being moved. `source` and `destination` identify the actual +`PlateHolder` resources: a stacker slot and a device-reported transfer nest. + +The transfer event encloses both the hardware command and PLR bookkeeping. A successful transfer +therefore produces this sequence, all with the same `operation_id`: + +```text +incubator.fetch_plate.started +resource.unassigned +resource.assigned +incubator.fetch_plate.completed +``` + +For `take_in_plate()` and `store_plate()`, the equivalent outer event is +`incubator.take_in_plate`; nest-to-nest moves use `incubator.transfer_plate`. If validation or +hardware motion fails after the operation begins, the plate remains at its original PLR location +and the outer operation emits `.failed`. + +Example invocation data: + +```python +{ + "device": {"name": "steristore", "type": "SteriStore", "model": "SteriStore"}, + "resources": [{"name": "plate_1", "type": "Plate"}], + "source": {"name": "rack_1_slot_3", "type": "PlateHolder"}, + "destination": {"name": "steristore_nest_1", "type": "PlateHolder"}, +} +``` + +Resource references may also contain rotation and ancestor information. + +## Environmental-control events + +Environmental events identify the sample store in `device` and use an empty `resources` list. +Humidity and gas targets are fractions, matching the Python API: `0.90` means 90% RH and `0.05` +means 5% gas concentration. + +| Control | Models | +| --- | --- | +| Temperature | SteriStore and TundraStore | +| Relative humidity | SteriStore and TundraStore | +| CO2 | SteriStore | +| O2 | SteriStore when the optional controllable channel is installed | + +AmbiStore does not expose `store.environment`, so it emits only transfer events. + +| Operation | Emitted by | Additional invocation fields | +| --- | --- | --- | +| `temperature_controller.set_temperature` | `set_temperature()` | `target_temperature`, `passive=False` | +| `temperature_controller.activate` | `start_temperature_control()` | — | +| `temperature_controller.deactivate` | `stop_temperature_control()` | — | +| `humidity_controller.set_humidity` | `set_humidity()` | `target_humidity` | +| `humidity_controller.activate` | `start_humidity_control()` | — | +| `humidity_controller.deactivate` | `stop_humidity_control()` | — | +| `co2_controller.set_co2` | `set_co2()` | `target_co2` | +| `co2_controller.activate` | `start_co2_control()` | — | +| `co2_controller.deactivate` | `stop_co2_control()` | — | +| `o2_controller.set_o2` | `set_o2()` | `target_o2` | +| `o2_controller.activate` | `start_o2_control()` | — | +| `o2_controller.deactivate` | `stop_o2_control()` | — | + +For example, `await store.environment.set_co2(0.05)` emits +`co2_controller.set_co2.started` followed by either `co2_controller.set_co2.completed` or +`co2_controller.set_co2.failed`: + +```python +{ + "device": {"name": "steristore", "type": "SteriStore", "model": "SteriStore"}, + "resources": [], + "target_co2": 0.05, +} +``` + +Calling an unavailable channel is observable as a failed operation. For example, attempting O2 +control on a store without an installed controllable O2 channel emits +`o2_controller.set_o2.failed` with `error_type="NotImplementedError"`. + +## Operations without semantic events + +Read-only status methods do not emit semantic operation events. This includes environmental +reads, tank-pressure reads, version and status requests, and inventory queries. `setup()`, +`stop()`, homing, recovery, door control, and clear-abort are also not currently +instrumented as semantic operations. + +Generic `resource.assigned` and `resource.unassigned` state events can still be emitted whenever +PLR resource bookkeeping changes, including nest creation during initial setup when an EventBus is +active. diff --git a/docs/user_guide/high_res/sample-storage/index.md b/docs/user_guide/high_res/sample-storage/index.md new file mode 100644 index 00000000000..1ec78663220 --- /dev/null +++ b/docs/user_guide/high_res/sample-storage/index.md @@ -0,0 +1,149 @@ +# Sample storage + +The HighRes Biosolutions AmbiStore, SteriStore, and TundraStore use the same TCP command protocol +and share a PyLabRobot interface. Choose the concrete class for the model you are configuring; the +product name reported by firmware is retained as version information and does not override that +choice. + +```{toctree} +:maxdepth: 1 + +ambistore/hello-world +steristore/hello-world +tundrastore/hello-world +events +``` + +## Models + +| Model | Environment | Verification | +| --- | --- | --- | +| [AmbiStore](ambistore/hello-world.ipynb) | Ambient storage | Work in progress; not hardware-verified | +| [SteriStore](steristore/hello-world.ipynb) | Heating, active cooling, humidity, CO2, and optional O2 | Hardware-verified | +| [TundraStore](tundrastore/hello-world.ipynb) | Refrigeration and temperature-dependent humidity control | Work in progress; not hardware-verified | + +The published temperature ranges and environmental options come from the current +[HighRes sample-storage page](https://www.highres.com/lab-instruments/sample-storage) and an +[archived HighRes sample-storage brochure](https://7157e75ac0509b6a8f5c-5b19c577d01b9ccfe75d2f9e4b17ab55.ssl.cf1.rackcdn.com/RAXZFZSW-PDF-2-613050-4526550137.pdf). + +## Network connection + +The remote-control server listens on TCP port 1000. The normal factory address is +`192.168.127.60`; HighRes devices also expose the service at `10.253.253.253`. Give the dedicated +host Ethernet interface an address on both subnets so either address remains reachable: + +```bash +sudo ip address replace 192.168.127.50/24 dev +sudo ip address replace 10.253.253.250/24 dev +``` + +These `ip address` changes are temporary and disappear when the USB Ethernet adapter is unplugged +or the host restarts. On Linux systems managed by NetworkManager, create a persistent connection +profile instead: + +```bash +sudo nmcli connection add \ + type ethernet \ + ifname \ + con-name highres-sample-storage \ + ipv4.method manual \ + ipv4.addresses "192.168.127.50/24,10.253.253.250/24" \ + ipv4.never-default yes \ + ipv6.method disabled +sudo nmcli connection up highres-sample-storage +``` + +Use `ip -brief link` to find ``. Verify the isolated link with +`ping 10.253.253.253`; do not add a gateway or default route to this connection. + +## Setup + +Pass the storage racks as a mapping from one-based physical stacker numbers to their PLR resources, +then instantiate the appropriate model. Stacker numbers may be sparse. For example: + +```python +from pylabrobot.high_res.sample_storage import SteriStore + +racks = {1: rack_1, 3: rack_3} +store = SteriStore(host="192.168.127.60", name="steristore", racks=racks) +await store.setup() +``` + +Each mapping key is the rack's one-based device stacker number. Within a rack, the zero-based +carrier `spot` maps to the one-based physical slot: spot 0 is device slot 1, spot 1 is slot 2, and +so on. Mapping insertion order does not affect this relationship. + +Alternatively, omit `racks` to select device-discovery mode. During `setup()`, the driver reads the +configured zero offset, slot height, and slot count with the read-only `getstackerdimensions` +command and creates empty stacker resources. Passing `racks={}` explicitly represents a store with +no configured racks and does not enable discovery. + +During setup, the device-reported transfer nests become `store.nests`. Their locations relative to +the store are left undefined because they depend on the surrounding robot installation. Setup does +not invent plate resources for occupied nests; assign any already-present plates to the matching +nest after setup. + +## Plate transfers + +Fetch a known plate from its stacker slot to a transfer nest: + +```python +plate = await store.fetch_plate_to_loading_tray("plate_1", tray_index=0) +``` + +Move the plate on a transfer nest back into storage, choosing either a specific `PlateHolder`, the +smallest available site, or a random available site: + +```python +await store.take_in_plate(tray_index=0, site="smallest") +``` + +Both operations update the PLR resource tree only after successful hardware motion. See +[Sample-storage events](events.md) for their structured execution events. + +Move a plate directly between two transfer nests using their zero-based tray indices: + +```python +plate = await store.transfer_plate_between_nests( + source_tray_index=1, + destination_tray_index=0, +) +``` + +The driver verifies every transfer-nest endpoint with its live presence sensor before motion. The +stacker itself has no non-destructive per-slot presence query: fetching detects an empty source only +during the pick, while storing relies on the PLR resource tree to determine that the destination +slot is free. Keep the modeled stacker inventory synchronized with the physical store; barcode +`EMPTY` is not a plate-presence result. + +## Barcode scans + +Barcode scans require every transfer nest to be clear. The driver checks this before starting the +scan because firmware 3.0.0.119 otherwise waits for an automation door and eventually times out. + +```python +barcodes = await store.request_stacker_barcodes(2) +barcode = await store.request_stacker_barcodes(2, slot=1) +``` + +A returned value of `EMPTY` means that the scanner did not read a barcode. It does not prove the +physical slot is empty; use resource bookkeeping or a physical plate-presence workflow for that. + +## Recovery + +`request_is_parked()` verifies that the device is homed and that both the spatula slide and lift +axes are retracted. `recover()` refuses to move when the spatula sensor reports a plate, because +the plate's physical support must be inspected before a safe recovery path can be chosen. + +If a transfer response is interrupted or otherwise ambiguous, the driver keeps the last confirmed +resource assignment in place, records `store.unresolved_transfer`, and blocks further plate moves, +homing, and barcode scans. Inspect the machine, recover it when necessary, then reconcile the +observed result: + +```python +await store.resolve_unresolved_transfer("source") +``` + +Use `"destination"` when the plate completed the move or `"unassigned"` when it is at neither +modeled endpoint. If the incomplete response invalidated the TCP stream, reconciliation reconnects +automatically before reading the spatula and nest sensors. diff --git a/docs/user_guide/high_res/sample-storage/steristore/hello-world.ipynb b/docs/user_guide/high_res/sample-storage/steristore/hello-world.ipynb new file mode 100644 index 00000000000..7e1258a7fad --- /dev/null +++ b/docs/user_guide/high_res/sample-storage/steristore/hello-world.ipynb @@ -0,0 +1,392 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "intro", + "metadata": {}, + "source": [ + "# HighRes SteriStore\n", + "\n", + "```{device-card} highres-steristore\n", + "```\n", + "\n", + "| Property | Value |\n", + "| --- | --- |\n", + "| Model | SteriStore |\n", + "| Transport | TCP, port 1000 |\n", + "| Environment | 4–80 °C; optional 100 °C, humidity, CO2, optional O2 |\n", + "| Driver support | Hardware-verified |\n", + "\n", + "```{warning}\n", + "Only enable gas control after its supply and exhaust/ventilation are correctly connected.\n", + "```" + ] + }, + { + "cell_type": "markdown", + "id": "protocol", + "metadata": {}, + "source": [ + "## How it talks\n", + "\n", + "The store exposes a line-oriented TCP service. Each command receives an acknowledgement, optional data, and one completion status. The driver validates the echoed command and command ID and closes the connection if a timeout or malformed response makes the stream unsafe to reuse." + ] + }, + { + "cell_type": "markdown", + "id": "physical-setup", + "metadata": {}, + "source": [ + "## Physical setup\n", + "\n", + "Connect the store to a dedicated Ethernet interface. Supply clean dry air above 80 psi before homing or moving pneumatic doors. The factory service is normally `192.168.127.60:1000` and also answers at `10.253.253.253:1000`. Give the host interface an address on the corresponding isolated subnet, without a default gateway.\n", + "\n", + "The example inventory below represents one known plate physically present in stacker 1, slot 1. It builds all 24 slots from example `getstackerdimensions` values. Replace the rack count, zero offsets, slot heights, and slot counts with the layout reported by the actual machine before running motion." + ] + }, + { + "cell_type": "markdown", + "id": "inventory-text", + "metadata": {}, + "source": [ + "## Describe the physical inventory\n", + "\n", + "`high_res_stacker` uses the measured HighRes stacker footprint and creates one holder per reported slot. The `racks` mapping keys are the physical stacker numbers reported by the device, which may be sparse. Carrier spots are zero-based in PLR and map to one-based device slots. The reported slot height is also the physical safety constraint used when selecting a destination." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "inventory-code", + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.high_res.sample_storage import SteriStore, high_res_stacker\n", + "from pylabrobot.resources import Coordinate, Plate, Well\n", + "\n", + "rack = high_res_stacker(\n", + " name=\"stacker_1\",\n", + " zero_offset=0.0,\n", + " slot_height=22.867,\n", + " slot_count=24,\n", + ")\n", + "site = rack.sites[0]\n", + "well = Well(name=\"A1\", size_x=8, size_y=8, size_z=10)\n", + "well.location = Coordinate(10, 10, 2)\n", + "plate = Plate(\n", + " name=\"plate_1\",\n", + " size_x=127.76,\n", + " size_y=85.48,\n", + " size_z=14,\n", + " ordered_items={\"A1\": well},\n", + ")\n", + "site.assign_child_resource(plate)\n", + "racks = {1: rack}" + ] + }, + { + "cell_type": "markdown", + "id": "connect-text", + "metadata": {}, + "source": [ + "## Connect\n", + "\n", + "`setup()` connects, reads version and environmental information where applicable, and discovers the actual transfer nests. It does not home unless `home=True` is passed." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "connect-code", + "metadata": {}, + "outputs": [], + "source": [ + "store = SteriStore(host=\"192.168.127.60\", name=\"steristore\", racks=racks)\n", + "await store.setup()" + ] + }, + { + "cell_type": "markdown", + "id": "version-text", + "metadata": {}, + "source": [ + "## Read version information\n", + "\n", + "Confirm that the expected device answered." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "version-code", + "metadata": {}, + "outputs": [], + "source": [ + "version = await store.request_version()\n", + "print(version)" + ] + }, + { + "cell_type": "markdown", + "id": "nests-text", + "metadata": {}, + "source": [ + "## Inspect transfer nests\n", + "\n", + "Compare the live sensor report with `store.nests`. Assign a `Plate` resource to any physically occupied nest before transfer operations." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "nests-code", + "metadata": {}, + "outputs": [], + "source": [ + "nest_status = await store.request_nest_status()\n", + "print(nest_status)" + ] + }, + { + "cell_type": "markdown", + "id": "fetch-text", + "metadata": {}, + "source": [ + "## Fetch a plate\n", + "\n", + "This example moves the known plate from stacker 1, slot 1 to the first transfer nest. The driver requires that nest's live sensor to report clear." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "fetch-code", + "metadata": {}, + "outputs": [], + "source": [ + "plate = await store.fetch_plate_to_loading_tray(\"plate_1\", tray_index=0)" + ] + }, + { + "cell_type": "markdown", + "id": "nest-transfer-text", + "metadata": {}, + "source": [ + "## Transfer between nests\n", + "\n", + "If the machine reports at least two nests, move the plate from the first to the second. Both live sensors and PLR bookkeeping are validated before motion." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "nest-transfer-code", + "metadata": {}, + "outputs": [], + "source": [ + "plate = await store.transfer_plate_between_nests(\n", + " source_tray_index=0, destination_tray_index=1\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "take-in-text", + "metadata": {}, + "source": [ + "## Return the plate to storage\n", + "\n", + "Move the plate from the second nest back to the smallest free site that is tall enough for it." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "take-in-code", + "metadata": {}, + "outputs": [], + "source": [ + "plate = await store.take_in_plate(tray_index=1, site=\"smallest\")" + ] + }, + { + "cell_type": "markdown", + "id": "barcode-text", + "metadata": {}, + "source": [ + "## Scan a barcode\n", + "\n", + "Barcode scans require every transfer nest to be physically clear. `EMPTY` means no readable barcode, not necessarily no plate." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "barcode-code", + "metadata": {}, + "outputs": [], + "source": [ + "barcodes = await store.request_stacker_barcodes(1, slot=1)\n", + "print(barcodes)" + ] + }, + { + "cell_type": "markdown", + "id": "open-doors-text", + "metadata": {}, + "source": [ + "## Open robot doors\n", + "\n", + "Ensure the carousel and automation interface are clear. If firmware reports an error, the driver waits for every robot door to reach the final open state." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "open-doors-code", + "metadata": {}, + "outputs": [], + "source": [ + "await store.open_all_doors()" + ] + }, + { + "cell_type": "markdown", + "id": "close-doors-text", + "metadata": {}, + "source": [ + "## Close robot doors\n", + "\n", + "Close and reseal the robot-access doors." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "close-doors-code", + "metadata": {}, + "outputs": [], + "source": [ + "await store.close_all_doors()" + ] + }, + { + "cell_type": "markdown", + "id": "park-text", + "metadata": {}, + "source": [ + "## Verify or recover the parked state\n", + "\n", + "Recovery refuses to move while the spatula sensor reports a plate. Otherwise it retracts and homes only when the store is not safely parked. Recovery proves that the mechanism is parked, but it cannot prove where a plate ended up after a timeout, cancellation, abort, or motion fault. In that case `store.unresolved_transfer` remains set and further plate moves are blocked. Inspect its source and destination, inspect the machine, then call `await store.resolve_unresolved_transfer(\"source\")`, `await store.resolve_unresolved_transfer(\"destination\")`, or `await store.resolve_unresolved_transfer(\"unassigned\")`. Nest choices are checked against live sensors; selecting a stacker endpoint is operator confirmation because stacker slots have no non-destructive presence sensor." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "park-code", + "metadata": {}, + "outputs": [], + "source": [ + "if not await store.request_is_parked():\n", + " recovered = await store.recover()\n", + " if not recovered:\n", + " raise RuntimeError(\"Store did not recover to a parked state\")" + ] + }, + { + "cell_type": "markdown", + "id": "environment-1-text", + "metadata": {}, + "source": [ + "## Read environmental state\n", + "\n", + "Read the installed channels and their current values. Humidity and gas concentrations are returned as fractions." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "environment-1-code", + "metadata": {}, + "outputs": [], + "source": [ + "environment = await store.environment.refresh()\n", + "temperature = await store.environment.request_current_temperature()\n", + "humidity = await store.environment.request_current_humidity()\n", + "print(temperature, humidity, environment)" + ] + }, + { + "cell_type": "markdown", + "id": "environment-2-text", + "metadata": {}, + "source": [ + "## Confirm environmental setpoints\n", + "\n", + "This writes each existing target back unchanged, exercising the setters without changing the requested conditions. Installed channel and temperature limits are checked first." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "environment-2-code", + "metadata": {}, + "outputs": [], + "source": [ + "temperature_target = await store.environment.request_target_temperature()\n", + "await store.environment.set_temperature(temperature_target)\n", + "humidity_target = await store.environment.request_target_humidity()\n", + "await store.environment.set_humidity(humidity_target)\n", + "if store.environment.supports_co2_control:\n", + " co2_target = await store.environment.request_target_co2()\n", + " await store.environment.set_co2(co2_target)\n", + "if store.environment.supports_o2_control:\n", + " o2_target = await store.environment.request_target_o2()\n", + " await store.environment.set_o2(o2_target)" + ] + }, + { + "cell_type": "markdown", + "id": "stop-text", + "metadata": {}, + "source": [ + "## Disconnect\n", + "\n", + "Close the TCP connection when the workflow is finished." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "stop-code", + "metadata": {}, + "outputs": [], + "source": [ + "await store.stop()" + ] + }, + { + "cell_type": "markdown", + "id": "reference", + "metadata": {}, + "source": [ + "## Reference\n", + "\n", + "See the [sample-storage overview](../index.md) and [event reference](../events.md) for the full API." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.10" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/user_guide/high_res/sample-storage/tundrastore/hello-world.ipynb b/docs/user_guide/high_res/sample-storage/tundrastore/hello-world.ipynb new file mode 100644 index 00000000000..dc37776c42e --- /dev/null +++ b/docs/user_guide/high_res/sample-storage/tundrastore/hello-world.ipynb @@ -0,0 +1,386 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "intro", + "metadata": {}, + "source": [ + "# HighRes TundraStore\n", + "\n", + "```{device-card} highres-tundrastore\n", + "```\n", + "\n", + "| Property | Value |\n", + "| --- | --- |\n", + "| Model | TundraStore |\n", + "| Transport | TCP, port 1000 |\n", + "| Environment | −20 to 4 °C; temperature-dependent humidity |\n", + "| Driver support | Work in progress; not hardware-verified |\n", + "\n", + "```{warning}\n", + "TundraStore support has not been verified against TundraStore hardware. Validate temperature, humidity, and motion in a controlled setup.\n", + "```" + ] + }, + { + "cell_type": "markdown", + "id": "protocol", + "metadata": {}, + "source": [ + "## How it talks\n", + "\n", + "The store exposes a line-oriented TCP service. Each command receives an acknowledgement, optional data, and one completion status. The driver validates the echoed command and command ID and closes the connection if a timeout or malformed response makes the stream unsafe to reuse." + ] + }, + { + "cell_type": "markdown", + "id": "physical-setup", + "metadata": {}, + "source": [ + "## Physical setup\n", + "\n", + "Connect the store to a dedicated Ethernet interface. Supply clean dry air above 80 psi before homing or moving pneumatic doors. The factory service is normally `192.168.127.60:1000` and also answers at `10.253.253.253:1000`. Give the host interface an address on the corresponding isolated subnet, without a default gateway.\n", + "\n", + "The example inventory below represents one known plate physically present in stacker 1, slot 1. It builds all 24 slots from example `getstackerdimensions` values. Replace the rack count, zero offsets, slot heights, and slot counts with the layout reported by the actual machine before running motion." + ] + }, + { + "cell_type": "markdown", + "id": "inventory-text", + "metadata": {}, + "source": [ + "## Describe the physical inventory\n", + "\n", + "`high_res_stacker` uses the measured HighRes stacker footprint and creates one holder per reported slot. The `racks` mapping keys are the physical stacker numbers reported by the device, which may be sparse. Carrier spots are zero-based in PLR and map to one-based device slots. The reported slot height is also the physical safety constraint used when selecting a destination." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "inventory-code", + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.high_res.sample_storage import TundraStore, high_res_stacker\n", + "from pylabrobot.resources import Coordinate, Plate, Well\n", + "\n", + "rack = high_res_stacker(\n", + " name=\"stacker_1\",\n", + " zero_offset=0.0,\n", + " slot_height=22.867,\n", + " slot_count=24,\n", + ")\n", + "site = rack.sites[0]\n", + "well = Well(name=\"A1\", size_x=8, size_y=8, size_z=10)\n", + "well.location = Coordinate(10, 10, 2)\n", + "plate = Plate(\n", + " name=\"plate_1\",\n", + " size_x=127.76,\n", + " size_y=85.48,\n", + " size_z=14,\n", + " ordered_items={\"A1\": well},\n", + ")\n", + "site.assign_child_resource(plate)\n", + "racks = {1: rack}" + ] + }, + { + "cell_type": "markdown", + "id": "connect-text", + "metadata": {}, + "source": [ + "## Connect\n", + "\n", + "`setup()` connects, reads version and environmental information where applicable, and discovers the actual transfer nests. It does not home unless `home=True` is passed." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "connect-code", + "metadata": {}, + "outputs": [], + "source": [ + "store = TundraStore(host=\"192.168.127.60\", name=\"tundrastore\", racks=racks)\n", + "await store.setup()" + ] + }, + { + "cell_type": "markdown", + "id": "version-text", + "metadata": {}, + "source": [ + "## Read version information\n", + "\n", + "Confirm that the expected device answered." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "version-code", + "metadata": {}, + "outputs": [], + "source": [ + "version = await store.request_version()\n", + "print(version)" + ] + }, + { + "cell_type": "markdown", + "id": "nests-text", + "metadata": {}, + "source": [ + "## Inspect transfer nests\n", + "\n", + "Compare the live sensor report with `store.nests`. Assign a `Plate` resource to any physically occupied nest before transfer operations." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "nests-code", + "metadata": {}, + "outputs": [], + "source": [ + "nest_status = await store.request_nest_status()\n", + "print(nest_status)" + ] + }, + { + "cell_type": "markdown", + "id": "fetch-text", + "metadata": {}, + "source": [ + "## Fetch a plate\n", + "\n", + "This example moves the known plate from stacker 1, slot 1 to the first transfer nest. The driver requires that nest's live sensor to report clear." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "fetch-code", + "metadata": {}, + "outputs": [], + "source": [ + "plate = await store.fetch_plate_to_loading_tray(\"plate_1\", tray_index=0)" + ] + }, + { + "cell_type": "markdown", + "id": "nest-transfer-text", + "metadata": {}, + "source": [ + "## Transfer between nests\n", + "\n", + "If the machine reports at least two nests, move the plate from the first to the second. Both live sensors and PLR bookkeeping are validated before motion." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "nest-transfer-code", + "metadata": {}, + "outputs": [], + "source": [ + "plate = await store.transfer_plate_between_nests(\n", + " source_tray_index=0, destination_tray_index=1\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "take-in-text", + "metadata": {}, + "source": [ + "## Return the plate to storage\n", + "\n", + "Move the plate from the second nest back to the smallest free site that is tall enough for it." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "take-in-code", + "metadata": {}, + "outputs": [], + "source": [ + "plate = await store.take_in_plate(tray_index=1, site=\"smallest\")" + ] + }, + { + "cell_type": "markdown", + "id": "barcode-text", + "metadata": {}, + "source": [ + "## Scan a barcode\n", + "\n", + "Barcode scans require every transfer nest to be physically clear. `EMPTY` means no readable barcode, not necessarily no plate." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "barcode-code", + "metadata": {}, + "outputs": [], + "source": [ + "barcodes = await store.request_stacker_barcodes(1, slot=1)\n", + "print(barcodes)" + ] + }, + { + "cell_type": "markdown", + "id": "open-doors-text", + "metadata": {}, + "source": [ + "## Open robot doors\n", + "\n", + "Ensure the carousel and automation interface are clear. If firmware reports an error, the driver waits for every robot door to reach the final open state." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "open-doors-code", + "metadata": {}, + "outputs": [], + "source": [ + "await store.open_all_doors()" + ] + }, + { + "cell_type": "markdown", + "id": "close-doors-text", + "metadata": {}, + "source": [ + "## Close robot doors\n", + "\n", + "Close and reseal the robot-access doors." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "close-doors-code", + "metadata": {}, + "outputs": [], + "source": [ + "await store.close_all_doors()" + ] + }, + { + "cell_type": "markdown", + "id": "park-text", + "metadata": {}, + "source": [ + "## Verify or recover the parked state\n", + "\n", + "Recovery refuses to move while the spatula sensor reports a plate. Otherwise it retracts and homes only when the store is not safely parked. Recovery proves that the mechanism is parked, but it cannot prove where a plate ended up after a timeout, cancellation, abort, or motion fault. In that case `store.unresolved_transfer` remains set and further plate moves are blocked. Inspect its source and destination, inspect the machine, then call `await store.resolve_unresolved_transfer(\"source\")`, `await store.resolve_unresolved_transfer(\"destination\")`, or `await store.resolve_unresolved_transfer(\"unassigned\")`. Nest choices are checked against live sensors; selecting a stacker endpoint is operator confirmation because stacker slots have no non-destructive presence sensor." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "park-code", + "metadata": {}, + "outputs": [], + "source": [ + "if not await store.request_is_parked():\n", + " recovered = await store.recover()\n", + " if not recovered:\n", + " raise RuntimeError(\"Store did not recover to a parked state\")" + ] + }, + { + "cell_type": "markdown", + "id": "environment-1-text", + "metadata": {}, + "source": [ + "## Read environmental state\n", + "\n", + "Read the installed channels and current temperature and humidity. Humidity is returned as a fraction." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "environment-1-code", + "metadata": {}, + "outputs": [], + "source": [ + "environment = await store.environment.refresh()\n", + "temperature = await store.environment.request_current_temperature()\n", + "humidity = await store.environment.request_current_humidity()\n", + "print(temperature, humidity, environment)" + ] + }, + { + "cell_type": "markdown", + "id": "environment-2-text", + "metadata": {}, + "source": [ + "## Confirm environmental setpoints\n", + "\n", + "This writes the existing targets back unchanged, exercising validation without changing the requested conditions." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "environment-2-code", + "metadata": {}, + "outputs": [], + "source": [ + "temperature_target = await store.environment.request_target_temperature()\n", + "await store.environment.set_temperature(temperature_target)\n", + "humidity_target = await store.environment.request_target_humidity()\n", + "await store.environment.set_humidity(humidity_target)" + ] + }, + { + "cell_type": "markdown", + "id": "stop-text", + "metadata": {}, + "source": [ + "## Disconnect\n", + "\n", + "Close the TCP connection when the workflow is finished." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "stop-code", + "metadata": {}, + "outputs": [], + "source": [ + "await store.stop()" + ] + }, + { + "cell_type": "markdown", + "id": "reference", + "metadata": {}, + "source": [ + "## Reference\n", + "\n", + "See the [sample-storage overview](../index.md) and [event reference](../events.md) for the full API." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.10" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/user_guide/machine-agnostic-features/event-bus.md b/docs/user_guide/machine-agnostic-features/event-bus.md index 59d484df5f1..c87b1c91af0 100644 --- a/docs/user_guide/machine-agnostic-features/event-bus.md +++ b/docs/user_guide/machine-agnostic-features/event-bus.md @@ -118,6 +118,7 @@ events. | --- | --- | | `legacy.machines.Machine` | `machine.setup`, `machine.stop` | | `legacy.storage.Incubator` | `incubator.fetch_plate`, `incubator.take_in_plate` | +| `high_res.sample_storage.HighResSampleStorage` | incubator fetch/take-in/nest transfer; temperature, humidity, CO2, and O2 control when supported | | `legacy.liquid_handling.LiquidHandler` | resource pickup/move/drop; tip pickup/drop; 96-head tip pickup/drop; aspirate; dispense | | `legacy.shaking.Shaker` | `shaker.shake`, `shaker.stop_shaking` | | `legacy.temperature_controlling.TemperatureController` | set temperature, wait for temperature, deactivate | @@ -132,7 +133,8 @@ Detailed operation references: - [Machine lifecycle](event-bus/machine-lifecycle.md) - [Incubator](event-bus/incubator.md) - [LiquidHandler](event-bus/liquid-handler.md) -- [Shaker and temperature controller](event-bus/thermal-and-shaking.md) +- [Shaker and environmental controllers](event-bus/thermal-and-shaking.md) +- [HighRes sample storage](../high_res/sample-storage/events.md) - [VSpin centrifuge and Access2 loader](../agilent/vspin/events.md) - [Diagnostic transports](event-bus/diagnostic-transports.md) - [Canonical schema for every operation above](../../contributor_guide/event-schemas.md) @@ -151,8 +153,8 @@ event-bus/diagnostic-transports ### Incubator -`incubator.fetch_plate` and `incubator.take_in_plate` include `device`, the moved plate in -`resources`, and physical `source` and `destination` resource references. +`incubator.fetch_plate`, `incubator.take_in_plate`, and `incubator.transfer_plate` include `device`, +the moved plate in `resources`, and physical `source` and `destination` resource references. ### LiquidHandler @@ -162,11 +164,13 @@ operated resources plus `liquid_operations`, one record per channel, with `chann `resource`, optional owning `plate`, and `volume`. Tip events similarly include direct tip locations and per-channel `tip_operations`. -### Shaker and TemperatureController +### Shaker and environmental controllers Shaker events include `speed_rpm` and optional `duration`. Temperature-controller events include -`target_temperature` where applicable. Both frontends are `ResourceHolder`s: when a -resource is loaded at operation start, it is included as the direct resource in `resources`. +`target_temperature` where applicable. The legacy shaker and temperature frontends are +`ResourceHolder`s: when a resource is loaded at operation start, it is included as the direct +resource in `resources`. HighRes sample-store humidity and gas targets are fractions and use an +empty `resources` list because the controller acts on the store environment rather than one plate. ### Brooks PreciseFlex diff --git a/docs/user_guide/machine-agnostic-features/event-bus/incubator.md b/docs/user_guide/machine-agnostic-features/event-bus/incubator.md index e741d243e16..a023ade1362 100644 --- a/docs/user_guide/machine-agnostic-features/event-bus/incubator.md +++ b/docs/user_guide/machine-agnostic-features/event-bus/incubator.md @@ -1,12 +1,18 @@ # Incubator events -The instrumented `legacy.storage.Incubator` frontend emits the following semantic operations: +The instrumented `legacy.storage.Incubator` and HighRes sample-storage frontends emit the following +semantic operations where their public APIs support them: | Operation | Lifecycle events | Primary fields | | --- | --- | --- | | `incubator.fetch_plate` | `started`, `completed`, `failed` | `device`, `resources`, `source`, `destination` | | `incubator.take_in_plate` | `started`, `completed`, `failed` | `device`, `resources`, `source`, `destination` | +| `incubator.transfer_plate` | `started`, `completed`, `failed` | `device`, `resources`, `source`, `destination` | `resources` contains the direct moved plate. `source` and `destination` identify the relevant PLR holders, such as a storage site and loading tray. The completed event can reflect the plate's post-operation resource assignment. + +`incubator.transfer_plate` describes a direct move between two transfer nests or other incubator +endpoints. The HighRes-specific environmental events are documented in its +[sample-storage event reference](../../high_res/sample-storage/events.md). diff --git a/docs/user_guide/machine-agnostic-features/event-bus/thermal-and-shaking.md b/docs/user_guide/machine-agnostic-features/event-bus/thermal-and-shaking.md index f025a08d912..20e6abe3b4f 100644 --- a/docs/user_guide/machine-agnostic-features/event-bus/thermal-and-shaking.md +++ b/docs/user_guide/machine-agnostic-features/event-bus/thermal-and-shaking.md @@ -1,4 +1,4 @@ -# Shaker and temperature-controller events +# Shaker and environmental-controller events Each listed semantic operation emits `started`, `completed`, or `failed` lifecycle records. @@ -11,13 +11,16 @@ The instrumented `legacy.shaking.Shaker` frontend emits: | `shaker.shake` | `device`, loaded `resources`, `speed_rpm`, optional `duration` | | `shaker.stop_shaking` | `device`, loaded `resources` | -## TemperatureController +## Temperature controllers -The instrumented `legacy.temperature_controlling.TemperatureController` frontend emits: +Instrumented temperature-control frontends emit the operations their public APIs support. The +legacy `TemperatureController` does not have a separate activation method; direct frontends such as +the HighRes sample stores do. | Operation | Primary fields | | --- | --- | | `temperature_controller.set_temperature` | `device`, loaded `resources`, `target_temperature`, `passive` | +| `temperature_controller.activate` | `device`, loaded `resources` | | `temperature_controller.wait_for_temperature` | `device`, loaded `resources`, `target_temperature`, `timeout`, `tolerance`; completed event adds `current_temperature` | | `temperature_controller.hold_temperature` | `device`, loaded `resources`, `duration`, configured `target_temperature` when known | | `temperature_controller.deactivate` | `device`, loaded `resources`, configured `target_temperature` when known | @@ -37,3 +40,24 @@ surrounding deck state. New direct temperature-controller frontends, such as vendor-specific Inheco frontends, should implement this semantic EventBus contract at their own public API boundary. They do not need to inherit from the legacy `TemperatureController` class. + +## Humidity and gas controllers + +Environmental-control frontends use the same set/activate/deactivate lifecycle for humidity, CO2, +and O2. Targets are fractions: `0.90` means 90% RH and `0.05` means 5% gas concentration. + +| Operation | Primary fields | +| --- | --- | +| `humidity_controller.set_humidity` | `device`, `resources`, `target_humidity` | +| `humidity_controller.activate` | `device`, `resources` | +| `humidity_controller.deactivate` | `device`, `resources` | +| `co2_controller.set_co2` | `device`, `resources`, `target_co2` | +| `co2_controller.activate` | `device`, `resources` | +| `co2_controller.deactivate` | `device`, `resources` | +| `o2_controller.set_o2` | `device`, `resources`, `target_o2` | +| `o2_controller.activate` | `device`, `resources` | +| `o2_controller.deactivate` | `device`, `resources` | + +The HighRes sample stores currently emit these operations for their installed controllable +channels. See the [device event reference](../../high_res/sample-storage/events.md) for model +coverage. diff --git a/pylabrobot/high_res/sample_storage/__init__.py b/pylabrobot/high_res/sample_storage/__init__.py new file mode 100644 index 00000000000..3a591de7769 --- /dev/null +++ b/pylabrobot/high_res/sample_storage/__init__.py @@ -0,0 +1,23 @@ +from .ambi_store import AmbiStore +from .driver import ( + DoorState, + EnvironmentControl, + EnvironmentParameter, + HighResSampleStorage, + HighResSampleStorageAbortedError, + HighResSampleStorageError, + HighResSampleStorageFault, + HighResSampleStorageProtocolError, + HighResSampleStorageSettings, + MachineType, + ModelInfo, + NestState, + NoFreeSiteError, + PlateNotFoundError, + StackerDimensions, + UnresolvedPlateTransfer, + VersionInfo, +) +from .stackers import high_res_stacker +from .steri_store import SteriStore +from .tundra_store import TundraStore diff --git a/pylabrobot/high_res/sample_storage/ambi_store.py b/pylabrobot/high_res/sample_storage/ambi_store.py new file mode 100644 index 00000000000..5544a3c7934 --- /dev/null +++ b/pylabrobot/high_res/sample_storage/ambi_store.py @@ -0,0 +1,15 @@ +from .driver import HighResSampleStorage + + +class AmbiStore(HighResSampleStorage): + """HighRes Biosolutions AmbiStore plate store. + + The AmbiStore is ambient, so it exposes only plate storage and retrieval. + Its environment-control behavior has not yet been verified against hardware. + """ + + _model_name = "AmbiStore" + _verification_warning = ( + "AmbiStore support is a work in progress and has not been verified against hardware. " + "Validate it in a controlled setup and report verified behavior so this warning can be removed." + ) diff --git a/pylabrobot/high_res/sample_storage/driver/__init__.py b/pylabrobot/high_res/sample_storage/driver/__init__.py new file mode 100644 index 00000000000..5a90968e7d6 --- /dev/null +++ b/pylabrobot/high_res/sample_storage/driver/__init__.py @@ -0,0 +1,19 @@ +from .driver import HighResSampleStorage, UnresolvedPlateTransfer +from .environment import EnvironmentControl +from .errors import ( + HighResSampleStorageAbortedError, + HighResSampleStorageError, + HighResSampleStorageFault, + HighResSampleStorageProtocolError, + NoFreeSiteError, + PlateNotFoundError, +) +from .models import ModelInfo +from .settings import HighResSampleStorageSettings, MachineType +from .types import ( + DoorState, + EnvironmentParameter, + NestState, + StackerDimensions, + VersionInfo, +) diff --git a/pylabrobot/high_res/sample_storage/driver/driver.py b/pylabrobot/high_res/sample_storage/driver/driver.py new file mode 100644 index 00000000000..1d63674212d --- /dev/null +++ b/pylabrobot/high_res/sample_storage/driver/driver.py @@ -0,0 +1,1240 @@ +import asyncio +import logging +import random +from dataclasses import dataclass +from typing import ( + Any, + Awaitable, + Callable, + Dict, + List, + Literal, + Mapping, + Optional, + Sequence, + Tuple, + Union, + cast, +) + +from pylabrobot.events import event_operation, resource_reference +from pylabrobot.io.socket import Socket +from pylabrobot.resources import ( + Coordinate, + Plate, + PlateCarrier, + PlateHolder, + Resource, + ResourceNotFoundError, + Rotation, +) +from pylabrobot.resources.barcode import Barcode +from pylabrobot.serializer import deserialize as deserialize_value + +from ..stackers import high_res_stacker +from .environment import EnvironmentControl +from .errors import ( + HighResSampleStorageAbortedError, + HighResSampleStorageError, + HighResSampleStorageFault, + HighResSampleStorageProtocolError, + NoFreeSiteError, + PlateNotFoundError, + left_unsafe, +) +from .models import ModelInfo, get_model_info +from .protocol import ( + ACK_TOKEN, + COMPLETION_ABORTED, + COMPLETION_ERROR, + COMPLETION_OK, + COMPLETION_TOKENS, + parse_kv, +) +from .settings import HighResSampleStorageSettings +from .types import ( + DOOR_STATES, + NEST_STATES, + DoorState, + EnvironmentParameter, + NestState, + StackerDimensions, + VersionInfo, +) + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class UnresolvedPlateTransfer: + """A plate move whose physical result and PLR state are not reconciled. + + The resource tree remains at its last confirmed state until + :meth:`HighResSampleStorage.resolve_unresolved_transfer` reconciles it. + """ + + plate: Plate + source: PlateHolder + destination: PlateHolder + command: str + error_type: str + error_message: str + + +class HighResSampleStorage(Resource): + """Base device for HighRes Biosolutions sample stores. + + The TundraStore, SteriStore and AmbiStore are the same machine family behind a + shared port-1000 API, so all of the implementation lives here and the concrete + devices are thin subclasses. Each rack is a *stacker* (a vertical column of + plate slots); plates enter and leave through one of the device's *nests* + (transfer stations). Fetch and store operations address a particular nest + with a 0-based ``tray_index``. + + If a plate move is interrupted without a definitive outcome, the driver + exposes it through :attr:`unresolved_transfer` and blocks further plate moves, + homing, and barcode scans until :meth:`resolve_unresolved_transfer` reconciles + the physical and PLR locations. :meth:`recover` remains available to park an + unsafe mechanism before reconciliation. + + Subclasses set :attr:`_model_name`; callers can override it with ``model``. + This configured model selects model-specific behavior. The product name + reported by the device is logged during setup but is not used as configuration. + + The store exposes a text-based remote-control server over TCP, port 1000. + Commands are case-sensitive, space-separated, terminated with ``\\r\\n``. Each + command is answered with an ``ACK!`` echo, optional data lines, then exactly + one completion line (``OK!`` / ``ABORTED!`` / ``ERROR!``). See the User + Manual, section "Message Formatting". + """ + + _model_name = "HighResSampleStorage" + _verification_warning: Optional[str] = None + + def __init__( + self, + host: str, + name: str, + racks: Optional[Mapping[int, PlateCarrier]] = None, + size_x: float = 0, + size_y: float = 0, + size_z: float = 0, + rotation: Optional[Rotation] = None, + category: Optional[str] = "plate_store", + model: Optional[str] = None, + port: int = 1000, + read_timeout: float = 30.0, + motion_timeout: float = 240.0, + nests: Optional[Sequence[PlateHolder]] = None, + barcode: Optional[Barcode] = None, + preferred_pickup_location: Optional[Coordinate] = None, + metadata: Optional[Mapping[str, Any]] = None, + ) -> None: + """Create a HighRes sample-store resource and configure its TCP transport. + + Args: + host: IP address of the store. The factory default is ``192.168.127.60``; + all HighRes devices also answer on the backdoor ``10.253.253.253``. + racks: Mapping of physical stacker numbers to stacker resources. When omitted, + ``setup()`` reads the configured dimensions from the device and creates empty + stackers. Passing an empty mapping explicitly configures no stackers and disables + discovery. + port: Remote-control server port (always 1000). + read_timeout: Timeout (s) for query/status commands. + motion_timeout: Timeout (s) for long-running motion commands + (``home``, ``pick``, ``place``, door moves). + model: Model used for model-specific behavior. Defaults to the concrete + class's model and is never replaced with the device-reported product name. + nests: Existing transfer-nest resources when restoring serialized state. + barcode: Optional barcode identifying the store resource. + preferred_pickup_location: Optional gripper pickup coordinate for the store resource. + metadata: Free-form resource metadata. + """ + configured_model = model if model is not None else self._model_name + model_info = get_model_info(configured_model) + Resource.__init__( + self, + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + rotation=rotation, + category=category, + model=configured_model, + barcode=barcode, + preferred_pickup_location=preferred_pickup_location, + metadata=metadata, + ) + + # The device reports its configured nest numbers during setup. Their + # robot-facing coordinates are intentionally undefined relative to the + # store, so the corresponding resources are attached with location=None. + self.nests: List[PlateHolder] = [] + if nests is not None: + for nest in nests: + self.assign_child_resource(nest, location=None) + self.nests.append(nest) + + self._racks_by_number: Dict[int, PlateCarrier] = {} + self._racks_loaded = racks is not None + if racks is not None: + for stacker in racks: + self._validate_stacker_number(stacker) + for stacker in sorted(racks): + self._add_rack(racks[stacker], stacker=stacker) + + # Slide (Y) and lift (Z) positions near zero are retracted. Faulted moves + # can leave either axis extended even when firmware still reports homed. + self._retracted_y_max = 50.0 + self._retracted_z_max = 50.0 + + self._model_info = model_info + if self._model_info.has_environment_control: + self.environment = EnvironmentControl(driver=self) + + self.io = Socket( + human_readable_device_name="HighRes sample store", + host=host, + port=port, + read_timeout=read_timeout, + write_timeout=read_timeout, + ) + self._host = host + self._port = port + self._read_timeout = read_timeout + self._motion_timeout = motion_timeout + self._transport_invalidated = False + self._command_lock = asyncio.Lock() + # A command lock prevents protocol responses from interleaving, but a plate + # transfer also includes resource validation and bookkeeping on either side + # of the hardware command. Keep that entire transaction atomic. + self._transfer_lock = asyncio.Lock() + self._unresolved_transfer: Optional[UnresolvedPlateTransfer] = None + + @property + def read_timeout(self) -> float: + return self._read_timeout + + @property + def motion_timeout(self) -> float: + return self._motion_timeout + + @property + def model_info(self) -> ModelInfo: + return self._model_info + + @property + def unresolved_transfer(self) -> Optional[UnresolvedPlateTransfer]: + """The plate move whose physical outcome must be reconciled, if any.""" + return self._unresolved_transfer + + def serialize(self) -> dict: + """Serialize the connection configuration and modeled inventory. + + An unresolved transfer cannot be serialized safely because restoring only + the last confirmed resource assignment would discard the recovery block. + """ + self._require_no_unresolved_transfer() + resource_data = Resource.serialize(self) + # These fields preserve physical stacker numbers and transfer-nest ordering. + resource_data.pop("children", None) + return { + **resource_data, + "host": self._host, + "port": self._port, + "read_timeout": self.read_timeout, + "motion_timeout": self.motion_timeout, + "racks": {str(stacker): rack.serialize() for stacker, rack in self._racks_by_number.items()}, + "nests": [nest.serialize() for nest in self.nests], + } + + @classmethod + def deserialize(cls, data: dict, allow_marshal: bool = False) -> "HighResSampleStorage": + """Restore a sample store and its modeled racks and transfer nests.""" + racks_data = cast(Dict[str, dict], data.get("racks", {})) + racks = { + int(stacker): PlateCarrier.deserialize(rack_data, allow_marshal=allow_marshal) + for stacker, rack_data in racks_data.items() + } + barcode_data = cast(Optional[dict], data.get("barcode")) + store = cls( + host=cast(str, data["host"]), + name=cast(str, data["name"]), + racks=racks, + size_x=cast(float, data.get("size_x", 0)), + size_y=cast(float, data.get("size_y", 0)), + size_z=cast(float, data.get("size_z", 0)), + rotation=cast(Optional[Rotation], deserialize_value(data.get("rotation"))), + category=cast(Optional[str], data.get("category")), + model=cast(Optional[str], data.get("model")), + port=cast(int, data.get("port", 1000)), + read_timeout=cast(float, data.get("read_timeout", 30.0)), + motion_timeout=cast(float, data.get("motion_timeout", 240.0)), + nests=[ + PlateHolder.deserialize(nest_data, allow_marshal=allow_marshal) + for nest_data in cast(List[dict], data.get("nests", [])) + ], + barcode=Barcode(**barcode_data) if barcode_data is not None else None, + preferred_pickup_location=cast( + Optional[Coordinate], deserialize_value(data.get("preferred_pickup_location")) + ), + metadata=cast(Optional[Mapping[str, Any]], data.get("metadata")), + ) + return store + + # --- lifecycle ------------------------------------------------------------ + + async def setup(self, home: bool = False) -> None: + if self._verification_warning is not None: + logger.warning("%s", self._verification_warning) + await self.io.setup() + self._transport_invalidated = False + try: + await self._setup_connected(home=home) + except BaseException: + self._transport_invalidated = True + await self.io.stop() + raise + + async def _setup_connected(self, home: bool) -> None: + version = await self.request_version() + logger.info( + "Connected to %s (serial %s, firmware %s)", + version.product_name, + version.serial_number, + version.firmware_version, + ) + + if not self._racks_loaded: + await self._load_racks_from_device() + + if self._model_info.has_environment_control: + await self.environment.refresh() + + nest_status = await self.request_nest_status() + if not nest_status: + raise RuntimeError("The sample store did not report any nests.") + if not self.nests: + nest_numbers = sorted(nest_status) + expected_nest_numbers = list(range(1, len(nest_numbers) + 1)) + if nest_numbers != expected_nest_numbers: + raise RuntimeError( + f"The sample store must report contiguous nest numbers starting at 1; got {nest_numbers}." + ) + for nest_number in nest_numbers: + nest = PlateHolder( + name=f"{self.name}_nest_{nest_number}", + size_x=127.76, + size_y=85.48, + size_z=0, + pedestal_size_z=0, + ) + self.assign_child_resource(nest, location=None) + self.nests.append(nest) + if home: + await self.home() + + def _add_rack(self, rack: PlateCarrier, stacker: int) -> None: + """Attach one rack under its physical stacker number.""" + self._validate_stacker_number(stacker) + if stacker in self._racks_by_number: + raise ValueError(f"Stacker {stacker} is already configured.") + for spot, site in rack.sites.items(): + if spot < 0: + raise ValueError(f"Rack site spot must be non-negative; got {spot} for {site.name!r}.") + self.assign_child_resource(rack, location=None) + self._racks_by_number[stacker] = rack + + @staticmethod + def _validate_stacker_number(stacker: int) -> None: + if not isinstance(stacker, int) or isinstance(stacker, bool) or stacker < 1: + raise ValueError(f"Stacker number must be a positive integer; got {stacker!r}.") + + async def _load_racks_from_device(self) -> None: + """Create empty stacker resources from ``getstackerdimensions``.""" + dimensions = await self.request_stacker_dimensions() + if not dimensions: + raise RuntimeError("The sample store did not report any stacker dimensions.") + + reported_stackers = set() + for stacker_dimensions in sorted(dimensions, key=lambda value: value.stacker): + stacker = stacker_dimensions.stacker + if stacker in reported_stackers: + raise RuntimeError(f"The sample store reported stacker {stacker} more than once.") + reported_stackers.add(stacker) + if stacker_dimensions.slot_count == 0: + continue + rack = high_res_stacker( + name=f"{self.name}_stacker_{stacker}", + zero_offset=stacker_dimensions.zero_offset, + slot_height=stacker_dimensions.slot_height, + slot_count=stacker_dimensions.slot_count, + ) + self._add_rack(rack, stacker=stacker) + + self._racks_loaded = True + logger.info("Loaded %d configured stackers for %s", len(self._racks_by_number), self.name) + + async def stop(self) -> None: + logger.info("Stopping %s", self.name) + try: + await self.io.stop() + finally: + self._transport_invalidated = True + + # --- transport ------------------------------------------------------------ + + async def _readline(self, timeout: Optional[float]) -> str: + raw = await self.io.readuntil(b"\n", timeout=timeout) + return raw.decode("ascii", errors="replace").rstrip("\r\n") + + async def _send_command(self, command: str, timeout: Optional[float] = None) -> List[str]: + """Send a command and return its data lines (those between the ``ACK!`` echo + and the completion line). + + Raises: + HighResSampleStorageError: if the device replies ``ERROR!``. + HighResSampleStorageAbortedError: if the device replies ``ABORTED!``. + HighResSampleStorageProtocolError: if the acknowledgement or completion + does not correspond exactly to this command. + """ + if not command or command != command.strip() or "\r" in command or "\n" in command: + raise ValueError("command must be a non-empty single line without surrounding whitespace") + if command.partition(" ")[0] in ("pick", "place", "nesttransfer"): + self._require_no_unresolved_transfer() + if timeout is None: + timeout = self._read_timeout + encoded_command = command.encode("ascii") + b"\r\n" + async with self._command_lock: + try: + await self.io.write(encoded_command) + + data_lines: List[str] = [] + ack = await self._readline(timeout) + ack_command, command_id = self._parse_envelope(ACK_TOKEN, ack, command) + if ack_command != command: + raise HighResSampleStorageProtocolError( + command, ack, f"ACK echoed command {ack_command!r}" + ) + + while True: + line = await self._readline(timeout) + completion_token = next( + (token for token in COMPLETION_TOKENS if line == token or line.startswith(f"{token} ")), + None, + ) + if completion_token is None: + if line == ACK_TOKEN or line.startswith(f"{ACK_TOKEN} "): + raise HighResSampleStorageProtocolError(command, line, "received a second ACK") + data_lines.append(line) + continue + + completion_command, completion_id = self._parse_envelope(completion_token, line, command) + if completion_command != command: + raise HighResSampleStorageProtocolError( + command, line, f"completion echoed command {completion_command!r}" + ) + if completion_id != command_id: + raise HighResSampleStorageProtocolError( + command, + line, + f"completion command ID {completion_id!r} does not match ACK ID {command_id!r}", + ) + break + except BaseException: + # A timeout, cancellation, or malformed envelope can leave unread response + # lines in the stream. Closing prevents the next command from consuming + # those stale lines as its own response. + logger.exception( + "Invalidating %s connection after incomplete command %r", self.name, command + ) + self._transport_invalidated = True + try: + await self.io.stop() + except BaseException: + logger.exception("Failed to close invalid %s connection", self.name) + raise + + if completion_token == COMPLETION_ERROR: + # Firmware 3.0.x emits the ``Error : ...`` stack as data lines *before* + # the ERROR! completion, so they are already collected in data_lines. + error_lines = [ln for ln in data_lines if ln.startswith("Error")] or data_lines + raise HighResSampleStorageError(command, error_lines) + if completion_token == COMPLETION_ABORTED: + raise HighResSampleStorageAbortedError(command) + if completion_token != COMPLETION_OK: + raise HighResSampleStorageProtocolError(command, line, "unknown completion status") + return data_lines + + async def _reconnect_invalidated_transport(self) -> None: + """Reconnect after an incomplete response left the command stream unusable.""" + if not self._transport_invalidated: + return + logger.info("Reconnecting to %s before transfer reconciliation", self.name) + await self.io.setup() + self._transport_invalidated = False + + @staticmethod + def _parse_envelope(token: str, line: str, command: str) -> Tuple[str, str]: + prefix = f"{token} " + if not line.startswith(prefix): + raise HighResSampleStorageProtocolError(command, line, f"expected {token} envelope") + echoed_command, separator, command_id = line[len(prefix) :].rpartition(" ") + if not separator or not echoed_command or not command_id.isdecimal(): + raise HighResSampleStorageProtocolError( + command, line, f"expected '{token} '" + ) + return echoed_command, command_id + + # --- shared device queries ------------------------------------------------ + + async def request_version(self) -> VersionInfo: + raw = parse_kv(await self._send_command("version")) + return VersionInfo( + product_name=raw.get("Product Name"), + serial_number=raw.get("Serial Number"), + firmware_version=raw.get("Firmware Version"), + firmware_build=raw.get("Firmware Build"), + raw=raw, + ) + + async def request_environment(self) -> Dict[str, EnvironmentParameter]: + """Parse ``environmentstatus`` into ``{name: EnvironmentParameter}``. + + Each channel reports ``NAME:current/setpoint/limit``; sensor-only channels + (e.g. the gas tank pressures) report only a current value. Shared by the + temperature and humidity controls. + """ + out: Dict[str, EnvironmentParameter] = {} + for line in await self._send_command("environmentstatus"): + if ":" not in line: + continue + name, _, rest = line.partition(":") + parts = rest.strip().rstrip(":").split("/") + try: + current = float(parts[0]) + except (ValueError, IndexError): + continue + + def _opt(i: int) -> Optional[float]: + try: + return float(parts[i]) + except (ValueError, IndexError): + return None + + channel = name.strip().upper() + out[channel] = EnvironmentParameter( + name=channel, current=current, setpoint=_opt(1), limit=_opt(2) + ) + return out + + # --- queries -------------------------------------------------------------- + + async def request_axis_positions(self) -> Dict[str, float]: + """Return the ``status`` report: carousel/theta/Y/Z positions.""" + out: Dict[str, float] = {} + for key, value in parse_kv(await self._send_command("status")).items(): + try: + out[key] = float(value) + except ValueError: + continue + return out + + async def request_is_homed(self) -> bool: + lines = await self._send_command("homedstatus") + return any(line.strip().lower() == "homed" for line in lines) + + async def request_door_status(self) -> Dict[str, DoorState]: + """Parsed ``doorstatus`` output, keyed by door name.""" + doors: Dict[str, DoorState] = {} + for name, value in parse_kv(await self._send_command("doorstatus")).items(): + state = value.lower() + doors[name] = cast(DoorState, state) if state in DOOR_STATES else "unknown" + return doors + + async def request_nest_status(self) -> Dict[int, NestState]: + """Parsed ``neststatus`` output, keyed by nest number.""" + nests: Dict[int, NestState] = {} + for key, value in parse_kv(await self._send_command("neststatus")).items(): + try: + nest = int(key) + except ValueError: + continue + state = value.lower() + if state == "plate_available": + state = "occupied" + nests[nest] = cast(NestState, state) if state in NEST_STATES else "unknown" + return nests + + async def request_spatula_is_holding(self) -> bool: + """Whether a plate is currently held on the spatula (``platestatus``).""" + lines = await self._send_command("platestatus") + return not any("NO_PLATE" in line for line in lines) + + async def request_nest_is_holding(self, nest: int) -> bool: + """Whether a plate is present on ``nest`` (per its plate sensor). + + Firmware ``PLATE_AVAILABLE`` responses are normalized to ``occupied`` by + :meth:`request_nest_status`; any other non-clear state also counts as holding. + """ + states = await self.request_nest_status() + if nest not in states: + raise ValueError(f"The device did not report nest {nest}.") + return states[nest] != "clear" + + async def _probe_presence(self, stacker: int, slot: int, to_nest: int = 1) -> bool: + """Probe whether a plate is present in ``(stacker, slot)`` by attempting a + pick. Returns ``True`` if a plate was there, ``False`` if the slot is empty. + + SIDE EFFECT: a plate that is found is moved to ``to_nest`` (the only way to + sense a stacker slot is to pick it). Only safe for non-top slots, where an + empty pick is graceful; the top slot (24) faults when empty — see + :meth:`_pick`. For nests, use :meth:`request_nest_is_holding` instead (a + non-destructive sensor read). + """ + try: + await self._pick(stacker, slot, to_nest) + return True + except PlateNotFoundError: + return False + + async def request_stacker_dimensions(self) -> List[StackerDimensions]: + """Parse ``getstackerdimensions`` (``: + ``).""" + dims: List[StackerDimensions] = [] + for line in await self._send_command("getstackerdimensions"): + key, _, rest = line.partition(":") + try: + stacker = int(key) + zero_offset, slot_height, slot_count = rest.split() + dims.append( + StackerDimensions( + stacker=stacker, + zero_offset=float(zero_offset), + slot_height=float(slot_height), + slot_count=int(slot_count), + ) + ) + except ValueError: + continue + return dims + + async def request_settings(self) -> HighResSampleStorageSettings: + """Read the device's full settings file (``NAME = value`` pairs) into a + frozen :class:`HighResSampleStorageSettings`.""" + lines = await self._send_command("settings", timeout=self.read_timeout) + return HighResSampleStorageSettings.from_lines(lines) + + async def request_stacker_barcodes( + self, stacker: Union[int, Literal["all"]], slot: Optional[int] = None + ) -> List[str]: + """Scan a stacker (or a single slot) for barcodes. + + All transfer nests must be clear: firmware 3.0.0.119 otherwise waits for + an automation door that cannot open and eventually reports a timeout. + ``EMPTY`` in the returned lines means no readable barcode; it is not a + plate-presence result. + + Args: + stacker: Stacker number, or the string ``"all"`` to scan the whole + inventory. + slot: Optional single slot to scan. + """ + if stacker != "all" and (not isinstance(stacker, int) or stacker < 1): + raise ValueError("stacker must be a positive integer or 'all'.") + if stacker == "all" and slot is not None: + raise ValueError("slot cannot be specified when stacker is 'all'.") + if slot is not None and slot < 1: + raise ValueError("slot must be a positive integer.") + self._require_no_unresolved_transfer() + + occupied_nests = [ + nest for nest, state in (await self.request_nest_status()).items() if state != "clear" + ] + if occupied_nests: + raise RuntimeError( + "Cannot scan barcodes while plates are present on nests " + + ", ".join(map(str, occupied_nests)) + + "; clear all nests first." + ) + + command = f"barcode {stacker}" + if slot is not None: + command += f" {slot}" + return await self._send_command(command, timeout=self.motion_timeout) + + # --- motion --------------------------------------------------------------- + + async def home(self) -> None: + """Home the system. The first step closes all doors, which requires the + pneumatic supply (clean dry air >80 psi); without it this raises + :class:`HighResSampleStorageError` ("Unable to close all doors").""" + self._require_no_unresolved_transfer() + if await self.request_spatula_is_holding(): + raise RuntimeError("Cannot home while the spatula reports that it is holding a plate.") + logger.info("Homing %s", self.name) + await self._send_command("home", timeout=self.motion_timeout) + logger.info("Homed %s", self.name) + + async def request_is_parked(self) -> bool: + """Whether the machine is genuinely safe to move: homed AND the spatula + slide/lift axes are retracted. + + Prefer this over :meth:`request_is_homed`. ``homedstatus`` reports homed even while + the spatula is stuck extended in a stacker after a faulted top-slot pick, so + it alone is not a safe-state check; this also verifies the slide (Y) and + lift (Z) axes are near their home positions. + """ + if not await self.request_is_homed(): + return False + positions = await self.request_axis_positions() + y = positions.get("Y axis") + z = positions.get("Z axis") + return ( + y is not None + and z is not None + and abs(y) < self._retracted_y_max + and abs(z) < self._retracted_z_max + ) + + async def recover(self) -> bool: + """Retract the spatula and re-home after a motion fault. + + A faulted command (e.g. an empty-slot ``pick`` in the top few slots) can + leave the spatula extended. This ALWAYS issues the retract (``spatulaout``) + + ``home`` — it does not trust ``homedstatus`` to decide whether recovery is + needed, because that reports homed even while the spatula is stuck extended. + Automatic recovery is refused while the spatula plate sensor is active, + because the plate cannot be relocated safely without physical inspection. + Retries a few times. Returns ``True`` once :meth:`request_is_parked`. + """ + if await self.request_spatula_is_holding(): + raise RuntimeError( + "Cannot recover automatically while the spatula reports that it is holding a plate." + ) + + logger.warning("Starting motion recovery for %s", self.name) + for attempt in range(1, 4): + for command in ("enable", "spatulaout"): + try: + await self._send_command(command, timeout=self.motion_timeout) + except HighResSampleStorageError as exc: + logger.warning( + "Recovery attempt %d: %s failed on %s: %s", + attempt, + command, + self.name, + exc, + ) + try: + await self._send_command("home", timeout=self.motion_timeout) + except HighResSampleStorageError as exc: + logger.warning("Recovery attempt %d: home failed on %s: %s", attempt, self.name, exc) + if await self.request_is_parked(): + logger.info("Motion recovery completed for %s on attempt %d", self.name, attempt) + return True + logger.error("Motion recovery failed for %s after 3 attempts", self.name) + return False + + async def _pick(self, stacker: int, slot: int, nest: int, close_door: bool = True) -> None: + """Retrieve a plate from ``(stacker, slot)`` to ``nest``. + + ``close_door=False`` re-opens the doors after the transfer (see :meth:`_place`). + + On failure the error is classified; no automatic motion is performed: + + - :class:`PlateNotFoundError` — the slot was empty ("No plate detected") + and the store retracted cleanly; the machine is safe to keep using. + - :class:`HighResSampleStorageFault` — the machine was left unsafe (spatula extended + / unhomed), e.g. an empty *top* slot where the firmware can't complete its + safe-travel retract. Call :meth:`recover` before any further motion. + + Note: ``homedstatus`` reports homed even when the spatula is stuck extended + at a top slot, so the error stack and live axis positions are both used to + determine whether the machine is parked. + """ + command = f"pick {stacker} {slot} {nest}" + logger.info( + "Moving a plate in %s from stacker %d slot %d to nest %d", + self.name, + stacker, + slot, + nest, + ) + try: + await self._send_command(command, timeout=self.motion_timeout) + except HighResSampleStorageError as exc: + if left_unsafe(exc.error_lines) or not await self.request_is_parked(): + logger.error("Pick left %s unsafe: %s", self.name, exc) + raise HighResSampleStorageFault(command, exc.error_lines) from exc + if any("no plate detected" in line.lower() for line in exc.error_lines): + logger.warning("No plate found in %s stacker %d slot %d", self.name, stacker, slot) + raise PlateNotFoundError(command, exc.error_lines) from exc + logger.error("Pick failed on %s: %s", self.name, exc) + raise + if not close_door: + await self.open_all_doors() + + async def _place(self, stacker: int, slot: int, nest: int, close_door: bool = True) -> None: + """Place the plate at ``nest`` into ``(stacker, slot)``. + + The store re-seals its doors as part of every transfer, so ``close_door`` + controls only the *end* state: with ``close_door=False`` the doors are + re-opened after the place, leaving the carousel accessible for a following + operation (handy when the cold environment doesn't matter). The default + leaves it sealed. + """ + command = f"place {stacker} {slot} {nest}" + logger.info( + "Moving a plate in %s from nest %d to stacker %d slot %d", + self.name, + nest, + stacker, + slot, + ) + try: + await self._send_command(command, timeout=self.motion_timeout) + except HighResSampleStorageError as exc: + if left_unsafe(exc.error_lines) or not await self.request_is_parked(): + logger.error("Place left %s unsafe: %s", self.name, exc) + raise HighResSampleStorageFault(command, exc.error_lines) from exc + logger.error("Place failed on %s: %s", self.name, exc) + raise + if not close_door: + await self.open_all_doors() + + @staticmethod + def _robot_doors_reached( + doors: Dict[str, DoorState], acceptable_states: Tuple[DoorState, ...] + ) -> bool: + robot_doors = [state for name, state in doors.items() if name.casefold() != "user door"] + return bool(robot_doors) and all(state in acceptable_states for state in robot_doors) + + async def _wait_for_robot_doors( + self, target: Literal["open", "closed"], moving: Literal["opening", "closing"] + ) -> bool: + """Wait for every robot door to reach ``target`` after a firmware error. + + Returns ``False`` immediately if a door reports a contradictory or unknown + state, or after the configured motion timeout expires. + """ + deadline = asyncio.get_running_loop().time() + self.motion_timeout + while True: + doors = await self.request_door_status() + if self._robot_doors_reached(doors, acceptable_states=(target,)): + return True + if not self._robot_doors_reached(doors, acceptable_states=(target, moving)): + return False + remaining = deadline - asyncio.get_running_loop().time() + if remaining <= 0: + return False + await asyncio.sleep(min(0.1, remaining)) + + async def open_all_doors(self) -> None: + """Open every pneumatic robot door. + + Firmware 3.0.0.119 can return ``ERROR!`` after completing this operation. + In that case the door sensors are the authoritative postcondition; the + original command error is re-raised unless every robot door reaches open. + Transitional states are polled up to :attr:`motion_timeout`. The manual user + door is intentionally excluded. + """ + try: + logger.info("Opening robot doors on %s", self.name) + await self._send_command("openalldoors", timeout=self.motion_timeout) + except HighResSampleStorageError: + logger.warning("Open-all-doors returned an error on %s; checking door sensors", self.name) + if await self._wait_for_robot_doors(target="open", moving="opening"): + logger.info("Robot doors on %s reached open despite firmware error", self.name) + return + raise + + async def close_all_doors(self) -> None: + """Close every pneumatic robot door. + + As with :meth:`open_all_doors`, accept an erroneous completion only when + the live door report reaches the requested final state. + """ + try: + logger.info("Closing robot doors on %s", self.name) + await self._send_command("closealldoors", timeout=self.motion_timeout) + except HighResSampleStorageError: + logger.warning("Close-all-doors returned an error on %s; checking door sensors", self.name) + if await self._wait_for_robot_doors(target="closed", moving="closing"): + logger.info("Robot doors on %s reached closed despite firmware error", self.name) + return + raise + + async def clear_abort(self) -> None: + """Clear an abort state reported by the device. + + Firmware 3.0.0.119 exposes ``clearabort`` but no command for initiating an + abort, so this is recovery for device- or externally-initiated aborts. + """ + logger.info("Clearing abort state on %s", self.name) + await self._send_command("clearabort") + + # --- plate retrieval ------------------------------------------------------- + + @property + def racks(self) -> List[PlateCarrier]: + return list(self._racks_by_number.values()) + + def get_num_free_sites(self) -> int: + return sum(len(rack.get_free_sites()) for rack in self._racks_by_number.values()) + + def get_site_by_plate_name(self, plate_name: str) -> PlateHolder: + for rack in self._racks_by_number.values(): + for site in rack.sites.values(): + if site.resource is not None and site.resource.name == plate_name: + return site + raise ResourceNotFoundError(f"Plate {plate_name!r} not found in {self.name!r}.") + + def _find_available_sites_sorted(self, plate: Plate) -> List[PlateHolder]: + plate_height = plate.get_size_z() + if plate.lid is not None: + lid_location = plate.get_lid_location(plate.lid) + plate_height = max(plate_height, lid_location.z + plate.lid.get_size_z()) + available = [ + site + for rack in self._racks_by_number.values() + for site in rack.get_free_sites() + if site.get_size_z() >= plate_height + ] + if not available: + raise NoFreeSiteError( + f"No free site at least {plate_height:g} mm high found for plate {plate.name!r}." + ) + return sorted(available, key=lambda site: site.get_size_z()) + + async def _require_nest_states(self, expected: Dict[int, NestState]) -> None: + """Require exact live sensor states before issuing a transfer command.""" + actual = await self.request_nest_status() + for nest, expected_state in expected.items(): + actual_state = actual.get(nest) + if actual_state != expected_state: + raise RuntimeError( + f"Cannot transfer plate: nest {nest} must be {expected_state}, " + f"but its sensor reports {actual_state or 'missing'}." + ) + + def _require_no_unresolved_transfer(self) -> None: + transfer = self._unresolved_transfer + if transfer is not None: + raise RuntimeError( + f"Plate location is unresolved after transfer command {transfer.command!r}; " + "recover the machine if necessary, then call resolve_unresolved_transfer() " + "before another plate move, home, or barcode scan." + ) + + def _record_unresolved_transfer( + self, + *, + plate: Plate, + source: PlateHolder, + destination: PlateHolder, + command: str, + error: BaseException, + ) -> None: + self._unresolved_transfer = UnresolvedPlateTransfer( + plate=plate, + source=source, + destination=destination, + command=command, + error_type=type(error).__name__, + error_message=str(error), + ) + logger.error( + "Plate %s transfer via %r is unresolved between %s and %s: %s", + plate.name, + command, + source.name, + destination.name, + error, + ) + + async def _execute_plate_transfer( + self, + *, + plate: Plate, + source: PlateHolder, + destination: PlateHolder, + command: str, + move: Callable[[], Awaitable[object]], + plate_not_found_is_definitive: bool = False, + ) -> Plate: + """Execute one physical move and commit its resource-tree transition.""" + self._require_no_unresolved_transfer() + try: + await move() + except BaseException as error: + if not (plate_not_found_is_definitive and isinstance(error, PlateNotFoundError)): + self._record_unresolved_transfer( + plate=plate, + source=source, + destination=destination, + command=command, + error=error, + ) + raise + + try: + plate.unassign() + destination.assign_child_resource(plate) + except BaseException as error: + # The device completed the move, but PLR could not commit the same state. + # Keep the transfer unresolved so a caller must reconcile both views. + self._record_unresolved_transfer( + plate=plate, + source=source, + destination=destination, + command=command, + error=error, + ) + raise + return plate + + async def resolve_unresolved_transfer( + self, location: Literal["source", "destination", "unassigned"] + ) -> Plate: + """Reconcile an ambiguous plate move without issuing further motion. + + The spatula must be empty. Any source or destination nest is checked against + its live plate sensor. Stacker slots have no non-destructive presence sensor, + so selecting a stacker endpoint is explicit operator confirmation that the + plate was found there. Use ``"unassigned"`` when inspection establishes that + the plate is outside the modeled source and destination. + """ + async with self._transfer_lock: + transfer = self._unresolved_transfer + if transfer is None: + raise RuntimeError("There is no unresolved plate transfer.") + if location not in ("source", "destination", "unassigned"): + raise ValueError( + f"location must be 'source', 'destination', or 'unassigned'; got {location!r}." + ) + + await self._reconnect_invalidated_transport() + + target = ( + transfer.source + if location == "source" + else transfer.destination + if location == "destination" + else None + ) + if target is not None and transfer.plate.parent is not target: + target.check_can_drop_resource_here(transfer.plate) + + if await self.request_spatula_is_holding(): + raise RuntimeError( + "Cannot resolve the plate location while the spatula reports that it is holding a plate." + ) + + expected_nests: Dict[int, NestState] = {} + for tray_index, nest in enumerate(self.nests): + if nest is transfer.source or nest is transfer.destination: + expected_nests[tray_index + 1] = "occupied" if nest is target else "clear" + if expected_nests: + await self._require_nest_states(expected_nests) + + if target is None: + transfer.plate.unassign() + elif transfer.plate.parent is not target: + transfer.plate.unassign() + target.assign_child_resource(transfer.plate) + + self._unresolved_transfer = None + logger.info( + "Resolved plate %s after %r as %s", + transfer.plate.name, + transfer.command, + target.name if target is not None else "unassigned", + ) + return transfer.plate + + def find_smallest_site_for_plate(self, plate: Plate) -> PlateHolder: + return self._find_available_sites_sorted(plate)[0] + + def find_random_site(self, plate: Plate) -> PlateHolder: + return random.choice(self._find_available_sites_sorted(plate)) + + def _locate(self, site: PlateHolder) -> Tuple[int, int]: + rack = site.parent + if not isinstance(rack, PlateCarrier): + raise ValueError(f"Site '{site.name}' is not attached to a plate carrier.") + + stacker = next( + ( + number + for number, configured_rack in self._racks_by_number.items() + if configured_rack is rack + ), + None, + ) + if stacker is None: + raise ValueError(f"Site '{site.name}' is not in a known stacker.") + + spot = next( + (spot for spot, configured_site in rack.sites.items() if configured_site is site), None + ) + if spot is None: + raise ValueError(f"Site '{site.name}' is not a known slot in stacker {stacker}.") + # PLR carrier spots are zero-based; HighRes stacker slots are one-based. + return stacker, spot + 1 + + def _nest_for_tray(self, tray_index: int) -> int: + """Map a 0-based tray index to its one-based device nest number.""" + if not self.nests: + raise RuntimeError("Nests have not been loaded; call setup() first.") + if not 0 <= tray_index < len(self.nests): + raise ValueError( + f"sample store has trays 0..{len(self.nests) - 1}; got tray_index={tray_index}." + ) + return tray_index + 1 + + async def fetch_plate_to_loading_tray(self, plate: Union[Plate, str], tray_index: int) -> Plate: + async with self._transfer_lock: + self._require_no_unresolved_transfer() + if isinstance(plate, str): + stored_site = self.get_site_by_plate_name(plate) + stored_resource = stored_site.resource + if not isinstance(stored_resource, Plate): + raise ResourceNotFoundError(f"Plate {plate!r} not found in {self.name!r}.") + plate = stored_resource + parent = plate.parent + if not isinstance(parent, PlateHolder): + raise ValueError(f"Plate '{plate.name}' is not in a stacker slot.") + stacker, slot = self._locate(parent) + nest_number = self._nest_for_tray(tray_index) + nest = self.nests[tray_index] + nest.check_can_drop_resource_here(plate) + await self._require_nest_states({nest_number: "clear"}) + command = f"pick {stacker} {slot} {nest_number}" + + with event_operation( + "incubator.fetch_plate", + device=resource_reference(self), + resources=[resource_reference(plate)], + source=resource_reference(parent), + destination=resource_reference(nest), + ): + return await self._execute_plate_transfer( + plate=plate, + source=parent, + destination=nest, + command=command, + move=lambda: self._pick(stacker, slot, nest_number), + plate_not_found_is_definitive=True, + ) + + async def take_in_plate( + self, + tray_index: int, + site: Union[PlateHolder, Literal["random", "smallest"]] = "smallest", + ) -> Plate: + async with self._transfer_lock: + self._require_no_unresolved_transfer() + self._nest_for_tray(tray_index) + plate = self.nests[tray_index].resource + if not isinstance(plate, Plate): + raise ResourceNotFoundError(f"No plate on tray {tray_index}.") + + if site == "random": + destination = self.find_random_site(plate) + elif site == "smallest": + destination = self.find_smallest_site_for_plate(plate) + elif isinstance(site, PlateHolder): + if site not in self._find_available_sites_sorted(plate): + raise ValueError(f"Site {site.name!r} is not available for plate {plate.name!r}.") + destination = site + else: + raise ValueError(f"Invalid site: {site!r}") + + nest = self.nests[tray_index] + with event_operation( + "incubator.take_in_plate", + device=resource_reference(self), + resources=[resource_reference(plate)], + source=resource_reference(nest), + destination=resource_reference(destination), + ): + await self._store_plate(plate, destination, tray_index) + return plate + + async def store_plate(self, plate: Plate, site: PlateHolder, tray_index: int) -> None: + async with self._transfer_lock: + self._require_no_unresolved_transfer() + self._nest_for_tray(tray_index) + nest = self.nests[tray_index] + with event_operation( + "incubator.take_in_plate", + device=resource_reference(self), + resources=[resource_reference(plate)], + source=resource_reference(nest), + destination=resource_reference(site), + ): + await self._store_plate(plate, site, tray_index) + + async def transfer_plate_between_nests( + self, source_tray_index: int, destination_tray_index: int + ) -> Plate: + """Move a plate between two transfer nests. + + Tray indices are zero-based and map to the sorted nest numbers reported by + the device during :meth:`setup`. + """ + async with self._transfer_lock: + self._require_no_unresolved_transfer() + if source_tray_index == destination_tray_index: + raise ValueError("Source and destination tray indices must be different.") + source_number = self._nest_for_tray(source_tray_index) + destination_number = self._nest_for_tray(destination_tray_index) + source = self.nests[source_tray_index] + destination = self.nests[destination_tray_index] + plate = source.resource + if not isinstance(plate, Plate): + raise ResourceNotFoundError(f"No plate on tray {source_tray_index}.") + destination.check_can_drop_resource_here(plate) + await self._require_nest_states({source_number: "occupied", destination_number: "clear"}) + command = f"nesttransfer {source_number} {destination_number}" + + with event_operation( + "incubator.transfer_plate", + device=resource_reference(self), + resources=[resource_reference(plate)], + source=resource_reference(source), + destination=resource_reference(destination), + ): + logger.info( + "Moving plate %s in %s from nest %d to nest %d", + plate.name, + self.name, + source_number, + destination_number, + ) + return await self._execute_plate_transfer( + plate=plate, + source=source, + destination=destination, + command=command, + move=lambda: self._send_command(command, timeout=self.motion_timeout), + ) + + async def _store_plate(self, plate: Plate, site: PlateHolder, tray_index: int) -> None: + stacker, slot = self._locate(site) + nest_number = self._nest_for_tray(tray_index) + nest = self.nests[tray_index] + if plate.parent is not nest: + raise ValueError(f"Plate '{plate.name}' is not on tray {tray_index}.") + site.check_can_drop_resource_here(plate) + await self._require_nest_states({nest_number: "occupied"}) + command = f"place {stacker} {slot} {nest_number}" + + await self._execute_plate_transfer( + plate=plate, + source=nest, + destination=site, + command=command, + move=lambda: self._place(stacker, slot, nest_number), + ) diff --git a/pylabrobot/high_res/sample_storage/driver/environment.py b/pylabrobot/high_res/sample_storage/driver/environment.py new file mode 100644 index 00000000000..1900359a569 --- /dev/null +++ b/pylabrobot/high_res/sample_storage/driver/environment.py @@ -0,0 +1,239 @@ +import logging +from typing import TYPE_CHECKING, Any, Dict, Optional, Tuple + +from pylabrobot.events import evented_operation, resource_reference + +from .errors import HighResSampleStorageError +from .types import EnvironmentParameter + +if TYPE_CHECKING: + from .driver import HighResSampleStorage + +logger = logging.getLogger(__name__) + + +def _control_event_context(self: "EnvironmentControl") -> Dict[str, Any]: + return {"device": resource_reference(self._driver), "resources": []} + + +def _set_temperature_event_context( + self: "EnvironmentControl", temperature: float +) -> Dict[str, Any]: + return { + **_control_event_context(self), + "target_temperature": float(temperature), + "passive": False, + } + + +def _set_humidity_event_context(self: "EnvironmentControl", humidity: float) -> Dict[str, Any]: + return {**_control_event_context(self), "target_humidity": float(humidity)} + + +def _set_co2_event_context(self: "EnvironmentControl", co2: float) -> Dict[str, Any]: + return {**_control_event_context(self), "target_co2": float(co2)} + + +def _set_o2_event_context(self: "EnvironmentControl", o2: float) -> Dict[str, Any]: + return {**_control_event_context(self), "target_o2": float(o2)} + + +class EnvironmentControl: + """Temperature, humidity, and gas control for a HighRes sample store. + + Concentrations and relative humidity use fractions in the public API. The + device protocol uses percentages, so ``0.05`` CO2 is sent as ``5``. + """ + + def __init__(self, driver: "HighResSampleStorage") -> None: + super().__init__() + self._driver = driver + self._parameters: Dict[str, EnvironmentParameter] = {} + + async def refresh(self) -> Dict[str, EnvironmentParameter]: + """Read and cache all environmental channels reported by the device.""" + self._parameters = await self._driver.request_environment() + return dict(self._parameters) + + @property + def parameters(self) -> Dict[str, EnvironmentParameter]: + """The environmental channels from the most recent read.""" + return dict(self._parameters) + + def _cached_channel_is_controllable(self, channel: str) -> Optional[bool]: + if not self._parameters: + return None + parameter = self._parameters.get(channel) + return parameter is not None and parameter.setpoint is not None + + async def _request_parameter(self, channel: str) -> EnvironmentParameter: + parameters = await self.refresh() + try: + return parameters[channel] + except KeyError as exc: + raise HighResSampleStorageError( + "environmentstatus", [f"no {channel} channel reported"] + ) from exc + + async def _request_setpoint(self, channel: str) -> float: + parameter = await self._request_parameter(channel) + if parameter.setpoint is None: + raise HighResSampleStorageError( + "environmentstatus", [f"{channel} does not report a setpoint"] + ) + return parameter.setpoint + + async def _require_controllable_channel(self, channel: str) -> EnvironmentParameter: + parameter = (await self.refresh()).get(channel) + if parameter is None or parameter.setpoint is None: + raise NotImplementedError(f"The installed device does not control {channel}.") + return parameter + + async def _set_percentage(self, channel: str, value: float) -> None: + if not 0.0 <= value <= 1.0: + raise ValueError(f"{channel} must be between 0 and 1.") + await self._require_controllable_channel(channel) + logger.info("Setting %s %s target to %g", self._driver.name, channel, value) + await self._driver._send_command(f"environmentset {channel} {value * 100:g}") + + async def _set_control_enabled(self, channel: str, enabled: bool) -> None: + await self._require_controllable_channel(channel) + action = "enable" if enabled else "disable" + logger.info("Setting %s %s control to %s", self._driver.name, channel, action) + await self._driver._send_command(f"environment {action} {channel.lower()}") + + @property + def supports_active_cooling(self) -> bool: + return self._driver.model_info.supports_active_cooling + + @property + def supports_heating(self) -> bool: + return self._driver.model_info.supports_heating + + @property + def temperature_range(self) -> Optional[Tuple[float, float]]: + return self._driver.model_info.temperature_range + + @property + def humidity_range(self) -> Optional[Tuple[float, float]]: + return self._driver.model_info.humidity_range + + async def request_current_temperature(self) -> float: + return (await self._request_parameter("TEMP")).current + + async def request_target_temperature(self) -> float: + return await self._request_setpoint("TEMP") + + @evented_operation("temperature_controller.set_temperature", _set_temperature_event_context) + async def set_temperature(self, temperature: float) -> None: + temperature_range = self.temperature_range + if temperature_range is not None: + minimum, maximum = temperature_range + if not minimum <= temperature <= maximum: + raise ValueError( + f"temperature must be between {minimum:g} and {maximum:g} C for {self._driver.model}." + ) + installed = await self._require_controllable_channel("TEMP") + if installed.limit is not None and temperature > installed.limit: + raise ValueError( + f"temperature must not exceed the installed limit of {installed.limit:g} C " + f"for {self._driver.model}." + ) + logger.info("Setting %s temperature target to %g C", self._driver.name, temperature) + await self._driver._send_command(f"environmentset TEMP {temperature}") + + @evented_operation("temperature_controller.activate", _control_event_context) + async def start_temperature_control(self) -> None: + await self._set_control_enabled("TEMP", True) + + @evented_operation("temperature_controller.deactivate", _control_event_context) + async def stop_temperature_control(self) -> None: + await self._set_control_enabled("TEMP", False) + + @property + def supports_humidity_control(self) -> bool: + installed = self._cached_channel_is_controllable("RH") + if installed is not None: + return installed + return self._driver.model_info.supports_humidity_control + + async def request_current_humidity(self) -> float: + return (await self._request_parameter("RH")).current / 100.0 + + async def request_target_humidity(self) -> float: + return await self._request_setpoint("RH") / 100.0 + + @evented_operation("humidity_controller.set_humidity", _set_humidity_event_context) + async def set_humidity(self, humidity: float) -> None: + humidity_range = self.humidity_range + if humidity_range is not None and not humidity_range[0] <= humidity <= humidity_range[1]: + raise ValueError( + f"humidity must be between {humidity_range[0]:g} and {humidity_range[1]:g} " + f"for {self._driver.model}." + ) + await self._set_percentage("RH", humidity) + + @evented_operation("humidity_controller.activate", _control_event_context) + async def start_humidity_control(self) -> None: + await self._set_control_enabled("RH", True) + + @evented_operation("humidity_controller.deactivate", _control_event_context) + async def stop_humidity_control(self) -> None: + await self._set_control_enabled("RH", False) + + @property + def supports_co2_control(self) -> bool: + installed = self._cached_channel_is_controllable("CO2") + if installed is not None: + return installed + return self._driver.model_info.supports_co2_control + + async def request_current_co2(self) -> float: + return (await self._request_parameter("CO2")).current / 100.0 + + async def request_target_co2(self) -> float: + return await self._request_setpoint("CO2") / 100.0 + + @evented_operation("co2_controller.set_co2", _set_co2_event_context) + async def set_co2(self, co2: float) -> None: + await self._set_percentage("CO2", co2) + + @evented_operation("co2_controller.activate", _control_event_context) + async def start_co2_control(self) -> None: + await self._set_control_enabled("CO2", True) + + @evented_operation("co2_controller.deactivate", _control_event_context) + async def stop_co2_control(self) -> None: + await self._set_control_enabled("CO2", False) + + @property + def supports_o2_control(self) -> bool: + installed = self._cached_channel_is_controllable("O2") + if installed is not None: + return installed + return self._driver.model_info.supports_o2_control + + async def request_current_o2(self) -> float: + return (await self._request_parameter("O2")).current / 100.0 + + async def request_target_o2(self) -> float: + return await self._request_setpoint("O2") / 100.0 + + @evented_operation("o2_controller.set_o2", _set_o2_event_context) + async def set_o2(self, o2: float) -> None: + await self._set_percentage("O2", o2) + + @evented_operation("o2_controller.activate", _control_event_context) + async def start_o2_control(self) -> None: + await self._set_control_enabled("O2", True) + + @evented_operation("o2_controller.deactivate", _control_event_context) + async def stop_o2_control(self) -> None: + await self._set_control_enabled("O2", False) + + async def request_tank_pressures(self) -> Dict[str, float]: + """Return ``TANK*`` sensor readings in the device-reported pressure unit.""" + parameters = await self.refresh() + return { + name: parameter.current for name, parameter in parameters.items() if name.startswith("TANK") + } diff --git a/pylabrobot/high_res/sample_storage/driver/errors.py b/pylabrobot/high_res/sample_storage/driver/errors.py new file mode 100644 index 00000000000..35c96948987 --- /dev/null +++ b/pylabrobot/high_res/sample_storage/driver/errors.py @@ -0,0 +1,83 @@ +from typing import List + + +class NoFreeSiteError(Exception): + """Raised when no free stacker site can accommodate a plate.""" + + +class HighResSampleStorageError(Exception): + """A command returned an ``ERROR!`` completion status. + + The TundraStore reports failures as an error stack: the completion line is + preceded (firmware 3.0.x) by one or more ``Error : ...`` lines, the last of + which is generally the most pertinent. Those lines are preserved in + :attr:`error_lines`. + """ + + def __init__(self, command: str, error_lines: List[str]) -> None: + self.command = command + self.error_lines = error_lines + detail = error_lines[-1] if error_lines else "no error detail returned" + super().__init__(f"'{command}' failed: {detail}") + + +class HighResSampleStorageAbortedError(Exception): + """A command returned an ``ABORTED!`` completion status.""" + + def __init__(self, command: str) -> None: + self.command = command + super().__init__(f"'{command}' was aborted") + + +class HighResSampleStorageProtocolError(Exception): + """The device returned a malformed or mismatched command response.""" + + def __init__(self, command: str, response: str, detail: str) -> None: + self.command = command + self.response = response + self.detail = detail + super().__init__(f"Invalid response to {command!r}: {detail}; received {response!r}") + + +class PlateNotFoundError(HighResSampleStorageError): + """A pick found no plate in the target slot ("No plate detected"). + + This is the normal *empty slot* outcome: the store's height detector reports + the absence and the machine stays homed and operational — not a fault. + Contrast with :class:`HighResSampleStorageFault` (the machine de-homed). Note that an + empty *top* slot raises a fault instead, because the firmware can't complete + its safe-travel retract from the topmost position. + """ + + +class HighResSampleStorageFault(HighResSampleStorageError): + """A motion command faulted and left the machine UNHOMED/extended. + + The canonical trigger is picking an empty *top* slot. The machine is not + usable until recovered — call + :meth:`HighResSampleStorage.recover` (retract the spatula and re-home) before + issuing further motion. + """ + + def __init__(self, command: str, error_lines: List[str]) -> None: + super().__init__(command, error_lines) + self.args = (f"{self.args[0]}; machine is unsafe — call recover()",) + + +# Error-stack substrings meaning the spatula was left extended / unsafe to move, +# even when ``homedstatus`` still reports homed (it does exactly that when the +# spatula is stuck extended at a top slot). Recover before any further motion. +_UNSAFE_SIGNATURES = ( + "unsafe for rotation", + "safe travel position", + "crash occurred", + "sensor was tripped", + "machine must be homed", +) + + +def left_unsafe(error_lines: List[str]) -> bool: + """Whether an error stack indicates the machine was left unsafe (spatula + extended / unhomed), requiring :meth:`HighResSampleStorage.recover`.""" + blob = " ".join(error_lines).lower() + return any(sig in blob for sig in _UNSAFE_SIGNATURES) diff --git a/pylabrobot/high_res/sample_storage/driver/models.py b/pylabrobot/high_res/sample_storage/driver/models.py new file mode 100644 index 00000000000..8b817f626fb --- /dev/null +++ b/pylabrobot/high_res/sample_storage/driver/models.py @@ -0,0 +1,74 @@ +from dataclasses import dataclass +from typing import Dict, Optional, Tuple + + +@dataclass(frozen=True) +class ModelInfo: + """Environmental capabilities and limits for one sample-store model.""" + + has_environment_control: bool + temperature_range: Optional[Tuple[float, float]] + humidity_range: Optional[Tuple[float, float]] + supports_heating: bool + supports_active_cooling: bool + supports_humidity_control: bool + supports_co2_control: bool + supports_o2_control: bool + + +_STERISTORE_INFO = ModelInfo( + has_environment_control=True, + temperature_range=(4.0, 100.0), + humidity_range=(0.0, 0.98), + supports_heating=True, + supports_active_cooling=True, + supports_humidity_control=True, + supports_co2_control=True, + # O2 regulation is optional, so it is discovered from environmentstatus. + supports_o2_control=False, +) + + +_MODEL_INFO: Dict[str, ModelInfo] = { + "HighResSampleStorage": ModelInfo( + has_environment_control=True, + temperature_range=None, + humidity_range=None, + supports_heating=False, + supports_active_cooling=False, + supports_humidity_control=False, + supports_co2_control=False, + supports_o2_control=False, + ), + "AmbiStore": ModelInfo( + has_environment_control=False, + temperature_range=None, + humidity_range=None, + supports_heating=False, + supports_active_cooling=False, + supports_humidity_control=False, + supports_co2_control=False, + supports_o2_control=False, + ), + "SteriStore": _STERISTORE_INFO, + "SteriStore2": _STERISTORE_INFO, + "TundraStore": ModelInfo( + has_environment_control=True, + temperature_range=(-20.0, 4.0), + # The supported RH range depends on the configured temperature. + humidity_range=None, + supports_heating=False, + supports_active_cooling=True, + supports_humidity_control=True, + supports_co2_control=False, + supports_o2_control=False, + ), +} + + +def get_model_info(model_name: str) -> ModelInfo: + """Return the known values for a user-configured sample-store model.""" + try: + return _MODEL_INFO[model_name] + except KeyError as exc: + raise ValueError(f"Unknown HighRes sample store model: {model_name!r}") from exc diff --git a/pylabrobot/high_res/sample_storage/driver/protocol.py b/pylabrobot/high_res/sample_storage/driver/protocol.py new file mode 100644 index 00000000000..89bf134b9f0 --- /dev/null +++ b/pylabrobot/high_res/sample_storage/driver/protocol.py @@ -0,0 +1,23 @@ +"""Low-level wire-protocol constants and parsing helpers.""" + +from typing import Dict, List + +# Completion-status tokens that terminate a command's reply (see the manual, +# "Message Formatting"). Every command ends with exactly one of these. +COMPLETION_OK = "OK!" +COMPLETION_ABORTED = "ABORTED!" +COMPLETION_ERROR = "ERROR!" +COMPLETION_TOKENS = (COMPLETION_OK, COMPLETION_ABORTED, COMPLETION_ERROR) + +# Immediate command-receipt echo prefix. +ACK_TOKEN = "ACK!" + + +def parse_kv(lines: List[str]) -> Dict[str, str]: + """Parse ``Key: value`` lines into a dict (first colon splits).""" + out: Dict[str, str] = {} + for line in lines: + if ":" in line: + key, _, value = line.partition(":") + out[key.strip()] = value.strip() + return out diff --git a/pylabrobot/high_res/sample_storage/driver/settings.py b/pylabrobot/high_res/sample_storage/driver/settings.py new file mode 100644 index 00000000000..86ac921ed9a --- /dev/null +++ b/pylabrobot/high_res/sample_storage/driver/settings.py @@ -0,0 +1,710 @@ +"""Typed, immutable view of a HighRes sample store's on-device settings. + +The device exposes its full calibration/configuration via the ``settings`` +command as ``NAME = value`` text. Each key is surfaced here as one explicitly +typed attribute (the device ``NAME`` lower-cased); types are inferred from the +device's own values. A :class:`HighResSampleStorageSettings` is loaded whole from the +device (or a capture) and is frozen once built. Firmware/model-specific keys are +preserved in :attr:`HighResSampleStorageSettings.raw`; known keys that a device +does not report remain ``None``. +""" + +import logging +from dataclasses import dataclass, field, fields +from types import MappingProxyType +from typing import Any, Dict, Iterable, Mapping, Optional, Tuple, get_args + +logger = logging.getLogger(__name__) + + +# Known family names reported by the device's MACHINE_TYPE setting. The wire +# value remains a string because firmware variants may introduce additional +# model names without requiring a driver release just to parse their settings. +MachineType = str +KNOWN_MACHINE_TYPES: Tuple[str, ...] = ( + "AmbiStore", + "SteriStore", + "SteriStore2", + "TundraStore", +) + + +@dataclass(frozen=True) +class HighResSampleStorageSettings: + """Known on-device settings plus the complete raw key/value response.""" + + product_name: Optional[str] = None + product_description: Optional[str] = None + + serial_number: Optional[str] = None + + machine_type: Optional[MachineType] = None + + rest_server_port: Optional[int] = None + + syslog_server: Optional[str] = None + syslog_level: Optional[int] = None + + internal_log_level: Optional[int] = None + + carousel_home_speed_fast: Optional[float] = None + carousel_home_speed_slow: Optional[float] = None + carousel_home_acceleration: Optional[float] = None + carousel_velocity: Optional[float] = None + carousel_idle_velocity: Optional[float] = None + carousel_acceleration: Optional[float] = None + carousel_abort_deceleration: Optional[float] = None + carousel_jerk: Optional[float] = None + carousel_final_drive_jerk: Optional[float] = None + carousel_stacker_0_pos: Optional[float] = None + carousel_stacker_1_pos: Optional[float] = None + carousel_stacker_2_pos: Optional[float] = None + carousel_stacker_count: Optional[int] = None + carousel_count: Optional[int] = None + carousel_calibration_offset: Optional[float] = None + + spatula_home_speed_fast: Optional[float] = None + spatula_home_speed_slow: Optional[float] = None + spatula_home_acceleration: Optional[float] = None + spatula_velocity: Optional[float] = None + spatula_velocity_with_plate: Optional[float] = None + spatula_acceleration: Optional[float] = None + spatula_abort_deceleration: Optional[float] = None + spatula_jerk: Optional[float] = None + spatula_rot_home_speed_fast: Optional[float] = None + spatula_rot_home_speed_slow: Optional[float] = None + spatula_rot_home_acceleration: Optional[float] = None + spatula_rot_velocity: Optional[float] = None + spatula_rot_acceleration: Optional[float] = None + spatula_rot_abort_deceleration: Optional[float] = None + spatula_rot_jerk: Optional[float] = None + spatula_rot_zero_pos: Optional[float] = None + spatula_rot_stack_pos_0: Optional[float] = None + spatula_rot_stack_pos_1: Optional[float] = None + spatula_rot_stack_pos_2: Optional[float] = None + spatula_rot_nest_1_pos: Optional[float] = None + spatula_rot_nest_2_pos: Optional[float] = None + spatula_rot_nest_3_pos: Optional[float] = None + spatula_rot_nest_4_pos: Optional[float] = None + spatula_rot_nest_5_pos: Optional[float] = None + spatula_rot_nest_6_pos: Optional[float] = None + spatula_rot_nest_7_pos: Optional[float] = None + spatula_rot_nest_8_pos: Optional[float] = None + spatula_rot_nest_9_pos: Optional[float] = None + spatula_rot_nest_10_pos: Optional[float] = None + spatula_rot_nest_21_pos: Optional[float] = None + spatula_rot_nest_22_pos: Optional[float] = None + spatula_rot_nest_23_pos: Optional[float] = None + spatula_rot_nest_24_pos: Optional[float] = None + spatula_rot_nest_51_pos: Optional[float] = None + spatula_rot_nest_52_pos: Optional[float] = None + spatula_rot_nest_61_pos: Optional[float] = None + spatula_rot_nest_62_pos: Optional[float] = None + spatula_rot_nest_63_pos: Optional[float] = None + spatula_rot_nest_64_pos: Optional[float] = None + spatula_rot_nest_65_pos: Optional[float] = None + spatula_rot_nest_66_pos: Optional[float] = None + spatula_rot_nest_67_pos: Optional[float] = None + spatula_rot_nest_68_pos: Optional[float] = None + spatula_rot_nest_69_pos: Optional[float] = None + spatula_slide_home_speed_fast: Optional[float] = None + spatula_slide_home_speed_slow: Optional[float] = None + spatula_slide_home_acceleration: Optional[float] = None + spatula_slide_home_offset: Optional[float] = None + spatula_slide_velocity: Optional[float] = None + spatula_slide_acceleration: Optional[float] = None + spatula_slide_abort_deceleration: Optional[float] = None + spatula_slide_jerk: Optional[float] = None + spatula_slide_in_pos_0: Optional[float] = None + spatula_slide_in_pos_1: Optional[float] = None + spatula_slide_in_pos_2: Optional[float] = None + spatula_slide_nest_1_pos: Optional[float] = None + spatula_slide_nest_2_pos: Optional[float] = None + spatula_slide_nest_3_pos: Optional[float] = None + spatula_slide_nest_4_pos: Optional[float] = None + spatula_slide_nest_5_pos: Optional[float] = None + spatula_slide_nest_6_pos: Optional[float] = None + spatula_slide_nest_7_pos: Optional[float] = None + spatula_slide_nest_8_pos: Optional[float] = None + spatula_slide_nest_9_pos: Optional[float] = None + spatula_slide_nest_10_pos: Optional[float] = None + spatula_slide_nest_21_pos: Optional[float] = None + spatula_slide_nest_22_pos: Optional[float] = None + spatula_slide_nest_23_pos: Optional[float] = None + spatula_slide_nest_24_pos: Optional[float] = None + spatula_slide_nest_51_pos: Optional[float] = None + spatula_slide_nest_52_pos: Optional[float] = None + spatula_slide_nest_61_pos: Optional[float] = None + spatula_slide_nest_62_pos: Optional[float] = None + spatula_slide_nest_63_pos: Optional[float] = None + spatula_slide_nest_64_pos: Optional[float] = None + spatula_slide_nest_65_pos: Optional[float] = None + spatula_slide_nest_66_pos: Optional[float] = None + spatula_slide_nest_67_pos: Optional[float] = None + spatula_slide_nest_68_pos: Optional[float] = None + spatula_slide_nest_69_pos: Optional[float] = None + spatula_valve_hold: Optional[int] = None + spatula_plate_sensor: Optional[int] = None + spatula_plate_release_sensor: Optional[int] = None + + inner_user_door_sensor: Optional[int] = None + + door_open_sensor_output: Optional[int] = None + + nest_count: Optional[int] = None + nest_1_height: Optional[float] = None + nest_2_height: Optional[float] = None + nest_3_height: Optional[float] = None + nest_4_height: Optional[float] = None + nest_5_height: Optional[float] = None + nest_6_height: Optional[float] = None + nest_7_height: Optional[float] = None + nest_8_height: Optional[float] = None + nest_9_height: Optional[float] = None + nest_10_height: Optional[float] = None + nest_21_height: Optional[float] = None + nest_22_height: Optional[float] = None + nest_23_height: Optional[float] = None + nest_24_height: Optional[float] = None + nest_51_height: Optional[float] = None + nest_52_height: Optional[float] = None + nest_61_height: Optional[float] = None + nest_62_height: Optional[float] = None + nest_63_height: Optional[float] = None + nest_64_height: Optional[float] = None + nest_65_height: Optional[float] = None + nest_66_height: Optional[float] = None + nest_67_height: Optional[float] = None + nest_68_height: Optional[float] = None + nest_69_height: Optional[float] = None + nest_1_style: Optional[str] = None + nest_2_style: Optional[str] = None + nest_3_style: Optional[str] = None + nest_4_style: Optional[str] = None + nest_5_style: Optional[str] = None + nest_6_style: Optional[str] = None + nest_7_style: Optional[str] = None + nest_8_style: Optional[str] = None + nest_9_style: Optional[str] = None + nest_10_style: Optional[str] = None + nest_clearance_above: Optional[float] = None + nest_clearance_below: Optional[float] = None + + handover_nest_clearance_above: Optional[float] = None + handover_nest_clearance_below: Optional[float] = None + handover_y_rotation_position: Optional[float] = None + + conveyor_clearance_above: Optional[float] = None + conveyor_clearance_below: Optional[float] = None + + static_nest_clearance_above: Optional[float] = None + static_nest_clearance_below: Optional[float] = None + + io_nest_clearance_above: Optional[float] = None + io_nest_clearance_below: Optional[float] = None + + nest_1_sense_input: Optional[int] = None + nest_2_sense_input: Optional[int] = None + nest_3_sense_input: Optional[int] = None + nest_4_sense_input: Optional[int] = None + nest_5_sense_input: Optional[int] = None + nest_6_sense_input: Optional[int] = None + nest_7_sense_input: Optional[int] = None + nest_8_sense_input: Optional[int] = None + nest_9_sense_input: Optional[int] = None + nest_10_sense_input: Optional[int] = None + + stacker_base_0: Optional[float] = None + stacker_base_1: Optional[float] = None + stacker_base_2: Optional[float] = None + + barcode_base_0: Optional[float] = None + barcode_base_1: Optional[float] = None + + stacker_clearance_above: Optional[float] = None + stacker_clearance_below: Optional[float] = None + + barcode_scanner: Optional[str] = None + barcode_laser_start: Optional[int] = None + barcode_laser_stop: Optional[int] = None + barcode_velocity: Optional[float] = None + barcode_acceleration: Optional[float] = None + barcode_config_itf_enable: Optional[str] = None + barcode_config_itf_status: Optional[str] = None + barcode_config_itf_length_1: Optional[int] = None + barcode_config_itf_length_2: Optional[int] = None + barcode_config_itf_range: Optional[str] = None + + plate_hold_settle_time: Optional[int] = None + + door_0_position: Optional[float] = None + door_height: Optional[float] = None + door_overlap_negative: Optional[float] = None + door_overlap_positive: Optional[float] = None + door_gasket_valve: Optional[int] = None + + big_door_valve: Optional[int] = None + + door_1_valve: Optional[int] = None + door_2_valve: Optional[int] = None + door_3_valve: Optional[int] = None + door_4_valve: Optional[int] = None + door_5_valve: Optional[int] = None + door_6_valve: Optional[int] = None + door_7_valve: Optional[int] = None + door_8_valve: Optional[int] = None + door_1_open_sensor: Optional[int] = None + door_2_open_sensor: Optional[int] = None + door_3_open_sensor: Optional[int] = None + door_4_open_sensor: Optional[int] = None + door_5_open_sensor: Optional[int] = None + door_6_open_sensor: Optional[int] = None + door_7_open_sensor: Optional[int] = None + door_8_open_sensor: Optional[int] = None + door_1_close_sensor: Optional[int] = None + door_2_close_sensor: Optional[int] = None + door_3_close_sensor: Optional[int] = None + door_4_close_sensor: Optional[int] = None + door_5_close_sensor: Optional[int] = None + door_6_close_sensor: Optional[int] = None + door_7_close_sensor: Optional[int] = None + door_8_close_sensor: Optional[int] = None + door_ri_open_sensor: Optional[int] = None + door_ri_close_sensor: Optional[int] = None + + gasket_deflate_delay_ms: Optional[int] = None + gasket_inflate_delay_ms: Optional[int] = None + + big_door_open_delay_ms: Optional[int] = None + big_door_close_delay_ms: Optional[int] = None + + door_open_delay_ms: Optional[int] = None + door_close_delay_ms: Optional[int] = None + door_open_signal_active_level: Optional[int] = None + + vac_1_enable: Optional[int] = None + vac_2_enable: Optional[int] = None + vac_1_purge: Optional[int] = None + vac_2_purge: Optional[int] = None + + lift_1_enable: Optional[int] = None + lift_2_enable: Optional[int] = None + + nest_1_sense: Optional[int] = None + nest_2_sense: Optional[int] = None + + vac_3_enable: Optional[int] = None + vac_4_enable: Optional[int] = None + vac_3_purge: Optional[int] = None + vac_4_purge: Optional[int] = None + + lift_3_enable: Optional[int] = None + lift_4_enable: Optional[int] = None + + nest_3_sense: Optional[int] = None + nest_4_sense: Optional[int] = None + + vac_5_enable: Optional[int] = None + vac_6_enable: Optional[int] = None + vac_5_purge: Optional[int] = None + vac_6_purge: Optional[int] = None + + lift_5_enable: Optional[int] = None + lift_6_enable: Optional[int] = None + + nest_5_sense: Optional[int] = None + nest_6_sense: Optional[int] = None + + vac_7_enable: Optional[int] = None + vac_8_enable: Optional[int] = None + vac_7_purge: Optional[int] = None + vac_8_purge: Optional[int] = None + + lift_7_enable: Optional[int] = None + lift_8_enable: Optional[int] = None + + nest_7_sense: Optional[int] = None + nest_8_sense: Optional[int] = None + + active_hotels: Optional[int] = None + + nest_rot_home_speed_fast: Optional[int] = None + nest_rot_home_speed_slow: Optional[int] = None + nest_rot_home_acceleration: Optional[int] = None + nest_rot_velocity: Optional[int] = None + nest_rot_acceleration: Optional[int] = None + nest_rot_abort_deceleration: Optional[int] = None + nest_rot_jerk: Optional[int] = None + nest_rot_zero_pos: Optional[float] = None + + microspin_door_closed: Optional[float] = None + microspin_door_open: Optional[float] = None + microspin_spindle_home_offset: Optional[float] = None + microspin_bucket_radius_m: Optional[float] = None + microspin_spindle_counts_per_rev: Optional[int] = None + microspin_spindle_position_window: Optional[int] = None + microspin_door_velocity: Optional[int] = None + microspin_door_home_velocity: Optional[int] = None + microspin_door_accel: Optional[int] = None + microspin_door_abort_decel: Optional[int] = None + microspin_door_jerk: Optional[int] = None + microspin_spindle_home_velocity: Optional[int] = None + microspin_spindle_velocity: Optional[int] = None + microspin_spindle_accel: Optional[float] = None + microspin_spindle_decel: Optional[float] = None + microspin_spindle_slow_accel: Optional[float] = None + microspin_spindle_slow_decel: Optional[float] = None + microspin_spindle_abort_decel: Optional[float] = None + microspin_spindle_jerk: Optional[int] = None + microspin_spindle_max_accel: Optional[float] = None + microspin_spindle_max_decel: Optional[float] = None + microspin_idle_spindle_threshold: Optional[float] = None + microspin_bucket_rise_rpm: Optional[int] = None + + pico_rot_home_speed_fast: Optional[int] = None + pico_rot_home_speed_slow: Optional[int] = None + pico_rot_home_acceleration: Optional[int] = None + pico_rot_velocity: Optional[int] = None + pico_rot_acceleration: Optional[int] = None + pico_rot_abort_deceleration: Optional[int] = None + pico_rot_jerk: Optional[int] = None + pico_rot_zero_pos: Optional[int] = None + pico_stacker_count: Optional[int] = None + + def_plate_height: Optional[float] = None + def_stack_height: Optional[float] = None + def_plate_thickness: Optional[float] = None + + jiggle_count: Optional[int] = None + jiggle_size: Optional[int] = None + + has_lock_sensor: Optional[str] = None + + lock_sensor_input: Optional[int] = None + + carousel_max_position: Optional[int] = None + carousel_max_velocity: Optional[int] = None + carousel_max_acceleration: Optional[int] = None + carousel_max_deceleration: Optional[int] = None + carousel_max_jerk: Optional[int] = None + carousel_home_pos_offset: Optional[int] = None + carousel_home_neg_offset: Optional[float] = None + carousel_homing_speed: Optional[int] = None + carousel_home_fast: Optional[int] = None + carousel_home_slow: Optional[int] = None + carousel_home_accel: Optional[int] = None + carousel_stacker_width_default: Optional[float] = None + carousel_small_flag_width: Optional[float] = None + carousel_large_flag_check_distance: Optional[float] = None + carousel_large_flag_width: Optional[float] = None + carousel_ring_numbering: Optional[str] = None + + effectuator_extended_position: Optional[float] = None + effectuator_max_position: Optional[float] = None + effectuator_lock_position: Optional[float] = None + effectuator_unlock_position: Optional[int] = None + effectuator_max_velocity: Optional[int] = None + effectuator_max_acceleration: Optional[int] = None + effectuator_abort_deceleration: Optional[int] = None + effectuator_max_jerk: Optional[int] = None + effectuator_home_offset: Optional[int] = None + effectuator_home_fast: Optional[int] = None + effectuator_home_slow: Optional[int] = None + effectuator_home_accel: Optional[int] = None + + spatula_max_position: Optional[float] = None + spatula_max_velocity: Optional[int] = None + spatula_measure_velocity: Optional[int] = None + spatula_max_acceleration: Optional[int] = None + spatula_max_jerk: Optional[int] = None + spatula_home_offset: Optional[int] = None + spatula_home_fast: Optional[int] = None + spatula_home_slow: Optional[int] = None + spatula_home_accel: Optional[int] = None + spatula_nest_offset: Optional[float] = None + spatula_measurement_tolerance: Optional[float] = None + spatula_beam_break_height: Optional[float] = None + + max_transparency_width_um: Optional[int] = None + + spatula_lock_position: Optional[float] = None + spatula_base_position: Optional[float] = None + + barcode_max_position: Optional[float] = None + barcode_max_velocity: Optional[int] = None + barcode_max_move_velocity: Optional[int] = None + barcode_abort_deceleration: Optional[int] = None + barcode_max_move_jerk: Optional[int] = None + barcode_max_read_velocity: Optional[int] = None + barcode_home_offset: Optional[int] = None + barcode_home_fast: Optional[int] = None + barcode_home_slow: Optional[int] = None + barcode_home_accel: Optional[int] = None + + spatula_slide_safe_position: Optional[float] = None + spatula_slide_barcode_position: Optional[float] = None + + nest_safe_rotation_clearance: Optional[float] = None + + limit_x_min: Optional[float] = None + limit_x_max: Optional[float] = None + limit_y_min: Optional[float] = None + limit_y_max: Optional[float] = None + limit_z_min: Optional[float] = None + limit_z_max: Optional[float] = None + limit_theta_min: Optional[float] = None + limit_theta_max: Optional[float] = None + limit_g_min: Optional[float] = None + limit_g_max: Optional[float] = None + limit_barcode_min: Optional[float] = None + limit_barcode_max: Optional[float] = None + + tundra_door_cycle_active: Optional[str] = None + tundra_door_cycle_time_sec: Optional[int] = None + tundra_door_cycle_open_time_sec: Optional[int] = None + + barcode_height_adjust: Optional[float] = None + + tundra_outer_door_cycle_active: Optional[str] = None + tundra_outer_door_cycle_time_sec: Optional[int] = None + tundra_outer_door_cycle_open_time_sec: Optional[int] = None + + spatula_door_clearance_below: Optional[float] = None + spatula_door_clearance_above: Optional[float] = None + + weigh_cell_door_output: Optional[int] = None + weigh_cell_door_input_status_0: Optional[int] = None + weigh_cell_door_input_status_1: Optional[int] = None + weigh_cell_door_input_status_2: Optional[int] = None + weigh_cell_led_red_output: Optional[int] = None + weigh_cell_led_green_output: Optional[int] = None + weigh_cell_led_blue_output: Optional[int] = None + + axis_x_home_speed_fast: Optional[float] = None + axis_x_home_speed_slow: Optional[float] = None + axis_x_home_acceleration: Optional[float] = None + axis_x_home_offset: Optional[float] = None + axis_x2_home_offset: Optional[float] = None + + tray_to_gantry_0_distance: Optional[float] = None + + axis_x2_calibration_adjustment: Optional[float] = None + axis_x_calibration_pos: Optional[float] = None + axis_x_velocity: Optional[float] = None + axis_x_acceleration: Optional[float] = None + axis_x_abort_deceleration: Optional[float] = None + axis_x_jerk: Optional[float] = None + axis_z_home_speed_fast: Optional[float] = None + axis_z_home_speed_slow: Optional[float] = None + axis_z_home_acceleration: Optional[float] = None + axis_z_home_offset: Optional[float] = None + axis_z_home_offset_hardstop: Optional[float] = None + axis_z_home_hardstop_current_ma: Optional[int] = None + axis_z_home_hardstop_current_time_ms: Optional[int] = None + axis_z_velocity: Optional[float] = None + axis_z_acceleration: Optional[float] = None + axis_z_abort_deceleration: Optional[float] = None + axis_z_jerk: Optional[float] = None + axis_barcode_home_speed_fast: Optional[float] = None + axis_barcode_home_speed_slow: Optional[float] = None + axis_barcode_home_acceleration: Optional[float] = None + axis_barcode_home_offset: Optional[float] = None + axis_barcode_velocity: Optional[float] = None + axis_barcode_acceleration: Optional[float] = None + axis_barcode_abort_deceleration: Optional[float] = None + axis_barcode_jerk: Optional[float] = None + axis_gripper_home_speed_fast: Optional[float] = None + axis_gripper_home_speed_slow: Optional[float] = None + axis_gripper_home_acceleration: Optional[float] = None + axis_gripper_home_offset: Optional[float] = None + axis_gripper_home_offset_hardstop: Optional[float] = None + axis_gripper_home_hardstop_current_ma: Optional[int] = None + axis_gripper_home_hardstop_current_time_ms: Optional[int] = None + axis_gripper_close_position: Optional[float] = None + axis_gripper_velocity: Optional[float] = None + axis_gripper_acceleration: Optional[float] = None + axis_gripper_abort_deceleration: Optional[float] = None + axis_gripper_jerk: Optional[float] = None + + height_detect_base: Optional[float] = None + height_detect_positive_adjustment: Optional[float] = None + height_detect_negative_adjustment: Optional[float] = None + height_detect_enable_address: Optional[int] = None + + barcode_input_number: Optional[int] = None + + height_detect_input_number: Optional[int] = None + + stacker_code_clearance_above: Optional[float] = None + stacker_code_clearance_below: Optional[float] = None + stacker_code_height: Optional[float] = None + + barcode_fixture_height: Optional[float] = None + barcode_fixture_groove_height: Optional[float] = None + barcode_ideal_stacker_tier_1: Optional[float] = None + barcode_ideal_stacker_tier_2: Optional[float] = None + + muting_bank_input_1: Optional[int] = None + muting_bank_input_2: Optional[int] = None + muting_bank_input_3: Optional[int] = None + muting_input_1: Optional[int] = None + muting_input_2: Optional[int] = None + + tool_head_sel_0: Optional[int] = None + tool_head_sel_1: Optional[int] = None + tool_head_addr_0: Optional[int] = None + tool_head_addr_1: Optional[int] = None + tool_head_addr_2: Optional[int] = None + tool_head_addr_3: Optional[int] = None + + ion_bar_air_output: Optional[int] = None + ion_bar_power_output: Optional[int] = None + + busybox_mode: Optional[str] = None + + microserve_bus_voltage_threshold: Optional[float] = None + microserve_recover_after_estop: Optional[str] = None + + randomserve_bus_voltage_threshold: Optional[float] = None + randomserve_recover_after_estop: Optional[str] = None + + psp_packet_delay: Optional[int] = None + + microspin_spindle_voltage_delay: Optional[int] = None + microspin_bus_voltage_threshold: Optional[float] = None + + home_trays_to_hardstop: Optional[str] = None + + dc_out_1_default_on: Optional[str] = None + dc_out_2_default_on: Optional[str] = None + dc_out_3_default_on: Optional[str] = None + + lid_discard_drop_wait_time_ms: Optional[int] = None + + lidvalet_plate_dropped_threshold: Optional[int] = None + lidvalet_hold_time_after_unlid: Optional[int] = None + lidvalet_purge_time_ms: Optional[int] = None + lidvalet_drop_down_time_ms: Optional[int] = None + lidvalet_drop_up_time_ms: Optional[int] = None + lidvalet_pickup_wait_ms: Optional[int] = None + + disable_blink_function: Optional[str] = None + + oled_blink_time_ms: Optional[int] = None + + suppress_copley_debug_statements: Optional[str] = None + + prime_waste_chute_installed: Optional[str] = None + prime_waste_chute_position: Optional[str] = None + + plate_sensor_high_is_plate_present: Optional[str] = None + + stacker_1_speed_multiplier: Optional[float] = None + stacker_2_speed_multiplier: Optional[float] = None + stacker_3_speed_multiplier: Optional[float] = None + stacker_4_speed_multiplier: Optional[float] = None + stacker_5_speed_multiplier: Optional[float] = None + stacker_6_speed_multiplier: Optional[float] = None + stacker_7_speed_multiplier: Optional[float] = None + stacker_8_speed_multiplier: Optional[float] = None + stacker_9_speed_multiplier: Optional[float] = None + stacker_10_speed_multiplier: Optional[float] = None + stacker_11_speed_multiplier: Optional[float] = None + stacker_12_speed_multiplier: Optional[float] = None + stacker_13_speed_multiplier: Optional[float] = None + stacker_14_speed_multiplier: Optional[float] = None + stacker_15_speed_multiplier: Optional[float] = None + stacker_16_speed_multiplier: Optional[float] = None + stacker_17_speed_multiplier: Optional[float] = None + stacker_18_speed_multiplier: Optional[float] = None + stacker_19_speed_multiplier: Optional[float] = None + stacker_20_speed_multiplier: Optional[float] = None + stacker_21_speed_multiplier: Optional[float] = None + stacker_22_speed_multiplier: Optional[float] = None + stacker_23_speed_multiplier: Optional[float] = None + stacker_24_speed_multiplier: Optional[float] = None + stacker_25_speed_multiplier: Optional[float] = None + stacker_26_speed_multiplier: Optional[float] = None + stacker_27_speed_multiplier: Optional[float] = None + stacker_28_speed_multiplier: Optional[float] = None + stacker_1_clearance_above_offset: Optional[float] = None + stacker_2_clearance_above_offset: Optional[float] = None + stacker_3_clearance_above_offset: Optional[float] = None + stacker_4_clearance_above_offset: Optional[float] = None + stacker_5_clearance_above_offset: Optional[float] = None + stacker_6_clearance_above_offset: Optional[float] = None + stacker_7_clearance_above_offset: Optional[float] = None + stacker_8_clearance_above_offset: Optional[float] = None + stacker_9_clearance_above_offset: Optional[float] = None + stacker_10_clearance_above_offset: Optional[float] = None + stacker_11_clearance_above_offset: Optional[float] = None + stacker_12_clearance_above_offset: Optional[float] = None + stacker_13_clearance_above_offset: Optional[float] = None + stacker_14_clearance_above_offset: Optional[float] = None + stacker_15_clearance_above_offset: Optional[float] = None + stacker_16_clearance_above_offset: Optional[float] = None + stacker_17_clearance_above_offset: Optional[float] = None + stacker_18_clearance_above_offset: Optional[float] = None + stacker_19_clearance_above_offset: Optional[float] = None + stacker_20_clearance_above_offset: Optional[float] = None + stacker_21_clearance_above_offset: Optional[float] = None + stacker_22_clearance_above_offset: Optional[float] = None + stacker_23_clearance_above_offset: Optional[float] = None + stacker_24_clearance_above_offset: Optional[float] = None + stacker_25_clearance_above_offset: Optional[float] = None + stacker_26_clearance_above_offset: Optional[float] = None + stacker_27_clearance_above_offset: Optional[float] = None + stacker_28_clearance_above_offset: Optional[float] = None + + mfg_door_time_low_limit_ms: Optional[int] = None + mfg_door_time_high_limit_ms: Optional[int] = None + + automation_door_time_ms: Optional[int] = None + + carousel_home_adj_low_limit: Optional[int] = None + carousel_home_adj_high_limit: Optional[int] = None + + store_calibration_fixture_y_distance: Optional[float] = None + store_y_teach_minimum: Optional[float] = None + store_y_teach_maximum: Optional[float] = None + + lidvalet_wait_for_lift_rise_ms: Optional[int] = None + + raw: Mapping[str, str] = field(default_factory=lambda: MappingProxyType({}), repr=False) + + @property + def extra(self) -> Dict[str, str]: + """Firmware keys that do not have a typed attribute in this version.""" + known = {f.name.upper() for f in fields(self) if f.name != "raw"} + return {key: value for key, value in self.raw.items() if key not in known} + + @classmethod + def from_lines(cls, lines: Iterable[str]) -> "HighResSampleStorageSettings": + """Build from the device's ``settings`` output (``NAME = value`` lines).""" + data: Dict[str, str] = {} + for line in lines: + if "=" in line: + key, _, value = line.partition("=") + data[key.strip().upper()] = value.strip() + + values: Dict[str, Any] = {"raw": MappingProxyType(dict(data))} + for f in fields(cls): + if f.name == "raw": + continue + key = f.name.upper() + if key not in data: + continue + value_type = next((arg for arg in get_args(f.type) if arg is not type(None)), str) + if value_type is int: + values[f.name] = int(data[key]) + elif value_type is float: + values[f.name] = float(data[key]) + else: + values[f.name] = data[key] + machine_type = values.get("machine_type") + if machine_type is not None and machine_type not in KNOWN_MACHINE_TYPES: + logger.warning( + "Unknown HighRes sample-store model %r; preserving all settings as raw values", + machine_type, + ) + return cls(**values) diff --git a/pylabrobot/high_res/sample_storage/driver/types.py b/pylabrobot/high_res/sample_storage/driver/types.py new file mode 100644 index 00000000000..c5a7a7e64ff --- /dev/null +++ b/pylabrobot/high_res/sample_storage/driver/types.py @@ -0,0 +1,51 @@ +from dataclasses import dataclass +from typing import Dict, Optional, Tuple + +try: + from typing import Literal +except ImportError: # pragma: no cover + from typing_extensions import Literal # type: ignore + + +# State of a single TundraStore door, as reported by ``doorstatus``. +DoorState = Literal["open", "closed", "opening", "closing", "unknown"] +DOOR_STATES: Tuple[DoorState, ...] = ("open", "closed", "opening", "closing", "unknown") + +# State of a transfer nest, as reported by ``neststatus``. +NestState = Literal["clear", "occupied", "unknown"] +NEST_STATES: Tuple[NestState, ...] = ("clear", "occupied", "unknown") + + +@dataclass +class VersionInfo: + """Parsed output of the ``version`` command.""" + + product_name: Optional[str] + serial_number: Optional[str] + firmware_version: Optional[str] + firmware_build: Optional[str] + raw: Dict[str, str] + + +@dataclass +class EnvironmentParameter: + """One row of ``environmentstatus`` (e.g. TEMP, RH, CO2, O2). + + The device reports ``NAME:current/setpoint/limit``. ``setpoint``/``limit`` are + ``None`` for sensor-only channels (e.g. the gas tank pressures). + """ + + name: str + current: float + setpoint: Optional[float] = None + limit: Optional[float] = None + + +@dataclass +class StackerDimensions: + """One stacker's geometry, from ``getstackerdimensions``.""" + + stacker: int + zero_offset: float + slot_height: float + slot_count: int diff --git a/pylabrobot/high_res/sample_storage/stackers.py b/pylabrobot/high_res/sample_storage/stackers.py new file mode 100644 index 00000000000..4977c8ba775 --- /dev/null +++ b/pylabrobot/high_res/sample_storage/stackers.py @@ -0,0 +1,65 @@ +from pylabrobot.resources import Coordinate, PlateCarrier, PlateHolder + +_STACKER_SIZE_X = 112.3 +_STACKER_SIZE_Y = 146.6 +_SITE_SIZE_X = 85.48 +_SITE_SIZE_Y = 127.76 + + +def high_res_stacker( + name: str, + *, + zero_offset: float, + slot_height: float, + slot_count: int, +) -> PlateCarrier: + """Create a HighRes sample-store stacker from its configured dimensions. + + The values correspond to one line returned by the device's + ``getstackerdimensions`` command. Carrier spots are zero-based, while site + names use the device's one-based slot numbers. + + Args: + name: Resource name for the stacker. + zero_offset: Vertical position of the first slot. + slot_height: Height and vertical pitch of each slot. + slot_count: Number of slots in the stacker. + + Returns: + A plate carrier containing one holder for each stacker slot. + """ + if zero_offset < 0: + raise ValueError(f"zero_offset must be non-negative; got {zero_offset}.") + if slot_height <= 0: + raise ValueError(f"slot_height must be positive; got {slot_height}.") + if slot_count < 0: + raise ValueError(f"slot_count must be non-negative; got {slot_count}.") + + return PlateCarrier( + name=name, + size_x=_STACKER_SIZE_X, + size_y=_STACKER_SIZE_Y, + size_z=zero_offset + slot_height * slot_count, + sites={ + spot: PlateHolder( + name=f"{name}_slot_{spot + 1}", + size_x=_SITE_SIZE_X, + size_y=_SITE_SIZE_Y, + size_z=slot_height, + pedestal_size_z=0, + ).at( + Coordinate( + x=(_STACKER_SIZE_X - _SITE_SIZE_X) / 2, + y=(_STACKER_SIZE_Y - _SITE_SIZE_Y) / 2, + z=zero_offset + slot_height * spot, + ) + ) + for spot in range(slot_count) + }, + model="high_res_stacker", + metadata={ + "zero_offset": zero_offset, + "slot_height": slot_height, + "slot_count": slot_count, + }, + ) diff --git a/pylabrobot/high_res/sample_storage/stackers_tests.py b/pylabrobot/high_res/sample_storage/stackers_tests.py new file mode 100644 index 00000000000..4bca4108903 --- /dev/null +++ b/pylabrobot/high_res/sample_storage/stackers_tests.py @@ -0,0 +1,59 @@ +import unittest + +from pylabrobot.high_res.sample_storage import high_res_stacker + + +class HighResStackerTests(unittest.TestCase): + def test_builds_reported_stacker_geometry(self): + stacker = high_res_stacker( + name="stacker_2", + zero_offset=5, + slot_height=22.867, + slot_count=24, + ) + + self.assertEqual(stacker.capacity, 24) + self.assertEqual(stacker.get_size_x(), 112.3) + self.assertEqual(stacker.get_size_y(), 146.6) + self.assertAlmostEqual(stacker.get_size_z(), 5 + 22.867 * 24) + self.assertEqual(stacker.model, "high_res_stacker") + self.assertEqual( + stacker.metadata, + {"zero_offset": 5, "slot_height": 22.867, "slot_count": 24}, + ) + + first = stacker.sites[0] + last = stacker.sites[23] + self.assertEqual(first.name, "stacker_2_slot_1") + self.assertEqual(last.name, "stacker_2_slot_24") + self.assertEqual(first.get_size_x(), 85.48) + self.assertEqual(first.get_size_y(), 127.76) + self.assertEqual(first.get_size_z(), 22.867) + self.assertEqual(first.pedestal_size_z, 0) + self.assertIsNotNone(first.location) + self.assertIsNotNone(last.location) + assert first.location is not None + assert last.location is not None + self.assertAlmostEqual(first.location.x, (112.3 - 85.48) / 2) + self.assertAlmostEqual(first.location.y, (146.6 - 127.76) / 2) + self.assertEqual(first.location.z, 5) + self.assertAlmostEqual(last.location.z, 5 + 22.867 * 23) + + def test_allows_disabled_stacker(self): + stacker = high_res_stacker( + name="stacker_1", + zero_offset=0, + slot_height=28.94, + slot_count=0, + ) + + self.assertEqual(stacker.capacity, 0) + self.assertEqual(stacker.get_size_z(), 0) + + def test_rejects_invalid_geometry(self): + with self.assertRaisesRegex(ValueError, "zero_offset"): + high_res_stacker("stacker", zero_offset=-1, slot_height=22.867, slot_count=24) + with self.assertRaisesRegex(ValueError, "slot_height"): + high_res_stacker("stacker", zero_offset=0, slot_height=0, slot_count=24) + with self.assertRaisesRegex(ValueError, "slot_count"): + high_res_stacker("stacker", zero_offset=0, slot_height=22.867, slot_count=-1) diff --git a/pylabrobot/high_res/sample_storage/steri_store.py b/pylabrobot/high_res/sample_storage/steri_store.py new file mode 100644 index 00000000000..95e6a7aea52 --- /dev/null +++ b/pylabrobot/high_res/sample_storage/steri_store.py @@ -0,0 +1,7 @@ +from .driver import HighResSampleStorage + + +class SteriStore(HighResSampleStorage): + """HighRes Biosolutions SteriStore plate store.""" + + _model_name = "SteriStore" diff --git a/pylabrobot/high_res/sample_storage/tests/__init__.py b/pylabrobot/high_res/sample_storage/tests/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/pylabrobot/high_res/sample_storage/tests/driver_tests.py b/pylabrobot/high_res/sample_storage/tests/driver_tests.py new file mode 100644 index 00000000000..ab0f5a5e8ce --- /dev/null +++ b/pylabrobot/high_res/sample_storage/tests/driver_tests.py @@ -0,0 +1,1207 @@ +import asyncio +import inspect +import json +import unittest +from typing import Dict, List +from unittest.mock import AsyncMock + +from pylabrobot.events import EventBus, PLREvent, use_event_bus +from pylabrobot.high_res.sample_storage import AmbiStore, SteriStore, TundraStore +from pylabrobot.high_res.sample_storage.driver import HighResSampleStorage +from pylabrobot.high_res.sample_storage.driver.errors import ( + HighResSampleStorageAbortedError, + HighResSampleStorageError, + HighResSampleStorageFault, + HighResSampleStorageProtocolError, + PlateNotFoundError, +) +from pylabrobot.high_res.sample_storage.driver.models import get_model_info +from pylabrobot.high_res.sample_storage.driver.settings import HighResSampleStorageSettings +from pylabrobot.resources import Coordinate, Lid, Plate, PlateCarrier, PlateHolder, Well + +# Real responses captured from a SteriStore (firmware 3.0.0.119, serial +# HRB-2209-35148) over the port-1000 remote-control server. +CAPTURES: Dict[str, List[str]] = { + "version": [ + "ACK! version 1", + "Product Name: SteriStore", + "Serial Number: HRB-2209-35148", + "libcommon Version: 1.1.0.119", + "libts7600 Version: 1.0.0.119", + "Firmware Version: 3.0.0.119", + "Firmware Build: D9BE232A", + "OK! version 1", + ], + "homedstatus": ["ACK! homedstatus 5", "not homed", "OK! homedstatus 5"], + "status": [ + "ACK! status 6", + "Carousel: 0.0", + "Y axis: 0.0", + "Z axis: 0.0", + "OK! status 6", + ], + "doorstatus": [ + "ACK! doorstatus 17", + "User Door: CLOSED", + "RI: CLOSED", + "SEAL: CLOSING", + "RO1: CLOSING", + "RO2: CLOSED", + "RO3: CLOSED", + "RO4: CLOSED", + "OK! doorstatus 17", + ], + "platestatus": ["ACK! platestatus 9", "NO_PLATE", "OK! platestatus 9"], + "neststatus": ["ACK! neststatus 11", "1: CLEAR", "2: CLEAR", "OK! neststatus 11"], + "environmentstatus": [ + "ACK! environmentstatus 31", + "TEMP:21.9/22.0/100.0", + "RH:54.7/0.0/-100.0", + "CO2:0.0/5.0/100.0", + "O2:20.5/5.0/100.0", + "TANK1:135.0:", + "TANK2:135.0:", + "OK! environmentstatus 31", + ], + "getstackerdimensions": [ + "ACK! getstackerdimensions 35", + "1: 0.000 28.940 0", + "2: 0.000 22.867 24", + "13: 0.000 22.867 0", + "OK! getstackerdimensions 35", + ], + # The home command failed because the pneumatic doors could not close (no air). + "home": [ + "ACK! home 13", + "Error 1: (00:32:44) 13: Unable to close all doors", + "ERROR! home 13", + ], +} + + +class FakeSocket: + """Replays scripted line responses keyed by the command written to it.""" + + def __init__(self, captures: Dict[str, List[str]]): + self.captures = {command: list(lines) for command, lines in captures.items()} + self.written: List[str] = [] + self._queue: List[str] = [] + self.setup_calls = 0 + self.stop_calls = 0 + + async def setup(self): + self.setup_calls += 1 + + async def stop(self): + self.stop_calls += 1 + + async def write(self, data: bytes, timeout=None): + command = data.decode("ascii").rstrip("\r\n") + self.written.append(command) + self._queue = list(self.captures[command]) + + async def readuntil(self, separator: bytes = b"\n", timeout=None) -> bytes: + return self._queue.pop(0).encode("ascii") + b"\r\n" + + +class TimeoutAfterAckSocket(FakeSocket): + """Return one acknowledgement and then simulate a stalled device response.""" + + def __init__(self, captures: Dict[str, List[str]], timeout_command: str): + super().__init__(captures) + self.timeout_command = timeout_command + + async def readuntil(self, separator: bytes = b"\n", timeout=None) -> bytes: + if self.written[-1] == self.timeout_command and len(self._queue) == 1: + raise TimeoutError("simulated response timeout") + return await super().readuntil(separator=separator, timeout=timeout) + + +class HighResSampleStorageTests(unittest.IsolatedAsyncioTestCase): + def setUp(self): + self.driver = SteriStore(host="10.253.253.253", name="sample_store", racks={}) + self.socket = FakeSocket(CAPTURES) + self.driver.io = self.socket # type: ignore[assignment] + self.retrieval = self.driver + + async def test_setup_loads_device_nests_without_locations(self): + await self.driver.setup() + + self.assertEqual(self.socket.written, ["version", "environmentstatus", "neststatus"]) + self.assertEqual( + [nest.name for nest in self.driver.nests], ["sample_store_nest_1", "sample_store_nest_2"] + ) + self.assertTrue(all(nest.parent is self.driver for nest in self.driver.nests)) + self.assertTrue(all(nest.location is None for nest in self.driver.nests)) + self.assertEqual( + set(self.driver.environment.parameters), {"TEMP", "RH", "CO2", "O2", "TANK1", "TANK2"} + ) + + async def test_setup_discovers_stackers_when_racks_are_omitted(self): + driver = SteriStore(host="10.253.253.253", name="discovered_store") + socket = FakeSocket(CAPTURES) + driver.io = socket # type: ignore[assignment] + + await driver.setup() + + self.assertEqual( + socket.written, + ["version", "getstackerdimensions", "environmentstatus", "neststatus"], + ) + self.assertEqual(len(driver.racks), 1) + rack = driver.racks[0] + self.assertEqual(rack.name, "discovered_store_stacker_2") + self.assertEqual(rack.capacity, 24) + self.assertNotIn("stacker", rack.metadata) + self.assertEqual(driver._racks_by_number, {2: rack}) + self.assertEqual(driver._locate(rack.sites[0]), (2, 1)) + self.assertEqual(driver._locate(rack.sites[23]), (2, 24)) + + def test_init_rejects_invalid_physical_stacker_number(self): + rack = PlateCarrier(name="rack", size_x=130, size_y=90, size_z=100) + + with self.assertRaisesRegex(ValueError, "positive integer"): + HighResSampleStorage(host="10.253.253.253", name="sample_store", racks={0: rack}) + + async def test_repeated_setup_reuses_discovered_stackers(self): + driver = SteriStore(host="10.253.253.253", name="discovered_store") + socket = FakeSocket(CAPTURES) + driver.io = socket # type: ignore[assignment] + + await driver.setup() + original_racks = list(driver.racks) + await driver.setup() + + self.assertEqual(driver.racks, original_racks) + self.assertTrue( + all(actual is original for actual, original in zip(driver.racks, original_racks)) + ) + self.assertEqual(socket.written.count("getstackerdimensions"), 1) + + async def test_setup_reuses_nests_when_called_again(self): + await self.driver.setup() + original_nests = list(self.driver.nests) + + await self.driver.setup() + + self.assertEqual(self.driver.nests, original_nests) + self.assertTrue( + all(actual is original for actual, original in zip(self.driver.nests, original_nests)) + ) + + async def test_setup_loads_nests_when_device_nest_is_occupied(self): + self.socket.captures["neststatus"] = [ + "ACK! neststatus 12", + "1: PLATE_AVAILABLE", + "2: CLEAR", + "OK! neststatus 12", + ] + + await self.driver.setup() + + self.assertEqual( + [nest.name for nest in self.driver.nests], ["sample_store_nest_1", "sample_store_nest_2"] + ) + self.assertEqual(len(self.driver.nests), 2) + self.assertTrue(all(nest.resource is None for nest in self.driver.nests)) + + async def test_repeated_setup_preserves_nest_bookkeeping(self): + await self.driver.setup() + plate = Plate( + name="plate_on_nest", + size_x=127.76, + size_y=85.48, + size_z=14, + ordered_items={}, + ) + self.driver.nests[0].assign_child_resource(plate, location=Coordinate.zero()) + self.socket.captures["neststatus"] = [ + "ACK! neststatus 12", + "2: CLEAR", + "3: CLEAR", + "OK! neststatus 12", + ] + + await self.driver.setup() + + self.assertEqual( + [nest.name for nest in self.driver.nests], ["sample_store_nest_1", "sample_store_nest_2"] + ) + self.assertIs(self.driver.nests[0].resource, plate) + + async def test_setup_rejects_noncontiguous_initial_nests(self): + self.socket.captures["neststatus"] = [ + "ACK! neststatus 12", + "1: CLEAR", + "3: CLEAR", + "OK! neststatus 12", + ] + + with self.assertRaisesRegex(RuntimeError, r"contiguous nest numbers.*\[1, 3\]"): + await self.driver.setup() + + async def test_setup_keeps_user_configured_model(self): + driver = HighResSampleStorage( + host="10.253.253.253", name="generic_store", racks={}, model="TundraStore" + ) + driver.io = FakeSocket(CAPTURES) # type: ignore[assignment] + + await driver.setup() + + self.assertEqual(driver.model, "TundraStore") + self.assertEqual(driver.model_info, get_model_info("TundraStore")) + self.assertEqual(driver.environment.temperature_range, (-20.0, 4.0)) + + async def test_device_report_does_not_add_environment_control(self): + driver = HighResSampleStorage( + host="10.253.253.253", name="generic_store", racks={}, model="AmbiStore" + ) + socket = FakeSocket(CAPTURES) + driver.io = socket # type: ignore[assignment] + + await driver.setup() + + self.assertEqual(socket.written, ["version", "neststatus"]) + self.assertFalse(hasattr(driver, "environment")) + + async def test_unverified_models_warn_during_setup_without_hiding_signature(self): + for model_class in (AmbiStore, TundraStore): + driver = model_class(host="10.253.253.253", name="unverified", racks={}) + driver.io = FakeSocket(CAPTURES) # type: ignore[assignment] + with self.assertLogs( + "pylabrobot.high_res.sample_storage.driver.driver", level="WARNING" + ) as logs: + await driver.setup() + self.assertIn("not been verified against hardware", " ".join(logs.output)) + self.assertIn("host: str", str(inspect.signature(model_class))) + + async def test_send_command_strips_ack_and_completion(self): + data = await self.driver._send_command("neststatus") + self.assertEqual(data, ["1: CLEAR", "2: CLEAR"]) + self.assertEqual(self.socket.written, ["neststatus"]) + + async def test_send_command_requires_acknowledgement_first(self): + self.socket.captures["bad"] = ["OK! bad 1"] + + with self.assertRaisesRegex(HighResSampleStorageProtocolError, "expected ACK! envelope"): + await self.driver._send_command("bad") + + async def test_send_command_validates_acknowledged_command(self): + self.socket.captures["bad"] = ["ACK! other 1", "OK! other 1"] + + with self.assertRaisesRegex(HighResSampleStorageProtocolError, "ACK echoed command 'other'"): + await self.driver._send_command("bad") + + async def test_send_command_validates_completion_command(self): + self.socket.captures["bad"] = ["ACK! bad 1", "OK! other 1"] + + with self.assertRaisesRegex( + HighResSampleStorageProtocolError, "completion echoed command 'other'" + ): + await self.driver._send_command("bad") + + async def test_send_command_validates_completion_command_id(self): + self.socket.captures["bad"] = ["ACK! bad 1", "OK! bad 2"] + + with self.assertRaisesRegex(HighResSampleStorageProtocolError, "does not match ACK ID '1'"): + await self.driver._send_command("bad") + + async def test_send_command_rejects_duplicate_acknowledgement(self): + self.socket.captures["bad"] = ["ACK! bad 1", "ACK! bad 1", "OK! bad 1"] + + with self.assertRaisesRegex(HighResSampleStorageProtocolError, "received a second ACK"): + await self.driver._send_command("bad") + + async def test_send_command_closes_transport_after_response_timeout(self): + socket = TimeoutAfterAckSocket({"slow": ["ACK! slow 1", "OK! slow 1"]}, timeout_command="slow") + self.driver.io = socket # type: ignore[assignment] + + with self.assertRaisesRegex(TimeoutError, "simulated response timeout"): + await self.driver._send_command("slow") + + self.assertEqual(socket.stop_calls, 1) + + async def test_version(self): + v = await self.driver.request_version() + self.assertEqual(v.product_name, "SteriStore") + self.assertEqual(v.serial_number, "HRB-2209-35148") + self.assertEqual(v.firmware_version, "3.0.0.119") + self.assertEqual(v.firmware_build, "D9BE232A") + + async def test_request_is_homed(self): + self.assertFalse(await self.retrieval.request_is_homed()) + + async def test_door_status(self): + doors = await self.retrieval.request_door_status() + self.assertEqual(doors["User Door"], "closed") + self.assertEqual(doors["SEAL"], "closing") + self.assertEqual(doors["RO1"], "closing") + self.assertFalse(all(state == "closed" for state in doors.values())) + + async def test_nest_status(self): + nests = await self.retrieval.request_nest_status() + self.assertEqual(nests, {1: "clear", 2: "clear"}) + + async def test_plate_available_nest_is_occupied(self): + self.socket.captures["neststatus"] = [ + "ACK! neststatus 12", + "1: PLATE_AVAILABLE", + "2: CLEAR", + "OK! neststatus 12", + ] + + self.assertEqual(await self.retrieval.request_nest_status(), {1: "occupied", 2: "clear"}) + self.assertTrue(await self.retrieval.request_nest_is_holding(1)) + self.assertFalse(await self.retrieval.request_nest_is_holding(2)) + + async def test_plate_on_spatula(self): + self.assertFalse(await self.retrieval.request_spatula_is_holding()) + + async def test_environment_parsing(self): + env = await self.driver.request_environment() + self.assertAlmostEqual(env["TEMP"].current, 21.9) + self.assertIsNotNone(env["TEMP"].setpoint) + assert env["TEMP"].setpoint is not None # narrow for type checker + self.assertAlmostEqual(env["TEMP"].setpoint, 22.0) + self.assertAlmostEqual(env["O2"].current, 20.5) + # Sensor-only channel: current value, no setpoint. + self.assertAlmostEqual(env["TANK1"].current, 135.0) + self.assertIsNone(env["TANK1"].setpoint) + + async def test_temperature_reads_temp_channel(self): + self.assertAlmostEqual(await self.driver.environment.request_current_temperature(), 21.9) + self.assertAlmostEqual(await self.driver.environment.request_target_temperature(), 22.0) + self.assertTrue(self.driver.environment.supports_active_cooling) + self.assertTrue(self.driver.environment.supports_heating) + self.assertEqual(self.driver.environment.temperature_range, (4.0, 100.0)) + + def test_model_info(self): + self.assertEqual(get_model_info("TundraStore").temperature_range, (-20.0, 4.0)) + self.assertTrue(get_model_info("TundraStore").supports_active_cooling) + self.assertFalse(get_model_info("TundraStore").supports_heating) + self.assertTrue(get_model_info("TundraStore").supports_humidity_control) + self.assertIsNone(get_model_info("TundraStore").humidity_range) + self.assertEqual(get_model_info("SteriStore").humidity_range, (0.0, 0.98)) + self.assertTrue(get_model_info("SteriStore").supports_co2_control) + self.assertFalse(get_model_info("AmbiStore").has_environment_control) + + def test_partial_settings_preserve_missing_and_extra_firmware_keys(self): + settings = HighResSampleStorageSettings.from_lines( + [ + "MACHINE_TYPE = FutureStore3", + "REST_SERVER_PORT = 1000", + "CAROUSEL_VELOCITY = 12.5", + "FUTURE_OPTION = enabled", + ] + ) + + self.assertEqual(settings.machine_type, "FutureStore3") + self.assertEqual(settings.rest_server_port, 1000) + self.assertEqual(settings.carousel_velocity, 12.5) + self.assertIsNone(settings.serial_number) + self.assertEqual(settings.extra, {"FUTURE_OPTION": "enabled"}) + self.assertEqual(settings.raw["FUTURE_OPTION"], "enabled") + + async def test_temperature_range_is_validated(self): + with self.assertRaises(ValueError): + await self.driver.environment.set_temperature(101) + self.assertEqual(self.socket.written, []) + + async def test_installed_temperature_limit_is_validated(self): + self.socket.captures["environmentstatus"] = [ + "ACK! environmentstatus 32", + "TEMP:21.9/22.0/80.0", + "OK! environmentstatus 32", + ] + + with self.assertRaisesRegex(ValueError, "installed limit of 80 C"): + await self.driver.environment.set_temperature(90) + + self.assertEqual(self.socket.written, ["environmentstatus"]) + + async def test_humidity_reads_rh_as_fraction(self): + self.assertAlmostEqual(await self.driver.environment.request_current_humidity(), 0.547) + self.assertAlmostEqual(await self.driver.environment.request_target_humidity(), 0.0) + self.assertTrue(self.driver.environment.supports_humidity_control) + + async def test_gas_levels_are_fractions(self): + self.assertAlmostEqual(await self.driver.environment.request_current_co2(), 0.0) + self.assertAlmostEqual(await self.driver.environment.request_target_co2(), 0.05) + self.assertAlmostEqual(await self.driver.environment.request_current_o2(), 0.205) + self.assertAlmostEqual(await self.driver.environment.request_target_o2(), 0.05) + self.assertTrue(self.driver.environment.supports_co2_control) + self.assertTrue(self.driver.environment.supports_o2_control) + + async def test_tank_pressures(self): + self.assertEqual( + await self.driver.environment.request_tank_pressures(), {"TANK1": 135.0, "TANK2": 135.0} + ) + + async def test_environment_setters_convert_fractions_to_percent(self): + commands = [ + "environmentset TEMP 37", + "environmentset RH 90", + "environmentset CO2 5", + "environmentset O2 10", + ] + for command_id, command in enumerate(commands, start=50): + self.socket.captures[command] = [ + f"ACK! {command} {command_id}", + f"OK! {command} {command_id}", + ] + + await self.driver.environment.set_temperature(37) + await self.driver.environment.set_humidity(0.90) + await self.driver.environment.set_co2(0.05) + await self.driver.environment.set_o2(0.10) + + self.assertEqual( + self.socket.written, + [ + "environmentstatus", + "environmentset TEMP 37", + "environmentstatus", + "environmentset RH 90", + "environmentstatus", + "environmentset CO2 5", + "environmentstatus", + "environmentset O2 10", + ], + ) + + async def test_environment_control_can_be_enabled_and_disabled(self): + commands = ["environment enable co2", "environment disable co2"] + for command_id, command in enumerate(commands, start=60): + self.socket.captures[command] = [ + f"ACK! {command} {command_id}", + f"OK! {command} {command_id}", + ] + + await self.driver.environment.start_co2_control() + await self.driver.environment.stop_co2_control() + + self.assertEqual( + self.socket.written, + [ + "environmentstatus", + "environment enable co2", + "environmentstatus", + "environment disable co2", + ], + ) + + async def test_environment_control_emits_operation_events(self): + self.socket.captures["environmentset TEMP 37"] = [ + "ACK! environmentset TEMP 37 65", + "OK! environmentset TEMP 37 65", + ] + events: List[PLREvent] = [] + event_bus = EventBus() + event_bus.subscribe(events.append) + + with use_event_bus(event_bus): + await self.driver.environment.set_temperature(37) + + self.assertEqual( + [event.name for event in events], + [ + "temperature_controller.set_temperature.started", + "temperature_controller.set_temperature.completed", + ], + ) + self.assertEqual(events[0].data["device"]["name"], "sample_store") + self.assertEqual(events[0].data["resources"], []) + self.assertEqual(events[0].data["target_temperature"], 37.0) + self.assertEqual(events[0].context["operation_id"], events[1].context["operation_id"]) + + async def test_environment_validation_emits_failed_operation_event(self): + events: List[PLREvent] = [] + event_bus = EventBus() + event_bus.subscribe(events.append) + + with use_event_bus(event_bus), self.assertRaises(ValueError): + await self.driver.environment.set_temperature(101) + + self.assertEqual( + [event.name for event in events], + [ + "temperature_controller.set_temperature.started", + "temperature_controller.set_temperature.failed", + ], + ) + self.assertEqual(events[1].data["error_type"], "ValueError") + self.assertEqual(events[0].context["operation_id"], events[1].context["operation_id"]) + + async def test_environment_fraction_ranges_are_validated(self): + with self.assertRaises(ValueError): + await self.driver.environment.set_humidity(0.99) + with self.assertRaises(ValueError): + await self.driver.environment.set_co2(1.01) + with self.assertRaises(ValueError): + await self.driver.environment.set_o2(-0.01) + self.assertEqual(self.socket.written, []) + + async def test_missing_optional_channel_cannot_be_controlled(self): + self.socket.captures["environmentstatus"] = [ + "ACK! environmentstatus 31", + "TEMP:21.9/22.0/100.0", + "RH:54.7/0.0/-100.0", + "CO2:0.0/5.0/100.0", + "OK! environmentstatus 31", + ] + + with self.assertRaisesRegex(NotImplementedError, "does not control O2"): + await self.driver.environment.set_o2(0.10) + + self.assertFalse(self.driver.environment.supports_o2_control) + + async def test_stacker_dimensions(self): + dims = await self.retrieval.request_stacker_dimensions() + self.assertEqual(dims[0].stacker, 1) + self.assertEqual(dims[0].slot_count, 0) + self.assertEqual(dims[1].stacker, 2) + self.assertAlmostEqual(dims[1].slot_height, 22.867) + self.assertEqual(dims[1].slot_count, 24) + + async def test_barcode_scan_requires_clear_nests(self): + self.socket.captures["neststatus"] = [ + "ACK! neststatus 70", + "1: PLATE_AVAILABLE", + "2: CLEAR", + "OK! neststatus 70", + ] + + with self.assertRaisesRegex(RuntimeError, "plates are present on nests 1"): + await self.driver.request_stacker_barcodes(2, 1) + + self.assertEqual(self.socket.written, ["neststatus"]) + + async def test_barcode_scan_with_clear_nests(self): + self.socket.captures["barcode 2 1"] = [ + "ACK! barcode 2 1 71", + "2 1: EMPTY", + "OK! barcode 2 1 71", + ] + + self.assertEqual(await self.driver.request_stacker_barcodes(2, 1), ["2 1: EMPTY"]) + self.assertEqual(self.socket.written, ["neststatus", "barcode 2 1"]) + + async def test_barcode_arguments_are_validated_before_querying_device(self): + with self.assertRaises(ValueError): + await self.driver.request_stacker_barcodes(0) + with self.assertRaises(ValueError): + await self.driver.request_stacker_barcodes("all", 1) + with self.assertRaises(ValueError): + await self.driver.request_stacker_barcodes(2, 0) + self.assertEqual(self.socket.written, []) + + async def test_open_all_doors_accepts_confirmed_firmware_error(self): + self.socket.captures["openalldoors"] = [ + "ACK! openalldoors 72", + "Entering calibration mode.", + "ERROR! openalldoors 72", + ] + self.socket.captures["doorstatus"] = [ + "ACK! doorstatus 73", + "User Door: CLOSED", + "RI: OPEN", + "SEAL: OPEN", + "RO1: OPEN", + "RO2: OPEN", + "OK! doorstatus 73", + ] + + await self.driver.open_all_doors() + + self.assertEqual(self.socket.written, ["openalldoors", "doorstatus"]) + + async def test_open_all_doors_waits_until_transitional_doors_reach_open(self): + self.socket.captures["openalldoors"] = [ + "ACK! openalldoors 72", + "Entering calibration mode.", + "ERROR! openalldoors 72", + ] + door_status = AsyncMock( + side_effect=[ + {"RI": "open", "SEAL": "opening"}, + {"RI": "open", "SEAL": "open"}, + ] + ) + self.driver.request_door_status = door_status # type: ignore[method-assign] + + await self.driver.open_all_doors() + + self.assertEqual(door_status.await_count, 2) + + async def test_open_all_doors_does_not_accept_stuck_opening_state(self): + self.socket.captures["openalldoors"] = [ + "ACK! openalldoors 72", + "Error 1: failed to finish opening doors", + "ERROR! openalldoors 72", + ] + self.driver._motion_timeout = 0 + self.driver.request_door_status = AsyncMock( # type: ignore[method-assign] + return_value={"RI": "open", "SEAL": "opening"} + ) + + with self.assertRaises(HighResSampleStorageError): + await self.driver.open_all_doors() + + async def test_open_all_doors_preserves_error_if_a_robot_door_remains_closed(self): + self.socket.captures["openalldoors"] = [ + "ACK! openalldoors 74", + "Error 1: failed to open doors", + "ERROR! openalldoors 74", + ] + self.socket.captures["doorstatus"] = [ + "ACK! doorstatus 75", + "User Door: CLOSED", + "RI: OPEN", + "SEAL: CLOSED", + "OK! doorstatus 75", + ] + + with self.assertRaises(HighResSampleStorageError): + await self.driver.open_all_doors() + + async def test_home_error_raises_with_stack_detail(self): + with self.assertRaises(HighResSampleStorageError) as ctx: + await self.retrieval.home() + self.assertIn("Unable to close all doors", str(ctx.exception)) + self.assertEqual(ctx.exception.command, "home") + + async def test_pick_formats_command(self): + self.socket.captures["pick 3 12 1"] = ["ACK! pick 3 12 1 99", "OK! pick 3 12 1 99"] + await self.retrieval._pick(3, 12, 1) + self.assertEqual(self.socket.written, ["pick 3 12 1"]) + + async def test_tray_maps_to_nest(self): + await self.driver.setup() + # 0-based tray -> device-reported nest number. + self.assertEqual(self.retrieval._nest_for_tray(0), 1) + self.assertEqual(self.retrieval._nest_for_tray(1), 2) + with self.assertRaises(ValueError): + self.retrieval._nest_for_tray(2) + + async def test_serialization_round_trip(self): + await self.driver.setup() + self.driver.metadata["owner"] = "lab" + + serialized = json.loads(json.dumps(self.driver.serialize())) + restored = SteriStore.deserialize(serialized) + + self.assertIsInstance(restored, SteriStore) + self.assertEqual(restored.metadata, {"owner": "lab"}) + self.assertEqual( + [nest.name for nest in restored.nests], [nest.name for nest in self.driver.nests] + ) + self.assertEqual(restored.racks, []) + self.assertEqual(restored.serialize(), serialized) + + +class HighResSampleStorageBookkeepingTests(unittest.IsolatedAsyncioTestCase): + def setUp(self): + self.site = PlateHolder( + name="site_1", size_x=127.76, size_y=85.48, size_z=20, pedestal_size_z=0 + ) + rack = PlateCarrier(name="rack_1", size_x=130, size_y=90, size_z=100) + rack.assign_child_resource(self.site, location=Coordinate.zero(), spot=0) + well = Well(name="A1", size_x=8, size_y=8, size_z=10) + well.location = Coordinate(10, 10, 2) + self.plate = Plate( + name="plate", size_x=127.76, size_y=85.48, size_z=14, ordered_items={"A1": well} + ) + self.site.assign_child_resource(self.plate) + + self.driver = HighResSampleStorage(host="10.253.253.253", name="sample_store", racks={1: rack}) + self.socket = FakeSocket(CAPTURES) + self.driver.io = self.socket # type: ignore[assignment] + + async def asyncSetUp(self): + await self.driver.setup() + self.socket.written.clear() + + def _set_nest_status(self, nest_1: str, nest_2: str = "CLEAR") -> None: + """Configure the fake live nest-sensor response.""" + self.socket.captures["neststatus"] = [ + "ACK! neststatus 12", + f"1: {nest_1}", + f"2: {nest_2}", + "OK! neststatus 12", + ] + + def test_serialization_preserves_stacker_mapping_and_inventory(self): + serialized = json.loads(json.dumps(self.driver.serialize())) + + restored = HighResSampleStorage.deserialize(serialized) + + self.assertEqual(list(restored._racks_by_number), [1]) + restored_plate = restored.get_site_by_plate_name("plate").resource + self.assertIsNotNone(restored_plate) + assert restored_plate is not None + self.assertEqual(restored_plate.name, "plate") + self.assertEqual(restored.serialize(), serialized) + + async def test_fetch_moves_plate_resource_to_nest(self): + self.socket.captures["pick 1 1 1"] = ["ACK! pick 1 1 1 40", "OK! pick 1 1 1 40"] + + result = await self.driver.fetch_plate_to_loading_tray(self.plate, tray_index=0) + + self.assertIs(result, self.plate) + self.assertIsNone(self.site.resource) + self.assertIs(self.driver.nests[0].resource, self.plate) + self.assertIs(self.plate.parent, self.driver.nests[0]) + self.assertIsNone(self.driver.unresolved_transfer) + + async def test_fetch_refuses_physically_occupied_destination_nest(self): + self._set_nest_status("PLATE_AVAILABLE") + + with self.assertRaisesRegex(RuntimeError, "nest 1 must be clear"): + await self.driver.fetch_plate_to_loading_tray(self.plate, tray_index=0) + + self.assertEqual(self.socket.written, ["neststatus"]) + self.assertIs(self.site.resource, self.plate) + + async def test_fetch_emits_correlated_operation_and_bookkeeping_events(self): + self.socket.captures["pick 1 1 1"] = ["ACK! pick 1 1 1 40", "OK! pick 1 1 1 40"] + events: List[PLREvent] = [] + event_bus = EventBus() + event_bus.subscribe(events.append) + + with use_event_bus(event_bus): + await self.driver.fetch_plate_to_loading_tray(self.plate, tray_index=0) + + self.assertEqual( + [event.name for event in events], + [ + "incubator.fetch_plate.started", + "resource.unassigned", + "resource.assigned", + "incubator.fetch_plate.completed", + ], + ) + operation_id = events[0].context["operation_id"] + self.assertTrue(all(event.context["operation_id"] == operation_id for event in events)) + self.assertEqual(events[0].data["source"]["name"], self.site.name) + self.assertEqual(events[0].data["destination"]["name"], self.driver.nests[0].name) + + def test_explicit_carrier_spots_define_physical_slots(self): + first_inserted = PlateHolder( + name="spot_7", size_x=127.76, size_y=85.48, size_z=20, pedestal_size_z=0 + ) + second_inserted = PlateHolder( + name="spot_2", size_x=127.76, size_y=85.48, size_z=20, pedestal_size_z=0 + ) + rack = PlateCarrier(name="out_of_order", size_x=130, size_y=90, size_z=100) + rack.assign_child_resource(first_inserted, location=Coordinate.zero(), spot=7) + rack.assign_child_resource(second_inserted, location=Coordinate.zero(), spot=2) + + driver = HighResSampleStorage( + host="10.253.253.253", name="explicit_slots", racks={7: rack}, model="SteriStore" + ) + + self.assertEqual(driver._locate(first_inserted), (7, 8)) + self.assertEqual(driver._locate(second_inserted), (7, 3)) + + def test_site_selection_rejects_slots_that_are_too_short_for_lidded_plate(self): + short = PlateHolder(name="short", size_x=127.76, size_y=85.48, size_z=16, pedestal_size_z=0) + tall = PlateHolder(name="tall", size_x=127.76, size_y=85.48, size_z=18, pedestal_size_z=0) + rack = PlateCarrier(name="height_rack", size_x=130, size_y=90, size_z=100) + rack.assign_child_resource(short, location=Coordinate.zero(), spot=0) + rack.assign_child_resource(tall, location=Coordinate.zero(), spot=1) + plate = Plate(name="lidded", size_x=127.76, size_y=85.48, size_z=14, ordered_items={}) + plate.assign_child_resource( + Lid(name="lid", size_x=127.76, size_y=85.48, size_z=5, nesting_z_height=2) + ) + driver = HighResSampleStorage( + host="10.253.253.253", name="height_store", racks={1: rack}, model="SteriStore" + ) + + self.assertIs(driver.find_smallest_site_for_plate(plate), tall) + + async def test_fetch_by_name_moves_plate_resource_to_nest(self): + self.socket.captures["pick 1 1 1"] = ["ACK! pick 1 1 1 40", "OK! pick 1 1 1 40"] + + result = await self.driver.fetch_plate_to_loading_tray("plate", tray_index=0) + + self.assertIs(result, self.plate) + self.assertIsNone(self.site.resource) + self.assertIs(self.driver.nests[0].resource, self.plate) + + async def test_transfer_lock_covers_validation_motion_and_bookkeeping(self): + transfer_started = asyncio.Event() + release_transfer = asyncio.Event() + pick_calls = [] + + async def blocked_pick(stacker: int, slot: int, nest: int, close_door: bool = True): + pick_calls.append((stacker, slot, nest, close_door)) + transfer_started.set() + await release_transfer.wait() + + self.driver._pick = blocked_pick # type: ignore[method-assign] + first = asyncio.create_task(self.driver.fetch_plate_to_loading_tray(self.plate, tray_index=0)) + await asyncio.wait_for(transfer_started.wait(), timeout=1) + + second = asyncio.create_task(self.driver.fetch_plate_to_loading_tray(self.plate, tray_index=1)) + await asyncio.sleep(0) + + # The second transfer must not even validate against the stale resource + # tree while the first hardware move is in progress. + self.assertEqual(pick_calls, [(1, 1, 1, True)]) + self.assertFalse(second.done()) + + release_transfer.set() + self.assertIs(await first, self.plate) + with self.assertRaisesRegex(ValueError, "not attached to a plate carrier"): + await second + self.assertEqual(pick_calls, [(1, 1, 1, True)]) + + def test_inventory_queries_use_resource_tree(self): + self.assertEqual(self.driver.get_num_free_sites(), 0) + self.assertIs(self.driver.get_site_by_plate_name("plate"), self.site) + + async def test_take_in_plate_moves_nest_resource_to_selected_site(self): + self.plate.unassign() + self.driver.nests[0].assign_child_resource(self.plate) + self._set_nest_status("PLATE_AVAILABLE") + self.socket.captures["place 1 1 1"] = ["ACK! place 1 1 1 41", "OK! place 1 1 1 41"] + + result = await self.driver.take_in_plate(tray_index=0) + + self.assertIs(result, self.plate) + self.assertIsNone(self.driver.nests[0].resource) + self.assertIs(self.site.resource, self.plate) + + async def test_take_in_plate_emits_correlated_operation_and_bookkeeping_events(self): + self.plate.unassign() + self.driver.nests[0].assign_child_resource(self.plate) + self._set_nest_status("PLATE_AVAILABLE") + self.socket.captures["place 1 1 1"] = ["ACK! place 1 1 1 41", "OK! place 1 1 1 41"] + events: List[PLREvent] = [] + event_bus = EventBus() + event_bus.subscribe(events.append) + + with use_event_bus(event_bus): + await self.driver.take_in_plate(tray_index=0) + + self.assertEqual( + [event.name for event in events], + [ + "incubator.take_in_plate.started", + "resource.unassigned", + "resource.assigned", + "incubator.take_in_plate.completed", + ], + ) + operation_id = events[0].context["operation_id"] + self.assertTrue(all(event.context["operation_id"] == operation_id for event in events)) + self.assertEqual(events[0].data["source"]["name"], self.driver.nests[0].name) + self.assertEqual(events[0].data["destination"]["name"], self.site.name) + + async def test_store_moves_plate_resource_to_site(self): + self.plate.unassign() + self.driver.nests[0].assign_child_resource(self.plate) + self._set_nest_status("PLATE_AVAILABLE") + self.socket.captures["place 1 1 1"] = ["ACK! place 1 1 1 41", "OK! place 1 1 1 41"] + + await self.driver.store_plate(self.plate, self.site, tray_index=0) + + self.assertIsNone(self.driver.nests[0].resource) + self.assertIs(self.site.resource, self.plate) + self.assertIs(self.plate.parent, self.site) + self.assertIsNone(self.driver.unresolved_transfer) + + async def test_store_refuses_logical_plate_when_physical_nest_is_clear(self): + self.plate.unassign() + self.driver.nests[0].assign_child_resource(self.plate) + + with self.assertRaisesRegex(RuntimeError, "nest 1 must be occupied"): + await self.driver.store_plate(self.plate, self.site, tray_index=0) + + self.assertEqual(self.socket.written, ["neststatus"]) + self.assertIs(self.driver.nests[0].resource, self.plate) + + async def test_transfer_plate_between_nests_updates_hardware_and_resource_tree(self): + self.plate.unassign() + self.driver.nests[1].assign_child_resource(self.plate) + self._set_nest_status("CLEAR", "PLATE_AVAILABLE") + self.socket.captures["nesttransfer 2 1"] = [ + "ACK! nesttransfer 2 1 50", + "OK! nesttransfer 2 1 50", + ] + events: List[PLREvent] = [] + event_bus = EventBus() + event_bus.subscribe(events.append) + + with use_event_bus(event_bus): + result = await self.driver.transfer_plate_between_nests(1, 0) + + self.assertIs(result, self.plate) + self.assertIsNone(self.driver.nests[1].resource) + self.assertIs(self.driver.nests[0].resource, self.plate) + self.assertEqual(self.socket.written, ["neststatus", "nesttransfer 2 1"]) + self.assertEqual( + [event.name for event in events], + [ + "incubator.transfer_plate.started", + "resource.unassigned", + "resource.assigned", + "incubator.transfer_plate.completed", + ], + ) + self.assertIsNone(self.driver.unresolved_transfer) + + async def test_transfer_plate_between_nests_requires_live_sensor_agreement(self): + self.plate.unassign() + self.driver.nests[1].assign_child_resource(self.plate) + + with self.assertRaisesRegex(RuntimeError, "nest 2 must be occupied"): + await self.driver.transfer_plate_between_nests(1, 0) + + self.assertEqual(self.socket.written, ["neststatus"]) + self.assertIs(self.driver.nests[1].resource, self.plate) + + async def test_aborted_nest_transfer_records_both_nest_endpoints(self): + self.plate.unassign() + self.driver.nests[1].assign_child_resource(self.plate) + self._set_nest_status("CLEAR", "PLATE_AVAILABLE") + self.socket.captures["nesttransfer 2 1"] = [ + "ACK! nesttransfer 2 1 50", + "ABORTED! nesttransfer 2 1 50", + ] + + with self.assertRaises(HighResSampleStorageAbortedError): + await self.driver.transfer_plate_between_nests(1, 0) + + transfer = self.driver.unresolved_transfer + self.assertIsNotNone(transfer) + assert transfer is not None + self.assertIs(transfer.source, self.driver.nests[1]) + self.assertIs(transfer.destination, self.driver.nests[0]) + self.assertIs(self.plate.parent, self.driver.nests[1]) + + async def test_timeout_after_pick_ack_records_unresolved_transfer(self): + command = "pick 1 1 1" + captures = dict(CAPTURES) + captures[command] = [f"ACK! {command} 42", f"OK! {command} 42"] + socket = TimeoutAfterAckSocket(captures, timeout_command=command) + self.driver.io = socket # type: ignore[assignment] + + with self.assertRaisesRegex(TimeoutError, "simulated response timeout"): + await self.driver.fetch_plate_to_loading_tray(self.plate, tray_index=0) + + transfer = self.driver.unresolved_transfer + self.assertIsNotNone(transfer) + assert transfer is not None + self.assertEqual(transfer.command, command) + self.assertEqual(transfer.error_type, "TimeoutError") + self.assertIs(self.plate.parent, self.site) + self.assertEqual(socket.stop_calls, 1) + with self.assertRaisesRegex(RuntimeError, "Plate location is unresolved"): + self.driver.serialize() + + result = await self.driver.resolve_unresolved_transfer("source") + + self.assertIs(result, self.plate) + self.assertEqual(socket.setup_calls, 1) + self.assertIsNone(self.driver.unresolved_transfer) + self.assertEqual(socket.written[-2:], ["platestatus", "neststatus"]) + + async def test_cancelled_pick_records_unresolved_transfer(self): + started = asyncio.Event() + + async def blocked_pick(stacker: int, slot: int, nest: int, close_door: bool = True): + started.set() + await asyncio.Event().wait() + + self.driver._pick = blocked_pick # type: ignore[method-assign] + task = asyncio.create_task(self.driver.fetch_plate_to_loading_tray(self.plate, tray_index=0)) + await asyncio.wait_for(started.wait(), timeout=1) + task.cancel() + + with self.assertRaises(asyncio.CancelledError): + await task + + transfer = self.driver.unresolved_transfer + self.assertIsNotNone(transfer) + assert transfer is not None + self.assertEqual(transfer.command, "pick 1 1 1") + self.assertEqual(transfer.error_type, "CancelledError") + self.assertIs(self.plate.parent, self.site) + + async def test_aborted_pick_records_unresolved_transfer(self): + self.socket.captures["pick 1 1 1"] = [ + "ACK! pick 1 1 1 42", + "ABORTED! pick 1 1 1 42", + ] + + with self.assertRaises(HighResSampleStorageAbortedError): + await self.driver.fetch_plate_to_loading_tray(self.plate, tray_index=0) + + transfer = self.driver.unresolved_transfer + self.assertIsNotNone(transfer) + assert transfer is not None + self.assertEqual(transfer.error_type, "HighResSampleStorageAbortedError") + self.assertIs(self.plate.parent, self.site) + + async def test_unsafe_pick_records_unresolved_transfer_and_recovery_does_not_clear_it(self): + self.socket.captures["pick 1 1 1"] = [ + "ACK! pick 1 1 1 42", + "Error 1: 42: Z height is unsafe for rotation, check machine", + "ERROR! pick 1 1 1 42", + ] + + with self.assertRaises(HighResSampleStorageFault): + await self.driver.fetch_plate_to_loading_tray(self.plate, tray_index=0) + + transfer = self.driver.unresolved_transfer + self.assertIsNotNone(transfer) + assert transfer is not None + self.assertEqual(transfer.error_type, "HighResSampleStorageFault") + + self.socket.captures["enable"] = ["ACK! enable 43", "OK! enable 43"] + self.socket.captures["spatulaout"] = ["ACK! spatulaout 44", "OK! spatulaout 44"] + self.socket.captures["home"] = ["ACK! home 45", "OK! home 45"] + self.socket.captures["homedstatus"] = [ + "ACK! homedstatus 46", + "homed", + "OK! homedstatus 46", + ] + self.socket.captures["status"] = [ + "ACK! status 47", + "Carousel: 0.0", + "Y axis: 0.0", + "Z axis: 0.0", + "OK! status 47", + ] + + self.assertTrue(await self.driver.recover()) + self.assertIs(self.driver.unresolved_transfer, transfer) + + async def test_resolve_unresolved_fetch_to_destination_uses_live_sensors(self): + async def ambiguous_pick(stacker: int, slot: int, nest: int, close_door: bool = True): + raise TimeoutError("completion was lost") + + self.driver._pick = ambiguous_pick # type: ignore[method-assign] + with self.assertRaises(TimeoutError): + await self.driver.fetch_plate_to_loading_tray(self.plate, tray_index=0) + + self._set_nest_status("PLATE_AVAILABLE") + result = await self.driver.resolve_unresolved_transfer("destination") + + self.assertIs(result, self.plate) + self.assertIs(self.plate.parent, self.driver.nests[0]) + self.assertIsNone(self.site.resource) + self.assertIsNone(self.driver.unresolved_transfer) + self.assertEqual(self.socket.written[-2:], ["platestatus", "neststatus"]) + + async def test_reconcile_rejects_spatula_plate_and_can_mark_plate_unassigned(self): + async def ambiguous_pick(stacker: int, slot: int, nest: int, close_door: bool = True): + raise TimeoutError("completion was lost") + + self.driver._pick = ambiguous_pick # type: ignore[method-assign] + with self.assertRaises(TimeoutError): + await self.driver.fetch_plate_to_loading_tray(self.plate, tray_index=0) + + transfer = self.driver.unresolved_transfer + self.socket.captures["platestatus"] = [ + "ACK! platestatus 9", + "PLATE_AVAILABLE", + "OK! platestatus 9", + ] + with self.assertRaisesRegex(RuntimeError, "spatula reports that it is holding a plate"): + await self.driver.resolve_unresolved_transfer("unassigned") + self.assertIs(self.driver.unresolved_transfer, transfer) + + self.socket.captures["platestatus"] = [ + "ACK! platestatus 10", + "NO_PLATE", + "OK! platestatus 10", + ] + result = await self.driver.resolve_unresolved_transfer("unassigned") + + self.assertIs(result, self.plate) + self.assertIsNone(self.plate.parent) + self.assertIsNone(self.driver.unresolved_transfer) + + async def test_failed_fetch_leaves_resource_in_site(self): + self.socket.captures["pick 1 1 1"] = [ + "ACK! pick 1 1 1 42", + "Error 1: 42: No plate detected", + "ERROR! pick 1 1 1 42", + ] + self.socket.captures["homedstatus"] = [ + "ACK! homedstatus 43", + "homed", + "OK! homedstatus 43", + ] + + with self.assertRaises(PlateNotFoundError): + await self.driver.fetch_plate_to_loading_tray(self.plate, tray_index=0) + + self.assertIs(self.site.resource, self.plate) + self.assertIsNone(self.driver.nests[0].resource) + self.assertIsNone(self.driver.unresolved_transfer) + + async def test_failed_store_records_unresolved_transfer_and_blocks_another_move(self): + self.plate.unassign() + self.driver.nests[0].assign_child_resource(self.plate) + self._set_nest_status("PLATE_AVAILABLE") + self.socket.captures["place 1 1 1"] = [ + "ACK! place 1 1 1 44", + "Error 1: 44: Place failed", + "ERROR! place 1 1 1 44", + ] + self.socket.captures["homedstatus"] = [ + "ACK! homedstatus 45", + "homed", + "OK! homedstatus 45", + ] + + with self.assertRaises(HighResSampleStorageError): + await self.driver.store_plate(self.plate, self.site, tray_index=0) + + self.assertIs(self.driver.nests[0].resource, self.plate) + self.assertIsNone(self.site.resource) + transfer = self.driver.unresolved_transfer + self.assertIsNotNone(transfer) + assert transfer is not None + self.assertIs(transfer.plate, self.plate) + self.assertIs(transfer.source, self.driver.nests[0]) + self.assertIs(transfer.destination, self.site) + self.assertEqual(transfer.command, "place 1 1 1") + self.assertEqual(transfer.error_type, "HighResSampleStorageError") + + written = list(self.socket.written) + with self.assertRaisesRegex(RuntimeError, "Plate location is unresolved"): + await self.driver.store_plate(self.plate, self.site, tray_index=0) + with self.assertRaisesRegex(RuntimeError, "Plate location is unresolved"): + await self.driver._send_command("nesttransfer 1 2") + with self.assertRaisesRegex(RuntimeError, "Plate location is unresolved"): + await self.driver.home() + with self.assertRaisesRegex(RuntimeError, "Plate location is unresolved"): + await self.driver.request_stacker_barcodes(1) + self.assertEqual(self.socket.written, written) + + async def test_resolve_unresolved_store_to_source_uses_live_nest_sensor(self): + self.plate.unassign() + self.driver.nests[0].assign_child_resource(self.plate) + self._set_nest_status("PLATE_AVAILABLE") + + async def ambiguous_place(stacker: int, slot: int, nest: int, close_door: bool = True): + raise TimeoutError("completion was lost") + + self.driver._place = ambiguous_place # type: ignore[method-assign] + with self.assertRaises(TimeoutError): + await self.driver.store_plate(self.plate, self.site, tray_index=0) + + result = await self.driver.resolve_unresolved_transfer("source") + + self.assertIs(result, self.plate) + self.assertIs(self.plate.parent, self.driver.nests[0]) + self.assertIsNone(self.driver.unresolved_transfer) + self.assertEqual(self.socket.written[-2:], ["platestatus", "neststatus"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/high_res/sample_storage/tests/recovery_tests.py b/pylabrobot/high_res/sample_storage/tests/recovery_tests.py new file mode 100644 index 00000000000..47b0f76bd8f --- /dev/null +++ b/pylabrobot/high_res/sample_storage/tests/recovery_tests.py @@ -0,0 +1,280 @@ +import unittest +from typing import List + +from pylabrobot.high_res.sample_storage.driver import HighResSampleStorage +from pylabrobot.high_res.sample_storage.driver.errors import ( + HighResSampleStorageFault, + PlateNotFoundError, +) + + +def _ok(command: str, cid: int) -> List[str]: + return [f"ACK! {command} {cid}", f"OK! {command} {cid}"] + + +class ScriptedSocket: + """Replays an ordered (expected_command, response_lines) script, asserting the + exact command sequence — used to verify multi-step recovery flows.""" + + def __init__(self, script): + self.script = list(script) + self.i = 0 + self.commands: List[str] = [] + self._queue: List[str] = [] + + async def setup(self): + pass + + async def stop(self): + pass + + async def write(self, data: bytes, timeout=None): + command = data.decode("ascii").rstrip("\r\n") + self.commands.append(command) + expected, lines = self.script[self.i] + self.i += 1 + assert command == expected, f"expected {expected!r}, got {command!r}" + self._queue = list(lines) + + async def readuntil(self, separator: bytes = b"\n", timeout=None) -> bytes: + return self._queue.pop(0).encode("ascii") + b"\r\n" + + +class HighResSampleStorageRecoveryTests(unittest.IsolatedAsyncioTestCase): + def setUp(self): + self.driver = HighResSampleStorage(host="10.253.253.253", name="sample_store", racks={}) + self.retrieval = self.driver + + async def test_empty_slot_pick_raises_plate_not_found_when_parked(self): + # The store reports "No plate detected" and stays parked (graceful empty). + empty = [ + "ACK! pick 5 12 1 50", + "Error 1: (00:00:01) 50: No plate detected", + "ERROR! pick 5 12 1 50", + ] + sock = ScriptedSocket( + [ + ("pick 5 12 1", empty), + ("homedstatus", ["ACK! homedstatus 51", "homed", "OK! homedstatus 51"]), + ( + "status", + [ + "ACK! status 52", + "Carousel: 0.0", + "Y axis: 0.0", + "Z axis: 0.0", + "OK! status 52", + ], + ), + ] + ) + self.driver.io = sock # type: ignore[assignment] + with self.assertRaises(PlateNotFoundError): + await self.retrieval._pick(5, 12, 1) + # classified by state, no recovery motion issued + self.assertEqual(sock.commands, ["pick 5 12 1", "homedstatus", "status"]) + + async def test_empty_slot_pick_raises_fault_when_homed_but_extended(self): + empty = [ + "ACK! pick 5 12 1 50", + "Error 1: (00:00:01) 50: No plate detected", + "ERROR! pick 5 12 1 50", + ] + sock = ScriptedSocket( + [ + ("pick 5 12 1", empty), + ("homedstatus", ["ACK! homedstatus 51", "homed", "OK! homedstatus 51"]), + ( + "status", + [ + "ACK! status 52", + "Carousel: 0.0", + "Y axis: 256.0", + "Z axis: 0.0", + "OK! status 52", + ], + ), + ] + ) + self.driver.io = sock # type: ignore[assignment] + + with self.assertRaises(HighResSampleStorageFault): + await self.retrieval._pick(5, 12, 1) + + self.assertEqual(sock.commands, ["pick 5 12 1", "homedstatus", "status"]) + + async def test_top_slot_stuck_raises_fault_despite_homed_lie(self): + # Empty TOP slot: "No plate detected" but the spatula is left extended and + # the firmware reports "unsafe for rotation" while homedstatus still says + # homed. The "unsafe" signal must win -> HighResSampleStorageFault (no homedstatus + # query needed, the signature short-circuits). + stuck = [ + "ACK! pick 5 24 1 60", + "Error 1: 60: No plate detected", + "Error 2: 60: Z height is unsafe for rotation, check machine", + "ERROR! pick 5 24 1 60", + ] + sock = ScriptedSocket([("pick 5 24 1", stuck)]) + self.driver.io = sock # type: ignore[assignment] + with self.assertRaises(HighResSampleStorageFault): + await self.retrieval._pick(5, 24, 1) + self.assertEqual(sock.commands, ["pick 5 24 1"]) + + async def test_dehomed_pick_raises_fault(self): + # An error with no "unsafe" signature but the machine reports unhomed. + fault = [ + "ACK! pick 5 24 1 70", + "Error 1: 70: motor fault", + "ERROR! pick 5 24 1 70", + ] + sock = ScriptedSocket( + [ + ("pick 5 24 1", fault), + ("homedstatus", ["ACK! homedstatus 71", "not homed", "OK! homedstatus 71"]), + ] + ) + self.driver.io = sock # type: ignore[assignment] + with self.assertRaises(HighResSampleStorageFault): + await self.retrieval._pick(5, 24, 1) + self.assertEqual(sock.commands, ["pick 5 24 1", "homedstatus"]) + + def _homed(self, cid: int) -> List[str]: + return [f"ACK! homedstatus {cid}", "homed", f"OK! homedstatus {cid}"] + + def _status(self, cid: int, y: float, z: float = 0.0) -> List[str]: + return [ + f"ACK! status {cid}", + "Carousel: 0.0", + f"Y axis: {y}", + f"Z axis: {z}", + f"OK! status {cid}", + ] + + def _plate_status(self, cid: int, holding: bool = False) -> List[str]: + state = "PLATE_AVAILABLE" if holding else "NO_PLATE" + return [f"ACK! platestatus {cid}", state, f"OK! platestatus {cid}"] + + async def test_request_is_parked_catches_the_homed_lie(self): + # homedstatus says homed, but the spatula is stuck extended (Y=256) -> NOT parked. + sock = ScriptedSocket([("homedstatus", self._homed(1)), ("status", self._status(2, 255.9999))]) + self.driver.io = sock # type: ignore[assignment] + self.assertFalse(await self.retrieval.request_is_parked()) + + async def test_request_is_parked_requires_retracted_z(self): + sock = ScriptedSocket( + [("homedstatus", self._homed(1)), ("status", self._status(2, y=0.0, z=384.0))] + ) + self.driver.io = sock # type: ignore[assignment] + self.assertFalse(await self.retrieval.request_is_parked()) + + async def test_recover_always_retracts_and_rehomes(self): + # recover() must issue retract+home even when homedstatus already says homed + # (the lie), then confirm parked via the slide position. + sock = ScriptedSocket( + [ + ("platestatus", self._plate_status(1)), + ("enable", _ok("enable", 2)), + ("spatulaout", _ok("spatulaout", 3)), + ("home", _ok("home", 4)), + ("homedstatus", self._homed(5)), + ("status", self._status(6, 0.0)), + ] + ) + self.driver.io = sock # type: ignore[assignment] + self.assertTrue(await self.retrieval.recover()) + self.assertEqual( + sock.commands, ["platestatus", "enable", "spatulaout", "home", "homedstatus", "status"] + ) + + async def test_recover_retries_until_parked(self): + # First round still reads extended (homed-lie); recover retries and succeeds. + sock = ScriptedSocket( + [ + ("platestatus", self._plate_status(1)), + ("enable", _ok("enable", 2)), + ("spatulaout", _ok("spatulaout", 3)), + ("home", _ok("home", 4)), + ("homedstatus", self._homed(5)), + ("status", self._status(6, 255.9999)), # still extended -> retry + ("enable", _ok("enable", 7)), + ("spatulaout", _ok("spatulaout", 8)), + ("home", _ok("home", 9)), + ("homedstatus", self._homed(10)), + ("status", self._status(11, 0.0)), # now retracted + ] + ) + self.driver.io = sock # type: ignore[assignment] + self.assertTrue(await self.retrieval.recover()) + + async def test_recover_refuses_plate_on_spatula(self): + sock = ScriptedSocket([("platestatus", self._plate_status(1, holding=True))]) + self.driver.io = sock # type: ignore[assignment] + + with self.assertRaisesRegex(RuntimeError, "holding a plate"): + await self.retrieval.recover() + + self.assertEqual(sock.commands, ["platestatus"]) + + async def test_unsafe_place_raises_fault(self): + unsafe = [ + "ACK! place 2 5 1 1", + "Error 1: 1: Z height is unsafe for rotation, check machine", + "ERROR! place 2 5 1 1", + ] + sock = ScriptedSocket([("place 2 5 1", unsafe)]) + self.driver.io = sock # type: ignore[assignment] + + with self.assertRaises(HighResSampleStorageFault): + await self.retrieval._place(2, 5, 1) + + async def test_place_raises_fault_when_homed_but_extended(self): + generic_error = [ + "ACK! place 2 5 1 1", + "Error 1: 1: motor fault", + "ERROR! place 2 5 1 1", + ] + sock = ScriptedSocket( + [ + ("place 2 5 1", generic_error), + ("homedstatus", self._homed(2)), + ("status", self._status(3, y=256.0)), + ] + ) + self.driver.io = sock # type: ignore[assignment] + + with self.assertRaises(HighResSampleStorageFault): + await self.retrieval._place(2, 5, 1) + + self.assertEqual(sock.commands, ["place 2 5 1", "homedstatus", "status"]) + + async def test_place_default_leaves_doors_sealed(self): + sock = ScriptedSocket([("place 2 5 1", _ok("place 2 5 1", 1))]) + self.driver.io = sock # type: ignore[assignment] + await self.retrieval._place(2, 5, 1) # close_door=True default + self.assertEqual(sock.commands, ["place 2 5 1"]) + + async def test_place_close_door_false_reopens(self): + sock = ScriptedSocket( + [ + ("place 2 5 1", _ok("place 2 5 1", 1)), + ("openalldoors", _ok("openalldoors", 2)), + ] + ) + self.driver.io = sock # type: ignore[assignment] + await self.retrieval._place(2, 5, 1, close_door=False) + self.assertEqual(sock.commands, ["place 2 5 1", "openalldoors"]) + + async def test_pick_close_door_false_reopens(self): + sock = ScriptedSocket( + [ + ("pick 2 5 1", _ok("pick 2 5 1", 1)), + ("openalldoors", _ok("openalldoors", 2)), + ] + ) + self.driver.io = sock # type: ignore[assignment] + await self.retrieval._pick(2, 5, 1, close_door=False) + self.assertEqual(sock.commands, ["pick 2 5 1", "openalldoors"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/high_res/sample_storage/tundra_store.py b/pylabrobot/high_res/sample_storage/tundra_store.py new file mode 100644 index 00000000000..42174b5c6bf --- /dev/null +++ b/pylabrobot/high_res/sample_storage/tundra_store.py @@ -0,0 +1,11 @@ +from .driver import HighResSampleStorage + + +class TundraStore(HighResSampleStorage): + """HighRes Biosolutions TundraStore refrigerated plate store.""" + + _model_name = "TundraStore" + _verification_warning = ( + "TundraStore support is a work in progress and has not been verified against hardware. " + "Validate it in a controlled setup and report verified behavior so this warning can be removed." + )