diff --git a/README.md b/README.md index 609bad3..d5e30ff 100644 --- a/README.md +++ b/README.md @@ -115,7 +115,17 @@ The GUI provides: protection shut the output down), the sweep aborts immediately, reports 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 - 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 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 diff --git a/testbench/gui.py b/testbench/gui.py index a16cafc..54d2f0b 100644 --- a/testbench/gui.py +++ b/testbench/gui.py @@ -29,6 +29,7 @@ from testbench.bench import MPPTTestbench from testbench.gui_workers import InstrumentWorker, Cmd, STM32Worker from testbench.stm32_link import ( FLAG_NAMES, FLAG_INFO_MASK, build_ping, build_clear_flags, + flags_to_names, ) @@ -1746,6 +1747,25 @@ class TestbenchGUI(tk.Tk): return None 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 def _supply_trip_cause(bench) -> str: """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 " 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.output_on() bench._apply_load_value(load_mode, l_start) @@ -1920,6 +1977,8 @@ class TestbenchGUI(tk.Tk): n = 0 v = v_start applied = l_start # last load setpoint actually commanded + t_pt = None # running avg seconds per accepted point + t_last = time.monotonic() try: while not stop.is_set(): @@ -1960,9 +2019,11 @@ class TestbenchGUI(tk.Tk): continue # Pause near the firmware thermal trips (holds at ~1 A) + t0_hold = time.monotonic() held = self._thermal_hold(bench, load_mode, vout_est, stop) if held is not None: applied = held + t_last += time.monotonic() - t0_hold # ETA: skip hold if stop.is_set(): break @@ -1990,6 +2051,23 @@ class TestbenchGUI(tk.Tk): f"{n} collected point(s)", "error") stop.set() 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: vout_est = point.load_voltage # Measured backstop: the estimate can be off (eff, Vout) @@ -2011,6 +2089,17 @@ class TestbenchGUI(tk.Tk): ll += l_step 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 gui_data = { "supply_V": point.supply_voltage, @@ -2040,11 +2129,14 @@ class TestbenchGUI(tk.Tk): pass self._sweep_data_queue.put_nowait(gui_data) + mins = rem * t_pt / 60.0 self.after( 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} " - f"EFF={pt.efficiency:.1f}%" + f"EFF={pt.efficiency:.1f}% ETA {eta} " + f"({rem} pts, ~{mins:.0f} min left)" ), )