|
| 1 | +""" |
| 2 | +Tests the CPA calculations. |
| 3 | +
|
| 4 | +Author: Omar Younis |
| 5 | +Date: 04/11/2025 [dd/mm/yyyy] |
| 6 | +""" |
| 7 | + |
| 8 | + |
| 9 | +from datetime import datetime |
| 10 | + |
| 11 | +import pytest |
| 12 | +import numpy as np |
| 13 | + |
| 14 | +from radar_plotter.core.cpa import find_cpa_point, find_time_to_cpa |
| 15 | +from radar_plotter.core.coordinates import bearing_to_cartesian, cartesian_to_bearing |
| 16 | +from radar_plotter.core.relative_motion import find_line_equation |
| 17 | + |
| 18 | + |
| 19 | + |
| 20 | +def test_find_cpa_point_basic(): |
| 21 | + """Test CPA calculation with known scenario.""" |
| 22 | + r_point = (45.0, 11.5, "14:00") |
| 23 | + m_point = (43.0, 9.0, "14:06") |
| 24 | + |
| 25 | + cpa_bearing, cpa_range = find_cpa_point(r_point, m_point) |
| 26 | + |
| 27 | + # CPA should be valid |
| 28 | + assert 0 <= cpa_bearing < 360 |
| 29 | + assert cpa_range >= 0 |
| 30 | + assert isinstance(cpa_bearing, float) |
| 31 | + assert isinstance(cpa_range, float) |
| 32 | + |
| 33 | + # Verify CPA calculation correctness using geometry |
| 34 | + # CPA is the point on the Relative Motion Line closest to origin |
| 35 | + # Convert to Cartesian |
| 36 | + r_x, r_y = bearing_to_cartesian(r_point[0], r_point[1]) |
| 37 | + m_x, m_y = bearing_to_cartesian(m_point[0], m_point[1]) |
| 38 | + |
| 39 | + # Get RML equation |
| 40 | + rml_slope, rml_intercept = find_line_equation((r_x, r_y), (m_x, m_y), cartesian=True) |
| 41 | + |
| 42 | + # CPA is perpendicular from origin to RML |
| 43 | + # Perpendicular slope = -1/rml_slope |
| 44 | + perp_slope = -1 / rml_slope |
| 45 | + |
| 46 | + # CPA point is intersection of RML and perpendicular line through origin |
| 47 | + # RML: y = m*x + c, Perpendicular: y = perp_slope * x |
| 48 | + # Solving: m*x + c = perp_slope * x |
| 49 | + expected_cpa_x = rml_intercept / (perp_slope - rml_slope) |
| 50 | + expected_cpa_y = rml_slope * expected_cpa_x + rml_intercept |
| 51 | + |
| 52 | + # Convert expected CPA to polar |
| 53 | + expected_cpa_bearing, expected_cpa_range = cartesian_to_bearing(expected_cpa_x, expected_cpa_y) |
| 54 | + |
| 55 | + # Verify calculated CPA matches expected |
| 56 | + assert np.isclose(cpa_bearing, expected_cpa_bearing, atol=2.0), \ |
| 57 | + f"CPA bearing should be {expected_cpa_bearing:.2f}°, got {cpa_bearing:.2f}°" |
| 58 | + assert np.isclose(cpa_range, expected_cpa_range, atol=0.2), \ |
| 59 | + f"CPA range should be {expected_cpa_range:.2f} NM, got {cpa_range:.2f} NM" |
| 60 | + |
| 61 | + |
| 62 | +def test_find_cpa_point_should_be_closer(): |
| 63 | + """Test that CPA is closer than initial points.""" |
| 64 | + r_point = (45.0, 11.5, "14:00") |
| 65 | + m_point = (43.0, 9.0, "14:06") |
| 66 | + |
| 67 | + _, cpa_range = find_cpa_point(r_point, m_point) |
| 68 | + |
| 69 | + # CPA range should be less than or equal to both R and M ranges |
| 70 | + assert cpa_range <= r_point[1] |
| 71 | + assert cpa_range <= m_point[1] |
| 72 | + |
| 73 | + |
| 74 | +def test_find_time_to_cpa_basic(): |
| 75 | + """Test time to CPA calculation.""" |
| 76 | + r_point = (45.0, 11.5, "14:00") |
| 77 | + cpa_point = (40.0, 1.5) |
| 78 | + srm = 25.0 # knots |
| 79 | + |
| 80 | + cpa_time = find_time_to_cpa(r_point, cpa_point, srm) |
| 81 | + |
| 82 | + # Should return a datetime object |
| 83 | + assert isinstance(cpa_time, datetime) |
| 84 | + |
| 85 | + # CPA time should be after r_point time |
| 86 | + r_time = datetime.strptime(r_point[2], "%H:%M") |
| 87 | + assert cpa_time >= r_time |
| 88 | + |
| 89 | + # Verify time calculation correctness |
| 90 | + # Time = Distance / Speed |
| 91 | + # Convert to Cartesian |
| 92 | + r_x, r_y = bearing_to_cartesian(r_point[0], r_point[1]) |
| 93 | + cpa_x, cpa_y = bearing_to_cartesian(cpa_point[0], cpa_point[1]) |
| 94 | + |
| 95 | + # Distance from R to CPA |
| 96 | + distance_to_cpa = np.sqrt((cpa_x - r_x)**2 + (cpa_y - r_y)**2) |
| 97 | + |
| 98 | + # Expected time: distance / speed (in hours) |
| 99 | + expected_time_hours = distance_to_cpa / srm |
| 100 | + expected_time_minutes = expected_time_hours * 60 |
| 101 | + |
| 102 | + # Calculate actual time difference |
| 103 | + actual_time_diff_minutes = (cpa_time - r_time).total_seconds() / 60 |
| 104 | + |
| 105 | + # Verify calculated time matches expected (within 1 minute tolerance) |
| 106 | + assert np.isclose(actual_time_diff_minutes, expected_time_minutes, atol=1.0), \ |
| 107 | + f"Time to CPA should be {expected_time_minutes:.1f} minutes (distance \ |
| 108 | + {distance_to_cpa:.2f} NM ÷ {srm} kts), got {actual_time_diff_minutes:.1f} minutes" |
| 109 | + |
| 110 | + |
| 111 | +def test_find_time_to_cpa_realistic(): |
| 112 | + """Test time to CPA with realistic scenario and verify calculation.""" |
| 113 | + r_point = (45.0, 11.5, "14:00") |
| 114 | + cpa_point = (40.0, 1.5) |
| 115 | + srm = 25.0 # knots |
| 116 | + |
| 117 | + cpa_time = find_time_to_cpa(r_point, cpa_point, srm) |
| 118 | + r_time = datetime.strptime(r_point[2], "%H:%M") |
| 119 | + |
| 120 | + # Calculate expected time using physics: time = distance / speed |
| 121 | + r_x, r_y = bearing_to_cartesian(r_point[0], r_point[1]) |
| 122 | + cpa_x, cpa_y = bearing_to_cartesian(cpa_point[0], cpa_point[1]) |
| 123 | + distance = np.sqrt((cpa_x - r_x)**2 + (cpa_y - r_y)**2) |
| 124 | + |
| 125 | + # Expected time in minutes |
| 126 | + expected_time_minutes = (distance / srm) * 60 |
| 127 | + |
| 128 | + # Actual time difference |
| 129 | + time_diff_minutes = (cpa_time - r_time).total_seconds() / 60 |
| 130 | + |
| 131 | + # Time should match expected calculation (within 1 minute) |
| 132 | + assert np.isclose(time_diff_minutes, expected_time_minutes, atol=1.0), \ |
| 133 | + f"Expected {expected_time_minutes:.1f} min, got {time_diff_minutes:.1f} min" |
| 134 | + |
| 135 | + # Sanity check: with SRM of 25 kts and distance ~10 NM, |
| 136 | + # time should be roughly 24 minutes (10/25 * 60) |
| 137 | + assert 20 <= time_diff_minutes <= 30, \ |
| 138 | + f"For ~10 NM distance at 25 kts, time should be ~24 minutes, got \ |
| 139 | + {time_diff_minutes:.1f} minutes" |
| 140 | + |
| 141 | + # Verify CPA time format is correct (HH:MM) |
| 142 | + assert cpa_time.hour >= 0 and cpa_time.hour < 24, "Hour should be 0-23" |
| 143 | + assert cpa_time.minute >= 0 and cpa_time.minute < 60, "Minute should be 0-59" |
0 commit comments