Lifecycle State Machines for Utility Assets

The Failure Mode: Status Fields That Everyone Edits and No One Trusts

The failure this guide solves is a lifecycle status that has decayed into a free-form annotation. On most utility networks the LIFECYCLESTATUS field started as a disciplined coded value and, over years of migrations, mobile edits, and one-off bulk updates, drifted into a field where “abandoned”, “ABND”, “out of service”, and a null all coexist and mean subtly different things to different teams. When that happens, every downstream automation inherits the ambiguity. A trace that should exclude retired mains includes a pipe that was cut and capped five years ago. A capital-planning report double-counts a transformer that is simultaneously flagged proposed in the design version and active in the as-built. A crew is dispatched to inspect an asset that no longer physically exists. None of these are geometry errors — the features map perfectly — and none of them trip a topology check. They are state errors, and they are invisible precisely because the status attribute was never treated as the output of a governed transition.

Modeling asset lifecycle correctly means treating status not as a label a user types but as the current node of a finite-state machine: a closed set of legal states, a defined set of permitted transitions between them, and an immutable record of every move. This discipline sits at the center of asset lifecycle and maintenance automation, because nearly every other automation — inspection scheduling, work-order sync, compliance reporting — is conditioned on an asset’s lifecycle state. It targets utility engineers, GIS data architects, and Python automation teams, and it covers the state vocabulary (proposed, active, abandoned, as-built, retired), the legal transition table, how state gates tracing eligibility, the containment and association implications of a transition, and the event-sourced audit history that makes the whole thing defensible.

The utility-asset lifecycle state machine and its legal transitions A directed state machine. Proposed is the entry state, reached from a start marker. Proposed transitions forward to Active at as-built capture, or sideways to Retired if the design is cancelled. Active transitions to Abandoned when a feature is left in place but de-energized, and Active or Abandoned transition to Retired when the feature is physically removed. A dashed reactivation edge runs from Abandoned back to Active. Retired is a terminal state with no outgoing transitions. Active and Abandoned are shaded as trace-eligible states; Proposed and Retired are shaded as trace-excluded states. Trace-eligible states Trace-excluded states PROPOSED design, not built ACTIVE energized, in service ABANDONED in place, de-energized RETIRED removed, archived AS-BUILT capture point: proposed → active as-built reactivate removed physical removal design cancelled

Prerequisite Checklist

Confirm every item below before you make lifecycle status a governed, automatable attribute. Skipping the domain or version-isolation checks is the most common reason a transition script “works” in testing and then corrupts state in production.

Core Data Model: States as a Closed Set, Transitions as an Adjacency Map

A lifecycle state machine has two halves that must be designed together: the set of states and the set of legal transitions between them. The states are a closed, coded vocabulary. Five carry the operational weight on a utility network:

  • Proposed — the feature exists in a design or planning version but has not been constructed. It is spatially placed and attributed, but it is not part of the energized or pressurized network, and it must never appear in an operational trace.
  • Active — the feature has been built, verified against the as-built record, and is in service. This is the only state in which an asset participates fully in connectivity and unqualified tracing.
  • Abandoned — the feature remains physically in place but has been de-energized or cut and capped. It is retained in the model because it still occupies the ground and matters for excavation and locate requests, but its trace eligibility is policy-dependent.
  • Retired — the feature has been physically removed. It is kept only as historical record; it carries no live connectivity and is terminal — nothing transitions out of retired without an explicit, logged reactivation that is really a new asset.
  • As-built is not a resting state but the capture point on the proposed-to-active transition: the reconciliation event where design geometry is verified against field survey and promoted into the operational network.

The transitions are where the engineering discipline lives. Not every state can reach every other state, and encoding the permitted moves as an explicit adjacency map is what turns a free-text field into a machine. A feature cannot leap from proposed straight to retired without ever having been built as a distinct event; an abandoned main can be reactivated back to active, but a retired one cannot — its removal is physical and final. Expressing this as a transition table, rather than as scattered if statements in a dozen edit scripts, gives one authoritative definition every automation shares.

from dataclasses import dataclass, field
from datetime import datetime, timezone


# The closed set of legal states, and the permitted transitions between them.
LEGAL_TRANSITIONS: dict[str, set[str]] = {
    "PROPOSED": {"ACTIVE", "RETIRED"},      # built (as-built) or design cancelled
    "ACTIVE": {"ABANDONED", "RETIRED"},     # de-energized in place, or removed
    "ABANDONED": {"ACTIVE", "RETIRED"},     # reactivated, or finally removed
    "RETIRED": set(),                        # terminal
}


