agenda/agenda/meteors.py
2025-08-01 06:21:22 +01:00

271 lines
9.7 KiB
Python

"""Meteor shower data calculations."""
import typing
from datetime import datetime, timedelta
import ephem # type: ignore
MeteorShower = dict[str, typing.Any]
# Meteor shower definitions with parent comet orbital elements
METEOR_SHOWERS = {
"Quadrantids": {
"name": "Quadrantids",
"radiant_ra": "15h20m", # Right ascension at peak
"radiant_dec": "+49.5°", # Declination at peak
"peak_solar_longitude": 283.16, # Solar longitude at peak
"activity_start": 283.16 - 10, # Activity period
"activity_end": 283.16 + 10,
"rate_max": 120,
"rate_min": 50,
"parent_body": "2003 EH1",
"visibility": "Northern Hemisphere",
"description": "The year kicks off with the Quadrantids, known for their brief but intense peak lasting only about 4 hours.",
"velocity_kms": 41,
},
"Lyrids": {
"name": "Lyrids",
"radiant_ra": "18h04m",
"radiant_dec": "+32.32°",
"peak_solar_longitude": 32.32,
"activity_start": 32.32 - 10,
"activity_end": 32.32 + 10,
"rate_max": 18,
"rate_min": 10,
"parent_body": "C/1861 G1 (Thatcher)",
"visibility": "Both hemispheres",
"description": "The Lyrids are one of the oldest recorded meteor showers, with observations dating back 2,700 years.",
"velocity_kms": 49,
},
"Eta Aquariids": {
"name": "Eta Aquariids",
"radiant_ra": "22h32m",
"radiant_dec": "-1.0°",
"peak_solar_longitude": 45.5,
"activity_start": 45.5 - 15,
"activity_end": 45.5 + 15,
"rate_max": 60,
"rate_min": 30,
"parent_body": "1P/Halley",
"visibility": "Southern Hemisphere (best)",
"description": "Created by debris from Halley's Comet, these meteors are fast and often leave glowing trails.",
"velocity_kms": 66,
},
"Perseids": {
"name": "Perseids",
"radiant_ra": "03h04m",
"radiant_dec": "+58.0°",
"peak_solar_longitude": 140.0,
"activity_start": 140.0 - 15,
"activity_end": 140.0 + 15,
"rate_max": 100,
"rate_min": 50,
"parent_body": "109P/Swift-Tuttle",
"visibility": "Northern Hemisphere",
"description": "One of the most popular meteor showers, viewing conditions vary by year based on moon phase.",
"velocity_kms": 59,
},
"Orionids": {
"name": "Orionids",
"radiant_ra": "06h20m",
"radiant_dec": "+16.0°",
"peak_solar_longitude": 208.0,
"activity_start": 208.0 - 15,
"activity_end": 208.0 + 15,
"rate_max": 25,
"rate_min": 15,
"parent_body": "1P/Halley",
"visibility": "Both hemispheres",
"description": "Another shower created by Halley's Comet debris, known for their speed and brightness.",
"velocity_kms": 66,
},
"Geminids": {
"name": "Geminids",
"radiant_ra": "07h28m",
"radiant_dec": "+32.0°",
"peak_solar_longitude": 262.2,
"activity_start": 262.2 - 10,
"activity_end": 262.2 + 10,
"rate_max": 120,
"rate_min": 60,
"parent_body": "3200 Phaethon",
"visibility": "Both hemispheres",
"description": "The best shower of most years with the highest rates. Unusual for being caused by an asteroid rather than a comet.",
"velocity_kms": 35,
},
"Ursids": {
"name": "Ursids",
"radiant_ra": "14h28m",
"radiant_dec": "+75.0°",
"peak_solar_longitude": 270.7,
"activity_start": 270.7 - 5,
"activity_end": 270.7 + 5,
"rate_max": 10,
"rate_min": 5,
"parent_body": "8P/Tuttle",
"visibility": "Northern Hemisphere",
"description": "A minor shower that closes out the year, best viewed from dark locations away from city lights.",
"velocity_kms": 33,
},
}
def calculate_solar_longitude_date(year: int, target_longitude: float) -> datetime:
"""Calculate the date when the Sun reaches a specific longitude for a given year."""
# Start from beginning of year
start_date = datetime(year, 1, 1)
# Use PyEphem to calculate solar longitude
observer = ephem.Observer()
observer.lat = "0" # Equator
observer.lon = "0" # Greenwich
observer.date = start_date
sun = ephem.Sun(observer)
# Search for the date when solar longitude matches target
# Solar longitude 0° = Spring Equinox (around March 20)
# We need to find when sun reaches the target longitude
# Approximate: start search from reasonable date based on longitude
if target_longitude < 90: # Spring (Mar-Jun)
search_start = datetime(year, 3, 1)
elif target_longitude < 180: # Summer (Jun-Sep)
search_start = datetime(year, 6, 1)
elif target_longitude < 270: # Fall (Sep-Dec)
search_start = datetime(year, 9, 1)
else: # Winter (Dec-Mar)
search_start = datetime(year, 12, 1)
observer.date = search_start
# Search within a reasonable range (±60 days)
for day_offset in range(-60, 61):
test_date = search_start + timedelta(days=day_offset)
observer.date = test_date
sun.compute(observer)
# Convert ecliptic longitude to degrees
sun_longitude = float(sun.hlon) * 180 / ephem.pi
# Check if we're close to target longitude (within 0.5 degrees)
if abs(sun_longitude - target_longitude) < 0.5:
return test_date
# Fallback: return approximation based on solar longitude
# Rough approximation: solar longitude increases ~1° per day
days_from_equinox = target_longitude
equinox_date = datetime(year, 3, 20) # Approximate spring equinox
return equinox_date + timedelta(days=days_from_equinox)
def calculate_moon_phase(date_obj: datetime) -> tuple[float, str]:
"""Calculate moon phase for a given date."""
observer = ephem.Observer()
observer.date = date_obj
moon = ephem.Moon(observer)
moon.compute(observer)
# Moon phase (0 = new moon, 0.5 = full moon, 1 = new moon again)
phase = moon.phase / 100.0
# Determine moon phase name and viewing quality
if phase < 0.1:
phase_name = "New Moon"
viewing_quality = "excellent"
elif phase < 0.3:
phase_name = "Waxing Crescent"
viewing_quality = "good"
elif phase < 0.7:
phase_name = "First Quarter" if phase < 0.5 else "Waxing Gibbous"
viewing_quality = "moderate"
elif phase < 0.9:
phase_name = "Full Moon"
viewing_quality = "poor"
else:
phase_name = "Waning Crescent"
viewing_quality = "good"
return phase, f"{phase_name} ({viewing_quality} viewing)"
def calculate_meteor_shower_data(year: int) -> list[MeteorShower]:
"""Calculate meteor shower data for a given year using astronomical calculations."""
meteor_data = []
for shower_id, shower_info in METEOR_SHOWERS.items():
# Calculate peak date based on solar longitude
peak_longitude = shower_info["peak_solar_longitude"]
assert isinstance(peak_longitude, (int, float))
peak_date = calculate_solar_longitude_date(year, peak_longitude)
# Calculate activity period
start_longitude = shower_info["activity_start"]
assert isinstance(start_longitude, (int, float))
activity_start = calculate_solar_longitude_date(year, start_longitude)
end_longitude = shower_info["activity_end"]
assert isinstance(end_longitude, (int, float))
activity_end = calculate_solar_longitude_date(year, end_longitude)
# Calculate moon phase at peak
moon_illumination, moon_phase_desc = calculate_moon_phase(peak_date)
# Format dates
peak_formatted = peak_date.strftime("%B %d")
if peak_date.day != (peak_date + timedelta(days=1)).day:
peak_formatted += f"-{(peak_date + timedelta(days=1)).strftime('%d')}"
active_formatted = (
f"{activity_start.strftime('%B %d')} - {activity_end.strftime('%B %d')}"
)
# Determine viewing quality based on moon phase
viewing_quality = (
"excellent"
if moon_illumination < 0.3
else (
"good"
if moon_illumination < 0.7
else "moderate" if moon_illumination < 0.9 else "poor"
)
)
meteor_shower = {
"name": shower_info["name"],
"peak": peak_formatted,
"active": active_formatted,
"rate": f"{shower_info['rate_min']}-{shower_info['rate_max']} meteors per hour",
"radiant": str(shower_info["radiant_ra"]).split("h")[0]
+ "h "
+ str(shower_info["radiant_dec"]),
"moon_phase": moon_phase_desc,
"visibility": shower_info["visibility"],
"description": shower_info["description"],
"peak_date": peak_date.strftime("%Y-%m-%d"),
"start_date": activity_start.strftime("%Y-%m-%d"),
"end_date": activity_end.strftime("%Y-%m-%d"),
"rate_min": shower_info["rate_min"],
"rate_max": shower_info["rate_max"],
"moon_illumination": moon_illumination,
"viewing_quality": viewing_quality,
"parent_body": shower_info["parent_body"],
"velocity_kms": shower_info["velocity_kms"],
}
meteor_data.append(meteor_shower)
# Sort by peak date
meteor_data.sort(key=lambda x: datetime.strptime(str(x["peak_date"]), "%Y-%m-%d"))
return meteor_data
def get_meteor_data(year: int | None = None) -> list[MeteorShower]:
"""Get meteor shower data for a specific year using astronomical calculations."""
if year is None:
year = datetime.now().year
return calculate_meteor_shower_data(year)