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.ModifyTerminalConfigurationand the subnetworks table are available. - Python 3.11 from a clone of the ArcGIS conda environment, with
arcpyimportable from the target interpreter. Never mutate the basearcgispro-py3environment. - An enterprise geodatabase connection (
.sde) pointing at a named, isolated version created for the work order — notDEFAULT, 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
- 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.
- 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.
- 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.
- 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.
- 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 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.
Production Deployment Pattern
- 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.
- Gate the post on
report.ok. A non-emptyproblemslist 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. - 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.
- 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.
- 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.
- Persist the audit record. Append the tier, controllers created and removed, before and
after counts, dirty flag, work-order number and the
arcpyversion to an append-only table. That record is what reconstructs which circuit a feature belonged to on a given date.
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.
Related
- Up to the parent topic: Subnetwork Management & Controller Configuration
- Up to the section: Core Utility GIS Fundamentals & Network Models
- Diagnosing Subnetwork Controller Drift
- Tier Definitions for Water Pressure Zones vs Electric Circuits
- Incremental Topology Rebuild After Field Edits
For authoritative reference, consult the ArcGIS Pro utility network documentation and the Python logging facility.