class IllegalTransition(ValueError):
    """Raised when a status change is not permitted by the state machine."""


@dataclass
class LifecycleMachine:
    """Validate and apply lifecycle status transitions for one asset."""

    asset_id: str
    status: str

    def can_transition(self, target: str) -> bool:
        """Return True when moving to `target` is a legal transition."""
        if target not in LEGAL_TRANSITIONS:
            raise KeyError(f"unknown lifecycle state {target!r}")
        return target in LEGAL_TRANSITIONS[self.status]

    def transition(self, target: str, actor: str, work_order: str | None) -> dict:
        """Apply a transition and return a structured, append-only audit event."""
        if not self.can_transition(target):
            raise IllegalTransition(
                f"{self.asset_id}: {self.status} -> {target} is not permitted"
            )
        prior, self.status = self.status, target
        return {
            "asset_id": self.asset_id,
            "prior_status": prior,
            "new_status": target,
            "actor": actor,
            "work_order": work_order,
            "timestamp": datetime.now(timezone.utc).isoformat(),
        }

The LEGAL_TRANSITIONS map is the single source of truth. Because it rejects an unknown target with a KeyError and an illegal move with an IllegalTransition, no code path can quietly write a bad status. That property is what lets every other automation trust the field.

Step-by-Step Implementation

Follow this sequence to take a lifecycle attribute from a free-form label to a governed, trace-aware state machine with an audit trail.

  1. Load the domain and clean existing values. Apply the four-value coded-value domain to every network feature class, then reconcile every current value against it. Map legacy strings (ABND, out of service, nulls) to the canonical states before anything else — a state machine cannot govern values it does not recognize.
  2. Instantiate the machine from the stored status. For each asset under edit, build a LifecycleMachine seeded with its current status so every proposed change is validated against the legal-transition map rather than written blind.
  3. Gate tracing eligibility on status. Before running any connectivity trace, filter the graph so proposed and retired features are excluded; keep or drop abandoned features according to the retirement policy decided in the checklist. This is the step that makes topology validation and every downstream trace honest.
  4. Reconcile containment and associations. When a container transitions, cascade or block the change against its contents and structural attachments so a retiring support structure cannot leave a live attachment dangling.
  5. Append an event, never overwrite. Persist each accepted transition as an immutable event carrying prior status, new status, actor, timestamp, and work order, so current state is always reconstructable as a replay of history.

Steps 3 and 4 are where lifecycle state stops being metadata and starts driving behavior. Tracing eligibility is the most consequential coupling: a trace is only correct if the graph it runs over contains exactly the features that are physically carrying flow. The topology and tracing workflows that compute isolation boundaries and downstream impact all assume the input graph has already been filtered by lifecycle status. Building that filtered graph is a matter of dropping ineligible nodes and edges before traversal begins:

import networkx as nx


TRACE_ELIGIBLE = {"ACTIVE", "ABANDONED"}   # policy: abandoned-in-place still occupies the network


def build_trace_graph(
    full_graph: nx.DiGraph,
    include_abandoned: bool = False,
) -> nx.DiGraph:
    """Return a subgraph containing only lifecycle-eligible features.

    Proposed and retired features are always excluded. Abandoned features are
    included only when the network's energization policy retains them.
    """
    eligible = {"ACTIVE"} | ({"ABANDONED"} if include_abandoned else set())

    def node_ok(n: str) -> bool:
        return full_graph.nodes[n].get("lifecycle_status") in eligible

    keep = [n for n in full_graph.nodes if node_ok(n)]
    trace_graph = full_graph.subgraph(keep).copy()

    dropped = full_graph.number_of_nodes() - trace_graph.number_of_nodes()
    if dropped:
        # Surfaced so a caller can log how much of the network the filter removed.
        trace_graph.graph["lifecycle_excluded_count"] = dropped
    return trace_graph


if __name__ == "__main__":
    g = nx.DiGraph()
    g.add_node("MAIN-1", lifecycle_status="ACTIVE")
    g.add_node("MAIN-2", lifecycle_status="RETIRED")
    g.add_node("MAIN-3", lifecycle_status="PROPOSED")
    g.add_edges_from([("MAIN-1", "MAIN-2"), ("MAIN-1", "MAIN-3")])
    tg = build_trace_graph(g)
    print(sorted(tg.nodes), tg.graph.get("lifecycle_excluded_count"))

The containment and association implications are the second non-obvious coupling. In a utility network, features are bound by containment (a device inside a vault, a service point inside a meter box) and by structural attachment (a transformer hung on a pole). A lifecycle transition on the container is not free of consequences for its contents, and the state machine must reconcile the two rather than treat them independently. The rule is directional: retiring a container must not silently strand active contents, and retiring a structure must first detach or transition whatever it supports. The following diagram makes the reconciliation explicit.

