Ephemeriden berechnet nun auch Kleinplaneten und Kometen

This commit is contained in:
2026-04-28 09:19:51 +02:00
parent c59e3e7f2e
commit 5adca790eb
2 changed files with 674 additions and 77 deletions
+228 -5
View File
@@ -15,6 +15,82 @@ $headerIntroSub = '';
$loggedIn = isset($_SESSION['user_id']); $loggedIn = isset($_SESSION['user_id']);
$defaultLocation = null; $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'])) { if ($loggedIn && isset($_SESSION['user_id'])) {
try { try {
@@ -37,8 +113,104 @@ if ($loggedIn && isset($_SESSION['user_id'])) {
); );
$stmtLoc->execute([(int) $_SESSION['user_id']]); $stmtLoc->execute([(int) $_SESSION['user_id']]);
$defaultLocation = $stmtLoc->fetch() ?: null; $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) { } catch (Throwable $e) {
$defaultLocation = null; $defaultLocation = null;
$favoriteMinorplanetOptions = [];
$favoriteCometOptions = [];
$minorplanetPayloads = [];
$cometPayloads = [];
} }
} }
@@ -54,6 +226,8 @@ $planetOptions = [
'Neptune' => 'Neptun', 'Neptune' => 'Neptun',
]; ];
$bodyOptions = $planetOptions + $favoriteMinorplanetOptions + $favoriteCometOptions;
$intervalOptions = [ $intervalOptions = [
'1' => '1 Minute', '1' => '1 Minute',
'10' => '10 Minuten', '10' => '10 Minuten',
@@ -77,7 +251,7 @@ $rangeUnitOptions = [
]; ];
$selectedBody = (string) ($_POST['body'] ?? 'Mercury'); $selectedBody = (string) ($_POST['body'] ?? 'Mercury');
if (!isset($planetOptions[$selectedBody])) { if (!isset($bodyOptions[$selectedBody])) {
$selectedBody = 'Mercury'; $selectedBody = 'Mercury';
} }
@@ -174,10 +348,13 @@ $exportFormat = (string) ($_POST['export_format'] ?? '');
if ($_SERVER['REQUEST_METHOD'] === 'POST') { if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if ($pageLocation === null || $pageLocation['latitude'] === null || $pageLocation['longitude'] === null) { 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.'; $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) { } elseif (!ctype_digit($rangeValueInput) || (int) $rangeValueInput < 1 || (int) $rangeValueInput > 31) {
$errorMessage = 'Der Zeitraum muss als ganze Zahl zwischen 1 und 31 angegeben werden.'; $errorMessage = 'Der Zeitraum muss als ganze Zahl zwischen 1 und 31 angegeben werden.';
} else { } else {
$pythonResult = runEphemeridenPythonApi('planet_ephemeris', [ $pythonAction = 'planet_ephemeris';
$pythonArgs = [
(string) $pageLocation['latitude'], (string) $pageLocation['latitude'],
(string) $pageLocation['longitude'], (string) $pageLocation['longitude'],
(string) $pageLocation['elevation'], (string) $pageLocation['elevation'],
@@ -186,7 +363,37 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$selectedInterval, $selectedInterval,
$rangeValueInput, $rangeValueInput,
$selectedRangeUnit, $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']) { if (!$pythonResult['ok']) {
$errorMessage = (string) ( $errorMessage = (string) (
@@ -201,7 +408,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
} }
if ($exportFormat === 'csv' && $ephemerisRows !== []) { 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-Type: text/csv; charset=utf-8');
header('Content-Disposition: attachment; filename="' . $filename . '"'); header('Content-Disposition: attachment; filename="' . $filename . '"');
@@ -335,11 +542,27 @@ require __DIR__ . '/header.php';
<form method="post" class="ephemeriden-form"> <form method="post" class="ephemeriden-form">
<div class="ephemeriden-fields-row"> <div class="ephemeriden-fields-row">
<div class="form-group"> <div class="form-group">
<label for="ephemeridenBody">Planet</label> <label for="ephemeridenBody">Himmelskörper</label>
<select id="ephemeridenBody" name="body"> <select id="ephemeridenBody" name="body">
<optgroup label="Sonne, Mond und Planeten">
<?php foreach ($planetOptions as $bodyValue => $bodyLabel): ?> <?php foreach ($planetOptions as $bodyValue => $bodyLabel): ?>
<option value="<?= ephemeriden_h($bodyValue) ?>"<?= $bodyValue === $selectedBody ? ' selected' : '' ?>><?= ephemeriden_h($bodyLabel) ?></option> <option value="<?= ephemeriden_h($bodyValue) ?>"<?= $bodyValue === $selectedBody ? ' selected' : '' ?>><?= ephemeriden_h($bodyLabel) ?></option>
<?php endforeach; ?> <?php endforeach; ?>
</optgroup>
<?php if ($favoriteMinorplanetOptions !== []): ?>
<optgroup label="Meine Kleinplaneten">
<?php foreach ($favoriteMinorplanetOptions as $bodyValue => $bodyLabel): ?>
<option value="<?= ephemeriden_h($bodyValue) ?>"<?= $bodyValue === $selectedBody ? ' selected' : '' ?>><?= ephemeriden_h($bodyLabel) ?></option>
<?php endforeach; ?>
</optgroup>
<?php endif; ?>
<?php if ($favoriteCometOptions !== []): ?>
<optgroup label="Meine Kometen">
<?php foreach ($favoriteCometOptions as $bodyValue => $bodyLabel): ?>
<option value="<?= ephemeriden_h($bodyValue) ?>"<?= $bodyValue === $selectedBody ? ' selected' : '' ?>><?= ephemeriden_h($bodyLabel) ?></option>
<?php endforeach; ?>
</optgroup>
<?php endif; ?>
</select> </select>
</div> </div>
+444 -70
View File
@@ -3,6 +3,7 @@ import json
import math import math
import os import os
import sys import sys
import base64
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
@@ -25,6 +26,12 @@ EPHEMERIS_BODIES = {
"Neptune": ("Neptun", astronomy.Body.Neptune), "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: def fail(message: str, *, extra: dict | None = None, code: int = 1) -> None:
payload = {"ok": False, "error": message} 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}) 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: def dt_to_time(dt_utc: datetime) -> astronomy.Time:
dt_utc = dt_utc.astimezone(timezone.utc) dt_utc = dt_utc.astimezone(timezone.utc)
return astronomy.Time.Make( 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}) 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: def action_planet_ephemeris(args: list[str]) -> dict:
if len(args) != 8: if len(args) != 8:
fail( fail(
@@ -185,53 +504,12 @@ def action_planet_ephemeris(args: list[str]) -> dict:
if range_unit not in {"minutes", "hours", "days", "weeks", "months", "years"}: if range_unit not in {"minutes", "hours", "days", "weeks", "months", "years"}:
fail("Zeitraum-Einheit ist ungueltig.", extra={"range_unit": range_unit}) 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] label, body = EPHEMERIS_BODIES[body_name]
observer = astronomy.Observer(latitude, longitude, elevation) 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 = [] def row_builder(current_local: datetime, time_value: astronomy.Time, rise_label: str | None, set_label: str | None) -> dict:
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) eq = astronomy.Equator(body, time_value, observer, False, True)
return {
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, "object_name": label,
"date_local": current_local.strftime("%d.%m.%Y"), "date_local": current_local.strftime("%d.%m.%Y"),
"time_local": current_local.strftime("%H:%M"), "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), "dec_decimal_deg": round(float(eq.dec), 8),
"rise": rise_label, "rise": rise_label,
"set": set_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 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)})
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: def main() -> None:
available_actions = ["planet_ephemeris", "small_body_ephemeris"]
if len(sys.argv) < 2: 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] action = sys.argv[1]
args = sys.argv[2:] args = sys.argv[2:]
@@ -281,7 +650,12 @@ def main() -> None:
print(json.dumps(result, ensure_ascii=True)) print(json.dumps(result, ensure_ascii=True))
return 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__": if __name__ == "__main__":