Parsing Modbus Registers for Turbidity Sensors

Continuous turbidity monitoring is a regulatory and operational imperative for drinking water treatment and distribution systems, and the exact engineering task on this page is turning the raw holding registers of an inline optical transmitter into a compliance-ready nephelometric turbidity unit (NTU) value with an unambiguous quality flag. This is the reference implementation within the parent Modbus TCP Parsing Workflows section, written for the Python automation builders who own the ingestion service and the environmental compliance teams who sign the resulting reports. Under 40 CFR Part 141 (Subpart T) and EPA Method 180.1, utilities must capture, validate, and report NTU with strict auditability, gap-filling protocols, and percentile-based compliance thresholds — yet a raw register read rarely maps cleanly onto that dataset. Accurate parsing demands deterministic handling of register mapping, IEEE 754 floating-point conversion, byte-order normalization, and regulatory validation before a value ever enters the broader SCADA Data Ingestion & Time-Series Sync architecture.

Prerequisites & Environment Setup

The implementation targets Python 3.10+ and the asynchronous pymodbus client, which is the practical baseline for high-frequency polling of field devices. Pin the library versions explicitly, because the payload-decoding API changed between major releases: on pymodbus 3.x the BinaryPayloadDecoder helper used below is available, whereas on 4.x it is removed and the same two registers are decoded with client.convert_from_registers(...) (see the Troubleshooting & Gotchas section). Schema enforcement with pydantic is optional here but recommended once records flow into the compliance pipeline.

Before writing code, you also need two things that do not come from a package: network access and a device profile. Turbidity transmitters live on the operational-technology (OT) control network, and the parser must reach them only through the read-only egress path described in Security Boundary Design — never as a writable client. The device profile is the manufacturer’s communication map, which pins the register block, the scaling factor, and the byte order for your specific transmitter; without it, every value you decode is a guess.

python3 -m venv .venv && source .venv/bin/activate
pip install "pymodbus[serial]==3.6.9" "pydantic==2.7.*"
# Optional: pandas for downstream historian batching
pip install "pandas==2.2.*"

Step-by-Step Implementation

Step 1 — Locate the register block and pin the byte-order profile

Turbidity transmitters generally allocate two consecutive 16-bit holding registers to represent a single 32-bit IEEE 754 floating-point NTU value. Manufacturers diverge in addressing conventions (0-based vs. 1-based, the 40001 offset notation), scaling multipliers (for example, 0.01 NTU/LSB), and byte ordering (ABCD, CDAB, BADC, DCBA). Many devices also embed diagnostic flags in adjacent registers to signal optical fouling, lamp degradation, or calibration mode.

A compliant routine must first isolate the exact register block documented in the transmitter’s communication map. For example, if registers 30010 and 30011 return the raw hexadecimal values 0x439A and 0x0000, the concatenated 32-bit word must be interpreted according to the sensor’s specified endianness before conversion. Misaligned byte or word swapping is the leading cause of phantom NTU spikes that trigger false exceedance alerts on compliance dashboards. Always confirm whether the device uses big-endian (network order) or little-endian (Intel order) byte and word sequencing, and whether the manufacturer applies a fixed scaling factor or offset after conversion. Python’s struct module handles low-level unpacking, but production deployments typically rely on a higher-level payload decoder to abstract away the endianness permutations. The decode-and-guard flow that the next step implements is summarized below.

