Measured pattern import: CSV, EMCAR, NEC2, Touchstone S1P parsers + web UI upload

Add 5 MCP tools (PatternImportMixin) and 1 prompt for importing external
antenna pattern data. Pure-Python parsers with IDW interpolation on the
sphere, single-cut-to-3D synthesis for EMCAR/2-col CSV, and Touchstone S1P
bridge to the analytical pattern engine. Web UI gets a "Load File" button
with multipart upload endpoint and WebSocket broadcast. 78 tools, 14 prompts.
This commit is contained in:
Ryan Malloy 2026-01-31 15:58:19 -07:00
parent 646c92324d
commit 430caf9e62
16 changed files with 1193 additions and 41 deletions

View File

@ -40,6 +40,28 @@ export async function getBands(): Promise<Record<string, { start_hz: number; sto
return resp.json(); return resp.json();
} }
export async function uploadPattern(
file: File,
options?: { frequency_hz?: number; polarization?: string; antenna_type?: string; reference_dbi?: number },
): Promise<PatternData> {
const form = new FormData();
form.append('file', file);
if (options?.frequency_hz) form.append('frequency_hz', String(options.frequency_hz));
if (options?.polarization) form.append('polarization', options.polarization);
if (options?.antenna_type) form.append('antenna_type', options.antenna_type);
if (options?.reference_dbi != null) form.append('reference_dbi', String(options.reference_dbi));
const resp = await fetch(`${BASE_URL}/api/pattern/import`, {
method: 'POST',
body: form,
});
if (!resp.ok) {
const body = await resp.text();
throw new Error(`Import failed (${resp.status}): ${body}`);
}
return resp.json();
}
export type PatternCallback = (data: PatternData) => void; export type PatternCallback = (data: PatternData) => void;
export type StatusCallback = (connected: boolean) => void; export type StatusCallback = (connected: boolean) => void;

View File

@ -1,9 +1,10 @@
import type { DisplayMode, AppState } from './types'; import type { DisplayMode, AppState } from './types';
import { iconRadio, iconActivity, iconCrosshair, iconWifi, iconWifiOff } from './icons'; import { iconRadio, iconActivity, iconCrosshair, iconUpload, iconWifi, iconWifiOff } from './icons';
export interface ControlCallbacks { export interface ControlCallbacks {
onCompute: () => void; onCompute: () => void;
onScan: () => void; onScan: () => void;
onFileImport: (file: File) => void;
onDisplayModeChange: (mode: DisplayMode) => void; onDisplayModeChange: (mode: DisplayMode) => void;
onAntennaTypeChange: (type: string) => void; onAntennaTypeChange: (type: string) => void;
onFrequencyChange: (mhz: number) => void; onFrequencyChange: (mhz: number) => void;
@ -137,6 +138,26 @@ export function createControls(
btnScan.addEventListener('click', callbacks.onScan); btnScan.addEventListener('click', callbacks.onScan);
container.appendChild(btnScan); container.appendChild(btnScan);
// Hidden file input for pattern import
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = '.csv,.dat,.out,.nec,.s1p';
fileInput.style.display = 'none';
fileInput.addEventListener('change', () => {
const file = fileInput.files?.[0];
if (file) {
callbacks.onFileImport(file);
fileInput.value = '';
}
});
container.appendChild(fileInput);
const btnImport = document.createElement('button');
btnImport.className = 'ctrl-btn ctrl-btn-outline';
btnImport.innerHTML = `${iconUpload} Load File`;
btnImport.addEventListener('click', () => fileInput.click());
container.appendChild(btnImport);
// Separator // Separator
container.appendChild(el('hr', 'controls-sep')); container.appendChild(el('hr', 'controls-sep'));
@ -182,6 +203,7 @@ export function createControls(
loadingEl.textContent = loading ? 'Computing...' : ''; loadingEl.textContent = loading ? 'Computing...' : '';
btnCompute.disabled = loading; btnCompute.disabled = loading;
btnScan.disabled = loading; btnScan.disabled = loading;
btnImport.disabled = loading;
}, },
}; };
} }

View File

