diff --git a/agenda/meteors.py b/agenda/meteors.py new file mode 100644 index 0000000..b40ce15 --- /dev/null +++ b/agenda/meteors.py @@ -0,0 +1,267 @@ +"""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_date = calculate_solar_longitude_date( + year, shower_info["peak_solar_longitude"] + ) + + # Calculate activity period + activity_start = calculate_solar_longitude_date( + year, shower_info["activity_start"] + ) + activity_end = calculate_solar_longitude_date(year, shower_info["activity_end"]) + + # 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": shower_info["radiant_ra"].split("h")[0] + + "h " + + 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(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) diff --git a/web_view.py b/web_view.py index 5afb099..91a0e94 100755 --- a/web_view.py +++ b/web_view.py @@ -23,6 +23,7 @@ import agenda.data import agenda.error_mail import agenda.fx import agenda.holidays +import agenda.meteors import agenda.stats import agenda.thespacedevs import agenda.trip @@ -216,78 +217,7 @@ def launch_list() -> str: @app.route("/meteors") def meteor_list() -> str: """Web page showing meteor shower information.""" - meteors = [ - { - "name": "Quadrantids", - "peak": "January 3-4", - "active": "December 28 - January 12", - "rate": "50-100 meteors per hour", - "radiant": "Boötes", - "moon_phase": "New Moon (excellent viewing)", - "visibility": "Northern Hemisphere", - "description": "The year kicks off with the Quadrantids, known for their brief but intense peak lasting only about 4 hours.", - }, - { - "name": "Lyrids", - "peak": "April 21-22", - "active": "April 14 - April 30", - "rate": "10-15 meteors per hour", - "radiant": "Lyra", - "moon_phase": "Waning Crescent (good viewing)", - "visibility": "Both hemispheres", - "description": "The Lyrids are one of the oldest recorded meteor showers, with observations dating back 2,700 years.", - }, - { - "name": "Eta Aquariids", - "peak": "May 5-6", - "active": "April 15 - May 27", - "rate": "30-60 meteors per hour", - "radiant": "Aquarius", - "moon_phase": "First Quarter (moderate viewing)", - "visibility": "Southern Hemisphere (best)", - "description": "Created by debris from Halley's Comet, these meteors are fast and often leave glowing trails.", - }, - { - "name": "Perseids", - "peak": "August 12-13", - "active": "July 17 - August 24", - "rate": "50-100 meteors per hour", - "radiant": "Perseus", - "moon_phase": "Full Moon (poor viewing)", - "visibility": "Northern Hemisphere", - "description": "One of the most popular meteor showers, though 2025 viewing will be hampered by bright moonlight.", - }, - { - "name": "Orionids", - "peak": "October 21-22", - "active": "October 2 - November 7", - "rate": "15-25 meteors per hour", - "radiant": "Orion", - "moon_phase": "Waning Crescent (good viewing)", - "visibility": "Both hemispheres", - "description": "Another shower created by Halley's Comet debris, known for their speed and brightness.", - }, - { - "name": "Geminids", - "peak": "December 13-14", - "active": "December 4 - December 20", - "rate": "60-120 meteors per hour", - "radiant": "Gemini", - "moon_phase": "Waxing Gibbous (moderate viewing)", - "visibility": "Both hemispheres", - "description": "The best shower of 2025 with the highest rates. Unusual for being caused by an asteroid (3200 Phaethon) rather than a comet.", - }, - { - "name": "Ursids", - "peak": "December 22-23", - "active": "December 17 - December 26", - "rate": "5-10 meteors per hour", - "radiant": "Ursa Minor", - "moon_phase": "New Moon (excellent viewing)", - "visibility": "Northern Hemisphere", - "description": "A minor shower that closes out the year, best viewed from dark locations away from city lights.", - }, - ] + meteors = agenda.meteors.get_meteor_data() return flask.render_template( "meteors.html",