Spin & Angle Data Quality Validation¶
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.
Date: 2026-04-15 Status: Approved Scope: Tighten spin confidence scoring, add hard RPM ceiling, add angle bounds, fix missing horizontal plausibility check
Problem¶
Session session_20260415_123037_range.jsonl (6 shots) revealed three data quality issues:
- Repeated spin values: 4/6 shots returned exactly 2637 RPM (FFT bin 12 of the 8192-point envelope FFT). These are not real spin measurements — the detector is locking onto the same low-frequency spectral feature. All received
spin_confidence >= 0.7, meaning the ballistics module's fallback policy treats them as trustworthy. - Impossible spin value: Shot 3 returned 20,455 RPM (340.9 Hz) despite
SPIN_MAX_SEAM_HZ = 200.0. This exceeds the physical maximum for any golf ball. It gotspin_confidence = 0.7. - Bad launch angles: Shot 4 had
launch_angle_horizontal = -34.0(physically impossible). Shot 3 hadlaunch_angle_vertical = 31.2at 101 mph ball speed (implausible for that speed). The horizontal angle path inserver.pyhas no plausibility check — angles are accepted unconditionally.
Approach¶
Fix at the source: add validation inside the existing detection code (processor.py for spin, radc.py for angles) rather than a separate validation layer. Each shot is evaluated independently (no cross-shot state).
Changes¶
1. Spin: Hard RPM ceiling (processor.py)¶
Add a final safety-net check in detect_spin() before returning SpinResult:
if spin_rpm > SPIN_MAX_SEAM_HZ * 60: # 12000 RPM
return SpinResult.no_spin_detected("Spin exceeds physical maximum")
This catches any code path that bypasses the FFT frequency mask, including the autocorrelation override (lines 573-576) which sets spin_rpm = acorr_rpm without rechecking bounds.
2. Spin: Tighten confidence scoring (processor.py)¶
Current scoring (lines 593-604) gives confidence = 0.7 for SNR >= 5.0 OR autocorr_confirmed. This is too generous — 0.7 is the ballistics module's "high" threshold for trusting measured spin.
New scoring matrix:
| Condition | Confidence |
|---|---|
SNR >= 8.0 AND seam_cycles >= 5 |
0.9 |
SNR >= 8.0 AND seam_cycles >= 3 |
0.8 |
SNR >= 5.0 AND (seam_cycles >= 3 OR autocorr_confirmed) |
0.7 |
SNR >= 5.0 OR autocorr_confirmed |
0.5 |
SNR >= 3.0 |
0.3 |
Key change: 0.7 now requires both decent SNR and either enough seam cycles or autocorrelation confirmation. Bare SNR >= 5.0 without cycle validation drops to 0.5 — below the ballistics "high" threshold. This directly targets the 2637 RPM shots that were getting 0.7 on thin evidence.
3. Spin: Modulation depth confidence floor (processor.py)¶
After the existing modulation depth check (line ~494), add a confidence cap: if modulation_depth < 0.01 (1%), limit the maximum confidence to 0.5 regardless of SNR. Real spin modulation is 1-5%; below 1% we're likely seeing noise with a coincidental spectral peak.
Implementation: set a flag (e.g., weak_modulation = modulation_depth < 0.01) and apply confidence = min(confidence, 0.5) before returning.
4. Spin: Shared confidence constant (launch_monitor.py)¶
Extract SPIN_CONFIDENCE_HIGH = 0.7 into launch_monitor.py and import it in:
- processor.py (for quality label assignment)
- ballistics.py (pending PR #61, currently duplicates the threshold)
5. Angles: Hard bounds in extract_launch_angle() (radc.py)¶
Before appending to results (after line 422), reject angles outside physical limits:
- Vertical: reject if
corrected_angle < 0.0orcorrected_angle > 45.0 - Horizontal: reject if
abs(corrected_angle) > 15.0
Requires threading orientation ("vertical" or "horizontal") from KLD7Tracker through _extract_ball_radc() into extract_launch_angle(). The tracker already knows its orientation.
6. Angles: Horizontal plausibility check (server.py)¶
Add a guard at lines 997-1002 for horizontal angles, matching the pattern used for vertical:
if kld7_angle_h and kld7_angle_h.horizontal_deg is not None:
if abs(kld7_angle_h.horizontal_deg) <= 15.0:
shot.launch_angle_horizontal = kld7_angle_h.horizontal_deg
...
else:
logger.warning(...)
Simpler than the vertical guard — no club-based estimate needed since horizontal aim offset doesn't vary by club.
Tests¶
Spin validation tests¶
detect_spin()returnsno_spin_detectedwhen peak frequency exceedsSPIN_MAX_SEAM_HZSNR=6.0, seam_cycles=2now scores 0.5 (was 0.7)SNR=6.0, seam_cycles=4, autocorr_confirmed=Truescores 0.7- Modulation depth < 1% caps confidence at 0.5
SPIN_CONFIDENCE_HIGHconstant is used consistently across modules
Angle validation tests¶
extract_launch_angle()rejects vertical angles outside [0, 45]extract_launch_angle()rejects horizontal angles outside [-15, +15]- Borderline-valid angles (44.9 vertical, 14.9 horizontal) pass through
- Server-level horizontal plausibility rejection works
All tests are unit tests on detection functions — no hardware or I/Q fixtures needed. Mock FFT output / peak values to exercise the validation gates.
Files Modified¶
| File | Changes |
|---|---|
src/openflight/rolling_buffer/processor.py |
Hard RPM cap, confidence rescoring, modulation depth floor |
src/openflight/launch_monitor.py |
Extract SPIN_CONFIDENCE_HIGH constant |
src/openflight/kld7/radc.py |
Hard angle bounds, accept orientation parameter |
src/openflight/kld7/tracker.py |
Pass orientation through to extract_launch_angle() |
src/openflight/server.py |
Horizontal angle plausibility check |
tests/test_rolling_buffer.py (or new file) |
Spin validation tests |
tests/test_kld7_radc.py (or new file) |
Angle validation tests |
Out of Scope¶
- Trigger latency investigation (17-28s latencies — separate debugging session)
- Cross-shot spin tracking / repeated-bin detection
- Per-club adaptive angle bounds
- Changes to the ballistics PR (#61) beyond importing the shared constant