Modbus TCP Parsing Workflows for Municipal Water Compliance

Reliable extraction of process telemetry from legacy and modern programmable logic controllers (PLCs) establishes the operational baseline for the SCADA Data Ingestion & Time-Series Sync domain. Within municipal water infrastructure, Modbus TCP remains the dominant field protocol for continuous monitoring of treatment basins, distribution pressure zones, and finished-water storage. This section covers the end-to-end workflow that turns raw register reads into auditable, EPA-ready datasets: deterministic connection management, byte-level decoding, rule-based validation, and time-series archival. It is written for the Python automation builders who own the ingestion service, the environmental compliance teams who sign the resulting reports, and the municipal operations engineers who maintain the field devices. Everything here feeds the same regulatory contract defined in the parent Core Architecture & SDWA Compliance Taxonomy, so a decoded value carries a device_id, a method_code, a UTC timestamp, and a quality flag from the moment it leaves the wire.

Modbus parsing skeleton, from raw register read to a quality-flagged value A left-to-right pipeline: read holding or input registers, assemble the multi-register payload, decode using the device byte and word order, then apply scale and offset. A decision node tests whether the result is within plausible range. In-range readings pass to a validated value; out-of-range readings branch to a SUSPECT or BAD flag. Read holding / input registers Assemble payload Decode byte / word order Apply scale & offset In plausible range? Validated value Flag SUSPECT / BAD yes no

Regulatory / Protocol Foundation

Modbus is an application-layer protocol with no authentication, no encryption, and — critically — no self-describing type information. A holding register is sixteen bits of opaque data; whether those bits represent a scaled integer, half of an IEEE 754 float, or a packed status word is knowledge that lives only in the device’s communication map, not in the frame. Modbus TCP wraps the classic protocol data unit in a Modbus Application Protocol (MBAP) header and runs over standard Ethernet on TCP port 502. The read operation is selected by function code, and municipal water measurement almost always uses one of four:

Function code Operation Register width Access Typical water use
0x03 Read Holding Registers 16-bit R/W Read Setpoints, scaled analog values, floats
0x04 Read Input Registers 16-bit read-only Read Live analyzer measurements
0x01 Read Coils 1-bit R/W Read Pump / valve run status
0x02 Read Discrete Inputs 1-bit read-only Read Alarm and fault bits

The reason this matters for compliance is that the EPA Safe Drinking Water Act (SDWA) framework and its implementing regulations in 40 CFR Part 141 impose continuous monitoring obligations that are satisfied by exactly these register reads. The Surface Water Treatment Rules drive sub-minute turbidity and disinfection surveillance; the Stage 1 and Stage 2 Disinfectants and Disinfection Byproducts Rules require chlorine-residual and disinfection-byproduct precursor tracking; and each rule dictates the sampling cadence the parser must sustain without gaps. Because a lapse in the telemetry stream is legally a monitoring gap, the parsing workflow is not merely a data-acquisition convenience — it is the evidentiary front end of the entire compliance record. The specific thresholds those measurements are judged against are resolved downstream against the SDWA MCL Reference Mapping, and the required intervals are generated by Monitoring Frequency Scheduling.

Architecture & Design Decisions

The parsing service is designed as a strictly passive, read-only consumer of the control network. It never writes a coil or a register, because a compliance pipeline that can actuate a pump is a treatment risk, not just a data risk — the rationale detailed in Security Boundary Design. Three design decisions shape the rest of this section.

Deterministic decoding over convenience. The most consequential design choice is that byte and word order are configuration, not inference. Because the protocol carries no type information, the same two registers decode to wildly different floats under different endianness profiles, and a wrong profile produces a plausible-but-false value with no protocol-layer error. Every device therefore has an explicit byte-order profile pinned to its communication map:

Profile Word order Byte order Notes
ABCD Big Big IEEE 754 network order; the nominal default
CDAB Little (word-swap) Big Very common on Modicon-lineage PLCs
BADC Big Little (byte-swap) Rarer; seen on some gateways
DCBA Little Little Full little-endian

Immutable raw records before transformation. The service serializes every read into an append-only record — device_id, register_address, raw_hex, byte_order, acquisition_timestamp, polling_latencybefore any scaling. Keeping the raw payload means a decoding rule can be corrected and the history re-decoded without a return trip to the field.

