Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/how-it-works/ballistics.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,11 @@ the club-typical value is used — see
$170 \cdot v \cdot \sin(\text{LA})^{1.2}$ even when a measured value exists,
keeping the measured number in `spin_rpm_measured` for offline scoring.

Shot finalization in the server is the only place that writes
`carry_spin_adjusted` for a live shot: the simulator when it can run, the
spin table otherwise. The same committed number is what the kiosk shows and
what the simulator connectors receive.

## Disabling it

```bash
Expand Down
4 changes: 4 additions & 0 deletions src/openflight/launch_monitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@
# Measured spin is trusted for physics simulation only above this level.
SPIN_CONFIDENCE_HIGH = 0.7

# Floor below which a measured spin is diagnostic only: it is still reported,
# but the carry table substitutes the club-optimal spin for the ball speed.
SPIN_CONFIDENCE_RELIABLE = 0.6


def estimate_carry_distance(ball_speed_mph: float, club: ClubType = ClubType.DRIVER) -> float:
"""
Expand Down
20 changes: 0 additions & 20 deletions src/openflight/rolling_buffer/monitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -751,15 +751,6 @@ def _create_shot(self, processed: ProcessedCapture) -> Optional[Shot]:
"%.0f" % spin.spin_rpm if spin else "N/A",
)

# Calculate carry distance.
# Use spin-adjusted carry only for reliable, plausible spin readings.
has_reliable_spin = bool(
processed.has_spin
and club_spin_rejection_reason is None
and spin is not None
and not spin.at_lower_rail
and not spin.at_upper_rail
)
has_reportable_spin = bool(
spin is not None
and spin.spin_rpm > 0
Expand Down Expand Up @@ -788,16 +779,6 @@ def _create_shot(self, processed: ProcessedCapture) -> Optional[Shot]:
f"Upper-rail spin candidate {spin.spin_rpm:.0f} RPM kept as diagnostic only"
)

if has_reliable_spin:
carry = estimate_carry_with_spin(
processed.ball_speed_mph,
spin.spin_rpm,
self._current_club,
club_speed_mph=processed.club_speed_mph,
)
else:
carry = estimate_carry_distance(processed.ball_speed_mph, self._current_club)

spin_rpm = spin.spin_rpm if has_reportable_spin else None
spin_confidence = spin.confidence if has_reportable_spin else None
spin_result_quality = spin.quality if has_reportable_spin else None
Expand Down Expand Up @@ -846,7 +827,6 @@ def _create_shot(self, processed: ProcessedCapture) -> Optional[Shot]:
spin_phase_agreement_pct=spin.phase_agreement_pct if spin else None,
spin_phase_confirmed=spin.phase_confirmed if spin else False,
spin_rejection_reason=spin_rejection_reason,
carry_spin_adjusted=carry if has_reliable_spin else None,
mode="rolling-buffer",
)

Expand Down
4 changes: 3 additions & 1 deletion src/openflight/rolling_buffer/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
from datetime import datetime
from typing import List, Optional

from ..launch_monitor import SPIN_CONFIDENCE_RELIABLE


@dataclass
class IQCapture:
Expand Down Expand Up @@ -282,7 +284,7 @@ class SpinResult:
@property
def is_reliable(self) -> bool:
"""Whether spin detection is considered reliable."""
return self.confidence >= 0.6 and self.quality in ("high", "medium")
return self.confidence >= SPIN_CONFIDENCE_RELIABLE and self.quality in ("high", "medium")

@classmethod
def no_spin_detected(
Expand Down
10 changes: 5 additions & 5 deletions src/openflight/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
get_club_physics,
get_club_simulation_profile,
)
from .launch_monitor import SPIN_CONFIDENCE_HIGH, Shot, summarize_shots
from .launch_monitor import SPIN_CONFIDENCE_HIGH, SPIN_CONFIDENCE_RELIABLE, Shot, summarize_shots
from .ops243 import (
UART_BAUD_COMMANDS,
Direction,
Expand Down Expand Up @@ -3182,9 +3182,9 @@ def _finalize_shot_detected(
# Compute carry. Prefer the physics simulator (drag + Magnus, RK4) when
# ballistics is enabled and a vertical launch angle is available; fall
# back to the table estimator otherwise (either ballistics disabled or
# angle missing → resolve_launch returns None).
_MIN_RELIABLE_SPIN_CONF = 0.6
if shot.carry_spin_adjusted is None and shot.mode != "mock":
# angle missing → resolve_launch returns None). This is the only place
# that writes carry_spin_adjusted for a live shot.
if shot.mode != "mock":
conditions = resolve_launch(shot) if ballistics_enabled else None
if conditions is not None:
trajectory = simulate(conditions)
Expand All @@ -3200,7 +3200,7 @@ def _finalize_shot_detected(
shot.spin_rpm
and shot.spin_rpm > 0
and shot.spin_confidence is not None
and shot.spin_confidence >= _MIN_RELIABLE_SPIN_CONF
and shot.spin_confidence >= SPIN_CONFIDENCE_RELIABLE
)
spin_for_carry = (
shot.spin_rpm
Expand Down
8 changes: 7 additions & 1 deletion src/openflight/sim/resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,13 @@ def resolve_shot(shot: Shot, player_state: PlayerState) -> ResolvedShot:
provenance["back_spin"] = derived_prov
provenance["side_spin"] = derived_prov

carry = float(shot.estimated_carry_yards)
# carry_spin_adjusted holds the server's committed carry (ballistic
# simulator, or the spin table when the simulator cannot run). The bare
# launch-angle table is only for shots that never went through finalization.
if shot.carry_spin_adjusted is not None:
carry = float(shot.carry_spin_adjusted)
else:
carry = float(shot.estimated_carry_yards)
# Carry is always model-derived (never directly observed), so "measured" here
# means launch-angle-informed: the carry model was driven by a measured launch
# angle rather than falling back to club-type defaults. The UI badge reflects
Expand Down
29 changes: 29 additions & 0 deletions tests/test_rolling_buffer.py
Original file line number Diff line number Diff line change
Expand Up @@ -971,6 +971,35 @@ def test_kld7_impact_timestamp_uses_hardware_trigger_timestamp(self):
assert shot.impact_timestamp == pytest.approx(12345.678)
assert shot.impact_timestamp_kld7 == pytest.approx(12345.678)

def test_create_shot_leaves_carry_to_server_finalization(self):
"""A clean, plausible spin must not pre-fill carry_spin_adjusted.

Carry is committed once, in server finalization, so the ballistic
simulator is never short-circuited by a table estimate written here.
"""
from openflight.rolling_buffer import RollingBufferMonitor

monitor = RollingBufferMonitor(port=None, trigger_type="sound")
monitor.set_club(ClubType.IRON_7)
processed = self._processed_with_spin(
SpinResult(
spin_rpm=6200,
confidence=0.8,
snr=12.0,
quality="high",
peak_freq_hz=103.3,
seam_cycles=4.0,
at_lower_rail=False,
)
)

shot = monitor._create_shot(processed)

assert shot is not None
assert shot.spin_rpm == 6200
assert shot.spin_rejection_reason is None
assert shot.carry_spin_adjusted is None

def test_lower_rail_driver_spin_kept_diagnostic_only(self):
"""Rail picks should be logged but not exposed as measured spin."""
from openflight.rolling_buffer import RollingBufferMonitor
Expand Down
134 changes: 134 additions & 0 deletions tests/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -4482,3 +4482,137 @@ def test_every_api_supported_baud_is_accepted(self, good):
a stricter check would reject a legitimate fallback to 115200, which the
flag's own help text tells operators to use."""
assert good in UART_BAUD_COMMANDS


class TestBallisticCarryPrecedence:
"""Finalization is the single writer of carry_spin_adjusted.

The simulator owns carry whenever it can run; the spin table owns it
otherwise. Anything already on the shot is replaced either way.
"""

@pytest.fixture(autouse=True)
def _isolate_finalization(self, monkeypatch):
server_module._reset_shot_sequence()
monkeypatch.setattr(server_module, "monitor", None)
monkeypatch.setattr(server_module, "kld7_vertical", None)
monkeypatch.setattr(server_module, "kld7_horizontal", None)
monkeypatch.setattr(server_module, "camera_capture_runtime", None)
monkeypatch.setattr(server_module, "ball_speed_correction_enabled", False)
monkeypatch.setattr(server_module, "calculated_spin_enabled", False)
monkeypatch.setattr(server_module, "debug_mode", False)
monkeypatch.setattr(server_module, "sim_connectors", [])
monkeypatch.setattr(server_module, "get_session_logger", lambda: None)
monkeypatch.setattr(server_module.socketio, "emit", lambda *_args, **_kwargs: None)
yield
_wait_for_shot_finalization_idle()

@staticmethod
def _shot(*, launch_angle: float | None, prefilled_carry: float | None) -> Shot:
# Field report, 2026-09-23: a 7-iron where the kiosk showed the 119 yd
# spin-table number while the simulator and a commercial launch
# monitor both landed near 143 yd.
return Shot(
ball_speed_mph=104.2,
club_speed_mph=83.7,
timestamp=datetime(2026, 9, 23, 12, 0, 0),
impact_timestamp=100.0,
club=ClubType.IRON_7,
spin_rpm=5164.0,
spin_confidence=0.9,
launch_angle_vertical=launch_angle,
launch_angle_confidence=0.9 if launch_angle is not None else None,
carry_spin_adjusted=prefilled_carry,
mode="rolling-buffer",
)

def test_simulator_overrides_prefilled_table_carry(self, monkeypatch):
monkeypatch.setattr(server_module, "ballistics_enabled", True)
shot = self._shot(launch_angle=19.1, prefilled_carry=119.2)

server_module._finalize_shot_detected(shot, emit_event="shot")

expected = server_module.simulate(server_module.resolve_launch(shot)).carry_yards
assert shot.carry_spin_adjusted == pytest.approx(expected)
assert shot.carry_spin_adjusted != pytest.approx(119.2)
assert shot.carry_spin_adjusted > 135.0

def test_table_fallback_replaces_prefilled_carry_when_ballistics_disabled(self, monkeypatch):
monkeypatch.setattr(server_module, "ballistics_enabled", False)
shot = self._shot(launch_angle=19.1, prefilled_carry=999.0)

server_module._finalize_shot_detected(shot, emit_event="shot")

expected = server_module.estimate_carry_with_spin(
104.2, 5164.0, ClubType.IRON_7, club_speed_mph=83.7
)
assert shot.carry_spin_adjusted == pytest.approx(expected)

def test_table_fallback_replaces_prefilled_carry_without_launch_angle(self, monkeypatch):
monkeypatch.setattr(server_module, "ballistics_enabled", True)
monkeypatch.setattr(server_module, "_ensure_user_facing_launch_angles", lambda _shot: None)
shot = self._shot(launch_angle=None, prefilled_carry=999.0)

server_module._finalize_shot_detected(shot, emit_event="shot")

assert shot.carry_spin_adjusted is not None
assert shot.carry_spin_adjusted != pytest.approx(999.0)
assert 0 < shot.carry_spin_adjusted < 200

@pytest.mark.parametrize(
("offset_from_floor", "uses_measured_spin"),
[(0.0, True), (-0.01, False)],
)
def test_table_fallback_spin_gate_matches_monitor_reliability(
self, monkeypatch, offset_from_floor, uses_measured_spin
):
"""The fallback trusts measured spin on the same floor SpinResult.is_reliable uses."""
from openflight.launch_monitor import SPIN_CONFIDENCE_RELIABLE
from openflight.rolling_buffer.types import SpinResult

confidence = SPIN_CONFIDENCE_RELIABLE + offset_from_floor
assert (
SpinResult(spin_rpm=5164, confidence=confidence, snr=10.0, quality="medium").is_reliable
is uses_measured_spin
)

monkeypatch.setattr(server_module, "ballistics_enabled", False)
shot = self._shot(launch_angle=19.1, prefilled_carry=None)
shot.spin_confidence = confidence

server_module._finalize_shot_detected(shot, emit_event="shot")

measured = server_module.estimate_carry_with_spin(
104.2, 5164.0, ClubType.IRON_7, club_speed_mph=83.7
)
optimal = server_module.estimate_carry_with_spin(
104.2,
server_module.get_optimal_spin_for_ball_speed(104.2, ClubType.IRON_7),
ClubType.IRON_7,
club_speed_mph=83.7,
)
assert measured != pytest.approx(optimal)
expected = measured if uses_measured_spin else optimal
assert shot.carry_spin_adjusted == pytest.approx(expected)

def test_table_fallback_fills_empty_carry(self, monkeypatch):
monkeypatch.setattr(server_module, "ballistics_enabled", False)
shot = self._shot(launch_angle=19.1, prefilled_carry=None)

server_module._finalize_shot_detected(shot, emit_event="shot")

assert shot.carry_spin_adjusted is not None
assert shot.carry_spin_adjusted > 0

def test_simulator_carry_reaches_sim_connectors(self, monkeypatch):
monkeypatch.setattr(server_module, "ballistics_enabled", True)
forwarded = []
monkeypatch.setattr(server_module, "_forward_shot_to_simulators", forwarded.append)
shot = self._shot(launch_angle=19.1, prefilled_carry=None)

server_module._finalize_shot_detected(shot, emit_event="shot")

assert forwarded == [shot]
resolved = server_module.resolve_shot(forwarded[0], server_module.SimPlayerState())
assert resolved.carry_yards == pytest.approx(shot.carry_spin_adjusted)
assert resolved.carry_yards > 135.0
12 changes: 12 additions & 0 deletions tests/test_sim_resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,3 +110,15 @@ def test_shot_number_uses_player_state():
ps.next_shot_number() # consume one
r = resolve_shot(_shot(spin_rpm=2500.0, spin_confidence=0.9), ps)
assert r.shot_number == 2


def test_carry_prefers_committed_spin_adjusted_carry():
shot = _shot(launch_angle_vertical=12.0, carry_spin_adjusted=244.0)
assert resolve_shot(shot, PlayerState()).carry_yards == pytest.approx(244.0)


def test_carry_falls_back_to_table_when_nothing_committed():
shot = _shot(launch_angle_vertical=12.0)
assert resolve_shot(shot, PlayerState()).carry_yards == pytest.approx(
shot.estimated_carry_yards
)
Loading