Estimating Restoration Times with Historical Outage Data
A restoration estimate is the most visible output an outage system produces. Customers see it, regulators increasingly require it, and dispatchers plan against it — and in many estates it is produced by a single blended average that is wrong for every outage it is applied to. The fix is not a more sophisticated model. It is to decompose by damage class, to use the historical distribution for that class rather than its mean, to add the queue the outage is actually waiting in, and above all to record the basis of every estimate so the next event can improve it. This guide builds that estimator from data an estate already has, and it deliberately publishes a range where the evidence supports only a range. It sits inside the regime discipline set out in storm response and restoration analytics.
Environment Prerequisites
- Python 3.11 with
pandas>=2.0andnumpy>=1.26in an isolated environment; no geodatabase dependency, since the estimator works on the outage history rather than the network. - A restored-outage history carrying, per outage: the damage class, the assignment time, the restoration time, the crew type and source, the season, and the affected count.
- A damage classification available early. The estimator is only as good as the class it is given, and a class assigned after restoration is useless for prediction.
- Current crew availability and the queue as the dispatch loop sees them, so the expected start reflects reality rather than an assumption of immediate assignment.
- A store for published estimates with their basis, timestamped and immutable, because the scoring pass reads it.
- A minimum sample size per class, below which the estimator must widen to a range instead of publishing a point estimate.
Schema-Aware Validation Protocol — Run Before Publishing Estimates
- Confirm the history’s durations measure the same interval. Assignment-to-restoration and report-to-restoration differ by the queue, and mixing them produces a distribution that models neither.
- Check the classification is assigned before restoration. A class populated retrospectively makes the model look excellent in backtest and useless in production.
- Verify the sample size per class and season. A class with a handful of examples cannot support a point estimate; the estimator must know its own sample size and widen accordingly.
- Look for survivorship in the history. Outages that were never restored — cancelled, merged, reclassified — must be excluded explicitly rather than left to skew the distribution.
- Confirm the crew-availability input is live. An estimator using a nominal crew count during an event where half the crews are committed will be systematically optimistic, and the error will be blamed on the duration model rather than on the queue.
Minimal Reproducible Implementation
The estimator below builds a duration distribution per damage class and season, combines it with the queue position to produce an expected start, and returns an estimate that carries its own basis. When the sample is thin, it returns a range and says why.
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("etr")
MIN_SAMPLE = 12 # below this, publish a range rather than a point estimate
@dataclass
class Estimate:
"""A published restoration estimate and everything that produced it."""
outage_id: str
damage_class: str
low_h: float
high_h: float
point_h: float | None
basis: dict = field(default_factory=dict)
@property
def is_range(self) -> bool:
return self.point_h is None
def duration_distribution(history: pd.DataFrame, damage_class: str,
season: str) -> np.ndarray:
"""Assignment-to-restoration durations, in hours, for one class and season."""
sel = history[(history["damage_class"] == damage_class)
& (history["season"] == season)
& history["restored_at"].notna()]
hours = (sel["restored_at"] - sel["assigned_at"]).dt.total_seconds() / 3600.0
return hours.to_numpy()
def estimate_restoration(
outage_id: str,
damage_class: str,
season: str,
history: pd.DataFrame,
queue_ahead: int,
crews_available: int,
crew_source: str = "HOME",
) -> Estimate:
"""Produce an estimate with its basis attached.
``queue_ahead`` is how many comparable jobs sit in front of this one; combined with
the crews available it converts a duration into an expected start. A thin sample
yields a range rather than a point estimate, and says so in the basis.
"""
durations = duration_distribution(history, damage_class, season)
n = int(durations.size)
if n == 0:
LOG.warning("no history for %s/%s — falling back to the class across seasons",
damage_class, season)
durations = duration_distribution(history, damage_class, season="ALL")
n = int(durations.size)
if n == 0:
return Estimate(outage_id, damage_class, low_h=2.0, high_h=24.0, point_h=None,
basis={"reason": "no history for this class", "sample": 0})
p25, p50, p75 = (float(np.percentile(durations, q)) for q in (25, 50, 75))
# Queue: comparable jobs ahead, divided by the crews that can take them.
per_wave = max(1, crews_available)
wait_h = (queue_ahead / per_wave) * p50
if crew_source != "HOME":
wait_h *= 1.15 # unfamiliar crews are slower on their first shift
basis = {
"sample": n, "p25": p25, "p50": p50, "p75": p75,
"queue_ahead": queue_ahead, "crews_available": crews_available,
"crew_source": crew_source, "wait_h": wait_h,
}
if n < MIN_SAMPLE:
basis["reason"] = f"sample below {MIN_SAMPLE} — range published"
return Estimate(outage_id, damage_class, low_h=wait_h + p25,
high_h=wait_h + p75, point_h=None, basis=basis)
return Estimate(outage_id, damage_class, low_h=wait_h + p25, high_h=wait_h + p75,
point_h=wait_h + p50, basis=basis)
def score_estimates(published: pd.DataFrame, actuals: pd.DataFrame) -> pd.DataFrame:
"""Compare each published estimate against the outcome, grouped by damage class."""
joined = published.merge(actuals, on="outage_id", how="inner")
joined["error_h"] = (joined["actual_h"] - joined["point_h"])
return (joined.groupby("damage_class")["error_h"]
.agg(["count", "mean", "median", "std"])
.rename(columns={"mean": "bias_h"}))
The score_estimates function is the half that most estates skip, and it is the half that makes
the estimator improve. An estimate that is never compared against the outcome is an opinion; one
that is scored by class every week is a model with a measured bias that can be corrected.
Production Deployment Pattern
- Re-estimate on every material change, not on a timer: a class reassignment, a crew assignment, or a queue change ahead of this outage. Publishing a new number when nothing changed erodes confidence in all of them.
- Publish ranges honestly. A range labelled as a range is trusted; a point estimate that turns out to be a guess is not, and the loss of credibility applies to every later estimate.
- Never publish an estimate without storing its basis. The basis is what the scoring pass reads, and it is also what answers a complaint about a missed estimate.
- Score weekly, in season. Group errors by damage class and crew source, and treat a systematic bias as an input defect. The patterns are stable enough that a season of scoring produces a materially better model.
- Feed the classification back. When scoring shows a class with large errors in both directions, that class is holding two populations and should be split — usually by access or by equipment requirement.
- Keep the estimator out of the operational path. It reads history and the current queue, and it publishes; it should never be able to block dispatch if it fails.
Conclusion
Restoration estimation gets better through decomposition and scoring rather than through sophistication. Splitting by damage class turns one useless average into four usable distributions; adding the queue turns a duration into an arrival time; recording the basis makes the next event an opportunity to improve rather than a repeat. The estimates and their outcomes also become the raw material for the reliability filing that follows the event, which is where post-storm reliability reporting picks up.
Related
- Up to the parent topic: Storm Response & Restoration Analytics
- Up to the section: Outage Routing & Impact Automation
- Storm-Mode Thresholds for Outage Detection
- Prioritizing Restoration with SAIDI/SAIFI Scoring
- Solving Crew Routing with OR-Tools
For authoritative reference, consult the pandas documentation and the NumPy statistics reference.