Wiring VISA, Serial, Async Capture, and LIMS into One Pipeline
This is the runnable walkthrough behind End-to-End Lab Automation Pipeline Assembly: a single main() that opens a PyVISA session and a pyserial port, dispatches through an async queue, parses and CRC-checks each frame, injects metadata, and pushes one record to a LIMS. It is part of the Instrument Control Architecture & Taxonomy section, and where the parent guide fixes the lifecycle contract, this one fills every stage with concrete code and follows one record end to end.
What This Walkthrough Assumes
The target rig is minimal on purpose: one SCPI source-measure unit reachable over a VISA Resource Manager session and one RS-232 sensor controller on a pyserial port, both feeding a single async dispatcher. Every byte a device returns is a length-prefixed frame with a trailing CRC-32; the sink is a LIMS REST endpoint that accepts one enriched record per POST and returns a commit id. Versions match the parent: Python 3.11+ for asyncio.TaskGroup, pyvisa 1.14+, pyserial 3.5, and pydantic 2.x for the typed record that crosses each boundary. The parent guide already argues why the seams matter and defines the generic Stage protocol; this guide does not restate that design — it shows the six concrete stage bodies and the one function that wires them, so the reader can paste it, point it at mock endpoints, and watch a record traverse the whole path.
The wiring rule is that adjacent stages never call each other directly; they hand off through bounded asyncio.Queue boundaries carrying one Pydantic model, so a slow LIMS applies backpressure the same way a slow parser does. Startup runs sink-first and shutdown drains source-first, exactly as the parent prescribes — the code below is the literal implementation of those two orderings.
Latency and Throughput Budget for One Cohesive Cycle
Before wiring, fix the two inequalities that decide whether the assembled pipeline stays bounded. The per-record cycle time T_cycle cannot be shorter than the sum of the individual stage service times, because the stages are serialized through queues along the critical path of one record:
The second inequality is the one that keeps memory finite. Over any window the sink must retire records at least as fast as the sources emit them, or a bounded queue on the path fills and blocks its producer:
If r_sink drops below r_ingest — a LIMS commit slows from 40 ms to 400 ms — the injector→sink queue saturates in queue_depth / (r_ingest - r_sink) seconds, and every stage upstream stalls in turn. That is the designed response, not a failure: a bounded queue converts unbounded memory growth into a throttled source. The budget tells you the drain deadline and the alert threshold to hand to Threshold Tuning & Alerting; these numbers are what you measure, not guess.
One record’s path: two sources enqueue raw frames, dispatch serializes them, parse and CRC produce a validated sample, injection stamps provenance, and the LIMS sink returns a commit id. Failed checksums divert to quarantine; a slow sink pushes backpressure (amber) upstream to the queue.
Step 1 — Define the Typed Record That Crosses Every Boundary
Fix the handoff type first, because every queue in the pipeline carries exactly one shape. A Pydantic model gives each stage a validated contract and makes an accidentally-skipped stage a load-time error rather than a silent None.
from __future__ import annotations
import asyncio
import binascii
import logging
import struct
from datetime import datetime, timezone
from typing import Optional
from pydantic import BaseModel, Field
log = logging.getLogger("pipeline.wiring")
class Sample(BaseModel):
"""A parsed, CRC-verified reading straight off a transport."""
device_id: str
channel: int
value: float
raw_crc_ok: bool
class Record(Sample):
"""A Sample after metadata injection; this is what the sink commits."""
sequence_no: int
captured_at: str
run_id: str
commit_id: Optional[str] = Field(default=None)
Step 2 — Open the VISA Session and the Serial Port (Sink-First Startup)
Resource acquisition is where a rig aborts cleanly or comes up half-wired. Open the sink first so a producer never emits into a stage that cannot accept, then the sources; if any open() raises, roll back everything already opened so no VISA lock or serial handle leaks. The stubs below call the real subsystems — a full VISA Resource Manager and tuned PySerial Configuration & Tuning live in their own guides — but the lifecycle is exact.
class VisaSource:
"""Opens a PyVISA session and reads one framed reply per poll."""
def __init__(self, resource: str, device_id: str) -> None:
self.resource, self.device_id = resource, device_id
self._sess = None
async def open(self) -> None:
import pyvisa
rm = pyvisa.ResourceManager()
self._sess = await asyncio.to_thread(rm.open_resource, self.resource)
self._sess.timeout = 2000 # ms; a dead instrument must not hang startup
async def poll(self) -> bytes:
if self._sess is None:
raise RuntimeError(f"{self.device_id}: poll before open")
# Blocking VISA I/O is pushed off the loop so it never stalls dispatch.
return await asyncio.to_thread(self._sess.read_raw)
async def close(self) -> None:
if self._sess is not None:
await asyncio.to_thread(self._sess.close)
self._sess = None
class SerialSource:
"""Opens a pyserial port and reads one length-prefixed frame per poll."""
def __init__(self, port: str, device_id: str, baud: int = 115200) -> None:
self.port, self.device_id, self.baud = port, device_id, baud
self._ser = None
async def open(self) -> None:
import serial
self._ser = await asyncio.to_thread(
serial.Serial, self.port, self.baud, timeout=2.0
)
async def poll(self) -> bytes:
if self._ser is None:
raise RuntimeError(f"{self.device_id}: poll before open")
header = await asyncio.to_thread(self._ser.read, 2)
(length,) = struct.unpack(">H", header)
return header + await asyncio.to_thread(self._ser.read, length)
async def close(self) -> None:
if self._ser is not None:
await asyncio.to_thread(self._ser.close)
self._ser = None
Both sources wrap their blocking libraries in asyncio.to_thread, the single most important wiring detail: a synchronous read_raw() or Serial.read() on the event loop would freeze dispatch, capture, and the sink at once.
Step 3 — Dispatch, Parse, Inject, and Sink as Queue-Coupled Coroutines
Each remaining stage is a coroutine consuming from an inbox and producing to an outbox, forwarding a sentinel on drain. Dispatch fans the two sources into one ordered stream — the concrete Async Command Queuing System that serializes per-transport traffic. Capture runs Binary & ASCII Format Parsing then Checksum & CRC Validation, diverting a bad CRC to quarantine instead of poisoning downstream. Injection applies Metadata Injection Workflows to stamp a monotonic sequence_no and provenance, and the sink commits one record per call.
SENTINEL = object()
async def dispatch(sources, out: asyncio.Queue, polls_per_source: int) -> None:
"""Poll each source in round-robin, forwarding raw (device_id, frame)."""
for _ in range(polls_per_source):
for src in sources:
frame = await src.poll()
await out.put((src.device_id, frame)) # blocks when out is full
await out.put(SENTINEL)
def _parse_and_check(device_id: str, frame: bytes) -> Sample:
"""Strip the 2-byte length header, verify the trailing CRC-32, decode."""
body, crc = frame[2:-4], frame[-4:]
expected = struct.pack(">I", binascii.crc32(body) & 0xFFFFFFFF)
ok = crc == expected
channel, value = struct.unpack(">Hf", body[:6])
return Sample(device_id=device_id, channel=channel, value=value, raw_crc_ok=ok)
async def capture(inp: asyncio.Queue, out: asyncio.Queue,
quarantine: asyncio.Queue) -> None:
while True:
item = await inp.get()
if item is SENTINEL:
await out.put(SENTINEL)
return
device_id, frame = item
sample = _parse_and_check(device_id, frame)
if not sample.raw_crc_ok:
await quarantine.put(sample) # diverted, never propagated
continue
await out.put(sample)
async def inject(inp: asyncio.Queue, out: asyncio.Queue, run_id: str) -> None:
seq = 0
while True:
sample = await inp.get()
if sample is SENTINEL:
await out.put(SENTINEL)
return
seq += 1
await out.put(Record(**sample.model_dump(), sequence_no=seq,
captured_at=datetime.now(timezone.utc).isoformat(),
run_id=run_id))
async def lims_sink(inp: asyncio.Queue, post, committed: list[Record]) -> None:
while True:
record = await inp.get()
if record is SENTINEL:
return
record.commit_id = await post(record) # transactional; may block
committed.append(record)
Step 4 — Compose It All in One main()
The orchestration function opens sink-first, runs every stage concurrently under one asyncio.TaskGroup, and closes source-first on the way out. Backpressure is implicit: every await out.put(...) blocks when its bounded queue is full, so a slow post throttles the sources without a line of explicit flow control.
async def run_pipeline(sources, post, *, run_id: str, polls_per_source: int,
depth: int = 64) -> list[Record]:
raw_q: asyncio.Queue = asyncio.Queue(maxsize=depth)
clean_q: asyncio.Queue = asyncio.Queue(maxsize=depth)
record_q: asyncio.Queue = asyncio.Queue(maxsize=depth)
quarantine: asyncio.Queue = asyncio.Queue()
committed: list[Record] = []
opened = []
for src in sources: # sources are the only openables here
try:
await src.open()
opened.append(src)
except Exception as exc: # roll back; no half-open rig
for s in reversed(opened):
await s.close()
raise RuntimeError(f"startup aborted opening {src.device_id}") from exc
try:
async with asyncio.TaskGroup() as tg:
tg.create_task(dispatch(sources, raw_q, polls_per_source))
tg.create_task(capture(raw_q, clean_q, quarantine))
tg.create_task(inject(clean_q, record_q, run_id))
tg.create_task(lims_sink(record_q, post, committed))
finally:
for src in reversed(opened): # source-first teardown after drain
await src.close()
log.info("committed %d records, quarantined %d", committed.count, quarantine.qsize())
return committed
Step 5 — Dry-Run Against Mock Endpoints and Assert One Record Traverses Every Stage
Prove the wiring before any hardware is attached by swapping mock sources and a mock post, then asserting that a record leaves the sink carrying a commit_id, a sequence_no, and a CRC-verified value — evidence it passed through parse, inject, and sink in order.
import pytest
def _good_frame(channel: int, value: float) -> bytes:
body = struct.pack(">Hf", channel, value)
crc = struct.pack(">I", binascii.crc32(body) & 0xFFFFFFFF)
return struct.pack(">H", len(body)) + body + crc
class MockSource:
def __init__(self, device_id, frames):
self.device_id, self._frames, self._i = device_id, frames, 0
async def open(self): ...
async def close(self): ...
async def poll(self):
f = self._frames[self._i % len(self._frames)]
self._i += 1
return f
@pytest.mark.asyncio
async def test_one_record_traverses_all_stages() -> None:
src = MockSource("smu-1", [_good_frame(3, 1.25)])
async def post(rec: Record) -> str:
return f"lims-{rec.sequence_no}"
out = await run_pipeline([src], post, run_id="R42", polls_per_source=1)
assert len(out) == 1
r = out[0]
assert r.raw_crc_ok and r.value == pytest.approx(1.25) # parse + CRC ran
assert r.sequence_no == 1 and r.run_id == "R42" # injection ran
assert r.commit_id == "lims-1" # sink ran
On a live rig the same assertions hold, plus three hardware-side signals: the LIMS returns monotonically increasing sequence_no values (proof the injector saw records in dispatch order), quarantine.qsize() stays flat (proof CRC seeds and framing match firmware), and none of the queues’ qsize() climbs monotonically (proof r_sink ≥ r_ingest holds). A single *OPC? after a poll batch confirms the instruments physically settled before the sink acknowledged.
Failure Modes in the Assembled Wiring
A source polled before its transport is open. Calling poll() before open() — the classic effect of bringing a source up before the sink and its queue are ready — raises RuntimeError: poll before open from the guard in each source. The fix is structural, not defensive: run_pipeline opens every source before the TaskGroup starts any coroutine, so no poll can run against a None handle. If you reorder startup to launch dispatch first, this guard converts a garbage first-sweep into an immediate, named abort.
Sink slower than ingest → the queue would grow unbounded. When post slows below the aggregate poll rate, record_q fills to depth, the injector’s await out.put(...) blocks, and the stall propagates back through capture to dispatch until the sources stop polling — the r_sink ≥ r_ingest inequality made physical. Memory stays bounded at 3 × depth records. The bug is raising maxsize to mask a slow sink; the fix is to spill overflow to a local durable buffer and drain it on recovery, exactly as the parent guide’s sink-outage row prescribes.
One instrument failing to open aborts the whole rig. If VisaSource.open() times out on a powered-off SMU, the rollback loop closes every already-opened source newest-first and raises startup aborted opening smu-1, so no serial handle or VISA lock leaks. For a rig that must degrade to N-1 rather than abort, catch the per-source open failure, emit a health event, and start the TaskGroup with the survivors — but never leave a partially opened rig live, and classify the fault through Error Code Categorization to decide retry-versus-quarantine.
Shutdown losing in-flight records. Cancelling the TaskGroup on Ctrl-C instead of letting the sentinel propagate would strand validated-but-uncommitted records in clean_q and record_q. The wiring here drains by design: dispatch emits one SENTINEL when polling ends, each stage forwards it only after its inbox empties, and the sink returns only after committing its backlog — so a clean stop leaves zero acknowledged records behind. A hard kill mid-commit still needs the idempotent upsert on the LIMS side to avoid a double-write on restart.
Related
- End-to-End Lab Automation Pipeline Assembly — the orchestrator design and lifecycle contract this walkthrough implements.
- Structuring a New Lab Automation Project from Scratch — the repository layout that hosts this
main(). - Async Command Queuing Systems — the dispatch stage and the bounded queue that carries backpressure.
- Checksum & CRC Validation and Binary & ASCII Format Parsing — the capture stage’s frame decode and integrity check.
- Metadata Injection Workflows — the provenance and
sequence_nostamping before the sink.
← Back to End-to-End Lab Automation Pipeline Assembly
References
- PyVISA documentation —
ResourceManager,open_resource, andread_rawsemantics. - Python
asyncio.TaskGroupreference — structured concurrency and coordinated cancellation.