agenda/agenda/thespacedevs.py
Edward Betts 04cb3e8179 Add 2-hour TTL cache check to SpaceDevs launch updater
Prevents hitting the SpaceDevs rate limit by skipping the API call
when the launches cache is still fresh (< 2 hours old), matching the
same TTL pattern already used for the active crewed flights cache.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 13:28:10 +00:00

554 lines
19 KiB
Python

"""Get details of upcoming space launches."""
import json
import os
import typing
from datetime import datetime
import requests
from .types import StrDict
from .utils import filename_timestamp, get_most_recent_file
Launch = dict[str, typing.Any]
Summary = dict[str, typing.Any]
ttl = 60 * 60 * 2 # two hours
LIMIT = 500
ACTIVE_CREWED_FLIGHTS_CACHE_FILE = "active_crewed_flights.json"
def next_launch_api_data(rocket_dir: str, limit: int = LIMIT) -> StrDict | None:
"""Get the next upcoming launches from the API."""
now = datetime.now()
filename = os.path.join(rocket_dir, now.strftime("%Y-%m-%d_%H:%M:%S.json"))
url = "https://ll.thespacedevs.com/2.2.0/launch/upcoming/"
params: dict[str, str | int] = {"limit": limit}
r = requests.get(url, params=params)
try:
data: StrDict = r.json()
except requests.exceptions.JSONDecodeError:
return None
# Only persist valid launch payloads; rate-limit / error responses must not
# overwrite the cache or they become the "most recent" file.
if isinstance(data.get("results"), list):
open(filename, "w").write(r.text)
return data
def next_launch_api(rocket_dir: str, limit: int = LIMIT) -> list[Summary] | None:
"""Get the next upcoming launches from the API."""
data = next_launch_api_data(rocket_dir, limit)
if not data:
return None
return [summarize_launch(launch) for launch in data["results"]]
def parse_api_datetime(value: typing.Any) -> datetime | None:
"""Parse API datetime strings into datetime objects."""
if not isinstance(value, str):
return None
try:
return datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
return None
def is_crewed_spaceflight(flight: Launch) -> bool:
"""Return True when a spaceflight is crewed/human-rated."""
spacecraft_config = get_nested(flight, ["spacecraft", "spacecraft_config"])
if isinstance(spacecraft_config, dict) and spacecraft_config.get("human_rated"):
return True
mission_type = get_nested(flight, ["launch", "mission", "type"])
if isinstance(mission_type, str) and "human" in mission_type.lower():
return True
return False
def is_active_crewed_spaceflight(flight: Launch, now: datetime) -> bool:
"""Return True when a crewed spaceflight is active (mission not yet ended)."""
launch = flight.get("launch")
if not isinstance(launch, dict):
return False
if not is_crewed_spaceflight(flight):
return False
launch_net = parse_api_datetime(launch.get("net"))
if launch_net and launch_net > now:
return False
mission_end = parse_api_datetime(flight.get("mission_end"))
if mission_end and mission_end <= now:
return False
spacecraft_in_space = get_nested(flight, ["spacecraft", "in_space"])
if spacecraft_in_space is True:
return True
if mission_end is None:
landing_success = get_nested(flight, ["landing", "success"])
if landing_success is True:
return False
return True
return True
def active_crewed_flights_api(limit: int = LIMIT) -> list[Summary] | None:
"""
Get active crewed spaceflights from the SpaceDevs API.
The API does not reliably expose a direct filter for active flights, so this
paginates through results and applies local filtering.
"""
now = datetime.now().astimezone()
url = "https://ll.thespacedevs.com/2.2.0/spacecraft/flight/"
params: dict[str, str | int] = {"limit": limit}
launches: list[Summary] = []
seen_slugs: set[str] = set()
page = 0
max_pages = 20
while url and page < max_pages:
r = requests.get(url, params=params if page == 0 else None, timeout=30)
if not r.ok:
return None
try:
data: StrDict = r.json()
except requests.exceptions.JSONDecodeError:
return None
results = data.get("results")
if not isinstance(results, list):
break
for flight in results:
if not isinstance(flight, dict):
continue
if not is_active_crewed_spaceflight(flight, now):
continue
launch = flight.get("launch")
if not isinstance(launch, dict):
continue
launch_summary = summarize_launch(typing.cast(Launch, launch))
slug = launch_summary.get("slug")
if not isinstance(slug, str):
continue
if slug in seen_slugs:
continue
seen_slugs.add(slug)
launches.append(launch_summary)
next_url = data.get("next")
url = next_url if isinstance(next_url, str) else ""
params = {}
page += 1
return launches
def get_active_crewed_flights_cache_filename(rocket_dir: str) -> str:
"""Path of the active crewed flights cache file."""
return os.path.join(rocket_dir, ACTIVE_CREWED_FLIGHTS_CACHE_FILE)
def load_active_crewed_flights_cache(
rocket_dir: str,
) -> tuple[datetime, list[Summary]] | None:
"""Load active crewed flights cache file."""
filename = get_active_crewed_flights_cache_filename(rocket_dir)
if not os.path.exists(filename):
return None
try:
cache_data = json.load(open(filename))
except (json.JSONDecodeError, OSError):
return None
updated_str = cache_data.get("updated")
updated = parse_api_datetime(updated_str)
results = cache_data.get("results")
if not updated or not isinstance(results, list):
return None
summary_results: list[Summary] = [r for r in results if isinstance(r, dict)]
return (updated.replace(tzinfo=None), summary_results)
def write_active_crewed_flights_cache(rocket_dir: str, launches: list[Summary]) -> None:
"""Write active crewed flights cache file."""
filename = get_active_crewed_flights_cache_filename(rocket_dir)
payload: StrDict = {
"updated": datetime.now().isoformat(),
"results": launches,
}
with open(filename, "w") as f:
json.dump(payload, f)
def get_active_crewed_flights(
rocket_dir: str, refresh: bool = False
) -> list[Summary] | None:
"""Get active crewed flights with cache and API fallback."""
now = datetime.now()
cached = load_active_crewed_flights_cache(rocket_dir)
if cached and not refresh and (now - cached[0]).seconds <= ttl:
return cached[1]
try:
active_flights = active_crewed_flights_api()
if active_flights is not None:
write_active_crewed_flights_cache(rocket_dir, active_flights)
return active_flights
except Exception:
pass
return cached[1] if cached else []
def format_time(time_str: str, net_precision: str) -> tuple[str, str | None]:
"""Format time based on precision."""
dt = datetime.strptime(time_str, "%Y-%m-%dT%H:%M:%SZ")
include_time = False
# Format the date based on precision
time_format: str | None
field2 = None
match net_precision:
case "Year":
time_format = "%Y"
case "Month":
time_format = "%b %Y"
case "Week":
time_format = "%d %b %Y"
field2 = "(Week of)"
case "Day" | "Hour" | "Minute":
time_format = "%d %b %Y"
include_time = net_precision in {"Hour", "Minute"}
case "Second":
time_format = "%d %b %Y"
include_time = True
case _ if net_precision and net_precision.startswith("Quarter "):
time_format = f"Q{net_precision[-1]} %Y"
case _ if net_precision and net_precision.startswith("Year Half "):
time_format = f"H{net_precision[-1]} %Y"
case _:
time_format = None
if not time_format:
return (repr(time_str), repr(net_precision))
assert time_format
formatted = dt.strftime(time_format)
if include_time:
return (formatted, dt.strftime("%H:%M"))
else:
return (formatted, field2)
launch_providers = {
"Indian Space Research Organization": "ISRO",
"United Launch Alliance": "ULA",
"Payload Aerospace S.L.": "Payload Aerospace",
"Russian Federal Space Agency (ROSCOSMOS)": "ROSCOSMOS",
"China Aerospace Science and Technology Corporation": "CASC",
}
def get_nested(data: dict[str, typing.Any], keys: list[str]) -> typing.Any | None:
"""
Safely get a nested value from a dictionary.
Args:
data (Dict[str, Any]): The dictionary to search.
keys (List[str]): A list of keys for the nested lookup.
Returns:
Optional[Any]: The retrieved value, or None if any key is missing.
"""
for key in keys:
if data is None or key not in data:
return None
data = data[key]
return data
def summarize_launch(launch: Launch) -> Summary:
"""Summarize rocket launch."""
try:
launch_provider = launch["launch_service_provider"]["name"]
launch_provider_abbrev = launch_providers.get(launch_provider)
except (TypeError, IndexError):
launch_provider = None
launch_provider_abbrev = None
net_precision = typing.cast(str, get_nested(launch, ["net_precision", "name"]))
t0_date, t0_time = format_time(launch["net"], net_precision)
return {
"name": launch.get("name"),
"slug": launch["slug"],
"status": launch.get("status"),
"net": launch.get("net"),
"net_precision": net_precision,
"t0_date": t0_date,
"t0_time": t0_time,
"window_start": launch.get("window_start"),
"window_end": launch.get("window_end"),
"launch_provider": launch_provider,
"launch_provider_abbrev": launch_provider_abbrev,
"launch_provider_type": get_nested(launch, ["launch_service_provider", "type"]),
"rocket": launch["rocket"]["configuration"],
"mission": launch.get("mission"),
"mission_name": get_nested(launch, ["mission", "name"]),
"pad_name": launch["pad"]["name"],
"pad_wikipedia_url": launch["pad"]["wiki_url"],
"location": launch["pad"]["location"]["name"],
"country_code": launch["pad"]["country_code"],
"orbit": get_nested(launch, ["mission", "orbit"]),
"probability": launch["probability"],
"weather_concerns": launch["weather_concerns"],
"image": launch.get("image"),
}
def is_launches_cache_fresh(rocket_dir: str) -> bool:
"""Return True if the launches cache is younger than the TTL."""
now = datetime.now()
existing = [
x for x in (filename_timestamp(f, "json") for f in os.listdir(rocket_dir)) if x
]
if not existing:
return False
existing.sort(reverse=True)
return (now - existing[0][0]).total_seconds() <= ttl
def load_cached_launches(rocket_dir: str) -> StrDict | None:
"""Read the most recent cache of launches."""
filename = get_most_recent_file(rocket_dir, "json")
return typing.cast(StrDict, json.load(open(filename))) if filename else None
def read_cached_launches(rocket_dir: str) -> list[Summary]:
"""Read cached launches."""
data = load_cached_launches(rocket_dir)
if not data or not isinstance(data.get("results"), list):
return []
return [summarize_launch(launch) for launch in data["results"]]
def get_launches(
rocket_dir: str, limit: int = LIMIT, refresh: bool = False
) -> list[Summary] | None:
"""Get rocket launches with caching."""
now = datetime.now()
existing = [
x for x in (filename_timestamp(f, "json") for f in os.listdir(rocket_dir)) if x
]
existing.sort(reverse=True)
if refresh or not existing or (now - existing[0][0]).seconds > ttl:
try:
upcoming = next_launch_api(rocket_dir, limit=limit)
if upcoming is None:
raise RuntimeError("unable to fetch upcoming launches")
active_crewed = get_active_crewed_flights(rocket_dir, refresh=refresh) or []
by_slug = {
typing.cast(str, launch["slug"]): launch
for launch in upcoming
if isinstance(launch.get("slug"), str)
}
for launch in active_crewed:
slug = launch.get("slug")
if isinstance(slug, str) and slug not in by_slug:
by_slug[slug] = launch
return sorted(by_slug.values(), key=lambda launch: str(launch.get("net")))
except Exception:
pass # fallback to cached version
# Find the most recent cache file that contains a valid results list.
# Older files without "results" (e.g. stale rate-limit responses) are skipped.
data = None
for _, f in existing:
filename = os.path.join(rocket_dir, f)
try:
candidate = json.load(open(filename))
except (json.JSONDecodeError, OSError):
continue
if isinstance(candidate.get("results"), list):
data = candidate
break
if not data:
return []
upcoming = [summarize_launch(launch) for launch in data["results"]]
active_crewed = get_active_crewed_flights(rocket_dir, refresh=refresh) or []
by_slug = {
typing.cast(str, launch["slug"]): launch
for launch in upcoming
if isinstance(launch.get("slug"), str)
}
for launch in active_crewed:
slug = launch.get("slug")
if isinstance(slug, str) and slug not in by_slug:
by_slug[slug] = launch
return sorted(by_slug.values(), key=lambda launch: str(launch.get("net")))
def format_date(dt: datetime) -> str:
"""Human readable date."""
return dt.strftime("%d %b %Y at %H:%M UTC")
def format_datetime_change(field_name: str, old_val: str, new_val: str) -> str:
"""Format a datetime field change with proper error handling."""
try:
old_dt = datetime.fromisoformat(old_val.replace("Z", "+00:00"))
new_dt = datetime.fromisoformat(new_val.replace("Z", "+00:00"))
return (
f"{field_name} changed from {format_date(old_dt)} to {format_date(new_dt)}"
)
except (ValueError, AttributeError):
return f"{field_name} changed from {old_val} to {new_val}"
def format_datetime_update(field_name: str, new_val: str) -> str:
"""Format a datetime field update (showing only the new value)."""
try:
new_dt = datetime.fromisoformat(new_val.replace("Z", "+00:00"))
return f"{field_name}: {format_date(new_dt)}"
except (ValueError, AttributeError):
return f"{field_name}: {new_val}"
def format_probability_change(old_val: int, new_val: int) -> str:
"""Format probability field changes."""
if old_val is None:
return f"Launch probability set to {new_val}%"
elif new_val is None:
return "Launch probability removed"
else:
return f"Launch probability changed from {old_val}% to {new_val}%"
def format_launch_changes(differences: StrDict) -> str:
"""Convert deepdiff output to human-readable format."""
changes: list[str] = []
processed_paths: set[str] = set()
SKIP_FIELDS = {
"agency_launch_attempt_count",
"agency_launch_attempt_count_year",
"location_launch_attempt_count",
"location_launch_attempt_count_year",
"pad_launch_attempt_count",
"pad_launch_attempt_count_year",
"orbital_launch_attempt_count",
"orbital_launch_attempt_count_year",
}
# --- 1. Handle Special Group Value Changes ---
# Process high-level, user-friendly summaries first.
values = differences.get("values_changed", {})
if "root['status']['name']" in values:
old_val = values["root['status']['name']"]["old_value"]
new_val = values["root['status']['name']"]["new_value"]
changes.append(f"Status changed from '{old_val}' to '{new_val}'")
processed_paths.add("root['status']")
if "root['net_precision']['name']" in values:
old_val = values["root['net_precision']['name']"]["old_value"]
new_val = values["root['net_precision']['name']"]["new_value"]
changes.append(f"Launch precision changed from '{old_val}' to '{new_val}'")
processed_paths.add("root['net_precision']")
# --- 2. Handle Type Changes ---
# This is often more significant than a value change (e.g., probability becoming None).
if "type_changes" in differences:
for path, change in differences["type_changes"].items():
if any(path.startswith(p) for p in processed_paths):
continue
field = path.replace("root['", "").replace("']", "").replace("root.", "")
if field == "probability":
# Use custom formatter only for meaningful None transitions.
if change["old_type"] is type(None) or change["new_type"] is type(None):
changes.append(
format_probability_change(
change["old_value"], change["new_value"]
)
)
else: # For other type changes (e.g., int to str), use the generic message.
changes.append(
f"{field.replace('_', ' ').title()} type changed "
+ f"from {change['old_type'].__name__} to {change['new_type'].__name__}"
)
else:
changes.append(
f"{field.replace('_', ' ').title()} type changed "
+ f"from {change['old_type'].__name__} to {change['new_type'].__name__}"
)
processed_paths.add(path)
# --- 3. Handle Remaining Value Changes ---
for path, change in values.items():
if any(path.startswith(p) for p in processed_paths):
continue
field = path.replace("root['", "").replace("']", "").replace("root.", "")
if field in SKIP_FIELDS:
continue
old_val = change["old_value"]
new_val = change["new_value"]
match field:
case "net":
changes.append(format_datetime_change("Launch time", old_val, new_val))
case "window_start":
changes.append(
format_datetime_change("Launch window start", old_val, new_val)
)
case "window_end":
changes.append(
format_datetime_change("Launch window end", old_val, new_val)
)
case "last_updated":
changes.append(format_datetime_update("Last updated", new_val))
case "name":
changes.append(f"Mission name changed from '{old_val}' to '{new_val}'")
case "probability":
changes.append(format_probability_change(old_val, new_val))
case _:
changes.append(f"{field} changed from '{old_val}' to '{new_val}'")
processed_paths.add(path)
# --- 4. Handle Added/Removed Fields ---
if "dictionary_item_added" in differences:
for path in differences["dictionary_item_added"]:
field = path.replace("root['", "").replace("']", "").replace("root.", "")
changes.append(f"New field added: {field.replace('_', ' ').title()}")
if "dictionary_item_removed" in differences:
for path in differences["dictionary_item_removed"]:
field = path.replace("root['", "").replace("']", "").replace("root.", "")
changes.append(f"Field removed: {field.replace('_', ' ').title()}")
# Sort changes for deterministic output in tests
return (
"\n".join(f"{change}" for change in sorted(changes))
if changes
else "No specific changes detected"
)