K-LD7 Full Stack Integration — Implementation 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: Integrate the K-LD7 angle radar into the openflight server so it provides real-time vertical (or horizontal) angle data alongside OPS243 speed/spin data, displayed in the UI.
Architecture: K-LD7 streams TDAT+PDAT frames in a background thread into a ring buffer. When OPS243 detects a shot, we search the ring buffer for the ball pass (highest-magnitude event) and attach the angle to the Shot. The UI shows separate Vertical and Horizontal angle cards.
Tech Stack: Python (kld7 library, pyserial), React/TypeScript, Flask-SocketIO
Spec: docs/plans/2026-03-28-kld7-integration-design.md
File Structure¶
| File | Action | Responsibility |
|---|---|---|
src/openflight/kld7/__init__.py |
Create | Package exports |
src/openflight/kld7/types.py |
Create | KLD7Angle, KLD7Frame dataclasses |
src/openflight/kld7/tracker.py |
Create | KLD7Tracker: connection, ring buffer, angle extraction |
src/openflight/server.py |
Modify | CLI flags, init_kld7(), on_shot_detected() K-LD7 step |
scripts/start-kiosk.sh |
Modify | Pass --kld7 flags |
ui/src/types/shot.ts |
Modify | Add angle_source field |
ui/src/components/ShotDisplay.tsx |
Modify | Split Launch Angle into Vertical + Horizontal cards |
tests/test_kld7.py |
Create | Unit tests for KLD7Tracker |
tests/test_server.py |
Modify | Test shot_to_dict with K-LD7 angle data |
Task 1: K-LD7 Data Types¶
Files:
- Create: src/openflight/kld7/__init__.py
- Create: src/openflight/kld7/types.py
- Test: tests/test_kld7.py
- Step 1: Create the kld7 package with types
- Step 2: Write types.py
Create src/openflight/kld7/types.py:
"""Data types for K-LD7 angle radar integration."""
from dataclasses import dataclass, field
from typing import Optional
@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
@dataclass
class KLD7Angle:
"""Angle measurement extracted from K-LD7 ring buffer after a shot."""
vertical_deg: Optional[float] = None # Populated when orientation="vertical"
horizontal_deg: Optional[float] = None # Populated when orientation="horizontal"
distance_m: float = 0.0
magnitude: float = 0.0
confidence: float = 0.0 # 0.0 - 1.0
num_frames: int = 0 # Frames with detection in the event
- Step 3: Write init.py
Create src/openflight/kld7/__init__.py:
"""K-LD7 angle radar integration module."""
from .types import KLD7Angle, KLD7Frame
__all__ = ["KLD7Angle", "KLD7Frame"]
- Step 4: Write basic type tests
Create tests/test_kld7.py:
"""Tests for K-LD7 angle radar integration."""
import pytest
from openflight.kld7.types import KLD7Angle, KLD7Frame
class TestKLD7Types:
"""Tests for K-LD7 data types."""
def test_kld7_frame_defaults(self):
frame = KLD7Frame(timestamp=1000.0)
assert frame.timestamp == 1000.0
assert frame.tdat is None
assert frame.pdat == []
def test_kld7_angle_vertical(self):
angle = KLD7Angle(vertical_deg=12.5, distance_m=2.0, magnitude=5000, confidence=0.8, num_frames=3)
assert angle.vertical_deg == 12.5
assert angle.horizontal_deg is None
def test_kld7_angle_horizontal(self):
angle = KLD7Angle(horizontal_deg=-3.2, distance_m=1.5, magnitude=4000, confidence=0.7, num_frames=2)
assert angle.horizontal_deg == -3.2
assert angle.vertical_deg is None
- Step 5: Run tests
Run: uv run pytest tests/test_kld7.py -v
Expected: 3 tests PASS
- Step 6: Commit
git add src/openflight/kld7/ tests/test_kld7.py
git commit -m "feat(kld7): add K-LD7 data types (KLD7Angle, KLD7Frame)"
Task 2: KLD7Tracker — Ring Buffer and Angle Extraction¶
Files:
- Create: src/openflight/kld7/tracker.py
- Modify: src/openflight/kld7/__init__.py
- Test: tests/test_kld7.py
- Step 1: Write failing tests for KLD7Tracker
Add to tests/test_kld7.py:
import time
from unittest.mock import MagicMock, patch
from openflight.kld7.tracker import KLD7Tracker
from openflight.kld7.types import KLD7Angle, KLD7Frame
class TestKLD7TrackerRingBuffer:
"""Tests for ring buffer and angle extraction logic (no hardware)."""
def _make_tracker(self, orientation="vertical"):
"""Create a tracker without connecting to hardware."""
tracker = KLD7Tracker.__new__(KLD7Tracker)
tracker.orientation = orientation
tracker.buffer_seconds = 2.0
tracker.max_buffer_frames = 70
tracker._init_ring_buffer()
return tracker
def test_ring_buffer_stores_frames(self):
tracker = self._make_tracker()
now = time.time()
for i in range(5):
tracker._add_frame(KLD7Frame(
timestamp=now + i * 0.03,
tdat={"distance": 1.0, "speed": 5.0, "angle": 10.0 + i, "magnitude": 3000 + i * 100},
pdat=[],
))
assert len(tracker._ring_buffer) == 5
def test_ring_buffer_max_size(self):
tracker = self._make_tracker()
tracker.max_buffer_frames = 10
now = time.time()
for i in range(20):
tracker._add_frame(KLD7Frame(
timestamp=now + i * 0.03,
tdat={"distance": 1.0, "speed": 5.0, "angle": 0.0, "magnitude": 1000},
pdat=[],
))
assert len(tracker._ring_buffer) == 10
def test_get_angle_finds_highest_magnitude_event(self):
tracker = self._make_tracker(orientation="vertical")
now = time.time()
# Background noise frames
for i in range(10):
tracker._add_frame(KLD7Frame(
timestamp=now + i * 0.03,
tdat=None,
pdat=[],
))
# Ball pass: 3 frames with high magnitude at angle ~15°
for i in range(3):
tracker._add_frame(KLD7Frame(
timestamp=now + 0.30 + i * 0.03,
tdat={"distance": 2.0, "speed": 50.0, "angle": 14.0 + i, "magnitude": 5000 + i * 100},
pdat=[{"distance": 2.0, "speed": 50.0, "angle": 14.0 + i, "magnitude": 5000 + i * 100}],
))
# More noise after
for i in range(5):
tracker._add_frame(KLD7Frame(
timestamp=now + 0.50 + i * 0.03,
tdat=None,
pdat=[],
))
result = tracker.get_angle_for_shot()
assert result is not None
assert result.vertical_deg is not None
assert 13.0 < result.vertical_deg < 17.0
assert result.horizontal_deg is None
assert result.num_frames == 3
assert result.confidence > 0.0
assert result.distance_m > 0.0
def test_get_angle_returns_none_when_no_detections(self):
tracker = self._make_tracker()
now = time.time()
for i in range(5):
tracker._add_frame(KLD7Frame(timestamp=now + i * 0.03, tdat=None, pdat=[]))
result = tracker.get_angle_for_shot()
assert result is None
def test_get_angle_horizontal_orientation(self):
tracker = self._make_tracker(orientation="horizontal")
now = time.time()
tracker._add_frame(KLD7Frame(
timestamp=now,
tdat={"distance": 1.5, "speed": 30.0, "angle": -5.0, "magnitude": 4500},
pdat=[{"distance": 1.5, "speed": 30.0, "angle": -5.0, "magnitude": 4500}],
))
result = tracker.get_angle_for_shot()
assert result is not None
assert result.horizontal_deg is not None
assert result.vertical_deg is None
def test_reset_clears_buffer(self):
tracker = self._make_tracker()
tracker._add_frame(KLD7Frame(timestamp=time.time(), tdat={"distance": 1.0, "speed": 5.0, "angle": 0.0, "magnitude": 3000}, pdat=[]))
assert len(tracker._ring_buffer) == 1
tracker.reset()
assert len(tracker._ring_buffer) == 0
def test_prefers_pdat_over_tdat(self):
"""PDAT raw detections should be preferred for angle extraction."""
tracker = self._make_tracker(orientation="vertical")
now = time.time()
# Frame with TDAT at 10° but PDAT at 20° (higher magnitude)
tracker._add_frame(KLD7Frame(
timestamp=now,
tdat={"distance": 1.0, "speed": 5.0, "angle": 10.0, "magnitude": 3000},
pdat=[{"distance": 1.5, "speed": 8.0, "angle": 20.0, "magnitude": 5000}],
))
result = tracker.get_angle_for_shot()
assert result is not None
# Should use the PDAT target with higher magnitude
assert abs(result.vertical_deg - 20.0) < 1.0
- Step 2: Run tests to verify they fail
Run: uv run pytest tests/test_kld7.py::TestKLD7TrackerRingBuffer -v
Expected: FAIL — tracker module doesn't exist yet
- Step 3: Implement KLD7Tracker
Create src/openflight/kld7/tracker.py:
"""K-LD7 angle radar tracker with ring buffer for shot correlation."""
import logging
import threading
import time
from collections import deque
from typing import Optional
from .types import KLD7Angle, KLD7Frame
logger = logging.getLogger(__name__)
def _target_to_dict(target):
"""Convert a kld7 Target namedtuple to a dict."""
if target is None:
return None
return {
"distance": target.distance,
"speed": target.speed,
"angle": target.angle,
"magnitude": target.magnitude,
}
def _find_port():
"""Auto-detect K-LD7 EVAL board USB serial port."""
try:
from serial.tools.list_ports import comports
except ImportError:
return None
for port in comports():
desc = (port.description or "").lower()
mfg = (port.manufacturer or "").lower()
if any(kw in desc for kw in ["ftdi", "cp210", "usb-serial", "uart"]):
return port.device
if any(kw in mfg for kw in ["ftdi", "silicon labs"]):
return port.device
return None
class KLD7Tracker:
"""
K-LD7 angle radar tracker.
Streams TDAT+PDAT frames in a background thread into a ring buffer.
When the OPS243 detects a shot, call get_angle_for_shot() to search
the buffer for the ball pass and extract angle data.
"""
def __init__(
self,
port: Optional[str] = None,
range_m: int = 5,
speed_kmh: int = 100,
orientation: str = "vertical",
buffer_seconds: float = 2.0,
):
self.port = port
self.range_m = range_m
self.speed_kmh = speed_kmh
self.orientation = orientation
self.buffer_seconds = buffer_seconds
# ~34 fps without FFT * 2 seconds
self.max_buffer_frames = int(34 * buffer_seconds)
self._radar = None
self._stream_thread: Optional[threading.Thread] = None
self._running = False
self._init_ring_buffer()
def _init_ring_buffer(self):
"""Initialize or reset the ring buffer."""
self._ring_buffer: deque[KLD7Frame] = deque(maxlen=self.max_buffer_frames)
def connect(self) -> bool:
"""Connect to K-LD7 and configure for golf."""
try:
from kld7 import KLD7, KLD7Exception
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=115200)
logger.info("K-LD7 connected on %s", port)
except Exception as e:
logger.error("K-LD7 connection failed: %s", e)
return False
# Configure for golf
self._configure_for_golf()
return True
def _configure_for_golf(self):
"""Configure K-LD7 parameters for golf ball detection."""
range_settings = {5: 0, 10: 1, 30: 2, 100: 3}
speed_settings = {12: 0, 25: 1, 50: 2, 100: 3}
params = self._radar.params
params.RRAI = range_settings.get(self.range_m, 0)
params.RSPI = speed_settings.get(self.speed_kmh, 3)
params.DEDI = 2 # Both directions
params.THOF = 10 # Low threshold
params.TRFT = 1 # Fast detection
params.MIAN = -90
params.MAAN = 90
params.MIRA = 0
params.MARA = 100
params.MISP = 0
params.MASP = 100
params.VISU = 0 # No vibration suppression
logger.info(
"K-LD7 configured: range=%dm, speed=%dkm/h, orientation=%s",
self.range_m, self.speed_kmh, self.orientation,
)
def start(self):
"""Start the background streaming thread."""
if self._running:
return
self._running = True
self._stream_thread = threading.Thread(target=self._stream_loop, daemon=True)
self._stream_thread.start()
logger.info("K-LD7 streaming started (orientation=%s)", self.orientation)
def stop(self):
"""Stop streaming and close connection."""
self._running = False
if self._stream_thread:
self._stream_thread.join(timeout=5)
self._stream_thread = None
if self._radar:
try:
self._radar.close()
except Exception:
pass
try:
self._radar._port = None
except Exception:
pass
self._radar = None
logger.info("K-LD7 stopped")
def _stream_loop(self):
"""Background thread: stream TDAT+PDAT into ring buffer."""
from kld7 import FrameCode
frame_codes = 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
# Detect frame boundary
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 == "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 []
# Save final frame
if seen_in_frame:
self._add_frame(current_frame)
except Exception as e:
if self._running:
logger.error("K-LD7 stream error: %s", e)
def _add_frame(self, frame: KLD7Frame):
"""Add a frame to the ring buffer."""
self._ring_buffer.append(frame)
def get_angle_for_shot(self) -> Optional[KLD7Angle]:
"""
Search the ring buffer for the ball pass and extract angle data.
Finds the highest-magnitude detection event in the buffer.
Prefers PDAT (raw detections) over TDAT (tracked target).
Returns KLD7Angle or None if no detections found.
"""
# Collect all detections from the buffer
detections = [] # (timestamp, angle, distance, magnitude, source)
for frame in self._ring_buffer:
# Prefer PDAT targets (more reliable for transient targets)
if frame.pdat:
for target in frame.pdat:
if target is not None and target.get("magnitude", 0) > 0:
detections.append((
frame.timestamp,
target["angle"],
target["distance"],
target["magnitude"],
))
elif frame.tdat and frame.tdat.get("magnitude", 0) > 0:
detections.append((
frame.timestamp,
frame.tdat["angle"],
frame.tdat["distance"],
frame.tdat["magnitude"],
))
if not detections:
return None
# Find the peak magnitude detection
peak = max(detections, key=lambda d: d[3])
peak_time = peak[0]
# Collect all detections within 0.5s of the peak (the "event")
event_detections = [
d for d in detections if abs(d[0] - peak_time) < 0.5
]
if not event_detections:
return None
# Weighted average angle (weighted by magnitude)
total_mag = sum(d[3] for d in event_detections)
if total_mag == 0:
return None
avg_angle = sum(d[1] * d[3] for d in event_detections) / total_mag
avg_distance = sum(d[2] * d[3] for d in event_detections) / total_mag
max_magnitude = max(d[3] for d in event_detections)
num_frames = len(set(d[0] for d in event_detections))
# Confidence based on:
# - Number of frames with detections (more = better)
# - Magnitude strength (higher = better signal)
# - Angle consistency (lower std dev = better)
frame_score = min(num_frames / 3.0, 1.0) # 3+ frames = full score
mag_score = min(max_magnitude / 5000.0, 1.0) # 5000+ = full score
angles = [d[1] for d in event_detections]
if len(angles) > 1:
mean_angle = sum(angles) / len(angles)
angle_std = (sum((a - mean_angle) ** 2 for a in angles) / len(angles)) ** 0.5
consistency_score = max(0.0, 1.0 - angle_std / 20.0) # 20°+ std = 0
else:
consistency_score = 0.5 # Single frame — moderate confidence
confidence = frame_score * 0.4 + mag_score * 0.3 + consistency_score * 0.3
confidence = round(min(max(confidence, 0.0), 1.0), 2)
# Map angle to orientation
if self.orientation == "vertical":
return KLD7Angle(
vertical_deg=round(avg_angle, 1),
horizontal_deg=None,
distance_m=round(avg_distance, 2),
magnitude=max_magnitude,
confidence=confidence,
num_frames=num_frames,
)
else:
return KLD7Angle(
vertical_deg=None,
horizontal_deg=round(avg_angle, 1),
distance_m=round(avg_distance, 2),
magnitude=max_magnitude,
confidence=confidence,
num_frames=num_frames,
)
def reset(self):
"""Clear the ring buffer after a shot is processed."""
self._ring_buffer.clear()
- Step 4: Update init.py exports
Modify src/openflight/kld7/__init__.py:
"""K-LD7 angle radar integration module."""
from .types import KLD7Angle, KLD7Frame
from .tracker import KLD7Tracker
__all__ = ["KLD7Angle", "KLD7Frame", "KLD7Tracker"]
- Step 5: Run tests
Run: uv run pytest tests/test_kld7.py -v
Expected: All tests PASS
- Step 6: Commit
git add src/openflight/kld7/ tests/test_kld7.py
git commit -m "feat(kld7): implement KLD7Tracker with ring buffer and angle extraction"
Task 3: Server Integration — CLI Flags and Init¶
Files:
- Modify: src/openflight/server.py:1169-1277 (argparse), src/openflight/server.py:1294-1359 (main startup)
- Modify: scripts/start-kiosk.sh:13-85 (arg parsing), scripts/start-kiosk.sh:144-176 (command building)
- Step 1: Add CLI flags to server.py argparse
In src/openflight/server.py, after the --gpio-debounce argument (line 1275), add:
parser.add_argument(
"--kld7", action="store_true", help="Enable K-LD7 angle radar module"
)
parser.add_argument(
"--kld7-port", default=None, help="K-LD7 serial port (auto-detect if not specified)"
)
parser.add_argument(
"--kld7-orientation",
choices=["vertical", "horizontal"],
default="vertical",
help="K-LD7 mount orientation — which angle plane it measures (default: vertical)",
)
- Step 2: Add K-LD7 globals and init function
In src/openflight/server.py, after the camera globals (near line 30), add:
After the init_camera() function (after line 302), add:
def init_kld7(port=None, orientation="vertical") -> bool:
"""Initialize K-LD7 angle radar tracker."""
global kld7_tracker # pylint: disable=global-statement
try:
from openflight.kld7 import KLD7Tracker
kld7_tracker = KLD7Tracker(port=port, orientation=orientation)
if kld7_tracker.connect():
kld7_tracker.start()
return True
else:
kld7_tracker = None
return False
except Exception as e:
logger.warning("K-LD7 initialization failed: %s", e)
kld7_tracker = None
return False
- Step 3: Wire up K-LD7 init in main()
In src/openflight/server.py main(), after the camera init block (after line 1349), add:
# Initialize K-LD7 angle radar (if enabled)
if args.kld7:
if init_kld7(port=args.kld7_port, orientation=args.kld7_orientation):
print(f"K-LD7 angle radar enabled (orientation: {args.kld7_orientation})")
else:
print("K-LD7 not available - running without angle radar")
```
- [ ] **Step 4: Add K-LD7 cleanup in finally block**
In `src/openflight/server.py`, in the `finally` block at the end of `main()` (line 1374), add before `stop_monitor()`:
```python
if kld7_tracker:
kld7_tracker.stop()
- Step 5: Add --kld7 flags to start-kiosk.sh
In scripts/start-kiosk.sh, add variable declarations after line 20:
In the while loop (after line 76), add cases:
--kld7)
KLD7=true
shift
;;
--kld7-port)
KLD7_PORT="$2"
shift 2
;;
--kld7-orientation)
KLD7_ORIENTATION="$2"
shift 2
;;
In the command building section (after line 176), add:
if [ "$KLD7" = true ]; then
SERVER_CMD="$SERVER_CMD --kld7"
fi
if [ -n "$KLD7_PORT" ]; then
SERVER_CMD="$SERVER_CMD --kld7-port $KLD7_PORT"
fi
if [ -n "$KLD7_ORIENTATION" ]; then
SERVER_CMD="$SERVER_CMD --kld7-orientation $KLD7_ORIENTATION"
fi
- Step 6: Commit
git add src/openflight/server.py scripts/start-kiosk.sh
git commit -m "feat(kld7): add --kld7 CLI flags and server init"
Task 4: Server Integration — Shot Angle Attachment¶
Files:
- Modify: src/openflight/server.py:763-861 (on_shot_detected)
- Modify: src/openflight/server.py:194-219 (shot_to_dict)
- Test: tests/test_server.py
- Step 1: Write failing test for shot_to_dict with angle_source
Add to tests/test_server.py:
def test_angle_source_field(self):
"""shot_to_dict should include angle_source."""
shot = Shot(
ball_speed_mph=150.0,
timestamp=datetime.now(),
launch_angle_vertical=12.5,
launch_angle_confidence=0.8,
)
result = shot_to_dict(shot)
assert "angle_source" in result
- Step 2: Run test to verify it fails
Run: uv run pytest tests/test_server.py::TestShotToDict::test_angle_source_field -v
Expected: FAIL — angle_source not in result
- Step 3: Add angle_source to Shot dataclass
In src/openflight/launch_monitor.py, add field to Shot (after line 258):
- Step 4: Add angle_source to shot_to_dict
In src/openflight/server.py, in shot_to_dict() (line 194-219), add to the return dict:
Update the comment on line 208 from # Launch angle from camera to # Launch angle data.
- Step 5: Add K-LD7 angle attachment to on_shot_detected
In src/openflight/server.py, in on_shot_detected(), add a K-LD7 block before the camera block (before line 773). The K-LD7 data should be checked first since it's higher priority:
# Try K-LD7 angle radar first (highest priority for angle data)
try:
if kld7_tracker and shot.mode != "mock":
kld7_angle = kld7_tracker.get_angle_for_shot()
if kld7_angle:
if kld7_angle.vertical_deg is not None:
shot.launch_angle_vertical = kld7_angle.vertical_deg
shot.launch_angle_confidence = kld7_angle.confidence
shot.angle_source = "radar"
logger.info(
"K-LD7 vertical angle: %.1f° (conf: %.0f%%, %d frames)",
kld7_angle.vertical_deg, kld7_angle.confidence * 100, kld7_angle.num_frames,
)
if kld7_angle.horizontal_deg is not None:
shot.launch_angle_horizontal = kld7_angle.horizontal_deg
if shot.angle_source is None:
shot.angle_source = "radar"
shot.launch_angle_confidence = kld7_angle.confidence
logger.info(
"K-LD7 horizontal angle: %.1f° (conf: %.0f%%)",
kld7_angle.horizontal_deg, kld7_angle.confidence * 100,
)
kld7_tracker.reset()
except Exception as e:
logger.warning("K-LD7 processing error: %s", e)
Then modify the existing camera block to only run if K-LD7 didn't provide the vertical angle. Change the condition on line 773 from:
to:
if camera_tracker and camera_enabled and shot.mode != "mock" and shot.launch_angle_vertical is None:
And after the camera sets the angle, add the source:
In the estimation fallback block (line 804), after setting the angles, add:
- Step 6: Run tests
Run: uv run pytest tests/test_server.py -v
Expected: All tests PASS
- Step 7: Commit
git add src/openflight/server.py src/openflight/launch_monitor.py tests/test_server.py
git commit -m "feat(kld7): attach K-LD7 angle data to shots in on_shot_detected"
Task 5: UI — Split Launch Angle into Vertical + Horizontal Cards¶
Files:
- Modify: ui/src/types/shot.ts:1-19
- Modify: ui/src/components/ShotDisplay.tsx:150-199
- Step 1: Add angle_source to Shot TypeScript interface
In ui/src/types/shot.ts, update the launch angle section (lines 10-13):
// Launch angle data (from K-LD7 radar, camera, or estimation)
launch_angle_vertical: number | null;
launch_angle_horizontal: number | null;
launch_angle_confidence: number | null;
angle_source: 'radar' | 'camera' | 'estimated' | null;
- Step 2: Update ShotDisplay to show separate Vertical and Horizontal cards
In ui/src/components/ShotDisplay.tsx, replace the single Launch Angle MetricCard (lines 177-188) with two cards:
<MetricCard
value={hasLaunchAngle ? shot.launch_angle_vertical!.toFixed(1) : '—'}
unit={hasLaunchAngle ? '°' : undefined}
label="Vertical"
subtext={hasLaunchAngle ? (shot.angle_source ?? undefined) : undefined}
variant="secondary"
confidence={hasLaunchAngle ? getLaunchAngleQuality(shot.launch_angle_confidence) : null}
/>
{shot.launch_angle_horizontal !== null && (
<MetricCard
value={shot.launch_angle_horizontal.toFixed(1)}
unit="°"
label="Horizontal"
subtext={shot.angle_source ?? undefined}
variant="secondary"
confidence={getLaunchAngleQuality(shot.launch_angle_confidence)}
/>
)}
- Step 3: Build UI and verify no errors
Run: cd ui && npm run build
Expected: Build succeeds with no TypeScript errors
- Step 4: Commit
git add ui/src/types/shot.ts ui/src/components/ShotDisplay.tsx
git commit -m "feat(ui): split launch angle into separate Vertical and Horizontal cards"
Task 6: Add kld7 dependency to pyproject.toml¶
Files:
- Modify: pyproject.toml
- Step 1: Add kld7 as optional dependency
In pyproject.toml, add a new optional dependency group for the K-LD7 module. Find the [project.optional-dependencies] section and add:
Also add kld7 to the ui extras group (or whichever group is used for the full install on Pi) so it's included in the standard deployment.
- Step 2: Commit
Task 7: Integration Test — End-to-End with Mock Data¶
Files:
- Test: tests/test_kld7.py
- Step 1: Write integration test
Add to tests/test_kld7.py:
from openflight.launch_monitor import Shot, ClubType
from openflight.server import shot_to_dict
from datetime import datetime
class TestKLD7Integration:
"""Integration tests for K-LD7 angle data flowing through to Shot."""
def test_angle_attaches_to_shot_vertical(self):
"""K-LD7 vertical angle should attach to Shot correctly."""
shot = Shot(
ball_speed_mph=150.0,
timestamp=datetime.now(),
launch_angle_vertical=12.5,
launch_angle_confidence=0.8,
angle_source="radar",
)
result = shot_to_dict(shot)
assert result["launch_angle_vertical"] == 12.5
assert result["launch_angle_confidence"] == 0.8
assert result["angle_source"] == "radar"
def test_angle_attaches_to_shot_horizontal(self):
"""K-LD7 horizontal angle should attach to Shot correctly."""
shot = Shot(
ball_speed_mph=150.0,
timestamp=datetime.now(),
launch_angle_horizontal=-3.5,
launch_angle_confidence=0.7,
angle_source="radar",
)
result = shot_to_dict(shot)
assert result["launch_angle_horizontal"] == -3.5
assert result["angle_source"] == "radar"
def test_carry_adjusts_for_vertical_angle(self):
"""Shot carry should adjust when vertical angle is provided."""
shot_no_angle = Shot(ball_speed_mph=150.0, timestamp=datetime.now())
shot_with_angle = Shot(
ball_speed_mph=150.0,
timestamp=datetime.now(),
launch_angle_vertical=15.0,
launch_angle_confidence=0.8,
angle_source="radar",
)
# Both should produce valid carry, but values differ
assert shot_no_angle.estimated_carry_yards > 0
assert shot_with_angle.estimated_carry_yards > 0
assert shot_no_angle.estimated_carry_yards != shot_with_angle.estimated_carry_yards
def test_tracker_angle_to_shot_flow(self):
"""Full flow: KLD7Tracker ring buffer → get_angle → attach to Shot."""
tracker = KLD7Tracker.__new__(KLD7Tracker)
tracker.orientation = "vertical"
tracker.buffer_seconds = 2.0
tracker.max_buffer_frames = 70
tracker._init_ring_buffer()
now = time.time()
# Simulate ball pass
for i in range(3):
tracker._add_frame(KLD7Frame(
timestamp=now + i * 0.03,
tdat={"distance": 2.0, "speed": 50.0, "angle": 12.0, "magnitude": 5000},
pdat=[{"distance": 2.0, "speed": 50.0, "angle": 12.0, "magnitude": 5000}],
))
angle = tracker.get_angle_for_shot()
assert angle is not None
shot = Shot(
ball_speed_mph=150.0,
timestamp=datetime.now(),
)
shot.launch_angle_vertical = angle.vertical_deg
shot.launch_angle_confidence = angle.confidence
shot.angle_source = "radar"
result = shot_to_dict(shot)
assert result["launch_angle_vertical"] == 12.0
assert result["angle_source"] == "radar"
assert result["launch_angle_confidence"] > 0.0
- Step 2: Run all tests
Run: uv run pytest tests/test_kld7.py tests/test_server.py tests/test_launch_monitor.py -v
Expected: All tests PASS
- Step 3: Commit
git add tests/test_kld7.py
git commit -m "test(kld7): add integration tests for K-LD7 angle data flow"
Verification¶
# All K-LD7 tests
uv run pytest tests/test_kld7.py -v
# Server tests (shot_to_dict, estimate_launch_angle)
uv run pytest tests/test_server.py -v
# Launch monitor tests (carry distance with angles)
uv run pytest tests/test_launch_monitor.py -v
# UI builds cleanly
cd ui && npm run build
# Manual test on Pi with K-LD7 connected:
scripts/start-kiosk.sh --kld7 --kld7-orientation vertical
# Manual test without K-LD7 (should work exactly as before):
scripts/start-kiosk.sh