Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
212 changes: 210 additions & 2 deletions services/meteor_tracking/hopi_circles.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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."""
Expand All @@ -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))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Longitude not normalized after destination

Medium Severity

destination_point returns math.degrees(lon2) without wrapping longitude to the conventional ±180° range. Near the antimeridian, consecutive circle waypoints can mix values above 180° with negative longitudes, so haversine_miles segment and route totals in generate_spiral_route and SearchRoute.segment_distances can be badly wrong for otherwise valid search centers.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 93f4fb7. Configure here.



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,
Expand Down Expand Up @@ -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)
Loading
Loading