diff --git a/output/battlecards/charts/test_comparison_bar.png b/output/battlecards/charts/test_comparison_bar.png index 15b4ac5..ea02d60 100644 Binary files a/output/battlecards/charts/test_comparison_bar.png and b/output/battlecards/charts/test_comparison_bar.png differ diff --git a/output/battlecards/charts/test_horizontal_bar.png b/output/battlecards/charts/test_horizontal_bar.png index 9bcd002..a3db386 100644 Binary files a/output/battlecards/charts/test_horizontal_bar.png and b/output/battlecards/charts/test_horizontal_bar.png differ diff --git a/output/battlecards/charts/test_line_trend.png b/output/battlecards/charts/test_line_trend.png index a75b5f2..a5ceb31 100644 Binary files a/output/battlecards/charts/test_line_trend.png and b/output/battlecards/charts/test_line_trend.png differ diff --git a/output/battlecards/charts/test_utilization_bar.png b/output/battlecards/charts/test_utilization_bar.png index 9da06d4..bceb3d8 100644 Binary files a/output/battlecards/charts/test_utilization_bar.png and b/output/battlecards/charts/test_utilization_bar.png differ diff --git a/src/battlecards/mini_charts.py b/src/battlecards/mini_charts.py index 9d3600c..8aabdb6 100644 --- a/src/battlecards/mini_charts.py +++ b/src/battlecards/mini_charts.py @@ -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()