diff --git a/docs/README_DEV_LOGIN.md b/docs/README_DEV_LOGIN.md new file mode 100644 index 0000000..2372d89 --- /dev/null +++ b/docs/README_DEV_LOGIN.md @@ -0,0 +1,59 @@ +# Lokaler Dev-Login + +Diese Projektkopie hat einen kleinen Entwicklungs-Bypass fuer den Login, damit geschuetzte Seiten auf `localhost` einfacher getestet werden koennen. + +## Zweck + +Der Dev-Login ist nur fuer die lokale Entwicklung gedacht. +Er hilft beim Testen von Seiten wie: + +- `public/comets.php` +- `public/my_favorites.php` +- andere nicht oeffentlich freigegebene Seiten + +## Aktivierung + +Im lokalen Browser einfach eine geschuetzte Seite mit `?dev_login=1` aufrufen, zum Beispiel: + +```text +http://localhost:8000/public/comets.php?dev_login=1 +``` + +Dann wird automatisch der erste aktive Benutzer mit Rolle `master` oder `admin` in die Session eingeloggt. + +Danach koennen auch weitere geschuetzte Seiten im selben Browser/Fenster normal aufgerufen werden. + +## Abmelden + +Zum lokalen Dev-Logout: + +```text +http://localhost:8000/public/comets.php?dev_logout=1 +``` + +Das entfernt die Dev-Session wieder. + +## Sicherheitsgrenzen + +Der Dev-Login greift nur bei lokalen Requests: + +- Host muss `localhost`, `127.0.0.1` oder `::1` sein +- der Request muss lokal kommen + +Auf dem echten Server oder unter normalen externen Domains hat dieser Mechanismus keine Wirkung. + +## Technische Umsetzung + +Die Erkennung und Session-Initialisierung liegt in: + +- [public/auth_helpers.php](/abs/path/c:/Users/HP/source/repos/skyview.astronomiemuseum.de/public/auth_helpers.php:4) + +Die Aktivierung beim Seitenaufbau liegt in: + +- [public/header.php](/abs/path/c:/Users/HP/source/repos/skyview.astronomiemuseum.de/public/header.php:3) + +## Hinweise + +- Der Dev-Login ist bewusst nicht fuer Produktion gedacht. +- Wenn lokal kein aktiver `master`- oder `admin`-Benutzer vorhanden ist, greift der Bypass nicht. +- Fuer reproduzierbare Tests am besten dieselbe Browser-Session weiterverwenden, statt staendig neu zu starten. diff --git a/public/auth_helpers.php b/public/auth_helpers.php index e3a79d1..0b40b97 100644 --- a/public/auth_helpers.php +++ b/public/auth_helpers.php @@ -1,6 +1,62 @@ PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC] + ); + + $stmt = $pdo->query(" + SELECT `id`, `username`, `role` + FROM `app_users` + WHERE `is_active` = 1 + AND `role` IN ('master', 'admin') + ORDER BY CASE WHEN `role` = 'master' THEN 0 ELSE 1 END, `id` ASC + LIMIT 1 + "); + $user = $stmt->fetch(); + + if (is_array($user)) { + $_SESSION['user_id'] = (int) $user['id']; + $_SESSION['username'] = (string) $user['username']; + $_SESSION['role'] = (string) $user['role']; + } + } catch (Throwable $e) { + return; + } +} + function currentUserRole(): string { return trim((string) ($_SESSION['role'] ?? '')); diff --git a/public/comets.php b/public/comets.php index ddd5cda..eae13a6 100644 --- a/public/comets.php +++ b/public/comets.php @@ -16,6 +16,81 @@ function h(?string $value): string return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8'); } +function runPythonApi(string $action, array $args): array +{ + $scriptPath = __DIR__ . '/py/api.py'; + $pythonCandidates = PHP_OS_FAMILY === 'Windows' + ? ['python', 'py'] + : ['/usr/bin/python3', '/usr/bin/python', 'python3', 'python']; + + $escapedArguments = array_map('escapeshellarg', array_merge([$scriptPath, $action], $args)); + $lastError = 'Python konnte nicht gestartet werden.'; + + foreach ($pythonCandidates as $pythonBinary) { + $command = $pythonBinary . ' ' . implode(' ', $escapedArguments) . ' 2>&1'; + $output = []; + $resultCode = 0; + exec($command, $output, $resultCode); + + $joined = trim(implode("\n", $output)); + $decoded = json_decode($joined, true); + + if (is_array($decoded)) { + return [ + 'ok' => (bool) ($decoded['ok'] ?? false), + 'data' => $decoded, + 'raw' => $joined, + 'command' => $command, + ]; + } + + if ($joined !== '') { + $lastError = $joined; + } elseif ($resultCode !== 0) { + $lastError = 'Fehlercode ' . $resultCode . ' bei ' . $pythonBinary; + } + } + + return [ + 'ok' => false, + 'error' => $lastError, + ]; +} + +function runPythonApiWithJsonPayloadFile(string $action, array $payload): array +{ + $tempFile = tempnam(sys_get_temp_dir(), 'skyview_py_'); + if ($tempFile === false) { + return [ + 'ok' => false, + 'error' => 'Temporäre Datei für Python-Payload konnte nicht erstellt werden.', + ]; + } + + $jsonPayload = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + if ($jsonPayload === false) { + @unlink($tempFile); + return [ + 'ok' => false, + 'error' => 'Python-Payload konnte nicht als JSON kodiert werden.', + ]; + } + + if (file_put_contents($tempFile, $jsonPayload) === false) { + @unlink($tempFile); + return [ + 'ok' => false, + 'error' => 'Temporäre Datei für Python-Payload konnte nicht geschrieben werden.', + ]; + } + + try { + return runPythonApi($action, ['@' . $tempFile]); + } finally { + @unlink($tempFile); + } +} + function formatDecimalGerman(?float $value, int $decimals = 3): string { if ($value === null || !is_finite($value)) { @@ -25,6 +100,51 @@ function formatDecimalGerman(?float $value, int $decimals = 3): string return number_format($value, $decimals, ',', '.'); } +function formatRaHours(?float $hours): string +{ + if ($hours === null || !is_finite($hours)) { + return '-'; + } + + $normalized = fmod($hours, 24.0); + if ($normalized < 0.0) { + $normalized += 24.0; + } + + $totalSeconds = (int) round($normalized * 3600.0); + $raHours = intdiv($totalSeconds, 3600) % 24; + $raMinutes = intdiv($totalSeconds % 3600, 60); + $raSeconds = $totalSeconds % 60; + + return sprintf('%02dh %02dm %02ds', $raHours, $raMinutes, $raSeconds); +} + +function formatDeclination(?float $degrees): string +{ + if ($degrees === null || !is_finite($degrees)) { + return '-'; + } + + $sign = $degrees < 0.0 ? '-' : '+'; + $absolute = abs($degrees); + $deg = (int) floor($absolute); + $minutesFloat = ($absolute - $deg) * 60.0; + $minutes = (int) floor($minutesFloat); + $seconds = (int) round(($minutesFloat - $minutes) * 60.0); + + if ($seconds >= 60) { + $seconds -= 60; + $minutes += 1; + } + + if ($minutes >= 60) { + $minutes -= 60; + $deg += 1; + } + + return sprintf('%s%02d° %02d\' %02d"', $sign, $deg, $minutes, $seconds); +} + function buildPerihelionDate(array $row): ?DateTimeImmutable { $year = isset($row['year_of_perihelion']) ? (int) $row['year_of_perihelion'] : 0; @@ -64,20 +184,6 @@ function classifyOrbitType(string $orbitType): string }; } -function estimatePerihelMagnitude(?float $absoluteMagnitudeH, ?float $perihelionDistanceAu): ?float -{ - if ($absoluteMagnitudeH === null || !is_finite($absoluteMagnitudeH) || $perihelionDistanceAu === null || !is_finite($perihelionDistanceAu) || $perihelionDistanceAu <= 0.0) { - return null; - } - - $heliocentricDistance = max(0.05, $perihelionDistanceAu); - $geocentricDistance = max(0.2, abs(1.0 - $perihelionDistanceAu)); - - return $absoluteMagnitudeH - + (5.0 * log10($geocentricDistance)) - + (10.0 * log10($heliocentricDistance)); -} - $config = require __DIR__ . '/../config/database.php'; $dsn = sprintf( 'mysql:host=%s;dbname=%s;charset=%s', @@ -121,36 +227,29 @@ $stmtComets->execute([ ':year_min' => $queryYearMin, ':year_max' => $queryYearMax, ]); +$cometRows = $stmtComets->fetchAll(); $allComets = []; $trackedComets = []; $interestingComets = []; -foreach ($stmtComets as $row) { +foreach ($cometRows as $row) { $perihelionDate = buildPerihelionDate($row); $daysFromToday = $perihelionDate ? (int) floor(($perihelionDate->getTimestamp() - $today->getTimestamp()) / 86400) : null; $row['perihelion_date_object'] = $perihelionDate; - $row['perihelion_date_display'] = $perihelionDate ? $perihelionDate->setTimezone(new DateTimeZone('Europe/Berlin'))->format('d.m.Y H:i') . ' UTC' : '-'; + $row['perihelion_date_display'] = $perihelionDate ? $perihelionDate->format('d.m.Y H:i') . ' UTC' : '-'; $row['days_from_today'] = $daysFromToday; $row['orbit_type_label'] = classifyOrbitType((string) ($row['orbit_type'] ?? '')); $row['absolute_magnitude_display'] = isset($row['absolute_magnitude_h']) && $row['absolute_magnitude_h'] !== null ? formatDecimalGerman((float) $row['absolute_magnitude_h'], 1) : '-'; - $row['slope_parameter_display'] = isset($row['slope_parameter_g']) && $row['slope_parameter_g'] !== null + $row['photometric_index_display'] = isset($row['slope_parameter_g']) && $row['slope_parameter_g'] !== null ? formatDecimalGerman((float) $row['slope_parameter_g'], 1) : '-'; - $row['perihelion_dist_display'] = formatDecimalGerman(isset($row['perihelion_dist_au']) ? (float) $row['perihelion_dist_au'] : null, 3); - $row['eccentricity_display'] = formatDecimalGerman(isset($row['eccentricity']) ? (float) $row['eccentricity'] : null, 4); - $row['inclination_display'] = formatDecimalGerman(isset($row['inclination_deg']) ? (float) $row['inclination_deg'] : null, 2); - $estimatedPerihelMagnitude = estimatePerihelMagnitude( - isset($row['absolute_magnitude_h']) && $row['absolute_magnitude_h'] !== null ? (float) $row['absolute_magnitude_h'] : null, - isset($row['perihelion_dist_au']) && $row['perihelion_dist_au'] !== null ? (float) $row['perihelion_dist_au'] : null - ); - $row['estimated_perihel_magnitude'] = $estimatedPerihelMagnitude; - $row['estimated_perihel_magnitude_display'] = $estimatedPerihelMagnitude !== null - ? formatDecimalGerman($estimatedPerihelMagnitude, 1) - : '-'; + $row['estimated_magnitude'] = null; + $row['estimated_magnitude_display'] = '-'; + $row['brightness_error'] = null; $allComets[] = $row; @@ -183,6 +282,94 @@ usort($interestingComets, static function (array $left, array $right): int { }); $interestingComets = array_slice($interestingComets, 0, 24); +$visibleCometIds = []; +foreach (array_merge($trackedComets, $interestingComets) as $visibleComet) { + $visibleId = isset($visibleComet['id']) ? (int) $visibleComet['id'] : 0; + if ($visibleId > 0) { + $visibleCometIds[$visibleId] = true; + } +} + +$calculationMoment = new DateTimeImmutable('now', new DateTimeZone('UTC')); +$brightnessByCometId = []; +$brightnessError = null; + +if ($visibleCometIds !== []) { + $pythonPayload = [ + 'date' => $calculationMoment->format('Y-m-d\TH:i:s\Z'), + 'comets' => array_values(array_map(static function (array $row): array { + return [ + 'id' => $row['id'] ?? null, + 'year_of_perihelion' => $row['year_of_perihelion'] ?? null, + 'month_of_perihelion' => $row['month_of_perihelion'] ?? null, + 'day_of_perihelion' => $row['day_of_perihelion'] ?? null, + 'perihelion_dist_au' => $row['perihelion_dist_au'] ?? null, + 'eccentricity' => $row['eccentricity'] ?? null, + 'arg_perihelion_deg' => $row['arg_perihelion_deg'] ?? null, + 'ascending_node_deg' => $row['ascending_node_deg'] ?? null, + 'inclination_deg' => $row['inclination_deg'] ?? null, + 'absolute_magnitude_h' => $row['absolute_magnitude_h'] ?? null, + 'slope_parameter_g' => $row['slope_parameter_g'] ?? null, + ]; + }, array_filter($cometRows, static function (array $row) use ($visibleCometIds): bool { + $rowId = isset($row['id']) ? (int) $row['id'] : 0; + return $rowId > 0 && isset($visibleCometIds[$rowId]); + }))), + ]; + + $pythonResult = runPythonApiWithJsonPayloadFile('comet_brightnesses', $pythonPayload); + + if (($pythonResult['ok'] ?? false) && isset($pythonResult['data']['results']) && is_array($pythonResult['data']['results'])) { + foreach ($pythonResult['data']['results'] as $result) { + $resultId = isset($result['id']) ? (int) $result['id'] : 0; + if ($resultId > 0) { + $brightnessByCometId[$resultId] = $result; + } + } + } else { + $brightnessError = (string) ($pythonResult['error'] ?? 'Python-Berechnung nicht verfuegbar.'); + } +} + +$applyBrightness = static function (array $row) use ($brightnessByCometId): array { + $brightnessData = isset($row['id']) ? ($brightnessByCometId[(int) $row['id']] ?? null) : null; + $estimatedMagnitude = isset($brightnessData['estimated_magnitude']) && is_numeric($brightnessData['estimated_magnitude']) + ? (float) $brightnessData['estimated_magnitude'] + : null; + $raHours = isset($brightnessData['ra_hours']) && is_numeric($brightnessData['ra_hours']) + ? (float) $brightnessData['ra_hours'] + : null; + $decDeg = isset($brightnessData['dec_deg']) && is_numeric($brightnessData['dec_deg']) + ? (float) $brightnessData['dec_deg'] + : null; + $heliocentricDistance = isset($brightnessData['heliocentric_distance_au']) && is_numeric($brightnessData['heliocentric_distance_au']) + ? (float) $brightnessData['heliocentric_distance_au'] + : null; + $geocentricDistance = isset($brightnessData['geocentric_distance_au']) && is_numeric($brightnessData['geocentric_distance_au']) + ? (float) $brightnessData['geocentric_distance_au'] + : null; + + $row['estimated_magnitude'] = $estimatedMagnitude; + $row['estimated_magnitude_display'] = $estimatedMagnitude !== null + ? formatDecimalGerman($estimatedMagnitude, 1) + : '-'; + $row['ra_hours'] = $raHours; + $row['ra_display'] = formatRaHours($raHours); + $row['dec_deg'] = $decDeg; + $row['dec_display'] = formatDeclination($decDeg); + $row['heliocentric_distance_au'] = $heliocentricDistance; + $row['heliocentric_distance_display'] = formatDecimalGerman($heliocentricDistance, 3); + $row['geocentric_distance_au'] = $geocentricDistance; + $row['geocentric_distance_display'] = formatDecimalGerman($geocentricDistance, 3); + $row['brightness_error'] = is_array($brightnessData) && !empty($brightnessData['error']) + ? (string) $brightnessData['error'] + : null; + + return $row; +}; + +$trackedComets = array_map($applyBrightness, $trackedComets); +$interestingComets = array_map($applyBrightness, $interestingComets); $orbitTypeCounts = []; foreach ($allComets as $comet) { $orbitKey = (string) ($comet['orbit_type'] ?? '?'); @@ -365,9 +552,15 @@ arsort($orbitTypeCounts); Nutzer-Favoriten aus app_user_comets werden zusaetzlich separat gezeigt.

