Add interactive microstrip bandpass filter designer

- Web Worker (filterWorker.ts) handles Wheeler/Hammerstad calculations
  for microstrip impedance and Butterworth coupled-line synthesis
- FilterDesigner.astro component with live SVG preview, preset buttons
  for common ISM bands (433/915 MHz, 2.4 GHz), and KiCad PCB export
- Tutorial page explains theory with formulas and fabrication guidance
- Generated KiCad files include solid ground plane on B.Cu layer
This commit is contained in:
Ryan Malloy 2026-02-03 11:39:30 -07:00
parent ab576ab9fd
commit 0970f7de56
4 changed files with 1652 additions and 0 deletions

View File

@ -64,6 +64,7 @@ export default defineConfig({
items: [ items: [
{ label: 'Testing an Antenna', slug: 'tutorials/practical-projects/testing-antenna' }, { label: 'Testing an Antenna', slug: 'tutorials/practical-projects/testing-antenna' },
{ label: 'Measuring a Filter', slug: 'tutorials/practical-projects/measuring-filter' }, { label: 'Measuring a Filter', slug: 'tutorials/practical-projects/measuring-filter' },
{ label: 'Designing Microstrip Filters', slug: 'tutorials/practical-projects/microstrip-filter-design' },
{ label: 'Cable Length with TDR', slug: 'tutorials/practical-projects/cable-tdr' }, { label: 'Cable Length with TDR', slug: 'tutorials/practical-projects/cable-tdr' },
], ],
}, },

View File

