added nicegui plotting

This commit is contained in:
2024-06-06 19:21:24 +02:00
parent 409ddd22e8
commit 653eeafd2c
3 changed files with 200 additions and 33 deletions
+14 -1
View File
@@ -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.
````
+72 -32
View File
@@ -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()
+114
View File
@@ -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()