- Die Spalte m grob ist eine sehr einfache Helligkeitsschaetzung nahe dem Perihel - auf Basis von MPC-H und q. Sie ist nur als Orientierung gedacht. + Die Spalte m grob nutzt jetzt die uebliche MPC-Kometenformel fuer heute: + H + 5 log(Delta) + 2,5 * n * log(r). Sie bleibt trotzdem nur eine Orientierung.

+

+ Position und Helligkeit werden fuer format('d.m.Y H:i')) ?> UTC berechnet. +

+ +

Die Python-Berechnung der Helligkeit war nicht verfuegbar:

+
$count): ?> @@ -410,9 +603,11 @@ arsort($orbitTypeCounts); Komet Typ + RA + Dec Perihel Abstand - m grob + m grob @@ -439,9 +634,11 @@ arsort($orbitTypeCounts);
+ + - + @@ -462,11 +659,11 @@ arsort($orbitTypeCounts); Komet Typ + RA + Dec Perihel Abstand - m grob - H - G + m grob @@ -494,11 +691,11 @@ arsort($orbitTypeCounts); + + - - - + diff --git a/public/header.php b/public/header.php index 205d121..5dcbcc7 100644 --- a/public/header.php +++ b/public/header.php @@ -1,8 +1,7 @@ dict: fail(str(exc), extra={"kind": kind}) +def action_comet_brightnesses(args: list[str]) -> dict: + if len(args) != 1: + fail( + "Aktion comet_brightnesses erwartet 1 Argument: payloadBase64 oder @payloadDatei", + extra={"argv": args}, + ) + + try: + payload_arg = args[0] + if payload_arg.startswith("@"): + payload_path = payload_arg[1:] + with open(payload_path, "r", encoding="utf-8") as handle: + payload_text = handle.read() + else: + payload_text = base64.urlsafe_b64decode(payload_arg.encode("ascii")).decode("utf-8") + payload = json.loads(payload_text) + except Exception as exc: + fail("Payload fuer comet_brightnesses ist ungueltig.", extra={"details": str(exc)}) + + try: + return comets.handle_request(payload) + except ValueError as exc: + fail(str(exc), extra={"action": "comet_brightnesses"}) + + def search_event( body: astronomy.Body, direction: astronomy.Direction, @@ -4173,7 +4199,7 @@ def action_satellite_passes(args: list[str]) -> dict: def main() -> None: if len(sys.argv) < 2: - fail("Es wurde keine Aktion uebergeben.", extra={"available_actions": ["sun_moon_rise_set", "solar_longitude_to_datetime", "current_solar_longitude", "moon_star_occultations", "moon_phase_details", "satellite_passes", "planet_rise_set", "month_sky_context", "moon_phases_for_month", "golden_handle_for_month", "season_changes_for_month", "time_changes_for_month", "moon_planet_approaches", "moon_deep_sky_approaches_for_month", "planet_bright_star_approaches_for_month", "planet_conjunctions_for_month", "eclipses_for_month", "moon_apsides_for_month", "sun_apsides_for_month", "inner_planet_elongations_for_month", "venus_peak_magnitude_for_month", "outer_planet_events_for_month", "outer_planet_stations_for_month", "jupiter_moons_one_side_for_month", "mercury_good_visibility_for_month", "planet_parades_for_month", "planet_constellation_changes_for_month", "moon_star_occultations_for_month"]}) + fail("Es wurde keine Aktion uebergeben.", extra={"available_actions": ["sun_moon_rise_set", "solar_longitude_to_datetime", "current_solar_longitude", "astronomical_conversions", "comet_brightnesses", "moon_star_occultations", "moon_phase_details", "satellite_passes", "planet_rise_set", "month_sky_context", "moon_phases_for_month", "golden_handle_for_month", "season_changes_for_month", "time_changes_for_month", "moon_planet_approaches", "moon_deep_sky_approaches_for_month", "planet_bright_star_approaches_for_month", "planet_conjunctions_for_month", "eclipses_for_month", "moon_apsides_for_month", "sun_apsides_for_month", "inner_planet_elongations_for_month", "venus_peak_magnitude_for_month", "outer_planet_events_for_month", "outer_planet_stations_for_month", "jupiter_moons_one_side_for_month", "mercury_good_visibility_for_month", "planet_parades_for_month", "planet_constellation_changes_for_month", "moon_star_occultations_for_month"]}) action = sys.argv[1] args = sys.argv[2:] @@ -4198,6 +4224,11 @@ def main() -> None: print(json.dumps(result, ensure_ascii=True)) return + if action == "comet_brightnesses": + result = action_comet_brightnesses(args) + print(json.dumps(result, ensure_ascii=True)) + return + if action == "moon_star_occultations": result = action_moon_star_occultations(args) print(json.dumps(result, ensure_ascii=True)) @@ -4323,7 +4354,7 @@ def main() -> None: print(json.dumps(result, ensure_ascii=True)) return - fail("Unbekannte Aktion.", extra={"action": action, "available_actions": ["sun_moon_rise_set", "solar_longitude_to_datetime", "current_solar_longitude", "astronomical_conversions", "moon_star_occultations", "moon_phase_details", "satellite_passes", "planet_rise_set", "month_sky_context", "moon_phases_for_month", "golden_handle_for_month", "season_changes_for_month", "time_changes_for_month", "moon_planet_approaches", "moon_deep_sky_approaches_for_month", "planet_bright_star_approaches_for_month", "planet_conjunctions_for_month", "eclipses_for_month", "moon_apsides_for_month", "sun_apsides_for_month", "inner_planet_elongations_for_month", "venus_peak_magnitude_for_month", "outer_planet_events_for_month", "outer_planet_stations_for_month", "jupiter_moons_one_side_for_month", "mercury_good_visibility_for_month", "planet_parades_for_month", "planet_constellation_changes_for_month", "moon_star_occultations_for_month"]}) + fail("Unbekannte Aktion.", extra={"action": action, "available_actions": ["sun_moon_rise_set", "solar_longitude_to_datetime", "current_solar_longitude", "astronomical_conversions", "comet_brightnesses", "moon_star_occultations", "moon_phase_details", "satellite_passes", "planet_rise_set", "month_sky_context", "moon_phases_for_month", "golden_handle_for_month", "season_changes_for_month", "time_changes_for_month", "moon_planet_approaches", "moon_deep_sky_approaches_for_month", "planet_bright_star_approaches_for_month", "planet_conjunctions_for_month", "eclipses_for_month", "moon_apsides_for_month", "sun_apsides_for_month", "inner_planet_elongations_for_month", "venus_peak_magnitude_for_month", "outer_planet_events_for_month", "outer_planet_stations_for_month", "jupiter_moons_one_side_for_month", "mercury_good_visibility_for_month", "planet_parades_for_month", "planet_constellation_changes_for_month", "moon_star_occultations_for_month"]}) if __name__ == "__main__": diff --git a/public/py/comets.py b/public/py/comets.py new file mode 100644 index 0000000..a71f681 --- /dev/null +++ b/public/py/comets.py @@ -0,0 +1,292 @@ +#!/usr/bin/env python3 +import math +from datetime import datetime, timedelta, timezone + +import astronomy + +GAUSSIAN_GRAVITATIONAL_CONSTANT = 0.01720209895 +J2000_OBLIQUITY_DEG = 23.439279444444445 +PARABOLIC_ECCENTRICITY_TOLERANCE = 1.0e-6 + + +def parse_float(value) -> float | None: + if value is None: + return None + + text = str(value).strip() + if text == "": + return None + + text = text.replace(",", ".") + try: + return float(text) + except ValueError: + return None + + +def parse_int(value) -> int | None: + if value is None: + return None + + text = str(value).strip() + if text == "": + return None + + try: + return int(text) + except ValueError: + return None + + +def dt_to_time(dt_utc: datetime) -> astronomy.Time: + dt_utc = dt_utc.astimezone(timezone.utc) + return astronomy.Time.Make( + dt_utc.year, + dt_utc.month, + dt_utc.day, + dt_utc.hour, + dt_utc.minute, + dt_utc.second + (dt_utc.microsecond / 1_000_000.0), + ) + + +def build_perihelion_datetime(comet: dict) -> datetime | None: + year = parse_int(comet.get("year_of_perihelion")) + month = parse_int(comet.get("month_of_perihelion")) + day_value = parse_float(comet.get("day_of_perihelion")) + + if year is None or month is None or day_value is None: + return None + + if month < 1 or month > 12 or day_value <= 0.0: + return None + + day = int(math.floor(day_value)) + if day < 1 or day > 31: + return None + + fractional_day = day_value - day + seconds = int(round(fractional_day * 86400.0)) + + try: + perihelion_dt = datetime(year, month, day, 0, 0, 0, tzinfo=timezone.utc) + except ValueError: + return None + + return perihelion_dt + timedelta(seconds=seconds) + + +def solve_elliptic_anomaly(mean_anomaly: float, eccentricity: float) -> float: + anomaly = mean_anomaly if eccentricity < 0.8 else (math.pi if mean_anomaly >= 0.0 else -math.pi) + for _ in range(30): + delta = (anomaly - eccentricity * math.sin(anomaly) - mean_anomaly) / (1.0 - eccentricity * math.cos(anomaly)) + anomaly -= delta + if abs(delta) < 1.0e-12: + break + return anomaly + + +def solve_hyperbolic_anomaly(mean_anomaly: float, eccentricity: float) -> float: + if mean_anomaly == 0.0: + anomaly = 0.0 + else: + anomaly = math.asinh(mean_anomaly / eccentricity) + + for _ in range(40): + sinh_value = math.sinh(anomaly) + cosh_value = math.cosh(anomaly) + delta = (eccentricity * sinh_value - anomaly - mean_anomaly) / (eccentricity * cosh_value - 1.0) + anomaly -= delta + if abs(delta) < 1.0e-12: + break + return anomaly + + +def solve_parabolic_parameter(delta_days: float, perihelion_distance_au: float) -> float: + scale = GAUSSIAN_GRAVITATIONAL_CONSTANT * delta_days / math.sqrt(2.0 * perihelion_distance_au**3) + parameter = scale + + for _ in range(40): + numerator = parameter + (parameter**3) / 3.0 - scale + denominator = 1.0 + parameter**2 + delta = numerator / denominator + parameter -= delta + if abs(delta) < 1.0e-12: + break + + return parameter + + +def true_anomaly_and_radius(delta_days: float, perihelion_distance_au: float, eccentricity: float) -> tuple[float, float]: + if perihelion_distance_au <= 0.0: + raise ValueError("Periheldistanz muss positiv sein.") + + if eccentricity < 1.0 - PARABOLIC_ECCENTRICITY_TOLERANCE: + semi_major_axis = perihelion_distance_au / (1.0 - eccentricity) + mean_motion = GAUSSIAN_GRAVITATIONAL_CONSTANT / (semi_major_axis ** 1.5) + mean_anomaly = math.fmod(mean_motion * delta_days, 2.0 * math.pi) + eccentric_anomaly = solve_elliptic_anomaly(mean_anomaly, eccentricity) + radius = semi_major_axis * (1.0 - eccentricity * math.cos(eccentric_anomaly)) + true_anomaly = 2.0 * math.atan2( + math.sqrt(1.0 + eccentricity) * math.sin(eccentric_anomaly / 2.0), + math.sqrt(1.0 - eccentricity) * math.cos(eccentric_anomaly / 2.0), + ) + return true_anomaly, radius + + if eccentricity > 1.0 + PARABOLIC_ECCENTRICITY_TOLERANCE: + semi_major_axis_abs = perihelion_distance_au / (eccentricity - 1.0) + mean_anomaly = GAUSSIAN_GRAVITATIONAL_CONSTANT * delta_days / (semi_major_axis_abs ** 1.5) + hyperbolic_anomaly = solve_hyperbolic_anomaly(mean_anomaly, eccentricity) + radius = semi_major_axis_abs * (eccentricity * math.cosh(hyperbolic_anomaly) - 1.0) + true_anomaly = 2.0 * math.atan2( + math.sqrt(eccentricity + 1.0) * math.sinh(hyperbolic_anomaly / 2.0), + math.sqrt(eccentricity - 1.0) * math.cosh(hyperbolic_anomaly / 2.0), + ) + return true_anomaly, radius + + parabolic_parameter = solve_parabolic_parameter(delta_days, perihelion_distance_au) + true_anomaly = 2.0 * math.atan(parabolic_parameter) + radius = perihelion_distance_au * (1.0 + parabolic_parameter**2) + return true_anomaly, radius + + +def ecliptic_to_equatorial(x_ecl: float, y_ecl: float, z_ecl: float) -> tuple[float, float, float]: + epsilon = math.radians(J2000_OBLIQUITY_DEG) + cos_epsilon = math.cos(epsilon) + sin_epsilon = math.sin(epsilon) + return ( + x_ecl, + y_ecl * cos_epsilon - z_ecl * sin_epsilon, + y_ecl * sin_epsilon + z_ecl * cos_epsilon, + ) + + +def comet_heliocentric_vector(comet: dict, dt_utc: datetime) -> tuple[float, float, float] | None: + perihelion_distance_au = parse_float(comet.get("perihelion_dist_au")) + eccentricity = parse_float(comet.get("eccentricity")) + arg_perihelion_deg = parse_float(comet.get("arg_perihelion_deg")) + ascending_node_deg = parse_float(comet.get("ascending_node_deg")) + inclination_deg = parse_float(comet.get("inclination_deg")) + perihelion_dt = build_perihelion_datetime(comet) + + if None in ( + perihelion_distance_au, + eccentricity, + arg_perihelion_deg, + ascending_node_deg, + inclination_deg, + perihelion_dt, + ): + return None + + delta_days = (dt_utc - perihelion_dt).total_seconds() / 86400.0 + true_anomaly, radius = true_anomaly_and_radius(delta_days, perihelion_distance_au, eccentricity) + + arg_perihelion = math.radians(arg_perihelion_deg) + ascending_node = math.radians(ascending_node_deg) + inclination = math.radians(inclination_deg) + argument_of_latitude = arg_perihelion + true_anomaly + + cos_node = math.cos(ascending_node) + sin_node = math.sin(ascending_node) + cos_inclination = math.cos(inclination) + sin_inclination = math.sin(inclination) + cos_argument = math.cos(argument_of_latitude) + sin_argument = math.sin(argument_of_latitude) + + x_ecl = radius * (cos_node * cos_argument - sin_node * sin_argument * cos_inclination) + y_ecl = radius * (sin_node * cos_argument + cos_node * sin_argument * cos_inclination) + z_ecl = radius * (sin_argument * sin_inclination) + + return ecliptic_to_equatorial(x_ecl, y_ecl, z_ecl) + + +def estimate_magnitude(absolute_magnitude_h: float | None, slope_parameter_g: float | None, heliocentric_distance_au: float, geocentric_distance_au: float) -> float | None: + if absolute_magnitude_h is None or slope_parameter_g is None: + return None + + if heliocentric_distance_au <= 0.0 or geocentric_distance_au <= 0.0: + return None + + return absolute_magnitude_h + (5.0 * math.log10(geocentric_distance_au)) + (2.5 * slope_parameter_g * math.log10(heliocentric_distance_au)) + + +def equatorial_coordinates_from_vector(x: float, y: float, z: float) -> tuple[float, float]: + distance = math.sqrt(x**2 + y**2 + z**2) + if distance <= 0.0: + raise ValueError("Geozentrischer Vektor ist null.") + + ra_hours = math.degrees(math.atan2(y, x)) / 15.0 + if ra_hours < 0.0: + ra_hours += 24.0 + + dec_deg = math.degrees(math.asin(z / distance)) + return ra_hours, dec_deg + + +def calculate_brightness(comet: dict, dt_utc: datetime) -> dict: + comet_id = parse_int(comet.get("id")) + result = { + "id": comet_id, + "heliocentric_distance_au": None, + "geocentric_distance_au": None, + "ra_hours": None, + "dec_deg": None, + "estimated_magnitude": None, + "model": "stellarium_like", + } + + heliocentric_position = comet_heliocentric_vector(comet, dt_utc) + if heliocentric_position is None: + result["error"] = "Bahnelemente unvollstaendig." + return result + + comet_x, comet_y, comet_z = heliocentric_position + heliocentric_distance_au = math.sqrt(comet_x**2 + comet_y**2 + comet_z**2) + earth_vector = astronomy.HelioVector(astronomy.Body.Earth, dt_to_time(dt_utc)) + geo_x = comet_x - earth_vector.x + geo_y = comet_y - earth_vector.y + geo_z = comet_z - earth_vector.z + geocentric_distance_au = math.sqrt(geo_x**2 + geo_y**2 + geo_z**2) + ra_hours, dec_deg = equatorial_coordinates_from_vector(geo_x, geo_y, geo_z) + + absolute_magnitude_h = parse_float(comet.get("absolute_magnitude_h")) + slope_parameter_g = parse_float(comet.get("slope_parameter_g")) + estimated_magnitude = estimate_magnitude( + absolute_magnitude_h, + slope_parameter_g, + heliocentric_distance_au, + geocentric_distance_au, + ) + + result["heliocentric_distance_au"] = heliocentric_distance_au + result["geocentric_distance_au"] = geocentric_distance_au + result["ra_hours"] = ra_hours + result["dec_deg"] = dec_deg + result["estimated_magnitude"] = estimated_magnitude + return result + + +def handle_request(payload: dict) -> dict: + date_text = str(payload.get("date") or "").strip() + if date_text == "": + dt_utc = datetime.now(timezone.utc) + else: + normalized = date_text.replace("Z", "+00:00") + dt_utc = datetime.fromisoformat(normalized) + if dt_utc.tzinfo is None: + dt_utc = dt_utc.replace(tzinfo=timezone.utc) + else: + dt_utc = dt_utc.astimezone(timezone.utc) + + comet_items = payload.get("comets") + if not isinstance(comet_items, list): + raise ValueError("Kometenliste fehlt oder ist ungueltig.") + + results = [calculate_brightness(comet, dt_utc) for comet in comet_items] + return { + "ok": True, + "action": "comet_brightnesses", + "date_utc": dt_utc.isoformat().replace("+00:00", "Z"), + "results": results, + }