Skip to content

Commit 634e47d

Browse files
[Autoloop: build-tsb-pandas-typescript-migration] Iteration 387: Add signal processing (FFT/STFT/Welch) and digital filters (FIR/Butterworth)
- src/stats/signal.ts: Cooley-Tukey radix-2 DIT FFT/IFFT/RFFT/IRFFT, fftFreq/rfftFreq, fftshift/ifftshift, 8 window functions (getWindow), STFT/ISTFT (overlap-add), Welch PSD (mean/median), periodogram. Mirrors numpy.fft + scipy.signal spectral utilities. - src/stats/filters.ts: FIR design via windowed-sinc (firwin), Butterworth IIR design (butter, SOS form), frequency response (freqz, sosfreqz), filter application (lfilter, filtfilt, sosfilt, sosfiltfilt). Mirrors scipy.signal filter utilities. - tests/stats/signal.test.ts: 50+ tests (unit + property-based) - tests/stats/filters.test.ts: 50+ tests (unit + property-based) - playground/signal.html, playground/filters.html: interactive tutorials - playground/index.html, src/stats/index.ts, src/index.ts: updated exports Metric: 183 → 185 (+2) Run: https://github.com/githubnext/tsb/actions/runs/28396990118 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 8d87625 commit 634e47d

9 files changed

Lines changed: 3199 additions & 0 deletions

File tree

playground/filters.html

Lines changed: 315 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,315 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6+
<title>tsb — Digital Filters</title>
7+
<style>
8+
:root {
9+
--bg: #0d1117;
10+
--surface: #161b22;
11+
--border: #30363d;
12+
--text: #e6edf3;
13+
--accent: #58a6ff;
14+
--green: #3fb950;
15+
--orange: #d29922;
16+
--red: #f85149;
17+
--font-mono: "Cascadia Code", "Fira Code", "JetBrains Mono", monospace;
18+
}
19+
* { box-sizing: border-box; margin: 0; padding: 0; }
20+
body {
21+
background: var(--bg);
22+
color: var(--text);
23+
font-family: system-ui, -apple-system, sans-serif;
24+
line-height: 1.6;
25+
padding: 2rem;
26+
max-width: 900px;
27+
margin: 0 auto;
28+
}
29+
a { color: var(--accent); }
30+
h1 { color: var(--accent); margin-bottom: 0.5rem; }
31+
h2 { color: var(--text); margin: 2rem 0 0.5rem; border-bottom: 1px solid var(--border); padding-bottom: 0.3rem; }
32+
h3 { color: var(--accent); margin: 1.5rem 0 0.5rem; }
33+
pre {
34+
background: var(--surface);
35+
border: 1px solid var(--border);
36+
border-radius: 6px;
37+
padding: 1rem;
38+
overflow-x: auto;
39+
font-family: var(--font-mono);
40+
font-size: 0.85rem;
41+
line-height: 1.5;
42+
}
43+
code { font-family: var(--font-mono); font-size: 0.9em; }
44+
.example { margin: 1.5rem 0; }
45+
.output {
46+
background: #0a2a0a;
47+
border: 1px solid var(--green);
48+
border-radius: 6px;
49+
padding: 0.75rem 1rem;
50+
font-family: var(--font-mono);
51+
font-size: 0.85rem;
52+
margin-top: 0.5rem;
53+
white-space: pre-wrap;
54+
}
55+
table { border-collapse: collapse; width: 100%; margin: 1rem 0; }
56+
th, td { border: 1px solid var(--border); padding: 0.5rem 1rem; text-align: left; }
57+
th { background: var(--surface); color: var(--accent); }
58+
</style>
59+
</head>
60+
<body>
61+
<h1>🎛️ Digital Filters</h1>
62+
<p>FIR and IIR filter design and application — mirrors <code>scipy.signal</code>.</p>
63+
64+
<h2>FIR Filters</h2>
65+
66+
<h3>1. Low-pass FIR with firwin</h3>
67+
<div class="example">
68+
<pre>import { firwin, freqz, cAbs } from "tsb";
69+
70+
// 51-tap Hamming-windowed low-pass at 0.3 * Nyquist
71+
const b = firwin(51, 0.3);
72+
console.log(`Coefficients: ${b.length} taps`);
73+
console.log(`DC gain: ${b.reduce((s, v) => s + v, 0).toFixed(6)}`);
74+
75+
// Frequency response
76+
const { w, H } = freqz(b, [1], 512);
77+
const mag = H.map(cAbs);
78+
79+
// DC (w=0): should be ≈ 1.0
80+
console.log(`Gain at DC: ${mag[0].toFixed(4)}`);
81+
// Nyquist (w=π): should be ≈ 0
82+
console.log(`Gain at Nyquist: ${mag[511].toFixed(4)}`);</pre>
83+
<div class="output" id="out1">Running…</div>
84+
</div>
85+
86+
<h3>2. High-pass FIR</h3>
87+
<div class="example">
88+
<pre>import { firwin, freqz, cAbs } from "tsb";
89+
90+
// 51-tap high-pass, pass_zero=false
91+
const b = firwin(51, 0.3, { pass_zero: false });
92+
const { H } = freqz(b, [1], 512);
93+
const mag = H.map(cAbs);
94+
95+
console.log(`Gain at DC: ${mag[0].toFixed(4)} (should be ≈ 0)`);
96+
console.log(`Gain at Nyquist: ${mag[511].toFixed(4)} (should be ≈ 1)`);</pre>
97+
<div class="output" id="out2">Running…</div>
98+
</div>
99+
100+
<h3>3. Apply FIR filter with lfilter and filtfilt</h3>
101+
<div class="example">
102+
<pre>import { firwin, lfilter, filtfilt } from "tsb";
103+
104+
const fs = 512, n = 512;
105+
// Mix 20 Hz (keep) + 200 Hz (remove) signals
106+
const x = Array.from({ length: n }, (_, i) =>
107+
Math.sin(2 * Math.PI * 20 * i / fs) +
108+
Math.sin(2 * Math.PI * 200 * i / fs),
109+
);
110+
111+
// Low-pass FIR: cutoff at 100 Hz
112+
const b = firwin(63, 100, { fs });
113+
114+
// Causal filter (introduces phase delay)
115+
const yLf = lfilter(b, [1], x);
116+
117+
// Zero-phase filter (no phase delay)
118+
const yFf = filtfilt(b, [1], x);
119+
120+
const mid = 256;
121+
console.log(`Input at mid-point: ${x[mid].toFixed(4)}`);
122+
console.log(`lfilter output (causal): ${yLf[mid].toFixed(4)}`);
123+
console.log(`filtfilt output (no delay): ${yFf[mid].toFixed(4)}`);</pre>
124+
<div class="output" id="out3">Running…</div>
125+
</div>
126+
127+
<h2>IIR Filters — Butterworth</h2>
128+
129+
<h3>4. Design a Butterworth low-pass filter</h3>
130+
<div class="example">
131+
<pre>import { butter, sosfreqz, cAbs } from "tsb";
132+
133+
// 4th-order Butterworth, cutoff at 0.3 * Nyquist
134+
const { sos, b, a } = butter(4, 0.3, "lowpass");
135+
console.log(`SOS sections: ${sos.length}`);
136+
console.log(`b coefficients: [${b.map(v => v.toFixed(6)).join(", ")}]`);
137+
console.log(`a coefficients: [${a.map(v => v.toFixed(6)).join(", ")}]`);
138+
139+
// Frequency response via SOS
140+
const { H } = sosfreqz(sos, 512);
141+
const mag = H.map(cAbs);
142+
console.log(`DC gain: ${mag[0].toFixed(4)} (should be ≈ 1.0)`);
143+
console.log(`At cutoff (~bin 77): ${mag[77].toFixed(4)} (≈ 0.707 = -3dB)`);</pre>
144+
<div class="output" id="out4">Running…</div>
145+
</div>
146+
147+
<h3>5. Apply Butterworth filter with sosfilt</h3>
148+
<div class="example">
149+
<pre>import { butter, sosfilt, sosfiltfilt } from "tsb";
150+
151+
const fs = 1000, n = 1000;
152+
// Noisy 50 Hz signal
153+
const x = Array.from({ length: n }, (_, i) =>
154+
Math.sin(2 * Math.PI * 50 * i / fs) +
155+
0.5 * Math.sin(2 * Math.PI * 300 * i / fs), // high-freq noise
156+
);
157+
158+
// 4th-order Butterworth low-pass at 150 Hz
159+
const { sos } = butter(4, 150 / (fs / 2), "lowpass");
160+
161+
// Causal
162+
const y1 = sosfilt(sos, x);
163+
// Zero-phase
164+
const y2 = sosfiltfilt(sos, x);
165+
166+
const mid = 500;
167+
const ref = Math.sin(2 * Math.PI * 50 * mid / fs);
168+
console.log(`Reference (50 Hz): ${ref.toFixed(4)}`);
169+
console.log(`sosfilt output: ${y1[mid].toFixed(4)}`);
170+
console.log(`sosfiltfilt output: ${y2[mid].toFixed(4)} (zero-phase, closer to ref)`);</pre>
171+
<div class="output" id="out5">Running…</div>
172+
</div>
173+
174+
<h3>6. High-pass Butterworth</h3>
175+
<div class="example">
176+
<pre>import { butter, sosfreqz, cAbs } from "tsb";
177+
178+
const { sos } = butter(2, 0.3, "highpass");
179+
const { w, H } = sosfreqz(sos, 512);
180+
const mag = H.map(cAbs);
181+
182+
console.log(`Gain at DC (w=0): ${mag[0].toFixed(4)} (should be ≈ 0)`);
183+
console.log(`Gain at Nyquist (w=π): ${mag[511].toFixed(4)} (should be ≈ 1.0)`);</pre>
184+
<div class="output" id="out6">Running…</div>
185+
</div>
186+
187+
<h3>7. Filter frequency response — multiple orders</h3>
188+
<div class="example">
189+
<pre>import { butter, sosfreqz, cAbs } from "tsb";
190+
191+
// Compare -3dB attenuation at cutoff for different orders
192+
const Wn = 0.3;
193+
console.log("Butterworth -3dB gain at cutoff frequency:");
194+
for (const N of [1, 2, 4, 6, 8]) {
195+
const { sos } = butter(N, Wn, "lowpass");
196+
// Evaluate at ω = Wn * π (the digital cutoff frequency)
197+
const { H } = sosfreqz(sos, [Wn * Math.PI]);
198+
const gain = cAbs(H[0]);
199+
const dB = 20 * Math.log10(gain);
200+
console.log(` N=${N}: gain=${gain.toFixed(4)} (${dB.toFixed(2)} dB)`);
201+
}</pre>
202+
<div class="output" id="out7">Running…</div>
203+
</div>
204+
205+
<h2>API Reference</h2>
206+
<table>
207+
<tr><th>Function</th><th>Description</th><th>Mirrors</th></tr>
208+
<tr><td><code>firwin(n, cutoff, opts?)</code></td><td>FIR design (windowed-sinc)</td><td><code>scipy.signal.firwin</code></td></tr>
209+
<tr><td><code>butter(N, Wn, type?)</code></td><td>Butterworth IIR design</td><td><code>scipy.signal.butter</code></td></tr>
210+
<tr><td><code>freqz(b, a?, worN?)</code></td><td>FIR/IIR frequency response</td><td><code>scipy.signal.freqz</code></td></tr>
211+
<tr><td><code>sosfreqz(sos, worN?)</code></td><td>SOS frequency response</td><td><code>scipy.signal.sosfreqz</code></td></tr>
212+
<tr><td><code>lfilter(b, a, x)</code></td><td>Causal FIR/IIR filter</td><td><code>scipy.signal.lfilter</code></td></tr>
213+
<tr><td><code>filtfilt(b, a, x)</code></td><td>Zero-phase filter</td><td><code>scipy.signal.filtfilt</code></td></tr>
214+
<tr><td><code>sosfilt(sos, x)</code></td><td>Causal SOS filter</td><td><code>scipy.signal.sosfilt</code></td></tr>
215+
<tr><td><code>sosfiltfilt(sos, x)</code></td><td>Zero-phase SOS filter</td><td><code>scipy.signal.sosfiltfilt</code></td></tr>
216+
</table>
217+
218+
<script type="module">
219+
import * as tsb from "https://esm.sh/tsb@latest";
220+
221+
function run(id, fn) {
222+
const el = document.getElementById(id);
223+
try {
224+
const logs = [];
225+
const origLog = console.log;
226+
console.log = (...args) => { logs.push(args.map(String).join(" ")); origLog(...args); };
227+
fn(tsb);
228+
console.log = origLog;
229+
el.textContent = logs.join("\n") || "(no output)";
230+
el.style.borderColor = "#3fb950";
231+
} catch (err) {
232+
el.textContent = "Error: " + err.message;
233+
el.style.borderColor = "#f85149";
234+
}
235+
}
236+
237+
run("out1", (tsb) => {
238+
const b = tsb.firwin(51, 0.3);
239+
console.log(`Coefficients: ${b.length} taps`);
240+
console.log(`DC gain: ${b.reduce((s, v) => s + v, 0).toFixed(6)}`);
241+
const { H } = tsb.freqz(b, [1], 512);
242+
const mag = H.map(tsb.cAbs);
243+
console.log(`Gain at DC: ${mag[0].toFixed(4)}`);
244+
console.log(`Gain at Nyquist: ${mag[511].toFixed(4)}`);
245+
});
246+
247+
run("out2", (tsb) => {
248+
const b = tsb.firwin(51, 0.3, { pass_zero: false });
249+
const { H } = tsb.freqz(b, [1], 512);
250+
const mag = H.map(tsb.cAbs);
251+
console.log(`Gain at DC: ${mag[0].toFixed(4)} (should be ≈ 0)`);
252+
console.log(`Gain at Nyquist: ${mag[511].toFixed(4)} (should be ≈ 1)`);
253+
});
254+
255+
run("out3", (tsb) => {
256+
const fs = 512, n = 512;
257+
const x = Array.from({ length: n }, (_, i) =>
258+
Math.sin(2 * Math.PI * 20 * i / fs) + Math.sin(2 * Math.PI * 200 * i / fs),
259+
);
260+
const b = tsb.firwin(63, 100, { fs });
261+
const yLf = tsb.lfilter(b, [1], x);
262+
const yFf = tsb.filtfilt(b, [1], x);
263+
const mid = 256;
264+
console.log(`Input at mid-point: ${x[mid].toFixed(4)}`);
265+
console.log(`lfilter output (causal): ${yLf[mid].toFixed(4)}`);
266+
console.log(`filtfilt output (no delay): ${yFf[mid].toFixed(4)}`);
267+
});
268+
269+
run("out4", (tsb) => {
270+
const { sos, b, a } = tsb.butter(4, 0.3, "lowpass");
271+
console.log(`SOS sections: ${sos.length}`);
272+
console.log(`b: [${b.map(v => v.toFixed(6)).join(", ")}]`);
273+
console.log(`a: [${a.map(v => v.toFixed(6)).join(", ")}]`);
274+
const { H } = tsb.sosfreqz(sos, 512);
275+
const mag = H.map(tsb.cAbs);
276+
console.log(`DC gain: ${mag[0].toFixed(4)}`);
277+
});
278+
279+
run("out5", (tsb) => {
280+
const fs = 1000, n = 1000;
281+
const x = Array.from({ length: n }, (_, i) =>
282+
Math.sin(2 * Math.PI * 50 * i / fs) + 0.5 * Math.sin(2 * Math.PI * 300 * i / fs),
283+
);
284+
const { sos } = tsb.butter(4, 150 / (fs / 2), "lowpass");
285+
const y1 = tsb.sosfilt(sos, x);
286+
const y2 = tsb.sosfiltfilt(sos, x);
287+
const mid = 500;
288+
const ref = Math.sin(2 * Math.PI * 50 * mid / fs);
289+
console.log(`Reference (50 Hz): ${ref.toFixed(4)}`);
290+
console.log(`sosfilt output: ${y1[mid].toFixed(4)}`);
291+
console.log(`sosfiltfilt output: ${y2[mid].toFixed(4)}`);
292+
});
293+
294+
run("out6", (tsb) => {
295+
const { sos } = tsb.butter(2, 0.3, "highpass");
296+
const { H } = tsb.sosfreqz(sos, 512);
297+
const mag = H.map(tsb.cAbs);
298+
console.log(`Gain at DC: ${mag[0].toFixed(4)}`);
299+
console.log(`Gain at Nyquist: ${mag[511].toFixed(4)}`);
300+
});
301+
302+
run("out7", (tsb) => {
303+
const Wn = 0.3;
304+
console.log("Butterworth -3dB gain at cutoff:");
305+
for (const N of [1, 2, 4, 6, 8]) {
306+
const { sos } = tsb.butter(N, Wn, "lowpass");
307+
const { H } = tsb.sosfreqz(sos, [Wn * Math.PI]);
308+
const gain = tsb.cAbs(H[0]);
309+
const dB = 20 * Math.log10(gain);
310+
console.log(` N=${N}: gain=${gain.toFixed(4)} (${dB.toFixed(2)} dB)`);
311+
}
312+
});
313+
</script>
314+
</body>
315+
</html>

playground/index.html

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -601,6 +601,16 @@ <h3><a href="information.html" style="color: var(--accent); text-decoration: non
601601
<p>Shannon entropy, KL divergence, Jensen-Shannon divergence/distance, cross-entropy, mutual information, conditional entropy, normalised MI, variation of information, joint entropy, Rényi entropy, and Tsallis entropy. Mirrors <code>scipy.stats.entropy</code> and related utilities.</p>
602602
<div class="status done">✅ Complete</div>
603603
</div>
604+
<div class="feature-card">
605+
<h3><a href="signal.html" style="color: var(--accent); text-decoration: none;">📡 Signal Processing — FFT, STFT, Welch PSD</a></h3>
606+
<p>FFT/IFFT/RFFT (Cooley-Tukey radix-2), <code>fftFreq</code>, <code>fftshift</code>/<code>ifftshift</code>, 8 window functions (<code>getWindow</code>), Short-Time Fourier Transform (<code>stft</code>/<code>istft</code> with overlap-add), Welch power spectral density, and periodogram. Mirrors <code>numpy.fft</code> and <code>scipy.signal</code>.</p>
607+
<div class="status done">✅ Complete</div>
608+
</div>
609+
<div class="feature-card">
610+
<h3><a href="filters.html" style="color: var(--accent); text-decoration: none;">🎛️ Digital Filters — FIR, Butterworth IIR</a></h3>
611+
<p>FIR filter design via windowed-sinc (<code>firwin</code>), Butterworth IIR (<code>butter</code>), frequency response (<code>freqz</code>, <code>sosfreqz</code>), and filter application: <code>lfilter</code> (causal), <code>filtfilt</code> (zero-phase), <code>sosfilt</code>, <code>sosfiltfilt</code>. Mirrors <code>scipy.signal</code>.</p>
612+
<div class="status done">✅ Complete</div>
613+
</div>
604614
</div>
605615
<div class="features-grid">
606616
<div class="feature-card">

0 commit comments

Comments
 (0)