# 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()