CellTriage / app /panels.py
Sarvarbek13's picture
CellTriage QC operator console - inference only, CPU-bound classical ML
749bffa verified
Raw
History Blame Contribute Delete
13.2 kB
"""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)