Skip to content

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

Open
rickwierenga wants to merge 130 commits into
mainfrom
highres-tundrastore-driver
Open

feat(highres): add TundraStore/SteriStore plate store driver#1090
rickwierenga wants to merge 130 commits into
mainfrom
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

rickwierenga and others added 30 commits March 23, 2026 12:42
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
y: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Notebook autoreload creates new class objects, breaking isinstance
checks on backend params (silently falling back to defaults). BackendParams
uses a metaclass with __instancecheck__ that falls back to qualname+module
comparison, which stays stable across reloads.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- DeviceBackend -> Driver (with backward-compat alias)
- Device._backend -> Device._driver, param backend -> driver
- New CapabilityBackend ABC for capability-specific backend interfaces
- All 15 abstract capability backends now extend CapabilityBackend
- Concrete backends extend both their capability backend and Driver
- Serialization key "backend" -> "driver" (deserialize accepts both)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Every monolithic backend that extended both a CapabilityBackend and Driver
is now split into:
- Driver: owns I/O, connection lifecycle, device-level ops
- CapabilityBackend: protocol translation, encodes capability calls into
  driver commands

Devices split: HepaFan, BioShake, Pico, Opentrons TempModule, Hamilton
HeaterShaker, Hamilton TiltModule, Keyence BarcodeScanner, XPeel, SCILA,
MettlerToledo, A4S, VSpin/Access2, CLARIOstar, SpectraMax 384+/M5.

