#!/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()