Skip to content

Commit 0d9b440

Browse files
committed
ci: added GitHub Actions for tests and linting.
Created all tests for GitHub Actions to run.
1 parent 18c8ce2 commit 0d9b440

File tree

12 files changed

+1519
-1
lines changed

12 files changed

+1519
-1
lines changed

.github/workflows/.gitkeep

Whitespace-only changes.

.github/workflows/lint.yml

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
name: Lint
2+
3+
on:
4+
push:
5+
branches: [ main, dev ]
6+
pull_request:
7+
branches: [ main, dev ]
8+
9+
jobs:
10+
lint:
11+
runs-on: ubuntu-latest
12+
13+
steps:
14+
- uses: actions/checkout@v4
15+
16+
- name: Install uv
17+
uses: astral-sh/setup-uv@v6
18+
with:
19+
version: "latest"
20+
21+
- name: Set up Python
22+
run: uv python install 3.12
23+
24+
- name: Install dependencies
25+
run: uv sync
26+
27+
- name: Run ruff check
28+
run: uv run ruff check .
29+
30+
- name: Run ruff format check
31+
run: uv ran ruff format --check .
32+
33+
- name: Run mypy
34+
run: uv run mypy src/

.github/workflows/tests.yml

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
name: Tests
2+
3+
on:
4+
push:
5+
branches: [ main, dev ]
6+
pull_request:
7+
branches: [ main, dev ]
8+
9+
jobs:
10+
tests:
11+
runs-on: ${{ matrix.os }}
12+
strategy:
13+
matrix:
14+
os: [ubuntu-latest]
15+
python-version: ['3.12']
16+
17+
steps:
18+
- uses: actions/checkout@v4
19+
20+
- name: Install uv
21+
uses: astral-sh/setup-uv@v6
22+
with:
23+
version: "latest"
24+
25+
- name: Set up Python ${{ matrix.python-version }}
26+
run: uv python install ${{ matrix.python-version }}
27+
28+
- name: Install dependencies
29+
run: |
30+
uv sync
31+
32+
- name: Run tests with pytest
33+
run: |
34+
uv run pytest --cov=radar_plotter --cov-report=xml --cov-report=term-missing
35+
36+
- name: Upload coverage to Codecov
37+
uses: codecov/codecov-action@v5
38+
with:
39+
files: ./coverage.xml
40+
fail_ci_if_error: false
41+
token: ${{ secrets.CODECOV_TOKEN }}
42+
43+
- name: Run type checking
44+
run: |
45+
uv run mypy src/
46+
47+
- name: Run linting
48+
run: |
49+
uv run ruff check .
50+

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ A Python application to calculate a collision avoidance radar plot to help other
44

55
[![Tests](https://github.com/osyounis/collision_avoidance_radar_plotting_app/actions/workflows/tests.yml/badge.svg)](https://github.com/osyounis/collision_avoidance_radar_plotting_app/actions/workflows/tests.yml)
66
[![codecov](https://codecov.io/gh/osyounis/collision_avoidance_radar_plotting_app/branch/main/graph/badge.svg)](https://codecov.io/gh/osyounis/collision_avoidance_radar_plotting_app)
7-
![Python](https://img.shields.io/badge/python-3.12%2B-blue?logo=python&logoColor=white)
7+
![Python](https://img.shields.io/badge/python-3.12-blue?logo=python&logoColor=white)
88
![GitHub License](https://img.shields.io/github/license/osyounis/collision_avoidance_radar_plotting_app)
99

1010
---

tests/core/test_coordinates.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
"""
2+
Tests the coordinate conversion functions.
3+
4+
Author: Omar Younis
5+
Date: 30/10/2025 [dd/mm/yyyy]
6+
"""
7+
8+
import pytest
9+
import numpy as np
10+
11+
from radar_plotter.core.coordinates import bearing_to_cartesian, cartesian_to_bearing
12+
13+
14+
def test_bearing_to_cartesian_north():
15+
"""Tests conversion for due North."""
16+
x, y = bearing_to_cartesian(0.0, 10.0)
17+
assert np.isclose(x, 0.0, atol=1e-10)
18+
assert np.isclose(y, 10.0)
19+
20+
def test_bearing_to_cartesian_east():
21+
"""Tests conversion for due East."""
22+
x, y = bearing_to_cartesian(90.0, 10.0)
23+
assert np.isclose(x, 10.0)
24+
assert np.isclose(y, 0.0, atol=1e-10)
25+
26+
def test_bearing_to_cartesian_south():
27+
"""Tests conversion for due South."""
28+
x, y = bearing_to_cartesian(180.0, 10.0)
29+
assert np.isclose(x, 0.0, atol=1e-10)
30+
assert np.isclose(y, -10.0)
31+
32+
def test_bearing_to_cartesian_west():
33+
"""Tests conversion for due West."""
34+
x, y = bearing_to_cartesian(270.0, 10.0)
35+
assert np.isclose(x, -10.0)
36+
assert np.isclose(y, 0.0, atol=1e-10)
37+
38+
def test_cartesian_to_bearing_north():
39+
"""Tests conversion back to bearing for North."""
40+
bearing, range_val = cartesian_to_bearing(0.0, 10.0)
41+
assert np.isclose(bearing, 0.0)
42+
assert np.isclose(range_val, 10.0)
43+
44+
def test_cartesian_to_bearing_east():
45+
"""Test conversion back to bearing to East."""
46+
bearing, range_val = cartesian_to_bearing(10.0, 0.0)
47+
assert np.isclose(bearing, 90.0)
48+
assert np.isclose(range_val, 10.0)
49+
50+
def test_round_trip_conversion():
51+
"""Test that converting back and forth preserves original values."""
52+
original_bearing = 45.0
53+
original_range = 5.5
54+
55+
x, y = bearing_to_cartesian(original_bearing, original_range)
56+
bearing, range_val = cartesian_to_bearing(x, y)
57+
58+
# Checking values
59+
assert np.isclose(bearing, original_bearing)
60+
assert np.isclose(range_val, original_range)
61+
62+
def test_multiple_round_trips():
63+
"""Test multiple round trip conversions."""
64+
test_cases = [
65+
(0.0, 10.0),
66+
(45.0, 5.5),
67+
(90.0, 8.2),
68+
(135.0, 12.0),
69+
(180.0, 6.7),
70+
(225.0, 9.1),
71+
(270.0, 4.3),
72+
(315.0, 11.5)
73+
]
74+
75+
for bearing_in, range_in in test_cases:
76+
x, y = bearing_to_cartesian(bearing_in, range_in)
77+
bearing_out, range_out = cartesian_to_bearing(x, y)
78+
79+
assert np.isclose(bearing_out, bearing_in, atol=1e-6)
80+
assert np.isclose(range_out, range_in, atol=1e-6)

tests/core/test_cpa.py

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
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

Comments
 (0)