"""Visual components for the console. WHY THESE ARE PLOTS AND NOT TEXT. "Decision: ACCEPT" as a line of text discards the information that produced it. An operator needs to see *how close* the call was -- whether the confidence interval crosses a grade boundary, whether the chosen action was cheapest by a wide margin or a hair, and how much of the escape budget the decision consumes. Each of those is a spatial question, and answering it in prose asks the reader to reconstruct a picture from numbers. """ from __future__ import annotations from typing import Any, Sequence import matplotlib import numpy as np from app import theme matplotlib.use("Agg", force=True) import matplotlib.pyplot as plt # noqa: E402 from matplotlib.patches import Rectangle # noqa: E402 plt.rcParams.update(theme.matplotlib_style()) def _close(fig): """Figures accumulate in matplotlib's registry until closed.""" return fig def decision_plot( predicted: float, lower: float, upper: float, boundaries: dict[str, float], warranty: int, assigned: str, ) -> plt.Figure: """Predicted life against the grade boundaries, with the conformal band drawn. The question this answers at a glance: does the interval CROSS a boundary? A prediction of 600 cycles with a band of 550-660 is a very different decision from 600 with a band of 400-900, and a point estimate hides that. """ fig, ax = plt.subplots(figsize=(9, 2.9)) thresholds = sorted(v for v in boundaries.values() if v > 0) upper_limit = max(upper * 1.25, max(thresholds) * 1.3) lower_limit = min(lower * 0.75, 100) # Grade bands as background shading, ordered least to most demanding. ordered = sorted(boundaries.items(), key=lambda kv: kv[1]) for i, (grade, floor) in enumerate(ordered): ceiling = ordered[i + 1][1] if i + 1 < len(ordered) else upper_limit ax.add_patch(Rectangle( (floor, 0), max(ceiling - floor, 1), 1, facecolor=theme.DECISION_COLOURS.get(grade, theme.GRID), alpha=0.10, edgecolor="none", zorder=0, )) centre = floor + (min(ceiling, upper_limit) - floor) / 2 if lower_limit < centre < upper_limit: ax.text(centre, 0.86, f"GRADE {grade}", ha="center", va="center", fontsize=8, color=theme.TEXT_MUTED, family="monospace") # The conformal interval, drawn as a band rather than error bars. ax.add_patch(Rectangle( (lower, 0.36), max(upper - lower, 1), 0.28, facecolor=theme.DATA_BAND, alpha=0.42, edgecolor=theme.DATA, linewidth=1.2, zorder=3, )) ax.plot([predicted], [0.5], "o", color=theme.DATA, markersize=11, markeredgecolor=theme.BG, markeredgewidth=1.6, zorder=5) for value, label, colour in ((warranty, "warranty target", theme.TEXT),): ax.axvline(value, color=colour, linestyle="--", linewidth=1.4, zorder=4) ax.text(value, 1.06, label, ha="center", fontsize=7.5, color=theme.TEXT_MUTED, family="monospace") for grade, floor in boundaries.items(): if floor > 0: ax.axvline(floor, color=theme.BORDER_STRONG, linewidth=0.9, zorder=2) crosses = any(lower < t < upper for t in thresholds) caption = ("interval CROSSES a grade boundary -- the decision is marginal" if crosses else "interval sits inside one grade -- the decision is clear") ax.text(0.5, -0.30, caption, transform=ax.transAxes, ha="center", fontsize=8, color=theme.CONTINUE if crosses else theme.TEXT_MUTED, family="monospace") ax.set_xlim(lower_limit, upper_limit) ax.set_ylim(0, 1.14) ax.set_yticks([]) ax.set_xscale("log") ax.set_xlabel("cycle life (log scale)") ax.set_title(f"Predicted cycle life and 90% conformal interval | assigned GRADE {assigned}", loc="left", family="monospace") ax.grid(axis="y", alpha=0) fig.tight_layout() return _close(fig) def cost_plot(action_costs: dict[str, float], chosen: str) -> plt.Figure: """Expected cost of every action, so the choice is visibly the cheapest. Showing only the chosen action asks the reader to trust the arithmetic. A comparison lets them see whether it won by a wide margin or a hair -- which is exactly what determines whether a supervisor should look closer. """ actions = list(action_costs) values = [action_costs[a] for a in actions] colours = [theme.DECISION_COLOURS.get(a, theme.DATA) if a == chosen else theme.BORDER_STRONG for a in actions] fig, ax = plt.subplots(figsize=(4.6, 2.9)) bars = ax.barh(range(len(actions)), values, color=colours, height=0.6) ax.set_yticks(range(len(actions))) ax.set_yticklabels([f"Grade {a}" if len(a) == 1 else a for a in actions], family="monospace", fontsize=9) ax.invert_yaxis() span = max(values) if max(values) > 0 else 1.0 for bar, value, action in zip(bars, values, actions): ax.text(value + span * 0.03, bar.get_y() + bar.get_height() / 2, f"{value:.2f}", va="center", fontsize=9, family="monospace", color=theme.TEXT if action == chosen else theme.TEXT_MUTED, fontweight="bold" if action == chosen else "normal") ax.set_xlim(0, span * 1.25) ax.set_xlabel("expected cost (relative units)") ax.set_title("Cost of each action", loc="left", family="monospace") ax.grid(axis="y", alpha=0) fig.tight_layout() return _close(fig) def risk_gauge(escape_probability: float, alpha: float, guaranteed: bool = True) -> plt.Figure: """Escape risk against the chosen bound, as a bar rather than a bare number. A percentage tells the reader the value; a gauge tells them whether it is within budget, which is the actual question. WHY `guaranteed` EXISTS. The bound is a conformal statement, and conformal validity requires exchangeability. Under campaign shift that assumption fails, so the number this gauge draws is no longer a guarantee -- and Phase 10 measured exactly the trap: coverage collapsed to 42.5% while intervals got NARROWER, meaning the reassuring reading is produced by the same conditions that invalidate it. Rendering a green "within budget" underneath a campaign-shift alarm would commit, in the interface, the precise error this project was built to expose. """ fig, ax = plt.subplots(figsize=(4.6, 1.7)) ax.add_patch(Rectangle((0, 0.28), 1, 0.44, facecolor=theme.PANEL_RAISED, edgecolor=theme.BORDER, linewidth=1)) fraction = min(escape_probability / max(alpha, 1e-9), 1.35) within = escape_probability <= alpha if not guaranteed: # Neutral hatching, never green: the value is displayed but withdrawn. ax.add_patch(Rectangle((0, 0.28), min(fraction, 1.0), 0.44, facecolor=theme.TEXT_DIM, alpha=0.35, hatch="///", edgecolor=theme.ALARM, linewidth=1.0)) else: ax.add_patch(Rectangle((0, 0.28), min(fraction, 1.0), 0.44, facecolor=theme.ACCEPT if within else theme.REJECT, alpha=0.85, edgecolor="none")) ax.axvline(1.0, color=theme.TEXT, linestyle="--", linewidth=1.4) ax.text(1.0, 0.80, f"bound α={alpha:.2f}", ha="center", fontsize=8, color=theme.TEXT_MUTED, family="monospace") if not guaranteed: ax.text(0.02, 0.06, f"P = {escape_probability:.3f} — BOUND VOID under campaign shift", fontsize=8.5, family="monospace", color=theme.ALARM) ax.set_title("Escape risk — guarantee suspended", loc="left", family="monospace", color=theme.ALARM) else: ax.text(0.02, 0.06, f"P(cell below warranty) = {escape_probability:.3f}" f"{' — within budget' if within else ' — EXCEEDS BOUND'}", fontsize=8.5, family="monospace", color=theme.ACCEPT if within else theme.REJECT) ax.set_title("Escape risk vs the guarantee", loc="left", family="monospace") ax.set_xlim(0, 1.4); ax.set_ylim(0, 1) ax.set_xticks([]); ax.set_yticks([]) ax.grid(alpha=0) for spine in ax.spines.values(): spine.set_visible(False) fig.tight_layout() return _close(fig) def budget_advisor_plot( budgets: Sequence[int], widths: Sequence[float], costs: Sequence[float], selected: int, knee: int, ) -> plt.Figure: """Interval width and expected cost against budget, with the current pick marked. This is the plot that carries RQ1: the cost curve is FLAT, so the user can see for themselves that moving the slider from 100 to 5 cycles costs essentially nothing -- a conclusion far more convincing discovered than asserted. """ fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(11, 3.4)) ax1.plot(budgets, widths, "o-", color=theme.DATA, linewidth=2, markersize=7) ax1.axvline(selected, color=theme.DATA_ALT, linestyle="--", linewidth=1.6) index = list(budgets).index(selected) if selected in budgets else 0 ax1.plot([selected], [widths[index]], "o", color=theme.DATA_ALT, markersize=12, markeredgecolor=theme.BG, markeredgewidth=1.6, zorder=5) ax1.set_xlabel("diagnostic budget (cycles observed)") ax1.set_ylabel("interval width (log10 cycle life)") ax1.set_title("More cycles buy a tighter statement", loc="left", family="monospace") ax2.plot(budgets, costs, "o-", color=theme.DATA, linewidth=2, markersize=7) ax2.axvline(knee, color=theme.ACCEPT, linestyle="--", linewidth=1.6) ax2.text(knee, max(costs), f" knee N={knee}", color=theme.ACCEPT, fontsize=8, family="monospace", va="top") ax2.axvline(selected, color=theme.DATA_ALT, linestyle="--", linewidth=1.6) span = max(costs) - min(costs) ax2.set_ylim(min(costs) - span * 2.2 - 0.05, max(costs) + span * 2.2 + 0.05) ax2.set_xlabel("diagnostic budget (cycles observed)") ax2.set_ylabel("expected cost per cell") ax2.set_title("...but cost is flat: the axis is deliberately widened", loc="left", family="monospace") fig.tight_layout() return _close(fig) def shap_waterfall(drivers: Sequence[dict[str, Any]], base: float, predicted: float) -> plt.Figure: """A readable waterfall, not a raw SHAP dump. The stock plot is dense and uses feature names. This one uses the plain-language description and orders by magnitude, because the reader is a process engineer rather than an analyst. """ labels, values = [], [] for driver in drivers: text = driver["description"] labels.append(text if len(text) <= 58 else text[:55] + "...") values.append(driver["shap"]) fig, ax = plt.subplots(figsize=(9.5, 0.55 * len(labels) + 1.6)) colours = [theme.ACCEPT if v > 0 else theme.REJECT for v in values] ax.barh(range(len(values)), values, color=colours, height=0.6, alpha=0.9) ax.set_yticks(range(len(labels))) ax.set_yticklabels(labels, fontsize=8.5) ax.invert_yaxis() ax.axvline(0, color=theme.BORDER_STRONG, linewidth=1.2) span = max(abs(min(values)), abs(max(values))) or 1.0 for i, value in enumerate(values): offset = span * 0.04 * (1 if value > 0 else -1) ax.text(value + offset, i, f"{value:+.4f}", va="center", ha="left" if value > 0 else "right", fontsize=8, family="monospace", color=theme.TEXT_MUTED) ax.set_xlim(-span * 1.5, span * 1.5) ax.set_xlabel("effect on predicted log10 cycle life") ax.set_title("What drove this decision " "(green raised the prediction, red lowered it)", loc="left", family="monospace") ax.grid(axis="y", alpha=0) fig.tight_layout() return _close(fig) def allocation_plot(policies: dict[str, float], escape: dict[str, float]) -> plt.Figure: """Greedy against the alternatives under a hard capacity constraint.""" names = list(policies) fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(11, 3.4)) best = min(policies, key=policies.get) colours = [theme.DATA if n == best else theme.BORDER_STRONG for n in names] ax1.bar(range(len(names)), [policies[n] for n in names], color=colours) ax1.set_xticks(range(len(names))) ax1.set_xticklabels([n.replace("_", "\n") for n in names], fontsize=7.5, family="monospace") ax1.set_ylabel("cost per cell") ax1.set_title("Cost by allocation policy", loc="left", family="monospace") ax2.bar(range(len(names)), [escape.get(n, 0) for n in names], color=[theme.DATA if n == best else theme.BORDER_STRONG for n in names]) ax2.set_xticks(range(len(names))) ax2.set_xticklabels([n.replace("_", "\n") for n in names], fontsize=7.5, family="monospace") ax2.set_ylabel("escape rate") ax2.set_title("Escape rate by allocation policy", loc="left", family="monospace") fig.tight_layout() return _close(fig)