dtollib Architecture Plan¶
Status: Initial design (v0) — pre-implementation
Target package name: dtollib
Proposed role: Experiment-facing DT-Open Layers acquisition layer for the
existing alicatlib / sartoriuslib / watlowlib / nidaqlib ecosystem
Primary dependency: the Data Translation DataAcq SDK (oldaapi32.dll /
oldaapi64.dll), wrapped via ctypes — there is no upstream Python binding
Reference design: nidaqlib
(closest sibling — same task/session/recorder shape, but dtollib owns the C
binding layer that nidaqlib inherits from nidaqmx-python)
Hardware in scope: DT9805 (multi-sensor USB module — AI only) and DT9806
(adds D/A, DIO, counter/timer)
Platform support: Windows only (the DataAcq SDK is Windows-only)
SDK reference: dasdk_digest.md (1100-line technical digest of
dasdk.md, the DataAcq SDK User's Manual UM-18326-AC)
Executive Summary¶
dtollib should be a typed, lifecycle-managed acquisition layer over a
hand-rolled ctypes binding to the DT-Open Layers C API. It fits the same
scientific-instrumentation ecosystem as alicatlib, sartoriuslib,
watlowlib, and nidaqlib.
Unlike nidaqlib, we own the C-binding layer itself. There is no
nidaqmx-python equivalent in the Data Translation world; the only path
between Python and a DT device is oldaapi64.dll through ctypes. That
extra responsibility doubles the careful-code surface: in addition to the
typed task-spec / session / recorder / sink stack that the rest of the
ecosystem has, we have to maintain a faithful low-level binding (capi)
and the §11.3.2-style driver-thread → asyncio bridge for SDK notification
callbacks.
The design principle, inherited from nidaqlib:
Wrap workflow, not capability. Where the SDK is already clean, re-export. Where the workflow benefits from structure (lifecycle, backpressure, typed channels, sinks), add it.
But with one addition specific to dtollib:
Treat the C binding as an internal seam, not as a public surface. Users live in the typed Python layer; the C layer is documented, tested, and reachable via an escape hatch but is not the path of first resort.
v0.1 covers DT9805's primary use case: analog-input voltage and thermocouple
channels, single-value and continuous acquisition, the §11.3.2 callback
bridge, a fake backend, ecosystem-compatible sinks, and a "raw-counts to
disk" fast sink that fills the role TDMS plays in nidaqlib. v0.2 adds AO,
DIO, and counter/timer for the DT9806.
1. Motivation¶
The existing ecosystem covers:
alicatlib— Alicat mass flow controllers (serial ASCII)sartoriuslib— Sartorius balances (xBPI + SBI serial protocols)watlowlib— Watlow temperature controllers (Standard Bus + Modbus RTU)nidaqlib— NI-DAQmx DAQ acquisition (built onnidaqmx-python)
A common experimental rig in this lab combines:
- Thermocouple acquisition on a DT9805 (currently has no Python option that fits the ecosystem)
- Mass flow controllers (Alicat)
- Sample-mass logging (Sartorius)
- Optional temperature controller setpoint sweeps (Watlow)
- Optional secondary DAQ on NI hardware (NI 9214, NI 9234, etc.)
Adding DT-Open Layers support closes the most painful gap. Today, anyone using a DT9805 has two options:
- Use Data Translation's Windows-only QuickDAQ GUI (no automation, no integration with the rest of the rig)
- Write
ctypesboilerplate per experiment, with no shared types, no shared error handling, and no shared logging
Neither composes with alicatlib-style record() + pipe() + SqliteSink
pipelines. dtollib should close that gap so a DT9805 thermocouple row
lands in the same run.sqlite table as an Alicat flow row and a Sartorius
mass row, joined on (device, t_mono_ns).
2. Core Recommendation¶
Build dtollib, scoped narrowly for v0.1 (analog input only), and grow
deliberately.
What it should be¶
- A typed, lifecycle-managed acquisition layer over a
ctypesbinding to the DataAcq SDK. - A driver-thread → asyncio bridge that turns SDK notification callbacks
into AnyIO memory-object streams of
DaqBlocks. - An ecosystem-compatible scalar polling path (
DaqReading) that joins cleanly againstalicatlib.Sample/sartoriuslib.Sample/watlowlib.Sampleon(device, t_mono_ns). - A fake backend that exercises the entire stack without a real DT device installed — enables CI on Linux/macOS GitHub runners despite the SDK being Windows-only.
- A "raw counts" fast sink that fills the role NI TDMS plays in
nidaqlib— durable, high-rate logging that does not depend on consumer back-pressure.
What it should not be¶
- A general-purpose binding generator. We transcribe the headers we need,
by hand, into the
capi/package. New SDK functions arrive when a real experiment needs them. - A GUI. QuickDAQ exists; we are not competing with it.
- A re-implementation of the SDK's queue model. The Ready/Inprocess/Done
queue is the SDK's truth — we expose it through a typed
BufferPlanon theTaskSpecand aBufferPoolhelper, not by hiding it. - Cross-platform. The SDK is Windows-only. Marking the package
Operating System :: Microsoft :: Windowsand skipping non-Windows install of the real backend is correct. - A wrapper around every
olDa*function. v0.1 covers what DT9805 AI needs; everything else is justified by a real experiment.
3. Ecosystem Comparison¶
3.1 Where dtollib sits relative to the four siblings¶
| Pattern | alicatlib |
sartoriuslib |
watlowlib |
nidaqlib |
dtollib |
|---|---|---|---|---|---|
Async-first API (anyio) |
✓ | ✓ | ✓ | ✓ | ✓ |
Sync facade (SyncPortal) |
✓ | ✓ | ✓ | ✓ | ✓ |
| Frozen-dataclass models | ✓ | ✓ | ✓ | ✓ | ✓ |
<Root>Error hierarchy + ErrorContext |
✓ | ✓ | ✓ | ✓ | ✓ |
| Manager for many devices | ✓ | ✓ | ✓ | ✓ | ✓ |
| Pluggable sinks (CSV/JSONL/SQLite/Parquet/Postgres) | ✓ | ✓ | ✓ | ✓ | ✓ |
| Fake backend for tests | ✓ (FakeTransport) | ✓ (FakeTransport) | ✓ (FakeTransport) | ✓ (FakeDaqBackend) | ✓ (FakeDtolBackend) |
| Hardware tests gated by markers/env vars | ✓ | ✓ | ✓ | ✓ | ✓ |
| Safety gates on destructive ops | ✓ | ✓ | ✓ | ✓ | ✓ |
transport/ byte layer |
✓ | ✓ | ✓ | — | — |
protocol/ framing layer |
✓ | ✓ | ✓ | — | — |
commands/ semantic dispatch |
✓ | ✓ | ✓ | — | — |
backend/ (substitutes for transport/protocol/commands) |
— | — | — | ✓ | ✓ |
| Owns the low-level C binding | — | — | — | — | ✓ (NEW) |
| TDMS-equivalent driver-side logging | — | — | — | ✓ (NI driver) | ✓ (RawCountsSink writes from callback thread) |
| Buffer queue exposed to user | — | — | — | hidden | exposed (BufferPlan on TaskSpec) |
The two new things, relative to nidaqlib:
- We own
capi— the typedctypesbinding.nidaqlibcouldimport nidaqmx; we cannot. - We expose
BufferPlan— the DT-Open Layers queue model is user-visible (Ready / Inprocess / Done), and hiding it leads to confusing OVERRUN errors when the consumer is slow.TaskSpec.bufferscarries a typedBufferPlan(count, samples-per-buffer, wrap mode, queue strategy); the backend implements it as an internalBufferPoolhelper aroundolDmAllocBuffer/olDaPutBuffer/olDaGetBuffer.
3.2 Architectural seam — where dtollib differs from siblings¶
Serial siblings own the wire-level bytes:
nidaqlib collapses the first three into nidaqmx-python and replaces
them with a single backend Protocol:
TaskSpec ─→ TaskBuilder ─→ DtolSession ─→ DaqBackend ─→ nidaqmx ─→ NI driver
│
└─→ FakeDaqBackend (tests)
dtollib follows nidaqlib's shape but the backend is implemented on top
of our own C binding, not a vendor-supplied Python package:
TaskSpec ─→ TaskBuilder ─→ DtolSession ─→ DtolBackend ─→ capi (ctypes) ─→ oldaapi64.dll
│
└─→ FakeDtolBackend (tests)
The split between DtolBackend (the typed, error-wrapping seam) and
capi (the raw ctypes surface) is intentional. capi is
pure ctypes/Win32 — no error wrapping, no async, no policy. DtolBackend
adds error wrapping, opaque-handle ownership, and the per-call
GIL-release discipline. Both are testable in isolation.
4. Design Goals¶
- Preserve DT-Open Layers correctness. Do not silently reinterpret queue ordering, callback timing, or capability rules. When in doubt, surface the SDK's own behaviour through a typed wrapper.
- Stable Python API.
TaskSpec/DtolSession/record()should read likenidaqlib's API. A user who knows one knows the other. - Ecosystem-aligned logging.
DaqReadingjoins on the same(device, t_mono_ns)tuple asalicatlib.Sample/sartoriuslib.Sample/watlowlib.Sample. - Support both low-rate and high-rate acquisition. Software polling
(
record_polled) for ≤100 Hz, hardware-clocked continuous (record) for the rest. Different correctness models — different recorders. - Explicit lifecycle. Subsystem allocation, configuration commit, start, stop, release, and terminate are all session-managed. Failed configuration tears down cleanly.
- Typed models at boundaries. Public inputs and outputs use frozen
dataclasses with
kw_only=True, enums, and Protocols. - High-rate efficiency.
DaqBlockis(n_channels, n_samples)np.ndarray, not a stream of dataclass instances. Avoid premature scalarisation. - Hardware-free tests. Most behaviour is tested against
FakeDtolBackend— including the §11.3.2 callback bridge, the buffer pool, and the recorder shutdown ordering. Real-hardware tests are opt-in. - Honest async. The SDK is synchronous and partially blocking. We
wrap calls at coarse boundaries with
anyio.to_thread.run_sync. We do not pretend the SDK is async-native. - Escape hatches. Users can always reach
session.raw_hdass/backend.dllfor advanced operations the wrapper does not cover. - Windows-only is fine. The SDK is Windows-only and that is
inherent. Mark the wheel
Operating System :: Microsoft :: Windows, skip non-Windows install of the real backend, run CI tests on Linux againstFakeDtolBackendonly. - Runtime capability query is the only authority. Per-device
capability matrices (DT9805/DT9806 in Appendix D, vendor datasheets,
user manuals) are planning context only. The wrapper validates every
configuration against the live
CapabilitySetpopulated fromolDaGetDevCaps/olDaGetSSCaps/olDaGetSSCapsEx/olDaEnumSSCaps/olDaEnumChannelCapsat session construction. Firmware revisions are known to flip capability flags within the same model number; trusting the matrix over the runtime query is a class of bug.
5. Non-Goals¶
- No support for non-DT-Open-Layers hardware.
- No re-implementation of QuickDAQ's GUI features.
- No replacement for the SDK itself (we don't reimplement queue logic in Python).
- No support for the legacy continuous pre-trigger / about-trigger data flow modes in v0.1 (they are flagged "Legacy Devices" in the manual; add only when a real DT9805/DT9806 supports them).
- No automatic re-detection of devices unplugged mid-acquisition (NI doesn't either; surfacing the error is enough).
- No ORM, no RPC server, no networked devices.
- No forced dependency on pandas.
- No "device pool" abstraction in v0.1 —
DtolManagerhandles many named tasks but does not pretend to be a hardware orchestrator.
6. Proposed Package Layout¶
src/
dtollib/
__init__.py
py.typed
version.py
_version.py # hatch-vcs generated
config.py # DtolConfig + config_from_env
errors.py # DtolError hierarchy + ErrorContext
units.py # to_pint helper
_logging.py # ROOT = "dtollib"
_runtime.py # eager_task_factory installer (port from alicatlib)
capi/ # LOW-LEVEL ctypes binding (internal) — three layers
__init__.py
loader.py # DLL discovery and load — TWO DLLs: oldaapi + olmem
types.py # HDRVR, HDASS, HBUF, HSSLIST, ECODE — opaque ctypes types
prototypes.py # argtypes/restype declarations only (raw ctypes surface)
constants.py # OL_DF_*, OL_TRG_*, OL_CLK_*, OLSS_*, OLSSC_*, OLDA_WM_*
errors.py # ECODE checker; olDaGetErrorString / olDmGetErrorString
callbacks.py # WINFUNCTYPE definitions for notification procedures
conversion.py # code↔volts; CJC deinterleave; sentinel detection
api.py # OpenLayersApi — direct ctypes calls + output-pointer
# extraction + source-aware ECODE → typed exceptions.
# Raises DtolCapiError subclasses; never returns ECODE.
backend/ # TYPED SEAM (analogue of nidaqlib.backend)
__init__.py
base.py # DtolBackend Protocol
dataacq.py # DataAcqBackend (real, wraps capi) — named after the SDK
fake.py # FakeDtolBackend (re-exported from testing.py) — named after the Protocol
_buffer_pool.py # BufferPool helper (internal) — HBUF alloc/recycle + output fill mode
_message_window.py # Win32 home — hidden HWND_MESSAGE window + pump thread (the ONLY user32/kernel32 caller)
_bridge_common.py # shared Sentinel/DrainStop/event-map for both bridges
_callback_bridge.py # input (AI) bridge — pump-thread → queue.SimpleQueue plumbing (mechanism-agnostic)
_output_callback_bridge.py # output (AO) bridge — refill-and-requeue drainer for play()
system/
__init__.py
discovery.py # find_devices() → list[BoardInfo]
capabilities.py # CapabilitySet, query_capabilities
models.py # BoardInfo, SubsystemInfo, DeviceInfo
channels/
__init__.py
base.py # ChannelSpec (kw_only=True, frozen, slots)
analog_input.py # AnalogInputVoltage, AnalogInputCurrent,
# ThermocoupleInput, RtdInput, ThermistorInput,
# StrainInput, BridgeInput, IepeInput
analog_output.py # AnalogOutputVoltage (v0.2)
digital.py # DigitalInputPort, DigitalOutputPort, DigitalLine (port+bitmask)
counter_input.py # CounterEdgeCount, CounterFrequency,
# CounterEdgeToEdge, QuadratureDecoder (v0.2)
counter_output.py # PulseTrainOutput, OneShotOutput (v0.2)
tasks/
__init__.py
spec.py # TaskSpec, Timing, RawLogging
builder.py # TaskBuilder — drives backend.create_subsystem / add_channel / ...
session.py # DtolSession — lifecycle, lock, escape hatch
models.py # DaqReading, DaqBlock, DaqSample, DataFlow, Edge
triggers.py # TriggerSpec hierarchy: SoftwareStart, ExternalDigitalStart,
# AnalogThresholdStart, ReferenceTrigger, RetriggerSpec
metadata.py # RunMetadata + sidecar serialisation
streaming/
__init__.py
block.py # record() — hardware-clocked block path
poll_source.py # PollSource / PollSourceAdapter
recorder.py # record_polled() — software-timed scalar path
_types.py # ErrorPolicy, OverflowPolicy, AcquisitionSummary
sinks/
__init__.py
base.py # ReadingSink, SampleSink, BlockSink Protocols + block_to_rows, reading_to_row
_schema.py # row/column helpers
memory.py # InMemorySink
csv.py # CsvSink
jsonl.py # JsonlSink
sqlite.py # SqliteSink
parquet.py # ParquetSink
postgres.py # PostgresSink
raw_counts.py # RawCountsSink — TDMS-equivalent fast path
# (writes from the callback thread, no consumer back-pressure)
manager.py # DtolManager — many tasks, ErrorPolicy, DeviceResult
sync/
__init__.py
portal.py # SyncPortal (direct port from siblings)
daq.py # Dtol — sync facade
session.py # sync DtolSession wrapper
recording.py # sync recording context
cli/
__init__.py
list.py # dtol-list (v0.1)
capture.py # dtol-capture (v0.1)
read.py # dtol-read (v0.2)
info.py # dtol-info (v0.2)
constants.py # re-exports of OL_TRG_*, OL_DF_*, DataFlow, Edge, ...
testing.py # FakeDtolBackend convenience builders
utils.py # ConvertTempToVolts / ConvertVoltsToTemp /
# GetThermocoupleRange / ComputeRectangular- /
# ComputeDeltaRosette — port from .NET Utility class
teds.py # StrainGageTeds / BridgeSensorTeds — TEDS read helpers (v0.3+)
Modules intentionally omitted¶
transport/ # no bytes on the wire; replaced by backend/ (same as nidaqlib)
protocol/ # capi is the protocol layer; nothing to re-implement
commands/ # olDa* functions are already typed via _signatures
registry/ # constants live in capi/constants.py; no parallel codes table
Key naming deviation from nidaqlib¶
Where nidaqlib uses nidaq-* CLI prefixes and NIDaq* class prefixes,
dtollib uses dtol-* and Dtol* respectively. Dtol is short for
"DT-Open Layers" and matches the package name.
7. Public API Shape¶
7.1 Basic single-value thermocouple read¶
import anyio
from dtollib import TaskSpec, ThermocoupleInput, ThermocoupleType, open_device
spec = TaskSpec(
name="surface_temperatures",
channels=[
ThermocoupleInput(
physical_channel=0,
name="surface_tc_K",
thermocouple_type=ThermocoupleType.K,
min_val_degc=-50.0,
max_val_degc=200.0,
),
ThermocoupleInput(
physical_channel=1,
name="back_tc_K",
thermocouple_type=ThermocoupleType.K,
min_val_degc=-50.0,
max_val_degc=200.0,
),
],
)
async def main() -> None:
async with await open_device(spec) as session:
reading = await session.poll()
print(reading.values) # {"surface_tc_K": 23.41, "back_tc_K": 22.97}
anyio.run(main)
7.2 Continuous hardware-clocked voltage acquisition to Parquet¶
import anyio
from dtollib import (
AnalogInputVoltage, BufferPlan, DataFlow, TaskSpec, Timing, open_device,
)
from dtollib.streaming import record
from dtollib.sinks import ParquetSink
spec = TaskSpec(
name="heat_flux_run",
data_flow=DataFlow.CONTINUOUS,
channels=[
AnalogInputVoltage(physical_channel=0, name="heat_flux",
min_val=-10.0, max_val=10.0),
AnalogInputVoltage(physical_channel=1, name="surface_tc",
min_val=-0.1, max_val=0.1),
],
timing=Timing(rate_hz=1000.0),
buffers=BufferPlan(buffers=4, samples_per_buffer=1000),
)
async def main() -> None:
async with (
await open_device(spec, autostart=False) as session,
record(session) as recording,
ParquetSink("run.parquet") as sink,
):
async for block in recording.stream:
await sink.write(block)
anyio.run(main)
autostart=False is required for record() because the SDK requires
notification callbacks to be registered before olDaStart() — same
constraint as NI's register_every_n_samples_acquired_into_buffer_event.
See §11.3.2.
7.3 Sync facade¶
from dtollib.sync import Dtol
with Dtol.open_device(spec) as session:
block = session.read_block(samples_per_channel=1000)
print(block.data.shape) # (2, 1000)
7.4 Escape hatch — direct ctypes access¶
async with await open_device(spec) as session:
hdass = session.raw_hdass # raw HDASS ctypes handle
dll = session.backend.dll # raw oldaapi64 ctypes.WinDLL
status = dll.olDaPause(hdass) # call any olDa* function directly
The escape hatch is documented and supported. The wrapper does not aspire to cover every SDK function; users with advanced needs reach through.
7.5 Unified experiment (DAQ + Alicat + Sartorius)¶
async with (
AlicatManager(error_policy=ErrorPolicy.RETURN) as mfc_mgr,
SartoriusManager(error_policy=ErrorPolicy.RETURN) as bal_mgr,
DtolManager(error_policy=ErrorPolicy.RETURN) as daq_mgr,
):
await mfc_mgr.add("fuel_mfc", "/dev/ttyUSB0")
await bal_mgr.add("sample_mass", "/dev/ttyUSB1")
await daq_mgr.add("thermal_signals", daq_spec)
async with (
record_polled(mfc_mgr, rate_hz=2.0) as mfc_rec,
record_polled(bal_mgr, rate_hz=2.0) as bal_rec,
record(daq_mgr.get("thermal_signals")) as daq_rec,
SqliteSink("run.sqlite") as scalar_sink,
ParquetSink("daq.parquet") as daq_sink,
):
...
Rows from all three sources join on (device, t_mono_ns).
8. Core Data Models¶
All spec dataclasses use @dataclass(frozen=True, slots=True, kw_only=True).
Construction is keyword-only across the public API. See nidaqlib design
doc §8 for the rationale (avoids the dataclass-inheritance trap when
defaulted parent fields precede required subclass fields).
8.1 TaskSpec¶
@dataclass(frozen=True, slots=True, kw_only=True)
class TaskSpec:
name: str
board: str | None = None # e.g. "DT9805(00)" — None = first found
# matches DT-Open Layers "board name" terminology
subsystem_type: SubsystemType | None = None # explicit when channels alone can't disambiguate
element: int = 0 # subsystem element index (default 0 = first AI subsys)
channels: Sequence[ChannelSpec]
data_flow: DataFlow = DataFlow.SINGLE_VALUE # SINGLE_VALUE / CONTINUOUS / *_PRETRIG / *_ABOUTTRIG
timing: Timing | None = None
trigger: TriggerSpec | None = None
buffers: BufferPlan | None = None # required for CONTINUOUS modes
logging: RawLogging | None = None # write raw counts to disk from callback thread
stop_on_error: bool = True # SDK-level: stop subsystem on OVERRUN / UNDERRUN
# maps to olDaSetStopOnError; orthogonal to ErrorPolicy (§14.3)
metadata: Mapping[str, str | int | float | bool] = field(default_factory=dict)
A TaskSpec declares one DT-Open Layers subsystem (HDASS) configured for
one data-flow mode. subsystem_type is usually inferred from the channel
kinds (all-AI → OLSS_AD, all-AO → OLSS_DA, etc.); mixing kinds in a
single TaskSpec is a validation error (matches the SDK's own rule that
each HDASS is one subsystem of one type). element distinguishes
multiple subsystems of the same type on one board (rare in v0.1 hardware
but valid SDK-wise).
Why BufferPlan lives on TaskSpec, not on record(): the SDK
treats buffer count and per-buffer sample width as part of the configured
subsystem state, not as a recorder concern. Two recorders against the
same task would share the same buffer pool. Putting it on the spec means
the pool is created and torn down with the session.
8.2 ChannelSpec (base)¶
@dataclass(frozen=True, slots=True, kw_only=True)
class ChannelSpec:
physical_channel: int # SDK uses 0-based int, not "Dev1/ai0"
name: str | None = None # display name; falls back to f"ch{physical_channel}"
unit: str | None = None # display unit; informational
metadata: Mapping[str, str | int | float | bool] = field(default_factory=dict)
# Class-level discriminator — used for to_dict / from_dict round-tripping.
kind: ClassVar[str] = ""
Concrete subclasses (AnalogInputVoltage, ThermocoupleInput,
RtdInput, …) each declare a kind: ClassVar[str] discriminator.
8.3 AnalogInputBase — common AI knobs¶
@dataclass(frozen=True, slots=True, kw_only=True)
class AnalogInputBase(ChannelSpec):
channel_type: ChannelType = ChannelType.SINGLE_ENDED # SE / DIFF / PSEUDODIFF
gain: float = 1.0
filter: FilterType | None = None
encoding: Encoding | None = None # BINARY / TWOS_COMP / OFFSET_BIN
coupling: CouplingType | None = None # AC / DC
These map to olDaSetChannelType / olDaSetGainListEntry /
olDaSetChannelFilter / olDaSetEncoding / olDaSetCouplingType calls
the backend issues after the channel is added to the list.
8.4 AnalogInputVoltage¶
@dataclass(frozen=True, slots=True, kw_only=True)
class AnalogInputVoltage(AnalogInputBase):
kind: ClassVar[str] = "ai_voltage"
min_val: float = -10.0 # volts
max_val: float = 10.0 # volts
8.5 ThermocoupleInput¶
@dataclass(frozen=True, slots=True, kw_only=True)
class ThermocoupleInput(AnalogInputBase):
kind: ClassVar[str] = "thermocouple"
thermocouple_type: ThermocoupleType # J / K / T / E / R / S / B / N
min_val_degc: float
max_val_degc: float
cjc_source: CjcSource = CjcSource.INTERNAL
units: TemperatureUnit = TemperatureUnit.DEG_C
When the channel emits thermocouple values, the backend reads CJC via
olDaGetCjcTemperature (single-value path) or via the in-stream
interleaving enabled by olDaSetReturnCjcTemperatureInStream (continuous
path). The wrapper applies linearisation through olDaGetSingleFloat
or the SDK's continuous-data CJC compensation as appropriate.
8.6 RtdInput, ThermistorInput, StrainInput, BridgeInput, IepeInput¶
Defined per SDK chapter 3:
RtdInput—RtdType(PT100/PT1000/custom),r0,a,b,c, excitation current source/value.ThermistorInput— Steinhart–Harta,b,ccoefficients.StrainInput—BridgeConfiguration(FULL/HALF/QUARTER), excitation voltage, gauge factor, shunt-cal flag.BridgeInput— bridge configuration + transducer type (LOAD_CELL, PRESSURE, DISPLACEMENT, …) + excitation voltage.IepeInput— coupling = AC (mandatory for IEPE), excitation current enabled, source/value.
All five share AnalogInputBase and reuse its knobs.
8.7 Timing¶
@dataclass(frozen=True, slots=True, kw_only=True)
class Timing:
rate_hz: float # internal clock; via olDaSetClockFrequency
clock_source: ClockSource = ClockSource.INTERNAL
external_divider: int | None = None # only for EXTERNAL clock
retrigger: RetriggerSpec | None = None # triggered scan mode (post-v0.1)
Timing is omitted for DataFlow.SINGLE_VALUE tasks. After
olDaConfig, the wrapper reads the actual clock frequency back via
olDaGetClockFrequency and records it on the session — the SDK may
quantise to the nearest achievable rate and the user needs the truth,
not the request.
8.7a BufferPlan — required for continuous tasks¶
@dataclass(frozen=True, slots=True, kw_only=True)
class BufferPlan:
buffers: int = 4 # how many HBUFs in the Ready/Inprocess/Done cycle
# minimum 3; default 4 (matches QuickDAQ default)
samples_per_buffer: int # samples-per-channel per buffer
sample_width_bytes: int | None = None # None = auto-detect from
# OLSSC_RETURNS_FLOATS + resolution + data width
wrap_mode: WrapMode = WrapMode.MULTIPLE # NONE (finite) / SINGLE (DAC waveform) / MULTIPLE
queue_strategy: QueueStrategy = QueueStrategy.REQUEUE # REQUEUE / KEEP / FREE_ON_DONE
Defaults are conservative — 4 buffers absorb ~4× consumer-latency
hiccups before OLDA_WM_OVERRUN_ERROR. Sizing rule of thumb documented
in streaming.md:
buffers >= ceil(max_consumer_latency_s * rate_hz / samples_per_buffer) + 1
with a hard minimum of 3 and a recommended floor of 4 in production.
The +1 is the extra buffer the SDK keeps filling while the drainer is
copying and recycling a just-completed buffer; without it, a consumer
running at exactly the average production rate still produces sporadic
overruns. The minimum-3 floor matches the SDK's own continuous-AI
sample (which allocates HBUF[3] — see dasdk_digest.md:906-918):
two buffers leave no headroom for "one in the driver, one being
recycled, one queued."
AcquisitionSummary.overruns_observed > 0 after a run is a signal to
increase buffers or samples_per_buffer.
8.8 TriggerSpec¶
class TriggerSpec: ... # marker base
@dataclass(frozen=True, slots=True, kw_only=True)
class SoftwareStart(TriggerSpec): pass # OL_TRG_SOFT (default)
@dataclass(frozen=True, slots=True, kw_only=True)
class ExternalDigitalStart(TriggerSpec):
edge: Edge = Edge.RISING # OL_TRG_EXTERN
@dataclass(frozen=True, slots=True, kw_only=True)
class AnalogThresholdStart(TriggerSpec):
channel: int # threshold-monitor channel
level: float # volts
slope: Edge # RISING → OL_TRG_THRESHPOS, FALLING → OL_TRG_THRESHNEG
@dataclass(frozen=True, slots=True, kw_only=True)
class SyncBusStart(TriggerSpec): pass # OL_TRG_SYNCBUS
@dataclass(frozen=True, slots=True, kw_only=True)
class ReferenceTrigger: # composed onto TriggerSpec via TaskSpec.reference
source: TriggerSpec
post_scan_count: int # samples after reference event
@dataclass(frozen=True, slots=True, kw_only=True)
class RetriggerSpec:
mode: RetriggerMode # SCAN_PER_TRIGGER / INTERNAL / EXTRA
frequency_hz: float | None = None # required for INTERNAL
source: TriggerSpec | None = None # required for EXTRA
8.9 DaqReading — scalar / cross-instrument bridge¶
@dataclass(frozen=True, slots=True, kw_only=True)
class DaqReading:
device: str # manager-add name; join key with siblings
task: str | None = None # underlying TaskSpec.name
values: Mapping[str, float | int | bool]
units: Mapping[str, str | None]
requested_at: datetime
received_at: datetime
t_utc: datetime # wall-clock acquisition midpoint
t_mono_ns: int # canonical monotonic join key
t_midpoint_mono_ns: int | None = None # integration-window midpoint in monotonic ns
latency_s: float
sensor_status: Mapping[str, SensorStatus] = field(default_factory=dict)
# see §13.1 — TC sentinel preservation
metadata: Mapping[str, str | int | float | bool] = field(default_factory=dict)
error: DtolError | None = None # populated only under ErrorPolicy.RETURN
Field shape matches current nidaqlib.DaqReading / alicatlib.Sample /
watlowlib.Sample for join compatibility — the canonical contract is
t_mono_ns (int, monotonic ns) plus t_utc (datetime, wall-clock at
midpoint) plus t_midpoint_mono_ns (int | None, integration-window
midpoint) plus requested_at / received_at for I/O provenance. The
latency_s field name matches alicatlib/watlowlib (sartoriuslib's
elapsed_s is a known divergence — same call as in nidaqlib §8.6).
The sensor_status overlay is the dtollib-specific addition: TC
channels can produce SENSOR_IS_OPEN / TEMP_OUT_OF_RANGE_LOW /
TEMP_OUT_OF_RANGE_HIGH sentinel values that must NOT be coerced into
plausible floats — see §13.1.
8.10 DaqBlock — hardware-clocked, rectangular¶
@dataclass(frozen=True, slots=True, kw_only=True)
class DaqBlock:
device: str
task: str | None = None
channels: tuple[str, ...] # in array-row order
data: np.ndarray # shape == (len(channels), samples_per_channel)
# dtype float64 (post conversion)
raw_codes: np.ndarray | None # shape == data.shape, dtype int16 / int32
# — populated if RawLogging requested
block_index: int # 0-based monotonic per task
first_sample_index: int # cumulative offset since task_started_at
samples_per_channel: int
sample_rate_hz: float | None
block_period_ns: int | None # ns per sample; derived from sample_rate_hz
task_started_at: datetime # wall-clock anchor for sample-time reconstruction
t0: datetime # wall-clock at first sample of THIS block
t_mono_ns: int # canonical monotonic key — at callback receipt
t_utc: datetime # wall-clock at block midpoint
t_midpoint_mono_ns: int | None = None # block-midpoint in monotonic ns
read_started_at: datetime # provenance
read_finished_at: datetime # provenance
elapsed_s: float
units: Mapping[str, str | None]
sensor_status: Mapping[str, np.ndarray] = field(default_factory=dict)
# per-channel sentinel mask (see §13.1)
error: DtolError | None = None
Sample-time reconstruction matches current nidaqlib §8.7 — derive
each sample's t_mono_ns from block.t_mono_ns + k * block.block_period_ns
and t_utc analogously; do not interpolate off t0 (which has
scheduler jitter). Use first_sample_index + k as the absolute sample
index.
Why raw_codes is a top-level field, not metadata: users who want the
RawCountsSink path always have raw counts in hand. Storing them as an
optional second array keeps the conversion olDaCodeToVolts close to the
data — replay tools can re-derive data if scaling changes.
8.11 DaqSample¶
Optional per-sample scalarisation, produced explicitly via
block_to_long_rows(block). Same shape and use cases as nidaqlib §8.9.
8.12 DataFlow + SubsystemType¶
Two enums name what SDK calls take. Both are StrEnum for JSON-friendly
round-trip via the discriminated serialisation in §19.3.
class DataFlow(StrEnum):
SINGLE_VALUE = "single_value" # OL_DF_SINGLEVALUE
CONTINUOUS = "continuous" # OL_DF_CONTINUOUS (post-trigger)
FINITE = "finite" # CONTINUOUS + WrapMode.NONE + sample ceiling
CONTINUOUS_PRETRIGGER = "continuous_pretrigger" # OL_DF_CONTINUOUS_PRETRIG (legacy, deferred)
CONTINUOUS_ABOUT_TRIGGER = "continuous_about_trigger" # OL_DF_CONTINUOUS_ABOUTTRIG (legacy, deferred)
class SubsystemType(StrEnum):
ANALOG_INPUT = "analog_input" # OLSS_AD
ANALOG_OUTPUT = "analog_output" # OLSS_DA
DIGITAL_INPUT = "digital_input" # OLSS_DIN
DIGITAL_OUTPUT = "digital_output" # OLSS_DOUT
COUNTER_TIMER = "counter_timer" # OLSS_CT
QUADRATURE = "quadrature" # OLSS_QUAD
TACHOMETER = "tachometer" # OLSS_TACH — first-class, separate from C/T
FINITE is implemented on top of CONTINUOUS + WrapMode.NONE + a
samples_per_channel ceiling on the configured task. The recorder
stops when the cumulative sample count is reached.
TACHOMETER is broken out from COUNTER_TIMER despite both flowing
through C/T-like configuration calls — the SDK treats them as distinct
subsystem types (OLSS_TACH ≠ OLSS_CT), and lumping them would force
runtime branching on per-channel kind.
8.13 SubsystemState — explicit lifecycle phase¶
Borrowed from the .NET API (SubsystemBase.State, UMOpenLayers.md:932).
The SDK already tracks this; we expose it instead of synthesising it
from is_running() plus implicit flags.
class SubsystemState(StrEnum):
INITIALIZED = "initialized"
CONFIGURED_FOR_SINGLE_VALUE = "configured_for_single_value"
CONFIGURED_FOR_CONTINUOUS = "configured_for_continuous"
PRESTARTED = "prestarted" # simultaneous-start pool
RUNNING = "running"
STOPPING = "stopping"
ABORTING = "aborting"
IO_COMPLETE = "io_complete" # finite/reference-trigger end
DtolSession.state -> SubsystemState is the canonical state query.
is_running() becomes a derived convenience (self.state ==
SubsystemState.RUNNING). Tests assert exact transitions instead of
"did we end up running?", and error messages can say "task is in
STOPPING — poll() is invalid mid-shutdown" instead of guessing.
8.14 BufferState — per-HBUF lifecycle¶
Borrowed from OIBuffer.State (UMOpenLayers.md:5958-5963). Tracked on
the internal RawBuffer returned by BufferPool:
class BufferState(StrEnum):
IDLE = "idle" # allocated, not yet queued
QUEUED = "queued" # on the Ready queue (olDaPutBuffer)
INPROCESS = "inprocess" # SDK is filling it
COMPLETED = "completed" # on Done queue / handed to drainer
RELEASED = "released" # olDmFreeBuffer called; ndarray view is invalid
The pool maintains buffer.state on every transition. Two payoffs:
- Use-after-free becomes an explicit error. Reading data from a
RELEASEDbuffer raisesDtolTaskStateErrorimmediately instead of producing silent garbage from freed C memory. - The §12.3.2 shutdown invariant ("drain-wait BEFORE close") is
asserted, not assumed.
BufferPool.free_all()refuses to run while any buffer isINPROCESS; the fake backend enforces the same.
8.15 IOType — channel measurement-kind discriminator¶
Borrowed from SupportedChannelInfo.IOType (UMOpenLayers.md:3924-3946).
Carried on CapabilitySet.channel_caps[ch]["IOType"] so the wrapper
can reject "configure channel 3 as RTD" when that channel reports
IOType.VOLTAGE_IN only.
class IOType(StrEnum):
VOLTAGE_IN = "voltage_in"
VOLTAGE_OUT = "voltage_out"
CURRENT = "current"
THERMOCOUPLE = "thermocouple"
RTD = "rtd"
THERMISTOR = "thermistor"
RESISTANCE = "resistance"
STRAIN_GAGE = "strain_gage"
BRIDGE = "bridge"
ACCELEROMETER = "accelerometer" # IEPE
DIGITAL_INPUT = "digital_input"
DIGITAL_OUTPUT = "digital_output"
COUNTER_TIMER = "counter_timer"
TACHOMETER = "tachometer"
QUADRATURE_DECODER = "quadrature_decoder"
MULTI_SENSOR = "multi_sensor" # see §8.5a
MULTI_SENSOR is the DT9805 case: one physical channel that the SDK
re-types at configure time based on what's wired to it.
8.5a MultiSensor channels — explicit configure-time discriminator¶
The DT9805 multi-sensor analog input subsystem reports
IOType.MULTI_SENSOR on every channel, meaning the same physical
channel can be voltage / current / thermocouple / RTD / strain / bridge
depending on what is wired and how the channel is configured.
The .NET API exposes this through
SupportedChannelInfo.MultiSensorType (UMOpenLayers.md:1724,
UMOpenLayers.md:4036, etc.) — a per-channel runtime setter that must
be called before any other per-channel configuration, because it
re-types the channel and invalidates earlier per-type settings.
In dtollib this is implicit in the channel-spec subclass (you write
ThermocoupleInput(physical_channel=3, ...) and the backend issues
the right SDK call), but the ordering must be explicit in the backend:
# Inside DataAcqBackend.add_channel(hdass, list_index, spec)
caps = self._capabilities_cache[id(hdass)]
io_type_for_ch = caps.io_type_for_channel(spec.physical_channel)
if io_type_for_ch == IOType.MULTI_SENSOR:
# FIRST — re-type the channel to the spec's kind.
self._api.set_multi_sensor_type(
hdass, spec.physical_channel, spec.kind_to_multi_sensor_type(),
)
# THEN — all per-type setters (range, gain, TC type, RTD coefficients, ...).
Skipping the multi-sensor set on a multi-sensor channel is a
silent-wrong-data bug, not an SDK error: the SDK will happily read
voltage off a channel you intended as thermocouple. The fake backend
enforces the ordering for tests; the real backend issues it
gated on capabilities.supports_multisensor — TC-only modules
(DT9806 reports OLSSC_SUP_MULTISENSOR=0) reject
olDaSetMultiSensorType with OLNOTSUPPORTED and must not have it
called.
CapabilitySet.io_type_for_channel(ch) -> IOType and
spec.kind_to_multi_sensor_type() -> IOType are the seams.
Thermocouples on the DT9805/DT9806 (SDK V7.0.0.7) — application-side¶
These boards do not linearise thermocouples in firmware. UM9800.md
Table 26 lists Voltage Converted to Temperature
(SupportsTemperatureDataInStream) and SupportsCjcSourceInternal as
unsupported; only SupportsCjcSourceChannel (CJC on channel 0) is
true. So the AD subsystem reports OLSSC_SUP_THERMOCOUPLES=1,
OLSSC_SUP_MULTISENSOR=0, OLSSC_RETURNS_FLOATS=0 (bench-verified
2026-05-28). The firmware-linearising SDK calls therefore correctly
return OLNOTSUPPORTED (ec=36): olDaSetThermocoupleType,
olDaSetReturnCjcTemperatureInStream, olDaGetCjcTemperature, and
olDaGetSingleValueEx. They target intelligent DT temperature modules,
not these dumb differential front-ends. (The earlier "TC mapping is
hardwired by the connector" note was wrong — the ec=36 is firmware-feature
absence, not a runtime-vs-stored typing distinction.)
The board provides only a CJC sensor on channel 0 at 10 mV/°C and high-impedance differential inputs on channels 1–7. The read path is therefore application-side (see §15 and docs/decisions.md):
- Configure the AD subsystem in differential mode
(
olDaSetChannelType(OL_CHNT_DIFFERENTIAL=101); subsystem-wide, no channel argument). Single-ended wiring reads TCs as rail-to-rail noise. - Read the CJC voltage on channel 0 at gain 1 →
cjc_°C = V / 0.010. (At gain 100 the ~0.25 V CJC saturates the ±10 V ADC.) - Read each TC channel's differential emf at high gain (default 100,
≈3 µV/LSB) and convert the raw code to volts ourselves —
olDaCodeToVoltsreturns ECODE=9 "Invalid Encoding" on this board, socode_to_voltsreads the configured encoding/resolution/range (olDaGetEncoding/olDaGetResolution/olDaGetRange) and applies the offset-binary formula inconversion.code_to_input_volts. - Linearise with
utils.convert_volts_to_temperature(tc_type, volts, cjc_temperature_c=cjc_°C)— NIST ITS-90 (Types K and J today).
Open-circuit detection is free: an open differential input is pulled to
the +2.5 V reference and pegs the ADC at +full scale, i.e. ≈ +V_RAIL /
gain at the input — flagged SensorStatus.SENSOR_OPEN. The path is
selected by supports_thermocouples and not returns_floats; the
firmware-linearised float path (returns_floats) remains for hypothetical
boards that report it. Gated branches live in
DtolSession._read_all_channels / _read_all_channels_app_side_tc and
DataAcqBackend.add_channel (which skips olDaSetThermocoupleType on the
application-side path). ThermocoupleInput defaults match this hardware:
channel_type=DIFFERENTIAL, gain=100, cjc_channel=0.
9. Session and Lifecycle Model¶
9.1 DtolSession¶
class DtolSession:
def __init__(
self,
spec: TaskSpec,
backend: DtolBackend,
*,
timeout: float = 10.0,
) -> None: ...
async def prepare(self) -> None:
"""Allocate HDASS, add channels, set timing/triggers/wrap mode/logging.
Stops short of `olDaConfig`. After `prepare()` the SDK is ready to
accept the notification procedure (`register_notification`) and ready
buffers (`pool.queue_all()`), both of which must land BEFORE
`olDaConfig` per the SDK sample code (see §12.3.2). Idempotent.
For single-value mode the recorder calls `prepare()` immediately
followed by `commit()` — there is no notification or buffer pool to
interleave."""
async def commit(self) -> None:
"""Call `olDaConfig`. Must follow `prepare()` and (for continuous
mode) the notification + buffer-queue setup. Idempotent."""
async def configure(self) -> None:
"""Convenience: `prepare()` followed by `commit()` with nothing
between. Correct for SINGLE_VALUE tasks. Continuous-mode callers
MUST drive the prepare → register → queue → commit sequence
themselves (or use `record()`, which does it for you)."""
async def start(self) -> None:
"""olDaStart. Transitions subsystem to running."""
async def stop(self) -> None:
"""olDaStop — orderly. Blocks until current buffer fills.
See §9.2 — do NOT call if trigger may never fire; use abort() instead."""
async def abort(self) -> None:
"""olDaAbort — immediate. Current buffer may be partial."""
def is_running(self) -> bool:
"""Cheap state query via olDaIsRunning. Safe to call from any thread.
Derived convenience — equivalent to `self.state == SubsystemState.RUNNING`."""
@property
def state(self) -> SubsystemState:
"""Canonical subsystem state (§8.13). Cheap read of the SDK's own
state-machine via olDaGetSSState. Useful in error messages and tests:
an invalid poll() during STOPPING gets a precise diagnostic instead
of a generic "task busy" error."""
@property
def queued_buffer_dones(self) -> int:
"""Number of BUFFER_DONE events currently queued for synchronous
delivery (via olDaGetQueueSize on the Done queue). Useful for
runtime monitoring: a steadily growing value means the drainer is
falling behind and an overrun is impending."""
async def poll(self, *, timeout: float | None = None) -> DaqReading:
"""One-shot scalar read across all channels. ON_DEMAND mode only.
Raises DtolTaskStateError if the task is buffering."""
async def read_block(
self,
samples_per_channel: int,
*,
timeout: float | None = None,
) -> DaqBlock:
"""Pulls one buffer worth of data via the buffer pool.
FINITE / CONTINUOUS modes only. Allocates if pool not yet primed."""
async def read_inprocess(self) -> DaqBlock | None:
"""Drains the currently-filling HBUF without waiting for it to
complete. Returns None if the buffer holds zero valid samples.
Equivalent to .NET's `AnalogInputSubsystem.MoveFromBufferInprocess`
(UMOpenLayers.md:5933-5945). Requires
`CapabilitySet.supports_inprocess_flush()` — raises
DtolCapabilityError otherwise. Useful for low-latency consumers
on low-rate continuous tasks (200 Hz TC, 1 kHz strain) where
waiting for a full buffer is unacceptable.
Backed by olDaCopyFromBuffer on the current Inprocess HBUF;
returns a copy into a fresh ndarray. The SDK transfers data in
device-specific segment sizes (some devices in 64-byte chunks),
so the returned sample count is not necessarily what the user
requested. Caller checks `block.samples_per_channel` for the
actual count."""
async def write(
self,
values: Mapping[str, float | bool],
*,
confirm: bool = False,
) -> None:
"""Single-value write to AO/DO/CO channels.
confirm=True is mandatory on channels marked requires_confirm. (v0.2)"""
async def close(self, *, graceful: bool = False) -> None:
"""Tear the session down. Default: abort-if-running, then flush/free/release.
Why default abort: olDaStop blocks waiting for the current buffer to
fill, which can hang forever if the configured trigger never fires.
`close()` is called from `__aexit__` and must not deadlock when an
outer exception is propagating.
Pass `graceful=True` to use olDaStop instead — appropriate when the
caller knows the trigger has fired and wants the final buffer to be
committed cleanly. The orderly close raises DtolTimeoutError if stop
does not complete within `self._timeout`."""
@property
def raw_hdass(self) -> Any:
"""Escape hatch — raw HDASS for direct olDa* calls."""
@property
def raw_hdrv(self) -> Any:
"""Escape hatch — raw HDRVR for direct olDa* calls."""
@property
def backend(self) -> DtolBackend:
"""Escape hatch — backend with .dll attribute for direct ctypes calls."""
# Named diagnostic escape hatches (advanced; documented but rarely used).
# Map directly to the .NET Device.Diag* methods (UMOpenLayers.md:860-863).
# Named methods are more discoverable than telling users "drop to ctypes."
async def diag_read_reg(self, reg: int) -> int:
"""olDaReadDevReg. Raw device-register read. Documented as advanced."""
async def diag_write_reg(self, reg: int, value: int) -> None:
"""olDaWriteDevReg. Raw device-register write. Documented as advanced
and gated behind safety policy — same gate as auto-calibration."""
async def __aenter__(self) -> Self: ...
async def __aexit__(self, exc_type, exc, tb) -> None: ...
9.2 Lifecycle invariants¶
The invariants below are stated in terms of SubsystemState (§8.13)
transitions. The fake backend enforces every transition; tests assert
the exact sequence rather than "did we end up running?"
Canonical happy path (single-value):
INITIALIZED → CONFIGURED_FOR_SINGLE_VALUE → RUNNING → IO_COMPLETE → INITIALIZED
Canonical happy path (continuous, post-trigger):
INITIALIZED → CONFIGURED_FOR_CONTINUOUS → RUNNING → STOPPING → IO_COMPLETE → INITIALIZED
Canonical abort path (continuous):
INITIALIZED → CONFIGURED_FOR_CONTINUOUS → RUNNING → ABORTING → INITIALIZED
- HDRVR and HDASS are allocated once per session.
- Configuration runs through
olDaConfigexactly once before start. - For continuous mode,
olDaConfigmust be preceded by notification registration andolDaPutBuffer × N. The SDK sample code atdasdk_digest.md:906-918allocates and queues all buffers before callingolDaConfig; the textual flowchart atdasdk_digest.md:420-426contradicts itself and is treated as advisory. The canonical order is:prepare → register notification → queue buffers → commit (olDaConfig) → start (olDaStart).FakeDtolBackendenforces this; registering or queueing afterolDaConfigraises a syntheticDtolTaskStateError. - Reads and writes serialise on a per-session
anyio.Lock(HDASS is not thread-safe per the SDK; the lock is the seam). close()is idempotent. Calls in order: abort-if-running (or stop, whengraceful=True) → flush buffers → free pool → release HDASS → release HDRVR (refcount). HDRVRolDaTerminateruns only when the last session against that board closes —DtolManager(or the implicit per-board cache inopen_device) owns the refcount.__aexit__always attempts the full cleanup chain, even if a prior call raised.- Wrapped SDK errors include task name, channel name when known,
operation, and the raw
OLSTATUScode. poll()is invalid mid-buffered-acquisition. RaisesDtolTaskStateErrorif the task is configuredCONTINUOUS/FINITEand is running. Matchesnidaqlib§9.2 — competing consumers on the same buffer queue would produce undefined results.poll()is also invalid duringSTOPPING/ABORTING. The error message names the currentSubsystemStateso the caller can distinguish "task is buffering" from "task is in the middle of shutdown" without guessing.stop()blocks (it waits for the current buffer to fill). If the trigger may never fire, useabort(). The wrapper does not silently prefer one or the other.- Callback-bridge shutdown is ordered. For sessions using the
record()callback bridge, the ordering is the same five-step shutdown asnidaqlib§9.2 / §11.3.2 (with one rename — see §13).
9.3 Open factory¶
async def open_device(
spec: TaskSpec,
*,
backend: DtolBackend | None = None,
timeout: float = 10.0,
autostart: bool = True,
confirm_start: bool = False,
) -> DtolSession: ...
Mirrors nidaqlib.open_device. autostart=False is required for any
caller that needs to register a callback before start — namely record().
10. Backend Abstraction¶
10.1 Why a backend, not a transport¶
There are no serial bytes to fake. The substitution point is the SDK API —
an HDASS-shaped seam. Same call as nidaqlib, executed at the same
level.
Two concrete implementations of the Protocol:
| Class | Module | Notes |
|---|---|---|
DataAcqBackend |
backend/dataacq.py |
Real — wraps capi (two DLLs). Windows-only at runtime. |
FakeDtolBackend |
backend/fake.py |
In-process simulator. Always available; powers all unit tests and Linux/macOS CI. |
The protocol name (DtolBackend) is package-prefixed; the real
implementation (DataAcqBackend) is named after the SDK it wraps so
that a hypothetical future replacement (e.g. a DT-Open-Layers v2 SDK
binding) would naturally be a sibling DataAcqV2Backend. The fake is
named after the Protocol (matching nidaqlib's FakeDaqBackend).
10.2 DtolBackend Protocol¶
class DtolBackend(Protocol):
# Lifecycle
def initialize(self, board_name: str) -> Any: ... # olDaInitialize → HDRVR
def terminate(self, hdrv: Any) -> None: ... # olDaTerminate
def get_dass(
self, hdrv: Any, subsys_type: SubsystemType, element: int
) -> Any: ... # olDaGetDASS
def release_dass(self, hdass: Any) -> None: ... # olDaReleaseDASS
def is_running(self, hdass: Any) -> bool: ... # olDaIsRunning
def get_state(self, hdass: Any) -> SubsystemState: ... # olDaGetSSState → §8.13
# Discovery
def enum_boards(self) -> tuple[BoardInfo, ...]: ... # olDaEnumBoardsEx
def enum_subsystems(self, hdrv: Any) -> tuple[SubsystemInfo, ...]: ...
def get_version(self) -> tuple[str, str]: ... # (olDaGetVersion, olDmGetVersion)
# Capability
def query_capabilities(self, hdass: Any) -> CapabilitySet: ...
# Composes olDaGetSSCaps / olDaGetSSCapsEx / olDaEnumSSCaps /
# olDaEnumChannelCaps into one snapshot. See §13.
# Configuration
def set_data_flow(self, hdass: Any, mode: int) -> None: ...
def set_stop_on_error(self, hdass: Any, stop: bool) -> None: ... # olDaSetStopOnError
def set_multi_sensor_type(
self, hdass: Any, physical_channel: int, io_type: IOType,
) -> None: ... # see §8.5a
def add_channel(self, hdass: Any, list_index: int, spec: ChannelSpec) -> None: ...
# Drives olDaSetChannelListEntry / olDaSetChannelType / olDaSetGainListEntry / ...
# Calls set_multi_sensor_type FIRST on MULTI_SENSOR channels (§8.5a).
def configure_timing(self, hdass: Any, timing: Timing) -> None: ...
def configure_trigger(self, hdass: Any, trigger: TriggerSpec) -> None: ...
def configure_logging(self, hdass: Any, logging: RawLogging) -> None: ...
def commit(self, hdass: Any) -> None: ... # olDaConfig
# Operation
def start(self, hdass: Any) -> None: ... # olDaStart
def stop(self, hdass: Any) -> None: ... # olDaStop
def abort(self, hdass: Any) -> None: ... # olDaAbort
def reset(self, hdass: Any) -> None: ... # olDaReset
def get_single_value(self, hdass: Any, channel: int, gain: float) -> int: ...
def get_single_values(self, hdass: Any, gain: float) -> np.ndarray: ...
def put_single_value(self, hdass: Any, channel: int, value: int, gain: float) -> None: ...
def code_to_volts(self, hdass: Any, code: int, gain: float) -> float: ...
# Buffer pool — owned by the backend, lifecycled with the subsystem
def create_buffer_pool(self, hdass: Any, plan: BufferPlan) -> BufferPool: ...
def free_buffer_pool(self, pool: BufferPool) -> None: ...
def read_done_buffer(self, hdass: Any) -> RawBuffer | None: ...
# Wraps olDaGetBuffer + olDmGetBufferPtr + olDmGetValidSamples.
# Dispatches on OLSSC_RETURNS_FLOATS: float-returning devices skip
# code-to-volts; int-returning devices apply per-channel scaling.
def requeue_buffer(self, hdass: Any, raw: RawBuffer) -> None: ...
def flush_buffers(self, hdass: Any) -> None: ...
def get_done_queue_size(self, hdass: Any) -> int: ... # olDaGetQueueSize
# Number of completed buffers on the Done queue waiting for drainage.
# Backs DtolSession.queued_buffer_dones — runtime back-pressure indicator.
def move_inprocess_buffer(self, hdass: Any) -> RawBuffer | None: ... # olDaCopyFromBuffer
# Snapshot of the currently-filling HBUF without waiting for it to
# complete. Returns None on zero valid samples. Requires
# CapabilitySet.supports_inprocess_flush(). Powers session.read_inprocess.
# Diagnostic register access (advanced — gated, rarely used)
def diag_read_reg(self, hdrv: Any, reg: int) -> int: ... # olDaReadDevReg
def diag_write_reg(self, hdrv: Any, reg: int, value: int) -> None: ... # olDaWriteDevReg
# Notification
def register_notification(
self,
hdass: Any,
callback: Callable[[SdkEventKind, int, int], None],
# (kind, wparam, lparam) - runs on DRIVER THREAD
) -> NotificationHandle: ...
def unregister_notification(self, hdass: Any, handle: NotificationHandle) -> None: ...
# Escape hatch
@property
def dll(self) -> Any: ... # raw oldaapi WinDLL
@property
def memdll(self) -> Any: ... # raw olmem WinDLL
10.3 The C-boundary stack — three layers¶
The hand-rolled C boundary is the new risk surface relative to nidaqlib
(which inherits a tested Python binding from nidaqmx-python). To make
the boundary testable in isolation, it splits into three layers:
capi/prototypes.py # raw ctypes signatures only — no error handling,
# no output-pointer extraction, no policy
↓
capi/api.py :: OpenLayersApi # one-call-per-method wrapper. Calls the prototype,
# extracts output pointers, calls the source-aware
# ECODE checker, raises typed DtolCapiError subclasses.
# No session state, no caching, no buffer pool.
↓
backend/dataacq.py :: DataAcqBackend # session-level orchestration. Holds
# capability cache, notification wrapper dict,
# buffer-pool helper. Drives the DtolBackend
# Protocol surface against OpenLayersApi.
OpenLayersApi exists because the prototype layer is too raw to test
(raw ECODE returns, output pointers as POINTER(HDASS) references)
and the backend is too concerned with session state. Splitting them
means:
OpenLayersApiis testable against a stub DLL, without a backend instance. The Windows binding-test lane exercises this layer directly: signature shape, output-pointer extraction, ECODE → exception classification, error-string DLL-source dispatch.DataAcqBackendis testable against a stubOpenLayersApi, without ctypes. The unit-test lane (cross-platform) exercises this layer: ordering invariants, capability cache, buffer-pool lifecycle, notification-wrapper GC pinning.
Example seam:
class OpenLayersApi:
def __init__(self, dlls: OpenLayersDlls) -> None:
self._oldaapi = dlls.oldaapi
self._olmem = dlls.olmem
# prototypes already declared on dlls at load time
...
def initialize(self, board_name: str) -> int:
hdrv = HDRVR()
status = self._oldaapi.olDaInitialize(board_name.encode("ascii"),
ctypes.byref(hdrv))
self._check(status, op="olDaInitialize", source="oldaapi",
board=board_name)
return hdrv.value
def get_dass(self, hdrv: int, subsys: int, element: int) -> int:
hdass = HDASS()
status = self._oldaapi.olDaGetDASS(hdrv, subsys, element,
ctypes.byref(hdass))
self._check(status, op="olDaGetDASS", source="oldaapi",
subsystem_type=subsys, element=element)
return hdass.value
# ... one method per olDa*/olDm* function used by the backend
10.4 DataAcqBackend (real implementation)¶
Lives in backend/dataacq.py. Drives the OpenLayersApi from §10.3
to satisfy the DtolBackend Protocol. Does NOT call ctypes directly
— every C call goes through OpenLayersApi.
Responsibilities:
- Construct via
capi.loader.load_openlayers()+OpenLayersApi(dlls)— 32-vs-64-bit detect viastruct.calcsize('P'). TwoWinDLLinstances; two env-var overrides (DTOLLIB_OLDAAPI_DLL,DTOLLIB_OLMEM_DLL). - Detect
OLSSC_RETURNS_FLOATSfor each subsystem atquery_capabilitiestime and cache it. The buffer pipeline branches on this — float-returning devices skip per-sample voltage conversion; int-returning devices apply per-channel scaling derived from range / gain / resolution / encoding. - Maintain
self._notification_wrappers: dict[int, WINFUNCTYPE]keyed byid(hdass)— the SDK stores callbacks as raw C function pointers and Python GC will silently break the seam otherwise (same hazard asnidaqlib§11.3.2). - Convert
HBUFdata viaOpenLayersApi.get_buffer_ptrinto anumpy.ndarraywithnumpy.ctypeslib.as_array(zero copy until the buffer is freed or recycled).
10.5 FakeDtolBackend (test backend)¶
Lives in backend/fake.py, re-exported from testing.py. Capabilities:
- Scripted boards and subsystems (with custom
CapabilitySets includingOLSSC_RETURNS_FLOATStoggling per subsystem so the float/int dispatch is testable). - Scripted scalar reads.
- Scripted block sequences (lists of
np.ndarraykeyed by task name). - Scripted SDK events of every kind in
SdkEventKind— tests firefake.fire_event(hdass, SdkEventKind.OVERRUN_ERROR)to exercise recorder error paths without hardware. - Simulated timeouts and
ECODEfailures (user injects codes). - Enforces the same ordering invariants the real SDK enforces:
- Register BEFORE start (rejects with synthetic error code).
- Unregister AFTER stop (rejects with synthetic error code).
olDmGetBufferPtron freedHBUFraises (catches use-after-free).- Operation log for assertions (
fake.operations: list[tuple[str, object]]).
11. The C Binding Layer (capi)¶
This module is the part that nidaqlib does not have. It is the single
place in the package where ctypes.WinDLL is touched. Internally it
splits into three layers (see §10.3): prototypes.py (raw ctypes
signatures), api.py (OpenLayersApi — output-pointer extraction +
typed exception wrapping), and the supporting loader.py / types.py
/ constants.py / callbacks.py / conversion.py / errors.py
modules described below.
11.1 Loader (capi/loader.py) — TWO DLLs¶
SDK install layout (reference — what the DataAcq SDK installer drops on disk).
On 64-bit Windows the SDK installer is a 32-bit installer, so the SDK
tree lands under Program Files (x86) regardless of OS bitness. The
manual (dasdk.md) claims 32-bit libs install to C:\Program Files\...
and 64-bit libs to C:\Program Files (x86)\... — that's misleading;
on modern 64-bit Windows both land under Program Files (x86). The
runtime DLLs themselves land in System32 / SysWOW64 per the usual
WOW64 redirection rules (and that is what capi/loader.py looks for —
nothing under Program Files is touched at load time).
| Location | Contents |
|---|---|
%SystemRoot%\System32\ (64-bit DLLs) / %SystemRoot%\SysWOW64\ (32-bit DLLs on 64-bit Windows) |
Runtime DLLs — oldaapi64.dll + olmem64.dll (or *32.dll). This is what capi/loader.py looks for. Verified present on a working DT9805 install. |
C:\Program Files (x86)\Data Translation\Win32\SDK\Include\ |
C headers — OLDAAPI.H, Olmem.h, OLERRORS.H, plus OLTYPES.H, OLDADEFS.H, OLDSPTCH.H, Oldacfg.h, GRAPHS.H, TedsApi.h, DtDeviceInterface.h, DtIoctl.h, DtRegKeys.h. Used only at dev time when transcribing prototypes/constants (§11.3, §11.4); not needed at runtime. |
C:\Program Files (x86)\Data Translation\Win32\SDK\Lib\ (32-bit) and ...\SDK\Lib64\ (64-bit) — both under the same root |
C import libraries — oldaapi32.lib / oldaapi64.lib / olmem32.lib / olmem64.lib plus .exp files. Not needed by dtollib at all (we use ctypes, not link-time binding). |
C:\Program Files (x86)\Data Translation\Win32\SDK\Examples\ |
C example programs (SvAdc, ContAdc, ThermoADC, IepContAdc, CExample, etc.). Reference material for the maintainer; cited from dasdk.md. |
Three consequences for this project:
dtollibships no DLLs and no headers. The user installs the DataAcq SDK separately; the runtime DLLs land inSystem32and that is what we load.- Headers are a maintainer-only convenience.
capi/prototypes.pyandcapi/constants.pyare hand-transcribed fromdasdk_digest.mdplus the on-disk headers when available.scripts/gen_openlayers.pyparses them only when a maintainer has the SDK installed locally — see Appendix D Q3 for the redistribution question. dtol-diag sdkis the user-facing surface for this layout. It reports the resolved DLL paths and, when DLLs cannot be found, hints at the expected SDK install location (§21.3). TheInclude\andLib\folders are not probed at runtime — their absence is irrelevant to acquisition.
Runtime split. The DataAcq SDK splits its API surface across two DLLs:
| DLL | Functions | Header |
|---|---|---|
oldaapi32.dll / oldaapi64.dll |
olDa* (acquisition) |
OLDAAPI.H |
olmem32.dll / olmem64.dll |
olDm* (buffer/memory) |
OLMEM.H |
Both must be loaded. They are separate WinDLL instances and each has
its own error-string function (olDaGetErrorString vs
olDmGetErrorString) — the classifier in capi/errors.py knows which
to call based on the function that returned the error.
@dataclass(frozen=True, slots=True)
class OpenLayersDlls:
oldaapi: ctypes.WinDLL
olmem: ctypes.WinDLL
oldaapi_path: Path
olmem_path: Path
bitness: int # 32 or 64
def load_openlayers(
*,
oldaapi_path: str | os.PathLike | None = None,
olmem_path: str | os.PathLike | None = None,
) -> OpenLayersDlls:
"""Locate and load both DataAcq SDK DLLs.
Resolution order (applied independently to each DLL):
1. Explicit path argument (if provided)
2. Environment variable:
DTOLLIB_OLDAAPI_DLL for oldaapi*.dll
DTOLLIB_OLMEM_DLL for olmem*.dll
3. Default install path for the current bitness:
64-bit: %SystemRoot%\\System32\\oldaapi64.dll
%SystemRoot%\\System32\\olmem64.dll
32-bit (on 64-bit Windows): %SystemRoot%\\SysWOW64\\oldaapi32.dll
%SystemRoot%\\SysWOW64\\olmem32.dll
4. ctypes.WinDLL("oldaapi64" / "olmem64") — relies on SDK installer
having added the DLLs to the system PATH.
Raises:
DtolDependencyError: a DLL was not found at any candidate location.
Message includes ALL candidate paths tried.
DtolDependencyError: bitness mismatch (e.g. 32-bit Python loading
64-bit DLL).
DtolDependencyError: platform is not Windows.
"""
Diagnostic surface: every successful load logs at INFO level with both
DLL paths and detected bitness. Failure messages include the full
candidate list — these are the messages the dtol-diag sdk CLI surfaces
(§21).
11.2 Opaque types (capi/types.py)¶
HDRVR = ctypes.c_void_p # device handle
HDASS = ctypes.c_void_p # subsystem handle
HBUF = ctypes.c_void_p # buffer handle
HLIST = ctypes.c_void_p # buffer list handle
HSSLIST = ctypes.c_void_p # simultaneous-start list handle
OLSTATUS = ctypes.c_ulong # SDK return code
ECODE = ctypes.c_ulong # error code (alias for OLSTATUS)
11.3 Prototypes (capi/prototypes.py)¶
Every olDa* / olDm* function used by the backend has its argtypes
and restype declared explicitly. Example:
def declarecapi(dll: ctypes.WinDLL) -> None:
dll.olDaInitialize.argtypes = [ctypes.c_char_p, ctypes.POINTER(HDRVR)]
dll.olDaInitialize.restype = ECODE
dll.olDaGetDASS.argtypes = [HDRVR, ctypes.c_uint, ctypes.c_uint, ctypes.POINTER(HDASS)]
dll.olDaGetDASS.restype = ECODE
dll.olDaConfig.argtypes = [HDASS]
dll.olDaConfig.restype = ECODE
dll.olDaSetWndHandle.argtypes = [HDASS, ctypes.wintypes.HWND, ctypes.wintypes.LPARAM]
dll.olDaSetWndHandle.restype = ECODE
# NOTE: the user-data (lParam) arg is LPARAM (pointer-sized), not c_long. On
# 64-bit Python with 64-bit oldaapi64.dll, c_long is 32-bit while the SDK
# expects LONG_PTR — passing c_long delivers a truncated user-data value to
# the window. olDaSetWndHandle is the buffer-done mechanism we actually bind;
# olDaSetNotificationProcedure is NOT bound (its OLNOTIFYPROC callback never
# fires on V7.0.0.7 — see §12.3.2).
# ... ~60 olDa* functions in v0.1; ~20 more in v0.2
def declare_olmem(dll: ctypes.WinDLL) -> None:
dll.olDmCallocBuffer.argtypes = [ctypes.c_uint, ctypes.c_uint, ULNG, UINT,
ctypes.POINTER(HBUF)]
dll.olDmCallocBuffer.restype = ECODE
# ... ~20 olDm* functions in v0.1
Two separate declaration functions because each WinDLL only knows its
own exports. The signatures live in functions (not at module-import time)
because they bind to specific WinDLL instances — important for
testability (swap in a ctypes.CDLL("./fake.dll") for unit tests of the
binding itself, separate from FakeDtolBackend).
11.4 Constants (capi/constants.py)¶
All SDK enums transcribed by hand. Single source of truth for
OL_DF_SINGLEVALUE, OL_DF_CONTINUOUS, OL_TRG_SOFT,
OL_TRG_THRESHPOS, OL_CLK_INTERNAL, OLSS_AD, OLSS_DA,
OLSSC_SUP_SINGLEVALUE, OLSSC_RETURNS_FLOATS, OLDA_WM_*, etc. Each
constant has a docstring naming the header that defines it.
Authoritative source: OLDADEFS.H (C, NOT OLDADEFS.bas)¶
%ProgramFiles(x86)%\Data Translation\Win32\SDK\Include\OLDADEFS.H
is the authoritative C header — every published example
(IepContAdc.c, ThermoADC.C, DtConsole.cpp) compiles against it
and the oldaapi64.dll exposes the same numeric values. The
older DTx-EZ\Include\OLDADEFS.bas (VB binding, dated 1997) uses
a different — much simpler — numbering (OL_DF_CONTINUOUS=0,
OL_WRP_MULTIPLE=1, etc.) and DOES NOT match the live DLL ABI.
Bench-confirmed on SDK V7.0.0.7 (docs/decisions.md, 2026-05-28):
the C-header values are correct, the VB-binding values are rejected
by the SDK with OLBADDATAFLOW / OLBADWRAPMODE / OLBADQUEUE.
Verified value families (see capi/constants.py for the full set):
OL_CHNT_*= 100, 101OL_ENC_*= 200, 201OL_TRG_*(legacy) = 300–306OL_CLK_*= 400, 401, 402OL_DF_*= 800–805OL_WRP_*= 1000, 1001, 1002OL_QUE_*= 1100, 1101, 1102OL_TRG_THRESHPOS / THRESHNEG / SYNCBUS= 1200, 1201, 1202OL_RETRIG_*= 1300, 1301, 1302OL_THERMOCOUPLE_TYPE_*= 1500–1508OLDA_WM_*=WM_USER + 100..114(= 0x464..0x472)
OLSSC_* values are zero-based sequential positions in the
olssc_tag enum, e.g. OLSSC_NUMCHANNELS=7,
OLSSC_SUP_CONTINUOUS=35, OLSSC_SUP_SINGLEVALUE=36,
OLSSCE_MAXTHROUGHPUT=61, OLSSC_RETURNS_FLOATS=116,
OLSSC_SUP_MULTISENSOR=143 — count from the OLDADEFS.H enum
declaration, never guess.
Verification status (per-row in docs/decisions.md) bench-confirms
each value family against olDaGetSSCaps / olDaSetDataFlow /
olDaSetWrapMode readbacks. Header sources:
OLDADEFS.H— the bulk of the constants, includingOL_DF_*,OL_TRG_*,OL_CLK_*,OLSS_*,OLSSC_*, and theOLDA_WM_*notification window-message codes.Oldacfg.h— definesSS_STATES(1000–1009) plus theDEVICE_CFG/DEVICE_CFG_EXstructs used by olDaConfig internally.OLERRORS.H— the error code constants (namedOL*— e.g.,OLNOERROR,OLBADCAP,OLBADELEMENT).OLMEM.H—olDm*prototypes and memory-manager constants.OLDAAPI.H—olDa*prototypes andOL_ENUM_*enumeration IDs (100–106). TheOLNOTIFYPROCtypedef lives here too, but we do not bindolDaSetNotificationProcedure— buffer-done events arrive viaolDaSetWndHandle+ a hidden message window instead (§12.3.2).
11.5 Callbacks (capi/callbacks.py + backend/_message_window.py)¶
capi/callbacks.py is mechanism-agnostic: it exports SdkEventKind
(the eleven OLDA_WM_* message kinds) and event_kind_from_message,
which the bridge uses to demultiplex an incoming message_id. It does
not define a SDK-callback function pointer — buffer-done events
arrive as Win32 window messages, not as a registered OLNOTIFYPROC.
The function pointer that matters is the WNDPROC, and it lives in
backend/_message_window.py (the single home of all user32 /
kernel32 calls — see §12.3.2):
_LRESULT = ctypes.c_ssize_t # LRESULT — pointer-sized signed
_WNDPROCTYPE = ctypes.WINFUNCTYPE(
_LRESULT, # return: LRESULT (DefWindowProcA result)
wintypes.HWND, # hWnd — which message window
ctypes.c_uint, # uMsg — OLDA_WM_BUFFER_DONE etc.
wintypes.WPARAM, # wParam — HDASS for buffer-done; pointer-sized
wintypes.LPARAM, # lParam — message-specific payload; pointer-sized
)
WINFUNCTYPE (not CFUNCTYPE) because the Win32 calling convention is
stdcall. A single process-wide _WNDPROC is pinned and shared across
all message windows; it dispatches by hWnd to the registered Python
callback, then returns DefWindowProcA for everything else.
wintypes.WPARAM / wintypes.LPARAM (not c_uint / c_long) because
on 64-bit Windows these types are UINT_PTR / LONG_PTR — both
pointer-sized (64-bit), whereas c_uint / c_long are 32-bit. The
practical failure mode on x64 is that the WNDPROC receives truncated
values with garbage in the high 32 bits — a switch (uMsg) over
OLDA_WM_* constants will branch unpredictably when wParam holds an
HDASS-typed value that's been truncated to 32 bits. Binding-test
assertion: "WPARAM/LPARAM round-trip correct values on 64-bit Python."
The x86 stdcall case (which we don't target) would additionally cause
caller/callee stack-cleanup mismatch; on x64 the caller cleans the
stack, so the failure is bad-data, not corrupted stack.
11.5a Optional SDK functions (absent in some builds)¶
The DataAcq SDK ships slightly different function sets across versions. Bench-confirmed missing on V7.0.0.7 (DT9805/DT9806):
olDaSetStopOnError(HDASS, BOOL)— informational only at the dtollib layer; the recorder'sErrorPolicyis the authoritative source of stop-on-overrun behaviour.OpenLayersApi.set_stop_on_erroris a no-op when the symbol is absent.olDaGetSSState(HDASS, ULNG*)— would return one of theSS_STATESenum codes (OL_STATE_DORMANT=1000,OL_STATE_RUNNING=1005, ...).DataAcqBackend.get_statederives state fromolDaIsRunning+ locally-tracked transitions when the SDK symbol is absent. The fake backend models the same derivation so the seam is covered by the §22 regression suite.
prototypes.py carries an OPTIONAL_OLDAAPI_FUNCTIONS tuple
documenting which symbols are bind-on-presence; new SDK revs that add
or remove functions update this tuple in the same commit that updates
docs/decisions.md.
11.6 Conversion (capi/conversion.py)¶
At the C boundary, samples come in two shapes depending on the device:
- Int devices (most older boards): raw counts; needs range / gain /
resolution / encoding to convert to volts. The wrapper uses a
vectorised NumPy formula on whole blocks (
olDaCodeToVoltsis correct but per-sample — too slow at 50 kS/s). - Float devices (declared via
OLSSC_RETURNS_FLOATS): samples are already engineering units (volts for AI voltage, °C for TC, etc.). Skip conversion entirely.
def detect_returns_floats(dll: ctypes.WinDLL, hdass: HDASS) -> bool:
"""Branch the buffer pipeline at backend construction, not per-block."""
def codes_to_volts_vectorised(
codes: np.ndarray, # shape (n_channels, n_samples), dtype int16/int32
*,
ranges: Sequence[tuple[float, float]], # per channel
gains: Sequence[float], # per channel
resolution_bits: int,
encoding: Encoding,
) -> np.ndarray: # shape (n_channels, n_samples), float64
"""Pure-NumPy whole-block conversion. olDaCodeToVolts is the oracle
used in unit tests to validate the formula on representative codes."""
def deinterleave_cjc(
raw: np.ndarray, # shape (n_channels * 2 * n_samples,) — flat HBUF view
*,
n_channels: int,
n_samples: int,
) -> tuple[np.ndarray, np.ndarray]: # (measurement, cjc); each (n_channels, n_samples)
"""Split a CJC-interleaved buffer into measurement + CJC streams.
When olDaSetReturnCjcTemperatureInStream is enabled, every scan
contains TWO values per channel: [ch0_value, ch0_cjc, ch1_value,
ch1_cjc, ...]. The raw HBUF therefore has n_channels * 2 * n_samples
elements; the deinterleaver reshapes it into (n_samples, n_channels, 2),
then splits axis -1 into measurement and CJC, and transposes back to
(n_channels, n_samples). See §13.2 for capability gating."""
def detect_thermocouple_sentinel(
values: np.ndarray, # float64 from olDaGetSingleFloat / float buffer
*,
tc_type: ThermocoupleType,
) -> np.ndarray: # int8 mask of SensorStatus ordinals
"""Recognise SDK sentinel floats and map them to SensorStatus values.
The SDK reports SENSOR_IS_OPEN, TEMP_OUT_OF_RANGE_LOW, and
TEMP_OUT_OF_RANGE_HIGH as documented magic floats. The backend
calls this at the C boundary so sentinels never reach the public
DaqReading/DaqBlock as plausible-looking temperatures (see §13.1)."""
11.7 Why hand-transcribe instead of auto-generating¶
cffi API mode would parse OLDAAPI.H / OLMEM.H automatically, but it
would force every user to have the SDK headers installed at install time.
With ctypes we only need the DLLs at runtime; the binding is plain
.py files that ship in the wheel. The tradeoff is real but small: ~80
function signatures and ~200 constants are easy to maintain and changes
to the SDK are extremely rare (the 25th edition of the manual is from
2015).
Hand-curated is the source of truth. A contributor-facing helper
script — scripts/gen_openlayers.py — parses installed headers (when
available) and prints diffs against the transcribed prototypes and
constants. CI runs the diff as a non-blocking check when SDK headers
are mounted on a self-hosted Windows runner; the output is informational
and lands in the action log so we notice when the SDK adds a function we
might want to bind. We do not auto-overwrite the hand-curated files
— curation buys us docstrings, header attribution, and the ability to
ignore deprecated symbols, all of which a generator would erase.
12. Async Strategy¶
The SDK is synchronous. The async API does not pretend otherwise.
12.0 Threading vocabulary¶
Three threads matter in this design. The rest of §12 (and the ordering-invariants table in §12.3.2, and the RawCountsSink discussion in §15.2) uses these names consistently:
- Asyncio thread. Runs the event loop. All user
awaitpoints, all AnyIO memory-object-stream sends, allrecord() async foriteration happen here. Never makes blocking SDK calls directly — always viaanyio.to_thread.run_sync. - Pump thread. A dedicated
threading.Thread(one per HDASS) that creates and owns the hiddenHWND_MESSAGEwindow and runsGetMessageA/DispatchMessageA. The SDK postsOLDA_WM_*window messages to that window;DispatchMessageAinvokes the pinnedWNDPROCon this thread. (This replaces the old "driver thread" that a registeredOLNOTIFYPROCwould have run on — that callback never fires on V7.0.0.7, §12.3.2.) No event loop, no AnyIO context, no asyncio primitives. The only safe operation here is aqueue.SimpleQueue.put_nowaitof a tiny event tuple (message ID + monotonic timestamp). Allocating arrays, callingolDaGetBuffer, or writing files in this thread is a bug. - Drainer thread (a.k.a. worker thread). A long-lived
anyioworker spun up byrecord(). Pulls events from the pump-thread queue, callsolDaGetBuffer/olDmGetBufferPtr, copies data into anumpy.ndarray, requeues theHBUFviaolDaPutBuffer, optionally writes raw counts to disk viaRawCountsSink, and forwards theDaqBlockinto the user-facing async stream. This is where every unit of work between "buffer-done callback fired" and "DaqBlock is in the consumer'sasync for" lives.
The cardinal rule: the pump-thread WNDPROC only signals; the drainer thread does the work. Any time §12, §14, or §15.2 says "writes from the drainer thread" or "from the callback," it means those threads specifically — not the asyncio thread.
12.1 Worker threads at coarse boundaries¶
Good:
block_ndarray = await anyio.to_thread.run_sync(
backend.read_block, # internally: pulls one HBUF from done queue, copies
hdass,
samples_per_channel,
timeout,
)
Bad:
for i in range(samples_per_channel):
sample = await anyio.to_thread.run_sync(backend.get_single_value, hdass, ch, gain)
12.2 Session locking¶
All HDASS operations serialise on a per-session anyio.Lock. The SDK is
thread-safe per-HDRVR but not per-HDASS. The lock is the seam.
12.3 Two paths for continuous acquisition¶
Same dispatch as nidaqlib:
TaskSpec.timing |
Recorder | Emits |
|---|---|---|
None / DataFlow.ON_DEMAND |
record_polled |
DaqReading |
DataFlow.CONTINUOUS / FINITE |
record |
DaqBlock |
12.3.1 Software-timed (low-rate)¶
For thermocouple acquisition ≤100 Hz typical of the DT9805 multi-sensor
inputs, software polling against the SDK's single-value path is fine.
Same absolute-target scheduling pattern as
alicatlib.streaming.recorder — compute target[n] = start + n * dt;
anyio.sleep_until(target[n]); to_thread.run_sync(session.poll); emit.
12.3.2 Hardware-timed (high-rate) — the bridge¶
This is the §11.3.2 analogue. The DataAcq SDK only delivers buffer-done events via:
- Window messages (
olDaSetWndHandle) — needs a Win32 message pump. - Notification procedure (
olDaSetNotificationProcedure) — C callback invoked on a driver-managed thread.
We use option 1 (window-handle + message pump) — implemented in
src/. Bench-verified against SDK V7.0.0.7 on DT9805/DT9806
(2026-05-28, docs/decisions.md): the SDK accepts
olDaSetNotificationProcedure with ec=0 but the OLNOTIFYPROC
callback never fires — neither when the calling thread runs a
GetMessage pump nor when it blocks idle. Option 2 is therefore not
bound at all. Every SDK sample under
%ProgramFiles(x86)%\Data Translation\Win32\SDK\Examples uses
olDaSetWndHandle even in console apps (DtConsole.cpp creates a
hidden HWND_MESSAGE window specifically for this).
The mechanism also has two companion requirements, both bench-proven and
implemented: olDaConfig is called twice (once after channel/timing
setup, then again after olDaSetWndHandle — the second config wires the
HWND into the SDK's buffer-rotation state machine; modeled as the
backend's commit() then arm()), and olDaSetDmaUsage(min(1, N)) is
called even when NUMDMACHANS == 0.
The bridge:
SDK driver ── PostMessage(OLDA_WM_*) ──> hidden HWND_MESSAGE window
│
dedicated message-pump thread:
while GetMessage / DispatchMessage:
WNDPROC(uiMsg) → queue.SimpleQueue
│
anyio.to_thread.run_sync(q.get)
│
anyio.MemoryObjectStream
│
async for block in stream:
The dtollib API surface (register_notification /
unregister_notification on DataAcqBackend, the SdkEventKind
enum) stays unchanged, and so does the _callback_bridge.py contract
(callback(msg_id, wparam, lparam) -> int). All Win32 machinery lives
in backend/_message_window.py (the sole home of user32 /
kernel32 calls) and is driven from DataAcqBackend.register_notification
— _callback_bridge.py stays mechanism-agnostic and FakeDtolBackend
is untouched (it already matches the seam). A per-HDASS MessageWindow
owns the hidden window + pump thread, spawned at register time and
joined (PostThreadMessageA WM_QUIT → join → DestroyWindow) at
unregister.
Same shape as the NI bridge, with three differences:
- The WNDPROC only signals "buffer done" — it does NOT carry the
data. We must call
olDaGetBufferfrom a worker thread to pull the HBUF, thenolDmGetBufferPtrto get the data, thenolDaPutBufferto recycle it. The WNDPROC hands the message kind to the queue; the drainer does the buffer-pool work on a worker thread. - The pool must be primed — at least 3 buffers, ideally 4–8. With
only 1 or 2, an OVERRUN is guaranteed because the SDK has nothing to
fill while the consumer is draining.
BufferPlan.buffers(§8.7a) defaults to 4 and enforces a minimum of 3 (matches the SDK's own continuous-AI sample atdasdk_digest.md:906-918, which allocates 3). - Ten distinct event types can arrive on the same callback — see the
full
SdkEventKindenum below. OnlyBUFFER_DONEis the happy path;OVERRUN_ERROR/UNDERRUN_ERROR/TRIGGER_ERRORraise (or, underErrorPolicy.RETURN, emit error blocks);BUFFER_REUSEDis logged loudly because it means data was overwritten inWrapMode.MULTIPLE;QUEUE_DONE/QUEUE_STOPPED/IO_COMPLETEsignal end-of-run;PRETRIGGER_BUFFER_DONE/EVENT_DONE/MEASURE_DONEare subsystem-specific and routed to dedicated handlers.
class SdkEventKind(StrEnum):
BUFFER_DONE = "buffer_done" # OLDA_WM_BUFFER_DONE
PRETRIGGER_BUFFER_DONE = "pretrigger_buffer_done" # OLDA_WM_PRETRIGGER_BUFFER_DONE
BUFFER_REUSED = "buffer_reused" # OLDA_WM_BUFFER_REUSED
QUEUE_DONE = "queue_done" # OLDA_WM_QUEUE_DONE
QUEUE_STOPPED = "queue_stopped" # OLDA_WM_QUEUE_STOPPED
IO_COMPLETE = "io_complete" # OLDA_WM_IO_COMPLETE
EVENT_DONE = "event_done" # OLDA_WM_EVENT_DONE
MEASURE_DONE = "measure_done" # OLDA_WM_MEASURE_DONE
TRIGGER_ERROR = "trigger_error" # OLDA_WM_TRIGGER_ERROR
OVERRUN_ERROR = "overrun_error" # OLDA_WM_OVERRUN_ERROR
UNDERRUN_ERROR = "underrun_error" # OLDA_WM_UNDERRUN_ERROR (D/A path)
Startup ordering (mandatory)¶
Per the SDK sample code (ThermoADC.C, IepContAdc.c,
DtConsole.cpp), the canonical startup sequence has TWO calls to
olDaConfig and requires olDaSetDmaUsage even when DMA is
unsupported. Bench-verified 2026-05-28: omitting either trips the
"buffers stuck in Inprocess forever" failure. The _callback_bridge
enforces this six-step prepare/commit split:
# 1. Allocate HDASS. Configure channels/clock/trigger/wrap mode/etc.
# set_dma_usage(min(1, NUMDMACHANS)) is MANDATORY even when
# NUMDMACHANS == 0. Stops short of olDaConfig.
# Also creates the BufferPool from spec.buffers.
await session.prepare()
# 2. First olDaConfig — commits the channel/clock/trigger state.
await session.commit_initial()
# 3. Queue all pool buffers to the SDK Ready queue.
session.pool.queue_all() # olDaPutBuffer × N
# 4. Create the hidden HWND_MESSAGE window and bind it via
# olDaSetWndHandle. ``register_notification`` does both, plus
# starts the message-pump thread.
handle = backend.register_notification(hdass, _on_notify)
# 5. Second olDaConfig — re-arms the SDK's internal buffer-rotation
# state machine with the window-handle in place. Without this
# second call, OLDA_WM_BUFFER_DONE never fires.
await session.commit()
# 6. olDaStart. First message lands shortly after.
await session.start()
record(session) validates session.is_prepared and not session.is_committed,
then runs steps 2–5 internally. The BufferPlan on TaskSpec.buffers is
the only place buffer-pool sizing is configured — recorder calls do not
take a buffer-count parameter.
Why this ordering matters: with notification + Ready buffers in place
before olDaConfig, the SDK transitions cleanly from configured → ready
→ running on olDaStart, and the first OLDA_WM_BUFFER_DONE lands on a
fully-initialised callback bridge. Inverting the order (config first,
then register/queue) is permissive on some devices but races on others
— the digest text walks through it that way, but the sample code does
not, and SDK sample code is the more reliable signal.
Shutdown ordering (mandatory)¶
anyio.to_thread.run_sync does not propagate cancellation into the
worker thread. The drainer awaiting queue.get() is not interrupted by
recorder exit — same hazard as nidaqlib. The recorder's __aexit__
runs (shielded from cancellation):
# 1. Stop the SDK subsystem. After this, in-flight callbacks have completed
# and no new ones fire. (olDaStop is the orderly path; for emergency
# shutdown, olDaAbort is the same place in the ordering.)
await session.stop()
# 2. Unregister the notification. SDK accepts this once stopped.
backend.unregister_notification(hdass, handle)
# 3. Wake the drainer with a sentinel.
chunk_q.put_nowait(_SENTINEL)
# 4. Wait for the drainer to exit cleanly.
await drain_done.wait()
# 5. Flush buffers, free pool, release HDASS, terminate HDRVR.
# Runs in DtolSession.close() inside open_device's __aexit__.
Ordering invariants:
| Ordering requirement | Why |
|---|---|
| Register BEFORE commit | SDK sample code at dasdk_digest.md:906-918 registers notification (and queues buffers) before olDaConfig. Registering after olDaConfig races the first OLDA_WM_BUFFER_DONE against an uninitialised callback bridge. The fake backend enforces this. |
| Queue BEFORE commit | Same SDK sample code shows olDaPutBuffer × N before olDaConfig. Without buffers on the Ready queue at commit time, olDaStart has nothing to fill and the first callback either never fires or fires with a sentinel HBUF. |
| Commit BEFORE start | olDaStart requires olDaConfig to have run; SDK returns a state-machine error otherwise. |
| Stop BEFORE unregister | Otherwise an in-flight callback can race with the unregister and fire after the wrapper is gone — segfault risk because the CFUNCTYPE reference may be GC'd. |
| Unregister BEFORE sentinel | After unregister the SDK cannot fire a callback that races with the sentinel and orphans an HBUF behind it in the queue. |
| Sentinel BEFORE drain-wait | The drainer is parked in chunk_q.get(); only the sentinel wakes it. |
| Drain-wait BEFORE close | The drainer holds the strong reference to the HBUF; freeing while it's mid-iteration is unsafe. |
Rules for the callback body¶
- No
anyio.*calls from the callback. It runs on the pump thread (which dispatches the WNDPROC) with no event loop context. Usequeue.SimpleQueue(thread-safe, no asyncio dependency). - The callback must be short. Don't call
olDaGetBufferfrom inside the callback — defer to the drainer on a worker thread. The callback just posts the message ID. - Keep a strong reference to the CFUNCTYPE wrapper for the lifetime
of the subsystem. The SDK stores a raw C function pointer; Python GC
will silently break the seam otherwise. The backend keeps it in
self._notification_wrapperskeyed byid(hdass).
13. Acquisition Modes¶
13.1 Single-value (DataFlow.SINGLE_VALUE)¶
Setup:
olDaSetDataFlow(hdass, OL_DF_SINGLEVALUE)
olDaSetChannelType(...) # per channel
olDaSetChannelRange(...)
olDaConfig(hdass)
Per call — the backend branches on OLSSC_RETURNS_FLOATS cached at
backend construction:
# Float-returning device (DT9805/DT9806 multi-sensor inputs):
olDaGetSingleFloat(hdass, &value, channel, gain)
# → value is already in engineering units (volts, °C, etc.); no conversion.
# Int-returning device (older voltage-only boards):
olDaGetSingleValue(hdass, &code, channel, gain)
# → convert via capi.conversion.codes_to_volts_vectorised on the scalar.
For simultaneous-sampling subsystems (DT9805 declares
OLSSC_SUP_SIMULTANEOUS_SH), olDaGetSingleValues /
olDaGetSingleFloats reads all channels in one call. The backend
prefers these when the cap is present.
Thermocouple linearisation: hardware-side vs application-side¶
We honour two linearisation paths, selected by capability:
- Application-linearised (DT9805/DT9806 — the v0.1 happy path,
bench-verified 2026-05-28): the device returns raw codes
(
OLSSC_RETURNS_FLOATS = 0) and exposes only a CJC channel (OLSSC_SUP_THERMOCOUPLES = 1). The wrapper reads the differential thermo-emf plus the CJC sensor on channel 0 and applies NIST ITS-90 polynomials (utils.convert_volts_to_temperature, Types K and J today). See §8.5a for the full call sequence. There is noOLSSC_SUP_LINEARIZE_TCcapability in this SDK build; the path is gated onsupports_thermocouples and not returns_floats. - Hardware-linearised (hypothetical firmware-linearising boards): the
device reports temperature directly via
olDaGetSingleFloat, gated onOLSSC_RETURNS_FLOATS = 1. No currently-owned board uses this path (the DT9805/06 do not), but it is kept for generality and covered in tests via a synthetic capability set (make_firmware_tc_capabilities).
A ThermocoupleInput on a subsystem that is neither
(not returns_floats and not supports_thermocouples) raises
DtolCapabilityError at configuration time, as does an unimplemented TC
type (anything but K/J today) on the application-side path.
Thermocouple sentinel values — preserve, never coerce¶
DT-Open Layers thermocouple readings can report three sentinel conditions in place of a temperature value:
SENSOR_IS_OPEN— TC wire is broken / not connected.TEMP_OUT_OF_RANGE_LOW— sensor below the TC type's valid range.TEMP_OUT_OF_RANGE_HIGH— sensor above the TC type's valid range.
These are data conditions, not numeric measurements. The wrapper
must NOT coerce them into plausible-looking temperatures (e.g.
SENSOR_IS_OPEN rendered as 0.0 °C). Instead:
class SensorStatus(StrEnum):
OK = "ok"
SENSOR_OPEN = "sensor_open"
TEMP_OUT_OF_RANGE_LOW = "temp_out_of_range_low"
TEMP_OUT_OF_RANGE_HIGH = "temp_out_of_range_high"
Single-value path (DaqReading): the sensor_status overlay
(Mapping[str, SensorStatus], keyed by channel display name) records
the condition; the corresponding entry in values is float("nan")
so a downstream consumer that ignores sensor_status still sees the
gap rather than a fake number.
Continuous path (DaqBlock): sensor_status is Mapping[str, np.ndarray]
— one int8 mask per channel, same length as samples_per_channel,
encoded with SensorStatus ordinals. The matching positions in data
are NaN-filled.
Sinks (CSV, JSONL, SQLite, Parquet, Postgres) propagate the sentinel
mask alongside the value column. RawCountsSink writes the raw int16
codes regardless; the sentinel decoding is part of replay-time
conversion against the channel's TC type.
Detected via olDaGetSingleFloat returning the SDK's documented
sentinel float values; the backend recognises them at the boundary and
populates sensor_status rather than passing the magic float through.
13.2 Continuous (DataFlow.CONTINUOUS)¶
Setup:
olDaSetDataFlow(hdass, OL_DF_CONTINUOUS)
olDaSetChannelListSize / olDaSetChannelListEntry # one per channel
olDaSetClockSource(OL_CLK_INTERNAL)
olDaSetClockFrequency(rate_hz)
olDaSetTrigger(OL_TRG_SOFT) # or external/threshold per spec
olDaSetWrapMode(OL_WRP_MULTIPLE) # continuous reuse
olDaSetDmaUsage(min(1, NUMDMACHANS)) # MANDATORY even when NUMDMACHANS=0
olDaConfig(hdass) # first config
# ... allocate + queue buffers, register notification (sets WndHandle) ...
olDaConfig(hdass) # second config — re-arms with WndHandle
Then the §12.3.2 bridge runs (with the prepare/commit split — first
config commits channel/timing state; buffers + window-handle land;
second config re-arms with the WndHandle so OLDA_WM_BUFFER_DONE
actually fires). Both config calls AND olDaSetDmaUsage are
bench-verified mandatory on SDK V7.0.0.7 (DT9805/DT9806) — see
ThermoADC.C and IepContAdc.c for the canonical order.
CJC-interleaved buffer shape (continuous TC path)¶
When olDaSetReturnCjcTemperatureInStream(hdass, TRUE) is enabled on a
subsystem that supports OLSSC_SUP_INTERLEAVED_CJC_IN_STREAM, every TC
channel in the channel/gain list produces TWO samples per scan: the
measurement value followed by the CJC value. The SDK buffer therefore
has shape:
HBUF data layout (interleaved CJC):
n_channels * 2 * samples_per_buffer (in elements)
Per-scan ordering (n_channels=2, both TC with CJC interleave):
[ch0_value, ch0_cjc, ch1_value, ch1_cjc, ch0_value, ch0_cjc, ...]
The deinterleaver in capi/conversion.py must split the measurement
stream from the CJC stream BEFORE constructing the public DaqBlock.
The resulting DaqBlock.data has shape (n_channels, samples_per_buffer)
in measurement values; CJC values are attached as a sibling field
(DaqBlock.cjc_data: np.ndarray | None, shape (n_channels,
samples_per_buffer)) for diagnostics and replay-time validation.
Forgetting the ×2 width is a class of bug that produces alternating "value, cjc, value, cjc" stripes in the recorded data — sample ordering looks plausible per-channel but every reading is wrong by the CJC delta. The deinterleaver has a unit test that asserts a synthetic buffer with known interleave pattern round-trips correctly.
Capability gate: the wrapper enables interleaved CJC only when both
OLSSC_SUP_TEMPERATURE_DATA_IN_STREAM and
OLSSC_SUP_INTERLEAVED_CJC_IN_STREAM are present in the subsystem's
CapabilitySet. Without the latter, CJC is read out-of-band via
olDaGetCjcTemperature and applied at conversion time.
13.3 Finite¶
FINITE is CONTINUOUS with OL_WRP_NONE (linear, no reuse) and a
samples_per_channel ceiling on the TaskSpec.timing. The recorder
counts samples emitted and calls session.stop() when the ceiling is
reached, then drains the done queue.
13.4 Triggered scan (post-v0.1)¶
RetriggerSpec enables olDaSetTriggeredScanUsage(1) and configures
mode + frequency + source. The SDK doc recommends OL_RETRIG_EXTRA over
OL_RETRIG_SCANPERTRIG for jitter-free retrigger — the wrapper defaults
to EXTRA when both are supported.
13.5 Simultaneous start (post-v0.1)¶
For multi-subsystem coordination (e.g., AI + counter), DtolManager
exposes start_synchronized(names) which runs the SDK's three-step
hardware-coordinated start:
olDaGetSSList(hdrv, &hsslist)
for hdass in subsystems:
olDaPutDassToSSList(hsslist, hdass)
olDaSimultaneousPreStart(hsslist) # arms all subsystems, releases on next trigger
olDaSimultaneousStart(hsslist) # fires the shared start trigger
# ... acquisition runs in each session normally ...
olDaReleaseSSList(hsslist) # in __aexit__
The pre-start step is mandatory and easy to miss — it arms every
subsystem on the list to wait for the simultaneous trigger; without it,
each olDaStart would happen independently. Defers to v0.2 / v0.3 since
it requires the DT9806 D/A or C/T subsystem.
13.6 CapabilitySet — runtime capability snapshot¶
Capabilities are queried at session construction (after olDaGetDASS,
before any configuration) and cached on the session. The model is
deliberately dict-shaped so new SDK caps don't require model changes:
@dataclass(frozen=True, slots=True, kw_only=True)
class CapabilitySet:
int_caps: Mapping[str, int] # OLSSC_* boolean / count caps
float_caps: Mapping[str, float] # OLSSCE_* throughput / frequency caps
enum_values: Mapping[str, tuple[int | float, ...]]
# discrete values per cap (ranges, gains, ...)
channel_caps: Mapping[int, Mapping[str, int]]
# per-channel cap overrides (some channels
# support TC, others don't, on some boards)
# Well-known accessor helpers — single source of truth for cap name spelling.
def supports_continuous(self) -> bool: return bool(self.int_caps.get("OLSSC_SUP_CONTINUOUS", 0))
def supports_simultaneous(self) -> bool: return bool(self.int_caps.get("OLSSC_SUP_SIMULTANEOUS_SH", 0))
def returns_floats(self) -> bool: return bool(self.int_caps.get("OLSSC_RETURNS_FLOATS", 0))
def hardware_linearises_tc(self) -> bool: return bool(self.int_caps.get("OLSSC_SUP_LINEARIZE_TC", 0))
def supports_inprocess_flush(self) -> bool: return bool(self.int_caps.get("OLSSC_SUP_INPROCESSFLUSH", 0))
def supports_multi_sensor(self) -> bool: return bool(self.int_caps.get("OLSSC_SUP_MULTI_SENSOR", 0))
def supports_synchronous_buffer_done(self) -> bool:
return bool(self.int_caps.get("OLSSC_SUP_SYNCBUFFERDONE", 0))
def max_throughput_hz(self) -> float: return self.float_caps.get("OLSSCE_MAX_THROUGHPUT", float("inf"))
def available_ranges(self) -> tuple[tuple[float, float], ...]:
return tuple(self.enum_values.get("OL_ENUM_RANGES", ())) # type: ignore
def channel_supports_tc(self, ch: int) -> bool:
return bool(self.channel_caps.get(ch, {}).get("OLSSC_SUP_LINEARIZE_TC", 0))
def io_type_for_channel(self, ch: int) -> IOType:
"""Per-channel IOType (§8.15). VOLTAGE_IN by default; MULTI_SENSOR
on DT9805-style multi-sensor channels. Drives the channel-spec ↔
IOType validation (cannot configure a VOLTAGE_IN-only channel as
a thermocouple)."""
raw = self.channel_caps.get(ch, {}).get("OLSSC_IO_TYPE", 0)
return _IO_TYPE_FROM_OLSSC.get(raw, IOType.VOLTAGE_IN)
Population is via four SDK calls, all wrapped by query_capabilities:
| SDK call | Populates |
|---|---|
olDaGetSSCaps |
int_caps |
olDaGetSSCapsEx |
float_caps |
olDaEnumSSCaps |
enum_values |
olDaEnumChannelCaps |
channel_caps |
Validation (during session.configure()) reads the cached snapshot —
zero extra SDK calls. If the user requests a config combination not
supported by the cap snapshot, the wrapper raises
DtolCapabilityError before olDaConfig runs. The SDK remains the
final authority — if olDaConfig rejects despite a passing preflight,
the SDK error is wrapped and surfaced with full context.
14. Recorder Design¶
14.1 Recorder dispatch¶
@asynccontextmanager
async def record_polled(
source: DtolSession | DtolManager,
*,
rate_hz: float,
error_policy: ErrorPolicy = ErrorPolicy.RAISE,
overflow: OverflowPolicy = OverflowPolicy.BLOCK,
) -> AsyncIterator[tuple[AsyncIterator[DaqReading], AcquisitionSummary]]:
"""Software-timed scalar polling. Mirrors alicatlib's record() exactly."""
@asynccontextmanager
async def record(
source: DtolSession,
*,
timeout: float = 10.0,
stream_buffer_size: int = 16,
error_policy: ErrorPolicy = ErrorPolicy.RAISE,
overflow: OverflowPolicy = OverflowPolicy.DROP_OLDEST,
) -> AsyncIterator[tuple[AsyncIterator[DaqBlock], AcquisitionSummary]]:
"""Hardware-clocked block acquisition. Drives the §12.3.2 bridge.
Chunk size and pool size come from `source.spec.buffers` (BufferPlan).
`stream_buffer_size` is the AnyIO MemoryObjectStream size — distinct
from the SDK buffer pool; this is the async-side back-pressure window.
"""
AcquisitionSummary mirrors nidaqlib.AcquisitionSummary:
@dataclass(frozen=True, slots=True)
class AcquisitionSummary:
blocks_emitted: int # readings_emitted for record_polled
blocks_dropped: int # > 0 only under DROP_* overflow
errors_observed: int # bumped on every wrapped SDK error
overruns_observed: int # OLDA_WM_OVERRUN_ERROR count
started_at: datetime
finished_at: datetime
The new field over nidaqlib is overruns_observed — the SDK has a
hard distinction between "consumer dropped a block" (DROP_OLDEST policy
applied) and "the SDK overran its own queue" (OLDA_WM_OVERRUN_ERROR).
Different root causes, so we count them separately.
14.2 Recorder invariants¶
Same as nidaqlib §13.2:
- Task started on recorder entry if not already running.
- Task stopped on exit if (and only if) the recorder started it.
- A producer task reads blocks (or, for the §12.3.2 path, drains the notification queue).
- Backpressure policy is explicit.
- Each block/reading includes timing metadata.
- The producer never silently drops blocks unless configured to do so.
Plus one dtollib-specific invariant:
RawLoggingis detected at recorder entry. IfRawLoggingis configured, theRawCountsSinkis attached automatically as a passive observer; the recorder still emitsDaqBlocks for the user-facing stream. The raw-counts path runs from the drainer thread without consumer back-pressure, so even if the async consumer blocks, the raw-counts file keeps growing.
14.3 Error policy semantics¶
There are two independent error knobs that govern continuous-mode error handling. They sit at different layers and answer different questions:
| Knob | Layer | Question it answers |
|---|---|---|
TaskSpec.stop_on_error |
SDK (olDaSetStopOnError) |
On OVERRUN_ERROR / UNDERRUN_ERROR, does the subsystem itself halt, or keep filling buffers? |
record(... error_policy) |
Wrapper recorder loop | When a wrapped DtolError reaches the Python producer loop, does the wrapper raise, emit an error-record, or log-and-continue? |
Borrowed from .NET's AnalogInputSubsystem.StopOnError (UMOpenLayers.md:917)
vs. the wrapper-side ErrorPolicy. Conflating them is the classic
"my recorder caught the OVERRUN but the SDK kept producing buffers anyway,
so I missed which buffer was bad" footgun.
Recommended pairings:
| Use case | stop_on_error |
error_policy |
|---|---|---|
| Unattended automation, errors are run-fatal | True (default) |
RAISE (default) |
| Long-running soak, isolated OVERRUNs are tolerable | False |
LOG_AND_CONTINUE |
| Downstream analysis pipeline wants gap markers in the data | False |
RETURN |
The recorder's error_policy parameter governs what happens when a
wrapped SDK error reaches the producer loop. Three values, all from the
shared ecosystem enum (ErrorPolicy.LOG_AND_CONTINUE is the dtollib
addition):
class ErrorPolicy(StrEnum):
RAISE = "raise" # cancel task group, propagate
RETURN = "return" # emit error record (.error set, .data zero-filled)
LOG_AND_CONTINUE = "log_and_continue" # log at WARNING, drop the offending block, keep running
RAISE(default) — cancel the recorder task group and propagate the typedDtolError. Correct for unattended scripts where any error is run-fatal.RETURN— emit aDaqReading/DaqBlockwith.errorset;.datais a zero-filled array of the expected shape; consumers MUST gate onerror is Nonebefore usingdata. Correct for downstream pipelines that want to record gaps in the data stream alongside the data.LOG_AND_CONTINUE— log the error at WARNING and skip the offending block; no record is emitted for that interval. Correct for long-running unattended acquisition services where occasional transient errors (OVERRUN under brief consumer-side hiccups) should not bring the run down but also shouldn't pollute the recorded data with zero blocks.
Under RAISE and LOG_AND_CONTINUE, the .error field on emitted
records is always None. The AcquisitionSummary.errors_observed
counter increments under all three policies — silent loss is never the
answer; the summary is the record.
14.4 Overflow policies — consumer-side back-pressure¶
Separate concern from ErrorPolicy. Where ErrorPolicy governs what
to do when the SDK reports an error, OverflowPolicy governs what
to do when the async send-stream to the consumer is full:
class OverflowPolicy(StrEnum):
BLOCK = "block" # producer awaits consumer; preserves every block
DROP_NEWEST = "drop_newest" # drop about-to-be-enqueued block; bounds latency
DROP_OLDEST = "drop_oldest" # evict oldest queued block; keeps newest data
Defaults:
| Recorder | Default | Rationale |
|---|---|---|
record_polled |
BLOCK |
Same as siblings. |
record |
DROP_OLDEST |
Same reason as nidaqlib: a blocked producer leaks into a buffer-pool exhaustion, which surfaces as OLDA_WM_OVERRUN_ERROR minutes later. DROP_OLDEST keeps the SDK queue moving. |
For high-rate durable logging, attach RawCountsSink in addition to the
streaming sink — raw-counts writes from the drainer thread and are not
subject to consumer back-pressure (this is the RawLogging-equivalent of
NI's TDMS).
15. Sink Design¶
15.1 Four Sink Protocols, two pipe drivers¶
Three Protocols port directly from nidaqlib §14.1 — ReadingSink,
SampleSink, BlockSink — driven by pipe() (for readings/samples)
and pipe_blocks() (for blocks). The fourth Protocol, RawBlockSink,
is dtollib-specific and exists because the raw-counts path carries a
fundamentally different payload (a RawBuffer of bytes from
olDmGetBufferPtr plus sidecar metadata) than the converted
DaqBlock. Typing them as distinct Protocols prevents accidental
miswiring — a RawCountsSink cannot satisfy a BlockSink parameter
slot, and a ParquetSink cannot satisfy a RawBlockSink slot.
class BlockSink(Protocol):
async def write(self, block: DaqBlock) -> None: ...
class RawBlockSink(Protocol):
async def write_raw(self, raw: RawBuffer) -> None: ...
# raw.payload is bytes from olDmGetBufferPtr — not unit-converted.
# raw.header is the JSON header written once at sink-open time.
The fan-out inside record() checks each attached sink's Protocol at
attach time and routes accordingly. A sink may implement both
BlockSink and RawBlockSink (rare but legal — e.g., a debug sink
that records both the converted block and the raw bytes for offline
comparison).
Matrix of concrete sinks:
| Sink | Reading | Sample | Block | RawBlock |
|---|---|---|---|---|
InMemorySink |
✓ | ✓ | ✓ | ✗ |
CsvSink |
✓ | ✓ | ✗ (opt-in via accept_blocks=True) |
✗ |
JsonlSink |
✓ | ✓ | ✗ (same) | ✗ |
SqliteSink |
✓ | ✓ | summary rows only | ✗ |
ParquetSink |
✓ | ✓ | ✓ (preferred; row groups per block) | ✗ |
PostgresSink |
✓ | ✓ | summary rows only | ✗ |
RawCountsSink |
✗ | ✗ | ✗ (deliberately — see §15.2) | ✓ (DT-only) |
Refuse-blocks-by-default on row-oriented sinks is the same rule:
prevents accidental 1-GB CSVs at 10 kHz × 8 channels. The reason
RawCountsSink is RawBlock-only and not Block-also is symmetry:
forcing the user to think about which durable format they want
(volt-converted DaqBlock → ParquetSink, raw bytes →
RawCountsSink) at sink-construction time is the right place for that
decision.
15.2 RawCountsSink — the TDMS-equivalent¶
This is the one sink unique to dtollib. It exists because the SDK has
no driver-side logging (no analogue of NI's task.in_stream.configure_logging).
RawCountsSink writes the raw int16/int32 buffer data (not the
volt-converted floats) plus a one-time file header AND a per-chunk
record header to a .dt-raw file format defined by the package:
file = file_header_len:uint32
+ file_header_json:bytes
+ (chunk_record)*
file_header_json = {
"format_version": 2, # bumped from v1 — per-chunk framing
"task_name": "...",
"device": "DT9805(00)",
"channels": [
{"name": "...", "physical_channel": 0, "range": [-10.0, 10.0],
"gain": 1.0, "encoding": "binary", "unit": "V",
"tc_type": null}, ...
],
"sample_rate_hz": 1000.0,
"block_period_ns": 1000000,
"resolution_bits": 16,
"dtype": "int16",
"interleaved_cjc": false, # if true, every scan is n_channels*2 wide
"task_started_at": "2026-05-27T12:34:56.789Z",
"task_started_mono_ns": 123456789000,
"sdk_version": "...",
"dll_paths": {"oldaapi": "...", "olmem": "..."},
"dtollib_version": "0.1.0",
"metadata": {...},
}
chunk_record =
chunk_header_len:uint32
+ chunk_header_json:bytes
+ chunk_payload:bytes
chunk_header_json = {
"seq": 0, # 0-based monotonic per task
"event_kind": "buffer_done", # see SdkEventKind; usually buffer_done
"first_sample_index": 0, # cumulative offset since task_started_*
"valid_samples": 1000, # may be < buffer capacity on final/partial
"buffer_capacity": 1000, # samples_per_channel the buffer was sized for
"t_mono_ns": 123456789000, # at callback receipt (drainer thread)
"t_utc": "2026-05-27T12:34:56.789Z",
"flags": ["final"] # subset of: final, partial, reused, overrun_marker
}
chunk_payload = (n_channels * valid_samples * sizeof(dtype)) bytes
# if interleaved_cjc is true: n_channels * 2 * valid_samples * sizeof(dtype)
Why per-chunk framing matters: the v1 "header + appended bytes" format
round-trips a clean run but cannot represent partial final buffers,
OLDA_WM_BUFFER_REUSED events (overwritten in WrapMode.MULTIPLE),
overrun gaps (OLDA_WM_OVERRUN_ERROR is recorded as a zero-payload
chunk with flags=["overrun_marker"]), or out-of-order sequence
numbers from a multi-threaded drainer. The replay utility uses seq +
flags to reconstruct the exact event timeline of the original run —
not just the data, but what the SDK reported about the data.
A replay utility (dtollib.tools.replay_raw) re-opens the file as a
stream of DaqBlocks for post-hoc analysis, applying the conversion at
read time using the file-header's channel metadata and the per-chunk
metadata for sample-time reconstruction.
The sink registers itself as a passive observer in record()'s internal
fan-out — buffer data flows into it directly from the drainer thread,
before the async stream. This means raw-counts logging keeps working
even if the async consumer is slow / has stopped. The file is the
durable record of the run; the async stream is the live view.
Why a custom format instead of TDMS:
- We'd have to bring in a TDMS writer dependency (npTDMS) and ship a
format we don't fully control.
- The raw-counts format is dead-simple to read in NumPy, MATLAB, or any
other tool — np.fromfile(f, dtype=...) is enough.
- It's faithful: the SDK gives us raw counts; we write raw counts. No
loss of information.
Users who want TDMS specifically can pipe DaqBlocks into nptdms from
a normal async consumer; we don't make it the default.
15.3 CSV / JSONL / SQLite / Parquet / Postgres¶
Direct ports of the sibling sinks. reading_to_row(reading),
sample_to_row(sample), block_to_long_rows(block) work the same way.
15.4 Utility helpers (dtollib.utils)¶
Port of the .NET Utility class (UMOpenLayers.md:2273-2294) — pure
Python, no SDK calls, no hardware needed. Lives in dtollib/utils.py
and re-exported from the package root for ergonomic access.
def convert_temperature_to_volts(
tc_type: ThermocoupleType,
temperature_c: float,
) -> float:
"""ITS-90 polynomial: temperature → thermocouple voltage (volts).
Useful for: simulating a TC source against a voltage AI channel,
sanity-checking hardware-linearised reads, replay-time conversion
when the .dt-raw file stored voltage rather than temperature."""
def convert_volts_to_temperature(
tc_type: ThermocoupleType,
volts: float,
cjc_temperature_c: float = 0.0,
) -> float:
"""ITS-90 inverse polynomial: TC voltage + CJC → temperature (°C).
The fallback path for boards that report VOLTAGE_IN on a TC channel
when SupportsTemperatureDataInStream is False (which is the
application-side TC linearisation case from §31.9)."""
def get_thermocouple_range(tc_type: ThermocoupleType) -> tuple[float, float]:
"""Documented operating range (°C, low, high) for a TC type.
Used by ThermocoupleInput.__post_init__ to validate that
(min_val_degc, max_val_degc) fits inside the physically meaningful
range — saves an SDK round-trip and gives a precise error."""
def compute_rectangular_rosette(
epsilon_0: float, epsilon_45: float, epsilon_90: float,
) -> tuple[float, float, float]:
"""Rectangular strain-gage rosette: returns (eps_max, eps_min, angle_deg).
Three quarter-bridge SGs at 0°/45°/90°."""
def compute_delta_rosette(
epsilon_0: float, epsilon_60: float, epsilon_120: float,
) -> tuple[float, float, float]:
"""Delta strain-gage rosette: returns (eps_max, eps_min, angle_deg).
Three quarter-bridge SGs at 0°/60°/120°."""
These are utilities the .NET API exposes off the static Utility
class, not on any subsystem. Same shape in Python: free functions that
don't touch the SDK, so they work in tests, replay tooling, and CI
on Linux/macOS without the real backend present.
The TC polynomial coefficients are the NIST ITS-90 reference tables; the rosette formulas are textbook ASTM E1561 / E132. Both are test-against-hand-calculated-vectors, no hardware needed.
16. Manager Design¶
16.1 DtolManager¶
class DtolManager:
def __init__(self, *, error_policy: ErrorPolicy = ErrorPolicy.RAISE) -> None: ...
async def add(
self,
name: str,
spec: TaskSpec,
*,
backend: DtolBackend | None = None,
) -> DtolSession: ...
async def remove(self, name: str) -> None: ...
def get(self, name: str) -> DtolSession: ...
async def start(self, names: Sequence[str] | None = None) -> Mapping[str, DeviceResult[None]]: ...
async def stop(self, names: Sequence[str] | None = None) -> Mapping[str, DeviceResult[None]]: ...
async def poll(self, names: Sequence[str] | None = None) -> Mapping[str, DeviceResult[DaqReading]]: ...
async def read_block(
self,
samples_per_channel: int,
names: Sequence[str] | None = None,
) -> Mapping[str, DeviceResult[DaqBlock]]: ...
async def start_synchronized(self, names: Sequence[str]) -> None: # v0.2
"""olDaSimultaneousStart all named tasks."""
16.2 Manager differences from nidaqlib¶
DT-Open Layers' subsystem-reservation rules:
- One HDASS per (board, subsystem-type, element). Two
TaskSpecs targeting(board="DT9805(00)", subsystem=A/D, element=0)cannot coexist. The manager's preflight catches this atadd()time. - HDRVR is shared, ref-counted. The manager keeps a
dict[str, _HdrvrRef]keyed by board name; firstadd()per board callsolDaInitialize, lastremove()per board callsolDaTerminate. Same ref-count shape as siblings. - Simultaneous start is hardware-coordinated, not software-coordinated.
start_synchronizedruns the four-step SDK sequence (olDaGetSSList→olDaPutDassToSSList× N →olDaSimultaneousPreStart→olDaSimultaneousStart).
16.3 Thread-safety stance — start conservative, relax with evidence¶
Per the SDK manual, HDRVR is thread-safe and HDASS is not. In practice the DataAcq SDK has not been thoroughly audited for cross-subsystem reentrancy on a single board (some subsystems share hardware resources internally — e.g. clock domains, DMA channels). We start conservative:
- v0.1: one
anyio.Lockper board (HDRVR), shared by every session that targets that board. Concurrent sessions across different boards run in parallel. - v0.2+ (if needed): measure under realistic load. If profiling shows lock contention on multi-subsystem boards (DT9806 AI + C/T running concurrently) and bench tests confirm cross-subsystem safety, relax to per-HDASS locks for the relevant subsystem pairs.
The relaxation should be earned by a documented bench experiment, not inferred from the SDK manual. Cost of over-locking is throughput; cost of under-locking is hours of debugging an SDK that returns generic ECODEs from a corrupted internal state.
16.4 Resource model¶
@dataclass(frozen=True, slots=True)
class SubsystemReservation:
board: str
subsystem_type: SubsystemType # A/D, D/A, DIN, DOUT, C/T, QUAD, TACH
element: int # usually 0; some boards have multiple AI subsystems
The manager rejects an add() that conflicts with an existing
reservation. Conflicts that the manager cannot detect (e.g. shared
clock or trigger lines) surface at olDaConfig / olDaStart time as
typed errors — same shape as nidaqlib §15.3.
16.5 DtolCollection — forward-looking (v0.3+, not committed)¶
The .NET API splits its surface into two namespaces: OpenLayers.Base
for individual boards and OpenLayers.DeviceCollection for
device collections — VIBbox-style chassis or user-defined collections
created via the DT Device Collection Manager. A collection is N
physical devices wired together over the Sync Bus, configured and
started as one logical device with a master/slave relationship
(UMOpenLayers.md:2655-2776).
DtolManager (§16.1) is not the right abstraction for collections.
The manager handles "N independent named tasks I want to coordinate";
a collection is "N devices the SDK already considers one." Different
lifecycle:
| Concern | DtolManager |
DtolCollection (proposed) |
|---|---|---|
| What it manages | N independent sessions | One logical device with N elements |
| Discovery | find_devices() per board |
find_collections() returns one entity |
olDaInitialize calls |
N (one per board) | 1 (the collection driver) |
| Synchronisation | software loop or start_synchronized |
hardware Sync Bus, intrinsic |
| Subsystem set | full (AI/AO/DIN/DOUT/CT/QUAD/TACH) | AI + AO only (per the .NET spec) |
| Master/slave roles | none | first-class (master_index, slave roles) |
Proposed shape, deferred to v0.3+ pending a real VIBbox-style rig in the lab:
class DtolCollection:
"""One logical DT device made of N Sync-Bus-coordinated boards.
Use this for VIBbox or DT Device Collection Manager-defined
collections. For ad-hoc multi-board coordination on independent
boards, use DtolManager instead."""
collection_name: str
collection_devices: tuple[BoardInfo, ...]
master_index: int
async def analog_input_subsystem(self, element: int = 0) -> DtolSession: ...
async def analog_output_subsystem(self, element: int = 0) -> DtolSession: ...
# No DIN/DOUT/CT/QUAD/TACH — collections only expose AI + AO.
async def find_collections() -> tuple[CollectionInfo, ...]: ...
For v0.1/v0.2, the plain DtolManager.start_synchronized(names) path
covers the case of "I have two independent DT9806s and want them to
start together via the SDK's simultaneous-start primitives." A
DtolCollection would be added only after a real VIBbox-style rig
shows up in the lab and exercises the master/slave semantics in
practice. Open Question 11 (added to §31) tracks this.
17. Error Model¶
17.1 Root error¶
class DtolError(Exception):
def __init__(self, message: str, *, context: ErrorContext | None = None) -> None: ...
Naming matches sibling root pattern: AlicatError, SartoriusError,
WatlowError, NIDaqError, DtolError.
17.2 ErrorContext¶
@dataclass(frozen=True, slots=True)
class ErrorContext:
task_name: str | None = None
board: str | None = None # DT-Open Layers board name
subsystem_type: SubsystemType | None = None
element: int | None = None
channel_name: str | None = None
channel: int | None = None # physical channel number (SDK int)
operation: str | None = None # e.g. "olDaConfig", "read_block"
ecode: int | None = None # raw ECODE / OLSTATUS value
ecode_source: Literal["oldaapi", "olmem"] | None = None
ecode_message: str | None = None # from olDaGetErrorString / olDmGetErrorString
sdk_event_kind: SdkEventKind | None = None # set on async-error wraps
extra: Mapping[str, object] = field(default_factory=dict)
17.3 Subclasses¶
DtolError
DtolConfigurationError # invalid spec / config commit rejected
DtolValidationError # client-side validation before SDK call
DtolTaskStateError # poll() during continuous, start-before-config, etc.
DtolReadError # olDaGetSingleValue / olDaGetBuffer failed
DtolWriteError # olDaPutSingleValue / put-buffer-to-list failed
DtolTimeoutError # operation exceeded explicit timeout
DtolResourceError # HDASS conflict, simultaneous-start mismatch
DtolCapabilityError # requested feature not supported per CapabilitySet
DtolCapiError # raw ECODE from oldaapi / olmem; parent for ↓ buffer/trigger
DtolBufferOverrunError # OLDA_WM_OVERRUN_ERROR (continuous AI)
DtolBufferUnderrunError # OLDA_WM_UNDERRUN_ERROR (continuous AO)
DtolTriggerError # OLDA_WM_TRIGGER_ERROR
DtolBackendError # generic SDK failure not in another bucket
DtolDependencyError # DLL not found, bitness mismatch, SDK not installed
DtolConfirmationRequiredError # safety-gate refusal
DtolSinkError
DtolSinkSchemaError
DtolSinkWriteError
DtolSinkDependencyError
17.4 Wrapping SDK errors¶
Single wrapping point at DataAcqBackend. The classifier knows which
DLL's error-string function to call based on the source function:
def _check(
self,
status: int,
*,
op: str,
source: Literal["oldaapi", "olmem"] = "oldaapi",
**ctx: object,
) -> None:
if status == OLNOERROR:
return
msg = (self._capi.errors.olda_error_string(status)
if source == "oldaapi"
else self._capi.errors.olmem_error_string(status))
raise self._classify(status)(
f"{op} failed: {msg}",
context=ErrorContext(
operation=op,
ecode=status,
ecode_source=source,
ecode_message=msg,
**ctx,
),
)
Interim classification table maps OLSTATUS ranges to DtolError
subclasses:
- 0x0001–0x00FF (device errors) → DtolBackendError
- 0x0100–0x01FF (init errors) → DtolConfigurationError
- 0x0200–0x02FF (configuration) → DtolConfigurationError
- 0x0300–0x03FF (operation) → DtolTaskStateError
- 0x0400–0x04FF (buffer) → DtolReadError / DtolWriteError
- 0x0500–0x05FF (memory) → DtolBackendError
Bench-frequent codes encountered during V7.0.0.7 verification
(OLERRORS.H / docs/decisions.md) — surface every one as a typed
exception, not a generic DtolBackendError:
| ECODE | Symbol | Meaning | Typical cause |
|---|---|---|---|
| 8 | OLBADCHANNELTYPE |
Invalid channel-type selector | wrong-rev OL_CHNT_* (use 100/101, not 0/1) |
| 10 | OLBADTRIGGER |
Invalid trigger selector | wrong-rev OL_TRG_* (use 300-series) |
| 12 | OLBADCLOCKSOURCE |
Invalid clock-source selector | wrong-rev OL_CLK_* (use 400-series) |
| 18 | OLBADDATAFLOW |
Invalid data-flow selector | wrong-rev OL_DF_* (use 800-series) |
| 20 | OLSUBSYSINUSE |
Subsystem already acquired | prior session didn't release HDASS |
| 27 | OLDATAFLOWMISMATCH |
Op not valid in current data-flow | called single-value API on a continuous-mode subsystem |
| 35 | OLBADWRAPMODE |
Invalid wrap-mode selector | wrong-rev OL_WRP_* (use 1000-series) |
| 36 | OLNOTSUPPORTED |
Feature not supported by this subsystem | called olDaSetMultiSensorType on TC-only board |
| 89 | OLBADQUEUE |
Invalid queue selector | wrong-rev OL_QUE_* (use 1100-series) |
ECODE 8/12/18/35/89 are almost always caller-side bugs in the constant family the dtollib was built against — the SDK is reporting "I don't recognise this value at all." If you see one of these from production code, the first hypothesis should be "the constant table slipped against the installed SDK rev."
Range-based mapping is interim. Ranges are not a stable taxonomy
across SDK revisions — the manual lists specific named codes, and a
single range routinely contains codes that should classify into
different subclasses (e.g., a "buffer" code that's really a
state-machine violation belongs in DtolTaskStateError, not
DtolReadError). The Phase 1 deliverable replaces the range table
with a per-code table built from OLERRORS.H: one row per named
OL* error constant (e.g. OLNOERROR, OLBADCAP, OLBADELEMENT —
~160 codes), with the DataAcq SDK version it was sourced from
recorded next to it (so a future SDK upgrade is a diff against a known
baseline, not a guess). The range table remains as a fallback for
unknown codes the per-code table doesn't cover, and as a safety net if
the SDK adds new codes between releases.
The classification table lives in capi/errors.py and is exposed
to the backend as a single classify(status: int) -> type[DtolError]
function. Tests assert each documented OLSTATUS lands in the right
bucket — a range-based parametrised test in v0.0, replaced by a
per-code parametrised test once Phase 1 lands.
18. Safety Model¶
DT-Open Layers can drive AO, DO, and CO outputs. Same seriousness as sibling-library actuators.
18.1 Operations requiring confirmation (v0.2+)¶
- AO writes above a configured
safe_max/ belowsafe_min. - DO writes on lines marked
requires_confirm. - C/O pulse-train generation.
- Auto-calibration (
olDaAutoCalibrate). - Anything that mutates persistent device state.
18.2 Example¶
18.3 Channel-level safety metadata¶
@dataclass(frozen=True, slots=True, kw_only=True)
class AnalogOutputVoltage(ChannelSpec):
kind: ClassVar[str] = "ao_voltage"
min_val: float = -10.0
max_val: float = 10.0
safe_min: float | None = None
safe_max: float | None = None
requires_confirm: bool = True
Validation runs before the SDK call — backends never silently clamp.
19. Configuration and Metadata¶
19.1 DtolConfig¶
@dataclass(frozen=True, slots=True, kw_only=True)
class DtolConfig:
default_timeout_s: float = 10.0
default_sample_rate_hz: float = 1000.0
default_chunk_size: int = 1000
default_buffers: int = 4 # BufferPlan.buffers default
default_stream_buffer: int = 16 # AnyIO send-stream buffer
eager_tasks: bool = False
oldaapi_dll_path: str | None = None # explicit oldaapi*.dll override
olmem_dll_path: str | None = None # explicit olmem*.dll override
def replace(self, **updates: object) -> "DtolConfig":
return dataclasses.replace(self, **updates)
def config_from_env(prefix: str = "DTOLLIB_") -> DtolConfig:
"""Reads:
DTOLLIB_DEFAULT_TIMEOUT_S, DTOLLIB_DEFAULT_SAMPLE_RATE_HZ,
DTOLLIB_DEFAULT_CHUNK_SIZE, DTOLLIB_DEFAULT_BUFFERS,
DTOLLIB_DEFAULT_STREAM_BUFFER, DTOLLIB_EAGER_TASKS,
DTOLLIB_OLDAAPI_DLL, DTOLLIB_OLMEM_DLL.
Mirrors nidaqlib.config_from_env."""
19.2 RunMetadata¶
Same shape as nidaqlib.RunMetadata, adding SDK-specific fields:
@dataclass(frozen=True, slots=True, kw_only=True)
class RunMetadata:
run_id: str
started_at: datetime
dtollib_version: str
sdk_version: str | None # from olDmGetVersion
dll_path: str # which DLL was loaded
python_version: str
platform: str # always "win32" but recorded
bitness: int # 32 or 64
task_specs: Mapping[str, TaskSpec]
user_metadata: Mapping[str, object] = field(default_factory=dict)
19.3 Serialisation¶
Same discriminated-union pattern as nidaqlib §18.3. Each
ChannelSpec subclass declares kind: ClassVar[str] and exposes
to_dict() / from_dict(). The base ChannelSpec.from_dict dispatches
through _CHANNEL_REGISTRY keyed by kind.
19.4 Sidecar metadata¶
For RawCountsSink paths, write a sidecar <file>.metadata.json
alongside the .dt-raw file. The JSON is RunMetadata.to_dict().
20. System Discovery¶
20.1 API¶
from dtollib.system import find_devices, list_subsystems
devices: list[DeviceInfo] = find_devices() # uses olDaEnumBoardsEx
subsystems: list[SubsystemInfo] = list_subsystems("DT9805(00)")
20.2 Models¶
@dataclass(frozen=True, slots=True)
class DeviceInfo:
name: str # e.g. "DT9805(00)"
model: str # "DT9805", "DT9806", ...
driver_name: str # from olDaEnumBoardsEx
instance: int # registry instance number
sdk_version: str | None # from olDmGetVersion
subsystems: tuple[SubsystemInfo, ...]
@dataclass(frozen=True, slots=True)
class SubsystemInfo:
type: SubsystemType # A/D / D/A / DIN / DOUT / C/T / QUAD
element: int # 0-based
num_channels: int # OLSSC_NUMCHANNELS
supports_continuous: bool # OLSSC_SUP_CONTINUOUS
supports_singlevalue: bool # OLSSC_SUP_SINGLEVALUE
supports_simultaneous: bool # OLSSC_SUP_SIMULTANEOUS_SH
supports_multisensor: bool # OLSSC_SUP_MULTISENSOR
supports_dma: bool # OLSSC_SUP_DMA
max_throughput_hz: float # OLSSCE_MAX_THROUGHPUT (olDaGetSSCapsEx)
max_channel_list_size: int # OLSSC_CGLDEPTH
available_ranges: tuple[tuple[float, float], ...] # OL_ENUM_RANGES
available_gains: tuple[float, ...] # OL_ENUM_GAINS
20.3 Discovery without hardware¶
find_devices() on a machine without the SDK installed raises
DtolDependencyError. On a machine with the SDK but no DT hardware, it
returns []. Both behaviours are tested with FakeDtolBackend plus a
mock loader.
21. CLI Tools¶
| CLI | Purpose | Version |
|---|---|---|
dtol-discover |
List boards and subsystems with capabilities. | v0.1 |
dtol-capture |
Short acquisition to file (Parquet or .dt-raw). |
v0.1 |
dtol-diag |
Diagnose SDK / DLL / driver install issues. | v0.1 |
dtol-read |
One-shot scalar read. | v0.2 |
dtol-info |
Print detailed per-board info. | v0.2 |
21.1 dtol-discover (v0.1)¶
dtol-discover # all boards, summary table
dtol-discover --board DT9805\(00\) # filter to one board
dtol-discover --json # machine-readable output
Lists boards and their subsystems with key capability flags
(OLSSC_RETURNS_FLOATS, OLSSC_SUP_CONTINUOUS,
OLSSC_SUP_SIMULTANEOUS_SH, max throughput, channel count).
21.2 dtol-capture (v0.1)¶
dtol-capture --board DT9805\(00\) --channels 0,1 --rate 1000 \
--duration 10 --out run.parquet
dtol-capture --board DT9805\(00\) --channels 0,1 --rate 10000 \
--duration 60 --out run.dt-raw
Output format selected by .parquet vs .dt-raw extension. The .dt-raw
path uses RawCountsSink and is the recommended choice for high-rate
acquisition.
21.3 dtol-diag (v0.1)¶
The install-troubleshooting tool. Surfaces every piece of context a support call would otherwise have to extract:
dtol-diag # all checks
dtol-diag sdk # just the SDK / DLL section
dtol-diag boards # just enumeration
Reports:
- Python interpreter path, architecture (32/64-bit), version
- Operating system version
oldaapi*.dlldiscovered path + bitness + load success/failureolmem*.dlldiscovered path + bitness + load success/failure- Expected SDK install root probed for diagnostic context (presence /
absence only — never required for load). All under
C:\Program Files (x86)\Data Translation\Win32\SDK\on 64-bit Windows: ...\SDK\Include\(headers)...\SDK\Lib\(32-bit import libs)...\SDK\Lib64\(64-bit import libs)...\SDK\Examples\(C reference examples)olDaGetVersion(acquisition library)olDmGetVersion(memory library)- Number of boards enumerated; for each: name, driver name, model, instance number
- For each subsystem on each board: type, element, num_channels, key capability flags
- Common failure hints when checks fail (DLL not found → install SDK
from Data Translation, expected at
C:\Program Files (x86)\Data Translation\Win32\SDK\on 64-bit Windows; bitness mismatch → install matching SDK; no boards → check Open Layers Control Panel)
Exit code is nonzero if any check fails — usable in CI as a precondition gate.
21.4 dtol-read / dtol-info (v0.2)¶
dtol-read --board DT9805\(00\) --channel 0 --range -10,10
dtol-info --board DT9805\(00\) # full per-board CapabilitySet dump
Same shape as nidaq-read / nidaq-info.
22. Testing Strategy¶
22.1 Unit tests (no hardware, no SDK)¶
Use FakeDtolBackend. Cover:
- TaskSpec validation (channel-kind mixing rejected, kw_only enforced).
- ChannelSpec validation (range bounds, gain enum, sensor-specific knobs).
- Timing / TriggerSpec validation.
- Backend call ordering (Set* before Config, Config before Start).
- §12.3.2 callback-bridge ordering (Register before Start, Stop before Unregister, Sentinel before Drain-wait).
- BufferPlan sizing minima (
buffers ≥ 2, recycling correctness). - Error wrapping (every documented
ECODE→ rightDtolErrorsubclass, via the classification table incapi/errors.py). - CapabilitySet population —
OLSSC_RETURNS_FLOATStoggling switches the buffer pipeline branch. - SdkEventKind dispatch (all 10 events routed correctly).
- Session lifecycle (idempotent close, configure-failure tears down).
- Recorder backpressure / OverflowPolicy semantics.
- Sink schema behaviour.
- Sync facade parity.
- RawCountsSink round-trip (write → replay → assert array equality).
- Discriminated channel-spec serialisation round-trip.
22.2 Binding tests (no DT hardware, real ctypes)¶
Cover the capi layer against a hand-rolled tests/_fake_dll.c
compiled to a stub DLL on Windows CI. Asserts:
- Signatures match (argtypes / restype produce the expected ctypes call shape).
- Constants match the values in
oldaapi.h(the stub DLL re-exports the header constants for read-back). - Notification callback fires under the right
WINFUNCTYPEshape.
This catches argtypes typos without needing a DT device.
22.3 Hardware tests (opt-in)¶
Same marker / env-var pattern as nidaqlib:
markers = [
"hardware: requires connected DT DAQ hardware",
"hardware_stateful: changes subsystem/device state",
"hardware_output: writes AO / DO / CO",
"hardware_destructive: calibration or potentially unsafe operations",
"slow: excluded from fast CI",
]
Environment gates:
DTOLLIB_ENABLE_HARDWARE_TESTS=1
DTOLLIB_ENABLE_STATEFUL_TESTS=1
DTOLLIB_ENABLE_OUTPUT_TESTS=1
DTOLLIB_ENABLE_DESTRUCTIVE_TESTS=1
Hardware test configuration:
DTOLLIB_TEST_DEVICE=DT9805(00)
DTOLLIB_TEST_AI_CHANNEL_PRIMARY=0
DTOLLIB_TEST_AI_CHANNEL_SECONDARY=1 # optional
DTOLLIB_TEST_TC_TYPE=K
DTOLLIB_TEST_RATE_HZ=10
DTOLLIB_TEST_TC_MIN_DEGC=-50
DTOLLIB_TEST_TC_MAX_DEGC=200
22.4 CI matrix¶
- Lint (Ubuntu): ruff check + ruff format check.
- Typecheck (Ubuntu): mypy strict + pyright strict.
- Test (Windows + Ubuntu + macOS, py3.13 + py3.14): unit + binding
tests against
FakeDtolBackend. Real backend tests run only on Windows; non-Windows platforms test the fake backend exclusively (the real backend module is import-guarded bysys.platform == "win32"). - Build (Ubuntu): sdist + wheel; twine check strict.
Workflows mirror nidaqlib/.github/workflows/{ci,docs,release}.yml
verbatim, with the addition of a Windows-only hardware job that runs
manually on a self-hosted runner with a DT9805 attached (opt-in via
workflow_dispatch).
23. Documentation Plan¶
docs/
index.md
quickstart-async.md
quickstart-sync.md
installation.md # how to install the SDK runtime, troubleshooting DLL discovery
task-specs.md
channels.md # voltage / thermocouple / RTD / strain / IEPE
timing.md
triggers.md
streaming.md
raw-logging.md # RawCountsSink and the .dt-raw format
safety.md
testing.md
architecture.md # short — pointer to design.md
design.md # this document (renamed at first commit)
troubleshooting.md # common SDK errors decoded
binding.md # capi internals (two DLLs) — for contributors
api/ # mkdocstrings auto-generated
Most-important docs at launch:
- Quickstart: thermocouple input (the DT9805 primary use case).
- Quickstart: continuous voltage acquisition to Parquet.
- Installation + DLL discovery (this is the most likely source of first-time install failures).
- High-rate acquisition via RawCountsSink.
- Unified experiment — DAQ + Alicat + Sartorius.
- How this differs from raw ctypes / QuickDAQ.
- When to use the escape hatch.
Build with Zensical + mkdocstrings-python (same as nidaqlib /
watlowlib).
24. Packaging¶
24.1 pyproject.toml sketch¶
[build-system]
requires = ["hatchling>=1.29.0", "hatch-vcs>=0.5.0"]
build-backend = "hatchling.build"
[project]
name = "dtollib"
dynamic = ["version"]
description = "Experiment-facing DT-Open Layers acquisition layer for scientific instrumentation."
readme = "README.md"
requires-python = ">=3.13"
license = "MIT"
license-files = ["LICENSE"]
authors = [{ name = "Grayson Bellamy", email = "gbellamy@umd.edu" }]
keywords = ["data-translation", "dt-open-layers", "daq", "data-acquisition",
"thermocouple", "instrument", "laboratory"]
classifiers = [
"Development Status :: 3 - Alpha",
"Framework :: AnyIO",
"Intended Audience :: Science/Research",
"License :: OSI Approved :: MIT License",
"Operating System :: Microsoft :: Windows",
"Programming Language :: Python",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Programming Language :: Python :: Implementation :: CPython",
"Topic :: Scientific/Engineering",
"Topic :: System :: Hardware",
"Typing :: Typed",
]
dependencies = [
"anyio>=4.13",
"numpy>=2",
]
[project.optional-dependencies]
postgres = ["asyncpg>=0.30"]
parquet = ["pyarrow>=16"]
docs = [
"zensical>=0.0.37",
"mkdocstrings-python>=1.12",
]
[project.scripts]
dtol-discover = "dtollib.cli.discover:main"
dtol-capture = "dtollib.cli.capture:main"
dtol-diag = "dtollib.cli.diag:main"
dtol-read = "dtollib.cli.read:main"
dtol-info = "dtollib.cli.info:main"
[project.urls]
Homepage = "https://github.com/GraysonBellamy/dtollib"
Repository = "https://github.com/GraysonBellamy/dtollib"
Documentation = "https://GraysonBellamy.github.io/dtollib/"
Issues = "https://github.com/GraysonBellamy/dtollib/issues"
Changelog = "https://github.com/GraysonBellamy/dtollib/blob/main/CHANGELOG.md"
Dev / lint / type / test / docs groups match nidaqlib verbatim.
24.2 Dependency note¶
Smallest core dependency footprint of the lab packages — only anyio
and numpy. Notably:
- No vendor Python SDK (we are the binding).
- No driver Python package (the SDK is a C DLL).
- No serial library (USB enumeration goes through the SDK).
The runtime dependency is the DataAcq SDK installer itself, which the
user must install separately from Data Translation. The package's
installation.md walks through this.
24.3 Wheel distribution strategy¶
Pure-Python wheel, classified Operating System :: Microsoft :: Windows.
The capi/loader.py raises DtolDependencyError at first use on
non-Windows platforms, with a clear message. Imports of the public
package at module-load time do not touch the DLL — discovery and load
are deferred to find_devices() / open_device().
This means the wheel installs cleanly on Linux/macOS for ecosystem
composition (e.g., a multi-instrument experiment script that imports
both dtollib and alicatlib can be type-checked on a Linux CI
runner), but the real backend only works on Windows.
25. Migration Map from nidaqlib¶
nidaqlib module |
dtollib equivalent |
Decision |
|---|---|---|
_logging.py |
_logging.py with ROOT = "dtollib" |
Direct port |
_runtime.py |
_runtime.py |
Direct port |
config.py |
config.py with DtolConfig |
Port the shape, change fields (add default_buffers, oldaapi_dll_path, olmem_dll_path) |
errors.py |
errors.py with DtolError |
Port the shape, add DtolBufferOverrunError / DtolBufferUnderrunError / DtolTriggerError / DtolDependencyError |
backend/base.py (DaqBackend Protocol) |
backend/base.py (DtolBackend Protocol) |
Port the role, expand methods to cover buffer-pool + notification surface |
backend/nidaqmx_backend.py |
backend/dtol_backend.py |
Rewrite — different SDK |
backend/fake.py |
backend/fake.py (FakeDtolBackend) |
Port the shape, add buffer-pool + notification simulation |
| (none) | capi/ |
NEW — the C binding |
| (none) | backend/_buffer_pool.py |
NEW — internal BufferPool helper backing BufferPlan |
| (none) | backend/_callback_bridge.py |
NEW — driver-thread bridge |
system/discovery.py |
system/discovery.py |
Port the role, use olDaEnumBoardsEx |
channels/ |
channels/ |
Port the shape, add DT-specific sensors (RTD, thermistor, strain, bridge, IEPE) |
tasks/spec.py |
tasks/spec.py |
Direct port with field renames |
tasks/triggers.py |
tasks/triggers.py |
Port the shape, DT-specific trigger types |
tasks/session.py |
tasks/session.py |
Port the shape, lifecycle includes HDRVR + HDASS |
tasks/models.py |
tasks/models.py |
Direct port (DaqReading, DaqBlock identical shape) |
streaming/recorder.py |
streaming/recorder.py (record_polled) |
Direct port |
streaming/block.py |
streaming/block.py (record) |
Port the shape, drive the §12.3.2 bridge instead of §11.3.2 |
sinks/ |
sinks/ |
Direct port, add RawCountsSink |
manager.py |
manager.py (DtolManager) |
Direct port with renames |
sync/ |
sync/ (Dtol facade) |
Direct port |
cli/ |
cli/ (dtol-*) |
Direct port with dtol- prefix |
testing.py |
testing.py |
Heavy port — add notification-firing helpers |
The two non-obvious deviations from nidaqlib¶
capi/exists. It is the lowest layer ofdtollib;nidaqlibdelegates this entirely tonidaqmx-python. Two DLLs (oldaapi,olmem); both loaded bycapi/loader.py.BufferPlanis user-visible.nidaqmxhides buffer management; the DataAcq SDK does not. Pretending otherwise leads to surprisingOLDA_WM_OVERRUN_ERROR. We exposebuffers/samples_per_bufferonTaskSpec.buffersand document the tradeoffs.
Everything else is a port.
26. Phased Roadmap¶
The roadmap is feature-sliced. Each phase lists exactly the SDK functions that get bound — bind small, test small. Functions not listed in a phase are not bound yet; the user-facing surface is bounded by what the current phase covers.
Hardware reconciliation. Several original acceptance targets below have been revised against bench findings on the owned DT9805/DT9806 (SDK V7.0.0.7) — continuous analog output (Phase 4), quadrature/ tachometer and AI+C/T simultaneous-start (Phase 5), and the
diag_read_reg/diag_write_regescape hatches (Phase 2). See plan-hardware-functional.md for the authoritative hardware envelope; the entries below are annotated where they are superseded.
Phase 0 — Scaffold¶
Scope:
- Package skeleton matching nidaqlib.
- pyproject.toml, zensical.toml, docs scaffolding, CI workflows
(ci.yml, docs.yml, release.yml).
- Port _logging, _runtime, sync/portal.py, the sink Protocols and
in-memory sink, test config.
- errors.py with the full DtolError hierarchy.
- config.py with DtolConfig + config_from_env.
- Import-smoke tests on all CI OSes (no SDK, no hardware).
No SDK functions bound yet. Goal is import-clean on Linux/macOS.
Phase 1 — C boundary, discovery, diagnostics¶
SDK functions bound:
- olDaGetVersion, olDmGetVersion
- olDaGetErrorString, olDmGetErrorString
- olDaEnumBoards, olDaEnumBoardsEx, olDaGetBoardInfo
- olDaInitialize, olDaTerminate
- olDaEnumSubSystems, olDaGetDevCaps
- olDaGetDASS, olDaReleaseDASS
- olDaGetSSCaps, olDaGetSSCapsEx, olDaEnumSSCaps,
olDaEnumChannelCaps
Public surface:
- find_devices(), find_subsystems(board), inspect_device(board)
- CapabilitySet, BoardInfo, SubsystemInfo, DeviceInfo
- IOType enum (§8.15) populated from OLSSC_IO_TYPE per channel
- dtollib.utils — pure-Python helpers (convert_temperature_to_volts,
convert_volts_to_temperature, get_thermocouple_range,
compute_rectangular_rosette, compute_delta_rosette). No SDK
required; ports of the .NET Utility class. Tested against
hand-calculated NIST ITS-90 reference vectors and textbook rosette
examples — same test suite runs on Linux/macOS CI.
- dtol-discover, dtol-diag CLIs
- FakeDtolBackend parity for everything above
Header verification (prerequisite for entering Phase 1):
- All capi/types.py aliases (HDRVR, HDASS, HBUF, HLIST,
HSSLIST, ECODE, OLSTATUS) cross-checked against installed
OLTYPES.H on the first maintainer machine that has the SDK
available. The opaque-pointer assumption (c_void_p) is committed
only after that check; if any handle is actually HANDLE /
HINSTANCE / c_uint32 per the header, the alias is corrected
before binding any function that uses it.
- All argtypes / restype declarations in capi/prototypes.py
cross-checked against OLDAAPI.H / OLMEM.H for the functions
bound in this phase. Notably: olDaSetWndHandle's third (lParam)
arg confirmed as LPARAM (not LONG) and its second arg as HWND;
every output parameter confirmed as a pointer type; every callback
typedef confirmed as stdcall (WINFUNCTYPE, not CFUNCTYPE).
- Findings recorded in docs/decisions.md with the SDK version they
were verified against. A future SDK upgrade then becomes a diff
against a known baseline, not a guess.
Acceptance: dtol-diag reports cleanly on a fresh SDK install;
find_devices() returns a populated list against a connected DT9805 or
DT9806; full diagnostic table renders on Windows; CI on Linux/macOS
runs the discovery suite against FakeDtolBackend; header
verification entries exist in docs/decisions.md for every type
alias and every prototype in this phase.
Phase 2 — Single-value analog input (DT9805 happy path)¶
SDK functions bound:
- olDaSetDataFlow
- olDaSetChannelType, olDaSetChannelRange, olDaSetRange
- olDaSetGainListEntry (single-channel form for spec'd gain)
- olDaSetMultiSensorType (must be issued BEFORE per-type setters on
MULTI_SENSOR channels — see §8.5a)
- olDaSetThermocoupleType, olDaSetReturnCjcTemperatureInStream
- olDaConfig, olDaGetSSState (powers DtolSession.state — §8.13)
- olDaGetSingleValue, olDaGetSingleFloat
- olDaGetSingleValueEx (autoranging path)
- olDaGetSingleValues, olDaGetSingleFloats (simultaneous SH)
- olDaGetCjcTemperature, olDaGetCjcTemperatures
- olDaCodeToVolts, olDaVoltsToCode (oracles for vectorised path)
Public surface:
- TaskSpec, ChannelSpec, AnalogInputBase, AnalogInputVoltage,
ThermocoupleInput, Timing (rate/clock only)
- SubsystemState enum + DtolSession.state property (§8.13)
- SoftwareStart only (full TriggerSpec hierarchy lands in Phase 3)
- DtolSession.configure / start / stop / abort / poll / close /
is_running / state
- ~~DtolSession.diag_read_reg / diag_write_reg~~ — descoped from
v0.1 (B2 in plan-hardware-functional.md):
olDaReadDevReg/olDaWriteDevReg are unbound and have no
bench-verified DLL export. Raw access goes through the raw_hdass /
raw_hdrv / backend escape hatches instead.
- open_device() factory
- DtolManager.add / remove / get / poll (single-device focus,
per-board lock)
- Sync facade Dtol.open_device(spec)
- DT9805/DT9806 read-only hardware smoke tests
- MULTI_SENSOR ordering test: the fake backend enforces that
set_multi_sensor_type precedes set_thermocouple_type /
set_range / etc. on IOType.MULTI_SENSOR channels.
Acceptance: a script reads K-type thermocouples on a DT9805 and prints temperatures; the same script passes type-check on Linux CI; hardware smoke tests pass on the bench.
Phase 3 — Continuous analog input + the §12.3.2 bridge¶
SDK functions bound:
- olDaSetChannelListSize, olDaSetChannelListEntry,
olDaSetChannelListEntryInhibit
- olDaSetGainListEntry (list form)
- olDaSetClockSource, olDaSetClockFrequency, olDaGetClockFrequency,
olDaSetExternalClockDivider
- olDaSetTrigger, olDaSetTriggerThresholdChannel,
olDaSetTriggerThresholdLevel
- olDaSetWrapMode, olDaSetDmaUsage, olDaSetStopOnError
- olDaSetWndHandle (buffer-done mechanism — olDaSetNotificationProcedure
is not bound; its callback never fires on V7.0.0.7, §12.3.2)
- olDaStart, olDaStop, olDaAbort, olDaIsRunning,
olDaGetQueueSize
- olDmCallocBuffer, olDmMallocBuffer, olDmReAllocBuffer,
olDmFreeBuffer
- olDmGetBufferPtr, olDmGetBufferSize, olDmGetMaxSamples,
olDmGetValidSamples, olDmGetDataWidth, olDmGetDataBits
- olDaPutBuffer, olDaGetBuffer, olDaFlushBuffers
- olDaCopyFromBuffer (powers DtolSession.read_inprocess — gated on
OLSSC_SUP_INPROCESSFLUSH; see §9.1 and the .NET
MoveFromBufferInprocess analogue at UMOpenLayers.md:5933-5945)
Public surface:
- DataFlow enum (full), BufferPlan, WrapMode, QueueStrategy
- BufferState enum (§8.14) tracked per RawBuffer in the pool;
use-after-free raises DtolTaskStateError immediately
- TaskSpec.stop_on_error (SDK-level, distinct from
recorder-level ErrorPolicy — see §14.3 pairing table)
- DtolSession.read_inprocess() for low-latency partial-buffer
drainage on devices where CapabilitySet.supports_inprocess_flush()
- DtolSession.queued_buffer_dones for runtime back-pressure
monitoring (number of completed buffers waiting for the drainer)
- ExternalDigitalStart, AnalogThresholdStart triggers
- record() recorder driving the §12.3.2 callback bridge
- record_polled() recorder
- DaqBlock, DaqReading, DaqSample, AcquisitionSummary,
ErrorPolicy (RAISE / RETURN / LOG_AND_CONTINUE),
OverflowPolicy
- All six sinks (InMemorySink, CsvSink, JsonlSink, SqliteSink,
ParquetSink, PostgresSink)
- RawCountsSink + replay tool (dtollib.tools.replay_raw)
- dtol-capture CLI
- Buffer-event ordering test suite (all 5 invariants enforced by
FakeDtolBackend)
- Overrun / trigger-error / buffer-reused tests
- stop_on_error=False + ErrorPolicy.LOG_AND_CONTINUE pairing test:
inject OVERRUN, assert the SDK keeps producing and the recorder
logs without raising
Acceptance: 60-minute continuous 1 kHz acquisition on DT9805 drops
zero blocks with BufferPlan(buffers=4); RawCountsSink round-trips losslessly;
deliberate consumer-pause test triggers OLDA_WM_OVERRUN_ERROR and the
recorder surfaces it correctly under each ErrorPolicy.
Phase 4 — Outputs and digital I/O (DT9806)¶
SDK functions bound:
- olDaPutSingleValue, olDaPutSingleValues
- olDmCopyToBuffer, olDmCopyFromBuffer, olDmCopyBuffer (waveform output)
- olDaSetSynchronousDigitalIOUsage, olDaSetDigitalIOListEntry
- olDaMute, olDaUnMute
Public surface:
- AnalogOutputVoltage, DigitalInputPort, DigitalOutputPort (+ DigitalLine
bit-views) specs
- DtolSession.write with confirm=True safety gate; safe_min /
safe_max validation before SDK call
- Continuous AO waveform output path (play()) — software-complete and
unit-tested against the fake. Withdrawn on owned hardware (B1 in
plan-hardware-functional.md): the
DT9806 D/A is single-value only (SUP_CONTINUOUS=0), so play()
fails loud with DtolCapabilityError. Retargeted to a future
continuous-DAC board.
- Hardware output tests with explicit DTOLLIB_ENABLE_OUTPUT_TESTS=1
gate
- dtol-read, dtol-info CLIs
Acceptance: single-value AO/DO writes honour the confirm=True
safety gate on a DT9806; out-of-range values are rejected before any SDK
call. (The original "60 s continuous waveform, zero underruns" target is
withdrawn — see B1.)
Phase 5 — Counter/timer, tachometer, simultaneous start¶
SDK functions bound:
- olDaSetCTMode, olDaSetCTClockSource, olDaSetCTClockFrequency
- olDaSetGateType, olDaSetPulseType, olDaSetPulseWidth
- olDaSetMeasureStartEdge, olDaSetMeasureStopEdge
- olDaSetCascadeMode
- olDaReadEvents, olDaMeasureFrequency
- olDaSetTriggeredScanUsage, olDaSetMultiscanCount,
olDaSetRetriggerMode, olDaSetRetrigger,
olDaSetRetriggerFrequency
- olDaGetSSList, olDaPutDassToSSList,
olDaSimultaneousPreStart, olDaSimultaneousStart,
olDaReleaseSSList
Public surface:
- CounterEdgeCount, CounterFrequency, CounterEdgeToEdge,
QuadratureDecoder, Tachometer channel specs
- PulseTrainOutput, OneShotOutput, RepetitiveOneShotOutput specs
- SubsystemType.TACHOMETER (first-class)
- RetriggerSpec (triggered scan mode — EXTRA preferred)
- DtolManager.start_synchronized (full four-step pre-start + start
sequence)
- Multi-subsystem composition tests (AI + counter on one DT9806)
Hardware reconciliation (B4/B5 in
plan-hardware-functional.md): the owned
DT9805/DT9806 expose no OLSS_QUAD / OLSS_TACH subsystem and the
C/T reports SUP_SIMULTANEOUS_START=0. QuadratureDecoder / Tachometer
and CounterMode.MEASURE are gated off via the runtime capability query
(raise DtolCapabilityError); all paths stay unit-tested against the
idealised fake. Real quadrature/tachometer and tight AI+C/T alignment
need hardware that reports those capabilities.
Acceptance: event-count (COUNT) and pulse-train (RATE) run on the
DT9806 C/T; start_synchronized builds and starts an SS-list (AI-only
where the AI subsystem reports SUP_SIMULTANEOUS_START). Quadrature/
tachometer/measure and AI+C/T one-sample-period alignment are out of
scope on owned hardware (B4/B5).
Out of scope (deferred to v0.3+): DtolCollection — the .NET
OpenLayers.DeviceCollection equivalent (§16.5). Phase 5's
start_synchronized covers the "two independent DT boards I want to
start together" case via the SDK simultaneous-start primitives.
DtolCollection is the separate "the SDK considers these one logical
device" abstraction (VIBbox-style chassis, DT Device Collection
Manager-defined collections), which adds master/slave semantics and a
distinct discovery surface. Deferred until a real collection-style rig
is in the lab.
Phase 6 — Full multi-sensor coverage¶
SDK functions bound:
- olDaSetRtdType, olDaSetRtdR0, olDaSetRtdA/B/C
- olDaSetThermistorA/B/C
- olDaSetIEPE, olDaSetExcitationCurrentSource,
olDaSetExcitationCurrentValue
- olDaSetCouplingType
- olDaSetStrainExcitationVoltageSource,
olDaSetStrainExcitationVoltage, olDaSetStrainShuntResistor,
olDaSetStrainBridgeConfiguration
- olDaSetBridgeConfiguration
- olDaSetTransducerType
- olDaVoltsToStrain, olDaVoltsToBridgeBasedSensor
- olDaReadBridgeSensorHardwareTeds,
olDaReadBridgeSensorVirtualTeds,
olDaReadStrainGageHardwareTeds,
olDaReadStrainGageVirtualTeds
Public surface:
- CurrentInput, ResistanceInput, RtdInput, ThermistorInput,
IepeInput, StrainInput, BridgeInput specs
- TEDS read helpers
- Application-side TC linearisation only if a real owned device
needs it (deferred until then; DtolCapabilityError if missing)
Phase 7 — Hardening¶
- Pre-trigger / about-trigger data flow modes (only if any owned hardware supports them — these are flagged "Legacy Devices" in the manual)
- Multi-board simultaneous start across two DT devices via Sync Bus
(
olDaSetSyncMode) olDaAutoCalibratewith destructive-test gate- Long-duration stress tests (24-hour continuous AI runs)
- Lock-relaxation experiments: profile per-board vs per-HDASS lock contention with real load
- Performance work on the §12.3.2 path (lock-free SPSC variant if measurement shows queue contention)
- Public API freeze for v1.0 beta
Future / not committed¶
DtolCollectionfor VIBbox-style chassis and DT Device Collection Manager-defined collections (§16.5). Adds a separatefind_collections()discovery path and a CollectionDevices / master/slave model thatDtolManageris intentionally not.- TimescaleDB sink variant for high-rate Postgres logging.
- HDF5 sink as an alternative to
.dt-raw. - Cross-instrument synchronisation primitives (DT external trigger shared with NI device on a common back-plane).
- Web dashboard (probably belongs in a separate package).
27. Risks and Mitigations¶
Risk: The capi binding diverges from the SDK silently¶
Mitigation: Binding tests exercise every declared signature against a stub DLL that re-exports the SDK header values. Adding a new function without a corresponding signature test fails CI.
Risk: The §12.3.2 callback bridge has the same hazards as §11.3.2¶
Mitigation: Port the nidaqlib bridge invariants table verbatim into
the FakeDtolBackend. Tests cover all five ordering invariants
(Register-before-Start, Stop-before-Unregister, etc.). Failing any one
ordering is a unit-test failure, not a hardware-day surprise.
Risk: GC of the CFUNCTYPE callback wrapper crashes the driver¶
Mitigation: Same fix as nidaqlib — keep a strong reference on the
backend instance, keyed by id(hdass), dropped only at
unregister_notification. A unit test exercises a register / GC-pressure
/ callback-fire sequence to catch regression.
Risk: Buffer-pool sizing is hard to choose¶
Mitigation: BufferPlan.buffers defaults to 4 (matches QuickDAQ's
default). Document the relationship buffers ≈ ceil(max_consumer_latency_s
* sample_rate_hz / samples_per_buffer) in streaming.md. The recorder
reports overruns_observed in AcquisitionSummary — a single overrun
in a run is a signal to increase buffers or samples_per_buffer.
Risk: Windows-only constrains the user base¶
Mitigation: Acknowledged. The SDK is Windows-only; there is no remediation. The wheel installs on all platforms for type-checking and ecosystem composition; the real backend is gated to Windows.
Risk: Bitness mismatch (32-bit Python + 64-bit DLL) fails confusingly¶
Mitigation: _loader.py detects bitness with
struct.calcsize('P') and raises DtolDependencyError with the
expected DLL filename in the message, before ctypes.WinDLL raises a
less-friendly OSError.
Risk: SDK errors are documented but classification is wrong¶
Mitigation: Classification table is a unit-tested mapping with one test per documented error code range. PRs adding new codes must add a test.
Risk: RawCountsSink writes from the callback thread cause GIL¶
contention
Mitigation: Bench it. The write path is np.ndarray.tofile(f) on a
pre-opened file handle; this releases the GIL during the actual I/O.
If contention shows up, switch to a background writer thread with its
own queue.SimpleQueue (same shape as the drainer).
28. Public API Surface¶
from dtollib import (
# Discovery + capability
BoardInfo,
CapabilitySet,
DeviceInfo,
SubsystemInfo,
SubsystemType,
find_devices,
# Spec primitives
BufferPlan,
ChannelType,
ClockSource,
CouplingType,
DataFlow,
Edge,
Encoding,
FilterType,
QueueStrategy,
RawLogging,
TaskSpec,
Timing,
WrapMode,
# Channels — v0.1
AnalogInputVoltage,
BridgeInput,
IepeInput,
RtdInput,
StrainInput,
ThermistorInput,
ThermocoupleInput,
ThermocoupleType,
TemperatureUnit,
CjcSource,
# Channels — v0.2
AnalogOutputVoltage,
DigitalInputPort,
DigitalOutputPort,
DigitalLine,
CounterEdgeCount,
CounterFrequency,
CounterEdgeToEdge,
QuadratureDecoder,
Tachometer,
PulseTrainOutput,
OneShotOutput,
# Triggers
AnalogThresholdStart,
ExternalDigitalStart,
ReferenceTrigger,
RetriggerMode,
RetriggerSpec,
SoftwareStart,
SyncBusStart,
TriggerSpec,
# Data models
DaqBlock,
DaqReading,
DaqSample,
# Session + manager
DtolConfig,
DtolManager,
DtolSession,
DeviceResult,
config_from_env,
open_device,
# Metadata
Recording,
RunMetadata,
read_sidecar,
sidecar_path_for,
write_sidecar,
# Errors
ErrorContext,
ErrorPolicy,
DtolError,
DtolBackendError,
DtolBufferOverrunError,
DtolBufferUnderrunError,
DtolCapabilityError,
DtolCapiError,
DtolConfigurationError,
DtolConfirmationRequiredError,
DtolDependencyError,
DtolReadError,
DtolResourceError,
DtolSinkError,
DtolSinkDependencyError,
DtolSinkSchemaError,
DtolSinkWriteError,
DtolTaskStateError,
DtolTimeoutError,
DtolTriggerError,
DtolValidationError,
DtolWriteError,
__version__,
)
from dtollib.streaming import (
AcquisitionSummary,
OverflowPolicy,
PollSource,
PollSourceAdapter,
SdkEventKind,
record,
record_polled,
)
from dtollib.sinks import (
BlockSink,
CsvSink,
InMemorySink,
JsonlSink,
ParquetSink,
PostgresSink,
RawCountsSink,
ReadingSink,
SampleSink,
SqliteSink,
block_to_rows,
pipe,
pipe_blocks,
reading_to_row,
)
from dtollib.testing import (
FakeDtolBackend,
)
from dtollib.sync import (
Dtol,
)
from dtollib.units import to_pint
29. README Positioning¶
Suggested README opening:
# dtollib
Experiment-facing DT-Open Layers acquisition tools for Python.
`dtollib` is the missing Python binding for Data Translation USB DAQ
modules (DT9805, DT9806, and other DT-Open Layers devices). It wraps
the DataAcq SDK C DLL via `ctypes` and exposes a typed, async,
lifecycle-managed acquisition layer that fits the same scientific-
instrumentation ecosystem as [`alicatlib`], [`sartoriuslib`],
[`watlowlib`], and [`nidaqlib`].
Use `dtollib` when you want:
- declarative task specifications,
- consistent async/sync APIs,
- structured errors,
- block-oriented acquisition,
- raw-counts / Parquet / SQLite / Postgres / CSV / JSONL logging,
- hardware-free tests,
- and unified experiment workflows across DT DAQ, NI DAQ, flow
controllers, balances, and temperature controllers.
## Platform
Windows only. The DataAcq SDK is Windows-only. The package installs on
Linux/macOS for type-checking and ecosystem composition, but the real
backend gates to Windows.
## Hardware tested
DT9805 (multi-sensor AI), DT9806 (AI + AO + DIO + C/T). Other
DT-Open-Layers devices should work but have not been verified.
30. Final Recommendation¶
Build dtollib.
Build the smallest thing that closes the most painful gap first:
- ctypes binding for the AI subset of the SDK.
- Typed
TaskSpec/DtolSession/record()/record_polled(). BufferPlanonTaskSpec+ internalBufferPool+_callback_bridge(the §12.3.2 analogue).- Fake backend that enforces the same ordering invariants the real SDK does.
- Six concrete sinks plus
RawCountsSink. - Sync facade.
- One excellent example combining DT9805 thermocouples, an Alicat MFC, and a Sartorius balance — all logging into one SQLite table.
That package adds a Python-shaped Data Translation story to the ecosystem without fighting the SDK and without writing a clone of QuickDAQ.
The core design principle:
Wrap workflow, not capability. Treat the C binding as an internal seam, not as a public surface.
31. Open Questions¶
These are not blockers for the architecture, but they should be
answered before Phase 0 scaffolding begins. Most have a default the
plan currently assumes (called out in parentheses); the value of
asking is to confirm the default or replace it. Track answers in
docs/decisions.md as they are made — each answer either confirms the
parenthesised default or generates a single follow-up edit in this
document.
- Package name. Is
dtollibdefinitely the final published name, or woulddatatranslationlib(more discoverable to someone searching PyPI for "Data Translation") be preferred? (Default:dtollib— matches the*libfamily convention.) - Bitness for first bench. Will the initial DT9805/DT9806
bring-up run on 64-bit Python with 64-bit
oldaapi64.dll/olmem64.dll? (Default: yes — 32-bit is supported by the loader but is not the primary CI or hardware target.) - SDK headers as repo fixtures. Are
OLDAAPI.H,OLMEM.H,OLERRORS.H,OLDADEFS.H, andOldacfg.hredistributable under the DT-Open Layers SDK license? If yes, commit them undertests/fixtures/headers/so thescripts/gen_openlayers.pydiff CI check can run on every PR rather than only on maintainer machines. (Default: assume no until license confirmed; headers live on maintainer machines and the CI diff check is a manual pre-release step.) - Actual board names. What strings do your DT9805 and DT9806
report in the Open Layers Control Panel? (
DT9805(00)andDT9806(00)are placeholders in this doc — the real names go into the smoke-test env vars in §22.3 and the README examples in §7.) - Primary v0.1 use case. Is the first real experiment
thermocouple temperature acquisition (TC channels via
olDaGetSingleFloaton a hardware-linearising subsystem), plain voltage AI (olDaGetSingleValue), or both equally? (Default: TC priority — drives the Phase 2 happy-path scope, the §13.1 thermocouple section, and the Quickstart example ordering in §23.) - DT9806 AO in v0.1? The plan defers AO to Phase 4 (v0.2). Is that right, or does a real near-term experiment need AO writes before continuous AI is stable? (Default: defer to v0.2 — keeps v0.1 free of safety-gate machinery while the input path stabilises.)
- Loopback wiring for output tests. Will the bench have safe loopback wiring (AO0 → AI0, DO0 → DI0, CTR_OUT → CTR_IN) so that output tests can verify the written value without operator inspection, or do output tests run open-loop? (Affects Phase 4 hardware-test design — closed-loop tests are much faster to add and far more reliable than open-loop visual checks.)
.dt-rawreplay ergonomics. Which replay shape matters most for post-hoc analysis: iterator ofDaqBlocks (matchesrecord()'s shape so analysis code is portable between live and replay), bulk NumPy load (np.ndarrayper channel for one-shot slicing), direct conversion to Parquet (interop with pandas/polars/duckdb), or all three? (Default: iterator first, NumPy bulk-load second in v0.1, Parquet converter as a CLI in v0.2.)- Application-side TC linearisation. ~~All currently-owned hardware
linearises thermocouples on-board.~~ Resolved 2026-05-28: the
DT9805/DT9806 do not linearise in firmware (UM9800 Table 26;
OLSSC_RETURNS_FLOATS = 0, noOLSSC_SUP_LINEARIZE_TCin the SDK). The application-side NIST ITS-90 path is the default and only path for owned hardware, implemented intasks/session.py(_read_all_channels_app_side_tc),capi/conversion.py(code_to_input_volts), andutils.py(Types K/J; T/E/R/S/B/N pending). See §8.5a and docs/decisions.md. - TimescaleDB / HDF5 sinks. The "future / not committed" list at the end of §26 includes TimescaleDB and HDF5 sinks. Are either of these on the actual roadmap for your lab, or are they speculative? (Default: speculative; reconsider after v0.2 ships and there is real usage data on which durable formats are reached for in practice.)
DtolCollectionpriority. §16.5 sketches a separate abstraction for DT-Open Layers device collections (VIBbox-style chassis, DT Device Collection Manager-defined collections), distinct fromDtolManager. The .NET API puts collections in their own namespace (OpenLayers.DeviceCollection), but adding this requires a real collection-style rig to validate the master/slave semantics. Is there a near-term plan for such hardware in the lab? (Default: no —DtolManager.start_synchronizedhandles ad-hoc multi-board sync for now; deferDtolCollectionto v0.3+ when a real rig arrives.)
Appendix A: Comparison to nidaqlib¶
| Concern | nidaqlib |
dtollib |
|---|---|---|
| Low-level binding owner | NI (via nidaqmx-python) |
us (capi/ package via ctypes, two DLLs) |
| Cross-platform | Linux + macOS + Windows | Windows only |
| Buffer model | hidden by NI | user-visible via BufferPlan on TaskSpec (buffers knob) |
| Driver-side logging | TDMS (task.in_stream.configure_logging) |
RawCountsSink (custom .dt-raw format) |
| Callback bridge | §11.3.2 every-N-samples callback | §12.3.2 notification procedure (OLDA_WM_*) |
| Subsystem identity | nidaqmx.Task |
HDASS (DT-Open Layers subsystem) |
| Device identity | Dev1 |
DT9805(00) (DT registry instance) |
| Discovery | nidaqmx.system.System.local() |
olDaEnumBoardsEx |
| Error wrapping point | one place (every backend method) | one place (every backend method) |
| Escape hatch | session.raw_task → nidaqmx.Task |
session.raw_hdass + session.backend.dll |
The two libraries are designed to be drop-in substitutes from the
ecosystem's perspective. A record()-based recorder script that uses
either one differs only in the import statement and the TaskSpec
construction.
Appendix B: Example FakeDtolBackend¶
class FakeDtolBackend:
def __init__(
self,
*,
block_sequences: Mapping[str, Sequence[np.ndarray]] | None = None,
device_info: DeviceInfo | None = None,
force_overrun_at_block: int | None = None,
) -> None:
self._block_sequences = {
name: list(values) for name, values in (block_sequences or {}).items()
}
self._device_info = device_info or _default_dt9805_info()
self._force_overrun_at = force_overrun_at_block
self.operations: list[tuple[str, object]] = []
self._notify_callbacks: dict[int, Callable[[int], None]] = {}
self._started: set[int] = set()
self._configured: set[int] = set()
self._buffer_pool: dict[int, list[np.ndarray]] = {}
def initialize(self, device_name: str) -> int:
self.operations.append(("initialize", device_name))
return id(device_name) # synthetic HDRVR
def get_dass(self, hdrv: int, subsys_type: int, element: int) -> int:
hdass = object() # opaque
self.operations.append(("get_dass", (subsys_type, element)))
return id(hdass)
def set_data_flow(self, hdass: int, mode: int) -> None:
if hdass in self._started:
raise self._sdk_error(0x0301, "cannot reconfigure running task")
self.operations.append(("set_data_flow", mode))
def commit(self, hdass: int) -> None:
self.operations.append(("commit", hdass))
self._configured.add(hdass)
def register_notification(self, hdass: int, callback) -> int:
if hdass in self._started:
# SDK enforces register-before-start; the fake does too.
raise self._sdk_error(200960,
"register all events prior to starting the task")
self._notify_callbacks[hdass] = callback
self.operations.append(("register_notification", hdass))
return hdass # handle == hdass for the fake
def unregister_notification(self, hdass: int, handle: int) -> None:
if hdass in self._started:
raise self._sdk_error(200986,
"cannot unregister event on running task")
self._notify_callbacks.pop(hdass, None)
def start(self, hdass: int) -> None:
if hdass not in self._configured:
raise self._sdk_error(0x0301, "start before commit")
self._started.add(hdass)
self.operations.append(("start", hdass))
def stop(self, hdass: int) -> None:
self._started.discard(hdass)
self.operations.append(("stop", hdass))
# Test helper — synthesise a buffer-done callback.
def fire_buffer_done(self, hdass: int) -> None:
cb = self._notify_callbacks.get(hdass)
if cb is None:
raise RuntimeError("no notification registered")
cb(_OLDA_WM_BUFFER_DONE)
Appendix C: Example callback-bridge skeleton¶
import queue
import ctypes
import anyio
import numpy as np
from anyio.from_thread import BlockingPortal
from dtollib.capi.constants import (
OLDA_WM_BUFFER_DONE,
OLDA_WM_OVERRUN_ERROR,
OLDA_WM_TRIGGER_ERROR,
)
_SENTINEL: object = object()
@asynccontextmanager
async def _callback_bridge(
backend: DtolBackend,
hdass: int,
pool: BufferPool,
*,
chunk_size: int,
):
"""Owns the lifetime of the notification registration + drain task.
Yields (rx, summary) — rx is the AsyncIterator[DaqBlock] for consumers.
"""
tx_send, tx_recv = anyio.create_memory_object_stream[DaqBlock](max_buffer_size=16)
chunk_q: queue.SimpleQueue = queue.SimpleQueue()
drain_done = anyio.Event()
overrun_count = 0
block_index = 0
def _on_notify(msg_id: int, _wparam: int, _lparam: int) -> int:
# DRIVER THREAD. Minimal work, no asyncio.
chunk_q.put_nowait((msg_id, time.monotonic_ns()))
return 0
handle = backend.register_notification(hdass, _on_notify)
async def _drain() -> None:
nonlocal overrun_count, block_index
try:
while True:
item = await anyio.to_thread.run_sync(chunk_q.get)
if item is _SENTINEL:
return
msg_id, mono_ns = item
if msg_id == OLDA_WM_BUFFER_DONE:
# Worker-thread pop from done queue + recycle to ready.
hbuf = await anyio.to_thread.run_sync(backend.get_buffer, hdass)
if hbuf is None:
continue
raw = await anyio.to_thread.run_sync(backend.get_buffer_ptr, hbuf)
block = _to_daq_block(raw, block_index, mono_ns)
await tx_send.send(block)
block_index += 1
await anyio.to_thread.run_sync(backend.put_buffer, hdass, hbuf)
elif msg_id == OLDA_WM_OVERRUN_ERROR:
overrun_count += 1
# ErrorPolicy.RAISE: cancel the task group, propagate.
# ErrorPolicy.RETURN: emit an error block.
...
elif msg_id == OLDA_WM_TRIGGER_ERROR:
...
finally:
drain_done.set()
await tx_send.aclose()
try:
async with anyio.create_task_group() as tg:
tg.start_soon(_drain)
yield tx_recv, _summary_view(...)
finally:
# Shutdown ordering: stop → unregister → sentinel → drain-wait → close.
with anyio.CancelScope(shield=True):
await anyio.to_thread.run_sync(backend.stop, hdass)
backend.unregister_notification(hdass, handle)
chunk_q.put_nowait(_SENTINEL)
await drain_done.wait()
Appendix D: DT9805 / DT9806 capability matrix (informational only)¶
This table is planning context, not a runtime authority. Per
Design Goal §4.12, the wrapper validates configuration against the
live CapabilitySet populated from olDaGetDevCaps /
olDaGetSSCaps / olDaGetSSCapsEx / olDaEnumSSCaps /
olDaEnumChannelCaps. The matrix below shapes scope decisions
(which channel specs to bind in v0.1, which CLIs to ship); it is
NOT consulted at session-construction time and is NOT used to
short-circuit the runtime capability query.
| Capability | DT9805 | DT9806 |
|---|---|---|
| A/D subsystem | ✓ | ✓ |
| Multi-sensor inputs (V / I / TC / RTD / Thermistor / Strain / Bridge / IEPE) | ✓ | ✓ |
| Simultaneous single-value AI | ✓ | ✓ |
| Continuous AI | ✓ | ✓ |
| DMA | ✓ | ✓ |
| D/A subsystem | ✗ | ✓ |
| DIN / DOUT subsystems | ✗ | ✓ |
| C/T subsystem | ✗ | ✓ |
| Quadrature decoder | ✗ | ✓ |
Firmware revisions are known to flip capability flags within the same
model number — for example, an OLSSC_SUP_INTERLEAVED_CJC_IN_STREAM
flag that's true on one DT9805 revision and false on another. Trusting
this matrix over the runtime query is a class of bug.
References¶
dasdk.md— the DataAcq SDK User's Manual (full text)dasdk_digest.md— the technical reference digest used to scope this plannidaqlib/docs/design.md— the sibling-package design document this plan inherits fromalicatlib,sartoriuslib,watlowlib— sibling packages whose architecture this plan adheres to- DataAcq SDK online help (
oldaapi.h,olmem.h,olerrors.h,olwin.h) — authoritative source for signatures and constants