Updating Subnetworks with Python After Controller Changes

A subnetwork controller change is one of the few edits that rewrites an attribute on thousands of features at once, and it does so through a process that reports success whether or not the result is what anyone intended. Moving a controller from one breaker to another, adding a second source to a pressure zone, or retiring the regulator that defined a gas tier all change which features carry which subnetwork name — and every downstream consumer of that name, from the dispatch console to the reliability report, inherits the change silently. Doing it by hand in a client, on the default version, during working hours, is how an estate acquires a feeder that reports thirty-one customers and serves four hundred. The routine below applies a controller change in a named version, updates the tier, and refuses to post unless the resulting membership matches what the change was supposed to do — the same gating discipline the subnetwork management and controller configuration reference argues for, expressed as code.

Environment Prerequisites

  • ArcGIS Pro 3.2+ with a Standard or Advanced licence, so arcpy.un.UpdateSubnetwork, arcpy.un.ModifyTerminalConfiguration and the subnetworks table are available.
  • Python 3.11 from a clone of the ArcGIS conda environment, with arcpy importable from the target interpreter. Never mutate the base arcgispro-py3 environment.
  • An enterprise geodatabase connection (.sde) pointing at a named, isolated version created for the work order — not DEFAULT, so an incorrect controller can be discarded.
  • A validated topology with no outstanding dirty areas in the affected tier. A controller change applied over an unvalidated extent walks a graph that does not describe the network, which the CRS and topology preconditions are there to prevent.
  • The tier name and the domain network name as they appear in the schema, parameterised rather than hard-coded, so the same routine serves water, gas and electric.
  • An expected membership band per subnetwork — the feature count the change should produce, within a tolerance the work order states. Without it there is nothing to gate on.
  • Write access to an append-only audit table, so each controller change records what it moved.

Schema-Aware Validation Protocol — Run Before the Change

  1. Confirm the target feature can host a controller. It must be a device or junction class the tier permits, with a terminal configuration assigned. A controller placed on a feature with no terminal model attaches to a default terminal and feeds the wrong direction.
  2. Confirm the tier is currently clean. Read the subnetworks table and check the dirty flag and last-updated timestamp for the tier. Applying a controller change on top of an unfinished update conflates two sets of differences and makes the membership delta uninterpretable.
  3. Record the current membership per subnetwork. The before-count is the only thing the after-count can be judged against, and it has to be captured in the same version the change will run in.
  4. Verify the new controller’s terminal. For a multi-terminal device — a three-phase switch, a regulator with distinct inlet and outlet — the terminal decides which direction the walk goes. This is the check that catches the plausible-but-inverted subnetwork.
  5. Confirm no other controller already claims the span. In a hierarchical tier two controllers reaching the same features merge two subnetworks; in a partitioned tier that may be intended, and the work order should say which case this is.

Minimal Reproducible Implementation

The routine creates the new controller before removing the superseded one, updates the tier inside the version, compares membership against the expected band, and returns a structured report a pipeline can gate on. Every geodatabase call is wrapped so one failure does not leave the version half-edited without a record of it.

from __future__ import annotations

import logging
from dataclasses import dataclass, field

import arcpy

logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
LOG = logging.getLogger("subnetwork-update")


@dataclass
class UpdateReport:
    """Structured outcome of one controller change and tier update."""

    tier: str
    before: dict[str, int] = field(default_factory=dict)
    after: dict[str, int] = field(default_factory=dict)
    dirty_after: bool = True
    problems: list[str] = field(default_factory=list)

    @property
    def ok(self) -> bool:
        return not self.problems and not self.dirty_after


def _membership(un_path: str, tier: str) -> dict[str, int]:
    """Feature count per subnetwork name in a tier, read from the subnetworks table."""
    counts: dict[str, int] = {}
    table = f"{un_path}_Subnetworks"
    fields = ["SUBNETWORKNAME", "FEATURECOUNT", "TIERNAME"]
    with arcpy.da.SearchCursor(table, fields) as cursor:
        for name, count, tier_name in cursor:
            if tier_name == tier:
                counts[name] = int(count or 0)
    return counts


def _is_dirty(un_path: str, tier: str) -> bool:
    with arcpy.da.SearchCursor(f"{un_path}_Subnetworks",
                               ["TIERNAME", "ISDIRTY"]) as cursor:
        return any(bool(dirty) for tier_name, dirty in cursor if tier_name == tier)


