diff --git a/services/meteor_tracking/hopi_circles.py b/services/meteor_tracking/hopi_circles.py index 72884dc..909dfc4 100644 --- a/services/meteor_tracking/hopi_circles.py +++ b/services/meteor_tracking/hopi_circles.py @@ -4,11 +4,62 @@ Named for the sacred geometry of expanding awareness. When debris is possible, generate systematic search patterns. + +Pattern flow: starting coords -> hopi circles -> home bases -> new days, +marking things along the outward going circles. """ import math -from dataclasses import dataclass -from typing import List, Tuple +from dataclasses import dataclass, field +from typing import List, Tuple, Optional + + +@dataclass +class Waypoint: + """A single search waypoint with metadata.""" + lat: float + lon: float + circle_index: int # Which circle this waypoint belongs to (0 = center) + waypoint_index: int # Position within the circle + label: str = "" # Human-readable label + + @property + def coordinates_str(self) -> str: + lat_dir = "N" if self.lat >= 0 else "S" + lon_dir = "W" if self.lon < 0 else "E" + return f"{abs(self.lat):.4f}{lat_dir} {abs(self.lon):.4f}{lon_dir}" + + +@dataclass +class SearchRoute: + """A connected search route through waypoints across circles.""" + waypoints: List[Waypoint] + total_distance_miles: float + num_circles_covered: int + center_lat: float + center_lon: float + + def to_prayer_string(self) -> str: + """Format for Lexicon prayer output.""" + lat_dir = "N" if self.center_lat >= 0 else "S" + lon_dir = "W" if self.center_lon < 0 else "E" + center = f"{abs(self.center_lat):.1f}{lat_dir} {abs(self.center_lon):.1f}{lon_dir}" + return ( + f"spiral-route {len(self.waypoints)} waypoints, " + f"{self.total_distance_miles:.1f}mi total, " + f"{self.num_circles_covered} circles from {center}" + ) + + def segment_distances(self) -> List[float]: + """Calculate distance between consecutive waypoints.""" + distances = [] + for i in range(1, len(self.waypoints)): + d = haversine_miles( + self.waypoints[i-1].lat, self.waypoints[i-1].lon, + self.waypoints[i].lat, self.waypoints[i].lon + ) + distances.append(d) + return distances @dataclass @@ -32,6 +83,23 @@ def contains_point(self, lat: float, lon: float) -> bool: distance = haversine_miles(self.center_lat, self.center_lon, lat, lon) return distance <= self.radius_miles + def waypoints(self, num_points: int = 8, start_bearing: float = 0.0) -> List[Waypoint]: + """Generate waypoints evenly distributed around this circle.""" + points = [] + for i in range(num_points): + bearing = start_bearing + (360.0 * i / num_points) + lat, lon = destination_point( + self.center_lat, self.center_lon, + bearing, self.radius_miles + ) + points.append(Waypoint( + lat=lat, lon=lon, + circle_index=self.priority, + waypoint_index=i, + label=f"C{self.priority}-W{i}" + )) + return points + @dataclass class SearchPattern: @@ -48,6 +116,60 @@ def to_prayer_string(self) -> str: lon_dir = "W" if self.center_lon < 0 else "E" return f"circles-search {self.max_radius_miles:.0f}mi from {abs(self.center_lat):.1f}{lat_dir} {abs(self.center_lon):.1f}{lon_dir}" + def generate_full_waypoints(self, points_per_circle: int = 8) -> List[Waypoint]: + """Generate waypoints for all circles, starting with center point.""" + all_waypoints = [ + Waypoint( + lat=self.center_lat, lon=self.center_lon, + circle_index=0, waypoint_index=0, + label="CENTER" + ) + ] + for circle in self.circles: + all_waypoints.extend(circle.waypoints(points_per_circle)) + return all_waypoints + + def generate_spiral_route(self, points_per_circle: int = 8) -> SearchRoute: + """ + Generate a connected spiral search route through all circles. + + Route pattern: center -> first circle waypoints (clockwise) -> + step outward -> second circle waypoints (clockwise) -> ... + + Each circle's waypoints are offset by half a step from the previous + circle, creating a true spiral rather than concentric rings. + """ + route_waypoints = [ + Waypoint( + lat=self.center_lat, lon=self.center_lon, + circle_index=0, waypoint_index=0, + label="CENTER-START" + ) + ] + + for ci, circle in enumerate(self.circles): + # Offset each circle's start bearing by half a waypoint step + # to create spiral interleaving + offset = (180.0 / points_per_circle) * ci + circle_wps = circle.waypoints(points_per_circle, start_bearing=offset) + route_waypoints.extend(circle_wps) + + # Calculate total route distance + total_dist = 0.0 + for i in range(1, len(route_waypoints)): + total_dist += haversine_miles( + route_waypoints[i-1].lat, route_waypoints[i-1].lon, + route_waypoints[i].lat, route_waypoints[i].lon + ) + + return SearchRoute( + waypoints=route_waypoints, + total_distance_miles=total_dist, + num_circles_covered=len(self.circles), + center_lat=self.center_lat, + center_lon=self.center_lon + ) + def haversine_miles(lat1: float, lon1: float, lat2: float, lon2: float) -> float: """Calculate great-circle distance between two points in miles.""" @@ -64,6 +186,58 @@ def haversine_miles(lat1: float, lon1: float, lat2: float, lon2: float) -> float return R * c +def destination_point( + lat: float, lon: float, + bearing_deg: float, distance_miles: float +) -> Tuple[float, float]: + """ + Calculate destination point given start, bearing, and distance. + + Uses the spherical law of cosines for accurate geodesic calculation. + + Args: + lat, lon: Starting point in degrees + bearing_deg: Bearing in degrees (0=N, 90=E, 180=S, 270=W) + distance_miles: Distance in miles + + Returns: + (lat, lon) tuple of destination point in degrees + """ + R = 3959.0 # Earth radius in miles + + lat1 = math.radians(lat) + lon1 = math.radians(lon) + brng = math.radians(bearing_deg) + d_over_R = distance_miles / R + + lat2 = math.asin( + math.sin(lat1) * math.cos(d_over_R) + + math.cos(lat1) * math.sin(d_over_R) * math.cos(brng) + ) + lon2 = lon1 + math.atan2( + math.sin(brng) * math.sin(d_over_R) * math.cos(lat1), + math.cos(d_over_R) - math.sin(lat1) * math.sin(lat2) + ) + + return (math.degrees(lat2), math.degrees(lon2)) + + +def initial_bearing(lat1: float, lon1: float, lat2: float, lon2: float) -> float: + """ + Calculate initial bearing from point 1 to point 2. + + Returns bearing in degrees (0-360). + """ + lat1, lon1, lat2, lon2 = map(math.radians, [lat1, lon1, lat2, lon2]) + dlon = lon2 - lon1 + + x = math.sin(dlon) * math.cos(lat2) + y = math.cos(lat1) * math.sin(lat2) - math.sin(lat1) * math.cos(lat2) * math.cos(dlon) + + bearing = math.degrees(math.atan2(x, y)) + return (bearing + 360) % 360 + + def generate_hopi_circles( center_lat: float, center_lon: float, @@ -148,3 +322,37 @@ def generate_waypoints_on_circle( waypoints.append((lat, lon)) return waypoints + + +def generate_spiral_route( + center_lat: float, + center_lon: float, + initial_radius_miles: float = 10.0, + expansion_factor: float = 2.0, + max_radius_miles: float = 100.0, + num_circles: int = 5, + points_per_circle: int = 8 +) -> SearchRoute: + """ + Generate a connected spiral search route from center outward. + + This is the main entry point for generating ground search navigation. + Follows the pattern: starting coords -> hopi circles -> outward spiral. + + Args: + center_lat, center_lon: Starting coordinates (estimated landing point) + initial_radius_miles: Radius of innermost circle + expansion_factor: How much each circle expands + max_radius_miles: Maximum search radius + num_circles: Number of concentric circles + points_per_circle: Waypoints per circle + + Returns: + SearchRoute with connected waypoints and total distance + """ + pattern = generate_hopi_circles( + center_lat, center_lon, + initial_radius_miles, expansion_factor, + max_radius_miles, num_circles + ) + return pattern.generate_spiral_route(points_per_circle) diff --git a/tests/unit/test_hopi_circles.py b/tests/unit/test_hopi_circles.py new file mode 100644 index 0000000..097aec4 --- /dev/null +++ b/tests/unit/test_hopi_circles.py @@ -0,0 +1,502 @@ +""" +NIGHTWATCH Hopi Circles & Search Pattern Tests +Comprehensive coverage for geodesic calculations, waypoint generation, +spiral routes, and search pattern geometry. + +presa-nightwatch. velmu-test. +""" + +import math +import pytest + +from services.meteor_tracking.hopi_circles import ( + Waypoint, + SearchCircle, + SearchPattern, + SearchRoute, + haversine_miles, + destination_point, + initial_bearing, + generate_hopi_circles, + generate_waypoints_on_circle, + generate_spiral_route, +) + + +# ============================================================================= +# Geodesic Math Tests +# ============================================================================= + +class TestHaversineMiles: + """Test haversine distance calculation.""" + + def test_same_point_zero_distance(self): + assert haversine_miles(39.5, -117.0, 39.5, -117.0) == 0.0 + + def test_known_distance_new_york_to_los_angeles(self): + # NYC to LA is roughly 2,451 miles + dist = haversine_miles(40.7128, -74.0060, 34.0522, -118.2437) + assert 2400 < dist < 2500 + + def test_symmetry(self): + d1 = haversine_miles(39.5, -117.0, 40.0, -116.5) + d2 = haversine_miles(40.0, -116.5, 39.5, -117.0) + assert abs(d1 - d2) < 1e-10 + + def test_short_distance_accuracy(self): + # 1 degree latitude is about 69 miles + dist = haversine_miles(39.0, -117.0, 40.0, -117.0) + assert 68.5 < dist < 69.5 + + def test_cross_equator(self): + dist = haversine_miles(1.0, 0.0, -1.0, 0.0) + assert 137 < dist < 139 # ~138 miles for 2 degrees + + def test_cross_prime_meridian(self): + dist = haversine_miles(51.5, -0.5, 51.5, 0.5) + assert dist > 0 + assert dist < 50 # less than 50 miles for 1 degree at this latitude + + +class TestDestinationPoint: + """Test geodesic destination point calculation.""" + + def test_north(self): + lat, lon = destination_point(39.5, -117.0, 0.0, 69.0) + # ~1 degree north + assert abs(lat - 40.5) < 0.1 + assert abs(lon - (-117.0)) < 0.01 + + def test_south(self): + lat, lon = destination_point(39.5, -117.0, 180.0, 69.0) + assert abs(lat - 38.5) < 0.1 + assert abs(lon - (-117.0)) < 0.01 + + def test_east(self): + lat, lon = destination_point(39.5, -117.0, 90.0, 50.0) + assert lat == pytest.approx(39.5, abs=0.1) + assert lon > -117.0 # should move east + + def test_west(self): + lat, lon = destination_point(39.5, -117.0, 270.0, 50.0) + assert lat == pytest.approx(39.5, abs=0.1) + assert lon < -117.0 # should move west + + def test_roundtrip_consistency(self): + """Go somewhere and measure the distance back.""" + start_lat, start_lon = 39.5, -117.0 + dest_lat, dest_lon = destination_point(start_lat, start_lon, 45.0, 100.0) + roundtrip_dist = haversine_miles(start_lat, start_lon, dest_lat, dest_lon) + assert abs(roundtrip_dist - 100.0) < 0.5 + + def test_zero_distance(self): + lat, lon = destination_point(39.5, -117.0, 0.0, 0.0) + assert abs(lat - 39.5) < 1e-10 + assert abs(lon - (-117.0)) < 1e-10 + + +class TestInitialBearing: + """Test initial bearing calculation.""" + + def test_due_north(self): + bearing = initial_bearing(39.0, -117.0, 40.0, -117.0) + assert abs(bearing - 0.0) < 1.0 + + def test_due_south(self): + bearing = initial_bearing(40.0, -117.0, 39.0, -117.0) + assert abs(bearing - 180.0) < 1.0 + + def test_due_east(self): + bearing = initial_bearing(39.5, -118.0, 39.5, -117.0) + assert abs(bearing - 90.0) < 1.0 + + def test_due_west(self): + bearing = initial_bearing(39.5, -117.0, 39.5, -118.0) + assert abs(bearing - 270.0) < 1.0 + + def test_northeast(self): + bearing = initial_bearing(39.0, -118.0, 40.0, -117.0) + assert 0 < bearing < 90 + + def test_bearing_range(self): + """Bearing should always be 0-360.""" + bearing = initial_bearing(39.5, -117.0, 38.0, -118.0) + assert 0 <= bearing < 360 + + +# ============================================================================= +# Waypoint Tests +# ============================================================================= + +class TestWaypoint: + """Test Waypoint dataclass.""" + + def test_coordinates_str_north_west(self): + wp = Waypoint(lat=39.5123, lon=-117.0456, circle_index=1, waypoint_index=0) + assert "39.5123N" in wp.coordinates_str + assert "117.0456W" in wp.coordinates_str + + def test_coordinates_str_south_east(self): + wp = Waypoint(lat=-33.8688, lon=151.2093, circle_index=1, waypoint_index=0) + assert "33.8688S" in wp.coordinates_str + assert "151.2093E" in wp.coordinates_str + + def test_label(self): + wp = Waypoint(lat=0, lon=0, circle_index=2, waypoint_index=3, label="C2-W3") + assert wp.label == "C2-W3" + + +# ============================================================================= +# SearchCircle Tests +# ============================================================================= + +class TestSearchCircle: + """Test SearchCircle with waypoint generation.""" + + def test_radius_km_conversion(self): + circle = SearchCircle(39.5, -117.0, 10.0, 1) + assert abs(circle.radius_km - 16.0934) < 0.001 + + def test_area(self): + circle = SearchCircle(39.5, -117.0, 10.0, 1) + expected = math.pi * 100 + assert abs(circle.area_sq_miles - expected) < 0.01 + + def test_contains_point_inside(self): + circle = SearchCircle(39.5, -117.0, 10.0, 1) + # A point ~5 miles away should be inside + assert circle.contains_point(39.57, -117.0) + + def test_contains_point_outside(self): + circle = SearchCircle(39.5, -117.0, 10.0, 1) + # A point ~100 miles away should be outside + assert not circle.contains_point(40.5, -117.0) + + def test_contains_point_on_boundary(self): + # 1 degree latitude is ~69.1 miles at this latitude + circle = SearchCircle(39.5, -117.0, 69.2, 1) + assert circle.contains_point(40.5, -117.0) + + def test_waypoints_count(self): + circle = SearchCircle(39.5, -117.0, 10.0, 1) + wps = circle.waypoints(num_points=8) + assert len(wps) == 8 + + def test_waypoints_distance_from_center(self): + """All waypoints should be approximately radius_miles from center.""" + circle = SearchCircle(39.5, -117.0, 10.0, 1) + wps = circle.waypoints(num_points=8) + for wp in wps: + dist = haversine_miles(circle.center_lat, circle.center_lon, wp.lat, wp.lon) + assert abs(dist - 10.0) < 0.5 # within 0.5 miles + + def test_waypoints_labels(self): + circle = SearchCircle(39.5, -117.0, 10.0, 1) + wps = circle.waypoints(4) + assert wps[0].label == "C1-W0" + assert wps[3].label == "C1-W3" + + def test_waypoints_circle_index(self): + circle = SearchCircle(39.5, -117.0, 10.0, 3) + wps = circle.waypoints(4) + for wp in wps: + assert wp.circle_index == 3 + + def test_waypoints_even_spacing(self): + """Consecutive waypoints should be roughly equally spaced.""" + circle = SearchCircle(39.5, -117.0, 20.0, 1) + wps = circle.waypoints(8) + distances = [] + for i in range(len(wps)): + j = (i + 1) % len(wps) + d = haversine_miles(wps[i].lat, wps[i].lon, wps[j].lat, wps[j].lon) + distances.append(d) + # All segment distances should be within 10% of average + avg = sum(distances) / len(distances) + for d in distances: + assert abs(d - avg) / avg < 0.1 + + def test_waypoints_start_bearing(self): + """First waypoint with bearing=0 should be roughly due north.""" + circle = SearchCircle(39.5, -117.0, 10.0, 1) + wps = circle.waypoints(8, start_bearing=0.0) + # First waypoint should be north of center + assert wps[0].lat > 39.5 + assert abs(wps[0].lon - (-117.0)) < 0.1 + + +# ============================================================================= +# SearchPattern Tests +# ============================================================================= + +class TestSearchPattern: + """Test SearchPattern with full waypoint and spiral generation.""" + + def test_generate_full_waypoints_includes_center(self): + pattern = generate_hopi_circles(39.5, -117.0, num_circles=3) + wps = pattern.generate_full_waypoints(points_per_circle=4) + # First waypoint should be center + assert wps[0].label == "CENTER" + assert wps[0].lat == 39.5 + assert wps[0].lon == -117.0 + assert wps[0].circle_index == 0 + + def test_generate_full_waypoints_count(self): + pattern = generate_hopi_circles(39.5, -117.0, num_circles=3) + wps = pattern.generate_full_waypoints(points_per_circle=8) + # 1 center + 3 circles * 8 points = 25 + assert len(wps) == 25 + + def test_generate_spiral_route(self): + pattern = generate_hopi_circles(39.5, -117.0, num_circles=3) + route = pattern.generate_spiral_route(points_per_circle=8) + + assert isinstance(route, SearchRoute) + assert route.num_circles_covered == 3 + assert route.total_distance_miles > 0 + assert len(route.waypoints) == 25 # 1 + 3*8 + + def test_spiral_route_starts_at_center(self): + pattern = generate_hopi_circles(39.5, -117.0, num_circles=2) + route = pattern.generate_spiral_route(4) + assert route.waypoints[0].label == "CENTER-START" + assert route.waypoints[0].lat == 39.5 + + def test_spiral_route_prayer_string(self): + pattern = generate_hopi_circles(39.5, -117.0, num_circles=3) + route = pattern.generate_spiral_route(8) + prayer = route.to_prayer_string() + assert "spiral-route" in prayer + assert "25 waypoints" in prayer + assert "3 circles" in prayer + assert "39.5N" in prayer + + def test_spiral_route_segment_distances(self): + pattern = generate_hopi_circles(39.5, -117.0, num_circles=2, initial_radius_miles=5.0) + route = pattern.generate_spiral_route(4) + segments = route.segment_distances() + assert len(segments) == len(route.waypoints) - 1 + assert all(d > 0 for d in segments) + + def test_spiral_route_outward_progression(self): + """Waypoints should generally move outward from center.""" + pattern = generate_hopi_circles(39.5, -117.0, num_circles=3, initial_radius_miles=10.0) + route = pattern.generate_spiral_route(8) + + # Average distance from center should increase for each circle's waypoints + circle_avg_distances = [] + for ci in range(3): + circle_wps = [w for w in route.waypoints if w.circle_index == ci + 1] + avg_dist = sum( + haversine_miles(39.5, -117.0, w.lat, w.lon) for w in circle_wps + ) / len(circle_wps) + circle_avg_distances.append(avg_dist) + + # Each circle should be farther out + for i in range(1, len(circle_avg_distances)): + assert circle_avg_distances[i] > circle_avg_distances[i-1] + + +# ============================================================================= +# SearchRoute Tests +# ============================================================================= + +class TestSearchRoute: + """Test SearchRoute dataclass.""" + + def test_prayer_string_format(self): + route = SearchRoute( + waypoints=[ + Waypoint(39.5, -117.0, 0, 0, "CENTER"), + Waypoint(39.6, -117.0, 1, 0, "C1-W0"), + ], + total_distance_miles=6.9, + num_circles_covered=1, + center_lat=39.5, + center_lon=-117.0 + ) + s = route.to_prayer_string() + assert "2 waypoints" in s + assert "6.9mi total" in s + assert "1 circles" in s + + def test_segment_distances_consistency(self): + """Sum of segments should equal total distance (for routes built by spiral).""" + pattern = generate_hopi_circles(39.5, -117.0, num_circles=2, initial_radius_miles=5.0) + route = pattern.generate_spiral_route(4) + segment_sum = sum(route.segment_distances()) + assert abs(segment_sum - route.total_distance_miles) < 0.01 + + +# ============================================================================= +# generate_waypoints_on_circle (legacy function) Tests +# ============================================================================= + +class TestGenerateWaypointsOnCircle: + """Test the legacy waypoint generation function.""" + + def test_returns_correct_count(self): + wps = generate_waypoints_on_circle(39.5, -117.0, 10.0, 8) + assert len(wps) == 8 + + def test_returns_tuples(self): + wps = generate_waypoints_on_circle(39.5, -117.0, 10.0, 4) + for wp in wps: + assert isinstance(wp, tuple) + assert len(wp) == 2 + + def test_first_waypoint_is_north(self): + """First waypoint at angle=0 should be due north.""" + wps = generate_waypoints_on_circle(39.5, -117.0, 10.0, 4) + lat, lon = wps[0] + assert lat > 39.5 # north + assert abs(lon - (-117.0)) < 0.01 # same longitude + + def test_waypoints_near_expected_radius(self): + """All waypoints should be approximately at the specified radius.""" + wps = generate_waypoints_on_circle(39.5, -117.0, 10.0, 8) + for lat, lon in wps: + dist = haversine_miles(39.5, -117.0, lat, lon) + assert abs(dist - 10.0) < 1.0 # within 1 mile + + def test_single_waypoint(self): + wps = generate_waypoints_on_circle(39.5, -117.0, 10.0, 1) + assert len(wps) == 1 + + +# ============================================================================= +# generate_spiral_route (top-level function) Tests +# ============================================================================= + +class TestGenerateSpiralRoute: + """Test the top-level spiral route generation.""" + + def test_basic_generation(self): + route = generate_spiral_route(39.5, -117.0, num_circles=3, points_per_circle=4) + assert isinstance(route, SearchRoute) + assert len(route.waypoints) == 1 + 3 * 4 + + def test_single_circle(self): + route = generate_spiral_route( + 39.5, -117.0, + initial_radius_miles=5.0, + num_circles=1, + points_per_circle=4 + ) + assert route.num_circles_covered == 1 + assert len(route.waypoints) == 5 # center + 4 + + def test_max_radius_limits_circles(self): + route = generate_spiral_route( + 39.5, -117.0, + initial_radius_miles=10.0, + expansion_factor=2.0, + max_radius_miles=25.0, + num_circles=10, + points_per_circle=4 + ) + # 10, 20 fit; 40 exceeds max_radius_miles=25 + assert route.num_circles_covered == 2 + + def test_different_center_coordinates(self): + """Test with southern hemisphere coordinates.""" + route = generate_spiral_route(-33.8688, 151.2093, num_circles=2, points_per_circle=4) + assert route.center_lat == -33.8688 + assert route.center_lon == 151.2093 + assert route.total_distance_miles > 0 + + +# ============================================================================= +# Integration: Full Flow from Trajectory to Spiral Route +# ============================================================================= + +class TestHopiCirclesIntegration: + """Integration tests simulating the full search pattern workflow.""" + + def test_chelyabinsk_style_event(self): + """ + Simulate a Chelyabinsk-style event: + - Entry from northeast at shallow angle + - Terminal point around 54.8N, 61.1E + - Generate search pattern from estimated debris field + """ + from services.meteor_tracking.trajectory import calculate_trajectory + + trajectory = calculate_trajectory( + start_lat=55.5, start_lon=60.0, + end_lat=54.8, end_lon=61.1, + start_alt_km=95, end_alt_km=20, + velocity_km_s=19.0 + ) + + # Debris field should exist (low terminal altitude) + assert trajectory.debris_field_center is not None + debris_lat, debris_lon = trajectory.debris_field_center + + # Generate hopi circles around debris field + pattern = generate_hopi_circles( + debris_lat, debris_lon, + initial_radius_miles=5, + expansion_factor=2.0, + max_radius_miles=40, + num_circles=4 + ) + assert len(pattern.circles) == 4 + + # Generate spiral search route + route = pattern.generate_spiral_route(points_per_circle=8) + assert route.total_distance_miles > 0 + assert route.waypoints[0].label == "CENTER-START" + + # Verify route covers increasing distances + center_wp = route.waypoints[0] + for ci in range(1, 4): + circle_wps = [w for w in route.waypoints if w.circle_index == ci] + for wp in circle_wps: + dist = haversine_miles(center_wp.lat, center_wp.lon, wp.lat, wp.lon) + expected_radius = 5 * (2.0 ** (ci - 1)) + assert abs(dist - expected_radius) < 1.0 + + def test_nevada_site_meteor_search(self): + """ + Test from NIGHTWATCH's home base in Nevada. + Starting coords -> hopi circles -> marking outward. + """ + # Nevada property coordinates + home_lat, home_lon = 39.5, -117.0 + + # Hypothetical fireball terminal point 30 miles NE + debris_lat, debris_lon = destination_point(home_lat, home_lon, 45.0, 30.0) + + # Generate search from debris point + pattern = generate_hopi_circles( + debris_lat, debris_lon, + initial_radius_miles=2, + expansion_factor=1.5, + max_radius_miles=20, + num_circles=5 + ) + + route = pattern.generate_spiral_route(points_per_circle=6) + + # Verify the route stays reasonable + assert route.total_distance_miles > 0 + assert route.total_distance_miles < 500 # shouldn't be absurdly long + + # Verify all waypoints are on Earth + for wp in route.waypoints: + assert -90 <= wp.lat <= 90 + assert -180 <= wp.lon <= 180 + + # Verify prayer output works + prayer = route.to_prayer_string() + assert "spiral-route" in prayer + assert "circles" in prayer + + # Verify search pattern prayer + search_prayer = pattern.to_prayer_string() + assert "circles-search" in search_prayer + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/tests/unit/test_trajectory.py b/tests/unit/test_trajectory.py new file mode 100644 index 0000000..272b3ba --- /dev/null +++ b/tests/unit/test_trajectory.py @@ -0,0 +1,259 @@ +""" +NIGHTWATCH Trajectory Calculator Tests +Coverage for trajectory estimation, visibility calculations, +and debris field prediction. + +presa-nightwatch. velmu-test. +""" + +import math +import pytest + +from services.meteor_tracking.trajectory import ( + Vector, + TrajectoryResult, + calculate_trajectory, + estimate_trajectory_from_single_point, + is_visible_from, + get_visibility_radius_km, +) + + +class TestVector: + """Test Vector operations.""" + + def test_magnitude(self): + v = Vector(3.0, 4.0, 0.0) + assert abs(v.magnitude() - 5.0) < 1e-10 + + def test_magnitude_3d(self): + v = Vector(1.0, 2.0, 2.0) + assert abs(v.magnitude() - 3.0) < 1e-10 + + def test_zero_vector_magnitude(self): + v = Vector(0, 0, 0) + assert v.magnitude() == 0.0 + + def test_normalize(self): + v = Vector(3.0, 4.0, 0.0) + n = v.normalize() + assert abs(n.magnitude() - 1.0) < 1e-10 + assert abs(n.x - 0.6) < 1e-10 + assert abs(n.y - 0.8) < 1e-10 + + def test_normalize_zero_vector(self): + v = Vector(0, 0, 0) + n = v.normalize() + assert n.x == 0 and n.y == 0 and n.z == 0 + + def test_to_compass_north(self): + v = Vector(0, 1, 0) # positive y = north + assert v.to_compass() == "N" + + def test_to_compass_east(self): + v = Vector(1, 0, 0) # positive x = east + assert v.to_compass() == "E" + + def test_to_compass_south(self): + v = Vector(0, -1, 0) + assert v.to_compass() == "S" + + def test_to_compass_west(self): + v = Vector(-1, 0, 0) + assert v.to_compass() == "W" + + def test_to_compass_northeast(self): + v = Vector(1, 1, 0) + assert v.to_compass() == "NE" + + +class TestTrajectoryResult: + """Test TrajectoryResult properties.""" + + def test_vector_trace_str(self): + result = TrajectoryResult( + entry_direction="NW", + travel_direction="SE", + entry_angle_deg=45.0, + velocity_km_s=20.0 + ) + assert result.vector_trace_str == "NW to SE, 45 entry" + + +class TestCalculateTrajectory: + """Test trajectory calculation with various scenarios.""" + + def test_southward_trajectory(self): + result = calculate_trajectory( + start_lat=40.0, start_lon=-117.0, + end_lat=39.0, end_lon=-117.0 + ) + assert result.travel_direction == "S" + assert result.entry_direction == "N" + + def test_northward_trajectory(self): + result = calculate_trajectory( + start_lat=39.0, start_lon=-117.0, + end_lat=40.0, end_lon=-117.0 + ) + assert result.travel_direction == "N" + + def test_default_entry_angle(self): + """Without altitude data, entry angle defaults to 45.""" + result = calculate_trajectory( + start_lat=40.0, start_lon=-118.0, + end_lat=39.0, end_lon=-117.0 + ) + assert result.entry_angle_deg == 45.0 + + def test_steep_entry_angle(self): + """With large altitude drop over short distance, angle should be steep.""" + result = calculate_trajectory( + start_lat=39.5, start_lon=-117.0, + end_lat=39.51, end_lon=-117.01, + start_alt_km=80, end_alt_km=20 + ) + assert result.entry_angle_deg > 45.0 # steep + + def test_shallow_entry_angle(self): + """With small altitude drop over long distance, angle should be shallow.""" + result = calculate_trajectory( + start_lat=40.0, start_lon=-119.0, + end_lat=39.0, end_lon=-117.0, + start_alt_km=80, end_alt_km=70 + ) + assert result.entry_angle_deg < 10.0 # very shallow + + def test_debris_field_predicted_for_low_terminal(self): + result = calculate_trajectory( + start_lat=40.0, start_lon=-118.0, + end_lat=39.0, end_lon=-117.0, + start_alt_km=80, end_alt_km=15, + velocity_km_s=18 + ) + assert result.debris_field_center is not None + assert result.debris_field_radius_km is not None + + def test_no_debris_field_for_high_terminal(self): + result = calculate_trajectory( + start_lat=40.0, start_lon=-118.0, + end_lat=39.0, end_lon=-117.0, + start_alt_km=80, end_alt_km=40, + velocity_km_s=30 + ) + assert result.debris_field_center is None + + def test_debris_radius_scales_with_velocity(self): + slow = calculate_trajectory( + start_lat=40.0, start_lon=-118.0, + end_lat=39.0, end_lon=-117.0, + start_alt_km=80, end_alt_km=15, + velocity_km_s=10 + ) + fast = calculate_trajectory( + start_lat=40.0, start_lon=-118.0, + end_lat=39.0, end_lon=-117.0, + start_alt_km=80, end_alt_km=15, + velocity_km_s=40 + ) + assert fast.debris_field_radius_km > slow.debris_field_radius_km + + def test_default_velocity(self): + result = calculate_trajectory( + start_lat=40.0, start_lon=-118.0, + end_lat=39.0, end_lon=-117.0 + ) + assert result.velocity_km_s == 20.0 # default + + +class TestEstimateTrajectoryFromSinglePoint: + """Test single-point trajectory estimation.""" + + def test_returns_trajectory_result(self): + result = estimate_trajectory_from_single_point(39.5, -117.0) + assert isinstance(result, TrajectoryResult) + + def test_unknown_directions(self): + result = estimate_trajectory_from_single_point(39.5, -117.0) + assert result.entry_direction == "unknown" + assert result.travel_direction == "unknown" + + def test_default_entry_angle(self): + result = estimate_trajectory_from_single_point(39.5, -117.0) + assert result.entry_angle_deg == 45.0 + + def test_preserves_coordinates(self): + result = estimate_trajectory_from_single_point(39.5, -117.0, velocity_km_s=25.0) + assert result.last_seen_lat == 39.5 + assert result.last_seen_lon == -117.0 + assert result.velocity_km_s == 25.0 + + def test_no_debris_field(self): + result = estimate_trajectory_from_single_point(39.5, -117.0) + assert result.debris_field_center is None + assert result.debris_field_radius_km is None + + +class TestIsVisibleFrom: + """Test fireball visibility calculations.""" + + def test_overhead_event_visible(self): + assert is_visible_from(39.5, -117.0, 39.5, -117.0, event_alt_km=80.0) + + def test_nearby_event_visible(self): + assert is_visible_from(39.5, -117.0, 40.0, -117.5, event_alt_km=80.0) + + def test_distant_low_event_not_visible(self): + assert not is_visible_from( + 39.5, -117.0, 60.0, -50.0, event_alt_km=30.0 + ) + + def test_very_close_always_visible(self): + """Events < 1km away should always be visible.""" + assert is_visible_from(39.5, -117.0, 39.5001, -117.0001, event_alt_km=0.1) + + def test_high_altitude_visible_from_far(self): + """High altitude events should be visible from further away.""" + visible = is_visible_from( + 39.5, -117.0, 42.0, -115.0, event_alt_km=100.0 + ) + not_visible = is_visible_from( + 39.5, -117.0, 42.0, -115.0, event_alt_km=10.0 + ) + assert visible + assert not not_visible + + def test_min_elevation_threshold(self): + """Higher min elevation should reduce visibility range.""" + visible_low_threshold = is_visible_from( + 39.5, -117.0, 41.0, -117.0, event_alt_km=50.0, min_elevation_deg=5.0 + ) + visible_high_threshold = is_visible_from( + 39.5, -117.0, 41.0, -117.0, event_alt_km=50.0, min_elevation_deg=30.0 + ) + assert visible_low_threshold + assert not visible_high_threshold + + +class TestGetVisibilityRadiusKm: + """Test visibility radius calculation.""" + + def test_higher_altitude_larger_radius(self): + r80 = get_visibility_radius_km(80.0) + r30 = get_visibility_radius_km(30.0) + assert r80 > r30 + + def test_known_value(self): + """At 80km altitude with 10 degree min elevation, radius should be ~454km.""" + r = get_visibility_radius_km(80.0, min_elevation_deg=10.0) + expected = 80.0 / math.tan(math.radians(10.0)) + assert abs(r - expected) < 0.01 + + def test_steeper_min_elevation_smaller_radius(self): + r10 = get_visibility_radius_km(80.0, min_elevation_deg=10.0) + r30 = get_visibility_radius_km(80.0, min_elevation_deg=30.0) + assert r10 > r30 + + +if __name__ == "__main__": + pytest.main([__file__, "-v"])