Skip to content

Commit 451fa83

Browse files
RFingAdamclaude
andcommitted
feat: 6 new MCP analysis tools (Workstream C)
Expose RFlect's existing deterministic analysis math as MCP tools so an agent can drive the full companion-plotter workflow. All reuse tested functions in plot_antenna (calculations / uwb_analysis / analysis_engine / file_utils); none reimplement math. Each returns a structured dict and never raises. New tools (MCP count 35 -> 41): - compare_antennas (comparison_tools): cross-measurement overlay — per-freq peak gain/TRP, deltas vs a reference, best-per-freq, best-overall, optional CSV. Closes the long-standing cross-file comparison request. - analyze_s11 (vna_tools): return loss / impedance bandwidth / VSWR / resonance from an S11 sweep. - analyze_group_delay (vna_tools): group delay + in-band flatness from a transmission-phase sweep (phase-only input). - estimate_link_budget (propagation_tools): max range + link margin from measured gains via Friis / log-distance / ITU-indoor, with optional Rayleigh/Rician fade margin and de-rated reliable range. - analyze_mimo_diversity (mimo_tools): ECC -> Vaughan-Andersen diversity gain + 2x2 capacity + isolation rating + optional capacity-vs-SNR curve. - generate_active_cal (calibration_tools): scriptable active chamber cal-file generation (also records into cal-drift history). 12 new tests cover happy paths + error/validation paths for every tool. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 2f729e3 commit 451fa83

7 files changed

Lines changed: 950 additions & 0 deletions

File tree

rflect-mcp/server.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,11 @@
3333
from tools.cal_drift_tools import register_cal_drift_tools
3434
from tools.orchestration import register_orchestration_tools
3535
from tools.iperf_angle_tools import register_iperf_angle_tools
36+
from tools.comparison_tools import register_comparison_tools
37+
from tools.vna_tools import register_vna_tools
38+
from tools.propagation_tools import register_propagation_tools
39+
from tools.mimo_tools import register_mimo_tools
40+
from tools.calibration_tools import register_calibration_tools
3641

3742
# Create MCP server
3843
mcp = FastMCP("rflect")
@@ -46,6 +51,11 @@
4651
register_cal_drift_tools(mcp)
4752
register_orchestration_tools(mcp)
4853
register_iperf_angle_tools(mcp)
54+
register_comparison_tools(mcp)
55+
register_vna_tools(mcp)
56+
register_propagation_tools(mcp)
57+
register_mimo_tools(mcp)
58+
register_calibration_tools(mcp)
4959

5060

