Compare commits

..

2 Commits

Author SHA1 Message Date
Silas Oettinghaus
a88d6568a0 changed plot layout 2026-05-14 13:27:51 +02:00
Silas Oettinghaus
361c2b68c0 add new plots 2026-05-14 13:14:31 +02:00

View File

@@ -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
@@ -899,15 +901,16 @@ 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))
self.plot_tabs = ttk.Notebook(plots_frame)
self.plot_tabs.grid(row=0, column=0, sticky="nsew")
self.plot_tabs.bind("<<NotebookTabChanged>>", lambda _event: self._redraw_active_plot())
self.scope_frame = ttk.Frame(self.plot_tabs, padding=10)
self.scope_frame.rowconfigure(0, weight=1)
self.scope_frame.columnconfigure(0, weight=1)
@@ -933,9 +936,9 @@ class LevelMonitorGUI:
self.waveform_y_limit_var.trace_add("write", lambda *_args: self.draw_scope_grid())
self.scope_canvas.bind("<Configure>", lambda _event: self.draw_scope_grid())
self.draw_scope_grid()
self.plot_tabs.add(self.scope_frame, text="Waveform")
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.Frame(self.plot_tabs, padding=10)
self.spectrum_frame.rowconfigure(0, weight=1)
self.spectrum_frame.columnconfigure(0, weight=1)
self.spectrum_canvas = self.tk.Canvas(
@@ -951,9 +954,9 @@ class LevelMonitorGUI:
)
self.spectrum_canvas.bind("<Configure>", lambda _event: self.draw_spectrum_grid())
self.draw_spectrum_grid()
self.plot_tabs.add(self.spectrum_frame, text="Input PSD")
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.Frame(self.plot_tabs, 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(
@@ -978,6 +981,43 @@ class LevelMonitorGUI:
self.power_history_window_var.trace_add("write", lambda *_args: self.draw_power_history())
self.power_history_canvas.bind("<Configure>", lambda _event: self.draw_power_history_grid())
self.draw_power_history_grid()
self.plot_tabs.add(self.power_history_frame, text="Power history")
self.power_limit_frame = ttk.Frame(self.plot_tabs, 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.plot_tabs.add(self.power_limit_frame, text="P vs WinISD")
self.headroom_frame = ttk.Frame(self.plot_tabs, 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.plot_tabs.add(self.headroom_frame, text="Headroom")
def _build_power_config_row(
self,
@@ -1030,6 +1070,23 @@ class LevelMonitorGUI:
vars_by_metric["light_id"] = light_id
self.power_rows[name] = vars_by_metric
def _redraw_active_plot(self) -> None:
selected = self.plot_tabs.select()
if selected == str(self.scope_frame):
self.draw_scope_grid()
elif selected == str(self.spectrum_frame):
self.draw_spectrum_grid()
self.draw_spectrum()
elif selected == str(self.power_history_frame):
self.draw_power_history_grid()
self.draw_power_history()
elif selected == str(self.power_limit_frame):
self.draw_power_limit_grid()
self.draw_power_limit()
elif selected == str(self.headroom_frame):
self.draw_headroom_grid()
self.draw_headroom()
def _select_initial_device(self, requested_index: int | None) -> None:
selected = 0
if requested_index is not None:
@@ -1137,6 +1194,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 +1314,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 +1325,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 +1338,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 +1717,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 +1948,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 +1966,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")