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:
parent
646c92324d
commit
430caf9e62
@ -40,6 +40,28 @@ export async function getBands(): Promise<Record<string, { start_hz: number; sto
|
||||
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 StatusCallback = (connected: boolean) => void;
|
||||
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
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 {
|
||||
onCompute: () => void;
|
||||
onScan: () => void;
|
||||
onFileImport: (file: File) => void;
|
||||
onDisplayModeChange: (mode: DisplayMode) => void;
|
||||
onAntennaTypeChange: (type: string) => void;
|
||||
onFrequencyChange: (mhz: number) => void;
|
||||
@ -137,6 +138,26 @@ export function createControls(
|
||||
btnScan.addEventListener('click', callbacks.onScan);
|
||||
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
|
||||
container.appendChild(el('hr', 'controls-sep'));
|
||||
|
||||
@ -182,6 +203,7 @@ export function createControls(
|
||||
loadingEl.textContent = loading ? 'Computing...' : '';
|
||||
btnCompute.disabled = loading;
|
||||
btnScan.disabled = loading;
|
||||
btnImport.disabled = loading;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@ -20,3 +20,6 @@ export const iconWifiOff = `<svg ${ATTRS}><line x1="1" y1="1" x2="23" y2="23"/><
|
||||
|
||||
/** 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>`;
|
||||
|
||||
/** 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>`;
|
||||
|
||||
@ -2,7 +2,7 @@ import { createScene, type SceneContext } from './scene';
|
||||
import { updatePattern } from './pattern';
|
||||
import { createControls } from './controls';
|
||||
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 './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() {
|
||||
const sceneContainer = document.getElementById('scene-container');
|
||||
const controlsContainer = document.getElementById('controls');
|
||||
@ -95,6 +111,7 @@ function init() {
|
||||
controlsUi = createControls(controlsContainer, {
|
||||
onCompute: handleCompute,
|
||||
onScan: handleScan,
|
||||
onFileImport: handleFileImport,
|
||||
onDisplayModeChange(mode: DisplayMode) {
|
||||
state.displayMode = mode;
|
||||
render();
|
||||
|
||||
@ -273,6 +273,20 @@ html, body {
|
||||
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 */
|
||||
.controls-mode-group {
|
||||
display: grid;
|
||||
|
||||
@ -10,6 +10,8 @@ export interface PatternData {
|
||||
h_plane: PlanePoint[];
|
||||
model: string;
|
||||
resonance?: ResonanceData;
|
||||
raw_cut?: { angles_deg: number[]; gain_db: number[]; plane: string };
|
||||
import_info?: { format: string; filename?: string; points: number };
|
||||
}
|
||||
|
||||
export interface PlanePoint {
|
||||
|
||||
@ -16,6 +16,7 @@ from mcnanovna.tools import (
|
||||
DiagnosticsMixin,
|
||||
DisplayMixin,
|
||||
MeasurementMixin,
|
||||
PatternImportMixin,
|
||||
RadiationMixin,
|
||||
)
|
||||
|
||||
@ -28,6 +29,7 @@ class NanoVNA(
|
||||
DiagnosticsMixin,
|
||||
AnalysisMixin,
|
||||
RadiationMixin,
|
||||
PatternImportMixin,
|
||||
):
|
||||
"""MCP tool class for NanoVNA-H vector network analyzers.
|
||||
|
||||
|
||||
700
src/mcnanovna/pattern_import.py
Normal file
700
src/mcnanovna/pattern_import.py
Normal 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")
|
||||
@ -877,3 +877,110 @@ nothing. The tool returns up to 4 solutions.
|
||||
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.""",
|
||||
),
|
||||
]
|
||||
|
||||
@ -94,6 +94,12 @@ _TOOL_METHODS = [
|
||||
"radiation_pattern",
|
||||
"radiation_pattern_from_data",
|
||||
"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_multi' (patterns at N frequencies for animation). "
|
||||
"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, "
|
||||
"analyze_antenna, measure_cable, compare_sweeps, analyze_crystal, "
|
||||
"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()
|
||||
|
||||
@ -15,6 +15,7 @@ from .device import DeviceMixin
|
||||
from .diagnostics import DiagnosticsMixin
|
||||
from .display import DisplayMixin
|
||||
from .measurement import MeasurementMixin
|
||||
from .pattern_import import PatternImportMixin
|
||||
from .radiation import RadiationMixin
|
||||
|
||||
|
||||
@ -31,5 +32,6 @@ __all__ = [
|
||||
"DiagnosticsMixin",
|
||||
"DisplayMixin",
|
||||
"MeasurementMixin",
|
||||
"PatternImportMixin",
|
||||
"RadiationMixin",
|
||||
]
|
||||
|
||||
213
src/mcnanovna/tools/pattern_import.py
Normal file
213
src/mcnanovna/tools/pattern_import.py
Normal 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"
|
||||
),
|
||||
}
|
||||
@ -10,7 +10,7 @@ import asyncio
|
||||
import json
|
||||
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.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel
|
||||
@ -143,6 +143,44 @@ def create_app() -> FastAPI:
|
||||
await _broadcast_pattern(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 ─────────────────────────────────────────────────
|
||||
|
||||
@app.websocket("/ws/pattern")
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -5,8 +5,8 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>mcnanovna -- Radiation Pattern</title>
|
||||
<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>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DeJaSUhK.css">
|
||||
<script type="module" crossorigin src="/assets/index-DVS5g-QC.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-C5SDH5i7.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user