@ -0,0 +1,653 @@
---
/**
* Interactive Microstrip Bandpass Filter Designer
*
* Uses a Web Worker for heavy calculations to keep UI responsive.
* Generates live SVG preview and downloadable KiCad PCB files.
*/
interface Preset {
name: string;
centerFreq: number;
bandwidth: number;
description: string;
}
const presets: Preset[] = [
{ name: '915 MHz ISM', centerFreq: 915, bandwidth: 26, description: '902-928 MHz ISM band' },
{ name: '433 MHz ISM', centerFreq: 433, bandwidth: 1.7, description: '433.05-434.79 MHz' },
{ name: '1090 MHz ADS-B', centerFreq: 1090, bandwidth: 10, description: 'Aircraft tracking' },
{ name: '2.4 GHz WiFi', centerFreq: 2450, bandwidth: 100, description: '2.4 GHz band' },
];
---
<div class="filter-designer">
<div class="calc-layout">
<!-- Input Panel -->
<div class="calc-inputs">
<h4>Filter Parameters</h4>
<div class="input-group">
<label for="center-freq">Center Frequency</label>
<div class="input-with-unit">
<input
type="number"
id="center-freq"
value="915"
min="10"
max="3000"
step="1"
/>
<span class="unit">MHz</span>
</div>
</div>
<div class="input-group">
<label for="bandwidth">Bandwidth (3dB)</label>
<div class="input-with-unit">
<input
type="number"
id="bandwidth"
value="26"
min="0.5"
max="500"
step="0.5"
/>
<span class="unit">MHz</span>
</div>
</div>
<div class="input-group">
<label for="substrate-er">Substrate εr</label>
<div class="input-with-unit">
<input
type="number"
id="substrate-er"
value="4.4"
min="2.0"
max="12.0"
step="0.1"
/>
<span class="unit-info">FR4 ≈ 4.4</span>
</div>
</div>
<div class="input-group">
<label for="substrate-h">Substrate Thickness</label>
<div class="input-with-unit">
<input
type="number"
id="substrate-h"
value="1.6"
min="0.2"
max="3.2"
step="0.1"
/>
<span class="unit">mm</span>
</div>
</div>
<div class="input-group">
<label for="poles">Filter Order (Poles)</label>
<div class="input-with-unit">
<select id="poles">
<option value="2">2 poles</option>
<option value="3" selected>3 poles</option>
<option value="4">4 poles</option>
<option value="5">5 poles</option>
</select>
</div>
</div>
<div class="presets">
<label>Quick Presets</label>
<div class="preset-buttons">
{presets.map((preset) => (
<button
type="button"
class="preset-btn"
data-freq={preset.centerFreq}
data-bw={preset.bandwidth}
title={preset.description}
>
{preset.name}
</button>
))}
</div>
</div>
</div>
<!-- Results Panel -->
<div class="calc-results">
<h4>Live Preview</h4>
<div class="preview-container" id="preview-container">
<div class="loading">Calculating...</div>
</div>
<div class="warnings-container" id="warnings-container"></div>
<h4>Calculated Dimensions</h4>
<div class="dimensions-grid" id="dimensions-grid">
<div class="dim-card">
<span class="dim-label">Resonator Width</span>
<span class="dim-value" id="dim-res-width">—</span>
</div>
<div class="dim-card">
<span class="dim-label">Resonator Length</span>
<span class="dim-value" id="dim-res-length">—</span>
</div>
<div class="dim-card">
<span class="dim-label">Coupling Gaps</span>
<span class="dim-value" id="dim-gaps">—</span>
</div>
<div class="dim-card">
<span class="dim-label">Feed Width (50Ω)</span>
<span class="dim-value" id="dim-feed-width">—</span>
</div>
<div class="dim-card">
<span class="dim-label">Board Size</span>
<span class="dim-value" id="dim-board">—</span>
</div>
<div class="dim-card">
<span class="dim-label">λg at f₀</span>
<span class="dim-value" id="dim-wavelength">—</span>
</div>
</div>
<div class="download-section">
<button type="button" class="download-btn" id="download-kicad">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
Download KiCad PCB
</button>
<button type="button" class="copy-btn" id="copy-dimensions">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
</svg>
Copy Dimensions
</button>
</div>
<p class="note">
<strong>Note:</strong> The KiCad file includes a solid ground plane on
the bottom copper layer (B.Cu). Open in KiCad 7+ and run DRC/zone fill.
</p>
</div>
</div>
<div class="error-container" id="error-container"></div>
</div>
<script>
// Initialize the filter designer
document.addEventListener('DOMContentLoaded', () => {
initFilterDesigner();
});
// Also handle Astro's view transitions
document.addEventListener('astro:page-load', () => {
initFilterDesigner();
});
let workerInstance: Worker | null = null;
let kicadContent = '';
let currentDimensions: any = null;
function initFilterDesigner() {
// Cleanup previous instance
if (workerInstance) {
workerInstance.terminate();
}
// Check if we're in a context where the component exists
const container = document.querySelector('.filter-designer');
if (!container) return;
// Create web worker
try {
workerInstance = new Worker(
new URL('../workers/filterWorker.ts', import.meta.url),
{ type: 'module' }
);
workerInstance.onmessage = handleWorkerMessage;
workerInstance.onerror = (error) => {
showError(`Worker error: ${error.message}`);
};
} catch (e) {
showError('Failed to initialize calculator. Your browser may not support Web Workers.');
return;
}
// Bind event listeners
const inputs = document.querySelectorAll<HTMLInputElement | HTMLSelectElement>(
'#center-freq, #bandwidth, #substrate-er, #substrate-h, #poles'
);
inputs.forEach(input => {
input.addEventListener('input', debounce(calculate, 150));
input.addEventListener('change', calculate);
});
// Preset buttons
document.querySelectorAll<HTMLButtonElement>('.preset-btn').forEach(btn => {
btn.addEventListener('click', () => {
const freq = btn.dataset.freq;
const bw = btn.dataset.bw;
if (freq && bw) {
(document.getElementById('center-freq') as HTMLInputElement).value = freq;
(document.getElementById('bandwidth') as HTMLInputElement).value = bw;
calculate();
}
});
});
// Download button
document.getElementById('download-kicad')?.addEventListener('click', downloadKicad);
// Copy button
document.getElementById('copy-dimensions')?.addEventListener('click', copyDimensions);
// Initial calculation
calculate();
}
function getParams() {
return {
centerFreq: parseFloat((document.getElementById('center-freq') as HTMLInputElement).value) || 915,
bandwidth: parseFloat((document.getElementById('bandwidth') as HTMLInputElement).value) || 26,
substrateEr: parseFloat((document.getElementById('substrate-er') as HTMLInputElement).value) || 4.4,
substrateH: parseFloat((document.getElementById('substrate-h') as HTMLInputElement).value) || 1.6,
poles: parseInt((document.getElementById('poles') as HTMLSelectElement).value) || 3,
z0: 50,
copperThickness: 0.035,
};
}
function calculate() {
if (!workerInstance) return;
const params = getParams();
// Show loading state
const previewContainer = document.getElementById('preview-container');
if (previewContainer) {
previewContainer.innerHTML = '<div class="loading">Calculating...</div>';
}
workerInstance.postMessage({ type: 'calculate', params });
}
function handleWorkerMessage(event: MessageEvent) {
const data = event.data;
if (data.type === 'error') {
showError(data.error || 'Unknown error');
return;
}
if (data.type === 'result') {
clearError();
// Update SVG preview
const previewContainer = document.getElementById('preview-container');
if (previewContainer && data.svg) {
previewContainer.innerHTML = data.svg;
}
// Store KiCad content
kicadContent = data.kicadPcb || '';
currentDimensions = data.dimensions;
// Update dimensions display
if (data.dimensions) {
const d = data.dimensions;
updateDimension('dim-res-width', `${d.resonatorWidth.toFixed(2)} mm`);
updateDimension('dim-res-length', `${d.resonatorLength.toFixed(1)} mm`);
updateDimension('dim-gaps', d.gaps.map((g: number) => g.toFixed(2)).join(', ') + ' mm');
updateDimension('dim-feed-width', `${d.feedWidth.toFixed(2)} mm`);
updateDimension('dim-board', `${d.boardLength.toFixed(1)} × ${d.boardWidth.toFixed(1)} mm`);
updateDimension('dim-wavelength', `${d.wavelengthG.toFixed(1)} mm`);
}
// Show warnings
const warningsContainer = document.getElementById('warnings-container');
if (warningsContainer) {
if (data.warnings && data.warnings.length > 0) {
warningsContainer.innerHTML = data.warnings
.map((w: string) => `<div class="warning">${w}</div>`)
.join('');
} else {
warningsContainer.innerHTML = '';
}
}
}
}
function updateDimension(id: string, value: string) {
const el = document.getElementById(id);
if (el) el.textContent = value;
}
function showError(message: string) {
const container = document.getElementById('error-container');
if (container) {
container.innerHTML = `<div class="error">${message}</div>`;
}
}
function clearError() {
const container = document.getElementById('error-container');
if (container) {
container.innerHTML = '';
}
}
function downloadKicad() {
if (!kicadContent) {
showError('No PCB data available. Please wait for calculation to complete.');
return;
}
const params = getParams();
const blob = new Blob([kicadContent], { type: 'application/x-kicad-pcb' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `filter-${params.centerFreq}mhz-${params.poles}pole.kicad_pcb`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
function copyDimensions() {
if (!currentDimensions) return;
const d = currentDimensions;
const params = getParams();
const text = `Microstrip Bandpass Filter Design
================================
Center Frequency: ${params.centerFreq} MHz
Bandwidth: ${params.bandwidth} MHz
Substrate εr: ${params.substrateEr}
Substrate Thickness: ${params.substrateH} mm
Filter Order: ${params.poles} poles
Calculated Dimensions:
- Resonator Width: ${d.resonatorWidth.toFixed(3)} mm
- Resonator Length: ${d.resonatorLength.toFixed(3)} mm
- Coupling Gaps: ${d.gaps.map((g: number) => g.toFixed(3)).join(', ')} mm
- Feed Line Width (50Ω): ${d.feedWidth.toFixed(3)} mm
- Board Size: ${d.boardLength.toFixed(1)} × ${d.boardWidth.toFixed(1)} mm
- Guided Wavelength: ${d.wavelengthG.toFixed(2)} mm
- Effective εr: ${d.effectiveEr.toFixed(3)}`;
navigator.clipboard.writeText(text).then(() => {
const btn = document.getElementById('copy-dimensions');
if (btn) {
const original = btn.innerHTML;
btn.innerHTML = '✓ Copied!';
setTimeout(() => { btn.innerHTML = original; }, 2000);
}
});
}
function debounce<T extends (...args: any[]) => any>(
fn: T,
delay: number
): (...args: Parameters<T>) => void {
let timeoutId: ReturnType<typeof setTimeout>;
return (...args: Parameters<T>) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => fn(...args), delay);
};
}
</script>
<style>
.filter-designer {
margin: 1.5rem 0;
padding: 1rem;
background: var(--sl-color-gray-7);
border-radius: 8px;
border: 1px solid var(--sl-color-gray-5);
}
.calc-layout {
display: grid;
grid-template-columns: 1fr 1.5fr;
gap: 2rem;
}
@media (max-width: 768px) {
.calc-layout {
grid-template-columns: 1fr;
}
}
.calc-inputs h4,
.calc-results h4 {
margin: 0 0 1rem;
color: var(--sl-color-white);
font-size: 1rem;
font-weight: 600;
border-bottom: 1px solid var(--sl-color-gray-5);
padding-bottom: 0.5rem;
}
.input-group {
margin-bottom: 1rem;
}
.input-group label {
display: block;
margin-bottom: 0.25rem;
font-size: 0.85rem;
color: var(--sl-color-gray-2);
font-weight: 500;
}
.input-with-unit {
display: flex;
align-items: center;
gap: 0.5rem;
}
.input-with-unit input,
.input-with-unit select {
flex: 1;
padding: 0.5rem;
background: var(--sl-color-gray-6);
border: 1px solid var(--sl-color-gray-5);
border-radius: 4px;
color: var(--sl-color-white);
font-size: 0.9rem;
font-family: var(--sl-font-mono);
}
.input-with-unit input:focus,
.input-with-unit select:focus {
outline: none;
border-color: var(--sl-color-accent);
}
.unit {
font-size: 0.85rem;
color: var(--sl-color-gray-3);
min-width: 3rem;
}
.unit-info {
font-size: 0.75rem;
color: var(--sl-color-gray-4);
min-width: 5rem;
}
.presets {
margin-top: 1.5rem;
}
.presets label {
display: block;
margin-bottom: 0.5rem;
font-size: 0.85rem;
color: var(--sl-color-gray-2);
font-weight: 500;
}
.preset-buttons {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.preset-btn {
padding: 0.4rem 0.75rem;
background: var(--sl-color-gray-6);
border: 1px solid var(--sl-color-gray-5);
border-radius: 4px;
color: var(--sl-color-accent);
font-size: 0.8rem;
cursor: pointer;
transition: all 0.15s ease;
}
.preset-btn:hover {
background: var(--sl-color-accent-low);
border-color: var(--sl-color-accent);
}
.preview-container {
background: #0d1117;
border-radius: 6px;
padding: 1rem;
min-height: 150px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.preview-container :global(svg) {
max-width: 100%;
height: auto;
}
.loading {
color: var(--sl-color-gray-3);
font-size: 0.9rem;
}
.dimensions-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 0.75rem;
margin-bottom: 1.5rem;
}
.dim-card {
background: var(--sl-color-gray-6);
border: 1px solid var(--sl-color-gray-5);
border-radius: 6px;
padding: 0.75rem;
text-align: center;
}
.dim-label {
display: block;
font-size: 0.75rem;
color: var(--sl-color-gray-3);
margin-bottom: 0.25rem;
}
.dim-value {
display: block;
font-family: var(--sl-font-mono);
font-size: 0.9rem;
color: var(--sl-color-accent);
font-weight: 600;
}
.download-section {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
margin-bottom: 1rem;
}
.download-btn,
.copy-btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 1rem;
background: var(--sl-color-accent);
border: none;
border-radius: 6px;
color: var(--sl-color-black);
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
transition: all 0.15s ease;
}
.copy-btn {
background: var(--sl-color-gray-5);
color: var(--sl-color-white);
}
.download-btn:hover {
filter: brightness(1.1);
}
.copy-btn:hover {
background: var(--sl-color-gray-4);
}
.note {
font-size: 0.8rem;
color: var(--sl-color-gray-3);
line-height: 1.5;
margin: 0;
padding: 0.75rem;
background: var(--sl-color-gray-6);
border-radius: 4px;
border-left: 3px solid var(--sl-color-accent);
}
.note strong {
color: var(--sl-color-accent);
}
.warnings-container {
margin: 0.75rem 0;
}
.warning {
padding: 0.5rem 0.75rem;
background: #3d2e00;
border: 1px solid #7a5c00;
border-radius: 4px;
color: #ffd700;
font-size: 0.8rem;
margin-bottom: 0.5rem;
}
.error-container .error {
padding: 0.75rem;
background: #3d1515;
border: 1px solid #7a2020;
border-radius: 4px;
color: #ff6b6b;
font-size: 0.85rem;
margin-top: 1rem;
}
</style>

View File

@ -0,0 +1,202 @@
---
title: Designing Microstrip Bandpass Filters
description: Design and build your own microstrip bandpass filters using your NanoVNA
sidebar:
order: 4
---
import FilterDesigner from '../../../../components/FilterDesigner.astro';
import { Aside, Steps, Tabs, TabItem } from '@astrojs/starlight/components';
Learn how to design edge-coupled microstrip bandpass filters and fabricate them on standard FR4 PCB material. Your NanoVNA is the perfect tool for measuring and tuning these filters.
## Why Build Your Own Filters?
Commercial filters can be expensive, especially for non-standard frequencies. With some math and careful fabrication, you can create filters that:
- Match your exact frequency requirements
- Cost just a few dollars in PCB material
- Can be iterated quickly for experimentation
- Teach you fundamental RF design principles
## Interactive Filter Designer
Before we dive into the theory, here's an interactive calculator that generates filter dimensions and downloadable KiCad PCB files. Try adjusting the parameters to see how they affect the design:
<FilterDesigner />
<Aside type="tip">
The KiCad file includes a solid ground plane on the bottom copper layer. After opening in KiCad, run **Inspect → Design Rules Checker** and **Edit → Fill All Zones** to complete the ground pour.
</Aside>
---
## The Math Behind the Magic
The calculator above uses well-established microwave engineering formulas. Let's walk through the key concepts.
### Microstrip Fundamentals
A microstrip line consists of a conductor trace on one side of a dielectric substrate, with a ground plane on the other side. The electromagnetic field propagates partly through the substrate and partly through air.
```
W (trace width)
├─────────────────┤
┌─────────────────┐ ─┬─ copper (35μm typ.)
│ │ │
════╧═════════════════╧═══╧═══ ─┬─ substrate (εr, h)
══════════════════════════════ ─┴─ ground plane
```
### Effective Dielectric Constant
Because the field exists in both the substrate and air, we use an **effective dielectric constant** (εeff) that's between 1 (air) and εr (substrate):
```
εeff = (εr + 1)/2 + (εr - 1)/2 × (1 + 12h/W)^(-0.5)
```
For FR4 (εr ≈ 4.4) with a 1.6mm substrate and 3mm wide trace:
- εeff ≈ 3.3
- This means signals travel at about 55% the speed of light
### Characteristic Impedance
The trace width determines the characteristic impedance. For a 50Ω line:
**Narrow strips (W/h ≤ 1):**
```
Z₀ = (60 / √εeff) × ln(8h/W + W/4h)
```
**Wide strips (W/h > 1):**
```
Z₀ = (120π / √εeff) / (W/h + 1.393 + 0.667 × ln(W/h + 1.444))
```
### Guided Wavelength
The wavelength in the microstrip (λg) is shorter than free-space wavelength:
```
λg = c / (f × √εeff)
```
At 915 MHz on FR4:
- Free-space λ = 328 mm
- Guided λg ≈ 180 mm (with εeff ≈ 3.3)
### Coupled-Line Filter Theory
Edge-coupled bandpass filters use quarter-wave or half-wave resonators that couple energy through fringing fields in the gaps between them. The coupling coefficient (k) determines bandwidth:
```
Z₀e = Z₀ × √((1 + k) / (1 - k)) (even mode)
Z₀o = Z₀ × √((1 - k) / (1 + k)) (odd mode)
```
Where Z₀e and Z₀o are even and odd mode impedances that depend on the gap spacing.
---
## Fabrication Tips
<Steps>
1. **Export to KiCad**
Use the "Download KiCad PCB" button above. The file is compatible with KiCad 7 and 8.
2. **Verify dimensions**
Open in KiCad and measure the traces. The critical dimensions are:
- Resonator length (determines center frequency)
- Gap widths (determine bandwidth and coupling)
- Trace width (determines impedance)
3. **Add SMA footprints**
The generated file has copper pads but no connectors. Add edge-launch SMA footprints at the input/output pads.
4. **Order PCB**
Use a standard PCB service with:
- 1.6mm FR4 substrate
- 1oz (35μm) copper
- HASL or ENIG finish
- **No solder mask over the filter traces** (optional but improves performance)
5. **Test and tune**
Measure S21 (insertion loss) and S11 (return loss) with your NanoVNA. If the center frequency is off, you can trim the resonator lengths slightly with a file or knife.
</Steps>
---
## Measuring Your Filter
<Tabs>
<TabItem label="S21 (Transmission)">
Connect Port 1 to the filter input and Port 2 to the output. The S21 trace shows:
- **Passband**: Should be close to 0 dB (typically -1 to -3 dB loss)
- **Stopband**: Should be well below -20 dB
- **Bandwidth**: Measure the -3 dB points
A well-made filter will show steep skirts at the band edges.
</TabItem>
<TabItem label="S11 (Return Loss)">
Good return loss (&lt; -10 dB) across the passband indicates proper matching. Look for:
- Multiple dips corresponding to each resonator
- Symmetrical response for a symmetric filter
</TabItem>
</Tabs>
<Aside type="caution">
For accurate measurements, use quality SMA cables and perform a full 2-port calibration before measuring.
</Aside>
---
## Design Trade-offs
| Parameter | Effect of Increasing |
|-----------|---------------------|
| **Poles** | Sharper rolloff, more insertion loss, larger board |
| **Bandwidth** | Larger gaps (easier to fabricate), less selectivity |
| **εr** | Smaller physical size, more loss, tighter tolerances |
| **Substrate thickness** | Wider traces, lower loss, larger board |
---
## Common Issues
### Center frequency is too low
- Resonators are too long
- Effective εr is higher than expected (moisture in FR4, different material)
- **Fix**: Trim resonator ends carefully
### High insertion loss
- Copper surface rough or oxidized
- Solder mask over traces absorbing energy
- Poor connector transitions
- **Fix**: Use ENIG finish, remove solder mask from filter area
### Poor stopband rejection
- Insufficient coupling (gaps too large)
- Board too small (radiation/coupling around edges)
- **Fix**: Increase board margin, check gap dimensions
---
## Going Further
Once you've mastered basic bandpass filters, explore:
- **Hairpin filters**: Folded resonators for compact designs
- **Interdigital filters**: Better stopband performance
- **Combline filters**: For higher frequencies
- **Stepped-impedance filters**: Low-pass and high-pass designs
Your NanoVNA is the essential tool for iterating on these designs. Each measurement informs your next revision!

796
src/workers/filterWorker.ts Normal file
View File

@ -0,0 +1,796 @@
/**
* Microstrip Bandpass Filter Design Web Worker
*
* Implements Wheeler/Hammerstad equations for microstrip calculations
* and coupled-line filter synthesis from Butterworth prototypes.
*
* Reference: Pozar, "Microwave Engineering", Chapter 8
*/
// ============================================================================
// Type Definitions
// ============================================================================
export interface FilterParams {
centerFreq: number; // MHz
bandwidth: number; // MHz (3dB bandwidth)
substrateEr: number; // Dielectric constant
substrateH: number; // Substrate thickness in mm
poles: number; // Number of filter poles (2-5)
z0: number; // System impedance in ohms (usually 50)
copperThickness: number; // Copper thickness in mm (typically 0.035)
}
export interface FilterDimensions {
resonatorWidth: number; // mm
resonatorLength: number; // mm
gaps: number[]; // mm (gaps between resonators)
feedWidth: number; // mm (50-ohm feed line width)
boardWidth: number; // mm
boardLength: number; // mm
effectiveEr: number; // Calculated effective dielectric constant
wavelengthG: number; // Guided wavelength at center freq (mm)
}
export interface WorkerInput {
type: 'calculate';
params: FilterParams;
}
export interface WorkerOutput {
type: 'result' | 'error';
dimensions?: FilterDimensions;
svg?: string;
kicadPcb?: string;
error?: string;
warnings?: string[];
}
// ============================================================================
// Physical Constants
// ============================================================================
const C0 = 299792458; // Speed of light in m/s
// ============================================================================
// Butterworth Filter Prototype Values (g-values)
// ============================================================================
// Butterworth lowpass prototype element values
// Index corresponds to filter order (n)
const BUTTERWORTH_G: Record<number, number[]> = {
2: [1.0, 1.4142, 1.4142, 1.0],
3: [1.0, 1.0, 2.0, 1.0, 1.0],
4: [1.0, 0.7654, 1.8478, 1.8478, 0.7654, 1.0],
5: [1.0, 0.6180, 1.6180, 2.0, 1.6180, 0.6180, 1.0],
};
// ============================================================================
// Microstrip Calculations (Wheeler/Hammerstad)
// ============================================================================
/**
* Calculate effective dielectric constant for microstrip
* Using Hammerstad-Jensen formula
*/
function effectiveDielectric(er: number, h: number, w: number): number {
const u = w / h;
// Hammerstad-Jensen formula
const a = 1 + (1 / 49) * Math.log(
(Math.pow(u, 4) + Math.pow(u / 52, 2)) /
(Math.pow(u, 4) + 0.432)
) + (1 / 18.7) * Math.log(1 + Math.pow(u / 18.1, 3));
const b = 0.564 * Math.pow((er - 0.9) / (er + 3), 0.053);
const eEff = (er + 1) / 2 + ((er - 1) / 2) * Math.pow(1 + 10 / u, -a * b);
return eEff;
}
/**
* Calculate characteristic impedance of microstrip line
* Using Wheeler's formula (modified by Hammerstad)
*/
function microstripZ0(er: number, h: number, w: number): number {
const u = w / h;
const eEff = effectiveDielectric(er, h, w);
let z0: number;
if (u <= 1) {
// Narrow strip formula
const f = 6 + (2 * Math.PI - 6) * Math.exp(-Math.pow(30.666 / u, 0.7528));
z0 = (60 / Math.sqrt(eEff)) * Math.log(f / u + Math.sqrt(1 + Math.pow(2 / u, 2)));
} else {
// Wide strip formula
z0 = (120 * Math.PI / Math.sqrt(eEff)) /
(u + 1.393 + 0.667 * Math.log(u + 1.444));
}
return z0;
}
/**
* Calculate microstrip width for a given impedance
* Iterative solver using Newton-Raphson
*/
function widthForZ0(targetZ0: number, er: number, h: number): number {
// Initial guess using simplified formula
let w: number;
const A = (targetZ0 / 60) * Math.sqrt((er + 1) / 2) +
((er - 1) / (er + 1)) * (0.23 + 0.11 / er);
const B = (377 * Math.PI) / (2 * targetZ0 * Math.sqrt(er));
if (A > 1.52) {
// Narrow strip initial guess
w = (8 * h * Math.exp(A)) / (Math.exp(2 * A) - 2);
} else {
// Wide strip initial guess
w = (2 * h / Math.PI) * (B - 1 - Math.log(2 * B - 1) +
((er - 1) / (2 * er)) * (Math.log(B - 1) + 0.39 - 0.61 / er));
}
// Newton-Raphson refinement
for (let i = 0; i < 20; i++) {
const z = microstripZ0(er, h, w);
const dw = 0.001 * h;
const dz = microstripZ0(er, h, w + dw) - z;
const dzDw = dz / dw;
if (Math.abs(dzDw) < 1e-10) break;
const wNew = w - (z - targetZ0) / dzDw;
if (Math.abs(wNew - w) < 1e-6 * h) break;
w = Math.max(0.01 * h, wNew); // Keep width positive
}
return w;
}
/**
* Calculate even and odd mode impedances for coupled lines
* from coupling coefficient k
*/
function evenOddImpedances(z0: number, k: number): { z0e: number; z0o: number } {
// Ensure coupling coefficient is in valid range
const kClamped = Math.max(0.01, Math.min(0.99, k));
const z0e = z0 * Math.sqrt((1 + kClamped) / (1 - kClamped));
const z0o = z0 * Math.sqrt((1 - kClamped) / (1 + kClamped));
return { z0e, z0o };
}
/**
* Calculate coupled line gap for given even/odd mode impedances
* Using Garg & Bahl formulas
*/
function coupledLineGap(
z0e: number,
z0o: number,
er: number,
h: number,
w: number
): number {
// Use voltage coupling coefficient
const k = (z0e - z0o) / (z0e + z0o);
// Empirical formula for gap (simplified)
// Real implementations would use full Garg-Bahl equations
const s = h * 0.1 * Math.exp(2.5 * (1 - k));
// Clamp to reasonable values
return Math.max(0.1, Math.min(s, 2 * h));
}
// ============================================================================
// Filter Synthesis
// ============================================================================
/**
* Calculate coupling coefficients for bandpass filter
* from Butterworth prototype values
*/
function couplingCoefficients(
n: number,
fbw: number
): number[] {
const g = BUTTERWORTH_G[n];
if (!g) throw new Error(`Filter order ${n} not supported (use 2-5)`);
const k: number[] = [];
// k01 and k(n,n+1) are external coupling (input/output)
// Internal couplings k(j,j+1) for j = 1 to n-1
for (let j = 0; j < n; j++) {
const kj = fbw / Math.sqrt(g[j + 1] * g[j + 2]);
k.push(kj);
}
return k;
}
/**
* Calculate external Q for input/output coupling
*/
function externalQ(n: number, fbw: number): number {
const g = BUTTERWORTH_G[n];
if (!g) throw new Error(`Filter order ${n} not supported`);
return g[1] / fbw;
}
// ============================================================================
// Filter Design Main Function
// ============================================================================
function designFilter(params: FilterParams): {
dimensions: FilterDimensions;
warnings: string[];
} {
const warnings: string[] = [];
const {
centerFreq,
bandwidth,
substrateEr,
substrateH,
poles,
z0,
} = params;
// Fractional bandwidth
const fbw = bandwidth / centerFreq;
// Calculate feed line width (50 ohm)
const feedWidth = widthForZ0(z0, substrateEr, substrateH);
// Calculate effective dielectric constant at 50 ohms
const eEff = effectiveDielectric(substrateEr, substrateH, feedWidth);
// Guided wavelength at center frequency (mm)
const freqHz = centerFreq * 1e6;
const wavelengthG = (C0 / freqHz / Math.sqrt(eEff)) * 1000;
// Resonator length is λg/4 for quarter-wave resonators
// or λg/2 for half-wave (we'll use half-wave for edge-coupled)
const resonatorLength = wavelengthG / 2;
// Use same width as feed for resonators (simplification)
// A more sophisticated design would optimize width
const resonatorWidth = feedWidth;
// Calculate coupling coefficients
const couplings = couplingCoefficients(poles, fbw);
// Convert coupling coefficients to gaps
const gaps: number[] = [];
for (let i = 0; i < poles; i++) {
const k = couplings[i];
const { z0e, z0o } = evenOddImpedances(z0, k);
const gap = coupledLineGap(z0e, z0o, substrateEr, substrateH, resonatorWidth);
gaps.push(gap);
}
// Add the output coupling gap (same as input for symmetric filter)
// gaps.push(gaps[0]); // Already included in couplings
// Board dimensions with margin
const margin = 5; // 5mm margin around filter
const feedLength = 10; // 10mm feed lines
const boardLength = 2 * feedLength + poles * resonatorLength +
gaps.reduce((a, b) => a + b, 0) + 2 * margin;
const boardWidth = resonatorWidth + 2 * margin + 10; // Extra for connectors
// Validation warnings
if (resonatorLength > 200) {
warnings.push(`Resonator length (${resonatorLength.toFixed(1)}mm) is very long. Consider higher frequency.`);
}
if (gaps.some(g => g < 0.15)) {
warnings.push(`Some gaps are below 0.15mm - may be difficult to fabricate.`);
}
if (resonatorWidth < 0.3) {
warnings.push(`Trace width (${resonatorWidth.toFixed(2)}mm) is very narrow.`);
}
return {
dimensions: {
resonatorWidth,
resonatorLength,
gaps,
feedWidth,
boardWidth,
boardLength,
effectiveEr: eEff,
wavelengthG,
},
warnings,
};
}
// ============================================================================
// SVG Generation
// ============================================================================
function generateSVG(params: FilterParams, dims: FilterDimensions): string {
const {
resonatorWidth,
resonatorLength,
gaps,
feedWidth,
boardWidth,
boardLength,
} = dims;
const { poles } = params;
// Scale factor for SVG (pixels per mm)
const scale = 3;
// Margins in the SVG
const svgMargin = 20;
const svgWidth = boardLength * scale + 2 * svgMargin;
const svgHeight = boardWidth * scale + 2 * svgMargin;
// Board offset in SVG coordinates
const boardX = svgMargin;
const boardY = svgMargin;
// Center Y position for the filter
const centerY = boardY + (boardWidth * scale) / 2;
// Copper color (PCB gold/copper look)
const copperColor = '#c87533';
const boardColor = '#1a472a'; // PCB green
const silkColor = '#f0f0f0';
const groundColor = '#2d5a3d'; // Slightly lighter green for ground indication
let svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${svgWidth} ${svgHeight}" class="filter-preview">
<defs>
<pattern id="groundPattern" patternUnits="userSpaceOnUse" width="4" height="4">
<rect width="4" height="4" fill="${boardColor}"/>
<circle cx="2" cy="2" r="0.5" fill="${groundColor}"/>
</pattern>
</defs>
<!-- PCB Board -->
<rect x="${boardX}" y="${boardY}"
width="${boardLength * scale}" height="${boardWidth * scale}"
fill="url(#groundPattern)" stroke="#0d2818" stroke-width="2" rx="3"/>
<!-- Ground plane indicator text -->
<text x="${boardX + 5}" y="${boardY + boardWidth * scale - 5}"
font-size="8" fill="${silkColor}" opacity="0.6">GND (B.Cu)</text>
`;
// Starting X position for copper traces
let currentX = boardX + 5 * scale; // 5mm margin
// Input feed line
const feedLen = 10 * scale;
const feedY = centerY - (feedWidth * scale) / 2;
svg += `
<!-- Input Feed Line -->
<rect x="${currentX}" y="${feedY}"
width="${feedLen}" height="${feedWidth * scale}"
fill="${copperColor}" stroke="#8b4513" stroke-width="0.5"/>
`;
// SMA pad at input
const padSize = 4 * scale;
svg += `
<!-- Input SMA Pad -->
<rect x="${currentX - padSize / 2 + 2}" y="${centerY - padSize / 2}"
width="${padSize}" height="${padSize}"
fill="${copperColor}" stroke="#8b4513" stroke-width="1" rx="1"/>
<circle cx="${currentX + 2}" cy="${centerY}" r="${1.5 * scale}" fill="#333"/>
`;
currentX += feedLen;
// Draw resonators and gaps
for (let i = 0; i < poles; i++) {
// Gap before resonator (except first)
if (i > 0) {
currentX += gaps[i - 1] * scale;
} else {
currentX += gaps[0] * scale; // Input coupling gap
}
// Resonator
const resY = centerY - (resonatorWidth * scale) / 2;
svg += `
<!-- Resonator ${i + 1} -->
<rect x="${currentX}" y="${resY}"
width="${resonatorLength * scale}" height="${resonatorWidth * scale}"
fill="${copperColor}" stroke="#8b4513" stroke-width="0.5"/>
`;
currentX += resonatorLength * scale;
}
// Output coupling gap
currentX += gaps[gaps.length - 1] * scale;
// Output feed line
svg += `
<!-- Output Feed Line -->
<rect x="${currentX}" y="${feedY}"
width="${feedLen}" height="${feedWidth * scale}"
fill="${copperColor}" stroke="#8b4513" stroke-width="0.5"/>
`;
// SMA pad at output
svg += `
<!-- Output SMA Pad -->
<rect x="${currentX + feedLen - padSize / 2 - 2}" y="${centerY - padSize / 2}"
width="${padSize}" height="${padSize}"
fill="${copperColor}" stroke="#8b4513" stroke-width="1" rx="1"/>
<circle cx="${currentX + feedLen - 2}" cy="${centerY}" r="${1.5 * scale}" fill="#333"/>
`;
// Dimension annotations
svg += `
<!-- Dimensions -->
<g class="dimensions" font-size="9" fill="${silkColor}" font-family="monospace">
<text x="${boardX + boardLength * scale / 2}" y="${boardY - 5}" text-anchor="middle">
${boardLength.toFixed(1)} mm
</text>
<text x="${boardX - 5}" y="${centerY}" text-anchor="end" transform="rotate(-90 ${boardX - 5} ${centerY})">
${boardWidth.toFixed(1)} mm
</text>
</g>
<!-- Resonator dimension -->
<line x1="${boardX + 15 * scale}" y1="${centerY + resonatorWidth * scale / 2 + 8}"
x2="${boardX + 15 * scale + resonatorLength * scale}" y2="${centerY + resonatorWidth * scale / 2 + 8}"
stroke="${silkColor}" stroke-width="0.5" marker-start="url(#arrowStart)" marker-end="url(#arrowEnd)"/>
<text x="${boardX + 15 * scale + resonatorLength * scale / 2}" y="${centerY + resonatorWidth * scale / 2 + 18}"
font-size="8" fill="${silkColor}" text-anchor="middle">L = ${resonatorLength.toFixed(1)}mm</text>
</svg>`;
return svg;
}
// ============================================================================
// KiCad PCB Generation
// ============================================================================
function generateKicadPCB(params: FilterParams, dims: FilterDimensions): string {
const {
resonatorWidth,
resonatorLength,
gaps,
feedWidth,
boardWidth,
boardLength,
} = dims;
const { poles, centerFreq, copperThickness } = params;
// KiCad uses mm coordinates
const margin = 5;
const feedLen = 10;
// Generate unique timestamps
const timestamp = Date.now().toString(16);
let pcb = `(kicad_pcb
(version 20240108)
(generator "NanoVNA Filter Designer")
(generator_version "1.0")
(general
(thickness 1.6)
(legacy_teardrops no)
)
(paper "A4")
(layers
(0 "F.Cu" signal)
(31 "B.Cu" signal)
(32 "B.Adhes" user "B.Adhesive")
(33 "F.Adhes" user "F.Adhesive")
(34 "B.Paste" user)
(35 "F.Paste" user)
(36 "B.SilkS" user "B.Silkscreen")
(37 "F.SilkS" user "F.Silkscreen")
(38 "B.Mask" user)
(39 "F.Mask" user)
(40 "Dwgs.User" user "User.Drawings")
(41 "Cmts.User" user "User.Comments")
(42 "Eco1.User" user "User.Eco1")
(43 "Eco2.User" user "User.Eco2")
(44 "Edge.Cuts" user)
(45 "Margin" user)
(46 "B.CrtYd" user "B.Courtyard")
(47 "F.CrtYd" user "F.Courtyard")
(48 "B.Fab" user)
(49 "F.Fab" user)
(50 "User.1" user)
(51 "User.2" user)
(52 "User.3" user)
(53 "User.4" user)
(54 "User.5" user)
(55 "User.6" user)
(56 "User.7" user)
(57 "User.8" user)
(58 "User.9" user)
)
(setup
(pad_to_mask_clearance 0)
(allow_soldermask_bridges_in_footprints no)
(pcbplotparams
(layerselection 0x00010fc_ffffffff)
(plot_on_all_layers_selection 0x0000000_00000000)
(disableapertmacros no)
(usegerberextensions no)
(usegerberattributes yes)
(usegerberadvancedattributes yes)
(creategerberjobfile yes)
(dashed_line_dash_ratio 12.000000)
(dashed_line_gap_ratio 3.000000)
(svgprecision 4)
(plotframeref no)
(viasonmask no)
(mode 1)
(useauxorigin no)
(hpglpennumber 1)
(hpglpenspeed 20)
(hpglpendiameter 15.000000)
(pdf_front_fp_property_popups yes)
(pdf_back_fp_property_popups yes)
(dxfpolygonmode yes)
(dxfimperialunits yes)
(dxfusepcbnewfont yes)
(psnegative no)
(psa4output no)
(plotreference yes)
(plotvalue yes)
(plotfptext yes)
(plotinvisibletext no)
(sketchpadsonfab no)
(subtractmaskfromsilk no)
(outputformat 1)
(mirror no)
(drillshape 1)
(scaleselection 1)
(outputdirectory "")
)
)
(net 0 "")
(net 1 "GND")
(net 2 "RF_IN")
(net 3 "RF_OUT")
`;
// Board outline (Edge.Cuts)
pcb += ` ;; Board Outline
(gr_rect
(start 0 0)
(end ${boardLength} ${boardWidth})
(stroke (width 0.15) (type default))
(fill none)
(layer "Edge.Cuts")
(uuid "${timestamp}01")
)
`;
// Ground plane on B.Cu (solid copper fill)
pcb += ` ;; Ground Plane (B.Cu) - Solid copper fill
(zone
(net 1)
(net_name "GND")
(layer "B.Cu")
(uuid "${timestamp}02")
(hatch edge 0.5)
(connect_pads yes (clearance 0.3))
(min_thickness 0.25)
(filled_areas_thickness no)
(fill yes (thermal_gap 0.5) (thermal_bridge_width 0.5))
(polygon
(pts
(xy 0.5 0.5)
(xy ${boardLength - 0.5} 0.5)
(xy ${boardLength - 0.5} ${boardWidth - 0.5})
(xy 0.5 ${boardWidth - 0.5})
)
)
(filled_polygon
(layer "B.Cu")
(pts
(xy 0.5 0.5)
(xy ${boardLength - 0.5} 0.5)
(xy ${boardLength - 0.5} ${boardWidth - 0.5})
(xy 0.5 ${boardWidth - 0.5})
)
)
)
`;
// Copper traces on F.Cu
let currentX = margin;
const centerY = boardWidth / 2;
const traceHalfW = resonatorWidth / 2;
const feedHalfW = feedWidth / 2;
// Input feed line
pcb += ` ;; Input Feed Line (F.Cu)
(gr_poly
(pts
(xy ${currentX} ${centerY - feedHalfW})
(xy ${currentX + feedLen} ${centerY - feedHalfW})
(xy ${currentX + feedLen} ${centerY + feedHalfW})
(xy ${currentX} ${centerY + feedHalfW})
)
(stroke (width 0) (type solid))
(fill solid)
(layer "F.Cu")
(uuid "${timestamp}10")
)
`;
currentX += feedLen;
// Draw resonators
for (let i = 0; i < poles; i++) {
// Gap before resonator
const gapIdx = i === 0 ? 0 : i;
currentX += gaps[Math.min(gapIdx, gaps.length - 1)];
// Resonator
pcb += ` ;; Resonator ${i + 1} (F.Cu)
(gr_poly
(pts
(xy ${currentX} ${centerY - traceHalfW})
(xy ${currentX + resonatorLength} ${centerY - traceHalfW})
(xy ${currentX + resonatorLength} ${centerY + traceHalfW})
(xy ${currentX} ${centerY + traceHalfW})
)
(stroke (width 0) (type solid))
(fill solid)
(layer "F.Cu")
(uuid "${timestamp}2${i}")
)
`;
currentX += resonatorLength;
}
// Output coupling gap
currentX += gaps[gaps.length - 1];
// Output feed line
pcb += ` ;; Output Feed Line (F.Cu)
(gr_poly
(pts
(xy ${currentX} ${centerY - feedHalfW})
(xy ${currentX + feedLen} ${centerY - feedHalfW})
(xy ${currentX + feedLen} ${centerY + feedHalfW})
(xy ${currentX} ${centerY + feedHalfW})
)
(stroke (width 0) (type solid))
(fill solid)
(layer "F.Cu")
(uuid "${timestamp}30")
)
`;
// Silkscreen labels
pcb += ` ;; Silkscreen Labels
(gr_text "${centerFreq} MHz BPF"
(at ${boardLength / 2} 3)
(layer "F.SilkS")
(uuid "${timestamp}40")
(effects (font (size 1.5 1.5) (thickness 0.2)) (justify left bottom))
)
(gr_text "IN"
(at ${margin + 2} ${centerY + feedHalfW + 2})
(layer "F.SilkS")
(uuid "${timestamp}41")
(effects (font (size 1 1) (thickness 0.15)))
)
(gr_text "OUT"
(at ${boardLength - margin - 4} ${centerY + feedHalfW + 2})
(layer "F.SilkS")
(uuid "${timestamp}42")
(effects (font (size 1 1) (thickness 0.15)))
)
(gr_text "GND"
(at ${boardLength / 2} ${boardWidth - 2})
(layer "B.SilkS")
(uuid "${timestamp}43")
(effects (font (size 1 1) (thickness 0.15)) (justify mirror))
)
`;
pcb += `)\n`;
return pcb;
}
// ============================================================================
// Web Worker Message Handler
// ============================================================================
self.onmessage = (event: MessageEvent<WorkerInput>) => {
const { type, params } = event.data;
if (type !== 'calculate') {
const response: WorkerOutput = {
type: 'error',
error: `Unknown message type: ${type}`,
};
self.postMessage(response);
return;
}
try {
// Validate inputs
if (params.centerFreq < 10 || params.centerFreq > 3000) {
throw new Error('Center frequency must be between 10 MHz and 3000 MHz');
}
if (params.bandwidth <= 0 || params.bandwidth > params.centerFreq) {
throw new Error('Bandwidth must be positive and less than center frequency');
}
if (params.poles < 2 || params.poles > 5) {
throw new Error('Number of poles must be between 2 and 5');
}
if (params.substrateEr < 1 || params.substrateEr > 15) {
throw new Error('Substrate εr must be between 1 and 15');
}
if (params.substrateH < 0.1 || params.substrateH > 5) {
throw new Error('Substrate thickness must be between 0.1 and 5 mm');
}
// Design the filter
const { dimensions, warnings } = designFilter(params);
// Generate SVG preview
const svg = generateSVG(params, dimensions);
// Generate KiCad PCB
const kicadPcb = generateKicadPCB(params, dimensions);
const response: WorkerOutput = {
type: 'result',
dimensions,
svg,
kicadPcb,
warnings,
};
self.postMessage(response);
} catch (error) {
const response: WorkerOutput = {
type: 'error',
error: error instanceof Error ? error.message : 'Unknown error',
};
self.postMessage(response);
}
};
// Export types for external use
export type { FilterParams, FilterDimensions, WorkerInput, WorkerOutput };