Compare commits
2 Commits
2342caeb0a
...
a88d6568a0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a88d6568a0 | ||
|
|
361c2b68c0 |
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user