""" Predefined Scheduled Tasks All registered tasks for scheduled execution """ import logging from datetime import datetime, timedelta from typing import Dict, Any, List, Optional from sqlalchemy import select, func from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.dialects.postgresql import insert from app.jobs.registry import task_registry from app.models.db.celestial_body import CelestialBody from app.models.db.position import Position from app.models.db.celestial_event import CelestialEvent from app.services.horizons import HorizonsService from app.services.nasa_sbdb_service import nasa_sbdb_service from app.services.event_service import event_service from app.services.planetary_events_service import planetary_events_service logger = logging.getLogger(__name__) @task_registry.register( name="sync_solar_system_positions", description="同步太阳系天体位置数据,从NASA Horizons API获取指定天体的位置数据并保存到数据库", category="data_sync", parameters=[ { "name": "body_ids", "type": "array", "description": "要同步的天体ID列表,例如['10', '199', '299']。如果不指定,则同步所有活跃的太阳系天体", "required": False, "default": None }, { "name": "days", "type": "integer", "description": "同步天数,从今天开始向未来延伸的天数", "required": False, "default": 7 }, { "name": "source", "type": "string", "description": "数据源标记,用于标识数据来源", "required": False, "default": "nasa_horizons_cron" } ] ) async def sync_solar_system_positions( db: AsyncSession, logger: logging.Logger, params: Dict[str, Any] ) -> Dict[str, Any]: """ Sync solar system body positions from NASA Horizons Args: db: Database session logger: Logger instance params: Task parameters - body_ids: List of body IDs to sync (optional, defaults to all active) - days: Number of days to sync (default: 7) - source: Source tag for the data (default: "nasa_horizons_cron") Returns: Summary of sync operation """ # Parse parameters with type conversion (params come from JSON, may be strings) body_ids = params.get("body_ids") days = int(params.get("days", 7)) source = str(params.get("source", "nasa_horizons_cron")) logger.info(f"Starting solar system position sync: days={days}, source={source}") # Get list of bodies to sync if body_ids: # Use specified body IDs result = await db.execute( select(CelestialBody).where( CelestialBody.id.in_(body_ids), CelestialBody.is_active == True ) ) bodies = result.scalars().all() logger.info(f"Syncing {len(bodies)} specified bodies") else: # Get all active solar system bodies # Typically solar system bodies include planets, dwarf planets, major satellites, comets, and probes result = await db.execute( select(CelestialBody).where( CelestialBody.is_active == True, CelestialBody.system_id == 1, CelestialBody.type.in_([ 'planet', 'dwarf_planet', 'satellite', 'comet', 'probe', 'asteroid','star' ]) ) ) bodies = result.scalars().all() logger.info(f"Syncing all {len(bodies)} active solar system bodies") if not bodies: logger.warning("No bodies found to sync") return { "success": True, "bodies_synced": 0, "total_positions": 0, "message": "No bodies found" } # Initialize services horizons = HorizonsService() # Sync positions for each body total_positions = 0 synced_bodies = [] failed_bodies = [] start_time = datetime.utcnow() end_time = start_time + timedelta(days=days) for body in bodies: # Use savepoint for this body's operations async with db.begin_nested(): # Creates a SAVEPOINT try: logger.debug(f"Fetching positions for {body.name} ({body.id})") # Fetch positions from NASA Horizons positions = await horizons.get_body_positions( body_id=body.id, start_time=start_time, end_time=end_time, step="1d" # Daily positions ) # Save positions to database (upsert logic) count = 0 for pos in positions: # Use PostgreSQL's INSERT ... ON CONFLICT to handle duplicates stmt = insert(Position).values( body_id=body.id, time=pos.time, x=pos.x, y=pos.y, z=pos.z, vx=getattr(pos, 'vx', None), vy=getattr(pos, 'vy', None), vz=getattr(pos, 'vz', None), source=source ) # On conflict (body_id, time), update the existing record stmt = stmt.on_conflict_do_update( index_elements=['body_id', 'time'], set_={ 'x': pos.x, 'y': pos.y, 'z': pos.z, 'vx': getattr(pos, 'vx', None), 'vy': getattr(pos, 'vy', None), 'vz': getattr(pos, 'vz', None), 'source': source } ) await db.execute(stmt) count += 1 # Savepoint will auto-commit if no exception total_positions += count synced_bodies.append(body.name) logger.debug(f"Saved {count} positions for {body.name}") except Exception as e: # Savepoint will auto-rollback on exception logger.error(f"Failed to sync {body.name}: {str(e)}") failed_bodies.append({"body": body.name, "error": str(e)}) # Continue to next body # Summary result = { "success": len(failed_bodies) == 0, "bodies_synced": len(synced_bodies), "total_positions": total_positions, "synced_bodies": synced_bodies, "failed_bodies": failed_bodies, "time_range": f"{start_time.date()} to {end_time.date()}", "source": source } logger.info(f"Sync completed: {len(synced_bodies)} bodies, {total_positions} positions") return result @task_registry.register( name="fetch_close_approach_events", description="从NASA SBDB获取小行星/彗星近距离飞掠事件,并保存到数据库", category="data_sync", parameters=[ { "name": "body_ids", "type": "array", "description": "要查询的天体ID列表,例如['399', '499']表示地球和火星。如果不指定,默认只查询地球(399)", "required": False, "default": None }, { "name": "days_ahead", "type": "integer", "description": "向未来查询的天数,例如30表示查询未来30天内的事件", "required": False, "default": 30 }, { "name": "dist_max", "type": "string", "description": "最大距离(AU),例如'30'表示30天文单位内的飞掠", "required": False, "default": "30" }, { "name": "limit", "type": "integer", "description": "每个天体最大返回事件数量", "required": False, "default": 100 }, { "name": "clean_old_events", "type": "boolean", "description": "是否清理已过期的旧事件", "required": False, "default": True } ] ) async def fetch_close_approach_events( db: AsyncSession, logger: logging.Logger, params: Dict[str, Any] ) -> Dict[str, Any]: """ Fetch close approach events from NASA SBDB and save to database This task queries the NASA Small-Body Database (SBDB) for upcoming close approach events (asteroid/comet flybys) and stores them in the celestial_events table. Note: Uses tomorrow's date as the query start date to avoid fetching events that have already occurred today. Args: db: Database session logger: Logger instance params: Task parameters - body_ids: List of body IDs to query (default: ['399'] for Earth) - days_ahead: Number of days to query ahead from tomorrow (default: 30) - dist_max: Maximum approach distance in AU (default: '30') - limit: Maximum number of events per body (default: 100) - clean_old_events: Clean old events before inserting (default: True) Returns: Summary of fetch operation """ # Parse parameters with type conversion (params come from JSON, may be strings) body_ids = params.get("body_ids") or ["399"] # Default to Earth days_ahead = int(params.get("days_ahead", 30)) dist_max = str(params.get("dist_max", "30")) # Keep as string for API limit = int(params.get("limit", 100)) clean_old_events = bool(params.get("clean_old_events", True)) logger.info(f"Fetching close approach events: body_ids={body_ids}, days={days_ahead}, dist_max={dist_max}AU") # Calculate date range - use tomorrow as start date to avoid past events tomorrow = datetime.utcnow() + timedelta(days=1) date_min = tomorrow.strftime("%Y-%m-%d") date_max = (tomorrow + timedelta(days=days_ahead)).strftime("%Y-%m-%d") # Statistics total_events_fetched = 0 total_events_saved = 0 total_events_failed = 0 body_results = [] # Process each body for body_id in body_ids: try: # Query celestial_bodies table to find the target body body_result = await db.execute( select(CelestialBody).where(CelestialBody.id == body_id) ) target_body = body_result.scalar_one_or_none() if not target_body: logger.warning(f"Body '{body_id}' not found in celestial_bodies table, skipping") body_results.append({ "body_id": body_id, "success": False, "error": "Body not found in database" }) continue target_body_id = target_body.id approach_body_name = target_body.name # Use short_name from database if available (for NASA SBDB API) # NASA SBDB API uses abbreviated names for planets (e.g., Juptr for Jupiter) api_body_name = target_body.short_name if target_body.short_name else approach_body_name logger.info(f"Processing events for: {target_body.name} (ID: {target_body_id}, API name: {api_body_name})") # Clean old events if requested if clean_old_events: try: cutoff_date = datetime.utcnow() deleted_count = await event_service.delete_events_for_body_before( body_id=target_body_id, before_time=cutoff_date, db=db ) logger.info(f"Cleaned {deleted_count} old events for {target_body.name}") except Exception as e: logger.warning(f"Failed to clean old events for {target_body.name}: {e}") # Fetch events from NASA SBDB sbdb_events = await nasa_sbdb_service.get_close_approaches( date_min=date_min, date_max=date_max, dist_max=dist_max, body=api_body_name, # Use mapped API name limit=limit, fullname=True ) logger.info(f"Retrieved {len(sbdb_events)} events from NASA SBDB for {target_body.name}") total_events_fetched += len(sbdb_events) if not sbdb_events: body_results.append({ "body_id": target_body_id, "body_name": target_body.name, "events_saved": 0, "message": "No events found" }) continue # Parse and save events saved_count = 0 failed_count = 0 for sbdb_event in sbdb_events: try: # Parse SBDB event to CelestialEvent format parsed_event = nasa_sbdb_service.parse_event_to_celestial_event( sbdb_event, approach_body=approach_body_name ) if not parsed_event: logger.warning(f"Failed to parse SBDB event: {sbdb_event.get('des', 'Unknown')}") failed_count += 1 continue # Create event data event_data = { "body_id": target_body_id, "title": parsed_event["title"], "event_type": parsed_event["event_type"], "event_time": parsed_event["event_time"], "description": parsed_event["description"], "details": parsed_event["details"], "source": parsed_event["source"] } event = CelestialEvent(**event_data) db.add(event) await db.flush() saved_count += 1 logger.debug(f"Saved event: {event.title}") except Exception as e: logger.error(f"Failed to save event {sbdb_event.get('des', 'Unknown')}: {e}") failed_count += 1 # Commit events for this body await db.commit() total_events_saved += saved_count total_events_failed += failed_count body_results.append({ "body_id": target_body_id, "body_name": target_body.name, "events_fetched": len(sbdb_events), "events_saved": saved_count, "events_failed": failed_count }) logger.info(f"Saved {saved_count}/{len(sbdb_events)} events for {target_body.name}") except Exception as e: logger.error(f"Error processing body {body_id}: {e}") body_results.append({ "body_id": body_id, "success": False, "error": str(e) }) # Summary result = { "success": True, "total_bodies_processed": len(body_ids), "total_events_fetched": total_events_fetched, "total_events_saved": total_events_saved, "total_events_failed": total_events_failed, "date_range": f"{date_min} to {date_max}", "dist_max_au": dist_max, "body_results": body_results } logger.info(f"Task completed: {total_events_saved} events saved for {len(body_ids)} bodies") return result @task_registry.register( name="calculate_planetary_events", description="计算太阳系主要天体的合、冲等事件,使用Skyfield进行天文计算", category="data_sync", parameters=[ { "name": "body_ids", "type": "array", "description": "要计算事件的天体ID列表,例如['199', '299', '499']。如果不指定,则计算所有主要行星(水星到海王星)", "required": False, "default": None }, { "name": "days_ahead", "type": "integer", "description": "向未来计算的天数", "required": False, "default": 365 }, { "name": "calculate_close_approaches", "type": "boolean", "description": "是否同时计算行星之间的近距离接近事件", "required": False, "default": False }, { "name": "threshold_degrees", "type": "number", "description": "近距离接近的角度阈值(度),仅当calculate_close_approaches为true时有效", "required": False, "default": 5.0 }, { "name": "clean_old_events", "type": "boolean", "description": "是否清理已过期的旧事件", "required": False, "default": True } ] ) async def calculate_planetary_events( db: AsyncSession, logger: logging.Logger, params: Dict[str, Any] ) -> Dict[str, Any]: """ Calculate planetary events (conjunctions, oppositions) using Skyfield This task uses the Skyfield library to calculate astronomical events for major solar system bodies, including conjunctions (合) and oppositions (冲). Args: db: Database session logger: Logger instance params: Task parameters - body_ids: List of body IDs to calculate (default: all major planets) - days_ahead: Number of days to calculate ahead (default: 365) - calculate_close_approaches: Also calculate planet-planet close approaches (default: False) - threshold_degrees: Angle threshold for close approaches (default: 5.0) - clean_old_events: Clean old events before calculating (default: True) Returns: Summary of calculation operation """ # Parse parameters with type conversion (params come from JSON, may be strings) body_ids = params.get("body_ids") days_ahead = int(params.get("days_ahead", 365)) calculate_close_approaches = bool(params.get("calculate_close_approaches", False)) threshold_degrees = float(params.get("threshold_degrees", 5.0)) clean_old_events = bool(params.get("clean_old_events", True)) logger.info(f"Starting planetary event calculation: days_ahead={days_ahead}, close_approaches={calculate_close_approaches}") # Statistics total_events_calculated = 0 total_events_saved = 0 total_events_failed = 0 try: # Calculate oppositions and conjunctions logger.info("Calculating oppositions and conjunctions...") events = planetary_events_service.calculate_oppositions_conjunctions( body_ids=body_ids, days_ahead=days_ahead ) logger.info(f"Calculated {len(events)} opposition/conjunction events") total_events_calculated += len(events) # Optionally calculate close approaches between planet pairs if calculate_close_approaches: logger.info("Calculating planetary close approaches...") # Define interesting planet pairs planet_pairs = [ ('199', '299'), # Mercury - Venus ('299', '499'), # Venus - Mars ('499', '599'), # Mars - Jupiter ('599', '699'), # Jupiter - Saturn ] close_approach_events = planetary_events_service.calculate_planetary_distances( body_pairs=planet_pairs, days_ahead=days_ahead, threshold_degrees=threshold_degrees ) logger.info(f"Calculated {len(close_approach_events)} close approach events") events.extend(close_approach_events) total_events_calculated += len(close_approach_events) # Save events to database logger.info(f"Saving {len(events)} events to database...") for event_data in events: try: # Check if body exists in database body_result = await db.execute( select(CelestialBody).where(CelestialBody.id == event_data['body_id']) ) body = body_result.scalar_one_or_none() if not body: logger.warning(f"Body {event_data['body_id']} not found in database, skipping event") total_events_failed += 1 continue # Clean old events for this body if requested (only once per body) if clean_old_events: cutoff_date = datetime.utcnow() deleted_count = await event_service.delete_events_for_body_before( body_id=event_data['body_id'], before_time=cutoff_date, db=db ) if deleted_count > 0: logger.debug(f"Cleaned {deleted_count} old events for {body.name}") # Only clean once per body clean_old_events = False # Check if event already exists (to avoid duplicates) # Truncate event_time to minute precision for comparison event_time_minute = event_data['event_time'].replace(second=0, microsecond=0) existing_event = await db.execute( select(CelestialEvent).where( CelestialEvent.body_id == event_data['body_id'], CelestialEvent.event_type == event_data['event_type'], func.date_trunc('minute', CelestialEvent.event_time) == event_time_minute ) ) existing = existing_event.scalar_one_or_none() if existing: logger.debug(f"Event already exists, skipping: {event_data['title']}") continue # Create and save event event = CelestialEvent( body_id=event_data['body_id'], title=event_data['title'], event_type=event_data['event_type'], event_time=event_data['event_time'], description=event_data['description'], details=event_data['details'], source=event_data['source'] ) db.add(event) await db.flush() total_events_saved += 1 logger.debug(f"Saved event: {event.title}") except Exception as e: logger.error(f"Failed to save event {event_data.get('title', 'Unknown')}: {e}") total_events_failed += 1 # Commit all events await db.commit() result = { "success": True, "total_events_calculated": total_events_calculated, "total_events_saved": total_events_saved, "total_events_failed": total_events_failed, "calculation_period_days": days_ahead, "close_approaches_enabled": calculate_close_approaches, } logger.info(f"Task completed: {total_events_saved} events saved, {total_events_failed} failed") return result except Exception as e: logger.error(f"Error in planetary event calculation: {e}") await db.rollback() return { "success": False, "error": str(e), "total_events_calculated": total_events_calculated, "total_events_saved": total_events_saved, "total_events_failed": total_events_failed }