Skip to content

Add LI-COR Odyssey Classic + DeviceCard for instrument provenance - #1025

Open
vcjdeboer wants to merge 73 commits into
PyLabRobot:mainfrom
vcjdeboer:odyssey-v1b1
Open

Add LI-COR Odyssey Classic + DeviceCard for instrument provenance#1025
vcjdeboer wants to merge 73 commits into
PyLabRobot:mainfrom
vcjdeboer:odyssey-v1b1

Conversation

@vcjdeboer

Copy link
Copy Markdown
Contributor

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 is setup / stop / post / get / get_bytes (each with with_retry=True opt-in) plus serialize / from_env and 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.
  • OdysseyChatterboxDriver plus three *ChatterboxBackend classes 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).
  • Dual-base errors (OdysseyScanError(OdysseyError, ScanningError) etc.) so callers can except on either the vendor axis or the capability axis.

Capabilities — pylabrobot/capabilities/scanning/

Umbrella package mirroring plate_reading/'s shape:

capabilities/scanning/
├── scanning/             # configure / start / stop / pause / cancel
├── image_retrieval/      # list_groups / list_scans / download
└── instrument_status/    # read_status → InstrumentStatusReading

Each subpackage is a standard Capability + Backend ABC pair. Scanning accepts backend_params: Optional[SerializableMixin]; vendors provide a typed BackendParams subclass (OdysseyScanningParams here). InstrumentStatusReading is a generic state snapshot (state / current_user / progress / time_remaining / lid_open) — not Odyssey-specific.

DeviceCard — pylabrobot/device_card.py

A machine-readable description attached to a Device:

DeviceCard
├── name, vendor, model
├── identity     # PIDInst Handle URI, landing page, friendly name (per-unit)
├── capabilities # spec sheets — operating ranges, supported settings
└── connection   # protocol, port, auth, discovery

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 as HasLoadingTray, 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.py is the canary that consumes this — it accepts either a DeviceCard or 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:

  • Capability surfaces in isolation (recording / in-memory / stub backends)
  • `need_capability_ready` guard before setup
  • DeviceCard model-base defaults, instance merge, capability-spec override
  • `HasDeviceCard` `isinstance` discoverability
  • `setup_finished` transitions through `setup` / `stop`
  • Full chatterbox scan flow (configure → start → completed → download)
  • `stop_and_save` orchestration across all three capabilities