Normalization at the edges. Where a facility runs heterogeneous equipment, raw Modbus streams are reconciled against a common semantic model through OPC UA Data Extraction gateways, which supply unit metadata and native quality codes. The cleaned, decoded records then flow to Time-Series Alignment Strategies for UTC normalization and resampling, and to Async Batch Processing Setup when polling fan-out exceeds what a single event loop should carry.

Modbus tag data contract: immutable raw record, decode and scale, enriched compliance record Left panel is the immutable, append-only raw record with device_id, register_address, raw_hex, byte_order and acquisition_timestamp. It passes through a decode-and-scale stage that resolves byte order and applies the calibration curve. The right panel is the enriched compliance record with value, method_code, sample_ts in UTC and quality_flag. A dashed return arrow labelled re-decode shows that a corrected rule can be replayed over the retained raw payload. rule fix → re-decode retained history Raw record immutable · append-only Decode + scale byte order → eng. units Compliance record enriched · report-ready device_id register_address raw_hex byte_order acquisition_timestamp value method_code sample_ts (UTC) quality_flag

Phase-by-Phase Implementation

The workflow is built in four phases, each producing an artifact the next depends on: a resilient session, an immutable raw record, a validated engineering value, and a temporally aligned archive entry.

Phase 1 — Deterministic connection management and polling cadence

Production SCADA networks need connection pooling and fault-tolerant retry logic rather than a naive connect-per-read loop, which would exhaust device sockets and saturate switch buffers during peak polling. An asynchronous client with capped exponential backoff keeps the poller from hammering a degraded link while still recovering quickly from transient drops.

Implementation steps:

  1. Maintain a bounded pool of persistent sessions per PLC subnet, with keep-alive probes on the order of every 30 seconds to detect silent link degradation.
  2. Align read frequencies with the regulatory sampling interval for each tag; disinfection-byproduct precursors, chlorine residual, and turbidity typically demand sub-minute resolution to capture transient excursions.
  3. Assign a monotonically increasing transaction ID per request and log drops, re-establishment timestamps, and failed-transaction counts to preserve chain-of-custody documentation for audits.
  4. Route Modbus traffic over dedicated VLANs with default-deny firewall rules, and disable broadcast/multicast flooding on ports connected to RTUs or I/O modules.
import asyncio
import logging

from pymodbus.client import AsyncModbusTcpClient
from pymodbus.exceptions import ModbusException

logger = logging.getLogger("modbus.ingest")


async def connect_with_backoff(
    host: str,
    port: int = 502,
    max_attempts: int = 6,
    base_delay: float = 0.5,
) -> AsyncModbusTcpClient:
    """Open a Modbus/TCP session, retrying with capped exponential backoff."""
    client = AsyncModbusTcpClient(host, port=port, timeout=3.0)
    for attempt in range(1, max_attempts + 1):
        await client.connect()
        if client.connected:
            logger.info("Connected to %s:%s on attempt %d", host, port, attempt)
            return client
        delay = min(base_delay * 2 ** (attempt - 1), 30.0)
        logger.warning("Connect %s:%s failed; retrying in %.1fs", host, port, delay)
        await asyncio.sleep(delay)
    raise ModbusException(f"Unreachable: {host}:{port} after {max_attempts} attempts")

Phase 2 — Register mapping and raw byte extraction

Process variables such as flow rate, pH, or dissolved oxygen usually span multiple 16-bit registers in IEEE 754 or scaled-integer form. The parser must resolve byte order and validate payload length explicitly, because a short read or a swapped word yields a value that decodes without raising an exception.

Implementation steps:

  1. Map word and byte order per device profile (ABCD, CDAB, BADC, or DCBA) and never assume a default.
  2. Concatenate adjacent registers into the target width before casting, validating the register count first to prevent buffer overreads.
  3. Serialize the raw payload into an immutable record and write it to an append-only log before any transformation.
import struct


def decode_float32(registers: tuple[int, int], byte_order: str = "ABCD") -> float:
    """Decode two 16-bit registers into an IEEE-754 float32 for a given profile.

    `registers` is ordered exactly as returned by the device (low index first).
    """
    if len(registers) != 2:
        raise ValueError(f"float32 requires 2 registers, got {len(registers)}")

    a, b = registers[0] >> 8, registers[0] & 0xFF
    c, d = registers[1] >> 8, registers[1] & 0xFF
    layout = {
        "ABCD": (a, b, c, d),
        "DCBA": (d, c, b, a),
        "BADC": (b, a, d, c),
        "CDAB": (c, d, a, b),
    }
    try:
        ordered = layout[byte_order]
    except KeyError:
        raise ValueError(f"Unsupported byte order: {byte_order!r}") from None
    return struct.unpack(">f", bytes(ordered))[0]

Phase 3 — Validation, scaling, and compliance transformation

Unvalidated register data carries an inherent risk of misalignment, bit-shift errors, or sensor-saturation artifacts. Scaling converts raw counts to engineering units through a calibrated linear transform, and for a current-loop analyzer the two-point map from a 4–20 mA span to an engineering range is:

EU=EUmin+(EUmaxEUmin)I416\text{EU} = \text{EU}_{\min} + \left(\text{EU}_{\max} - \text{EU}_{\min}\right)\cdot\frac{I - 4}{16}

where II is the loop current in milliamps. For a raw ADC count the equivalent linear form is y=mxraw+by = m \cdot x_{\text{raw}} + b, with gain mm and offset bb taken from the device’s calibration certificate.

Implementation steps:

  1. Apply hard range gates from the sensor specification, then flag any reading whose deviation from the rolling 24-hour mean exceeds three standard deviations, xxˉ>3σ\lvert x - \bar{x}\rvert > 3\sigma, for review.
  2. Convert raw counts to engineering units with the calibrated curve; for example, Parsing Modbus Registers for Turbidity Sensors requires precise 4–20 mA to NTU linearization plus mandatory flagging of values above the regulatory action level.
  3. Suppress electrical-noise micro-fluctuations with a configurable deadband (for example ±0.05 pH) and a maximum rate-of-change limit, while preserving genuine step changes.
  4. Attach a standardized quality flag to every record and keep a separate audit trail for all overrides and manual corrections.
import statistics


def classify(value: float, low: float, high: float, history: list[float]) -> str:
    """Assign a quality flag from range gates and a rolling ±3σ test."""
    if not (low <= value <= high):
        return "SUSPECT"
    if len(history) >= 30:
        mean = statistics.fmean(history)
        sigma = statistics.pstdev(history)
        if sigma > 0 and abs(value - mean) > 3 * sigma:
            return "SUSPECT"
    return "GOOD"


def scale_counts(raw: int, gain: float, offset: float) -> float:
    """Linear engineering-unit transform: y = gain * raw + offset."""
    return gain * raw + offset

Phase 4 — Time-series integration and audit archival

Validated telemetry must be temporally aligned before it enters a historian or a reporting engine. Network jitter, PLC scan-time variability, and asynchronous polling introduce timestamp drift that can distort trend analysis and breach reporting windows.

Implementation steps:

  1. Enforce NTP — or PTP where the hardware supports it — across PLCs, edge gateways, and ingestion servers, normalize every timestamp to UTC, and verify clock skew stays below ±50 ms.
  2. Reconcile asynchronous reads with interpolation or nearest-neighbor alignment, documenting the method to satisfy data-provenance requirements.
  3. Bucket data into the standardized intervals (1-minute, 15-minute, or hourly) that Discharge Monitoring Reports and Consumer Confidence Reports require, using the shared Time-Series Alignment Strategies module.
  4. Write final datasets to write-once storage — Parquet on object storage or append-only SQL — retaining raw, scaled, and flagged records for the statutory retention period, typically three to ten years by state.
from datetime import datetime, timezone


def to_utc(epoch_seconds: float) -> datetime:
    """Normalize a device/poller epoch timestamp to timezone-aware UTC.

    Storing an aware UTC datetime avoids the DST and offset ambiguities that
    corrupt averaging windows when local wall-clock times are archived instead.
    """
    return datetime.fromtimestamp(epoch_seconds, tz=timezone.utc)

Validation, Quality Flags & Edge Cases

Every parsed tag carries one of four standardized quality codes, and downstream averaging engines consume the flag rather than re-deriving trust. Keeping this vocabulary consistent from the wire through the report is what lets a compliance calculation exclude an unreliable sample defensibly.

Flag Meaning Typical trigger Compliance handling
GOOD Valid reading Passes range and rate-of-change gates Usable in compliance calculations
SUSPECT Questionable Out-of-band value or >3σ spike Held for review; excluded from averages until confirmed
BAD Invalid NaN/Inf payload, timeout, framing fault Excluded; recorded as a monitoring gap
CALIBRATION_ACTIVE Device in calibration Calibration-mode status bit set Excluded from the compliance dataset
Quality-code state machine for a parsed Modbus tag States GOOD, SUSPECT, BAD and CALIBRATION_ACTIVE. From the start the tag enters GOOD. GOOD to SUSPECT on an out-of-band value or rate spike; SUSPECT back to GOOD on recovery; SUSPECT to BAD on a persistent fault; BAD back to GOOD after sustained valid reads; GOOD to CALIBRATION_ACTIVE when calibration mode is active, and CALIBRATION_ACTIVE back to GOOD when calibration ends. GOOD SUSPECT BAD CALIBRATION_ACTIVE out-of-band / spike recovers persistent fault sustained valid reads calibration mode calibration ends

Several edge cases recur in field deployments and each has a deterministic guard. Register rollover on a 16-bit accumulator (flow totalizers, runtime counters) wraps from 65535 to 0 and must be detected as a wrap, not read as a negative rate spike. NaN and infinity payloads arise when an analyzer reports a fault as a non-finite float; these are flagged BAD at decode time, never forwarded as a real value. Partial windows at the start of a shift or after an outage must not be averaged as though complete — a running annual average built on three of four required quarters is not yet a compliant result. Timezone and DST handling is the quietest offender: archiving local wall-clock timestamps causes a duplicated or missing hour twice a year, silently double-counting or dropping samples inside a compliance window, which is precisely why Phase 4 normalizes to UTC at ingest.

Deployment & Integration Patterns

The parser is packaged as a small, stateless microservice per site or per subnet rather than one monolith polling every device. Each instance runs a single asyncio event loop, holds its bounded connection pool, and publishes decoded records to a message broker (MQTT at the edge, or Kafka for the enterprise stream) so that ingestion is decoupled from validation and archival. This separation lets the compute-heavy validation stage scale independently and lets a broker absorb bursts without back-pressuring the poll loop into a missed sampling interval.

Deployment guidance:

  • Containerize with a read-only root filesystem. The service needs no local writes beyond a small spool directory; enforcing a read-only filesystem shrinks the attack surface on an OT-adjacent host.
  • Handle backpressure explicitly. When the broker or historian slows, buffer to a bounded local queue and emit BAD/gap markers for intervals that cannot be captured, rather than blocking the event loop and cascading missed reads across every device on the pool.
  • Batch high-fan-out polling. When one instance must serve hundreds of tags, hand the fan-out to Async Batch Processing Setup so that slow devices cannot starve fast ones.
  • Pass records forward under the shared contract. Decoded records enter the compliance evaluation path defined by the Violation Detection Rule Engine Logic, so the field-level quality_flag and method_code must already be populated when they leave this service.
Per-subnet parser deployment topology with broker fan-out and backpressure buffer A per-subnet parser container running an asyncio event loop, a bounded connection pool and a read-only root filesystem publishes decoded records to a message broker (MQTT or Kafka). The broker fans out to a validation worker and to an append-only Parquet or object-store archive. A dashed path shows the parser spilling to a bounded local buffer when the broker slows; the buffer emits gap markers and refills the broker rather than blocking the poll loop. Parser container per subnet Message broker MQTT / Kafka Validation worker range + quality Append-only archive Parquet / object store Bounded local buffer emits gap markers asyncio event loop bounded conn pool read-only rootfs broker slows

Production Validation Checklist

Failure Modes & Gotchas

The single most consequential configuration decision in a Modbus parsing workflow is byte-order resolution. An incorrect byte-order or word-order setting produces a value that parses without error but is numerically wrong — there is no Modbus-layer checksum on the decoded float to catch the mistake. Because the resulting value is often within plausible engineering-unit range (a coincidence of the IEEE 754 bit pattern), range gates alone cannot protect downstream compliance calculations. Byte and word order must therefore be verified empirically against the device’s communication map and a known reference reading before the parser is deployed to any production compliance pipeline. Catch it during commissioning by decoding a register whose live value is independently known — a bench calibrator, a local HMI readout, or a manual grab sample — under all four profiles and confirming that exactly one reproduces the reference. Re-run that check whenever a device firmware update, a gateway swap, or a vendor change could alter the register map, because a silently reintroduced swap looks identical to healthy data on every dashboard until an audit reconstructs the raw payload.