11# charts.py
22from __future__ import annotations
33
4- from typing import Any
5-
6- import matplotlib
7-
8- matplotlib .use ("Agg" ) # safe for headless environments
94import datetime as _dt
10-
11- import matplotlib .pyplot as plt
12- import numpy as np
13- from scipy .stats import gaussian_kde # type: ignore
5+ from typing import Any
146
157# Layout spec for compact charts (fractions of figure size)
168_LAYOUT = dict (
2214)
2315
2416
17+ def _optional_dependency_error (package : str , * , utility : str ) -> ImportError :
18+ return ImportError (
19+ f"{ utility } requires the optional dependency '{ package } '. "
20+ f"Install it to use this observability chart utility "
21+ f"(for example: pip install { package } )."
22+ )
23+
24+
25+ def _require_pyplot ():
26+ try :
27+ import matplotlib
28+
29+ matplotlib .use ("Agg" ) # safe for headless environments
30+ import matplotlib .pyplot as plt
31+ except ImportError as exc :
32+ raise _optional_dependency_error (
33+ "matplotlib" , utility = "Metrics chart generation"
34+ ) from exc
35+ return plt
36+
37+
38+ def _require_numpy ():
39+ try :
40+ import numpy as np
41+ except ImportError as exc :
42+ raise _optional_dependency_error (
43+ "numpy" , utility = "Metrics chart generation"
44+ ) from exc
45+ return np
46+
47+
48+ def _optional_gaussian_kde ():
49+ try :
50+ from scipy .stats import gaussian_kde # type: ignore
51+ except ImportError :
52+ return None
53+ return gaussian_kde
54+
55+
2556def compute_llm_wall_seconds (payload : dict ) -> float :
2657 evs = payload .get ("llm_events" ) or []
2758 intervals = []
@@ -193,6 +224,7 @@ def plot_time_breakdown(
193224 min_label_pct : float = 1.0 ,
194225 context : dict [str , Any ] | None = None , # NEW
195226) -> str :
227+ plt = _require_pyplot ()
196228 labels = [k for k , _ in parts ]
197229 values = [v for _ , v in parts ]
198230 overall = sum (values ) or 1.0
@@ -251,7 +283,7 @@ def plot_time_breakdown(
251283 fig .text (
252284 0.5 ,
253285 0.06 ,
254- f"{ started } \u2192 { ended } " ,
286+ f"{ started } → { ended } " ,
255287 ha = "center" ,
256288 va = "center" ,
257289 fontsize = "x-small" ,
@@ -304,7 +336,7 @@ def _fmt(pct):
304336 fig .text (
305337 0.5 ,
306338 0.06 ,
307- f"{ started } \u2192 { ended } " ,
339+ f"{ started } → { ended } " ,
308340 ha = "center" ,
309341 va = "center" ,
310342 fontsize = "x-small" ,
@@ -328,6 +360,7 @@ def plot_lollipop_time(
328360 exclude_zero : bool = True ,
329361 context : dict [str , Any ] | None = None , # NEW
330362) -> str :
363+ plt = _require_pyplot ()
331364 data = [(k , v ) for (k , v ) in parts if (v > 0 if exclude_zero else True )]
332365 data .sort (key = lambda kv : kv [1 ])
333366 labels = [k for k , _ in data ]
@@ -418,7 +451,7 @@ def plot_lollipop_time(
418451 fig .text (
419452 0.5 ,
420453 _LAYOUT ["footer2_y" ],
421- f"{ started } \u2192 { ended } " ,
454+ f"{ started } → { ended } " ,
422455 ha = "center" ,
423456 va = "center" ,
424457 fontsize = "x-small" ,
@@ -498,6 +531,7 @@ def plot_token_totals_bar(
498531 """
499532 Horizontal bar chart of token totals by category.
500533 """
534+ plt = _require_pyplot ()
501535
502536 order = [
503537 "input_tokens" ,
@@ -563,7 +597,7 @@ def plot_token_totals_bar(
563597 fig .text (
564598 0.5 ,
565599 _LAYOUT ["footer2_y" ],
566- f"{ started } \u2192 { ended } " ,
600+ f"{ started } → { ended } " ,
567601 ha = "center" ,
568602 va = "center" ,
569603 fontsize = "x-small" ,
@@ -587,9 +621,12 @@ def plot_token_kde(
587621) -> str :
588622 """
589623 Overlay KDEs for input/output/reasoning/cached/total tokens.
590- - Uses scipy.stats.gaussian_kde if available; falls back to a Gaussian-smoothed histogram .
624+ - Uses scipy.stats.gaussian_kde if available; falls back to a simple NumPy Gaussian KDE .
591625 - No seaborn.
592626 """
627+ plt = _require_pyplot ()
628+ np = _require_numpy ()
629+ gaussian_kde = _optional_gaussian_kde ()
593630
594631 # categories & pretty labels (skip empty series automatically)
595632 order = [
@@ -633,10 +670,22 @@ def plot_token_kde(
633670 x_max = max (1.0 , all_max * 1.05 )
634671 x = np .linspace (x_min , x_max , 600 )
635672
636- # Try scipy KDE; else fallback to simple Gaussian smoothing of hist density
637- def _kde (arr : np .ndarray ) -> np .ndarray :
638- kde = gaussian_kde (arr , bw_method = bandwidth )
639- return kde .evaluate (x )
673+ # Try scipy KDE; else fallback to simple NumPy Gaussian KDE.
674+ def _kde (arr ):
675+ if gaussian_kde is not None :
676+ kde = gaussian_kde (arr , bw_method = bandwidth )
677+ return kde .evaluate (x )
678+ std = float (np .std (arr )) or max (1.0 , x_max - x_min ) / 50.0
679+ bw = (
680+ float (bandwidth )
681+ if bandwidth
682+ else 1.06 * std * (arr .size ** (- 1 / 5 ))
683+ )
684+ bw = max (bw , 1e-9 )
685+ diffs = (x [:, None ] - arr [None , :]) / bw
686+ return np .exp (- 0.5 * diffs * diffs ).sum (axis = 1 ) / (
687+ arr .size * bw * np .sqrt (2 * np .pi )
688+ )
640689
641690 # Plot
642691 fig = plt .figure (figsize = (8 , 2.8 ))
@@ -687,7 +736,7 @@ def _kde(arr: np.ndarray) -> np.ndarray:
687736 fig .text (
688737 0.5 ,
689738 _LAYOUT ["footer2_y" ],
690- f"{ started } \u2192 { ended } " ,
739+ f"{ started } → { ended } " ,
691740 ha = "center" ,
692741 va = "center" ,
693742 fontsize = "x-small" ,
@@ -708,8 +757,8 @@ def plot_token_rates_bar(
708757 context : dict | None = None ,
709758 categories : list [str ] | None = None ,
710759) -> str :
711- import matplotlib . pyplot as plt
712- import numpy as np
760+ plt = _require_pyplot ()
761+ np = _require_numpy ()
713762
714763 cats = categories or [
715764 "input_tokens" ,
@@ -753,7 +802,7 @@ def _rate(x: int, denom: float) -> float:
753802 s
754803 for s in [
755804 f"run_id: { run_id_short } " if run_id_short else "" ,
756- f"{ started } \u2192 { ended } " if (started or ended ) else "" ,
805+ f"{ started } → { ended } " if (started or ended ) else "" ,
757806 ]
758807 if s
759808 )
@@ -855,7 +904,7 @@ def plot_tokens_bar_by_model(
855904 title : str = "LLM Token Totals by Category — by model" ,
856905 context : dict | None = None ,
857906) -> str :
858- import matplotlib . pyplot as plt
907+ plt = _require_pyplot ()
859908
860909 cats = [
861910 "input_tokens" ,
@@ -926,8 +975,8 @@ def plot_token_rates_by_model(
926975 title : str = "Tokens per second — by model" ,
927976 context : dict | None = None ,
928977) -> str :
929- import matplotlib . pyplot as plt
930- import numpy as np
978+ plt = _require_pyplot ()
979+ np = _require_numpy ()
931980
932981 cats = [
933982 "input_tokens" ,
@@ -1051,8 +1100,8 @@ def plot_tokens_by_agent_stacked(
10511100 title : str = "LLM Token Totals by Agent (thread)" ,
10521101 footer_lines : list [str ] | None = None ,
10531102) -> str :
1054- import matplotlib . pyplot as plt
1055- import numpy as np
1103+ plt = _require_pyplot ()
1104+ np = _require_numpy ()
10561105
10571106 cats = [
10581107 "input_tokens" ,
@@ -1165,8 +1214,8 @@ def plot_tps_by_agent_grouped(
11651214 title : str = "Tokens per second by Agent (thread)" ,
11661215 footer_lines : list [str ] | None = None ,
11671216) -> str :
1168- import matplotlib . pyplot as plt
1169- import numpy as np
1217+ plt = _require_pyplot ()
1218+ np = _require_numpy ()
11701219
11711220 cats = [
11721221 "input_tokens" ,
0 commit comments