From c59e3e7f2ec9817128d1a4219e24c7ebfd53523b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= Date: Tue, 28 Apr 2026 08:59:02 +0200 Subject: [PATCH] Ephemeriden eingbaut --- config/rechtesystem.json | 5 + public/ephemeriden.php | 450 ++++++++++++++++++++++ public/header.php | 1 + public/py/__pycache__/api.cpython-312.pyc | Bin 217661 -> 228072 bytes public/py/api.py | 187 ++++++++- public/py/ephemeriden_api.py | 288 ++++++++++++++ 6 files changed, 929 insertions(+), 2 deletions(-) create mode 100644 public/ephemeriden.php create mode 100644 public/py/ephemeriden_api.py diff --git a/config/rechtesystem.json b/config/rechtesystem.json index c8dbffc..8165f9e 100644 --- a/config/rechtesystem.json +++ b/config/rechtesystem.json @@ -44,6 +44,10 @@ "label": "Planetensichtbarkeit", "menu_group": "Himmel & Planeten" }, + "ephemeriden.php": { + "label": "Ephemeriden", + "menu_group": "Himmel & Planeten" + }, "comets.php": { "label": "Kometen", "menu_group": "Himmel & Planeten" @@ -120,6 +124,7 @@ "links": [ "moonphase.php", "planetensichtbarkeit.php", + "ephemeriden.php", "comets.php", "astronomical_conversions.php", "tcrb_lightcurve.php", diff --git a/public/ephemeriden.php b/public/ephemeriden.php new file mode 100644 index 0000000..e6b3883 --- /dev/null +++ b/public/ephemeriden.php @@ -0,0 +1,450 @@ + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ] + ); + + $stmtLoc = $pdo->prepare( + 'SELECT id, name, latitude, longitude, elevation, timezone + FROM app_user_locations + WHERE user_id = ? AND is_default = 1 + LIMIT 1' + ); + $stmtLoc->execute([(int) $_SESSION['user_id']]); + $defaultLocation = $stmtLoc->fetch() ?: null; + } catch (Throwable $e) { + $defaultLocation = null; + } +} + +$planetOptions = [ + 'Sun' => 'Sonne', + 'Moon' => 'Mond', + 'Mercury' => 'Merkur', + 'Venus' => 'Venus', + 'Mars' => 'Mars', + 'Jupiter' => 'Jupiter', + 'Saturn' => 'Saturn', + 'Uranus' => 'Uranus', + 'Neptune' => 'Neptun', +]; + +$intervalOptions = [ + '1' => '1 Minute', + '10' => '10 Minuten', + '30' => '30 Minuten', + '60' => '1 Stunde', + '360' => '6 Stunden', + '720' => '12 Stunden', + '1440' => '1 Tag', + '10080' => '7 Tage', + '20160' => '14 Tage', + '43200' => '30 Tage', +]; + +$rangeUnitOptions = [ + 'minutes' => 'Minuten', + 'hours' => 'Stunden', + 'days' => 'Tage', + 'weeks' => 'Wochen', + 'months' => 'Monate', + 'years' => 'Jahre', +]; + +$selectedBody = (string) ($_POST['body'] ?? 'Mercury'); +if (!isset($planetOptions[$selectedBody])) { + $selectedBody = 'Mercury'; +} + +$selectedInterval = (string) ($_POST['interval_minutes'] ?? '1440'); +if (!isset($intervalOptions[$selectedInterval])) { + $selectedInterval = '1440'; +} + +$selectedRangeUnit = (string) ($_POST['range_unit'] ?? 'days'); +if (!isset($rangeUnitOptions[$selectedRangeUnit])) { + $selectedRangeUnit = 'days'; +} + +$rangeValueInput = trim((string) ($_POST['range_value'] ?? '7')); +if ($rangeValueInput === '') { + $rangeValueInput = '7'; +} + +function ephemeriden_h(mixed $value): string +{ + return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8'); +} + +function ephemeriden_slug(string $value): string +{ + $value = strtolower(trim($value)); + $value = str_replace( + ['ä', 'ö', 'ü', 'ß', ' '], + ['ae', 'oe', 'ue', 'ss', '-'], + $value + ); + $value = preg_replace('/[^a-z0-9\-]+/', '-', $value) ?? $value; + $value = trim($value, '-'); + return $value !== '' ? $value : 'ephemeriden'; +} + +function runEphemeridenPythonApi(string $action, array $args): array +{ + $scriptPath = __DIR__ . '/py/ephemeriden_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, + ]; +} + +$pageLocation = null; +if (is_array($defaultLocation) && $defaultLocation !== []) { + $pageLocation = [ + 'name' => trim((string) ($defaultLocation['name'] ?? '')), + 'latitude' => isset($defaultLocation['latitude']) ? (float) $defaultLocation['latitude'] : null, + 'longitude' => isset($defaultLocation['longitude']) ? (float) $defaultLocation['longitude'] : null, + 'elevation' => isset($defaultLocation['elevation']) && $defaultLocation['elevation'] !== null ? (float) $defaultLocation['elevation'] : 0.0, + 'timezone' => (string) ($defaultLocation['timezone'] ?? 'Europe/Berlin'), + ]; +} + +$defaultLocationName = $pageLocation['name'] ?? ''; +$errorMessage = ''; +$ephemerisRows = []; +$exportFormat = (string) ($_POST['export_format'] ?? ''); + +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + if ($pageLocation === null || $pageLocation['latitude'] === null || $pageLocation['longitude'] === null) { + $errorMessage = 'Kein gültiger Standard-Standort verfügbar. Bitte zuerst in den Einstellungen einen Standard-Standort setzen.'; + } elseif (!ctype_digit($rangeValueInput) || (int) $rangeValueInput < 1 || (int) $rangeValueInput > 31) { + $errorMessage = 'Der Zeitraum muss als ganze Zahl zwischen 1 und 31 angegeben werden.'; + } else { + $pythonResult = runEphemeridenPythonApi('planet_ephemeris', [ + (string) $pageLocation['latitude'], + (string) $pageLocation['longitude'], + (string) $pageLocation['elevation'], + (string) $pageLocation['timezone'], + $selectedBody, + $selectedInterval, + $rangeValueInput, + $selectedRangeUnit, + ]); + + if (!$pythonResult['ok']) { + $errorMessage = (string) ( + $pythonResult['data']['error'] + ?? $pythonResult['error'] + ?? 'Die Ephemeriden konnten nicht berechnet werden.' + ); + } else { + $ephemerisRows = $pythonResult['data']['rows'] ?? []; + if (!is_array($ephemerisRows)) { + $ephemerisRows = []; + } + + if ($exportFormat === 'csv' && $ephemerisRows !== []) { + $filename = 'ephemeriden-' . ephemeriden_slug((string) ($planetOptions[$selectedBody] ?? 'objekt')) . '.csv'; + + header('Content-Type: text/csv; charset=utf-8'); + header('Content-Disposition: attachment; filename="' . $filename . '"'); + header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0'); + + echo "\xEF\xBB\xBF"; + $handle = fopen('php://output', 'wb'); + if ($handle === false) { + exit; + } + + fputcsv($handle, ['Name des Objekts', 'Datum', 'Uhrzeit', 'RA (J2000)', 'RA (J2000, dezimal h)', 'Dek (J2000)', 'Dek (J2000, dezimal °)', 'Aufgang', 'Untergang'], ';', '"', '\\'); + foreach ($ephemerisRows as $row) { + fputcsv($handle, [ + (string) ($row['object_name'] ?? ''), + (string) ($row['date_local'] ?? ''), + (string) ($row['time_local'] ?? ''), + (string) ($row['ra'] ?? ''), + (string) ($row['ra_decimal_hours'] ?? ''), + (string) ($row['dec'] ?? ''), + (string) ($row['dec_decimal_deg'] ?? ''), + (string) (($row['rise'] ?? null) ?? '—'), + (string) (($row['set'] ?? null) ?? '—'), + ], ';', '"', '\\'); + } + + fclose($handle); + exit; + } + } + } +} + +require __DIR__ . '/header.php'; + +?> + + + +
+
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+
+
+
+ +
+
+
+

Ephemeriden

+
+ Standard-Standort: + +
+
+ +
+ + + + + + +
+ +
+ + +
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Name des ObjektsDatumUhrzeitRA (J2000)Dek (J2000)AufgangUntergang
Für die gewählte Anfrage wurden keine Tabellenzeilen erzeugt.
Noch keine Berechnung ausgeführt.
+
+
+ + diff --git a/public/header.php b/public/header.php index a413308..e4966a1 100644 --- a/public/header.php +++ b/public/header.php @@ -143,6 +143,7 @@ $menuGroups = [ ['href' => 'moonphase.php', 'label' => 'Mondphase'], ['href' => 'moonyear.php', 'label' => 'Mondphasen-Jahresübersicht'], ['href' => 'planetensichtbarkeit.php', 'label' => 'Planetensichtbarkeit'], + ['href' => 'ephemeriden.php', 'label' => 'Ephemeriden'], ['href' => 'comets.php', 'label' => 'Kometen'], ['href' => 'astronomical_conversions.php', 'label' => 'Astronomical Conversions'], ['href' => 'tcrb_lightcurve.php', 'label' => 'T CrB Lichtkurve'], diff --git a/public/py/__pycache__/api.cpython-312.pyc b/public/py/__pycache__/api.cpython-312.pyc index b1047fbc65e748507a79e891b5c99900bb642e7a..46f8a6ad8698e40f8cb50d16769887d74799f7e4 100644 GIT binary patch delta 34279 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 Pzny+stAff?9ub`;-Dr3Q` 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 BCyxLC diff --git a/public/py/api.py b/public/py/api.py index 8052352..9e97983 100644 --- a/public/py/api.py +++ b/public/py/api.py @@ -414,6 +414,19 @@ PLANETS = [ ("Neptun", astronomy.Body.Neptune), ] +EPHEMERIS_BODIES = { + "Sun": ("Sonne", astronomy.Body.Sun), + "Moon": ("Mond", astronomy.Body.Moon), + "Mercury": ("Merkur", astronomy.Body.Mercury), + "Venus": ("Venus", astronomy.Body.Venus), + "Earth": ("Erde", astronomy.Body.Earth), + "Mars": ("Mars", astronomy.Body.Mars), + "Jupiter": ("Jupiter", astronomy.Body.Jupiter), + "Saturn": ("Saturn", astronomy.Body.Saturn), + "Uranus": ("Uranus", astronomy.Body.Uranus), + "Neptune": ("Neptun", astronomy.Body.Neptune), +} + PLANET_VISIBILITY_ROWS = [ { "key": "mercury", @@ -820,6 +833,171 @@ def action_planet_rise_set(args: list[str]) -> dict: } +def format_ra_hours(ra_hours: float) -> str: + total_seconds = int(round(float(ra_hours) * 3600.0)) + total_seconds %= 24 * 3600 + hours = total_seconds // 3600 + minutes = (total_seconds % 3600) // 60 + seconds = total_seconds % 60 + return f"{hours:02d}:{minutes:02d}:{seconds:02d}" + + +def format_dec_deg(dec_deg: float) -> str: + sign = "+" if dec_deg >= 0 else "-" + total_seconds = int(round(abs(float(dec_deg)) * 3600.0)) + degrees = total_seconds // 3600 + minutes = (total_seconds % 3600) // 60 + seconds = total_seconds % 60 + return f"{sign}{degrees:02d}:{minutes:02d}:{seconds:02d}" + + +def days_in_month(year: int, month: int) -> int: + if month == 12: + next_month = datetime(year + 1, 1, 1) + else: + next_month = datetime(year, month + 1, 1) + this_month = datetime(year, month, 1) + return (next_month - this_month).days + + +def add_calendar_unit(base: datetime, amount: int, unit: str) -> datetime: + if unit == "minutes": + return base + timedelta(minutes=amount) + if unit == "hours": + return base + timedelta(hours=amount) + if unit == "days": + return base + timedelta(days=amount) + if unit == "weeks": + return base + timedelta(weeks=amount) + if unit == "months": + month_index = (base.month - 1) + amount + year = base.year + (month_index // 12) + month = (month_index % 12) + 1 + day = min(base.day, days_in_month(year, month)) + return base.replace(year=year, month=month, day=day) + if unit == "years": + year = base.year + amount + day = base.day + if base.month == 2 and base.day == 29: + day = min(day, days_in_month(year, base.month)) + return base.replace(year=year, day=day) + fail("Zeitraum-Einheit ist ungueltig.", extra={"unit": unit}) + + +def action_planet_ephemeris(args: list[str]) -> dict: + if len(args) != 8: + fail( + "Aktion planet_ephemeris erwartet 8 Argumente: latitude longitude elevation timezone body intervalMinutes rangeValue rangeUnit", + extra={"argv": args}, + ) + + latitude = parse_float(args[0], "Latitude") + longitude = parse_float(args[1], "Longitude") + elevation = parse_float(args[2], "Elevation") + timezone_name = args[3] + body_name = str(args[4]).strip() + + try: + interval_minutes = int(args[5]) + range_value = int(args[6]) + except ValueError as exc: + fail("Intervall oder Zeitraum ist ungueltig.", extra={"details": str(exc), "argv": args}) + + range_unit = str(args[7]).strip() + + if interval_minutes <= 0: + fail("Intervall muss groesser als 0 sein.", extra={"interval_minutes": interval_minutes}) + if range_value <= 0: + fail("Zeitraum muss groesser als 0 sein.", extra={"range_value": range_value}) + if body_name not in EPHEMERIS_BODIES: + fail("Planet ist ungueltig.", extra={"body": body_name}) + if range_unit not in {"minutes", "hours", "days", "weeks", "months", "years"}: + fail("Zeitraum-Einheit ist ungueltig.", extra={"range_unit": range_unit}) + + try: + tz = ZoneInfo(timezone_name) + except Exception as exc: + fail("Zeitzone ist ungueltig.", extra={"details": str(exc)}) + + label, body = EPHEMERIS_BODIES[body_name] + if body == astronomy.Body.Earth: + fail("Fuer die Erde kann von einem Standort auf der Erde keine sinnvolle geozentrische Ephemeride berechnet werden.") + + observer = astronomy.Observer(latitude, longitude, elevation) + local_start = datetime.now(tz).replace(second=0, microsecond=0) + local_end = add_calendar_unit(local_start, range_value, range_unit) + + rows = [] + current_local = local_start + step = timedelta(minutes=interval_minutes) + rise_set_cache: dict[str, tuple[str | None, str | None]] = {} + max_rows = 5000 + + while current_local <= local_end: + if len(rows) >= max_rows: + fail( + "Die Anfrage erzeugt zu viele Tabellenzeilen. Bitte Zeitraum verkuerzen oder groesseres Intervall waehlen.", + extra={ + "max_rows": max_rows, + "interval_minutes": interval_minutes, + "range_value": range_value, + "range_unit": range_unit, + }, + ) + + current_utc = current_local.astimezone(timezone.utc) + time_value = dt_to_time(current_utc) + eq = astronomy.Equator(body, time_value, observer, True, True) + + day_key = current_local.strftime("%Y-%m-%d") + if day_key not in rise_set_cache: + local_day_start = current_local.replace(hour=0, minute=0, second=0, microsecond=0) + local_day_end = local_day_start + timedelta(days=1) + day_start_time = dt_to_time(local_day_start.astimezone(timezone.utc)) + day_end_utc = local_day_end.astimezone(timezone.utc) + + rise = search_event(body, astronomy.Direction.Rise, observer, day_start_time, day_end_utc) + set_ = search_event(body, astronomy.Direction.Set, observer, day_start_time, day_end_utc) + rise_label = serialize_event("Aufgang", rise, tz).get("local_time") if rise is not None else None + set_label = serialize_event("Untergang", set_, tz).get("local_time") if set_ is not None else None + rise_set_cache[day_key] = (rise_label, set_label) + + rise_label, set_label = rise_set_cache[day_key] + rows.append({ + "object_name": label, + "datetime_local": current_local.strftime("%d.%m.%Y %H:%M"), + "ra": format_ra_hours(float(eq.ra)), + "dec": format_dec_deg(float(eq.dec)), + "rise": rise_label, + "set": set_label, + }) + + current_local += step + + return { + "ok": True, + "action": "planet_ephemeris", + "observer": { + "latitude": latitude, + "longitude": longitude, + "elevation": elevation, + "timezone": timezone_name, + }, + "object": { + "key": body_name, + "label": label, + }, + "window": { + "local_start": local_start.isoformat(), + "local_end": local_end.isoformat(), + "interval_minutes": interval_minutes, + "range_value": range_value, + "range_unit": range_unit, + }, + "rows": rows, + } + + def body_altitude_deg( body: astronomy.Body, observer: astronomy.Observer, @@ -4932,7 +5110,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", "astronomical_conversions", "comet_brightnesses", "favorite_comet_events_for_month", "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", "favorite_comet_events_for_month", "moon_star_occultations", "moon_phase_details", "satellite_passes", "planet_rise_set", "planet_ephemeris", "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:] @@ -4997,6 +5175,11 @@ def main() -> None: print(json.dumps(result, ensure_ascii=True)) return + if action == "planet_ephemeris": + result = action_planet_ephemeris(args) + print(json.dumps(result, ensure_ascii=True)) + return + if action == "planet_visibility_chart": result = action_planet_visibility_chart(args) print(json.dumps(result, ensure_ascii=True)) @@ -5102,7 +5285,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", "comet_brightnesses", "favorite_comet_events_for_month", "moon_star_occultations", "moon_phase_details", "satellite_passes", "planet_rise_set", "planet_visibility_chart", "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", "favorite_comet_events_for_month", "moon_star_occultations", "moon_phase_details", "satellite_passes", "planet_rise_set", "planet_ephemeris", "planet_visibility_chart", "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/ephemeriden_api.py b/public/py/ephemeriden_api.py new file mode 100644 index 0000000..bc21f71 --- /dev/null +++ b/public/py/ephemeriden_api.py @@ -0,0 +1,288 @@ +#!/usr/bin/env python3 +import json +import math +import os +import sys +from datetime import datetime, timedelta, timezone +from zoneinfo import ZoneInfo + +SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +if SCRIPT_DIR not in sys.path: + sys.path.insert(0, SCRIPT_DIR) + +import astronomy + + +EPHEMERIS_BODIES = { + "Sun": ("Sonne", astronomy.Body.Sun), + "Moon": ("Mond", astronomy.Body.Moon), + "Mercury": ("Merkur", astronomy.Body.Mercury), + "Venus": ("Venus", astronomy.Body.Venus), + "Mars": ("Mars", astronomy.Body.Mars), + "Jupiter": ("Jupiter", astronomy.Body.Jupiter), + "Saturn": ("Saturn", astronomy.Body.Saturn), + "Uranus": ("Uranus", astronomy.Body.Uranus), + "Neptune": ("Neptun", astronomy.Body.Neptune), +} + + +def fail(message: str, *, extra: dict | None = None, code: int = 1) -> None: + payload = {"ok": False, "error": message} + if extra: + payload.update(extra) + print(json.dumps(payload, ensure_ascii=True)) + raise SystemExit(code) + + +def parse_float(value: str, label: str) -> float: + try: + return float(value) + except ValueError as exc: + fail(f"{label} ist ungueltig.", extra={"details": str(exc), "value": value}) + + +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 time_to_datetime(time_value: astronomy.Time) -> datetime: + year, month, day, hour, minute, second = time_value.Calendar() + second_int = int(second) + microsecond = int(round((second - second_int) * 1_000_000)) + + if microsecond >= 1_000_000: + second_int += 1 + microsecond -= 1_000_000 + + return datetime(year, month, day, hour, minute, second_int, microsecond, tzinfo=timezone.utc) + + +def serialize_event(label: str, event_time: astronomy.Time | None, tz: ZoneInfo) -> dict: + if event_time is None: + return {"label": label, "found": False} + + utc_dt = time_to_datetime(event_time) + local_dt = utc_dt.astimezone(tz) + + return { + "label": label, + "found": True, + "utc_iso": utc_dt.isoformat().replace("+00:00", "Z"), + "local_iso": local_dt.isoformat(), + "local_time": local_dt.strftime("%H:%M"), + "local_date": local_dt.strftime("%Y-%m-%d"), + } + + +def search_event( + body: astronomy.Body, + direction: astronomy.Direction, + observer: astronomy.Observer, + start_time: astronomy.Time, + end_utc: datetime, +) -> astronomy.Time | None: + search_limit_days = (end_utc - time_to_datetime(start_time)).total_seconds() / 86400.0 + if search_limit_days <= 0: + return None + + result = astronomy.SearchRiseSet(body, observer, direction, start_time, search_limit_days) + if result is None: + return None + + result_dt = time_to_datetime(result) + if result_dt >= end_utc: + return None + + return result + + +def format_ra_hours(ra_hours: float) -> str: + total_seconds = int(round(float(ra_hours) * 3600.0)) + total_seconds %= 24 * 3600 + hours = total_seconds // 3600 + minutes = (total_seconds % 3600) // 60 + seconds = total_seconds % 60 + return f"{hours:02d}:{minutes:02d}:{seconds:02d}" + + +def format_dec_deg(dec_deg: float) -> str: + sign = "+" if dec_deg >= 0 else "-" + total_seconds = int(round(abs(float(dec_deg)) * 3600.0)) + degrees = total_seconds // 3600 + minutes = (total_seconds % 3600) // 60 + seconds = total_seconds % 60 + return f"{sign}{degrees:02d}:{minutes:02d}:{seconds:02d}" + + +def days_in_month(year: int, month: int) -> int: + if month == 12: + next_month = datetime(year + 1, 1, 1) + else: + next_month = datetime(year, month + 1, 1) + this_month = datetime(year, month, 1) + return (next_month - this_month).days + + +def add_calendar_unit(base: datetime, amount: int, unit: str) -> datetime: + if unit == "minutes": + return base + timedelta(minutes=amount) + if unit == "hours": + return base + timedelta(hours=amount) + if unit == "days": + return base + timedelta(days=amount) + if unit == "weeks": + return base + timedelta(weeks=amount) + if unit == "months": + month_index = (base.month - 1) + amount + year = base.year + (month_index // 12) + month = (month_index % 12) + 1 + day = min(base.day, days_in_month(year, month)) + return base.replace(year=year, month=month, day=day) + if unit == "years": + year = base.year + amount + day = base.day + if base.month == 2 and base.day == 29: + day = min(day, days_in_month(year, base.month)) + return base.replace(year=year, day=day) + fail("Zeitraum-Einheit ist ungueltig.", extra={"unit": unit}) + + +def action_planet_ephemeris(args: list[str]) -> dict: + if len(args) != 8: + fail( + "Aktion planet_ephemeris erwartet 8 Argumente: latitude longitude elevation timezone body intervalMinutes rangeValue rangeUnit", + extra={"argv": args}, + ) + + latitude = parse_float(args[0], "Latitude") + longitude = parse_float(args[1], "Longitude") + elevation = parse_float(args[2], "Elevation") + timezone_name = args[3] + body_name = str(args[4]).strip() + + try: + interval_minutes = int(args[5]) + range_value = int(args[6]) + except ValueError as exc: + fail("Intervall oder Zeitraum ist ungueltig.", extra={"details": str(exc), "argv": args}) + + range_unit = str(args[7]).strip() + + if interval_minutes <= 0: + fail("Intervall muss groesser als 0 sein.", extra={"interval_minutes": interval_minutes}) + if range_value <= 0: + fail("Zeitraum muss groesser als 0 sein.", extra={"range_value": range_value}) + if body_name not in EPHEMERIS_BODIES: + fail("Objekt ist ungueltig.", extra={"body": body_name}) + if range_unit not in {"minutes", "hours", "days", "weeks", "months", "years"}: + fail("Zeitraum-Einheit ist ungueltig.", extra={"range_unit": range_unit}) + + try: + tz = ZoneInfo(timezone_name) + except Exception as exc: + fail("Zeitzone ist ungueltig.", extra={"details": str(exc)}) + + label, body = EPHEMERIS_BODIES[body_name] + observer = astronomy.Observer(latitude, longitude, elevation) + local_start = datetime.now(tz).replace(second=0, microsecond=0) + local_end = add_calendar_unit(local_start, range_value, range_unit) + + rows = [] + current_local = local_start + step = timedelta(minutes=interval_minutes) + rise_set_cache: dict[str, tuple[str | None, str | None]] = {} + max_rows = 50000 + + while current_local <= local_end: + if len(rows) >= max_rows: + fail( + "Die Anfrage erzeugt zu viele Tabellenzeilen. Bitte Zeitraum verkuerzen oder groesseres Intervall waehlen.", + extra={ + "max_rows": max_rows, + "interval_minutes": interval_minutes, + "range_value": range_value, + "range_unit": range_unit, + }, + ) + + current_utc = current_local.astimezone(timezone.utc) + time_value = dt_to_time(current_utc) + eq = astronomy.Equator(body, time_value, observer, False, True) + + day_key = current_local.strftime("%Y-%m-%d") + if day_key not in rise_set_cache: + local_day_start = current_local.replace(hour=0, minute=0, second=0, microsecond=0) + local_day_end = local_day_start + timedelta(days=1) + day_start_time = dt_to_time(local_day_start.astimezone(timezone.utc)) + day_end_utc = local_day_end.astimezone(timezone.utc) + + rise = search_event(body, astronomy.Direction.Rise, observer, day_start_time, day_end_utc) + set_ = search_event(body, astronomy.Direction.Set, observer, day_start_time, day_end_utc) + rise_label = serialize_event("Aufgang", rise, tz).get("local_time") if rise is not None else None + set_label = serialize_event("Untergang", set_, tz).get("local_time") if set_ is not None else None + rise_set_cache[day_key] = (rise_label, set_label) + + rise_label, set_label = rise_set_cache[day_key] + rows.append({ + "object_name": label, + "date_local": current_local.strftime("%d.%m.%Y"), + "time_local": current_local.strftime("%H:%M"), + "ra": format_ra_hours(float(eq.ra)), + "ra_decimal_hours": round(float(eq.ra), 8), + "dec": format_dec_deg(float(eq.dec)), + "dec_decimal_deg": round(float(eq.dec), 8), + "rise": rise_label, + "set": set_label, + }) + + current_local += step + + return { + "ok": True, + "action": "planet_ephemeris", + "observer": { + "latitude": latitude, + "longitude": longitude, + "elevation": elevation, + "timezone": timezone_name, + }, + "object": { + "key": body_name, + "label": label, + }, + "window": { + "local_start": local_start.isoformat(), + "local_end": local_end.isoformat(), + "interval_minutes": interval_minutes, + "range_value": range_value, + "range_unit": range_unit, + }, + "rows": rows, + } + + +def main() -> None: + if len(sys.argv) < 2: + fail("Es wurde keine Aktion uebergeben.", extra={"available_actions": ["planet_ephemeris"]}) + + action = sys.argv[1] + args = sys.argv[2:] + + if action == "planet_ephemeris": + result = action_planet_ephemeris(args) + print(json.dumps(result, ensure_ascii=True)) + return + + fail("Unbekannte Aktion.", extra={"action": action, "available_actions": ["planet_ephemeris"]}) + + +if __name__ == "__main__": + main()