From 5adca790eb93636afd5a127afae03f073e77cd62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= Date: Tue, 28 Apr 2026 09:19:51 +0200 Subject: [PATCH] Ephemeriden berechnet nun auch Kleinplaneten und Kometen --- public/ephemeriden.php | 239 +++++++++++++++- public/py/ephemeriden_api.py | 512 ++++++++++++++++++++++++++++++----- 2 files changed, 674 insertions(+), 77 deletions(-) diff --git a/public/ephemeriden.php b/public/ephemeriden.php index e6b3883..9f59f92 100644 --- a/public/ephemeriden.php +++ b/public/ephemeriden.php @@ -15,6 +15,82 @@ $headerIntroSub = ''; $loggedIn = isset($_SESSION['user_id']); $defaultLocation = null; +$favoriteMinorplanetOptions = []; +$favoriteCometOptions = []; +$minorplanetPayloads = []; +$cometPayloads = []; + +function decodeMpcPackedDateChar(string $value): ?int +{ + if ($value >= '0' && $value <= '9') { + return (int) $value; + } + + $upper = strtoupper($value); + if ($upper >= 'A' && $upper <= 'V') { + return ord($upper) - ord('A') + 10; + } + + return null; +} + +function decodeMpcPackedDate(?string $packed): ?string +{ + if ($packed === null || strlen($packed) !== 5) { + return null; + } + + $centuryMap = [ + 'I' => 1800, + 'J' => 1900, + 'K' => 2000, + ]; + + $centuryCode = strtoupper($packed[0]); + if (!isset($centuryMap[$centuryCode])) { + return null; + } + + $yearPart = substr($packed, 1, 2); + if (!ctype_digit($yearPart)) { + return null; + } + + $month = decodeMpcPackedDateChar($packed[3]); + $day = decodeMpcPackedDateChar($packed[4]); + if ($month === null || $day === null || $month < 1 || $month > 12 || $day < 1 || $day > 31) { + return null; + } + + return sprintf('%04d-%02d-%02d', $centuryMap[$centuryCode] + (int) $yearPart, $month, $day); +} + +function buildCometPerihelionIso(array $comet): ?string +{ + $year = isset($comet['year_of_perihelion']) ? (int) $comet['year_of_perihelion'] : 0; + $month = isset($comet['month_of_perihelion']) ? (int) $comet['month_of_perihelion'] : 0; + $dayValue = isset($comet['day_of_perihelion']) ? (float) $comet['day_of_perihelion'] : 0.0; + + if ($year <= 0 || $month < 1 || $month > 12 || $dayValue <= 0.0) { + return null; + } + + $day = (int) floor($dayValue); + if ($day < 1 || $day > 31) { + return null; + } + + $fractionalDay = $dayValue - $day; + $seconds = (int) round($fractionalDay * 86400.0); + + try { + $date = new DateTimeImmutable(sprintf('%04d-%02d-%02d 00:00:00', $year, $month, $day), new DateTimeZone('UTC')); + } catch (Throwable $e) { + return null; + } + + return $date->modify(sprintf('+%d seconds', $seconds))->format('c'); +} if ($loggedIn && isset($_SESSION['user_id'])) { try { @@ -37,8 +113,104 @@ if ($loggedIn && isset($_SESSION['user_id'])) { ); $stmtLoc->execute([(int) $_SESSION['user_id']]); $defaultLocation = $stmtLoc->fetch() ?: null; + + $stmtFavoriteMinorplanets = $pdo->prepare( + 'SELECT + mp.id, + mp.mp_number, + mp.designation_text, + mp.desig_packed, + mp.epoch_packed, + mp.mean_anomaly_deg, + mp.arg_perihelion_deg, + mp.ascending_node_deg, + mp.inclination_deg, + mp.eccentricity, + mp.mean_motion_deg_per_day, + mp.semimajor_axis_au + FROM app_user_minorplanets aum + INNER JOIN minorplanets_mpc mp + ON mp.id = aum.minorplanet_id + WHERE aum.user_id = ? + ORDER BY aum.is_favorite DESC, mp.mp_number IS NULL ASC, mp.mp_number ASC, mp.designation_text ASC' + ); + $stmtFavoriteMinorplanets->execute([(int) $_SESSION['user_id']]); + foreach ($stmtFavoriteMinorplanets->fetchAll() as $minorplanet) { + $minorplanetLabel = trim((string) ($minorplanet['designation_text'] ?? '')); + if ($minorplanetLabel === '') { + $minorplanetLabel = trim((string) ($minorplanet['desig_packed'] ?? '')); + } + if ($minorplanetLabel === '') { + continue; + } + + if ( + $minorplanet['mp_number'] !== null + && !str_contains($minorplanetLabel, '(' . (string) $minorplanet['mp_number'] . ')') + ) { + $minorplanetLabel = '(' . (string) $minorplanet['mp_number'] . ') ' . $minorplanetLabel; + } + + $bodyKey = 'minorplanet:' . (string) $minorplanet['id']; + $favoriteMinorplanetOptions[$bodyKey] = $minorplanetLabel; + $minorplanetPayloads[$bodyKey] = [ + 'id' => (int) $minorplanet['id'], + 'designation' => $minorplanetLabel, + 'epochDateIso' => decodeMpcPackedDate((string) ($minorplanet['epoch_packed'] ?? '')), + 'meanAnomalyDeg' => isset($minorplanet['mean_anomaly_deg']) ? (float) $minorplanet['mean_anomaly_deg'] : null, + 'argPerihelionDeg' => isset($minorplanet['arg_perihelion_deg']) ? (float) $minorplanet['arg_perihelion_deg'] : null, + 'ascendingNodeDeg' => isset($minorplanet['ascending_node_deg']) ? (float) $minorplanet['ascending_node_deg'] : null, + 'inclinationDeg' => isset($minorplanet['inclination_deg']) ? (float) $minorplanet['inclination_deg'] : null, + 'eccentricity' => isset($minorplanet['eccentricity']) ? (float) $minorplanet['eccentricity'] : null, + 'meanMotionDegPerDay' => isset($minorplanet['mean_motion_deg_per_day']) ? (float) $minorplanet['mean_motion_deg_per_day'] : null, + 'semimajorAxisAu' => isset($minorplanet['semimajor_axis_au']) ? (float) $minorplanet['semimajor_axis_au'] : null, + ]; + } + + $stmtFavoriteComets = $pdo->prepare( + 'SELECT + c.id, + c.designation_and_name, + c.year_of_perihelion, + c.month_of_perihelion, + c.day_of_perihelion, + c.perihelion_dist_au, + c.eccentricity, + c.arg_perihelion_deg, + c.ascending_node_deg, + c.inclination_deg + FROM app_user_comets auc + INNER JOIN comets_mpc c + ON c.id = auc.comet_id + WHERE auc.user_id = ? + ORDER BY auc.is_favorite DESC, c.designation_and_name ASC' + ); + $stmtFavoriteComets->execute([(int) $_SESSION['user_id']]); + foreach ($stmtFavoriteComets->fetchAll() as $comet) { + $cometLabel = trim((string) ($comet['designation_and_name'] ?? '')); + if ($cometLabel === '') { + continue; + } + + $bodyKey = 'comet:' . (string) $comet['id']; + $favoriteCometOptions[$bodyKey] = $cometLabel; + $cometPayloads[$bodyKey] = [ + 'id' => (int) $comet['id'], + 'designation' => $cometLabel, + 'perihelionIso' => buildCometPerihelionIso($comet), + 'perihelionDistanceAu' => isset($comet['perihelion_dist_au']) ? (float) $comet['perihelion_dist_au'] : null, + 'eccentricity' => isset($comet['eccentricity']) ? (float) $comet['eccentricity'] : null, + 'argPerihelionDeg' => isset($comet['arg_perihelion_deg']) ? (float) $comet['arg_perihelion_deg'] : null, + 'ascendingNodeDeg' => isset($comet['ascending_node_deg']) ? (float) $comet['ascending_node_deg'] : null, + 'inclinationDeg' => isset($comet['inclination_deg']) ? (float) $comet['inclination_deg'] : null, + ]; + } } catch (Throwable $e) { $defaultLocation = null; + $favoriteMinorplanetOptions = []; + $favoriteCometOptions = []; + $minorplanetPayloads = []; + $cometPayloads = []; } } @@ -54,6 +226,8 @@ $planetOptions = [ 'Neptune' => 'Neptun', ]; +$bodyOptions = $planetOptions + $favoriteMinorplanetOptions + $favoriteCometOptions; + $intervalOptions = [ '1' => '1 Minute', '10' => '10 Minuten', @@ -77,7 +251,7 @@ $rangeUnitOptions = [ ]; $selectedBody = (string) ($_POST['body'] ?? 'Mercury'); -if (!isset($planetOptions[$selectedBody])) { +if (!isset($bodyOptions[$selectedBody])) { $selectedBody = 'Mercury'; } @@ -174,10 +348,13 @@ $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 (false && (str_starts_with($selectedBody, 'comet:') || str_starts_with($selectedBody, 'minorplanet:'))) { + $errorMessage = 'Favoriten-Kometen und Favoriten-Kleinplaneten sind jetzt in der Auswahl sichtbar. Die eigentliche Ephemeriden-Berechnung dafür binden wir als Nächstes an.'; } 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', [ + $pythonAction = 'planet_ephemeris'; + $pythonArgs = [ (string) $pageLocation['latitude'], (string) $pageLocation['longitude'], (string) $pageLocation['elevation'], @@ -186,7 +363,37 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $selectedInterval, $rangeValueInput, $selectedRangeUnit, - ]); + ]; + + if (isset($minorplanetPayloads[$selectedBody])) { + $pythonAction = 'small_body_ephemeris'; + $pythonArgs = [ + (string) $pageLocation['latitude'], + (string) $pageLocation['longitude'], + (string) $pageLocation['elevation'], + (string) $pageLocation['timezone'], + 'minorplanet', + base64_encode(json_encode($minorplanetPayloads[$selectedBody], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}'), + $selectedInterval, + $rangeValueInput, + $selectedRangeUnit, + ]; + } elseif (isset($cometPayloads[$selectedBody])) { + $pythonAction = 'small_body_ephemeris'; + $pythonArgs = [ + (string) $pageLocation['latitude'], + (string) $pageLocation['longitude'], + (string) $pageLocation['elevation'], + (string) $pageLocation['timezone'], + 'comet', + base64_encode(json_encode($cometPayloads[$selectedBody], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}'), + $selectedInterval, + $rangeValueInput, + $selectedRangeUnit, + ]; + } + + $pythonResult = runEphemeridenPythonApi($pythonAction, $pythonArgs); if (!$pythonResult['ok']) { $errorMessage = (string) ( @@ -201,7 +408,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { } if ($exportFormat === 'csv' && $ephemerisRows !== []) { - $filename = 'ephemeriden-' . ephemeriden_slug((string) ($planetOptions[$selectedBody] ?? 'objekt')) . '.csv'; + $filename = 'ephemeriden-' . ephemeriden_slug((string) ($bodyOptions[$selectedBody] ?? 'objekt')) . '.csv'; header('Content-Type: text/csv; charset=utf-8'); header('Content-Disposition: attachment; filename="' . $filename . '"'); @@ -335,11 +542,27 @@ require __DIR__ . '/header.php';
- +
diff --git a/public/py/ephemeriden_api.py b/public/py/ephemeriden_api.py index bc21f71..2411c8c 100644 --- a/public/py/ephemeriden_api.py +++ b/public/py/ephemeriden_api.py @@ -3,6 +3,7 @@ import json import math import os import sys +import base64 from datetime import datetime, timedelta, timezone from zoneinfo import ZoneInfo @@ -25,6 +26,12 @@ EPHEMERIS_BODIES = { "Neptune": ("Neptun", astronomy.Body.Neptune), } +J2000_OBLIQUITY_DEG = 23.439279444444445 +GAUSSIAN_GRAVITATIONAL_CONSTANT = 0.01720209895 +PARABOLIC_ECCENTRICITY_TOLERANCE = 1.0e-6 +MAX_ROWS = 50000 +CUSTOM_EVENT_STEP_MINUTES = 10 + def fail(message: str, *, extra: dict | None = None, code: int = 1) -> None: payload = {"ok": False, "error": message} @@ -41,6 +48,13 @@ def parse_float(value: str, label: str) -> float: fail(f"{label} ist ungueltig.", extra={"details": str(exc), "value": value}) +def parse_payload_float(payload: dict, key: str) -> float: + value = payload.get(key) + if value is None: + raise ValueError(f"{key} fehlt.") + return float(value) + + def dt_to_time(dt_utc: datetime) -> astronomy.Time: dt_utc = dt_utc.astimezone(timezone.utc) return astronomy.Time.Make( @@ -155,6 +169,311 @@ def add_calendar_unit(base: datetime, amount: int, unit: str) -> datetime: fail("Zeitraum-Einheit ist ungueltig.", extra={"unit": unit}) +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: + anomaly = 0.0 if mean_anomaly == 0.0 else 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 minorplanet_heliocentric_vector(payload: dict, dt_utc: datetime) -> tuple[float, float, float]: + epoch_date_iso = str(payload.get("epochDateIso") or "").strip() + if epoch_date_iso == "": + raise ValueError("Epochendatum fehlt.") + + epoch_dt = datetime.fromisoformat(f"{epoch_date_iso}T00:00:00+00:00").astimezone(timezone.utc) + days_since_epoch = (dt_utc - epoch_dt).total_seconds() / 86400.0 + + mean_anomaly_deg = parse_payload_float(payload, "meanAnomalyDeg") + mean_motion_deg_per_day = parse_payload_float(payload, "meanMotionDegPerDay") + eccentricity = max(0.0, min(0.999999, parse_payload_float(payload, "eccentricity"))) + semimajor_axis_au = parse_payload_float(payload, "semimajorAxisAu") + inclination = math.radians(parse_payload_float(payload, "inclinationDeg")) + ascending_node = math.radians(parse_payload_float(payload, "ascendingNodeDeg")) + arg_perihelion = math.radians(parse_payload_float(payload, "argPerihelionDeg")) + + mean_anomaly = math.radians(mean_anomaly_deg + (mean_motion_deg_per_day * days_since_epoch)) + eccentric_anomaly = solve_elliptic_anomaly(math.fmod(mean_anomaly, 2.0 * math.pi), eccentricity) + 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), + ) + radius_au = semimajor_axis_au * (1.0 - eccentricity * math.cos(eccentric_anomaly)) + argument_of_latitude = true_anomaly + arg_perihelion + + x_ecl = radius_au * ( + (math.cos(ascending_node) * math.cos(argument_of_latitude)) + - (math.sin(ascending_node) * math.sin(argument_of_latitude) * math.cos(inclination)) + ) + y_ecl = radius_au * ( + (math.sin(ascending_node) * math.cos(argument_of_latitude)) + + (math.cos(ascending_node) * math.sin(argument_of_latitude) * math.cos(inclination)) + ) + z_ecl = radius_au * (math.sin(argument_of_latitude) * math.sin(inclination)) + + return ecliptic_to_equatorial(x_ecl, y_ecl, z_ecl) + + +def comet_heliocentric_vector(payload: dict, dt_utc: datetime) -> tuple[float, float, float]: + perihelion_iso = str(payload.get("perihelionIso") or "").strip() + if perihelion_iso == "": + raise ValueError("Perihelzeit fehlt.") + + perihelion_dt = datetime.fromisoformat(perihelion_iso.replace("Z", "+00:00")).astimezone(timezone.utc) + delta_days = (dt_utc - perihelion_dt).total_seconds() / 86400.0 + + perihelion_distance_au = parse_payload_float(payload, "perihelionDistanceAu") + eccentricity = parse_payload_float(payload, "eccentricity") + inclination = math.radians(parse_payload_float(payload, "inclinationDeg")) + ascending_node = math.radians(parse_payload_float(payload, "ascendingNodeDeg")) + arg_perihelion = math.radians(parse_payload_float(payload, "argPerihelionDeg")) + + true_anomaly, radius = true_anomaly_and_radius(delta_days, perihelion_distance_au, eccentricity) + argument_of_latitude = arg_perihelion + true_anomaly + + x_ecl = radius * (math.cos(ascending_node) * math.cos(argument_of_latitude) - math.sin(ascending_node) * math.sin(argument_of_latitude) * math.cos(inclination)) + y_ecl = radius * (math.sin(ascending_node) * math.cos(argument_of_latitude) + math.cos(ascending_node) * math.sin(argument_of_latitude) * math.cos(inclination)) + z_ecl = radius * (math.sin(argument_of_latitude) * math.sin(inclination)) + + return ecliptic_to_equatorial(x_ecl, y_ecl, z_ecl) + + +def custom_geocentric_vector(body_type: str, payload: dict, time_value: astronomy.Time) -> astronomy.Vector: + dt_utc = time_to_datetime(time_value) + if body_type == "minorplanet": + helio = minorplanet_heliocentric_vector(payload, dt_utc) + elif body_type == "comet": + helio = comet_heliocentric_vector(payload, dt_utc) + else: + raise ValueError("Objekttyp ist ungueltig.") + + earth_vector = astronomy.HelioVector(astronomy.Body.Earth, time_value) + return astronomy.Vector( + helio[0] - earth_vector.x, + helio[1] - earth_vector.y, + helio[2] - earth_vector.z, + time_value, + ) + + +def custom_topocentric_vector(body_type: str, payload: dict, observer: astronomy.Observer, time_value: astronomy.Time) -> astronomy.Vector: + geocentric_vector = custom_geocentric_vector(body_type, payload, time_value) + observer_vector = astronomy.ObserverVector(time_value, observer, False) + return astronomy.Vector( + geocentric_vector.x - observer_vector.x, + geocentric_vector.y - observer_vector.y, + geocentric_vector.z - observer_vector.z, + time_value, + ) + + +def custom_altitude_deg(body_type: str, payload: dict, observer: astronomy.Observer, time_value: astronomy.Time) -> float: + topocentric_vector = custom_topocentric_vector(body_type, payload, observer, time_value) + rotation = astronomy.Rotation_EQJ_HOR(time_value, observer) + horizontal_vector = astronomy.RotateVector(rotation, topocentric_vector) + horizontal = astronomy.HorizonFromVector(horizontal_vector, astronomy.Refraction.Normal) + return float(horizontal.lat) + + +def refine_custom_event( + body_type: str, + payload: dict, + observer: astronomy.Observer, + left_dt: datetime, + right_dt: datetime, + *, + rising: bool, +) -> datetime: + for _ in range(24): + midpoint = left_dt + (right_dt - left_dt) / 2 + altitude = custom_altitude_deg(body_type, payload, observer, dt_to_time(midpoint)) + if (altitude >= 0.0) == rising: + right_dt = midpoint + else: + left_dt = midpoint + return right_dt + + +def search_custom_events_for_day( + body_type: str, + payload: dict, + observer: astronomy.Observer, + local_day_start: datetime, + tz: ZoneInfo, +) -> tuple[str | None, str | None]: + step = timedelta(minutes=CUSTOM_EVENT_STEP_MINUTES) + day_end = local_day_start + timedelta(days=1) + + previous_dt = local_day_start.astimezone(timezone.utc) + previous_altitude = custom_altitude_deg(body_type, payload, observer, dt_to_time(previous_dt)) + rise_time = None + set_time = None + + current_dt = previous_dt + step + while current_dt <= day_end.astimezone(timezone.utc): + current_altitude = custom_altitude_deg(body_type, payload, observer, dt_to_time(current_dt)) + + if rise_time is None and previous_altitude < 0.0 <= current_altitude: + rise_time = refine_custom_event(body_type, payload, observer, previous_dt, current_dt, rising=True) + if set_time is None and previous_altitude >= 0.0 > current_altitude: + set_time = refine_custom_event(body_type, payload, observer, previous_dt, current_dt, rising=False) + + previous_dt = current_dt + previous_altitude = current_altitude + current_dt += step + + rise_label = rise_time.astimezone(tz).strftime("%H:%M") if rise_time is not None else None + set_label = set_time.astimezone(tz).strftime("%H:%M") if set_time is not None else None + return rise_label, set_label + + +def build_rows_response( + *, + observer: astronomy.Observer, + timezone_name: str, + object_key: str, + object_label: str, + interval_minutes: int, + range_value: int, + range_unit: str, + row_builder, + rise_set_builder, + action_name: str, +) -> dict: + try: + tz = ZoneInfo(timezone_name) + except Exception as exc: + fail("Zeitzone ist ungueltig.", extra={"details": str(exc)}) + + 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]] = {} + + 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) + + 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) + rise_set_cache[day_key] = rise_set_builder(local_day_start) + + rise_label, set_label = rise_set_cache[day_key] + row = row_builder(current_local, time_value, rise_label, set_label) + rows.append(row) + current_local += step + + return { + "ok": True, + "action": action_name, + "observer": { + "latitude": observer.latitude, + "longitude": observer.longitude, + "elevation": observer.height, + "timezone": timezone_name, + }, + "object": { + "key": object_key, + "label": object_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 action_planet_ephemeris(args: list[str]) -> dict: if len(args) != 8: fail( @@ -185,53 +504,12 @@ def action_planet_ephemeris(args: list[str]) -> dict: 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) + def row_builder(current_local: datetime, time_value: astronomy.Time, rise_label: str | None, set_label: str | None) -> dict: 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({ + return { "object_name": label, "date_local": current_local.strftime("%d.%m.%Y"), "time_local": current_local.strftime("%H:%M"), @@ -241,37 +519,128 @@ def action_planet_ephemeris(args: list[str]) -> dict: "dec_decimal_deg": round(float(eq.dec), 8), "rise": rise_label, "set": set_label, - }) + } - current_local += step + def rise_set_builder(local_day_start: datetime) -> tuple[str | None, str | None]: + try: + tz = ZoneInfo(timezone_name) + except Exception as exc: + fail("Zeitzone ist ungueltig.", extra={"details": str(exc)}) - 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, - } + 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 + return rise_label, set_label + + return build_rows_response( + observer=observer, + timezone_name=timezone_name, + object_key=body_name, + object_label=label, + interval_minutes=interval_minutes, + range_value=range_value, + range_unit=range_unit, + row_builder=row_builder, + rise_set_builder=rise_set_builder, + action_name="planet_ephemeris", + ) + + +def action_small_body_ephemeris(args: list[str]) -> dict: + if len(args) != 9: + fail( + "Aktion small_body_ephemeris erwartet 9 Argumente: latitude longitude elevation timezone bodyType payload 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_type = str(args[4]).strip().lower() + + try: + payload_json = base64.b64decode(args[5]).decode("utf-8") + payload = json.loads(payload_json) + except json.JSONDecodeError as exc: + fail("Objektdaten sind ungueltig.", extra={"details": str(exc)}) + + try: + interval_minutes = int(args[6]) + range_value = int(args[7]) + except ValueError as exc: + fail("Intervall oder Zeitraum ist ungueltig.", extra={"details": str(exc), "argv": args}) + + range_unit = str(args[8]).strip() + + if body_type not in {"minorplanet", "comet"}: + fail("Objekttyp ist ungueltig.", extra={"body_type": body_type}) + if not isinstance(payload, dict): + fail("Objektdaten sind ungueltig.") + 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 range_unit not in {"minutes", "hours", "days", "weeks", "months", "years"}: + fail("Zeitraum-Einheit ist ungueltig.", extra={"range_unit": range_unit}) + + observer = astronomy.Observer(latitude, longitude, elevation) + object_label = str(payload.get("designation") or payload.get("label") or "Objekt").strip() or "Objekt" + object_key = f"{body_type}:{payload.get('id', '')}" + + def row_builder(current_local: datetime, time_value: astronomy.Time, rise_label: str | None, set_label: str | None) -> dict: + try: + topocentric_vector = custom_topocentric_vector(body_type, payload, observer, time_value) + except Exception as exc: + fail("Die Ephemeriden konnten fuer dieses Objekt nicht berechnet werden.", extra={"details": str(exc), "object": object_label}) + + eq = astronomy.EquatorFromVector(topocentric_vector) + return { + "object_name": object_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, + } + + def rise_set_builder(local_day_start: datetime) -> tuple[str | None, str | None]: + try: + tz = ZoneInfo(timezone_name) + except Exception as exc: + fail("Zeitzone ist ungueltig.", extra={"details": str(exc)}) + + try: + return search_custom_events_for_day(body_type, payload, observer, local_day_start, tz) + except Exception as exc: + fail("Auf- und Untergang konnten fuer dieses Objekt nicht berechnet werden.", extra={"details": str(exc), "object": object_label}) + + return build_rows_response( + observer=observer, + timezone_name=timezone_name, + object_key=object_key, + object_label=object_label, + interval_minutes=interval_minutes, + range_value=range_value, + range_unit=range_unit, + row_builder=row_builder, + rise_set_builder=rise_set_builder, + action_name="small_body_ephemeris", + ) def main() -> None: + available_actions = ["planet_ephemeris", "small_body_ephemeris"] + if len(sys.argv) < 2: - fail("Es wurde keine Aktion uebergeben.", extra={"available_actions": ["planet_ephemeris"]}) + fail("Es wurde keine Aktion uebergeben.", extra={"available_actions": available_actions}) action = sys.argv[1] args = sys.argv[2:] @@ -281,7 +650,12 @@ def main() -> None: print(json.dumps(result, ensure_ascii=True)) return - fail("Unbekannte Aktion.", extra={"action": action, "available_actions": ["planet_ephemeris"]}) + if action == "small_body_ephemeris": + result = action_small_body_ephemeris(args) + print(json.dumps(result, ensure_ascii=True)) + return + + fail("Unbekannte Aktion.", extra={"action": action, "available_actions": available_actions}) if __name__ == "__main__":