Skip to content

Commit 044e974

Browse files
authored
fix: faster (threaded) plotting (#207)
* fix: faster (threaded) plotting Signed-off-by: Henry Schreiner <[email protected]> * fix: faster (threaded) plotting Signed-off-by: Henry Schreiner <[email protected]> * fix: better plotting Signed-off-by: Henry Schreiner <[email protected]> --------- Signed-off-by: Henry Schreiner <[email protected]>
1 parent 275830e commit 044e974

File tree

5 files changed

+72
-21
lines changed

5 files changed

+72
-21
lines changed

src/uproot_browser/plot.py

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
import numpy as np
1414
import plotext as plt
1515
import uproot
16+
import uproot.behaviors.TH1
17+
import uproot.models.RNTuple
1618

1719
from uproot_browser.exceptions import EmptyTreeError
1820

@@ -42,7 +44,7 @@ def make_hist_title(item: Any, histogram: hist.Hist) -> str:
4244

4345

4446
@functools.singledispatch
45-
def plot(tree: Any, *, expr: str = "") -> None: # noqa: ARG001
47+
def plot(tree: Any, *, width: int = 100, expr: str = "") -> None: # noqa: ARG001
4648
"""
4749
Implement this for each type of plottable.
4850
"""
@@ -53,7 +55,10 @@ def plot(tree: Any, *, expr: str = "") -> None: # noqa: ARG001
5355
# Simpler in Python 3.11+
5456
@plot.register(uproot.TBranch)
5557
def plot_branch(
56-
tree: uproot.TBranch | uproot.models.RNTuple.RField, *, expr: str = ""
58+
tree: uproot.TBranch | uproot.models.RNTuple.RField,
59+
*,
60+
width: int = 100,
61+
expr: str = "",
5762
) -> None:
5863
"""
5964
Plot a single tree branch.
@@ -64,14 +69,13 @@ def plot_branch(
6469
if len(finite) < 1:
6570
msg = f"Branch {tree.name} is empty."
6671
raise EmptyTreeError(msg)
67-
histogram: hist.Hist = hist.numpy.histogram(finite, bins=100, histogram=hist.Hist)
72+
histogram: hist.Hist = hist.numpy.histogram(finite, bins=width, histogram=hist.Hist)
6873
if expr:
6974
# pylint: disable-next=eval-used
7075
histogram = eval(expr, {"h": histogram})
7176
plt.bar(
72-
histogram.axes[0].centers,
77+
histogram.axes[0].edges,
7378
histogram.values().astype(float),
74-
width=histogram.axes[0].widths,
7579
)
7680
plt.ylim(lower=0)
7781
plt.xticks(np.linspace(histogram.axes[0][0][0], histogram.axes[0][-1][-1], 5))
@@ -83,15 +87,19 @@ def plot_branch(
8387

8488

8589
@plot.register
86-
def plot_hist(tree: uproot.behaviors.TH1.Histogram, expr: str = "") -> None:
90+
def plot_hist(
91+
tree: uproot.behaviors.TH1.Histogram,
92+
width: int = 100, # noqa: ARG001
93+
expr: str = "",
94+
) -> None:
8795
"""
8896
Plot a 1-D Histogram.
8997
"""
9098
histogram = hist.Hist(tree.to_hist())
9199
if expr:
92100
# pylint: disable-next=eval-used
93101
histogram = eval(expr, {"h": histogram})
94-
plt.bar(histogram.axes[0].centers, histogram.values().astype(float))
102+
plt.bar(histogram.axes[0].edges, histogram.values().astype(float))
95103
plt.ylim(lower=0)
96104
plt.xticks(np.linspace(histogram.axes[0][0][0], histogram.axes[0][-1][-1], 5))
97105
plt.xlabel(histogram.axes[0].name)

src/uproot_browser/tui/browser.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import textual.containers
1414
import textual.events
1515
import textual.widgets
16+
import textual.worker
1617
from textual.reactive import var
1718

1819
with contextlib.suppress(AttributeError):
@@ -36,7 +37,7 @@
3637
from .header import Header
3738
from .help import HelpScreen
3839
from .left_panel import UprootTree
39-
from .messages import ErrorMessage, UprootSelected
40+
from .messages import ErrorMessage, RequestPlot, UprootSelected
4041
from .plot import Plotext
4142
from .tools import Info, Tools
4243
from .viewer import ViewWidget
@@ -140,6 +141,16 @@ def on_empty_message(self) -> None:
140141
def on_error_message(self, message: ErrorMessage) -> None:
141142
self.view_widget.item = message.err
142143

144+
def on_request_plot(self, message: RequestPlot) -> None:
145+
self.render_plot(message.plot)
146+
147+
@textual.work(exclusive=True, thread=True)
148+
def render_plot(self, plot: Plotext) -> None:
149+
worker = textual.worker.get_current_worker()
150+
new_plot = plot.make_plot()
151+
if new_plot and not worker.is_cancelled:
152+
self.call_from_thread(self.view_widget.plot_widget.update, new_plot)
153+
143154

144155
if __name__ in {"<run_path>", "__main__"}:
145156
fname = "../scikit-hep-testdata/src/skhep_testdata/data/uproot-Event.root"

src/uproot_browser/tui/messages.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
if TYPE_CHECKING:
99
from .error import Error
10+
from .plot import Plotext
1011

1112

1213
@rich.repr.auto
@@ -27,3 +28,10 @@ class ErrorMessage(textual.message.Message, bubble=True):
2728
def __init__(self, err: Error) -> None:
2829
self.err = err
2930
super().__init__()
31+
32+
33+
@rich.repr.auto
34+
class RequestPlot(textual.message.Message, bubble=True):
35+
def __init__(self, plot: Plotext) -> None:
36+
self.plot = plot
37+
super().__init__()

src/uproot_browser/tui/plot.py

Lines changed: 34 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,12 @@
77

88
import plotext as plt # plots in text
99
import rich.text
10-
import textual.widgets
1110

1211
import uproot_browser.plot
1312
from uproot_browser.exceptions import EmptyTreeError
1413

1514
from .error import Error
16-
from .messages import EmptyMessage, ErrorMessage
15+
from .messages import EmptyMessage, ErrorMessage, RequestPlot
1716

1817
if TYPE_CHECKING:
1918
from .browser import Browser
@@ -32,7 +31,7 @@ def make_plot(item: Any, theme: str, *size: int, expr: str) -> Any:
3231
plt.clf()
3332
plt.theme(theme)
3433
plt.plotsize(*size)
35-
uproot_browser.plot.plot(item, expr=expr)
34+
uproot_browser.plot.plot(item, width=(size[0] - 5) * 4, expr=expr)
3635
return plt.build()
3736

3837

@@ -44,6 +43,24 @@ class Plotext:
4443
theme: str
4544
app: Browser
4645
expr: str = ""
46+
size: tuple[int, int] | None = None
47+
previous: rich.text.Text | None = None
48+
old_expr: str = ""
49+
50+
def make_plot(self) -> Plotext | None:
51+
*_, item = apply_selection(self.upfile, self.selection.split(":"))
52+
assert self.size
53+
try:
54+
canvas = make_plot(item, self.theme, *self.size, expr=self.expr)
55+
return dataclasses.replace(self, previous=rich.text.Text.from_ansi(canvas))
56+
except EmptyTreeError:
57+
self.app.post_message(EmptyMessage())
58+
return None
59+
except Exception:
60+
exc = sys.exc_info()
61+
assert exc[1]
62+
self.app.post_message(ErrorMessage(Error(exc)))
63+
return None
4764

4865
def __rich_console__(
4966
self, console: rich.console.Console, options: rich.console.ConsoleOptions
@@ -59,13 +76,17 @@ def __rich_console__(
5976
width = options.max_width or console.width
6077
height = options.height or console.height
6178

62-
try:
63-
canvas = make_plot(item, self.theme, width, height, expr=self.expr)
64-
yield rich.text.Text.from_ansi(canvas)
65-
except EmptyTreeError:
66-
self.app.post_message(EmptyMessage())
67-
except Exception:
68-
self.app.query_one("#plot-input", textual.widgets.Input).value = ""
69-
exc = sys.exc_info()
70-
assert exc[1]
71-
self.app.post_message(ErrorMessage(Error(exc)))
79+
if (
80+
self.size
81+
and (width, height) == self.size
82+
and self.previous is not None
83+
and self.old_expr == self.expr
84+
):
85+
yield self.previous
86+
87+
else:
88+
self.size = (width, height)
89+
self.previous = rich.text.Text("... plotting ...")
90+
self.old_expr = self.expr
91+
yield self.previous
92+
self.app.post_message(RequestPlot(self))

tests/test_tui.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ async def test_browse_plot() -> None:
1616
skhep_testdata.data_path("uproot-Event.root")
1717
).run_test() as pilot:
1818
await pilot.press("down", "down", "down", "enter")
19+
await pilot.pause()
1920
assert isinstance(pilot.app.view_widget.item, Plotext)
2021

2122

@@ -24,6 +25,7 @@ async def test_browse_empty() -> None:
2425
skhep_testdata.data_path("uproot-empty.root")
2526
).run_test() as pilot:
2627
await pilot.press("down", "space", "down", "enter")
28+
await pilot.pause()
2729
assert pilot.app.view_widget.item is None
2830

2931

@@ -32,6 +34,7 @@ async def test_browse_empty_vim() -> None:
3234
skhep_testdata.data_path("uproot-empty.root")
3335
).run_test() as pilot:
3436
await pilot.press("j", "l", "j", "enter")
37+
await pilot.pause()
3538
assert pilot.app.view_widget.item is None
3639

3740

0 commit comments

Comments
 (0)