implement new monitoring values based on max_power limits from winISD

This commit is contained in:
Silas Oettinghaus
2026-05-14 13:05:08 +02:00
parent 53b011ee16
commit 2342caeb0a
6 changed files with 1658 additions and 24 deletions

View File

@@ -25,6 +25,7 @@ from lspower.audio import (
)
from lspower.impedance import ImpedanceCurve
from lspower.power import PowerEstimator, PowerFrame, VoltageSpectrumEstimator, VoltageSpectrumFrame, WayConfig
from lspower.winisd import WinISDLimitCurve, check_excursion_scaling, headroom_status, run_self_test
np = None
@@ -45,6 +46,12 @@ DEFAULT_POWER_UPDATE_INTERVAL_S = 0.25
DEFAULT_AMP_GAIN_DB = 32.0
DEFAULT_POWER_WARNING_W = 400.0
DEFAULT_POWER_CRITICAL_W = 550.0
WINISD_MAX_POWER_FILE = Path("winisd/max_power.csv")
WINISD_EXCURSION_10W_FILE = Path("winisd/excursion_10w.csv")
WINISD_EXCURSION_100W_FILE = Path("winisd/excursion_100w.csv")
ENABLE_WINISD_HEADROOM = True
HEADROOM_EPS_W = 1e-12
HEADROOM_SMOOTHING_ALPHA = 0.9
# Convert sounddevice's normalized floating-point samples to volts.
#
@@ -252,6 +259,21 @@ def format_levels(
return " | ".join(parts)
def smooth_headroom_db(previous: float | None, raw: float, alpha: float) -> float:
if previous is None or not math.isfinite(previous) or not math.isfinite(raw) or alpha <= 0.0:
return raw
return alpha * previous + (1.0 - alpha) * raw
def format_headroom(headroom: dict | None) -> str:
if not headroom:
return "WinISD headroom disabled"
return (
f"WinISD headroom={headroom['worst_headroom_db']:+6.2f} dB "
f"@ {headroom['critical_frequency_hz']:7.1f} Hz | {headroom['status']}"
)
class PowerConsoleRunner:
"""Live console runner for estimated amplifier output power."""
@@ -276,6 +298,8 @@ class PowerConsoleRunner:
)
self.estimators: dict[str, PowerEstimator] = {}
self.buffers: dict[str, object] = {}
self.headroom_curves: dict[str, WinISDLimitCurve] = {}
self.headroom_smoothed_db: dict[str, float] = {}
self.hop_size = 0
@staticmethod
@@ -379,10 +403,30 @@ class PowerConsoleRunner:
way.name: np.zeros(0, dtype=np.float64)
for way in self.ways
}
self._create_headroom_curves()
@staticmethod
def format_power_line(name: str, frame: PowerFrame) -> str:
return (
def _create_headroom_curves(self) -> None:
self.headroom_curves = {}
self.headroom_smoothed_db = {}
if self.args.disable_winisd_headroom:
return
path = Path(self.args.winisd_max_power)
lf_ways = [way for way in self.ways if way.name.upper() == "LF"]
if not lf_ways:
return
try:
curve = WinISDLimitCurve.from_file(path, eps_w=HEADROOM_EPS_W)
except Exception as exc:
print(f"WinISD headroom disabled: {exc}", file=sys.stderr)
return
for way in lf_ways:
self.headroom_curves[way.name] = curve
def format_power_line(self, name: str, frame: PowerFrame) -> str:
headroom = self._compute_headroom(name, frame)
line = (
f"{name}: "
f"Vdsp={frame.rms_input_v:9.4f} Vrms, "
f"Vamp={frame.rms_amp_v:9.2f} Vrms, "
@@ -390,6 +434,26 @@ class PowerConsoleRunner:
f"Q={frame.total_q_var:10.2f} var, "
f"Sapp={frame.total_s_va:10.2f} VA"
)
if headroom:
line += ", " + format_headroom(headroom)
return line
def _compute_headroom(self, name: str, frame: PowerFrame) -> dict | None:
curve = self.headroom_curves.get(name)
if curve is None:
return None
headroom = curve.compute_headroom(frame.p_w_per_bin, frame.frequencies_hz)
raw = float(headroom["worst_headroom_db"])
smoothed = smooth_headroom_db(
self.headroom_smoothed_db.get(name),
raw,
self.args.headroom_smoothing_alpha,
)
self.headroom_smoothed_db[name] = smoothed
headroom["worst_headroom_db"] = smoothed
headroom["status"] = headroom_status(smoothed)
headroom["over_limit_bool"] = bool(math.isfinite(smoothed) and smoothed <= 0.0)
return headroom
def run(self) -> int:
try:
@@ -410,6 +474,15 @@ class PowerConsoleRunner:
)
print(f"Input voltage scale: {self.args.input_volts_per_sample_unit:g} V / sample unit")
print("Assumption: ideal linear amplifier voltage gain per way.")
if self.headroom_curves:
print(f"WinISD headroom: {Path(self.args.winisd_max_power)}")
if WINISD_EXCURSION_10W_FILE.exists() and WINISD_EXCURSION_100W_FILE.exists():
try:
print(check_excursion_scaling(WINISD_EXCURSION_10W_FILE, WINISD_EXCURSION_100W_FILE).message)
except Exception as exc:
print(f"Excursion check skipped: {exc}")
else:
print("WinISD headroom: disabled")
for way in self.ways:
curve = self.impedances[way.name]
print(
@@ -617,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.headroom_curves: dict[str, WinISDLimitCurve] = {}
self.headroom_smoothed_db: dict[str, float] = {}
self.power_history_start_s: float | None = None
self.power_hop_size = 0
self.level_rows: list[dict] = []
@@ -646,6 +721,10 @@ class LevelMonitorGUI:
self.power_fft_size_var = tk.StringVar(value=str(DEFAULT_POWER_FFT_SIZE))
self.power_overlap_var = tk.StringVar(value=f"{DEFAULT_POWER_OVERLAP:g}")
self.power_smoothing_var = tk.StringVar(value=f"{DEFAULT_POWER_SMOOTHING_ALPHA:g}")
self.winisd_enabled_var = tk.BooleanVar(value=not args.disable_winisd_headroom and ENABLE_WINISD_HEADROOM)
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.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 "")
@@ -776,19 +855,49 @@ class LevelMonitorGUI:
ttk.Entry(self.power_frame, textvariable=self.power_smoothing_var, width=8).grid(
row=1, column=5, sticky="w", padx=(8, 16)
)
ttk.Checkbutton(self.power_frame, text="WinISD", variable=self.winisd_enabled_var).grid(
row=2, column=0, sticky="w", pady=(8, 0)
)
ttk.Entry(self.power_frame, textvariable=self.winisd_max_power_var, width=44).grid(
row=2, column=1, columnspan=3, sticky="ew", padx=(8, 12), pady=(8, 0)
)
ttk.Label(self.power_frame, text="Headroom smooth").grid(row=2, column=4, sticky="e", pady=(8, 0))
ttk.Entry(self.power_frame, textvariable=self.headroom_smoothing_var, width=8).grid(
row=2, column=5, sticky="w", padx=(8, 16), pady=(8, 0)
)
ttk.Label(self.power_frame, textvariable=self.winisd_status_var).grid(
row=2, column=6, columnspan=9, sticky="w", pady=(8, 0)
)
headings = ("Way", "On", "Ch", "Impedance file", "Gain dB", "Vdsp", "Vamp", "P", "Q", "Sapp", "P meter", "", "Max P")
headings = (
"Way",
"On",
"Ch",
"Impedance file",
"Gain dB",
"Vdsp",
"Vamp",
"P",
"Q",
"Sapp",
"Headroom",
"Crit Hz",
"Limit",
"P meter",
"",
"Max P",
)
for col, heading in enumerate(headings):
ttk.Label(self.power_frame, text=heading).grid(
row=2,
row=3,
column=col,
sticky="w" if col <= 4 else "e",
pady=(8, 0),
padx=(0, 12 if col < 9 else 0),
)
self._build_power_config_row("LF", 3, self.lf_enabled_var, self.lf_channel_var, self.lf_impedance_var, self.lf_gain_var)
self._build_power_config_row("HF", 4, self.hf_enabled_var, self.hf_channel_var, self.hf_impedance_var, self.hf_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)
plots_frame = ttk.Frame(outer)
plots_frame.pack(fill="both", expand=True, pady=(12, 0))
@@ -892,9 +1001,12 @@ class LevelMonitorGUI:
"p": self.tk.StringVar(value="-"),
"q": self.tk.StringVar(value="-"),
"s": self.tk.StringVar(value="-"),
"headroom": self.tk.StringVar(value="-"),
"crit_hz": self.tk.StringVar(value="-"),
"limit": self.tk.StringVar(value="-"),
"max_p": self.tk.StringVar(value="-"),
}
for offset, key in enumerate(("vdsp", "vamp", "p", "q", "s"), start=5):
for offset, key in enumerate(("vdsp", "vamp", "p", "q", "s", "headroom", "crit_hz", "limit"), start=5):
ttk.Label(self.power_frame, textvariable=vars_by_metric[key], width=12, anchor="e").grid(
row=row, column=offset, sticky="e", padx=(0, 12 if offset < 9 else 0), pady=4
)
@@ -906,12 +1018,12 @@ class LevelMonitorGUI:
style="PowerOk.Horizontal.TProgressbar",
length=150,
)
meter.grid(row=row, column=10, sticky="ew", padx=(8, 8), pady=4)
meter.grid(row=row, column=13, sticky="ew", padx=(8, 8), pady=4)
light = self.tk.Canvas(self.power_frame, width=18, height=18, highlightthickness=0)
light.grid(row=row, column=11, sticky="w", padx=(0, 6), pady=4)
light.grid(row=row, column=14, sticky="w", padx=(0, 6), pady=4)
light_id = light.create_oval(3, 3, 15, 15, fill="#22a447", outline="#555555")
ttk.Label(self.power_frame, textvariable=vars_by_metric["max_p"], width=12, anchor="e").grid(
row=row, column=12, sticky="e", pady=4
row=row, column=15, sticky="e", pady=4
)
vars_by_metric["meter"] = meter
vars_by_metric["light"] = light
@@ -972,12 +1084,15 @@ class LevelMonitorGUI:
fft_size = int(self.power_fft_size_var.get())
overlap = float(self.power_overlap_var.get())
smoothing = float(self.power_smoothing_var.get())
headroom_smoothing = float(self.headroom_smoothing_var.get())
if fft_size <= 0 or fft_size % 2:
raise ValueError("power FFT size must be a positive even number")
if not 0.0 <= overlap < 1.0:
raise ValueError("power overlap must be >= 0 and < 1")
if not 0.0 <= smoothing < 1.0:
raise ValueError("power smoothing must be >= 0 and < 1")
if not 0.0 <= headroom_smoothing < 1.0:
raise ValueError("headroom smoothing must be >= 0 and < 1")
hop_size = int(round(fft_size * (1.0 - overlap)))
if hop_size <= 0:
@@ -1011,9 +1126,10 @@ class LevelMonitorGUI:
names = {way.name for way in ways}
for name, row in self.power_rows.items():
if name not in names:
for key in ("vdsp", "vamp", "p", "q", "s", "max_p"):
for key in ("vdsp", "vamp", "p", "q", "s", "headroom", "crit_hz", "limit", "max_p"):
row[key].set("-")
row["meter"]["value"] = 0.0
row["meter"]["maximum"] = DEFAULT_POWER_CRITICAL_W
row["meter"].configure(style="PowerOk.Horizontal.TProgressbar")
row["light"].itemconfigure(row["light_id"], fill="#22a447")
@@ -1021,6 +1137,8 @@ class LevelMonitorGUI:
self.power_buffers = {}
self.power_history = {}
self.power_max_hold_w = {}
self.headroom_curves = {}
self.headroom_smoothed_db = {}
self.power_history_start_s = None
self.power_hop_size = hop_size
self.input_spectrum_estimators = {}
@@ -1050,6 +1168,42 @@ class LevelMonitorGUI:
self.power_history[way.name] = []
self.power_max_hold_w[way.name] = 0.0
self._load_gui_winisd_curves()
def _load_gui_winisd_curves(self) -> None:
self.headroom_curves = {}
self.headroom_smoothed_db = {}
if not self.winisd_enabled_var.get():
self.winisd_status_var.set("WinISD headroom disabled.")
return
lf_estimator = self.power_estimators.get("LF")
if lf_estimator is None:
self.winisd_status_var.set("WinISD headroom disabled: LF way is not active.")
return
path = Path(self.winisd_max_power_var.get().strip() or WINISD_MAX_POWER_FILE)
try:
curve = WinISDLimitCurve.from_file(path, eps_w=HEADROOM_EPS_W)
except Exception as exc:
self.winisd_status_var.set(f"WinISD headroom disabled: {exc}")
return
self.headroom_curves["LF"] = curve
status = (
f"WinISD headroom active: {path}, "
f"{curve.frequency_hz[0]:.1f}-{curve.frequency_hz[-1]:.1f} Hz."
)
excursion_10w = WINISD_EXCURSION_10W_FILE
excursion_100w = WINISD_EXCURSION_100W_FILE
if excursion_10w.exists() and excursion_100w.exists():
try:
check = check_excursion_scaling(excursion_10w, excursion_100w)
status += " " + check.message
except Exception as exc:
status += f" Excursion check skipped: {exc}"
self.winisd_status_var.set(status)
def _process_input_spectrum_block(self, block) -> None:
if np is None or not self.input_spectrum_estimators:
return
@@ -1109,29 +1263,73 @@ class LevelMonitorGUI:
row["p"].set(f"{latest.total_p_w:.1f} W")
row["q"].set(f"{latest.total_q_var:.1f} var")
row["s"].set(f"{latest.total_s_va:.1f} VA")
self._update_power_meter(name, latest.total_p_w)
headroom = self._compute_gui_headroom(name, latest)
if headroom is None:
row["headroom"].set("-")
row["crit_hz"].set("-")
row["limit"].set("-")
else:
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"])
self._update_power_meter(name, latest.total_p_w, headroom)
self._append_power_history(name, latest)
self.draw_power_history()
def _update_power_meter(self, name: str, power_w: float) -> None:
def _compute_gui_headroom(self, name: str, frame: PowerFrame) -> dict | None:
curve = self.headroom_curves.get(name)
if curve is None:
return None
headroom = curve.compute_headroom(frame.p_w_per_bin, frame.frequencies_hz)
raw = float(headroom["worst_headroom_db"])
try:
alpha = float(self.headroom_smoothing_var.get())
except ValueError:
alpha = HEADROOM_SMOOTHING_ALPHA
alpha = min(max(alpha, 0.0), 0.999999)
smoothed = smooth_headroom_db(self.headroom_smoothed_db.get(name), raw, alpha)
self.headroom_smoothed_db[name] = smoothed
headroom["worst_headroom_db"] = smoothed
headroom["status"] = headroom_status(smoothed)
headroom["over_limit_bool"] = bool(math.isfinite(smoothed) and smoothed <= 0.0)
return headroom
def _update_power_meter(self, name: str, power_w: float, headroom: dict | None = None) -> None:
row = self.power_rows.get(name)
if row is None:
return
max_hold = max(self.power_max_hold_w.get(name, 0.0), float(power_w))
self.power_max_hold_w[name] = max_hold
meter_value = min(max(float(power_w), 0.0), DEFAULT_POWER_CRITICAL_W)
if power_w >= DEFAULT_POWER_CRITICAL_W:
color = "#d62728"
style = "PowerCritical.Horizontal.TProgressbar"
elif power_w >= DEFAULT_POWER_WARNING_W:
color = "#e6a400"
style = "PowerWarn.Horizontal.TProgressbar"
if headroom is not None and math.isfinite(float(headroom["critical_pmax_w"])):
critical_pmax = max(float(headroom["critical_pmax_w"]), HEADROOM_EPS_W)
critical_power = max(float(headroom["critical_power_w"]), 0.0)
meter_value = min((critical_power / critical_pmax) * 100.0, 100.0)
row["meter"]["maximum"] = 100.0
status = headroom["status"]
if status == "LIMIT EXCEEDED":
color = "#d62728"
style = "PowerCritical.Horizontal.TProgressbar"
elif status == "WARNING":
color = "#e6a400"
style = "PowerWarn.Horizontal.TProgressbar"
else:
color = "#22a447"
style = "PowerOk.Horizontal.TProgressbar"
else:
color = "#22a447"
style = "PowerOk.Horizontal.TProgressbar"
meter_value = min(max(float(power_w), 0.0), DEFAULT_POWER_CRITICAL_W)
row["meter"]["maximum"] = DEFAULT_POWER_CRITICAL_W
if power_w >= DEFAULT_POWER_CRITICAL_W:
color = "#d62728"
style = "PowerCritical.Horizontal.TProgressbar"
elif power_w >= DEFAULT_POWER_WARNING_W:
color = "#e6a400"
style = "PowerWarn.Horizontal.TProgressbar"
else:
color = "#22a447"
style = "PowerOk.Horizontal.TProgressbar"
row["meter"]["value"] = meter_value
row["meter"].configure(style=style)
@@ -1505,15 +1703,18 @@ class LevelMonitorGUI:
self.power_buffers = {}
self.power_history = {}
self.power_max_hold_w = {}
self.headroom_curves = {}
self.headroom_smoothed_db = {}
self.power_history_start_s = None
self.power_hop_size = 0
for row in self.input_metric_rows.values():
for value_var in row.values():
value_var.set("-")
for row in self.power_rows.values():
for key in ("vdsp", "vamp", "p", "q", "s", "max_p"):
for key in ("vdsp", "vamp", "p", "q", "s", "headroom", "crit_hz", "limit", "max_p"):
row[key].set("-")
row["meter"]["value"] = 0.0
row["meter"]["maximum"] = DEFAULT_POWER_CRITICAL_W
row["meter"].configure(style="PowerOk.Horizontal.TProgressbar")
row["light"].itemconfigure(row["light_id"], fill="#22a447")
self.draw_spectrum_grid()
@@ -1830,6 +2031,27 @@ def build_arg_parser() -> argparse.ArgumentParser:
default=DEFAULT_POWER_UPDATE_INTERVAL_S,
help=f"Power estimator console update interval. Default: {DEFAULT_POWER_UPDATE_INTERVAL_S:g}.",
)
parser.add_argument(
"--winisd-max-power",
default=str(WINISD_MAX_POWER_FILE),
help=f"WinISD frequency-dependent max-power CSV for LF headroom. Default: {WINISD_MAX_POWER_FILE}.",
)
parser.add_argument(
"--disable-winisd-headroom",
action="store_true",
help="Disable WinISD max-power headroom monitoring.",
)
parser.add_argument(
"--headroom-smoothing-alpha",
type=float,
default=HEADROOM_SMOOTHING_ALPHA,
help=f"WinISD headroom smoothing alpha. Default: {HEADROOM_SMOOTHING_ALPHA:g}.",
)
parser.add_argument(
"--self-test-winisd",
action="store_true",
help="Run synthetic WinISD headroom tests and exit.",
)
parser.add_argument(
"--list-devices",
action="store_true",
@@ -1869,9 +2091,21 @@ def main(argv: list[str] | None = None) -> int:
parser.error("--power-overlap must be >= 0 and < 1")
if not 0.0 <= args.power_smoothing_alpha < 1.0:
parser.error("--power-smoothing-alpha must be >= 0 and < 1")
if not 0.0 <= args.headroom_smoothing_alpha < 1.0:
parser.error("--headroom-smoothing-alpha must be >= 0 and < 1")
if args.samplerate is not None and args.samplerate <= 0:
parser.error("--samplerate must be positive")
if args.self_test_winisd:
run_self_test()
for path in (Path(args.winisd_max_power), WINISD_EXCURSION_10W_FILE, WINISD_EXCURSION_100W_FILE):
if path.exists():
WinISDLimitCurve.from_file(path, eps_w=HEADROOM_EPS_W)
if WINISD_EXCURSION_10W_FILE.exists() and WINISD_EXCURSION_100W_FILE.exists():
print(check_excursion_scaling(WINISD_EXCURSION_10W_FILE, WINISD_EXCURSION_100W_FILE).message)
print("WinISD self-test OK")
return 0
sd = load_dependencies()
if args.list_devices: