Compare commits

..
2 Commits
Author SHA1 Message Date
janikandClaude Fable 5 158e1ccf03 stm32_link: sync iin_avg_ma comment with live firmware protocol
Firmware now runs BOTH iin limits (IIN_MAX reverse-feed and the IIN_MIN
forward OCP, raised to -47A) on the 8-sample boxcar; wire format is
unchanged (verified field-by-field against the live code64 console,
including a synthetic-frame decode). Note the field is a raw int16 mA
cast and wraps at +-32.768A (observed +26.5A at a true -39.0A) -- the
float iin field is the honest monitor above that.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 17:06:00 +07:00
janikandClaude Fable 5 465455d427 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>
2026-07-07 14:39:48 +07:00
3 changed files with 106 additions and 4 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)"
), ),
) )
+1 -1
View File
@@ -164,7 +164,7 @@ class BroadcastData:
param_value: int = 0 # raw bits; interpret via decode_param_bits(param_type, ...) param_value: int = 0 # raw bits; interpret via decode_param_bits(param_type, ...)
pong: int = 0 # increments when the MCU processes CMD_PING pong: int = 0 # increments when the MCU processes CMD_PING
sys_current_ma: int = 0 # Vout-rail housekeeping current, mA sys_current_ma: int = 0 # Vout-rail housekeeping current, mA
iin_avg_ma: int = 0 # 8-sample boxcar of iin, mA (the IIN_MAX trip quantity) iin_avg_ma: int = 0 # 8-sample boxcar of iin, mA (the IIN_MAX/IIN_MIN trip quantity)
timestamp: float = field(default_factory=time.time) timestamp: float = field(default_factory=time.time)
@property @property