Skip to content

Micronic rack reader v1b1#1009

Open
alexjamesgodfrey wants to merge 45 commits into
PyLabRobot:v1b1from
alexjamesgodfrey:micronic-rack-reader-v1b1
Open

Micronic rack reader v1b1#1009
alexjamesgodfrey wants to merge 45 commits into
PyLabRobot:v1b1from
alexjamesgodfrey:micronic-rack-reader-v1b1

Conversation

@alexjamesgodfrey
Copy link
Copy Markdown

@alexjamesgodfrey alexjamesgodfrey commented Apr 25, 2026

Micronic provides liquid storage solutions in the form of barcoded vials, racks for the vials, and rack readers that read all vial Data Matrix codes plus the rack barcode.

This PR adds a generic rack_reading capability and a Micronic v1b1 integration that controls the reader hardware directly through PyLabRobot, without depending on Micronic Code Reader software, IO Monitor, or its HTTP server controlled by the software.

The direct path:

  • acquires the rack image through an operator-installed scanner bridge:
    • Windows: TWAIN helper configured by path or available on PATH
    • Linux/Ubuntu: SANE scanimage
    • custom: user-provided scan_command
  • decodes tube Data Matrix codes locally with open Python dependencies
  • reads the side rack barcode through PLR io.Serial
  • returns a standard RackScanResult

What's in this PR

  • RackReader capability (rack_reading) with scan_rack, scan_rack_id, layout management, and state polling
  • MicronicCodeReader device exposing the rack_reading capability
  • MicronicDriver for direct local scanner + serial rack barcode control
  • Local Data Matrix image decoding for the 8x12 / 96-position Micronic rack layout
  • PLR io.Serial rack barcode read path for the side barcode reader
  • Windows TWAIN-helper, Linux SANE, custom command, and image-input acquisition paths
  • Docs: docs/user_guide/capabilities/rack-reading.md, docs/user_guide/micronic/index.md
  • Unit tests for rack-reading behavior, Micronic driver state transitions, scanner command selection, serial rack-id reading, and device wiring
  • Live smoke-tested on Micronic hardware with an 8x12 rack: rack ID read plus full 96-vial scan

Why not IO Monitor?

Micronic’s IO Monitor HTTP server can trigger rack scans and return results, but relying on that server means PLR is not actually controlling the hardware. This PR keeps the v1b1 shape, but moves hardware ownership into PLR itself.

PLR does not ship a TWAIN helper executable, SANE, or scanner drivers. The operator is responsible for installing the OS-level scanner bridge and Python decode dependencies in their runtime environment. For example I hd to install a driver for the COM4 barcode scanner that came with my rack reader.

Supported operations

Operation PLR API Implementation
Full rack scan reader.rack_reading.scan_rack() acquire scanner image, decode tube Data Matrix codes, read rack barcode over serial
Rack barcode only reader.rack_reading.scan_rack_id() read side barcode reader over PLR io.Serial
State polling reader.rack_reading.get_state() local driver scan state
Layout list reader.rack_reading.get_layouts() currently fixed to 8x12 / 96
Current layout get_current_layout() / set_current_layout() accepts 8x12, 96(8x12), 96

Example

from pylabrobot.micronic import MicronicCodeReader

reader = MicronicCodeReader(
  scanner_backend="twain",
  twain_scanner_path=r"C:\Tools\twain_scan.exe",
  twain_source="AVA6PlusG",
  image_dir=r"C:\ProgramData\PyLabRobot\micronic-images",
  serial_port="COM4",
  keep_images=True,
)

await reader.setup()
try:
  result = await reader.rack_reading.scan_rack(timeout=90.0, poll_interval=1.0)
  print(result.rack_id)
  print(len([entry for entry in result.entries if entry.tube_id]))

  rack_id = await reader.rack_reading.scan_rack_id(timeout=5.0, poll_interval=0.5)
  print(rack_id)
finally:
  await reader.stop()

Hardware validation

Live-tested with an 8x12 Micronic rack (rd235) on the scanner:

  • rack barcode only read succeeded over serial
  • full rack scan succeeded through PLR direct hardware control
  • decoded all 96 tube positions
  • returned the physical rack ID
  • no Micronic Code Reader / IO Monitor HTTP server dependency

Copy link
Copy Markdown
Member

@rickwierenga rickwierenga left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

direct machine support is awesome