5161
@mcp.resource("rflect://status")
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
"""
2+
Active chamber calibration MCP tool for RFlect.
3+
4+
Scriptable wrapper over plot_antenna.file_utils.generate_active_cal_file so the
5+
chamber active-calibration workflow can be driven by an MCP agent. The
6+
underlying routine also records the run into the cal-drift history, so this
7+
pairs with the cal_drift_* tools. No LLM, no network. Returns a structured
8+
dict and never raises; failures populate `warnings`.
9+
"""
10+
11+
from __future__ import annotations
12+
13+
import os
14+
from typing import Any, Dict, List
15+
16+
from plot_antenna.file_utils import generate_active_cal_file
17+
18+
19+
def register_calibration_tools(mcp):
20+
"""Register active-calibration tools with the MCP server."""
21+
22+
@mcp.tool()
23+
def generate_active_cal(
24+
power_measurement_file: str,
25+
gain_standard_file: str,
26+
hpol_file: str,
27+
vpol_file: str,
28+
freq_list: List[float],
29+
cable_loss: float = 0.0,
30+
) -> Dict[str, Any]:
31+
"""
32+
Generate an active chamber calibration file from reference-antenna data.
33+
34+
Computes the per-frequency path-loss calibration (P + G − HPol and
35+
P + G − VPol) from a power-measurement file, a gain-standard file, and the
36+
reference antenna's HPol/VPol scans, writing a TRP cal file + summary. The
37+
run is automatically recorded into the cal-drift history (see the
38+
cal_drift_* tools to compare against prior calibrations).
39+
40+
Note: the gain-standard reference is rotated 90 deg between polarizations,
41+
so HPol/VPol angle headers legitimately differ — this routine accounts for
42+
that (it does not require matching angle grids).
43+
44+
Args:
45+
power_measurement_file: Path to the power-measurement file.
46+
gain_standard_file: Path to the gain-standard (reference antenna) file.
47+
hpol_file: Path to the reference HPol data file.
48+
vpol_file: Path to the reference VPol data file.
49+
freq_list: Frequencies (MHz) to include in the calibration.
50+
cable_loss: Cable loss (dB); reserved for future use.
51+
52+
Returns:
53+
Dict: output_path, summary_path, rows_written, rows_missing. Never
54+
raises; missing files / generation errors populate `warnings`.
55+
"""
56+
result: Dict[str, Any] = {
57+
"output_path": None,
58+
"summary_path": None,
59+
"rows_written": None,
60+
"rows_missing": None,
61+
"warnings": [],
62+
}
63+
64+
for label, path in (
65+
("power_measurement_file", power_measurement_file),
66+
("gain_standard_file", gain_standard_file),
67+
("hpol_file", hpol_file),
68+
("vpol_file", vpol_file),
69+
):
70+
if not path or not os.path.isfile(path):
71+
result["warnings"].append(f"file_not_found: {label}={path!r}")
72+
if result["warnings"]:
73+
return result
74+
if not freq_list:
75+
result["warnings"].append("empty_freq_list")
76+
return result
77+
78+
try:
79+
cal = generate_active_cal_file(
80+
power_measurement_file=power_measurement_file,
81+
gain_standard_file=gain_standard_file,
82+
hpol_file=hpol_file,
83+
vpol_file=vpol_file,
84+
cable_loss=cable_loss,
85+
freq_list=list(freq_list),
86+
)
87+
except Exception as exc:
88+
result["warnings"].append(f"generate_active_cal_failed: {exc}")
89+
return result
90+
91+
if isinstance(cal, dict):
92+
result.update(
93+
{
94+
"output_path": cal.get("output_path"),
95+
"summary_path": cal.get("summary_path"),
96+
"rows_written": cal.get("rows_written"),
97+
"rows_missing": cal.get("rows_missing"),
98+
}
99+
)
100+
if cal.get("rows_missing"):
101+
result["warnings"].append(
102+
f"{cal['rows_missing']} frequencies had no data and were skipped"
103+
)
104+
return result
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
"""
2+
Cross-measurement comparison MCP tool for RFlect.
3+
4+
Overlays N loaded measurements and reports a per-frequency comparison of peak
5+
gain / TRP with deltas versus a reference and the best performer per frequency.
6+
Reuses the deterministic AntennaAnalyzer; no LLM, no network. Returns a
7+
structured dict and never raises; failures populate `warnings`.
8+
9+
Closes the long-standing request for a cross-file overlay comparison tool.
10+
"""
11+
12+
from __future__ import annotations
13+
14+
import csv
15+
import os
16+
from typing import Any, Dict, List, Optional
17+
18+
from plot_antenna.analysis_engine import AntennaAnalyzer
19+
20+
from .import_tools import get_loaded_measurements
21+
22+
23+
def _peak_metric(stats: Dict[str, Any], scan_type: str):
24+
"""Return (value, unit) of the headline 'peak' metric for a scan type."""
25+
if scan_type == "active":
26+
return stats.get("max_power_dBm"), "dBm"
27+
return stats.get("max_gain_dBi"), "dBi"
28+
29+
30+
def register_comparison_tools(mcp):
31+
"""Register cross-measurement comparison tools with the MCP server."""
32+
33+
@mcp.tool()
34+
def compare_antennas(
35+
measurement_names: Optional[List[str]] = None,
36+
reference: Optional[str] = None,
37+
out_csv: Optional[str] = None,
38+
) -> Dict[str, Any]:
39+
"""
40+
Compare loaded antenna measurements head-to-head across frequency.
41+
42+
For every measurement, reports the peak metric (gain dBi for passive,
43+
TRP/peak power dBm for active) at each common frequency, the delta versus
44+
a reference measurement, and the best performer per frequency. Import the
45+
measurements first (import_antenna_file / import_passive_pair /
46+
import_antenna_folder), then call this to overlay them.
47+
48+
Args:
49+
measurement_names: Names to compare (default: all loaded). Must share
50+
a scan type for the metric to be apples-to-apples.
51+
reference: Measurement to compute deltas against (default: the first).
52+
out_csv: Optional path to also write the comparison table as CSV.
53+
54+
Returns:
55+
Dict: metric, unit, measurements, frequencies, rows (per-frequency:
56+
{freq_mhz, values:{name:peak}, best:{name,value}, deltas_vs_ref}),
57+
best_overall (name with the highest mean peak), out_csv. Never raises;
58+
failures populate `warnings`.
59+
"""
60+
result: Dict[str, Any] = {
61+
"metric": None,
62+
"unit": None,
63+
"measurements": [],
64+
"frequencies": [],
65+
"rows": [],
66+
"best_overall": None,
67+
"out_csv": None,
68+
"warnings": [],
69+
}
70+
71+
loaded = get_loaded_measurements()
72+
if not loaded:
73+
result["warnings"].append("no_data_loaded")
74+
return result
75+
76+
names = measurement_names or list(loaded.keys())
77+
names = [n for n in names if n in loaded]
78+
missing = [n for n in (measurement_names or []) if n not in loaded]
79+
for n in missing:
80+
result["warnings"].append(f"measurement_not_found: {n}")
81+
if len(names) < 2:
82+
result["warnings"].append("need_at_least_2_measurements_to_compare")
83+
return result
84+
85+
scan_types = {loaded[n].scan_type for n in names}
86+
if len(scan_types) > 1:
87+
result["warnings"].append(
88+
f"mixed_scan_types: {sorted(scan_types)} — peak metric not comparable"
89+
)
90+
scan_type = loaded[names[0]].scan_type
91+
result["metric"] = "peak_power" if scan_type == "active" else "peak_gain"
92+
result["unit"] = "dBm" if scan_type == "active" else "dBi"
93+
result["measurements"] = names
94+
95+
ref = reference if reference in names else names[0]
96+
if reference and reference not in names:
97+
result["warnings"].append(f"reference_not_in_set: {reference}; using {ref}")
98+
99+
# Build analyzers + the union of frequencies (rounded to MHz).
100+
analyzers: Dict[str, AntennaAnalyzer] = {}
101+
for n in names:
102+
m = loaded[n]
103+
try:
104+
analyzers[n] = AntennaAnalyzer(
105+
measurement_data=m.data, scan_type=m.scan_type, frequencies=m.frequencies
106+
)
107+
except Exception as exc:
108+
result["warnings"].append(f"analyzer_failed:{n}: {exc}")
109+
110+
all_freqs = sorted({round(float(fq), 1) for n in names for fq in loaded[n].frequencies})
111+
result["frequencies"] = all_freqs
112+
113+
sums: Dict[str, float] = {n: 0.0 for n in names}
114+
counts: Dict[str, int] = {n: 0 for n in names}
115+
116+
for fq in all_freqs:
117+
values: Dict[str, float] = {}
118+
for n in names:
119+
az = analyzers.get(n)
120+
if az is None:
121+
continue
122+
try:
123+
stats = az.get_gain_statistics(frequency=fq)
124+
val, _ = _peak_metric(stats, scan_type)
125+
if val is not None:
126+
values[n] = float(val)
127+
sums[n] += float(val)
128+
counts[n] += 1
129+
except Exception as exc:
130+
result["warnings"].append(f"stats_failed:{n}@{fq}: {exc}")
131+
if not values:
132+
continue
133+
best_name = max(values, key=values.get)
134+
ref_val = values.get(ref)
135+
deltas = (
136+
{k: round(v - ref_val, 2) for k, v in values.items()} if ref_val is not None else {}
137+
)
138+
result["rows"].append(
139+
{
140+
"freq_mhz": fq,
141+
"values": {k: round(v, 2) for k, v in values.items()},
142+
"best": {"name": best_name, "value": round(values[best_name], 2)},
143+
"deltas_vs_ref": deltas,
144+
}
145+
)
146+
147+
means = {n: (sums[n] / counts[n]) for n in names if counts[n] > 0}
148+
if means:
149+
result["best_overall"] = max(means, key=means.get)
150+
result["mean_peak_by_measurement"] = {k: round(v, 2) for k, v in means.items()}
151+
152+
if out_csv:
153+
try:
154+
with open(out_csv, "w", newline="", encoding="utf-8") as fh:
155+
w = csv.writer(fh)
156+
w.writerow(["freq_mhz"] + names + ["best", "best_value"])
157+
for row in result["rows"]:
158+
vals = [row["values"].get(n, "") for n in names]
159+
w.writerow(
160+
[row["freq_mhz"]] + vals + [row["best"]["name"], row["best"]["value"]]
161+
)
162+
result["out_csv"] = out_csv
163+
except (OSError, UnicodeError) as exc:
164+
result["warnings"].append(f"csv_write_failed: {exc}")
165+
166+
return result

0 commit comments

Comments
 (0)