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. """Mini-chart engine for battle card embeddings."""
Generates compact, themed 5x3 inch charts suitable for embedding
in FIA battle cards. Uses the existing project styling utilities.
"""
from __future__ import annotations
import argparse import argparse
import sys
from pathlib import Path
import matplotlib import matplotlib
matplotlib.use("Agg") matplotlib.use("Agg")
import matplotlib.figure # noqa: E402 import matplotlib.pyplot as plt
import matplotlib.pyplot as plt # noqa: E402 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 from pathlib import Path
AI_SPEND,
BUBBLE_ZONE, from src.utils.styling import (
REVENUE,
GRAY_DARK,
WHITE,
EXPORT_DPI,
get_theme, 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
# --------------------------------------------------------------------------- MINI_FIGURE_SIZE = (5, 3)
# Sizing constants
# ---------------------------------------------------------------------------
MINI_FIGURE_SIZE = (5, 3) # width, height in inches
MINI_DPI = 300 MINI_DPI = 300
MIN_LABEL_FONT_SIZE = 9 MIN_LABEL_FONT_SIZE = 9
MIN_ANNOTATION_FONT_SIZE = 11 MIN_ANNOTATION_FONT_SIZE = 11
MIN_TITLE_FONT_SIZE = 13 MIN_TITLE_FONT_SIZE = 13
class MiniChartEngine: 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, def _init_figure(self, title: str):
draws the requested chart type, and saves as a PNG at 300 DPI. """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( def generate_line_trend(
self, data: list[dict], title: str, save_path: str self,
) -> str: years,
"""Generate a line trend mini-chart. 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 ax.plot(years, values, color=color, linewidth=2, marker="o", markersize=5)
----------
data : list[dict]
Each dict should have 'x' and 'y' keys.
title : str
Chart title.
save_path : str
Output file path.
Returns # Highlight year
------- if highlight_year is not None:
str idx = years.index(highlight_year) if highlight_year in years else len(years) - 1
Absolute path to the saved PNG file. ax.axvline(
""" x=highlight_year,
fig, ax = plt.subplots(figsize=MINI_FIGURE_SIZE) color=BUBBLE_ZONE,
apply_theme(fig, ax) 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.tick_params(axis="both", labelsize=MIN_LABEL_FONT_SIZE)
ax.set_xlabel("Time", fontsize=MIN_LABEL_FONT_SIZE) ax.grid(True, axis="y", alpha=0.4)
ax.set_ylabel("Value", fontsize=MIN_LABEL_FONT_SIZE) plt.tight_layout()
return self._save(fig, save_path)
self._save(fig, save_path)
return save_path
def generate_horizontal_bar( def generate_horizontal_bar(
self, data: list[dict], title: str, save_path: str self,
) -> str: categories,
"""Generate a horizontal bar mini-chart. 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 if colors is None:
---------- colors = [AI_SPEND] * len(categories)
data : list[dict]
Each dict should have 'label' and 'value' keys.
title : str
Chart title.
save_path : str
Output file path.
Returns y_pos = range(len(categories))
------- bars = ax.barh(
str y_pos, values, color=colors[: len(categories)], height=0.55
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] # Value labels on bars
values = [d["value"] for d in data] if value_labels is not None:
colors = [ for bar, label in zip(bars, value_labels):
AI_SPEND, BUBBLE_ZONE, REVENUE, GRAY_DARK, WHITE ax.text(
] * (len(labels) // 5 + 1) 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_yticks(list(y_pos))
ax.set_title(title, fontsize=MIN_TITLE_FONT_SIZE, fontweight="bold") ax.set_yticklabels(categories, fontsize=MIN_LABEL_FONT_SIZE)
ax.tick_params(axis="both", labelsize=MIN_LABEL_FONT_SIZE) ax.tick_params(axis="x", labelsize=MIN_LABEL_FONT_SIZE)
self._save(fig, save_path) if max_value is not None:
return save_path 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( def generate_utilization_bar(
self, data: list[dict], title: str, save_path: str self,
) -> str: label,
"""Generate a utilization bar chart (e.g. GPU utilization). percentage,
title,
save_path,
context_text=None,
):
"""Single horizontal bar showing utilization rate."""
fig, ax = self._init_figure(title)
Parameters # Color coding based on percentage
---------- if percentage > 50:
data : list[dict] bar_color = REVENUE # green
Each dict should have 'label', 'value', and 'max_value' keys. elif percentage >= 20:
title : str bar_color = WARNING_ZONE # orange
Chart title. else:
save_path : str bar_color = BUBBLE_ZONE # red
Output file path.
Returns # Background track
------- ax.barh(
str 0,
Absolute path to the saved PNG file. 100,
""" height=0.6,
fig, ax = plt.subplots(figsize=MINI_FIGURE_SIZE) color="#ecf0f1",
apply_theme(fig, ax) edgecolor="#cccccc",
linewidth=0.5,
)
labels = [d["label"] for d in data] # Filled utilization bar
values = [d["value"] for d in data] bar = ax.barh(0, percentage, height=0.6, color=bar_color)
max_values = [d.get("max_value", 100) for d in data]
# Background bars for max # Large percentage annotation on the bar
ax.barh(labels, max_values, color="#ecf0f1", height=0.6) ax.text(
# Foreground bars for actual utilization percentage / 2,
colors = [ 0,
BUBBLE_ZONE if v / m < 0.3 else REVENUE f"{percentage:.0f}%",
for v, m in zip(values, max_values) ha="center",
] va="center",
ax.barh(labels, values, color=colors, height=0.6) 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") # Label
ax.tick_params(axis="both", labelsize=MIN_LABEL_FONT_SIZE) 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) # Context text below the bar
return save_path 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( def generate_comparison_bar(
self, data: list[dict], title: str, save_path: str self,
) -> str: categories,
"""Generate a grouped comparison bar chart. 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 if colors is None:
---------- color_left = AI_SPEND
data : list[dict] color_right = GRAY_MEDIUM
Each dict should have 'label' and multiple value keys elif len(colors) >= 2:
(e.g. 'value_a', 'value_b'). color_left = colors[0]
title : str color_right = colors[1]
Chart title. else:
save_path : str color_left = colors[0] if len(colors) == 1 else AI_SPEND
Output file path. color_right = GRAY_MEDIUM
Returns x = list(range(len(categories)))
-------
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))
width = 0.35 width = 0.35
for i, key in enumerate(keys): bars_left = ax.bar(
values = [d.get(key, 0) for d in data] [p - width / 2 for p in x],
color = AI_SPEND if i == 0 else REVENUE values_left,
ax.bar(x + i * width, values, width, label=key, color=color) 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)
ax.set_xticks(x + width / 2) ax.set_xticklabels(categories, fontsize=MIN_LABEL_FONT_SIZE)
ax.set_xticklabels(labels, fontsize=MIN_LABEL_FONT_SIZE)
ax.tick_params(axis="y", labelsize=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: # Standalone convenience functions (for use by card workers)
"""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") def create_cape_chart(years, values, save_path):
plt.close(fig) """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: 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") base_dir = Path("output/battlecards/charts")
engine = MiniChartEngine() engine = MiniChartEngine()
base_dir.mkdir(parents=True, exist_ok=True)
# Test 1: Line trend # Test 1: Line trend — CAPE-like trend with 2026 highlighted
line_data = [ years = [2018, 2019, 2020, 2021, 2022, 2023, 2024, 2025, 2026]
{"x": f"Q{i}", "y": 10 + i * 3} for i in range(1, 5) 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( path1 = engine.generate_line_trend(
line_data, years=years,
"AI Spending Trend (Test)", values=cape_values,
str(base_dir / "test_line_trend.png"), 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 # Test 2: Horizontal bar — Hyperscaler capex comparison
bar_data = [ companies = ["Microsoft", "Amazon", "Google", "Meta"]
{"label": "Cloud A", "value": 85}, capex_values = [234, 118, 90, 72]
{"label": "Cloud B", "value": 62}, capex_colors = ["#00a4ef", "#ff9900", "#4285f4", "#1877f2"]
{"label": "On-prem", "value": 28},
]
path2 = engine.generate_horizontal_bar( path2 = engine.generate_horizontal_bar(
bar_data, categories=companies,
"AI Infrastructure Spending (Test)", values=capex_values,
str(base_dir / "test_horizontal_bar.png"), 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 # Test 3: Utilization bar — GPU utilization gauge at 5%
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},
]
path3 = engine.generate_utilization_bar( path3 = engine.generate_utilization_bar(
util_data, label="Enterprise Average",
"GPU Utilization (Test)", percentage=5.0,
str(base_dir / "test_utilization_bar.png"), 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 # Test 4: Comparison bar — AI vs non-AI vulnerability rates
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},
]
path4 = engine.generate_comparison_bar( path4 = engine.generate_comparison_bar(
comp_data, categories=["Vulnerability Rate (%)"],
"Spend vs Revenue Comparison (Test)", values_left=[47],
str(base_dir / "test_comparison_bar.png"), 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__": if __name__ == "__main__":
@@ -277,7 +407,7 @@ if __name__ == "__main__":
parser.add_argument( parser.add_argument(
"--test", "--test",
action="store_true", action="store_true",
help="Generate 4 placeholder test charts", help="Generate 4 test charts demonstrating each chart type",
) )
args = parser.parse_args() args = parser.parse_args()