How a container's retirement is reconciled against its contents A decision flow. A retire request arrives for a container feature. The state machine checks whether the container holds any active contents. If no active contents remain, the retirement is permitted and an audit event is written. If active contents remain, the transition is blocked, and the resolution branches two ways: cascade, which transitions the contents first under the same work order, or reject, which returns the request to the editor with the offending content identifiers. Retire request container feature Active contents? Permit + audit write transition event Blocked Cascade transition contents first Reject return offending IDs no yes

Diagnostic Protocol

When lifecycle state produces a wrong result — a trace that includes a dead main, a report that miscounts assets, a transition that is silently rejected — work this checklist in order. The first item is the most common root cause.

  1. Out-of-domain status first. Query every network feature class for a LIFECYCLESTATUS outside {PROPOSED, ACTIVE, ABANDONED, RETIRED}. A single legacy string or null defeats the eligibility filter, because the membership test never matches and the feature falls through to whichever default the code assumes.
  2. Trace eligibility mismatch. Confirm the include_abandoned policy passed to build_trace_graph matches the trace’s intent. An energization trace that includes abandoned features reports dead sections as live; a locate-request trace that excludes them under-reports what is in the ground.
  3. Illegal transitions that were written anyway. Audit for status pairs that violate LEGAL_TRANSITIONS — most tellingly, features that moved out of RETIRED. Their existence means an edit path bypassed the machine and wrote the field directly.
  4. Orphaned contents after a container transition. For every recently retired container, check that no contained feature remains active. An active content inside a retired container is the signature of a cascade that was skipped.
  5. Stale as-built promotions. Compare proposed features against the reconciled as-built record. A feature still marked proposed after construction is complete will be invisible to operational traces even though it is energized.
  6. Missing audit events. Reconcile the current status of a sample of assets against a replay of their audit history. A current state that the event log cannot reproduce means a transition happened outside the append-only path and the history is no longer authoritative.

Performance & Scale Considerations

At enterprise scale a network carries millions of features, and the eligibility filter runs on every trace, so it must be cheap. Precompute the eligible node set once per topology snapshot and cache it, rather than testing lifecycle_status inside every traversal loop; a status change is a delta applied to the cached set, not a reason to rebuild it. Where the network is partitioned by pressure zone, feeder, or watershed, filter within the partition so the eligibility pass never touches features a given trace could not reach anyway.

Run bulk transitions — an as-built promotion of a whole subdivision, a mass retirement after a main-replacement project — against a versioned, isolated workspace or a snapshot file geodatabase, so the batch holds no locks on the default version while editors work. Apply transitions in work-order-sized batches inside a single edit operation so a failure rolls back cleanly rather than leaving the network half-promoted. The audit store is append-only by design, which makes writes fast and contention-free, but it grows without bound; partition it by transition date and archive cold partitions so the replay used in the diagnostic protocol stays fast on the recent history that matters. Keep the as-built reconciliation on its own cadence — stage design-version edits and promote them in scheduled reconcile/post passes rather than promoting feature-by-feature inside an interactive edit session.

Compliance Notes

An explicit lifecycle state machine is the mechanism that turns asset management from an assertion into evidence. ISO 55000 and ISO 55001 asset-management practice expect a defined lifecycle with governed transitions and a retained history; the state set and transition table documented here are a direct implementation of that expectation, and the append-only audit store is the retained history an assessor asks for. For water utilities, AWWA G400 operational and asset-management requirements assume an accurate inventory that distinguishes in-service, abandoned-in-place, and removed assets — precisely the distinction the abandoned and retired states preserve, and a distinction that also governs one-call and excavation-locate obligations for buried infrastructure. For electric systems, the change-management discipline behind NERC CIP expects that the operational system of record reflects only assets that are actually in service, which is exactly the guarantee the eligibility filter enforces.

To remain auditable, every transition must be logged with a fixed event schema: asset identifier, prior status, new status, actor, timestamp, originating work order, and the validation result captured at commit time. Python’s standard logging facility is sufficient to emit these events, and routing them to an append-only store or an asset-management system of record gives the chain of custody a reliability review or rate case requires. Authority for the practices themselves should be drawn only from primary sources such as the AWWA standards program and the NERC reliability standards. Because the current state is always reconstructable from the event history, the system is auditable by default rather than auditable on demand — the property that separates a governed lifecycle from a status field everyone edits and no one trusts.