main
Colby Knox Altium Designer build of the TPS54202 buck molecule: Altium project, manufacturing set, STEP and GLB, design, simulatio… 845dd0d 4d ago
#!/usr/bin/env python3
"""Loop gain of the TPS54202 5 V design in ngspice (averaged peak-current-mode model).

The power stage is the standard PCM average model: the inductor becomes a current source Vc/Ri
into COUT || load, followed by the sampling double pole at fs/2 (Ridley). TI does not publish the
internal compensation; the equivalent gm-stage (gm*Rc/Ri) is calibrated so that, with no Cff, the
crossover equals the datasheet's own estimate eq 14 (fo = 3.95 / (VOUT * COUT) kHz, COUT in uF).
The loop is broken with a Middlebrook injection between VOUT and the divider.

Run: python3 loop.py  -> loop.csv, loop.json, loop.png
"""
import json, math, subprocess, os
import numpy as np

HERE = os.path.dirname(os.path.abspath(__file__))
calcs = json.load(open(os.path.join(HERE, "..", "design", "calcs.json")))
VREF, VOUT, FSW, L = 0.596, 5.0, 500e3, 15e-6
RTOP, RBOT = calcs["feedback"]["Rtop"], calcs["feedback"]["Rbot"]
COUT = calcs["cout"]["effective_uF"] * 1e-6
ESR = 0.003 / 3
RI = 0.25                                   # V/A, equivalent current-sense gain (only gm*Rc/Ri matters mid-band)
GMRC = 3.95 * 2 * math.pi / VREF * RI       # calibration to eq 14: fo[Hz] = 3.95 / (VOUT * COUT[F])
GM = 1e-4
RC = GMRC / GM
CC = 1 / (2 * math.pi * 2e3 * RC)           # comp zero ~2 kHz
CP = 1 / (2 * math.pi * 200e3 * RC)         # HF pole ~200 kHz
RO = 1000 / GM                              # EA DC gain 60 dB


def netlist(vin, rload, cff):
    D = VOUT / vin
    Sn = (vin - VOUT) / L * RI              # on-slope at the sense output (V/s)
    Se = 0.5 * VOUT / L * RI                # slope comp: half the down-slope
    mc = 1 + Se / Sn
    Qp = 1 / (math.pi * (mc * (1 - D) - 0.5))
    wn = math.pi * FSW
    # sampling double pole as a series RLC low-pass: wn = 1/sqrt(LC), Q = sqrt(L/C)/R
    Cs = 1e-9
    Ls = 1 / (wn**2 * Cs)
    Rs = math.sqrt(Ls / Cs) / Qp
    cff_line = f"Cff vfbtop fb {cff}p" if cff else ""
    return f"""* TPS54202 PCM loop gain vin={vin} rload={rload} cff={cff}p
Vx vout vfbtop DC 0 AC 1
Rtop vfbtop fb {RTOP}
{cff_line}
Rbot fb 0 {RBOT}
* gm error amplifier (fb vs Vref as AC ground), internal type-II equivalent
Gea vc 0 fb 0 {GM}
Rea vc 0 {RO}
Rc vc cz {RC}
Cc cz 0 {CC}
Cp vc 0 {CP}
* sampling double pole on the control path
Ebuf vcb 0 vc 0 1
Rs vcb s1 {Rs}
Ls s1 s2 {Ls}
Cs s2 0 {Cs}
* PCM average power stage: iL = Vc/Ri into COUT||load
Gpwr 0 vout s2 0 {1/RI}
Cout vout ce {COUT}
Resr ce 0 {ESR}
Rload vout 0 {rload}
.ac dec 200 100 1meg
.control
run
let T = -v(vout)/v(vfbtop)
wrdata {HERE}/_ac.txt vdb(vout) vdb(vfbtop) vp(vout) vp(vfbtop)
.endc
.end
"""


def run(vin, rload, cff):
    open(os.path.join(HERE, "_loop.cir"), "w").write(netlist(vin, rload, cff))
    subprocess.run(["ngspice", "-b", os.path.join(HERE, "_loop.cir")], capture_output=True, check=True)
    d = np.loadtxt(os.path.join(HERE, "_ac.txt"))
    f = d[:, 0]
    H = 10 ** ((d[:, 1] - d[:, 3]) / 20) * np.exp(1j * (d[:, 5] - d[:, 7]))
    T = -H                                   # vout = -T * vfbtop at the injection point
    mag_db = 20 * np.log10(np.abs(T))
    ph = np.degrees(np.angle(T))             # wrapped to (-180, 180]; PM = 180 + angle at crossover
    i = np.where(np.diff(np.sign(mag_db)) < 0)[0]
    if not len(i):
        return f, mag_db, ph, None, None
    k = i[0]
    fc = f[k] + (f[k + 1] - f[k]) * (0 - mag_db[k]) / (mag_db[k + 1] - mag_db[k])
    phc = ph[k]
    return f, mag_db, ph, fc, 180 + phc if phc < 0 else 180 - phc


results = []
curves = {}
for cff in (0, 47, 68, 100):
    for vin in (9, 12, 16):
        for rload in (5.0, 10.0):
            f, m, p, fc, pm = run(vin, rload, cff)
            results.append({"cff_pF": cff, "vin": vin, "iout": VOUT / rload,
                            "fc_kHz": round(fc / 1e3, 2) if fc else None, "pm_deg": round(pm, 1) if pm else None})
            if rload == 5.0 and vin == 12:
                curves[cff] = (f, m, p)
json.dump({"model": {"Ri": RI, "gm": GM, "Rc": RC, "Cc": CC, "Cp": CP, "gmRc_over_Ri": GMRC / RI,
                     "calibration": "eq 14, no Cff"}, "results": results},
          open(os.path.join(HERE, "loop.json"), "w"), indent=1)
for r in results:
    print(r)

import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
fig, (a1, a2) = plt.subplots(2, 1, figsize=(10, 7), sharex=True)
for cff, (f, m, p) in curves.items():
    a1.semilogx(f, m, label=f"Cff {cff} pF")
    a2.semilogx(f, 180 + p, label=f"Cff {cff} pF")
a1.axhline(0, color="k", lw=0.6); a1.set_ylabel("|T| dB"); a1.legend(); a1.grid(which="both", alpha=0.3)
a2.set_ylabel("phase margin curve (180+∠T) deg"); a2.set_xlabel("Hz"); a2.grid(which="both", alpha=0.3)
a1.set_title("TPS54202 5 V / 1 A loop gain, VIN 12 V (ngspice, PCM average model calibrated to datasheet eq 14)")
fig.tight_layout(); fig.savefig(os.path.join(HERE, "loop.png"), dpi=120)