`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:

  1. Add LI-COR Odyssey Classic to v1b1 architecture
  2. Add DeviceCard for instrument identity / provenance metadata
  3. Refactor Odyssey driver to transport-only (P-06)
  4. Tests + cross-capability orchestration cleanup
  5. Pre-PR readiness: lint, format, mypy, changelog, optional dep

Happy to split into multiple PRs if the DeviceCard or capability-trio discussions are likely to block the device merge.

rickwierenga and others added 30 commits March 23, 2026 12:42
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
…obot#954)

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

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 (PyLabRobot#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>
…ot#885)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
…ot#980)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…Robot#964)

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 21 commits April 8, 2026 19:33
- Fix close_gripper plate_width_tolerance default: 0 → 2.0mm (matching
  firmware spec default of 20 in 0.1mm units and legacy behavior)
- Set minimum plate_width_tolerance to 0.5mm (firmware rejects lower)
- Add manual movement section under Common Tasks
- Add rotation safety warning and safe-position move before rotate
- Wrap close_gripper demos in try/except (no plate gripped in docs)
- Ensure plate movements alternate between [1] and [2] for end-to-end runs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
dispense() with multiple volume groups was starting/stopping a batch per
group. Wrap the loop in a single batch() so the device stays in ready
state throughout. Add advanced usage section to hello-world notebook
covering manual batching, cross-subsystem protocols, and well masking.

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

- Add HasLoadingTray mixin for devices with a loading tray
- Add MolecularDevicesLoadingTrayBackend (sends !OPEN/!CLOSE directly)
- SpectraMaxM5/384Plus: replace PlateHolder with LoadingTray capability
- BioTekLoadingTrayBackend: send J/A commands directly, not via driver.open/close
- Cytation5/Cytation1: add HasLoadingTray mixin

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>
Co-authored-by: Rick Wierenga <rick_wierenga@icloud.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Rick Wierenga <rick_wierenga@icloud.com>
Each of the 4 drawers is now a LoadingTray keyed by drawer_id in
`scila.drawers`. Command sequences (PrepareForInput/OpenDoor,
PrepareForOutput/CloseDoor) and drawer_id validation move from the
driver into SCILADrawerLoadingTrayBackend. Re-add CO2-flow warning
suppression (log + continue) that had been lost in the rewrite.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…re (PyLabRobot#989)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Hazlam Shamin <69739427+hazlamshamin@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…imbus

Deck is now passed into the driver at __init__ time (not through setup), so
the driver can construct backends with deck as a required arg. PIP/Head96
capabilities also take deck required on __init__. Legacy STARBackend still
receives deck via set_deck (legacy flow) and forwards it to the already-built
STARDriver.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Makes gripper_length and gripper_z_offset required on PreciseFlexArmBackend
(no sensible backend-level defaults — they depend on the mounted gripper)
and moves the per-model defaults (162 mm length, 0 mm z offset) to the
PreciseFlex400 Device wrapper so users can override them when installing
a non-stock gripper.

Also renames PF400Params.z_tool_offset to gripper_z_offset for naming
consistency with the user-facing parameter.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Rick Wierenga <rick_wierenga@icloud.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PIPChannel.request_firmware_version and STARDriver.request_firmware_version
now parse the RF response through parse_star_firmware_version_date and
return a datetime.date, matching what STARHead96Backend already did.
ztouch_probe_z_height uses version.year < 2022 directly instead of
regex re-parsing the raw string.

Drop the chatterbox-None fallback from STARHead96Backend.request_firmware_version:
chatterbox paths should explicitly override the method (or accept the
ValueError) rather than silently advertising a fabricated date.

Legacy STARBackend.request_pip_channel_version keeps its str return type
by inlining the raw RF query — the new PIPChannel method now returns
datetime so direct delegation would have broken the legacy contract.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
@rickwierenga

Copy link
Copy Markdown
Member

thanks for the PR!

high level comments

  • DeviceCard is interesting, but definitely a separate PR :) I would want to think more about a standard structure for describing capabilities (probably capabilities describing themselves) rather than the capabilities being capabilities: dict[str, dict[str, Any]]
  • the proposed capabilities seem very specific to this particular scanner. What if one does not work with start/stop/pause and listing scans? I think we should focus capabilities on "scientific actions". Here, that seems to be "image a sample and return images in 700/800nm." Are you familiar with similar machines? How do those work and what is shared? Claude tells me "gel/blot/flatbed documentation imager family — same shape as Bio-Rad ChemiDoc, Azure Sapphire, GE Typhoon, ProteinSimple FluorChem" but I do not know. Are these machines always 700 / 800, are they always two lasers? I think most functions here should be abstracted into one image call that does configure > start > poll-to-done > download.
  • "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." I like this part a lot, and should definitely be part of all "analytical capabilities". The tagging.py should probably exist elsewhere in PLR than within the Odyssey directory.

@rickwierenga

Copy link
Copy Markdown
Member

configure > start > poll-to-done > download

The backend of course would still have separate methods for these things so your server and other applications like that will still be possible. But in terms of the capability level abstraction, I think a complete action is the best abstraction. A function that the user calls and performs the complete operation. Similar to how the microscope abstraction works

@rickwierenga

Copy link
Copy Markdown
Member
  result = await odyssey.flatbed_imager.image(
    region=Region(x=0, y=0, w=10, h=10),
    channels=[700, 800],
  )
  tiff_700, tiff_800 = (f.data for f in result.frames)

@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 September 2, 2026 00:00
@rickwierenga
rickwierenga requested a review from a team as a code owner September 7, 2026 23:34
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.

5 participants