# SPDX-License-Identifier: MIT """Build the figures for YS-EN-0005 from results.json and write them into the note between markers. Usage: python build_figures.py (after analyse.py) """ from __future__ import annotations import json import sys from pathlib import Path HERE = Path(__file__).resolve().parent sys.path.insert(0, str(HERE.parent)) import svgchart as sc # noqa: E402 PAGE = HERE.parent.parent / "ys-en-0005-what-a-40-year-mtbf-promises.html" R = json.loads((HERE / "results.json").read_text()) MODELS = {m["model"]: m for m in R["models"]} X24_DATASHEET_AFR = 0.0035 # Exos X24 data sheet, "Reliability Rating @ Full 24x7 Operation (AFR)" MIN_BIN_DRIVE_YEARS = 3000 # don't plot age bins thinner than this def pct(v, d=1): return f"{100 * v:.{d}f}%" def name(m): """Readable model label: maker, model number, capacity.""" mm = MODELS.get(m) tb = f'{mm["capacity_tb"]:g} TB' if mm else "" short = m.replace("HGST ", "HGST ").replace("TOSHIBA ", "Toshiba ").replace("WDC ", "WDC ") if short.startswith("ST"): short = "Seagate " + short return f"{short} ({tb})" if tb else short # ---------------------------------------------------------------- figure 1 def fig_age(): h = R["hazard_all"] e = h["edges"] keep = [i for i, dy in enumerate(h["drive_years"]) if dy >= MIN_BIN_DRIVE_YEARS] xs = [(e[i] + e[i + 1]) / 2 for i in keep] afr = [100 * h["afr"][i] for i in keep] lo = [100 * h["lo"][i] for i in keep] hi = [100 * h["hi"][i] for i in keep] mtbf_rate = 100 * R["mtbf"]["afr_rate"] fr = sc.Frame(720, 340, "Annualized failure rate of Backblaze hard drives by drive age, all models pooled") fr.x_scale(0, 10) fr.y_scale(0, 3.5) fr.grid_y([0, 0.5, 1, 1.5, 2, 2.5, 3, 3.5], fmt=lambda v: f"{v:g}%") fr.grid_x([0, 2, 4, 6, 8, 10], fmt=lambda v: f"{v:g} yr", lines=False) fr.baseline() # 95% band band = " L".join(f"{fr.x(x):.1f},{fr.y(v):.1f}" for x, v in zip(xs, hi)) band += " L" + " L".join(f"{fr.x(x):.1f},{fr.y(v):.1f}" for x, v in zip(reversed(xs), reversed(lo))) fr.out.append(f'') fr.hline(mtbf_rate, cls="axis") fr.note(fr.x(0.15), fr.y(mtbf_rate) - 6, f"a 40-year MTBF: {mtbf_rate:.1f}% a year") fr.hline(100 * X24_DATASHEET_AFR, cls="axis") fr.note(fr.x(0.15), fr.y(100 * X24_DATASHEET_AFR) - 6, "Exos X24 datasheet: 0.35%") fr.titles("Drive age", "Failures per 100 drive-years") fr.cursor() series = [{"slot": 1, "name": "All drives", "values": afr, "label": False}] sc.draw_lines(fr, xs, series) sc.column_hits(fr, xs, series, lambda i, x: f"Age {e[keep[i]]:.2f}–{e[keep[i] + 1]:.2f} yr", lambda v: f"{v:.2f}%") rows = [[f"{e[i]:.2f}–{e[i + 1]:.2f}", f"{h['drive_years'][i]:,.0f}", f"{h['failures'][i]:,}", f"{100 * h['afr'][i]:.2f}%", f"{100 * h['lo'][i]:.2f}–{100 * h['hi'][i]:.2f}%"] for i in keep] d = R["data"] return sc.figure( code="FIG. 1 — FAILURE RATE BY AGE", fid="fig-age", plot=fr.svg(), caption=(f"Figure 1. Failures per 100 drive-years in each quarter-year of drive age, for " f"{d['drives']:,} hard drives of {d['models']} models (Backblaze, April 2013 to June 2026). " f"The shaded band is the 95% Poisson interval. Ages with fewer than {MIN_BIN_DRIVE_YEARS:,} " "drive-years are left out. Every model is pooled here, so the shape mixes ageing with the " "changing mix of models at each age."), data_html=sc.table("Failure rate by age, all models", ["Age, yr", "Drive-years", "Failures", "Rate", "95% interval"], rows), ) # ---------------------------------------------------------------- figure 2 SHOWN = [("ST4000DM000", 1, "Seagate 4 TB"), ("HGST HUH721212ALN604", 3, "HGST 12 TB"), ("HGST HMS5C4040BLE640", 2, "HGST 4 TB")] def fig_models(): hb = R["hazard_by_model"] xs = [k + 0.5 for k in range(10)] series = [] for m, slot, short in SHOWN: h = hb[m] vals = [100 * a if dy >= 300 else None for a, dy in zip(h["afr"], h["drive_years"])][: len(xs)] vals += [None] * (len(xs) - len(vals)) fit = MODELS[m] series.append({"slot": slot, "name": f"{short} {m.split()[-1]} (β {fit['beta']:.2f})", "end": short, "values": vals}) last = max(i for s in series for i, v in enumerate(s["values"]) if v is not None) xs = xs[: last + 1] for s in series: s["values"] = s["values"][: last + 1] mtbf_rate = 100 * R["mtbf"]["afr_rate"] fr = sc.Frame(720, 340, "Failure rate by age for three drive models: one roughly constant, one rising, one falling") fr.x_scale(0, 10) fr.y_scale(0, 6) fr.grid_y([0, 1, 2, 3, 4, 5, 6], fmt=lambda v: f"{v:g}%") fr.grid_x([0, 2, 4, 6, 8, 10], fmt=lambda v: f"{v:g} yr", lines=False) fr.baseline() fr.hline(mtbf_rate) fr.note(fr.x(10), fr.y(mtbf_rate) + 16, f"40-year MTBF: {mtbf_rate:.1f}%", anchor="end") fr.titles("Drive age (one-year bins)", "Failures per 100 drive-years") fr.cursor() sc.draw_lines(fr, xs, series) sc.column_hits(fr, xs, series, lambda i, x: f"Age {x - 0.5:.0f}–{x + 0.5:.0f} yr", lambda v: f"{v:.2f}%") rows = [] for m, slot, short in SHOWN: h = hb[m] for k, (a, f, dy) in enumerate(zip(h["afr"], h["failures"], h["drive_years"])): if dy >= 300: rows.append([f"{short} {m.split()[-1]}", f"{k}–{k + 1}", f"{dy:,.0f}", f"{f:,}", f"{100 * a:.2f}%"]) return sc.figure( code="FIG. 2 — THREE MODELS, THREE SHAPES", fid="fig-models", plot=fr.svg(), legend_html=sc.legend(series), caption=("Figure 2. Failures per 100 drive-years by year of age for three of the largest models. " f"Over the ages observed, the Seagate 4 TB ({MODELS['ST4000DM000']['drives']:,} drives) and the HGST 12 TB " f"({MODELS['HGST HUH721212ALN604']['drives']:,}) both averaged about one failure per 40 drive-years: an MTBF of " f"{MODELS['ST4000DM000']['mtbf_yr_observed']:.1f} and {MODELS['HGST HUH721212ALN604']['mtbf_yr_observed']:.1f} years. " "The Seagate stays near that rate all its life. The HGST runs at about 0.5% for three years, then " "fails eight to ten times as often. The HGST 4 TB is worst in its first year and improves after that. " "Years with fewer than 300 drive-years are left out."), data_html=sc.table("Failure rate by year of age, three models", ["Model", "Age, yr", "Drive-years", "Failures", "Rate"], rows), ) # ---------------------------------------------------------------- figure 3 def fig_shapes(): ms = sorted(R["models"], key=lambda m: m["beta"]) row_h = 22 left = 250 fr = sc.Frame(720, 40 + row_h * len(ms) + 46, "Fitted Weibull shape for each drive model with its 95% interval", margins=(left, 30, 30, 46)) fr.x_scale(0.5, 5, log=True) fr.y_scale(0, 1) ticks = [0.5, 0.7, 1, 1.5, 2, 3, 5] fr.out.append('') for t in ticks: fr.out.append(f'') fr.out.append("") for t in ticks: fr.out.append(f'{t:g}') x1 = fr.x(1) fr.out.append(f'') fr.note(x1 - 6, fr.mt - 10, "← failure rate falls with age", anchor="end") fr.note(x1 + 6, fr.mt - 10, "constant (exponential) · rises with age →") fr.titles("Weibull shape β (log scale)") rows = [] for i, m in enumerate(ms): cy = fr.mt + row_h * (i + 0.5) + 4 slot = 3 if m["beta_lo"] > 1 else (2 if m["beta_hi"] < 1 else 0) lab = name(m["model"]) fr.out.append(f'{sc.esc(lab)}') a, b = fr.x(max(0.5, m["beta_lo"])), fr.x(min(5, m["beta_hi"])) fr.out.append(f'') cx = fr.x(m["beta"]) fr.out.append(f'') info = (f'{slot}|β {m["beta"]:.2f} ({m["beta_lo"]:.2f}–{m["beta_hi"]:.2f})|' f'{m["drives"]:,} drives, {m["failures"]:,} failures, {pct(m["afr"], 2)} a year') sc.point_hit(fr, cx, cy, i, lab, info, r=11) rows.append([lab, f'{m["drives"]:,}', f'{m["failures"]:,}', f'{m["drive_years"]:,.0f}', pct(m["afr"], 2), f'{m["max_age_yr"]:.1f}', f'{m["beta"]:.2f}', f'{m["beta_lo"]:.2f}–{m["beta_hi"]:.2f}', f'{m["p_exponential"]:.1g}']) s = R["model_summary"] return sc.figure( code="FIG. 3 — IS THE RATE CONSTANT?", fid="fig-shapes", plot=fr.svg(), caption=(f"Figure 3. Weibull shape β fitted to each of the {s['n']} models with at least " f"{R['inputs']['min_drives']:,} drives and {R['inputs']['min_fails']} failures, with 95% profile-likelihood " f"intervals. β = 1 is a constant failure rate, the assumption behind every MTBF. " f"For {s['beta_gt1_sig']} models (coral) the rate rises with age, for {s['beta_lt1_sig']} (violet) it falls, and " f"for {s['beta_ci_covers_1']} (grey) a constant rate cannot be ruled out. A single β is itself a simplification; " "Figure 2 shows shapes it cannot capture."), data_html=sc.table("Weibull fits by model (left-truncated, right-censored maximum likelihood)", ["Model", "Drives", "Failures", "Drive-years", "Rate", "Oldest, yr", "β", "95% interval", "p (β = 1)"], rows), ) # ---------------------------------------------------------------- figure 4 def fig_extrap(age="5"): pts = [m for m in R["models"] if age in m["cum_failed"]] fr = sc.Frame(720, 400, f"Share of drives failed by age {age}: predicted from the first year's rate versus observed", margins=(58, 30, 30, 46)) top = 20 fr.x_scale(0, top) fr.y_scale(0, top) tk = [0, 5, 10, 15, 20] fr.grid_y(tk, fmt=lambda v: f"{v:g}%") fr.grid_x(tk, fmt=lambda v: f"{v:g}%") fr.baseline() fr.out.append(f'') fr.note(fr.x(top) - 4, fr.y(top) + 16, "prediction = outcome", anchor="end") fr.titles(f"Predicted from the first year, as a constant rate: share failed by year {age}", f"Observed share failed by year {age}") rows = [] for i, m in enumerate(sorted(pts, key=lambda m: m["cum_failed"][age]["first_year_rate_predicts"])): c = m["cum_failed"][age] p, o = 100 * c["first_year_rate_predicts"], 100 * c["observed_failed"] slot = 3 if o > 1.25 * p else (2 if o < 0.8 * p else 1) cx, cy = fr.x(min(p, top)), fr.y(min(o, top)) fr.out.append(f'') lab = name(m["model"]) sc.point_hit(fr, cx, cy, i, lab, f"{slot}|Predicted {p:.1f}%, observed {o:.1f}%|ratio {o / p:.1f}×", r=10) rows.append([lab, f"{100 * m['first_year_afr']:.2f}%", f"{p:.1f}%", f"{o:.1f}%", f"{o / p:.2f}×"]) under = sum(1 for r in rows if float(r[4][:-1]) > 1.25) over = sum(1 for r in rows if float(r[4][:-1]) < 0.8) return sc.figure( code="FIG. 4 — EXTRAPOLATING THE FIRST YEAR", fid="fig-extrap", plot=fr.svg(), caption=(f"Figure 4. For each model followed to age {age}: the share of drives that failed by then, against " "the share a constant rate measured in the first year of life would predict. That is how a " "short test turns into an MTBF. " f"Of {len(rows)} models, {under} failed more than 1.25× as often as predicted (coral) and {over} " "less than 0.8× as often (violet). Cumulative shares come from the piecewise-constant hazard in " "quarter-year bins, which handles drives that joined or left partway."), data_html=sc.table(f"First-year extrapolation versus outcome at year {age}", ["Model", "First-year rate", f"Predicted by yr {age}", f"Observed by yr {age}", "Observed ÷ predicted"], rows), ) # ---------------------------------------------------------------- table: the 4077 under real shapes LIKE = {"0.66": "HGST 4 TB HMS5C4040BLE640", "1": "Seagate 4 TB ST4000DM000", "1.45": "Seagate 12 TB ST12000NM0008", "2.18": "HGST 12 TB HUH721212ALN604", "4": "Seagate 3 TB ST3000DM001"} def table_4077(): w = R["mtbf"]["weibull_same_mtbf"] n = R["inputs"]["fleet"] rows = [] for b, v in w.items(): y1 = v["afr_year1"] rows.append([f"β {float(b):g}", LIKE[b], f'{v["fail_by_10"]:.0f}', f'{v["fail_by_40"]:.0f}', (f"{100 * y1:.1f}%" if y1 >= 0.001 else f"{100 * y1:.3f}%" if y1 >= 1e-5 else "<0.001%"), f'{100 * v["afr_year30"]:.1f}%', f'{v["mrl_at_30"]:.0f} yr']) return sc.table(f"{n} cores with a 40-year MTBF, unrepaired, under five Weibull shapes fitted to real drive models", ["Shape", "Fitted to", "Failed by year 10", "Failed by year 40", "Fails in year 1", "Fails in year 30, if still running", "Expected further life at 30"], rows, numeric_from=2) def main() -> None: sc.inject(PAGE, "table4077", table_4077()) for nm, fn in (("age", fig_age), ("models", fig_models), ("shapes", fig_shapes), ("extrap", fig_extrap)): sc.inject(PAGE, nm, fn()) print("figures written to", PAGE.name) if __name__ == "__main__": main()