feat(battlecard): implement mini-chart engine with 4 chart templates (line trend, horizontal bar, utilization gauge, comparison bar)

This commit is contained in:
Orchestrator
2026-06-05 14:20:25 -05:00
parent 2f85d31f5e
commit 15105d3faa
5 changed files with 333 additions and 203 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 45 KiB

View File

@@ -1,211 +1,338 @@
"""Mini-chart engine for battle card visualizations.
Generates compact, themed 5x3 inch charts suitable for embedding
in FIA battle cards. Uses the existing project styling utilities.
"""
from __future__ import annotations
"""Mini-chart engine for battle card embeddings."""
import argparse
import sys
from pathlib import Path
import matplotlib
matplotlib.use("Agg")
import matplotlib.figure # noqa: E402
import matplotlib.pyplot as plt # noqa: E402
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
from matplotlib.path import Path as MPLPath
# Python 3.14 matplotlib patch (required)
_orig = MPLPath.__deepcopy__
def _safe_deepcopy(self, memo):
if id(self) in memo:
return memo[id(self)]
memo[id(self)] = self
return self
MPLPath.__deepcopy__ = _safe_deepcopy
from src.utils.styling import ( # noqa: E402
AI_SPEND,
BUBBLE_ZONE,
REVENUE,
GRAY_DARK,
WHITE,
EXPORT_DPI,
from pathlib import Path
from src.utils.styling import (
get_theme,
apply_theme,
EXPORT_DPI,
BUBBLE_ZONE,
AI_SPEND,
REVENUE,
WARNING_ZONE,
NORMAL_ZONE,
GRAY_DARK,
GRAY_MEDIUM,
WHITE,
)
from src.utils.export import save_chart
# ---------------------------------------------------------------------------
# Sizing constants
# ---------------------------------------------------------------------------
MINI_FIGURE_SIZE = (5, 3) # width, height in inches
MINI_FIGURE_SIZE = (5, 3)
MINI_DPI = 300
MIN_LABEL_FONT_SIZE = 9
MIN_ANNOTATION_FONT_SIZE = 11
MIN_TITLE_FONT_SIZE = 13
class MiniChartEngine:
"""Engine for generating compact, themed mini-charts for battle cards.
"""Engine for generating compact, themed mini-charts for battle cards."""
Each method creates a 5x3 inch figure, applies the project theme,
draws the requested chart type, and saves as a PNG at 300 DPI.
"""
def _init_figure(self, title: str):
"""Create a themed figure and axes."""
plt.rcParams.update(get_theme())
fig, ax = plt.subplots(figsize=MINI_FIGURE_SIZE)
fig.set_facecolor(WHITE)
ax.set_facecolor(WHITE)
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)
ax.spines["left"].set_color("#cccccc")
ax.spines["bottom"].set_color("#cccccc")
ax.set_title(title, fontsize=MIN_TITLE_FONT_SIZE, fontweight="bold", pad=8)
return fig, ax
def _save(self, fig, save_path: str) -> str:
"""Save figure with tight layout."""
Path(save_path).parent.mkdir(parents=True, exist_ok=True)
fig.savefig(save_path, dpi=MINI_DPI, bbox_inches="tight", pad_inches=0.15)
plt.close(fig)
return save_path
def generate_line_trend(
self, data: list[dict], title: str, save_path: str
) -> str:
"""Generate a line trend mini-chart.
self,
years,
values,
title,
save_path,
highlight_year=None,
highlight_value=None,
color=GRAY_DARK,
secondary_color=None,
):
"""Single line chart showing trend over time."""
fig, ax = self._init_figure(title)
Parameters
----------
data : list[dict]
Each dict should have 'x' and 'y' keys.
title : str
Chart title.
save_path : str
Output file path.
ax.plot(years, values, color=color, linewidth=2, marker="o", markersize=5)
Returns
-------
str
Absolute path to the saved PNG file.
"""
fig, ax = plt.subplots(figsize=MINI_FIGURE_SIZE)
apply_theme(fig, ax)
# Highlight year
if highlight_year is not None:
idx = years.index(highlight_year) if highlight_year in years else len(years) - 1
ax.axvline(
x=highlight_year,
color=BUBBLE_ZONE,
linestyle="--",
linewidth=1,
alpha=0.7,
)
if highlight_value is not None:
ax.annotate(
str(highlight_value),
xy=(highlight_year, values[idx]),
xytext=(5, 10),
textcoords="offset points",
fontsize=MIN_ANNOTATION_FONT_SIZE,
fontweight="bold",
color=BUBBLE_ZONE,
)
x_values = [d["x"] for d in data]
y_values = [d["y"] for d in data]
ax.plot(x_values, y_values, color=AI_SPEND, linewidth=1.5)
ax.set_title(title, fontsize=MIN_TITLE_FONT_SIZE, fontweight="bold")
ax.tick_params(axis="both", labelsize=MIN_LABEL_FONT_SIZE)
ax.set_xlabel("Time", fontsize=MIN_LABEL_FONT_SIZE)
ax.set_ylabel("Value", fontsize=MIN_LABEL_FONT_SIZE)
self._save(fig, save_path)
return save_path
ax.grid(True, axis="y", alpha=0.4)
plt.tight_layout()
return self._save(fig, save_path)
def generate_horizontal_bar(
self, data: list[dict], title: str, save_path: str
) -> str:
"""Generate a horizontal bar mini-chart.
self,
categories,
values,
title,
save_path,
colors=None,
value_labels=None,
max_value=None,
):
"""Horizontal bar chart for comparing categories."""
fig, ax = self._init_figure(title)
Parameters
----------
data : list[dict]
Each dict should have 'label' and 'value' keys.
title : str
Chart title.
save_path : str
Output file path.
if colors is None:
colors = [AI_SPEND] * len(categories)
Returns
-------
str
Absolute path to the saved PNG file.
"""
fig, ax = plt.subplots(figsize=MINI_FIGURE_SIZE)
apply_theme(fig, ax)
y_pos = range(len(categories))
bars = ax.barh(
y_pos, values, color=colors[: len(categories)], height=0.55
)
labels = [d["label"] for d in data]
values = [d["value"] for d in data]
colors = [
AI_SPEND, BUBBLE_ZONE, REVENUE, GRAY_DARK, WHITE
] * (len(labels) // 5 + 1)
# Value labels on bars
if value_labels is not None:
for bar, label in zip(bars, value_labels):
ax.text(
bar.get_width() + max(values) * 0.01,
bar.get_y() + bar.get_height() / 2,
str(label),
va="center",
fontsize=MIN_LABEL_FONT_SIZE,
color=GRAY_DARK,
)
ax.barh(labels, values, color=colors[:len(labels)], height=0.6)
ax.set_title(title, fontsize=MIN_TITLE_FONT_SIZE, fontweight="bold")
ax.tick_params(axis="both", labelsize=MIN_LABEL_FONT_SIZE)
ax.set_yticks(list(y_pos))
ax.set_yticklabels(categories, fontsize=MIN_LABEL_FONT_SIZE)
ax.tick_params(axis="x", labelsize=MIN_LABEL_FONT_SIZE)
self._save(fig, save_path)
return save_path
if max_value is not None:
ax.set_xlim(0, max_value * 1.15)
ax.grid(True, axis="x", alpha=0.3)
plt.tight_layout()
return self._save(fig, save_path)
def generate_utilization_bar(
self, data: list[dict], title: str, save_path: str
) -> str:
"""Generate a utilization bar chart (e.g. GPU utilization).
self,
label,
percentage,
title,
save_path,
context_text=None,
):
"""Single horizontal bar showing utilization rate."""
fig, ax = self._init_figure(title)
Parameters
----------
data : list[dict]
Each dict should have 'label', 'value', and 'max_value' keys.
title : str
Chart title.
save_path : str
Output file path.
# Color coding based on percentage
if percentage > 50:
bar_color = REVENUE # green
elif percentage >= 20:
bar_color = WARNING_ZONE # orange
else:
bar_color = BUBBLE_ZONE # red
Returns
-------
str
Absolute path to the saved PNG file.
"""
fig, ax = plt.subplots(figsize=MINI_FIGURE_SIZE)
apply_theme(fig, ax)
# Background track
ax.barh(
0,
100,
height=0.6,
color="#ecf0f1",
edgecolor="#cccccc",
linewidth=0.5,
)
labels = [d["label"] for d in data]
values = [d["value"] for d in data]
max_values = [d.get("max_value", 100) for d in data]
# Filled utilization bar
bar = ax.barh(0, percentage, height=0.6, color=bar_color)
# Background bars for max
ax.barh(labels, max_values, color="#ecf0f1", height=0.6)
# Foreground bars for actual utilization
colors = [
BUBBLE_ZONE if v / m < 0.3 else REVENUE
for v, m in zip(values, max_values)
]
ax.barh(labels, values, color=colors, height=0.6)
# Large percentage annotation on the bar
ax.text(
percentage / 2,
0,
f"{percentage:.0f}%",
ha="center",
va="center",
fontsize=MIN_ANNOTATION_FONT_SIZE + 3,
fontweight="bold",
color=WHITE if percentage > 10 else GRAY_DARK,
)
ax.set_title(title, fontsize=MIN_TITLE_FONT_SIZE, fontweight="bold")
ax.tick_params(axis="both", labelsize=MIN_LABEL_FONT_SIZE)
# Label
ax.set_yticks([])
ax.set_xlim(0, 100)
ax.text(
0.02,
-0.25,
str(label),
transform=ax.transData,
fontsize=MIN_LABEL_FONT_SIZE,
color=GRAY_DARK,
)
self._save(fig, save_path)
return save_path
# Context text below the bar
if context_text is not None:
ax.text(
50,
-0.55,
context_text,
ha="center",
fontsize=MIN_LABEL_FONT_SIZE - 1,
color=GRAY_MEDIUM,
style="italic",
)
ax.set_ylim(-0.9, 0.5)
ax.axis("off")
plt.tight_layout()
return self._save(fig, save_path)
def generate_comparison_bar(
self, data: list[dict], title: str, save_path: str
) -> str:
"""Generate a grouped comparison bar chart.
self,
categories,
values_left,
values_right,
title,
save_path,
label_left=None,
label_right=None,
colors=None,
):
"""Side-by-side grouped bar chart for comparisons."""
fig, ax = self._init_figure(title)
Parameters
----------
data : list[dict]
Each dict should have 'label' and multiple value keys
(e.g. 'value_a', 'value_b').
title : str
Chart title.
save_path : str
Output file path.
if colors is None:
color_left = AI_SPEND
color_right = GRAY_MEDIUM
elif len(colors) >= 2:
color_left = colors[0]
color_right = colors[1]
else:
color_left = colors[0] if len(colors) == 1 else AI_SPEND
color_right = GRAY_MEDIUM
Returns
-------
str
Absolute path to the saved PNG file.
"""
fig, ax = plt.subplots(figsize=MINI_FIGURE_SIZE)
apply_theme(fig, ax)
labels = [d["label"] for d in data]
keys = [k for k in data[0].keys() if k != "label"]
import numpy as np
x = np.arange(len(labels))
x = list(range(len(categories)))
width = 0.35
for i, key in enumerate(keys):
values = [d.get(key, 0) for d in data]
color = AI_SPEND if i == 0 else REVENUE
ax.bar(x + i * width, values, width, label=key, color=color)
bars_left = ax.bar(
[p - width / 2 for p in x],
values_left,
width,
label=label_left or "Left",
color=color_left,
)
bars_right = ax.bar(
[p + width / 2 for p in x],
values_right,
width,
label=label_right or "Right",
color=color_right,
)
ax.set_title(title, fontsize=MIN_TITLE_FONT_SIZE, fontweight="bold")
ax.set_xticks(x + width / 2)
ax.set_xticklabels(labels, fontsize=MIN_LABEL_FONT_SIZE)
ax.set_xticks(x)
ax.set_xticklabels(categories, fontsize=MIN_LABEL_FONT_SIZE)
ax.tick_params(axis="y", labelsize=MIN_LABEL_FONT_SIZE)
ax.legend(fontsize=MIN_LABEL_FONT_SIZE)
ax.legend(
loc="upper center",
bbox_to_anchor=(0.5, -0.12),
ncol=2,
fontsize=MIN_LABEL_FONT_SIZE,
)
ax.grid(True, axis="y", alpha=0.3)
plt.tight_layout()
return self._save(fig, save_path)
self._save(fig, save_path)
return save_path
@staticmethod
def _save(fig: matplotlib.figure.Figure, save_path: str) -> None:
"""Save a figure with tight layout at MINI_DPI."""
Path(save_path).parent.mkdir(parents=True, exist_ok=True)
fig.savefig(save_path, dpi=MINI_DPI, bbox_inches="tight")
plt.close(fig)
# ---------------------------------------------------------------------------
# Standalone convenience functions (for use by card workers)
# ---------------------------------------------------------------------------
def create_cape_chart(years, values, save_path):
"""Create historical CAPE trend with current value highlighted."""
engine = MiniChartEngine()
engine.generate_line_trend(
years=years,
values=values,
title="CAPE Ratio Trend",
save_path=save_path,
highlight_year=years[-1],
highlight_value=values[-1],
color=GRAY_DARK,
)
def create_capex_chart(companies, values, save_path):
"""Create hyperscaler capex comparison bar chart."""
engine = MiniChartEngine()
colors_list = [AI_SPEND, WARNING_ZONE, REVENUE, GRAY_MEDIUM, BUBBLE_ZONE]
engine.generate_horizontal_bar(
categories=companies,
values=values,
title="Hyperscaler AI Capex",
save_path=save_path,
colors=colors_list[: len(companies)],
value_labels=[f"${v}B" for v in values],
max_value=max(values) * 1.3,
)
def create_utilization_chart(percentage, context_text, save_path):
"""Create GPU utilization gauge chart."""
engine = MiniChartEngine()
engine.generate_utilization_bar(
label="GPU Utilization",
percentage=percentage,
title="Current GPU Utilization",
save_path=save_path,
context_text=context_text,
)
def create_vulnerability_chart(ai_rate, non_ai_rate, save_path):
"""Create AI vs non-AI code vulnerability comparison."""
engine = MiniChartEngine()
engine.generate_comparison_bar(
categories=["Code Vulnerability Rate"],
values_left=[ai_rate],
values_right=[non_ai_rate],
title="AI vs Non-AI Code Vulnerability",
save_path=save_path,
label_left="AI-Generated Code",
label_right="Human-Generated Code",
colors=[BUBBLE_ZONE, GRAY_MEDIUM],
)
# ---------------------------------------------------------------------------
@@ -213,61 +340,64 @@ class MiniChartEngine:
# ---------------------------------------------------------------------------
def _run_tests() -> None:
"""Generate 4 placeholder test charts to output/battlecards/charts/."""
"""Generate 4 test charts demonstrating each chart type."""
base_dir = Path("output/battlecards/charts")
engine = MiniChartEngine()
base_dir.mkdir(parents=True, exist_ok=True)
# Test 1: Line trend
line_data = [
{"x": f"Q{i}", "y": 10 + i * 3} for i in range(1, 5)
]
# Test 1: Line trend — CAPE-like trend with 2026 highlighted
years = [2018, 2019, 2020, 2021, 2022, 2023, 2024, 2025, 2026]
cape_values = [18.2, 28.5, 31.1, 25.3, 22.7, 33.8, 37.2, 41.5, 44.8]
path1 = engine.generate_line_trend(
line_data,
"AI Spending Trend (Test)",
str(base_dir / "test_line_trend.png"),
years=years,
values=cape_values,
title="S&P 500 CAPE Ratio Trend",
save_path=str(base_dir / "test_line_trend.png"),
highlight_year=2026,
highlight_value="44.8x",
color=GRAY_DARK,
)
print(f" Line trend: {path1}")
print(f" [1/4] Line trend: {path1}")
# Test 2: Horizontal bar
bar_data = [
{"label": "Cloud A", "value": 85},
{"label": "Cloud B", "value": 62},
{"label": "On-prem", "value": 28},
]
# Test 2: Horizontal bar — Hyperscaler capex comparison
companies = ["Microsoft", "Amazon", "Google", "Meta"]
capex_values = [234, 118, 90, 72]
capex_colors = ["#00a4ef", "#ff9900", "#4285f4", "#1877f2"]
path2 = engine.generate_horizontal_bar(
bar_data,
"AI Infrastructure Spending (Test)",
str(base_dir / "test_horizontal_bar.png"),
categories=companies,
values=capex_values,
title="2025 AI Infrastructure Capex ($B)",
save_path=str(base_dir / "test_horizontal_bar.png"),
colors=capex_colors,
value_labels=[f"${v}B" for v in capex_values],
max_value=280,
)
print(f" Horizontal bar: {path2}")
print(f" [2/4] Horizontal bar: {path2}")
# Test 3: Utilization bar
util_data = [
{"label": "Company A", "value": 18, "max_value": 100},
{"label": "Company B", "value": 24, "max_value": 100},
{"label": "Company C", "value": 45, "max_value": 100},
]
# Test 3: Utilization bar — GPU utilization gauge at 5%
path3 = engine.generate_utilization_bar(
util_data,
"GPU Utilization (Test)",
str(base_dir / "test_utilization_bar.png"),
label="Enterprise Average",
percentage=5.0,
title="GPU Utilization Rate",
save_path=str(base_dir / "test_utilization_bar.png"),
context_text="Most GPUs sit idle — 95% capacity wasted",
)
print(f" Utilization bar: {path3}")
print(f" [3/4] Utilization bar: {path3}")
# Test 4: Comparison bar
comp_data = [
{"label": "2023", "value_a": 50, "value_b": 30},
{"label": "2024", "value_a": 75, "value_b": 45},
{"label": "2025", "value_a": 90, "value_b": 55},
]
# Test 4: Comparison bar — AI vs non-AI vulnerability rates
path4 = engine.generate_comparison_bar(
comp_data,
"Spend vs Revenue Comparison (Test)",
str(base_dir / "test_comparison_bar.png"),
categories=["Vulnerability Rate (%)"],
values_left=[47],
values_right=[12],
title="Code Vulnerability Comparison",
save_path=str(base_dir / "test_comparison_bar.png"),
label_left="AI-Generated Code",
label_right="Human-Generated Code",
colors=[BUBBLE_ZONE, GRAY_MEDIUM],
)
print(f" Comparison bar: {path4}")
print(f" [4/4] Comparison bar: {path4}")
print("All 4 test charts generated successfully.")
print("\nAll 4 test charts generated successfully.")
if __name__ == "__main__":
@@ -277,7 +407,7 @@ if __name__ == "__main__":
parser.add_argument(
"--test",
action="store_true",
help="Generate 4 placeholder test charts",
help="Generate 4 test charts demonstrating each chart type",
)
args = parser.parse_args()