Failures after maintenance
An asset failed soon after work was done on it. Which recent part, lot, crew, procedure or firmware most likely explains it, and which other assets got the same work?
Summary
We give reliability engineers a ranked, explained list of the recent work orders behind each unplanned failure, plus every asset that received the same suspect work and hasn't failed yet. HeapDB learns from teardown findings and from every failure and non-failure across the fleet.
The question comes up when a pump, compressor, vehicle or aircraft component fails days or weeks after maintenance. The engineer wants to know which part, lot, crew, procedure or firmware change to suspect first. When a pattern emerges, such as a bad bearing lot, the next step is to find every other asset with that lot installed recently, before it fails too.
Model
| HeapDB | Fleet reliability |
|---|---|
| Entity | One asset (a pump, compressor, vehicle or serialized component). |
| Events | Work orders and changes: part replacements, inspections, firmware updates. |
| Features | part:<number>, lot:<id>, supplier:<name>, crew:<id>, proc:<id>, fw:<version>, operating conditions such as duty:high and env:dusty. |
| Weight (dose) | 1.0 per work order in the example. Could scale with the number of parts replaced or the hours of work. |
| Effects and kernels | unplanned_failure with GammaKernel(shape=2.0, scale=6 days, tail_stds=4.0). The hazard after work rises over the first days, peaks at 6 days and has a tail to about 46 days. This is the infant-mortality shape: defects introduced by work show up soon, not instantly and not months later. A LogNormalKernel would also fit and has a longer tail. |
| Leak | 0.10. Many failures are wear-out or random and have no recent work behind them. |
| Rules | inspection-only x0.3: an inspection with no part replaced rarely introduces a defect. |
Learning
- Confirms (feedback): a teardown or root-cause finding names the work order that caused the failure. It is confirmed and the other recent work orders on that asset are rejected.
- Observed without labels (observe): every unplanned failure, whether or not anyone investigates it. Recent work orders get soft credit in proportion to their score; the leak takes the share of "no recent work explains this".
- Settles (settle): a work order whose kernel window passed without a failure on that asset adds negative evidence to its features. The example uses
settle(partial=True)weekly, so work partway through its window adds evidence in proportion to the kernel mass already passed. - Priors: a sceptical default prior Beta(1, 9) for every feature. Engineers could set stronger priors for known risky procedures from FMEA worksheets.
Sidecar architecture
The system of record is the maintenance management system (CMMS or EAM) plus the asset registry and the firmware deployment log. Work orders, part installs and firmware updates reach HeapDB through an outbox table or change data capture; failure reports and teardown findings arrive the same way.
HeapDB writes back a ranked suspect list on each failure record and a watch list of at-risk assets. It holds no data that the source systems lack, so it can be rebuilt from them at any time.
Worked example
The simulation covers 900 pumps at four sites. One bearing lot from one supplier, installed during a 40-day window, fails 40% of the time a week or two after install, while a firmware update, a high-duty site and a busy crew act as distractors.
Output of 0007-failures-after-maintenance.py
SCENARIO
900 pumps, 4 sites, 90 days to now: 3,186 work orders (196 installed lot:B-2291), firmware rollout fw:4.2.0.
210 unplanned failures observed (72 from the bad lot, 51 other infant mortality, 87 background); 54 teardowns.
ONE FAILURE, EXPLAINED (pump-0414, day 88.6)
Top causes of 'unplanned_failure' for pump-0414 at t=2026-05-29T13:14:50Z:
1. #1700 work_order@2026-05-14T17:42:30Z [crew:C8,duty:normal,lot:B-2291,part:6205-bearing,proc:BRG-REPL-03,supplier:kestrel] w=1 score=0.2270
lag 355.54h -> kernel 0.568 x weight 1 x link 0.399 [noisy-OR of crew:C8+lot:B-2291+supplier:kestrel=0.25 inseparable, part:6205-bearing+proc:BRG-REPL-03=0.14 inseparable, duty:normal=0.07 (a=1.19, b=15.79)]
warning: 'part:6205-bearing', 'proc:BRG-REPL-03' always co-occur (2 events): feedback cannot separate their effects yet; reported as joint cause part:6205-bearing+proc:BRG-REPL-03
2. #1699 firmware@2026-04-18T10:51:01Z [fw:4.2.0] w=1 score=0.0014
lag 986.40h -> kernel 0.020 x weight 1 x link 0.071 [noisy-OR of fw:4.2.0=0.07 (a=1.06, b=13.94)]
[segments: 2 scanned / 0 pruned / 2 in window; events scored: 2]
ACROSS ALL FAILURES (link_report, top 8)
Links of 'unplanned_failure' for (global) (baseline 0.020; associations under the model, not causal effects):
feature mean 90% CI lift n alpha beta
lot:B-2291 0.114 [0.078, 0.155] 5.73 172.4 20.76 161.62
crew:C5 0.031 [0.010, 0.060] 1.54 113.1 3.76 119.35
supplier:kestrel 0.030 [0.020, 0.042] 1.53 624.8 19.32 615.46
crew:C2 0.028 [0.008, 0.058] 1.42 99.9 3.10 106.79
crew:C8 0.024 [0.006, 0.052] 1.22 100.2 2.68 107.55
crew:C11 0.023 [0.005, 0.052] 1.17 90.8 2.35 98.41
crew:C12 0.023 [0.005, 0.051] 1.17 97.8 2.50 105.25
crew:C4 0.023 [0.007, 0.048] 1.16 124.0 3.09 130.87
... fw:4.2.0 #26 of 39, mean 0.013
... crew:C1 #23 of 39, mean 0.016
... duty:high #20 of 39, mean 0.017
PROACTIVE: 100 PUMPS WITH lot:B-2291 IN WINDOW, NOT YET FAILED, BY RISK
pump-0048 installed 10.4 d ago risk=0.1856 fails in next 30 d: no
pump-0198 installed 11.1 d ago risk=0.1687 fails in next 30 d: no
pump-0582 installed 10.5 d ago risk=0.1656 fails in next 30 d: no
pump-0079 installed 11.8 d ago risk=0.1638 fails in next 30 d: yes
pump-0469 installed 12.2 d ago risk=0.1579 fails in next 30 d: no
in the next 30 d (simulation): 10 of 100 flagged pumps fail (top half 8 of 50, bottom half 2 of 50);
33 of 803 pumps not flagged fail (4.1% vs 10.0% flagged).
CHECK
lot:B-2291 ranks #1 of 39 features in link_report (mean 0.114); fw:4.2.0 ranks #26.Show the full script (255 lines)
"""Failures after maintenance: which recent work explains a failure, and who is next?
Simulates 150 days of work orders for a fleet of 900 centrifugal pumps at four
sites. Each work order records the part replaced (part number, lot, supplier),
the crew, the procedure and the site's operating conditions. A firmware update
rolls out to the controllers mid-period. Pumps fail now and then. HeapDB ranks
the recent work behind each failure (top_causes), learns across the fleet
(link_report), and flags pumps that got the suspect work and have not failed
yet (top_causes at "now").
Hidden ground truth (the seeded cause):
* lot:B-2291 of bearing part:6205-bearing from supplier:kestrel is bad. It was
installed between day 40 and day 80. Each install fails with probability
0.40, after a gamma-distributed delay (shape 2, scale 6 days: mode 6 days,
mean 12 days). Kestrel's other bearing lots are fine.
* Any work order carries a small infant-mortality risk (2%), whatever was done.
* Background failures: 0.08% per pump-day, three times that at site:S3.
Distractors:
* Confounder: site:S3 runs high duty (duty:high) and fails more in general.
* Near-miss timing: firmware fw:4.2.0 rolls out from day 45 to day 75, in the
same weeks as the bad lot. It is harmless.
* Busy crew: crew:C1 does about 45% of all work orders. It is harmless.
Learning: every failure is observe()d (no known cause). One in four failures
before "now" gets a teardown; its finding goes to feedback() (the work order
that caused it is confirmed, the other recent ones are rejected). Each week,
settle(partial=True) adds negative evidence for work without a failure since,
scaled by how much of its window (the kernel's mass) has passed.
Run:
pip install heapdb
python 0007-failures-after-maintenance.py
"""
from __future__ import annotations
import calendar
import random
from heapdb import GammaKernel, HeapDB, format_result
M, H, D = 60.0, 3600.0, 86400.0
SEED = 11
N_PUMPS = 900
DAYS = 150
NOW_DAY = 90 # the analyst's "now"; later failures are hidden from HeapDB
LOOKAHEAD = 30 # days after now used to score the proactive list
T0 = float(calendar.timegm((2026, 3, 2, 0, 0, 0)))
NOW = T0 + NOW_DAY * D
EFFECT = "unplanned_failure"
BAD_LOT = "lot:B-2291"
BAD_WINDOW = (40, 80) # days in which the bad lot was being installed
P_BAD = 0.40
P_INFANT = 0.02
BASE_RATE = 0.0008 # per pump-day
FW = "fw:4.2.0"
SITES = { # site -> (operating-condition features, background multiplier)
"site:S1": ({"duty:normal"}, 1.0),
"site:S2": ({"duty:normal", "env:dusty"}, 1.0),
"site:S3": ({"duty:high"}, 3.0),
"site:S4": ({"duty:normal"}, 1.0),
}
CREWS = ["crew:C1"] * 9 + ["crew:C2", "crew:C3", "crew:C4", "crew:C5",
"crew:C6", "crew:C7", "crew:C8", "crew:C9", "crew:C10",
"crew:C11", "crew:C12"]
# part -> list of (lot, supplier); BAD_LOT only enters during BAD_WINDOW
PARTS = {
"part:6205-bearing": [("lot:B-2288", "supplier:kestrel"), ("lot:B-2290", "supplier:kestrel"),
("lot:N-7714", "supplier:norrland"), ("lot:N-7731", "supplier:norrland")],
"part:SL-40-seal": [("lot:S-0412", "supplier:sealtek"), ("lot:S-0419", "supplier:sealtek")],
"part:IMP-9-impeller": [("lot:I-1180", "supplier:kestrel"), ("lot:I-1192", "supplier:castor")],
"part:CPL-2-coupling": [("lot:C-3301", "supplier:castor")],
}
PROCS = {"part:6205-bearing": "proc:BRG-REPL-03", "part:SL-40-seal": "proc:SEAL-REPL-01",
"part:IMP-9-impeller": "proc:IMP-REPL-02", "part:CPL-2-coupling": "proc:CPL-ALIGN-01"}
def gamma_lag(rng: random.Random) -> float:
return rng.gammavariate(2.0, 6.0) * D
def simulate(rng: random.Random):
"""Return (events, failures, truth).
events: (pump, t, type, features); failures: (t, pump, cause) where cause is
"lot", "infant" or "background"; truth: index of the event behind a
work-order-caused failure, keyed by (pump, failure time).
"""
events, failures, truth = [], [], {}
for p in range(N_PUMPS):
pump = f"pump-{p:04d}"
site = rng.choice(list(SITES))
conds, mult = SITES[site]
evs = []
t = T0 + rng.uniform(0, 25) * D
while t < T0 + DAYS * D:
day = (t - T0) / D
crew = rng.choice(CREWS)
if rng.random() < 0.35: # inspection only, no part
feats = {"proc:PM-INSPECT", crew} | conds
else:
part = rng.choices(list(PARTS), weights=[5, 3, 1, 1])[0]
lot, sup = rng.choice(PARTS[part])
if part == "part:6205-bearing" and BAD_WINDOW[0] <= day < BAD_WINDOW[1] \
and rng.random() < 0.45:
lot, sup = BAD_LOT, "supplier:kestrel"
feats = {part, lot, sup, PROCS[part], crew} | conds
evs.append((t, "work_order", feats))
t += rng.uniform(12, 40) * D
if rng.random() < 0.6: # firmware rollout (near-miss timing)
evs.append((T0 + rng.uniform(45, 75) * D, "firmware", {FW}))
evs.sort(key=lambda e: e[0])
base = len(events)
events += [(pump, t, typ, feats) for t, typ, feats in evs]
for i, (t, typ, feats) in enumerate(evs):
if typ != "work_order":
continue
if BAD_LOT in feats and rng.random() < P_BAD:
tf = t + gamma_lag(rng)
failures.append((tf, pump, "lot"))
truth[(pump, tf)] = base + i
elif rng.random() < P_INFANT:
tf = t + gamma_lag(rng)
failures.append((tf, pump, "infant"))
truth[(pump, tf)] = base + i
day = 0.0
while True: # background failures (Poisson)
day += rng.expovariate(BASE_RATE * mult)
if day >= DAYS:
break
failures.append((T0 + day * D, pump, "background"))
failures = [f for f in failures if f[0] < T0 + DAYS * D]
failures.sort()
return events, failures, truth
def make_db() -> HeapDB:
db = HeapDB(bucket_seconds=2 * D)
# Infant mortality after work: hazard rises over the first days, peaks
# around a week (mode = (shape-1)*scale = 6 d), tail cut at mean + 4 std (~46 d).
db.register_effect(EFFECT, GammaKernel(shape=2.0, scale=6 * D, tail_stds=4.0),
default_prior=(1.0, 9.0), leak=0.10)
# An inspection with no part replaced rarely introduces a defect.
db.add_rule(EFFECT, "inspection-only", lambda e: "proc:PM-INSPECT" in e.features, 0.3,
types={"work_order"})
return db
def main() -> None:
rng = random.Random(SEED)
events, failures, truth = simulate(rng)
db = make_db()
ids = {} # event index -> HeapDB event id
for i, (pump, t, typ, feats) in enumerate(events):
if t <= NOW:
ids[i] = db.insert(pump, t, typ, feats).id
past = [f for f in failures if f[0] <= NOW]
teardowns = 0
next_settle = T0 + 7 * D
for tf, pump, cause in past:
while next_settle < tf:
db.settle(next_settle, partial=True)
next_settle += 7 * D
db.observe(pump, EFFECT, tf)
if rng.random() < 0.25: # teardown / root-cause finding
teardowns += 1
cand = db.top_causes(pump, EFFECT, tf, k=4)
conf = ids.get(truth.get((pump, tf), -1))
rej = [c.event.id for c in cand if c.event.id != conf]
db.feedback(pump, EFFECT, tf, confirmed_event_id=conf, rejected_ids=rej)
db.settle(NOW, partial=True)
n_bad = sum(1 for _, t, _, f in events if BAD_LOT in f and t <= NOW)
n_wo = sum(1 for _, t, typ, _ in events if typ == "work_order" and t <= NOW)
by = {c: sum(1 for f in past if f[2] == c) for c in ("lot", "infant", "background")}
print("SCENARIO")
print(f" {N_PUMPS} pumps, 4 sites, {NOW_DAY} days to now: {n_wo:,} work orders "
f"({n_bad} installed {BAD_LOT}), firmware rollout {FW}.")
print(f" {len(past)} unplanned failures observed ({by['lot']} from the bad lot, "
f"{by['infant']} other infant mortality, {by['background']} background); "
f"{teardowns} teardowns.")
# One failure, explained: the latest lot-caused failure with the firmware in its window.
pick = None
for tf, pump, cause in reversed(past):
if cause != "lot":
continue
res = db.top_causes(pump, EFFECT, tf, k=3, explain=True)
if any(FW in c.event.features for c in res):
pick = (pump, tf, res)
break
if pick is None:
raise SystemExit(1)
pump, tf, res = pick
print()
print(f"ONE FAILURE, EXPLAINED ({pump}, day {(tf - T0) / D:.1f})")
print(format_result(res))
print()
print("ACROSS ALL FAILURES (link_report, top 8)")
rep = db.link_report(EFFECT, min_support=10)
lines = str(rep).splitlines()
print("\n".join(lines[:2 + 8]))
order = [r.feature for r in rep]
for f in (FW, "crew:C1", "duty:high"):
print(f" ... {f:<34} #{order.index(f) + 1} of {len(order)}, mean {rep.row(f).strength:.3f}")
# Proactive: pumps that got the bad lot within the kernel window and have not failed
# since. Risk = feature_risk of that work order x kernel mass still ahead of it.
kern = db.effect(EFFECT).kernel
flagged = []
for i, (p, t, typ, feats) in enumerate(events):
if BAD_LOT not in feats or not NOW - kern.support < t <= NOW:
continue
if any(p == fp and t < ft <= NOW for ft, fp, _ in past):
continue
risk = db.feature_risk(EFFECT, feats, entity=p, type=typ)
flagged.append((risk * (1.0 - kern.mass_fraction(NOW - t)), p, (NOW - t) / D, i))
flagged.sort(key=lambda r: (-r[0], r[1]))
def fails_later(p: str) -> bool:
return any(fp == p and NOW < ft <= NOW + LOOKAHEAD * D for ft, fp, _ in failures)
print()
print(f"PROACTIVE: {len(flagged)} PUMPS WITH {BAD_LOT} IN WINDOW, NOT YET FAILED, BY RISK")
for score, p, age, _ in flagged[:5]:
print(f" {p} installed {age:4.1f} d ago risk={score:.4f} "
f"fails in next {LOOKAHEAD} d: {'yes' if fails_later(p) else 'no'}")
half = len(flagged) // 2
top_n = sum(fails_later(p) for _, p, _, _ in flagged[:half])
bot_n = sum(fails_later(p) for _, p, _, _ in flagged[half:])
others = sorted({e[0] for e in events} - {p for _, p, _, _ in flagged})
oth_n = sum(fails_later(p) for p in others)
print(f" in the next {LOOKAHEAD} d (simulation): {top_n + bot_n} of {len(flagged)} flagged pumps fail "
f"(top half {top_n} of {half}, bottom half {bot_n} of {len(flagged) - half});")
print(f" {oth_n} of {len(others)} pumps not flagged fail "
f"({oth_n / len(others):.1%} vs {(top_n + bot_n) / max(len(flagged), 1):.1%} flagged).")
print()
print("CHECK")
rank = order.index(BAD_LOT) + 1
print(f" {BAD_LOT} ranks #{rank} of {len(order)} features in link_report "
f"(mean {rep.row(BAD_LOT).strength:.3f}); {FW} ranks #{order.index(FW) + 1}.")
if rank != 1:
raise SystemExit(1)
if __name__ == "__main__":
main()- One failure: on pump-0414, the work order that installed
lot:B-2291scores 0.2270 against 0.0014 for the firmware update. The lot is reported jointly withcrew:C8andsupplier:kestrel, which never appear apart on this pump. - Across the fleet,
lot:B-2291ranks first with mean 0.114 and lift 5.73. The firmwarefw:4.2.0was rolled out in the same weeks but falls to #26 of 39, because settle sees most firmware updates pass without failure. - The confounders stay low: the busy
crew:C1ranks #23 and the high-dutyduty:highranks #20.supplier:kestrelranks third with lift 1.53, because it carries the bad lot's failures as well as its good ones. - Proactive step: 100 pumps had the lot installed within the window and have not failed. In the next 30 days of the simulation, 10 of 100 fail, against 33 of 803 pumps not flagged (10.0% vs 4.1%). Ranking by risk helps: the top half has 8 of 50 failures, the bottom half 2 of 50.
Run it:
pip install heapdb
python 0007-failures-after-maintenance.py # copy the script from "Show the full script" aboveEvaluation
- Per-failure metric: the rank of the work order a teardown confirms, as top-1 and top-3 accuracy.
- Fleet metric: the rank of the true defective lot or procedure in
link_report, and the time from the first failure until it ranks first. - Watch-list metric: failures caught among flagged assets in a fixed look-ahead, against the base failure rate of assets not flagged.
- Baseline to beat: "the most recent work order" for the per-failure rank, and plain failure counts per feature (no timing, no settle) for the fleet rank.
- Ground truth: teardown and root-cause reports, supplier quality notices and recalls. In simulation, the seeded lot.
Limits and risks
- Rankings are associations under the model, not causal effects. A supplier or crew that always handles a bad lot shares its credit.
- Confounded features are reported jointly. If one crew always uses one supplier, HeapDB says so and cannot separate them until the data does.
- Results depend on maintenance record quality. Missing lot numbers, late entries and free-text procedures weaken or hide the signal.
- The kernel shape is an assumption. Defects with long latency, such as slow corrosion, need a different kernel or another effect.
- Safety-critical decisions stay with engineers and regulators. HeapDB suggests where to look; grounding a fleet or recalling a lot is a human call.
Prior art
Reliability engineering already uses Weibull and other survival models to estimate failure rates by life stage, including infant mortality after repair. FMEA ranks failure modes by severity and likelihood before they happen. Both work on populations or designs, not on one failure's specific history.
Semiconductor fabs run equipment commonality analysis to find which tool or time window low-yield wafers share; see yieldWerx and a Winter Simulation Conference paper on association-rule tool commonality analysis. HeapDB applies a similar idea per failure, with a timing kernel and learning from teardowns. It complements survival models: they say how often a part fails, and HeapDB ranks which recent work explains this failure.
Status and open questions
In design. The example on this page runs today. Your answers to these open questions shape what we build.
- How should operating hours or cycles replace calendar time in the kernel for assets that sit idle?
- Should the watch list use
feature_riskwith the remaining kernel mass, as in the example, or a survival model fitted per lot? - How should a supplier recall notice enter: as a strong prior on the lot, or as direct feedback?
- How can the same lot be tracked when it moves between assets during overhauls?