"""UE Client Data API Flat JSON endpoints designed for easy parsing in Unreal Engine C++/Blueprint. Avoids GeoJSON nesting — every field is at the top level of each item. """ from typing import Any, Dict, List, Optional from fastapi import APIRouter, Depends, Query from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, func from app.core.collected_data_fields import get_record_field from app.core.satellite_tle import build_tle_lines_from_elements from app.core.time import to_iso8601_utc from app.db.session import get_db from app.models.collected_data import CollectedData router = APIRouter() # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _current_stmt(source: str, limit: Optional[int] = None): stmt = ( select(CollectedData) .where(CollectedData.source == source) .where(CollectedData.is_current.is_(True)) .order_by(CollectedData.id.desc()) ) if limit: stmt = stmt.limit(limit) return stmt async def _fetch(db: AsyncSession, source: str, limit: Optional[int] = None) -> List[CollectedData]: result = await db.execute(_current_stmt(source, limit)) return list(result.scalars().all()) def _safe_float(value: Any) -> Optional[float]: try: v = float(value) return v if v == v else None # reject NaN except (TypeError, ValueError): return None # --------------------------------------------------------------------------- # Status endpoint # --------------------------------------------------------------------------- @router.get("/status") async def ue_status(db: AsyncSession = Depends(get_db)): """Quick health-check + data counts for the UE client.""" from datetime import UTC, datetime async def count_source(source: str) -> int: result = await db.execute( select(func.count()) .select_from(CollectedData) .where(CollectedData.source == source) .where(CollectedData.is_current.is_(True)) ) return result.scalar() or 0 return { "ok": True, "server_time": to_iso8601_utc(datetime.now(UTC)), "compute_points_count": await count_source("top500"), "cables_count": await count_source("telegeography_cables"), "landing_points_count": await count_source("arcgis_landing"), "satellites_count": await count_source("celestrak"), } # --------------------------------------------------------------------------- # Compute points (TOP500 supercomputers) # --------------------------------------------------------------------------- @router.get("/compute-points") async def ue_compute_points( limit: int = Query(default=500, ge=1, le=2000), db: AsyncSession = Depends(get_db), ): """ Returns TOP500 supercomputer data as a flat JSON array. Response shape: { "count": 500, "items": [ { "id": "top500_1", "name": "Frontier", "latitude": 36.01, "longitude": -84.26, "country": "United States", "city": "Oak Ridge", "rank": 1, "rmax_tflops": 1194000.0, "rpeak_tflops": 1679616.0, "cores": 8730112, "power_kw": 22703.0 } ] } """ records = await _fetch(db, "top500", limit) items = [] for record in records: meta = record.extra_data or {} lat = _safe_float(get_record_field(record, "latitude")) lon = _safe_float(get_record_field(record, "longitude")) if lat is None or lon is None: continue items.append({ "id": f"top500_{record.id}", "name": record.name or "Unknown", "latitude": lat, "longitude": lon, "country": get_record_field(record, "country") or "", "city": get_record_field(record, "city") or "", "rank": meta.get("rank"), "rmax_tflops": _safe_float(get_record_field(record, "rmax")), "rpeak_tflops": _safe_float(get_record_field(record, "rpeak")), "cores": meta.get("cores"), "power_kw": _safe_float(get_record_field(record, "power")), }) return {"count": len(items), "items": items} # --------------------------------------------------------------------------- # Cable landing points # --------------------------------------------------------------------------- @router.get("/landing-points") async def ue_landing_points(db: AsyncSession = Depends(get_db)): """ Returns cable landing points as a flat JSON array. Response shape: { "count": 1200, "items": [ { "id": "lp_42", "name": "Shoreham", "latitude": 50.83, "longitude": -0.28, "country": "United Kingdom", "city": "Shoreham-by-Sea", "cable_names": ["FLAG", "TAT-14"] } ] } """ # Load landing points lp_records = await _fetch(db, "arcgis_landing") # Load relation + cable data for cable_names mapping rel_result = await db.execute( select(CollectedData) .where(CollectedData.source == "arcgis_relation") .where(CollectedData.is_current.is_(True)) ) rel_records = list(rel_result.scalars().all()) cable_result = await db.execute( select(CollectedData) .where(CollectedData.source == "telegeography_cables") .where(CollectedData.is_current.is_(True)) ) cable_records = list(cable_result.scalars().all()) # Build mapping: city_id → list of cable names city_to_cable_ids: Dict[int, List[int]] = {} for r in rel_records: meta = r.extra_data or {} city_id = meta.get("city_id") cable_id = meta.get("cable_id") if city_id is not None and cable_id is not None: city_to_cable_ids.setdefault(city_id, []) if cable_id not in city_to_cable_ids[city_id]: city_to_cable_ids[city_id].append(cable_id) cable_id_to_name: Dict[int, str] = {} for r in cable_records: meta = r.extra_data or {} cable_id = meta.get("cable_id") if cable_id and r.name: cable_id_to_name[cable_id] = r.name items = [] for record in lp_records: lat = _safe_float(get_record_field(record, "latitude")) lon = _safe_float(get_record_field(record, "longitude")) if lat is None or lon is None: continue meta = record.extra_data or {} city_id = meta.get("city_id") cable_names = [] if city_id in city_to_cable_ids: cable_names = [ cable_id_to_name[cid] for cid in city_to_cable_ids[city_id] if cid in cable_id_to_name ] items.append({ "id": f"lp_{record.id}", "name": record.name or "Unknown", "latitude": lat, "longitude": lon, "country": get_record_field(record, "country") or "", "city": get_record_field(record, "city") or "", "cable_names": cable_names, }) return {"count": len(items), "items": items} # --------------------------------------------------------------------------- # Cables (route geometry) # --------------------------------------------------------------------------- @router.get("/cables") async def ue_cables(db: AsyncSession = Depends(get_db)): """ Returns cable route geometry. Each segment is a flat array of [lon, lat] pairs. Response shape: { "count": 100, "items": [ { "id": "cable_42", "cable_id": "flag", "name": "FLAG", "status": "active", "length_km": 28000, "segments": [ [[lon, lat], [lon, lat], ...] ] } ] } """ records = await _fetch(db, "telegeography_cables") items = [] for record in records: meta = record.extra_data or {} route_coords = meta.get("route_coordinates", []) segments: List[List[List[float]]] = [] if route_coords: # Support both flat [lon,lat] array and array-of-arrays if route_coords and isinstance(route_coords[0][0], list): raw_lines = route_coords else: raw_lines = [route_coords] for raw_line in raw_lines: line = [] for pt in raw_line: try: line.append([float(pt[0]), float(pt[1])]) except (TypeError, ValueError, IndexError): continue if len(line) >= 2: segments.append(line) if not segments: continue items.append({ "id": f"cable_{record.id}", "cable_id": record.source_id or record.name or "", "name": record.name or "Unknown", "status": meta.get("status", "active"), "length_km": _safe_float(get_record_field(record, "value")), "owners": meta.get("owners") or [], "rfs": meta.get("rfs"), "color": meta.get("color"), "segments": segments, }) return {"count": len(items), "items": items} # --------------------------------------------------------------------------- # Satellites (TLE data) # --------------------------------------------------------------------------- @router.get("/satellites") async def ue_satellites( limit: int = Query(default=200, ge=1, le=5000), db: AsyncSession = Depends(get_db), ): """ Returns satellite TLE data for orbit propagation in UE. Response shape: { "count": 200, "items": [ { "id": "sat_42", "norad_id": "25544", "name": "ISS (ZARYA)", "tle_line1": "1 25544U ...", "tle_line2": "2 25544 ...", "epoch": "2026-04-14T00:00:00Z", "inclination": 51.6, "mean_motion": 15.5 } ] } """ records = await _fetch(db, "celestrak", limit) items = [] for record in records: meta = record.extra_data or {} norad_id = meta.get("norad_cat_id") if not norad_id: continue tle1 = meta.get("tle_line1") tle2 = meta.get("tle_line2") if not tle1 or not tle2: tle1, tle2 = build_tle_lines_from_elements( norad_cat_id=norad_id, epoch=meta.get("epoch"), inclination=meta.get("inclination"), raan=meta.get("raan"), eccentricity=meta.get("eccentricity"), arg_of_perigee=meta.get("arg_of_perigee"), mean_anomaly=meta.get("mean_anomaly"), mean_motion=meta.get("mean_motion"), ) items.append({ "id": f"sat_{record.id}", "norad_id": str(norad_id), "name": record.name or "Unknown", "tle_line1": tle1 or "", "tle_line2": tle2 or "", "epoch": meta.get("epoch") or "", "inclination": _safe_float(meta.get("inclination")), "raan": _safe_float(meta.get("raan")), "eccentricity": _safe_float(meta.get("eccentricity")), "mean_motion": _safe_float(meta.get("mean_motion")), }) return {"count": len(items), "items": items}