Files
mppt-testbench/code64/debug_console/duty_fft.py
T
janikandClaude Opus 4.8 d2dfc73f9e tooling: sync bench + debug console to live STM32 protocol
The mppt-testbench Python tooling had drifted from the flashed firmware
(bundled fw e7a23a3 vs live 1b85532) and could no longer communicate:

- stm32_link.py: CRC8 -> CRC-16/CCITT-FALSE; telemetry 68B -> 78B
  (btemp, cmp_outer/inner, iout_slow, vfly_ofs_applied); add PTYPE_INT16
  and commands 0x12-0x18; replace PARAMS with the current 37-param map
  (single dt_normal, no dt brackets; test_corr/phase_ofs, phase PI,
  precharge PI, duty dither; vfly_active 0-3).
- tuner.py: retire per-bracket deadtime; sweep the single dt_normal.
- cli.py: update tune-deadtime, help/examples, btemp readout;
  default ports COM11 (load) / COM4 (stm32).
- debug console TUI: sync protocol.py/app.py/status_bar/telemetry_panel
  from live (new command keys, link RX/TX/loss stats, single dead-time,
  new telemetry fields, param-write auto-retry); add duty_fft.py.
- README: rewrite parameter table, deadtime section, ports, keybindings.

Verified: protocol round-trip self-tests + live `bench stm32-read`
reading all 37 params over COM4.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 12:03:51 +07:00

140 lines
5.2 KiB
Python

