Skip to content

Commit fdaaddd

Browse files
committed
Cleaning up a few commits. Deals with #260 in the right way.
1 parent 2dbed2c commit fdaaddd

7 files changed

Lines changed: 199 additions & 78 deletions

File tree

pyproject.toml

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,9 @@ dependencies = [
2323
"arxiv>=2.2.0,<3.0",
2424
"beautifulsoup4>=4.13.4,<5.0",
2525
"randomname>=0.2.1,<0.3",
26-
"pandas>=2.3.1,<3.0",
2726
"pillow>=11.3.0,<12.0",
28-
"pymupdf>=1.26.0,<2.0",
27+
"pymupdf>=1.28.0,<2.0",
28+
"pymupdf4llm>=0.3.4,<1.0",
2929
"rich>=13.9.4,<14.0",
3030
"ddgs>=9.5.5",
3131
"typer>=0.16.1",
@@ -48,8 +48,6 @@ dependencies = [
4848
"fastmcp~=3.0",
4949
"pyyaml>=6.0.3",
5050
"justext>=3.0.2",
51-
"matplotlib>=3.10.6",
52-
"scipy>=1.16.2,<2.0.0",
5351
]
5452
classifiers = [
5553
"Operating System :: OS Independent",
@@ -101,6 +99,11 @@ image = [
10199
mp = [
102100
"mp-api>=0.45.8,<0.45.13",
103101
]
102+
metric_plots = [
103+
"matplotlib>=3.10.6",
104+
"pandas>=2.3.1,<3.0",
105+
"scipy>=1.16.2,<2.0.0",
106+
]
104107

105108
[build-system]
106109
requires = ["setuptools>=74.1,<80", "setuptools-git-versioning>=2.0,<3"]

src/ursa/observability/metrics_charts.py

Lines changed: 78 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,8 @@
11
# charts.py
22
from __future__ import annotations
33

4-
from typing import Any
5-
6-
import matplotlib
7-
8-
matplotlib.use("Agg") # safe for headless environments
94
import 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(
@@ -22,6 +14,45 @@
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+
2556
def 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",

src/ursa/observability/metrics_session.py

Lines changed: 49 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,13 @@
44
import json
55
import os
66
import re
7+
from collections import defaultdict
78
from dataclasses import dataclass
89
from datetime import datetime, timezone
10+
from typing import TYPE_CHECKING
911

10-
import matplotlib
11-
12-
matplotlib.use("Agg")
13-
from collections import defaultdict
14-
15-
import matplotlib.dates as mdates
16-
import matplotlib.pyplot as plt
17-
import pandas as pd
12+
if TYPE_CHECKING:
13+
import pandas as pd
1814

1915
from ursa.observability.metrics_charts import (
2016
compute_attribution,
@@ -24,6 +20,48 @@
2420
from ursa.observability.metrics_io import load_metrics
2521

2622

23+
def _optional_dependency_error(package: str, *, utility: str) -> ImportError:
24+
return ImportError(
25+
f"{utility} requires the optional dependency '{package}'. "
26+
f"Install it to use this observability metrics utility "
27+
f"(for example: pip install {package})."
28+
)
29+
30+
31+
def _require_matplotlib():
32+
try:
33+
import matplotlib
34+
35+
matplotlib.use("Agg")
36+
import matplotlib.dates as mdates
37+
import matplotlib.pyplot as plt
38+
except ImportError as exc:
39+
raise _optional_dependency_error(
40+
"matplotlib", utility="Metrics session chart generation"
41+
) from exc
42+
return plt, mdates
43+
44+
45+
def _require_pandas():
46+
try:
47+
import pandas as pd
48+
except ImportError as exc:
49+
raise _optional_dependency_error(
50+
"pandas", utility="Metrics session DataFrame/CSV export"
51+
) from exc
52+
return pd
53+
54+
55+
def _require_plotly_express():
56+
try:
57+
import plotly.express as px
58+
except ImportError as exc:
59+
raise _optional_dependency_error(
60+
"plotly", utility="Interactive metrics session timeline"
61+
) from exc
62+
return px
63+
64+
2765
def _dt(x: str) -> datetime:
2866
return datetime.fromisoformat(str(x).replace("Z", "+00:00"))
2967

@@ -153,6 +191,7 @@ def plot_thread_timeline(
153191
• Exact bounds: xlim is precisely first start → last end
154192
• Legend outside bars: compact legend drawn above the axes
155193
"""
194+
plt, mdates = _require_matplotlib()
156195
if not runs:
157196
raise ValueError("No runs provided for timeline")
158197

@@ -303,6 +342,7 @@ def plot_thread_timeline(
303342

304343

305344
def runs_to_dataframe(runs: list[RunRecord]) -> pd.DataFrame:
345+
pd = _require_pandas()
306346
rows = [
307347
{
308348
"thread_id": r.thread_id,
@@ -326,7 +366,7 @@ def plot_thread_timeline_interactive(
326366
*,
327367
group_by: str = "agent", # "agent" (few lanes) or "run" (one lane per run)
328368
) -> str:
329-
import plotly.express as px
369+
px = _require_plotly_express()
330370

331371
if not runs:
332372
raise ValueError("No runs provided")

0 commit comments

Comments
 (0)