How four byte-order profiles reassemble the same two registers into different floats The two source registers supply four physical bytes A, B, C and D. Each endianness profile rebuilds the 32-bit IEEE 754 word from most-significant byte to least-significant byte in a different order. ABCD keeps A B C D and yields the correct 308.0 NTU; CDAB (word swap), BADC (byte swap) and DCBA (byte and word swap) all yield phantom values from the identical raw registers. Register 30010 · high word Register 30011 · low word A B C D 0x43 0x9A 0x00 0x00 Each profile rebuilds the 32-bit word MSB → LSB: MSB LSB ABCD ABCD 0x43 9A 00 00 = 308.0 NTU · correct CDAB CDAB 0x00 00 43 9A word-swapped · phantom BADC BADC 0x9A 43 00 00 byte-swapped · phantom DCBA DCBA 0x00 00 9A 43 byte+word · phantom Turbidity decode pipeline with NaN/Inf and NTU-range guards feeding a quality flag A vertical decode path with two decision diamonds. After reading two registers and decoding the IEEE 754 float, a NaN or Inf check branches to a BAD quality flag. Otherwise scale and offset are applied and an NTU-range check branches to SUSPECT when out of range or GOOD when in range. The BAD, SUSPECT and GOOD quality flags all converge into the fallback router. Read 2 holding registers Decode IEEE-754 float Apply scale & offset NaN / Inf? In NTU range? quality = BAD quality = SUSPECT quality = GOOD Fallback router yes no no yes

Step 2 — Decode the 32-bit float with explicit validity guards

Municipal developers and automation engineers deploy asynchronous Modbus clients for high-frequency polling, paired with deterministic payload unpacking so that every reading lands in the historian with an unambiguous quality flag. The function below reads the register block, validates response integrity, applies IEEE 754 conversion, and enforces scaling and physical bounds in a single pass. After decoding, the calibrated value is derived from the raw float with a linear scaling equation, NTUcal=mNTUraw+b\text{NTU}_{cal} = m \cdot \text{NTU}_{raw} + b, where mm is the device scale factor and bb the offset. It targets the pymodbus v3.x async client and includes explicit NaN/Inf guards.

import logging
import math
from datetime import datetime, timezone
from typing import Any, Dict, Optional
from pymodbus.client import AsyncModbusTcpClient
from pymodbus.payload import BinaryPayloadDecoder
from pymodbus.constants import Endian

logger = logging.getLogger("turbidity_parser")

# Maps a device's declared register layout to (byteorder, wordorder) as
# expected by BinaryPayloadDecoder.fromRegisters. The label names the on-wire
# byte sequence, where A is the most significant byte of the 32-bit float:
#   ABCD = no swap          -> byteorder BIG,    wordorder BIG
#   BADC = bytes swapped    -> byteorder LITTLE, wordorder BIG
#   CDAB = words swapped    -> byteorder BIG,    wordorder LITTLE
#   DCBA = bytes and words  -> byteorder LITTLE, wordorder LITTLE
# pymodbus 3.x exposes the uppercase members Endian.BIG / Endian.LITTLE.
BYTE_ORDER_MAP = {
    "ABCD": (Endian.BIG, Endian.BIG),
    "BADC": (Endian.LITTLE, Endian.BIG),
    "CDAB": (Endian.BIG, Endian.LITTLE),
    "DCBA": (Endian.LITTLE, Endian.LITTLE),
}

async def parse_turbidity_ntu(
    client: AsyncModbusTcpClient,
    register_address: int,
    unit_id: int,
    byte_order: str = "ABCD",
    scale_factor: float = 1.0,
    offset: float = 0.0
) -> Dict[str, Any]:
    """
    Reads two consecutive holding registers, decodes IEEE 754 float,
    applies scaling/offset, and returns compliance-ready payload.
    """
    if byte_order not in BYTE_ORDER_MAP:
        raise ValueError(f"Unsupported byte order: {byte_order}. Use {list(BYTE_ORDER_MAP.keys())}")

    byteorder, wordorder = BYTE_ORDER_MAP[byte_order]

    try:
        response = await client.read_holding_registers(
            address=register_address, count=2, slave=unit_id
        )

        if response.isError():
            logger.error(f"Modbus read error at register {register_address}: {response}")
            return {"value": None, "quality": "BAD", "timestamp": datetime.now(timezone.utc)}

        decoder = BinaryPayloadDecoder.fromRegisters(
            response.registers, byteorder=byteorder, wordorder=wordorder
        )
        raw_ntu = decoder.decode_32bit_float()

        # IEEE 754 validity check
        if math.isnan(raw_ntu) or math.isinf(raw_ntu):
            logger.warning(f"Invalid IEEE 754 value detected: {raw_ntu}")
            return {"value": None, "quality": "BAD", "timestamp": datetime.now(timezone.utc)}

        calibrated_ntu = (raw_ntu * scale_factor) + offset

        # Physical bounds validation (typical optical range: 0.00 - 4000 NTU)
        if not (0.0 <= calibrated_ntu <= 4000.0):
            logger.warning(f"Out-of-range NTU value: {calibrated_ntu}")
            return {"value": calibrated_ntu, "quality": "SUSPECT", "timestamp": datetime.now(timezone.utc)}

        return {
            "value": round(calibrated_ntu, 4),
            "quality": "GOOD",
            "timestamp": datetime.now(timezone.utc)
        }

    except Exception as e:
        logger.exception(f"Unhandled parsing exception: {e}")
        return {"value": None, "quality": "BAD", "timestamp": datetime.now(timezone.utc)}

Step 3 — Add deterministic fallback routing

Network instability, PLC reboots, and sensor maintenance windows inevitably interrupt Modbus polling, and compliance reporting cannot tolerate unhandled None values or silent data gaps. Production systems therefore need deterministic fallback routing that preserves audit continuity while raising immediate operational alerts. The router below serves the last known-good reading within a bounded staleness window, then escalates to an explicitly invalid hold value once that window expires. This is the same discipline applied — from the detection side — when handling missing sensor readings without triggering false violations.

Fallback router quality state machine: GOOD, INTERPOLATED and OFFLINE Three states left to right. The machine starts in GOOD (live reading). A read failure within the staleness window transitions to INTERPOLATED (last-good held); a fresh good read returns to GOOD. When the staleness window expires it transitions to OFFLINE (excluded from historian, alert fires); sustained valid reads return to GOOD. GOOD INTERPOLATED OFFLINE live reading last-good held excluded · alert read fails · in window fresh good read window expires sustained valid reads
class TurbidityFallbackRouter:
    def __init__(self, max_stale_seconds: int = 120):
        self._last_good: Optional[Dict[str, Any]] = None
        self._stale_threshold = max_stale_seconds
        self._alert_triggered = False

    def resolve(self, current: Dict[str, Any]) -> Dict[str, Any]:
        if current["quality"] == "GOOD":
            self._last_good = current
            self._alert_triggered = False
            return current

        now = datetime.now(timezone.utc)

        # Fallback 1: Last-known-good interpolation
        if self._last_good and (now - self._last_good["timestamp"]).total_seconds() < self._stale_threshold:
            fallback = self._last_good.copy()
            fallback["quality"] = "INTERPOLATED"
            fallback["timestamp"] = now
            return fallback

        # Fallback 2: Compliance-safe default with hard alert
        if not self._alert_triggered:
            logger.critical("Turbidity sensor offline. Defaulting to compliance-safe hold. Verify field diagnostics.")
            self._alert_triggered = True

        return {
            "value": -1.0,  # Explicitly invalid to force historian exclusion
            "quality": "OFFLINE",
            "timestamp": now
        }

This routing pattern keeps downstream time-series databases from ingesting unvalidated floats and escalates to operators as soon as polling degrades beyond an acceptable threshold. Tie SNMP traps or MQTT alerts to the OFFLINE quality flag so that field technicians are dispatched before any regulatory reporting window closes.

Step 4 — Attach compliance quality flags and audit provenance

After decoding and fallback resolution, NTU values must undergo regulatory validation. Under 40 CFR Part 141, utilities flag values that exceed 0.3 NTU for filtered systems or 5.0 NTU for unfiltered systems, and apply rolling 95th-percentile calculations for monthly reporting. The parser attaches a data-quality flag (GOOD, SUSPECT, BAD, INTERPOLATED, or OFFLINE) derived from IEEE 754 validity, sensor diagnostics, and the out-of-range check. This structured output feeds the SDWA MCL Reference Mapping for threshold context and the Violation Detection Rule Engine for exceedance evaluation, while the raw timestamps are reconciled to a single axis by the time-series alignment strategies module.

For audit readiness, every parsed record must include:

  1. Source metadata: Device ID, register address, polling interval
  2. Quality provenance: Raw vs. interpolated vs. offline
  3. Regulatory flags: Threshold breaches, percentile impact
  4. Immutable timestamps: UTC-synced with millisecond precision

