RADC Launch Angle Full-Stack Integration Plan¶
ARCHIVED DOCUMENT
This is a historical design or implementation note, kept as a record of why the code is shaped the way it is. It describes the project as of the date in its filename and is not a guide to follow — commands, paths, and constants may no longer match the code. See the Archive index for current alternatives.
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Ship RADC-based phase-interferometry launch angle extraction into the production K-LD7 tracker, replacing the PDAT distance-based method as primary, with PDAT as fallback.
Architecture: KLD7Tracker streams RADC+TDAT+PDAT at 3Mbaud (up from 115200 with TDAT+PDAT only). Raw RADC payloads are buffered alongside existing PDAT/TDAT frames. When get_angle_for_shot(ball_speed_mph=X) is called, it runs the RADC phase-interferometry pipeline (FFT → ball-speed-filtered CFAR → per-bin angle) and falls back to the existing PDAT distance-based method if RADC extraction fails. The server.py call site passes shot.ball_speed_mph to get_angle_for_shot(). No UI changes needed — the existing KLD7Angle type and WebSocket fields already carry the data.
Tech Stack: Python, numpy, kld7 library (3Mbaud RADC streaming), phase interferometry
File Map¶
| Action | File | Responsibility |
|---|---|---|
| Create | src/openflight/kld7/radc.py |
RADC signal processing (moved from scripts/kld7_radc_lib.py) |
| Modify | src/openflight/kld7/types.py:7-14 |
Add radc field to KLD7Frame |
| Modify | src/openflight/kld7/tracker.py |
Stream RADC at 3Mbaud, RADC angle extraction, fallback to PDAT |
| Modify | src/openflight/kld7/__init__.py |
Export radc module |
| Modify | src/openflight/server.py:857 |
Pass ball_speed_mph to get_angle_for_shot() |
| Modify | tests/test_kld7.py |
Add RADC extraction tests |
| Modify | tests/test_kld7_radc_lib.py |
Update imports to new location |
Task 1: Move RADC processing into the kld7 package¶
The signal processing code currently lives in scripts/kld7_radc_lib.py which isn't importable from the production code. Move the core functions into src/openflight/kld7/radc.py.
Files:
- Create: src/openflight/kld7/radc.py
- Modify: src/openflight/kld7/__init__.py
- Modify: tests/test_kld7_radc_lib.py (update imports)
- Step 1: Create
src/openflight/kld7/radc.py
Copy these functions and types from scripts/kld7_radc_lib.py — these are the ones needed by the tracker:
"""RADC signal processing for K-LD7 phase-interferometry angle extraction."""
from __future__ import annotations
from dataclasses import dataclass
import numpy as np
# --- Constants ---
RADC_PAYLOAD_BYTES = 3072
SAMPLES_PER_CHANNEL = 256
DC_MASK_BINS = 8
# K-LD7 antenna parameters (24 GHz)
WAVELENGTH_M = 3e8 / 24.125e9 # ~12.43 mm
ANTENNA_SPACING_M = 8.0e-3 # ~0.64λ, calibrated against PDAT reference data
Then copy these functions verbatim from scripts/kld7_radc_lib.py:
- parse_radc_payload
- to_complex_iq
- compute_spectrum
- compute_fft_complex
- per_bin_angle_deg
- bin_to_velocity_kmh
- _velocity_to_bin
- ball_bin_range_from_speed
- cfar_detect and CFARDetection
- find_impact_frames
- extract_launch_angle (with the fixes from this session: single-frame detection, ball bin range in impact detection)
Do NOT copy: analyze_capture, process_radc_frame, process_radc_frame_spatial, compare_radc_vs_pdat, RADCDetection, SpatialDetection, ball_bin_range, club_bin_range, estimate_angle_from_phase, compute_angle_velocity_map — those are offline analysis only.
- Step 2: Update
src/openflight/kld7/__init__.py
from .types import KLD7Angle, KLD7Frame
from .tracker import KLD7Tracker
__all__ = ["KLD7Angle", "KLD7Frame", "KLD7Tracker"]
No change needed — radc is an internal module imported by tracker.py, not a public export.
- Step 3: Update
tests/test_kld7_radc_lib.pyimports
Add a fallback import so the tests work with either location. At the top of tests/test_kld7_radc_lib.py, change the import block:
try:
from openflight.kld7.radc import (
CFARDetection,
bin_to_velocity_kmh,
cfar_detect,
compute_spectrum,
parse_radc_payload,
to_complex_iq,
)
except ImportError:
import sys
sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))
from kld7_radc_lib import (
CFARDetection,
bin_to_velocity_kmh,
cfar_detect,
compute_spectrum,
parse_radc_payload,
to_complex_iq,
)
Remove the old RADCDetection, compare_radc_vs_pdat, estimate_angle_from_phase, process_radc_frame imports — those functions stay in the scripts module only.
- Step 4: Run existing RADC tests
Run: uv run pytest tests/test_kld7_radc_lib.py -v
Expected: All existing tests PASS (imports resolve to new location).
- Step 5: Commit
git add src/openflight/kld7/radc.py tests/test_kld7_radc_lib.py
git commit -m "refactor: move RADC processing into kld7 package"
Task 2: Add radc field to KLD7Frame¶
Files:
- Modify: src/openflight/kld7/types.py:7-14
- Step 1: Write the failing test
In tests/test_kld7.py, add to TestKLD7Types:
def test_kld7_frame_radc_field(self):
frame = KLD7Frame(timestamp=1000.0)
assert frame.radc is None
frame_with_radc = KLD7Frame(timestamp=1000.0, radc=b"\x00" * 3072)
assert len(frame_with_radc.radc) == 3072
- Step 2: Run test to verify it fails
Run: uv run pytest tests/test_kld7.py::TestKLD7Types::test_kld7_frame_radc_field -v
Expected: FAIL — KLD7Frame.__init__() got an unexpected keyword argument 'radc'
- Step 3: Add
radcfield toKLD7Frame
In src/openflight/kld7/types.py, add the field:
@dataclass
class KLD7Frame:
"""A single frame from the K-LD7 radar stream."""
timestamp: float
tdat: Optional[dict] = None # {"distance", "speed", "angle", "magnitude"}
pdat: list = field(default_factory=list) # list of target dicts
radc: Optional[bytes] = None # Raw 3072-byte RADC payload
- Step 4: Run test to verify it passes
Run: uv run pytest tests/test_kld7.py::TestKLD7Types -v
Expected: All PASS
- Step 5: Commit
git add src/openflight/kld7/types.py tests/test_kld7.py
git commit -m "feat: add radc field to KLD7Frame"
Task 3: Add RADC angle extraction method to KLD7Tracker¶
Files:
- Modify: src/openflight/kld7/tracker.py
- Modify: tests/test_kld7.py
- Step 1: Write the failing test for RADC angle extraction
Add a new test class to tests/test_kld7.py:
import numpy as np
class TestRADCAngleExtraction:
"""Tests for RADC-based phase-interferometry launch angle extraction."""
def _make_tracker(self, orientation="vertical"):
tracker = KLD7Tracker.__new__(KLD7Tracker)
tracker.orientation = orientation
tracker.buffer_seconds = 2.0
tracker.max_buffer_frames = 70
tracker.angle_offset_deg = 0.0
tracker._init_ring_buffer()
return tracker
def _make_radc_payload_with_tone(self, velocity_kmh, angle_deg=10.0, amplitude=5000):
"""Create a synthetic RADC payload with a tone at the given velocity.
Generates I/Q samples for F1A and F2A channels with a phase offset
corresponding to the target angle, so phase interferometry recovers it.
"""
from openflight.kld7.radc import (
ANTENNA_SPACING_M, WAVELENGTH_M, SAMPLES_PER_CHANNEL,
)
n = SAMPLES_PER_CHANNEL # 256
max_speed_kmh = 100.0
fft_size = 2048
# Velocity to normalized frequency
if velocity_kmh >= 0:
norm_freq = velocity_kmh / (2 * max_speed_kmh)
else:
norm_freq = 1.0 + velocity_kmh / (2 * max_speed_kmh)
t = np.arange(n)
phase_per_sample = 2 * np.pi * norm_freq
# F1A channel: reference
f1a_i = (amplitude * np.cos(phase_per_sample * t)).astype(np.int16) + 32768
f1a_q = (amplitude * np.sin(phase_per_sample * t)).astype(np.int16) + 32768
# F2A channel: same tone shifted by angle-dependent phase
angle_rad = np.radians(angle_deg)
steering_phase = 2 * np.pi * ANTENNA_SPACING_M * np.sin(angle_rad) / WAVELENGTH_M
f2a_i = (amplitude * np.cos(phase_per_sample * t + steering_phase)).astype(np.int16) + 32768
f2a_q = (amplitude * np.sin(phase_per_sample * t + steering_phase)).astype(np.int16) + 32768
# F1B channel: zeros (not used for angle)
zeros = np.full(n, 32768, dtype=np.uint16)
# Pack into 3072-byte payload
payload = b""
for ch in [f1a_i, f1a_q, f2a_i, f2a_q, zeros, zeros]:
payload += ch.astype(np.uint16).tobytes()
return payload
def test_extracts_angle_from_radc_with_ball_speed(self):
"""RADC extraction should find the angle at the OPS-anchored velocity bin."""
tracker = self._make_tracker()
now = time.time()
# Ball at 72 mph = 115.9 km/h, aliases to -84.1 km/h at 200 km/h range
ball_speed_mph = 72.0
ball_kmh = ball_speed_mph * 1.609
aliased_kmh = ball_kmh % 200.0
if aliased_kmh > 100.0:
aliased_kmh -= 200.0
target_angle = 12.0
radc = self._make_radc_payload_with_tone(aliased_kmh, angle_deg=target_angle)
# Quiet frames, then impact frame, then quiet frames
for i in range(10):
tracker._add_frame(KLD7Frame(timestamp=now + i * 0.056, radc=None))
tracker._add_frame(KLD7Frame(timestamp=now + 0.56, radc=radc))
for i in range(10):
tracker._add_frame(KLD7Frame(timestamp=now + 0.62 + i * 0.056, radc=None))
result = tracker.get_angle_for_shot(ball_speed_mph=ball_speed_mph)
assert result is not None
assert result.detection_class == "ball"
assert result.vertical_deg == pytest.approx(target_angle, abs=3.0)
assert result.confidence > 0.0
def test_falls_back_to_pdat_without_radc(self):
"""When no RADC frames are present, should fall back to PDAT-based detection."""
tracker = self._make_tracker()
now = time.time()
# PDAT-only frames with ball signature
for i in range(3):
tracker._add_frame(KLD7Frame(
timestamp=now + i * 0.033,
pdat=[{"distance": 4.2, "speed": 25.0, "angle": 15.0, "magnitude": 2500}],
))
result = tracker.get_angle_for_shot(ball_speed_mph=72.0)
assert result is not None
assert result.detection_class == "ball"
assert 14.0 < result.vertical_deg < 16.0
def test_falls_back_to_pdat_without_ball_speed(self):
"""When ball_speed_mph is None, should use PDAT-based detection."""
tracker = self._make_tracker()
now = time.time()
for i in range(3):
tracker._add_frame(KLD7Frame(
timestamp=now + i * 0.033,
pdat=[{"distance": 4.2, "speed": 25.0, "angle": 15.0, "magnitude": 2500}],
))
result = tracker.get_angle_for_shot(ball_speed_mph=None)
assert result is not None
def test_angle_offset_applied_to_radc(self):
"""Angle offset should be applied to RADC-extracted angle."""
tracker = self._make_tracker()
tracker.angle_offset_deg = 5.0
now = time.time()
ball_speed_mph = 72.0
ball_kmh = ball_speed_mph * 1.609
aliased_kmh = ball_kmh % 200.0
if aliased_kmh > 100.0:
aliased_kmh -= 200.0
radc = self._make_radc_payload_with_tone(aliased_kmh, angle_deg=10.0)
for i in range(10):
tracker._add_frame(KLD7Frame(timestamp=now + i * 0.056, radc=None))
tracker._add_frame(KLD7Frame(timestamp=now + 0.56, radc=radc))
for i in range(10):
tracker._add_frame(KLD7Frame(timestamp=now + 0.62 + i * 0.056, radc=None))
result = tracker.get_angle_for_shot(ball_speed_mph=ball_speed_mph)
assert result is not None
# Should be ~10° raw + 5° offset = ~15°
assert result.vertical_deg == pytest.approx(15.0, abs=3.0)
- Step 2: Run tests to verify they fail
Run: uv run pytest tests/test_kld7.py::TestRADCAngleExtraction -v
Expected: FAIL — get_angle_for_shot() got an unexpected keyword argument 'ball_speed_mph'
- Step 3: Add
_extract_ball_radcmethod and updateget_angle_for_shot
In src/openflight/kld7/tracker.py, add the RADC extraction method and update the public API:
At the top of the file, add the import:
Add this method to KLD7Tracker, before _extract_ball:
def _extract_ball_radc(self, ball_speed_mph: float) -> Optional[KLD7Angle]:
"""Extract ball launch angle from RADC frames using phase interferometry.
Uses OPS243 ball speed to narrow the FFT velocity search to the
exact aliased bin, then extracts angle from the F1A/F2A phase
difference at that bin.
Args:
ball_speed_mph: Ball speed from OPS243, used to compute the
expected aliased velocity bin in the K-LD7 spectrum.
Returns:
KLD7Angle or None if no RADC detection found.
"""
from .radc import extract_launch_angle
# Collect frames that have RADC data
radc_frames = []
for frame in self._ring_buffer:
radc_frames.append({
"timestamp": frame.timestamp,
"radc": frame.radc,
})
if not any(f["radc"] for f in radc_frames):
return None
results = extract_launch_angle(
radc_frames,
ops243_ball_speed_mph=ball_speed_mph,
angle_offset_deg=self.angle_offset_deg,
speed_tolerance_mph=10.0,
)
if not results:
logger.debug("K-LD7 RADC: no ball detected at %.1f mph", ball_speed_mph)
return None
# Use the best (first) result
best = results[0]
logger.info(
"K-LD7 RADC: angle=%.1f° speed=%.1f mph snr=%.1f conf=%.2f frames=%d",
best["launch_angle_deg"], best["ball_speed_mph"],
best["avg_snr_db"], best["confidence"], best["frame_count"],
)
if self.orientation == "vertical":
return KLD7Angle(
vertical_deg=best["launch_angle_deg"],
horizontal_deg=None,
distance_m=0.0,
magnitude=best["avg_snr_db"],
confidence=best["confidence"],
num_frames=best["frame_count"],
detection_class="ball",
)
return KLD7Angle(
vertical_deg=None,
horizontal_deg=best["launch_angle_deg"],
distance_m=0.0,
magnitude=best["avg_snr_db"],
confidence=best["confidence"],
num_frames=best["frame_count"],
detection_class="ball",
)
Update get_angle_for_shot to accept ball_speed_mph and try RADC first:
def get_angle_for_shot(
self,
shot_timestamp: Optional[float] = None,
ball_speed_mph: Optional[float] = None,
) -> Optional[KLD7Angle]:
"""Search the ring buffer for the ball launch angle.
If ball_speed_mph is provided and RADC frames are available, uses
phase-interferometry for higher accuracy. Falls back to PDAT
distance-based detection otherwise.
"""
# Try RADC phase-interferometry first (requires ball speed anchor)
if ball_speed_mph is not None:
try:
radc_result = self._extract_ball_radc(ball_speed_mph)
if radc_result is not None:
return radc_result
logger.debug("K-LD7 RADC extraction failed, falling back to PDAT")
except Exception as e:
logger.warning("K-LD7 RADC error, falling back to PDAT: %s", e)
# Fallback: PDAT distance-based detection
return self._extract_ball(shot_timestamp)
- Step 4: Run tests to verify they pass
Run: uv run pytest tests/test_kld7.py -v
Expected: All PASS — both new RADC tests and existing PDAT tests.
- Step 5: Commit
git add src/openflight/kld7/tracker.py tests/test_kld7.py
git commit -m "feat: RADC phase-interferometry angle extraction with PDAT fallback"
Task 4: Stream RADC at 3Mbaud in the tracker¶
Files:
- Modify: src/openflight/kld7/tracker.py
- Step 1: Update
connect()to use 3Mbaud
Change the baud rate from 115200 to 3000000:
def connect(self) -> bool:
"""Connect to K-LD7 and configure for golf."""
try:
from kld7 import KLD7
except ImportError:
logger.error("kld7 package not installed. Run: pip install kld7")
return False
port = self.port or _find_port()
if not port:
logger.error("No K-LD7 EVAL board detected")
return False
try:
self._radar = KLD7(port, baudrate=3000000)
logger.info("K-LD7 connected on %s at 3Mbaud", port)
except Exception as e:
logger.error("K-LD7 connection failed: %s", e)
return False
self._configure_for_golf()
return True
- Step 2: Update
_stream_loopto capture RADC frames
Change frame_codes to include RADC, and store the RADC payload:
def _stream_loop(self):
"""Background thread: stream RADC+TDAT+PDAT into ring buffer."""
from kld7 import FrameCode
frame_codes = FrameCode.RADC | FrameCode.TDAT | FrameCode.PDAT
current_frame = KLD7Frame(timestamp=time.time())
seen_in_frame = set()
try:
for code, payload in self._radar.stream_frames(frame_codes, max_count=-1):
if not self._running:
break
if code in seen_in_frame:
self._add_frame(current_frame)
current_frame = KLD7Frame(timestamp=time.time())
seen_in_frame = set()
seen_in_frame.add(code)
if code == "RADC":
current_frame.radc = payload
elif code == "TDAT":
current_frame.tdat = _target_to_dict(payload)
elif code == "PDAT":
current_frame.pdat = [_target_to_dict(t) for t in payload] if payload else []
if seen_in_frame:
self._add_frame(current_frame)
except Exception as e:
if self._running:
logger.error("K-LD7 stream error: %s", e)
- Step 3: Run all K-LD7 tests
Run: uv run pytest tests/test_kld7.py -v
Expected: All PASS (no hardware needed — tests construct frames directly)
- Step 4: Commit
git add src/openflight/kld7/tracker.py
git commit -m "feat: stream RADC at 3Mbaud for phase-interferometry data"
Task 5: Pass ball_speed_mph from server to tracker¶
Files:
- Modify: src/openflight/server.py:857
- Step 1: Update the
get_angle_for_shotcall inon_shot_detected
In src/openflight/server.py, find the call at line ~857:
Change it to pass the ball speed:
kld7_angle = kld7_tracker.get_angle_for_shot(
shot_timestamp=shot_ts,
ball_speed_mph=shot.ball_speed_mph,
)
- Step 2: Run the full test suite
Run: uv run pytest tests/ -v
Expected: All PASS
- Step 3: Commit
git add src/openflight/server.py
git commit -m "feat: pass ball speed to K-LD7 for RADC velocity filtering"
Task 6: Keep scripts/kld7_radc_lib.py in sync¶
The scripts module is used for offline analysis and needs to keep working. Rather than maintaining two copies, make it import from the package.
Files:
- Modify: scripts/kld7_radc_lib.py
- Step 1: Replace duplicated code with re-exports
Replace the top of scripts/kld7_radc_lib.py so it re-exports from the package for shared functions, and keeps offline-only functions in place:
"""Standalone helpers for K-LD7 raw ADC (RADC) signal processing.
Core processing functions live in src/openflight/kld7/radc.py.
This module re-exports them and adds offline-analysis-only functions
(analyze_capture, compare_radc_vs_pdat, etc.).
"""
from __future__ import annotations
import sys
from pathlib import Path
# Allow importing from src/ when run as a script
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src"))
# Re-export core processing functions
from openflight.kld7.radc import ( # noqa: F401, E402
ANTENNA_SPACING_M,
CFARDetection,
DC_MASK_BINS,
RADC_PAYLOAD_BYTES,
SAMPLES_PER_CHANNEL,
WAVELENGTH_M,
ball_bin_range_from_speed,
bin_to_velocity_kmh,
cfar_detect,
compute_fft_complex,
compute_spectrum,
extract_launch_angle,
find_impact_frames,
parse_radc_payload,
per_bin_angle_deg,
to_complex_iq,
)
Keep all the offline-only functions (analyze_capture, process_radc_frame, process_radc_frame_spatial, compare_radc_vs_pdat, RADCDetection, SpatialDetection, ball_bin_range, club_bin_range, estimate_angle_from_phase, compute_angle_velocity_map, ADC_MIDPOINT, _velocity_to_bin) in this file below the re-exports. _velocity_to_bin is used by ball_bin_range and club_bin_range which are offline-only, so copy it here too. Same for ADC_MIDPOINT which is referenced by tests/analysis.
- Step 2: Run analysis script smoke test
Run: python3 -c "import sys; sys.path.insert(0, 'scripts'); from kld7_radc_lib import analyze_capture, extract_launch_angle, parse_radc_payload; print('OK')"
Expected: OK
- Step 3: Run RADC lib tests
Run: uv run pytest tests/test_kld7_radc_lib.py -v
Expected: All PASS
- Step 4: Commit
git add scripts/kld7_radc_lib.py
git commit -m "refactor: scripts/kld7_radc_lib re-exports from kld7 package"
Task 7: End-to-end verification¶
- Step 1: Run full test suite
Run: uv run pytest tests/ -v
Expected: All PASS
- Step 2: Run lint
Run: uv run ruff check src/openflight/kld7/
Expected: No errors
- Step 3: Verify the capture analysis still works
Run: `python3 -c " import pickle, sys sys.path.insert(0, 'scripts') from kld7_radc_lib import analyze_capture
with open('session_logs/kld7_radc_20260406_161627-7i.pkl', 'rb') as f: data = pickle.load(f)
for s in data.get('ops243_shots', []): if 'impact_timestamp' not in s: s['impact_timestamp'] = s['timestamp']
results = analyze_capture(data)
assert len(results) >= 1
print(f'Shot 0: angle={results[0][\"launch_angle_deg\"]}°, speed={results[0][\"ball_speed_mph\"]} mph')
print('OK')
"Expected:Shot 0: angle=8.0°, speed=71.7 mphandOK`
- Step 4: Commit all remaining changes