Condition-Based Maintenance Scheduling for Utility Assets

The Failure Mode: A Calendar That Ignores the Asset

Fixed-interval maintenance fails in two directions at once, and both are expensive. Servicing every asset on the same clock over-maintains the ones that are young, low-stress, and rarely used — burning crew hours on a five-year-old polyethylene lateral in a quiet cul-de-sac — while under-maintaining the ones that actually threaten service: a 1962 cast-iron main under an arterial road, a switchgear cabinet feeding a hospital, a pressure-reducing valve at the head of a whole zone. The calendar treats a critical, aged, corrosion-prone asset and a benign new one identically, because the only variable it reads is elapsed time. When a main breaks or a transformer fails between scheduled visits, the post-incident review always finds the same thing: the data needed to predict the failure existed in the geodatabase, but nothing in the schedule was reading it.

Condition-based maintenance scheduling closes that gap by making the schedule a function of the asset rather than the calendar. Instead of “inspect every valve every three years,” the rule becomes “compute each asset’s risk from its age, material, criticality, and last inspection, then schedule intervention in risk order.” This is the entry discipline of Asset Lifecycle & Maintenance Automation: it sits directly on top of a validated spatial register and feeds the work-management system downstream. This reference targets utility engineers, GIS technicians, and Python automation teams, and it covers the health index, the criticality model, the probability-times-consequence risk score, the inspection feedback loop, and batch scoring across the full asset register. The two computations at its core are worked end to end in risk-scoring utility assets with Python and automating inspection-cycle generation from asset age.

From validated asset register to risk-ranked maintenance schedule with an inspection feedback loop Four stages flow left to right: a validated GIS asset register supplies age, material, and criticality attributes; a health-index stage blends age against service life and the last inspection grade; a risk-score stage multiplies probability of failure by consequence; and a schedule stage bands and dates the work. Completed inspections feed a dashed return path from the schedule back into the register, so the next batch reflects observed condition rather than age alone. 1 · Asset register install date · material criticality · last inspection 2 · Health index age / service life + degradation + inspection grade 3 · Risk score probability × consequence = priority 4 · Schedule risk bands target dates work orders completed inspection grade feeds the next batch

Prerequisite Checklist

Confirm every item below before scoring or scheduling against production data. Skipping the register-validation or criticality checks is the most common cause of a schedule that looks defensible but ranks the wrong assets first.

Core Data Model: Health Index, Criticality, and the Risk Product

Condition-based scheduling rests on three derived quantities, each computed per asset and each traceable back to a source attribute. Getting the model right means keeping them separate: the health index measures how likely the asset is to fail, criticality measures how much it matters if it does, and risk is their product. Collapsing them — ranking by age alone, or by customers-served alone — reproduces exactly the failure mode this discipline exists to fix.

The health index normalizes condition onto a bounded 0-to-100 scale where 100 is as-new and 0 is failed. Its dominant input is age relative to expected service life for the asset’s material class: ductile iron is rated for a longer life than unlined cast iron, PVC longer than asbestos-cement. Age alone is a weak predictor, so the index is corrected by the most recent inspection grade — a 40-year-old main that inspected as sound is healthier than its age implies, and a 15-year-old main with active tuberculation is worse. Where telemetry exists (pressure-transient counts, fault history), it further discounts the index.

Criticality is a consequence weight independent of condition. It rolls up from the asset hierarchy: how many service points are downstream, whether any are critical facilities, the pressure or voltage class, and the cost and duration of a failure at that location. This is where a clean containment model earns its keep — without it, the system cannot answer “how many customers depend on this asset” without re-deriving it from raw geometry on every run.

Risk is the product. A defensible score multiplies a probability-of-failure derived from the health index by that consequence weight, yielding a single number that orders the whole register. The full computation, over a GeoDataFrame, is the subject of risk-scoring utility assets with Python; the model below shows how the health index that feeds it is assembled.

from dataclasses import dataclass
from datetime import date
import logging

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

# Expected service life in years, keyed on material class.
SERVICE_LIFE = {"DI": 100, "CI": 75, "PVC": 80, "PE": 60, "AC": 50, "STEEL": 65}


@dataclass(frozen=True)
class HealthInputs:
    """Per-asset inputs for the health index."""
    material: str
    install_date: date
    inspection_grade: int | None  # 1 (new) .. 5 (imminent failure), or None


