GUI sweep: converter-collapse abort + measured finish-time estimate

The mid-sweep abort only watched the supply readback (~0V = supply
protection tripped), so a DUT-side collapse sailed past it: run
2026-07-07 10:58, Vout 48->3.1V at Vin=55V with the supply still up at
55V/0A, sweep kept stepping the 75V column through garbage. Add a
second abort when the load voltage drops below half the expected Vout;
the message names the STM32 limit/fault bits from the last frame
before the brownout blackout (the MCU rides the Vout rail).

Status line now appends ETA hh:mm (N pts, ~M min left): remaining
grid is deterministic (feasibility-gated steps), per-point cost is not
(instrument round-trips vary per setup), so it is a running average of
measured point times (thermal holds excluded) extrapolated over the
remaining feasible points. Grid size is printed at sweep start.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
janik
2026-07-07 14:39:48 +07:00
co-authored by Claude Fable 5
parent f709d634e8
commit 465455d427
2 changed files with 105 additions and 3 deletions
+11 -1
View File
@@ -115,7 +115,17 @@ The GUI provides:
protection shut the output down), the sweep aborts immediately, reports protection shut the output down), the sweep aborts immediately, reports
which protection fired (OVP / OV / OC / OP / OT), and saves the points which protection fired (OVP / OV / OC / OP / OT), and saves the points
collected so far instead of logging garbage rows for the rest of the collected so far instead of logging garbage rows for the rest of the
grid. grid. A converter-output collapse (load voltage below half the expected
Vout while the supply is still up -- the DUT shut itself down) aborts
the same way; when the STM32 link is up the abort message names the
firmware limit/fault bits from the last frame before the brownout
blackout.
- Sweep finish estimate: the status line shows `ETA hh:mm (N pts, ~M min
left)` next to each measured point. The remaining grid is deterministic
(the steps that pass the feasibility gate), but the cost per point is
not (settle time + instrument round-trips that vary per setup), so the
per-point time is measured as a running average -- thermal-hold pauses
excluded -- and extrapolated over the remaining feasible points.
- Load range pinning: a mid-sweep auto-range transition on the Prodigit - Load range pinning: a mid-sweep auto-range transition on the Prodigit
momentarily unloads the converter, so at sweep start the CC range is momentarily unloads the converter, so at sweep start the CC range is
pinned to Range II for the whole run (auto-ranging restored after, with pinned to Range II for the whole run (auto-ranging restored after, with
+94 -2
View File
@@ -29,6 +29,7 @@ from testbench.bench import MPPTTestbench
from testbench.gui_workers import InstrumentWorker, Cmd, STM32Worker from testbench.gui_workers import InstrumentWorker, Cmd, STM32Worker
from testbench.stm32_link import ( from testbench.stm32_link import (
FLAG_NAMES, FLAG_INFO_MASK, build_ping, build_clear_flags, FLAG_NAMES, FLAG_INFO_MASK, build_ping, build_clear_flags,
flags_to_names,
) )
@@ -1746,6 +1747,25 @@ class TestbenchGUI(tk.Tk):
return None return None
return b.etemp, b.btemp return b.etemp, b.btemp
def _stm32_fault_note(self) -> str:
"""Last STM32 fault flags as a console suffix, '' when unlinked.
On a converter collapse the MCU (fed from the Vout rail) browns
out, so the LAST frame before the blackout usually carries the
limit/fault bit that fired -- report it even when stale.
"""
w = self.stm32
if not w:
return ""
b, wall = w.get_latest()
if b is None:
return ""
names = flags_to_names(b.status_flags & ~FLAG_INFO_MASK)
txt = ", ".join(names) if names else "no fault bits"
age = time.time() - wall
stale = f", {age:.0f}s stale" if age > TELEM_STALE_S else ""
return f" [STM32: {txt}{stale}]"
@staticmethod @staticmethod
def _supply_trip_cause(bench) -> str: def _supply_trip_cause(bench) -> str:
"""Best-effort query of which supply protection fired.""" """Best-effort query of which supply protection fired."""
@@ -1911,6 +1931,43 @@ class TestbenchGUI(tk.Tk):
self._console(f"Load range readback failed ({e}) - range check " self._console(f"Load range readback failed ({e}) - range check "
f"skipped", "warn") f"skipped", "warn")
# Finish-time prediction. The grid is deterministic (steps that
# pass the feasibility gate), but the cost per point is NOT: it is
# settle (known) + instrument round-trips (HIOKI auto-range wait +
# VISA latency, varies per instrument setup) + occasional holds.
# So the per-point time is MEASURED as a running average and
# extrapolated over the remaining feasible grid points.
def _in_range(x, stop_, step_):
return (x <= stop_ + step_ / 2 if step_ > 0
else x >= stop_ + step_ / 2)
def _gate_ok(ll_, v_):
# mirror of the in-loop rejection gate (reads the live
# vout_est / range_max / psu_imax)
if range_max is not None and ll_ > range_max * 1.001:
return False
return _est_input_current(load_mode, ll_, v_, vout_est) <= psu_imax
def _pts_remaining(v_now, ll_next):
"""Feasible grid points from (v_now, ll_next) to the end."""
if v_step == 0 or l_step == 0:
return 0
cnt = 0
vv, ll_ = v_now, ll_next
while _in_range(vv, v_stop, v_step):
while _in_range(ll_, l_stop, l_step):
if _gate_ok(ll_, vv):
cnt += 1
ll_ += l_step
vv += v_step
ll_ = l_start
return cnt
self._console(
f"Sweep grid: {_pts_remaining(v_start, l_start)} feasible "
f"point(s) planned - finish estimate appears after the first "
f"point")
bench.supply.set_current(current_limit) bench.supply.set_current(current_limit)
bench.supply.output_on() bench.supply.output_on()
bench._apply_load_value(load_mode, l_start) bench._apply_load_value(load_mode, l_start)
@@ -1920,6 +1977,8 @@ class TestbenchGUI(tk.Tk):
n = 0 n = 0
v = v_start v = v_start
applied = l_start # last load setpoint actually commanded applied = l_start # last load setpoint actually commanded
t_pt = None # running avg seconds per accepted point
t_last = time.monotonic()
try: try:
while not stop.is_set(): while not stop.is_set():
@@ -1960,9 +2019,11 @@ class TestbenchGUI(tk.Tk):
continue continue
# Pause near the firmware thermal trips (holds at ~1 A) # Pause near the firmware thermal trips (holds at ~1 A)
t0_hold = time.monotonic()
held = self._thermal_hold(bench, load_mode, vout_est, stop) held = self._thermal_hold(bench, load_mode, vout_est, stop)
if held is not None: if held is not None:
applied = held applied = held
t_last += time.monotonic() - t0_hold # ETA: skip hold
if stop.is_set(): if stop.is_set():
break break
@@ -1990,6 +2051,23 @@ class TestbenchGUI(tk.Tk):
f"{n} collected point(s)", "error") f"{n} collected point(s)", "error")
stop.set() stop.set()
break break
# Converter-output collapse abort: the DUT shut itself
# down (firmware limit / fault) while the supply stayed
# up -- the supply-side check above never sees this
# (run 2026-07-07: Vout 48->3V at Vin=55V and the sweep
# kept stepping the rest of the grid).
if point.load_voltage < vout_est * 0.5:
results.pop() # this point is garbage
n -= 1
self._console(
f"Converter output collapsed at V={v:g}V "
f"{load_mode}={ll:g}{unit}: Vout "
f"{point.load_voltage:.1f}V, expected ~"
f"{vout_est:.0f}V{self._stm32_fault_note()} - "
f"aborting sweep, saving {n} collected "
f"point(s)", "error")
stop.set()
break
if point.load_voltage > 5.0: if point.load_voltage > 5.0:
vout_est = point.load_voltage vout_est = point.load_voltage
# Measured backstop: the estimate can be off (eff, Vout) # Measured backstop: the estimate can be off (eff, Vout)
@@ -2011,6 +2089,17 @@ class TestbenchGUI(tk.Tk):
ll += l_step ll += l_step
continue continue
# Update the finish-time estimate from this point's
# measured wall time (thermal holds already excluded)
now_m = time.monotonic()
dt_pt = now_m - t_last
t_last = now_m
t_pt = (dt_pt if t_pt is None
else 0.7 * t_pt + 0.3 * dt_pt)
rem = _pts_remaining(v, ll + l_step)
eta = time.strftime(
"%H:%M", time.localtime(time.time() + rem * t_pt))
# Push data for live graph/readout updates # Push data for live graph/readout updates
gui_data = { gui_data = {
"supply_V": point.supply_voltage, "supply_V": point.supply_voltage,
@@ -2040,11 +2129,14 @@ class TestbenchGUI(tk.Tk):
pass pass
self._sweep_data_queue.put_nowait(gui_data) self._sweep_data_queue.put_nowait(gui_data)
mins = rem * t_pt / 60.0
self.after( self.after(
0, 0,
lambda v=v, ll=ll, pt=point, n=n: self._svi_status.config( lambda v=v, ll=ll, pt=point, n=n, eta=eta, rem=rem,
mins=mins: self._svi_status.config(
text=f"[{n}] V={v:.1f}V {load_mode}={ll:.1f}{unit} " text=f"[{n}] V={v:.1f}V {load_mode}={ll:.1f}{unit} "
f"EFF={pt.efficiency:.1f}%" f"EFF={pt.efficiency:.1f}% ETA {eta} "
f"({rem} pts, ~{mins:.0f} min left)"
), ),
) )