OPC UA Data Extraction for Water Utility Compliance Pipelines

OPC UA data extraction is the secure telemetry backbone of a modern water utility SCADA environment, replacing fragmented vendor protocols with a single, information-model-driven acquisition layer. This section covers the end-to-end workflow that turns an OPC UA server’s address space into auditable, EPA-ready records: endpoint discovery, certificate-based mutual authentication, monitored-item subscriptions, status-code validation, and time-aligned archival. It is written for the Python automation builders who own the ingestion service, the environmental compliance teams who sign the resulting Safe Drinking Water Act (SDWA) reports, and the municipal operations engineers who maintain the field instrumentation. Everything here feeds the ingestion contract defined by the parent SCADA Data Ingestion & Time-Series Sync domain, so an extracted value already carries a node_id, a UTC SourceTimestamp, a StatusCode, and native engineering-unit metadata from the moment it leaves the server. Where legacy plants depended on Modbus TCP Parsing Workflows, OPC UA’s self-describing address space lets a single client unify multi-vendor instrumentation under one compliance schema without per-device byte-order guesswork.

OPC UA extraction sequence from endpoint discovery to StatusCode-validated routing A vertical sequence between two actors. The OPC UA client discovers the endpoint and authenticates with a certificate, then creates a monitored-item subscription on the OPC UA server. The server returns a datachange notification containing Value, SourceTimestamp, ServerTimestamp and StatusCode. The client then performs two internal steps on itself: it validates the StatusCode and routes only Good values downstream. OPC UA client OPC UA server discover endpoint & authenticate (certificate) create subscription (monitored items) datachange_notification (Value, timestamps, StatusCode) validate StatusCode route Good values downstream

Regulatory / Protocol Foundation

OPC UA (IEC 62541) differs from field bus protocols in one decisive way: it is self-describing. Where a raw Modbus holding register is sixteen opaque bits whose meaning lives only in an external communication map, an OPC UA node carries its own data type, engineering-unit metadata, browse name, and — critically — a StatusCode and two timestamps in every value envelope. That envelope is the atomic unit the compliance pipeline depends on:

Field Meaning Compliance role
Value The process variable at the node The measured quantity reported to the primacy agency
SourceTimestamp When the device sampled the value (UTC) Authoritative sample time for reporting windows
ServerTimestamp When the server processed the value (UTC) Detects server-side buffering / clock skew
StatusCode Quality of the reading (Good/Uncertain/Bad) Governs whether the sample is usable in an average

The reason this matters is that the EPA Safe Drinking Water Act (SDWA) framework and its implementing regulations in 40 CFR Part 141 impose continuous monitoring obligations — sub-minute turbidity and disinfection surveillance under the Surface Water Treatment Rules, chlorine-residual tracking under the Stage 1 and Stage 2 Disinfectants and Disinfection Byproducts Rules — and each rule dictates a sampling cadence the extractor must sustain without gaps. Because a lapse in the telemetry stream is legally a monitoring gap, the extraction workflow is the evidentiary front end of the entire compliance record. The thresholds those readings are judged against are resolved downstream against the SDWA MCL Reference Mapping, and the required intervals come from Monitoring Frequency Scheduling.

Reference implementations should follow the OPC UA Specification Part 4: Services for subscription lifecycles, security policies, and namespace-indexing conventions. Two protocol constraints shape everything below. First, security is mandatory in production: OPC UA supports None, Sign, and SignAndEncrypt message security modes, and a compliance-grade deployment uses SignAndEncrypt with certificate-based mutual authentication so telemetry cannot be forged or replayed en route. Second, namespaces are resolved, not assumed: a node identifier such as ns=2;s=Plant1.Treatment.Clarifier.Turbidity embeds a namespace index (ns=2) that is only stable relative to the server’s namespace array, which can renumber on a firmware upgrade — so the client resolves namespace URIs to indexes at session startup rather than hardcoding numeric indexes.

Architecture & Design Decisions

The extraction service is a strictly passive, read-only consumer of the control network. It browses and subscribes; it never writes a node, because a compliance pipeline that can actuate a process is a treatment risk, not merely a data risk — the rationale detailed in Security Boundary Design. Three design decisions shape the rest of this section.

Subscriptions over polling. OPC UA’s monitored-item model pushes only changed values from server to client, which delivers high-resolution telemetry at a fraction of the network cost of repeated Read calls. The client registers monitored items with a sampling_interval (how often the server checks the source) and a queue_size (how many changes it buffers between publishes), and the server emits a datachange_notification only when a value crosses its deadband. This is both an efficiency win and a compliance subtlety: a monitoring point that stops changing produces no notification, so silence must be handled explicitly rather than read as “no change” — a hazard covered in Failure Modes below.

Namespace resolution as a startup contract. Because numeric namespace indexes are volatile, the service maintains an explicit map from stable namespace URIs to the string-identifier browse paths of every compliance-relevant node, and resolves the live index at session start. This means a vendor firmware upgrade that renumbers the namespace array is caught at connect time as a resolution failure, not silently mis-addressed as a wrong-but-plausible reading.

Immutable raw envelopes before transformation. The service serializes every notification into an append-only record — node_id, raw_value, source_ts, server_ts, status_code, security_policybefore any unit conversion or reconciliation. Keeping the untransformed envelope means a validation rule can be corrected and history re-evaluated without a return trip to the plant. Downstream, decoded records flow to Time-Series Alignment Strategies for UTC normalization and resampling, and heavy subscription fan-out is handed to Async Batch Processing Setup when one event loop should not carry every plant’s tag load.

Data contract for one OPC UA tag, from address-space node to compliance record Three stages left to right. On the left, a server address-space node carries a namespace URI and a string browse path. It is subscribed as a monitored item whose envelope, in the middle, carries four fields: Value, SourceTimestamp, ServerTimestamp and StatusCode. That raw envelope is written append-only and remains re-evaluable. On the right, validation and enrichment produce a compliance record with value, engineering_unit, sample_ts in UTC and quality_flag. Address-space node namespace URI http://plant1/UA/ browse path (s=…) …Clarifier.Turbidity Monitored-item envelope Value SourceTimestamp ServerTimestamp StatusCode Compliance record value engineering_unit sample_ts (UTC) quality_flag subscribe validate + enrich raw envelope stored append-only · re-evaluable

Phase-by-Phase Implementation

Municipal automation teams typically build this extraction layer on the asynchronous asyncua library, which supports concurrent node subscriptions, secure-channel renewal, and session keep-alives. The workflow is built in four phases, each producing an artifact the next depends on: a secure session, a resolved subscription, a validated engineering value, and a time-aligned archive entry.

Phase 1 — Secure session establishment and keep-alive

A production client establishes a certificate-authenticated session with SignAndEncrypt, automatic reconnection, and configurable keep-alive intervals. Anonymous and no-security sessions are disabled outright. Capped exponential backoff keeps a reconnect storm from flooding logs or exhausting the server’s session table during a prolonged network partition.

Implementation steps:

  1. Load the client certificate and private key, and pin the server’s application certificate so the mutual handshake fails closed if the server identity does not match.
  2. Set the security policy to Basic256Sha256 (or the current recommended policy) with mode SignAndEncrypt; never negotiate down to None in production.
  3. Configure a session keep-alive so a silent link is detected within a bounded interval, and wrap connect in capped exponential backoff.
import asyncio
import logging

from asyncua import Client, ua

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


async def connect_secure(
    url: str,
    client_cert: str,
    client_key: str,
    server_cert: str,
    max_attempts: int = 6,
    base_delay: float = 0.5,
) -> Client:
    """Open a SignAndEncrypt OPC UA session with capped exponential backoff."""
    for attempt in range(1, max_attempts + 1):
        client = Client(url=url, timeout=4.0)
        await client.set_security_string(
            f"Basic256Sha256,SignAndEncrypt,{client_cert},{client_key},{server_cert}"
        )
        client.session_timeout = 30_000  # ms; server drops the session if silent
        try:
            await client.connect()
            logger.info("Connected to %s on attempt %d", url, attempt)
            return client
        except (OSError, ua.UaError) as exc:
            delay = min(base_delay * 2 ** (attempt - 1), 30.0)
            logger.warning("Connect %s failed (%s); retry in %.1fs", url, exc, delay)
            await asyncio.sleep(delay)
    raise ConnectionError(f"Unreachable OPC UA endpoint: {url}")

Phase 2 — Namespace resolution and monitored-item subscription

Numeric namespace indexes are resolved to stable URIs at startup, and monitored items are registered with sampling intervals matched to the regulatory cadence for each tag. A SubHandler receives every datachange_notification and serializes the full envelope before any transformation.

Implementation steps:

  1. Read the server’s namespace array and build a URI-to-index map; resolve every compliance node’s string browse path against it, failing fast on any unresolved node.
  2. Create one subscription per publishing interval and add monitored items with the sampling interval mapped to SDWA monitoring frequency (for example, 15-minute windows for turbidity or pH).
  3. In the notification handler, capture Value, SourceTimestamp, ServerTimestamp, and StatusCode together and append the raw envelope to write-once storage.
from datetime import datetime, timezone

from asyncua import Client, ua


class SubHandler:
    """Receives datachange notifications and appends raw envelopes."""

    def __init__(self, sink) -> None:
        self._sink = sink  # append-only writer

    def datachange_notification(self, node, val, data) -> None:
        mv = data.monitored_item.Value
        self._sink(
            {
                "node_id": node.nodeid.to_string(),
                "raw_value": val,
                "source_ts": mv.SourceTimestamp,
                "server_ts": mv.ServerTimestamp,
                "status_code": mv.StatusCode.name,
                "received_ts": datetime.now(timezone.utc),
            }
        )


async def subscribe_nodes(
    client: Client, browse_paths: dict[str, str], sink, sampling_ms: float = 1000.0
) -> None:
    """Resolve namespace URIs, then subscribe to compliance nodes."""
    ns_uri = "http://plant1.example/UA/"
    idx = await client.get_namespace_index(ns_uri)  # resolved, never hardcoded
    handler = SubHandler(sink)
    sub = await client.create_subscription(period=sampling_ms, handler=handler)
    for tag, ident in browse_paths.items():
        node = client.get_node(f"ns={idx};s={ident}")
        await sub.subscribe_data_change(node)

Phase 3 — Status-code validation and engineering-unit normalization

Every notification is validated against its StatusCode and against engineering-unit range gates before it can enter the compliance dataset. OPC UA status codes have a severity in their top two bits — Good (0x0), Uncertain (0x4...), and Bad (0x8...) — and the pipeline maps them to the site-standard quality vocabulary rather than trusting a raw value whose status is not Good.

Engineering-unit normalization uses the node’s native EU metadata where present; for an analyzer exposed only as a scaled current-loop count, the two-point transform 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.

Implementation steps:

  1. Reject or quarantine any record whose StatusCode is Bad or Uncertain (for example Bad_CommunicationError, Bad_OutOfService, Uncertain_LastUsableValue); never coerce a non-Good status to Good.
  2. 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.
  3. Attach a standardized quality flag and keep a separate audit trail for every override or manual correction, so a reviewer can reconstruct why a sample was excluded.
import statistics

GOOD, UNCERTAIN, BAD = 0x0, 0x40000000, 0x80000000


def status_to_flag(status_code: int) -> str:
    """Map an OPC UA StatusCode severity to the site quality vocabulary."""
    severity = status_code & 0xC0000000
    if severity == BAD:
        return "BAD"
    if severity == UNCERTAIN:
        return "SUSPECT"
    return "GOOD"


def classify(value: float, low: float, high: float, history: list[float]) -> str:
    """Refine a GOOD status against range gates and a rolling +/-3 sigma 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"

For specific water-quality parameters, targeted extraction routines — such as those detailed in Extracting OPC UA Nodes for Chlorine Residuals — show how to isolate critical disinfection metrics while preserving the full audit trail and engineering-unit normalization.

Phase 4 — Temporal reconciliation and audit archival

Validated telemetry must be temporally aligned before it enters a historian or reporting engine. Because OPC UA timestamps are already UTC and millisecond-precise, the extractor preserves SourceTimestamp as the authoritative sample time and uses ServerTimestamp only to detect buffering or skew — never as the reporting time.

Implementation steps:

  1. Treat SourceTimestamp as the compliance sample time; if it is absent or older than ServerTimestamp by more than a configured tolerance, flag the record for review rather than silently substituting the server clock.
  2. 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.
  3. Write final datasets to write-once storage — Parquet on object storage or append-only SQL — retaining raw, normalized, and flagged records for the statutory retention period, typically three to ten years by state.
from datetime import datetime, timezone


def resolve_sample_time(source_ts, server_ts, tolerance_s: float = 5.0):
    """Prefer SourceTimestamp; flag when it lags the server clock too far."""
    if source_ts is None:
        return server_ts, "SUSPECT"
    skew = (server_ts - source_ts).total_seconds()
    if skew > tolerance_s:
        return source_ts, "SUSPECT"
    return source_ts.astimezone(timezone.utc), "GOOD"

Validation, Quality Flags & Edge Cases

Every extracted tag carries one of four standardized quality codes, and downstream averaging engines consume the flag rather than re-deriving trust from the raw StatusCode. Keeping this vocabulary consistent from the wire through the report is what lets a compliance calculation exclude an unreliable sample defensibly, and it is the same flag set the Violation Detection Rule Engine Logic expects at its input.

Flag Meaning Typical trigger Compliance handling
GOOD Valid reading StatusCode Good and within range Usable in compliance calculations
SUSPECT Questionable Uncertain status, out-of-band, or >3σ spike Held for review; excluded from averages until confirmed
BAD Invalid Bad status, timeout, or non-finite value Excluded; recorded as a monitoring gap
CALIBRATION_ACTIVE Device in calibration Calibration-mode status bit set on the node Excluded from the compliance dataset
Quality-code state transitions for an extracted OPC UA tag A state machine with four states. From the start, a tag enters GOOD. An Uncertain status or rate spike transitions GOOD to SUSPECT; the reading recovering returns SUSPECT to GOOD, while a Bad status or persistent fault moves SUSPECT to BAD. BAD returns to GOOD after sustained valid reads. Entering calibration mode moves GOOD to CALIBRATION_ACTIVE, and GOOD resumes when calibration ends. CALIBRATION_ACTIVE GOOD SUSPECT BAD Uncertain / spike recovers Bad / fault sustained valid reads calibration mode calibration ends

Several edge cases recur in field deployments and each has a deterministic guard. Subscription silence is the quietest offender: because notifications fire only on change, a frozen or offline sensor produces no data at all, so the client relies on the subscription keep-alive to distinguish “value is steady” from “value stopped arriving” and emits a gap marker when publishes cease — the concern that Monitoring Gap Detection Algorithms formalize downstream. Uncertain_LastUsableValue is deceptive because it carries a real, recent number; treating it as Good would fold a stale reading into a live average, so it is flagged SUSPECT. Namespace renumbering after a firmware upgrade shifts numeric indexes and is caught at session start by re-resolving URIs. Timestamp skew between SourceTimestamp and ServerTimestamp reveals server-side buffering; a large positive skew means the reported sample time is not when the value actually landed, which can push a reading across a reporting-window boundary.

Deployment & Integration Patterns

The extractor is packaged as a small, stateless microservice per site or per server rather than one monolith subscribing to every plant. Each instance runs a single asyncio event loop, holds its own secure channel and subscription set, and publishes normalized records to a message broker (MQTT at the edge, or Kafka for the enterprise stream) so acquisition 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 subscription loop into a missed publish.

Deployment guidance:

  • Containerize with a read-only root filesystem. The service needs no local writes beyond a small certificate store and spool directory; a read-only filesystem shrinks the attack surface on an OT-adjacent host.
  • Mount certificates as secrets, not image layers. Client keys and the pinned server certificate are injected at runtime so a rotated certificate never requires a rebuild, and so keys never rest in a registry.
  • Handle backpressure explicitly. When the broker or historian slows, buffer to a bounded local queue and emit gap markers for intervals that cannot be captured, rather than blocking the event loop and cascading missed notifications across every subscription.
  • Fan out heavy subscription loads. When one instance must serve hundreds of nodes across slow and fast servers, hand the fan-out to Async Batch Processing Setup so a slow server cannot starve a fast one.
Per-server extractor deployment topology An OPC UA server sends notifications to a per-server extractor container. The container runs an asyncio event loop, a SignAndEncrypt secure channel, a subscription set of monitored items and a bounded local buffer that absorbs backpressure; it runs on a read-only filesystem with a certificate secret mounted at runtime. The container publishes normalized records to a message broker (MQTT or Kafka), which fans out to a validation worker and to an append-only Parquet or object-store archive. OPC UA server Extractor container · one per server asyncio event loop secure channel (SignAndEncrypt) subscription set (monitored items) bounded local buffer · backpressure read-only filesystem · cert secret mounted at runtime Message broker MQTT / Kafka Validation worker Append-only archive Parquet / object store notify publish normalized validate archive

Production Validation Checklist

Failure Modes & Gotchas

The single most consequential misconfiguration in an OPC UA extraction pipeline is treating the absence of a datachange_notification as evidence that the process variable has not changed. The subscription model deliberately shifts change detection from client to server, so a monitoring point that stops changing — because a sensor has frozen, lost power, or dropped off the network — generates no notification, and a naive client simply carries forward its last value indefinitely. That last value keeps flowing into rolling averages and compliance reports as though the instrument were healthy, which means an undetected sensor failure can produce a clean-looking record that hides a genuine monitoring gap. The guard is a keep-alive or heartbeat that explicitly detects subscription silence: when the server’s publishing interval elapses with no keep-alive and no data, the client raises a gap for that node rather than reusing the stale value. Catch it in commissioning by disconnecting a test sensor and confirming the pipeline emits a BAD/gap marker within one publishing interval — not a frozen-but-plausible reading. Re-run that check after any firmware upgrade or gateway swap, because a silently reintroduced namespace shift or a reset keep-alive setting looks identical to healthy data on every dashboard until an audit reconstructs the raw envelope.