"""Reusable cost visualisation components using Plotly.""" from __future__ import annotations from typing import Dict, List, Optional import pandas as pd import plotly.express as px import plotly.graph_objects as go def cost_bar_chart( costs: List[float], labels: List[str], title: str = "Cost per Run", ) -> go.Figure: if not labels or not costs or len(labels) != len(costs): labels = ["No Data Available"] costs = [0.0] df = pd.DataFrame({"label": labels, "cost_usd": costs}) fig = px.bar( df, x="label", y="cost_usd", title=title, color="cost_usd", color_continuous_scale="Blues", labels={"cost_usd": "Cost (USD)", "label": ""}, ) fig.update_layout( xaxis_tickangle=-30, height=350, margin=dict(l=40, r=20, t=40, b=60), ) return fig def cost_pie_chart( breakdown: Dict[str, dict], title: str = "Cost by Model", ) -> go.Figure: if not breakdown: fig = go.Figure() fig.add_annotation(text="No data", showarrow=False) return fig models = list(breakdown.keys()) costs = [breakdown[m]["cost"] for m in models] calls = [breakdown[m]["calls"] for m in models] df = pd.DataFrame( { "model": models, "cost_usd": costs, "calls": calls, } ) fig = px.pie( df, names="model", values="cost_usd", title=title, hover_data={"calls": True}, ) fig.update_traces(textposition="inside", textinfo="label+percent") fig.update_layout(height=350, margin=dict(l=20, r=20, t=40, b=20)) return fig def budget_gauge( current_cost: float, budget: float, title: str = "Budget Utilisation", ) -> go.Figure: if budget <= 0: fig = go.Figure() fig.add_annotation(text="Budget not configured", showarrow=False) fig.update_layout(height=280, margin=dict(l=30, r=30, t=30, b=30)) return fig pct = min(current_cost / budget * 100, 100) fig = go.Figure( go.Indicator( mode="gauge+number+delta", value=current_cost, number={"suffix": " USD", "font": {"size": 24}}, delta={"reference": budget, "valueformat": ".2f"}, title={"text": title, "font": {"size": 16}}, gauge={ "axis": {"range": [0, budget], "tickprefix": "$"}, "bar": { "color": ( "#22c55e" if pct < 80 else "#eab308" if pct < 100 else "#ef4444" ) }, "steps": [ {"range": [0, budget * 0.8], "color": "#f0fdf4"}, {"range": [budget * 0.8, budget], "color": "#fef9c3"}, ], "threshold": { "line": {"color": "#ef4444", "width": 4}, "thickness": 0.75, "value": budget, }, }, ) ) fig.update_layout(height=280, margin=dict(l=30, r=30, t=30, b=30)) return fig def model_usage_stacked_bar( records_list: List[Dict], title: str = "Cost by Model per Run", ) -> go.Figure: if not records_list: fig = go.Figure() fig.add_annotation(text="No data", showarrow=False) return fig df = pd.DataFrame(records_list) model_cols = [c for c in df.columns if c.startswith("model_cost_")] if not model_cols: costs = df.get("total_cost_usd") labels = df.get("case_id") costs_list = costs.tolist() if costs is not None and not costs.empty else [] labels_list = labels.tolist() if labels is not None and not labels.empty else [] if len(costs_list) != len(labels_list): costs_list = [0.0] labels_list = ["No Data Available"] return cost_bar_chart( costs_list, labels_list, title, ) fig = px.bar( df, x="case_id", y=model_cols, title=title, labels={"value": "Cost (USD)", "case_id": "", "variable": "Model"}, ) fig.update_layout( barmode="stack", xaxis_tickangle=-30, height=350, margin=dict(l=40, r=20, t=40, b=60), ) return fig