Comment thread pylabrobot/micronic/code_reader/driver.py
rickwierenga and others added 4 commits May 14, 2026 19:43
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The generic RackReader capability had a state machine (IDLE/SCANNING/DATAREADY)
plus trigger+poll+result orchestration that only made sense for Micronic's
hardware. Move that into the Micronic backend and narrow the generic capability
to scan_rack(rack, ...) + scan_rack_id(...), mirroring how the plate-reader
capability takes a Plate. The driver validates rack shape (8x12) and raises
MicronicError on mismatch.

- RackReaderState -> MicronicRackReaderState in pylabrobot/micronic
- MicronicRackReadingBackend.scan_rack owns trigger+poll+stale-DATAREADY guard
- Backend raises plain TimeoutError on poll timeout (no custom timeout class)
- Drop LayoutInfo, get_layouts/get_current_layout/set_current_layout
- Drop _on_setup probe (it only read a Python attribute)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Now that trigger_rack_scan takes a TubeRack, the expected well count comes
from the rack itself instead of a separate constructor parameter. Stores
rack.num_items on the driver when a scan is triggered and uses it as the
decode threshold.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
rickwierenga and others added 6 commits May 14, 2026 20:14
Drop RackReaderError (generic base) and MicronicRackReaderError (multi-inherited
wrapper). The Micronic backend no longer catch/re-raises driver errors at every
call site; MicronicError propagates as-is.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Callers can timestamp scans themselves; carrying these as result fields adds
no information beyond what wall-clock at scan time would give.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Normalize chatterbox/test fixture "Code OK" to "OK" so the type closes
on the two values the Micronic driver actually emits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Micronic decoder cascade (full-image -> crop-N -> perspective-N) is a
debugging detail, not part of the rack scan result. Emit it at DEBUG and
drop free_text from the dataclass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Matches ItemizedResource identifiers and the split_identifier contract so
RackScanEntry.position can be passed directly to rack.children-by-identifier
lookups.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The cached-or-fresh accessor only existed to back the dropped capability-level
get_rack_id and has no remaining callers. Remove the method and its test.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@rickwierenga
Copy link
Copy Markdown
Member

can we use the PLR barcode class for tube or rack id?

rickwierenga and others added 12 commits May 14, 2026 20:44
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ebook

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace MicronicDriver's flat twain_scanner_path/sane_device/scan_command/
scanner_backend/image_extension/image_input params with a single scanner: Scanner
argument. Concrete subclasses TwainScanner and SaneScanner own their own
resolution and extension. For other acquisition stacks, users subclass Scanner
directly.

- New: pylabrobot/micronic/code_reader/scanner.py (Scanner ABC + TwainScanner +
  SaneScanner) and errors.py (MicronicError moved here to break the import cycle).
- MicronicDriver.__init__ takes scanner + required serial_port (no more "COM4"
  default) plus a smaller flat-param surface.
- MicronicCodeReader matches: scanner + serial_port required, no driver injection.
- Module-level run_scan / choose_image_extension / normalize_* / resolve_* helpers
  in driver.py are deleted; what remains is owned by the scanner classes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the standalone read_rack_id_plr_serial / read_rack_id / extract_rack_id
trio with a single scan_rack_id method on the driver. The driver holds the
Serial instance, opens it in setup(), closes it in stop(), and reads the side
barcode through self.io.

trigger_rack_scan now awaits scan_rack_id() before kicking off the executor
thread, so the sync _scan_rack_blocking receives the rack ID as an argument
and no longer bridges sync/async via asyncio.run.

serial_port is no longer stored on the driver (the Serial instance owns the
connection info).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
trigger_rack_scan / get_rack_reader_state / get_scan_result and the
MicronicRackReaderState enum existed only to expose the executor Future as a
poll-and-finalize state machine. Replace them with a single async scan_rack
method on the driver that awaits the executor directly. The backend collapses
to asyncio.wait_for, the stale-DATAREADY workaround disappears, and the driver
loses _state / _scan_task / _scan_error / _reported_scanning_since_trigger /
_expected_well_count.

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The state-machine removal dropped the "already in progress" guard. Replace it
with an asyncio.Lock + locked() check that refuses overlapping scan_rack calls
rather than queuing them, since the underlying hardware can only run one scan
at a time.

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

poll_interval became dead weight when the state machine went away; nothing
polls now. default_timeout was misleading: it only fed the scanner subprocess
timeout, never any rack-reading default. Rename the ctor arg to
scanner_timeout (its real meaning), drop both stored attributes, and drop the
serialize override since Device.serialize already emits the driver state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@rickwierenga
Copy link
Copy Markdown
Member

I think considering using Barcode and please testing if I didn't break anything by running the hello world notebook are the last things needed before merging :)

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.

2 participants