add new plots

This commit is contained in:
Silas Oettinghaus
2026-05-14 13:14:31 +02:00
parent 2342caeb0a
commit 361c2b68c0

View File

@@ -690,6 +690,8 @@ class LevelMonitorGUI:
self.power_rows: dict[str, dict] = {} self.power_rows: dict[str, dict] = {}
self.power_history: dict[str, list[tuple[float, float, float]]] = {} self.power_history: dict[str, list[tuple[float, float, float]]] = {}
self.power_max_hold_w: dict[str, float] = {} self.power_max_hold_w: dict[str, float] = {}
self.latest_power_frames: dict[str, PowerFrame] = {}
self.latest_headroom_frames: dict[str, dict] = {}
self.headroom_curves: dict[str, WinISDLimitCurve] = {} self.headroom_curves: dict[str, WinISDLimitCurve] = {}
self.headroom_smoothed_db: dict[str, float] = {} self.headroom_smoothed_db: dict[str, float] = {}
self.power_history_start_s: float | None = None self.power_history_start_s: float | None = None
@@ -725,6 +727,11 @@ class LevelMonitorGUI:
self.winisd_max_power_var = tk.StringVar(value=str(args.winisd_max_power or WINISD_MAX_POWER_FILE)) self.winisd_max_power_var = tk.StringVar(value=str(args.winisd_max_power or WINISD_MAX_POWER_FILE))
self.headroom_smoothing_var = tk.StringVar(value=f"{args.headroom_smoothing_alpha:g}") self.headroom_smoothing_var = tk.StringVar(value=f"{args.headroom_smoothing_alpha:g}")
self.winisd_status_var = tk.StringVar(value="WinISD headroom uses per-bin P/Pmax for LF.") self.winisd_status_var = tk.StringVar(value="WinISD headroom uses per-bin P/Pmax for LF.")
self.show_waveform_plot_var = tk.BooleanVar(value=True)
self.show_psd_plot_var = tk.BooleanVar(value=True)
self.show_power_history_plot_var = tk.BooleanVar(value=True)
self.show_power_limit_plot_var = tk.BooleanVar(value=True)
self.show_headroom_plot_var = tk.BooleanVar(value=True)
self.lf_enabled_var = tk.BooleanVar(value=True) self.lf_enabled_var = tk.BooleanVar(value=True)
self.lf_channel_var = tk.StringVar(value="1") self.lf_channel_var = tk.StringVar(value="1")
self.lf_impedance_var = tk.StringVar(value="impedance.txt" if Path("impedance.txt").exists() else "") self.lf_impedance_var = tk.StringVar(value="impedance.txt" if Path("impedance.txt").exists() else "")
@@ -899,15 +906,36 @@ class LevelMonitorGUI:
self._build_power_config_row("LF", 4, self.lf_enabled_var, self.lf_channel_var, self.lf_impedance_var, self.lf_gain_var) self._build_power_config_row("LF", 4, self.lf_enabled_var, self.lf_channel_var, self.lf_impedance_var, self.lf_gain_var)
self._build_power_config_row("HF", 5, self.hf_enabled_var, self.hf_channel_var, self.hf_impedance_var, self.hf_gain_var) self._build_power_config_row("HF", 5, self.hf_enabled_var, self.hf_channel_var, self.hf_impedance_var, self.hf_gain_var)
plots_frame = ttk.Frame(outer) plots_frame = ttk.LabelFrame(outer, text="Plot viewer", padding=10)
plots_frame.pack(fill="both", expand=True, pady=(12, 0)) plots_frame.pack(fill="both", expand=True, pady=(12, 0))
plots_frame.columnconfigure(0, weight=1) plots_frame.columnconfigure(0, weight=1)
plots_frame.columnconfigure(1, weight=1)
plots_frame.rowconfigure(0, weight=1)
plots_frame.rowconfigure(1, weight=1) plots_frame.rowconfigure(1, weight=1)
self.scope_frame = ttk.LabelFrame(plots_frame, text="Input waveform, calibrated voltage", padding=10) plot_selector = ttk.Frame(plots_frame)
self.scope_frame.grid(row=0, column=0, sticky="nsew", padx=(0, 6)) plot_selector.grid(row=0, column=0, sticky="ew", pady=(0, 8))
plot_options = (
("Waveform", self.show_waveform_plot_var),
("Input PSD", self.show_psd_plot_var),
("Power history", self.show_power_history_plot_var),
("P vs WinISD", self.show_power_limit_plot_var),
("Headroom", self.show_headroom_plot_var),
)
for col, (label, variable) in enumerate(plot_options):
ttk.Checkbutton(
plot_selector,
text=label,
variable=variable,
command=self._layout_plot_tiles,
).grid(row=0, column=col, sticky="w", padx=(0, 16))
self.plot_area = ttk.Frame(plots_frame)
self.plot_area.grid(row=1, column=0, sticky="nsew")
self.plot_area.columnconfigure(0, weight=1)
self.plot_area.columnconfigure(1, weight=1)
for row in range(3):
self.plot_area.rowconfigure(row, weight=1)
self.scope_frame = ttk.LabelFrame(self.plot_area, text="Input waveform, calibrated voltage", padding=10)
self.scope_frame.rowconfigure(0, weight=1) self.scope_frame.rowconfigure(0, weight=1)
self.scope_frame.columnconfigure(0, weight=1) self.scope_frame.columnconfigure(0, weight=1)
@@ -934,8 +962,7 @@ class LevelMonitorGUI:
self.scope_canvas.bind("<Configure>", lambda _event: self.draw_scope_grid()) self.scope_canvas.bind("<Configure>", lambda _event: self.draw_scope_grid())
self.draw_scope_grid() self.draw_scope_grid()
self.spectrum_frame = ttk.LabelFrame(plots_frame, text="Input PSD, Welch scaled", padding=10) self.spectrum_frame = ttk.LabelFrame(self.plot_area, text="Input PSD, Welch scaled", padding=10)
self.spectrum_frame.grid(row=0, column=1, sticky="nsew", padx=(6, 0))
self.spectrum_frame.rowconfigure(0, weight=1) self.spectrum_frame.rowconfigure(0, weight=1)
self.spectrum_frame.columnconfigure(0, weight=1) self.spectrum_frame.columnconfigure(0, weight=1)
self.spectrum_canvas = self.tk.Canvas( self.spectrum_canvas = self.tk.Canvas(
@@ -952,8 +979,7 @@ class LevelMonitorGUI:
self.spectrum_canvas.bind("<Configure>", lambda _event: self.draw_spectrum_grid()) self.spectrum_canvas.bind("<Configure>", lambda _event: self.draw_spectrum_grid())
self.draw_spectrum_grid() self.draw_spectrum_grid()
self.power_history_frame = ttk.LabelFrame(plots_frame, text="Output power history", padding=10) self.power_history_frame = ttk.LabelFrame(self.plot_area, text="Output power history", padding=10)
self.power_history_frame.grid(row=1, column=0, columnspan=2, sticky="nsew", pady=(12, 0))
self.power_history_frame.rowconfigure(0, weight=1) self.power_history_frame.rowconfigure(0, weight=1)
self.power_history_frame.columnconfigure(0, weight=1) self.power_history_frame.columnconfigure(0, weight=1)
self.power_history_canvas = self.tk.Canvas( self.power_history_canvas = self.tk.Canvas(
@@ -979,6 +1005,41 @@ class LevelMonitorGUI:
self.power_history_canvas.bind("<Configure>", lambda _event: self.draw_power_history_grid()) self.power_history_canvas.bind("<Configure>", lambda _event: self.draw_power_history_grid())
self.draw_power_history_grid() self.draw_power_history_grid()
self.power_limit_frame = ttk.LabelFrame(self.plot_area, text="Power Spectrum vs WinISD Limit", padding=10)
self.power_limit_frame.rowconfigure(0, weight=1)
self.power_limit_frame.columnconfigure(0, weight=1)
self.power_limit_canvas = self.tk.Canvas(
self.power_limit_frame,
background="#111111",
highlightthickness=0,
height=240,
)
self.power_limit_canvas.grid(row=0, column=0, sticky="nsew")
self.power_limit_status_var = self.tk.StringVar(value="P_bin and WinISD Pmax over frequency.")
ttk.Label(self.power_limit_frame, textvariable=self.power_limit_status_var).grid(
row=1, column=0, sticky="w", pady=(8, 0)
)
self.power_limit_canvas.bind("<Configure>", lambda _event: self.draw_power_limit_grid())
self.draw_power_limit_grid()
self.headroom_frame = ttk.LabelFrame(self.plot_area, text="WinISD Headroom", padding=10)
self.headroom_frame.rowconfigure(0, weight=1)
self.headroom_frame.columnconfigure(0, weight=1)
self.headroom_canvas = self.tk.Canvas(
self.headroom_frame,
background="#111111",
highlightthickness=0,
height=240,
)
self.headroom_canvas.grid(row=0, column=0, sticky="nsew")
self.headroom_status_var = self.tk.StringVar(value="H(f,t) = 10 log10(Pmax / P_bin).")
ttk.Label(self.headroom_frame, textvariable=self.headroom_status_var).grid(
row=1, column=0, sticky="w", pady=(8, 0)
)
self.headroom_canvas.bind("<Configure>", lambda _event: self.draw_headroom_grid())
self.draw_headroom_grid()
self._layout_plot_tiles()
def _build_power_config_row( def _build_power_config_row(
self, self,
name: str, name: str,
@@ -1030,6 +1091,39 @@ class LevelMonitorGUI:
vars_by_metric["light_id"] = light_id vars_by_metric["light_id"] = light_id
self.power_rows[name] = vars_by_metric self.power_rows[name] = vars_by_metric
def _layout_plot_tiles(self) -> None:
plot_specs = (
(self.scope_frame, self.show_waveform_plot_var, self.draw_scope_grid),
(self.spectrum_frame, self.show_psd_plot_var, self.draw_spectrum_grid),
(self.power_history_frame, self.show_power_history_plot_var, self.draw_power_history_grid),
(self.power_limit_frame, self.show_power_limit_plot_var, self.draw_power_limit_grid),
(self.headroom_frame, self.show_headroom_plot_var, self.draw_headroom_grid),
)
active = [(frame, redraw) for frame, variable, redraw in plot_specs if variable.get()]
for frame, _variable, _redraw in plot_specs:
frame.grid_forget()
for idx, (frame, redraw) in enumerate(active):
row = idx // 2
col = idx % 2
frame.grid(
row=row,
column=col,
sticky="nsew",
padx=(0, 6) if col == 0 else (6, 0),
pady=(0, 12),
)
redraw()
if self.show_psd_plot_var.get():
self.draw_spectrum()
if self.show_power_history_plot_var.get():
self.draw_power_history()
if self.show_power_limit_plot_var.get():
self.draw_power_limit()
if self.show_headroom_plot_var.get():
self.draw_headroom()
def _select_initial_device(self, requested_index: int | None) -> None: def _select_initial_device(self, requested_index: int | None) -> None:
selected = 0 selected = 0
if requested_index is not None: if requested_index is not None:
@@ -1137,6 +1231,8 @@ class LevelMonitorGUI:
self.power_buffers = {} self.power_buffers = {}
self.power_history = {} self.power_history = {}
self.power_max_hold_w = {} self.power_max_hold_w = {}
self.latest_power_frames = {}
self.latest_headroom_frames = {}
self.headroom_curves = {} self.headroom_curves = {}
self.headroom_smoothed_db = {} self.headroom_smoothed_db = {}
self.power_history_start_s = None self.power_history_start_s = None
@@ -1255,6 +1351,7 @@ class LevelMonitorGUI:
if latest is None: if latest is None:
continue continue
self.latest_power_frames[name] = latest
row = self.power_rows.get(name) row = self.power_rows.get(name)
if row is None: if row is None:
continue continue
@@ -1265,10 +1362,12 @@ class LevelMonitorGUI:
row["s"].set(f"{latest.total_s_va:.1f} VA") row["s"].set(f"{latest.total_s_va:.1f} VA")
headroom = self._compute_gui_headroom(name, latest) headroom = self._compute_gui_headroom(name, latest)
if headroom is None: if headroom is None:
self.latest_headroom_frames.pop(name, None)
row["headroom"].set("-") row["headroom"].set("-")
row["crit_hz"].set("-") row["crit_hz"].set("-")
row["limit"].set("-") row["limit"].set("-")
else: else:
self.latest_headroom_frames[name] = headroom
row["headroom"].set(f"{headroom['worst_headroom_db']:+.1f} dB") row["headroom"].set(f"{headroom['worst_headroom_db']:+.1f} dB")
row["crit_hz"].set(f"{headroom['critical_frequency_hz']:.1f}") row["crit_hz"].set(f"{headroom['critical_frequency_hz']:.1f}")
row["limit"].set(headroom["status"]) row["limit"].set(headroom["status"])
@@ -1276,6 +1375,8 @@ class LevelMonitorGUI:
self._append_power_history(name, latest) self._append_power_history(name, latest)
self.draw_power_history() self.draw_power_history()
self.draw_power_limit()
self.draw_headroom()
def _compute_gui_headroom(self, name: str, frame: PowerFrame) -> dict | None: def _compute_gui_headroom(self, name: str, frame: PowerFrame) -> dict | None:
curve = self.headroom_curves.get(name) curve = self.headroom_curves.get(name)
@@ -1653,6 +1754,187 @@ class LevelMonitorGUI:
f"Window {window_s:g} s, y {y_min:.1f} to {y_max:.1f} W / VA" f"Window {window_s:g} s, y {y_min:.1f} to {y_max:.1f} W / VA"
) )
def _winisd_plot_data(self):
for name in sorted(self.latest_headroom_frames):
frame = self.latest_power_frames.get(name)
headroom = self.latest_headroom_frames.get(name)
curve = self.headroom_curves.get(name)
if frame is not None and headroom is not None and curve is not None:
return name, frame, headroom, curve
return None
def draw_power_limit_grid(self) -> None:
canvas = self.power_limit_canvas
canvas.delete("grid")
canvas.delete("limit")
width = max(canvas.winfo_width(), 2)
height = max(canvas.winfo_height(), 2)
for frac in (0.0, 0.25, 0.5, 0.75, 1.0):
y = int(frac * (height - 1))
color = "#555555" if frac == 0.5 else "#2a2a2a"
canvas.create_line(0, y, width, y, fill=color, tags="grid")
x = int(frac * (width - 1))
canvas.create_line(x, 0, x, height, fill="#242424", tags="grid")
canvas.create_text(6, 6, anchor="nw", text="Power [W], log scale", fill="#bbbbbb", tags="grid")
canvas.create_text(6, height - 20, anchor="nw", text="20 Hz", fill="#bbbbbb", tags="grid")
canvas.create_text(width - 6, height - 20, anchor="ne", text="Nyquist", fill="#bbbbbb", tags="grid")
def draw_power_limit(self) -> None:
data = self._winisd_plot_data()
if np is None or data is None:
return
name, frame, headroom, curve = data
canvas = self.power_limit_canvas
canvas.delete("limit")
width = max(canvas.winfo_width(), 2)
height = max(canvas.winfo_height(), 2)
freq = frame.frequencies_hz
pmax = curve.interpolate(freq)
p_bin = np.maximum(frame.p_w_per_bin, 0.0)
mask = np.isfinite(freq) & np.isfinite(pmax) & (freq >= 20.0) & (pmax > HEADROOM_EPS_W)
if np.count_nonzero(mask) < 2:
return
freq = freq[mask]
pmax = pmax[mask]
p_bin = p_bin[mask]
p_plot = np.maximum(p_bin, 1e-9)
f_min = max(20.0, float(freq[0]))
f_max = float(freq[-1])
if f_max <= f_min:
return
log_f_min = math.log10(f_min)
log_f_max = math.log10(f_max)
positive_values = np.concatenate((p_plot[np.isfinite(p_plot)], pmax[np.isfinite(pmax)]))
positive_values = positive_values[positive_values > 0.0]
if len(positive_values) == 0:
return
y_min = max(1e-6, 10.0 ** math.floor(math.log10(float(np.nanmin(positive_values)))))
y_max = 10.0 ** math.ceil(math.log10(float(np.nanmax(positive_values)) * 1.2))
if y_max <= y_min:
y_max = y_min * 10.0
log_y_min = math.log10(y_min)
log_y_max = math.log10(y_max)
def to_points(y_values: np.ndarray) -> list[float]:
x = (np.log10(freq) - log_f_min) / (log_f_max - log_f_min) * (width - 1)
y = (log_y_max - np.log10(np.maximum(y_values, y_min))) / (log_y_max - log_y_min) * (height - 1)
step = max(1, len(x) // max(width, 1))
points: list[float] = []
for px, py in zip(x[::step], y[::step]):
points.extend((float(np.clip(px, 0, width - 1)), float(np.clip(py, 0, height - 1))))
return points
p_points = to_points(p_plot)
pmax_points = to_points(pmax)
if len(p_points) >= 4:
canvas.create_line(p_points, fill="#1f77b4", width=1.4, smooth=False, tags="limit")
if len(pmax_points) >= 4:
canvas.create_line(pmax_points, fill="#d62728", width=1.8, smooth=False, tags="limit")
critical_f = float(headroom["critical_frequency_hz"])
if math.isfinite(critical_f) and f_min <= critical_f <= f_max:
x = (math.log10(critical_f) - log_f_min) / (log_f_max - log_f_min) * (width - 1)
canvas.create_line(x, 0, x, height, fill="#e6a400", width=1.4, dash=(4, 3), tags="limit")
canvas.create_text(6, 22, anchor="nw", text=f"{y_max:g} W", fill="#888888", tags="limit")
canvas.create_text(6, height - 38, anchor="nw", text=f"{y_min:g} W", fill="#888888", tags="limit")
canvas.create_text(width - 8, 8, anchor="ne", text=f"{name} P_bin", fill="#1f77b4", tags="limit")
canvas.create_text(width - 8, 24, anchor="ne", text="WinISD Pmax", fill="#d62728", tags="limit")
self.power_limit_status_var.set(
f"{name}: P_bin vs Pmax, critical {critical_f:.1f} Hz, "
f"worst {headroom['worst_headroom_db']:+.1f} dB"
)
def draw_headroom_grid(self) -> None:
canvas = self.headroom_canvas
canvas.delete("grid")
canvas.delete("headroom")
width = max(canvas.winfo_width(), 2)
height = max(canvas.winfo_height(), 2)
for frac in (0.0, 0.25, 0.5, 0.75, 1.0):
y = int(frac * (height - 1))
color = "#555555" if frac == 0.5 else "#2a2a2a"
canvas.create_line(0, y, width, y, fill=color, tags="grid")
x = int(frac * (width - 1))
canvas.create_line(x, 0, x, height, fill="#242424", tags="grid")
canvas.create_text(6, 6, anchor="nw", text="Headroom [dB]", fill="#bbbbbb", tags="grid")
canvas.create_text(6, height - 20, anchor="nw", text="20 Hz", fill="#bbbbbb", tags="grid")
canvas.create_text(width - 6, height - 20, anchor="ne", text="Nyquist", fill="#bbbbbb", tags="grid")
def draw_headroom(self) -> None:
data = self._winisd_plot_data()
if np is None or data is None:
return
name, frame, headroom, curve = data
canvas = self.headroom_canvas
canvas.delete("headroom")
width = max(canvas.winfo_width(), 2)
height = max(canvas.winfo_height(), 2)
freq = frame.frequencies_hz
pmax = curve.interpolate(freq)
h_db = np.asarray(headroom["headroom_db_bin"], dtype=np.float64)
mask = np.isfinite(freq) & np.isfinite(pmax) & np.isfinite(h_db) & (freq >= 20.0) & (pmax > HEADROOM_EPS_W)
if np.count_nonzero(mask) < 2:
return
freq = freq[mask]
h_db = h_db[mask]
f_min = max(20.0, float(freq[0]))
f_max = float(freq[-1])
if f_max <= f_min:
return
log_f_min = math.log10(f_min)
log_f_max = math.log10(f_max)
y_min = min(-6.0, math.floor((float(np.nanmin(h_db)) - 3.0) / 3.0) * 3.0)
h_display_top = min(float(np.nanpercentile(h_db, 95.0)), 60.0)
y_max = max(12.0, math.ceil((h_display_top + 3.0) / 3.0) * 3.0)
if y_max <= y_min:
y_max = y_min + 18.0
h_plot = np.clip(h_db, y_min, y_max)
x = (np.log10(freq) - log_f_min) / (log_f_max - log_f_min) * (width - 1)
y = (y_max - h_plot) / (y_max - y_min) * (height - 1)
step = max(1, len(x) // max(width, 1))
points: list[float] = []
for px, py in zip(x[::step], y[::step]):
points.extend((float(np.clip(px, 0, width - 1)), float(np.clip(py, 0, height - 1))))
if len(points) >= 4:
canvas.create_line(points, fill="#1f77b4", width=1.5, smooth=False, tags="headroom")
def y_for_db(value: float) -> float:
return (y_max - value) / (y_max - y_min) * (height - 1)
for value, color, label in ((0.0, "#d62728", "0 dB"), (6.0, "#e6a400", "+6 dB")):
if y_min <= value <= y_max:
y_line = y_for_db(value)
canvas.create_line(0, y_line, width, y_line, fill=color, width=1.2, dash=(5, 3), tags="headroom")
canvas.create_text(width - 8, y_line - 2, anchor="se", text=label, fill=color, tags="headroom")
critical_f = float(headroom["critical_frequency_hz"])
if math.isfinite(critical_f) and f_min <= critical_f <= f_max:
x_line = (math.log10(critical_f) - log_f_min) / (log_f_max - log_f_min) * (width - 1)
canvas.create_line(x_line, 0, x_line, height, fill="#e6a400", width=1.4, dash=(4, 3), tags="headroom")
canvas.create_text(6, 22, anchor="nw", text=f"{y_max:g} dB", fill="#888888", tags="headroom")
canvas.create_text(6, height - 38, anchor="nw", text=f"{y_min:g} dB", fill="#888888", tags="headroom")
canvas.create_text(width - 8, 8, anchor="ne", text=f"{name} H(f,t)", fill="#1f77b4", tags="headroom")
self.headroom_status_var.set(
f"{name}: worst {headroom['worst_headroom_db']:+.1f} dB "
f"at {critical_f:.1f} Hz, {headroom['status']}"
)
def start(self) -> None: def start(self) -> None:
if self.capture is not None: if self.capture is not None:
return return
@@ -1703,6 +1985,8 @@ class LevelMonitorGUI:
self.power_buffers = {} self.power_buffers = {}
self.power_history = {} self.power_history = {}
self.power_max_hold_w = {} self.power_max_hold_w = {}
self.latest_power_frames = {}
self.latest_headroom_frames = {}
self.headroom_curves = {} self.headroom_curves = {}
self.headroom_smoothed_db = {} self.headroom_smoothed_db = {}
self.power_history_start_s = None self.power_history_start_s = None
@@ -1719,6 +2003,8 @@ class LevelMonitorGUI:
row["light"].itemconfigure(row["light_id"], fill="#22a447") row["light"].itemconfigure(row["light_id"], fill="#22a447")
self.draw_spectrum_grid() self.draw_spectrum_grid()
self.draw_power_history_grid() self.draw_power_history_grid()
self.draw_power_limit_grid()
self.draw_headroom_grid()
self.start_button.configure(state="normal") self.start_button.configure(state="normal")
self.stop_button.configure(state="disabled") self.stop_button.configure(state="disabled")
self.calibrate_button.configure(state="disabled") self.calibrate_button.configure(state="disabled")