Add analytical radiation pattern models for 5 antenna types (dipole, monopole, EFHW, loop, patch) driven by S11 impedance measurements. Pure Python math with closed-form far-field equations — no numpy or simulation dependencies. New MCP tools: - radiation_pattern: scan S11 → find resonance → compute 3D pattern - radiation_pattern_from_data: compute from known impedance (no hardware) - radiation_pattern_multi: patterns across a frequency band for animation Web UI (opt-in via MCNANOVNA_WEB_PORT env var): - Three.js gain-mapped sphere with OrbitControls - Surface/wireframe/plane cut display modes with teal→amber color ramp - Smith chart overlay, dBi reference rings, E/H plane cross-sections - Real-time WebSocket push on new pattern computation - FastAPI backend shares process with MCP server, zero new core deps Frontend: Vite + TypeScript + Three.js, built assets committed to webui/static/. Optional dependencies: fastapi + uvicorn via pip install mcnanovna[webui].
155 lines
4.1 KiB
TypeScript
155 lines
4.1 KiB
TypeScript
import type { ResonanceData } from './types';
|
|
|
|
const GRID_COLOR = '#475569'; // slate-600
|
|
const POINT_COLOR = '#2dd4bf'; // teal-400
|
|
const BG_COLOR = '#1e293b'; // slate-800
|
|
const BORDER_COLOR = '#334155'; // slate-700
|
|
const TEXT_COLOR = '#94a3b8'; // slate-400
|
|
|
|
/**
|
|
* Draw the Smith chart grid on a 2D canvas.
|
|
* Uses normalized impedance: z = Z/Z0 where Z0=50.
|
|
* Smith chart maps z to reflection coefficient Gamma = (z-1)/(z+1).
|
|
*/
|
|
export function drawSmithChart(canvas: HTMLCanvasElement, resonance?: ResonanceData): void {
|
|
const ctx = canvas.getContext('2d');
|
|
if (!ctx) return;
|
|
|
|
const w = canvas.width;
|
|
const h = canvas.height;
|
|
const cx = w / 2;
|
|
const cy = h / 2;
|
|
const R = (Math.min(w, h) / 2) * 0.85; // chart radius in pixels
|
|
|
|
// Clear
|
|
ctx.fillStyle = BG_COLOR;
|
|
ctx.fillRect(0, 0, w, h);
|
|
|
|
// Border
|
|
ctx.strokeStyle = BORDER_COLOR;
|
|
ctx.lineWidth = 1;
|
|
ctx.strokeRect(0, 0, w, h);
|
|
|
|
ctx.save();
|
|
ctx.beginPath();
|
|
ctx.arc(cx, cy, R, 0, 2 * Math.PI);
|
|
ctx.clip();
|
|
|
|
// Constant R circles
|
|
ctx.strokeStyle = GRID_COLOR;
|
|
ctx.lineWidth = 0.5;
|
|
const rValues = [0, 0.2, 0.5, 1, 2, 5];
|
|
for (const r of rValues) {
|
|
// Circle center at ((r/(r+1))*R + cx, cy), radius R/(r+1)
|
|
const circR = R / (r + 1);
|
|
const circX = cx + (r / (r + 1)) * R;
|
|
ctx.beginPath();
|
|
ctx.arc(circX, cy, circR, 0, 2 * Math.PI);
|
|
ctx.stroke();
|
|
}
|
|
|
|
// Constant X arcs
|
|
const xValues = [0.2, 0.5, 1, 2, 5];
|
|
for (const x of xValues) {
|
|
// Positive X arc (inductive, above center)
|
|
drawXArc(ctx, cx, cy, R, x);
|
|
// Negative X arc (capacitive, below center)
|
|
drawXArc(ctx, cx, cy, R, -x);
|
|
}
|
|
|
|
// Horizontal center line
|
|
ctx.beginPath();
|
|
ctx.moveTo(cx - R, cy);
|
|
ctx.lineTo(cx + R, cy);
|
|
ctx.stroke();
|
|
|
|
ctx.restore();
|
|
|
|
// Outer circle
|
|
ctx.strokeStyle = GRID_COLOR;
|
|
ctx.lineWidth = 1;
|
|
ctx.beginPath();
|
|
ctx.arc(cx, cy, R, 0, 2 * Math.PI);
|
|
ctx.stroke();
|
|
|
|
// Title
|
|
ctx.fillStyle = TEXT_COLOR;
|
|
ctx.font = "10px 'Inter', sans-serif";
|
|
ctx.textAlign = 'center';
|
|
ctx.fillText('Smith Chart', cx, h - 4);
|
|
|
|
// Plot impedance point if we have resonance data
|
|
if (resonance) {
|
|
const z_real = resonance.impedance_real / 50;
|
|
const z_imag = resonance.impedance_imag / 50;
|
|
|
|
// Reflection coefficient: Gamma = (z-1)/(z+1) where z = z_real + j*z_imag
|
|
const denom_r = (z_real + 1) * (z_real + 1) + z_imag * z_imag;
|
|
const gamma_real = ((z_real * z_real + z_imag * z_imag - 1)) / denom_r;
|
|
const gamma_imag = (2 * z_imag) / denom_r;
|
|
|
|
const px = cx + gamma_real * R;
|
|
const py = cy - gamma_imag * R; // y-axis inverted in canvas
|
|
|
|
// Draw point
|
|
ctx.fillStyle = POINT_COLOR;
|
|
ctx.beginPath();
|
|
ctx.arc(px, py, 4, 0, 2 * Math.PI);
|
|
ctx.fill();
|
|
|
|
// Outline
|
|
ctx.strokeStyle = '#ffffff';
|
|
ctx.lineWidth = 1;
|
|
ctx.beginPath();
|
|
ctx.arc(px, py, 4, 0, 2 * Math.PI);
|
|
ctx.stroke();
|
|
|
|
// Label
|
|
ctx.fillStyle = POINT_COLOR;
|
|
ctx.font = "bold 9px 'Inter', sans-serif";
|
|
ctx.textAlign = 'left';
|
|
const label = `${resonance.impedance_real.toFixed(0)}${resonance.impedance_imag >= 0 ? '+' : ''}${resonance.impedance_imag.toFixed(0)}j`;
|
|
ctx.fillText(label, px + 7, py + 3);
|
|
}
|
|
}
|
|
|
|
function drawXArc(
|
|
ctx: CanvasRenderingContext2D,
|
|
cx: number,
|
|
cy: number,
|
|
R: number,
|
|
x: number
|
|
): void {
|
|
const arcR = R / Math.abs(x);
|
|
const centerX = cx + R;
|
|
const centerY = x > 0 ? cy - arcR : cy + arcR;
|
|
|
|
// We need to clip the arc to within the unit circle.
|
|
// Use many small segments and only draw those inside.
|
|
const steps = 100;
|
|
ctx.beginPath();
|
|
let drawing = false;
|
|
|
|
for (let i = 0; i <= steps; i++) {
|
|
const t = (i / steps) * Math.PI;
|
|
const angle = x > 0 ? -Math.PI / 2 + t : Math.PI / 2 - t;
|
|
const px = centerX + arcR * Math.cos(angle);
|
|
const py = centerY + arcR * Math.sin(angle);
|
|
|
|
// Check if inside unit circle
|
|
const dx = px - cx;
|
|
const dy = py - cy;
|
|
if (dx * dx + dy * dy <= R * R * 1.01) {
|
|
if (!drawing) {
|
|
ctx.moveTo(px, py);
|
|
drawing = true;
|
|
} else {
|
|
ctx.lineTo(px, py);
|
|
}
|
|
} else {
|
|
drawing = false;
|
|
}
|
|
}
|
|
ctx.stroke();
|
|
}
|