fusepoint 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fusepoint/__init__.py +26 -0
- fusepoint/card.py +592 -0
- fusepoint/core.py +419 -0
- fusepoint/engine.py +378 -0
- fusepoint/parsers.py +246 -0
- fusepoint/result.py +112 -0
- fusepoint-0.1.0.dist-info/METADATA +164 -0
- fusepoint-0.1.0.dist-info/RECORD +13 -0
- fusepoint-0.1.0.dist-info/WHEEL +5 -0
- fusepoint-0.1.0.dist-info/licenses/LICENSE +29 -0
- fusepoint-0.1.0.dist-info/licenses/license_AGPL.txt +30 -0
- fusepoint-0.1.0.dist-info/licenses/license_COMMERCIAL.txt +21 -0
- fusepoint-0.1.0.dist-info/top_level.txt +1 -0
fusepoint/__init__.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Fuse — Find where your system breaks, before it does.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
from fusepoint import analyze
|
|
6
|
+
card = analyze(x, y, x_name="Load", y_name="Latency")
|
|
7
|
+
card = analyze(df, x="load", y="latency") # DataFrame mode
|
|
8
|
+
print(card.score)
|
|
9
|
+
card.save("stability.png")
|
|
10
|
+
|
|
11
|
+
https://www.forgottenforge.xyz
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from fusepoint.core import analyze, scan, compare
|
|
15
|
+
from fusepoint.result import StabilityResult
|
|
16
|
+
from fusepoint.parsers import parse_json, detect_x_column
|
|
17
|
+
|
|
18
|
+
__version__ = "0.1.0"
|
|
19
|
+
__all__ = [
|
|
20
|
+
"analyze",
|
|
21
|
+
"scan",
|
|
22
|
+
"compare",
|
|
23
|
+
"StabilityResult",
|
|
24
|
+
"parse_json",
|
|
25
|
+
"detect_x_column",
|
|
26
|
+
]
|
fusepoint/card.py
ADDED
|
@@ -0,0 +1,592 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Fuse Report — the publication-quality visualization.
|
|
3
|
+
|
|
4
|
+
Redesigned for clarity and shareability:
|
|
5
|
+
- Light background (works in Slack, papers, presentations)
|
|
6
|
+
- Clean gauge without segmentation artifacts
|
|
7
|
+
- Subtle zone indicators (not competing with data)
|
|
8
|
+
- Actionable recommendation text in the user's language
|
|
9
|
+
- Column names on axes, not "Parameter" / "Observable"
|
|
10
|
+
- Smart rounding (2-3 significant figures, not 5 decimals)
|
|
11
|
+
- Fuse / Forgotten Forge branding
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
import math
|
|
16
|
+
import os
|
|
17
|
+
import numpy as np
|
|
18
|
+
import matplotlib
|
|
19
|
+
matplotlib.use("Agg")
|
|
20
|
+
import matplotlib.pyplot as plt
|
|
21
|
+
import matplotlib.patches as mpatches
|
|
22
|
+
import matplotlib.patheffects as pe
|
|
23
|
+
import matplotlib.gridspec as gridspec
|
|
24
|
+
from matplotlib.colors import LinearSegmentedColormap
|
|
25
|
+
from matplotlib.offsetbox import OffsetImage, AnnotationBbox
|
|
26
|
+
from PIL import Image
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
# Color palette — Light theme
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
C_BG = "#ffffff"
|
|
33
|
+
C_CARD = "#f8f9fb"
|
|
34
|
+
C_TEXT = "#1a1a2e"
|
|
35
|
+
C_DIM = "#6b7280"
|
|
36
|
+
C_LIGHT = "#d1d5db"
|
|
37
|
+
C_GREEN = "#059669"
|
|
38
|
+
C_YELLOW = "#d97706"
|
|
39
|
+
C_ORANGE = "#ea580c"
|
|
40
|
+
C_RED = "#dc2626"
|
|
41
|
+
C_BLUE = "#2563eb"
|
|
42
|
+
C_PURPLE = "#7c3aed"
|
|
43
|
+
C_GRID = "#e5e7eb"
|
|
44
|
+
C_SAFE_BG = "#ecfdf5"
|
|
45
|
+
C_WARN_BG = "#fffbeb"
|
|
46
|
+
C_CRIT_BG = "#fef2f2"
|
|
47
|
+
|
|
48
|
+
# Brand colors (from Forgotten Forge / Fuse logos)
|
|
49
|
+
C_BRAND_DARK = "#1a1a1e"
|
|
50
|
+
C_BRAND_LIGHT = "#f0f0f0"
|
|
51
|
+
|
|
52
|
+
GRADE_COLORS = {
|
|
53
|
+
"STABLE": C_GREEN,
|
|
54
|
+
"MODERATE": C_YELLOW,
|
|
55
|
+
"WARNING": C_ORANGE,
|
|
56
|
+
"CRITICAL": C_RED,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
ZONE_CMAP = LinearSegmentedColormap.from_list(
|
|
60
|
+
"stability", [C_GREEN, C_YELLOW, C_ORANGE, C_RED], N=256
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _score_color(score):
|
|
65
|
+
if score >= 85:
|
|
66
|
+
return C_GREEN
|
|
67
|
+
if score >= 60:
|
|
68
|
+
return C_YELLOW
|
|
69
|
+
if score >= 35:
|
|
70
|
+
return C_ORANGE
|
|
71
|
+
return C_RED
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _clean_label(label):
|
|
75
|
+
"""Clean up nested JSON path names into readable labels.
|
|
76
|
+
|
|
77
|
+
'results.Collatz_3np1_cycle.gamma_values.gamma_M' → 'Gamma M'
|
|
78
|
+
'results.3np1_single_step.gamma_values.M' → 'M'
|
|
79
|
+
'temperature_c' → 'Temperature C'
|
|
80
|
+
"""
|
|
81
|
+
# Take only the last segment of dotted paths
|
|
82
|
+
if "." in label:
|
|
83
|
+
label = label.rsplit(".", 1)[-1]
|
|
84
|
+
# Replace underscores/hyphens with spaces, then title-case
|
|
85
|
+
label = label.replace("_", " ").replace("-", " ").strip()
|
|
86
|
+
if label == label.lower() or label == label.upper():
|
|
87
|
+
label = label.title()
|
|
88
|
+
return label
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _truncate_label(label, max_len=28):
|
|
92
|
+
"""Clean and shorten long column names for axis labels."""
|
|
93
|
+
label = _clean_label(label)
|
|
94
|
+
if len(label) <= max_len:
|
|
95
|
+
return label
|
|
96
|
+
# Try to truncate at a word boundary
|
|
97
|
+
trunc = label[:max_len - 1]
|
|
98
|
+
last_space = trunc.rfind(" ")
|
|
99
|
+
if last_space > max_len // 2:
|
|
100
|
+
trunc = trunc[:last_space]
|
|
101
|
+
return trunc + "\u2026"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# ---------------------------------------------------------------------------
|
|
105
|
+
# Logo / branding helpers
|
|
106
|
+
# ---------------------------------------------------------------------------
|
|
107
|
+
_ASSETS_DIR = os.path.join(os.path.dirname(__file__), "assets")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _load_logo(name, height_px=28):
|
|
111
|
+
"""Load a logo from assets, remove dark bg, crop to content."""
|
|
112
|
+
path = os.path.join(_ASSETS_DIR, name)
|
|
113
|
+
if not os.path.exists(path):
|
|
114
|
+
return None
|
|
115
|
+
try:
|
|
116
|
+
img = Image.open(path).convert("RGBA")
|
|
117
|
+
arr = np.array(img)
|
|
118
|
+
|
|
119
|
+
# Make dark background transparent (pixels darker than ~25%)
|
|
120
|
+
brightness = arr[:, :, :3].max(axis=2)
|
|
121
|
+
arr[brightness < 64, 3] = 0
|
|
122
|
+
|
|
123
|
+
# Crop to non-transparent bounding box
|
|
124
|
+
alpha = arr[:, :, 3]
|
|
125
|
+
rows = np.any(alpha > 0, axis=1)
|
|
126
|
+
cols = np.any(alpha > 0, axis=0)
|
|
127
|
+
if not rows.any():
|
|
128
|
+
return None
|
|
129
|
+
r0, r1 = np.where(rows)[0][[0, -1]]
|
|
130
|
+
c0, c1 = np.where(cols)[0][[0, -1]]
|
|
131
|
+
arr = arr[r0:r1+1, c0:c1+1]
|
|
132
|
+
|
|
133
|
+
# Scale to target height
|
|
134
|
+
cropped = Image.fromarray(arr)
|
|
135
|
+
aspect = cropped.width / cropped.height
|
|
136
|
+
new_w = int(height_px * aspect)
|
|
137
|
+
cropped = cropped.resize((new_w, height_px), Image.LANCZOS)
|
|
138
|
+
return np.array(cropped)
|
|
139
|
+
except Exception:
|
|
140
|
+
return None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _draw_brand_footer(fig):
|
|
144
|
+
"""Draw the Fuse / Forgotten Forge brand bar at the bottom."""
|
|
145
|
+
footer = fig.add_axes([0, 0, 1, 0.055])
|
|
146
|
+
footer.set_xlim(0, 1)
|
|
147
|
+
footer.set_ylim(0, 1)
|
|
148
|
+
footer.set_facecolor(C_BRAND_DARK)
|
|
149
|
+
footer.set_zorder(10)
|
|
150
|
+
footer.set_xticks([])
|
|
151
|
+
footer.set_yticks([])
|
|
152
|
+
for spine in footer.spines.values():
|
|
153
|
+
spine.set_visible(False)
|
|
154
|
+
|
|
155
|
+
# FUSE — left
|
|
156
|
+
footer.text(0.03, 0.5, "FUSE", ha="left", va="center",
|
|
157
|
+
fontsize=18, fontweight="bold", color="#ffffff",
|
|
158
|
+
fontfamily="sans-serif")
|
|
159
|
+
|
|
160
|
+
# Centered: brand + license
|
|
161
|
+
footer.text(0.5, 0.5,
|
|
162
|
+
"\u00a9 Forgotten Forge \u2022 AGPL-3.0 \u2022 forgottenforge.xyz",
|
|
163
|
+
ha="center", va="center", fontsize=7.5,
|
|
164
|
+
color="#aaaaaa", fontfamily="monospace")
|
|
165
|
+
|
|
166
|
+
# FORGOTTEN FORGE — right
|
|
167
|
+
footer.text(0.97, 0.5, "FORGOTTEN FORGE",
|
|
168
|
+
ha="right", va="center",
|
|
169
|
+
fontsize=11, fontweight="bold", color="#ffffff",
|
|
170
|
+
fontfamily="sans-serif")
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# ---------------------------------------------------------------------------
|
|
174
|
+
# Smart rounding — human-friendly numbers
|
|
175
|
+
# ---------------------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
def _smart_round(value, sig_figs=2):
|
|
178
|
+
"""Round to N significant figures, producing human-friendly numbers.
|
|
179
|
+
|
|
180
|
+
_smart_round(6480, 2) → 6500
|
|
181
|
+
_smart_round(0.03097, 2) → 0.031
|
|
182
|
+
_smart_round(7200, 2) → 7200
|
|
183
|
+
_smart_round(0.04459, 2) → 0.045
|
|
184
|
+
"""
|
|
185
|
+
if value == 0:
|
|
186
|
+
return 0
|
|
187
|
+
d = math.ceil(math.log10(abs(value)))
|
|
188
|
+
power = sig_figs - d
|
|
189
|
+
factor = 10 ** power
|
|
190
|
+
return round(value * factor) / factor
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _fmt(value, sig_figs=2):
|
|
194
|
+
"""Format a number with smart rounding, dropping unnecessary decimals."""
|
|
195
|
+
rounded = _smart_round(value, sig_figs)
|
|
196
|
+
# Use 'g' format to avoid trailing zeros but keep precision
|
|
197
|
+
if abs(rounded) >= 1:
|
|
198
|
+
return f"{rounded:g}"
|
|
199
|
+
else:
|
|
200
|
+
# For small numbers, show enough decimals
|
|
201
|
+
decimals = max(0, sig_figs - math.floor(math.log10(abs(rounded))) - 1) if rounded != 0 else 1
|
|
202
|
+
return f"{rounded:.{decimals}f}"
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
# ---------------------------------------------------------------------------
|
|
206
|
+
# Actionable recommendation generator — in the user's language
|
|
207
|
+
# ---------------------------------------------------------------------------
|
|
208
|
+
|
|
209
|
+
def _generate_recommendation(result):
|
|
210
|
+
"""Generate a human-readable, actionable recommendation using column names."""
|
|
211
|
+
score = result.score
|
|
212
|
+
critical_x = result.critical_x
|
|
213
|
+
current_x = result.current_x
|
|
214
|
+
margin = result.safety_margin
|
|
215
|
+
p = result.p_value
|
|
216
|
+
kappa = result.kappa
|
|
217
|
+
xn = _clean_label(result.x_name)
|
|
218
|
+
yn = _clean_label(result.y_name)
|
|
219
|
+
|
|
220
|
+
cx_str = _fmt(critical_x)
|
|
221
|
+
cur_str = _fmt(current_x) if current_x is not None else None
|
|
222
|
+
|
|
223
|
+
if p >= 0.05:
|
|
224
|
+
if kappa > 3.0 and p < 0.20:
|
|
225
|
+
return (f"A possible pattern was found near {xn} = {cx_str}, but the data "
|
|
226
|
+
f"is too noisy to be certain (p = {p:.2f}). "
|
|
227
|
+
f"Try adding more data points or reducing measurement noise.")
|
|
228
|
+
return (f"No reliable tipping point found in this data. "
|
|
229
|
+
f"The variations in {yn} are consistent with random noise. "
|
|
230
|
+
f"Collect more data or verify the relationship between {xn} and {yn}.")
|
|
231
|
+
|
|
232
|
+
# Significant tipping point
|
|
233
|
+
alert_val = _fmt(critical_x * 0.9)
|
|
234
|
+
margin_pct = f"{margin * 100:.0f}%"
|
|
235
|
+
|
|
236
|
+
if score >= 85:
|
|
237
|
+
if cur_str:
|
|
238
|
+
return (f"System is stable. {yn} tips at {xn} = {cx_str}. "
|
|
239
|
+
f"Currently at {cur_str} — {margin_pct} safety margin. "
|
|
240
|
+
f"Recommendation: set alert at {alert_val}.")
|
|
241
|
+
return (f"Clear tipping point at {xn} = {cx_str} with high confidence. "
|
|
242
|
+
f"Safety margin: {margin_pct}. "
|
|
243
|
+
f"Recommendation: stay below {alert_val}.")
|
|
244
|
+
elif score >= 60:
|
|
245
|
+
keep_below = _fmt(critical_x * 0.8)
|
|
246
|
+
if cur_str:
|
|
247
|
+
return (f"{yn} changes sharply at {xn} = {cx_str}. "
|
|
248
|
+
f"Currently at {cur_str} — {margin_pct} from the edge. "
|
|
249
|
+
f"Recommendation: keep {xn} below {keep_below}.")
|
|
250
|
+
return (f"Tipping point at {xn} = {cx_str} with moderate confidence. "
|
|
251
|
+
f"Safety margin: {margin_pct}. Consider adding headroom.")
|
|
252
|
+
else:
|
|
253
|
+
reduce_to = _fmt(critical_x * 0.7)
|
|
254
|
+
if cur_str:
|
|
255
|
+
return (f"Warning: operating close to tipping point at {xn} = {cx_str}. "
|
|
256
|
+
f"Current {xn} ({cur_str}) is only {margin_pct} away. "
|
|
257
|
+
f"Immediate action recommended: reduce to {reduce_to} or below.")
|
|
258
|
+
return (f"Tipping point at {xn} = {cx_str}. Low safety margin ({margin_pct}). "
|
|
259
|
+
f"Reduce {xn} or increase system resilience.")
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
# ---------------------------------------------------------------------------
|
|
263
|
+
# Clean gauge (smooth arc, no segmentation)
|
|
264
|
+
# ---------------------------------------------------------------------------
|
|
265
|
+
|
|
266
|
+
def _draw_gauge(ax, score, grade):
|
|
267
|
+
"""Draw a semi-circular gauge with colored needle and score below."""
|
|
268
|
+
ax.set_xlim(-1.5, 1.5)
|
|
269
|
+
ax.set_ylim(-0.95, 1.45)
|
|
270
|
+
ax.set_aspect("equal")
|
|
271
|
+
ax.axis("off")
|
|
272
|
+
|
|
273
|
+
n_pts = 500
|
|
274
|
+
r_outer, r_inner = 1.0, 0.72
|
|
275
|
+
score_frac = np.clip(score / 100, 0, 1)
|
|
276
|
+
needle_color = _score_color(score)
|
|
277
|
+
|
|
278
|
+
# Background arc — full range, subtle
|
|
279
|
+
theta_bg = np.linspace(np.pi, 0, n_pts)
|
|
280
|
+
for i in range(len(theta_bg) - 1):
|
|
281
|
+
t0, t1 = theta_bg[i], theta_bg[i + 1]
|
|
282
|
+
frac = 1.0 - t0 / np.pi
|
|
283
|
+
color = ZONE_CMAP(frac)
|
|
284
|
+
xs = [r_inner * np.cos(t0), r_inner * np.cos(t1),
|
|
285
|
+
r_outer * np.cos(t1), r_outer * np.cos(t0)]
|
|
286
|
+
ys = [r_inner * np.sin(t0), r_inner * np.sin(t1),
|
|
287
|
+
r_outer * np.sin(t1), r_outer * np.sin(t0)]
|
|
288
|
+
ax.fill(xs, ys, color=color, alpha=0.12, linewidth=0)
|
|
289
|
+
|
|
290
|
+
# Filled arc up to score
|
|
291
|
+
theta_fill = np.linspace(np.pi, np.pi * (1 - score_frac), n_pts)
|
|
292
|
+
for i in range(len(theta_fill) - 1):
|
|
293
|
+
t0, t1 = theta_fill[i], theta_fill[i + 1]
|
|
294
|
+
frac = 1.0 - t0 / np.pi
|
|
295
|
+
color = ZONE_CMAP(frac)
|
|
296
|
+
xs = [r_inner * np.cos(t0), r_inner * np.cos(t1),
|
|
297
|
+
r_outer * np.cos(t1), r_outer * np.cos(t0)]
|
|
298
|
+
ys = [r_inner * np.sin(t0), r_inner * np.sin(t1),
|
|
299
|
+
r_outer * np.sin(t1), r_outer * np.sin(t0)]
|
|
300
|
+
ax.fill(xs, ys, color=color, alpha=0.9, linewidth=0)
|
|
301
|
+
|
|
302
|
+
# Tick marks (0, 20, 40, 60, 80, 100)
|
|
303
|
+
for tick_val in [0, 20, 40, 60, 80, 100]:
|
|
304
|
+
t = np.pi * (1 - tick_val / 100)
|
|
305
|
+
x0 = r_outer * np.cos(t)
|
|
306
|
+
y0 = r_outer * np.sin(t)
|
|
307
|
+
x1 = (r_outer + 0.08) * np.cos(t)
|
|
308
|
+
y1 = (r_outer + 0.08) * np.sin(t)
|
|
309
|
+
ax.plot([x0, x1], [y0, y1], color=C_LIGHT, linewidth=1.2, zorder=5)
|
|
310
|
+
lx = (r_outer + 0.19) * np.cos(t)
|
|
311
|
+
ly = (r_outer + 0.19) * np.sin(t)
|
|
312
|
+
ax.text(lx, ly, str(tick_val), ha="center", va="center",
|
|
313
|
+
fontsize=7, color=C_DIM)
|
|
314
|
+
|
|
315
|
+
# Needle — colored to match the score zone
|
|
316
|
+
needle_theta = np.pi * (1 - score_frac)
|
|
317
|
+
needle_len = r_outer + 0.04
|
|
318
|
+
nx = needle_len * np.cos(needle_theta)
|
|
319
|
+
ny = needle_len * np.sin(needle_theta)
|
|
320
|
+
# Triangular needle
|
|
321
|
+
perp = needle_theta + np.pi / 2
|
|
322
|
+
base_w = 0.05
|
|
323
|
+
tri_x = [nx,
|
|
324
|
+
base_w * np.cos(perp), -base_w * np.cos(perp)]
|
|
325
|
+
tri_y = [ny,
|
|
326
|
+
base_w * np.sin(perp), -base_w * np.sin(perp)]
|
|
327
|
+
ax.fill(tri_x, tri_y, color=needle_color, linewidth=0, zorder=8,
|
|
328
|
+
alpha=0.9)
|
|
329
|
+
# Center hub — same color
|
|
330
|
+
ax.plot(0, 0, "o", color=needle_color, markersize=7,
|
|
331
|
+
markeredgecolor="white", markeredgewidth=1.0, zorder=9)
|
|
332
|
+
|
|
333
|
+
# Score number — centered below the arc
|
|
334
|
+
ax.text(0, -0.22, str(score), ha="center", va="top",
|
|
335
|
+
fontsize=42, fontweight="bold", color=needle_color)
|
|
336
|
+
|
|
337
|
+
# Grade label — clearly separated below score
|
|
338
|
+
ax.text(0, -0.85, grade, ha="center", va="top",
|
|
339
|
+
fontsize=11, fontweight="bold", fontfamily="sans-serif",
|
|
340
|
+
color=GRADE_COLORS.get(grade, C_TEXT))
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
# ---------------------------------------------------------------------------
|
|
344
|
+
# Stability map — clean, subtle zones, with user's column names
|
|
345
|
+
# ---------------------------------------------------------------------------
|
|
346
|
+
|
|
347
|
+
def _draw_stability_map(ax, result, compact=False):
|
|
348
|
+
"""Draw the observable with subtle zone indication."""
|
|
349
|
+
x, y, chi = result.x, result.y, result.chi
|
|
350
|
+
critical_x = result.critical_x
|
|
351
|
+
ci_low, ci_high = result.ci
|
|
352
|
+
|
|
353
|
+
# Subtle background: green left of critical, red right (only if significant)
|
|
354
|
+
if result.p_value < 0.05:
|
|
355
|
+
ax.axvspan(x[0], critical_x, alpha=0.04, color=C_GREEN, linewidth=0)
|
|
356
|
+
ax.axvspan(critical_x, x[-1], alpha=0.04, color=C_RED, linewidth=0)
|
|
357
|
+
|
|
358
|
+
# CI band
|
|
359
|
+
ax.axvspan(ci_low, ci_high, alpha=0.10, color=C_RED,
|
|
360
|
+
label="95% CI", zorder=1)
|
|
361
|
+
# Critical line
|
|
362
|
+
ax.axvline(critical_x, color=C_RED, linewidth=1.8, linestyle="--",
|
|
363
|
+
alpha=0.7, label="Tipping point", zorder=4)
|
|
364
|
+
|
|
365
|
+
# Observable curve
|
|
366
|
+
ax.plot(x, y, color=C_BLUE, linewidth=2.2, label=_truncate_label(result.y_name, 22), zorder=3)
|
|
367
|
+
|
|
368
|
+
# Current operating point
|
|
369
|
+
if result.current_x is not None:
|
|
370
|
+
y_interp = np.interp(result.current_x, x, y)
|
|
371
|
+
ax.plot(result.current_x, y_interp, "o", color=C_GREEN, markersize=9,
|
|
372
|
+
markeredgecolor="white", markeredgewidth=2, zorder=5,
|
|
373
|
+
label="Current")
|
|
374
|
+
|
|
375
|
+
ax.set_xlabel(_truncate_label(result.x_name), fontsize=10, color=C_DIM)
|
|
376
|
+
if not compact:
|
|
377
|
+
ax.set_ylabel(_truncate_label(result.y_name), fontsize=10, color=C_DIM)
|
|
378
|
+
ax.set_facecolor(C_CARD)
|
|
379
|
+
ax.tick_params(colors=C_DIM, labelsize=9)
|
|
380
|
+
for spine in ax.spines.values():
|
|
381
|
+
spine.set_color(C_GRID)
|
|
382
|
+
ax.grid(True, alpha=0.3, color=C_GRID, linewidth=0.5)
|
|
383
|
+
ax.legend(fontsize=8, loc="best", framealpha=0.8,
|
|
384
|
+
facecolor=C_BG, edgecolor=C_GRID, labelcolor=C_TEXT)
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
# ---------------------------------------------------------------------------
|
|
388
|
+
# Sensitivity curve (was "Susceptibility")
|
|
389
|
+
# ---------------------------------------------------------------------------
|
|
390
|
+
|
|
391
|
+
def _draw_sensitivity(ax, result):
|
|
392
|
+
"""Draw the sensitivity (rate of change) curve."""
|
|
393
|
+
x, chi = result.x, result.chi
|
|
394
|
+
critical_x = result.critical_x
|
|
395
|
+
|
|
396
|
+
ax.fill_between(x, 0, chi, alpha=0.15, color=C_PURPLE)
|
|
397
|
+
ax.plot(x, chi, color=C_PURPLE, linewidth=1.8)
|
|
398
|
+
|
|
399
|
+
if result.p_value < 0.05:
|
|
400
|
+
ax.axvline(critical_x, color=C_RED, linewidth=1.2,
|
|
401
|
+
linestyle="--", alpha=0.6)
|
|
402
|
+
peak_y = chi[np.argmin(np.abs(x - critical_x))]
|
|
403
|
+
ax.annotate(f"k = {result.kappa:.1f}",
|
|
404
|
+
xy=(critical_x, peak_y),
|
|
405
|
+
xytext=(critical_x + np.ptp(x) * 0.1, peak_y * 0.85),
|
|
406
|
+
fontsize=9, color=C_TEXT,
|
|
407
|
+
arrowprops=dict(arrowstyle="->", color=C_DIM, lw=0.8))
|
|
408
|
+
|
|
409
|
+
ax.set_xlabel(_truncate_label(result.x_name), fontsize=10, color=C_DIM)
|
|
410
|
+
ax.set_ylabel("Sensitivity", fontsize=10, color=C_DIM)
|
|
411
|
+
|
|
412
|
+
# Subtitle explaining what this shows
|
|
413
|
+
ax.text(0.98, 0.96, f"How fast {_truncate_label(result.y_name)} changes",
|
|
414
|
+
transform=ax.transAxes, fontsize=7.5, color=C_LIGHT,
|
|
415
|
+
ha="right", va="top", style="italic")
|
|
416
|
+
|
|
417
|
+
ax.set_facecolor(C_CARD)
|
|
418
|
+
ax.tick_params(colors=C_DIM, labelsize=9)
|
|
419
|
+
for spine in ax.spines.values():
|
|
420
|
+
spine.set_color(C_GRID)
|
|
421
|
+
ax.grid(True, alpha=0.3, color=C_GRID, linewidth=0.5)
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
# ---------------------------------------------------------------------------
|
|
425
|
+
# Recommendation panel (replaces raw metrics)
|
|
426
|
+
# ---------------------------------------------------------------------------
|
|
427
|
+
|
|
428
|
+
def _draw_recommendation(ax, result, verbose=False):
|
|
429
|
+
"""Draw actionable recommendation text, with optional full metrics."""
|
|
430
|
+
ax.set_xlim(0, 1)
|
|
431
|
+
ax.set_ylim(0, 1)
|
|
432
|
+
ax.axis("off")
|
|
433
|
+
ax.set_facecolor(C_CARD)
|
|
434
|
+
|
|
435
|
+
# Recommendation text — the main event
|
|
436
|
+
rec = _generate_recommendation(result)
|
|
437
|
+
|
|
438
|
+
ax.text(0.05, 0.95, "Recommendation", fontsize=11, fontweight="bold",
|
|
439
|
+
color=C_TEXT, va="top")
|
|
440
|
+
|
|
441
|
+
ax.text(0.05, 0.82, rec, fontsize=9, color=C_TEXT, va="top",
|
|
442
|
+
wrap=True, linespacing=1.4)
|
|
443
|
+
|
|
444
|
+
xn = _clean_label(result.x_name)
|
|
445
|
+
if verbose:
|
|
446
|
+
# Full metrics table for experts
|
|
447
|
+
sig_mark = "sig." if result.p_value < 0.05 else "n.s."
|
|
448
|
+
metrics = [
|
|
449
|
+
("Tipping Point", f"{xn} = {_fmt(result.critical_x, 3)}"),
|
|
450
|
+
("95% CI", f"{_fmt(result.ci[0], 3)} - {_fmt(result.ci[1], 3)}"),
|
|
451
|
+
("Sharpness (k)", f"{result.kappa:.2f}"),
|
|
452
|
+
("p-value", f"{result.p_value:.4f} {sig_mark}"),
|
|
453
|
+
("Safety Margin", f"{result.safety_margin * 100:.0f}%"),
|
|
454
|
+
]
|
|
455
|
+
y_pos = 0.38
|
|
456
|
+
ax.text(0.05, y_pos + 0.05, "Details", fontsize=9, fontweight="bold",
|
|
457
|
+
color=C_DIM, va="top")
|
|
458
|
+
for label, value in metrics:
|
|
459
|
+
ax.text(0.05, y_pos, label, fontsize=8, color=C_DIM, va="top")
|
|
460
|
+
ax.text(0.95, y_pos, value, fontsize=8, color=C_TEXT, va="top",
|
|
461
|
+
ha="right", fontfamily="monospace")
|
|
462
|
+
y_pos -= 0.07
|
|
463
|
+
else:
|
|
464
|
+
# Compact one-liner for casual viewers
|
|
465
|
+
sig_mark = "sig." if result.p_value < 0.05 else "n.s."
|
|
466
|
+
details = (
|
|
467
|
+
f"{xn}={_fmt(result.critical_x, 3)} "
|
|
468
|
+
f"CI:{_fmt(result.ci[0], 3)}-{_fmt(result.ci[1], 3)} "
|
|
469
|
+
f"k={result.kappa:.1f} "
|
|
470
|
+
f"p={result.p_value:.3f} {sig_mark} "
|
|
471
|
+
f"margin:{result.safety_margin * 100:.0f}%"
|
|
472
|
+
)
|
|
473
|
+
ax.text(0.05, 0.06, details, fontsize=7.5, color=C_DIM, va="bottom",
|
|
474
|
+
fontfamily="monospace")
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
# ---------------------------------------------------------------------------
|
|
478
|
+
# Main render function
|
|
479
|
+
# ---------------------------------------------------------------------------
|
|
480
|
+
|
|
481
|
+
def render_card(result, figsize=(14, 8), verbose=False):
|
|
482
|
+
"""Render a Stability Card and return the matplotlib Figure.
|
|
483
|
+
|
|
484
|
+
Parameters
|
|
485
|
+
----------
|
|
486
|
+
verbose : bool
|
|
487
|
+
If True, show full metrics table alongside the recommendation.
|
|
488
|
+
Default False shows clean recommendation + tiny detail line.
|
|
489
|
+
"""
|
|
490
|
+
fig = plt.figure(figsize=figsize, facecolor=C_BG)
|
|
491
|
+
fig.subplots_adjust(left=0.06, right=0.94, top=0.88, bottom=0.12)
|
|
492
|
+
|
|
493
|
+
gs = gridspec.GridSpec(2, 3, figure=fig, hspace=0.35, wspace=0.30,
|
|
494
|
+
width_ratios=[1.1, 1.5, 1.3])
|
|
495
|
+
|
|
496
|
+
# Title — clean, modern
|
|
497
|
+
fig.text(0.5, 0.96, "Fuse Report",
|
|
498
|
+
ha="center", va="top", fontsize=20, fontweight="bold",
|
|
499
|
+
color=C_TEXT)
|
|
500
|
+
fig.text(0.5, 0.925, result.label,
|
|
501
|
+
ha="center", va="top", fontsize=11, color=C_DIM)
|
|
502
|
+
|
|
503
|
+
# Gauge (top-left)
|
|
504
|
+
ax_gauge = fig.add_subplot(gs[0, 0])
|
|
505
|
+
_draw_gauge(ax_gauge, result.score, result.grade)
|
|
506
|
+
|
|
507
|
+
# Stability Map (top-center + top-right)
|
|
508
|
+
ax_map = fig.add_subplot(gs[0, 1:])
|
|
509
|
+
_draw_stability_map(ax_map, result)
|
|
510
|
+
|
|
511
|
+
# Sensitivity (bottom-left + bottom-center)
|
|
512
|
+
ax_chi = fig.add_subplot(gs[1, :2])
|
|
513
|
+
_draw_sensitivity(ax_chi, result)
|
|
514
|
+
|
|
515
|
+
# Recommendation (bottom-right)
|
|
516
|
+
ax_rec = fig.add_subplot(gs[1, 2])
|
|
517
|
+
_draw_recommendation(ax_rec, result, verbose=verbose)
|
|
518
|
+
|
|
519
|
+
# Brand footer
|
|
520
|
+
_draw_brand_footer(fig)
|
|
521
|
+
|
|
522
|
+
return fig
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
# ---------------------------------------------------------------------------
|
|
526
|
+
# Comparison card (before / after)
|
|
527
|
+
# ---------------------------------------------------------------------------
|
|
528
|
+
|
|
529
|
+
def render_comparison_card(comp, figsize=(15, 9)):
|
|
530
|
+
"""Render a before/after comparison card."""
|
|
531
|
+
fig = plt.figure(figsize=figsize, facecolor=C_BG)
|
|
532
|
+
|
|
533
|
+
gs = gridspec.GridSpec(3, 2, figure=fig,
|
|
534
|
+
height_ratios=[1.2, 1.4, 0.5],
|
|
535
|
+
hspace=0.30, wspace=0.25)
|
|
536
|
+
fig.subplots_adjust(left=0.06, right=0.94, top=0.88, bottom=0.12)
|
|
537
|
+
|
|
538
|
+
# Title
|
|
539
|
+
delta = comp.delta_score
|
|
540
|
+
sign = "+" if delta >= 0 else ""
|
|
541
|
+
delta_color = C_GREEN if delta > 0 else (C_RED if delta < 0 else C_DIM)
|
|
542
|
+
fig.text(0.5, 0.97, "Fuse Comparison",
|
|
543
|
+
ha="center", va="top", fontsize=20, fontweight="bold",
|
|
544
|
+
color=C_TEXT)
|
|
545
|
+
fig.text(0.5, 0.935,
|
|
546
|
+
f"{comp.before.score} \u2192 {comp.after.score} ({sign}{delta})",
|
|
547
|
+
ha="center", va="top", fontsize=24, fontweight="bold",
|
|
548
|
+
color=delta_color)
|
|
549
|
+
|
|
550
|
+
# Before gauge
|
|
551
|
+
ax1 = fig.add_subplot(gs[0, 0])
|
|
552
|
+
ax1.set_title(comp.before.label, fontsize=12, color=C_DIM, pad=6)
|
|
553
|
+
_draw_gauge(ax1, comp.before.score, comp.before.grade)
|
|
554
|
+
|
|
555
|
+
# After gauge
|
|
556
|
+
ax2 = fig.add_subplot(gs[0, 1])
|
|
557
|
+
ax2.set_title(comp.after.label, fontsize=12, color=C_DIM, pad=6)
|
|
558
|
+
_draw_gauge(ax2, comp.after.score, comp.after.grade)
|
|
559
|
+
|
|
560
|
+
# Before stability map
|
|
561
|
+
ax3 = fig.add_subplot(gs[1, 0])
|
|
562
|
+
_draw_stability_map(ax3, comp.before, compact=True)
|
|
563
|
+
|
|
564
|
+
# After stability map
|
|
565
|
+
ax4 = fig.add_subplot(gs[1, 1])
|
|
566
|
+
_draw_stability_map(ax4, comp.after, compact=True)
|
|
567
|
+
|
|
568
|
+
# Recommendation text (bottom row, spanning both columns)
|
|
569
|
+
ax_rec = fig.add_subplot(gs[2, :])
|
|
570
|
+
ax_rec.axis("off")
|
|
571
|
+
ax_rec.set_facecolor(C_BG)
|
|
572
|
+
|
|
573
|
+
rec_after = _generate_recommendation(comp.after)
|
|
574
|
+
|
|
575
|
+
# Compact comparison summary
|
|
576
|
+
if delta > 0:
|
|
577
|
+
summary = (f"Improvement: +{delta} points. "
|
|
578
|
+
f"After: {rec_after}")
|
|
579
|
+
elif delta < 0:
|
|
580
|
+
summary = (f"Regression: {delta} points. "
|
|
581
|
+
f"After: {rec_after}")
|
|
582
|
+
else:
|
|
583
|
+
summary = f"No change. {rec_after}"
|
|
584
|
+
|
|
585
|
+
ax_rec.text(0.5, 0.7, summary, fontsize=9.5, color=C_TEXT,
|
|
586
|
+
ha="center", va="top", wrap=True, linespacing=1.4,
|
|
587
|
+
transform=ax_rec.transAxes)
|
|
588
|
+
|
|
589
|
+
# Brand footer
|
|
590
|
+
_draw_brand_footer(fig)
|
|
591
|
+
|
|
592
|
+
return fig
|