def health_index(inp: HealthInputs, as_of: date) -> float:
    """Return a bounded 0..100 health index for one asset.

    Age against expected service life sets the baseline; the most recent
    inspection grade corrects it up or down. Higher is healthier.
    """
    life = SERVICE_LIFE.get(inp.material)
    if life is None:
        raise KeyError(f"unknown material class {inp.material!r}")

    age_years = (as_of - inp.install_date).days / 365.25
    if age_years < 0:
        raise ValueError(f"install_date {inp.install_date} is in the future")

    # Baseline: linear consumption of service life, clamped to [0, 100].
    baseline = max(0.0, 100.0 * (1.0 - age_years / life))

    # Inspection correction: grade 3 is neutral; 1 lifts, 5 depresses.
    if inp.inspection_grade is not None:
        correction = (3 - inp.inspection_grade) * 12.5  # +25 .. -25
        baseline = min(100.0, max(0.0, baseline + correction))

    return round(baseline, 1)


if __name__ == "__main__":
    old_but_sound = HealthInputs("CI", date(1985, 6, 1), inspection_grade=2)
    print(health_index(old_but_sound, date(2026, 7, 13)))

Step-by-Step Implementation

Follow this sequence to take a raw asset register to a risk-ranked, dated maintenance schedule.

  1. Assemble the register. Join install_date, material, criticality, customers_served, and the last inspection date and grade onto one validated asset table. Resolve nulls with documented estimation rules before scoring; an undocumented default silently biases the ranking.
  2. Compute the health index. Apply health_index per asset to blend age against material service life and correct by the last inspection grade. Persist it as a column so the score is reproducible and auditable.
  3. Score risk. Multiply a probability-of-failure derived from the health index by the consequence weight from criticality and customers served. Batch this across the whole GeoDataFrame rather than looping per feature.
  4. Band and rank. Sort by risk descending and cut the population into intervention bands — for example the top decile as “immediate,” the next as “this year,” the remainder as “monitor.” Bands, not raw scores, are what crews and planners act on.
  5. Generate inspection cycles. Derive per-asset inspection due dates from install date, material interval, and criticality, worked in detail in automating inspection-cycle generation from asset age.
  6. Close the loop. Write each completed inspection’s grade and date back to the register so the next batch scores on observed condition, not age alone.

The batch scoring step is where the discipline becomes production automation rather than a spreadsheet. Vectorize the score over the register, band it, and emit a structured, sorted result a planner or a pipeline can act on:

import pandas as pd


def band_register(scored: pd.DataFrame, risk_col: str = "risk_score") -> pd.DataFrame:
    """Assign an intervention band to each asset from its risk score.

    Expects a DataFrame already carrying `risk_col`. Returns a new frame
    sorted by risk descending with an added `band` column.
    """
    if risk_col not in scored.columns:
        raise KeyError(f"missing required column {risk_col!r}")

    ranked = scored.sort_values(risk_col, ascending=False).reset_index(drop=True)
    n = len(ranked)
    if n == 0:
        logging.warning("empty register; nothing to band")
        ranked["band"] = pd.Series(dtype="object")
        return ranked

    # Percentile cut points: top 10% immediate, next 20% this year, rest monitor.
    cutoffs = ranked[risk_col].quantile([0.90, 0.70])
    def to_band(v: float) -> str:
        if v >= cutoffs[0.90]:
            return "immediate"
        if v >= cutoffs[0.70]:
            return "this_year"
        return "monitor"

    ranked["band"] = ranked[risk_col].apply(to_band)
    logging.info("banded %d assets: %s", n, ranked["band"].value_counts().to_dict())
    return ranked


if __name__ == "__main__":
    demo = pd.DataFrame({"asset_id": ["A", "B", "C", "D"],
                         "risk_score": [820.0, 140.0, 60.0, 15.0]})
    print(band_register(demo).to_string(index=False))
Risk-ranked register cut into three intervention bands A horizontal risk axis runs from high on the left to low on the right. The ranked asset population is divided into three bands: an immediate band at the top decile mapped to a work order now, a this-year band in the next twenty percent mapped to a scheduled inspection, and a monitor band covering the remainder mapped to a periodic re-score. Each band lists its action. Risk-ranked asset register high risk low risk Immediate top decile action: work order now This year next 20% action: scheduled inspection Monitor remaining 70% action: periodic re-score Percentile cut points are policy inputs, not constants — tune them to crew capacity and regulatory windows.

