added nicegui plotting
This commit is contained in:
@@ -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
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user