The squared transfer function for a Butterworth filter of order $n$ should be
$$ |H(f)|^2 = \frac{1}{1+\left(\frac{f}{f_c}\right)^{2n}} $$
where $f_c$ is the cut-off frequency. (Here's one of many possible references.)
I'm unable to correctly reproduce this with either Python or Matlab. For example, let's say I want a third-order filter ($n=3$) with a $f_\mathrm{c}$ = 20 Hz cut-off frequency for a signal sampled at $f_\mathrm{s}$ = 100 Hz.
In Python, I would implement this as
import numpy as np
import matplotlib.pyplot as plt
from scipy.signal import butter, sosfreqz
fc = 20 # Hz, Cut-off frequency
fs = 100 # Hz, sampling frequency
order = 3 # Filter order
nyq = 0.5*fs # Nyquist frequency
sos = butter(order, fc/nyq, analog=False, btype='low', output='sos')
w, h = sosfreqz(sos, worN=2000)
f = nyq*w/np.pi # Convert to Hz
Hf2_scipy = np.abs(h)**2
Hf2_theory = 1/(1+(f/fc)**(2*order))
Plotting the result gives curves that don't match
fig, ax = plt.subplots()
ax.plot(f, Hf2_scipy, label='Scipy')
ax.plot(f, Hf2_theory, label='Theory')
ax.set(xlabel='f (Hz)', ylabel='|H(f)|^2', xlim=[0, nyq], ylim=[0, 1])
ax.axhline(0.5, color='0.5')
ax.axvline(fc, color='0.5')
ax.legend()
What am I missing?
Here's the equivalent attempt in Matlab, which produces the same result
fc = 20; % Cut-off frequency
fs = 100; % Sample frequency
order = 3; % Filter order
[z, p, k] = butter(order, fc/(fs/2), 'low');
sos = zp2sos(z, p, k);
[h, w] = freqz(sos, 2000);
f = w*fs/(2*pi); % Convert to Hz
Hf2_matlab = abs(h).^2;
Hf2_theory = 1./(1+(f/fc).^(2*order));
plot(f, Hf2_matlab, 'k')
plot(f, Hf2_theory)
plot([fc, fc], [0, 1], 'color', 0.5*[1, 1, 1])
plot([0, fs/2], [0.5, 0.5], 'color', 0.5*[1, 1, 1])
legend('Matlab', 'Theory')
xlabel('f (Hz)')
ylabel('|H(f)|^2')