Percentile banding keeps the schedule bounded by crew capacity: because the bands are cut on quantiles rather than absolute thresholds, the “immediate” queue is always the worst decile of what actually exists, not an open-ended list that balloons after a bad inspection season. Where a regulatory window demands an absolute floor — every critical-facility feeder inspected within a fixed period regardless of score — layer that constraint on after banding rather than baking it into the risk formula, so the two rationales stay auditable independently.

Diagnostic Protocol

When a schedule ranks an obviously wrong asset first, or omits one that later fails, work this checklist in order. The first item is the most common root cause.

  1. Null or defaulted install dates. Query for install_date that is null or set to an import sentinel (a suspicious pile-up on 1900-01-01 or the load date). A defaulted date makes an old asset look new or a new one ancient, and it silently dominates the health index.
  2. Out-of-domain material class. Any material value outside the service-life lookup either raises or falls to a default life, skewing every age normalization for that class. Check the domain before scoring.
  3. Criticality that ignores the hierarchy. Confirm customers_served and criticality were rolled up from a validated containment model. A fragmented network under-counts downstream service points, so a trunk main scores like a lateral.
  4. Stale inspection grades. Compare the last-inspection date against today. A grade captured a decade ago is not current condition; treat inspections beyond their own cycle as expired and let age dominate rather than trusting the old grade.
  5. Banding on absolute thresholds. If the “immediate” queue is empty or enormous, check whether bands were cut on fixed numbers instead of quantiles. Absolute cut points drift out of calibration as the register ages.
  6. Silent unit or sign errors. A negative age (future install date) or a life expressed in months rather than years produces a plausible-looking but wrong index. Assert on ranges before publishing the schedule.

Performance & Scale Considerations

An enterprise register runs to hundreds of thousands of assets, so scoring must be vectorized, not looped. Compute the health index and risk product as column operations over the GeoDataFrame — a per-feature Python loop over a full water system is minutes where a vectorized pass is sub-second. Cache the criticality roll-up: it changes only when the network topology or customer assignment changes, so recompute it on a nightly cadence rather than inside every scoring run, and read it as a joined column. The same batch discipline documented for network-wide processing applies here; heavy scoring belongs in the ingestion-and-transform layer described under data ingestion pipelines for utility assets, not in an interactive session.

Run scoring against a versioned, isolated workspace or a snapshot file geodatabase so a long batch pass never holds locks on the default version during an active edit session. Keep the inspection feedback write on its own transaction: stage completed grades in a table and apply them in batches, so a mobile field sync and the nightly re-score never contend. Where the register spans multiple systems (water, gas, electric), partition by system and score each partition independently — service-life lookups and consequence models differ, and partitioning keeps each domain’s assumptions explicit rather than averaged together.

Compliance Notes

Condition-based scheduling is not only an efficiency measure; it is the evidence base for asset-management compliance. The workflow maps onto specific checkpoints rather than gesturing at them. ISO 55001 asset-management practice expects a documented, risk-based basis for maintenance decisions — the health index, the criticality roll-up, and the risk product supply exactly that, with each score traceable to its source attributes. For water systems, AWWA G400 operational and asset-management practice assumes a defensible condition-assessment and prioritization program; a reproducible, version-stamped risk ranking over a validated register is what an auditor asks to see. The lifecycle transitions the schedule drives — an asset moving from in-service to inspection-due to work-planned — are the same states modeled in lifecycle state machines for utility assets, and logging each transition is what makes the record defensible.

To remain auditable, every scoring run should persist its inputs and outputs: the register snapshot identifier, the service-life lookup version, the banding cut points, and the resulting per-asset score and band, written to a timestamped, version-controlled log. Python’s standard logging facility captures the run metadata; routing the scored register into the asset-management system gives the durable audit trail. The required audit metadata for each run is therefore: register snapshot id, scoring timestamp, service-life and criticality model versions, banding thresholds, and the actor or pipeline that executed it. Authority for the practices themselves should be drawn only from primary sources such as the AWWA standards program.