From 361c2b68c0910b2c6aa8d614692002af02ac2f7f Mon Sep 17 00:00:00 2001 From: Silas Oettinghaus Date: Thu, 14 May 2026 13:14:31 +0200 Subject: [PATCH] add new plots --- soundcard_input.py | 304 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 295 insertions(+), 9 deletions(-) diff --git a/soundcard_input.py b/soundcard_input.py index d67ae85..81d3289 100644 --- a/soundcard_input.py +++ b/soundcard_input.py @@ -690,6 +690,8 @@ class LevelMonitorGUI: self.power_rows: dict[str, dict] = {} self.power_history: dict[str, list[tuple[float, float, 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_smoothed_db: dict[str, float] = {} 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.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.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_channel_var = tk.StringVar(value="1") 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("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.columnconfigure(0, weight=1) - plots_frame.columnconfigure(1, weight=1) - plots_frame.rowconfigure(0, weight=1) plots_frame.rowconfigure(1, weight=1) - self.scope_frame = ttk.LabelFrame(plots_frame, text="Input waveform, calibrated voltage", padding=10) - self.scope_frame.grid(row=0, column=0, sticky="nsew", padx=(0, 6)) + plot_selector = ttk.Frame(plots_frame) + 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.columnconfigure(0, weight=1) @@ -934,8 +962,7 @@ class LevelMonitorGUI: self.scope_canvas.bind("", lambda _event: 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.grid(row=0, column=1, sticky="nsew", padx=(6, 0)) + self.spectrum_frame = ttk.LabelFrame(self.plot_area, text="Input PSD, Welch scaled", padding=10) self.spectrum_frame.rowconfigure(0, weight=1) self.spectrum_frame.columnconfigure(0, weight=1) self.spectrum_canvas = self.tk.Canvas( @@ -952,8 +979,7 @@ class LevelMonitorGUI: self.spectrum_canvas.bind("", lambda _event: 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.grid(row=1, column=0, columnspan=2, sticky="nsew", pady=(12, 0)) + self.power_history_frame = ttk.LabelFrame(self.plot_area, text="Output power history", padding=10) self.power_history_frame.rowconfigure(0, weight=1) self.power_history_frame.columnconfigure(0, weight=1) self.power_history_canvas = self.tk.Canvas( @@ -979,6 +1005,41 @@ class LevelMonitorGUI: self.power_history_canvas.bind("", lambda _event: 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("", 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("", lambda _event: self.draw_headroom_grid()) + self.draw_headroom_grid() + self._layout_plot_tiles() + def _build_power_config_row( self, name: str, @@ -1030,6 +1091,39 @@ class LevelMonitorGUI: vars_by_metric["light_id"] = light_id 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: selected = 0 if requested_index is not None: @@ -1137,6 +1231,8 @@ class LevelMonitorGUI: self.power_buffers = {} self.power_history = {} self.power_max_hold_w = {} + self.latest_power_frames = {} + self.latest_headroom_frames = {} self.headroom_curves = {} self.headroom_smoothed_db = {} self.power_history_start_s = None @@ -1255,6 +1351,7 @@ class LevelMonitorGUI: if latest is None: continue + self.latest_power_frames[name] = latest row = self.power_rows.get(name) if row is None: continue @@ -1265,10 +1362,12 @@ class LevelMonitorGUI: row["s"].set(f"{latest.total_s_va:.1f} VA") headroom = self._compute_gui_headroom(name, latest) if headroom is None: + self.latest_headroom_frames.pop(name, None) row["headroom"].set("-") row["crit_hz"].set("-") row["limit"].set("-") else: + self.latest_headroom_frames[name] = headroom row["headroom"].set(f"{headroom['worst_headroom_db']:+.1f} dB") row["crit_hz"].set(f"{headroom['critical_frequency_hz']:.1f}") row["limit"].set(headroom["status"]) @@ -1276,6 +1375,8 @@ class LevelMonitorGUI: self._append_power_history(name, latest) self.draw_power_history() + self.draw_power_limit() + self.draw_headroom() def _compute_gui_headroom(self, name: str, frame: PowerFrame) -> dict | None: 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" ) + 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: if self.capture is not None: return @@ -1703,6 +1985,8 @@ class LevelMonitorGUI: self.power_buffers = {} self.power_history = {} self.power_max_hold_w = {} + self.latest_power_frames = {} + self.latest_headroom_frames = {} self.headroom_curves = {} self.headroom_smoothed_db = {} self.power_history_start_s = None @@ -1719,6 +2003,8 @@ class LevelMonitorGUI: row["light"].itemconfigure(row["light_id"], fill="#22a447") self.draw_spectrum_grid() self.draw_power_history_grid() + self.draw_power_limit_grid() + self.draw_headroom_grid() self.start_button.configure(state="normal") self.stop_button.configure(state="disabled") self.calibrate_button.configure(state="disabled")