Handling Floating-Point Drift in Sensor Readings
Floating-point drift in manufacturing telemetry is rarely a mathematical curiosity; it is a deterministic pipeline failure mode. When analog process variables traverse from PLC analog input modules through edge gateways into cloud aggregation layers, IEEE 754 binary representation introduces cumulative rounding artifacts that silently corrupt cycle-time baselines, throughput counters, and quality-yield thresholds. This is a focused problem within Precision & Rounding Limits, the precision subsystem of Core Architecture & Data Mapping: here we deal with the specific case where a value such as 12.45 arrives as 12.450000000000001 and trips a hard threshold it should never have crossed. For industrial engineers and IIoT developers, unmanaged drift directly skews the Availability, Performance, and Quality terms of OEE, producing phantom equipment stops, false threshold crossings, and inconsistent run-to-run yield reporting.
The defensible fix is not a single rounding call buried in a dashboard query. It is an architecture that isolates the serialization boundary, transmits exact values across MQTT, and enforces deterministic quantization before telemetry reaches the OEE calculation engine. The sections below cover the root cause and the three mitigation strategies that actually hold up at factory throughput.
Where the drift enters: fixed-point PLCs vs. IEEE 754 serialization Permalink to this section
The primary driver of drift is the impedance mismatch between PLC native fixed-point scaling and cloud-native floating-point expectations. Most mid-tier and legacy PLCs store analog inputs as scaled integers — 16-bit raw counts mapped to engineering units (EU) through a linear scale factor, EU = (Raw - Offset) × Scale. This integer-and-scale convention is exactly what disciplined PLC tag standardization is meant to preserve end to end. The trouble starts when MQTT topic hierarchies serialize those values as JSON floats for cross-platform interoperability.
During that conversion, decimal fractions that cannot be exactly represented in binary — 0.1, 0.2, 0.3 and most non-power-of-two fractions — pick up representation error. For float32 the error floor is roughly 1e-7 of the magnitude; for float64 it is roughly 1e-15. Each hop that re-parses and re-serializes the value can re-quantize it. By the time the payload reaches a time-series database sync process, the drift compounds across partition boundaries, particularly during high-frequency resampling or rolling-window aggregation. A vibration sensor reading 12.450000000000001 instead of 12.45 is harmless until it crosses a hard-coded > 12.45 comparison in a state machine and triggers an unwarranted line stop.
The error is bounded but real. A reading rounded into float32 near 12.45 carries an absolute uncertainty on the order of , which is why a tolerance band of ±1e-6 is the practical detection floor for single-precision payloads and why exact equality or hard >/< comparisons on raw floats are unsafe.
Option 1 — Transmit scaled integers at the edge Permalink to this section
The cleanest mitigation removes the float from the wire entirely. Instead of letting the gateway emit {"value": 12.450000001}, transmit the raw scaled integer plus the metadata needed to reconstruct the engineering unit deterministically downstream. A well-structured topic tree keeps raw telemetry, aggregates, and scaling metadata on separate branches:
factory/line_01/station_04/raw/temperature_c
factory/line_01/station_04/agg/temperature_c_1m
factory/line_01/station_04/meta/temperature_c_scale
The payload carries the integer count and an exact decimal scale factor, never the pre-multiplied float:
{
"ts": 1718452800000,
"v": 124500,
"scale": "0.0001",
"unit": "degC",
"precision": "float32"
}
The consumer reconstructs the value with exact decimal arithmetic, so 124500 × 0.0001 resolves to 12.4500 with no binary residue. Sending scale as a decimal string rather than 1e-4 avoids reintroducing a float at the very step you are trying to protect. This pattern is the strongest defence because it eliminates the lossy hop instead of trying to repair its output, and it pairs naturally with discrete-state telemetry delivered over QoS 1 to avoid duplicate event counters.
from decimal import Decimal
def decode_scaled_payload(payload: dict) -> Decimal:
"""Reconstruct an engineering-unit value from a scaled-integer MQTT payload.
Keeps arithmetic in Decimal so the integer count and string scale factor
never touch IEEE 754 binary representation.
"""
raw = int(payload["v"])
scale = Decimal(str(payload["scale"]))
return Decimal(raw) * scale # e.g. 124500 * Decimal("0.0001") -> Decimal("12.4500")
Option 2 — Deterministic rounding with Python’s decimal module Permalink to this section
When you cannot control the edge firmware and floats are already on the wire, enforce a deterministic round at the ingestion boundary — before the value is persisted or compared. Python’s decimal module sidesteps binary representation and lets you pin both precision and rounding mode, so every node in the pipeline produces byte-identical results.
from decimal import Decimal, ROUND_HALF_EVEN, getcontext
import pandas as pd
# Configure a single, explicit context for manufacturing precision.
getcontext().prec = 18
getcontext().rounding = ROUND_HALF_EVEN # banker's rounding: unbiased over many samples
def quantize_telemetry_series(series: pd.Series, precision: int = 4) -> pd.Series:
"""Apply deterministic rounding to a Series of sensor readings.
Converts via str() so the *displayed* float (12.45), not its binary
expansion (12.450000000000001), is what gets quantized.
"""
quantizer = Decimal(10) ** -precision
return series.apply(lambda x: Decimal(str(x)).quantize(quantizer))
raw_df = pd.DataFrame({"temp_c": [12.450000001, 12.449999999, 12.450000002]})
raw_df["temp_c_clean"] = quantize_telemetry_series(raw_df["temp_c"], precision=4)
Two details matter. First, Decimal(str(x)) rather than Decimal(x) — the former captures the human-meaningful value, the latter faithfully reproduces the binary error you are trying to remove. Second, ROUND_HALF_EVEN (banker’s rounding) is the correct default for SPC and OEE: ROUND_HALF_UP introduces a small positive bias that accumulates into inflated performance rates over millions of samples. Choose precision from the sensor’s physical resolution, not the database’s column width — see the precision budget in the parent Precision & Rounding Limits reference.
Option 3 — Fixed-point integer emulation for high-throughput aggregation Permalink to this section
Decimal is correct but slow; at hundreds of thousands of rows per second it becomes the bottleneck. For hot paths, emulate fixed-point with int64 arithmetic and keep aggregation entirely in integer space:
import numpy as np
import pandas as pd
def to_fixed_point(series: pd.Series, decimals: int = 4) -> np.ndarray:
"""Scale floats to int64 fixed-point. Rounds at the boundary so the
integer count is exact; all downstream sum()/count()/compare stay exact.
"""
factor = 10 ** decimals
return np.rint(series.to_numpy() * factor).astype(np.int64)
def aggregate_fixed_point(scaled: np.ndarray, decimals: int = 4) -> dict:
"""Aggregate in integer space, divide back to float only for reporting."""
factor = 10 ** decimals
total = int(scaled.sum()) # exact integer sum, no drift
count = int(scaled.size)
return {
"sum": total / factor,
"mean": (total / count) / factor if count else 0.0,
"count": count,
}
readings = pd.Series([12.450000001, 12.449999999, 12.450000002])
agg = aggregate_fixed_point(to_fixed_point(readings))
The rule is: round once, at the int64 boundary, then never touch a float again until the visualization or reporting layer. sum(), count(), and threshold comparisons in integer space are exact by construction, which completely neutralizes drift in the inputs to OEE. The same discipline underpins downstream OEE formula validation and prevents off-by-epsilon errors when tuning thresholds for microstops, where a single false threshold crossing can register a phantom stop.
Watch the dynamic range: an int64 holds about 9.2×10¹⁸, so four decimal places leaves headroom up to ~9.2×10¹⁴ engineering units — ample for process variables, but verify before scaling counters with very large magnitudes.
Diagnosing drift at the serialization boundary Permalink to this section
To prove where drift originates rather than guessing, log both the raw payload bytes and their IEEE 754 hex representation at the deserialization point. A lightweight interceptor flags any value that exceeds tolerance before it reaches the broker.
import struct
import numpy as np
def inspect_float_drift(raw_bytes: bytes, expected: float, tol: float = 1e-6) -> dict:
"""Decode a 4-byte float32 payload and compare its IEEE 754 form against
the expected engineering value, surfacing the exact hex bit pattern."""
val = struct.unpack("!f", raw_bytes)[0] # network byte order
hex_repr = f"0x{np.float32(val).view(np.uint32):08X}"
drift = abs(val - expected)
return {
"raw_hex": hex_repr,
"deserialized_f32": val,
"expected": expected,
"absolute_drift": drift,
"exceeds_tolerance": drift > tol,
}
print(inspect_float_drift(struct.pack("!f", 12.45), 12.45))
If the hex pattern is already wrong at the gateway, the PLC scaling or edge serialization is the culprit; if it is clean at the edge but drifts after a TSDB round-trip, the aggregation layer is re-quantizing. This is the same boundary-inspection discipline used when syncing edge timestamps with NTP servers — instrument the exact hop before assigning blame.
Gotchas & anti-patterns Permalink to this section
- Rounding only at the dashboard. Cosmetic rounding in a Grafana query leaves drifted values in storage, so alerts, continuous queries, and state machines still compare against the dirty number. Round at ingestion, before persistence.
Decimal(x)instead ofDecimal(str(x)). Passing a float straight intoDecimalfaithfully copies its binary error — exactly the artifact you meant to remove.ROUND_HALF_UPon SPC data. The default-feeling choice adds a tiny positive bias that compounds into inflated performance rates across millions of samples; useROUND_HALF_EVEN.- Exact equality or hard
>/<on raw floats. A> 12.45threshold on un-quantized telemetry fires on12.450000001. Compare quantized values, or use an explicit tolerance band. - Re-serializing through intermediate float hops. Every gateway that parses and re-emits a value can re-quantize it. Carry scaled integers, or quantize once and mark the tag as normalized so downstream stages do not “helpfully” round again.
Quick-reference: choosing a mitigation Permalink to this section
| Strategy | Best when | Throughput cost | Exactness | Key caveat |
|---|---|---|---|---|
| Scaled-integer edge payload | You control the gateway firmware | Lowest (no float on wire) | Exact | Requires scale/unit metadata contract |
decimal deterministic round |
Floats already on the wire; correctness over speed | High (per-value Python) | Exact to set precision | Must use Decimal(str(x)) and ROUND_HALF_EVEN |
int64 fixed-point aggregation |
Hot path, millions of rows/sec | Low (vectorized integer math) | Exact for sum/count/compare | Mind int64 dynamic range |
| Hex/byte interceptor | Diagnosing an existing drift bug | Logging only (sampled) | n/a (diagnostic) | Adds I/O; sample, don’t log every payload |
Treating precision as a first-class pipeline property — set at the edge, enforced at ingestion, preserved through aggregation — turns floating-point drift from an intermittent mystery into a closed engineering constraint, and keeps OEE reporting deterministic and audit-ready.
Related Permalink to this section
- Precision & Rounding Limits — parent reference: precision budgets, ADC resolution, and rounding contracts.
- Syncing Edge Timestamps with NTP Servers — the temporal counterpart to numeric drift.
- How to Map Siemens S7 Tags to OPC UA — preserve scale factors and data types across the tag boundary.
- Z-Score Filtering for Vibration Anomalies — distinguishing real outliers from precision noise.