239 lines
8.3 KiB
Python
239 lines
8.3 KiB
Python
"""
|
|
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
|
|
import httpx
|
|
|
|
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
|
|
|
|
async def get_object_data_raw(self, body_id: str) -> str:
|
|
"""
|
|
Get raw object data (terminal style text) from Horizons
|
|
|
|
Args:
|
|
body_id: JPL Horizons ID
|
|
|
|
Returns:
|
|
Raw text response from NASA
|
|
"""
|
|
url = "https://ssd.jpl.nasa.gov/api/horizons.api"
|
|
# Ensure ID is quoted for COMMAND
|
|
cmd_val = f"'{body_id}'" if not body_id.startswith("'") else body_id
|
|
|
|
params = {
|
|
"format": "text",
|
|
"COMMAND": cmd_val,
|
|
"OBJ_DATA": "YES",
|
|
"MAKE_EPHEM": "NO",
|
|
"EPHEM_TYPE": "VECTORS",
|
|
"CENTER": "@sun"
|
|
}
|
|
|
|
try:
|
|
async with httpx.AsyncClient() as client:
|
|
logger.info(f"Fetching raw data for body {body_id}")
|
|
response = await client.get(url, params=params, timeout=30.0)
|
|
|
|
if response.status_code != 200:
|
|
raise Exception(f"NASA API returned status {response.status_code}")
|
|
|
|
return response.text
|
|
except Exception as e:
|
|
logger.error(f"Error fetching raw data for {body_id}: {str(e)}")
|
|
raise
|
|
|
|
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()
|