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:
@@ -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
@@ -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)"
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user