flexPTP-test/gui/ResultsTab.py
András Wiesner 23e5005914 - Parameter configuration added
- Plotting added
- Statistic calculation added
- Save measurement data as XLSX feature added
- Test controller functionality extracted from the GUI class
2026-05-27 20:06:53 +02:00

379 lines
14 KiB
Python

from collections.abc import Callable
import time
import tkinter as tk
from tkinter import ttk
from typing import Callable, Literal
from matplotlib.axes import Axes
from matplotlib.figure import Figure
import matplotlib.pyplot as plt
import numpy
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
from matplotlib.ticker import MaxNLocator
import tkinter.filedialog
import Common
import gui.GuiCommon as gcm
import xlsxwriter
# initialize plot style
#plt.rcParams["font.family"] = ""
plt.rcParams["font.size"] = 8
plt.rcParams["lines.antialiased"] = True
plt.rcParams["lines.linewidth"] = 0.75
plt.rcParams["grid.color"] = "#262626"
plt.rcParams["grid.alpha"] = 0.15
plt.rcParams["grid.linewidth"] = 0.5
plt.rcParams["legend.loc"] = "upper right"
class ResultsInfoPanel(ttk.LabelFrame):
def __init_style(self) -> None:
style = ttk.Style()
style.configure("MeasInfo.TLabel", font=gcm.TERMINAL_FONT)
style.configure("MeasTitle.TLabel", font=("Ubuntu", 10, "bold"))
style.configure("MeasStatPENDING.TFrame", background="Gray")
style.configure("MeasStatIN-PROGRESS.TFrame", background="Gold")
style.configure("MeasStatFAILED.TFrame", background="Salmon")
style.configure("MeasStatERRORED.TFrame", background="firebrick4")
style.configure("MeasStatPASSED.TFrame", background="LawnGreen")
style.configure("MeasStatSKIPPED.TFrame", background="Gray")
style.configure("MeasStatPENDING.TLabel", background="Gray")
style.configure("MeasStatIN-PROGRESS.TLabel", background="Gold")
style.configure("MeasStatFAILED.TLabel", background="Salmon")
style.configure("MeasStatERRORED.TLabel", background="firebrick4")
style.configure("MeasStatPASSED.TLabel", background="LawnGreen")
style.configure("MeasStatSKIPPED.TLabel", background="Gray")
def __init_layout(self) -> None:
self.columnconfigure([0, 1], weight=1)
def __init_widgets(self) -> None:
# Start time
start_label = ttk.Label(self, text="Start:")
start_label.grid(row=0, column=0)
# End time
end_label = ttk.Label(self, text="End:")
end_label.grid(row=1, column=0)
# Duration
duration_label = ttk.Label(self, text="Duration:")
duration_label.grid(row=2, column=0)
# separator
separator1 = ttk.Separator(self)
separator1.grid(row=3, column=0, columnspan=2, sticky=tk.EW)
# Cycles label
cycles_label = ttk.Label(self, text="Cycles:")
cycles_label.grid(row=4, column=0)
# Mean label
mean_label = ttk.Label(self, text="Mean:")
mean_label.grid(row=5, column=0)
# Variance label
variance_label = ttk.Label(self, text="Variance:")
variance_label.grid(row=6, column=0)
# Global maximum label
gmax_label = ttk.Label(self, text="Max. [global]:")
gmax_label.grid(row=7, column=0)
# Global minimum label
gmin_label = ttk.Label(self, text="Min. [global]:")
gmin_label.grid(row=8, column=0)
# Local maximum label
lmax_label = ttk.Label(self, text="Max. [local]:")
lmax_label.grid(row=9, column=0)
# Local minimum label
lmin_label = ttk.Label(self, text="Min. [local]:")
lmin_label.grid(row=10, column=0)
# separator
separator2 = ttk.Separator(self)
separator2.grid(row=11, column=0, columnspan=2, sticky=tk.EW)
# -----------------
# Start time display
self.__start_display = ttk.Label(self, text="-")
self.__start_display.grid(row=0, column=1)
# End time display
self.__end_display = ttk.Label(self, text="-")
self.__end_display.grid(row=1, column=1)
# Duration display
self.__duration_display = ttk.Label(self, text="-")
self.__duration_display.grid(row=2, column=1)
# Cycles display
self.__cycles_display = ttk.Label(self, text="-")
self.__cycles_display.grid(row=4, column=1)
# Mean display
self.__mean_display = ttk.Label(self, text="-")
self.__mean_display.grid(row=5, column=1)
# Variance display
self.__variance_display = ttk.Label(self, text="-")
self.__variance_display.grid(row=6, column=1)
# Global maximum display
self.__gmax_display = ttk.Label(self, text="-")
self.__gmax_display.grid(row=7, column=1)
# Global minimum display
self.__gmin_display = ttk.Label(self, text="-")
self.__gmin_display.grid(row=8, column=1)
# Local maximum display
self.__lmax_display = ttk.Label(self, text="-")
self.__lmax_display.grid(row=9, column=1)
# Local minimum display
self.__lmin_display = ttk.Label(self, text="-")
self.__lmin_display.grid(row=10, column=1)
# ----------------
# apply styling
for widget in self.winfo_children():
match widget:
case ttk.Label():
widget.configure(style="MeasInfo.TLabel")
widget.grid(sticky=tk.E)
# -----------------
self.__status_frame = ttk.Frame(self)
self.__status_frame.grid(row=12, column=0, columnspan=2, pady=8, sticky=tk.EW)
self.__status_display = ttk.Label(self.__status_frame, text="-", font=("Ubuntu", 12, "bold"))
self.__status_display.pack()
# ------------------
self.__save_data_btn = ttk.Button(self, command=self.save_results, text="Save", state="disabled")
self.__save_data_btn.grid(row=13, column=0, columnspan=2, pady=16, sticky=tk.EW + tk.S)
def refresh(self) -> None:
result = self.__test.get("result", "pending").upper()
match result:
case "IN-PROGRESS" | "PASSED" | "FAILED":
cycles = self.__test["data"]["cycles"]
self.__cycles_display["text"] = "{:d}".format(self.__test["data"]["cycles"])
if cycles > 0:
self.__gmax_display["text"] = "{:d} ns".format(self.__test["data"]["max"]["global"])
self.__gmin_display["text"] = "{:d} ns".format(self.__test["data"]["min"]["global"])
if cycles > self.__test["data"]["skip"]:
self.__variance_display["text"] = "{:.0f} ns^2".format(self.__test["data"]["var"][-1])
self.__mean_display["text"] = "{:.0f} ns".format(self.__test["data"]["mean"][-1])
self.__lmax_display["text"] = "{:d} ns".format(self.__test["data"]["max"]["local"])
self.__lmin_display["text"] = "{:d} ns".format(self.__test["data"]["min"]["local"])
match result:
case "PENDING":
pass
case "IN-PROGRESS":
self.__start_display["text"] = Common.time_to_str(self.__test.get("start", "0"))
case "PASSED" | "FAILED":
self.__start_display["text"] = Common.time_to_str(self.__test.get("start", "0"))
self.__end_display["text"] = Common.time_to_str(self.__test.get("end", "0"))
self.__duration_display["text"] = time.strftime("%H:%M:%S", time.gmtime(self.__test["duration"]))
self.__status_display["text"] = result
self.__status_display.configure(style="MeasStat{:s}.TLabel".format(result))
self.__status_frame.configure(style="MeasStat{:s}.TFrame".format(result))
def finalize(self) -> None:
self.__save_data_btn.configure(state="normal")
def __init__(self, master: tk.Widget, test: dict) -> None:
super().__init__(master)
self.__test = test
self.__init_style()
self.__init_layout()
self.__init_widgets()
self.configure(labelwidget=ttk.Label(self, text="Measurement info", style="MeasTitle.TLabel"), width=240)
self.refresh()
def save_results(self) -> None:
# pick a place to save to
fn = tkinter.filedialog.asksaveasfilename(
title="Save measurement data",
filetypes=[("XLSX", "*.xlsx")]
)
if fn == ():
return
if not fn.endswith(".xlsx"):
fn += ".xlsx"
# --------------
# create the workbook
workbook = xlsxwriter.Workbook(fn)
# save the summary
summary_sheet = workbook.add_worksheet("summary")
summary = [
[ "Start:", Common.time_to_str(self.__test["start"])],
[ "End:", Common.time_to_str(self.__test["end"]) ],
[ "Duration:", time.strftime("%H:%M:%S", time.gmtime(self.__test["duration"])) ],
[ "Cycles:", self.__test["data"]["cycles"] ],
[ "Mean [ns]:", self.__test["data"]["mean"][-1] ],
[ "Variance [ns^2]:", self.__test["data"]["var"][-1] ],
[ "Max. (global) [ns]:", self.__test["data"]["max"]["global"] ],
[ "Min. (global) [ns]:", self.__test["data"]["min"]["global"] ],
[ "Max. (local) [ns]:", self.__test["data"]["max"]["local"] ],
[ "Min. (local) [ns]:", self.__test["data"]["min"]["local"] ]
]
rowi = 0
for summary_line in summary:
summary_sheet.write_row(rowi, 0, summary_line)
rowi += 1
# create the raw dump sheet
dump_sheet = workbook.add_worksheet("raw_data")
# write the header
if self.__test["data"]["rawtype"] == "flexPTP":
header = [ "T1", "T4", "Dt", "corr_ppb", "mpd_ns", "sync_period_ns" ]
else:
header = [ "Dt", "mpd_ns" ]
dump_sheet.write_row(0, 0, header)
# save actual sync cycle data
rowi = 1
for record in self.__test["data"]["raw"]:
dump_sheet.write_row(rowi, 0, record)
rowi += 1
workbook.close()
class PlotPanel(ttk.Frame):
def __init_widgets(self, figsize: tuple) -> None:
self.__fig, self.__ax = plt.subplots(figsize=figsize)
self.__full_plot, = self.__ax.plot([], [])
self.__ax.set_ylabel("Time error [ns]")
self.__ax.set_xlabel("Cycles")
self.__ax.grid(True)
self.__ax.xaxis.set_major_locator(MaxNLocator(integer=True))
self.__ax.yaxis.set_major_locator(MaxNLocator(integer=True))
self.__full_plot_canvas = FigureCanvasTkAgg(self.__fig, master=self) # A tk.DrawingArea.
style = ttk.Style()
bg = style.lookup("TFrame", "background")
self.__full_plot_canvas.get_tk_widget().config(bg=bg)
self.__full_plot_canvas.get_tk_widget().pack(expand=True, fill="both")
self.__fig.tight_layout()
self.__fig.patch.set_alpha(0)
def plot(self, start: int, stop: int, data: list[int]) -> None:
self.__full_plot.set_xdata(range(start, stop))
self.__full_plot.set_ydata(data[start:stop])
self.__ax.relim()
self.__ax.autoscale_view()
self.draw()
def draw(self) -> None:
self.__full_plot_canvas.draw()
self.__full_plot_canvas.flush_events()
def get_ax(self) -> Axes:
return self.__ax
def get_fig(self) -> Figure:
return self.__fig
def __init__(self, master: tk.Widget, figsize: tuple = (8, 2)) -> None:
super().__init__(master)
self.__init_widgets(figsize)
class ResultsEntry(ttk.LabelFrame):
MIN_MAINPLOT_XRANGE = 100
MIN_LOOKIN_WINDOW_LENGTH = 5
def __init_layout(self) -> None:
self.columnconfigure(0, weight=6)
self.columnconfigure([1, 2], weight=2)
def __init_widgets(self) -> None:
# full measurement figure
self.__main_figure = PlotPanel(self, figsize=(8, 3))
self.__main_figure.get_ax().set_xlim(0, self.MIN_MAINPLOT_XRANGE)
self.__main_figure.grid(row=0, column=0, sticky=tk.NSEW)
# look-in window
self.__lookin_figure = PlotPanel(self, figsize=(2, 3))
self.__lookin_figure.get_ax().set_xlim(0, self.__lookin_window_size - 1)
self.__lookin_figure.get_ax().set_ylabel("")
self.__lookin_figure.get_ax().yaxis.tick_right()
self.__lookin_figure.get_fig().subplots_adjust(left=0.05, right=0.78)
self.__lookin_figure.grid(row=0, column=1, sticky=tk.NSEW)
# info panel
self.__info_panel = ResultsInfoPanel(self, self.__test)
self.__info_panel.grid(row=0, column=2, sticky=tk.NSEW, ipadx=4, padx=4, pady=4)
self.__info_panel.grid_propagate(False)
def refresh(self) -> None:
self.__info_panel.refresh()
data = self.__test["data"]
n = data["cycles"]
self.__main_figure.get_ax().set_xlim(0, numpy.max([self.MIN_MAINPLOT_XRANGE, n, self.__params["sync_min_cycles"]]))
self.__main_figure.plot(0, n, data["dt"])
if n > self.__lookin_window_size:
n0 = n - self.__lookin_window_size
self.__lookin_figure.get_ax().set_xlim(n0, n - 1)
self.__lookin_figure.plot(n0, n, data["dt"])
def finalize(self) -> None:
self.__main_figure.get_ax().set_xlim(0, self.__test["data"]["cycles"] - 1)
self.__main_figure.draw()
self.__info_panel.finalize()
def __init__(self, master: tk.Widget, test: dict, params: dict) -> None:
super().__init__(master)
self.configure(labelwidget=ttk.Label(self, text=test["name"], font=gcm.TITLE_FONT))
self.__test = test
self.__params = params
self.__lookin_window_size = numpy.max([self.__params["averaging_window_size"], self.MIN_LOOKIN_WINDOW_LENGTH])
self.__init_layout()
self.__init_widgets()
class ResultsTab(ttk.Frame):
def __init__(self, master: tk.Misc | None = None) -> None:
super().__init__(master)