def apply_controller_change(
    un_path: str,
    tier: str,
    add: list[dict] | None = None,
    remove: list[str] | None = None,
    expected: dict[str, tuple[int, int]] | None = None,
) -> UpdateReport:
    """Apply controller additions and removals, update the tier, and report membership.

    ``add`` entries carry the feature class, the global id, the terminal name and the
    subnetwork name to create. ``remove`` holds subnetwork names whose controller is
    superseded. ``expected`` maps a subnetwork name to an inclusive (low, high) band for
    its feature count after the update; anything outside it is reported as a problem.
    """
    report = UpdateReport(tier=tier)
    add, remove, expected = add or [], remove or [], expected or {}

    if _is_dirty(un_path, tier):
        report.problems.append(f"tier {tier} was already dirty before the change")
        return report

    report.before = _membership(un_path, tier)

    # Create first, remove second: the tier must never be left without a source.
    for spec in add:
        try:
            arcpy.un.SetSubnetworkDefinition(
                in_utility_network=un_path,
                domain_network=spec["domain_network"],
                tier=tier,
                subnetwork_controller=spec["subnetwork_name"],
            )
            arcpy.un.ModifySubnetworkController(
                in_utility_network=un_path,
                operation="CREATE",
                feature_class=spec["feature_class"],
                global_id=spec["global_id"],
                terminal_name=spec["terminal"],
                subnetwork_controller_name=spec["subnetwork_name"],
            )
            LOG.info("created controller %s", spec["subnetwork_name"])
        except arcpy.ExecuteError as exc:
            report.problems.append(f"create {spec['subnetwork_name']}: {exc}")

    for name in remove:
        try:
            arcpy.un.ModifySubnetworkController(
                in_utility_network=un_path, operation="DELETE",
                subnetwork_controller_name=name,
            )
            LOG.info("removed controller %s", name)
        except arcpy.ExecuteError as exc:
            report.problems.append(f"remove {name}: {exc}")

    try:
        arcpy.un.UpdateSubnetwork(in_utility_network=un_path, tier=tier,
                                  all_subnetworks_in_tier="ALL_SUBNETWORKS_IN_TIER")
    except arcpy.ExecuteError as exc:
        report.problems.append(f"update subnetwork: {exc}")
        return report

    report.after = _membership(un_path, tier)
    report.dirty_after = _is_dirty(un_path, tier)

    for name, (low, high) in expected.items():
        got = report.after.get(name)
        if got is None:
            report.problems.append(f"{name}: no subnetwork after the update")
        elif not low <= got <= high:
            report.problems.append(
                f"{name}: {got} features, expected {low}-{high}")

    for name, was in report.before.items():
        now = report.after.get(name, 0)
        if was and abs(now - was) / was > 0.25 and name not in expected:
            report.problems.append(
                f"{name}: membership moved {was} -> {now} and was not predicted")

    return report
The order a controller change has to be applied in, and where each step can fail A controller change begins in a named version. The new controller is created before the old one is removed, so the tier is never left without a source — a gap during which an update would unstamp the whole subnetwork. The tier is then updated inside that version, which is what proves the new controller reaches what it should. Only a clean update reconciles and posts, and the default version is updated again afterwards so operational consumers see the new membership rather than the pre-change stamp. Automation Named version Subnetwork tier Default version create the new controller first remove the superseded controller never the other way round UpdateSubnetwork inside the version feature counts + dirty flag read back reconcile and post only on a clean walk update the tier again on DEFAULT Removing the old controller first leaves a window in which the subnetwork has no source.

The two guarantees worth reading twice are the ordering and the band. Creating before removing means the tier always has a source, so an update that runs mid-change stamps something sensible rather than clearing every name. The expected band turns “the update succeeded” into “the update did what the work order said it would,” which is the only version of success that is worth gating on.

What a subnetwork update costs, by the scope it is asked to walk An update scoped to one tier in a named version walks only what that version changed and finishes in seconds. The same tier updated on the default version walks the whole tier, which is where the minutes go. Updating every tier on a combined water, gas and electric estate multiplies that by the number of tiers for no benefit when only one commodity was edited. The numbers here are the shape of the cost rather than a benchmark: what matters is that scope, not edit size, is what the walk is proportional to. illustrative shape of the cost, not a benchmark One tier, in-version 4 s edits only One tier, DEFAULT 95 s whole tier walked All tiers, DEFAULT 380 s nothing gained Cost follows the scope walked, not the size of the edit that triggered it.

Production Deployment Pattern

  1. Run every controller change in a version created for the work order. Name the version for the order so the audit trail links the model change to the field change that caused it, and discard rather than repair when the gate fails.
  2. Gate the post on report.ok. A non-empty problems list or a tier still dirty after the update must fail the job. Posting a controller change that left the tier dirty publishes a partially stamped network to every operational consumer.
  3. Update the default version’s tier immediately after the post. The post moves the features; it does not re-walk the tier. Until the default version is updated, dispatch is reading the pre-change membership.
  4. Apply bounded retry with backoff on the geodatabase calls. Enterprise locks under multi-user editing fail intermittently, and a controller change abandoned half-way is worse than one retried.
  5. Publish the membership delta. Push the before and after counts per subnetwork to the operations channel that tracks switching, so a controller change is visible to the people whose picture of the network it just altered.
  6. Persist the audit record. Append the tier, controllers created and removed, before and after counts, dirty flag, work-order number and the arcpy version to an append-only table. That record is what reconstructs which circuit a feature belonged to on a given date.
The gate that decides whether a controller change may post After the in-version update, three questions decide whether the change is safe to post. Does every controller in the tier still resolve to a feature that exists and is enabled? Did the walk leave the tier clean, or are dirty areas still outstanding? And is the change in membership within the band the work order predicted? A membership swing far larger than expected is the signature of a controller placed on the wrong terminal, which produces a subnetwork that is plausible and wrong. In-version update completed — may it post? structural checks Controllers resolve and tier is clean? no Missing or disabled controller — do not post dirty Dirty areas remain — the walk did not finish membership check Is the membership delta expected? yes Within the predicted band — reconcile and post no Large unexplained swing — wrong terminal, review A membership delta nobody predicted is a review item, not a rounding difference.

Conclusion

A controller change is a bulk rewrite of operational membership dressed up as a small edit, and the tooling will not tell you when it has gone wrong. Applying it in a version, creating before removing, updating the tier inside that version and gating the post on a predicted membership band turns it into a change with a verifiable outcome. The audit record then answers the question that always arrives later — which feeder was this asset on when the outage happened — without an archaeology exercise. The natural next step is to run the same membership comparison on a schedule rather than only after a change, which is what surfaces controller drift before anyone reports it.

For authoritative reference, consult the ArcGIS Pro utility network documentation and the Python logging facility.