diff --git a/README.md b/README.md index ba59542..e0d3982 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,17 @@ # plot_channels -Basic plotter for EPICS or BS-based channels with output to either the terminal console or a web interface. +Basic plotter for EPICS or BS-based channels with output to either the terminal console or a web interface based on NiceGUI and Plotly. +```` +Usage: plot_channels.py [OPTIONS] [CHANNEL_NAMES]... + + Plot channels from BS (beam synchronous) or PV (EPICS) sources. + +Options: + --acquire-interval FLOAT Interval in seconds for data collection. + --plot-interval INTEGER Interval in seconds for plot update. + --save-prefix TEXT Prefix for the save filenames. + --in-subplots BOOLEAN Whether to plot in subplots. + --web Whether to use the local web interface as a GUI. + --help Show this message and exit. +```` \ No newline at end of file diff --git a/plot_channels.py b/plot_channels.py index ae4b844..7b78cbd 100644 --- a/plot_channels.py +++ b/plot_channels.py @@ -13,9 +13,16 @@ import numpy as np from bsread import dispatcher, source -import plotext as plt import epics +try: + from nicegui import ui, app, context, run +except ImportError as e: + print("nicegui not available, falling back to console plot.") + print(e) + +from plots import ConsolePlot, PlotlyPlot + def clean_name(fname): """Returns the filename with all special characters but / . _ - # @@ -156,6 +163,18 @@ def yield_pv_data(channel_names: list[str]): yield channel_data +def yield_test_data(channel_names: list[str]): + """Yields test data from the given channels with timestamp.""" + import random + import time + + while True: + channel_data = { + channel: {"value": random.random(), "timestamp": time.time()} for channel in channel_names + } + yield channel_data + + def roundrobin(*iterables): "Visit input iterables in a cycle until each is exhausted." # roundrobin('ABC', 'D', 'EF') → A D E B F C @@ -174,7 +193,10 @@ def yield_data(channel_names: list, only_pv=False): """ bs_channels = dispatcher.get_current_channels() - # separate into BS and PV channels + # separate into BS, PV and test channels + test_list = [ch for ch in channel_names if ch.startswith("TEST_CHANNEL")] + for test_ch in test_list: + channel_names.remove(test_ch) if only_pv: pv_list = channel_names @@ -183,7 +205,9 @@ def yield_data(channel_names: list, only_pv=False): bs_list = [ch for ch in channel_names if ch in bs_channels] pv_list = [ch for ch in channel_names if ch not in bs_list] - for channel_data in roundrobin(yield_bs_data(bs_list), yield_pv_data(pv_list)): + for channel_data in roundrobin( + yield_bs_data(bs_list), yield_pv_data(pv_list), yield_test_data(test_list) + ): yield channel_data @@ -203,34 +227,16 @@ def collect_data(channels: dict): yield channels -def plot(channels, start_time=0, in_subplots=False, marker="hd"): - """Plots channels on the console.""" - - # plt.clf() - plt.limit_size(False, False) - plt.theme("pro") - - plt.xlabel("Timestamp") - plt.ylabel("Value") - - num_channels = len(channels) - - if in_subplots: - plt.subplots(num_channels, 1) - plt.main().plot_size(plt.tw(), plt.th() - 1) - plt.clear_data() - - for i, channel in enumerate(channels.values()): - if in_subplots: - plt.subplot(i + 1, 1) - - plt.plot(channel.x - start_time, channel.y, label=channel.name, marker=marker) - - plt.show() async def runner( - channels: dict, acquire_interval=0.1, plot_interval=1, in_subplots=False, abort=False, relative_time=True + channels: dict, + plot_interface, + acquire_interval=0.1, + plot_interval=1, + in_subplots=False, + abort=False, + relative_time=True, ): """ Loop for data collection and plotting. @@ -248,6 +254,8 @@ async def runner( last_collect_time = time.time() last_plot_time = time.time() + plot = plot_interface + while not abort: now = time.time() elapsed_collect = now - last_collect_time @@ -258,7 +266,7 @@ async def runner( last_collect_time = time.time() if elapsed_plot > plot_interval: # default: 1 Hz plot updates and save interval - plot(channels, in_subplots=in_subplots, start_time=start_time) + plot.update_plot(channels, in_subplots=in_subplots, start_time=start_time) for channel in channels.values(): channel.trigger_save() @@ -276,18 +284,50 @@ async def runner( @click.option("--plot-interval", default=1, help="Interval in seconds for plot update.") @click.option("--save-prefix", default=None, help="Prefix for the save filenames.") @click.option("--in-subplots", default=False, type=bool, help="Whether to plot in subplots.") +@click.option( + "--web", is_flag=True, default=False, type=bool, help="Whether to use the local web interface as a GUI." +) def main( channel_names: Tuple[str, ...], acquire_interval=0.1, plot_interval=1, save_prefix: Optional[str] = None, in_subplots=False, + web=False, ): - """ Plot channels from BS (beam synchronous) or PV (EPICS) sources. """ + """Plot channels from BS (beam synchronous) or PV (EPICS) sources.""" channels = {channel: ChannelData(channel, save_file_prefix=save_prefix) for channel in channel_names} - asyncio.run(runner(channels, acquire_interval=float(acquire_interval), plot_interval=plot_interval, in_subplots=in_subplots)) + + start_time = time.time() + + if not web: # console plot version + plot = ConsolePlot(channels, start_time=start_time, in_subplots=in_subplots) + asyncio.run( + runner( + channels, + plot, + acquire_interval=float(acquire_interval), + plot_interval=plot_interval, + in_subplots=in_subplots, + ) + ) + + else: # nicegui version + plot = PlotlyPlot(channels, start_time=start_time, in_subplots=in_subplots) + + async def run_async(): + await runner( + channels, + plot, + acquire_interval=float(acquire_interval), + plot_interval=plot_interval, + in_subplots=in_subplots, + ) + + app.on_startup(run_async) + + ui.run(reload=False, port=8004) - if __name__ == "__main__": main() diff --git a/plots.py b/plots.py new file mode 100644 index 0000000..3ea7d43 --- /dev/null +++ b/plots.py @@ -0,0 +1,114 @@ +from abc import ABC, abstractmethod +import plotext as plt + +import plotly.express as px +from plotly.subplots import make_subplots + +import plotly.graph_objects as go +from nicegui import ui, app, context + + +class AbstractPlot(ABC): + + @abstractmethod + def setup_plot(self): + pass + + @abstractmethod + def update_plot(self, channels, start_time=0, in_subplots=False, marker="hd"): + pass + + +class PlotlyPlot(AbstractPlot): + def __init__(self, channels, start_time=0, in_subplots=False, marker="hd"): + self.channels = channels + self.start_time = start_time + self.in_subplots = in_subplots + self.marker = marker + + self.plot, self.fig = self.setup_plot() + + def setup_plot(self): + + num_traces = len(self.channels) + labels = [channel.name for channel in self.channels.values()] + + context.client.content.classes("h-[100vh]") # 1 + + colors = px.colors.qualitative.D3 + fig = make_subplots(rows=num_traces, cols=1, shared_xaxes=False, vertical_spacing=0.05) + + subfigures = [ + px.line( + markers=True, + ) + for _ in range(num_traces) + ] + for i, subfig in enumerate(subfigures): + + trace = subfig["data"][0] + + subfig["data"][0]["showlegend"] = True + subfig["data"][0]["name"] = f"{labels[i]}" + subfig["data"][0]["line"]["color"] = colors[i % len(colors)] + + fig.add_trace(trace, row=i + 1, col=1) + + fig.update_layout( + autosize=True, + margin=dict(l=10, r=10, b=10, t=10, pad=5), + # paper_bgcolor="LightSteelBlue", + ) + + # set xaxis title for last subplot at the bottom + fig["layout"][f"xaxis{num_traces}"]["title"] = "time (s)" + + # print(fig) + plot = ui.plotly(fig).classes("w-full h-full") + + return plot, fig + + def update_plot(self, channels, start_time=0, **kwargs) -> None: + + for i, channel in enumerate(channels.values()): + self.fig["data"][i]["x"] = channel.x + self.fig["data"][i]["y"] = channel.y + + self.plot.update_figure(self.fig) + + +class ConsolePlot(AbstractPlot): + def __init__(self, channels, start_time=0, in_subplots=False, marker="hd"): + self.channels = channels + self.start_time = start_time + self.in_subplots = in_subplots + self.marker = marker + + self.setup_plot() + + def setup_plot(self): + """Set up the plot.""" + plt.clf() + plt.limit_size(False, False) + plt.theme("pro") + + plt.xlabel("Timestamp") + plt.ylabel("Value") + + def update_plot(self, channels, start_time=0, in_subplots=False, marker="hd"): + """Plots channels on the console.""" + + num_channels = len(channels) + + if in_subplots: + plt.subplots(num_channels, 1) + plt.main().plot_size(plt.tw(), plt.th() - 1) + plt.clear_data() + + for i, channel in enumerate(channels.values()): + if in_subplots: + plt.subplot(i + 1, 1) + + plt.plot(channel.x - start_time, channel.y, label=channel.name, marker=marker) + + plt.show()