rtd-acquire provides the acquisition layer between physical RTD measurement
hardware and RTD interpretation software.
Its primary output is a trustworthy estimate of RTD element resistance in ohms, together with acquisition-level status, diagnostics, and—where a well-founded calculation is available—standard uncertainty in ohms.
The project is designed to support both common hobbyist/maker hardware and professional/industrial acquisition architectures.
The separation between rtd-acquire and rtd-sensor is intentional.
rtd-acquire owns:
- communication with acquisition hardware;
- converter, ADC, transmitter, and controller behavior;
- electrical transfer functions that produce resistance;
- reference-resistor, ADC, gain, offset, excitation, and related acquisition configuration;
- acquisition-chain calibration;
- measurement-topology and lead compensation when needed to estimate the RTD element resistance;
- acquisition diagnostics and native hardware evidence;
- acquisition-derived resistance uncertainty;
- simulated acquisition devices and device emulation.
rtd-sensor owns:
- RTD resistance-to-temperature and temperature-to-resistance models;
- Pt, Ni, and other RTD characteristic interpretation;
- Callendar–Van Dusen and other model coefficients;
- RTD-model fitting and calibration;
- RTD tolerance classes;
- model validity ranges;
- model-level and propagated temperature uncertainty.
rtd-acquire must not apply temperature offsets or otherwise reinterpret an RTD
resistance as temperature.
rtd-acquire is not required merely because physical RTD hardware exists. If
another system already provides the desired trustworthy estimate of RTD-element
resistance in ohms, a consumer may pass that resistance directly to
rtd-sensor or another model layer.
A direct-resistance path does not become an rtd-acquire driver requirement
just to make the hardware interoperable with the broader RTD ecosystem. A
future wrapper or backend should have independent acquisition value, such as
common configuration, diagnostics, calibration, or multi-channel behavior.
A device belongs in the core rtd-acquire backend model only when the chosen
interface exposes resistance or sufficiently direct electrical observations
from which RTD-element resistance can be defensibly estimated.
A device that exposes only an internally calculated temperature is outside the
core resistance-acquisition contract. rtd-acquire must not reconstruct a
synthetic resistance from that temperature merely to force the device through
the acquisition boundary; doing so would discard the original acquisition
evidence and duplicate model interpretation after the device has already
performed it.
The primary public abstraction is AcquisitionDevice.
A hardware-specific implementation such as MAX31865 implements the
AcquisitionDevice contract. The standard operation is:
measurement = device.read()The result is a Measurement.
Terminology:
- AcquisitionDevice — something capable of producing resistance measurements.
- Measurement — the result of an acquisition operation.
- Driver — hardware-specific implementation details.
- Transport — communication such as SPI, I²C, serial, Modbus, or BACnet.
- Converter — actual physical conversion hardware, not the top-level software abstraction.
An AcquisitionDevice does not require or expose a particular transport.
The locked Python abstraction uses structural typing:
class AcquisitionDevice(Protocol):
def read(self) -> Measurement:
...Hardware implementations do not have to inherit from a common base class. An
object satisfies the contract by providing a compatible read() operation.
This keeps drivers, simulations, application test doubles, and future protocol-
based backends substitutable without imposing an inheritance hierarchy.
The base AcquisitionDevice contract does not require read() to be
thread-safe or reentrant. Callers must serialize operations on a device instance
unless that implementation explicitly documents a stronger guarantee. Likewise,
if multiple device instances share one stateful transport, serialization belongs
to the caller, transport, or platform adapter rather than being silently added
to every acquisition driver.
AcquisitionDevice represents one logical resistance source, not
necessarily one physical IC, module, transport, or enclosure.
A multi-channel converter or controller may own shared communication state,
locking, device-wide configuration, and channel switching while exposing one
channel-scoped logical AcquisitionDevice per resistance source:
physical backend / shared transport
├── channel 0 view → AcquisitionDevice → Measurement
├── channel 1 view → AcquisitionDevice → Measurement
└── channel N view → AcquisitionDevice → Measurement
A channel view may own channel-specific electrical configuration, but
application metadata stays above the acquisition contract. Do not add physical
location, application channel labels, equipment identity, RTD-model selection,
or control-loop roles to Measurement merely because a physical device has
multiple inputs.
Multi-channel hardware also does not by itself justify a generic batch or simultaneous-read API. Coordinated sampling semantics differ by device and should be designed only when a concrete backend requires them.
Device-reported acquisition conditions are represented in a Measurement,
including FAULT measurements. Exceptions are reserved for cases where the
requested software operation itself cannot complete and therefore cannot return
a measurement.
The initial exception hierarchy is:
RtdAcquireError
├── ConfigurationError
└── AcquisitionError
ConfigurationErrormeans caller-supplied acquisition configuration is invalid or unsupported. It is distinct fromDiagnosticCode.CONFIGURATION_ERROR, which means the acquisition device itself reported a configuration fault.AcquisitionErrormeansread()or another acquisition operation could not complete far enough to return aMeasurement, such as a host-side I/O or transport failure. More specific transport/backend exceptions may subclass it later.
This distinction prevents software-operation failures from being disguised as device-reported diagnostic evidence.
The portable C API preserves the same distinction without requiring C to mimic
Python exceptions. Device-specific operation result enums may expose more
specific causes when that improves embedded error handling. For MAX31865,
rtd_acquire_max31865_result_t separates invalid programmer arguments,
caller/configuration errors, insufficient caller-owned result storage, SPI I/O
failure, delay failure, and defensive internal-invariant failure. SPI and delay
failures are both C forms of Python's acquisition-operation failure; invalid
static device configuration or incompatible SPI settings are configuration
failures. rtd_acquire_max31865_config_is_valid() remains a Boolean predicate
because its only question is whether one static configuration is valid.
The locked Python contract is:
class MeasurementStatus(StrEnum):
OK = "ok"
WARNING = "warning"
FAULT = "fault"
@dataclass(frozen=True, slots=True)
class Measurement:
resistance_ohms: float | None
diagnostics: tuple[Diagnostic, ...] = ()
standard_uncertainty_ohms: float | None = None
@property
def status(self) -> MeasurementStatus:
...status is derived from diagnostic severity rather than independently stored.
This prevents contradictory states such as an OK measurement carrying a
FAULT diagnostic.
resistance_ohms is the acquisition system's best trustworthy estimate of the
RTD element's electrical resistance, expressed in ohms. When present, it must
be finite and non-negative.
A failed acquisition uses no magic resistance value. If no trustworthy
resistance is available, the resistance is absent rather than represented by
infinity, NaN, or another sentinel value. Zero ohms is a valid electrical
result when the acquisition hardware actually measures it; rtd-acquire does
not reject that value merely because it would be invalid for a particular RTD
model. Raw or otherwise untrusted converter output belongs in a future debugging/inspection interface rather than
in Measurement.resistance_ohms.
Every measurement has one of three statuses:
- OK — a usable resistance is available and there are no diagnostics.
- WARNING — a usable resistance is available and one or more diagnostics
are present, all with
WARNINGseverity. - FAULT — at least one diagnostic has
FAULTseverity and no trustworthy resistance is available.
Status is derived deterministically:
no diagnostics -> OK
WARNING diagnostics only -> WARNING
one or more FAULT diagnostics -> FAULT
A measurement with no resistance must therefore contain at least one FAULT
diagnostic. A fault measurement must not expose resistance_ohms or a
standard_uncertainty_ohms value as though either remained trustworthy.
WARNING and FAULT diagnostics may coexist; FAULT takes precedence.
Diagnostics carry the acquisition-level reason or reasons behind a warning or
fault. The diagnostic contract is deliberately split into a normalized
rtd-acquire layer and optional device/protocol-native evidence.
The locked Python shape is:
class DiagnosticSeverity(StrEnum):
WARNING = "warning"
FAULT = "fault"
@dataclass(frozen=True, slots=True)
class NativeEvidence:
identifier: str | None = None
message: str | None = None
@dataclass(frozen=True, slots=True)
class Diagnostic:
code: DiagnosticCode
severity: DiagnosticSeverity
native_evidence: tuple[NativeEvidence, ...] = ()
@property
def message(self) -> str:
return diagnostic_message(self.code)Semantics:
codeis the stable normalized machine-readablertd-acquirediagnostic identity.severityisWARNINGorFAULT. Normal operation does not create anOKdiagnostic.messageis the canonicalrtd-acquirewording forcode; it is derived from the code rather than independently stored on each diagnostic.native_evidencepreserves zero, one, or multiple native observations that support the normalized diagnosis.- A
Measurementcontains at most oneDiagnosticfor eachDiagnosticCode. When several native observations support the same normalized condition, they are combined in that diagnostic'snative_evidencetuple rather than emitted as duplicate normalized diagnostics. NativeEvidence.identifieris a string so native identifiers serialize consistently across Python, C-facing conformance data, and JSON. Preserve recognizable vendor notation such asD5,8A,REF_L0, or0x7FFDrather than coercing it into a project-specific numeric scheme.NativeEvidence.messageis the concise native diagnostic name or device-specific wording useful for manufacturer documentation and debugging.- Each native-evidence item must contain at least a non-empty identifier or message.
A diagnostic may legitimately have no native evidence. For example, a driver
may compare a device-supplied conversion CRC and emit DATA_CRC_ERROR when the
comparison fails even though the device has no native CRC-error status bit.
A normalized diagnosis may also require multiple native observations. For a
backend where the manufacturer documents Overrange + Error as an open-circuit
condition, represent those as two NativeEvidence entries. Never manufacture a
fake combined native identifier that the device did not emit.
Applications make decisions from code, not by parsing message or native
text. Canonical messages should remain consistent, but message wording is not a
machine-parsing contract.
rtd-acquire provides its own stable, easy-to-understand diagnostic vocabulary
while preserving the originating device evidence in native_evidence.
Normalization is evidence-based:
- Native conditions are grouped only when their documented meanings genuinely overlap.
- The normalized
rtd-acquirecode/message preserves the greatest useful specificity justified by that semantic group. - A less-specific outlier device does not force a commonly available specific diagnostic to become vague. The outlier receives an appropriately broader normalized code instead.
- Conversely, a driver never maps a broad or ambiguous native condition to a more specific physical diagnosis than the evidence establishes.
- Device-specific troubleshooting causes listed by a vendor are possibilities, not observed facts, unless the device explicitly reports that diagnosis.
- A condition that a device can detect internally is not automatically native
evidence available to
rtd-acquire. The backend must have a documented observation path—such as a register bit, protocol status/message, process data flag, or defined analog fault signal—before it can emit that evidence. - Diagnostic specificity is a property of device + configuration + observation interface, not just the device model.
- Device-internal SPI/ASIC/protocol diagnostics remain distinct from failure of
the host transport used by
rtd-acquire; the latter belongs to the operation error contract unless explicitly represented otherwise later. - Native evidence is preserved as evidence, not rewritten into invented vendor terminology.
- Canonical normalized messages never strengthen the claim beyond the
semantics of
codeand its supporting evidence.
For example, several devices may explicitly and equivalently report a low
reference condition and map to REFERENCE_LOW. A different device that reports
only a broad reference failure maps to REFERENCE_FAULT; the specific devices
are not degraded merely to accommodate the outlier.
Likewise, a resistance-high threshold does not become a sensor-open diagnosis
just because an open circuit is one possible cause. SENSOR_CIRCUIT_OPEN is
used only when the device/interface actually supports that level of diagnosis.
The initial DiagnosticCode vocabulary is derived bottom-up from the hardware
survey in DIAGNOSTICS.md. It is intentionally extensible: later hardware may
add new codes, but existing codes must not be redefined to absorb unrelated
semantics.
standard_uncertainty_ohms is optional. When present, it represents standard
uncertainty attributable to the acquisition chain, expressed in ohms.
Possible contributors include ADC/reference behavior, reference-resistor uncertainty, excitation, amplifier gain/offset, quantization, noise, and acquisition calibration.
Absence of a quantified uncertainty means that rtd-acquire is not providing a
defensible value; it does not mean zero uncertainty. When present, standard
uncertainty must be finite and non-negative. A fault measurement has no
standard uncertainty because it has no trustworthy resistance result to which
the uncertainty could apply.
The architecture separates device behavior from communication and platform I/O:
AcquisitionDevice
↓
Driver
↓
Transport / I/O capability
↓
platform implementation
Design requirements:
AcquisitionDevicedoes not assume a transport.- Hardware-specific drivers interpret device behavior and native diagnostics.
- Communication/I/O capabilities are injected rather than hard-coded into device logic.
- Transport/I/O layers perform communication mechanics, not device interpretation.
- Drivers may use SPI, I²C, serial, ADC input, current-loop input, Modbus, BACnet, or other mechanisms.
- The public behavior remains
read() -> Measurementregardless of transport. - Device configuration is separate from individual measurements.
- Low-level interfaces must be replaceable with deterministic test doubles.
A driver may use zero, one, or several lower-level capabilities; transport is
therefore not a required property of every AcquisitionDevice.
The first MAX31865 public configuration is deliberately limited to stable electrical facts that are required to interpret the converter correctly:
MAX31865Config(
reference_resistance_ohms=430.0,
wire_count=3,
filter_frequency_hz=60,
low_fault_threshold_ohms=None,
high_fault_threshold_ohms=None,
)The fields mean:
reference_resistance_ohms— the actual reference resistance used by the acquisition circuit. The MAX31865 datasheet specifies 350 Ω through 10 kΩ as its recommended reference-resistor operating range. No Pt100/Pt1000 default is assumed.wire_count— the physical 2-, 3-, or 4-wire RTD connection. The MAX31865 uses a dedicated configuration bit only for 3-wire compensation; 2-wire and 4-wire operation share the other device setting, but the public contract preserves the physical topology rather than collapsing them.filter_frequency_hz— 50 or 60 Hz mains-rejection notch selection. This is explicit rather than inferred from locale.low_fault_threshold_ohmsandhigh_fault_threshold_ohms— optional resistance thresholds. The driver translates them to the device's ratiometric threshold-register format. When omitted, the driver uses the device's full-range defaults rather than inventing RTD-model limits.
Thresholds are acquisition settings, not RTD-model validity limits. They must
be within the converter's representable resistance range and, when both are
provided, the low threshold must be below the high threshold. Low thresholds
round downward and high thresholds round upward so quantization cannot move a
native threshold into the caller's requested diagnostic-free window. Because
the 15-bit native threshold code has no value above 32767, a high threshold in
the final unrepresentable band below R_REF is rejected rather than silently
clamped downward. Invalid, unsupported, or directionally unrepresentable values
raise the public ConfigurationError used for caller-supplied acquisition
configuration.
The configuration intentionally contains no RTD family/model name, nominal
R0, temperature range, or temperature-model coefficients. Those belong to
rtd-sensor. A MAX31865 configuration is therefore reusable wherever the
electrical resistance range and acquisition circuit are appropriate.
BIAS control, one-shot commands, fault-status clearing, and master-initiated fault-detection-cycle command bits are operational device state rather than static circuit configuration and are not fields in this contract. Conversion policy and settling/timing behavior will be defined with the MAX31865 driver and transport/timing abstraction rather than encoded prematurely here.
SPI drivers receive a configured per-device SpiDevice capability rather than
importing a platform SPI library directly. The transport exposes its effective
settings and one full-duplex transaction operation:
class SpiDevice(Protocol):
@property
def settings(self) -> SpiSettings:
...
def transfer(self, tx: bytes, /) -> bytes:
...One transfer() call is one contiguous SPI transaction. The transport owns
chip-select assertion/deassertion and must keep chip select active for the whole
call. A successful transaction returns exactly one received byte for every
transmitted byte. Platform adapters translate host I/O failures into the public
acquisition-operation error boundary.
Chip select is intentionally not modeled as a separate generic GPIO operation for the first driver. Raspberry Pi SPI controllers commonly manage CS as part of the SPI peripheral, while Arduino-class adapters can implement the same transaction contract by asserting/deasserting their chosen CS GPIO around the transfer. This keeps device logic independent of that platform distinction.
SpiSettings records the electrical/protocol settings a driver may need to
validate: CPOL, CPHA, clock frequency, bit order, bits per word, and chip-select
polarity. Device-specific limits remain in device drivers rather than in the
generic SPI contract. For MAX31865 specifically, the datasheet permits either
CPOL value, requires CPHA=1, transfers MSB first, uses active-low CS, and
specifies SCLK up to 5 MHz.
The MAX31865 DRDY signal is not part of the initial transport contract. The
first driver can use documented conversion timing; optional data-ready GPIO
support may be added later if it provides a meaningful benefit without becoming
a required capability.
LinuxSpidevDevice implements SpiDevice through the Linux kernel spidev
userspace interface. It opens a caller-selected device node or stable symlink
with Python spidev 3.8+ open_path(), applies the requested SpiSettings, and
uses one xfer2() call for each transfer() so chip select remains asserted
for the complete transaction. Host/kernel failures are translated to the public
AcquisitionError boundary.
The adapter is Linux-generic rather than Raspberry-Pi-specific. Raspberry Pi 4
and Raspberry Pi 5 reach the same userspace /dev/spidev* contract through
their respective kernel controller drivers, so rtd-acquire does not inspect
BCM2711/RP1 registers or branch on Pi model. This makes the implementation
architecturally suitable for both generations while allowing physical
validation status to remain explicit and separate.
The first documented Pi connection uses SPI0 with its standard 40-pin-header
MOSI/MISO/SCLK/CE lines. SPI must be enabled by the operating system before the
device node is available. The adapter accepts a full device path rather than
hard-coding /dev/spidev0.0, permitting alternate chip selects and stable udev
symlinks where bus numbering is not guaranteed.
The spidev Python package is an optional dependency. Importing
rtd_acquire.transports does not require it; a missing backend is reported only
when LinuxSpidevDevice is instantiated.
SpiSettings and device drivers raise ConfigurationError for incompatibilities
they can determine before touching the operating-system backend. Once
LinuxSpidevDevice asks the kernel/backend to open or apply settings, a failure
is treated as an operational AcquisitionError: the adapter does not infer from
platform-specific errno values whether the root cause was permissions, device
availability, controller capability, or a rejected setting. A future backend may
provide a more specific subclass only when it has a documented and portable
distinction.
MAX31865 register interpretation is independent of SPI/platform code. The RTD
register is a 16-bit wire value whose least-significant bit is the device fault
flag; the resistance code is the remaining 15-bit value. rtd-acquire computes
resistance as:
R_RTD = (ADC code / 32768) * R_REF
A zero ADC code therefore represents a zero-ohm electrical result. The generic
Measurement contract permits zero resistance so acquisition does not silently
impose an RTD-model validity rule.
Fault Status bits D7 through D2 are normalized at the specificity actually reported by the MAX31865. D7/D6 threshold crossings are warnings and retain the resistance result. D5/D4/D3 electrical comparison faults and D2 combined input voltage fault are faults and suppress the normal resistance result. Datasheet troubleshooting causes are not promoted into normalized diagnoses.
The shared MAX31865 measurement-decode conformance vectors execute directly against this decode layer. A separate language-neutral threshold-encoding vector set pins configuration-to-register behavior, including directional rounding and the unrepresentable high-threshold boundary, for reuse by the portable-C implementation.
The first public MAX31865 driver uses documented one-shot conversion timing and
runs a fresh automatic fault-detection cycle for every read(). Each read is
therefore deterministic with respect to driver-owned registers rather than
assuming that a previous process left the converter configured correctly.
The operation is:
- write the high/low threshold registers from
MAX31865Config, restoring the device full-range defaults when application thresholds are omitted; - write the static wire/filter bits, enable VBIAS, and clear latched faults;
- allow the input network to settle;
- run the documented automatic fault-detection cycle so D5-D3 evidence is refreshed;
- allow the input network to settle again after fault detection;
- trigger one one-shot conversion and wait the datasheet maximum conversion time for the selected 50/60 Hz filter;
- read the two RTD data bytes and, when their fault flag is set, read the Fault Status register;
- return VBIAS to off;
- pass the captured native registers to the platform-independent decode layer.
Threshold conversion is directional. High thresholds round upward and low thresholds round downward to the nearest representable 15-bit ratio code so quantization does not make a fault trigger inside the resistance window the caller requested.
MAX31865Timing models the external input-filter time constant separately from
static converter configuration. The default is 1 ms, matching the conservative
time constant used in the datasheet startup characterization. Hardware with a
larger external RC time constant must supply its actual value. Bias settling is
computed as 10.5 time constants plus 1 ms; post-fault settling uses five time
constants plus 1 ms. Automatic fault detection waits its 600 us datasheet
maximum, and one-shot conversion waits 55 ms for the 60 Hz notch or 66 ms for
the 50 Hz notch.
The driver validates that its injected SpiDevice uses CPHA=1, MSB-first
8-bit words, active-low chip select, and a clock no faster than 5 MHz. Either
CPOL value remains valid. SPI response-shape violations are acquisition errors.
MAX31865SpiEmulator is a device-specific deterministic test transport that
implements the same SpiDevice contract consumed by the real driver. It takes
a native 15-bit RTD code and an optional native D7-D2 Fault Status value, then
models the register transactions used by the current driver: threshold writes,
fault clearing, automatic fault-cycle re-latching, one-shot RTD reads, and
Fault Status reads.
The emulator deliberately accepts native converter state rather than a temperature or RTD model. It does not infer why a fault bit is present and does not simulate analog settling, noise, temperature, or physical sensor behavior. That keeps it useful for exercising the real register/driver path without turning a deterministic fake into an unvalidated electrical model.
The fault-clear/fault-cycle distinction is intentional. A configured native fault condition is cleared when the driver issues the documented clear command and is re-latched when the subsequent automatic fault-detection cycle runs. This lets CI exercise the actual driver sequence rather than bypassing it with a scripted list of SPI responses.
SimulatedAcquisitionDevice operates at the public AcquisitionDevice layer
rather than pretending to be any particular converter or transport. It replays
a deterministic non-empty script whose entries are either validated
Measurement objects or explicit SimulatedAcquisitionFailure records.
A measurement entry is returned unchanged, preserving its resistance,
diagnostics, and quantified acquisition uncertainty. A simulated acquisition
failure raises AcquisitionError and then advances the script, matching the
same operation-error boundary used by real drivers. Reading beyond a finite
script raises AcquisitionError by default; callers may request deterministic
repetition of the entire script. The simulator can also be reset to its first
entry.
This generic simulator is complementary to MAX31865SpiEmulator: the latter
exercises the real device driver through native register/SPI behavior, while
the former gives applications a hardware-free acquisition source without any
device emulation. Neither layer simulates an RTD temperature model or physical
process. Future noise, drift, clipping, or other acquisition-behavior models may
build on this boundary, but stochastic behavior should be explicitly controlled
and reproducible rather than silently introduced into the base simulator.
The embedded implementation is a portable C core. Arduino/HERO support is a platform adapter, not the core architecture.
Design requirements:
- no mandatory dynamic allocation;
- caller-controlled/fixed-size storage for measurements and diagnostics;
- small capability-specific HAL interfaces rather than one giant HAL object;
- platform functions injected through callbacks/interface structures;
- separate API-execution errors from acquisition
OK/WARNING/FAULT; - preserve the same measurement and diagnostic semantics as Python;
- support desktop fake HAL implementations for deterministic tests;
- allow an optional Arduino/C++ convenience wrapper later without creating a separate implementation.
The initial embedded hardware platform is an Arduino-compatible HERO board. The core must remain suitable for other MCU families such as STM32, ESP32, RP2040, and similar platforms.
The first capability-specific C HAL is rtd_acquire_spi_t. It carries:
- caller-owned opaque context;
- effective SPI settings corresponding to the Python
SpiSettingssemantics; - one transfer callback for a complete full-duplex transaction.
The transfer callback owns chip-select assertion/deassertion for the entire transaction, matching the Python transport boundary. A platform adapter may use hardware chip select or manually drive a GPIO internally; the MAX31865 driver does not need to know which mechanism is used.
The HAL reports host/platform transfer success separately from device-reported acquisition diagnostics. It requires no dynamic allocation and no Arduino-specific headers. The first contract test compiles and executes with an ordinary C11 host compiler so the current Arduino AVR / HERO adapter and future STM32, ESP32, RP2040, and Linux adapters can implement the same capability.
The MAX31865 core needs one additional platform capability: a blocking relative delay. It does not require a wall clock, monotonic timestamp source, timer allocation, or a generic GPIO API for the first implementation.
rtd_acquire_delay_t therefore contains only caller-owned opaque context and a
delay_us callback. Durations are unsigned integer microseconds rather than
floating-point seconds so the HAL has a deterministic language- and
platform-neutral unit and does not force an embedded adapter to provide
floating-point timing support.
A successful callback return means the requested interval has elapsed; an adapter may delay longer but must not return early. The MAX31865 C driver conservatively rounds computed minimum delays upward to whole microseconds before calling the HAL. Platform inability to perform the delay is reported through the HAL result and remains an API/acquisition-operation error, not a device diagnostic.
The delay HAL requires no dynamic allocation or Arduino-specific headers. The Arduino AVR / HERO adapter implements it with Arduino timing facilities, while desktop tests inject a fake callback that records requested durations without sleeping.
The C result contract preserves the Python Measurement, Diagnostic,
DiagnosticSeverity, DiagnosticCode, NativeEvidence, and derived-status
semantics without requiring heap allocation or a project-wide fixed diagnostic
maximum.
rtd_acquire_measurement_t binds caller-supplied arrays for normalized
diagnostics and native-evidence records. Each caller chooses capacities suitable
for its platform and use case. The completed result records the used counts, and
each diagnostic refers to a contiguous range within the measurement's shared
native-evidence array. This avoids nested allocation while preserving composite
native evidence.
Optional numeric values use explicit presence flags rather than NaN, infinity,
or another sentinel. The C scalar type is rtd_acquire_real_t, currently an
alias for C float. Cross-language conformance for the supported
Python-binary64/C-binary32 case is governed by the frozen
python-binary64-c-binary32 numeric profile; C conformance runners assert the
required binary32-style float properties before applying that profile.
Status remains derived rather than stored independently:
no diagnostics -> OK
WARNING diagnostics only -> WARNING
one or more FAULT diagnostics -> FAULT
The C validation helper enforces the same core invariants as Python: finite non-negative resistance and uncertainty when present, no duplicate normalized diagnostic codes, valid native-evidence ranges, non-empty native evidence, no resistance or uncertainty on a fault result, and at least one fault diagnostic when no trustworthy resistance is available.
Native-evidence identifier/message pointers are non-owning references. The caller, driver, or platform adapter that supplies the referenced text must keep it alive for as long as the completed measurement is consumed. Device drivers with fixed native terms can satisfy this naturally with static string literals.
The mutable initialized/reset state is assembly storage, not itself a completed
valid Measurement; a driver fills it and returns only a semantically valid
result or a separate execution/storage error.
The MAX31865 C configuration layer is pure device logic with no HAL side
effects. rtd_acquire_max31865_config_t mirrors the electrical facts in the
Python MAX31865Config contract using explicit presence flags for optional
thresholds. It validates the same reference-resistance range, 2-/3-/4-wire
topologies, 50/60 Hz filter choices, threshold ordering, and high-threshold
representability rule.
The layer also derives the static MAX31865 configuration bits for three-wire compensation and 50 Hz filtering. Operational bits such as BIAS, one-shot, fault-cycle control, and fault clearing are acquisition-sequence concerns rather than static configuration. Their register addresses and operational masks remain private implementation constants rather than becoming public configuration/API surface.
Public MAX31865 operations return rtd_acquire_max31865_result_t rather than a
bare success Boolean. This result vocabulary was frozen before acquisition
sequencing so the sequence can report configuration, SPI, delay, and
caller-storage failures without retrofitting the public signatures later. The
acquisition entry point validates rtd_acquire_spi_t.settings before its first
transfer; settings incompatible with MAX31865 requirements are reported as
RTD_ACQUIRE_MAX31865_RESULT_CONFIGURATION_ERROR rather than being folded into
SPI I/O failure.
Threshold encoding preserves the shared directional rule: low thresholds round
downward and high thresholds round upward so quantization cannot move a native
threshold inside the caller's requested diagnostic-free window. A high
threshold in the final unrepresentable band below R_REF is rejected rather
than clamped downward. Disabled thresholds use the device defaults 0x0000
(low) and 0xFFFF (high).
This threshold behavior is exact at the 16-bit register-output boundary, so the language-neutral threshold vectors require exact register outcomes in both Python and C. The separate binary64/binary32 profile applies to floating-point measurement outputs; it does not relax integer threshold-register results.
The second MAX31865 C slice decodes one native RTD register plus one fault-status
register into the caller-owned rtd_acquire_measurement_t contract. The mapping
uses the same six documented MAX31865 fault bits as Python, in D7-through-D2
order. Each normalized diagnostic retains one non-owning native-evidence record
with the documented bit identifier and device message. Reserved D1/D0 bits do
not create inferred diagnostics.
The native high- and low-resistance threshold bits are warnings, so a trustworthy resistance remains present when they are the only diagnostics. Any D5-through-D2 FAULT diagnostic removes resistance and uncertainty from the completed result, matching the shared trust invariant rather than returning a magic value. The RTD resistance is computed from the 15-bit ADC code and configured reference resistance; the RTD-register fault-indicator bit is excluded by shifting the register right by one bit.
The decoder performs no allocation. A MAX31865 native register state can produce
at most six normalized diagnostics and six native-evidence records, but those are
device-specific worst-case requirements rather than a project-wide fixed result
capacity. The public RTD_ACQUIRE_MAX31865_MAX_DIAGNOSTICS and
RTD_ACQUIRE_MAX31865_MAX_NATIVE_EVIDENCE constants let callers reserve enough
fixed storage when they want to guarantee that any native MAX31865 fault state
can be represented. If supplied result storage is insufficient, decoding fails as
an operation/storage error instead of silently dropping evidence.
The version-1 measurement-decode vectors execute against both Python and C
under the frozen python-binary64-c-binary32 numeric profile. The vector set
includes a non-binary32-exact reference resistance so the tolerance path is
exercised rather than relying only on specially exact seed values.
The portable C MAX31865 driver completes the same fault-checked one-shot
operation defined for Python while consuming only the frozen SPI and blocking-
delay HALs. rtd_acquire_max31865_read() accepts caller-owned SPI, delay,
configuration, timing, and measurement objects; it allocates no storage.
The C timing policy stores the external input-filter time constant as whole
microseconds. Callers whose physical time constant is not an integral number of
microseconds must round upward before supplying it. The driver then requests
the same conservative minimum delays as Python: 10.5 time constants plus 1 ms
after enabling VBIAS, 600 us for automatic fault detection, five time constants
plus 1 ms after fault detection, and 55 ms or 66 ms for 60 Hz or 50 Hz one-shot
conversion respectively. Half-microsecond results from the 10.5 multiplier are
rounded upward before reaching the integer delay HAL. Timing values whose
required waits cannot be represented by uint32_t are rejected as configuration
errors before I/O.
Before the first transfer, the acquisition entry point validates the SPI HAL and its effective settings: CPOL must be 0 or 1, CPHA must be 1, clock frequency must be greater than zero and no more than 5 MHz, transfers must be MSB-first with 8-bit words, and chip select must be active low. Incompatible settings are configuration errors rather than SPI I/O failures. Operational register addresses and BIAS/one-shot/fault-cycle masks remain private implementation constants.
Each read restores threshold registers, enables VBIAS while clearing faults, waits for settling, performs automatic fault detection, waits again, triggers one conversion, reads the RTD register, conditionally reads Fault Status when the RTD fault flag is set, and writes the static base configuration to turn VBIAS off before decoding the captured registers.
If SPI or delay execution fails after the driver has started the biased
sequence, it makes a best-effort write of the static base configuration to turn
VBIAS off while preserving the original SPI_IO_ERROR or DELAY_ERROR. A
failure of the normal final bias-off write is itself SPI_IO_ERROR. The caller's
Measurement is not rewritten for SPI, delay, or final-bias-off failures;
decoding occurs only after I/O and normal bias shutdown complete. If the native
fault state requires more diagnostics/evidence than the caller supplied, the
completed I/O operation can still return INSUFFICIENT_STORAGE; the decoder
leaves the previous measurement contents untouched rather than truncating
evidence.
The first concrete embedded platform adapter targets the Arduino AVR core used
by UNO R3-class boards, including the inventr.io HERO board. HERO is published
as a derivative of the Arduino UNO R3 reference design, so arduino:avr:uno is
the build target used for compile validation. The adapter is intentionally
platform-specific C++ around the portable C11 core; Arduino headers do not enter
c/include/rtd_acquire/ or c/src/.
rtd_acquire_arduino_avr_spi_init() binds an Arduino SPIClass instance and a
caller-selected chip-select GPIO to rtd_acquire_spi_t. It maps CPOL/CPHA to
Arduino SPI modes, maps bit order, supports 8-bit words, owns chip-select
assertion/deassertion around each beginTransaction()/endTransaction() pair,
and calls SPI.begin() during adapter initialization. The caller may release
that initialization reference with rtd_acquire_arduino_avr_spi_end().
The portable SPI contract records effective settings. ArduinoCore-avr's
SPISettings selects the fastest discrete AVR SPI divider that is no faster
than the requested rate, or the slowest available divider when the request is
below the hardware minimum. The adapter mirrors that divider selection when it
populates rtd_acquire_spi_t.settings.clock_frequency_hz. The requested rate is
still passed to SPISettings for each transaction, letting Arduino configure
the same divider. This matters for device validation: a 5 MHz MAX31865 request
on a 16 MHz HERO/UNO becomes an effective 4 MHz SPI clock rather than being
reported inaccurately as 5 MHz.
Arduino's byte SPI.transfer() API does not expose a transport-status result.
After adapter argument validation, a completed Arduino transaction therefore
maps to RTD_ACQUIRE_SPI_OK; the adapter cannot manufacture a host I/O error
that the platform API does not report. Device-reported MAX31865 faults remain in
Measurement diagnostics as usual.
rtd_acquire_arduino_avr_delay_init() binds the blocking delay HAL to Arduino's
delay() and delayMicroseconds() facilities. Because AVR unsigned int is
narrower than the HAL's uint32_t microsecond duration, the adapter sends whole
milliseconds through delay() and only the sub-millisecond remainder through
delayMicroseconds(). This preserves the HAL's full duration range without
truncating MAX31865 conversion or settling waits.
A strict host C++11 adapter test uses minimal Arduino/SPI stubs to verify
settings mapping, effective-clock reporting, transaction/chip-select ordering,
and long-delay splitting without requiring Arduino headers. CI additionally
stages the same portable C sources and adapter as a temporary Arduino library
and compiles the example sketch for arduino:avr:uno using Arduino AVR Boards
1.8.8. That compile gate validates toolchain/API compatibility but is not
physical HERO/MAX31865 validation; the real-hardware comparison remains a
separate unchecked roadmap item.
The optional Arduino/C++ convenience wrapper was evaluated at the 0.2 software scope-freeze checkpoint and deliberately deferred. The current adapter already lets Arduino sketches consume the portable C MAX31865 driver directly, and no real-hardware or user-feedback evidence yet demonstrates that a second object model would improve usability enough to justify another public API. A future wrapper may be added if that evidence emerges, but it must delegate to the C driver rather than duplicate acquisition, diagnostic, or timing semantics.
Python and C are independent implementations of a shared behavioral specification, not wrappers around one another by default.
When both implementations support the same hardware/configuration, identical raw/native device states should produce semantically equivalent:
- resistance values within explicitly defined numeric tolerances;
- measurement status;
- generic diagnostics;
- native diagnostic preservation;
- configuration behavior;
- acquisition uncertainty calculations when implemented.
Shared deterministic conformance vectors will exercise both implementations.
The version-1 language-neutral JSON format records public configuration,
raw/native device state, and expected normalized Measurement semantics.
Canonical normalized diagnostic messages are derived from DiagnosticCode and
therefore are not duplicated in vectors. Composite native evidence remains a
list of individual observations rather than an invented combined vendor code.
Numeric expected values in vectors are reference values. Acceptance tolerances belong to the conformance runner/profile so binary64 Python and embedded binary32 implementations can use appropriate numeric policies without forking the fixture data.
The first frozen numeric profile is stored in
conformance/v1/numeric_profiles.json as
python-binary64-c-binary32. It applies when Python uses radix-2, 53-bit
significand float and the C conformance target uses radix-2, 24-bit
significand float with the binary32 exponent range. The host C conformance
runners assert those C properties before executing the profile. A C target with
a different floating representation can still implement the portable API, but
it needs an explicitly defined target profile before claiming this particular
cross-language numeric conformance.
For MAX31865 measurement_decode, status, resistance presence/absence,
diagnostics, native evidence, and all integer/native fields remain exact
semantic requirements. Expected zero resistance must match zero exactly. A
nonzero C resistance is accepted when its relative difference from the
language-neutral reference value is no greater than 2^-22
(2.384185791015625e-7), with zero absolute tolerance. This is two binary32
machine epsilons: a deliberately tight computational allowance for binary32
input representation and arithmetic rounding, not a sensor-accuracy or
hardware-uncertainty allowance. It remains far below one MAX31865 ADC count
throughout the supported reference-resistance range.
Configuration acceptance is not made fuzzy by this profile. Cross-language
vectors that require exact acceptance/rejection must remain stable when their
floating configuration values are represented as binary32. In ordinary public
API use, Python can distinguish decimal threshold values that collapse to the
same C float; the C implementation validates the values it can actually
represent and may therefore reject such a configuration. Sub-binary32
configuration distinctions are outside this shared profile rather than being
hidden behind widened comparisons.
Real-hardware Python-vs-C comparison supplements but does not replace deterministic conformance testing.
Feature releases do not have to occur in Python/C lockstep.
rtd-acquire calibrates the electrical acquisition chain. rtd-sensor
calibrates or fits the RTD resistance/temperature model.
Examples that belong in rtd-acquire:
- measured reference-resistor values;
- ADC gain/offset correction;
- amplifier gain/offset correction;
- excitation-current correction;
- current-loop scaling;
- electrical zero/span calibration;
- measurement-topology/lead correction used to estimate element resistance.
Examples that belong in rtd-sensor:
- fitted
R0; - Callendar–Van Dusen coefficients;
- RTD characteristic fitting from temperature/resistance observations;
- RTD model uncertainty and tolerance interpretation.
Acquisition calibration belongs to device/configuration state rather than being repeated in every measurement.
Three compatibility claims are distinct:
- Manufacturer-supported — the vendor explicitly documents the relevant RTD family/model or a resistance-input mode that covers the stated use, with the required configuration/topology. The claim applies only to what the vendor actually documents: support for a generic resistance-input mode does not become a vendor claim for an RTD family that the documentation does not name.
- Electrically compatible — documented device limits plus engineering analysis show that the configured acquisition chain can measure the complete required resistance envelope and wiring/topology without violating relevant reference, excitation, gain, input, or representability limits, even if the vendor does not advertise that RTD family.
- rtd-acquire validated — the project has reproducible physical test evidence for the hardware/configuration combination. Validation never upgrades an electrically compatible combination into a manufacturer-supported claim.
Compatibility is configuration-specific rather than a simple property of a
converter chip. Reference resistance, excitation, gain, wiring, input limits,
and other configuration may determine whether a combination is usable. A nominal
R0 value alone is not enough: compatibility is checked against the complete
ideal resistance envelope of the companion rtd-sensor characteristic.
A compatibility record should identify enough evidence to reproduce the claim:
- exact acquisition device/module and relevant hardware revision when known;
- interface/backend used and software version or commit;
- RTD family/characteristic being assessed;
- required resistance envelope and its source/provenance;
- reference resistor/reference network, excitation, gain, wiring mode, channel, and other electrical settings that affect usable range;
- vendor documentation supporting any manufacturer-support claim;
- calculations and device limits supporting any electrical-compatibility claim;
- physical test platform/date, reference equipment, tested resistance points, acceptance limits, and result artifacts for project validation; and
- explicit limitations, untested regions, and unresolved assumptions.
Absence of one evidence class must remain visible. For example, successful bench tests can establish project validation for a configuration but cannot manufacture a vendor support statement that the vendor never made.
rtd-acquire validated can carry two useful evidence depths without creating
additional compatibility categories:
- range-validated — physical precision-resistance testing covers representative low, middle, and high points spanning the required acquisition envelope for a configuration. This validates resistance acquisition without depending on RTD temperature-model interpretation.
- family/hardware validated — the configured acquisition path is additionally exercised with the intended RTD family on real hardware, with applicable wiring behavior and any native-fault behavior required by the validation plan recorded.
A family/hardware validation does not imply that every resistance across the characteristic was physically tested. Conversely, range validation does not prove that a particular physical probe is suitable across its mathematical model's full temperature range. The validation record must say which evidence exists.
The project must not turn electrical-compatibility analysis, range validation, or family testing into a stronger claim than the evidence supports.
Versioned compatibility/evidence data lives under compatibility/. It is a
project evidence artifact rather than a runtime API and is deliberately separate
from deterministic implementation conformance under conformance/.
The initial v1 data freezes the six current RTD-family resistance requirements and the three independent claim dimensions defined above. Device/configuration record sets are added only when their evidence has been explicitly classified. The first v1 record set classifies conservative MAX31865 4-wire reference-network configurations; absent records remain neither implicit support nor implicit incompatibility.
Machine-readable records must retain explicit unknown/not-assessed states. They
must not collapse manufacturer support, electrical compatibility, and project
validation into one supported flag. Physical-validation depth also remains
separate from those compatibility dimensions. A record with
project_validation = not_validated must have no validation depths; a record
with project_validation = validated must declare at least one validation depth.
This invariant is enforced by compatibility-data tests so a depth label cannot
contradict the physical-validation claim.
As a project-level goal, rtd-acquire should provide at least one supported and
validated acquisition path for every RTD family supported by rtd-sensor.
Individual devices may legitimately support only a subset. The canonical list of
current parity targets lives in HARDWARE.md; it is intentionally not duplicated
here.
A future RTD family added to rtd-sensor creates a compatibility and validation
review in rtd-acquire. That review defaults to classifying existing acquisition
paths for manufacturer support, electrical compatibility, and project validation.
It does not by itself require a new hardware driver. New driver work is added
only when existing acquisition paths cannot provide suitable practical coverage
or when the normal roadmap criteria independently justify it.
Acquisition compatibility should be reasoned about electrically—primarily in
terms of resistance range and acquisition topology—not by embedding
resistance-to-temperature model logic into rtd-acquire.
A future rtd_acquire.custom facility should allow configurable acquisition
circuits without embedding RTD model science.
Prefer several explicit electrical models over an unstructured dictionary of specifications. Candidate models include:
- linear voltage-to-resistance;
- current-to-resistance;
- constant-current excitation;
- voltage divider;
- ratiometric ADC;
- amplified/offset input;
- user-defined transfer function.
The Python implementation may accept callables as an escape hatch; the C implementation may provide equivalent function-pointer callbacks.
Custom acquisition still returns the normal Measurement contract and obeys
the same no-inference diagnostic rule.
Declarative TOML/YAML/JSON configuration is a possible later feature, not a 0.1 requirement.
Simulation is first-class but remains within the acquisition domain.
rtd-acquire may simulate resistance sources, converters, ADC behavior, noise,
drift, clipping, communication failures, and acquisition diagnostics. It does
not simulate kettles, HVAC systems, or other physical processes being measured.
Two simulation levels are planned:
- generic simulated acquisition device for application/consumer tests;
- device-specific emulation/fake transports for exercising real drivers.
Fault injection must reproduce actual native/observable conditions rather than invent inferred causes. Random behavior must be deterministic when given a seed and should be disabled by default in normal tests.
Testing tiers:
- Unit/conformance — no physical hardware; deterministic vectors and fake transports; runs in ordinary CI.
- Hardware-in-the-loop — real converter + RTD/reference resistance on supported test platforms.
- Cross-platform physical validation — compare Python/Raspberry Pi, C/HERO, and where practical an independent reference instrument.
Precision reference resistors or a resistance simulator should be used to separate acquisition validation from RTD temperature-model validation.
- MAX31865 — first implementation; common dedicated RTD converter.
- ADS124S08 — second family; configurable precision ADC/front end that exercises excitation, references, gain, channels, calibration, and richer diagnostics.
- Industrial acquisition — universal resistance inputs and 4–20 mA transmitters, followed by appropriate digital interfaces where raw resistance/electrical data can be acquired without duplicating RTD model interpretation.
- Additional precision ADCs — including the AD7124 family where useful.
- Custom acquisition — generic electrical transfer models and eventual home-built analog front ends.
The initial repository keeps implementation areas separate without freezing fine-grained module structure too early:
rtd-acquire/
├── README.md
├── LICENSE
├── pyproject.toml
├── src/rtd_acquire/ # Python implementation
├── tests/ # Python/unit/conformance tests
├── c/ # portable C implementation and HAL adapters
├── conformance/ # language-neutral fixtures/vectors
├── compatibility/ # versioned RTD requirements/evidence data
├── examples/ # integration and hardware examples
├── docs/
│ ├── DESIGN.md
│ ├── ROADMAP.md
│ ├── HARDWARE.md
│ ├── DIAGNOSTICS.md
│ ├── REFERENCES.md
│ └── CHANGELOG.md
└── .rtd-acquire-local/ # ignored local research/experiments
Fine-grained driver/transport package layout should be introduced as real implementation work requires it rather than pre-created speculatively.
The following should remain explicit design work rather than hidden assumptions:
- exact constructor/configuration shapes for hardware families after MAX31865;
- exact HAL signatures for capabilities beyond the frozen SPI and delay interfaces;
- exact industrial device/interface selected for the first industrial driver;
- exact development board/module selected for ADS124S08 hardware tests;
- uncertainty models that can be defended for each acquisition family.