diff options
| author | Vaino Kauppila <vaino@vke.fi> | 2026-10-11 15:29:25 +0300 |
|---|---|---|
| committer | Vaino Kauppila <vaino@vke.fi> | 2026-10-11 15:29:25 +0300 |
| commit | 3366961e7881376d6192af92ff01f2dcf0b0a0c7 (patch) | |
| tree | 77b0da7064209e53ad8bf9db540a312d4fbc644f | |
| parent | abe94972619bcd48fa928ccefdff9a814971a5a5 (diff) | |
| download | kisim-3366961e7881376d6192af92ff01f2dcf0b0a0c7.tar.gz kisim-3366961e7881376d6192af92ff01f2dcf0b0a0c7.zip | |
The RC example now sweeps R1 (+-5%) and C1 (+-10%) extremes, reads the
pole off each transient (tau = 1 - 1/e crossing, fc = 1/(2*pi*tau)),
overlays the analytic scipy.signal step response per corner, and adds
a Bode plot of H(s) = 1/(1 + sRC) with pole/cutoff comparison table.
| -rw-r--r-- | README.md | 10 | ||||
| -rw-r--r-- | examples/rc_lp.py | 101 |
2 files changed, 106 insertions, 5 deletions
@@ -53,10 +53,14 @@ raw files. ## Example -`examples/` is a small runnable demo: +`examples/` is a small runnable demo — a 5 V step into an RC low-pass: -- `rc_lp.cir` — a plain ngspice netlist: 5 V step into 1 kΩ + 100 nF -- `rc_lp.py` — runs it with kisim and plots V(in)/V(out) +- `rc_lp.cir` — a plain ngspice netlist (1 kΩ, 100 nF) +- `rc_lp.py` — runs it; then sweeps R1/C1 tolerance corners of the step + response (`corners` + `run_cases`), reading τ and the pole (f−3dB) off each + transient; then cross-checks the transient results against the control + model H(s) = 1/(1 + sRC) with `scipy.signal` — analytic step overlay, poles + and a Bode plot ```sh micromamba run -n kicad_work python examples/rc_lp.py diff --git a/examples/rc_lp.py b/examples/rc_lp.py index 0a79b60..14b062c 100644 --- a/examples/rc_lp.py +++ b/examples/rc_lp.py @@ -1,6 +1,14 @@ #!/usr/bin/env python3 """Example: run a plain ngspice netlist with kisim -- no KiCad involved. +Part 1: a single transient run -- 5 V step into an RC low-pass. +Part 2: tolerance-corner sweep of R1/C1 (kisim corners + run_cases), reading + the pole of H(s) = 1/(1 + s*R*C) off each step response: a first-order + step reaches 1 - 1/e of its final value at t = tau = R*C, so + s = -1/tau and -3 dB at fc = 1/(2*pi*tau). +Part 3: the same as a control model with scipy.signal -- analytic step + response over the ngspice curves, poles, and a Bode plot. + micromamba run -n kicad_work python examples/rc_lp.py """ import sys @@ -8,11 +16,15 @@ from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +import matplotlib.pyplot as plt import numpy as np +from scipy import signal -from kisim import KiSim +from kisim import Case, KiSim, corners, run_cases, tol, worst sim = KiSim("rc_lp.cir", tran="1u 2m") # .cir input: used as-is, no kicad-cli + +# --------------------------------------------------------------- single run r = sim.run() # -> results/run.cir + run.raw (cached) tau = 1e3 * 100e-9 # R1 * C1 @@ -22,4 +34,89 @@ print(f"V(vout) at 1 tau: {np.interp(tau, r.t, vout):.3f} V (1 - 1/e of 5 V)") print(f"V(vout) settled: {vout[-1]:.3f} V") r.plot(["vin", "vout"], title="RC low-pass: 5 V step", file=sim.workdir / "rc_lp.png") -print("plot:", sim.workdir / "rc_lp.png") + + +# ---------------------------------------------------------- tolerance corners +def tau_fc(r): + """Read tau off the step response (the 1 - 1/e crossing), then the pole: + s = -1/tau, i.e. -3 dB at fc = 1/(2*pi*tau).""" + v = r.v("vout") + tau = float(np.interp(0.632 * v[-1], v, r.t)) + return {"tau (us)": tau * 1e6, "fc -3dB (kHz)": 1e-3 / (2 * np.pi * tau)} + + +nominal = Case("nominal", analyses=[tau_fc], + limits={"fc -3dB (kHz)": lambda fc: 1.3 <= fc <= 2.0}) +wca = corners(nominal, { + "R1": tol("1k", 0.05), # +/- 5 % -> tau 85.5 .. 115.5 us, + "C1": tol("100n", 0.10), # +/- 10 % fc 1.38 .. 1.86 kHz +}, mode="extremes") # every min/max combination: 2^2 runs + nominal +res = run_cases(sim, [nominal] + wca) + +f_lo = worst(res, "fc -3dB (kHz)", "min", prefix="nominal__") +f_hi = worst(res, "fc -3dB (kHz)", "max", prefix="nominal__") +print(f"\npole spread over the corners: fc = {f_lo[1]:.2f} .. {f_hi[1]:.2f} kHz (s = -1/tau)") + + +def corner_label(name): + return "nominal" if name == "nominal" else name.split("__", 1)[1].replace("__", ", ") + + +def rc_of(name): + """R1/C1 back out of the corner tag, e.g. nominal__R1=950__C1=9e-08.""" + if name == "nominal": + return 1e3, 100e-9 + vals = dict(kv.split("=") for kv in name.split("__")[1:]) + return float(vals["R1"]), float(vals["C1"]) + + +# ngspice step responses (solid) with the analytic model overlaid (dashed) +fig, ax = plt.subplots(figsize=(8.5, 4.5)) +for name, entry in res.items(): + rr = entry["result"] + R1c, C1c = rc_of(name) + t_model = np.linspace(0, rr.t[-1], 2001) + line = ax.plot(rr.t * 1e6, rr.v("vout"), label=corner_label(name))[0] + _, y_model = signal.step(signal.TransferFunction([1.0], [R1c * C1c, 1.0]), T=t_model) + ax.plot(t_model * 1e6, 5 * y_model, "--", lw=1, alpha=0.65, # unit step -> 5 V drive + color=line.get_color()) +ax.plot([np.nan], [np.nan], "k--", lw=1, alpha=0.65, label="scipy model") +ax.axhline(0.632 * 5, color="grey", ls=":", lw=1) +ax.text(1950, 3.35, "63.2 % = 1 - 1/e", fontsize=8, color="grey", ha="right") +ax.set(xlabel="time (us)", ylabel="V(vout) (V)", + title="RC low-pass at R1/C1 tolerance corners (dashed: scipy.signal model)") +ax.grid(alpha=0.3) +ax.legend(fontsize=8) +fig.tight_layout() +fig.savefig(sim.workdir / "rc_lp_corners.png", dpi=110) +print("plot:", sim.workdir / "rc_lp_corners.png") + +# --------------------------------------------------- s-domain / control view +print("\n corner pole (rad/s) fc (kHz) tau sim tau = RC (us)") +for name in res: + R1c, C1c = rc_of(name) + sysc = signal.TransferFunction([1.0], [R1c * C1c, 1.0]) + p = sysc.poles[0] + print(f" {name:30s} {p:13.1f} {abs(p) / (2 * np.pi * 1e3):9.3f}" + f" {res[name]['metrics']['tau (us)']:8.1f} {R1c * C1c * 1e6:11.1f}") + +w = 2 * np.pi * np.logspace(1.5, 5, 300) # 30 Hz .. 100 kHz +fig, (axm, axp) = plt.subplots(2, 1, figsize=(8.5, 5.5), sharex=True) +for name in res: + R1c, C1c = rc_of(name) + wm, mag, phase = signal.bode(signal.TransferFunction([1.0], [R1c * C1c, 1.0]), w) + f_khz = wm / (2 * np.pi) / 1e3 + axm.semilogx(f_khz, mag, label=corner_label(name)) + axp.semilogx(f_khz, phase) +axm.axhline(-3.01, color="grey", ls=":", lw=1) +axm.text(12, -4.0, "-3 dB", fontsize=8, color="grey", ha="center", va="top") +axm.set_ylabel("|H(s)| (dB)") +axp.set_ylabel("phase (deg)") +axp.set_xlabel("frequency (kHz)") +axm.set_title("s-domain view: H(s) = 1 / (1 + s R C) per corner") +axm.grid(alpha=0.3, which="both") +axp.grid(alpha=0.3, which="both") +axm.legend(fontsize=8) +fig.tight_layout() +fig.savefig(sim.workdir / "rc_lp_bode.png", dpi=110) +print("plot:", sim.workdir / "rc_lp_bode.png") |
