"""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)
