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:
parent
ab576ab9fd
commit
0970f7de56
@ -64,6 +64,7 @@ export default defineConfig({
|
||||
items: [
|
||||
{ label: 'Testing an Antenna', slug: 'tutorials/practical-projects/testing-antenna' },
|
||||
{ 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' },
|
||||
],
|
||||
},
|
||||
|
||||
653
src/components/FilterDesigner.astro
Normal file
653
src/components/FilterDesigner.astro
Normal 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>
|
||||
@ -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 (< -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
796
src/workers/filterWorker.ts
Normal 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 };
|
||||
Loading…
x
Reference in New Issue
Block a user