"""Reconstruct per-cycle duty from a G1-G4 logic capture and FFT it to find
the low-frequency (audible) modulation. G1=T1 (outer/Timer F), G2=T2 (inner/
Timer E). common = main-loop duty, diff = V_fly duty-asymmetry (vfly_correction)."""
import sys
import numpy as np
CSV = sys.argv[1] if len(sys.argv) > 1 else "digital.csv"
# --- load (int8 gates to keep memory down) ---
try:
import pandas as pd
df = pd.read_csv(CSV, dtype={"Time [s]": np.float64, "G1": np.int8,
"G2": np.int8, "G3": np.int8, "G4": np.int8})
t = df["Time [s]"].to_numpy()
G = {c: df[c].to_numpy() for c in ("G1", "G2", "G3", "G4")}
except ImportError:
raw = np.genfromtxt(CSV, delimiter=",", skip_header=1)
t = raw[:, 0]
G = {f"G{i}": raw[:, i].astype(np.int8) for i in range(1, 5)}
print(f"rows={len(t)} span={t[-1]-t[0]:.4f}s")
def duty_series(g):
"""Return (cycle_start_times, duty[0..1]) from rising/falling edges of g."""
dg = np.diff(g.astype(np.int16))
tr = t[np.where(dg == 1)[0] + 1] # rising-edge times (cycle starts)
tf = t[np.where(dg == -1)[0] + 1] # falling-edge times
idx = np.searchsorted(tf, tr, side="right")
valid = idx < len(tf)
tr, hi = tr[valid], tf[np.clip(idx[valid], 0, len(tf) - 1)] - tr[valid]
period = np.diff(tr)
return tr[:-1], hi[:-1] / period
t_o, d_o = duty_series(G["G1"]) # outer
t_i, d_i = duty_series(G["G2"]) # inner
fsw = 1.0 / np.median(np.diff(t_o))
print(f"f_sw ~= {fsw/1e3:.1f} kHz outer cycles={len(d_o)} inner cycles={len(d_i)}")
print(f"D_outer mean={d_o.mean():.4f} std={d_o.std():.4f} | "
f"D_inner mean={d_i.mean():.4f} std={d_i.std():.4f}")
# resample both onto a uniform grid at f_sw, align, build common / diff
t0, t1 = max(t_o[0], t_i[0]), min(t_o[-1], t_i[-1])
fs = fsw
tu = np.arange(t0, t1, 1.0 / fs)
do_u = np.interp(tu, t_o, d_o)
di_u = np.interp(tu, t_i, d_i)
common = 0.5 * (do_u + di_u) # main control loop
diff = 0.5 * (do_u - di_u) # V_fly asymmetry (= corr/MAX_DUTY)
def top_peaks(x, label, n=6, fmin=50.0, fmax=40e3):
x = x - x.mean()
win = np.hanning(len(x))
X = np.abs(np.fft.rfft(x * win)) / (len(x) * 0.5)
f = np.fft.rfftfreq(len(x), d=1.0 / fs)
band = (f >= fmin) & (f <= fmax)
fb, Xb = f[band], X[band]
order = np.argsort(Xb)[::-1]
# de-duplicate neighbouring bins (keep local maxima >= 25 Hz apart)
picks, fseen = [], []
for i in order:
if all(abs(fb[i] - fs0) > 25 for fs0 in fseen):
picks.append(i); fseen.append(fb[i])
if len(picks) >= n:
break
print(f"\n[{label}] rms={x.std()*1e3:.3f}e-3 duty")
for i in picks:
print(f" {fb[i]:9.1f} Hz amp={Xb[i]*1e3:8.4f}e-3 "
f"({Xb[i]/Xb[order[0]]*100:5.1f}% of peak)")
return fb[order[0]], Xb[order[0]]
top_peaks(common, "COMMON (main loop)")
top_peaks(diff, "DIFF (V_fly asymmetry)")
top_peaks(do_u, "OUTER (G1/Timer F)")
# strongest line above the mains band, + harmonic-ladder check
def spectrum(x):
x = x - x.mean()
X = np.abs(np.fft.rfft(x * np.hanning(len(x)))) / (len(x) * 0.5)
f = np.fft.rfftfreq(len(x), d=1.0 / fs)
return f, X
fc, Xc = spectrum(common)
above = fc > 400.0
ipk = np.argmax(Xc[above])
fpk = fc[above][ipk]
print(f"\n[COMMON strongest >400Hz] {fpk:.1f} Hz")
print(f" f_sw/f_pk = {fsw/fpk:.2f} ; 50kHz-loop/f_pk = {50e3/fpk:.3f}")
# --- zoom 300-1500 Hz: where is the 500-600 Hz energy, common or diff? ---
def band_peak(x, lbl, lo=300, hi=1500):
f, X = spectrum(x)
b = (f >= lo) & (f <= hi)
i = np.argmax(X[b])
print(f" [{lbl}] {lo}-{hi}Hz peak: {f[b][i]:7.1f} Hz amp={X[b][i]*1e3:.4f}e-3")
print("\n=== 300-1500 Hz band ===")
band_peak(common, "COMMON")
band_peak(diff, "DIFF ")
# --- detect large cycle-to-cycle duty steps (per-cycle, not resampled) ---
def steps(td, d, lbl, thr=0.02):
jumps = np.abs(np.diff(d))
idx = np.where(jumps > thr)[0]
print(f"\n[{lbl}] cyc-to-cyc |dD|>{thr*100:.0f}%: {len(idx)} events "
f"(of {len(d)} cyc), max step={jumps.max()*100:.2f}%")
if len(idx) > 3:
ev_t = td[idx + 1]
gaps = np.diff(ev_t)
gaps = gaps[gaps > 1e-4] # ignore bursts within one event
if len(gaps):
med = np.median(gaps)
print(f" median spacing {med*1e3:.3f} ms -> {1/med:.1f} Hz "
f"(min {gaps.min()*1e3:.2f}ms max {gaps.max()*1e3:.2f}ms)")
steps(t_o, d_o, "OUTER step rate")
steps(t_i, d_i, "INNER step rate")
# plot
try:
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
fig, ax = plt.subplots(3, 1, figsize=(11, 9), sharex=True)
for a, (x, lbl) in zip(ax, [(common, "COMMON (main loop)"),
(diff, "DIFF (V_fly asymmetry)"),
(do_u, "OUTER (G1)")]):
f, X = spectrum(x)
m = f <= 50e3
a.semilogy(f[m] / 1e3, X[m])
a.set_ylabel(lbl + "\nduty amp"); a.grid(True, which="both", alpha=0.3)
a.axvline(24.77, color="r", ls=":", lw=1)
ax[-1].set_xlabel("kHz")
fig.tight_layout()
fig.savefig("debug_console/duty_fft.png", dpi=110)
print("\nsaved debug_console/duty_fft.png")
except Exception as e:
print("plot skipped:", e)