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: [
|
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' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
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