notes/butter_plot.py

108 lines
3.5 KiB
Python

#!/usr/bin/env python3
"""
2nd-order unity-gain Butterworth low-pass (Sallen-Key design) calculator + Bode plots.
- Computes R1=R2=R (user-chosen) and C1,C2 from TI normalized values:
C1n=1.414, C2n=0.707, with scaling C = Cn/(R*ω0), ω0=2πfc
- Plots magnitude (dB) and phase (deg) of the ideal 2nd-order Butterworth:
H(s) = ω0^2 / (s^2 + (ω0/Q)s + ω0^2), Q=1/√2
Usage:
python3 butterworth_sallenkey_bode.py 10000 --R 10000
"""
import argparse
import math
import numpy as np
import matplotlib.pyplot as plt
C1N = 1.414
C2N = 0.707
Q_BUTTERWORTH = 1.0 / math.sqrt(2.0)
def format_si(x, unit=""):
if x == 0:
return f"0 {unit}".strip()
prefixes = [
(1e-12, "p"), (1e-9, "n"), (1e-6, "u"), (1e-3, "m"),
(1, ""), (1e3, "k"), (1e6, "M"), (1e9, "G")
]
ax = abs(x)
# choose prefix so scaled value is ~[1..1000)
best = min(prefixes, key=lambda p: abs(math.log10(ax/p[0])))
scale, pref = best
return f"{x/scale:.4g} {pref}{unit}".strip()
def butterworth_H(jw, w0, Q=Q_BUTTERWORTH):
# H(jw) = w0^2 / ( (jw)^2 + (w0/Q)*(jw) + w0^2 )
s = 1j * jw
return (w0**2) / (s**2 + (w0/Q)*s + w0**2)
def main():
ap = argparse.ArgumentParser(description="Butterworth Sallen-Key LPF (2nd order) calculator + Bode plotter.")
ap.add_argument("fc", type=float, help="Cutoff frequency (Hz), e.g. 10000")
ap.add_argument("--R", type=float, default=10_000.0, help="Choose R1=R2=R in ohms (default 10000)")
ap.add_argument("--fmin", type=float, default=None, help="Plot min frequency (Hz). Default fc/100")
ap.add_argument("--fmax", type=float, default=None, help="Plot max frequency (Hz). Default fc*100")
ap.add_argument("--points", type=int, default=2000, help="Number of frequency points (default 2000)")
ap.add_argument("--save", type=str, default=None, help="Save plot to PNG path instead of showing it")
args = ap.parse_args()
fc = args.fc
R = args.R
w0 = 2.0 * math.pi * fc
# Component calculation (ideal per TI normalized values)
C1 = C1N / (R * w0)
C2 = C2N / (R * w0)
print("2nd-order unity-gain Butterworth Sallen-Key LPF (ideal)")
print(f"fc = {format_si(fc, 'Hz')}")
print(f"R1=R2= {format_si(R, 'ohm')}")
print(f"C1 = {format_si(C1, 'F')} (norm 1.414)")
print(f"C2 = {format_si(C2, 'F')} (norm 0.707)")
print(f"Q = {Q_BUTTERWORTH:.6g}")
print()
# Frequency axis for Bode plot
fmin = args.fmin if args.fmin is not None else fc / 100.0
fmax = args.fmax if args.fmax is not None else fc * 100.0
if fmin <= 0:
fmin = fc / 100.0
f = np.logspace(np.log10(fmin), np.log10(fmax), args.points)
w = 2.0 * np.pi * f
H = butterworth_H(w, w0, Q_BUTTERWORTH)
mag_db = 20.0 * np.log10(np.abs(H))
phase_deg = np.unwrap(np.angle(H)) * 180.0 / np.pi
# Plot
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(9, 7), sharex=True)
ax1.semilogx(f, mag_db)
ax1.axvline(fc, linestyle="--", linewidth=1)
ax1.axhline(-3.0, linestyle="--", linewidth=1)
ax1.set_ylabel("Magnitude (dB)")
ax1.grid(True, which="both", linestyle=":")
ax2.semilogx(f, phase_deg)
ax2.axvline(fc, linestyle="--", linewidth=1)
ax2.set_ylabel("Phase (deg)")
ax2.set_xlabel("Frequency (Hz)")
ax2.grid(True, which="both", linestyle=":")
fig.suptitle(f"2nd-order Butterworth LPF (fc={fc:g} Hz, R={R:g} Ω)")
fig.tight_layout()
if args.save:
plt.savefig(args.save, dpi=200)
print(f"Saved plot to: {args.save}")
else:
plt.show()
if __name__ == "__main__":
main()