+
+
Press Compute to generate a pattern,
or Scan & Visualize with a connected VNA.
+
+ `;
+ container.appendChild(msg);
+
+ // Remove after first pattern
+ const check = setInterval(() => {
+ if (state.pattern) {
+ msg.remove();
+ clearInterval(check);
+ }
+ }, 200);
+}
+
+document.addEventListener('DOMContentLoaded', init);
diff --git a/frontend/src/pattern.ts b/frontend/src/pattern.ts
new file mode 100644
index 0000000..45e9355
--- /dev/null
+++ b/frontend/src/pattern.ts
@@ -0,0 +1,231 @@
+import * as THREE from 'three';
+import type { SceneContext } from './scene';
+import type { PatternData, DisplayMode, PlanePoint } from './types';
+
+const TEAL_400 = new THREE.Color(0x2dd4bf);
+const AMBER_400 = new THREE.Color(0xfbbf24);
+
+// HSL interpolation from teal-400 to amber-400
+function gainColor(t: number): THREE.Color {
+ const hslA = { h: 0, s: 0, l: 0 };
+ const hslB = { h: 0, s: 0, l: 0 };
+ TEAL_400.getHSL(hslA);
+ AMBER_400.getHSL(hslB);
+
+ const clamped = Math.max(0, Math.min(1, t));
+
+ // Interpolate in HSL — handle hue wrap
+ let dh = hslB.h - hslA.h;
+ if (dh > 0.5) dh -= 1;
+ if (dh < -0.5) dh += 1;
+
+ const h = hslA.h + dh * clamped;
+ const s = hslA.s + (hslB.s - hslA.s) * clamped;
+ const l = hslA.l + (hslB.l - hslA.l) * clamped;
+
+ return new THREE.Color().setHSL(((h % 1) + 1) % 1, s, l);
+}
+
+function normalizeGain(dbi: number, minDbi: number, maxDbi: number): number {
+ if (maxDbi === minDbi) return 0.55;
+ const t = (dbi - minDbi) / (maxDbi - minDbi);
+ return 0.1 + Math.max(0, Math.min(1, t)) * 0.9; // maps to [0.1, 1.0]
+}
+
+function deg2rad(deg: number): number {
+ return (deg * Math.PI) / 180;
+}
+
+/** Build 3D sphere mesh from theta/phi/gain data. */
+function buildPatternGeometry(
+ data: PatternData,
+ baseRadius: number
+): { geometry: THREE.BufferGeometry; minGain: number; maxGain: number } {
+ const { theta_deg, phi_deg, gain_dbi } = data;
+ const nTheta = theta_deg.length;
+ const nPhi = phi_deg.length;
+
+ // Flatten gain to find range
+ let minGain = Infinity;
+ let maxGain = -Infinity;
+ for (let ti = 0; ti < nTheta; ti++) {
+ for (let pi = 0; pi < nPhi; pi++) {
+ const g = gain_dbi[ti][pi];
+ if (g < minGain) minGain = g;
+ if (g > maxGain) maxGain = g;
+ }
+ }
+
+ // Build vertices and colors
+ const positions: number[] = [];
+ const colors: number[] = [];
+ const indices: number[] = [];
+
+ // Vertex grid: nTheta rows x nPhi columns
+ for (let ti = 0; ti < nTheta; ti++) {
+ const theta = deg2rad(theta_deg[ti]);
+ for (let pi = 0; pi < nPhi; pi++) {
+ const phi = deg2rad(phi_deg[pi]);
+ const g = gain_dbi[ti][pi];
+ const normG = normalizeGain(g, minGain, maxGain);
+ const r = baseRadius * normG;
+
+ // Spherical to Cartesian (physics convention: theta=polar, phi=azimuthal)
+ const x = r * Math.sin(theta) * Math.cos(phi);
+ const y = r * Math.cos(theta);
+ const z = r * Math.sin(theta) * Math.sin(phi);
+
+ positions.push(x, y, z);
+
+ const color = gainColor(normG);
+ colors.push(color.r, color.g, color.b);
+ }
+ }
+
+ // Triangle indices (quads split into 2 triangles)
+ for (let ti = 0; ti < nTheta - 1; ti++) {
+ for (let pi = 0; pi < nPhi - 1; pi++) {
+ const a = ti * nPhi + pi;
+ const b = ti * nPhi + (pi + 1);
+ const c = (ti + 1) * nPhi + pi;
+ const d = (ti + 1) * nPhi + (pi + 1);
+
+ indices.push(a, b, d);
+ indices.push(a, d, c);
+ }
+ // Wrap phi: connect last column to first
+ const a = ti * nPhi + (nPhi - 1);
+ const b = ti * nPhi;
+ const c = (ti + 1) * nPhi + (nPhi - 1);
+ const d = (ti + 1) * nPhi;
+
+ indices.push(a, b, d);
+ indices.push(a, d, c);
+ }
+
+ const geometry = new THREE.BufferGeometry();
+ geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
+ geometry.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3));
+ geometry.setIndex(indices);
+ geometry.computeVertexNormals();
+
+ return { geometry, minGain, maxGain };
+}
+
+/** Build a plane-cut line from PlanePoint[] data. */
+function buildPlaneLine(
+ points: PlanePoint[],
+ isEPlane: boolean,
+ minGain: number,
+ maxGain: number,
+ baseRadius: number
+): THREE.Line {
+ const linePoints: THREE.Vector3[] = [];
+ const lineColors: number[] = [];
+
+ points.forEach((pt) => {
+ const angleDeg = isEPlane ? (pt.theta_deg ?? 0) : (pt.phi_deg ?? 0);
+ const angle = deg2rad(angleDeg);
+ const normG = normalizeGain(pt.gain_dbi, minGain, maxGain);
+ const r = baseRadius * normG;
+
+ let x: number, y: number, z: number;
+ if (isEPlane) {
+ // E-plane: varies theta at phi=0, shown in XY plane
+ x = r * Math.sin(angle);
+ y = r * Math.cos(angle);
+ z = 0;
+ } else {
+ // H-plane: varies phi at theta=90, shown in XZ plane
+ x = r * Math.cos(angle);
+ y = 0;
+ z = r * Math.sin(angle);
+ }
+
+ linePoints.push(new THREE.Vector3(x, y, z));
+ const color = gainColor(normG);
+ lineColors.push(color.r, color.g, color.b);
+ });
+
+ // Close the loop
+ if (linePoints.length > 0) {
+ linePoints.push(linePoints[0].clone());
+ lineColors.push(lineColors[0], lineColors[1], lineColors[2]);
+ }
+
+ const geo = new THREE.BufferGeometry().setFromPoints(linePoints);
+ geo.setAttribute('color', new THREE.Float32BufferAttribute(lineColors, 3));
+
+ const mat = new THREE.LineBasicMaterial({
+ vertexColors: true,
+ linewidth: 2,
+ });
+
+ return new THREE.Line(geo, mat);
+}
+
+export function updatePattern(ctx: SceneContext, data: PatternData, mode: DisplayMode): void {
+ // Clear previous
+ while (ctx.patternGroup.children.length > 0) {
+ const child = ctx.patternGroup.children[0];
+ ctx.patternGroup.remove(child);
+ if (child instanceof THREE.Mesh || child instanceof THREE.Line) {
+ child.geometry.dispose();
+ if (Array.isArray(child.material)) {
+ child.material.forEach((m) => m.dispose());
+ } else {
+ child.material.dispose();
+ }
+ }
+ }
+
+ const baseRadius = 2.0;
+ const { geometry, minGain, maxGain } = buildPatternGeometry(data, baseRadius);
+
+ if (mode === 'surface') {
+ const material = new THREE.MeshPhongMaterial({
+ vertexColors: true,
+ side: THREE.DoubleSide,
+ shininess: 40,
+ transparent: true,
+ opacity: 0.85,
+ });
+ const mesh = new THREE.Mesh(geometry, material);
+ ctx.patternGroup.add(mesh);
+ } else if (mode === 'wireframe') {
+ const material = new THREE.MeshPhongMaterial({
+ vertexColors: true,
+ wireframe: true,
+ side: THREE.DoubleSide,
+ });
+ const mesh = new THREE.Mesh(geometry, material);
+ ctx.patternGroup.add(mesh);
+ } else if (mode === 'e-plane') {
+ if (data.e_plane && data.e_plane.length > 0) {
+ const line = buildPlaneLine(data.e_plane, true, minGain, maxGain, baseRadius);
+ ctx.patternGroup.add(line);
+ }
+ // Also show a dim surface for context
+ const material = new THREE.MeshPhongMaterial({
+ vertexColors: true,
+ side: THREE.DoubleSide,
+ transparent: true,
+ opacity: 0.15,
+ });
+ const mesh = new THREE.Mesh(geometry, material);
+ ctx.patternGroup.add(mesh);
+ } else if (mode === 'h-plane') {
+ if (data.h_plane && data.h_plane.length > 0) {
+ const line = buildPlaneLine(data.h_plane, false, minGain, maxGain, baseRadius);
+ ctx.patternGroup.add(line);
+ }
+ const material = new THREE.MeshPhongMaterial({
+ vertexColors: true,
+ side: THREE.DoubleSide,
+ transparent: true,
+ opacity: 0.15,
+ });
+ const mesh = new THREE.Mesh(geometry, material);
+ ctx.patternGroup.add(mesh);
+ }
+}
diff --git a/frontend/src/scene.ts b/frontend/src/scene.ts
new file mode 100644
index 0000000..bd77558
--- /dev/null
+++ b/frontend/src/scene.ts
@@ -0,0 +1,176 @@
+import * as THREE from 'three';
+import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
+import { CSS2DRenderer, CSS2DObject } from 'three/examples/jsm/renderers/CSS2DRenderer.js';
+
+const BG_COLOR = 0x0f172a;
+const GRID_COLOR = 0x334155; // slate-700
+const RING_COLOR = 0x475569; // slate-600
+const LABEL_COLOR = '#94a3b8'; // slate-400
+
+export interface SceneContext {
+ scene: THREE.Scene;
+ camera: THREE.PerspectiveCamera;
+ renderer: THREE.WebGLRenderer;
+ labelRenderer: CSS2DRenderer;
+ controls: OrbitControls;
+ patternGroup: THREE.Group;
+}
+
+export function createScene(container: HTMLElement): SceneContext {
+ const scene = new THREE.Scene();
+ scene.background = new THREE.Color(BG_COLOR);
+
+ // Camera
+ const aspect = container.clientWidth / container.clientHeight;
+ const camera = new THREE.PerspectiveCamera(55, aspect, 0.1, 100);
+ camera.position.set(3, 2.5, 3);
+ camera.lookAt(0, 0, 0);
+
+ // WebGL renderer
+ const renderer = new THREE.WebGLRenderer({ antialias: true });
+ renderer.setPixelRatio(window.devicePixelRatio);
+ renderer.setSize(container.clientWidth, container.clientHeight);
+ container.appendChild(renderer.domElement);
+
+ // CSS2D renderer for labels
+ const labelRenderer = new CSS2DRenderer();
+ labelRenderer.setSize(container.clientWidth, container.clientHeight);
+ labelRenderer.domElement.style.position = 'absolute';
+ labelRenderer.domElement.style.top = '0';
+ labelRenderer.domElement.style.left = '0';
+ labelRenderer.domElement.style.pointerEvents = 'none';
+ container.appendChild(labelRenderer.domElement);
+
+ // Orbit controls
+ const controls = new OrbitControls(camera, renderer.domElement);
+ controls.enableDamping = true;
+ controls.dampingFactor = 0.08;
+ controls.minDistance = 1.5;
+ controls.maxDistance = 12;
+
+ // Lighting
+ const ambientLight = new THREE.AmbientLight(0xffffff, 0.4);
+ scene.add(ambientLight);
+
+ const dirLight = new THREE.DirectionalLight(0xffffff, 0.8);
+ dirLight.position.set(5, 8, 5);
+ scene.add(dirLight);
+
+ const dirLight2 = new THREE.DirectionalLight(0xffffff, 0.3);
+ dirLight2.position.set(-3, -2, -4);
+ scene.add(dirLight2);
+
+ // Grid on XZ plane
+ const gridHelper = new THREE.GridHelper(6, 12, GRID_COLOR, GRID_COLOR);
+ (gridHelper.material as THREE.Material).opacity = 0.3;
+ (gridHelper.material as THREE.Material).transparent = true;
+ scene.add(gridHelper);
+
+ // Axis lines
+ addAxisLines(scene);
+
+ // Axis labels
+ addAxisLabel(scene, 'X', new THREE.Vector3(3.3, 0, 0));
+ addAxisLabel(scene, 'Y', new THREE.Vector3(0, 3.3, 0));
+ addAxisLabel(scene, 'Z', new THREE.Vector3(0, 0, 3.3));
+
+ // Reference dBi rings on XZ plane
+ addReferenceRings(scene);
+
+ // Group for pattern meshes
+ const patternGroup = new THREE.Group();
+ scene.add(patternGroup);
+
+ // Resize handler
+ const onResize = () => {
+ const w = container.clientWidth;
+ const h = container.clientHeight;
+ camera.aspect = w / h;
+ camera.updateProjectionMatrix();
+ renderer.setSize(w, h);
+ labelRenderer.setSize(w, h);
+ };
+ window.addEventListener('resize', onResize);
+
+ // Animation loop
+ const animate = () => {
+ requestAnimationFrame(animate);
+ controls.update();
+ renderer.render(scene, camera);
+ labelRenderer.render(scene, camera);
+ };
+ animate();
+
+ return { scene, camera, renderer, labelRenderer, controls, patternGroup };
+}
+
+function addAxisLines(scene: THREE.Scene) {
+ const len = 3.2;
+
+ const xGeo = new THREE.BufferGeometry().setFromPoints([
+ new THREE.Vector3(0, 0, 0),
+ new THREE.Vector3(len, 0, 0),
+ ]);
+ const xMat = new THREE.LineBasicMaterial({ color: 0xef4444, opacity: 0.6, transparent: true });
+ scene.add(new THREE.Line(xGeo, xMat));
+
+ const yGeo = new THREE.BufferGeometry().setFromPoints([
+ new THREE.Vector3(0, 0, 0),
+ new THREE.Vector3(0, len, 0),
+ ]);
+ const yMat = new THREE.LineBasicMaterial({ color: 0x22c55e, opacity: 0.6, transparent: true });
+ scene.add(new THREE.Line(yGeo, yMat));
+
+ const zGeo = new THREE.BufferGeometry().setFromPoints([
+ new THREE.Vector3(0, 0, 0),
+ new THREE.Vector3(0, 0, len),
+ ]);
+ const zMat = new THREE.LineBasicMaterial({ color: 0x3b82f6, opacity: 0.6, transparent: true });
+ scene.add(new THREE.Line(zGeo, zMat));
+}
+
+function addAxisLabel(scene: THREE.Scene, text: string, position: THREE.Vector3) {
+ const el = document.createElement('span');
+ el.textContent = text;
+ el.style.color = LABEL_COLOR;
+ el.style.fontFamily = "'Inter', sans-serif";
+ el.style.fontSize = '12px';
+ el.style.fontWeight = '600';
+ const label = new CSS2DObject(el);
+ label.position.copy(position);
+ scene.add(label);
+}
+
+function addReferenceRings(scene: THREE.Scene) {
+ const dbiLevels = [-3, 0, 3, 6];
+ const minDbi = -3;
+ const maxDbi = 6;
+
+ dbiLevels.forEach((dbi) => {
+ const normalized = (dbi - minDbi) / (maxDbi - minDbi);
+ const radius = 0.1 + normalized * 1.9; // map to [0.1, 2.0]
+
+ const curve = new THREE.EllipseCurve(0, 0, radius, radius, 0, 2 * Math.PI, false, 0);
+ const points = curve.getPoints(64);
+ const geo = new THREE.BufferGeometry().setFromPoints(
+ points.map((p) => new THREE.Vector3(p.x, 0, p.y))
+ );
+ const mat = new THREE.LineBasicMaterial({
+ color: RING_COLOR,
+ opacity: 0.35,
+ transparent: true,
+ });
+ const ring = new THREE.Line(geo, mat);
+ scene.add(ring);
+
+ // Label for the ring
+ const el = document.createElement('span');
+ el.textContent = `${dbi >= 0 ? '+' : ''}${dbi} dBi`;
+ el.style.color = '#64748b'; // slate-500
+ el.style.fontFamily = "'Inter', sans-serif";
+ el.style.fontSize = '10px';
+ const label = new CSS2DObject(el);
+ label.position.set(radius + 0.1, 0, 0);
+ scene.add(label);
+ });
+}
diff --git a/frontend/src/smith.ts b/frontend/src/smith.ts
new file mode 100644
index 0000000..b4d3341
--- /dev/null
+++ b/frontend/src/smith.ts
@@ -0,0 +1,154 @@
+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();
+}
diff --git a/frontend/src/style.css b/frontend/src/style.css
new file mode 100644
index 0000000..cb49cb8
--- /dev/null
+++ b/frontend/src/style.css
@@ -0,0 +1,393 @@
+/* === Base === */
+*,
+*::before,
+*::after {
+ box-sizing: border-box;
+ margin: 0;
+ padding: 0;
+}
+
+:root {
+ --slate-50: #f8fafc;
+ --slate-100: #f1f5f9;
+ --slate-200: #e2e8f0;
+ --slate-300: #cbd5e1;
+ --slate-400: #94a3b8;
+ --slate-500: #64748b;
+ --slate-600: #475569;
+ --slate-700: #334155;
+ --slate-800: #1e293b;
+ --slate-900: #0f172a;
+ --slate-950: #020617;
+ --teal-400: #2dd4bf;
+ --teal-500: #14b8a6;
+ --amber-400: #fbbf24;
+ --amber-500: #f59e0b;
+ --red-400: #f87171;
+ --green-400: #4ade80;
+
+ --panel-width: 280px;
+ --panel-bg: rgba(15, 23, 42, 0.88);
+ --panel-border: rgba(51, 65, 85, 0.5);
+ --radius: 8px;
+ --radius-sm: 5px;
+}
+
+html, body {
+ height: 100%;
+ width: 100%;
+ overflow: hidden;
+ font-family: 'Inter', system-ui, -apple-system, sans-serif;
+ font-size: 13px;
+ line-height: 1.5;
+ color: var(--slate-300);
+ background: var(--slate-900);
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+/* === Layout === */
+#app {
+ position: relative;
+ width: 100%;
+ height: 100%;
+ display: flex;
+}
+
+#scene-container {
+ flex: 1;
+ position: relative;
+ overflow: hidden;
+}
+
+#smith-chart {
+ position: absolute;
+ bottom: 16px;
+ right: 16px;
+ width: 200px;
+ height: 200px;
+ border-radius: var(--radius);
+ z-index: 10;
+ box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
+}
+
+/* === Controls Panel === */
+.controls-panel {
+ width: var(--panel-width);
+ min-width: var(--panel-width);
+ height: 100%;
+ background: var(--panel-bg);
+ backdrop-filter: blur(16px);
+ border-right: 1px solid var(--panel-border);
+ padding: 20px 16px;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ overflow-y: auto;
+ z-index: 20;
+}
+
+.controls-header {
+ margin-bottom: 4px;
+}
+
+.controls-logo {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ color: var(--slate-100);
+ font-weight: 600;
+ font-size: 16px;
+}
+
+.controls-logo svg {
+ color: var(--teal-400);
+}
+
+.controls-subtitle {
+ color: var(--slate-500);
+ font-size: 11px;
+ margin-top: 2px;
+ letter-spacing: 0.03em;
+}
+
+/* Status */
+.controls-status {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 12px;
+ color: var(--slate-400);
+ padding: 6px 10px;
+ background: rgba(30, 41, 59, 0.6);
+ border-radius: var(--radius-sm);
+}
+
+.controls-status svg {
+ width: 14px;
+ height: 14px;
+}
+
+.status-dot {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ flex-shrink: 0;
+}
+
+.status-dot.status-on {
+ background: var(--green-400);
+ box-shadow: 0 0 6px rgba(74, 222, 128, 0.5);
+}
+
+.status-dot.status-off {
+ background: var(--slate-600);
+}
+
+/* Separators */
+.controls-sep {
+ border: none;
+ border-top: 1px solid var(--panel-border);
+ margin: 4px 0;
+}
+
+/* Section titles */
+.controls-section-title {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 11px;
+ font-weight: 600;
+ color: var(--slate-400);
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ margin-top: 2px;
+}
+
+.controls-section-title svg {
+ opacity: 0.7;
+}
+
+/* Fields */
+.ctrl-field {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ flex: 1;
+ min-width: 0;
+}
+
+.ctrl-label {
+ font-size: 11px;
+ color: var(--slate-500);
+ font-weight: 500;
+}
+
+.ctrl-input,
+.ctrl-select {
+ width: 100%;
+ padding: 7px 10px;
+ font-family: inherit;
+ font-size: 13px;
+ color: var(--slate-200);
+ background: var(--slate-800);
+ border: 1px solid var(--slate-700);
+ border-radius: var(--radius-sm);
+ outline: none;
+ transition: border-color 0.15s;
+}
+
+.ctrl-input:focus,
+.ctrl-select:focus {
+ border-color: var(--teal-400);
+}
+
+.ctrl-input::placeholder {
+ color: var(--slate-600);
+}
+
+.ctrl-select {
+ appearance: none;
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
+ background-repeat: no-repeat;
+ background-position: right 8px center;
+ padding-right: 28px;
+}
+
+/* Row layout */
+.controls-row {
+ display: flex;
+ gap: 8px;
+}
+
+/* Spacer */
+.controls-spacer {
+ height: 4px;
+}
+
+/* Buttons */
+.ctrl-btn {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
+ width: 100%;
+ padding: 9px 14px;
+ font-family: inherit;
+ font-size: 13px;
+ font-weight: 500;
+ border: 1px solid transparent;
+ border-radius: var(--radius);
+ cursor: pointer;
+ transition: all 0.15s;
+}
+
+.ctrl-btn svg {
+ width: 15px;
+ height: 15px;
+ flex-shrink: 0;
+}
+
+.ctrl-btn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.ctrl-btn-primary {
+ background: var(--teal-500);
+ color: var(--slate-950);
+}
+
+.ctrl-btn-primary:hover:not(:disabled) {
+ background: var(--teal-400);
+}
+
+.ctrl-btn-secondary {
+ background: transparent;
+ color: var(--slate-300);
+ border-color: var(--slate-600);
+}
+
+.ctrl-btn-secondary:hover:not(:disabled) {
+ border-color: var(--slate-400);
+ color: var(--slate-100);
+}
+
+/* Display mode toggle group */
+.controls-mode-group {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 4px;
+}
+
+.ctrl-mode-btn {
+ padding: 6px 8px;
+ font-family: inherit;
+ font-size: 12px;
+ font-weight: 500;
+ color: var(--slate-400);
+ background: var(--slate-800);
+ border: 1px solid var(--slate-700);
+ border-radius: var(--radius-sm);
+ cursor: pointer;
+ transition: all 0.15s;
+}
+
+.ctrl-mode-btn:hover {
+ color: var(--slate-200);
+ border-color: var(--slate-500);
+}
+
+.ctrl-mode-btn.active {
+ color: var(--teal-400);
+ border-color: var(--teal-400);
+ background: rgba(45, 212, 191, 0.08);
+}
+
+/* Readout */
+.controls-readout {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ padding: 10px 12px;
+ background: rgba(30, 41, 59, 0.6);
+ border-radius: var(--radius-sm);
+}
+
+.readout-label {
+ font-size: 11px;
+ color: var(--slate-500);
+ font-weight: 500;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+
+.readout-value {
+ font-size: 18px;
+ font-weight: 600;
+ color: var(--amber-400);
+ font-variant-numeric: tabular-nums;
+}
+
+/* Loading overlay for controls */
+.controls-loading {
+ font-size: 12px;
+ color: var(--teal-400);
+ text-align: center;
+ padding: 6px;
+ animation: pulse 1.2s ease-in-out infinite;
+}
+
+/* Scene loading overlay */
+.scene-loading {
+ position: absolute;
+ inset: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 5;
+ pointer-events: none;
+}
+
+.scene-loading-content {
+ text-align: center;
+ color: var(--slate-500);
+ font-size: 14px;
+}
+
+.scene-loading-content strong {
+ color: var(--slate-300);
+}
+
+.scene-loading-spinner {
+ width: 32px;
+ height: 32px;
+ margin: 0 auto 16px;
+ border: 3px solid var(--slate-700);
+ border-top-color: var(--teal-400);
+ border-radius: 50%;
+ animation: spin 0.8s linear infinite;
+}
+
+@keyframes spin {
+ to { transform: rotate(360deg); }
+}
+
+@keyframes pulse {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.5; }
+}
+
+/* Scrollbar */
+.controls-panel::-webkit-scrollbar {
+ width: 4px;
+}
+
+.controls-panel::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+.controls-panel::-webkit-scrollbar-thumb {
+ background: var(--slate-700);
+ border-radius: 2px;
+}
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
new file mode 100644
index 0000000..6a66abe
--- /dev/null
+++ b/frontend/src/types.ts
@@ -0,0 +1,54 @@
+export interface PatternData {
+ antenna_type: string;
+ frequency_hz: number;
+ wavelength_m: number;
+ theta_deg: number[];
+ phi_deg: number[];
+ gain_dbi: number[][];
+ peak_gain_dbi: number;
+ e_plane: PlanePoint[];
+ h_plane: PlanePoint[];
+ model: string;
+ resonance?: ResonanceData;
+}
+
+export interface PlanePoint {
+ theta_deg?: number;
+ phi_deg?: number;
+ gain_dbi: number;
+}
+
+export interface ResonanceData {
+ frequency_hz: number;
+ swr: number;
+ return_loss_db: number;
+ impedance_real: number;
+ impedance_imag: number;
+}
+
+export interface ComputeParams {
+ antenna_type: string;
+ frequency_hz: number;
+ impedance_real: number;
+ impedance_imag: number;
+}
+
+export interface ScanParams {
+ antenna_type: string;
+ start_hz: number;
+ stop_hz: number;
+ points: number;
+}
+
+export type DisplayMode = 'surface' | 'wireframe' | 'e-plane' | 'h-plane';
+
+export interface AppState {
+ connected: boolean;
+ loading: boolean;
+ pattern: PatternData | null;
+ displayMode: DisplayMode;
+ antennaType: string;
+ frequencyMhz: number;
+ impedanceReal: number;
+ impedanceImag: number;
+}
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
new file mode 100644
index 0000000..42849dc
--- /dev/null
+++ b/frontend/tsconfig.json
@@ -0,0 +1,26 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "useDefineForClassFields": true,
+ "module": "ESNext",
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "types": ["vite/client"],
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": false,
+ "noUnusedParameters": false,
+ "erasableSyntaxOnly": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedSideEffectImports": true
+ },
+ "include": ["src"]
+}
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
new file mode 100644
index 0000000..23ecff5
--- /dev/null
+++ b/frontend/vite.config.ts
@@ -0,0 +1,17 @@
+import { defineConfig } from 'vite';
+
+export default defineConfig({
+ build: {
+ outDir: '../src/mcnanovna/webui/static',
+ emptyOutDir: true,
+ },
+ server: {
+ proxy: {
+ '/api': 'http://localhost:8080',
+ '/ws': {
+ target: 'ws://localhost:8080',
+ ws: true,
+ },
+ },
+ },
+});
diff --git a/pyproject.toml b/pyproject.toml
index 4076bac..539fcc2 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "mcnanovna"
-version = "2026.01.30"
+version = "2026.01.31"
description = "MCP server for NanoVNA-H vector network analyzers"
authors = [{name = "Ryan Malloy", email = "ryan@supported.systems"}]
requires-python = ">=3.11"
@@ -10,6 +10,12 @@ dependencies = [
"Pillow>=11.0.0",
]
+[project.optional-dependencies]
+webui = [
+ "fastapi>=0.115.0",
+ "uvicorn[standard]>=0.34.0",
+]
+
[project.scripts]
mcnanovna = "mcnanovna.server:main"
diff --git a/src/mcnanovna/nanovna.py b/src/mcnanovna/nanovna.py
index 15e8d15..9aeadb1 100644
--- a/src/mcnanovna/nanovna.py
+++ b/src/mcnanovna/nanovna.py
@@ -16,6 +16,7 @@ from mcnanovna.tools import (
DiagnosticsMixin,
DisplayMixin,
MeasurementMixin,
+ RadiationMixin,
)
@@ -26,6 +27,7 @@ class NanoVNA(
DeviceMixin,
DiagnosticsMixin,
AnalysisMixin,
+ RadiationMixin,
):
"""MCP tool class for NanoVNA-H vector network analyzers.
diff --git a/src/mcnanovna/prompts.py b/src/mcnanovna/prompts.py
index 7cd151b..78b59c9 100644
--- a/src/mcnanovna/prompts.py
+++ b/src/mcnanovna/prompts.py
@@ -723,6 +723,88 @@ Let me run `analyze_lc_shunt` now.""",
),
]
+ @mcp.prompt
+ def visualize_radiation_pattern(
+ antenna_type: str = "dipole",
+ band: str = "2m",
+ start_hz: int | None = None,
+ stop_hz: int | None = None,
+ points: int = 101,
+ ) -> list[Message]:
+ """Guide through 3D antenna radiation pattern visualization.
+
+ Scans S11 to determine resonant frequency and impedance, then computes
+ an analytical 3D radiation pattern for the specified antenna type.
+ If the web UI is running (MCNANOVNA_WEB_PORT), the pattern is pushed
+ to the 3D viewer in real time.
+
+ Args:
+ antenna_type: Antenna model — 'dipole', 'monopole', 'efhw', 'loop', 'patch', or 'auto'
+ band: Ham band name (e.g., '2m', '70cm', '20m') or 'custom'
+ start_hz: Start frequency in Hz (overrides band)
+ stop_hz: Stop frequency in Hz (overrides band)
+ points: Number of measurement points
+ """
+ if start_hz is not None and stop_hz is not None:
+ f_start, f_stop = start_hz, stop_hz
+ band_label = f"Custom ({_format_freq(f_start)} – {_format_freq(f_stop)})"
+ elif band in HAM_BANDS:
+ f_start, f_stop = HAM_BANDS[band]
+ band_label = f"{band.upper()} band"
+ else:
+ f_start, f_stop = HAM_BANDS["2m"]
+ band_label = "2M band"
+
+ type_label = antenna_type if antenna_type != "auto" else "auto-detected"
+
+ return [
+ Message(
+ role="user",
+ content=(
+ f"Visualize the radiation pattern of my {type_label} antenna "
+ f"on the {band_label} ({_format_freq(f_start)} – {_format_freq(f_stop)})."
+ ),
+ ),
+ Message(
+ role="assistant",
+ content=f"""I'll generate a 3D radiation pattern for your antenna.
+
+**Antenna type**: {type_label}
+**Band**: {band_label} ({_format_freq(f_start)} – {_format_freq(f_stop)})
+**Points**: {points}
+
+**Supported antenna models** (Phase 1 — analytical patterns):
+| Type | Pattern shape | Feed-point Z at resonance |
+|------|--------------|--------------------------|
+| Dipole | Figure-8 (donut) | ~73 Ω |
+| Monopole | Half-donut over ground | ~36 Ω |
+| EFHW | Same as dipole | ~2500-5000 Ω |
+| Small loop | Figure-8 (rotated 90°) | Low R, inductive |
+| Patch | Broadside hemisphere | ~50-300 Ω |
+
+**How it works:**
+1. Scan S11 across the band to find resonant frequency and impedance
+2. {"Estimate antenna type from impedance" if antenna_type == "auto" else f"Use the '{antenna_type}' analytical model"}
+3. Compute 3D gain pattern on a θ×φ grid (~6500 points)
+4. Return gain in dBi at each (θ, φ) — ready for 3D rendering
+
+**Output includes:**
+- Full 3D gain grid (θ: 0-180°, φ: 0-355°)
+- Peak gain in dBi
+- E-plane cut (φ=0°) and H-plane cut (θ=90°)
+- S11 analysis context (resonance, impedance, SWR)
+
+**Web UI** (if running):
+Set `MCNANOVNA_WEB_PORT=8080` to launch the 3D viewer at http://localhost:8080.
+The pattern renders as an interactive gain-mapped sphere with OrbitControls.
+
+**No hardware?** Use `radiation_pattern_from_data` to compute a pattern from
+known impedance values — no VNA connection required.
+
+Let me scan and generate the pattern now.""",
+ ),
+ ]
+
@mcp.prompt
def impedance_match(
frequency_hz: int = 145_000_000,
diff --git a/src/mcnanovna/radiation.py b/src/mcnanovna/radiation.py
new file mode 100644
index 0000000..3b74b13
--- /dev/null
+++ b/src/mcnanovna/radiation.py
@@ -0,0 +1,423 @@
+"""Antenna radiation pattern models driven by S11 measurements.
+
+Phase 1: Analytical models using closed-form equations for common antenna types.
+S11 gives us impedance + resonant frequency; combined with user-specified antenna
+type, we compute idealized 3D radiation patterns.
+
+All functions are pure Python (math + list comprehensions). No external dependencies.
+Grid size is typically 91x72 = 6552 points — trivial without numpy.
+
+Phase 2 hooks: generate_3d_pattern() returns standardized {theta, phi, gain_dbi}
+dicts. Future measured-pattern tools will produce the same format.
+"""
+
+from __future__ import annotations
+
+import math
+
+# Speed of light in m/s
+C = 299_792_458
+
+
+def _deg_range(start: float, stop: float, step: float) -> list[float]:
+ """Generate a list of angles in degrees from start to stop (inclusive)."""
+ result = []
+ val = start
+ while val <= stop + step * 0.01:
+ result.append(val)
+ val += step
+ return result
+
+
+def _directivity_to_dbi(directivity: float) -> float:
+ """Convert linear directivity to dBi."""
+ if directivity <= 0:
+ return -40.0
+ return 10.0 * math.log10(directivity)
+
+
+# ── Antenna pattern functions ─────────────────────────────────────
+#
+# Each returns linear gain (directivity) for given angles.
+# Convention: theta = polar angle from z-axis (0° = zenith),
+# phi = azimuthal angle in x-y plane (0° = x-axis).
+# Patterns are normalized so peak directivity matches the theoretical value.
+
+
+def dipole_gain(theta_deg: float, frequency_hz: float, length_m: float | None = None) -> float:
+ """Half-wave dipole radiation pattern.
+
+ Uses the exact far-field expression for a center-fed thin dipole:
+ E(θ) = cos(kL/2 · cos θ) - cos(kL/2) / sin θ
+
+ For a half-wave dipole (L = λ/2), this simplifies to:
+ E(θ) = cos(π/2 · cos θ) / sin θ
+
+ Peak directivity: 2.15 dBi (1.64 linear).
+
+ Args:
+ theta_deg: Polar angle from antenna axis (degrees, 0=along wire)
+ frequency_hz: Operating frequency in Hz
+ length_m: Physical length in meters (default: half-wavelength)
+ """
+ wavelength = C / frequency_hz
+ if length_m is None:
+ length_m = wavelength / 2.0
+
+ theta = math.radians(theta_deg)
+ sin_theta = math.sin(theta)
+
+ if abs(sin_theta) < 1e-10:
+ return 0.0
+
+ k = 2.0 * math.pi / wavelength
+ half_kl = k * length_m / 2.0
+ cos_theta = math.cos(theta)
+
+ numerator = math.cos(half_kl * cos_theta) - math.cos(half_kl)
+ e_field = numerator / sin_theta
+
+ # Normalize to peak directivity of 1.64 (2.15 dBi) for half-wave
+ # The raw max of cos(π/2·cosθ)/sinθ is 1.0 at θ=90°
+ directivity = 1.64 * e_field * e_field
+ return directivity
+
+
+def monopole_gain(theta_deg: float, frequency_hz: float, length_m: float | None = None) -> float:
+ """Quarter-wave monopole over perfect ground plane.
+
+ Image theory: a monopole over ground has the same pattern as the upper
+ hemisphere of a dipole, but with doubled directivity (energy only radiates
+ into the upper hemisphere).
+
+ Peak directivity: 5.15 dBi (3.28 linear) = dipole + 3 dB ground gain.
+ Impedance at resonance: ~36 Ω (half of dipole's 73 Ω).
+
+ Args:
+ theta_deg: Elevation angle (0=horizon, 90=zenith)
+ frequency_hz: Operating frequency in Hz
+ length_m: Physical length in meters (default: quarter-wavelength)
+ """
+ wavelength = C / frequency_hz
+ if length_m is None:
+ length_m = wavelength / 4.0
+
+ # Monopole: no radiation below ground plane
+ if theta_deg > 90.0:
+ return 0.0
+
+ # Map elevation to dipole theta (monopole elevation 0°=horizon → dipole 90°)
+ dipole_theta = 90.0 - theta_deg
+
+ # Use dipole pattern with doubled length (image theory)
+ gain = dipole_gain(dipole_theta, frequency_hz, length_m * 2.0)
+ # Double directivity for hemisphere-only radiation
+ return gain * 2.0
+
+
+def loop_gain(theta_deg: float, frequency_hz: float, circumference_m: float | None = None) -> float:
+ """Small magnetic loop antenna pattern.
+
+ A small loop (circumference << λ) acts as a magnetic dipole with
+ a sin(θ) pattern (null along the loop axis, maximum in the plane
+ of the loop).
+
+ Peak directivity: 1.76 dBi (1.5 linear) — same as a short dipole.
+
+ Args:
+ theta_deg: Polar angle from loop axis (0=through loop, 90=in loop plane)
+ frequency_hz: Operating frequency in Hz
+ circumference_m: Loop circumference in meters (default: λ/10)
+ """
+ wavelength = C / frequency_hz
+ if circumference_m is None:
+ circumference_m = wavelength / 10.0
+
+ theta = math.radians(theta_deg)
+ sin_theta = math.sin(theta)
+
+ # Small loop: D(θ) = 1.5 * sin²(θ)
+ return 1.5 * sin_theta * sin_theta
+
+
+def patch_gain(
+ theta_deg: float,
+ phi_deg: float,
+ frequency_hz: float,
+ width_m: float | None = None,
+ length_m: float | None = None,
+ er: float = 4.4,
+) -> float:
+ """Rectangular microstrip patch antenna (cavity model).
+
+ The patch radiates broadside (maximum at θ=0). The E-plane (φ=0) and
+ H-plane (φ=90°) patterns differ due to the rectangular aperture.
+
+ E-plane: cos(θ) envelope × sinc(kW/2 · sin θ)
+ H-plane: cos(kL_eff/2 · sin θ) × cos(θ)
+
+ Peak directivity: ~6-8 dBi depending on substrate.
+
+ Args:
+ theta_deg: Polar angle from broadside (0=directly above patch)
+ phi_deg: Azimuthal angle (0=E-plane, 90=H-plane)
+ frequency_hz: Operating frequency in Hz
+ width_m: Patch width in meters (default: estimated from frequency + εr)
+ length_m: Patch length in meters (default: λ_eff/2)
+ er: Substrate relative permittivity (default 4.4 for FR-4)
+ """
+ wavelength = C / frequency_hz
+ lambda_eff = wavelength / math.sqrt(er)
+
+ if length_m is None:
+ length_m = lambda_eff / 2.0
+ if width_m is None:
+ width_m = wavelength / 2.0 # Typical width ~ free-space λ/2
+
+ theta = math.radians(theta_deg)
+ phi = math.radians(phi_deg)
+ cos_theta = math.cos(theta)
+ sin_theta = math.sin(theta)
+ cos_phi = math.cos(phi)
+ sin_phi = math.sin(phi)
+
+ # No radiation behind ground plane
+ if theta_deg > 90.0:
+ return 0.0
+
+ k0 = 2.0 * math.pi / wavelength
+
+ # E-plane factor (along patch length)
+ kw_arg = k0 * width_m * sin_theta * cos_phi / 2.0
+ if abs(kw_arg) < 1e-10:
+ sinc_e = 1.0
+ else:
+ sinc_e = math.sin(kw_arg) / kw_arg
+
+ # H-plane factor (along patch width)
+ kl_arg = k0 * length_m * sin_theta * sin_phi / 2.0
+ cos_h = math.cos(kl_arg)
+
+ # Combine: directivity pattern
+ pattern = cos_theta * sinc_e * cos_h
+ gain = pattern * pattern
+
+ # Approximate peak directivity for a patch: D ≈ 4πWL/λ² * radiation efficiency
+ # Simplified to ~6.6 (8.2 dBi) for typical patch
+ peak_d = min(4.0 * math.pi * width_m * length_m / (wavelength * wavelength), 8.0)
+ peak_d = max(peak_d, 4.0) # Floor at ~6 dBi
+
+ return peak_d * gain
+
+
+def efhw_gain(theta_deg: float, frequency_hz: float) -> float:
+ """End-Fed Half-Wave (EFHW) antenna pattern.
+
+ An EFHW has fundamentally the same radiation pattern as a center-fed
+ half-wave dipole — the current distribution is the same sinusoidal
+ shape. The feed-point impedance is very high (~2500-5000 Ω) because
+ it's fed at the voltage maximum, but the far-field pattern is identical.
+
+ Peak directivity: 2.15 dBi (1.64 linear).
+
+ Args:
+ theta_deg: Polar angle from wire axis (0=along wire)
+ frequency_hz: Operating frequency in Hz
+ """
+ return dipole_gain(theta_deg, frequency_hz, length_m=None)
+
+
+# ── Antenna type estimation ───────────────────────────────────────
+
+
+def estimate_antenna_type(
+ impedance_real: float,
+ impedance_imag: float,
+ resonant_freq_hz: float,
+ bandwidth_hz: float = 0,
+) -> str:
+ """Estimate antenna type from measured impedance at resonance.
+
+ Uses feed-point impedance as the primary discriminator:
+ - ~73 Ω → half-wave dipole
+ - ~36 Ω → quarter-wave monopole (or λ/4 vertical)
+ - ~2500-5000 Ω → EFHW
+ - Low R with high inductive X → small loop
+ - ~50-300 Ω broadside → patch
+
+ This is a heuristic — many antennas don't fit neatly into categories.
+
+ Args:
+ impedance_real: Resistance at resonance (Ω)
+ impedance_imag: Reactance at resonance (Ω)
+ resonant_freq_hz: Resonant frequency in Hz
+ bandwidth_hz: 2:1 SWR bandwidth in Hz (0 if unknown)
+ """
+ r = impedance_real
+ x = abs(impedance_imag)
+
+ if r > 1500:
+ return "efhw"
+ if r < 5 and x > 10:
+ return "loop"
+ if 25 <= r <= 42 and x < 20:
+ return "monopole"
+ if 45 <= r <= 100 and x < 30:
+ return "dipole"
+ if 100 < r <= 500 and x < 50:
+ return "patch"
+
+ # Fallback: use resonant impedance vs canonical values
+ if abs(r - 73) < abs(r - 36):
+ return "dipole"
+ return "monopole"
+
+
+# ── 3D pattern generation ─────────────────────────────────────────
+
+
+def generate_3d_pattern(
+ antenna_type: str,
+ frequency_hz: float,
+ s11_analysis: dict | None = None,
+ theta_step: float = 2.0,
+ phi_step: float = 5.0,
+ length_m: float | None = None,
+ er: float = 4.4,
+) -> dict:
+ """Generate a full 3D radiation pattern grid.
+
+ Returns a dict with theta/phi axes and a 2D gain grid suitable for
+ rendering as a gain-mapped sphere in Three.js.
+
+ This is the standardized output format shared between Phase 1 (analytical)
+ and Phase 2 (measured) patterns.
+
+ Args:
+ antenna_type: One of 'dipole', 'monopole', 'efhw', 'loop', 'patch'
+ frequency_hz: Operating frequency in Hz
+ s11_analysis: Optional S11 analysis dict (from analyze_scan) for context
+ theta_step: Polar angle resolution in degrees (default 2°)
+ phi_step: Azimuthal angle resolution in degrees (default 5°)
+ length_m: Override antenna element length in meters
+ er: Substrate εr for patch antennas (default 4.4)
+ """
+ thetas = _deg_range(0.0, 180.0, theta_step)
+ phis = _deg_range(0.0, 355.0, phi_step)
+
+ # Build gain grid: gain[i_theta][i_phi] in dBi
+ gain_grid: list[list[float]] = []
+ peak_linear = 0.0
+
+ for theta in thetas:
+ row: list[float] = []
+ for phi in phis:
+ if antenna_type == "dipole":
+ g = dipole_gain(theta, frequency_hz, length_m)
+ elif antenna_type == "monopole":
+ g = monopole_gain(theta, frequency_hz, length_m)
+ elif antenna_type == "efhw":
+ g = efhw_gain(theta, frequency_hz)
+ elif antenna_type == "loop":
+ g = loop_gain(theta, frequency_hz)
+ elif antenna_type == "patch":
+ g = patch_gain(theta, phi, frequency_hz, length_m=length_m, er=er)
+ else:
+ g = dipole_gain(theta, frequency_hz, length_m)
+
+ if g > peak_linear:
+ peak_linear = g
+ row.append(g)
+ gain_grid.append(row)
+
+ # Convert to dBi
+ gain_dbi: list[list[float]] = []
+ for row in gain_grid:
+ gain_dbi.append([_directivity_to_dbi(g) for g in row])
+
+ peak_dbi = _directivity_to_dbi(peak_linear)
+
+ # Compute E-plane and H-plane cuts
+ # E-plane: φ=0° (first column of each theta row)
+ e_plane = [{"theta_deg": thetas[i], "gain_dbi": gain_dbi[i][0]} for i in range(len(thetas))]
+
+ # H-plane: θ=90° (row at theta=90°, all phi values)
+ theta_90_idx = min(range(len(thetas)), key=lambda i: abs(thetas[i] - 90.0))
+ h_plane = [{"phi_deg": phis[j], "gain_dbi": gain_dbi[theta_90_idx][j]} for j in range(len(phis))]
+
+ wavelength = C / frequency_hz
+
+ result: dict = {
+ "antenna_type": antenna_type,
+ "frequency_hz": frequency_hz,
+ "wavelength_m": round(wavelength, 4),
+ "theta_deg": thetas,
+ "phi_deg": phis,
+ "gain_dbi": gain_dbi,
+ "peak_gain_dbi": round(peak_dbi, 2),
+ "e_plane": e_plane,
+ "h_plane": h_plane,
+ "grid_size": {"theta_points": len(thetas), "phi_points": len(phis), "total_points": len(thetas) * len(phis)},
+ "model": "analytical",
+ }
+
+ if s11_analysis:
+ result["s11_context"] = s11_analysis
+
+ return result
+
+
+# ── Multi-frequency pattern generation ────────────────────────────
+
+
+def generate_multi_frequency_patterns(
+ antenna_type: str,
+ frequencies_hz: list[float],
+ s11_analysis: dict | None = None,
+ theta_step: float = 4.0,
+ phi_step: float = 10.0,
+ length_m: float | None = None,
+ er: float = 4.4,
+) -> dict:
+ """Generate patterns at multiple frequencies for animation.
+
+ Uses a coarser grid (4°×10° = 46×36 = 1656 points per frequency)
+ to keep total payload reasonable for WebSocket streaming.
+
+ Args:
+ antenna_type: Antenna type string
+ frequencies_hz: List of frequencies to compute patterns at
+ s11_analysis: Optional analysis context
+ theta_step: Coarser theta step for multi-freq (default 4°)
+ phi_step: Coarser phi step for multi-freq (default 10°)
+ length_m: Override antenna element length
+ er: Substrate εr for patch
+ """
+ patterns = []
+ for freq in frequencies_hz:
+ p = generate_3d_pattern(
+ antenna_type,
+ freq,
+ s11_analysis=None,
+ theta_step=theta_step,
+ phi_step=phi_step,
+ length_m=length_m,
+ er=er,
+ )
+ # Slim down for multi-freq: drop plane cuts, keep grid
+ patterns.append(
+ {
+ "frequency_hz": freq,
+ "gain_dbi": p["gain_dbi"],
+ "peak_gain_dbi": p["peak_gain_dbi"],
+ }
+ )
+
+ return {
+ "antenna_type": antenna_type,
+ "count": len(patterns),
+ "theta_deg": _deg_range(0.0, 180.0, theta_step),
+ "phi_deg": _deg_range(0.0, 355.0, phi_step),
+ "patterns": patterns,
+ "model": "analytical",
+ }
diff --git a/src/mcnanovna/server.py b/src/mcnanovna/server.py
index 62ae59e..9176772 100644
--- a/src/mcnanovna/server.py
+++ b/src/mcnanovna/server.py
@@ -90,6 +90,10 @@ _TOOL_METHODS = [
"analyze_lc_match",
"analyze_s11_resonance",
"analyze",
+ # tools/radiation.py — RadiationMixin
+ "radiation_pattern",
+ "radiation_pattern_from_data",
+ "radiation_pattern_multi",
]
@@ -112,10 +116,14 @@ def create_server() -> FastMCP:
"'analyze_lc_series' and 'analyze_lc_shunt' (resonator parameters from S21), "
"'analyze_lc_match' (L-network matching solver, accepts direct R+jX or scans S11), "
"'analyze_s11_resonance' (find up to 6 resonant frequencies).\n\n"
+ "Radiation pattern tools: 'radiation_pattern' (scan S11 → 3D pattern), "
+ "'radiation_pattern_from_data' (compute pattern from known impedance, no hardware), "
+ "'radiation_pattern_multi' (patterns at N frequencies for animation). "
+ "Supported antenna types: dipole, monopole, efhw, loop, patch, or 'auto' to estimate.\n\n"
"Prompts are available for guided workflows: calibrate, export_touchstone, "
"analyze_antenna, measure_cable, compare_sweeps, analyze_crystal, "
"analyze_filter_response, measure_tdr, measure_component, measure_lc_series, "
- "measure_lc_shunt, and impedance_match."
+ "measure_lc_shunt, impedance_match, and visualize_radiation_pattern."
),
)
vna = NanoVNA()
@@ -131,6 +139,8 @@ def create_server() -> FastMCP:
def main() -> None:
+ import os
+
try:
from importlib.metadata import version
@@ -140,6 +150,19 @@ def main() -> None:
print(f"mcnanovna v{package_version} — NanoVNA-H MCP server")
+ web_port = os.environ.get("MCNANOVNA_WEB_PORT")
+ if web_port:
+ try:
+ port = int(web_port)
+ from mcnanovna.webui import start_web_server
+
+ start_web_server(port)
+ print(f"Web UI available at http://localhost:{port}")
+ except ImportError:
+ print("Web UI requires optional dependencies: pip install mcnanovna[webui]")
+ except ValueError:
+ print(f"Invalid MCNANOVNA_WEB_PORT: {web_port}")
+
server = create_server()
server.run()
diff --git a/src/mcnanovna/tools/__init__.py b/src/mcnanovna/tools/__init__.py
index 473b5d1..dae87e2 100644
--- a/src/mcnanovna/tools/__init__.py
+++ b/src/mcnanovna/tools/__init__.py
@@ -15,6 +15,7 @@ from .device import DeviceMixin
from .diagnostics import DiagnosticsMixin
from .display import DisplayMixin
from .measurement import MeasurementMixin
+from .radiation import RadiationMixin
async def _progress(ctx: Context | None, progress: float, total: float, message: str) -> None:
@@ -30,4 +31,5 @@ __all__ = [
"DiagnosticsMixin",
"DisplayMixin",
"MeasurementMixin",
+ "RadiationMixin",
]
diff --git a/src/mcnanovna/tools/radiation.py b/src/mcnanovna/tools/radiation.py
new file mode 100644
index 0000000..35f23fe
--- /dev/null
+++ b/src/mcnanovna/tools/radiation.py
@@ -0,0 +1,204 @@
+"""RadiationMixin — 3D antenna radiation pattern tools."""
+
+from __future__ import annotations
+
+import asyncio
+
+from fastmcp import Context
+
+
+class RadiationMixin:
+ """Radiation pattern tools: radiation_pattern, radiation_pattern_from_data, radiation_pattern_multi."""
+
+ async def radiation_pattern(
+ self,
+ antenna_type: str = "dipole",
+ start_hz: int | None = None,
+ stop_hz: int | None = None,
+ points: int = 101,
+ theta_step: float = 2.0,
+ phi_step: float = 5.0,
+ er: float = 4.4,
+ ctx: Context | None = None,
+ ) -> dict:
+ """Scan S11, find resonance, and compute a 3D radiation pattern.
+
+ Performs an S11 scan across the specified range, identifies the resonant
+ frequency and impedance, optionally estimates the antenna type, then
+ generates an analytical 3D radiation pattern grid.
+
+ Returns {theta_deg, phi_deg, gain_dbi} suitable for 3D visualization.
+
+ Args:
+ antenna_type: Antenna model — 'dipole', 'monopole', 'efhw', 'loop', 'patch', or 'auto' to estimate from impedance
+ start_hz: Start frequency in Hz (default: 2m band 144 MHz)
+ stop_hz: Stop frequency in Hz (default: 2m band 148 MHz)
+ points: Number of S11 scan points (default 101)
+ theta_step: Polar angle resolution in degrees (default 2°)
+ phi_step: Azimuthal angle resolution in degrees (default 5°)
+ er: Substrate εr for patch antennas (default 4.4 for FR-4)
+ """
+ from mcnanovna.calculations import analyze_scan, find_resonance
+ from mcnanovna.radiation import estimate_antenna_type, generate_3d_pattern
+ from mcnanovna.tools import _progress
+
+ if start_hz is None:
+ start_hz = 144_000_000
+ if stop_hz is None:
+ stop_hz = 148_000_000
+
+ await _progress(ctx, 1, 5, "Connecting to NanoVNA...")
+ await asyncio.to_thread(self._ensure_connected)
+
+ await _progress(ctx, 2, 5, f"Scanning S11 ({points} points)...")
+ scan_result = await self.scan(start_hz, stop_hz, points, s11=True, s21=False)
+ if "error" in scan_result:
+ return scan_result
+
+ await _progress(ctx, 3, 5, "Analyzing S11 data...")
+ freqs = [pt["frequency_hz"] for pt in scan_result["data"]]
+ s11_data = [pt["s11"] for pt in scan_result["data"]]
+ s11_complex = [complex(s["real"], s["imag"]) for s in s11_data]
+
+ analysis = analyze_scan(scan_result["data"])
+ resonance = find_resonance(s11_complex, freqs)
+
+ # Auto-detect antenna type from impedance
+ if antenna_type == "auto":
+ res_freq = resonance.get("frequency_hz", freqs[len(freqs) // 2])
+ z_real = resonance.get("impedance_real", 50.0)
+ z_imag = resonance.get("impedance_imag", 0.0)
+ bw = analysis.get("s11_analysis", {}).get("bandwidth_2_1", {}).get("bandwidth_hz", 0)
+ antenna_type = estimate_antenna_type(z_real, z_imag, res_freq, bw)
+
+ res_freq = resonance.get("frequency_hz", (start_hz + stop_hz) // 2)
+
+ await _progress(ctx, 4, 5, f"Computing {antenna_type} radiation pattern at {res_freq} Hz...")
+ pattern = generate_3d_pattern(
+ antenna_type=antenna_type,
+ frequency_hz=res_freq,
+ s11_analysis=analysis.get("s11_analysis"),
+ theta_step=theta_step,
+ phi_step=phi_step,
+ er=er,
+ )
+
+ pattern["scan_info"] = {
+ "start_hz": start_hz,
+ "stop_hz": stop_hz,
+ "points": scan_result["points"],
+ }
+ pattern["resonance"] = resonance
+
+ await _progress(ctx, 5, 5, "Radiation pattern complete")
+ return pattern
+
+ async def radiation_pattern_from_data(
+ self,
+ antenna_type: str = "dipole",
+ frequency_hz: float = 145_000_000,
+ impedance_real: float = 73.0,
+ impedance_imag: float = 0.0,
+ theta_step: float = 2.0,
+ phi_step: float = 5.0,
+ length_m: float | None = None,
+ er: float = 4.4,
+ ctx: Context | None = None,
+ ) -> dict:
+ """Compute a 3D radiation pattern from provided impedance (no hardware).
+
+ Generates an analytical radiation pattern using the specified antenna
+ type and frequency. No VNA connection required — useful for what-if
+ analysis or when impedance is already known.
+
+ Args:
+ antenna_type: Antenna model — 'dipole', 'monopole', 'efhw', 'loop', 'patch', or 'auto'
+ frequency_hz: Operating frequency in Hz (default 145 MHz)
+ impedance_real: Known resistance at resonance in ohms (default 73)
+ impedance_imag: Known reactance at resonance in ohms (default 0)
+ theta_step: Polar angle resolution in degrees (default 2°)
+ phi_step: Azimuthal angle resolution in degrees (default 5°)
+ length_m: Override antenna element length in meters (default: calculated from frequency)
+ er: Substrate εr for patch antennas (default 4.4)
+ """
+ from mcnanovna.radiation import estimate_antenna_type, generate_3d_pattern
+ from mcnanovna.tools import _progress
+
+ if antenna_type == "auto":
+ antenna_type = estimate_antenna_type(impedance_real, impedance_imag, frequency_hz)
+
+ await _progress(ctx, 1, 2, f"Computing {antenna_type} pattern at {frequency_hz} Hz...")
+
+ s11_context = {
+ "resonance": {
+ "frequency_hz": frequency_hz,
+ "impedance_real": impedance_real,
+ "impedance_imag": impedance_imag,
+ }
+ }
+
+ pattern = generate_3d_pattern(
+ antenna_type=antenna_type,
+ frequency_hz=frequency_hz,
+ s11_analysis=s11_context,
+ theta_step=theta_step,
+ phi_step=phi_step,
+ length_m=length_m,
+ er=er,
+ )
+
+ await _progress(ctx, 2, 2, "Pattern generation complete")
+ return pattern
+
+ async def radiation_pattern_multi(
+ self,
+ antenna_type: str = "dipole",
+ start_hz: int = 144_000_000,
+ stop_hz: int = 148_000_000,
+ num_frequencies: int = 5,
+ theta_step: float = 4.0,
+ phi_step: float = 10.0,
+ length_m: float | None = None,
+ er: float = 4.4,
+ ctx: Context | None = None,
+ ) -> dict:
+ """Compute radiation patterns at multiple frequencies across a band.
+
+ Generates a series of 3D patterns at evenly-spaced frequencies for
+ animation or comparison. Uses a coarser grid to keep payload size
+ manageable for WebSocket streaming.
+
+ Args:
+ antenna_type: Antenna model — 'dipole', 'monopole', 'efhw', 'loop', 'patch'
+ start_hz: Band start frequency in Hz
+ stop_hz: Band stop frequency in Hz
+ num_frequencies: Number of frequency steps (default 5)
+ theta_step: Polar angle resolution in degrees (default 4°, coarser for multi)
+ phi_step: Azimuthal angle resolution in degrees (default 10°, coarser for multi)
+ length_m: Override antenna element length in meters
+ er: Substrate εr for patch antennas (default 4.4)
+ """
+ from mcnanovna.radiation import generate_multi_frequency_patterns
+ from mcnanovna.tools import _progress
+
+ if num_frequencies < 2:
+ num_frequencies = 2
+ if num_frequencies > 20:
+ num_frequencies = 20
+
+ step = (stop_hz - start_hz) / (num_frequencies - 1)
+ frequencies = [start_hz + int(i * step) for i in range(num_frequencies)]
+
+ await _progress(ctx, 1, 2, f"Computing {num_frequencies} patterns from {start_hz} to {stop_hz} Hz...")
+
+ result = generate_multi_frequency_patterns(
+ antenna_type=antenna_type,
+ frequencies_hz=frequencies,
+ theta_step=theta_step,
+ phi_step=phi_step,
+ length_m=length_m,
+ er=er,
+ )
+
+ await _progress(ctx, 2, 2, f"Generated {num_frequencies} radiation patterns")
+ return result
diff --git a/src/mcnanovna/webui/__init__.py b/src/mcnanovna/webui/__init__.py
new file mode 100644
index 0000000..ffdbcba
--- /dev/null
+++ b/src/mcnanovna/webui/__init__.py
@@ -0,0 +1,32 @@
+"""Optional web UI for 3D radiation pattern visualization.
+
+Requires optional dependencies: pip install mcnanovna[webui]
+Activated by setting MCNANOVNA_WEB_PORT=8080 environment variable.
+"""
+
+from __future__ import annotations
+
+import threading
+
+
+def start_web_server(port: int = 8080) -> None:
+ """Start the FastAPI web server in a background thread.
+
+ The web server shares the same process as the MCP server but listens
+ on a separate TCP port. It serves the built frontend assets and
+ provides REST + WebSocket endpoints for pattern computation.
+
+ Args:
+ port: TCP port to listen on (default 8080)
+ """
+ import uvicorn
+
+ from mcnanovna.webui.api import create_app
+
+ app = create_app()
+
+ def _run() -> None:
+ uvicorn.run(app, host="0.0.0.0", port=port, log_level="warning")
+
+ thread = threading.Thread(target=_run, daemon=True, name="mcnanovna-webui")
+ thread.start()
diff --git a/src/mcnanovna/webui/api.py b/src/mcnanovna/webui/api.py
new file mode 100644
index 0000000..13cd469
--- /dev/null
+++ b/src/mcnanovna/webui/api.py
@@ -0,0 +1,218 @@
+"""FastAPI REST + WebSocket endpoints for the radiation pattern web UI.
+
+Serves the built frontend assets and provides endpoints for pattern
+computation, VNA scanning, and real-time WebSocket updates.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import json
+from pathlib import Path
+
+from fastapi import FastAPI, WebSocket, WebSocketDisconnect
+from fastapi.responses import FileResponse
+from fastapi.staticfiles import StaticFiles
+from pydantic import BaseModel
+
+STATIC_DIR = Path(__file__).parent / "static"
+
+# Shared lock prevents concurrent VNA scans from web + MCP
+_scan_lock = asyncio.Lock()
+
+# Connected WebSocket clients for pattern broadcast
+_ws_clients: set[WebSocket] = set()
+
+
+class ComputeRequest(BaseModel):
+ antenna_type: str = "dipole"
+ frequency_hz: float = 145_000_000
+ impedance_real: float = 73.0
+ impedance_imag: float = 0.0
+ theta_step: float = 2.0
+ phi_step: float = 5.0
+ length_m: float | None = None
+ er: float = 4.4
+
+
+class ScanRequest(BaseModel):
+ antenna_type: str = "auto"
+ start_hz: int = 144_000_000
+ stop_hz: int = 148_000_000
+ points: int = 101
+ theta_step: float = 2.0
+ phi_step: float = 5.0
+ er: float = 4.4
+
+
+def create_app() -> FastAPI:
+ app = FastAPI(title="mcnanovna Web UI", docs_url=None, redoc_url=None)
+
+ # ── API routes ────────────────────────────────────────────────
+
+ @app.get("/api/status")
+ async def api_status():
+ """VNA connection status."""
+ return {"status": "ok", "web_ui": True, "clients": len(_ws_clients)}
+
+ @app.get("/api/bands")
+ async def api_bands():
+ """Ham band presets for the frequency selector."""
+ from mcnanovna.prompts import HAM_BANDS
+
+ return {name: {"start_hz": start, "stop_hz": stop} for name, (start, stop) in HAM_BANDS.items()}
+
+ @app.post("/api/pattern/compute")
+ async def api_pattern_compute(req: ComputeRequest):
+ """Compute a radiation pattern from provided impedance (no VNA needed)."""
+ from mcnanovna.radiation import estimate_antenna_type, generate_3d_pattern
+
+ antenna_type = req.antenna_type
+ if antenna_type == "auto":
+ antenna_type = estimate_antenna_type(req.impedance_real, req.impedance_imag, req.frequency_hz)
+
+ pattern = generate_3d_pattern(
+ antenna_type=antenna_type,
+ frequency_hz=req.frequency_hz,
+ s11_analysis={
+ "resonance": {
+ "frequency_hz": req.frequency_hz,
+ "impedance_real": req.impedance_real,
+ "impedance_imag": req.impedance_imag,
+ }
+ },
+ theta_step=req.theta_step,
+ phi_step=req.phi_step,
+ length_m=req.length_m,
+ er=req.er,
+ )
+
+ # Broadcast to WebSocket clients
+ await _broadcast_pattern(pattern)
+
+ return pattern
+
+ @app.post("/api/pattern")
+ async def api_pattern_scan(req: ScanRequest):
+ """Scan S11 on the VNA and compute a radiation pattern."""
+ from mcnanovna.calculations import analyze_scan, find_resonance
+ from mcnanovna.radiation import estimate_antenna_type, generate_3d_pattern
+
+ async with _scan_lock:
+ # Lazy import — NanoVNA singleton not available until server starts
+ vna = _get_vna()
+ if vna is None:
+ return {"error": "VNA not available — MCP server must be running"}
+
+ scan_result = await vna.scan(req.start_hz, req.stop_hz, req.points, s11=True, s21=False)
+
+ if "error" in scan_result:
+ return scan_result
+
+ freqs = [pt["frequency_hz"] for pt in scan_result["data"]]
+ s11_data = [pt["s11"] for pt in scan_result["data"]]
+ s11_complex = [complex(s["real"], s["imag"]) for s in s11_data]
+
+ analysis = analyze_scan(scan_result["data"])
+ resonance = find_resonance(s11_complex, freqs)
+
+ antenna_type = req.antenna_type
+ if antenna_type == "auto":
+ z_real = resonance.get("impedance_real", 50.0)
+ z_imag = resonance.get("impedance_imag", 0.0)
+ bw = analysis.get("s11_analysis", {}).get("bandwidth_2_1", {}).get("bandwidth_hz", 0)
+ antenna_type = estimate_antenna_type(z_real, z_imag, resonance.get("frequency_hz", 0), bw)
+
+ res_freq = resonance.get("frequency_hz", (req.start_hz + req.stop_hz) // 2)
+
+ pattern = generate_3d_pattern(
+ antenna_type=antenna_type,
+ frequency_hz=res_freq,
+ s11_analysis=analysis.get("s11_analysis"),
+ theta_step=req.theta_step,
+ phi_step=req.phi_step,
+ er=req.er,
+ )
+ pattern["resonance"] = resonance
+ pattern["scan_info"] = {
+ "start_hz": req.start_hz,
+ "stop_hz": req.stop_hz,
+ "points": scan_result["points"],
+ }
+
+ await _broadcast_pattern(pattern)
+ return pattern
+
+ # ── WebSocket ─────────────────────────────────────────────────
+
+ @app.websocket("/ws/pattern")
+ async def ws_pattern(websocket: WebSocket):
+ await websocket.accept()
+ _ws_clients.add(websocket)
+ try:
+ while True:
+ # Keep connection alive; client can also send requests
+ data = await websocket.receive_text()
+ try:
+ msg = json.loads(data)
+ if msg.get("type") == "compute":
+ req = ComputeRequest(**msg.get("params", {}))
+ result = await api_pattern_compute(req)
+ await websocket.send_json({"type": "pattern", "data": result})
+ except (json.JSONDecodeError, TypeError, ValueError):
+ await websocket.send_json({"type": "error", "message": "Invalid request"})
+ except WebSocketDisconnect:
+ pass
+ finally:
+ _ws_clients.discard(websocket)
+
+ # ── Static files (built frontend) ─────────────────────────────
+
+ @app.get("/")
+ async def serve_index():
+ index_path = STATIC_DIR / "index.html"
+ if index_path.exists():
+ return FileResponse(index_path)
+ return {"error": "Frontend not built. Run: cd frontend && npm run build"}
+
+ if STATIC_DIR.exists():
+ app.mount("/assets", StaticFiles(directory=str(STATIC_DIR / "assets")), name="assets")
+
+ return app
+
+
+# ── Helpers ───────────────────────────────────────────────────────
+
+
+def _get_vna():
+ """Try to get the shared NanoVNA instance from the MCP server.
+
+ Returns None if the MCP server hasn't started yet or if the module
+ structure doesn't support it. The web UI can still compute patterns
+ without hardware using the /api/pattern/compute endpoint.
+ """
+ try:
+ # The NanoVNA is instantiated in create_server() — we create a
+ # separate lightweight instance for the web UI. It shares the same
+ # USB device (auto-discovery handles this).
+ from mcnanovna.nanovna import NanoVNA
+
+ if not hasattr(_get_vna, "_instance"):
+ _get_vna._instance = NanoVNA()
+ return _get_vna._instance
+ except Exception:
+ return None
+
+
+async def _broadcast_pattern(pattern: dict) -> None:
+ """Push a pattern update to all connected WebSocket clients."""
+ if not _ws_clients:
+ return
+ message = json.dumps({"type": "pattern", "data": pattern})
+ disconnected = set()
+ for ws in _ws_clients:
+ try:
+ await ws.send_text(message)
+ except Exception:
+ disconnected.add(ws)
+ _ws_clients.difference_update(disconnected)
diff --git a/src/mcnanovna/webui/static/assets/index-DeJaSUhK.css b/src/mcnanovna/webui/static/assets/index-DeJaSUhK.css
new file mode 100644
index 0000000..97e7c66
--- /dev/null
+++ b/src/mcnanovna/webui/static/assets/index-DeJaSUhK.css
@@ -0,0 +1 @@
+*,*:before,*:after{box-sizing:border-box;margin:0;padding:0}:root{--slate-50: #f8fafc;--slate-100: #f1f5f9;--slate-200: #e2e8f0;--slate-300: #cbd5e1;--slate-400: #94a3b8;--slate-500: #64748b;--slate-600: #475569;--slate-700: #334155;--slate-800: #1e293b;--slate-900: #0f172a;--slate-950: #020617;--teal-400: #2dd4bf;--teal-500: #14b8a6;--amber-400: #fbbf24;--amber-500: #f59e0b;--red-400: #f87171;--green-400: #4ade80;--panel-width: 280px;--panel-bg: rgba(15, 23, 42, .88);--panel-border: rgba(51, 65, 85, .5);--radius: 8px;--radius-sm: 5px}html,body{height:100%;width:100%;overflow:hidden;font-family:Inter,system-ui,-apple-system,sans-serif;font-size:13px;line-height:1.5;color:var(--slate-300);background:var(--slate-900);-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}#app{position:relative;width:100%;height:100%;display:flex}#scene-container{flex:1;position:relative;overflow:hidden}#smith-chart{position:absolute;bottom:16px;right:16px;width:200px;height:200px;border-radius:var(--radius);z-index:10;box-shadow:0 4px 24px #0006}.controls-panel{width:var(--panel-width);min-width:var(--panel-width);height:100%;background:var(--panel-bg);-webkit-backdrop-filter:blur(16px);backdrop-filter:blur(16px);border-right:1px solid var(--panel-border);padding:20px 16px;display:flex;flex-direction:column;gap:10px;overflow-y:auto;z-index:20}.controls-header{margin-bottom:4px}.controls-logo{display:flex;align-items:center;gap:8px;color:var(--slate-100);font-weight:600;font-size:16px}.controls-logo svg{color:var(--teal-400)}.controls-subtitle{color:var(--slate-500);font-size:11px;margin-top:2px;letter-spacing:.03em}.controls-status{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--slate-400);padding:6px 10px;background:#1e293b99;border-radius:var(--radius-sm)}.controls-status svg{width:14px;height:14px}.status-dot{width:6px;height:6px;border-radius:50%;flex-shrink:0}.status-dot.status-on{background:var(--green-400);box-shadow:0 0 6px #4ade8080}.status-dot.status-off{background:var(--slate-600)}.controls-sep{border:none;border-top:1px solid var(--panel-border);margin:4px 0}.controls-section-title{display:flex;align-items:center;gap:6px;font-size:11px;font-weight:600;color:var(--slate-400);text-transform:uppercase;letter-spacing:.06em;margin-top:2px}.controls-section-title svg{opacity:.7}.ctrl-field{display:flex;flex-direction:column;gap:4px;flex:1;min-width:0}.ctrl-label{font-size:11px;color:var(--slate-500);font-weight:500}.ctrl-input,.ctrl-select{width:100%;padding:7px 10px;font-family:inherit;font-size:13px;color:var(--slate-200);background:var(--slate-800);border:1px solid var(--slate-700);border-radius:var(--radius-sm);outline:none;transition:border-color .15s}.ctrl-input:focus,.ctrl-select:focus{border-color:var(--teal-400)}.ctrl-input::placeholder{color:var(--slate-600)}.ctrl-select{appearance:none;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 8px center;padding-right:28px}.controls-row{display:flex;gap:8px}.controls-spacer{height:4px}.ctrl-btn{display:flex;align-items:center;justify-content:center;gap:6px;width:100%;padding:9px 14px;font-family:inherit;font-size:13px;font-weight:500;border:1px solid transparent;border-radius:var(--radius);cursor:pointer;transition:all .15s}.ctrl-btn svg{width:15px;height:15px;flex-shrink:0}.ctrl-btn:disabled{opacity:.5;cursor:not-allowed}.ctrl-btn-primary{background:var(--teal-500);color:var(--slate-950)}.ctrl-btn-primary:hover:not(:disabled){background:var(--teal-400)}.ctrl-btn-secondary{background:transparent;color:var(--slate-300);border-color:var(--slate-600)}.ctrl-btn-secondary:hover:not(:disabled){border-color:var(--slate-400);color:var(--slate-100)}.controls-mode-group{display:grid;grid-template-columns:1fr 1fr;gap:4px}.ctrl-mode-btn{padding:6px 8px;font-family:inherit;font-size:12px;font-weight:500;color:var(--slate-400);background:var(--slate-800);border:1px solid var(--slate-700);border-radius:var(--radius-sm);cursor:pointer;transition:all .15s}.ctrl-mode-btn:hover{color:var(--slate-200);border-color:var(--slate-500)}.ctrl-mode-btn.active{color:var(--teal-400);border-color:var(--teal-400);background:#2dd4bf14}.controls-readout{display:flex;align-items:baseline;justify-content:space-between;padding:10px 12px;background:#1e293b99;border-radius:var(--radius-sm)}.readout-label{font-size:11px;color:var(--slate-500);font-weight:500;text-transform:uppercase;letter-spacing:.05em}.readout-value{font-size:18px;font-weight:600;color:var(--amber-400);font-variant-numeric:tabular-nums}.controls-loading{font-size:12px;color:var(--teal-400);text-align:center;padding:6px;animation:pulse 1.2s ease-in-out infinite}.scene-loading{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;z-index:5;pointer-events:none}.scene-loading-content{text-align:center;color:var(--slate-500);font-size:14px}.scene-loading-content strong{color:var(--slate-300)}.scene-loading-spinner{width:32px;height:32px;margin:0 auto 16px;border:3px solid var(--slate-700);border-top-color:var(--teal-400);border-radius:50%;animation:spin .8s linear infinite}@keyframes spin{to{transform:rotate(360deg)}}@keyframes pulse{0%,to{opacity:1}50%{opacity:.5}}.controls-panel::-webkit-scrollbar{width:4px}.controls-panel::-webkit-scrollbar-track{background:transparent}.controls-panel::-webkit-scrollbar-thumb{background:var(--slate-700);border-radius:2px}
diff --git a/src/mcnanovna/webui/static/assets/index-khVaWAJ2.js b/src/mcnanovna/webui/static/assets/index-khVaWAJ2.js
new file mode 100644
index 0000000..776f26f
--- /dev/null
+++ b/src/mcnanovna/webui/static/assets/index-khVaWAJ2.js
@@ -0,0 +1,4022 @@
+(function(){const e=document.createElement("link").relList;if(e&&e.supports&&e.supports("modulepreload"))return;for(const s of document.querySelectorAll('link[rel="modulepreload"]'))n(s);new MutationObserver(s=>{for(const r of s)if(r.type==="childList")for(const a of r.addedNodes)a.tagName==="LINK"&&a.rel==="modulepreload"&&n(a)}).observe(document,{childList:!0,subtree:!0});function t(s){const r={};return s.integrity&&(r.integrity=s.integrity),s.referrerPolicy&&(r.referrerPolicy=s.referrerPolicy),s.crossOrigin==="use-credentials"?r.credentials="include":s.crossOrigin==="anonymous"?r.credentials="omit":r.credentials="same-origin",r}function n(s){if(s.ep)return;s.ep=!0;const r=t(s);fetch(s.href,r)}})();const ca="182",hi={ROTATE:0,DOLLY:1,PAN:2},ci={ROTATE:0,PAN:1,DOLLY_PAN:2,DOLLY_ROTATE:3},ql=0,Ia=1,Yl=2,fs=1,Kl=2,Ci=3,Rn=0,It=1,Bt=2,mn=0,ui=1,Ua=2,Na=3,Fa=4,Zl=5,Bn=100,jl=101,$l=102,Jl=103,Ql=104,ec=200,tc=201,nc=202,ic=203,fr=204,pr=205,sc=206,rc=207,ac=208,oc=209,lc=210,cc=211,hc=212,uc=213,dc=214,mr=0,_r=1,gr=2,fi=3,xr=4,vr=5,Mr=6,Sr=7,ha=0,fc=1,pc=2,tn=0,$o=1,Jo=2,Qo=3,el=4,tl=5,nl=6,il=7,sl=300,Wn=301,pi=302,Er=303,yr=304,bs=306,Tr=1e3,pn=1001,br=1002,vt=1003,mc=1004,Hi=1005,yt=1006,Is=1007,Gn=1008,zt=1009,rl=1010,al=1011,Li=1012,ua=1013,rn=1014,Qt=1015,gn=1016,da=1017,fa=1018,Ii=1020,ol=35902,ll=35899,cl=1021,hl=1022,Zt=1023,xn=1026,Vn=1027,ul=1028,pa=1029,mi=1030,ma=1031,_a=1033,ps=33776,ms=33777,_s=33778,gs=33779,Ar=35840,wr=35841,Rr=35842,Cr=35843,Pr=36196,Dr=37492,Lr=37496,Ir=37488,Ur=37489,Nr=37490,Fr=37491,Or=37808,Br=37809,zr=37810,Gr=37811,Vr=37812,Hr=37813,kr=37814,Wr=37815,Xr=37816,qr=37817,Yr=37818,Kr=37819,Zr=37820,jr=37821,$r=36492,Jr=36494,Qr=36495,ea=36283,ta=36284,na=36285,ia=36286,_c=3200,dl=0,gc=1,An="",kt="srgb",_i="srgb-linear",Ms="linear",Ze="srgb",Kn=7680,Oa=519,xc=512,vc=513,Mc=514,ga=515,Sc=516,Ec=517,xa=518,yc=519,Ba=35044,za="300 es",en=2e3,Ss=2001;function fl(i){for(let e=i.length-1;e>=0;--e)if(i[e]>=65535)return!0;return!1}function Es(i){return document.createElementNS("http://www.w3.org/1999/xhtml",i)}function Tc(){const i=Es("canvas");return i.style.display="block",i}const Ga={};function Va(...i){const e="THREE."+i.shift();console.log(e,...i)}function Ae(...i){const e="THREE."+i.shift();console.warn(e,...i)}function We(...i){const e="THREE."+i.shift();console.error(e,...i)}function Ui(...i){const e=i.join(" ");e in Ga||(Ga[e]=!0,Ae(...i))}function bc(i,e,t){return new Promise(function(n,s){function r(){switch(i.clientWaitSync(e,i.SYNC_FLUSH_COMMANDS_BIT,0)){case i.WAIT_FAILED:s();break;case i.TIMEOUT_EXPIRED:setTimeout(r,t);break;default:n()}}setTimeout(r,t)})}class qn{addEventListener(e,t){this._listeners===void 0&&(this._listeners={});const n=this._listeners;n[e]===void 0&&(n[e]=[]),n[e].indexOf(t)===-1&&n[e].push(t)}hasEventListener(e,t){const n=this._listeners;return n===void 0?!1:n[e]!==void 0&&n[e].indexOf(t)!==-1}removeEventListener(e,t){const n=this._listeners;if(n===void 0)return;const s=n[e];if(s!==void 0){const r=s.indexOf(t);r!==-1&&s.splice(r,1)}}dispatchEvent(e){const t=this._listeners;if(t===void 0)return;const n=t[e.type];if(n!==void 0){e.target=this;const s=n.slice(0);for(let r=0,a=s.length;r