""" NASA JPL Horizons data query service """ from datetime import datetime, timedelta from astroquery.jplhorizons import Horizons from astropy.time import Time import logging import re from app.models.celestial import Position, CelestialBody logger = logging.getLogger(__name__) class HorizonsService: """Service for querying NASA JPL Horizons system""" def __init__(self): """Initialize the service""" self.location = "@sun" # Heliocentric coordinates def get_body_positions( self, body_id: str, start_time: datetime | None = None, end_time: datetime | None = None, step: str = "1d", ) -> list[Position]: """ Get positions for a celestial body over a time range Args: body_id: JPL Horizons ID (e.g., '-31' for Voyager 1) start_time: Start datetime (default: now) end_time: End datetime (default: now) step: Time step (e.g., '1d' for 1 day, '1h' for 1 hour) Returns: List of Position objects """ try: # Set default times if start_time is None: start_time = datetime.utcnow() if end_time is None: end_time = start_time # Convert to astropy Time objects for single point queries # For ranges, use ISO format strings which Horizons prefers # Create time range if start_time == end_time: # Single time point - use JD format epochs = Time(start_time).jd else: # Time range - use ISO format (YYYY-MM-DD HH:MM) # Horizons expects this format for ranges start_str = start_time.strftime('%Y-%m-%d %H:%M') end_str = end_time.strftime('%Y-%m-%d %H:%M') epochs = {"start": start_str, "stop": end_str, "step": step} logger.info(f"Querying Horizons for body {body_id} from {start_time} to {end_time}") # Query JPL Horizons obj = Horizons(id=body_id, location=self.location, epochs=epochs) vectors = obj.vectors() # Extract positions positions = [] if isinstance(epochs, dict): # Multiple time points for i in range(len(vectors)): pos = Position( time=Time(vectors["datetime_jd"][i], format="jd").datetime, x=float(vectors["x"][i]), y=float(vectors["y"][i]), z=float(vectors["z"][i]), ) positions.append(pos) else: # Single time point pos = Position( time=start_time, x=float(vectors["x"][0]), y=float(vectors["y"][0]), z=float(vectors["z"][0]), ) positions.append(pos) logger.info(f"Successfully retrieved {len(positions)} positions for body {body_id}") return positions except Exception as e: logger.error(f"Error querying Horizons for body {body_id}: {str(e)}") raise def search_body_by_name(self, name: str) -> dict: """ Search for a celestial body by name in NASA Horizons database Args: name: Body name or ID to search for Returns: Dictionary with search results: { "success": bool, "id": str (extracted or input), "name": str (short name), "full_name": str (complete name from NASA), "error": str (if failed) } """ try: logger.info(f"Searching Horizons for: {name}") # Try to query with the name obj = Horizons(id=name, location=self.location) vec = obj.vectors() # Get the full target name from response targetname = vec['targetname'][0] logger.info(f"Found target: {targetname}") # Extract ID and name from targetname # Possible formats: # 1. "136472 Makemake (2005 FY9)" - ID at start # 2. "Voyager 1 (spacecraft) (-31)" - ID in parentheses # 3. "Mars (499)" - ID in parentheses # 4. "Parker Solar Probe (spacecraft)" - no ID # 5. "Hubble Space Telescope (spacecra" - truncated numeric_id = None short_name = None # Check if input is already a numeric ID input_is_numeric = re.match(r'^-?\d+$', name.strip()) if input_is_numeric: numeric_id = name.strip() # Extract name from targetname # Remove leading ID if present name_part = re.sub(r'^\d+\s+', '', targetname) short_name = name_part.split('(')[0].strip() else: # Try to extract ID from start of targetname (format: "136472 Makemake") start_match = re.match(r'^(\d+)\s+(.+)', targetname) if start_match: numeric_id = start_match.group(1) short_name = start_match.group(2).split('(')[0].strip() else: # Try to extract ID from parentheses (format: "Name (-31)" or "Name (499)") id_match = re.search(r'\((-?\d+)\)', targetname) if id_match: numeric_id = id_match.group(1) short_name = targetname.split('(')[0].strip() else: # No numeric ID found, use input name as ID numeric_id = name short_name = targetname.split('(')[0].strip() return { "success": True, "id": numeric_id, "name": short_name, "full_name": targetname, "error": None } except Exception as e: error_msg = str(e) logger.error(f"Error searching for {name}: {error_msg}") # Check for specific error types if 'Ambiguous target name' in error_msg: return { "success": False, "id": None, "name": None, "full_name": None, "error": "名称不唯一,请提供更具体的名称或 JPL Horizons ID" } elif 'No matches found' in error_msg or 'Unknown target' in error_msg: return { "success": False, "id": None, "name": None, "full_name": None, "error": "未找到匹配的天体,请检查名称或 ID" } else: return { "success": False, "id": None, "name": None, "full_name": None, "error": f"查询失败: {error_msg}" } # Singleton instance horizons_service = HorizonsService()