Configuration Reference

The three tables below capture every device-specific parameter the parser depends on. Pull the register map and scaling values directly from the transmitter’s communication map; treat them as versioned configuration, never as inline constants.

Register map (representative 32-bit float transmitter)

Register Address (0-based) Width Contents Access
Turbidity high word 30010 16-bit Most-significant half of IEEE 754 float Read (FC 0x04)
Turbidity low word 30011 16-bit Least-significant half of IEEE 754 float Read (FC 0x04)
Diagnostic status 30012 16-bit Fouling / lamp / calibration bit flags Read (FC 0x04)

Byte-order profiles

Profile Byte order Word order Typical origin
ABCD Big Big IEEE 754 network order; nominal default
CDAB Big Little (word swap) Modicon-lineage PLCs
BADC Little (byte swap) Big Some protocol gateways
DCBA Little Little Full little-endian devices

Parser parameters and quality-flag codes

Parameter / flag Type Default Meaning
register_address int 0-based address of the high word
unit_id int Modbus slave / unit identifier
byte_order str ABCD Endianness profile from the device map
scale_factor float 1.0 Multiplier mm applied after decode
offset float 0.0 Additive offset bb applied after decode
GOOD flag Finite value within 0–4000 NTU
SUSPECT flag Decoded but outside physical range
BAD flag Modbus error or NaN/Inf
INTERPOLATED flag Last-known-good served within staleness window
OFFLINE flag Staleness window expired; excluded from historian

Verification & Testing

Confirm the decode path with a deterministic unit test that feeds known register words through the byte-order map and asserts the exact float. Because 0x439A0000 is 308.0 in IEEE 754 big-endian order, a correct ABCD decode must return that value, and the same words under CDAB must not.

import struct
from pymodbus.payload import BinaryPayloadDecoder
from pymodbus.constants import Endian


def decode(registers, byteorder, wordorder):
    return BinaryPayloadDecoder.fromRegisters(
        registers, byteorder=byteorder, wordorder=wordorder
    ).decode_32bit_float()


def test_abcd_decode_matches_ieee754():
    # 0x439A0000 -> 308.0 as a big-endian IEEE 754 single
    assert struct.unpack(">f", bytes.fromhex("439A0000"))[0] == 308.0
    assert decode([0x439A, 0x0000], Endian.BIG, Endian.BIG) == 308.0


def test_wrong_word_order_changes_value():
    correct = decode([0x439A, 0x0000], Endian.BIG, Endian.BIG)
    swapped = decode([0x439A, 0x0000], Endian.BIG, Endian.LITTLE)
    assert correct != swapped  # phantom-spike signature

Acceptance criteria before promoting the parser to production:

Troubleshooting & Gotchas

  • Phantom NTU spikes on the compliance dashboard. The classic signature of a wrong endianness profile: a plausible-but-false value with no protocol-layer error. Reproduce the reading under all four profiles and match against a bench reference or the transmitter’s local display; pin the profile that agrees, then lock it in configuration.
  • ImportError: BinaryPayloadDecoder on pymodbus 4.x. The helper is removed in 4.x. Decode the same two registers with client.convert_from_registers(response.registers, data_type=client.DATATYPE.FLOAT32, word_order="big"), or pin pymodbus==3.6.9 if you are not ready to migrate.
  • Every read returns a Modbus error. Usually a wrong unit_id/slave or a register offset mismatch (1-based map vs. 0-based client). Confirm the transmitter’s slave ID and subtract the 40001/30001 offset when translating documented addresses to the client’s 0-based address.
  • None values corrupt historian averages. A raw None written into a time-series database poisons percentile math. Always route the parser output through TurbidityFallbackRouter so gaps become an explicit OFFLINE sentinel that downstream queries can exclude.
  • Readings drift by an hour twice a year. The transmitter is stamping local wall-clock time and crossing a daylight-saving transition. Normalize to UTC at ingestion and reconcile with aligning irregular SCADA timestamps to UTC before any averaging window is computed.