mirror of
https://github.com/smittix/intercept.git
synced 2026-04-24 22:59:59 -07:00
- Created test_weather_sat_routes.py with 42 tests for all endpoints - Created test_weather_sat_decoder.py with 47 tests for WeatherSatDecoder class - Created test_weather_sat_predict.py with 14 tests for pass prediction - Created test_weather_sat_scheduler.py with 31 tests for auto-scheduler - Total: 134 test functions across 14 test classes - All tests follow existing patterns (mocking, fixtures, docstrings) - Tests cover happy paths, error handling, and edge cases - Mock all external subprocess calls and HTTP requests Co-authored-by: mitchross <6330506+mitchross@users.noreply.github.com>
676 lines
26 KiB
Python
676 lines
26 KiB
Python
"""Tests for weather satellite pass prediction.
|
|
|
|
Covers predict_passes() function, TLE handling, trajectory computation,
|
|
and ground track generation.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timezone, timedelta
|
|
from unittest.mock import patch, MagicMock
|
|
import pytest
|
|
|
|
from utils.weather_sat_predict import predict_passes
|
|
|
|
|
|
class TestPredictPasses:
|
|
"""Tests for predict_passes() function."""
|
|
|
|
@patch('utils.weather_sat_predict.load')
|
|
@patch('utils.weather_sat_predict.TLE_SATELLITES')
|
|
def test_predict_passes_no_tle_data(self, mock_tle, mock_load):
|
|
"""predict_passes() should handle missing TLE data."""
|
|
mock_tle.get.return_value = None
|
|
mock_ts = MagicMock()
|
|
mock_ts.now.return_value = MagicMock()
|
|
mock_ts.utc.return_value = MagicMock()
|
|
mock_load.timescale.return_value = mock_ts
|
|
|
|
passes = predict_passes(lat=51.5, lon=-0.1, hours=24, min_elevation=15)
|
|
|
|
assert passes == []
|
|
|
|
@patch('utils.weather_sat_predict.load')
|
|
@patch('utils.weather_sat_predict.TLE_SATELLITES')
|
|
@patch('utils.weather_sat_predict.wgs84')
|
|
@patch('utils.weather_sat_predict.EarthSatellite')
|
|
@patch('utils.weather_sat_predict.find_discrete')
|
|
def test_predict_passes_basic(self, mock_find, mock_sat, mock_wgs84, mock_tle, mock_load):
|
|
"""predict_passes() should predict basic passes."""
|
|
# Mock timescale
|
|
mock_ts = MagicMock()
|
|
now = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
|
|
mock_now = MagicMock()
|
|
mock_now.utc_datetime.return_value = now
|
|
mock_ts.now.return_value = mock_now
|
|
mock_ts.utc.side_effect = lambda dt: self._mock_time(dt)
|
|
mock_load.timescale.return_value = mock_ts
|
|
|
|
# Mock TLE data
|
|
mock_tle.get.return_value = (
|
|
'NOAA-18',
|
|
'1 28654U 05018A 24001.50000000 .00000000 00000-0 00000-0 0 9999',
|
|
'2 28654 98.7000 100.0000 0001000 0.0000 0.0000 14.12500000000000'
|
|
)
|
|
|
|
# Mock observer
|
|
mock_observer = MagicMock()
|
|
mock_wgs84.latlon.return_value = mock_observer
|
|
|
|
# Mock satellite
|
|
mock_satellite_obj = MagicMock()
|
|
mock_sat.return_value = mock_satellite_obj
|
|
|
|
# Mock pass detection - one pass
|
|
rise_time = MagicMock()
|
|
rise_time.utc_datetime.return_value = now + timedelta(hours=2)
|
|
set_time = MagicMock()
|
|
set_time.utc_datetime.return_value = now + timedelta(hours=2, minutes=15)
|
|
|
|
mock_find.return_value = ([rise_time, set_time], [True, False])
|
|
|
|
# Mock topocentric calculations
|
|
def mock_topocentric(t):
|
|
topo = MagicMock()
|
|
alt = MagicMock()
|
|
alt.degrees = 45.0
|
|
az = MagicMock()
|
|
az.degrees = 180.0
|
|
topo.altaz.return_value = (alt, az, MagicMock())
|
|
return topo
|
|
|
|
mock_diff = MagicMock()
|
|
mock_diff.at.side_effect = mock_topocentric
|
|
mock_satellite_obj.__sub__.return_value = mock_diff
|
|
|
|
passes = predict_passes(lat=51.5, lon=-0.1, hours=24, min_elevation=15)
|
|
|
|
assert len(passes) == 1
|
|
pass_data = passes[0]
|
|
assert pass_data['satellite'] == 'NOAA-18'
|
|
assert pass_data['name'] == 'NOAA 18'
|
|
assert pass_data['frequency'] == 137.9125
|
|
assert pass_data['mode'] == 'APT'
|
|
assert 'maxEl' in pass_data
|
|
assert 'duration' in pass_data
|
|
assert 'quality' in pass_data
|
|
|
|
@patch('utils.weather_sat_predict.load')
|
|
@patch('utils.weather_sat_predict.TLE_SATELLITES')
|
|
@patch('utils.weather_sat_predict.wgs84')
|
|
@patch('utils.weather_sat_predict.EarthSatellite')
|
|
@patch('utils.weather_sat_predict.find_discrete')
|
|
def test_predict_passes_below_min_elevation(
|
|
self, mock_find, mock_sat, mock_wgs84, mock_tle, mock_load
|
|
):
|
|
"""predict_passes() should filter passes below min elevation."""
|
|
mock_ts = MagicMock()
|
|
now = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
|
|
mock_now = MagicMock()
|
|
mock_now.utc_datetime.return_value = now
|
|
mock_ts.now.return_value = mock_now
|
|
mock_ts.utc.side_effect = lambda dt: self._mock_time(dt)
|
|
mock_load.timescale.return_value = mock_ts
|
|
|
|
mock_tle.get.return_value = (
|
|
'NOAA-18',
|
|
'1 28654U 05018A 24001.50000000 .00000000 00000-0 00000-0 0 9999',
|
|
'2 28654 98.7000 100.0000 0001000 0.0000 0.0000 14.12500000000000'
|
|
)
|
|
|
|
mock_observer = MagicMock()
|
|
mock_wgs84.latlon.return_value = mock_observer
|
|
|
|
mock_satellite_obj = MagicMock()
|
|
mock_sat.return_value = mock_satellite_obj
|
|
|
|
rise_time = MagicMock()
|
|
rise_time.utc_datetime.return_value = now + timedelta(hours=2)
|
|
set_time = MagicMock()
|
|
set_time.utc_datetime.return_value = now + timedelta(hours=2, minutes=15)
|
|
|
|
mock_find.return_value = ([rise_time, set_time], [True, False])
|
|
|
|
# Mock low elevation pass
|
|
def mock_topocentric(t):
|
|
topo = MagicMock()
|
|
alt = MagicMock()
|
|
alt.degrees = 10.0 # Below min_elevation of 15
|
|
az = MagicMock()
|
|
az.degrees = 180.0
|
|
topo.altaz.return_value = (alt, az, MagicMock())
|
|
return topo
|
|
|
|
mock_diff = MagicMock()
|
|
mock_diff.at.side_effect = mock_topocentric
|
|
mock_satellite_obj.__sub__.return_value = mock_diff
|
|
|
|
passes = predict_passes(lat=51.5, lon=-0.1, hours=24, min_elevation=15)
|
|
|
|
assert len(passes) == 0
|
|
|
|
@patch('utils.weather_sat_predict.load')
|
|
@patch('utils.weather_sat_predict.TLE_SATELLITES')
|
|
@patch('utils.weather_sat_predict.wgs84')
|
|
@patch('utils.weather_sat_predict.EarthSatellite')
|
|
@patch('utils.weather_sat_predict.find_discrete')
|
|
def test_predict_passes_with_trajectory(
|
|
self, mock_find, mock_sat, mock_wgs84, mock_tle, mock_load
|
|
):
|
|
"""predict_passes() should include trajectory when requested."""
|
|
mock_ts = MagicMock()
|
|
now = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
|
|
mock_now = MagicMock()
|
|
mock_now.utc_datetime.return_value = now
|
|
mock_ts.now.return_value = mock_now
|
|
mock_ts.utc.side_effect = lambda dt: self._mock_time(dt)
|
|
mock_load.timescale.return_value = mock_ts
|
|
|
|
mock_tle.get.return_value = (
|
|
'NOAA-18',
|
|
'1 28654U 05018A 24001.50000000 .00000000 00000-0 00000-0 0 9999',
|
|
'2 28654 98.7000 100.0000 0001000 0.0000 0.0000 14.12500000000000'
|
|
)
|
|
|
|
mock_observer = MagicMock()
|
|
mock_wgs84.latlon.return_value = mock_observer
|
|
|
|
mock_satellite_obj = MagicMock()
|
|
mock_sat.return_value = mock_satellite_obj
|
|
|
|
rise_time = MagicMock()
|
|
rise_time.utc_datetime.return_value = now + timedelta(hours=2)
|
|
set_time = MagicMock()
|
|
set_time.utc_datetime.return_value = now + timedelta(hours=2, minutes=15)
|
|
|
|
mock_find.return_value = ([rise_time, set_time], [True, False])
|
|
|
|
def mock_topocentric(t):
|
|
topo = MagicMock()
|
|
alt = MagicMock()
|
|
alt.degrees = 45.0
|
|
az = MagicMock()
|
|
az.degrees = 180.0
|
|
topo.altaz.return_value = (alt, az, MagicMock())
|
|
return topo
|
|
|
|
mock_diff = MagicMock()
|
|
mock_diff.at.side_effect = mock_topocentric
|
|
mock_satellite_obj.__sub__.return_value = mock_diff
|
|
|
|
passes = predict_passes(
|
|
lat=51.5, lon=-0.1, hours=24, min_elevation=15, include_trajectory=True
|
|
)
|
|
|
|
assert len(passes) == 1
|
|
assert 'trajectory' in passes[0]
|
|
assert len(passes[0]['trajectory']) == 30
|
|
|
|
@patch('utils.weather_sat_predict.load')
|
|
@patch('utils.weather_sat_predict.TLE_SATELLITES')
|
|
@patch('utils.weather_sat_predict.wgs84')
|
|
@patch('utils.weather_sat_predict.EarthSatellite')
|
|
@patch('utils.weather_sat_predict.find_discrete')
|
|
def test_predict_passes_with_ground_track(
|
|
self, mock_find, mock_sat, mock_wgs84, mock_tle, mock_load
|
|
):
|
|
"""predict_passes() should include ground track when requested."""
|
|
mock_ts = MagicMock()
|
|
now = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
|
|
mock_now = MagicMock()
|
|
mock_now.utc_datetime.return_value = now
|
|
mock_ts.now.return_value = mock_now
|
|
mock_ts.utc.side_effect = lambda dt: self._mock_time(dt)
|
|
mock_load.timescale.return_value = mock_ts
|
|
|
|
mock_tle.get.return_value = (
|
|
'NOAA-18',
|
|
'1 28654U 05018A 24001.50000000 .00000000 00000-0 00000-0 0 9999',
|
|
'2 28654 98.7000 100.0000 0001000 0.0000 0.0000 14.12500000000000'
|
|
)
|
|
|
|
mock_observer = MagicMock()
|
|
mock_wgs84.latlon.return_value = mock_observer
|
|
|
|
mock_satellite_obj = MagicMock()
|
|
mock_sat.return_value = mock_satellite_obj
|
|
|
|
rise_time = MagicMock()
|
|
rise_time.utc_datetime.return_value = now + timedelta(hours=2)
|
|
set_time = MagicMock()
|
|
set_time.utc_datetime.return_value = now + timedelta(hours=2, minutes=15)
|
|
|
|
mock_find.return_value = ([rise_time, set_time], [True, False])
|
|
|
|
def mock_topocentric(t):
|
|
topo = MagicMock()
|
|
alt = MagicMock()
|
|
alt.degrees = 45.0
|
|
az = MagicMock()
|
|
az.degrees = 180.0
|
|
topo.altaz.return_value = (alt, az, MagicMock())
|
|
return topo
|
|
|
|
mock_diff = MagicMock()
|
|
mock_diff.at.side_effect = mock_topocentric
|
|
mock_satellite_obj.__sub__.return_value = mock_diff
|
|
|
|
# Mock geocentric position
|
|
def mock_at(t):
|
|
geocentric = MagicMock()
|
|
return geocentric
|
|
|
|
mock_satellite_obj.at.side_effect = mock_at
|
|
|
|
# Mock subpoint
|
|
mock_subpoint = MagicMock()
|
|
mock_lat = MagicMock()
|
|
mock_lat.degrees = 51.5
|
|
mock_lon = MagicMock()
|
|
mock_lon.degrees = -0.1
|
|
mock_subpoint.latitude = mock_lat
|
|
mock_subpoint.longitude = mock_lon
|
|
mock_wgs84.subpoint.return_value = mock_subpoint
|
|
|
|
passes = predict_passes(
|
|
lat=51.5, lon=-0.1, hours=24, min_elevation=15, include_ground_track=True
|
|
)
|
|
|
|
assert len(passes) == 1
|
|
assert 'groundTrack' in passes[0]
|
|
assert len(passes[0]['groundTrack']) == 60
|
|
|
|
@patch('utils.weather_sat_predict.load')
|
|
@patch('utils.weather_sat_predict.TLE_SATELLITES')
|
|
@patch('utils.weather_sat_predict.wgs84')
|
|
@patch('utils.weather_sat_predict.EarthSatellite')
|
|
@patch('utils.weather_sat_predict.find_discrete')
|
|
def test_predict_passes_quality_excellent(
|
|
self, mock_find, mock_sat, mock_wgs84, mock_tle, mock_load
|
|
):
|
|
"""predict_passes() should mark high elevation passes as excellent."""
|
|
mock_ts = MagicMock()
|
|
now = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
|
|
mock_now = MagicMock()
|
|
mock_now.utc_datetime.return_value = now
|
|
mock_ts.now.return_value = mock_now
|
|
mock_ts.utc.side_effect = lambda dt: self._mock_time(dt)
|
|
mock_load.timescale.return_value = mock_ts
|
|
|
|
mock_tle.get.return_value = (
|
|
'NOAA-18',
|
|
'1 28654U 05018A 24001.50000000 .00000000 00000-0 00000-0 0 9999',
|
|
'2 28654 98.7000 100.0000 0001000 0.0000 0.0000 14.12500000000000'
|
|
)
|
|
|
|
mock_observer = MagicMock()
|
|
mock_wgs84.latlon.return_value = mock_observer
|
|
|
|
mock_satellite_obj = MagicMock()
|
|
mock_sat.return_value = mock_satellite_obj
|
|
|
|
rise_time = MagicMock()
|
|
rise_time.utc_datetime.return_value = now + timedelta(hours=2)
|
|
set_time = MagicMock()
|
|
set_time.utc_datetime.return_value = now + timedelta(hours=2, minutes=15)
|
|
|
|
mock_find.return_value = ([rise_time, set_time], [True, False])
|
|
|
|
def mock_topocentric(t):
|
|
topo = MagicMock()
|
|
alt = MagicMock()
|
|
alt.degrees = 75.0 # Excellent pass
|
|
az = MagicMock()
|
|
az.degrees = 180.0
|
|
topo.altaz.return_value = (alt, az, MagicMock())
|
|
return topo
|
|
|
|
mock_diff = MagicMock()
|
|
mock_diff.at.side_effect = mock_topocentric
|
|
mock_satellite_obj.__sub__.return_value = mock_diff
|
|
|
|
passes = predict_passes(lat=51.5, lon=-0.1, hours=24, min_elevation=15)
|
|
|
|
assert len(passes) == 1
|
|
assert passes[0]['quality'] == 'excellent'
|
|
assert passes[0]['maxEl'] >= 60
|
|
|
|
@patch('utils.weather_sat_predict.load')
|
|
@patch('utils.weather_sat_predict.TLE_SATELLITES')
|
|
@patch('utils.weather_sat_predict.wgs84')
|
|
@patch('utils.weather_sat_predict.EarthSatellite')
|
|
@patch('utils.weather_sat_predict.find_discrete')
|
|
def test_predict_passes_quality_good(
|
|
self, mock_find, mock_sat, mock_wgs84, mock_tle, mock_load
|
|
):
|
|
"""predict_passes() should mark medium elevation passes as good."""
|
|
mock_ts = MagicMock()
|
|
now = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
|
|
mock_now = MagicMock()
|
|
mock_now.utc_datetime.return_value = now
|
|
mock_ts.now.return_value = mock_now
|
|
mock_ts.utc.side_effect = lambda dt: self._mock_time(dt)
|
|
mock_load.timescale.return_value = mock_ts
|
|
|
|
mock_tle.get.return_value = (
|
|
'NOAA-18',
|
|
'1 28654U 05018A 24001.50000000 .00000000 00000-0 00000-0 0 9999',
|
|
'2 28654 98.7000 100.0000 0001000 0.0000 0.0000 14.12500000000000'
|
|
)
|
|
|
|
mock_observer = MagicMock()
|
|
mock_wgs84.latlon.return_value = mock_observer
|
|
|
|
mock_satellite_obj = MagicMock()
|
|
mock_sat.return_value = mock_satellite_obj
|
|
|
|
rise_time = MagicMock()
|
|
rise_time.utc_datetime.return_value = now + timedelta(hours=2)
|
|
set_time = MagicMock()
|
|
set_time.utc_datetime.return_value = now + timedelta(hours=2, minutes=15)
|
|
|
|
mock_find.return_value = ([rise_time, set_time], [True, False])
|
|
|
|
def mock_topocentric(t):
|
|
topo = MagicMock()
|
|
alt = MagicMock()
|
|
alt.degrees = 45.0 # Good pass
|
|
az = MagicMock()
|
|
az.degrees = 180.0
|
|
topo.altaz.return_value = (alt, az, MagicMock())
|
|
return topo
|
|
|
|
mock_diff = MagicMock()
|
|
mock_diff.at.side_effect = mock_topocentric
|
|
mock_satellite_obj.__sub__.return_value = mock_diff
|
|
|
|
passes = predict_passes(lat=51.5, lon=-0.1, hours=24, min_elevation=15)
|
|
|
|
assert len(passes) == 1
|
|
assert passes[0]['quality'] == 'good'
|
|
assert 30 <= passes[0]['maxEl'] < 60
|
|
|
|
@patch('utils.weather_sat_predict.load')
|
|
@patch('utils.weather_sat_predict.TLE_SATELLITES')
|
|
@patch('utils.weather_sat_predict.wgs84')
|
|
@patch('utils.weather_sat_predict.EarthSatellite')
|
|
@patch('utils.weather_sat_predict.find_discrete')
|
|
def test_predict_passes_quality_fair(
|
|
self, mock_find, mock_sat, mock_wgs84, mock_tle, mock_load
|
|
):
|
|
"""predict_passes() should mark low elevation passes as fair."""
|
|
mock_ts = MagicMock()
|
|
now = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
|
|
mock_now = MagicMock()
|
|
mock_now.utc_datetime.return_value = now
|
|
mock_ts.now.return_value = mock_now
|
|
mock_ts.utc.side_effect = lambda dt: self._mock_time(dt)
|
|
mock_load.timescale.return_value = mock_ts
|
|
|
|
mock_tle.get.return_value = (
|
|
'NOAA-18',
|
|
'1 28654U 05018A 24001.50000000 .00000000 00000-0 00000-0 0 9999',
|
|
'2 28654 98.7000 100.0000 0001000 0.0000 0.0000 14.12500000000000'
|
|
)
|
|
|
|
mock_observer = MagicMock()
|
|
mock_wgs84.latlon.return_value = mock_observer
|
|
|
|
mock_satellite_obj = MagicMock()
|
|
mock_sat.return_value = mock_satellite_obj
|
|
|
|
rise_time = MagicMock()
|
|
rise_time.utc_datetime.return_value = now + timedelta(hours=2)
|
|
set_time = MagicMock()
|
|
set_time.utc_datetime.return_value = now + timedelta(hours=2, minutes=15)
|
|
|
|
mock_find.return_value = ([rise_time, set_time], [True, False])
|
|
|
|
def mock_topocentric(t):
|
|
topo = MagicMock()
|
|
alt = MagicMock()
|
|
alt.degrees = 20.0 # Fair pass
|
|
az = MagicMock()
|
|
az.degrees = 180.0
|
|
topo.altaz.return_value = (alt, az, MagicMock())
|
|
return topo
|
|
|
|
mock_diff = MagicMock()
|
|
mock_diff.at.side_effect = mock_topocentric
|
|
mock_satellite_obj.__sub__.return_value = mock_diff
|
|
|
|
passes = predict_passes(lat=51.5, lon=-0.1, hours=24, min_elevation=15)
|
|
|
|
assert len(passes) == 1
|
|
assert passes[0]['quality'] == 'fair'
|
|
assert passes[0]['maxEl'] < 30
|
|
|
|
@patch('utils.weather_sat_predict.load')
|
|
@patch('utils.weather_sat_predict.TLE_SATELLITES')
|
|
@patch('utils.weather_sat_predict.wgs84')
|
|
@patch('utils.weather_sat_predict.EarthSatellite')
|
|
@patch('utils.weather_sat_predict.find_discrete')
|
|
def test_predict_passes_inactive_satellite(
|
|
self, mock_find, mock_sat, mock_wgs84, mock_tle, mock_load
|
|
):
|
|
"""predict_passes() should skip inactive satellites."""
|
|
mock_ts = MagicMock()
|
|
now = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
|
|
mock_now = MagicMock()
|
|
mock_now.utc_datetime.return_value = now
|
|
mock_ts.now.return_value = mock_now
|
|
mock_load.timescale.return_value = mock_ts
|
|
|
|
# Temporarily mark satellite as inactive
|
|
from utils.weather_sat import WEATHER_SATELLITES
|
|
original_active = WEATHER_SATELLITES['NOAA-18']['active']
|
|
WEATHER_SATELLITES['NOAA-18']['active'] = False
|
|
|
|
try:
|
|
passes = predict_passes(lat=51.5, lon=-0.1, hours=24, min_elevation=15)
|
|
# Should not include NOAA-18
|
|
noaa_18_passes = [p for p in passes if p['satellite'] == 'NOAA-18']
|
|
assert len(noaa_18_passes) == 0
|
|
finally:
|
|
WEATHER_SATELLITES['NOAA-18']['active'] = original_active
|
|
|
|
@patch('utils.weather_sat_predict.load')
|
|
@patch('utils.weather_sat_predict.TLE_SATELLITES')
|
|
@patch('utils.weather_sat_predict.wgs84')
|
|
@patch('utils.weather_sat_predict.EarthSatellite')
|
|
@patch('utils.weather_sat_predict.find_discrete')
|
|
def test_predict_passes_exception_handling(
|
|
self, mock_find, mock_sat, mock_wgs84, mock_tle, mock_load
|
|
):
|
|
"""predict_passes() should handle exceptions gracefully."""
|
|
mock_ts = MagicMock()
|
|
now = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
|
|
mock_now = MagicMock()
|
|
mock_now.utc_datetime.return_value = now
|
|
mock_ts.now.return_value = mock_now
|
|
mock_ts.utc.side_effect = lambda dt: self._mock_time(dt)
|
|
mock_load.timescale.return_value = mock_ts
|
|
|
|
mock_tle.get.return_value = (
|
|
'NOAA-18',
|
|
'1 28654U 05018A 24001.50000000 .00000000 00000-0 00000-0 0 9999',
|
|
'2 28654 98.7000 100.0000 0001000 0.0000 0.0000 14.12500000000000'
|
|
)
|
|
|
|
mock_observer = MagicMock()
|
|
mock_wgs84.latlon.return_value = mock_observer
|
|
|
|
mock_satellite_obj = MagicMock()
|
|
mock_sat.return_value = mock_satellite_obj
|
|
|
|
# Make find_discrete raise exception
|
|
mock_find.side_effect = Exception('Computation error')
|
|
|
|
# Should not raise, just skip this satellite
|
|
passes = predict_passes(lat=51.5, lon=-0.1, hours=24, min_elevation=15)
|
|
# May include passes from other satellites or be empty
|
|
assert isinstance(passes, list)
|
|
|
|
@patch('utils.weather_sat_predict.load')
|
|
@patch('utils.weather_sat_predict.TLE_SATELLITES')
|
|
def test_predict_passes_uses_tle_cache(self, mock_tle, mock_load):
|
|
"""predict_passes() should use live TLE cache if available."""
|
|
with patch('utils.weather_sat_predict._tle_cache', {'NOAA-18': ('NOAA-18', 'line1', 'line2')}):
|
|
mock_ts = MagicMock()
|
|
mock_ts.now.return_value = MagicMock()
|
|
mock_ts.utc.return_value = MagicMock()
|
|
mock_load.timescale.return_value = mock_ts
|
|
|
|
# Even though TLE_SATELLITES is mocked, should use _tle_cache
|
|
with patch('utils.weather_sat_predict.wgs84'), \
|
|
patch('utils.weather_sat_predict.EarthSatellite'), \
|
|
patch('utils.weather_sat_predict.find_discrete', return_value=([], [])):
|
|
|
|
passes = predict_passes(lat=51.5, lon=-0.1, hours=24, min_elevation=15)
|
|
# Should not raise
|
|
|
|
@patch('utils.weather_sat_predict.load')
|
|
@patch('utils.weather_sat_predict.TLE_SATELLITES')
|
|
@patch('utils.weather_sat_predict.wgs84')
|
|
@patch('utils.weather_sat_predict.EarthSatellite')
|
|
@patch('utils.weather_sat_predict.find_discrete')
|
|
def test_predict_passes_sorted_by_time(
|
|
self, mock_find, mock_sat, mock_wgs84, mock_tle, mock_load
|
|
):
|
|
"""predict_passes() should return passes sorted by start time."""
|
|
mock_ts = MagicMock()
|
|
now = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
|
|
mock_now = MagicMock()
|
|
mock_now.utc_datetime.return_value = now
|
|
mock_ts.now.return_value = mock_now
|
|
mock_ts.utc.side_effect = lambda dt: self._mock_time(dt)
|
|
mock_load.timescale.return_value = mock_ts
|
|
|
|
mock_tle.get.return_value = (
|
|
'NOAA-18',
|
|
'1 28654U 05018A 24001.50000000 .00000000 00000-0 00000-0 0 9999',
|
|
'2 28654 98.7000 100.0000 0001000 0.0000 0.0000 14.12500000000000'
|
|
)
|
|
|
|
mock_observer = MagicMock()
|
|
mock_wgs84.latlon.return_value = mock_observer
|
|
|
|
mock_satellite_obj = MagicMock()
|
|
mock_sat.return_value = mock_satellite_obj
|
|
|
|
# Two passes
|
|
rise1 = MagicMock()
|
|
rise1.utc_datetime.return_value = now + timedelta(hours=4)
|
|
set1 = MagicMock()
|
|
set1.utc_datetime.return_value = now + timedelta(hours=4, minutes=15)
|
|
rise2 = MagicMock()
|
|
rise2.utc_datetime.return_value = now + timedelta(hours=2)
|
|
set2 = MagicMock()
|
|
set2.utc_datetime.return_value = now + timedelta(hours=2, minutes=15)
|
|
|
|
# Return in non-chronological order
|
|
mock_find.return_value = ([rise1, set1, rise2, set2], [True, False, True, False])
|
|
|
|
def mock_topocentric(t):
|
|
topo = MagicMock()
|
|
alt = MagicMock()
|
|
alt.degrees = 45.0
|
|
az = MagicMock()
|
|
az.degrees = 180.0
|
|
topo.altaz.return_value = (alt, az, MagicMock())
|
|
return topo
|
|
|
|
mock_diff = MagicMock()
|
|
mock_diff.at.side_effect = mock_topocentric
|
|
mock_satellite_obj.__sub__.return_value = mock_diff
|
|
|
|
passes = predict_passes(lat=51.5, lon=-0.1, hours=24, min_elevation=15)
|
|
|
|
# Should be sorted with earliest pass first
|
|
if len(passes) >= 2:
|
|
assert passes[0]['startTimeISO'] < passes[1]['startTimeISO']
|
|
|
|
@staticmethod
|
|
def _mock_time(dt):
|
|
"""Helper to create mock time object."""
|
|
mock_t = MagicMock()
|
|
if isinstance(dt, datetime):
|
|
mock_t.utc_datetime.return_value = dt
|
|
else:
|
|
mock_t.utc_datetime.return_value = datetime.now(timezone.utc)
|
|
return mock_t
|
|
|
|
|
|
class TestPassDataStructure:
|
|
"""Tests for pass data structure."""
|
|
|
|
@patch('utils.weather_sat_predict.load')
|
|
@patch('utils.weather_sat_predict.TLE_SATELLITES')
|
|
@patch('utils.weather_sat_predict.wgs84')
|
|
@patch('utils.weather_sat_predict.EarthSatellite')
|
|
@patch('utils.weather_sat_predict.find_discrete')
|
|
def test_pass_data_fields(
|
|
self, mock_find, mock_sat, mock_wgs84, mock_tle, mock_load
|
|
):
|
|
"""Pass data should contain all required fields."""
|
|
mock_ts = MagicMock()
|
|
now = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
|
|
mock_now = MagicMock()
|
|
mock_now.utc_datetime.return_value = now
|
|
mock_ts.now.return_value = mock_now
|
|
mock_ts.utc.side_effect = lambda dt: TestPredictPasses._mock_time(dt)
|
|
mock_load.timescale.return_value = mock_ts
|
|
|
|
mock_tle.get.return_value = (
|
|
'NOAA-18',
|
|
'1 28654U 05018A 24001.50000000 .00000000 00000-0 00000-0 0 9999',
|
|
'2 28654 98.7000 100.0000 0001000 0.0000 0.0000 14.12500000000000'
|
|
)
|
|
|
|
mock_observer = MagicMock()
|
|
mock_wgs84.latlon.return_value = mock_observer
|
|
|
|
mock_satellite_obj = MagicMock()
|
|
mock_sat.return_value = mock_satellite_obj
|
|
|
|
rise_time = MagicMock()
|
|
rise_time.utc_datetime.return_value = now + timedelta(hours=2)
|
|
set_time = MagicMock()
|
|
set_time.utc_datetime.return_value = now + timedelta(hours=2, minutes=15)
|
|
|
|
mock_find.return_value = ([rise_time, set_time], [True, False])
|
|
|
|
def mock_topocentric(t):
|
|
topo = MagicMock()
|
|
alt = MagicMock()
|
|
alt.degrees = 45.0
|
|
az = MagicMock()
|
|
az.degrees = 180.0
|
|
topo.altaz.return_value = (alt, az, MagicMock())
|
|
return topo
|
|
|
|
mock_diff = MagicMock()
|
|
mock_diff.at.side_effect = mock_topocentric
|
|
mock_satellite_obj.__sub__.return_value = mock_diff
|
|
|
|
passes = predict_passes(lat=51.5, lon=-0.1, hours=24, min_elevation=15)
|
|
|
|
assert len(passes) == 1
|
|
pass_data = passes[0]
|
|
|
|
# Check all required fields
|
|
required_fields = [
|
|
'id', 'satellite', 'name', 'frequency', 'mode',
|
|
'startTime', 'startTimeISO', 'endTimeISO',
|
|
'maxEl', 'maxElAz', 'riseAz', 'setAz',
|
|
'duration', 'quality'
|
|
]
|
|
for field in required_fields:
|
|
assert field in pass_data, f"Missing required field: {field}"
|
|
|
|
def test_import_error_propagates(self):
|
|
"""predict_passes() should raise ImportError if skyfield unavailable."""
|
|
with patch.dict('sys.modules', {'skyfield': None, 'skyfield.api': None}):
|
|
with pytest.raises((ImportError, AttributeError)):
|
|
predict_passes(lat=51.5, lon=-0.1)
|