@ -20,3 +20,6 @@ export const iconWifiOff = `<svg ${ATTRS}><line x1="1" y1="1" x2="23" y2="23"/><
/** Crosshair / target icon */ /** Crosshair / target icon */
export const iconCrosshair = `<svg ${ATTRS}><circle cx="12" cy="12" r="10"/><line x1="22" y1="12" x2="18" y2="12"/><line x1="6" y1="12" x2="2" y2="12"/><line x1="12" y1="6" x2="12" y2="2"/><line x1="12" y1="22" x2="12" y2="18"/></svg>`; export const iconCrosshair = `<svg ${ATTRS}><circle cx="12" cy="12" r="10"/><line x1="22" y1="12" x2="18" y2="12"/><line x1="6" y1="12" x2="2" y2="12"/><line x1="12" y1="6" x2="12" y2="2"/><line x1="12" y1="22" x2="12" y2="18"/></svg>`;
/** Upload icon (Lucide) */
export const iconUpload = `<svg ${ATTRS}><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>`;

View File

@ -2,7 +2,7 @@ import { createScene, type SceneContext } from './scene';
import { updatePattern } from './pattern'; import { updatePattern } from './pattern';
import { createControls } from './controls'; import { createControls } from './controls';
import { drawSmithChart } from './smith'; import { drawSmithChart } from './smith';
import { fetchPattern, scanPattern, connectWebSocket } from './api'; import { fetchPattern, scanPattern, uploadPattern, connectWebSocket } from './api';
import type { AppState, DisplayMode, PatternData } from './types'; import type { AppState, DisplayMode, PatternData } from './types';
import './style.css'; import './style.css';
@ -78,6 +78,22 @@ async function handleScan() {
} }
} }
async function handleFileImport(file: File) {
state.loading = true;
controlsUi.updateLoading(true);
try {
const data = await uploadPattern(file, {
frequency_hz: state.frequencyMhz * 1e6,
antenna_type: state.antennaType,
});
handlePatternData(data);
} catch (err) {
console.error('Import error:', err);
state.loading = false;
controlsUi.updateLoading(false);
}
}
function init() { function init() {
const sceneContainer = document.getElementById('scene-container'); const sceneContainer = document.getElementById('scene-container');
const controlsContainer = document.getElementById('controls'); const controlsContainer = document.getElementById('controls');
@ -95,6 +111,7 @@ function init() {
controlsUi = createControls(controlsContainer, { controlsUi = createControls(controlsContainer, {
onCompute: handleCompute, onCompute: handleCompute,
onScan: handleScan, onScan: handleScan,
onFileImport: handleFileImport,
onDisplayModeChange(mode: DisplayMode) { onDisplayModeChange(mode: DisplayMode) {
state.displayMode = mode; state.displayMode = mode;
render(); render();

View File

@ -273,6 +273,20 @@ html, body {
color: var(--slate-100); color: var(--slate-100);
} }
.ctrl-btn-outline {
background: transparent;
color: var(--slate-400);
border-color: var(--slate-700);
border-style: dashed;
font-size: 0.8rem;
}
.ctrl-btn-outline:hover:not(:disabled) {
border-color: var(--slate-500);
border-style: solid;
color: var(--slate-200);
}
/* Display mode toggle group */ /* Display mode toggle group */
.controls-mode-group { .controls-mode-group {
display: grid; display: grid;

View File

@ -10,6 +10,8 @@ export interface PatternData {
h_plane: PlanePoint[]; h_plane: PlanePoint[];
model: string; model: string;
resonance?: ResonanceData; resonance?: ResonanceData;
raw_cut?: { angles_deg: number[]; gain_db: number[]; plane: string };
import_info?: { format: string; filename?: string; points: number };
} }
export interface PlanePoint { export interface PlanePoint {

View File

@ -16,6 +16,7 @@ from mcnanovna.tools import (
DiagnosticsMixin, DiagnosticsMixin,
DisplayMixin, DisplayMixin,
MeasurementMixin, MeasurementMixin,
PatternImportMixin,
RadiationMixin, RadiationMixin,
) )
@ -28,6 +29,7 @@ class NanoVNA(
DiagnosticsMixin, DiagnosticsMixin,
AnalysisMixin, AnalysisMixin,
RadiationMixin, RadiationMixin,
PatternImportMixin,
): ):
"""MCP tool class for NanoVNA-H vector network analyzers. """MCP tool class for NanoVNA-H vector network analyzers.

View File

@ -0,0 +1,700 @@
"""Import measured antenna patterns from external files.
Parses CSV, EMCAR vna.dat, NEC2 radiation output, and Touchstone S1P formats
into the standardized {theta_deg, phi_deg, gain_dbi} dict that the 3D viewer
and MCP tools already consume.
All functions are pure Python (math + list comprehensions). No external dependencies.
Content is passed as strings, not file paths MCP tools receive content from the LLM,
and the web UI endpoint handles multipart upload separately.
Inspired by the EMCAR antenna range (https://emcar.sourceforge.net/) which uses
LinuxCNC + HP8754A VNA for IEEE-149 stop-and-measure pattern recording.
"""
from __future__ import annotations
import math
import re
from mcnanovna.radiation import C, _deg_range
# ── Shared utilities ──────────────────────────────────────────────
def _build_pattern_dict(
thetas: list[float],
phis: list[float],
gain_dbi: list[list[float]],
model: str,
frequency_hz: float | None,
antenna_type: str,
metadata: dict | None = None,
) -> dict:
"""Assemble a standardized pattern dict matching generate_3d_pattern() output."""
peak = -999.0
for row in gain_dbi:
for g in row:
if g > peak:
peak = g
# E-plane: phi=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: theta=90 (row closest to 90 deg, 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))]
result: dict = {
"antenna_type": antenna_type,
"frequency_hz": frequency_hz,
"theta_deg": thetas,
"phi_deg": phis,
"gain_dbi": gain_dbi,
"peak_gain_dbi": round(peak, 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": model,
}
if frequency_hz:
result["wavelength_m"] = round(C / frequency_hz, 4)
if metadata:
result["import_info"] = metadata
return result
# ── Interpolation ─────────────────────────────────────────────────
def _great_circle_distance(t1: float, p1: float, t2: float, p2: float) -> float:
"""Great-circle angular distance between two (theta, phi) points in degrees.
Uses the Vincenty formula for numerical stability at small/large distances.
"""
t1r, p1r = math.radians(t1), math.radians(p1)
t2r, p2r = math.radians(t2), math.radians(p2)
dp = p2r - p1r
sin_t1, cos_t1 = math.sin(t1r), math.cos(t1r)
sin_t2, cos_t2 = math.sin(t2r), math.cos(t2r)
sin_dp, cos_dp = math.sin(dp), math.cos(dp)
num = math.sqrt((cos_t2 * sin_dp) ** 2 + (cos_t1 * sin_t2 - sin_t1 * cos_t2 * cos_dp) ** 2)
den = sin_t1 * sin_t2 + cos_t1 * cos_t2 * cos_dp
return math.degrees(math.atan2(num, den))
def interpolate_to_grid(
measurements: list[tuple[float, float, float]],
theta_step: float = 2.0,
phi_step: float = 5.0,
) -> tuple[list[float], list[float], list[list[float]]]:
"""Interpolate sparse measurements to a regular theta/phi grid.
Uses inverse-distance weighting (IDW) with great-circle distance on the
sphere surface. K=6 nearest neighbors, power=2 (Shepard's method).
Args:
measurements: List of (theta_deg, phi_deg, gain_dbi) tuples
theta_step: Output grid polar angle step (degrees)
phi_step: Output grid azimuthal angle step (degrees)
Returns:
(thetas, phis, gain_grid) where gain_grid[i][j] is gain in dBi
"""
thetas = _deg_range(0.0, 180.0, theta_step)
phis = _deg_range(0.0, 355.0, phi_step)
k = min(6, len(measurements))
gain_grid: list[list[float]] = []
for theta in thetas:
row: list[float] = []
for phi in phis:
# Compute distances to all measurement points
dists = []
for mt, mp, mg in measurements:
d = _great_circle_distance(theta, phi, mt, mp)
dists.append((d, mg))
# Exact match shortcut
dists.sort(key=lambda x: x[0])
if dists[0][0] < 0.01:
row.append(dists[0][1])
continue
# IDW with K nearest neighbors
nearest = dists[:k]
w_sum = 0.0
g_sum = 0.0
for d, g in nearest:
w = 1.0 / (d * d + 1e-10)
w_sum += w
g_sum += w * g
row.append(g_sum / w_sum)
gain_grid.append(row)
return thetas, phis, gain_grid
def single_cut_to_3d(
angles_deg: list[float],
gain_db: list[float],
cut_plane: str = "azimuth",
theta_step: float = 2.0,
phi_step: float = 5.0,
) -> tuple[list[float], list[float], list[list[float]]]:
"""Synthesize a 3D pattern from a single-plane cut measurement.
For azimuth cuts (typical of EMCAR): the measured data becomes the H-plane
(theta=90 deg), and elevation is synthesized with a sin(theta) taper
physically approximate but produces a useful 3D shape.
For elevation cuts: the measured data becomes the E-plane (phi=0 deg),
and the pattern is rotated around the vertical axis.
Args:
angles_deg: Measurement angles in degrees
gain_db: Gain values in dB at each angle
cut_plane: "azimuth" (H-plane) or "elevation" (E-plane)
theta_step: Output grid polar angle step
phi_step: Output grid azimuthal angle step
"""
thetas = _deg_range(0.0, 180.0, theta_step)
phis = _deg_range(0.0, 355.0, phi_step)
# Build a lookup for the measured cut via linear interpolation
def _interp_cut(angle: float) -> float:
"""Linearly interpolate the measured cut at an arbitrary angle."""
# Normalize to [0, 360)
angle = angle % 360.0
n = len(angles_deg)
if n == 0:
return -40.0
if n == 1:
return gain_db[0]
# Find bracketing points
normed = [a % 360.0 for a in angles_deg]
for i in range(n - 1):
a0, a1 = normed[i], normed[i + 1]
if a0 <= angle <= a1 and a1 > a0:
t = (angle - a0) / (a1 - a0)
return gain_db[i] * (1 - t) + gain_db[i + 1] * t
# Wrap-around interpolation
return gain_db[-1]
gain_grid: list[list[float]] = []
if cut_plane == "azimuth":
# Measured data is H-plane (theta=90). Synthesize elevation via sin(theta) taper.
for theta in thetas:
sin_t = math.sin(math.radians(theta))
taper = max(sin_t, 0.01) # Avoid -inf at poles
taper_db = 20.0 * math.log10(taper)
row = [_interp_cut(phi) + taper_db for phi in phis]
gain_grid.append(row)
else:
# Elevation cut: measured data defines E-plane, rotate around phi
for theta in thetas:
g_at_theta = _interp_cut(theta)
row = [g_at_theta] * len(phis)
gain_grid.append(row)
return thetas, phis, gain_grid
# ── Format parsers ────────────────────────────────────────────────
def parse_csv_pattern(content: str, frequency_hz: float | None = None) -> dict:
"""Parse a CSV file with antenna pattern data.
Auto-detects 2-column (angle, gain) vs 3-column (theta, phi, gain) from the
header row. Flexible header names: theta/elevation, phi/azimuth,
gain/gain_dbi/amplitude/db/dbi.
For 2-column data, the angle column name determines the cut plane:
- "phi" or "azimuth" azimuth cut
- "theta" or "elevation" elevation cut
Args:
content: CSV file content as string
frequency_hz: Operating frequency in Hz (optional, for metadata)
"""
lines = [line.strip() for line in content.strip().splitlines() if line.strip()]
if not lines:
raise ValueError("Empty CSV content")
# Parse header
header = lines[0].lower().replace(" ", "_")
cols = re.split(r"[,;\t]+", header)
# Identify column indices
theta_idx = phi_idx = gain_idx = -1
angle_idx = -1 # For 2-column mode
for i, col in enumerate(cols):
col_clean = col.strip()
if col_clean in ("theta", "theta_deg", "elevation", "el"):
theta_idx = i
elif col_clean in ("phi", "phi_deg", "azimuth", "az"):
phi_idx = i
elif col_clean in ("gain", "gain_dbi", "dbi", "db", "amplitude", "gain_db", "magnitude"):
gain_idx = i
elif col_clean in ("angle", "angle_deg", "deg", "degrees"):
angle_idx = i
# Determine mode
three_col = theta_idx >= 0 and phi_idx >= 0 and gain_idx >= 0
two_col = not three_col and gain_idx >= 0 and (theta_idx >= 0 or phi_idx >= 0 or angle_idx >= 0)
if not three_col and not two_col:
# Try numeric-only (no header): assume 2-col azimuth or 3-col
first_data = re.split(r"[,;\t]+", lines[0])
try:
vals = [float(v) for v in first_data]
if len(vals) >= 3:
three_col = True
theta_idx, phi_idx, gain_idx = 0, 1, 2
lines = lines # No header to skip
elif len(vals) == 2:
two_col = True
angle_idx, gain_idx = 0, 1
phi_idx = -1
lines = lines
else:
raise ValueError(f"Cannot parse CSV: unrecognized columns: {cols}")
except ValueError:
raise ValueError(f"Cannot parse CSV: unrecognized header columns: {cols}")
else:
lines = lines[1:] # Skip header
# Parse data
if three_col:
measurements: list[tuple[float, float, float]] = []
for line in lines:
parts = re.split(r"[,;\t]+", line.strip())
if len(parts) < max(theta_idx, phi_idx, gain_idx) + 1:
continue
try:
t = float(parts[theta_idx])
p = float(parts[phi_idx])
g = float(parts[gain_idx])
measurements.append((t, p, g))
except (ValueError, IndexError):
continue
if not measurements:
raise ValueError("No valid data rows found in CSV")
thetas, phis, gain_grid = interpolate_to_grid(measurements)
return _build_pattern_dict(
thetas,
phis,
gain_grid,
model="imported_csv",
frequency_hz=frequency_hz,
antenna_type="imported",
metadata={"format": "csv", "points": len(measurements), "columns": 3},
)
else:
# 2-column mode
use_idx = theta_idx if theta_idx >= 0 else (phi_idx if phi_idx >= 0 else angle_idx)
cut_plane = "elevation" if theta_idx >= 0 else "azimuth"
angles: list[float] = []
gains: list[float] = []
for line in lines:
parts = re.split(r"[,;\t]+", line.strip())
if len(parts) < max(use_idx, gain_idx) + 1:
continue
try:
a = float(parts[use_idx])
g = float(parts[gain_idx])
angles.append(a)
gains.append(g)
except (ValueError, IndexError):
continue
if not angles:
raise ValueError("No valid data rows found in CSV")
thetas, phis, gain_grid = single_cut_to_3d(angles, gains, cut_plane=cut_plane)
result = _build_pattern_dict(
thetas,
phis,
gain_grid,
model="imported_csv_single_cut",
frequency_hz=frequency_hz,
antenna_type="imported",
metadata={"format": "csv", "points": len(angles), "columns": 2, "cut_plane": cut_plane},
)
result["raw_cut"] = {"angles_deg": angles, "gain_db": gains, "plane": cut_plane}
return result
def parse_emcar_vna_dat(
content: str,
frequency_hz: float | None = None,
reference_dbi: float | None = None,
) -> dict:
"""Parse EMCAR vna.dat format (angle + amplitude_dBV pairs).
EMCAR records raw ADC voltage from the HP8754A VNA. Lines starting with #
are comments. Data is whitespace-separated: angle amplitude.
The gnuplot transform from EMCAR's plotting scripts is applied:
- Angle rotation: (-angle + 90) to convert from positioner coords to antenna coords
- Amplitude: 20*log10(amplitude + 0.01) to convert voltage to dB scale
Without a reference antenna, this shows relative pattern shape only.
Pass reference_dbi to offset the entire pattern to absolute gain.
Args:
content: vna.dat file content as string
frequency_hz: Operating frequency in Hz (optional)
reference_dbi: Offset to apply for absolute gain calibration (optional)
"""
angles: list[float] = []
gains: list[float] = []
for line in content.strip().splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
parts = line.split()
if len(parts) < 2:
continue
try:
angle = float(parts[0])
amplitude = float(parts[1])
except ValueError:
continue
# EMCAR gnuplot transform
rotated_angle = (-angle + 90.0) % 360.0
# Voltage to dB: 20*log10(amplitude + 0.01) — the +0.01 prevents log(0)
gain_db = 20.0 * math.log10(abs(amplitude) + 0.01)
if reference_dbi is not None:
gain_db += reference_dbi
angles.append(rotated_angle)
gains.append(gain_db)
if not angles:
raise ValueError("No valid data rows found in EMCAR vna.dat content")
# Sort by angle for clean interpolation
paired = sorted(zip(angles, gains), key=lambda x: x[0])
angles = [a for a, _ in paired]
gains = [g for _, g in paired]
thetas, phis, gain_grid = single_cut_to_3d(angles, gains, cut_plane="azimuth")
result = _build_pattern_dict(
thetas,
phis,
gain_grid,
model="imported_emcar",
frequency_hz=frequency_hz,
antenna_type="imported",
metadata={
"format": "emcar",
"points": len(angles),
"reference_dbi": reference_dbi,
"note": "Relative pattern shape" if reference_dbi is None else "Calibrated with reference",
},
)
result["raw_cut"] = {"angles_deg": angles, "gain_db": gains, "plane": "azimuth"}
return result
def parse_nec2_radiation(content: str, polarization: str = "total") -> dict:
"""Parse NEC2 radiation pattern output.
Finds the "RADIATION PATTERNS" section in NEC2 output files and extracts
the theta/phi/gain grid. Supports TOTAL, VERT, and HOR polarization selection.
NEC2 output columns (after the header):
THETA PHI VERT(dB) HOR(dB) TOTAL(dB) AXIAL_RATIO TILT SENSE
Values of -999.99 are mapped to a -40 dBi floor.
Args:
content: NEC2 output file content as string
polarization: Which gain column to use: "total", "vert", or "hor"
"""
lines = content.splitlines()
frequency_hz: float | None = None
measurements: list[tuple[float, float, float]] = []
# Column index for the selected polarization
pol_map = {"vert": 0, "hor": 1, "total": 2}
if polarization.lower() not in pol_map:
raise ValueError(f"polarization must be 'total', 'vert', or 'hor', got '{polarization}'")
pol_col = pol_map[polarization.lower()]
in_pattern = False
header_lines_remaining = 0
for line in lines:
stripped = line.strip()
# Look for frequency in the header area
freq_match = re.search(r"FREQUENCY\s*[=:]\s*([\d.]+)\s*(MHZ|MHz|GHZ|GHz|HZ|Hz)", stripped, re.IGNORECASE)
if freq_match:
freq_val = float(freq_match.group(1))
unit = freq_match.group(2).upper()
if unit == "MHZ":
frequency_hz = freq_val * 1e6
elif unit == "GHZ":
frequency_hz = freq_val * 1e9
else:
frequency_hz = freq_val
# Detect start of radiation pattern section
if "RADIATION PATTERNS" in stripped.upper():
in_pattern = True
header_lines_remaining = 3 # Skip title + column headers + separator
continue
if in_pattern:
if header_lines_remaining > 0:
header_lines_remaining -= 1
continue
# End of section: blank line or new section header
if not stripped or stripped.startswith("*") or stripped.startswith("-" * 10):
if measurements:
break
continue
# Parse data line
parts = stripped.split()
if len(parts) < 5:
continue
try:
theta = float(parts[0])
phi = float(parts[1])
# Columns 2,3,4 are VERT(dB), HOR(dB), TOTAL(dB)
gain_val = float(parts[2 + pol_col])
# NEC2 uses -999.99 for undefined/zero
if gain_val < -900:
gain_val = -40.0
measurements.append((theta, phi, gain_val))
except (ValueError, IndexError):
continue
if not measurements:
raise ValueError("No radiation pattern data found in NEC2 output")
thetas, phis, gain_grid = interpolate_to_grid(measurements)
return _build_pattern_dict(
thetas,
phis,
gain_grid,
model="imported_nec2",
frequency_hz=frequency_hz,
antenna_type="imported",
metadata={
"format": "nec2",
"points": len(measurements),
"polarization": polarization.lower(),
},
)
def parse_touchstone_s1p(
content: str,
antenna_type: str = "auto",
) -> dict:
"""Parse Touchstone S1P file and generate an analytical pattern at resonance.
Reads S11 complex data, finds the resonant frequency (minimum |S11|),
computes impedance, estimates antenna type, and delegates to
radiation.generate_3d_pattern(). This bridges imported S1P data to the
Phase 1 analytical pattern engine.
Args:
content: Touchstone .s1p file content as string
antenna_type: Antenna model, or 'auto' to estimate from impedance
"""
from mcnanovna.radiation import estimate_antenna_type, generate_3d_pattern
lines = content.strip().splitlines()
freq_mult = 1.0
data_format = "ri" # Default: real/imaginary
z0 = 50.0
frequencies: list[float] = []
s11_complex: list[complex] = []
for line in lines:
line = line.strip()
if not line or line.startswith("!"):
continue
# Option line: # Hz S RI R 50
if line.startswith("#"):
parts = line[1:].strip().upper().split()
for i, p in enumerate(parts):
if p in ("HZ", "KHZ", "MHZ", "GHZ"):
freq_mult = {"HZ": 1.0, "KHZ": 1e3, "MHZ": 1e6, "GHZ": 1e9}[p]
elif p in ("RI", "MA", "DB"):
data_format = p.lower()
elif p == "R" and i + 1 < len(parts):
try:
z0 = float(parts[i + 1])
except ValueError:
pass
continue
# Data line
parts = line.split()
if len(parts) < 3:
continue
try:
freq = float(parts[0]) * freq_mult
v1, v2 = float(parts[1]), float(parts[2])
if data_format == "ri":
s11 = complex(v1, v2)
elif data_format == "ma":
s11 = v1 * complex(math.cos(math.radians(v2)), math.sin(math.radians(v2)))
elif data_format == "db":
mag = 10.0 ** (v1 / 20.0)
s11 = mag * complex(math.cos(math.radians(v2)), math.sin(math.radians(v2)))
else:
s11 = complex(v1, v2)
frequencies.append(freq)
s11_complex.append(s11)
except (ValueError, IndexError):
continue
if not frequencies:
raise ValueError("No valid S-parameter data found in Touchstone file")
# Find resonance: minimum |S11|
min_mag = 999.0
min_idx = 0
for i, s in enumerate(s11_complex):
mag = abs(s)
if mag < min_mag:
min_mag = mag
min_idx = i
res_freq = frequencies[min_idx]
s11_res = s11_complex[min_idx]
# Compute impedance at resonance: Z = Z0 * (1+S11) / (1-S11)
denom = 1.0 - s11_res
if abs(denom) < 1e-10:
z_real, z_imag = 9999.0, 0.0
else:
z = z0 * (1.0 + s11_res) / denom
z_real, z_imag = z.real, z.imag
# Estimate antenna type if needed
if antenna_type == "auto":
antenna_type = estimate_antenna_type(z_real, z_imag, res_freq)
pattern = generate_3d_pattern(
antenna_type=antenna_type,
frequency_hz=res_freq,
s11_analysis={
"resonance": {
"frequency_hz": res_freq,
"impedance_real": round(z_real, 2),
"impedance_imag": round(z_imag, 2),
"s11_magnitude": round(min_mag, 4),
}
},
)
pattern["model"] = "imported_s1p_analytical"
pattern["import_info"] = {
"format": "s1p",
"points": len(frequencies),
"frequency_range_hz": [frequencies[0], frequencies[-1]],
"resonant_frequency_hz": res_freq,
"impedance_at_resonance": {"real": round(z_real, 2), "imag": round(z_imag, 2)},
"z0": z0,
}
return pattern
# ── Format auto-detection ─────────────────────────────────────────
def detect_format(filename: str, content: str | None = None) -> str:
"""Detect pattern file format from filename extension and optional content inspection.
Returns one of: "csv", "emcar", "nec2", "s1p"
"""
ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
if ext == "csv":
return "csv"
if ext == "dat":
return "emcar"
if ext in ("out", "nec"):
return "nec2"
if ext == "s1p":
return "s1p"
# Content-based detection fallback
if content:
if "RADIATION PATTERNS" in content.upper():
return "nec2"
if content.lstrip().startswith("#") and re.search(r"#\s*\w+\s+S\s+", content[:200]):
return "s1p"
if re.match(r"[\w_]+[,;\t]", content.lstrip().split("\n")[0]):
return "csv"
raise ValueError(f"Cannot detect format from filename '{filename}'. Use .csv, .dat, .out, .nec, or .s1p")
def parse_pattern(
content: str,
format: str,
frequency_hz: float | None = None,
**kwargs,
) -> dict:
"""Parse a pattern file with explicit format selection.
Convenience dispatcher that routes to the appropriate parser.
Args:
content: File content as string
format: One of "csv", "emcar", "nec2", "s1p"
frequency_hz: Operating frequency (used by CSV and EMCAR)
**kwargs: Additional format-specific arguments
"""
if format == "csv":
return parse_csv_pattern(content, frequency_hz=frequency_hz)
elif format == "emcar":
return parse_emcar_vna_dat(content, frequency_hz=frequency_hz, reference_dbi=kwargs.get("reference_dbi"))
elif format == "nec2":
return parse_nec2_radiation(content, polarization=kwargs.get("polarization", "total"))
elif format == "s1p":
return parse_touchstone_s1p(content, antenna_type=kwargs.get("antenna_type", "auto"))
else:
raise ValueError(f"Unknown format '{format}'. Supported: csv, emcar, nec2, s1p")

View File

@ -877,3 +877,110 @@ nothing. The tool returns up to 4 solutions.
Let me compute the matching solutions now using `analyze_lc_match`.""", Let me compute the matching solutions now using `analyze_lc_match`.""",
), ),
] ]
@mcp.prompt
def import_pattern(
format: str = "csv",
) -> list[Message]:
"""Guide through importing a measured or simulated antenna pattern.
Walks through the process of importing pattern data from external files
(CSV, EMCAR vna.dat, NEC2 output, or Touchstone S1P) for 3D visualization.
Args:
format: File format \u2014 'csv', 'emcar', 'nec2', or 's1p'
"""
format_details = {
"csv": {
"name": "CSV",
"ext": ".csv",
"tool": "import_pattern_csv",
"desc": (
"Comma/semicolon/tab-separated with flexible headers.\n"
"- **3-column**: theta, phi, gain_dbi \u2014 full 3D pattern\n"
"- **2-column**: angle, gain \u2014 single cut, synthesized to 3D"
),
"example": "theta,phi,gain_dbi\\n0,0,-40\\n90,0,2.15\\n90,90,2.15\\n180,0,-40",
},
"emcar": {
"name": "EMCAR vna.dat",
"ext": ".dat",
"tool": "import_pattern_emcar",
"desc": (
"EMCAR antenna range format \u2014 angle + amplitude pairs.\n"
"Single azimuth cut from a positioner-driven measurement.\n"
"The gnuplot transform is applied automatically:\n"
"(-angle+90) rotation and 20*log10(amplitude+0.01)."
),
"example": "# EMCAR measurement\\n0 0.5\\n45 0.8\\n90 1.0\\n135 0.8\\n180 0.5",
},
"nec2": {
"name": "NEC2 Radiation Output",
"ext": ".out / .nec",
"tool": "import_pattern_nec2",
"desc": (
"NEC2/NEC4 output containing a RADIATION PATTERNS section.\n"
"Full theta\u00d7phi grid with VERT, HOR, and TOTAL gain columns.\n"
"Frequency is auto-detected from the file header."
),
"example": "(Standard NEC2 output file \u2014 run your .nec model first)",
},
"s1p": {
"name": "Touchstone S1P",
"ext": ".s1p",
"tool": "import_pattern_s1p",
"desc": (
"Touchstone S-parameter file containing S11 data.\n"
"Finds resonance, computes impedance, then generates an\n"
"analytical pattern using the Phase 1 antenna models.\n"
"Supports RI, MA, and DB formats."
),
"example": "# Hz S RI R 50\\n! NanoVNA export\\n144000000 0.1 -0.05\\n145000000 0.02 -0.01",
},
}
fmt = format_details.get(format.lower(), format_details["csv"])
return [
Message(
role="user",
content=f"I want to import a {fmt['name']} ({fmt['ext']}) antenna pattern for 3D visualization.",
),
Message(
role="assistant",
content=f"""I'll help you import a {fmt["name"]} pattern file.
**Format**: {fmt["name"]} ({fmt["ext"]})
**Tool**: `{fmt["tool"]}`
**Data format:**
{fmt["desc"]}
**Example content:**
```
{fmt["example"]}
```
**Workflow:**
1. Read the file content (or paste it directly)
2. Call `{fmt["tool"]}` with the content string
3. The tool returns a standard pattern dict with {{theta_deg, phi_deg, gain_dbi}}
4. If the web UI is running, upload via the "Load File" button for instant 3D rendering
**All supported formats** (use `list_pattern_formats` for full details):
| Format | Extension | Data |
|--------|-----------|------|
| CSV | .csv | 2-col or 3-col angle/gain |
| EMCAR | .dat | Positioner angle + amplitude |
| NEC2 | .out, .nec | Full radiation pattern grid |
| S1P | .s1p | S11 \u2192 analytical pattern |
**Tips:**
- CSV and EMCAR accept an optional `frequency_hz` parameter for metadata
- NEC2 supports `polarization` selection: 'total', 'vert', or 'hor'
- S1P supports `antenna_type` override or 'auto' detection from impedance
- Single-cut data (2-col CSV, EMCAR) is synthesized to 3D with a sin(\u03b8) taper
Please share the file content and I'll import it.""",
),
]

View File

@ -94,6 +94,12 @@ _TOOL_METHODS = [
"radiation_pattern", "radiation_pattern",
"radiation_pattern_from_data", "radiation_pattern_from_data",
"radiation_pattern_multi", "radiation_pattern_multi",
# tools/pattern_import.py — PatternImportMixin
"import_pattern_csv",
"import_pattern_emcar",
"import_pattern_nec2",
"import_pattern_s1p",
"list_pattern_formats",
] ]
@ -120,10 +126,14 @@ def create_server() -> FastMCP:
"'radiation_pattern_from_data' (compute pattern from known impedance, no hardware), " "'radiation_pattern_from_data' (compute pattern from known impedance, no hardware), "
"'radiation_pattern_multi' (patterns at N frequencies for animation). " "'radiation_pattern_multi' (patterns at N frequencies for animation). "
"Supported antenna types: dipole, monopole, efhw, loop, patch, or 'auto' to estimate.\n\n" "Supported antenna types: dipole, monopole, efhw, loop, patch, or 'auto' to estimate.\n\n"
"Pattern import tools: 'import_pattern_csv', 'import_pattern_emcar', "
"'import_pattern_nec2', 'import_pattern_s1p' — import measured/simulated patterns "
"from external files (CSV, EMCAR vna.dat, NEC2 output, Touchstone S1P). "
"Use 'list_pattern_formats' for format details and examples.\n\n"
"Prompts are available for guided workflows: calibrate, export_touchstone, " "Prompts are available for guided workflows: calibrate, export_touchstone, "
"analyze_antenna, measure_cable, compare_sweeps, analyze_crystal, " "analyze_antenna, measure_cable, compare_sweeps, analyze_crystal, "
"analyze_filter_response, measure_tdr, measure_component, measure_lc_series, " "analyze_filter_response, measure_tdr, measure_component, measure_lc_series, "
"measure_lc_shunt, impedance_match, and visualize_radiation_pattern." "measure_lc_shunt, impedance_match, visualize_radiation_pattern, and import_pattern."
), ),
) )
vna = NanoVNA() vna = NanoVNA()

View File

@ -15,6 +15,7 @@ from .device import DeviceMixin
from .diagnostics import DiagnosticsMixin from .diagnostics import DiagnosticsMixin
from .display import DisplayMixin from .display import DisplayMixin
from .measurement import MeasurementMixin from .measurement import MeasurementMixin
from .pattern_import import PatternImportMixin
from .radiation import RadiationMixin from .radiation import RadiationMixin
@ -31,5 +32,6 @@ __all__ = [
"DiagnosticsMixin", "DiagnosticsMixin",
"DisplayMixin", "DisplayMixin",
"MeasurementMixin", "MeasurementMixin",
"PatternImportMixin",
"RadiationMixin", "RadiationMixin",
] ]

View File

@ -0,0 +1,213 @@
"""PatternImportMixin — MCP tools for importing measured antenna patterns."""
from __future__ import annotations
from fastmcp import Context
class PatternImportMixin:
"""Pattern import tools: import CSV, EMCAR, NEC2, S1P files and list formats."""
async def import_pattern_csv(
self,
content: str,
frequency_hz: float | None = None,
theta_step: float = 2.0,
phi_step: float = 5.0,
ctx: Context | None = None,
) -> dict:
"""Import an antenna pattern from CSV data.
Auto-detects 2-column (angle, gain) vs 3-column (theta, phi, gain) from
the header. Flexible header names: theta/elevation, phi/azimuth,
gain/gain_dbi/amplitude/db.
For 2-column data, the angle column name determines the cut plane
the single cut is synthesized into a full 3D pattern with a sin(theta) taper.
Returns the standardized {theta_deg, phi_deg, gain_dbi} pattern dict
for 3D visualization.
Args:
content: CSV file content as string
frequency_hz: Operating frequency in Hz (optional, for metadata)
theta_step: Output grid polar angle step in degrees (default 2)
phi_step: Output grid azimuthal angle step in degrees (default 5)
"""
from mcnanovna.pattern_import import parse_csv_pattern
from mcnanovna.tools import _progress
await _progress(ctx, 1, 2, "Parsing CSV pattern data...")
result = parse_csv_pattern(content, frequency_hz=frequency_hz)
await _progress(ctx, 2, 2, f"Imported CSV pattern: {result.get('import_info', {}).get('points', '?')} points")
return result
async def import_pattern_emcar(
self,
content: str,
frequency_hz: float | None = None,
reference_dbi: float | None = None,
theta_step: float = 2.0,
phi_step: float = 5.0,
ctx: Context | None = None,
) -> dict:
"""Import an antenna pattern from EMCAR vna.dat format.
EMCAR records angle + amplitude_dBV pairs from a positioner-driven
antenna range (typically HP8754A VNA). Lines starting with # are comments.
The gnuplot transform is applied: (-angle+90) rotation and
20*log10(amplitude+0.01) voltage-to-dB conversion. This produces a
single azimuth cut which is synthesized into a 3D pattern.
Without a reference antenna, the pattern shows relative shape only.
Pass reference_dbi to offset to absolute gain.
Args:
content: vna.dat file content as string
frequency_hz: Operating frequency in Hz (optional)
reference_dbi: Reference antenna gain offset in dBi (optional)
theta_step: Output grid polar angle step in degrees (default 2)
phi_step: Output grid azimuthal angle step in degrees (default 5)
"""
from mcnanovna.pattern_import import parse_emcar_vna_dat
from mcnanovna.tools import _progress
await _progress(ctx, 1, 2, "Parsing EMCAR vna.dat data...")
result = parse_emcar_vna_dat(content, frequency_hz=frequency_hz, reference_dbi=reference_dbi)
await _progress(ctx, 2, 2, f"Imported EMCAR pattern: {result.get('import_info', {}).get('points', '?')} points")
return result
async def import_pattern_nec2(
self,
content: str,
polarization: str = "total",
theta_step: float = 2.0,
phi_step: float = 5.0,
ctx: Context | None = None,
) -> dict:
"""Import an antenna pattern from NEC2 radiation output.
Parses the RADIATION PATTERNS section from NEC2/NEC4 output files.
Extracts frequency from the header and provides full theta x phi gain data.
Supports polarization selection: 'total' (default), 'vert', or 'hor'.
Values of -999.99 in NEC2 output are mapped to a -40 dBi floor.
Args:
content: NEC2 output file content as string
polarization: Gain column to use 'total', 'vert', or 'hor' (default 'total')
theta_step: Output grid polar angle step in degrees (default 2)
phi_step: Output grid azimuthal angle step in degrees (default 5)
"""
from mcnanovna.pattern_import import parse_nec2_radiation
from mcnanovna.tools import _progress
await _progress(ctx, 1, 2, "Parsing NEC2 radiation pattern...")
result = parse_nec2_radiation(content, polarization=polarization)
await _progress(ctx, 2, 2, f"Imported NEC2 pattern: {result.get('import_info', {}).get('points', '?')} points")
return result
async def import_pattern_s1p(
self,
content: str,
antenna_type: str = "auto",
theta_step: float = 2.0,
phi_step: float = 5.0,
ctx: Context | None = None,
) -> dict:
"""Import S-parameters from a Touchstone S1P file and generate a pattern.
Parses S11 complex data, finds the resonant frequency (minimum |S11|),
computes impedance, and generates an analytical 3D pattern using the
Phase 1 antenna models. This bridges external VNA measurements to
pattern visualization.
Supports RI (real/imaginary), MA (magnitude/angle), and DB (dB/angle)
Touchstone formats. Reference impedance and frequency units are read
from the option line.
Args:
content: Touchstone .s1p file content as string
antenna_type: Antenna model 'dipole', 'monopole', 'efhw', 'loop', 'patch', or 'auto'
theta_step: Output grid polar angle step in degrees (default 2)
phi_step: Output grid azimuthal angle step in degrees (default 5)
"""
from mcnanovna.pattern_import import parse_touchstone_s1p
from mcnanovna.tools import _progress
await _progress(ctx, 1, 2, "Parsing Touchstone S1P data...")
result = parse_touchstone_s1p(content, antenna_type=antenna_type)
await _progress(
ctx,
2,
2,
f"Generated pattern from S1P: {result.get('antenna_type')} at "
f"{result.get('import_info', {}).get('resonant_frequency_hz', '?')} Hz",
)
return result
async def list_pattern_formats(
self,
ctx: Context | None = None,
) -> dict:
"""List supported pattern import formats with descriptions and examples.
Returns details about each format including file extensions, expected
data structure, and example content for testing.
"""
return {
"formats": [
{
"name": "CSV",
"extensions": [".csv"],
"tool": "import_pattern_csv",
"description": (
"Comma/semicolon/tab-separated pattern data. "
"Auto-detects 2-column (angle, gain) or 3-column (theta, phi, gain) from headers."
),
"example_3col": "theta,phi,gain_dbi\n0,0,-40\n90,0,2.15\n90,90,2.15\n180,0,-40",
"example_2col": "azimuth,gain_dbi\n0,2.15\n90,1.5\n180,0.8\n270,1.5",
},
{
"name": "EMCAR vna.dat",
"extensions": [".dat"],
"tool": "import_pattern_emcar",
"description": (
"EMCAR antenna range format: angle amplitude_dBV pairs. "
"Single azimuth cut from positioner-driven measurement. "
"Gnuplot transform applied: (-angle+90) rotation, 20*log10(amplitude+0.01)."
),
"example": "# EMCAR measurement\n0 0.5\n45 0.8\n90 1.0\n135 0.8\n180 0.5\n225 0.3\n270 0.2\n315 0.3",
"reference": "https://emcar.sourceforge.net/",
},
{
"name": "NEC2 Radiation",
"extensions": [".out", ".nec"],
"tool": "import_pattern_nec2",
"description": (
"NEC2/NEC4 output file with RADIATION PATTERNS section. "
"Full theta x phi grid with VERT, HOR, and TOTAL gain columns. "
"Frequency auto-detected from header."
),
"polarization_options": ["total", "vert", "hor"],
},
{
"name": "Touchstone S1P",
"extensions": [".s1p"],
"tool": "import_pattern_s1p",
"description": (
"Touchstone S-parameter file (S11 only). "
"Finds resonance, computes impedance, generates analytical pattern. "
"Supports RI, MA, and DB data formats."
),
"note": "Generates an analytical pattern from S11 data — not a measured pattern.",
},
],
"common_workflow": (
"1. Read the file content\n"
"2. Call the appropriate import_pattern_* tool with the content string\n"
"3. The tool returns the standard {theta_deg, phi_deg, gain_dbi} pattern dict\n"
"4. If the web UI is running, the pattern is automatically available for 3D rendering"
),
}

View File

@ -10,7 +10,7 @@ import asyncio
import json import json
from pathlib import Path from pathlib import Path
from fastapi import FastAPI, WebSocket, WebSocketDisconnect from fastapi import FastAPI, File, Form, UploadFile, WebSocket, WebSocketDisconnect
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel from pydantic import BaseModel
@ -143,6 +143,44 @@ def create_app() -> FastAPI:
await _broadcast_pattern(pattern) await _broadcast_pattern(pattern)
return pattern return pattern
@app.post("/api/pattern/import")
async def api_pattern_import(
file: UploadFile = File(...),
format: str | None = Form(None),
frequency_hz: float | None = Form(None),
polarization: str = Form("total"),
antenna_type: str = Form("auto"),
reference_dbi: float | None = Form(None),
):
"""Import a pattern file (CSV, EMCAR, NEC2, or S1P) via multipart upload."""
from mcnanovna.pattern_import import detect_format, parse_pattern
content = (await file.read()).decode("utf-8", errors="replace")
filename = file.filename or "unknown"
# Auto-detect format from extension if not specified
if not format:
format = detect_format(filename, content)
kwargs: dict = {}
if format == "nec2":
kwargs["polarization"] = polarization
elif format == "s1p":
kwargs["antenna_type"] = antenna_type
elif format == "emcar" and reference_dbi is not None:
kwargs["reference_dbi"] = reference_dbi
pattern = parse_pattern(content, format, frequency_hz=frequency_hz, **kwargs)
# Add filename to import_info
if "import_info" in pattern:
pattern["import_info"]["filename"] = filename
else:
pattern["import_info"] = {"format": format, "filename": filename}
await _broadcast_pattern(pattern)
return pattern
# ── WebSocket ───────────────────────────────────────────────── # ── WebSocket ─────────────────────────────────────────────────
@app.websocket("/ws/pattern") @app.websocket("/ws/pattern")

View File

@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>mcnanovna -- Radiation Pattern</title> <title>mcnanovna -- Radiation Pattern</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
<script type="module" crossorigin src="/assets/index-khVaWAJ2.js"></script> <script type="module" crossorigin src="/assets/index-DVS5g-QC.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DeJaSUhK.css"> <link rel="stylesheet" crossorigin href="/assets/index-C5SDH5i7.css">
</head> </head>
<body> <body>
<div id="app"> <div id="app">