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 b1047fb..46f8a6a 100644 Binary files a/public/py/__pycache__/api.cpython-312.pyc and b/public/py/__pycache__/api.cpython-312.pyc differ 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()