Also: CapabilityBackend gains _on_setup/_on_stop hooks, Capability._on_setup
calls backend._on_setup, updated creating-capabilities.md, updated all
legacy wrappers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Recording backends and chatterbox backends are now pure CapabilityBackends.
Test devices use a _NullDriver for the Device lifecycle.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Test capabilities directly via cap._on_setup() instead of wrapping
in a fake Device.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Fix import sorting across 10+ files (ruff format --fix)
- Fix MolecularDevices legacy backend: reference renamed class, update test mocks to patch at correct level (Driver/Protocol instead of legacy wrapper)
- Fix Pico legacy tests: split Driver/MicroscopyBackend usage to match new architecture
- Fix Opentrons temp module: add base-type annotations for if/else branches
- Fix Liconic: use _on_setup/_on_stop (CapabilityBackend API)
- Fix Azenta A4S: type: ignore[safe-super] for abstract Driver methods
- Fix Pico backend: self._snap_images() instead of self._driver._snap_images()

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Move shaker/tc capabilities into base class with has_shaking,
has_temperature, supports_active_cooling flags. Add resource
definitions for BioShake3000, BioShake3000Elm, BioShake3000ElmDWP,
and BioShakeQ1 from spec sheets.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…nds (#957)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Extract autoload firmware protocol into a standalone class that takes a
driver reference and operates on track numbers instead of Carrier objects.
The legacy STARBackend and new STAR device can both wire into this class.
Includes 36 tests covering all command types.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
PlateReader now delegates reads through AbsorbanceCapability,
LuminescenceCapability, and FluorescenceCapability via adapter backends
that wrap the legacy PlateReaderBackend. Extracted _DictBackendParams
into pylabrobot/legacy/_backend_params.py for reuse across legacy
adapters.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Move autoload, cover, x-arm, wash station, and ~44 generic driver
infrastructure methods (firmware queries, EEPROM, area reservation,
configuration) into the new STARDriver architecture. Legacy backend
methods now delegate to new classes or have deprecation docstrings.

- STARAutoload: autoload module control (carrier loading, barcode, LEDs)
- STARCover: front cover lock/unlock/enable/disable
- STARXArm: left/right X-arm positioning (parameterized by side)
- STARWashStation: dual-chamber wash station drain/fill/init
- STARDriver: generic instrument operations directly on driver
- STARChatterboxDriver: updated with all subsystems
- STAR device only exposes capabilities (PIP, Head96, iSWAP)
- Subsystems live on the driver, accessed via star._driver
- 114 tests across all subsystems
- Right X-arm and wash station are conditional on hardware config
- X-arm methods use mm (PLR standard), not 0.1mm firmware units
- Fixed pre-existing assertion bugs in release_occupied_area and
  set_instrument_configuration

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
These methods send a command to hardware and wait for a response,
so request_ better reflects the I/O semantics. Covers PreciseFlex,
capability interfaces (temperature, humidity), and all vendor backends
(Azenta, Agilent, BMG, Byonoy, Hamilton, INHECO, Liconic, Molecular
Devices, Opentrons, Qinstruments, Thermo Fisher). Legacy public APIs
keep get_ names unchanged; only internal delegations are updated.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Move 14 multi-channel PIP operations to STARPIPBackend: channel
positioning (Y/Z), initialization, spread, z-safety, foil piercing.
Parameters use mm (PLR standard) with internal 0.1mm conversion.

Key changes:
- pierce_foil and step_off_foil on STARPIPBackend with explicit deck param
- iSWAP-parked checks on Y-movement methods
- Channel min Y spacing queried from firmware in driver setup()
- Right X-arm conditional on right_x_drive_large
- Wash station conditional on wash_station_*_installed
- Legacy backend aliases (left_x_arm, iswap) for PIPBackend compat
- Fixed pierce_foil one_by_one bug (z vs z+distance_from_bottom)
- Fixed _ensure_can_reach_position dead fallback (is None vs not)
- Architecture doc updated

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Two new dispensing capabilities under bulk_dispensers/:
- SyringeDispensing: dispense(plate, volumes={col: vol}), prime(plate, volume)
- PeristalticDispensing: dispense(plate, volumes={col: vol}), prime(), purge()

Both use BackendParams for device-specific parameters.
Also adds BackendParams to PlateWashingCapability.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- EL406Driver: FTDI I/O, batch management, device-level ops, queries
- EL406PlateWashingBackend: manifold ops (wash, aspirate, dispense, prime)
- EL406ShakingBackend: shake/soak
- EL406SyringeDispensingBackend: syringe dispense/prime
- EL406PeristalticDispensingBackend: peristaltic dispense/prime/purge

Legacy code is thin wrappers delegating to new backends. All 385 tests pass.

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Move plate washer docs from 00_liquid-handling/plate-washing/ to
agilent/biotek/el406/ and heater-shaker docs from
01_material-handling/heating_shaking/ to qinstruments/bioshake/.
Add Manufacturers toctree section with manufacturer-level indexes.
Include migration guide at repo root for future device migrations.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Move device docs from legacy category dirs (00_liquid-handling/,
01_material-handling/, 02_analytical/) to manufacturer-based layout
mirroring the codebase. Add Manufacturers toctree section and API
reference RST files for all manufacturers with autosummary directives
and autoclass for nested BackendParams. Add Sphinx cross-references
for BackendParams in notebook markdown cells.

Devices migrated: EL406, BioShake, Mettler Toledo WXS205SDU,
Azenta a4S, Azenta XPeel, Liconic STX, Inheco ThermoShake,
Inheco CPAC, Inheco SCILA, Inheco Incubator Shaker, Inheco ODTC,
Thermo Fisher Multidrop Combi.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
rickwierenga and others added 23 commits June 27, 2026 22:15
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>
#1125)

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>
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>
#1129)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…edRetrieval ` with `RandomAccessRetrieval `) (#1142)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Rick Wierenga <rick@pylabrobot.org>
Delegate host/port/timeouts to Socket.serialize() under an "io" key
(matching vspin/EVO backends) rather than reaching into io._host/_port.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reconcile the sample-store multi-tray work with upstream's split of
AutomatedRetrieval into a base + RandomAccessRetrieval / StackerRetrieval.

Fold in finding #2 correctly: multi-tray support now lives in the shared
AutomatedRetrieval base (loading_trays + tray_index resolution), so both
RandomAccessRetrieval and StackerRetrieval are multi-tray. tray_index=None
resolves to the backend's default_tray_index on BOTH the capability's resource
bookkeeping and the backend's motion, so they always target the same physical
tray (previously the capability hardcoded tray 0, defeating a non-zero default).

- capability base owns loading_tray(s) + _loading_tray/_plate_on_loading_tray
- AutomatedRetrievalBackend/StackerBackend gain default_tray_index (default 0)
- Random-access + stacker ops thread tray_index through to the backend
- sample store overrides default_tray_index = loading_tray_nest - 1
- Liconic/Cytomat compose RandomAccessRetrieval (single loading_tray)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The public knob was loading_tray_nest (1-based device nest); rename to
default_tray_index (0-based, default 0) so the capability API is uniformly
0-based. The 0-based -> 1-based device-nest conversion now lives solely in
_nest_for_tray(), where the wire protocol needs it.

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

vikichu commented Jul 25, 2026

Copy link
Copy Markdown

Is this compatible with the AmbiStore? The AmbiStore is the same commands as the TundraStore without the refrigeration.

@rickwierenga

Copy link
Copy Markdown
Member Author

should be! do you have one to test it with?

@vikichu

vikichu commented Jul 25, 2026

Copy link
Copy Markdown

Yes! I will test when I find time! Thanks!

@rickwierenga

Copy link
Copy Markdown
Member Author

sweet, I dont have one for testing, so would appreciate the feedback there! the docs I have are actually for tundra store but work just the same on steri. look forward to hearing how it goes, please dont hesitate to reach out

@rickwierenga
rickwierenga force-pushed the v1b1 branch 2 times, most recently from 6af085c to 1ae9dc6 Compare August 1, 2026 20:32
@rickwierenga
rickwierenga changed the base branch from v1b1 to main August 20, 2026 22:57
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.

8 participants