Skip to content

feat(highres): add TundraStore/SteriStore plate store driver#1090

Open
rickwierenga wants to merge 24 commits into
v1b1from
highres-tundrastore-driver
Open

feat(highres): add TundraStore/SteriStore plate store driver#1090
rickwierenga wants to merge 24 commits into
v1b1from
highres-tundrastore-driver

Conversation

@rickwierenga

Copy link
Copy Markdown
Member

Summary

Adds a PyLabRobot driver for the HighRes Biosolutions TundraStore (branded "SteriStore") refrigerated automated plate store. The device exposes a line-based text protocol over TCP port 1000 (ACK! echo → optional data → OK!/ABORTED!/ERROR! completion).

New package pylabrobot/highres/tundrastore/, modeled on the v1b1 Cytomat driver:

  • TundraStoreBackend — transport over pylabrobot.io.socket.Socket, with command framing + error-stack parsing. Mixes in the AutomatedRetrieval, TemperatureController, and (read-only) HumidityController capability backends + Driver. Exposes low-level home / pick(stacker, slot, nest) / place / door / barcode commands and status queries (version, doors, nests, spatula, environment, stacker dimensions).
  • TundraStoreChatterboxBackend — device-free backend for offline testing.
  • TundraStoreResource + Device frontend (racks → stackers, capability registration), mirroring the Cytomat frontend.
  • backend_tests.py — 13 tests driven by real responses captured from hardware (firmware 3.0.0.119, serial HRB-2209-35148), including the real ERROR! home … Unable to close all doors stack.

Validation

  • 13 unit tests pass; ruff format/ruff check and mypy clean.
  • Verified live against the device (read-only queries): version, homed state, nests, doors, temperature (21.9 °C), humidity, and 14-stacker layout all read back correctly through the backend.

Open question / follow-up

The TundraStore has two transfer nests, but the AutomatedRetrieval capability is single-loading-tray. For now the capability uses a configurable loading_tray_nest (default 1); pick/place can address either nest directly. A proper two-nest mapping is left as a follow-up.

The temperature/humidity setter paths (environmentset TEMP …, environment TEMP off) are constructed from the manual's command grammar but not yet hardware-verified (couldn't safely run state changes during testing). Read paths are verified.

🤖 Generated with Claude Code

rick and others added 2 commits June 12, 2026 17:57
Add a PyLabRobot driver for the HighRes Biosolutions TundraStore
(branded "SteriStore") refrigerated automated plate store, which speaks
a line-based text protocol over TCP port 1000.

- TundraStoreBackend: pylabrobot.io.socket.Socket transport with ACK /
  data / completion (OK!/ABORTED!/ERROR!) framing and error-stack parsing.
  Implements AutomatedRetrieval, TemperatureController and (read-only)
  HumidityController capabilities, plus low-level home/pick/place/door/
  barcode commands and status queries (version, doors, nests, spatula,
  environment, stacker dimensions).
- TundraStoreChatterboxBackend for device-free use.
- TundraStore frontend (Resource + Device) modeled on Cytomat.
- Tests driven by real captured firmware-3.0.0.119 responses.

Note: the device has two transfer nests; mapping the single-loading-tray
AutomatedRetrieval model onto both is left as a follow-up — the capability
currently uses a configurable single nest (default 1).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…'s two nests

Add an optional 0-based `tray` argument to AutomatedRetrieval.fetch_plate_to_
loading_tray / store_plate (and the backend abstract methods). `tray=None`
selects the device default, so all existing callers are unaffected.

- Single-tray devices (Cytomat, Heraeus, Liconic) validate via the new
  ensure_single_tray() helper (accept None/0, reject others).
- TundraStore maps tray i -> device nest i+1; the frontend now models both
  nests as TundraStore.nests[0|1] with per-tray plate tracking
  (fetch_plate_to_nest / take_in_plate take a `tray`).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@rickwierenga rickwierenga changed the title feat(highres): add TundraStore plate store driver feat(highres): add TundraStore/SteriStore plate store driver Jun 15, 2026
rickwierenga and others added 22 commits June 15, 2026 22:55
- TundraStoreSettings: client-side dataclass over the device's ~545-key
  settings file, with typed accessors and named properties (height-detect,
  beam-break, stacker bases, nest heights/inputs, plate defaults). Loadable
  from the device (backend.request_settings()) or JSON.
