diff --git a/public/monatsvorhersage.php b/public/monatsvorhersage.php index a6eaf92..c1d8758 100644 --- a/public/monatsvorhersage.php +++ b/public/monatsvorhersage.php @@ -1022,6 +1022,19 @@ if (!function_exists('monthForecastBuildNarrativeText')) { $sepStr = ' in nur ' . $sepMatch[1] . ' Abstand'; } $apSd = $shortDate($apDate !== '' ? $apDate : $date); + $eventKind = (string) ($ap['event_kind'] ?? ''); + if ($eventKind !== '' && str_starts_with($apLabel, 'Mond bedeckt ')) { + $target = substr($apLabel, strlen('Mond bedeckt ')); + $apTime = trim((string) ($ap['time'] ?? '')); + $timeClause = $apTime !== '' ? ' um ' . $apTime . ' Uhr' : ''; + $moonSentences[] = match ($eventKind) { + 'start' => 'Am ' . $apSd . $timeClause . ' beginnt die Bedeckung von ' . $target . ' durch den Mond.', + 'maximum' => 'Am ' . $apSd . $timeClause . ' erreicht die Bedeckung von ' . $target . ' durch den Mond ihr Maximum.', + 'end' => 'Am ' . $apSd . $timeClause . ' endet die Bedeckung von ' . $target . ' durch den Mond.', + default => 'Am ' . $apSd . $timeClause . ' bedeckt der Mond ' . $target . '.', + }; + continue; + } if (str_starts_with($apLabel, 'Mond nahe ')) { $target = substr($apLabel, strlen('Mond nahe ')); $moonSentences[] = 'Am ' . $apSd . ' zieht der Mond' . $sepStr . ' an ' . $target . ' vorbei.'; @@ -2451,7 +2464,8 @@ foreach ($moonPlanetApproaches as $approachEvent) { continue; } - $distanceText = isset($approachEvent['separation_deg']) + $eventKind = (string) ($approachEvent['event_kind'] ?? ''); + $distanceText = $eventKind === '' && isset($approachEvent['separation_deg']) ? number_format((float) $approachEvent['separation_deg'], 2, ',', '') . ' Grad' : ''; @@ -2460,7 +2474,7 @@ foreach ($moonPlanetApproaches as $approachEvent) { 'date' => (string) ($approachEvent['local_date'] ?? ''), 'time' => (string) ($approachEvent['local_time'] ?? ''), 'local_iso' => (string) ($approachEvent['local_iso'] ?? ''), - 'type' => 'moon_planet', + 'type' => $eventKind !== '' ? 'moon_occultation' : 'moon_planet', ]; } diff --git a/public/py/api.py b/public/py/api.py index 7ed9358..ca36030 100644 --- a/public/py/api.py +++ b/public/py/api.py @@ -18,6 +18,15 @@ from skyfield import almanac MOON_RADIUS_KM = 1737.4 SYNODIC_MONTH = 29.530588853 +PLANET_RADIUS_KM = { + astronomy.Body.Mercury: 2439.7, + astronomy.Body.Venus: 6051.8, + astronomy.Body.Mars: 3396.2, + astronomy.Body.Jupiter: astronomy.JUPITER_EQUATORIAL_RADIUS_KM, + astronomy.Body.Saturn: 60268.0, + astronomy.Body.Uranus: 25559.0, + astronomy.Body.Neptune: 24764.0, +} STAR_BODIES = [ astronomy.Body.Star1, astronomy.Body.Star2, @@ -67,6 +76,30 @@ def dt_to_time(dt_utc: datetime) -> astronomy.Time: ) +def jupiter_emission_datetime(observation_dt: datetime) -> datetime: + """Berechnet den Emissionszeitpunkt für eine Beobachtung von der Erde.""" + observation_dt = observation_dt.astimezone(timezone.utc) + emission_dt = observation_dt + + # Die Jupiterentfernung wird am zunächst geschätzten Emissionszeitpunkt + # neu bestimmt. Zwei Durchläufe reichen für die Lichtlaufzeit im + # Jupiter-System deutlich aus. + for _ in range(2): + geo = astronomy.GeoVector( + astronomy.Body.Jupiter, + dt_to_time(emission_dt), + True, + ) + distance_au = math.sqrt( + (float(geo.x) * float(geo.x)) + + (float(geo.y) * float(geo.y)) + + (float(geo.z) * float(geo.z)) + ) + emission_dt = observation_dt - timedelta(days=distance_au / astronomy.C_AUDAY) + + return emission_dt + + def time_to_datetime(time_value: astronomy.Time) -> datetime: year, month, day, hour, minute, second = time_value.Calendar() second_int = int(second) @@ -2874,6 +2907,65 @@ def moon_planet_separation_deg( return spherical_separation_deg(float(moon_eq.ra), float(moon_eq.dec), float(body_eq.ra), float(body_eq.dec)) +def moon_planet_occultation_margin_deg( + body: astronomy.Body, + observer: astronomy.Observer, + dt_utc: datetime, +) -> float: + """Negativ bedeutet Überlappung der scheinbaren Mond- und Planetenscheiben.""" + time_value = dt_to_time(dt_utc) + moon_eq = astronomy.Equator(astronomy.Body.Moon, time_value, observer, True, True) + body_eq = astronomy.Equator(body, time_value, observer, True, True) + moon_radius = moon_angular_radius_deg(float(moon_eq.dist)) + planet_radius_km = PLANET_RADIUS_KM[body] + planet_ratio = planet_radius_km / (float(body_eq.dist) * astronomy.KM_PER_AU) + planet_radius = math.degrees(math.asin(max(-1.0, min(1.0, planet_ratio)))) + separation = spherical_separation_deg( + float(moon_eq.ra), float(moon_eq.dec), float(body_eq.ra), float(body_eq.dec) + ) + return separation - moon_radius - planet_radius + + +def refine_occultation_contact( + body: astronomy.Body, + observer: astronomy.Observer, + center_utc: datetime, + direction: int, +) -> datetime | None: + """Findet den Scheibenkontakt vor oder nach der größten Bedeckung.""" + step = timedelta(minutes=5) + inner = center_utc + inner_margin = moon_planet_occultation_margin_deg(body, observer, inner) + if inner_margin > 0.0: + return None + + outer = inner + for _ in range(288): # maximal 24 Stunden vom Maximum entfernt suchen + outer = outer + (step if direction > 0 else -step) + outer_margin = moon_planet_occultation_margin_deg(body, observer, outer) + if outer_margin >= 0.0: + left, right = (inner, outer) if direction > 0 else (outer, inner) + for _ in range(40): + middle = left + (right - left) / 2 + middle_margin = moon_planet_occultation_margin_deg(body, observer, middle) + if direction > 0: + if middle_margin < 0.0: + left = middle + else: + right = middle + else: + if middle_margin < 0.0: + right = middle + else: + left = middle + return right if direction > 0 else left + + inner = outer + inner_margin = outer_margin + + return None + + def moon_fixed_equatorial_separation_deg( ra_hours: float, dec_deg: float, @@ -3291,6 +3383,30 @@ def action_moon_planet_approaches(args: list[str]) -> dict: continue seen_ranges.append((left, right)) + occultation_margin = moon_planet_occultation_margin_deg(body, observer, min_time_utc) + if occultation_margin <= 0.0: + contact_times = [ + ("start", refine_occultation_contact(body, observer, min_time_utc, -1), "Beginn der Bedeckung"), + ("maximum", min_time_utc, "Größte Bedeckung"), + ("end", refine_occultation_contact(body, observer, min_time_utc, 1), "Ende der Bedeckung"), + ] + if all(contact_time is not None for _, contact_time, _ in contact_times): + for event_kind, contact_time, event_label in contact_times: + assert contact_time is not None + contact_local = contact_time.astimezone(tz) + approaches.append({ + "planet_key": key, + "planet_label": label, + "label": f"Mond bedeckt {label} – {event_label}", + "event_kind": event_kind, + "separation_deg": float(min_sep), + "utc_iso": contact_time.isoformat().replace("+00:00", "Z"), + "local_iso": contact_local.isoformat(), + "local_date": contact_local.strftime("%d.%m.%Y"), + "local_time": contact_local.strftime("%H:%M"), + }) + continue + approaches.append({ "planet_key": key, "planet_label": label, @@ -4644,7 +4760,11 @@ def action_jupiter_moons_one_side_for_month(args: list[str]) -> dict: moon_keys = ["io", "europa", "ganymede", "callisto"] def classify_side(dt_utc: datetime) -> tuple[str | None, str | None, dict[str, float], dict[str, float]]: - time_value = dt_to_time(dt_utc) + # Die ausgegebenen Ereigniszeiten bleiben Beobachtungszeiten in UTC. + # Für die tatsächliche Stellung der Monde rechnen wir auf den + # Emissionszeitpunkt des Jupiter-Lichts zurück. + emission_dt = jupiter_emission_datetime(dt_utc) + time_value = dt_to_time(emission_dt) rotation_eqj_to_ecl = astronomy.Rotation_EQJ_ECL() jupiter_geo = astronomy.GeoVector(astronomy.Body.Jupiter, time_value, True) jupiter_helio = astronomy.HelioVector(astronomy.Body.Jupiter, time_value)