cosmo/backend/app/services/orbit_service.py

680 lines
8.6 KiB
Python
Raw Normal View History

2025-11-29 15:09:31 +00:00
"""
Service for managing orbital data
"""
from datetime import datetime, timedelta
from typing import List, Dict, Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.dialects.postgresql import insert
from app.models.db.orbit import Orbit
from app.models.db.celestial_body import CelestialBody
from app.services.horizons import HorizonsService
import logging
logger = logging.getLogger(__name__)
class OrbitService:
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
"""Service for orbit CRUD operations and generation"""
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
@staticmethod
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
async def get_orbit(body_id: str, session: AsyncSession) -> Optional[Orbit]:
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
"""Get orbit data for a specific body"""
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
result = await session.execute(
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
select(Orbit).where(Orbit.body_id == body_id)
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
)
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
return result.scalar_one_or_none()
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
@staticmethod
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
async def get_all_orbits(
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
session: AsyncSession,
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
body_type: Optional[str] = None
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
) -> List[Orbit]:
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
"""Get all orbits, optionally filtered by body type"""
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
if body_type:
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
# Join with celestial_bodies to filter by type
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
query = (
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
select(Orbit)
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
.join(CelestialBody, Orbit.body_id == CelestialBody.id)
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
.where(CelestialBody.type == body_type)
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
)
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
else:
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
query = select(Orbit)
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
result = await session.execute(query)
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
return list(result.scalars().all())
2025-12-11 08:31:26 +00:00
2025-12-08 10:55:38 +00:00
@staticmethod
2025-12-11 08:31:26 +00:00
2025-12-08 10:55:38 +00:00
async def get_all_orbits_with_bodies(
2025-12-11 08:31:26 +00:00
2025-12-08 10:55:38 +00:00
session: AsyncSession,
2025-12-11 08:31:26 +00:00
2025-12-08 10:55:38 +00:00
body_type: Optional[str] = None
2025-12-11 08:31:26 +00:00
2025-12-08 10:55:38 +00:00
) -> List[tuple[Orbit, CelestialBody]]:
2025-12-11 08:31:26 +00:00
2025-12-08 10:55:38 +00:00
"""
2025-12-11 08:31:26 +00:00
2025-12-08 10:55:38 +00:00
Get all orbits with their associated celestial bodies in a single query.
2025-12-11 08:31:26 +00:00
2025-12-08 10:55:38 +00:00
This is optimized to avoid N+1 query problem.
2025-12-11 08:31:26 +00:00
2025-12-08 10:55:38 +00:00
Returns:
2025-12-11 08:31:26 +00:00
2025-12-08 10:55:38 +00:00
List of (Orbit, CelestialBody) tuples
2025-12-11 08:31:26 +00:00
2025-12-08 10:55:38 +00:00
"""
2025-12-11 08:31:26 +00:00
2025-12-08 10:55:38 +00:00
if body_type:
2025-12-11 08:31:26 +00:00
2025-12-08 10:55:38 +00:00
query = (
2025-12-11 08:31:26 +00:00
2025-12-08 10:55:38 +00:00
select(Orbit, CelestialBody)
2025-12-11 08:31:26 +00:00
2025-12-08 10:55:38 +00:00
.join(CelestialBody, Orbit.body_id == CelestialBody.id)
2025-12-11 08:31:26 +00:00
2025-12-08 10:55:38 +00:00
.where(CelestialBody.type == body_type)
2025-12-11 08:31:26 +00:00
2025-12-08 10:55:38 +00:00
)
2025-12-11 08:31:26 +00:00
2025-12-08 10:55:38 +00:00
else:
2025-12-11 08:31:26 +00:00
2025-12-08 10:55:38 +00:00
query = (
2025-12-11 08:31:26 +00:00
2025-12-08 10:55:38 +00:00
select(Orbit, CelestialBody)
2025-12-11 08:31:26 +00:00
2025-12-08 10:55:38 +00:00
.join(CelestialBody, Orbit.body_id == CelestialBody.id)
2025-12-11 08:31:26 +00:00
2025-12-08 10:55:38 +00:00
)
2025-12-11 08:31:26 +00:00
2025-12-08 10:55:38 +00:00
result = await session.execute(query)
2025-12-11 08:31:26 +00:00
2025-12-08 10:55:38 +00:00
return list(result.all())
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
@staticmethod
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
async def save_orbit(
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
body_id: str,
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
points: List[Dict[str, float]],
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
num_points: int,
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
period_days: Optional[float],
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
color: Optional[str],
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
session: AsyncSession
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
) -> Orbit:
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
"""Save or update orbit data using UPSERT"""
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
stmt = insert(Orbit).values(
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
body_id=body_id,
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
points=points,
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
num_points=num_points,
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
period_days=period_days,
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
color=color,
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
created_at=datetime.utcnow(),
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
updated_at=datetime.utcnow()
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
)
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
# On conflict, update all fields
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
stmt = stmt.on_conflict_do_update(
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
index_elements=['body_id'],
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
set_={
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
'points': points,
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
'num_points': num_points,
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
'period_days': period_days,
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
'color': color,
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
'updated_at': datetime.utcnow()
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
}
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
)
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
await session.execute(stmt)
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
await session.commit()
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
# Fetch and return the saved orbit
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
return await OrbitService.get_orbit(body_id, session)
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
@staticmethod
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
async def delete_orbit(body_id: str, session: AsyncSession) -> bool:
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
"""Delete orbit data for a specific body"""
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
orbit = await OrbitService.get_orbit(body_id, session)
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
if orbit:
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
await session.delete(orbit)
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
await session.commit()
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
return True
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
return False
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
@staticmethod
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
async def generate_orbit(
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
body_id: str,
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
body_name: str,
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
period_days: float,
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
color: Optional[str],
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
session: AsyncSession,
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
horizons_service: HorizonsService
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
) -> Orbit:
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
"""
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
Generate complete orbital data for a celestial body
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
Args:
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
body_id: JPL Horizons ID
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
body_name: Display name (for logging)
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
period_days: Orbital period in days
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
color: Hex color for orbit line
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
session: Database session
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
horizons_service: NASA Horizons API service
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
Returns:
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
Generated Orbit object
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
"""
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
logger.info(f"🌌 Generating orbit for {body_name} (period: {period_days:.1f} days)")
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
# Calculate number of sample points
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
# Use at least 100 points for smooth ellipse
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
# For very long periods, cap at 1000 to avoid excessive data
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
MIN_POINTS = 100
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
MAX_POINTS = 1000
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
if period_days < 3650: # < 10 years
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
# For planets: aim for ~1 point per day, minimum 100
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
num_points = max(MIN_POINTS, min(int(period_days), 365))
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
else: # >= 10 years
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
# For outer planets and dwarf planets: monthly sampling
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
num_points = min(int(period_days / 30), MAX_POINTS)
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
# Calculate step size in days
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
step_days = max(1, int(period_days / num_points))
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
logger.info(f" 📊 Sampling {num_points} points (every {step_days} days)")
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
# Query NASA Horizons for complete orbital period
2025-12-11 08:31:26 +00:00
2025-12-10 08:49:16 +00:00
# NASA Horizons has limited date range (typically 1900-2200)
2025-12-11 08:31:26 +00:00
2025-12-10 08:49:16 +00:00
# For very long periods, we need to limit the query range
2025-12-11 08:31:26 +00:00
2025-12-10 08:49:16 +00:00
MAX_QUERY_YEARS = 250 # Maximum years we can query (1900-2150)
2025-12-11 08:31:26 +00:00
2025-12-10 08:49:16 +00:00
MAX_QUERY_DAYS = MAX_QUERY_YEARS * 365
2025-12-11 08:31:26 +00:00
2025-12-10 08:49:16 +00:00
if period_days > MAX_QUERY_DAYS:
2025-12-11 08:31:26 +00:00
2025-12-10 08:49:16 +00:00
# For extremely long periods (>250 years), sample a partial orbit
2025-12-11 08:31:26 +00:00
2025-12-10 08:49:16 +00:00
# Use enough data to show the orbital shape accurately
2025-12-11 08:31:26 +00:00
2025-12-10 08:49:16 +00:00
actual_query_days = MAX_QUERY_DAYS
2025-12-11 08:31:26 +00:00
2025-12-10 08:49:16 +00:00
start_time = datetime(1900, 1, 1)
2025-12-11 08:31:26 +00:00
2025-12-10 08:49:16 +00:00
end_time = datetime(1900 + MAX_QUERY_YEARS, 1, 1)
2025-12-11 08:31:26 +00:00
2025-12-10 08:49:16 +00:00
logger.warning(f" ⚠️ Period too long ({period_days/365:.1f} years), sampling {MAX_QUERY_YEARS} years only")
2025-12-11 08:31:26 +00:00
2025-12-10 08:49:16 +00:00
logger.info(f" 📅 Using partial orbit range: 1900-{1900 + MAX_QUERY_YEARS}")
2025-12-11 08:31:26 +00:00
2025-12-10 08:49:16 +00:00
# Adjust sampling rate for partial orbit
2025-12-11 08:31:26 +00:00
2025-12-10 08:49:16 +00:00
# We still want enough points to show the shape
2025-12-11 08:31:26 +00:00
2025-12-10 08:49:16 +00:00
partial_ratio = actual_query_days / period_days
2025-12-11 08:31:26 +00:00
2025-12-10 08:49:16 +00:00
adjusted_num_points = max(MIN_POINTS, int(num_points * 0.5)) # At least half the intended points
2025-12-11 08:31:26 +00:00
2025-12-10 08:49:16 +00:00
step_days = max(1, int(actual_query_days / adjusted_num_points))
2025-12-11 08:31:26 +00:00
2025-12-10 08:49:16 +00:00
logger.info(f" 📊 Adjusted sampling: {adjusted_num_points} points (every {step_days} days)")
2025-12-11 08:31:26 +00:00
2025-12-10 08:49:16 +00:00
elif period_days > 150 * 365: # More than 150 years but <= 250 years
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
# Start from year 1900 for historical data
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
start_time = datetime(1900, 1, 1)
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
end_time = start_time + timedelta(days=period_days)
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
logger.info(f" 📅 Using historical date range (1900-{end_time.year}) for long-period orbit")
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
else:
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
start_time = datetime.utcnow()
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
end_time = start_time + timedelta(days=period_days)
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
try:
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
# Get positions from Horizons (synchronous call)
2025-12-11 08:31:26 +00:00
positions = await horizons_service.get_body_positions(
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
body_id=body_id,
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
start_time=start_time,
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
end_time=end_time,
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
step=f"{step_days}d"
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
)
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
if not positions or len(positions) == 0:
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
raise ValueError(f"No position data returned for {body_name}")
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
# Convert Position objects to list of dicts
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
points = [
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
{"x": pos.x, "y": pos.y, "z": pos.z}
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
for pos in positions
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
]
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
logger.info(f" ✅ Retrieved {len(points)} orbital points")
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
# Save to database
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
orbit = await OrbitService.save_orbit(
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
body_id=body_id,
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
points=points,
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
num_points=len(points),
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
period_days=period_days,
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
color=color,
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
session=session
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
)
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
logger.info(f" 💾 Saved orbit for {body_name}")
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
return orbit
2025-12-11 08:31:26 +00:00
2025-11-29 15:09:31 +00:00
except Exception as e:
2025-12-11 08:31:26 +00:00
logger.error(f" ❌ Failed to generate orbit for {body_name}: {repr(e)}")
2025-11-29 15:09:31 +00:00
raise
2025-12-11 08:31:26 +00:00
# Singleton instance
2025-11-29 15:09:31 +00:00
orbit_service = OrbitService()
2025-12-11 08:31:26 +00:00