- pick() now classifies failures without performing hidden motion:
  - PlateNotFoundError when a slot is empty ("No plate detected") and the
    store retracted cleanly (the normal empty-slot outcome at any non-top slot).
  - TundraStoreFault when the machine was left unsafe (spatula extended /
    unhomed) — e.g. an empty TOP slot, where the firmware can't complete its
    safe-travel retract. homedstatus reports homed even while stuck, so the
    firmware's "unsafe for rotation" signal is used, not just is_homed().
- recover(): explicit retract-spatula + re-home, for use after a fault.

Hardware-validated: empty middle-slot pick -> graceful "No plate detected";
empty top-slot pick -> fault + recover() restores a homed state.
Replace raw status-command calls with typed methods:
- spatula_request_is_holding() -> bool
- nest_request_is_holding(nest) -> bool   (occupied nest reports UNKNOWN)
- probe_presence(stacker, slot, to_nest=1) -> bool  — sense a stacker slot by
  attempting a pick (graceful empty at non-top slots); a found plate is moved
  to to_nest as a side effect.
recover() short-circuited on is_homed(), but homedstatus reports homed even
while the spatula is stuck extended in a stacker after a faulted top-slot pick
— so recover() returned True without retracting (left the machine stuck).

- Add is_parked(): homed AND the slide (Y) axis retracted near home (a stuck
  spatula sits at the ~256mm slide-in depth). This is the real "safe to move"
  check; is_homed() alone is not.
- recover() now ALWAYS issues enable -> spatulaout -> home and confirms via
  is_parked(), retrying a few times. Never trusts homedstatus.

Hardware-validated: empty-pick fault -> is_homed()=True but is_parked()=False;
recover() retracts Y 256->0 and re-homes. Also found the empty-pick fault
affects the top *few* slots (23 faults too), not only slot 24.
The store re-seals its doors during every transfer (the firmware exposes no
way to skip that — a 4th `place` arg errors on 3.0.0.119), so close_door
controls the END state: close_door=False re-opens the doors after the move,
leaving the carousel accessible for back-to-back ops. Defaults to True (sealed).
One firmware-agnostic implementation (place/pick + conditional openalldoors).
Renames the new loading-tray/nest index parameter from `tray` to
`tray_index` across the AutomatedRetrieval capability, its backends
(TundraStore, Liconic, Cytomat, Heraeus), and the TundraStore frontend,
including docstrings and user-facing error messages.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Moves DoorState/NestState into standard.py (next to the DoorStatus/
NestStatus dataclasses that use them) and the protocol reply tokens
(ACK!/OK!/ABORTED!/ERROR!) into backend.py, their only consumer. Drops
the unused PLATE_AVAILABLE constant and deletes the now-empty module.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Removes the ensure_single_tray free function (and its export from the
automated_retrieval capability) and inlines the equivalent
``tray_index not in (None, 0)`` check directly into the fetch/store
methods of the single-tray Liconic, Cytomat, and Heraeus backends.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
These single-field dataclasses just wrapped a dict. request_door_status
now returns Dict[str, DoorState] and request_nest_status returns
Dict[int, NestState] directly. The only behavior on them, DoorStatus.
all_closed, was test-only and is inlined at its sole call site.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replaces the raw-dict-plus-property settings view with a frozen dataclass
exposing all 545 device settings as explicit typed attributes (device key
lower-cased), with types inferred from the device's values. machine_type
is a Literal of known models and warns ("please contribute") on an unknown
model. Settings are loaded whole from the device; no shipped defaults.

request_settings() is simplified accordingly: drops the unused search
filter and the separate serial/firmware plumbing (SERIAL_NUMBER is now a
field; firmware stays on request_version).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The device's settings command is the only source, returning NAME = value
text, so keep just from_lines (used by request_settings) and fold the
field mapping into it. Drops the unused from_dict/from_json/to_json;
direct construction is already available via the dataclass constructor.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…river

# Conflicts:
#	pylabrobot/capabilities/automated_retrieval/automated_retrieval.py
…trays

