File size: 13,157 Bytes
749bffa
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
"""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)