diff --git a/docs/how-it-works/ballistics.md b/docs/how-it-works/ballistics.md index f57b455fd..64e028476 100644 --- a/docs/how-it-works/ballistics.md +++ b/docs/how-it-works/ballistics.md @@ -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 diff --git a/src/openflight/launch_monitor.py b/src/openflight/launch_monitor.py index 86a3ecdd1..67c37f910 100644 --- a/src/openflight/launch_monitor.py +++ b/src/openflight/launch_monitor.py @@ -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: """ diff --git a/src/openflight/rolling_buffer/monitor.py b/src/openflight/rolling_buffer/monitor.py index c61b6722d..d6352b609 100644 --- a/src/openflight/rolling_buffer/monitor.py +++ b/src/openflight/rolling_buffer/monitor.py @@ -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 @@ -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 @@ -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", ) diff --git a/src/openflight/rolling_buffer/types.py b/src/openflight/rolling_buffer/types.py index fed969e42..43386bcc6 100644 --- a/src/openflight/rolling_buffer/types.py +++ b/src/openflight/rolling_buffer/types.py @@ -9,6 +9,8 @@ from datetime import datetime from typing import List, Optional +from ..launch_monitor import SPIN_CONFIDENCE_RELIABLE + @dataclass class IQCapture: @@ -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( diff --git a/src/openflight/server.py b/src/openflight/server.py index 5ee4fc265..96fe07ecb 100644 --- a/src/openflight/server.py +++ b/src/openflight/server.py @@ -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, @@ -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) @@ -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 diff --git a/src/openflight/sim/resolver.py b/src/openflight/sim/resolver.py index fe0d8730b..74b22c621 100644 --- a/src/openflight/sim/resolver.py +++ b/src/openflight/sim/resolver.py @@ -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 diff --git a/tests/test_rolling_buffer.py b/tests/test_rolling_buffer.py index b4b49725c..6b214f570 100644 --- a/tests/test_rolling_buffer.py +++ b/tests/test_rolling_buffer.py @@ -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 diff --git a/tests/test_server.py b/tests/test_server.py index 47412e059..4772ae051 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -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 diff --git a/tests/test_sim_resolver.py b/tests/test_sim_resolver.py index 667b27a27..dfb6a7345 100644 --- a/tests/test_sim_resolver.py +++ b/tests/test_sim_resolver.py @@ -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 + )