#1125 moved storage bookkeeping into AutomatedRetrieval (single loading
tray). Extend that standard to multiple loading trays addressed by a
0-based tray_index (defaulting to the first), so multi-nest devices get
the shared bookkeeping too:

- AutomatedRetrieval takes loading_trays: List[PlateHolder]; fetch_plate_
  to_loading_tray / take_in_plate take tray_index and pass it to the
  backend (which already accepts it).
- TundraStore composes the capability with its nests as loading_trays and
  drops its duplicated bookkeeping (sites, fetch/store, NoFreeSiteError,
  default_tray); users go through store.retrieval.* with tray_index.
- Liconic and Cytomat pass loading_trays=[self.loading_tray].
- Updated the automated-retrieval notebook to loading_trays=[...].

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
After #1125 moved storage operations onto AutomatedRetrieval, the device
frontends no longer expose take_in_plate / fetch_plate_to_loading_tray /
summary. Route those calls through device.retrieval in both hello-world
notebooks, and repoint the Cytomat {meth} cross-references to the
AutomatedRetrieval capability. (.loading_tray and .racks stay on the device.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Coercion mostly returned the raw string unchanged; only int/float fields
need conversion. Inline that single expression in from_lines instead of a
dedicated helper.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
DoorState and NestState are now Literal aliases with lowercase values plus
DOOR_STATES/NEST_STATES valid-value tuples, matching the MachineType
pattern. The backend lower-cases the device's status output and validates
against the tuples (falling back to "unknown") instead of constructing
enums. Rename standard.py to types.py.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ckends

The single TundraStoreBackend mixed transport, retrieval, temperature and
humidity. Split it so a HighResSampleStorageDriver owns the transport
(send_command + shared version/environment queries) and the per-capability
backends it constructs:

- HighResSampleStorageAutomatedRetrievalBackend (storage/motion)
- HighResSampleStorageTemperatureControllerBackend
- HighResSampleStorageHumidityControllerBackend

Capability backends build commands on driver.send_command (no duplication on
the driver). The device pulls them off driver.{automated_retrieval,temperature,
humidity}; the chatterbox is now a driver that fakes only the transport and
owns the real capability backends.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…lasses

The TundraStore, SteriStore and AmbiStore are the same machine family behind a
shared port-1000 API. Rename the module to highres.sample_storage and put all
implementation on a private base device _HighResSampleStorage; the concrete
devices are thin subclasses:

- TundraStore / SteriStore: refrigerated, full retrieval + temperature/humidity.
- AmbiStore: WORK IN PROGRESS (warns on init); ambient, so it wires only the
  retrieval capability and no environment control (unverified against hardware).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…r file

backend.py held the driver and all three capability backends. Split it into a
driver/ subpackage:

- driver/protocol.py   - wire tokens + parse_kv (shared helpers)
- driver/automated_retrieval.py
- driver/temperature.py
- driver/humidity.py
- driver/driver.py      - HighResSampleStorageDriver (owns the three backends)

Capability backends reference the driver via a TYPE_CHECKING import to avoid a
runtime import cycle; the driver/ __init__ re-exports the four classes so the
public import paths are unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Relocate backend_tests.py into tests/, matching the repo convention of a tests/
package with *_tests.py modules, and split the two suites (which share no
fixtures) into self-contained files:

- tests/driver_tests.py   - transport, queries, temperature/humidity (FakeSocket)
- tests/recovery_tests.py - pick/place fault classification + recovery (ScriptedSocket)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Use a consistent request_ prefix for every method that reads state from the
device, matching request_version/request_environment/etc:

- get_stacker_dimensions   -> request_stacker_dimensions
- scan_stacker_barcodes     -> request_stacker_barcodes
- is_homed                  -> request_is_homed
- is_parked                 -> request_is_parked
- spatula_request_is_holding -> request_spatula_is_holding
- nest_request_is_holding    -> request_nest_is_holding

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tterbox

Mirror STARChatterboxDriver: call the real driver __init__ (which constructs an
inert, unconnected socket and owns the real per-capability backends) and only
override setup/send_command/request_* to fake the transport. Drops the
`self.io = None  # type: ignore` placeholder and the duplicate backend
construction that came from bypassing the parent constructor.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Remove the device-free chatterbox driver and its export; it can be reintroduced
later if needed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant