Syncing SCADA Barriers to Valve State in Real Time

A valve or isolator is only a trustworthy barrier for as long as the network model agrees with the field. The moment a SCADA-operated sectionalizing valve closes on a pressure excursion but the graph still carries it as OPEN, every isolation trace that walks through that node returns a boundary that is quietly, dangerously wrong — energizing or pressurizing a span an operator believes is dead. Batch-refreshing valve states on a nightly cadence cannot close that gap, because outages and switching happen on the timescale of seconds and the traces that consume barrier state fire continuously during an event. The barrier discipline established in Valve & Isolator Mapping Strategies gives each device a constrained OPERATIONAL_STATUS and a terminal model; this page supplies the streaming layer that keeps that status current, so the upstream and downstream tracing algorithms inside the broader Topology & Tracing Workflows framework always run against live device positions rather than a stale design snapshot.

The engineering problem is not “read a value and write it.” SCADA telemetry arrives duplicated, out of order, and occasionally in contradiction with a mobile field edit a crew just committed. A naive writer that applies every message as it lands will thrash the model, resurrect superseded states, and silently clobber an operator’s manual correction. The consumer below is therefore built on three guarantees — idempotency, conflict detection, and targeted trace recomputation — and it draws its raw feed from the same ingestion discipline documented in real-time telemetry: SCADA and AMI integration.

Environment Prerequisites

Lock the following before pointing a consumer at a live SCADA feed. A missing tag map or an unconstrained status domain is the most common cause of a sync that appears to run while writing garbage into the barrier model.

  • ArcGIS Pro 3.2+ with a Standard or Advanced license, so arcpy.da edit cursors and utility network terminal configurations are available to the writer that persists confirmed transitions.
  • Python 3.11 in a cloned ArcGIS conda environment, with networkx>=3.0 for the cached barrier graph and arcpy importable from the target interpreter.
  • A published SCADA point-to-feature tag map — a lookup that resolves each RTU point tag (for example PRV_0472.CMD_STATUS) to a network isolator identifier. Without an authoritative map, no operation can be resolved to a node and the whole sync is guesswork.
  • The OPERATIONAL_STATUS coded-value domain loaded with exactly OPEN, CLOSED, PARTIALLY_OPEN, and UNKNOWN, applied to the isolation feature class, so the translated SCADA state can only ever be one of the four values the traversal predicate understands.
  • A durable, ordered message source — a queue or event log that carries an event timestamp and a monotonic sequence number per device. Idempotency depends on those two fields; a feed that supplies neither cannot be de-duplicated safely.
  • A field-edit staging table exposing any uncommitted mobile edits per device, so the consumer can detect a SCADA/field disagreement before it overwrites a crew’s correction.
  • A versioned, isolated workspace for the persisted writes, never the DEFAULT version of the production enterprise geodatabase, so a burst of switching operations does not contend with editor locks.

Schema-Aware Reconciliation Protocol — Run Before the Stream

Most real-time sync failures are not race conditions; they are schema and mapping defects that only surface under load. Work this ordered checklist first — the earliest item is the most frequent culprit.

  1. Verify every live tag resolves to exactly one isolator. Join the SCADA tag map against the isolation feature class and assert a one-to-one match. A tag that resolves to zero features drops operations on the floor; a tag that resolves to two applies one valve’s state to a second, unrelated barrier. This is the single highest-yield check.
  2. Confirm the translated state is always in-domain. Every raw SCADA code — 0/1, OPEN/SHUT, analogue percentages — must map deterministically into {OPEN, CLOSED, PARTIALLY_OPEN, UNKNOWN}. An unmapped raw value must translate to UNKNOWN (a barrier) and raise an alert, never pass through as free text, because the traversal predicate branches only on the four domain values.
  3. Check the ordering keys are present and monotonic. Sample the feed and confirm each message carries a non-null event timestamp and a per-device sequence number that never decreases for a device. Idempotency and out-of-order rejection both hinge on these keys; a feed missing them needs a broker-side fix before sync, not a workaround in the consumer.
  4. Reconcile clock authority. RTU clocks drift. Decide whether the SCADA gateway or the ingest layer stamps authoritative event time, and confirm all devices share one time source. A few seconds of skew is enough to let a superseded OPEN overwrite a newer CLOSED.
  5. Inspect the field-edit staging table for open conflicts. Before starting the stream, list devices with a pending mobile edit. Those are exactly the nodes where an incoming SCADA operation may collide with a human correction, and they must be flagged for review rather than silently resolved.

Minimal Reproducible Implementation

The consumer below is the centerpiece. It ingests a batch of normalized SCADA operations, applies idempotent OPERATIONAL_STATUS updates to the cached barrier graph, detects conflicts against pending field edits, and recomputes only the isolation traces whose reachable set includes a changed device. It handles exceptions per operation so one malformed message never halts the batch, and it returns a structured report a pipeline can gate against.

from __future__ import annotations

import logging
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Iterable

import networkx as nx

logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")

VALID_STATUSES = {"OPEN", "CLOSED", "PARTIALLY_OPEN", "UNKNOWN"}
# Statuses that halt a trace, used to decide whether a boundary must refresh.
BARRIER_STATUSES = {"CLOSED", "UNKNOWN"}


@dataclass(frozen=True)
class ScadaOperation:
    """A normalized SCADA device operation resolved to a network node."""
    device_id: str
    new_status: str
    event_time: datetime
    sequence: int
    source: str = "SCADA"


@dataclass
class SyncReport:
    """Structured, pipeline-gateable result of one sync batch."""
    applied: list[str] = field(default_factory=list)
    ignored_stale: list[str] = field(default_factory=list)
    conflicts: list[dict] = field(default_factory=list)
    rejected: list[dict] = field(default_factory=list)
    retraced: list[str] = field(default_factory=list)

    def as_dict(self) -> dict:
        return {
            "applied": self.applied,
            "ignored_stale": self.ignored_stale,
            "conflicts": self.conflicts,
            "rejected": self.rejected,
            "retraced": self.retraced,
            "ok": not self.rejected,
        }


def sync_scada_barriers(
    graph: nx.DiGraph,
    operations: Iterable[ScadaOperation],
    pending_field_edits: dict[str, str],
    trace_origins: dict[str, str],
) -> SyncReport:
    """Apply SCADA operations to isolator nodes and recompute affected traces.

    Args:
        graph: Cached directed barrier graph; isolator nodes carry
            'status', 'last_seq', and 'last_event_time' attributes.
        operations: Normalized SCADA operations for this batch.
        pending_field_edits: device_id -> status a crew has staged but not
            reconciled; a disagreement here is a conflict, not an overwrite.
        trace_origins: trace_id -> origin node id for every live isolation
            trace whose boundary must stay current.

    Returns:
        A SyncReport enumerating applied, stale, conflicting, and rejected
        operations plus the ids of traces that were recomputed.
    """
    report = SyncReport()
    changed_nodes: set[str] = set()

    for op in operations:
        try:
            # 1. Validate against the constrained status domain.
            if op.new_status not in VALID_STATUSES:
                raise ValueError(f"status {op.new_status!r} outside domain")
            if op.device_id not in graph:
                raise KeyError(f"device {op.device_id!r} not in network snapshot")

            node = graph.nodes[op.device_id]

            # 2. Idempotency guard: drop re-delivered or out-of-order messages.
            #    A message is stale if its sequence is not strictly newer, or
            #    its event time is older than what we last applied.
            last_seq = node.get("last_seq", -1)
            last_time = node.get("last_event_time")
            if op.sequence <= last_seq or (last_time and op.event_time <= last_time):
                report.ignored_stale.append(op.device_id)
                continue

            # 3. Conflict detection: an incoming SCADA state that disagrees with
            #    a pending field edit must never silently win.
            staged = pending_field_edits.get(op.device_id)
            if staged is not None and staged != op.new_status:
                report.conflicts.append(
                    {"device": op.device_id, "scada": op.new_status, "field": staged}
                )
                logging.warning(
                    "Conflict on %s: SCADA=%s field=%s -> review queue",
                    op.device_id, op.new_status, staged,
                )
                continue

            # 4. Apply the transition idempotently; record prior state for audit.
            prior = node.get("status", "UNKNOWN")
            node["status"] = op.new_status
            node["last_seq"] = op.sequence
            node["last_event_time"] = op.event_time
            report.applied.append(op.device_id)

            # A change only affects traces when barrier-ness actually flips.
            if (prior in BARRIER_STATUSES) != (op.new_status in BARRIER_STATUSES):
                changed_nodes.add(op.device_id)
            logging.info("%s: %s -> %s (seq %d)", op.device_id, prior,
                         op.new_status, op.sequence)

        except (ValueError, KeyError) as exc:
            report.rejected.append({"device": op.device_id, "reason": str(exc)})
            logging.error("Rejected operation on %s: %s", op.device_id, exc)

    # 5. Recompute only the traces whose reachable set touches a changed node.
    for trace_id, origin in trace_origins.items():
        if origin not in graph:
            continue
        reachable = _isolation_reach(graph, origin)
        if changed_nodes & reachable or changed_nodes & set(graph.predecessors(origin)):
            report.retraced.append(trace_id)

    return report


def _isolation_reach(graph: nx.DiGraph, origin: str, max_hops: int = 500) -> set[str]:
    """Return nodes reachable from origin, halting at barrier-status nodes."""
    visited: set[str] = set()
    queue = [origin]
    while queue and len(visited) < max_hops:
        node = queue.pop(0)
        if node in visited:
            continue
        visited.add(node)
        for nbr in graph.successors(node):
            if graph.nodes.get(nbr, {}).get("status") in BARRIER_STATUSES:
                continue  # traversal stops at an active barrier
            queue.append(nbr)
    return visited


if __name__ == "__main__":
    G = nx.DiGraph()
    G.add_node("SRC", status="OPEN")
    G.add_node("PRV_0472", status="OPEN", last_seq=11)
    G.add_node("SVC_A", status="OPEN")
    G.add_edges_from([("SRC", "PRV_0472"), ("PRV_0472", "SVC_A")])

    ops = [
        ScadaOperation("PRV_0472", "CLOSED",
                       datetime(2026, 7, 13, 14, 5, tzinfo=timezone.utc), 12),
        # Re-delivered duplicate — must be ignored by the idempotency guard.
        ScadaOperation("PRV_0472", "CLOSED",
                       datetime(2026, 7, 13, 14, 5, tzinfo=timezone.utc), 12),
    ]
    result = sync_scada_barriers(
        G, ops, pending_field_edits={}, trace_origins={"zone-3": "SRC"}
    )
    print(result.as_dict())

Running the example applies the first operation, drops the duplicate as stale, and reports zone-3 as retraced because closing PRV_0472 inserts a barrier on the only path out of the origin. The idempotency guard is what makes the consumer safe to run against an at-least-once delivery feed: re-delivery is the normal case, not the exception, and the sequence-plus-event-time check turns every redundant message into a cheap no-op instead of a state flip.

SCADA operation stream flowing through idempotency and conflict guards into targeted trace recomputation A SCADA operation stream enters an idempotency guard that drops stale or re-delivered messages by sequence and event time. Surviving operations reach a conflict check against pending field edits: disagreements branch to a review queue while agreeing operations apply an OPERATIONAL_STATUS update to the cached isolator node. Only when a node's barrier state flips does the flow trigger recomputation of the isolation traces whose reachable set includes that device, and every applied transition writes to a structured audit log. SCADA STREAM device ops seq · event time IDEMPOTENCY drop stale / re-delivered CONFLICT CHECK vs pending field edit APPLY UPDATE node status + record prior RETRACE affected boundaries stale → no-op REVIEW QUEUE SCADA ≠ field retrace only when barrier state flips Structured audit log device · prior → new status · sequence · event time · source · trace ids retraced

The recomputation step is deliberately narrow. Recomputing every trace on every operation does not scale during a storm, when thousands of switching operations arrive per hour. The consumer instead tracks whether an update actually flips a node’s barrier-ness — an OPEN to PARTIALLY_OPEN change leaves the pass/halt behavior unchanged and triggers nothing, whereas an OPEN to CLOSED change inserts a barrier and invalidates exactly the traces whose reachable set includes that node. That set-intersection test is what keeps the sync loop cheap enough to run inline with the feed.

Production Deployment Pattern

A notebook that drains a queue once is a diagnostic; a durable service is an engineering control. Promote the consumer into the asset lifecycle as follows.

  1. Run the consumer as an idempotent queue worker. Bind it to the durable SCADA event log with at-least-once delivery and manual acknowledgement. Acknowledge a message only after the transition is applied or explicitly classified as stale, conflicting, or rejected, so a crash mid-batch replays cleanly rather than losing operations. The sequence guard makes replay harmless.
  2. Persist confirmed transitions to the versioned workspace. Apply the report.applied set to the isolation feature class through an arcpy.da.UpdateCursor inside an edit session on an isolated version, then reconcile and post on a controlled cadence. Never write live switching state directly to DEFAULT.
  3. Route conflicts to the review queue as work items. Every entry in report.conflicts is a SCADA/field disagreement that a controller must adjudicate. Open a ticket carrying both proposed states and the device identifier; block the automated write on that device until it is resolved, so a crew’s manual correction is never overwritten by an RTU report.
  4. Publish refreshed boundaries downstream. Push the report.retraced boundaries to the outage-management and impact layers over their REST endpoints so dispatch and notification consume the same live device positions. This closes the loop with the telemetry ingest described in real-time telemetry: SCADA and AMI integration.
  5. Apply retry with backoff on transient faults. Wrap geodatabase writes and REST publishes in a bounded exponential-backoff retry so a momentary lock or gateway timeout produces a retry rather than a rejected operation and a false alarm.
  6. Persist an audit trail per transition. Append device identifier, prior and new status, sequence number, event time, source system, and the trace identifiers recomputed to a timestamped, append-only log. That record is what lets a reliability review reconstruct precisely which barrier states the model held at any second of an event.

Conclusion

Keeping valve barrier states synchronized with SCADA in real time is what separates a network model that merely maps isolation devices from one that can be trusted to trace them under load. The consumer here earns that trust with three guarantees: idempotency that makes at-least-once delivery safe, conflict detection that protects a field crew’s manual correction from being silently overwritten, and a barrier-flip test that recomputes only the traces a state change actually affects. Wired as a durable queue worker with versioned writes and an append-only audit log, it turns a noisy telemetry feed into a live, defensible barrier model. The natural next step is to codify, per device class, how a persistent SCADA/field conflict is adjudicated so every controller resolves it identically.

For authoritative reference, consult the NetworkX documentation and the Python logging facility.