Calibrating Service Life Tables from Failure History
Every risk model in a utility rests on an assumption about how long each material lasts, and in most estates that assumption came from a handbook. Published service-life figures are averages over soils, water chemistry, installation practice and operating pressure that no single utility experiences — which means they are wrong locally in both directions. Cast iron in aggressive soil fails well before the published figure; the same material in benign ground outlasts it by decades. A replacement programme driven by the published number therefore over-invests in one population and under-invests in another, and the failure record that would have revealed this is usually sitting unused in the work-management system. This guide calibrates the table against that record, handling the censoring that makes the difference between a survival estimate and a misleading average. It feeds the risk model described in condition-based maintenance scheduling.
Environment Prerequisites
- Python 3.11 with
pandas>=2.0andnumpy>=1.26; a survival library is optional and the worked estimator below uses neither. - A failure history naming assets, not addresses. A failure record joined by location is a join you cannot trust; the record needs an asset identifier.
- Install dates on the surviving population, because survivors are the larger half of the evidence and dropping them biases everything.
- An environment grouping — soil corrosivity, pressure zone, installation era — since material alone rarely explains the spread.
- A minimum-sample policy stating how many observed failures a group needs before its figure replaces the published one.
- Version control for the resulting table, because it is a model input and a change to it changes every risk score in the estate.
Schema-Aware Validation Protocol — Run Before Fitting Anything
- Confirm failures resolve to assets that still exist. A failure on a retired and replaced main belongs to the retired asset, and attributing it to the replacement makes the new material look terrible.
- Check for repeat failures on one asset. A main that has failed four times is one asset with four events, and counting it as four assets distorts the sample.
- Include the survivors. Every asset still in service is a censored observation, and omitting them is the single largest source of bias in this exercise.
- Look for an install-date sentinel. A pile-up on 1900-01-01 produces implausible ages that will dominate any median.
- Confirm the environment grouping is populated. A material split by soil corrosivity is far more predictive than material alone, but only where the soil attribution actually exists.
Minimal Reproducible Implementation
from __future__ import annotations
import logging
from dataclasses import dataclass, field
import numpy as np
import pandas as pd
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
LOG = logging.getLogger("service-life")
MIN_FAILURES = 30 # below this, the published figure stands
@dataclass
class LifeEstimate:
group: str
median_years: float | None
failures: int
survivors: int
status: str # CALIBRATED, PROVISIONAL, INSUFFICIENT
note: str = ""
@dataclass
class Calibration:
estimates: list[LifeEstimate] = field(default_factory=list)
def table(self) -> dict[str, float]:
return {e.group: e.median_years for e in self.estimates
if e.median_years is not None and e.status == "CALIBRATED"}
def kaplan_meier_median(ages: np.ndarray, failed: np.ndarray) -> float | None:
"""Median survival time from right-censored observations.
``ages`` is the age of each asset at failure or at the observation date; ``failed``
marks which of those ages ended in a failure. Survivors contribute to the risk set
without contributing a failure, which is exactly what keeps the estimate from
collapsing toward the age of the failures alone.
"""
order = np.argsort(ages)
ages, failed = ages[order], failed[order]
at_risk = len(ages)
survival = 1.0
for i, age in enumerate(ages):
if failed[i]:
survival *= (at_risk - 1) / at_risk
if survival <= 0.5:
return float(age)
at_risk -= 1
return None # never reached 50% survival within the observation window
def calibrate(assets: pd.DataFrame, failures: pd.DataFrame,
as_of: pd.Timestamp) -> Calibration:
"""Estimate median service life per material-and-environment group."""
result = Calibration()
first_failure = (failures.sort_values("failed_at")
.groupby("asset_id")["failed_at"].first())
joined = assets.copy()
joined["failed_at"] = joined["asset_id"].map(first_failure)
joined["end"] = joined["failed_at"].fillna(as_of)
joined["age"] = (joined["end"] - joined["install_date"]).dt.days / 365.25
joined["failed"] = joined["failed_at"].notna()
joined = joined[joined["age"] > 0]
for group, rows in joined.groupby("life_group"):
n_fail = int(rows["failed"].sum())
n_surv = int((~rows["failed"]).sum())
if n_fail < 10:
result.estimates.append(LifeEstimate(
str(group), None, n_fail, n_surv, "INSUFFICIENT",
"too few failures — keep the published figure"))
continue
median = kaplan_meier_median(rows["age"].to_numpy(),
rows["failed"].to_numpy())
status = "CALIBRATED" if n_fail >= MIN_FAILURES else "PROVISIONAL"
note = "" if status == "CALIBRATED" else "provisional — sample below threshold"
if median is None:
status, note = "INSUFFICIENT", "survival never reached 50% in the window"
result.estimates.append(LifeEstimate(str(group), median, n_fail, n_surv,
status, note))
LOG.info("%d group(s): %d calibrated", len(result.estimates),
sum(e.status == "CALIBRATED" for e in result.estimates))
return result
The censoring is the whole point of using a survival estimator rather than averaging the ages of failed assets. An estate whose cast iron is mostly still in the ground will show a median failure age far below its true service life if survivors are excluded — and the replacement programme built on that number will spend heavily on material that had decades left.
Using a Calibrated Table Responsibly
A calibrated figure is a better input, not a prediction about an individual asset. Three disciplines keep it from being over-read.
Publish the sample with the figure. A median supported by thirty-one failures and one supported by three hundred are different kinds of number, and a risk model that treats them identically inherits the difference invisibly.
Recalibrate on a schedule, not continuously. Service life moves slowly; a table that changes every week produces a risk ranking that churns for reasons nobody can explain to a regulator.
Keep the published figures as a fallback. Where a group is insufficient, the handbook number is the honest answer, and recording that it was used is what lets a reviewer see which parts of the model rest on local evidence.
Production Deployment Pattern
- Recalibrate annually against the full failure history, and treat the resulting table as a reviewed change rather than an automatic update.
- Version the table with the risk model. A risk score is only reproducible if the service-life table that produced it can be recovered.
- Group by material and environment, and widen the grouping rather than publishing a figure an insufficient sample cannot support.
- Report the coverage. The share of assets whose group is calibrated rather than published is the honest description of how locally grounded the model is.
- Feed repeat-failure assets into the model separately. An asset that has failed three times is telling you something the material average cannot, and it belongs in the intervention queue regardless of its age.
- Persist every calibration run — groups, samples, medians, status — because the next run is a comparison and a moving median is itself a signal.
Conclusion
Calibrating service life against local failure history replaces a national average with an estimate of what happens in this ground, to this material, under this operating regime. Including survivors as censored observations is what makes the estimate honest; a minimum-sample policy is what keeps it from producing confident nonsense on thin data; and publishing the sample alongside the figure is what lets the risk model downstream treat a well-supported number differently from a provisional one. The replacement programme that follows is then aimed by evidence rather than by handbook.
Related
- Up to the parent topic: Condition-Based Maintenance Scheduling
- Up to the section: Asset Lifecycle & Maintenance Automation
- Risk Scoring Utility Assets with Python
- Automating Inspection Cycle Generation from Asset Age
- Lifecycle State Machines for Utility Assets
For authoritative reference, consult the NumPy statistics reference and the AWWA standards catalogue.