Add LI-COR Odyssey Classic + DeviceCard for instrument provenance#1025
Open
vcjdeboer wants to merge 5 commits intoPyLabRobot:v1b1from
Open
Add LI-COR Odyssey Classic + DeviceCard for instrument provenance#1025vcjdeboer wants to merge 5 commits intoPyLabRobot:v1b1from
vcjdeboer wants to merge 5 commits intoPyLabRobot:v1b1from
Conversation
Three new capabilities under pylabrobot/capabilities/scanning/: Scanning, ImageRetrieval, InstrumentStatus — mirroring the plate_reading/ umbrella with three sibling capability packages. Device package at pylabrobot/li_cor/odyssey/ with OdysseyDriver, three concrete backends, three chatterbox backends sharing _OdysseyChatterboxState, plus a minimal OdysseyChatterboxDriver to satisfy Device(driver=...). Dual-base errors join vendor and capability axes (OdysseyScanError(OdysseyError, ScanningError)). Chatterbox lifecycle verified end-to-end. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
DeviceCard is a machine-readable description attached to a Device
via the HasDeviceCard mixin (same shape as HasLoadingTray). Two
tiers: a model-base card ships with the device package; deployments
populate an instance card with their unit's PIDInst Handle URI,
landing page, and friendly name. base.merge(instance) produces the
effective deployed card.
Wires Odyssey as the first device. ODYSSEY_CLASSIC_BASE carries
specs from the operator's manual; OdysseyClassic accepts
card=DeviceCard.instance(identity={...}) and exposes the merged
card on self.card. tagging.py overload accepts either a DeviceCard
or a plain identity dict.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Push protocol encoding off the driver and into the capability backends. v1b1's P-06 — "driver is the wire, backend is the protocol" — was satisfied for class inheritance in the initial port, but driver methods like configure_scan / start_scan / download_tiff / get_status were still doing form encoding, redirect orchestration, error parsing, and HTML scraping. All of that now lives in the backends. Driver public surface shrinks to: setup / stop / post / get / get_bytes (each with optional with_retry) / serialize / from_env, plus shutdown_instrument and get_instrument_info as non-capability admin ops. ~600 LOC moved. Backends absorb: - ScanningBackend: configure (POST + redirect-follow + Error parse), the 7→1 initialization countdown, command.pl GETs for start/stop/ pause/cancel, estimate_time, get_progress, _parse_info_html. OdysseyScanningParams + DEFAULT_GROUP move here. - ImageRetrievalBackend: download_channel (with Content-Length verification), get_preview, download_scan_log, list_groups, list_scans. _tiff_xml + _jpeg_xml + _parse_select_options move here. - InstrumentStatusBackend: full read_status path (GET status page + HTML parse + state normalization), force_stop. _parse_status_html moves here. Last-HTML diagnostic cache moves here too. OdysseyClassic loses the dead `group` constructor parameter — the default group lives in OdysseyScanningParams now and is set per-scan. Chatterbox lifecycle re-verified end-to-end. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add tests covering the three new capabilities and the Odyssey device chatterbox path: - pylabrobot/capabilities/scanning/scanning/scanning_tests.py - pylabrobot/capabilities/scanning/image_retrieval/image_retrieval_tests.py - pylabrobot/capabilities/scanning/instrument_status/instrument_status_tests.py - pylabrobot/li_cor/odyssey/odyssey_tests.py 23 tests, all passing. Recording / in-memory / stub backends verify the capability surfaces in isolation; the Odyssey suite exercises DeviceCard merging, the lifecycle transitions, the scan flow, and stop_and_save end-to-end through the chatterbox. Move stop_and_save off OdysseyScanningBackend onto OdysseyClassic. Cross-capability orchestration (scanning Stop + status poll + per-channel image probe) belongs at the device, not on a backend holding references to the other two backends. The scanning backend exposes a small ``current_scan`` property the device reads. Drop a broken :doc: reference on the Scanning capability; add ``download_channel`` to the chatterbox image-retrieval backend so ``stop_and_save`` is exercisable without an instrument. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Apply ruff format across all new files - Fix mypy errors in new code (TypeVar on the retry helper, narrow base-class type annotations on OdysseyClassic backends, drop the Optional on HasDeviceCard.card to match HasLoadingTray's shape, replace OdysseyDriver.__bases__[0].__init__ with a direct Driver.__init__ call) - Add ``[mypy-aiohttp.*] ignore_missing_imports = True`` to mypy.ini matching the convention for the other stubless deps - Declare ``odyssey = ["aiohttp"]`` as an optional dependency in pyproject.toml; include it in ``all`` - CHANGELOG entry under Unreleased 23 new tests still pass; 86 tests in the affected modules pass; net mypy errors in this PR's files: 0. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This PR adds the LI-COR Odyssey Classic (model 9120) infrared imaging system as a new device under the v1b1 capability architecture. It also introduces three new capabilities the Odyssey needs (
Scanning,ImageRetrieval,InstrumentStatus) and proposes a small architectural addition (DeviceCard) for instrument identity / provenance metadata.What's added
Device —
pylabrobot/li_cor/odyssey/OdysseyDriver— pure HTTP transport over Basic Auth. Public surface issetup/stop/post/get/get_bytes(each withwith_retry=Trueopt-in) plusserialize/from_envand two non-capability admin operations (shutdown_instrument,get_instrument_info). No protocol logic on the driver.OdysseyScanningBackend,OdysseyImageRetrievalBackend,OdysseyInstrumentStatusBackend— own all CGI protocol: form encoding, redirect orchestration,<Error shorterror=\"...\" />parsing, the 7→1 initialization countdown, HTML scraping for status / progress.OdysseyChatterboxDriverplus three*ChatterboxBackendclasses sharing an_OdysseyChatterboxState— runs the full lifecycle without an instrument (CI / notebooks).OdysseyClassic(Device, HasDeviceCard)wires everything;stop_and_save()is a device-level orchestration helper (it spans the three capabilities).OdysseyScanError(OdysseyError, ScanningError)etc.) so callers canexcepton either the vendor axis or the capability axis.Capabilities —
pylabrobot/capabilities/scanning/Umbrella package mirroring
plate_reading/'s shape:Each subpackage is a standard Capability + Backend ABC pair.
Scanningacceptsbackend_params: Optional[SerializableMixin]; vendors provide a typedBackendParamssubclass (OdysseyScanningParamshere).InstrumentStatusReadingis a generic state snapshot (state / current_user / progress / time_remaining / lid_open) — not Odyssey-specific.DeviceCard —
pylabrobot/device_card.pyA machine-readable description attached to a Device:
Two-tier: a model-base card ships with the device package (
ODYSSEY_CLASSIC_BASE); each deployment populates an instance card with its unit's identity.base.merge(instance)produces the effective deployed card.Devices opt in via
HasDeviceCard— same shape asHasLoadingTray, a Device-attribute marker mixin. Existing devices are unaffected.The motivation is FAIR / provenance: a TIFF or PNG written by the instrument can carry the PIDInst Handle URI in its metadata, so a scan lifted out of its surrounding context still resolves back to the unit it came from.
pylabrobot/li_cor/odyssey/tagging.pyis the canary that consumes this — it accepts either aDeviceCardor a plain identity dict and embeds the JSON in TIFF tags 270 / 305.Design notes worth flagging
Three capabilities, one implementation each (P3)
The v1b1 norm is to extract a Backend ABC only when a second device needs the same operation. This PR ships three new ABCs with one implementation each.
The honest framing: Odyssey is the first; Bio-Rad ChemiDoc, ProteinSimple FluorChem, GE Typhoon, and similar flatbed fluorescence imagers fit the same shape, but those drivers don't exist in PLR yet. The Scanning / ImageRetrieval / InstrumentStatus split reflects real differences in their lifecycles (scanning is exclusive control; image retrieval is a read on persistent state; status is generic device-state polling) — but I'm open to merging them if you'd prefer one capability for now and we extract later.
`OdysseyChatterboxDriver` exists only as a placeholder
Odyssey doesn't do runtime discovery (no firmware queries to enumerate installed modules) so the chatterbox lives at the backend tier per the prose. But `Device.init` requires a `Driver` instance, so there's a minimal `OdysseyChatterboxDriver(OdysseyDriver)` that overrides `setup`/`stop` to no-ops. The chatterbox backends don't call it; they use a shared `_OdysseyChatterboxState`.
P-06 — driver is the wire, backend is the protocol
The driver is genuinely transport-only. `configure_scan`, `start_scan`, `download_tiff`, `get_status`, `_parse_status_html`, `_tiff_xml`, `_jpeg_xml` — all the protocol — lives in the backends. The first commit had protocol on the driver; the third commit refactored it. I left the history intact so the diff between commits 1 and 3 illustrates the shape of the cleanup.
Tests
23 tests, all passing:
```
pylabrobot/capabilities/scanning/scanning/scanning_tests.py (4 tests)
pylabrobot/capabilities/scanning/image_retrieval/image_retrieval_tests.py (4 tests)
pylabrobot/capabilities/scanning/instrument_status/instrument_status_tests.py (3 tests)
pylabrobot/li_cor/odyssey/odyssey_tests.py (12 tests)
```
Coverage:
`make lint` / `make format-check` / `make typecheck` clean (0 new mypy errors against the v1b1 baseline). `aiohttp` declared as new optional dependency `odyssey = ["aiohttp"]` and added to `all`.
Real-world deployment
The Odyssey driver and DeviceCard pattern are running in production at the WUR Human and Animal Physiology lab. The lab's FastAPI control app, `vcjdeboer/odyssey-app-hap`, is the first reference consumer and demonstrates the full pattern end-to-end including PIDInst-aware identity tagging. The instrument is registered at b2inst as `hdl.handle.net/21.11157/psf97-zv353` and that handle now travels in TIFF tag 270 / PNG tEXt for every scan the lab writes.
Commits
Five commits, each independently reviewable:
Happy to split into multiple PRs if the DeviceCard or capability-trio discussions are likely to block the device merge.