# SPDX-License-Identifier: MIT
"""Build the figures for YS-EN-0004 from results.json and write them into the
note between markers.
Usage: python build_figures.py (after sparing.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-0004-spares-for-a-one-way-trip.html"
R = json.loads((HERE / "results.json").read_text())
ITEMS = list(R["inputs"]["rate_share"])
def cores(v):
return f"{v:,.0f}" if v >= 100 else f"{v:.1f}".rstrip("0").rstrip(".")
# ---------------------------------------------------------------- figure 1
def fig_strategies():
bt = R["strategies"]["by_target"]
p = bt["0.999"]
cats = ["Spare cores", "Spare cores, stripped for parts", "Spare modules + chassis"]
vals = [p["A_mass"], p["B_mass"], p["C_mass"]]
fr = sc.Frame(720, 320, "Spares mass in whole-core equivalents for three strategies at 0.999 fleet probability of sufficiency",
margins=(58, 30, 26, 46))
fr.y_scale(0, 300)
fr.grid_y([0, 100, 200, 300])
fr.baseline()
fr.titles("", "Spares mass, in whole cores")
sc.columns(fr, cats, vals, slots=[3, 2, 1], fmt=cores, metric="Mass, cores")
rows = []
for t, v in bt.items():
c = v["C_spares"]
rows.append([t, f'{v["A_spare_cores"]} cores', f'{v["B_spare_cores"]} cores',
f'{" / ".join(str(c[i]) for i in ITEMS)}', cores(v["C_mass"])])
return sc.figure(
code="FIG. 1 — WHAT TO CARRY",
fid="fig-strategies",
plot=fr.svg(),
caption=("Figure 1. Mass of spares needed for a 0.999 chance that all 225 cores are still working "
"after 40 years, in units of one complete core. Stripping dead cores for parts makes every "
"spare core a kit of one of each part; carrying the parts separately stops paying for chassis "
"and for the modules that rarely fail. Rates and masses are this note's inputs."),
data_html=sc.table("Spares by strategy and fleet target",
["Fleet POS", "Spare cores", "Stripped for parts", "M1 / M2 / M3 / M4 / chassis", "Modules: mass, cores"],
rows),
)
# ---------------------------------------------------------------- figure 2
def fig_demand():
d = R["demand_curves"]
s98 = R["pos_vs_spares"]["m1_chosen"]
k = d["k"][:201]
pois = [v * 100 for v in d["poisson"][:201]]
mixed = [v * 100 for v in d["mixed"][:201]]
series = [{"slot": 1, "name": "Rate known", "values": pois, "label": False},
{"slot": 2, "name": f"Rate known to ×{d['error_factor']:g}", "values": mixed, "label": False}]
fr = sc.Frame(720, 340, "Distribution of M1 failures over 40 years: rate known exactly versus rate known only to a factor of two")
fr.x_scale(0, 200)
fr.y_scale(0, 5)
fr.grid_y([0, 1, 2, 3, 4, 5], fmt=lambda v: f"{v:g}%")
fr.grid_x([0, 50, 100, 150, 200], lines=False)
fr.baseline()
fr.vline(s98, f"{s98} spares carried")
fr.titles("M1 failures over the mission", "Probability")
fr.cursor()
sc.draw_lines(fr, k, series)
step = 5
xs = k[::step]
hs = [{**s, "values": s["values"][::step]} for s in series]
sc.column_hits(fr, xs, hs, lambda i, x: f"{x} failures", lambda v: f"{v:.2f}%", hover_marks=False)
tail_p = 100 * (1 - sum(d["poisson"][:s98 + 1]))
tail_m = 100 * (1 - sum(d["mixed"][:s98 + 1]))
rows = [[str(x), f"{a:.2f}%", f"{b:.2f}%"] for x, a, b in zip(xs, hs[0]["values"], hs[1]["values"]) if x % 10 == 0]
rows.append([f"more than {s98}", f"{tail_p:.2f}%", f"{tail_m:.1f}%"])
return sc.figure(
code="FIG. 2 — LUCK VS IGNORANCE",
fid="fig-demand",
plot=fr.svg(),
legend_html=sc.legend(series),
caption=(f"Figure 2. How many M1 modules fail over 40 years, expected {d['mean']:.1f}. If the rate "
f"is known, the spread is pure chance and {s98} spares run out with probability "
f"{tail_p:.2f}%. If the rate is only known to within a factor of {d['error_factor']:g} (a "
f"lognormal whose 95th percentile is twice its median), the same {s98} spares run out "
f"{tail_m:.0f}% of the time."),
data_html=sc.table("M1 failures, probability per value", ["Failures", series[0]["name"], series[1]["name"]], rows),
)
# ---------------------------------------------------------------- figure 3
def fig_hedge():
ru = R["rate_uncertainty"]
efs = [float(e) for e in ru["by_ef"]]
conf = [ru["by_ef"][e]["conf95_mass"] for e in ru["by_ef"]]
pred = [ru["by_ef"][e]["predictive_mass"] for e in ru["by_ef"]]
series = [{"slot": 1, "name": "Every rate at its 95th percentile", "end": "95th percentile", "values": conf},
{"slot": 2, "name": "Averaged over the uncertainty", "end": "Averaged", "values": pred}]
fr = sc.Frame(720, 340, "Spares mass needed for 0.999 as the failure-rate error factor grows, two ways of hedging")
fr.x_scale(1, 5)
fr.y_scale(30, 1200, log=True)
fr.grid_y([30, 100, 300, 1000])
fr.grid_x([1, 2, 3, 4, 5], fmt=lambda v: f"×{v:g}", lines=False)
fr.baseline()
fr.hline(ru["nominal_mass"])
fr.note(fr.x(5), fr.y(ru["nominal_mass"]) + 16, f"rates assumed exact: {cores(ru['nominal_mass'])}", anchor="end")
fr.titles("Error factor on every failure rate (95th / 50th percentile)", "Spares mass, in whole cores (log)")
fr.cursor()
sc.draw_lines(fr, efs, series)
sc.column_hits(fr, efs, series, lambda i, x: f"Error factor ×{x:g}", cores)
rows = []
for e, v in ru["by_ef"].items():
ind, com = v["independent"], v["common"]
rows.append([f"×{float(e):g}", f'{ind["pos_median"]:.3f}', f'{ind["p_below_target"]:.0%}',
f'{com["p_below_target"]:.0%}', cores(v["conf95_mass"]),
f'{v["conf95_meets_target_independent"]:.0%} / {v["conf95_meets_target_common"]:.0%}',
cores(v["predictive_mass"])])
return sc.figure(
code="FIG. 3 — PAYING FOR WHAT YOU DON'T KNOW",
fid="fig-hedge",
plot=fr.svg(),
legend_html=sc.legend(series),
caption=("Figure 3. Two ways to size the manifest when every rate is uncertain by the error factor "
"on the x-axis. The table also shows what happens if you ignore the uncertainty and fly the "
"nominal manifest: its median POS, and how often its true POS is below 0.999, with each part's "
"error drawn separately or one shared error for the whole design, and how often the 95th-percentile "
"manifest really meets 0.999. The averaged manifest is a greedy allocation on a heavy-tailed "
"mixture and is approximate."),
data_html=sc.table("Rate uncertainty and the 0.999 manifest",
["Error factor", "Nominal manifest: median POS", "Below 0.999 (separate errors)",
"Below 0.999 (shared error)", "95th-percentile manifest",
"It meets 0.999 (separate / shared)", "Averaged manifest (approx.)"],
rows),
)
# ---------------------------------------------------------------- figure 4
def fig_wear():
w = R["wear_out"]
names = {"infant (beta 0.7)": "Early (β 0.7)",
"random (beta 1)": "Random (β 1)",
"wear-out (beta 3)": "Wear-out (β 3)",
"random + wear-out at eta 45 yr": "+ wear-out, η 45 yr",
"random + wear-out at eta 30 yr": "+ wear-out, η 30 yr"}
order = list(names)
cats = [names[c] for c in order]
means = [w["cases"][c]["mean"] for c in order]
spares = [w["cases"][c]["spares"] for c in order]
fr = sc.Frame(720, 330, "M1 spares needed under five ageing assumptions, all with the same datasheet MTBF",
margins=(58, 30, 26, 46))
fr.y_scale(0, 350)
fr.grid_y([0, 100, 200, 300])
fr.baseline()
fr.titles("", "M1 spares needed")
sc.columns(fr, cats, spares, slots=[2, 1, 2, 3, 3], fmt=lambda v: f"{v:,.0f}", metric="M1 spares")
rows = [[names[c], f'{w["cases"][c]["mean"]:.1f}', f'{w["cases"][c]["sd"]:.1f}', str(w["cases"][c]["spares"])]
for c in order]
return sc.figure(
code="FIG. 4 — SAME MTBF, DIFFERENT AGEING",
fid="fig-wear",
plot=fr.svg(),
caption=(f"Figure 4. M1 spares needed for a {w['p_item']:.4f} chance of not running out (the share "
f"of a 0.999 fleet target that falls on M1), from {w['reps']:,} simulated missions per case. "
f"The first three cases share M1's {w['item_mtbf_yr']:.0f}-year MTBF and differ only in Weibull "
"shape β. The last two keep the random failures and add a wear-out mechanism (β 4, characteristic life η) "
"that the datasheet MTBF never saw."),
data_html=sc.table("M1 failures by ageing case", ["Case", "Mean failures", "SD", "Spares needed"], rows),
)
def main() -> None:
for name, fn in (("strategies", fig_strategies), ("demand", fig_demand),
("hedge", fig_hedge), ("wear", fig_wear)):
sc.inject(PAGE, name, fn())
print("figures written to", PAGE.name)
if __name__ == "__main__":
main()