diff --git a/.github/workflows/pylint.yml b/.github/workflows/pylint.yml index 54bfdd8a7..c053d85e2 100644 --- a/.github/workflows/pylint.yml +++ b/.github/workflows/pylint.yml @@ -1,4 +1,4 @@ -name: Pylint +name: Lint on: push: @@ -7,22 +7,24 @@ on: pull_request: jobs: - pylint: + lint: runs-on: ubuntu-latest strategy: matrix: python-version: ["3.11"] steps: - uses: actions/checkout@v7 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v6 + - name: Install uv + uses: astral-sh/setup-uv@v5 with: - python-version: ${{ matrix.python-version }} + enable-cache: true + - name: Set up Python ${{ matrix.python-version }} + run: uv python install ${{ matrix.python-version }} - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install pylint pyserial flask flask-socketio flask-cors numpy opencv-python supervision - pip install trackers@git+https://github.com/roboflow/trackers.git + run: uv sync --group dev --python ${{ matrix.python-version }} + - name: Ruff Check + run: uv run --python ${{ matrix.python-version }} ruff check src/openflight/ tests/ + - name: Ruff Format Check + run: uv run --python ${{ matrix.python-version }} ruff format --check src/openflight/ tests/ - name: Analysing the code with pylint - run: | - pylint src/openflight/ --fail-under=9 + run: uv run --python ${{ matrix.python-version }} pylint src/openflight/ --fail-under=9 diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index fb9d61a5a..83a0d974f 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -14,16 +14,13 @@ jobs: python-version: ["3.10", "3.11", "3.12"] steps: - uses: actions/checkout@v7 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v6 + - name: Install uv + uses: astral-sh/setup-uv@v5 with: - python-version: ${{ matrix.python-version }} + enable-cache: true + - name: Set up Python ${{ matrix.python-version }} + run: uv python install ${{ matrix.python-version }} - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install pytest pyserial flask flask-socketio flask-cors numpy opencv-python supervision - pip install trackers@git+https://github.com/roboflow/trackers.git - pip install -e . + run: uv sync --group dev --python ${{ matrix.python-version }} - name: Run tests - run: | - pytest tests/ -v --tb=short + run: uv run --python ${{ matrix.python-version }} pytest tests/ -v --tb=short diff --git a/.github/workflows/ui-build.yml b/.github/workflows/ui-build.yml index 301b9f514..4f33c1c84 100644 --- a/.github/workflows/ui-build.yml +++ b/.github/workflows/ui-build.yml @@ -59,18 +59,35 @@ jobs: run: npm run format:check working-directory: ui + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version-file: ".node-version" + cache: "npm" + cache-dependency-path: ui/package-lock.json + - name: Install dependencies + run: npm ci + working-directory: ui + - name: Run unit tests + run: npm run test + working-directory: ui + e2e: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - - name: Set up Python - uses: actions/setup-python@v6 + - name: Install uv + uses: astral-sh/setup-uv@v5 with: - python-version: "3.12" + enable-cache: true + - name: Set up Python + run: uv python install 3.12 - name: Install backend dependencies - run: | - python -m pip install --upgrade pip - pip install -e . + run: uv sync --python 3.12 - name: Set up Node.js uses: actions/setup-node@v6 with: 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/Makefile b/Makefile index 604317fff..914567713 100644 --- a/Makefile +++ b/Makefile @@ -1,19 +1,23 @@ -.PHONY: test lint format dev build-ui start +.PHONY: test test-ui lint format dev build-ui start ## Run Python tests test: uv run pytest tests/ -v +## Run UI unit tests +test-ui: + cd ui && npm run test + ## Run all linters (Python + UI) lint: - uv run ruff check src/openflight/ + uv run ruff check src/openflight/ tests/ uv run pylint src/openflight/ --fail-under=9 cd ui && npm run lint ## Auto-format Python code format: - uv run ruff format src/openflight/ - uv run ruff check --fix src/openflight/ + uv run ruff format src/openflight/ tests/ + uv run ruff check --fix src/openflight/ tests/ ## Start server in mock mode (no hardware needed) dev: diff --git a/pyproject.toml b/pyproject.toml index 3047d0720..a5fe052d7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -89,7 +89,7 @@ dev = [ "pylint>=3.3.9", "pre-commit>=4.0.0", "pytest>=9.0.3", - "ruff>=0.1.0", + "ruff>=0.9.10", ] [tool.ruff] @@ -127,3 +127,4 @@ max-line-length = 100 [tool.pytest.ini_options] testpaths = ["tests"] python_files = ["test_*.py"] +addopts = "--basetemp=.pytest_temp" diff --git a/src/openflight/ballistics.py b/src/openflight/ballistics.py index 85a53ed2c..6ad00656a 100644 --- a/src/openflight/ballistics.py +++ b/src/openflight/ballistics.py @@ -22,6 +22,7 @@ from dataclasses import dataclass from typing import Literal, Optional +from .club_data import CLUB_TYPICAL_SPIN_RPM from .launch_monitor import SPIN_CONFIDENCE_HIGH, ClubType, Shot MPH_TO_MPS = 0.44704 @@ -33,7 +34,7 @@ # the rules rather than by a guess at the specific ball in play. BALL_MASS_KG = 0.04593 BALL_RADIUS_M = 0.02135 -BALL_AREA_M2 = math.pi * BALL_RADIUS_M ** 2 +BALL_AREA_M2 = math.pi * BALL_RADIUS_M**2 AIR_DENSITY_STD = 1.225 # kg/m³ at sea level, 15 °C ISA # Cd = CD_BASE + CD_SPIN_COEFF * Sp @@ -65,32 +66,6 @@ # keeping payload size reasonable for UI/log consumers. SAMPLE_INTERVAL_S = 0.05 -# Club-typical spin (RPM) from TrackMan PGA Tour averages. -# Used as fallback when measured spin is missing or low-confidence. -CLUB_TYPICAL_SPIN_RPM: dict[ClubType, float] = { - ClubType.DRIVER: 2700, - ClubType.WOOD_3: 3500, - ClubType.WOOD_5: 4200, - ClubType.WOOD_7: 4800, - ClubType.HYBRID_3: 4400, - ClubType.HYBRID_5: 4900, - ClubType.HYBRID_7: 5300, - ClubType.HYBRID_9: 5800, - ClubType.IRON_2: 4000, - ClubType.IRON_3: 4500, - ClubType.IRON_4: 5000, - ClubType.IRON_5: 5400, - ClubType.IRON_6: 6000, - ClubType.IRON_7: 6500, - ClubType.IRON_8: 7500, - ClubType.IRON_9: 8500, - ClubType.PW: 9000, - ClubType.GW: 9500, - ClubType.SW: 10000, - ClubType.LW: 10500, - ClubType.UNKNOWN: 5000, -} - @dataclass class LaunchConditions: @@ -156,9 +131,7 @@ def resolve_launch(shot: Shot) -> Optional[LaunchConditions]: spin_rpm = float(shot.spin_rpm) source: Literal["measured", "club_typical"] = "measured" else: - spin_rpm = CLUB_TYPICAL_SPIN_RPM.get( - shot.club, CLUB_TYPICAL_SPIN_RPM[ClubType.UNKNOWN] - ) + spin_rpm = CLUB_TYPICAL_SPIN_RPM.get(shot.club, CLUB_TYPICAL_SPIN_RPM[ClubType.UNKNOWN]) source = "club_typical" return LaunchConditions( @@ -244,10 +217,7 @@ def _rk4_step( k3 = _derivatives(s3, omega, axis, air_density) s4 = tuple(state[i] + dt * k3[i] for i in range(6)) k4 = _derivatives(s4, omega, axis, air_density) - return tuple( - state[i] + (dt / 6.0) * (k1[i] + 2 * k2[i] + 2 * k3[i] + k4[i]) - for i in range(6) - ) + return tuple(state[i] + (dt / 6.0) * (k1[i] + 2 * k2[i] + 2 * k3[i] + k4[i]) for i in range(6)) def simulate( @@ -311,15 +281,17 @@ def simulate( final = tuple(state[i] + frac * (new_state[i] - state[i]) for i in range(6)) fx, fy, fz, fvx, fvy, fvz = final v_final = math.sqrt(fvx * fvx + fvy * fvy + fvz * fvz) - landing_angle = math.degrees( - math.atan2(-fvz, math.sqrt(fvx * fvx + fvy * fvy)) + landing_angle = math.degrees(math.atan2(-fvz, math.sqrt(fvx * fvx + fvy * fvy))) + points.append( + TrajectoryPoint( + t_hit, + fx * M_TO_YD, + fy * M_TO_YD, + max(fz, 0.0) * M_TO_YD, + v_final * MPS_TO_MPH, + omega * 60 / (2 * math.pi), + ) ) - points.append(TrajectoryPoint( - t_hit, - fx * M_TO_YD, fy * M_TO_YD, max(fz, 0.0) * M_TO_YD, - v_final * MPS_TO_MPH, - omega * 60 / (2 * math.pi), - )) return Trajectory( points=points, carry_yards=fx * M_TO_YD, @@ -335,12 +307,16 @@ def simulate( if t - last_sample_t >= SAMPLE_INTERVAL_S: sx_, sy_, sz_, svx, svy, svz = state v = math.sqrt(svx * svx + svy * svy + svz * svz) - points.append(TrajectoryPoint( - t, - sx_ * M_TO_YD, sy_ * M_TO_YD, sz_ * M_TO_YD, - v * MPS_TO_MPH, - omega * 60 / (2 * math.pi), - )) + points.append( + TrajectoryPoint( + t, + sx_ * M_TO_YD, + sy_ * M_TO_YD, + sz_ * M_TO_YD, + v * MPS_TO_MPH, + omega * 60 / (2 * math.pi), + ) + ) last_sample_t = t # Flight did not terminate — return current state as best-effort diff --git a/src/openflight/camera_tracker.py b/src/openflight/camera_tracker.py index e7db8f99e..6de9350cc 100644 --- a/src/openflight/camera_tracker.py +++ b/src/openflight/camera_tracker.py @@ -16,6 +16,7 @@ try: import cv2 import numpy as np + CV2_AVAILABLE = True except ImportError: CV2_AVAILABLE = False @@ -23,18 +24,21 @@ try: import supervision as sv from trackers import ByteTrackTracker + BYTETRACK_AVAILABLE = True except ImportError: BYTETRACK_AVAILABLE = False try: from ultralytics import YOLO + YOLO_AVAILABLE = True except ImportError: YOLO_AVAILABLE = False try: from inference_sdk import InferenceHTTPClient + ROBOFLOW_AVAILABLE = True except ImportError: ROBOFLOW_AVAILABLE = False @@ -43,6 +47,7 @@ @dataclass class BallPosition: """A detected ball position in a frame.""" + x: int y: int radius: int @@ -54,6 +59,7 @@ class BallPosition: @dataclass class LaunchAngle: """Calculated launch angle from ball trajectory.""" + vertical: float # degrees, positive = up horizontal: float # degrees, positive = right of target confidence: float # 0-1 @@ -69,7 +75,7 @@ def __init__( max_radius: int = 43, param1: int = 48, param2: int = 33, - min_dist: int = 266 + min_dist: int = 266, ): self.min_radius = min_radius self.max_radius = max_radius @@ -94,7 +100,7 @@ def detect(self, frame: np.ndarray) -> List[dict]: param1=self.param1, param2=self.param2, minRadius=self.min_radius, - maxRadius=self.max_radius + maxRadius=self.max_radius, ) detections = [] @@ -102,12 +108,9 @@ def detect(self, frame: np.ndarray) -> List[dict]: circles = np.uint16(np.around(circles)) for circle in circles[0, :]: x, y, r = circle - detections.append({ - 'x': float(x), - 'y': float(y), - 'radius': float(r), - 'confidence': 0.8 - }) + detections.append( + {"x": float(x), "y": float(y), "radius": float(r), "confidence": 0.8} + ) return detections @@ -226,12 +229,12 @@ def process_frame(self, frame: np.ndarray) -> Optional[BallPosition]: return None position = BallPosition( - x=int(best['x']), - y=int(best['y']), - radius=int(best['radius']), - confidence=best['confidence'], + x=int(best["x"]), + y=int(best["y"]), + radius=int(best["radius"]), + confidence=best["confidence"], timestamp=now, - track_id=best.get('track_id') + track_id=best.get("track_id"), ) self.positions.append(position) @@ -243,15 +246,21 @@ def process_frame(self, frame: np.ndarray) -> Optional[BallPosition]: def _apply_tracking(self, detections: List[dict]) -> Optional[dict]: """Apply ByteTrack to detections, or pick best detection if unavailable.""" if self.tracker and BYTETRACK_AVAILABLE: - xyxy = np.array([ - [d['x'] - d['radius'], d['y'] - d['radius'], - d['x'] + d['radius'], d['y'] + d['radius']] - for d in detections - ]) + xyxy = np.array( + [ + [ + d["x"] - d["radius"], + d["y"] - d["radius"], + d["x"] + d["radius"], + d["y"] + d["radius"], + ] + for d in detections + ] + ) sv_detections = sv.Detections( xyxy=xyxy, - confidence=np.array([d['confidence'] for d in detections]), - class_id=np.zeros(len(detections), dtype=int) + confidence=np.array([d["confidence"] for d in detections]), + class_id=np.zeros(len(detections), dtype=int), ) tracked = self.tracker.update(sv_detections) @@ -260,16 +269,16 @@ def _apply_tracking(self, detections: List[dict]) -> Optional[dict]: bbox = tracked.xyxy[0] return { - 'x': (bbox[0] + bbox[2]) / 2, - 'y': (bbox[1] + bbox[3]) / 2, - 'radius': (bbox[2] - bbox[0]) / 2, - 'confidence': tracked.confidence[0] if tracked.confidence is not None else 0.8, - 'track_id': int(tracked.tracker_id[0]) if tracked.tracker_id is not None else 0, + "x": (bbox[0] + bbox[2]) / 2, + "y": (bbox[1] + bbox[3]) / 2, + "radius": (bbox[2] - bbox[0]) / 2, + "confidence": tracked.confidence[0] if tracked.confidence is not None else 0.8, + "track_id": int(tracked.tracker_id[0]) if tracked.tracker_id is not None else 0, } # No tracking - use highest confidence detection - best = max(detections, key=lambda d: d['confidence']) - best['track_id'] = None + best = max(detections, key=lambda d: d["confidence"]) + best["track_id"] = None return best def _check_launch(self, current: BallPosition): @@ -284,7 +293,7 @@ def _check_launch(self, current: BallPosition): dx = current.x - prev.x dy = prev.y - current.y # Invert Y (image coords are top-down) - velocity = math.sqrt(dx*dx + dy*dy) / dt + velocity = math.sqrt(dx * dx + dy * dy) / dt if velocity > self.launch_velocity_threshold and not self.launch_detected: self.launch_detected = True @@ -305,14 +314,16 @@ def _detect_yolo(self, frame: np.ndarray) -> List[dict]: cls = int(box.cls[0]) class_name = self.model.names[cls] - if cls == 32 or 'ball' in class_name.lower() or 'golf' in class_name.lower(): + if cls == 32 or "ball" in class_name.lower() or "golf" in class_name.lower(): x1, y1, x2, y2 = box.xyxy[0].tolist() - detections.append({ - 'x': (x1 + x2) / 2, - 'y': (y1 + y2) / 2, - 'radius': (x2 - x1 + y2 - y1) / 4, - 'confidence': float(box.conf[0]) - }) + detections.append( + { + "x": (x1 + x2) / 2, + "y": (y1 + y2) / 2, + "radius": (x2 - x1 + y2 - y1) / 4, + "confidence": float(box.conf[0]), + } + ) return detections @@ -322,18 +333,18 @@ def _detect_roboflow(self, frame: np.ndarray) -> List[dict]: return [] try: - _, buffer = cv2.imencode('.jpg', frame) + _, buffer = cv2.imencode(".jpg", frame) result = self.roboflow_client.infer(buffer.tobytes(), model_id=self.roboflow_model_id) return [ { - 'x': pred.get('x', 0), - 'y': pred.get('y', 0), - 'radius': (pred.get('width', 0) + pred.get('height', 0)) / 4, - 'confidence': pred.get('confidence', 0) + "x": pred.get("x", 0), + "y": pred.get("y", 0), + "radius": (pred.get("width", 0) + pred.get("height", 0)) / 4, + "confidence": pred.get("confidence", 0), } - for pred in result.get('predictions', []) - if pred.get('confidence', 0) >= 0.3 + for pred in result.get("predictions", []) + if pred.get("confidence", 0) >= 0.3 ] except Exception as e: print(f"Roboflow detection error: {e}") @@ -388,7 +399,7 @@ def calculate_launch_angle(self) -> Optional[LaunchAngle]: vertical=round(vertical, 1), horizontal=round(horizontal, 1), confidence=round(confidence, 2), - positions=positions.copy() + positions=positions.copy(), ) def _reset_tracking_state(self): @@ -409,8 +420,12 @@ def get_debug_frame(self, frame: np.ndarray) -> np.ndarray: display = frame.copy() colors = [ - (255, 0, 0), (0, 255, 0), (0, 0, 255), - (255, 255, 0), (255, 0, 255), (0, 255, 255) + (255, 0, 0), + (0, 255, 0), + (0, 0, 255), + (255, 255, 0), + (255, 0, 255), + (0, 255, 255), ] for i, pos in enumerate(self.positions): @@ -418,30 +433,55 @@ def get_debug_frame(self, frame: np.ndarray) -> np.ndarray: color = colors[pos.track_id % len(colors)] else: t = i / max(1, len(self.positions) - 1) - color = (int(255 * (1-t)), int(255 * t), 0) + color = (int(255 * (1 - t)), int(255 * t), 0) cv2.circle(display, (pos.x, pos.y), pos.radius, color, 2) cv2.circle(display, (pos.x, pos.y), 3, color, -1) if pos.track_id is not None: - cv2.putText(display, f"ID:{pos.track_id}", (pos.x + 5, pos.y - 5), - cv2.FONT_HERSHEY_SIMPLEX, 0.4, color, 1) + cv2.putText( + display, + f"ID:{pos.track_id}", + (pos.x + 5, pos.y - 5), + cv2.FONT_HERSHEY_SIMPLEX, + 0.4, + color, + 1, + ) # Draw trajectory line if len(self.positions) >= 2: points = [(p.x, p.y) for p in self.positions] for i in range(len(points) - 1): - cv2.line(display, points[i], points[i+1], (0, 255, 255), 2) + cv2.line(display, points[i], points[i + 1], (0, 255, 255), 2) # Show launch angle if calculated angle = self.calculate_launch_angle() if angle: - cv2.putText(display, f"Launch: {angle.vertical:.1f} V, {angle.horizontal:.1f} H", - (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 0), 2) - cv2.putText(display, f"Confidence: {angle.confidence:.0%}", - (10, 55), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 1) + cv2.putText( + display, + f"Launch: {angle.vertical:.1f} V, {angle.horizontal:.1f} H", + (10, 30), + cv2.FONT_HERSHEY_SIMPLEX, + 0.7, + (0, 255, 0), + 2, + ) + cv2.putText( + display, + f"Confidence: {angle.confidence:.0%}", + (10, 55), + cv2.FONT_HERSHEY_SIMPLEX, + 0.5, + (0, 255, 0), + 1, + ) - status = "LAUNCH DETECTED" if self.launch_detected else f"Tracking: {len(self.positions)} positions" + status = ( + "LAUNCH DETECTED" + if self.launch_detected + else f"Tracking: {len(self.positions)} positions" + ) cv2.putText(display, status, (10, 80), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (255, 255, 0), 2) return display diff --git a/src/openflight/club_data.py b/src/openflight/club_data.py new file mode 100644 index 000000000..c5c4c2465 --- /dev/null +++ b/src/openflight/club_data.py @@ -0,0 +1,410 @@ +"""Golf club physics profiles and canonical reference data. + +Consolidates TrackMan-derived and empirical club physics data (launch angles, +smash factors, spin models, and speed profiles) into a single source of truth +to prevent numerical divergence across modules. +""" + +from dataclasses import dataclass +from enum import Enum +from typing import Dict, Optional, Tuple + + +class ClubType(Enum): + """Golf club types for distance estimation and physics simulation.""" + + DRIVER = "driver" + WOOD_3 = "3-wood" + WOOD_5 = "5-wood" + WOOD_7 = "7-wood" + HYBRID_3 = "3-hybrid" + HYBRID_5 = "5-hybrid" + HYBRID_7 = "7-hybrid" + HYBRID_9 = "9-hybrid" + IRON_2 = "2-iron" + IRON_3 = "3-iron" + IRON_4 = "4-iron" + IRON_5 = "5-iron" + IRON_6 = "6-iron" + IRON_7 = "7-iron" + IRON_8 = "8-iron" + IRON_9 = "9-iron" + PW = "pw" + GW = "gw" + SW = "sw" + LW = "lw" + UNKNOWN = "unknown" + + +@dataclass(frozen=True) +class ClubProfile: + """Physics characteristics and reference averages for a single club type.""" + + club: ClubType + optimal_launch_deg: float + avg_launch_deg: float + avg_ball_speed_mph: float + launch_deg_per_mph: float + optimal_smash: float + typical_spin_rpm: float + spin_multiplier: float + # Amateur simulation distributions (used by mock monitors) + ball_speed_std_dev: float = 10.0 + mock_smash: float = 1.35 + spin_std_dev: float = 500.0 + launch_std_dev: float = 2.0 + + +# Canonical per-club data table based on TrackMan averages and empirical calibrations. +CLUB_PROFILES: Dict[ClubType, ClubProfile] = { + ClubType.DRIVER: ClubProfile( + club=ClubType.DRIVER, + optimal_launch_deg=11.0, + avg_launch_deg=11.0, + avg_ball_speed_mph=143.0, + launch_deg_per_mph=0.15, + optimal_smash=1.48, + typical_spin_rpm=2700.0, + spin_multiplier=1.0, + ball_speed_std_dev=12.0, + mock_smash=1.45, + spin_std_dev=400.0, + launch_std_dev=2.0, + ), + ClubType.WOOD_3: ClubProfile( + club=ClubType.WOOD_3, + optimal_launch_deg=12.5, + avg_launch_deg=12.5, + avg_ball_speed_mph=135.0, + launch_deg_per_mph=0.18, + optimal_smash=1.44, + typical_spin_rpm=3500.0, + spin_multiplier=1.15, + ball_speed_std_dev=10.0, + mock_smash=1.42, + spin_std_dev=400.0, + launch_std_dev=2.0, + ), + ClubType.WOOD_5: ClubProfile( + club=ClubType.WOOD_5, + optimal_launch_deg=14.0, + avg_launch_deg=14.0, + avg_ball_speed_mph=128.0, + launch_deg_per_mph=0.20, + optimal_smash=1.42, + typical_spin_rpm=4200.0, + spin_multiplier=1.25, + ball_speed_std_dev=10.0, + mock_smash=1.40, + spin_std_dev=400.0, + launch_std_dev=2.0, + ), + ClubType.WOOD_7: ClubProfile( + club=ClubType.WOOD_7, + optimal_launch_deg=15.5, + avg_launch_deg=15.5, + avg_ball_speed_mph=122.0, + launch_deg_per_mph=0.20, + optimal_smash=1.41, + typical_spin_rpm=4800.0, + spin_multiplier=1.32, + ball_speed_std_dev=9.0, + mock_smash=1.40, + spin_std_dev=500.0, + launch_std_dev=2.0, + ), + ClubType.HYBRID_3: ClubProfile( + club=ClubType.HYBRID_3, + optimal_launch_deg=13.5, + avg_launch_deg=13.5, + avg_ball_speed_mph=123.0, + launch_deg_per_mph=0.22, + optimal_smash=1.39, + typical_spin_rpm=4400.0, + spin_multiplier=1.45, + ball_speed_std_dev=9.0, + mock_smash=1.39, + spin_std_dev=400.0, + launch_std_dev=2.0, + ), + ClubType.HYBRID_5: ClubProfile( + club=ClubType.HYBRID_5, + optimal_launch_deg=15.0, + avg_launch_deg=15.0, + avg_ball_speed_mph=118.0, + launch_deg_per_mph=0.22, + optimal_smash=1.37, + typical_spin_rpm=4900.0, + spin_multiplier=1.55, + ball_speed_std_dev=9.0, + mock_smash=1.37, + spin_std_dev=500.0, + launch_std_dev=2.0, + ), + ClubType.HYBRID_7: ClubProfile( + club=ClubType.HYBRID_7, + optimal_launch_deg=16.5, + avg_launch_deg=16.5, + avg_ball_speed_mph=112.0, + launch_deg_per_mph=0.25, + optimal_smash=1.35, + typical_spin_rpm=5300.0, + spin_multiplier=1.65, + ball_speed_std_dev=8.0, + mock_smash=1.35, + spin_std_dev=500.0, + launch_std_dev=2.0, + ), + ClubType.HYBRID_9: ClubProfile( + club=ClubType.HYBRID_9, + optimal_launch_deg=18.0, + avg_launch_deg=18.0, + avg_ball_speed_mph=106.0, + launch_deg_per_mph=0.25, + optimal_smash=1.33, + typical_spin_rpm=5800.0, + spin_multiplier=1.75, + ball_speed_std_dev=8.0, + mock_smash=1.33, + spin_std_dev=500.0, + launch_std_dev=2.5, + ), + ClubType.IRON_2: ClubProfile( + club=ClubType.IRON_2, + optimal_launch_deg=13.0, + avg_launch_deg=13.0, + avg_ball_speed_mph=120.0, + launch_deg_per_mph=0.25, + optimal_smash=1.36, + typical_spin_rpm=4000.0, + spin_multiplier=1.50, + ball_speed_std_dev=9.0, + mock_smash=1.35, + spin_std_dev=400.0, + launch_std_dev=2.0, + ), + ClubType.IRON_3: ClubProfile( + club=ClubType.IRON_3, + optimal_launch_deg=14.5, + avg_launch_deg=14.5, + avg_ball_speed_mph=118.0, + launch_deg_per_mph=0.25, + optimal_smash=1.35, + typical_spin_rpm=4500.0, + spin_multiplier=1.60, + ball_speed_std_dev=9.0, + mock_smash=1.35, + spin_std_dev=400.0, + launch_std_dev=2.0, + ), + ClubType.IRON_4: ClubProfile( + club=ClubType.IRON_4, + optimal_launch_deg=16.0, + avg_launch_deg=16.0, + avg_ball_speed_mph=114.0, + launch_deg_per_mph=0.28, + optimal_smash=1.33, + typical_spin_rpm=5000.0, + spin_multiplier=1.80, + ball_speed_std_dev=8.0, + mock_smash=1.33, + spin_std_dev=500.0, + launch_std_dev=2.0, + ), + ClubType.IRON_5: ClubProfile( + club=ClubType.IRON_5, + optimal_launch_deg=17.5, + avg_launch_deg=17.5, + avg_ball_speed_mph=110.0, + launch_deg_per_mph=0.28, + optimal_smash=1.31, + typical_spin_rpm=5400.0, + spin_multiplier=2.00, + ball_speed_std_dev=8.0, + mock_smash=1.31, + spin_std_dev=500.0, + launch_std_dev=2.0, + ), + ClubType.IRON_6: ClubProfile( + club=ClubType.IRON_6, + optimal_launch_deg=19.0, + avg_launch_deg=19.0, + avg_ball_speed_mph=105.0, + launch_deg_per_mph=0.30, + optimal_smash=1.29, + typical_spin_rpm=6000.0, + spin_multiplier=2.20, + ball_speed_std_dev=7.0, + mock_smash=1.29, + spin_std_dev=600.0, + launch_std_dev=2.5, + ), + ClubType.IRON_7: ClubProfile( + club=ClubType.IRON_7, + optimal_launch_deg=20.5, + avg_launch_deg=20.5, + avg_ball_speed_mph=100.0, + launch_deg_per_mph=0.30, + optimal_smash=1.27, + typical_spin_rpm=6500.0, + spin_multiplier=2.50, + ball_speed_std_dev=7.0, + mock_smash=1.27, + spin_std_dev=600.0, + launch_std_dev=2.5, + ), + ClubType.IRON_8: ClubProfile( + club=ClubType.IRON_8, + optimal_launch_deg=23.0, + avg_launch_deg=23.0, + avg_ball_speed_mph=94.0, + launch_deg_per_mph=0.30, + optimal_smash=1.25, + typical_spin_rpm=7500.0, + spin_multiplier=2.80, + ball_speed_std_dev=6.0, + mock_smash=1.25, + spin_std_dev=700.0, + launch_std_dev=3.0, + ), + ClubType.IRON_9: ClubProfile( + club=ClubType.IRON_9, + optimal_launch_deg=25.5, + avg_launch_deg=25.5, + avg_ball_speed_mph=88.0, + launch_deg_per_mph=0.30, + optimal_smash=1.23, + typical_spin_rpm=8500.0, + spin_multiplier=3.20, + ball_speed_std_dev=6.0, + mock_smash=1.23, + spin_std_dev=800.0, + launch_std_dev=3.0, + ), + ClubType.PW: ClubProfile( + club=ClubType.PW, + optimal_launch_deg=28.0, + avg_launch_deg=28.0, + avg_ball_speed_mph=82.0, + launch_deg_per_mph=0.30, + optimal_smash=1.21, + typical_spin_rpm=9000.0, + spin_multiplier=3.60, + ball_speed_std_dev=5.0, + mock_smash=1.21, + spin_std_dev=800.0, + launch_std_dev=3.0, + ), + ClubType.GW: ClubProfile( + club=ClubType.GW, + optimal_launch_deg=30.0, + avg_launch_deg=30.0, + avg_ball_speed_mph=76.0, + launch_deg_per_mph=0.30, + optimal_smash=1.19, + typical_spin_rpm=9500.0, + spin_multiplier=4.10, + ball_speed_std_dev=5.0, + mock_smash=1.20, + spin_std_dev=900.0, + launch_std_dev=3.5, + ), + ClubType.SW: ClubProfile( + club=ClubType.SW, + optimal_launch_deg=32.0, + avg_launch_deg=32.0, + avg_ball_speed_mph=73.0, + launch_deg_per_mph=0.30, + optimal_smash=1.18, + typical_spin_rpm=10000.0, + spin_multiplier=4.30, + ball_speed_std_dev=5.0, + mock_smash=1.19, + spin_std_dev=1000.0, + launch_std_dev=4.0, + ), + ClubType.LW: ClubProfile( + club=ClubType.LW, + optimal_launch_deg=35.0, + avg_launch_deg=35.0, + avg_ball_speed_mph=70.0, + launch_deg_per_mph=0.30, + optimal_smash=1.17, + typical_spin_rpm=10500.0, + spin_multiplier=4.60, + ball_speed_std_dev=5.0, + mock_smash=1.18, + spin_std_dev=1000.0, + launch_std_dev=4.0, + ), + ClubType.UNKNOWN: ClubProfile( + club=ClubType.UNKNOWN, + optimal_launch_deg=18.0, + avg_launch_deg=18.0, + avg_ball_speed_mph=120.0, + launch_deg_per_mph=0.25, + optimal_smash=1.35, + typical_spin_rpm=5000.0, + spin_multiplier=1.0, + ball_speed_std_dev=15.0, + mock_smash=1.35, + spin_std_dev=800.0, + launch_std_dev=3.0, + ), +} + +# Derived mapping dictionaries for high-performance direct lookups +OPTIMAL_LAUNCH_ANGLES: Dict[ClubType, float] = { + c: p.optimal_launch_deg for c, p in CLUB_PROFILES.items() +} + +OPTIMAL_SMASH_FACTORS: Dict[ClubType, float] = { + c: p.optimal_smash for c, p in CLUB_PROFILES.items() +} + +CLUB_TYPICAL_SPIN_RPM: Dict[ClubType, float] = { + c: p.typical_spin_rpm for c, p in CLUB_PROFILES.items() +} + +CLUB_LAUNCH_MODELS: Dict[ClubType, Tuple[float, float, float]] = { + c: (p.avg_launch_deg, p.avg_ball_speed_mph, p.launch_deg_per_mph) + for c, p in CLUB_PROFILES.items() +} + +CLUB_SPIN_MULTIPLIERS: Dict[ClubType, float] = { + c: p.spin_multiplier for c, p in CLUB_PROFILES.items() +} + +CLUB_BALL_SPEEDS: Dict[ClubType, Tuple[float, float, float]] = { + c: (p.avg_ball_speed_mph, p.ball_speed_std_dev, p.mock_smash) for c, p in CLUB_PROFILES.items() +} + +CLUB_SPIN_DISTRIBUTIONS: Dict[ClubType, Tuple[float, float]] = { + c: (p.typical_spin_rpm, p.spin_std_dev) for c, p in CLUB_PROFILES.items() +} + +CLUB_LAUNCH_DISTRIBUTIONS: Dict[ClubType, Tuple[float, float]] = { + c: (p.avg_launch_deg, p.launch_std_dev) for c, p in CLUB_PROFILES.items() +} + + +def get_club_profile(club: Optional[ClubType]) -> ClubProfile: + """Return the profile for the given club, defaulting to UNKNOWN if None or unrecognized.""" + if club is None: + return CLUB_PROFILES[ClubType.UNKNOWN] + return CLUB_PROFILES.get(club, CLUB_PROFILES[ClubType.UNKNOWN]) + + +def get_optimal_launch_angle(club: Optional[ClubType]) -> float: + """Return optimal vertical launch angle in degrees for the given club.""" + return get_club_profile(club).optimal_launch_deg + + +def get_optimal_smash(club: Optional[ClubType]) -> float: + """Return optimal smash factor for the given club.""" + return get_club_profile(club).optimal_smash + + +def get_typical_spin_rpm(club: Optional[ClubType]) -> float: + """Return typical spin in RPM for the given club.""" + return get_club_profile(club).typical_spin_rpm diff --git a/src/openflight/gspro/codec.py b/src/openflight/gspro/codec.py index 3919b3fe3..6af84b476 100644 --- a/src/openflight/gspro/codec.py +++ b/src/openflight/gspro/codec.py @@ -46,9 +46,7 @@ class GSProCodec: GSPro, "opengolfsim" when this codec drives OGS over its OpenConnect plugin. """ - def __init__( - self, device_id: str = "OpenFlight", units: str = "Yards", name: str = "gspro" - ): + def __init__(self, device_id: str = "OpenFlight", units: str = "Yards", name: str = "gspro"): self.name = name self.device_id = device_id self.units = units diff --git a/src/openflight/iwr6843/doa.py b/src/openflight/iwr6843/doa.py index 1685f9bb5..430d55a2f 100644 --- a/src/openflight/iwr6843/doa.py +++ b/src/openflight/iwr6843/doa.py @@ -264,9 +264,7 @@ def angle_points( def circular_median(values: list[float]) -> float: """Median of angles, wrapping correctly across +/-pi.""" array = np.asarray(values, dtype=float) - scores = [ - np.median(np.abs(np.angle(np.exp(1j * (array - candidate))))) for candidate in array - ] + scores = [np.median(np.abs(np.angle(np.exp(1j * (array - candidate))))) for candidate in array] return float(array[int(np.argmin(scores))]) diff --git a/src/openflight/iwr6843/trajectory.py b/src/openflight/iwr6843/trajectory.py index 8b10aa87c..cd554b193 100644 --- a/src/openflight/iwr6843/trajectory.py +++ b/src/openflight/iwr6843/trajectory.py @@ -22,6 +22,7 @@ radar, h = meters above the RADAR PLANE (tilt already applied). Two-ray heights are meters above the FLOOR. """ + from __future__ import annotations from dataclasses import dataclass @@ -40,20 +41,20 @@ class TrajectoryFit: method: str launch_angle_deg: float n_points: int - h_rms_m: float # scatter about the fit - launch_cross_m: float # where the fit meets the radar plane + h_rms_m: float # scatter about the fit + launch_cross_m: float # where the fit meets the radar plane -def _ground_xy(points: list[AnglePoint], cal: Calibration - ) -> tuple[np.ndarray, np.ndarray]: +def _ground_xy(points: list[AnglePoint], cal: Calibration) -> tuple[np.ndarray, np.ndarray]: """AnglePoints -> (x horizontal, h above radar plane).""" theta = np.array([p.theta_rad for p in points]) + cal.tilt_rad rng = np.array([p.range_m for p in points]) return rng * np.cos(theta), rng * np.sin(theta) -def fit_free(points: list[AnglePoint], cal: Calibration, - min_points: int = 8) -> TrajectoryFit | None: +def fit_free( + points: list[AnglePoint], cal: Calibration, min_points: int = 8 +) -> TrajectoryFit | None: """Unconstrained least-squares line fit.""" if len(points) < min_points: return None @@ -61,16 +62,21 @@ def fit_free(points: list[AnglePoint], cal: Calibration, slope, icpt = np.polyfit(x_m, h_m, 1) resid = h_m - (slope * x_m + icpt) cross = float(-icpt / slope) if slope else float("nan") - return TrajectoryFit(method="free", - launch_angle_deg=float(np.degrees(np.arctan(slope))), - n_points=len(points), - h_rms_m=float(np.sqrt((resid ** 2).mean())), - launch_cross_m=cross) + return TrajectoryFit( + method="free", + launch_angle_deg=float(np.degrees(np.arctan(slope))), + n_points=len(points), + h_rms_m=float(np.sqrt((resid**2).mean())), + launch_cross_m=cross, + ) -def fit_tee(points: list[AnglePoint], cal: Calibration, - min_points: int = 8, - th_gate_rad: float | None = None) -> TrajectoryFit | None: +def fit_tee( + points: list[AnglePoint], + cal: Calibration, + min_points: int = 8, + th_gate_rad: float | None = None, +) -> TrajectoryFit | None: """Line forced through the measured launch point (floor-referenced). ``th_gate_rad`` keeps only points measured BELOW that boresight angle @@ -88,17 +94,18 @@ def fit_tee(points: list[AnglePoint], cal: Calibration, d_x = x_m - x_0 slope = float(np.sum(d_x * (h_m - h_0)) / np.sum(d_x * d_x)) resid = h_m - (h_0 + slope * d_x) - return TrajectoryFit(method="tee", - launch_angle_deg=float(np.degrees(np.arctan(slope))), - n_points=len(points), - h_rms_m=float(np.sqrt((resid ** 2).mean())), - launch_cross_m=float(x_0 - h_0 / slope) if slope - else float("nan")) + return TrajectoryFit( + method="tee", + launch_angle_deg=float(np.degrees(np.arctan(slope))), + n_points=len(points), + h_rms_m=float(np.sqrt((resid**2).mean())), + launch_cross_m=float(x_0 - h_0 / slope) if slope else float("nan"), + ) -def _two_ray_solve(snapshot: np.ndarray, x_m: float, radar_height_m: float, - tilt_rad: float, grid_m: np.ndarray - ) -> tuple[float, float, float]: +def _two_ray_solve( + snapshot: np.ndarray, x_m: float, radar_height_m: float, tilt_rad: float, grid_m: np.ndarray +) -> tuple[float, float, float]: """Best-fit ball height ABOVE THE FLOOR for one snapshot (vectorized). For each hypothesized height the direct and floor-image arrival angles @@ -118,32 +125,38 @@ def _two_ray_solve(snapshot: np.ndarray, x_m: float, radar_height_m: float, det = np.where(np.abs(det) < 1e-9, 1e-9, det) c_a = (n_el * b_1 - a12 * b_2) / det c_b = (n_el * b_2 - np.conj(a12) * b_1) / det - pred_power = (np.abs(c_a) ** 2 * n_el + np.abs(c_b) ** 2 * n_el - + 2 * np.real(np.conj(c_a) * c_b * a12)) + pred_power = ( + np.abs(c_a) ** 2 * n_el + np.abs(c_b) ** 2 * n_el + 2 * np.real(np.conj(c_a) * c_b * a12) + ) power = float(np.vdot(snapshot, snapshot).real) + 1e-12 expl = np.real(pred_power) / power k = int(np.argmax(expl)) p_a, p_b = abs(c_a[k]) ** 2, abs(c_b[k]) ** 2 - return (float(grid_m[k]), float(expl[k]), - float(p_b / (p_a + p_b + 1e-12))) + return (float(grid_m[k]), float(expl[k]), float(p_b / (p_a + p_b + 1e-12))) -def _two_ray_height(snapshot: np.ndarray, x_m: float, radar_height_m: float, - tilt_rad: float, grid_m: np.ndarray - ) -> tuple[float, float]: +def _two_ray_height( + snapshot: np.ndarray, x_m: float, radar_height_m: float, tilt_rad: float, grid_m: np.ndarray +) -> tuple[float, float]: """Back-compat wrapper: (height_m, explained_fraction).""" - h_b, expl, _imf = _two_ray_solve(snapshot, x_m, radar_height_m, - tilt_rad, grid_m) + h_b, expl, _imf = _two_ray_solve(snapshot, x_m, radar_height_m, tilt_rad, grid_m) return h_b, expl -def fit_two_ray(snap_points: list[tuple[float, float, np.ndarray]], - cal: Calibration, *, radar_height_m: float | None = None, - grid_step_m: float = 0.01, min_points: int = 6, - min_explained: float = 0.70, weighted: bool = True, - dominance: bool = False, th_gate_rad: float | None = None, - x_max_m: float | None = None, - anchor_tee: bool = False) -> TrajectoryFit | None: +def fit_two_ray( + snap_points: list[tuple[float, float, np.ndarray]], + cal: Calibration, + *, + radar_height_m: float | None = None, + grid_step_m: float = 0.01, + min_points: int = 6, + min_explained: float = 0.70, + weighted: bool = True, + dominance: bool = False, + th_gate_rad: float | None = None, + x_max_m: float | None = None, + anchor_tee: bool = False, +) -> TrajectoryFit | None: """Two-ray trajectory: per-snapshot height solve, then a line fit. ``snap_points`` is [(t_s, range_m, calibrated 8-el snapshot), ...] from @@ -166,7 +179,7 @@ def fit_two_ray(snap_points: list[tuple[float, float, np.ndarray]], hs: list[float] = [] ws: list[float] = [] for _t, rng_m, snap in snap_points: - x_m = float(rng_m) # slant ~ horizontal at these angles + x_m = float(rng_m) # slant ~ horizontal at these angles if x_max_m is not None and x_m > x_max_m: continue # Bartlett-peak gate for the corrupted zone. Tested alternative @@ -175,8 +188,7 @@ def fit_two_ray(snap_points: list[tuple[float, float, np.ndarray]], # heights, while their Bartlett peak drifts high and betrays them. if th_gate_rad is not None and est_bartlett(snap) > th_gate_rad: continue - height, explained, imfrac = _two_ray_solve( - snap, x_m, radar_height_m, cal.tilt_rad, grid) + height, explained, imfrac = _two_ray_solve(snap, x_m, radar_height_m, cal.tilt_rad, grid) if explained >= min_explained and np.isfinite(height): w = (explained - min_explained + 1e-3) if weighted else 1.0 if dominance: @@ -206,16 +218,22 @@ def fit_two_ray(snap_points: list[tuple[float, float, np.ndarray]], resid = h_a - (slope * x_a + icpt) # launch_cross in radar-plane coords: floor height == radar_height below cross = float((radar_height_m - icpt) / slope) if slope else float("nan") - return TrajectoryFit(method="two_ray", - launch_angle_deg=float(np.degrees(np.arctan(slope))), - n_points=len(xs), - h_rms_m=float(np.sqrt((resid ** 2).mean())), - launch_cross_m=cross) + return TrajectoryFit( + method="two_ray", + launch_angle_deg=float(np.degrees(np.arctan(slope))), + n_points=len(xs), + h_rms_m=float(np.sqrt((resid**2).mean())), + launch_cross_m=cross, + ) -def cosine_speed_factor(la_deg: float, r_first_m: float, r_last_m: float, - cal: Calibration, - radar_height_m: float | None = None) -> float: +def cosine_speed_factor( + la_deg: float, + r_first_m: float, + r_last_m: float, + cal: Calibration, + radar_height_m: float | None = None, +) -> float: """Radial-projection factor: track slope = factor * true ball speed. The radar measures range rate along the line of sight; a climbing ball's @@ -232,7 +250,7 @@ def cosine_speed_factor(la_deg: float, r_first_m: float, r_last_m: float, la = np.radians(np.clip(la_deg, 0.0, 40.0)) z_0 = cal.tee_ball_height_m d_z = z_0 - radar_height_m - x_tee = float(np.sqrt(max(cal.tee_range_m ** 2 - d_z * d_z, 0.25))) + x_tee = float(np.sqrt(max(cal.tee_range_m**2 - d_z * d_z, 0.25))) def r_of(x: float) -> float: z = z_0 + np.tan(la) * (x - x_tee) 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/tracker.py b/src/openflight/kld7/tracker.py index 019187169..cafbfa340 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 @@ -169,6 +170,12 @@ def __init__( 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/launch_monitor.py b/src/openflight/launch_monitor.py index 0ce8fde65..c19919003 100644 --- a/src/openflight/launch_monitor.py +++ b/src/openflight/launch_monitor.py @@ -7,66 +7,17 @@ from dataclasses import dataclass, field from datetime import datetime -from enum import Enum from typing import List, Optional +from .club_data import OPTIMAL_LAUNCH_ANGLES, ClubType from .ops243 import SpeedReading # Spin confidence threshold for "high" quality — used across modules. # Measured spin is trusted for physics simulation only above this level. SPIN_CONFIDENCE_HIGH = 0.7 - -class ClubType(Enum): - """Golf club types for distance estimation.""" - - DRIVER = "driver" - WOOD_3 = "3-wood" - WOOD_5 = "5-wood" - WOOD_7 = "7-wood" - HYBRID_3 = "3-hybrid" - HYBRID_5 = "5-hybrid" - HYBRID_7 = "7-hybrid" - HYBRID_9 = "9-hybrid" - IRON_2 = "2-iron" - IRON_3 = "3-iron" - IRON_4 = "4-iron" - IRON_5 = "5-iron" - IRON_6 = "6-iron" - IRON_7 = "7-iron" - IRON_8 = "8-iron" - IRON_9 = "9-iron" - PW = "pw" - GW = "gw" - SW = "sw" - LW = "lw" - UNKNOWN = "unknown" - - -# Optimal launch angles by club (from TrackMan data) -_OPTIMAL_LAUNCH = { - ClubType.DRIVER: 11.0, - ClubType.WOOD_3: 12.5, - ClubType.WOOD_5: 14.0, - ClubType.WOOD_7: 15.5, - ClubType.HYBRID_3: 13.5, - ClubType.HYBRID_5: 15.0, - ClubType.HYBRID_7: 16.5, - ClubType.HYBRID_9: 18.0, - ClubType.IRON_2: 13.0, - ClubType.IRON_3: 14.5, - ClubType.IRON_4: 16.0, - ClubType.IRON_5: 17.5, - ClubType.IRON_6: 19.0, - ClubType.IRON_7: 20.5, - ClubType.IRON_8: 23.0, - ClubType.IRON_9: 25.5, - ClubType.PW: 28.0, - ClubType.GW: 30.0, - ClubType.SW: 32.0, - ClubType.LW: 35.0, - ClubType.UNKNOWN: 18.0, -} +# Optimal launch angles by club (from TrackMan data, canonical source: club_data.py) +_OPTIMAL_LAUNCH = OPTIMAL_LAUNCH_ANGLES def estimate_carry_distance(ball_speed_mph: float, club: ClubType = ClubType.DRIVER) -> float: diff --git a/src/openflight/ops243.py b/src/openflight/ops243.py index 8303691d5..893e934f6 100644 --- a/src/openflight/ops243.py +++ b/src/openflight/ops243.py @@ -612,8 +612,7 @@ def read_once() -> bool: # mid-dump). Abandon the sync; the caller falls back to # first-byte timing. Retrying writes would only re-block. logger.warning( - "[OPS] Clock sync C? write timed out — port jammed, " - "abandoning clock sync" + "[OPS] Clock sync C? write timed out — port jammed, abandoning clock sync" ) return False buf = "" diff --git a/src/openflight/rolling_buffer/monitor.py b/src/openflight/rolling_buffer/monitor.py index 09b71f7d1..7674e0df3 100644 --- a/src/openflight/rolling_buffer/monitor.py +++ b/src/openflight/rolling_buffer/monitor.py @@ -12,6 +12,7 @@ from datetime import datetime from typing import Callable, List, Optional +from ..club_data import CLUB_SPIN_MULTIPLIERS, OPTIMAL_SMASH_FACTORS from ..launch_monitor import ClubType, Shot, estimate_carry_distance from ..ops243 import OPS243Radar, SpeedReading from ..session_logger import get_session_logger, log_session_error @@ -33,20 +34,17 @@ def get_optimal_spin_for_ball_speed( - Lower ball speeds need MORE spin to maintain lift Reference data points (driver): - - 120 mph ball speed → ~2900 rpm optimal - - 140 mph ball speed → ~2700 rpm optimal - - 160 mph ball speed → ~2550 rpm optimal (Tour average zone) - 180 mph ball speed → ~2050 rpm optimal + - 167 mph (PGA Tour avg) → ~2450 rpm optimal + - 160 mph ball speed → ~2550 rpm optimal + - 140 mph ball speed → ~2700 rpm optimal + - 120 mph ball speed → ~2900 rpm optimal + - 100 mph ball speed → ~3200 rpm optimal - Args: - ball_speed_mph: Ball speed in mph - club: Club type (affects optimal spin) - - Returns: - Optimal spin rate in RPM + For irons/wedges, optimal spin is higher (scaled by club multiplier). """ - # Driver optimal spin (baseline) - interpolated from TrackMan/PING data - # Table: (min_speed, base_rpm_at_upper_bound, rpm_per_mph_below_upper, upper_bound) + # Base optimal spin curve for driver (from TrackMan data) + # Piecewise linear interpolation based on ball speed _spin_table = [ (180, 2050, 0, 999), (170, 2050, 25, 180), @@ -62,32 +60,8 @@ def get_optimal_spin_for_ball_speed( optimal = base_rpm + (upper - ball_speed_mph) * rpm_per_mph break - # Adjust for club type - irons need more spin - club_spin_multipliers = { - ClubType.DRIVER: 1.0, - ClubType.WOOD_3: 1.15, - ClubType.WOOD_5: 1.25, - ClubType.WOOD_7: 1.32, - ClubType.HYBRID_3: 1.45, - ClubType.HYBRID_5: 1.55, - ClubType.HYBRID_7: 1.65, - ClubType.HYBRID_9: 1.75, - ClubType.IRON_2: 1.5, - ClubType.IRON_3: 1.6, - ClubType.IRON_4: 1.8, - ClubType.IRON_5: 2.0, - ClubType.IRON_6: 2.2, - ClubType.IRON_7: 2.5, - ClubType.IRON_8: 2.8, - ClubType.IRON_9: 3.2, - ClubType.PW: 3.6, - ClubType.GW: 4.1, - ClubType.SW: 4.3, - ClubType.LW: 4.6, - ClubType.UNKNOWN: 1.0, - } - - multiplier = club_spin_multipliers.get(club, 1.0) + # Adjust for club type - irons need more spin (canonical source: club_data.py) + multiplier = CLUB_SPIN_MULTIPLIERS.get(club, 1.0) return optimal * multiplier @@ -162,32 +136,7 @@ def estimate_carry_with_spin( if club_speed_mph and club_speed_mph > 0: smash = ball_speed_mph / club_speed_mph - # Optimal smash factors by club type - optimal_smash = { - ClubType.DRIVER: 1.48, - ClubType.WOOD_3: 1.44, - ClubType.WOOD_5: 1.42, - ClubType.WOOD_7: 1.41, - ClubType.HYBRID_3: 1.39, - ClubType.HYBRID_5: 1.37, - ClubType.HYBRID_7: 1.35, - ClubType.HYBRID_9: 1.33, - ClubType.IRON_2: 1.36, - ClubType.IRON_3: 1.35, - ClubType.IRON_4: 1.33, - ClubType.IRON_5: 1.31, - ClubType.IRON_6: 1.29, - ClubType.IRON_7: 1.27, - ClubType.IRON_8: 1.25, - ClubType.IRON_9: 1.23, - ClubType.PW: 1.21, - ClubType.GW: 1.19, - ClubType.SW: 1.18, - ClubType.LW: 1.17, - ClubType.UNKNOWN: 1.35, - } - - target_smash = optimal_smash.get(club, 1.35) + target_smash = OPTIMAL_SMASH_FACTORS.get(club, 1.35) smash_delta = target_smash - smash if smash_delta > 0: diff --git a/src/openflight/rolling_buffer/trigger.py b/src/openflight/rolling_buffer/trigger.py index ca51b390c..27e2a4c75 100644 --- a/src/openflight/rolling_buffer/trigger.py +++ b/src/openflight/rolling_buffer/trigger.py @@ -942,9 +942,7 @@ def _select_clock_sync_for_capture( ): selected_sync = previous_sync selected_source = "previous" - selected_reason = ( - f"fresh_rejected:{fresh_reason};previous_age:{previous_age_s:.1f}s" - ) + selected_reason = f"fresh_rejected:{fresh_reason};previous_age:{previous_age_s:.1f}s" elif previous_valid and previous_age_s is not None: selected_reason = ( f"fresh_rejected:{fresh_reason};previous_too_old:{previous_age_s:.1f}s" diff --git a/src/openflight/server.py b/src/openflight/server.py index 5fdae2dfa..86e0bdabd 100644 --- a/src/openflight/server.py +++ b/src/openflight/server.py @@ -22,6 +22,13 @@ from flask_socketio import SocketIO from .ballistics import resolve_launch, simulate +from .club_data import ( + CLUB_BALL_SPEEDS, + CLUB_LAUNCH_DISTRIBUTIONS, + CLUB_LAUNCH_MODELS, + CLUB_SPIN_DISTRIBUTIONS, + OPTIMAL_SMASH_FACTORS, +) from .launch_monitor import SPIN_CONFIDENCE_HIGH, ClubType, Shot from .ops243 import ( UART_BAUD_COMMANDS, @@ -234,56 +241,11 @@ def _shutdown_process_after_delay(delay_s: float = 0.5) -> None: os._exit(0) -# Baseline launch angles by club (TrackMan data) -# Format: (avg_launch_deg, avg_ball_speed_mph, deg_per_mph_deviation) -_CLUB_LAUNCH_MODEL = { - ClubType.DRIVER: (11.0, 143, 0.15), - ClubType.WOOD_3: (12.5, 135, 0.18), - ClubType.WOOD_5: (14.0, 128, 0.20), - ClubType.WOOD_7: (15.5, 122, 0.20), - ClubType.HYBRID_3: (13.5, 123, 0.22), - ClubType.HYBRID_5: (15.0, 118, 0.22), - ClubType.HYBRID_7: (16.5, 112, 0.25), - ClubType.HYBRID_9: (18.0, 106, 0.25), - ClubType.IRON_2: (13.0, 120, 0.25), - ClubType.IRON_3: (14.5, 118, 0.25), - ClubType.IRON_4: (16.0, 114, 0.28), - ClubType.IRON_5: (17.5, 110, 0.28), - ClubType.IRON_6: (19.0, 105, 0.30), - ClubType.IRON_7: (20.5, 100, 0.30), - ClubType.IRON_8: (23.0, 94, 0.30), - ClubType.IRON_9: (25.5, 88, 0.30), - ClubType.PW: (28.0, 82, 0.30), - ClubType.GW: (30.0, 76, 0.30), - ClubType.SW: (32.0, 73, 0.30), - ClubType.LW: (35.0, 70, 0.30), - ClubType.UNKNOWN: (18.0, 120, 0.25), -} +# Baseline launch angles by club (TrackMan data, canonical source: club_data.py) +_CLUB_LAUNCH_MODEL = CLUB_LAUNCH_MODELS -# Optimal smash factor by club type (ball_speed / club_speed) -_OPTIMAL_SMASH = { - ClubType.DRIVER: 1.48, - ClubType.WOOD_3: 1.44, - ClubType.WOOD_5: 1.42, - ClubType.WOOD_7: 1.42, - ClubType.HYBRID_3: 1.39, - ClubType.HYBRID_5: 1.38, - ClubType.HYBRID_7: 1.37, - ClubType.HYBRID_9: 1.36, - ClubType.IRON_2: 1.37, - ClubType.IRON_3: 1.36, - ClubType.IRON_4: 1.35, - ClubType.IRON_5: 1.35, - ClubType.IRON_6: 1.34, - ClubType.IRON_7: 1.34, - ClubType.IRON_8: 1.33, - ClubType.IRON_9: 1.33, - ClubType.PW: 1.25, - ClubType.GW: 1.23, - ClubType.SW: 1.22, - ClubType.LW: 1.20, - ClubType.UNKNOWN: 1.35, -} +# Optimal smash factor by club type (ball_speed / club_speed, canonical source: club_data.py) +_OPTIMAL_SMASH = OPTIMAL_SMASH_FACTORS # Max smash factor adjustment in degrees (clamped to prevent floor-dependence) _MAX_SMASH_ADJ_LOW = -3.0 # max degrees to subtract for thin/toe hits @@ -3230,79 +3192,13 @@ class MockLaunchMonitor: """Mock launch monitor for UI development without radar hardware.""" # TrackMan averages for amateur golfers: (avg_ball_speed, std_dev, smash_factor) - _CLUB_BALL_SPEEDS = { - ClubType.DRIVER: (143, 12, 1.45), - ClubType.WOOD_3: (135, 10, 1.42), - ClubType.WOOD_5: (128, 10, 1.40), - ClubType.WOOD_7: (122, 9, 1.40), - ClubType.HYBRID_3: (123, 9, 1.39), - ClubType.HYBRID_5: (118, 9, 1.37), - ClubType.HYBRID_7: (112, 8, 1.35), - ClubType.HYBRID_9: (106, 8, 1.33), - ClubType.IRON_2: (120, 9, 1.35), - ClubType.IRON_3: (118, 9, 1.35), - ClubType.IRON_4: (114, 8, 1.33), - ClubType.IRON_5: (110, 8, 1.31), - ClubType.IRON_6: (105, 7, 1.29), - ClubType.IRON_7: (100, 7, 1.27), - ClubType.IRON_8: (94, 6, 1.25), - ClubType.IRON_9: (88, 6, 1.23), - ClubType.PW: (82, 5, 1.21), - ClubType.GW: (76, 5, 1.20), - ClubType.SW: (73, 5, 1.19), - ClubType.LW: (70, 5, 1.18), - ClubType.UNKNOWN: (120, 15, 1.35), - } + _CLUB_BALL_SPEEDS = CLUB_BALL_SPEEDS # Spin rates (avg_rpm, std_dev) — drivers: low spin, wedges: high spin - _CLUB_SPIN = { - ClubType.DRIVER: (2700, 400), - ClubType.WOOD_3: (3200, 400), - ClubType.WOOD_5: (3700, 400), - ClubType.WOOD_7: (4200, 500), - ClubType.HYBRID_3: (3800, 400), - ClubType.HYBRID_5: (4200, 500), - ClubType.HYBRID_7: (4600, 500), - ClubType.HYBRID_9: (5000, 500), - ClubType.IRON_2: (3800, 400), - ClubType.IRON_3: (4100, 400), - ClubType.IRON_4: (4500, 500), - ClubType.IRON_5: (5000, 500), - ClubType.IRON_6: (5500, 600), - ClubType.IRON_7: (6000, 600), - ClubType.IRON_8: (7000, 700), - ClubType.IRON_9: (7800, 800), - ClubType.PW: (8500, 800), - ClubType.GW: (9200, 900), - ClubType.SW: (9800, 1000), - ClubType.LW: (10200, 1000), - ClubType.UNKNOWN: (5000, 800), - } + _CLUB_SPIN = CLUB_SPIN_DISTRIBUTIONS # Launch angles in degrees (avg, std_dev) — drivers: low, wedges: high - _CLUB_LAUNCH = { - ClubType.DRIVER: (11.0, 2.0), - ClubType.WOOD_3: (12.5, 2.0), - ClubType.WOOD_5: (14.0, 2.0), - ClubType.WOOD_7: (15.5, 2.0), - ClubType.HYBRID_3: (13.5, 2.0), - ClubType.HYBRID_5: (15.0, 2.0), - ClubType.HYBRID_7: (16.5, 2.0), - ClubType.HYBRID_9: (18.0, 2.5), - ClubType.IRON_2: (13.0, 2.0), - ClubType.IRON_3: (14.5, 2.0), - ClubType.IRON_4: (16.0, 2.0), - ClubType.IRON_5: (17.5, 2.0), - ClubType.IRON_6: (19.0, 2.5), - ClubType.IRON_7: (20.5, 2.5), - ClubType.IRON_8: (23.0, 3.0), - ClubType.IRON_9: (25.5, 3.0), - ClubType.PW: (28.0, 3.0), - ClubType.GW: (30.0, 3.5), - ClubType.SW: (32.0, 4.0), - ClubType.LW: (35.0, 4.0), - ClubType.UNKNOWN: (18.0, 3.0), - } + _CLUB_LAUNCH = CLUB_LAUNCH_DISTRIBUTIONS def __init__(self): """Initialize mock monitor.""" diff --git a/src/openflight/sim/resolver.py b/src/openflight/sim/resolver.py index c9426eb17..c8852a03a 100644 --- a/src/openflight/sim/resolver.py +++ b/src/openflight/sim/resolver.py @@ -8,39 +8,17 @@ import math from typing import Dict, Tuple +from openflight.club_data import CLUB_TYPICAL_SPIN_RPM, OPTIMAL_LAUNCH_ANGLES from openflight.launch_monitor import ( - _OPTIMAL_LAUNCH, SPIN_CONFIDENCE_HIGH, ClubType, Shot, ) from openflight.sim.types import IncompleteShotError, PlayerState, ResolvedShot -# Temporary per-club spin model (rpm), used only when a measured spin is absent -# or low-confidence. Slated for replacement by the shared ballistics spin model. -SPIN_MODEL_RPM: Dict[ClubType, float] = { - ClubType.DRIVER: 2500.0, - ClubType.WOOD_3: 3000.0, - ClubType.WOOD_5: 3500.0, - ClubType.WOOD_7: 4000.0, - ClubType.HYBRID_3: 3500.0, - ClubType.HYBRID_5: 4000.0, - ClubType.HYBRID_7: 4500.0, - ClubType.HYBRID_9: 5000.0, - ClubType.IRON_2: 4000.0, - ClubType.IRON_3: 4500.0, - ClubType.IRON_4: 5000.0, - ClubType.IRON_5: 5500.0, - ClubType.IRON_6: 6000.0, - ClubType.IRON_7: 7000.0, - ClubType.IRON_8: 8000.0, - ClubType.IRON_9: 9000.0, - ClubType.PW: 9500.0, - ClubType.GW: 10000.0, - ClubType.SW: 10500.0, - ClubType.LW: 11000.0, - ClubType.UNKNOWN: 5000.0, -} +# Per-club spin model (rpm) from canonical TrackMan averages (club_data.py) +SPIN_MODEL_RPM: Dict[ClubType, float] = dict(CLUB_TYPICAL_SPIN_RPM) +_OPTIMAL_LAUNCH: Dict[ClubType, float] = dict(OPTIMAL_LAUNCH_ANGLES) _DEFAULT_SPIN_RPM = 5000.0 _DEFAULT_VLA_DEG = 18.0 diff --git a/src/openflight/sim/transport.py b/src/openflight/sim/transport.py index ec242c436..9f29c1089 100644 --- a/src/openflight/sim/transport.py +++ b/src/openflight/sim/transport.py @@ -283,9 +283,7 @@ def _connection_loop(self) -> None: # connection that was once established and then lost, so we never # imply a connection that never happened. backoff_state = ( - ConnectionState.RECONNECT_BACKOFF - if ever_connected - else ConnectionState.CONNECTING + ConnectionState.RECONNECT_BACKOFF if ever_connected else ConnectionState.CONNECTING ) self._set_state( backoff_state, diff --git a/tests/conftest.py b/tests/conftest.py index 3844cd4cc..f8b7e59a9 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 diff --git a/tests/spin_synth.py b/tests/spin_synth.py index 1c1f172d8..07a348a0a 100644 --- a/tests/spin_synth.py +++ b/tests/spin_synth.py @@ -49,9 +49,7 @@ def synth_capture( ) phase = 2 * np.pi * np.cumsum(inst_freq) / sample_rate - envelope = amplitude * ( - 1.0 + am_depth * np.sin(2 * np.pi * seam_hz * time_since_onset + 0.7) - ) + envelope = amplitude * (1.0 + am_depth * np.sin(2 * np.pi * seam_hz * time_since_onset + 0.7)) active = t >= onset_s if visible_ms is not None: active &= t < onset_s + visible_ms / 1000.0 diff --git a/tests/test_camera.py b/tests/test_camera.py index f0df3330c..3c45c37d0 100644 --- a/tests/test_camera.py +++ b/tests/test_camera.py @@ -1,24 +1,24 @@ """Tests for camera module.""" import pytest -import math # Mock numpy for testing try: import numpy as np + NUMPY_AVAILABLE = True except ImportError: NUMPY_AVAILABLE = False from openflight.camera import ( + CameraCalibration, CaptureConfig, CapturedFrame, CaptureResult, - MockCameraCapture, DetectedBall, DetectorConfig, LaunchAngles, - CameraCalibration, + MockCameraCapture, ) @@ -37,11 +37,7 @@ def test_default_config(self): def test_custom_config(self): """Custom config values should be respected.""" config = CaptureConfig( - width=1280, - height=720, - framerate=60, - pre_trigger_frames=15, - post_trigger_frames=45 + width=1280, height=720, framerate=60, pre_trigger_frames=15, post_trigger_frames=45 ) assert config.width == 1280 assert config.height == 720 @@ -55,11 +51,7 @@ class TestCapturedFrame: def test_frame_creation(self): """Create a basic captured frame.""" data = np.zeros((480, 640, 3), dtype=np.uint8) - frame = CapturedFrame( - data=data, - timestamp=12345.67, - frame_number=42 - ) + frame = CapturedFrame(data=data, timestamp=12345.67, frame_number=42) assert frame.timestamp == 12345.67 assert frame.frame_number == 42 assert frame.data.shape == (480, 640, 3) @@ -81,17 +73,9 @@ def test_pre_post_trigger_split(self): frames = [] for i in range(10): data = np.zeros((100, 100, 3), dtype=np.uint8) - frames.append(CapturedFrame( - data=data, - timestamp=float(i), - frame_number=i - )) - - result = CaptureResult( - frames=frames, - trigger_time=5.0, - trigger_frame_index=5 - ) + frames.append(CapturedFrame(data=data, timestamp=float(i), frame_number=i)) + + result = CaptureResult(frames=frames, trigger_time=5.0, trigger_frame_index=5) assert len(result.pre_trigger_frames) == 5 assert len(result.post_trigger_frames) == 5 @@ -148,12 +132,7 @@ class TestDetectedBall: def test_ball_creation(self): """Create a basic detected ball.""" ball = DetectedBall( - x=320.0, - y=240.0, - radius=15.0, - confidence=0.85, - frame_number=10, - timestamp=12345.67 + x=320.0, y=240.0, radius=15.0, confidence=0.85, frame_number=10, timestamp=12345.67 ) assert ball.x == 320.0 assert ball.y == 240.0 @@ -163,24 +142,13 @@ def test_ball_creation(self): def test_center_property(self): """Center property should return (x, y) tuple.""" ball = DetectedBall( - x=100.5, - y=200.5, - radius=10.0, - confidence=0.9, - frame_number=0, - timestamp=0.0 + x=100.5, y=200.5, radius=10.0, confidence=0.9, frame_number=0, timestamp=0.0 ) assert ball.center == (100.5, 200.5) def test_area_property(self): """Area property should calculate circle area.""" - ball = DetectedBall( - x=0, y=0, - radius=10.0, - confidence=1.0, - frame_number=0, - timestamp=0.0 - ) + ball = DetectedBall(x=0, y=0, radius=10.0, confidence=1.0, frame_number=0, timestamp=0.0) # pi * r^2 = pi * 100 ≈ 314.159 assert abs(ball.area - 314.159) < 1.0 @@ -199,10 +167,7 @@ def test_default_config(self): def test_custom_config(self): """Custom config values should be respected.""" config = DetectorConfig( - brightness_threshold=180, - min_radius=3, - max_radius=60, - min_confidence=0.7 + brightness_threshold=180, min_radius=3, max_radius=60, min_confidence=0.7 ) assert config.brightness_threshold == 180 assert config.min_radius == 3 @@ -221,7 +186,7 @@ def test_angles_creation(self): initial_x=320.0, initial_y=400.0, velocity_x=2.5, - velocity_y=-15.0 + velocity_y=-15.0, ) assert angles.vertical_deg == 12.5 assert angles.horizontal_deg == -2.3 @@ -262,6 +227,7 @@ class TestLaunchAngleCalculation: def test_calculator_creation(self): """Calculator should be creatable.""" from openflight.camera import LaunchAngleCalculator + calc = LaunchAngleCalculator() assert calc.min_detections == 3 assert calc.max_frames == 10 @@ -270,6 +236,7 @@ def test_calculator_creation(self): def test_insufficient_detections(self): """Should return None with too few detections.""" from openflight.camera import LaunchAngleCalculator + calc = LaunchAngleCalculator() # Only 2 detections, need at least 3 @@ -285,6 +252,7 @@ def test_insufficient_detections(self): def test_upward_trajectory(self): """Ball moving up should have positive vertical angle.""" from openflight.camera import LaunchAngleCalculator + calc = LaunchAngleCalculator() # Ball moving upward (y decreasing in image coords) @@ -304,6 +272,7 @@ def test_upward_trajectory(self): def test_right_trajectory(self): """Ball moving right should have positive horizontal angle.""" from openflight.camera import LaunchAngleCalculator + calc = LaunchAngleCalculator() # Ball moving right (x increasing) @@ -322,6 +291,7 @@ def test_right_trajectory(self): def test_handles_none_detections(self): """Should handle None values in detection list.""" from openflight.camera import LaunchAngleCalculator + calc = LaunchAngleCalculator() # Some frames have no detection @@ -341,6 +311,7 @@ def test_handles_none_detections(self): def test_with_radar_speed(self): """Calculate with radar-measured ball speed should work.""" from openflight.camera import LaunchAngleCalculator + calc = LaunchAngleCalculator() detections = [ @@ -358,18 +329,17 @@ def test_with_radar_speed(self): def test_ball_distance_estimation(self): """Ball distance estimation from apparent size.""" from openflight.camera import LaunchAngleCalculator + calc = LaunchAngleCalculator() # Large ball (close) close_ball = DetectedBall( - x=320, y=240, radius=30, - confidence=0.9, frame_number=0, timestamp=0.0 + x=320, y=240, radius=30, confidence=0.9, frame_number=0, timestamp=0.0 ) # Small ball (far) far_ball = DetectedBall( - x=320, y=240, radius=10, - confidence=0.9, frame_number=0, timestamp=0.0 + x=320, y=240, radius=10, confidence=0.9, frame_number=0, timestamp=0.0 ) close_dist = calc.estimate_ball_distance(close_ball) diff --git a/tests/test_cloud_commands.py b/tests/test_cloud_commands.py index e4beb4356..785f03fb3 100644 --- a/tests/test_cloud_commands.py +++ b/tests/test_cloud_commands.py @@ -2,8 +2,6 @@ import json -import pytest - from openflight.cloud import commands, spool from openflight.cloud.client import LinkPoll, LinkStart, UploadResult from openflight.cloud.config import CloudConfig diff --git a/tests/test_cloud_config.py b/tests/test_cloud_config.py index 7f637f138..91a658f01 100644 --- a/tests/test_cloud_config.py +++ b/tests/test_cloud_config.py @@ -2,6 +2,7 @@ import json import stat +import sys import pytest @@ -39,6 +40,10 @@ def test_defaults_endpoint_and_enabled_when_missing(self, tmp_path): class TestSaveConfig: + @pytest.mark.skipif( + sys.platform == "win32", + reason="POSIX file permissions (0600) are not enforced 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_cloud_spool.py b/tests/test_cloud_spool.py index ba3497498..4f992fe9f 100644 --- a/tests/test_cloud_spool.py +++ b/tests/test_cloud_spool.py @@ -2,8 +2,6 @@ import json -import pytest - from openflight.cloud import spool diff --git a/tests/test_cloud_trigger.py b/tests/test_cloud_trigger.py index f0c6db413..9f67eaea9 100644 --- a/tests/test_cloud_trigger.py +++ b/tests/test_cloud_trigger.py @@ -1,7 +1,5 @@ """Tests for the non-blocking session-end push trigger.""" -import pytest - from openflight.cloud import trigger from openflight.cloud.config import CloudConfig diff --git a/tests/test_club_data.py b/tests/test_club_data.py new file mode 100644 index 000000000..a1e97576f --- /dev/null +++ b/tests/test_club_data.py @@ -0,0 +1,167 @@ +"""Tests for canonical club data consolidation and cross-module consistency.""" + +import pytest + +from openflight.ballistics import CLUB_TYPICAL_SPIN_RPM as BALLISTICS_SPIN +from openflight.club_data import ( + CLUB_BALL_SPEEDS, + CLUB_LAUNCH_DISTRIBUTIONS, + CLUB_LAUNCH_MODELS, + CLUB_PROFILES, + CLUB_SPIN_DISTRIBUTIONS, + CLUB_SPIN_MULTIPLIERS, + CLUB_TYPICAL_SPIN_RPM, + OPTIMAL_LAUNCH_ANGLES, + OPTIMAL_SMASH_FACTORS, + ClubProfile, + ClubType, + get_club_profile, + get_optimal_launch_angle, + get_optimal_smash, + get_typical_spin_rpm, +) +from openflight.launch_monitor import _OPTIMAL_LAUNCH as LM_OPTIMAL_LAUNCH +from openflight.rolling_buffer.monitor import ( + CLUB_SPIN_MULTIPLIERS as RB_SPIN_MULTIPLIERS, + OPTIMAL_SMASH_FACTORS as RB_SMASH_FACTORS, +) +from openflight.server import ( + _CLUB_LAUNCH_MODEL as SERVER_LAUNCH_MODEL, + _OPTIMAL_SMASH as SERVER_OPTIMAL_SMASH, + MockLaunchMonitor, +) +from openflight.sim.resolver import ( + _OPTIMAL_LAUNCH as SIM_OPTIMAL_LAUNCH, + SPIN_MODEL_RPM as SIM_SPIN_MODEL, +) + +WOODS = [ClubType.DRIVER, ClubType.WOOD_3, ClubType.WOOD_5, ClubType.WOOD_7] +HYBRIDS = [ClubType.HYBRID_3, ClubType.HYBRID_5, ClubType.HYBRID_7, ClubType.HYBRID_9] +IRONS_WEDGES = [ + ClubType.IRON_2, + ClubType.IRON_3, + ClubType.IRON_4, + ClubType.IRON_5, + ClubType.IRON_6, + ClubType.IRON_7, + ClubType.IRON_8, + ClubType.IRON_9, + ClubType.PW, + ClubType.GW, + ClubType.SW, + ClubType.LW, +] + + +class TestClubDataCompleteness: + def test_all_club_types_have_profiles(self): + for club in ClubType: + assert club in CLUB_PROFILES + profile = CLUB_PROFILES[club] + assert isinstance(profile, ClubProfile) + assert profile.club == club + + def test_all_derived_dictionaries_contain_all_clubs(self): + for club in ClubType: + assert club in OPTIMAL_LAUNCH_ANGLES + assert club in OPTIMAL_SMASH_FACTORS + assert club in CLUB_TYPICAL_SPIN_RPM + assert club in CLUB_LAUNCH_MODELS + assert club in CLUB_SPIN_MULTIPLIERS + assert club in CLUB_BALL_SPEEDS + assert club in CLUB_SPIN_DISTRIBUTIONS + assert club in CLUB_LAUNCH_DISTRIBUTIONS + + +class TestClubPhysicsProgression: + @pytest.mark.parametrize("family", [WOODS, HYBRIDS, IRONS_WEDGES]) + def test_optimal_launch_angles_increase_within_family(self, family): + angles = [CLUB_PROFILES[c].optimal_launch_deg for c in family] + assert angles == sorted(angles) + + @pytest.mark.parametrize("family", [WOODS, HYBRIDS, IRONS_WEDGES]) + def test_optimal_smash_factors_decrease_within_family(self, family): + smash = [CLUB_PROFILES[c].optimal_smash for c in family] + assert smash == sorted(smash, reverse=True) + + @pytest.mark.parametrize("family", [WOODS, HYBRIDS, IRONS_WEDGES]) + def test_typical_spin_increases_within_family(self, family): + spin = [CLUB_PROFILES[c].typical_spin_rpm for c in family] + assert spin == sorted(spin) + + @pytest.mark.parametrize("family", [WOODS, HYBRIDS, IRONS_WEDGES]) + def test_average_ball_speed_decreases_within_family(self, family): + speeds = [CLUB_PROFILES[c].avg_ball_speed_mph for c in family] + assert speeds == sorted(speeds, reverse=True) + + @pytest.mark.parametrize("family", [WOODS, HYBRIDS, IRONS_WEDGES]) + def test_spin_multipliers_increase_within_family(self, family): + mults = [CLUB_PROFILES[c].spin_multiplier for c in family] + assert mults == sorted(mults) + + def test_overall_extrema(self): + # Driver is lowest launch/spin, highest speed/smash + driver = CLUB_PROFILES[ClubType.DRIVER] + lw = CLUB_PROFILES[ClubType.LW] + assert driver.optimal_launch_deg == min( + p.optimal_launch_deg for p in CLUB_PROFILES.values() + ) + assert driver.optimal_smash == max(p.optimal_smash for p in CLUB_PROFILES.values()) + assert driver.avg_ball_speed_mph == max( + p.avg_ball_speed_mph for p in CLUB_PROFILES.values() + ) + assert driver.typical_spin_rpm == min(p.typical_spin_rpm for p in CLUB_PROFILES.values()) + + # Lob Wedge is highest launch/spin, lowest speed/smash + assert lw.optimal_launch_deg == max(p.optimal_launch_deg for p in CLUB_PROFILES.values()) + assert lw.optimal_smash == min(p.optimal_smash for p in CLUB_PROFILES.values()) + assert lw.avg_ball_speed_mph == min(p.avg_ball_speed_mph for p in CLUB_PROFILES.values()) + assert lw.typical_spin_rpm == max(p.typical_spin_rpm for p in CLUB_PROFILES.values()) + + +class TestCrossModuleConsistency: + def test_launch_monitor_optimal_launch_matches(self): + assert LM_OPTIMAL_LAUNCH == OPTIMAL_LAUNCH_ANGLES + + def test_ballistics_typical_spin_matches(self): + assert BALLISTICS_SPIN == CLUB_TYPICAL_SPIN_RPM + + def test_sim_resolver_tables_match(self): + assert SIM_SPIN_MODEL == CLUB_TYPICAL_SPIN_RPM + assert SIM_OPTIMAL_LAUNCH == OPTIMAL_LAUNCH_ANGLES + + def test_server_tables_match(self): + assert SERVER_LAUNCH_MODEL == CLUB_LAUNCH_MODELS + assert SERVER_OPTIMAL_SMASH == OPTIMAL_SMASH_FACTORS + assert MockLaunchMonitor._CLUB_BALL_SPEEDS == CLUB_BALL_SPEEDS + assert MockLaunchMonitor._CLUB_SPIN == CLUB_SPIN_DISTRIBUTIONS + assert MockLaunchMonitor._CLUB_LAUNCH == CLUB_LAUNCH_DISTRIBUTIONS + + def test_rolling_buffer_monitor_tables_match(self): + assert RB_SPIN_MULTIPLIERS == CLUB_SPIN_MULTIPLIERS + assert RB_SMASH_FACTORS == OPTIMAL_SMASH_FACTORS + + +class TestHelperFunctions: + def test_get_club_profile_known(self): + profile = get_club_profile(ClubType.IRON_7) + assert profile.club == ClubType.IRON_7 + assert profile.optimal_launch_deg == 20.5 + assert profile.optimal_smash == 1.27 + assert profile.typical_spin_rpm == 6500.0 + + def test_get_club_profile_unknown_and_none(self): + assert get_club_profile(None) == CLUB_PROFILES[ClubType.UNKNOWN] + assert get_club_profile(ClubType.UNKNOWN) == CLUB_PROFILES[ClubType.UNKNOWN] + + def test_get_optimal_launch_angle(self): + assert get_optimal_launch_angle(ClubType.DRIVER) == 11.0 + assert get_optimal_launch_angle(None) == 18.0 + + def test_get_optimal_smash(self): + assert get_optimal_smash(ClubType.DRIVER) == 1.48 + assert get_optimal_smash(None) == 1.35 + + def test_get_typical_spin_rpm(self): + assert get_typical_spin_rpm(ClubType.DRIVER) == 2700.0 + assert get_typical_spin_rpm(None) == 5000.0 diff --git a/tests/test_club_path_report.py b/tests/test_club_path_report.py index 337092c86..9bfb7219e 100644 --- a/tests/test_club_path_report.py +++ b/tests/test_club_path_report.py @@ -13,21 +13,31 @@ def _session(tmp_path, name, paths): target = tmp_path / name with target.open("w", encoding="utf-8") as handle: for index, value in enumerate(paths, 1): - handle.write(json.dumps({ - "type": "iwr6843_capture", - "shot_number": index, - "club_path": {"status": "accepted", "path_deg": value}, - }) + "\n") + handle.write( + json.dumps( + { + "type": "iwr6843_capture", + "shot_number": index, + "club_path": {"status": "accepted", "path_deg": value}, + } + ) + + "\n" + ) return target def test_load_group_reads_accepted_paths_only(tmp_path): path = _session(tmp_path, "a.jsonl", [1.0, 2.0]) with path.open("a", encoding="utf-8") as handle: - handle.write(json.dumps({ - "type": "iwr6843_capture", - "club_path": {"status": "rejected_no_club_track", "path_deg": None}, - }) + "\n") + handle.write( + json.dumps( + { + "type": "iwr6843_capture", + "club_path": {"status": "rejected_no_club_track", "path_deg": None}, + } + ) + + "\n" + ) assert club_path_report.load_group(path) == [1.0, 2.0] @@ -41,14 +51,19 @@ def test_load_group_counts_tdm_sign_fallback_as_accepted(tmp_path): target = tmp_path / "fallback.jsonl" with target.open("w", encoding="utf-8") as handle: for index in range(1, 11): - handle.write(json.dumps({ - "type": "iwr6843_capture", - "shot_number": index, - "club_path": { - "status": "accepted_tdm_sign_fallback", - "path_deg": float(index), - }, - }) + "\n") + handle.write( + json.dumps( + { + "type": "iwr6843_capture", + "shot_number": index, + "club_path": { + "status": "accepted_tdm_sign_fallback", + "path_deg": float(index), + }, + } + ) + + "\n" + ) assert club_path_report.load_group(target) == [float(i) for i in range(1, 11)] coverage = club_path_report.group_coverage(target) assert coverage == {"total": 10, "accepted": 10, "rejected": 0, "skipped": 0} @@ -74,11 +89,13 @@ def test_group_coverage_separates_skipped_from_rejected(tmp_path): def test_separation_passes_when_groups_order_and_clear(): - report = club_path_report.separation({ - "out-to-in": [-6.0, -5.0, -7.0, -6.5, -5.5], - "square": [0.0, 1.0, -1.0, 0.5, -0.5], - "in-to-out": [6.0, 5.0, 7.0, 6.5, 5.5], - }) + report = club_path_report.separation( + { + "out-to-in": [-6.0, -5.0, -7.0, -6.5, -5.5], + "square": [0.0, 1.0, -1.0, 0.5, -0.5], + "in-to-out": [6.0, 5.0, 7.0, 6.5, 5.5], + } + ) assert report["ordered"] is True assert report["separated"] is True assert report["reasons"] == [] @@ -89,11 +106,13 @@ def test_separation_fails_when_groups_overlap(): result) but the spread of values overlaps heavily -- the within-group stdev guard must be the thing that catches this. """ - report = club_path_report.separation({ - "out-to-in": [-1.0, 3.0, -4.0, 2.0, -2.0], - "square": [0.0, 1.0, -1.0, 0.5, -0.5], - "in-to-out": [1.0, -2.0, 4.0, -1.0, 3.0], - }) + report = club_path_report.separation( + { + "out-to-in": [-1.0, 3.0, -4.0, 2.0, -2.0], + "square": [0.0, 1.0, -1.0, 0.5, -0.5], + "in-to-out": [1.0, -2.0, 4.0, -1.0, 3.0], + } + ) assert report["ordered"] is True assert report["separated"] is False assert any("within-group spread" in reason for reason in report["reasons"]) @@ -105,22 +124,26 @@ def test_separation_fails_when_groups_have_too_few_shots(): size gate must catch that even though ordering and the naive gap check would otherwise both pass. """ - report = club_path_report.separation({ - "out-to-in": [-6.0], - "square": [0.0], - "in-to-out": [6.0], - }) + report = club_path_report.separation( + { + "out-to-in": [-6.0], + "square": [0.0], + "in-to-out": [6.0], + } + ) assert report["ordered"] is True assert report["separated"] is False assert any("n=1" in reason for reason in report["reasons"]) def test_separation_reports_insufficient_n_per_group(): - report = club_path_report.separation({ - "out-to-in": [-6.0, -5.0], - "square": [0.0, 1.0, -1.0, 0.5, -0.5], - "in-to-out": [6.0, 5.0, 7.0, 6.5, 5.5], - }) + report = club_path_report.separation( + { + "out-to-in": [-6.0, -5.0], + "square": [0.0, 1.0, -1.0, 0.5, -0.5], + "in-to-out": [6.0, 5.0, 7.0, 6.5, 5.5], + } + ) assert report["groups"]["out-to-in"]["insufficient_n"] is True assert report["groups"]["square"]["insufficient_n"] is False @@ -131,10 +154,12 @@ def test_separation_reports_both_reasons_when_a_gap_fails_both_guards(): an ``elif`` between them would silently drop whichever reason lost the race, hiding real information from a failing report. """ - report = club_path_report.separation({ - "out-to-in": [0.0, 1.0, -1.0, 0.5, -0.5], - "square": [0.1, 1.1, -0.9, 0.6, -0.4], - }) + report = club_path_report.separation( + { + "out-to-in": [0.0, 1.0, -1.0, 0.5, -0.5], + "square": [0.1, 1.1, -0.9, 0.6, -0.4], + } + ) gap_reasons = [r for r in report["reasons"] if r.startswith("out-to-in->square")] assert any("measurement floor" in r for r in gap_reasons) assert any("within-group spread" in r for r in gap_reasons) @@ -148,11 +173,13 @@ def test_separation_fails_when_gaps_are_below_measurement_precision(): them. This is the case a future refactor is most likely to break if the two guards get collapsed into one. """ - report = club_path_report.separation({ - "out-to-in": [-0.10, -0.10, -0.10, -0.10, -0.10], - "square": [0.05, 0.05, 0.05, 0.05, 0.05], - "in-to-out": [0.20, 0.20, 0.20, 0.20, 0.20], - }) + report = club_path_report.separation( + { + "out-to-in": [-0.10, -0.10, -0.10, -0.10, -0.10], + "square": [0.05, 0.05, 0.05, 0.05, 0.05], + "in-to-out": [0.20, 0.20, 0.20, 0.20, 0.20], + } + ) assert report["groups"]["out-to-in"]["insufficient_n"] is False assert report["groups"]["square"]["insufficient_n"] is False assert report["groups"]["in-to-out"]["insufficient_n"] is False diff --git a/tests/test_compare_trackman.py b/tests/test_compare_trackman.py index d13131c6a..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 @@ -170,11 +219,7 @@ 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" - ) + 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 @@ -185,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 @@ -197,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 @@ -223,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) @@ -256,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 @@ -267,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" @@ -277,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 @@ -293,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" @@ -306,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) @@ -330,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. @@ -359,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 @@ -376,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) @@ -401,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 @@ -417,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_diagnose.py b/tests/test_diagnose.py index 2b2fcc2ba..5393780ae 100644 --- a/tests/test_diagnose.py +++ b/tests/test_diagnose.py @@ -21,8 +21,11 @@ def test_default_status_fields(self): def test_with_all_fields(self): r = diagnose.CheckResult( - name="Test", status="fail", detail="something broke", - hint="try this", elapsed_s=1.5, + name="Test", + status="fail", + detail="something broke", + hint="try this", + elapsed_s=1.5, ) assert r.detail == "something broke" assert r.hint == "try this" @@ -168,7 +171,9 @@ def test_returns_skip_when_no_port(self, mock_detect): @patch("diagnose.detect_ops243_port") @patch("diagnose.OPS243Radar") def test_returns_pass_when_connects_and_returns_version( - self, mock_radar_class, mock_detect, + self, + mock_radar_class, + mock_detect, ): mock_detect.return_value = "/dev/ttyACM0" mock_radar = MagicMock() @@ -187,7 +192,9 @@ def test_returns_pass_when_connects_and_returns_version( @patch("diagnose.detect_ops243_port") @patch("diagnose.OPS243Radar") def test_returns_fail_on_connect_exception( - self, mock_radar_class, mock_detect, + self, + mock_radar_class, + mock_detect, ): mock_detect.return_value = "/dev/ttyACM0" mock_radar = MagicMock() @@ -297,7 +304,10 @@ def test_skip_when_no_kld7_detected(self, mock_detect): @patch("diagnose.KLD7Tracker") @patch("diagnose.time.sleep") def test_pass_when_frames_stream( - self, mock_sleep, mock_tracker_class, mock_detect, + self, + mock_sleep, + mock_tracker_class, + mock_detect, ): mock_detect.return_value = ["/dev/ttyUSB0"] mock_tracker = MagicMock() @@ -317,7 +327,10 @@ def test_pass_when_frames_stream( @patch("diagnose.KLD7Tracker") @patch("diagnose.time.sleep") def test_fail_when_no_frames( - self, mock_sleep, mock_tracker_class, mock_detect, + self, + mock_sleep, + mock_tracker_class, + mock_detect, ): mock_detect.return_value = ["/dev/ttyUSB0"] mock_tracker = MagicMock() @@ -334,7 +347,9 @@ def test_fail_when_no_frames( @patch("diagnose.detect_kld7_ports") @patch("diagnose.KLD7Tracker") def test_fail_when_connect_returns_false( - self, mock_tracker_class, mock_detect, + self, + mock_tracker_class, + mock_detect, ): mock_detect.return_value = ["/dev/ttyUSB0"] mock_tracker = MagicMock() @@ -370,7 +385,10 @@ def test_skip_when_only_one_kld7_detected(self, mock_detect): @patch("diagnose.KLD7Tracker") @patch("diagnose.time.sleep") def test_pass_with_second_port( - self, mock_sleep, mock_tracker_class, mock_detect, + self, + mock_sleep, + mock_tracker_class, + mock_detect, ): mock_detect.return_value = ["/dev/ttyUSB0", "/dev/ttyUSB1"] mock_tracker = MagicMock() @@ -470,17 +488,21 @@ def test_skipped_when_no_port_given(self): def test_passes_when_environment_is_clean(self): state = diagnose.DiagnosticState(ops243_port="/dev/ttyAMA0") - with patch.object(diagnose.os.path, "exists", return_value=True), \ - patch.object(diagnose, "_serial_console_units", return_value=[]), \ - patch.object(diagnose, "detect_ops243_port", return_value=None): + with ( + patch.object(diagnose.os.path, "exists", return_value=True), + patch.object(diagnose, "_serial_console_units", return_value=[]), + patch.object(diagnose, "detect_ops243_port", return_value=None), + ): result = diagnose.check_uart_preflight(state) assert result.status == "pass" def test_fails_when_device_node_missing(self): state = diagnose.DiagnosticState(ops243_port="/dev/ttyAMA0") - with patch.object(diagnose.os.path, "exists", return_value=False), \ - patch.object(diagnose, "_serial_console_units", return_value=[]), \ - patch.object(diagnose, "detect_ops243_port", return_value=None): + with ( + patch.object(diagnose.os.path, "exists", return_value=False), + patch.object(diagnose, "_serial_console_units", return_value=[]), + patch.object(diagnose, "detect_ops243_port", return_value=None), + ): result = diagnose.check_uart_preflight(state) assert result.status == "fail" assert "does not exist" in result.detail @@ -489,12 +511,15 @@ def test_fails_when_device_node_missing(self): def test_fails_when_serial_console_holds_the_port(self): """Console chatter is transmitted into the radar's RxD pin.""" state = diagnose.DiagnosticState(ops243_port="/dev/ttyAMA0") - with patch.object(diagnose.os.path, "exists", return_value=True), \ - patch.object( - diagnose, "_serial_console_units", - return_value=["serial-getty@ttyAMA0.service"], - ), \ - patch.object(diagnose, "detect_ops243_port", return_value=None): + with ( + patch.object(diagnose.os.path, "exists", return_value=True), + patch.object( + diagnose, + "_serial_console_units", + return_value=["serial-getty@ttyAMA0.service"], + ), + patch.object(diagnose, "detect_ops243_port", return_value=None), + ): result = diagnose.check_uart_preflight(state) assert result.status == "fail" assert "console" in result.detail @@ -503,9 +528,11 @@ def test_fails_when_serial_console_holds_the_port(self): def test_fails_when_ops_usb_is_also_enumerated(self): """Enumerating USB silences the UART entirely (AN-010-AD).""" state = diagnose.DiagnosticState(ops243_port="/dev/ttyAMA0") - with patch.object(diagnose.os.path, "exists", return_value=True), \ - patch.object(diagnose, "_serial_console_units", return_value=[]), \ - patch.object(diagnose, "detect_ops243_port", return_value="/dev/ttyACM0"): + with ( + patch.object(diagnose.os.path, "exists", return_value=True), + patch.object(diagnose, "_serial_console_units", return_value=[]), + patch.object(diagnose, "detect_ops243_port", return_value="/dev/ttyACM0"), + ): result = diagnose.check_uart_preflight(state) assert result.status == "fail" assert "USB" in result.detail @@ -547,8 +574,10 @@ def test_usb_detail_omits_baud(self): def test_explicit_port_overrides_autodetect(self): state = diagnose.DiagnosticState(ops243_port="/dev/ttyAMA0") - with patch.object(diagnose, "OPS243Radar", return_value=self._radar(230400)) as radar_cls, \ - patch.object(diagnose, "detect_ops243_port", return_value="/dev/ttyACM0"): + with ( + patch.object(diagnose, "OPS243Radar", return_value=self._radar(230400)) as radar_cls, + patch.object(diagnose, "detect_ops243_port", return_value="/dev/ttyACM0"), + ): diagnose.check_ops243_connectivity(state) assert radar_cls.call_args.kwargs["port"] == "/dev/ttyAMA0" diff --git a/tests/test_geekworm_setup.py b/tests/test_geekworm_setup.py index df66aa4fc..553f6a3de 100644 --- a/tests/test_geekworm_setup.py +++ b/tests/test_geekworm_setup.py @@ -1,9 +1,10 @@ -"""Tests for the Raspberry Pi Geekworm provisioning script.""" - import hashlib import subprocess +import sys from pathlib import Path +import pytest + PROJECT_ROOT = Path(__file__).resolve().parents[1] SETUP_SCRIPT = PROJECT_ROOT / "scripts" / "battery" / "geekworm" / "setup.sh" PANEL_PACKAGE = ( @@ -34,10 +35,12 @@ def _run_function(function: str, config_path: Path) -> subprocess.CompletedProce ) +@pytest.mark.skipif(sys.platform == "win32", reason="Bash setup script execution requires Linux") def test_setup_script_has_valid_bash_syntax(): subprocess.run(["bash", "-n", SETUP_SCRIPT], check=True) +@pytest.mark.skipif(sys.platform == "win32", reason="Bash setup script execution requires Linux") def test_boot_configuration_update_is_idempotent(tmp_path): config = tmp_path / "config.txt" config.write_text("[cm5]\nfoo=bar\n", encoding="ascii") @@ -55,6 +58,7 @@ def test_boot_configuration_update_is_idempotent(tmp_path): assert "\n[all]\n# OpenFlight Geekworm" in first_content +@pytest.mark.skipif(sys.platform == "win32", reason="Bash setup script execution requires Linux") def test_boot_configuration_accepts_existing_openflight_settings(tmp_path): config = tmp_path / "config.txt" original = """\ @@ -71,6 +75,7 @@ def test_boot_configuration_accepts_existing_openflight_settings(tmp_path): assert config.read_text(encoding="ascii") == original +@pytest.mark.skipif(sys.platform == "win32", reason="Bash setup script execution requires Linux") def test_boot_configuration_rejects_conflicting_charger_overlay(tmp_path): config = tmp_path / "config.txt" config.write_text( @@ -84,6 +89,7 @@ def test_boot_configuration_rejects_conflicting_charger_overlay(tmp_path): assert "different gpio-charger overlay" in result.stderr +@pytest.mark.skipif(sys.platform == "win32", reason="Bash setup script execution requires Linux") def test_eeprom_configuration_replaces_and_deduplicates_power_settings(tmp_path): config = tmp_path / "eeprom.conf" config.write_text( diff --git a/tests/test_gpio_pin_factory.py b/tests/test_gpio_pin_factory.py index 176084419..8370c6ea9 100644 --- a/tests/test_gpio_pin_factory.py +++ b/tests/test_gpio_pin_factory.py @@ -61,7 +61,9 @@ def _reset(): def _install(**kwargs): - with patch("openflight.gpio_factory._load_gpiozero", return_value=(FakeDevice, FakeLGPIOFactory)): + with patch( + "openflight.gpio_factory._load_gpiozero", return_value=(FakeDevice, FakeLGPIOFactory) + ): return ensure_lgpio_pin_factory(**kwargs) @@ -75,8 +77,10 @@ def test_falls_back_to_chip_zero(self): assert detect_gpio_chip() == 0 def test_env_override_wins(self): - with patch.dict(os.environ, {GPIO_CHIP_ENV: "0"}), \ - patch.object(os.path, "exists", lambda p: p == "/dev/gpiochip4"): + with ( + patch.dict(os.environ, {GPIO_CHIP_ENV: "0"}), + patch.object(os.path, "exists", lambda p: p == "/dev/gpiochip4"), + ): assert detect_gpio_chip() == 0 def test_bad_env_override_is_rejected_loudly(self): @@ -160,10 +164,13 @@ def close(self): pass monitor = self._monitor(tmp_path) - with patch( - "openflight.iwr6843.monitor.ensure_lgpio_pin_factory", - side_effect=lambda: calls.append("factory"), - ), patch.dict("sys.modules"): + with ( + patch( + "openflight.iwr6843.monitor.ensure_lgpio_pin_factory", + side_effect=lambda: calls.append("factory"), + ), + patch.dict("sys.modules"), + ): import sys import types @@ -191,9 +198,7 @@ def close(self): pass monitor = self._monitor(tmp_path, button_factory=FakeButton) - with patch( - "openflight.iwr6843.monitor.ensure_lgpio_pin_factory" - ) as ensure: + with patch("openflight.iwr6843.monitor.ensure_lgpio_pin_factory") as ensure: monitor.start(armed=False) try: ensure.assert_not_called() diff --git a/tests/test_gspro_codec.py b/tests/test_gspro_codec.py index 3fb2b779b..1b4bcb0b1 100644 --- a/tests/test_gspro_codec.py +++ b/tests/test_gspro_codec.py @@ -1,4 +1,5 @@ """Tests for gspro.codec — OpenConnectV1 wire serialization + inbound parsing.""" + import json from openflight.gspro.codec import GSProCodec @@ -8,10 +9,18 @@ def _resolved(**kw) -> ResolvedShot: base = dict( - shot_number=1, ball_speed_mph=140.0, vla=12.0, hla=1.5, - total_spin_rpm=2500.0, spin_axis_deg=-3.0, back_spin_rpm=2496.6, - side_spin_rpm=-130.8, carry_yards=255.0, club_path_deg=0.5, - club=ClubType.DRIVER, club_speed_mph=110.0, + shot_number=1, + ball_speed_mph=140.0, + vla=12.0, + hla=1.5, + total_spin_rpm=2500.0, + spin_axis_deg=-3.0, + back_spin_rpm=2496.6, + side_spin_rpm=-130.8, + carry_yards=255.0, + club_path_deg=0.5, + club=ClubType.DRIVER, + club_speed_mph=110.0, provenance={}, ) base.update(kw) diff --git a/tests/test_gspro_messages.py b/tests/test_gspro_messages.py index 1ef2f21f1..c96f5ee3f 100644 --- a/tests/test_gspro_messages.py +++ b/tests/test_gspro_messages.py @@ -1,20 +1,36 @@ """Tests for src/openflight/gspro/messages.py.""" + import json import pytest from openflight.gspro.messages import ( - BallData, ClubData, GSProResponse, ShotDataOptions, ShotPayload, - parse_response, serialize_payload, build_heartbeat, + BallData, + ClubData, + ShotDataOptions, + ShotPayload, + build_heartbeat, + parse_response, + serialize_payload, ) def test_serialize_minimum_shot(): payload = ShotPayload( - DeviceID="OpenFlight", Units="Yards", ShotNumber=1, APIversion="1", - BallData=BallData(Speed=147.5, HLA=2.3, VLA=14.3, TotalSpin=2500.0, - SpinAxis=-3.0, BackSpin=2496.6, SideSpin=-130.8, - CarryDistance=240.0), + DeviceID="OpenFlight", + Units="Yards", + ShotNumber=1, + APIversion="1", + BallData=BallData( + Speed=147.5, + HLA=2.3, + VLA=14.3, + TotalSpin=2500.0, + SpinAxis=-3.0, + BackSpin=2496.6, + SideSpin=-130.8, + CarryDistance=240.0, + ), ClubData=ClubData(Speed=110.0, Path=1.0), ShotDataOptions=ShotDataOptions(), ) @@ -29,20 +45,48 @@ def test_serialize_minimum_shot(): def test_serialize_includes_all_required_keys(): payload = ShotPayload( - DeviceID="X", Units="Yards", ShotNumber=1, APIversion="1", - BallData=BallData(), ClubData=ClubData(), + DeviceID="X", + Units="Yards", + ShotNumber=1, + APIversion="1", + BallData=BallData(), + ClubData=ClubData(), ShotDataOptions=ShotDataOptions(), ) obj = json.loads(serialize_payload(payload)) - for key in ("DeviceID", "Units", "ShotNumber", "APIversion", - "BallData", "ClubData", "ShotDataOptions"): + for key in ( + "DeviceID", + "Units", + "ShotNumber", + "APIversion", + "BallData", + "ClubData", + "ShotDataOptions", + ): assert key in obj - for key in ("Speed", "SpinAxis", "TotalSpin", "BackSpin", "SideSpin", - "HLA", "VLA", "CarryDistance"): + for key in ( + "Speed", + "SpinAxis", + "TotalSpin", + "BackSpin", + "SideSpin", + "HLA", + "VLA", + "CarryDistance", + ): assert key in obj["BallData"] - for key in ("Speed", "AngleOfAttack", "FaceToTarget", "Lie", "Loft", - "Path", "SpeedAtImpact", "VerticalFaceImpact", - "HorizontalFaceImpact", "ClosureRate"): + for key in ( + "Speed", + "AngleOfAttack", + "FaceToTarget", + "Lie", + "Loft", + "Path", + "SpeedAtImpact", + "VerticalFaceImpact", + "HorizontalFaceImpact", + "ClosureRate", + ): assert key in obj["ClubData"] diff --git a/tests/test_gspro_state.py b/tests/test_gspro_state.py index adcfecc9d..0c5a42ad3 100644 --- a/tests/test_gspro_state.py +++ b/tests/test_gspro_state.py @@ -1,4 +1,5 @@ """Tests for gspro.state — GSPro club-code mapping.""" + from openflight.gspro.state import gspro_code_to_club from openflight.launch_monitor import ClubType @@ -28,9 +29,26 @@ def test_all_openconnect_codes_from_ogs_clubsync_map_to_real_clubs(): here, or club sync would silently produce UNKNOWN. """ codes = [ - "DR", "W3", "W5", "W7", "H3", "H5", "H7", "H9", - "I2", "I3", "I4", "I5", "I6", "I7", "I8", "I9", - "PW", "GW", "SW", "LW", + "DR", + "W3", + "W5", + "W7", + "H3", + "H5", + "H7", + "H9", + "I2", + "I3", + "I4", + "I5", + "I6", + "I7", + "I8", + "I9", + "PW", + "GW", + "SW", + "LW", ] for code in codes: assert gspro_code_to_club(code) is not ClubType.UNKNOWN, code diff --git a/tests/test_iwr6843_channel_selection.py b/tests/test_iwr6843_channel_selection.py index f3aeeba5d..89b458f06 100644 --- a/tests/test_iwr6843_channel_selection.py +++ b/tests/test_iwr6843_channel_selection.py @@ -41,12 +41,8 @@ def test_collapsed_channel_is_dropped(): def test_spread_threshold_boundary(): - just_inside = lcmf.combine_channels( - {"a": 10.0, "b": 17.9}, {"a": 1.0, "b": 1.0} - ) - just_outside = lcmf.combine_channels( - {"a": 10.0, "b": 18.1}, {"a": 0.1, "b": 1.0} - ) + just_inside = lcmf.combine_channels({"a": 10.0, "b": 17.9}, {"a": 1.0, "b": 1.0}) + just_outside = lcmf.combine_channels({"a": 10.0, "b": 18.1}, {"a": 0.1, "b": 1.0}) assert just_inside[2] is False assert just_outside[2] is True @@ -66,7 +62,7 @@ def test_curvature_measures_sharpness_of_the_minimum(): def test_edge_minimum_scores_none_not_zero(): - """"Not measured" and "flat" are different verdicts and must not collide. + """ "Not measured" and "flat" are different verdicts and must not collide. An argmin on the grid edge means the true minimum lies OUTSIDE the searched range -- the grid runs to 45 deg and a lofted club can exceed @@ -77,8 +73,8 @@ def test_edge_minimum_scores_none_not_zero(): A flat array is the same failure, not a separate "flat" case: every value ties, so argmin lands on index 0. """ - rising = np.arange(41, dtype=float) # minimum at index 0 - falling = rising[::-1].copy() # minimum at the last index + rising = np.arange(41, dtype=float) # minimum at index 0 + falling = rising[::-1].copy() # minimum at the last index assert lcmf.grid_curvature(rising) is None assert lcmf.grid_curvature(falling) is None assert lcmf.grid_curvature(np.zeros(41)) is None diff --git a/tests/test_iwr6843_club_path.py b/tests/test_iwr6843_club_path.py index d36241720..34d09b9b5 100644 --- a/tests/test_iwr6843_club_path.py +++ b/tests/test_iwr6843_club_path.py @@ -95,7 +95,11 @@ def _synth_club(path_deg, *, club_speed_ms=22.0, tee_range_m=1.372, n_samples=12 value = amp * az_factor * np.exp(1j * (tdm_phase + doppler_phase)) cube[frame, loop * n_tx + tx, :, bin_at] = value return pack_dump( - cube, n_tx=n_tx, version=3, frame_period_us=4000, trigger_frame=0, + cube, + n_tx=n_tx, + version=3, + frame_period_us=4000, + trigger_frame=0, sample_fmt=SAMPLE_RANGE_FFT_IQ16, ) @@ -127,19 +131,13 @@ def test_recovers_known_path(path_deg): def test_sign_convention_is_in_to_out_positive(): """A club moving rightward relative to the target line reads positive.""" - out_in = club.estimate_club_path( - _synth_club(-6.0), _cal(), ops_club_speed_mph=74.0, tdm_sign=1 - ) - in_out = club.estimate_club_path( - _synth_club(6.0), _cal(), ops_club_speed_mph=74.0, tdm_sign=1 - ) + out_in = club.estimate_club_path(_synth_club(-6.0), _cal(), ops_club_speed_mph=74.0, tdm_sign=1) + in_out = club.estimate_club_path(_synth_club(6.0), _cal(), ops_club_speed_mph=74.0, tdm_sign=1) assert in_out.path_deg > 0 > out_in.path_deg def test_aim_offset_is_added(): - without = club.estimate_club_path( - _synth_club(0.0), _cal(), ops_club_speed_mph=74.0, tdm_sign=1 - ) + without = club.estimate_club_path(_synth_club(0.0), _cal(), ops_club_speed_mph=74.0, tdm_sign=1) with_offset = club.estimate_club_path( _synth_club(0.0), _cal(), ops_club_speed_mph=74.0, tdm_sign=1, aim_offset_deg=2.0 ) @@ -147,15 +145,22 @@ def test_aim_offset_is_added(): def test_result_serialises(): - result = club.estimate_club_path( - _synth_club(3.0), _cal(), ops_club_speed_mph=74.0, tdm_sign=1 - ) + result = club.estimate_club_path(_synth_club(3.0), _cal(), ops_club_speed_mph=74.0, tdm_sign=1) payload = result.to_dict() assert payload["status"] == "accepted" assert set(payload) >= { - "status", "path_deg", "confidence", "azimuth_rate_dps", "range_rate_ms", - "club_range_m", "n_frames", "n_snapshots", "fit_residual_deg", - "track_rms_bins", "track_inliers", "track_span_s", + "status", + "path_deg", + "confidence", + "azimuth_rate_dps", + "range_rate_ms", + "club_range_m", + "n_frames", + "n_snapshots", + "fit_residual_deg", + "track_rms_bins", + "track_inliers", + "track_span_s", } @@ -170,8 +175,7 @@ def test_two_tx_dump_is_rejected(): """Club path needs TX2; a 2-TX dump has no horizontal aperture.""" cube = np.zeros((18, 24, 4, 128), dtype=complex) cube[:, :, :, 30] = 1000.0 - raw = pack_dump(cube, n_tx=2, version=3, frame_period_us=4000, - sample_fmt=SAMPLE_RANGE_FFT_IQ16) + raw = pack_dump(cube, n_tx=2, version=3, frame_period_us=4000, sample_fmt=SAMPLE_RANGE_FFT_IQ16) result = club.estimate_club_path(raw, _cal(), ops_club_speed_mph=74.0) assert result.status == "rejected_requires_three_tx" assert result.path_deg is None @@ -179,8 +183,7 @@ def test_two_tx_dump_is_rejected(): def test_empty_dump_reports_no_club_track(): cube = np.zeros((18, 36, 4, 128), dtype=complex) - raw = pack_dump(cube, n_tx=3, version=3, frame_period_us=4000, - sample_fmt=SAMPLE_RANGE_FFT_IQ16) + raw = pack_dump(cube, n_tx=3, version=3, frame_period_us=4000, sample_fmt=SAMPLE_RANGE_FFT_IQ16) result = club.estimate_club_path(raw, _cal(), ops_club_speed_mph=74.0) assert result.status == "rejected_no_club_track" assert result.path_deg is None @@ -188,9 +191,7 @@ def test_empty_dump_reports_no_club_track(): def test_club_speed_mismatch_is_rejected(): """A 22 m/s radial track cannot belong to a 20 mph club.""" - result = club.estimate_club_path( - _synth_club(0.0), _cal(), ops_club_speed_mph=20.0, tdm_sign=1 - ) + result = club.estimate_club_path(_synth_club(0.0), _cal(), ops_club_speed_mph=20.0, tdm_sign=1) assert result.status == "rejected_club_speed_mismatch" assert result.path_deg is None assert result.range_rate_ms is not None, "evidence must survive the rejection" @@ -198,9 +199,7 @@ def test_club_speed_mismatch_is_rejected(): def test_rejections_carry_their_evidence(): """A threshold that rejects a value must record the value it rejected.""" - result = club.estimate_club_path( - _synth_club(0.0), _cal(), ops_club_speed_mph=20.0, tdm_sign=1 - ) + result = club.estimate_club_path(_synth_club(0.0), _cal(), ops_club_speed_mph=20.0, tdm_sign=1) payload = result.to_dict() assert payload["range_rate_ms"] is not None assert payload["track_inliers"] is not None @@ -210,26 +209,21 @@ def test_short_ring_reports_no_pre_impact_frames(): """A ring with no pre-impact window cannot produce club path.""" cube = np.zeros((4, 36, 4, 128), dtype=complex) cube[:, :, :, 30] = 1000.0 - raw = pack_dump(cube, n_tx=3, version=3, frame_period_us=4000, - sample_fmt=SAMPLE_RANGE_FFT_IQ16) + raw = pack_dump(cube, n_tx=3, version=3, frame_period_us=4000, sample_fmt=SAMPLE_RANGE_FFT_IQ16) result = club.estimate_club_path(raw, _cal(), ops_club_speed_mph=74.0) assert result.status == "rejected_no_pre_impact_frames" def test_insufficient_snapshots_is_rejected(monkeypatch): monkeypatch.setattr(club, "CLUB_MIN_SNAPSHOTS", 10_000) - result = club.estimate_club_path( - _synth_club(0.0), _cal(), ops_club_speed_mph=74.0, tdm_sign=1 - ) + result = club.estimate_club_path(_synth_club(0.0), _cal(), ops_club_speed_mph=74.0, tdm_sign=1) assert result.status == "rejected_insufficient_snapshots" assert result.n_snapshots > 0, "the count that failed must be recorded" def test_azimuth_fit_residual_is_rejected(monkeypatch): monkeypatch.setattr(club, "CLUB_MAX_AZIMUTH_FIT_RESIDUAL_DEG", 1e-9) - result = club.estimate_club_path( - _synth_club(4.0), _cal(), ops_club_speed_mph=74.0, tdm_sign=1 - ) + result = club.estimate_club_path(_synth_club(4.0), _cal(), ops_club_speed_mph=74.0, tdm_sign=1) assert result.status == "rejected_azimuth_fit" assert result.fit_residual_deg is not None @@ -237,9 +231,7 @@ def test_azimuth_fit_residual_is_rejected(monkeypatch): def test_phase_wrap_is_rejected(monkeypatch): """The true azimuth swing is ~0.04 rad; anything near a wrap is a bad track.""" monkeypatch.setattr(club, "CLUB_MAX_PHASE_SWING_RAD", 1e-6) - result = club.estimate_club_path( - _synth_club(4.0), _cal(), ops_club_speed_mph=74.0, tdm_sign=1 - ) + result = club.estimate_club_path(_synth_club(4.0), _cal(), ops_club_speed_mph=74.0, tdm_sign=1) assert result.status == "rejected_phase_wrap" @@ -255,13 +247,19 @@ def test_club_search_does_not_fit_the_ball(monkeypatch): for frame in range(n_frames): for loop in range(loops): t = frame * 4e-3 + loop * 90e-6 - if t < 0.030: # nothing before the ball launches + if t < 0.030: # nothing before the ball launches continue bin_at = int((1.5 + 40.0 * (t - 0.030)) / res) if 0 <= bin_at < n_samples: cube[frame, loop * n_tx : (loop + 1) * n_tx, :, bin_at] = 1000.0 - raw = pack_dump(cube, n_tx=n_tx, version=3, frame_period_us=4000, trigger_frame=0, - sample_fmt=SAMPLE_RANGE_FFT_IQ16) + raw = pack_dump( + cube, + n_tx=n_tx, + version=3, + frame_period_us=4000, + trigger_frame=0, + sample_fmt=SAMPLE_RANGE_FFT_IQ16, + ) result = club.estimate_club_path(raw, _cal(), ops_club_speed_mph=74.0, tdm_sign=1) assert result.status != "accepted", ( f"fitted a post-impact mover as club path: {result.to_dict()}" diff --git a/tests/test_iwr6843_club_track.py b/tests/test_iwr6843_club_track.py index af8e61ef8..eaf34c823 100644 --- a/tests/test_iwr6843_club_track.py +++ b/tests/test_iwr6843_club_track.py @@ -96,17 +96,14 @@ def test_speed_bounds_are_the_callers_not_the_module_default(): with_ball_bounds = _track(raw, speed_bounds_ms=tracking.SPEED_BOUNDS_MS, **shared) assert with_ball_bounds is None, ( - "12 m/s is below the ball band's 20 m/s floor and must be rejected; " - f"got {with_ball_bounds}" + f"12 m/s is below the ball band's 20 m/s floor and must be rejected; got {with_ball_bounds}" ) def test_ball_behaviour_unchanged_by_defaults(): """Defaults must reproduce today's behaviour exactly.""" raw = _synth(2.6, 45.0) - explicit = _track( - raw, gates_m=tracking.BALL_GATES_M, speed_bounds_ms=tracking.SPEED_BOUNDS_MS - ) + explicit = _track(raw, gates_m=tracking.BALL_GATES_M, speed_bounds_ms=tracking.SPEED_BOUNDS_MS) implicit = _track(raw) assert (explicit is None) == (implicit is None) if explicit is not None: diff --git a/tests/test_iwr6843_real_captures.py b/tests/test_iwr6843_real_captures.py index 5a4bf60d7..b0e7a813d 100644 --- a/tests/test_iwr6843_real_captures.py +++ b/tests/test_iwr6843_real_captures.py @@ -54,8 +54,12 @@ def _estimate(capture): ball_height_m=0.040, ) return estimate_lcmf_v1( - raw, cal, ball_speed_mph=capture["ball_speed_mph"], club="7i", - net_range_m=4.064, tx_order="normal", + raw, + cal, + ball_speed_mph=capture["ball_speed_mph"], + club="7i", + net_range_m=4.064, + tx_order="normal", ) diff --git a/tests/test_iwr6843_span_gate.py b/tests/test_iwr6843_span_gate.py index 648ad1fbe..b9862e173 100644 --- a/tests/test_iwr6843_span_gate.py +++ b/tests/test_iwr6843_span_gate.py @@ -36,8 +36,8 @@ def __init__(self, rms, inliers): self.t_first = 0.0 self.t_last = 0.033 - assert shotmod.track_broken(FakeTrack(0.60, 40)) is True # rms >= 0.50 - assert shotmod.track_broken(FakeTrack(0.20, 27)) is True # inliers < 28 + assert shotmod.track_broken(FakeTrack(0.60, 40)) is True # rms >= 0.50 + assert shotmod.track_broken(FakeTrack(0.20, 27)) is True # inliers < 28 assert shotmod.track_broken(FakeTrack(0.20, 40)) is False diff --git a/tests/test_launch_monitor.py b/tests/test_launch_monitor.py index f4d6ac86b..c0add6198 100644 --- a/tests/test_launch_monitor.py +++ b/tests/test_launch_monitor.py @@ -1,13 +1,14 @@ """Tests for launch_monitor module.""" -import pytest from datetime import datetime +import pytest + from openflight.launch_monitor import ( - Shot, ClubType, - estimate_carry_distance, + Shot, adjust_carry_for_launch_angle, + estimate_carry_distance, ) @@ -125,7 +126,8 @@ def test_carry_adjusts_for_launch_angle(self): """Shot with launch angle should adjust carry distance.""" shot_no_angle = Shot(ball_speed_mph=150.0, timestamp=datetime.now()) shot_low_angle = Shot( - ball_speed_mph=150.0, timestamp=datetime.now(), + ball_speed_mph=150.0, + timestamp=datetime.now(), launch_angle_vertical=7.0, # well below 11 optimal for driver launch_angle_confidence=1.0, ) @@ -141,11 +143,14 @@ def test_carry_range_tighter_with_angle(self): """Shot with launch angle should have tighter carry range.""" shot_no_angle = Shot(ball_speed_mph=150.0, timestamp=datetime.now()) shot_angle = Shot( - ball_speed_mph=150.0, timestamp=datetime.now(), + ball_speed_mph=150.0, + timestamp=datetime.now(), launch_angle_vertical=11.0, launch_angle_confidence=0.5, ) - no_angle_spread = shot_no_angle.estimated_carry_range[1] - shot_no_angle.estimated_carry_range[0] + no_angle_spread = ( + shot_no_angle.estimated_carry_range[1] - shot_no_angle.estimated_carry_range[0] + ) angle_spread = shot_angle.estimated_carry_range[1] - shot_angle.estimated_carry_range[0] assert angle_spread < no_angle_spread @@ -216,7 +221,7 @@ def test_set_num_reports_single_digit(self): # Verify the method exists and handles single digits # Can't test actual command without hardware, but method should not raise - assert hasattr(radar, 'set_num_reports') + assert hasattr(radar, "set_num_reports") def test_direction_constants(self): """Verify direction enum values.""" diff --git a/tests/test_ops243.py b/tests/test_ops243.py index 9fea1b9f0..34430d38c 100644 --- a/tests/test_ops243.py +++ b/tests/test_ops243.py @@ -189,10 +189,7 @@ def test_returns_last_valid_complete_line(self): """When several lines arrive together, the newest valid reading wins.""" radar = OPS243Radar.__new__(OPS243Radar) radar.serial = _ChunkedSpeedSerial( - [ - '{"magnitude":21.0, "speed":-21.22}\n' - '{"magnitude":90.0, "speed":-44.07}\n' - ] + ['{"magnitude":21.0, "speed":-21.22}\n{"magnitude":90.0, "speed":-44.07}\n'] ) radar._json_mode = True radar._unit = "mph" @@ -209,10 +206,7 @@ def test_multi_candidate_reader_returns_all_array_speeds(self): """Multi-object JSON should expose all speed candidates.""" radar = OPS243Radar.__new__(OPS243Radar) radar.serial = _ChunkedSpeedSerial( - [ - '{"magnitude":[900.0, 120.0, 80.0], ' - '"speed":[-55.0, -101.0, 42.0]}\n' - ] + ['{"magnitude":[900.0, 120.0, 80.0], "speed":[-55.0, -101.0, 42.0]}\n'] ) radar._json_mode = True radar._unit = "mph" @@ -240,13 +234,21 @@ class FakeSerial: radar = OPS243Radar.__new__(OPS243Radar) radar.serial = FakeSerial() - monkeypatch.setattr(radar, "set_units", lambda unit: calls.append(("set_units", unit.value))) - monkeypatch.setattr(radar, "set_transmit_power", lambda value: calls.append(("set_transmit_power", value))) - monkeypatch.setattr(radar, "set_sample_rate", lambda value: calls.append(("set_sample_rate", value))) + monkeypatch.setattr( + radar, "set_units", lambda unit: calls.append(("set_units", unit.value)) + ) + monkeypatch.setattr( + radar, "set_transmit_power", lambda value: calls.append(("set_transmit_power", value)) + ) + monkeypatch.setattr( + radar, "set_sample_rate", lambda value: calls.append(("set_sample_rate", value)) + ) monkeypatch.setattr( radar, "rearm_rolling_buffer", - lambda pre_trigger_segments: calls.append(("rearm_rolling_buffer", pre_trigger_segments)), + lambda pre_trigger_segments: calls.append( + ("rearm_rolling_buffer", pre_trigger_segments) + ), ) monkeypatch.setattr( radar, @@ -282,8 +284,12 @@ def reset_input_buffer(self): radar = OPS243Radar.__new__(OPS243Radar) radar.serial = FakeSerial() - monkeypatch.setattr(radar, "set_units", lambda unit: calls.append(("set_units", unit.value))) - monkeypatch.setattr(radar, "set_transmit_power", lambda value: calls.append(("set_transmit_power", value))) + monkeypatch.setattr( + radar, "set_units", lambda unit: calls.append(("set_units", unit.value)) + ) + monkeypatch.setattr( + radar, "set_transmit_power", lambda value: calls.append(("set_transmit_power", value)) + ) monkeypatch.setattr( radar, "enter_rolling_buffer_mode", diff --git a/tests/test_rolling_buffer.py b/tests/test_rolling_buffer.py index aea4c0e3e..9d5e26be8 100644 --- a/tests/test_rolling_buffer.py +++ b/tests/test_rolling_buffer.py @@ -554,10 +554,7 @@ class FakeSerial: def __init__(self): self._response = ( - b'{"sample_time": 1.0}\r\n' - b'{"trigger_time": 1.1}\r\n' - b'{"I": [1]}\r\n' - b'{"Q": [1]}' + b'{"sample_time": 1.0}\r\n{"trigger_time": 1.1}\r\n{"I": [1]}\r\n{"Q": [1]}' ) @property diff --git a/tests/test_serial_latency.py b/tests/test_serial_latency.py index cd19232ab..24f3a6008 100644 --- a/tests/test_serial_latency.py +++ b/tests/test_serial_latency.py @@ -1,10 +1,15 @@ -"""Tests for USB serial latency timer diagnostics.""" - +import sys from pathlib import Path +import pytest + from openflight.serial_latency import read_usb_serial_latency_timer +@pytest.mark.skipif( + sys.platform == "win32", + reason="Symlink creation requires elevated privileges 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 36b060b6c..9b89751fe 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -1017,7 +1017,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( @@ -1169,7 +1171,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) @@ -1241,7 +1246,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. @@ -2756,9 +2760,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) @@ -3019,9 +3021,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() diff --git a/tests/test_sim_concurrent.py b/tests/test_sim_concurrent.py index 7a09a5e7d..b2400f1d6 100644 --- a/tests/test_sim_concurrent.py +++ b/tests/test_sim_concurrent.py @@ -3,6 +3,7 @@ Both ride the shared OpenConnect V1 codec; they differ only in target/name. The shot must reach each connector's own endpoint independently. """ + import json import sys import threading @@ -46,10 +47,8 @@ def test_shot_reaches_both_sims(): ogs_srv = MockSimServer() try: cfgs = [ - ConnectorConfig(type="gspro", enabled=True, host=gspro_srv.host, - port=gspro_srv.port), - ConnectorConfig(type="opengolfsim", enabled=True, host=ogs_srv.host, - port=ogs_srv.port), + ConnectorConfig(type="gspro", enabled=True, host=gspro_srv.host, port=gspro_srv.port), + ConnectorConfig(type="opengolfsim", enabled=True, host=ogs_srv.host, port=ogs_srv.port), ] connectors = build_connectors(cfgs) assert {c.name for c in connectors} == {"gspro", "opengolfsim"} @@ -58,9 +57,13 @@ def test_shot_reaches_both_sims(): try: assert all(_wait(c, ConnectionState.CONNECTED) for c in connectors) - shot = Shot(ball_speed_mph=135.0, timestamp=datetime(2026, 6, 13, 12, 0, 0), - club=ClubType.DRIVER, launch_angle_vertical=11.1, - launch_angle_horizontal=1.2) + shot = Shot( + ball_speed_mph=135.0, + timestamp=datetime(2026, 6, 13, 12, 0, 0), + club=ClubType.DRIVER, + launch_angle_vertical=11.1, + launch_angle_horizontal=1.2, + ) resolved = resolve_shot(shot, PlayerState()) for c in connectors: c.send_shot(resolved) diff --git a/tests/test_sim_config.py b/tests/test_sim_config.py index ca3138758..7cfc2e33e 100644 --- a/tests/test_sim_config.py +++ b/tests/test_sim_config.py @@ -1,4 +1,5 @@ """Tests for sim.config — config/sim.json parsing (enabled connectors only).""" + import json import pytest @@ -17,9 +18,14 @@ def test_missing_file_means_no_connectors(tmp_path): def test_file_enabled_connector_loaded(tmp_path): - p = _write(tmp_path, {"connectors": [ - {"type": "gspro", "enabled": True, "host": "10.0.0.5", "port": 921}, - ]}) + p = _write( + tmp_path, + { + "connectors": [ + {"type": "gspro", "enabled": True, "host": "10.0.0.5", "port": 921}, + ] + }, + ) cfgs = load_sim_config(config_path=p) assert len(cfgs) == 1 assert cfgs[0].type == "gspro" @@ -28,10 +34,15 @@ def test_file_enabled_connector_loaded(tmp_path): def test_disabled_connectors_excluded(tmp_path): - p = _write(tmp_path, {"connectors": [ - {"type": "gspro", "enabled": False, "port": 921}, - {"type": "opengolfsim", "enabled": True}, - ]}) + p = _write( + tmp_path, + { + "connectors": [ + {"type": "gspro", "enabled": False, "port": 921}, + {"type": "opengolfsim", "enabled": True}, + ] + }, + ) cfgs = load_sim_config(config_path=p) assert [c.type for c in cfgs] == ["opengolfsim"] @@ -48,18 +59,28 @@ def test_opengolfsim_default_port(tmp_path): def test_explicit_port_overrides_default(tmp_path): - p = _write(tmp_path, {"connectors": [ - {"type": "opengolfsim", "enabled": True, "host": "192.168.1.9", "port": 9000}, - ]}) + p = _write( + tmp_path, + { + "connectors": [ + {"type": "opengolfsim", "enabled": True, "host": "192.168.1.9", "port": 9000}, + ] + }, + ) cfg = load_sim_config(config_path=p)[0] assert cfg.host == "192.168.1.9" and cfg.port == 9000 def test_multiple_connectors_both_enabled(tmp_path): - p = _write(tmp_path, {"connectors": [ - {"type": "gspro", "enabled": True}, - {"type": "opengolfsim", "enabled": True}, - ]}) + p = _write( + tmp_path, + { + "connectors": [ + {"type": "gspro", "enabled": True}, + {"type": "opengolfsim", "enabled": True}, + ] + }, + ) assert {c.type for c in load_sim_config(config_path=p)} == {"gspro", "opengolfsim"} @@ -92,8 +113,13 @@ def test_unreadable_path_degrades_to_empty(tmp_path): def test_malformed_connector_entry_skipped_others_kept(tmp_path): """One broken connector (non-int port) is skipped with a warning, not allowed to drop the valid ones (PR #115 review #4).""" - p = _write(tmp_path, {"connectors": [ - {"type": "gspro", "enabled": True, "port": "not-a-number"}, - {"type": "opengolfsim", "enabled": True, "port": 3111}, - ]}) + p = _write( + tmp_path, + { + "connectors": [ + {"type": "gspro", "enabled": True, "port": "not-a-number"}, + {"type": "opengolfsim", "enabled": True, "port": 3111}, + ] + }, + ) assert [c.type for c in load_sim_config(config_path=p)] == ["opengolfsim"] diff --git a/tests/test_sim_connector.py b/tests/test_sim_connector.py index 7e2ac462e..8a394199a 100644 --- a/tests/test_sim_connector.py +++ b/tests/test_sim_connector.py @@ -1,4 +1,5 @@ """Tests for sim.codec — SimConnector wiring and the codec registry.""" + import json import socket import time @@ -7,17 +8,26 @@ from openflight.gspro.codec import GSProCodec from openflight.launch_monitor import ClubType -from openflight.sim.codec import build_connector, SimConnector +from openflight.sim.codec import SimConnector, build_connector from openflight.sim.config import ConnectorConfig from openflight.sim.types import ConnectionState, PlayerUpdate, ResolvedShot def _resolved() -> ResolvedShot: return ResolvedShot( - shot_number=3, ball_speed_mph=120.0, vla=14.0, hla=0.0, - total_spin_rpm=3000.0, spin_axis_deg=0.0, back_spin_rpm=3000.0, - side_spin_rpm=0.0, carry_yards=210.0, club_path_deg=0.0, - club=ClubType.IRON_7, club_speed_mph=90.0, provenance={}, + shot_number=3, + ball_speed_mph=120.0, + vla=14.0, + hla=0.0, + total_spin_rpm=3000.0, + spin_axis_deg=0.0, + back_spin_rpm=3000.0, + side_spin_rpm=0.0, + carry_yards=210.0, + club_path_deg=0.0, + club=ClubType.IRON_7, + club_speed_mph=90.0, + provenance={}, ) @@ -32,8 +42,12 @@ def _wait(connector, state, deadline=3.0): def test_connector_routes_status_with_target(mock_sim): statuses = [] - c = SimConnector(GSProCodec(), mock_sim.host, mock_sim.port, - on_status=lambda name, evt: statuses.append((name, evt))) + c = SimConnector( + GSProCodec(), + mock_sim.host, + mock_sim.port, + on_status=lambda name, evt: statuses.append((name, evt)), + ) c.start() try: assert _wait(c, ConnectionState.CONNECTED) @@ -45,8 +59,12 @@ def test_connector_routes_status_with_target(mock_sim): def test_connector_routes_inbound_with_target(mock_sim): inbound = [] - c = SimConnector(GSProCodec(), mock_sim.host, mock_sim.port, - on_inbound=lambda name, evt: inbound.append((name, evt))) + c = SimConnector( + GSProCodec(), + mock_sim.host, + mock_sim.port, + on_inbound=lambda name, evt: inbound.append((name, evt)), + ) mock_sim.queue_reply({"Code": 201, "Player": {"Club": "I7"}}) c.start() try: @@ -88,9 +106,13 @@ def test_first_connect_failure_stays_connecting_not_reconnecting(): probe.close() states = [] - c = SimConnector(GSProCodec(), "127.0.0.1", closed_port, - on_status=lambda name, evt: states.append(evt.state), - backoff_seconds=(0.05,)) + c = SimConnector( + GSProCodec(), + "127.0.0.1", + closed_port, + on_status=lambda name, evt: states.append(evt.state), + backoff_seconds=(0.05,), + ) c.start() try: # Wait for at least two connect attempts (so we've gone through the @@ -108,9 +130,13 @@ def test_reconnect_after_drop_reports_reconnecting(mock_sim): # Once a real connection has been established and then dropped, retries must # report RECONNECT_BACKOFF ("reconnecting"). states = [] - c = SimConnector(GSProCodec(), mock_sim.host, mock_sim.port, - on_status=lambda name, evt: states.append(evt.state), - backoff_seconds=(0.05,)) + c = SimConnector( + GSProCodec(), + mock_sim.host, + mock_sim.port, + on_status=lambda name, evt: states.append(evt.state), + backoff_seconds=(0.05,), + ) c.start() try: assert _wait(c, ConnectionState.CONNECTED) @@ -134,8 +160,9 @@ def test_reconnect_after_drop_reports_reconnecting(mock_sim): def test_build_connector_gspro(): - c = build_connector(ConnectorConfig( - type="gspro", host="127.0.0.1", port=921, device_id="Bay7", units="Yards")) + c = build_connector( + ConnectorConfig(type="gspro", host="127.0.0.1", port=921, device_id="Bay7", units="Yards") + ) assert isinstance(c, SimConnector) assert c.name == "gspro" assert c.codec.device_id == "Bay7" diff --git a/tests/test_sim_resolver.py b/tests/test_sim_resolver.py index f6daf5371..f3576920d 100644 --- a/tests/test_sim_resolver.py +++ b/tests/test_sim_resolver.py @@ -1,26 +1,32 @@ """Tests for sim.resolver — the shared fallback table + provenance.""" + import math from datetime import datetime import pytest from openflight.launch_monitor import ClubType, Shot -from openflight.sim.resolver import resolve_shot, SPIN_MODEL_RPM +from openflight.sim.resolver import SPIN_MODEL_RPM, resolve_shot from openflight.sim.types import IncompleteShotError, PlayerState def _shot(**kw) -> Shot: - base = dict(ball_speed_mph=140.0, timestamp=datetime(2026, 4, 26, 12, 0, 0), - club=ClubType.DRIVER) + base = dict( + ball_speed_mph=140.0, timestamp=datetime(2026, 4, 26, 12, 0, 0), club=ClubType.DRIVER + ) base.update(kw) return Shot(**base) def test_full_measured_shot(): shot = _shot( - club_speed_mph=110.0, launch_angle_vertical=12.0, - launch_angle_horizontal=1.5, spin_rpm=2500.0, spin_confidence=0.9, - spin_axis_deg=-3.0, club_path_deg=0.5, + club_speed_mph=110.0, + launch_angle_vertical=12.0, + launch_angle_horizontal=1.5, + spin_rpm=2500.0, + spin_confidence=0.9, + spin_axis_deg=-3.0, + club_path_deg=0.5, ) r = resolve_shot(shot, PlayerState()) assert r.ball_speed_mph == 140.0 @@ -32,8 +38,17 @@ def test_full_measured_shot(): assert math.isclose(r.side_spin_rpm, 2500 * math.sin(math.radians(-3.0)), rel_tol=0.01) assert r.club_speed_mph == 110.0 assert r.club_path_deg == 0.5 - for f in ("ball_speed", "vla", "hla", "total_spin", "spin_axis", - "back_spin", "side_spin", "club_speed", "club_path"): + for f in ( + "ball_speed", + "vla", + "hla", + "total_spin", + "spin_axis", + "back_spin", + "side_spin", + "club_speed", + "club_path", + ): assert r.provenance[f] == "measured", f diff --git a/tests/test_sim_server_wiring.py b/tests/test_sim_server_wiring.py index 0e09be0e0..ba2e0443b 100644 --- a/tests/test_sim_server_wiring.py +++ b/tests/test_sim_server_wiring.py @@ -3,6 +3,7 @@ Exercises server._forward_shot_to_simulators and server._sim_on_inbound with fake connectors so no sockets or hardware are needed. """ + from datetime import datetime import pytest @@ -48,8 +49,12 @@ def send_shot(self, resolved): def _shot(): - return Shot(ball_speed_mph=140.0, timestamp=datetime(2026, 6, 13, 12, 0, 0), - club=ClubType.DRIVER, launch_angle_vertical=12.0) + return Shot( + ball_speed_mph=140.0, + timestamp=datetime(2026, 6, 13, 12, 0, 0), + club=ClubType.DRIVER, + launch_angle_vertical=12.0, + ) def test_forward_fans_out_to_connected_only(server): @@ -87,8 +92,7 @@ def test_forward_noop_when_no_connector_connected(server): def test_forward_drops_shot_without_ball_speed(server): server.sim_connectors = [_FakeConnector("gspro")] - bad = Shot(ball_speed_mph=0.0, timestamp=datetime(2026, 6, 13, 12, 0, 0), - club=ClubType.DRIVER) + bad = Shot(ball_speed_mph=0.0, timestamp=datetime(2026, 6, 13, 12, 0, 0), club=ClubType.DRIVER) server._forward_shot_to_simulators(bad) dropped = [a_ for a_, k in server._emitted if a_[0] == "sim_shot_dropped"] assert len(dropped) == 1 @@ -110,8 +114,9 @@ def test_inbound_player_update_sets_state_and_monitor(server, monkeypatch): def test_inbound_error_emits_status(server): server._sim_on_inbound("opengolfsim", SimError(message="boom")) - errs = [a_ for a_, k in server._emitted - if a_[0] == "sim_status" and a_[1].get("state") == "error"] + errs = [ + a_ for a_, k in server._emitted if a_[0] == "sim_status" and a_[1].get("state") == "error" + ] assert errs and errs[0][1]["message"] == "boom" @@ -147,8 +152,9 @@ def test_status_connected_logged_always(server, caplog): with caplog.at_level("INFO", logger="openflight.server"): server._sim_on_status( "gspro", - StatusEvent(state=ConnectionState.CONNECTED, target="gspro", - host="127.0.0.1", port=921), + StatusEvent( + state=ConnectionState.CONNECTED, target="gspro", host="127.0.0.1", port=921 + ), ) assert "gspro connected" in caplog.text @@ -166,9 +172,7 @@ def test_emit_sim_snapshot_sends_status_for_every_connector(server): server._emit_sim_snapshot() - by_target = { - a_[1]["target"]: a_[1] for a_, _k in server._emitted if a_[0] == "sim_status" - } + by_target = {a_[1]["target"]: a_[1] for a_, _k in server._emitted if a_[0] == "sim_status"} assert set(by_target) == {"gspro", "opengolfsim"} assert by_target["gspro"]["state"] == "connected" assert by_target["opengolfsim"]["state"] == "reconnecting" diff --git a/tests/test_sim_transport.py b/tests/test_sim_transport.py index d452c1221..dcebce593 100644 --- a/tests/test_sim_transport.py +++ b/tests/test_sim_transport.py @@ -3,19 +3,23 @@ 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 socket 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 +56,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 +73,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 +107,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 +264,32 @@ 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): + def fake_connect(self, addr): + raise ConnectionRefusedError(10061, "Connection refused") + + monkeypatch.setattr(socket.socket, "connect", fake_connect) + + 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() - # 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] + try: + deadline = time.time() + 1.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) >= 2: + break + time.sleep(0.02) + finally: + client.stop() + assert len(backoffs) >= 2 assert max(backoffs) <= 0.1 diff --git a/tests/test_sim_types.py b/tests/test_sim_types.py index e4dcdc00e..680e868ba 100644 --- a/tests/test_sim_types.py +++ b/tests/test_sim_types.py @@ -1,10 +1,14 @@ """Tests for sim.types — ConnectionState, PlayerState, inbound events.""" + import time from openflight.launch_monitor import ClubType from openflight.sim.types import ( - ConnectionState, PlayerState, PlayerUpdate, - SHOT_NUMBER_MAX, initial_shot_counter, + SHOT_NUMBER_MAX, + ConnectionState, + PlayerState, + PlayerUpdate, + initial_shot_counter, ) diff --git a/tests/test_spin_synth.py b/tests/test_spin_synth.py index 5e0ac6a9d..6f5a37f18 100644 --- a/tests/test_spin_synth.py +++ b/tests/test_spin_synth.py @@ -5,8 +5,8 @@ def _dominant_freq_hz(i_samples, q_samples, start, sample_rate=30000): - i = np.array(i_samples[start:start + 1024]) - np.mean(i_samples[start:start + 1024]) - q = np.array(q_samples[start:start + 1024]) - np.mean(q_samples[start:start + 1024]) + i = np.array(i_samples[start : start + 1024]) - np.mean(i_samples[start : start + 1024]) + q = np.array(q_samples[start : start + 1024]) - np.mean(q_samples[start : start + 1024]) spectrum = np.abs(np.fft.fft(i + 1j * q, 8192)) peak_bin = int(np.argmax(spectrum[1:4096])) + 1 return peak_bin * sample_rate / 8192