diff --git a/services/meteor_tracking/neo_feed_client.py b/services/meteor_tracking/neo_feed_client.py index 6fd470c..38c4209 100644 --- a/services/meteor_tracking/neo_feed_client.py +++ b/services/meteor_tracking/neo_feed_client.py @@ -14,7 +14,7 @@ import logging from dataclasses import dataclass -from datetime import date, datetime, timedelta +from datetime import date, datetime, timedelta, timezone from typing import List, Optional import aiohttp @@ -81,7 +81,9 @@ async def fetch_neo_feed( List of CloseApproach objects (same type as CAD client) """ if start_date is None: - start_date = date.today() + # Use UTC (the CAD client and all NEO timing are UTC), so the feed + # window cannot shift a day relative to close-approach queries. + start_date = datetime.now(timezone.utc).date() if end_date is None: end_date = start_date + timedelta(days=7) @@ -145,11 +147,22 @@ def _parse_neo_object(self, obj: dict) -> CloseApproach | None: d_min = meters.get('estimated_diameter_min') d_max = meters.get('estimated_diameter_max') - # Distance + # Distance — prefer the astronomical value, fall back to kilometres. + # A missing distance must NOT default to 0: distance_ld == 0 reads as an + # extremely close pass and would trip a false ALERT, so skip such objects. miss_distance = ca.get('miss_distance', {}) - dist_au = float(miss_distance.get('astronomical', 0)) - dist_km = float(miss_distance.get('kilometers', 0)) - dist_ld = dist_au / LD_TO_AU if dist_au else 0 + au_raw = miss_distance.get('astronomical') + km_raw = miss_distance.get('kilometers') + if au_raw is not None: + dist_au = float(au_raw) + dist_km = float(km_raw) if km_raw is not None else dist_au * AU_TO_KM + elif km_raw is not None: + dist_km = float(km_raw) + dist_au = dist_km / AU_TO_KM + else: + logger.debug("NEO object missing miss_distance; skipping") + return None + dist_ld = dist_au / LD_TO_AU # Velocity rel_velocity = ca.get('relative_velocity', {}) diff --git a/tests/unit/test_neo_feed_client.py b/tests/unit/test_neo_feed_client.py index 4632182..f79a28d 100644 --- a/tests/unit/test_neo_feed_client.py +++ b/tests/unit/test_neo_feed_client.py @@ -164,6 +164,56 @@ async def test_parse_neo_feed_no_approach_data(self): approaches = client._parse_neo_feed(response) assert len(approaches) == 0 + @pytest.mark.asyncio + async def test_missing_miss_distance_is_skipped_not_alerted(self): + """A NEO with no miss_distance must be SKIPPED, not defaulted to 0 + (distance 0 would read as an extremely close pass and trip a false ALERT).""" + client = NEOFeedClient() + response = { + 'near_earth_objects': { + '2026-03-23': [{ + 'neo_reference_id': 'no-distance', + 'name': 'no-distance', + 'absolute_magnitude_h': 22.0, + 'estimated_diameter': {'meters': { + 'estimated_diameter_min': 10, 'estimated_diameter_max': 20}}, + 'is_potentially_hazardous_asteroid': False, + 'close_approach_data': [{ + 'close_approach_date': '2026-03-23', + 'relative_velocity': {'kilometers_per_second': '12.0'}, + # miss_distance intentionally absent + }], + }] + } + } + approaches = client._parse_neo_feed(response) + assert approaches == [] # skipped, not a phantom ALERT at distance 0 + + @pytest.mark.asyncio + async def test_kilometers_only_distance_derives_au(self): + """When only kilometres are present, AU/LD are derived (not defaulted to 0).""" + client = NEOFeedClient() + response = { + 'near_earth_objects': { + '2026-03-23': [{ + 'neo_reference_id': 'km-only', + 'name': 'km-only', + 'absolute_magnitude_h': 22.0, + 'estimated_diameter': {'meters': { + 'estimated_diameter_min': 10, 'estimated_diameter_max': 20}}, + 'is_potentially_hazardous_asteroid': False, + 'close_approach_data': [{ + 'close_approach_date': '2026-03-23', + 'relative_velocity': {'kilometers_per_second': '12.0'}, + 'miss_distance': {'kilometers': '14959787.07'}, # 0.1 AU + }], + }] + } + } + approaches = client._parse_neo_feed(response) + assert len(approaches) == 1 + assert approaches[0].distance_au == pytest.approx(0.1, rel=1e-3) + @pytest.mark.asyncio async def test_close_no_session(self): """Test closing with no active session."""