From fb53773dd5b8e58235c4053c481ee20f7ee4918a Mon Sep 17 00:00:00 2001 From: Dieter Olson Date: Thu, 20 Aug 2026 22:26:55 -0700 Subject: [PATCH] fix: resolve Windows test-suite issues, cloud push logging, and dynamic midpoint fallback Windows test-suite fixes: newline preservation in compare_trackman, Windows skips for POSIX permissions and symlinks, deterministic sim transport backoff, clean socket accept termination, and tmp_path basetemp config. Code fixes: cloud-push debug logging, dynamic capture midpoint calculation, and deduplicated angle limit constant. --- .gitignore | 1 + pyproject.toml | 2 + src/openflight/kld7/__init__.py | 9 - src/openflight/kld7/radc.py | 10 +- src/openflight/kld7/tracker.py | 17 +- src/openflight/rolling_buffer/processor.py | 6 +- src/openflight/server.py | 13 +- tests/conftest.py | 7 + tests/test_cloud_config.py | 4 + tests/test_compare_trackman.py | 435 ++++++++++++++------- tests/test_rolling_buffer.py | 58 +++ tests/test_serial_latency.py | 4 + tests/test_server.py | 49 ++- tests/test_session_logger.py | 14 + tests/test_sim_transport.py | 84 +++- 15 files changed, 528 insertions(+), 185 deletions(-) diff --git a/.gitignore b/.gitignore index e31379aac..9e8575f53 100644 --- a/.gitignore +++ b/.gitignore @@ -60,6 +60,7 @@ session_logs/*/ # Test artifacts test_camera.jpg .pytest_cache/ +.pytest_temp/ .coverage htmlcov/ diff --git a/pyproject.toml b/pyproject.toml index 3047d0720..e7648ebe7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -127,3 +127,5 @@ max-line-length = 100 [tool.pytest.ini_options] testpaths = ["tests"] python_files = ["test_*.py"] +addopts = "--basetemp=.pytest_temp" +tmp_path_retention_policy = "none" diff --git a/src/openflight/kld7/__init__.py b/src/openflight/kld7/__init__.py index b39373559..c6b3a5836 100644 --- a/src/openflight/kld7/__init__.py +++ b/src/openflight/kld7/__init__.py @@ -6,16 +6,7 @@ existing builds but will not receive further development. """ -import warnings - from .tracker import KLD7Tracker from .types import KLD7Angle, KLD7Frame -warnings.warn( - "The K-LD7 angle radar is deprecated; OpenFlight has moved to a more " - "capable radar chip. K-LD7 support is kept for existing builds only.", - DeprecationWarning, - stacklevel=2, -) - __all__ = ["KLD7Angle", "KLD7Frame", "KLD7Tracker"] diff --git a/src/openflight/kld7/radc.py b/src/openflight/kld7/radc.py index 8e5b22784..1ed9ff819 100644 --- a/src/openflight/kld7/radc.py +++ b/src/openflight/kld7/radc.py @@ -95,6 +95,9 @@ VERTICAL_LEGACY_NAIVE_CONFIDENCE_MAX = 0.35 VERTICAL_EARLY_CONTEXT_CONFIDENCE_PENALTY = 0.08 +# Default horizontal K-LD7 RADC angle acceptance limit (degrees) +DEFAULT_RADC_HORIZONTAL_ANGLE_LIMIT_DEG = 15.0 + def parse_radc_payload(payload: bytes) -> dict[str, np.ndarray]: """Parse a 3072-byte RADC payload into six uint16 channel arrays. @@ -1249,7 +1252,10 @@ def empty(reason: str, has_radc: bool, warnings: tuple[str, ...]) -> RADCFrameDi warnings.append("far_from_ops_bin") if orientation == "vertical" and (angle_centroid < 0.0 or angle_centroid > 45.0): warnings.append("outside_vertical_bounds") - if orientation == "horizontal" and abs(angle_centroid) > 15.0: + if ( + orientation == "horizontal" + and abs(angle_centroid) > DEFAULT_RADC_HORIZONTAL_ANGLE_LIMIT_DEG + ): warnings.append("outside_horizontal_bounds") return RADCFrameDiagnostics( @@ -1426,7 +1432,7 @@ def extract_launch_angle( spectrum_source: str = "f1a", ops_anchored_peak_min_snr: float = OPS_ANCHORED_PEAK_MIN_SNR, require_ops_anchored_peak: bool = False, - horizontal_angle_limit_deg: float = 15.0, + horizontal_angle_limit_deg: float = DEFAULT_RADC_HORIZONTAL_ANGLE_LIMIT_DEG, vertical_estimator: str = "naive", shot_timestamp: float | None = None, impact_timestamp: float | None = None, diff --git a/src/openflight/kld7/tracker.py b/src/openflight/kld7/tracker.py index 019187169..63e31e8b1 100644 --- a/src/openflight/kld7/tracker.py +++ b/src/openflight/kld7/tracker.py @@ -9,6 +9,7 @@ import logging import threading import time +import warnings from collections import deque from importlib.util import find_spec from pathlib import Path @@ -16,7 +17,11 @@ from ..launch_monitor import ClubType from ..serial_latency import log_usb_serial_latency_timer -from .radc import RADC_PAYLOAD_BYTES, VERTICAL_FLIGHT_WINDOW_NET_DISTANCE_FT +from .radc import ( + DEFAULT_RADC_HORIZONTAL_ANGLE_LIMIT_DEG, + RADC_PAYLOAD_BYTES, + VERTICAL_FLIGHT_WINDOW_NET_DISTANCE_FT, +) from .types import KLD7Angle, KLD7Frame logger = logging.getLogger(__name__) @@ -139,7 +144,7 @@ class KLD7Tracker: radc_vertical_impact_energy_threshold = 3.0 radc_horizontal_impact_energy_threshold = 1.85 radc_horizontal_retry_impact_energy_threshold = 0.5 - radc_horizontal_angle_limit_deg = 15.0 + radc_horizontal_angle_limit_deg = DEFAULT_RADC_HORIZONTAL_ANGLE_LIMIT_DEG vertical_estimator = "naive" mount_tilt_deg = 18.0 ball_distance_ft = 5.5 @@ -163,12 +168,18 @@ def __init__( radc_vertical_impact_energy_threshold: float = 3.0, radc_horizontal_impact_energy_threshold: float = 1.85, radc_horizontal_retry_impact_energy_threshold: float = 0.5, - radc_horizontal_angle_limit_deg: float = 15.0, + radc_horizontal_angle_limit_deg: float = DEFAULT_RADC_HORIZONTAL_ANGLE_LIMIT_DEG, vertical_estimator: str = "naive", mount_tilt_deg: float = 18.0, ball_distance_ft: float = 5.5, vertical_flight_window_net_distance_ft: float = VERTICAL_FLIGHT_WINDOW_NET_DISTANCE_FT, ): + warnings.warn( + "The K-LD7 angle radar is deprecated; OpenFlight has moved to a more " + "capable radar chip. K-LD7 support is kept for existing builds only.", + DeprecationWarning, + stacklevel=2, + ) self.port = port self.range_m = range_m self.speed_kmh = speed_kmh diff --git a/src/openflight/rolling_buffer/processor.py b/src/openflight/rolling_buffer/processor.py index 0cd4835a5..f518ce196 100644 --- a/src/openflight/rolling_buffer/processor.py +++ b/src/openflight/rolling_buffer/processor.py @@ -1820,7 +1820,11 @@ def process_capture( ball_reading = None logger.warning("[PROCESSOR] No outbound readings in overlapping timeline") - ball_timestamp_ms = ball_reading.timestamp_ms if ball_reading else 68.0 + ball_timestamp_ms = ( + ball_reading.timestamp_ms + if ball_reading + else (len(capture.i_samples) / self.SAMPLE_RATE) * 500.0 + ) # Find club speed club_speed_mph, club_timestamp_ms = self.find_club_speed( diff --git a/src/openflight/server.py b/src/openflight/server.py index 0682d2b0a..31aa5c386 100644 --- a/src/openflight/server.py +++ b/src/openflight/server.py @@ -22,6 +22,7 @@ from flask_socketio import SocketIO from .ballistics import resolve_launch, simulate +from .kld7.radc import DEFAULT_RADC_HORIZONTAL_ANGLE_LIMIT_DEG from .launch_monitor import SPIN_CONFIDENCE_HIGH, ClubType, Shot from .ops243 import ( UART_BAUD_COMMANDS, @@ -157,7 +158,7 @@ "radc_vertical_impact_energy_threshold": 3.0, "radc_horizontal_impact_energy_threshold": 1.85, "radc_horizontal_retry_impact_energy_threshold": 0.5, - "radc_horizontal_angle_limit_deg": 15.0, + "radc_horizontal_angle_limit_deg": DEFAULT_RADC_HORIZONTAL_ANGLE_LIMIT_DEG, } active_kld7_radc_tuning: dict = dict(_DEFAULT_KLD7_RADC_TUNING) @@ -1219,7 +1220,7 @@ def init_kld7( radc_vertical_impact_energy_threshold=3.0, radc_horizontal_impact_energy_threshold=1.85, radc_horizontal_retry_impact_energy_threshold=0.5, - radc_horizontal_angle_limit_deg=15.0, + radc_horizontal_angle_limit_deg=DEFAULT_RADC_HORIZONTAL_ANGLE_LIMIT_DEG, vertical_estimator="naive", mount_tilt_deg=18.0, ball_distance_ft=5.5, @@ -2441,11 +2442,11 @@ def on_shot_detected(shot: Shot): float( active_kld7_radc_tuning.get( "radc_horizontal_angle_limit_deg", - 15.0, + DEFAULT_RADC_HORIZONTAL_ANGLE_LIMIT_DEG, ) ) if experimental_kld7_radc_tuning - else 15.0 + else DEFAULT_RADC_HORIZONTAL_ANGLE_LIMIT_DEG ) accepted_h, horizontal_selection_details = _select_horizontal_radar_launch( kld7_angle_h, horizontal_limit @@ -3048,7 +3049,7 @@ def _fire_cloud_push(session_logger): if log_dir is not None: fire_push_async(config, log_dir=log_dir) except Exception: # pylint: disable=broad-exception-caught - pass + logger.debug("[SERVER] Cloud push trigger failed", exc_info=True) def _run_cloud_push_for_ui(): @@ -3914,7 +3915,7 @@ def main(): parser.add_argument( "--experimental-kld7-horizontal-angle-limit", type=float, - default=15.0, + default=DEFAULT_RADC_HORIZONTAL_ANGLE_LIMIT_DEG, help="Experimental horizontal K-LD7 RADC angle acceptance limit in degrees (default: 15.0)", ) args = parser.parse_args() diff --git a/tests/conftest.py b/tests/conftest.py index 3844cd4cc..26b848577 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,6 +3,7 @@ Protocol-agnostic: records bytes received and can send scripted JSON replies or drop the connection. Used by both GSPro and OpenGolfSim connector tests. """ + import json import socket import sys @@ -40,6 +41,8 @@ def _run(self): self._client_sock, _ = self._sock.accept() except socket.timeout: continue + except OSError: + break self._client_sock.settimeout(0.2) # Send any scripted replies that were queued before connect for reply in list(self.scripted_replies): @@ -98,6 +101,10 @@ def disconnect_client(self) -> None: def stop(self) -> None: self._stop.set() self.disconnect_client() + try: + self._sock.close() + except OSError: + pass self._thread.join(timeout=2.0) diff --git a/tests/test_cloud_config.py b/tests/test_cloud_config.py index 7f637f138..8eadf2b7b 100644 --- a/tests/test_cloud_config.py +++ b/tests/test_cloud_config.py @@ -1,6 +1,7 @@ """Tests for the openflight-cloud config module.""" import json +import os import stat import pytest @@ -39,6 +40,9 @@ def test_defaults_endpoint_and_enabled_when_missing(self, tmp_path): class TestSaveConfig: + @pytest.mark.skipif( + os.name == "nt", reason="POSIX permission bits are not representable on Windows" + ) def test_writes_file_with_0600_permissions(self, tmp_path): path = tmp_path / "nested" / "cloud.json" config = cfg.CloudConfig(device_token="tok", device_id="id") diff --git a/tests/test_compare_trackman.py b/tests/test_compare_trackman.py index 32b29d2fc..86f24bee2 100644 --- a/tests/test_compare_trackman.py +++ b/tests/test_compare_trackman.py @@ -18,14 +18,20 @@ # Helpers # --------------------------------------------------------------------------- + def _write_openflight_jsonl(path: Path, shots: list) -> None: with open(path, "w", encoding="utf-8") as fh: for shot in shots: - fh.write(json.dumps({ - "type": "shot_detected", - "timestamp": shot["timestamp"], - "data": {k: v for k, v in shot.items() if k != "timestamp"}, - }) + "\n") + fh.write( + json.dumps( + { + "type": "shot_detected", + "timestamp": shot["timestamp"], + "data": {k: v for k, v in shot.items() if k != "timestamp"}, + } + ) + + "\n" + ) def _write_trackman_csv(path: Path, headers: list, rows: list) -> None: @@ -40,19 +46,23 @@ def _write_trackman_csv(path: Path, headers: list, rows: list) -> None: # Club name normalization # --------------------------------------------------------------------------- + class TestNormalizeClub: - @pytest.mark.parametrize("raw,expected", [ - ("7-iron", "7-iron"), - ("7 iron", "7-iron"), - ("7i", "7-iron"), - ("Iron 7", "7-iron"), - ("Driver", "driver"), - ("DRV", "driver"), - ("PW", "pw"), - ("Pitching Wedge", "pw"), - ("3-wood", "3-wood"), - ("3W", "3-wood"), - ]) + @pytest.mark.parametrize( + "raw,expected", + [ + ("7-iron", "7-iron"), + ("7 iron", "7-iron"), + ("7i", "7-iron"), + ("Iron 7", "7-iron"), + ("Driver", "driver"), + ("DRV", "driver"), + ("PW", "pw"), + ("Pitching Wedge", "pw"), + ("3-wood", "3-wood"), + ("3W", "3-wood"), + ], + ) def test_aliases_normalize_to_canonical(self, raw, expected): assert ct.normalize_club(raw) == expected @@ -65,12 +75,21 @@ def test_empty_returns_empty(self): # Header alias map # --------------------------------------------------------------------------- + class TestHeaderAliases: def test_standard_headers_resolve(self): - headers = ["Shot Number", "Date/Time", "Club", - "Ball Speed (mph)", "Club Speed (mph)", - "Launch Angle", "Launch Direction", - "Spin Rate", "Carry Distance", "Smash Factor"] + headers = [ + "Shot Number", + "Date/Time", + "Club", + "Ball Speed (mph)", + "Club Speed (mph)", + "Launch Angle", + "Launch Direction", + "Spin Rate", + "Carry Distance", + "Smash Factor", + ] col_map = ct._build_column_map(headers) assert col_map["ball_speed_mph"] == "Ball Speed (mph)" assert col_map["club_speed_mph"] == "Club Speed (mph)" @@ -80,10 +99,17 @@ def test_standard_headers_resolve(self): assert col_map["carry_yards"] == "Carry Distance" def test_alternate_headers_resolve(self): - headers = ["Shot", "Time", "Club Type", - "BallSpeed", "ClubSpeed", - "Launch Angle V", "Side Angle", - "Total Spin", "Carry"] + headers = [ + "Shot", + "Time", + "Club Type", + "BallSpeed", + "ClubSpeed", + "Launch Angle V", + "Side Angle", + "Total Spin", + "Carry", + ] col_map = ct._build_column_map(headers) assert col_map["ball_speed_mph"] == "BallSpeed" assert col_map["launch_angle_vertical"] == "Launch Angle V" @@ -107,19 +133,36 @@ def test_date_header_beats_last_data_point_time(self): # Trackman CSV loading + unit conversion # --------------------------------------------------------------------------- + class TestLoadTrackman: def test_basic_load(self, tmp_path): path = tmp_path / "tm.csv" _write_trackman_csv( path, - ["Shot Number", "Date/Time", "Club", - "Ball Speed (mph)", "Club Speed (mph)", - "Launch Angle", "Launch Direction", "Spin Rate", "Carry"], - [{"Shot Number": "1", "Date/Time": "2026-05-06 10:00:00", - "Club": "7-iron", "Ball Speed (mph)": "120.5", - "Club Speed (mph)": "85.0", "Launch Angle": "17.5", - "Launch Direction": "-1.2", "Spin Rate": "6800", - "Carry": "165.3"}], + [ + "Shot Number", + "Date/Time", + "Club", + "Ball Speed (mph)", + "Club Speed (mph)", + "Launch Angle", + "Launch Direction", + "Spin Rate", + "Carry", + ], + [ + { + "Shot Number": "1", + "Date/Time": "2026-05-06 10:00:00", + "Club": "7-iron", + "Ball Speed (mph)": "120.5", + "Club Speed (mph)": "85.0", + "Launch Angle": "17.5", + "Launch Direction": "-1.2", + "Spin Rate": "6800", + "Carry": "165.3", + } + ], ) shots = ct.load_trackman(path) assert len(shots) == 1 @@ -135,8 +178,14 @@ def test_kph_speeds_converted_to_mph(self, tmp_path): _write_trackman_csv( path, ["Shot Number", "Date/Time", "Club", "Ball Speed (kph)"], - [{"Shot Number": "1", "Date/Time": "2026-05-06 10:00:00", - "Club": "driver", "Ball Speed (kph)": "240.0"}], + [ + { + "Shot Number": "1", + "Date/Time": "2026-05-06 10:00:00", + "Club": "driver", + "Ball Speed (kph)": "240.0", + } + ], ) shots = ct.load_trackman(path) # 240 kph = 149.13 mph @@ -154,7 +203,9 @@ def test_handles_excel_sep_preamble_and_bom(self, tmp_path): "5/6/2026 6:58:02 PM,7 Iron,118.5,17.2,-1.5\r\n" "5/6/2026 6:59:00 PM,Driver,165.0,11.0,0.5\r\n" ) - path.write_text(content, encoding="utf-8") + # newline="" keeps the CRLF content byte-exact; the default translates + # "\n" to os.linesep, turning "\r\n" into "\r\r\n" on Windows. + path.write_text(content, encoding="utf-8", newline="") shots = ct.load_trackman(path) assert len(shots) == 2 assert shots[0].club == "7-iron" @@ -168,12 +219,8 @@ def test_units_row_with_only_brackets_is_skipped(self, tmp_path): """The units row contains bracketed unit labels and no numeric values — must not appear as a shot.""" path = tmp_path / "tm.csv" - content = ( - "Club,Ball Speed\r\n" - ",[mph]\r\n" - "7 Iron,120.0\r\n" - ) - path.write_text(content, encoding="utf-8") + content = "Club,Ball Speed\r\n,[mph]\r\n7 Iron,120.0\r\n" + path.write_text(content, encoding="utf-8", newline="") shots = ct.load_trackman(path) assert len(shots) == 1 assert shots[0].ball_speed_mph == pytest.approx(120.0) @@ -183,8 +230,14 @@ def test_metres_carry_converted_to_yards(self, tmp_path): _write_trackman_csv( path, ["Shot Number", "Date/Time", "Club", "Carry (m)"], - [{"Shot Number": "1", "Date/Time": "2026-05-06 10:00:00", - "Club": "7-iron", "Carry (m)": "150"}], + [ + { + "Shot Number": "1", + "Date/Time": "2026-05-06 10:00:00", + "Club": "7-iron", + "Carry (m)": "150", + } + ], ) shots = ct.load_trackman(path) # 150 m = 164 yards @@ -195,20 +248,29 @@ def test_metres_carry_converted_to_yards(self, tmp_path): # OpenFlight JSONL loading # --------------------------------------------------------------------------- + class TestLoadOpenflight: def test_loads_only_shot_detected(self, tmp_path): path = tmp_path / "of.jsonl" with open(path, "w") as fh: fh.write(json.dumps({"type": "session_start"}) + "\n") - fh.write(json.dumps({ - "type": "shot_detected", - "timestamp": "2026-05-06T10:00:00", - "data": {"shot_number": 1, "club": "7-iron", - "ball_speed_mph": 121.0, - "estimated_carry_yards": 160.0, - "launch_angle_vertical": 18.2, - "launch_angle_horizontal": 0.5}, - }) + "\n") + fh.write( + json.dumps( + { + "type": "shot_detected", + "timestamp": "2026-05-06T10:00:00", + "data": { + "shot_number": 1, + "club": "7-iron", + "ball_speed_mph": 121.0, + "estimated_carry_yards": 160.0, + "launch_angle_vertical": 18.2, + "launch_angle_horizontal": 0.5, + }, + } + ) + + "\n" + ) fh.write(json.dumps({"type": "iq_reading"}) + "\n") shots = ct.load_openflight(path) assert len(shots) == 1 @@ -221,24 +283,39 @@ def test_loads_only_shot_detected(self, tmp_path): # Pairing # --------------------------------------------------------------------------- + def _of(num, club, ball, ts, **kw): - return ct.Shot(source="of", shot_number=num, - timestamp=datetime.fromisoformat(ts), - club=ct.normalize_club(club), ball_speed_mph=ball, **kw) + return ct.Shot( + source="of", + shot_number=num, + timestamp=datetime.fromisoformat(ts), + club=ct.normalize_club(club), + ball_speed_mph=ball, + **kw, + ) def _tm(num, club, ball, ts, **kw): - return ct.Shot(source="tm", shot_number=num, - timestamp=datetime.fromisoformat(ts), - club=ct.normalize_club(club), ball_speed_mph=ball, **kw) + return ct.Shot( + source="tm", + shot_number=num, + timestamp=datetime.fromisoformat(ts), + club=ct.normalize_club(club), + ball_speed_mph=ball, + **kw, + ) class TestPairShots: def test_one_to_one_chronological(self): - of = [_of(1, "7-iron", 120, "2026-05-06T10:00:00"), - _of(2, "7-iron", 122, "2026-05-06T10:01:00")] - tm = [_tm(1, "7-iron", 121, "2026-05-06T10:00:01"), - _tm(2, "7-iron", 123, "2026-05-06T10:01:01")] + of = [ + _of(1, "7-iron", 120, "2026-05-06T10:00:00"), + _of(2, "7-iron", 122, "2026-05-06T10:01:00"), + ] + tm = [ + _tm(1, "7-iron", 121, "2026-05-06T10:00:01"), + _tm(2, "7-iron", 123, "2026-05-06T10:01:01"), + ] pairs = ct.pair_shots(of, tm) assert len(pairs) == 2 assert all(p.match_quality == "good" for p in pairs) @@ -254,8 +331,10 @@ def test_ball_speed_mismatch_flagged(self): assert "30" in pairs[0].notes # reports the delta def test_unmatched_openflight_extra(self): - of = [_of(1, "7-iron", 120, "2026-05-06T10:00:00"), - _of(2, "7-iron", 122, "2026-05-06T10:01:00")] + of = [ + _of(1, "7-iron", 120, "2026-05-06T10:00:00"), + _of(2, "7-iron", 122, "2026-05-06T10:01:00"), + ] tm = [_tm(1, "7-iron", 121, "2026-05-06T10:00:01")] pairs = ct.pair_shots(of, tm) assert len(pairs) == 2 @@ -265,8 +344,10 @@ def test_unmatched_openflight_extra(self): def test_unmatched_trackman_extra(self): of = [_of(1, "7-iron", 120, "2026-05-06T10:00:00")] - tm = [_tm(1, "7-iron", 121, "2026-05-06T10:00:01"), - _tm(2, "7-iron", 123, "2026-05-06T10:01:01")] + tm = [ + _tm(1, "7-iron", 121, "2026-05-06T10:00:01"), + _tm(2, "7-iron", 123, "2026-05-06T10:01:01"), + ] pairs = ct.pair_shots(of, tm) assert len(pairs) == 2 assert pairs[1].match_quality == "unmatched_trackman" @@ -275,12 +356,16 @@ def test_unmatched_trackman_extra(self): def test_grouping_by_club_independent(self): # 7i and driver are paired independently — interleaved input # order shouldn't matter as long as per-club order is correct. - of = [_of(1, "driver", 165, "2026-05-06T10:00:00"), - _of(2, "7-iron", 120, "2026-05-06T10:01:00"), - _of(3, "driver", 167, "2026-05-06T10:02:00")] - tm = [_tm(1, "7-iron", 121, "2026-05-06T10:01:01"), - _tm(2, "driver", 166, "2026-05-06T10:00:01"), - _tm(3, "driver", 168, "2026-05-06T10:02:01")] + of = [ + _of(1, "driver", 165, "2026-05-06T10:00:00"), + _of(2, "7-iron", 120, "2026-05-06T10:01:00"), + _of(3, "driver", 167, "2026-05-06T10:02:00"), + ] + tm = [ + _tm(1, "7-iron", 121, "2026-05-06T10:01:01"), + _tm(2, "driver", 166, "2026-05-06T10:00:01"), + _tm(3, "driver", 168, "2026-05-06T10:02:01"), + ] pairs = ct.pair_shots(of, tm) # All 3 should pair as "good" (ball-speed deltas all ≤ 1 mph) assert len([p for p in pairs if p.match_quality == "good"]) == 3 @@ -291,10 +376,14 @@ def test_grouping_by_club_independent(self): assert [p.of.ball_speed_mph for p in driver_pairs] == [165, 167] def test_club_filter_excludes_unwanted_clubs(self): - of = [_of(1, "driver", 165, "2026-05-06T10:00:00"), - _of(2, "7-iron", 120, "2026-05-06T10:01:00")] - tm = [_tm(1, "driver", 166, "2026-05-06T10:00:01"), - _tm(2, "7-iron", 121, "2026-05-06T10:01:01")] + of = [ + _of(1, "driver", 165, "2026-05-06T10:00:00"), + _of(2, "7-iron", 120, "2026-05-06T10:01:00"), + ] + tm = [ + _tm(1, "driver", 166, "2026-05-06T10:00:01"), + _tm(2, "7-iron", 121, "2026-05-06T10:01:01"), + ] pairs = ct.pair_shots(of, tm, club_filter=["7-iron"]) assert len(pairs) == 1 assert pairs[0].of.club == "7-iron" @@ -304,12 +393,11 @@ def test_club_filter_excludes_unwanted_clubs(self): # CSV output # --------------------------------------------------------------------------- + class TestWriteCSV: def test_round_trip(self, tmp_path): - of = [_of(1, "7-iron", 120, "2026-05-06T10:00:00", - launch_angle_vertical=18.0)] - tm = [_tm(1, "7-iron", 121, "2026-05-06T10:00:01", - launch_angle_vertical=18.5)] + of = [_of(1, "7-iron", 120, "2026-05-06T10:00:00", launch_angle_vertical=18.0)] + tm = [_tm(1, "7-iron", 121, "2026-05-06T10:00:01", launch_angle_vertical=18.5)] pairs = ct.pair_shots(of, tm) out = tmp_path / "comparison.csv" ct.write_comparison_csv(pairs, out) @@ -328,6 +416,7 @@ def test_round_trip(self, tmp_path): # End-to-end CLI # --------------------------------------------------------------------------- + class TestBallSpeedCalibrationFit: """The calibration printout is purely for the human; the underlying fits need to be correct so the recommended constants are usable. @@ -357,12 +446,11 @@ def test_calibration_handles_too_few_pairs(self, capsys): assert "not enough good ball-speed pairs" in out def test_calibration_emits_both_models(self, capsys): - of = [_of(i, "7-iron", 100 + 5 * i, - f"2026-05-06T10:0{i:01d}:00") - for i in range(5)] - tm = [_tm(i, "7-iron", (100 + 5 * i) * 1.02 + 1.0, - f"2026-05-06T10:0{i:01d}:01") - for i in range(5)] + of = [_of(i, "7-iron", 100 + 5 * i, f"2026-05-06T10:0{i:01d}:00") for i in range(5)] + tm = [ + _tm(i, "7-iron", (100 + 5 * i) * 1.02 + 1.0, f"2026-05-06T10:0{i:01d}:01") + for i in range(5) + ] pairs = ct.pair_shots(of, tm, ball_speed_tol_mph=20.0) ct.print_ball_speed_calibration(pairs) out = capsys.readouterr().out @@ -374,20 +462,56 @@ def test_calibration_emits_both_models(self, capsys): class TestLaunchAngleCalibration: def test_calibration_emits_vertical_and_horizontal_models(self, capsys): of = [ - _of(1, "7-iron", 120, "2026-05-06T10:00:00", - launch_angle_vertical=10.0, launch_angle_horizontal=-2.0), - _of(2, "7-iron", 121, "2026-05-06T10:01:00", - launch_angle_vertical=12.0, launch_angle_horizontal=0.0), - _of(3, "7-iron", 122, "2026-05-06T10:02:00", - launch_angle_vertical=14.0, launch_angle_horizontal=2.0), + _of( + 1, + "7-iron", + 120, + "2026-05-06T10:00:00", + launch_angle_vertical=10.0, + launch_angle_horizontal=-2.0, + ), + _of( + 2, + "7-iron", + 121, + "2026-05-06T10:01:00", + launch_angle_vertical=12.0, + launch_angle_horizontal=0.0, + ), + _of( + 3, + "7-iron", + 122, + "2026-05-06T10:02:00", + launch_angle_vertical=14.0, + launch_angle_horizontal=2.0, + ), ] tm = [ - _tm(1, "7-iron", 120, "2026-05-06T10:00:01", - launch_angle_vertical=15.0, launch_angle_horizontal=-1.0), - _tm(2, "7-iron", 121, "2026-05-06T10:01:01", - launch_angle_vertical=17.0, launch_angle_horizontal=1.0), - _tm(3, "7-iron", 122, "2026-05-06T10:02:01", - launch_angle_vertical=19.0, launch_angle_horizontal=3.0), + _tm( + 1, + "7-iron", + 120, + "2026-05-06T10:00:01", + launch_angle_vertical=15.0, + launch_angle_horizontal=-1.0, + ), + _tm( + 2, + "7-iron", + 121, + "2026-05-06T10:01:01", + launch_angle_vertical=17.0, + launch_angle_horizontal=1.0, + ), + _tm( + 3, + "7-iron", + 122, + "2026-05-06T10:02:01", + launch_angle_vertical=19.0, + launch_angle_horizontal=3.0, + ), ] pairs = ct.pair_shots(of, tm) ct.print_launch_angle_calibration(pairs) @@ -399,10 +523,8 @@ def test_calibration_emits_vertical_and_horizontal_models(self, capsys): def test_calibration_handles_too_few_pairs(self, capsys): pairs = ct.pair_shots( - [_of(1, "driver", 150, "2026-05-06T10:00:00", - launch_angle_vertical=10.0)], - [_tm(1, "driver", 150, "2026-05-06T10:00:01", - launch_angle_vertical=11.0)], + [_of(1, "driver", 150, "2026-05-06T10:00:00", launch_angle_vertical=10.0)], + [_tm(1, "driver", 150, "2026-05-06T10:00:01", launch_angle_vertical=11.0)], ) ct.print_launch_angle_calibration(pairs) out = capsys.readouterr().out @@ -415,45 +537,82 @@ def test_full_pipeline(self, tmp_path, capsys): tm_path = tmp_path / "trackman.csv" out_path = tmp_path / "comparison.csv" - _write_openflight_jsonl(of_path, [ - {"timestamp": "2026-05-06T10:00:00", - "shot_number": 1, "club": "7-iron", - "ball_speed_mph": 120.0, "club_speed_mph": 85.0, - "launch_angle_vertical": 18.0, - "launch_angle_horizontal": 0.5, - "spin_rpm": 6500.0, - "estimated_carry_yards": 160.0}, - {"timestamp": "2026-05-06T10:01:00", - "shot_number": 2, "club": "driver", - "ball_speed_mph": 165.0, "club_speed_mph": 110.0, - "launch_angle_vertical": 12.0, - "launch_angle_horizontal": -1.0, - "spin_rpm": 2800.0, - "estimated_carry_yards": 240.0}, - ]) + _write_openflight_jsonl( + of_path, + [ + { + "timestamp": "2026-05-06T10:00:00", + "shot_number": 1, + "club": "7-iron", + "ball_speed_mph": 120.0, + "club_speed_mph": 85.0, + "launch_angle_vertical": 18.0, + "launch_angle_horizontal": 0.5, + "spin_rpm": 6500.0, + "estimated_carry_yards": 160.0, + }, + { + "timestamp": "2026-05-06T10:01:00", + "shot_number": 2, + "club": "driver", + "ball_speed_mph": 165.0, + "club_speed_mph": 110.0, + "launch_angle_vertical": 12.0, + "launch_angle_horizontal": -1.0, + "spin_rpm": 2800.0, + "estimated_carry_yards": 240.0, + }, + ], + ) _write_trackman_csv( tm_path, - ["Shot Number", "Date/Time", "Club", - "Ball Speed (mph)", "Club Speed (mph)", - "Launch Angle", "Launch Direction", - "Spin Rate", "Carry"], - [{"Shot Number": "1", "Date/Time": "2026-05-06 10:00:01", - "Club": "7-iron", "Ball Speed (mph)": "121.0", - "Club Speed (mph)": "85.5", "Launch Angle": "17.8", - "Launch Direction": "0.7", "Spin Rate": "6600", - "Carry": "163.0"}, - {"Shot Number": "2", "Date/Time": "2026-05-06 10:01:01", - "Club": "Driver", "Ball Speed (mph)": "166.0", - "Club Speed (mph)": "110.5", "Launch Angle": "11.5", - "Launch Direction": "-0.8", "Spin Rate": "2750", - "Carry": "242.0"}], + [ + "Shot Number", + "Date/Time", + "Club", + "Ball Speed (mph)", + "Club Speed (mph)", + "Launch Angle", + "Launch Direction", + "Spin Rate", + "Carry", + ], + [ + { + "Shot Number": "1", + "Date/Time": "2026-05-06 10:00:01", + "Club": "7-iron", + "Ball Speed (mph)": "121.0", + "Club Speed (mph)": "85.5", + "Launch Angle": "17.8", + "Launch Direction": "0.7", + "Spin Rate": "6600", + "Carry": "163.0", + }, + { + "Shot Number": "2", + "Date/Time": "2026-05-06 10:01:01", + "Club": "Driver", + "Ball Speed (mph)": "166.0", + "Club Speed (mph)": "110.5", + "Launch Angle": "11.5", + "Launch Direction": "-0.8", + "Spin Rate": "2750", + "Carry": "242.0", + }, + ], ) - rc = ct.main([ - "--openflight", str(of_path), - "--trackman", str(tm_path), - "--output", str(out_path), - ]) + rc = ct.main( + [ + "--openflight", + str(of_path), + "--trackman", + str(tm_path), + "--output", + str(out_path), + ] + ) assert rc == 0 assert out_path.exists() diff --git a/tests/test_rolling_buffer.py b/tests/test_rolling_buffer.py index c003df3b9..78caf57e8 100644 --- a/tests/test_rolling_buffer.py +++ b/tests/test_rolling_buffer.py @@ -2989,3 +2989,61 @@ def test_range_falloff_decay_does_not_fake_driver_spin(self): assert result.spin_rpm == 0 or result.quality == "low", ( f"Decay ramp faked spin: {result.spin_rpm} RPM quality={result.quality}" ) + + +class TestProcessorCaptureMidpointFallback: + def test_fallback_derived_from_sample_rate_and_buffer_length(self, monkeypatch): + """When overlapping timeline lacks outbound readings, midpoint is derived + dynamically from sample_rate and capture length.""" + processor_30k = RollingBufferProcessor(sample_rate=30000) + capture = IQCapture( + sample_time=0.0, + trigger_time=0.068, + i_samples=[2048] * 4096, + q_samples=[2048] * 4096, + ) + standard_res = SpeedTimeline( + readings=[ + SpeedReading( + speed_mph=100.0, direction="outbound", magnitude=1000.0, timestamp_ms=0.0 + ) + ], + sample_rate_hz=56.0, + ) + empty_timeline = SpeedTimeline( + readings=[ + SpeedReading(speed_mph=50.0, direction="inbound", magnitude=100.0, timestamp_ms=0.0) + ], + sample_rate_hz=937.0, + ) + monkeypatch.setattr(processor_30k, "process_standard", lambda c: standard_res) + monkeypatch.setattr(processor_30k, "_find_consistent_ball_speed", lambda r: 100.0) + monkeypatch.setattr(processor_30k, "process_overlapping", lambda c: empty_timeline) + monkeypatch.setattr(processor_30k, "find_club_speed", lambda tl, bs, bt, **kw: (None, None)) + monkeypatch.setattr( + processor_30k, + "estimate_impact", + lambda *a, **kw: ImpactEstimate(timestamp_ms=0.0, source="fallback"), + ) + monkeypatch.setattr(processor_30k, "detect_spin", lambda *a, **kw: SpinResult()) + + result_30k = processor_30k.process_capture(capture) + assert result_30k is not None + assert result_30k.ball_timestamp_ms == pytest.approx((4096 / 30000) * 500.0) + + # Verify sample_rate = 20000 derives midpoint appropriately (102.4 ms) + processor_20k = RollingBufferProcessor(sample_rate=20000) + monkeypatch.setattr(processor_20k, "process_standard", lambda c: standard_res) + monkeypatch.setattr(processor_20k, "_find_consistent_ball_speed", lambda r: 100.0) + monkeypatch.setattr(processor_20k, "process_overlapping", lambda c: empty_timeline) + monkeypatch.setattr(processor_20k, "find_club_speed", lambda tl, bs, bt, **kw: (None, None)) + monkeypatch.setattr( + processor_20k, + "estimate_impact", + lambda *a, **kw: ImpactEstimate(timestamp_ms=0.0, source="fallback"), + ) + monkeypatch.setattr(processor_20k, "detect_spin", lambda *a, **kw: SpinResult()) + + result_20k = processor_20k.process_capture(capture) + assert result_20k is not None + assert result_20k.ball_timestamp_ms == pytest.approx((4096 / 20000) * 500.0) diff --git a/tests/test_serial_latency.py b/tests/test_serial_latency.py index cd19232ab..9bad0235a 100644 --- a/tests/test_serial_latency.py +++ b/tests/test_serial_latency.py @@ -1,10 +1,14 @@ """Tests for USB serial latency timer diagnostics.""" +import os from pathlib import Path +import pytest + from openflight.serial_latency import read_usb_serial_latency_timer +@pytest.mark.skipif(os.name == "nt", reason="symlink creation requires elevation on Windows") def test_read_usb_serial_latency_timer_resolves_udev_alias(tmp_path: Path): dev_root = tmp_path / "dev" sysfs_root = tmp_path / "sys" / "bus" / "usb-serial" / "devices" diff --git a/tests/test_server.py b/tests/test_server.py index 703b9145b..157e69663 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -860,7 +860,9 @@ def test_set_player_updates_future_swing_speed_payloads(self, monkeypatch): """Selected UI player should be stamped on subsequent swing speed reps.""" emitted = [] monkeypatch.setattr(server_module, "current_player_name", "Player 1") - monkeypatch.setattr(server_module.socketio, "emit", lambda *args, **kwargs: emitted.append(args)) + monkeypatch.setattr( + server_module.socketio, "emit", lambda *args, **kwargs: emitted.append(args) + ) server_module.handle_set_player({"player_name": "David"}) event = SwingSpeedEvent( @@ -1012,7 +1014,10 @@ def test_mock_swing_speed_stamps_training_implement(self): """Mock reps should use the selected training implement metadata.""" monitor = MockSwingSpeedMonitor() - assert server_module.TRAINING_IMPLEMENT_LABELS["rypstick-3w-cw"] == "Rypstick 3 Weights + Counterweight" + assert ( + server_module.TRAINING_IMPLEMENT_LABELS["rypstick-3w-cw"] + == "Rypstick 3 Weights + Counterweight" + ) monitor.set_training_implement("rypstick-3w-cw", "Rypstick 3 Weights + Counterweight") event = monitor.simulate_shot(peak_speed=95.0) @@ -1084,7 +1089,6 @@ class StubSwingSpeedMonitor: assert server_module.monitor.max_speed_mph == 115.0 assert emitted[-1] == ("radar_config", {"min_speed": 55, "max_speed": 115}) - def test_set_radar_config_forwards_zero_max_speed_to_clear_the_filter(self, monkeypatch): """max_speed 0 must still reach the radar on the default launch path. @@ -2599,9 +2603,7 @@ def _run_with_no_radar_hardware(self, monkeypatch, shot): on_shot_detected(shot) def test_spin_axis_emitted_when_horizontal_confidence_clears_gate(self, monkeypatch): - shot = self._spin_axis_shot( - horizontal_confidence=server_module.SPIN_AXIS_MIN_CONFIDENCE - ) + shot = self._spin_axis_shot(horizontal_confidence=server_module.SPIN_AXIS_MIN_CONFIDENCE) self._run_with_no_radar_hardware(monkeypatch, shot) @@ -2839,9 +2841,7 @@ class TestClubPathOwnershipGuard: existing --iwr6843/--kld7 (vertical) guard.""" def test_iwr6843_and_kld7_horizontal_cannot_both_own_club_path(self, monkeypatch, capsys): - monkeypatch.setattr( - sys, "argv", ["openflight-server", "--iwr6843", "--kld7-horizontal"] - ) + monkeypatch.setattr(sys, "argv", ["openflight-server", "--iwr6843", "--kld7-horizontal"]) with pytest.raises(SystemExit) as exc_info: server_module.main() @@ -2902,3 +2902,34 @@ 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 TestFireCloudPush: + def test_logs_debug_on_exception(self, monkeypatch, caplog): + import logging + + from openflight.server import _fire_cloud_push + + def _bad_load(): + raise RuntimeError("disk corrupted") + + monkeypatch.setattr("openflight.cloud.config.load_config", _bad_load) + with caplog.at_level(logging.DEBUG): + _fire_cloud_push(None) + + assert any( + "Cloud push trigger failed" in record.message and record.levelno == logging.DEBUG + for record in caplog.records + ) + + +class TestKld7AngleLimitConstant: + def test_default_horizontal_angle_limit_constant(self): + from openflight.kld7.radc import DEFAULT_RADC_HORIZONTAL_ANGLE_LIMIT_DEG + from openflight.server import _DEFAULT_KLD7_RADC_TUNING + + assert DEFAULT_RADC_HORIZONTAL_ANGLE_LIMIT_DEG == 15.0 + assert ( + _DEFAULT_KLD7_RADC_TUNING["radc_horizontal_angle_limit_deg"] + == DEFAULT_RADC_HORIZONTAL_ANGLE_LIMIT_DEG + ) diff --git a/tests/test_session_logger.py b/tests/test_session_logger.py index 4a2e18112..92ae42bde 100644 --- a/tests/test_session_logger.py +++ b/tests/test_session_logger.py @@ -748,3 +748,17 @@ def close(self): assert events[0] == "write" assert "close" in events assert events.index("close") == len(events) - 1 + + +class TestKld7ImportWarning: + def test_import_session_logger_does_not_emit_deprecation_warning(self): + import importlib + import warnings + + with warnings.catch_warnings(record=True) as recorded: + warnings.simplefilter("always") + import openflight.session_logger + + importlib.reload(openflight.session_logger) + dep_warnings = [w for w in recorded if issubclass(w.category, DeprecationWarning)] + assert not dep_warnings diff --git a/tests/test_sim_transport.py b/tests/test_sim_transport.py index d452c1221..6e8f152bf 100644 --- a/tests/test_sim_transport.py +++ b/tests/test_sim_transport.py @@ -3,19 +3,22 @@ Exercises TcpSimClient through a real codec (GSProCodec) against the mock sim server, plus framing unit tests for the brace-balanced JSON framer. """ + import json import time -from typing import List, Optional +from typing import Optional import pytest from openflight.gspro.codec import GSProCodec -from openflight.sim.transport import find_json_end, TcpSimClient +from openflight.launch_monitor import ClubType +from openflight.sim.transport import TcpSimClient, find_json_end from openflight.sim.types import ( - ConnectionState, PlayerUpdate, ResolvedShot, ShotAck, + ConnectionState, + PlayerUpdate, + ResolvedShot, + ShotAck, ) -from openflight.launch_monitor import ClubType - # --- framing unit tests ------------------------------------------------------ @@ -52,7 +55,7 @@ def test_nested_objects(): def test_empty_buffer_returns_none(): - assert find_json_end(b'') is None + assert find_json_end(b"") is None def test_leading_whitespace_before_object(): @@ -69,6 +72,7 @@ def test_non_ascii_inside_string(): class _NoHeartbeatCodec: """Minimal codec whose protocol has no keepalive (no heartbeat thread).""" + name = "noheartbeat" def build_shot(self, resolved) -> bytes: @@ -102,10 +106,19 @@ def _wait_for_state(client, state, deadline=3.0): def _resolved() -> ResolvedShot: return ResolvedShot( - shot_number=7, ball_speed_mph=140.0, vla=12.0, hla=0.0, - total_spin_rpm=2500.0, spin_axis_deg=0.0, back_spin_rpm=2500.0, - side_spin_rpm=0.0, carry_yards=255.0, club_path_deg=0.0, - club=ClubType.DRIVER, club_speed_mph=None, provenance={}, + shot_number=7, + ball_speed_mph=140.0, + vla=12.0, + hla=0.0, + total_spin_rpm=2500.0, + spin_axis_deg=0.0, + back_spin_rpm=2500.0, + side_spin_rpm=0.0, + carry_yards=255.0, + club_path_deg=0.0, + club=ClubType.DRIVER, + club_speed_mph=None, + provenance={}, ) @@ -250,20 +263,57 @@ def test_reconnect_after_server_drop(mock_sim): client.stop() -def test_backoff_progression_capped(): - client = TcpSimClient("127.0.0.1", 1, GSProCodec(), heartbeat_interval_s=60, - backoff_seconds=(0.05, 0.1, 0.1)) +def test_backoff_progression_capped(monkeypatch): + # Refuse instantly instead of dialing a real closed port: how fast the OS + # rejects a connect to 127.0.0.1:1 is platform-dependent (slow enough on + # Windows that a fixed sleep captured fewer than two retries), and the + # backoff schedule under test doesn't need a real socket at all. + from openflight.sim import transport as transport_mod + + class _InstantRefusalSocket: + def __init__(self, *args, **kwargs): + pass + + def settimeout(self, _timeout): + pass + + def connect(self, _addr): + raise ConnectionRefusedError("refused (test)") + + def close(self): + pass + + monkeypatch.setattr(transport_mod.socket, "socket", _InstantRefusalSocket) + + client = TcpSimClient( + "127.0.0.1", 1, GSProCodec(), heartbeat_interval_s=60, backoff_seconds=(0.05, 0.1, 0.1) + ) statuses = [] client.on_status = statuses.append client.start() - time.sleep(0.5) - client.stop() + try: + # Poll for the retries instead of sleeping a fixed interval. + deadline = time.time() + 3.0 + while time.time() < deadline: + backoffs = [ + s.next_retry_in_s + for s in statuses + if s.state == ConnectionState.CONNECTING and s.next_retry_in_s > 0 + ] + if len(backoffs) >= 3: + break + time.sleep(0.02) + finally: + client.stop() # Before the first successful connection the client reports CONNECTING during # the retry backoff (RECONNECT_BACKOFF is reserved for a connection that was # established and then dropped). The backoff schedule is still carried on # next_retry_in_s, so assert on the CONNECTING retries here. - backoffs = [s.next_retry_in_s for s in statuses - if s.state == ConnectionState.CONNECTING and s.next_retry_in_s > 0] + backoffs = [ + s.next_retry_in_s + for s in statuses + if s.state == ConnectionState.CONNECTING and s.next_retry_in_s > 0 + ] assert len(backoffs) >= 2 assert max(backoffs) <= 0.1