From 8f0bc4b688da8a8774cc996e9ed5df908189cc59 Mon Sep 17 00:00:00 2001
From: Eskimue
Date: Sat, 11 Apr 2026 16:00:27 +0200
Subject: [PATCH] Kometenseite erstmal fertig
---
docs/README_DEV_LOGIN.md | 59 ++++
public/auth_helpers.php | 56 ++++
public/comets.php | 273 ++++++++++++++---
public/header.php | 5 +-
public/py/__pycache__/comets.cpython-314.pyc | Bin 0 -> 17154 bytes
public/py/api.py | 35 ++-
public/py/comets.py | 292 +++++++++++++++++++
7 files changed, 677 insertions(+), 43 deletions(-)
create mode 100644 docs/README_DEV_LOGIN.md
create mode 100644 public/py/__pycache__/comets.cpython-314.pyc
create mode 100644 public/py/comets.py
diff --git a/docs/README_DEV_LOGIN.md b/docs/README_DEV_LOGIN.md
new file mode 100644
index 0000000..2372d89
--- /dev/null
+++ b/docs/README_DEV_LOGIN.md
@@ -0,0 +1,59 @@
+# Lokaler Dev-Login
+
+Diese Projektkopie hat einen kleinen Entwicklungs-Bypass fuer den Login, damit geschuetzte Seiten auf `localhost` einfacher getestet werden koennen.
+
+## Zweck
+
+Der Dev-Login ist nur fuer die lokale Entwicklung gedacht.
+Er hilft beim Testen von Seiten wie:
+
+- `public/comets.php`
+- `public/my_favorites.php`
+- andere nicht oeffentlich freigegebene Seiten
+
+## Aktivierung
+
+Im lokalen Browser einfach eine geschuetzte Seite mit `?dev_login=1` aufrufen, zum Beispiel:
+
+```text
+http://localhost:8000/public/comets.php?dev_login=1
+```
+
+Dann wird automatisch der erste aktive Benutzer mit Rolle `master` oder `admin` in die Session eingeloggt.
+
+Danach koennen auch weitere geschuetzte Seiten im selben Browser/Fenster normal aufgerufen werden.
+
+## Abmelden
+
+Zum lokalen Dev-Logout:
+
+```text
+http://localhost:8000/public/comets.php?dev_logout=1
+```
+
+Das entfernt die Dev-Session wieder.
+
+## Sicherheitsgrenzen
+
+Der Dev-Login greift nur bei lokalen Requests:
+
+- Host muss `localhost`, `127.0.0.1` oder `::1` sein
+- der Request muss lokal kommen
+
+Auf dem echten Server oder unter normalen externen Domains hat dieser Mechanismus keine Wirkung.
+
+## Technische Umsetzung
+
+Die Erkennung und Session-Initialisierung liegt in:
+
+- [public/auth_helpers.php](/abs/path/c:/Users/HP/source/repos/skyview.astronomiemuseum.de/public/auth_helpers.php:4)
+
+Die Aktivierung beim Seitenaufbau liegt in:
+
+- [public/header.php](/abs/path/c:/Users/HP/source/repos/skyview.astronomiemuseum.de/public/header.php:3)
+
+## Hinweise
+
+- Der Dev-Login ist bewusst nicht fuer Produktion gedacht.
+- Wenn lokal kein aktiver `master`- oder `admin`-Benutzer vorhanden ist, greift der Bypass nicht.
+- Fuer reproduzierbare Tests am besten dieselbe Browser-Session weiterverwenden, statt staendig neu zu starten.
diff --git a/public/auth_helpers.php b/public/auth_helpers.php
index e3a79d1..0b40b97 100644
--- a/public/auth_helpers.php
+++ b/public/auth_helpers.php
@@ -1,6 +1,62 @@
PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]
+ );
+
+ $stmt = $pdo->query("
+ SELECT `id`, `username`, `role`
+ FROM `app_users`
+ WHERE `is_active` = 1
+ AND `role` IN ('master', 'admin')
+ ORDER BY CASE WHEN `role` = 'master' THEN 0 ELSE 1 END, `id` ASC
+ LIMIT 1
+ ");
+ $user = $stmt->fetch();
+
+ if (is_array($user)) {
+ $_SESSION['user_id'] = (int) $user['id'];
+ $_SESSION['username'] = (string) $user['username'];
+ $_SESSION['role'] = (string) $user['role'];
+ }
+ } catch (Throwable $e) {
+ return;
+ }
+}
+
function currentUserRole(): string
{
return trim((string) ($_SESSION['role'] ?? ''));
diff --git a/public/comets.php b/public/comets.php
index ddd5cda..eae13a6 100644
--- a/public/comets.php
+++ b/public/comets.php
@@ -16,6 +16,81 @@ function h(?string $value): string
return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
}
+function runPythonApi(string $action, array $args): array
+{
+ $scriptPath = __DIR__ . '/py/api.py';
+ $pythonCandidates = PHP_OS_FAMILY === 'Windows'
+ ? ['python', 'py']
+ : ['/usr/bin/python3', '/usr/bin/python', 'python3', 'python'];
+
+ $escapedArguments = array_map('escapeshellarg', array_merge([$scriptPath, $action], $args));
+ $lastError = 'Python konnte nicht gestartet werden.';
+
+ foreach ($pythonCandidates as $pythonBinary) {
+ $command = $pythonBinary . ' ' . implode(' ', $escapedArguments) . ' 2>&1';
+ $output = [];
+ $resultCode = 0;
+ exec($command, $output, $resultCode);
+
+ $joined = trim(implode("\n", $output));
+ $decoded = json_decode($joined, true);
+
+ if (is_array($decoded)) {
+ return [
+ 'ok' => (bool) ($decoded['ok'] ?? false),
+ 'data' => $decoded,
+ 'raw' => $joined,
+ 'command' => $command,
+ ];
+ }
+
+ if ($joined !== '') {
+ $lastError = $joined;
+ } elseif ($resultCode !== 0) {
+ $lastError = 'Fehlercode ' . $resultCode . ' bei ' . $pythonBinary;
+ }
+ }
+
+ return [
+ 'ok' => false,
+ 'error' => $lastError,
+ ];
+}
+
+function runPythonApiWithJsonPayloadFile(string $action, array $payload): array
+{
+ $tempFile = tempnam(sys_get_temp_dir(), 'skyview_py_');
+ if ($tempFile === false) {
+ return [
+ 'ok' => false,
+ 'error' => 'Temporäre Datei für Python-Payload konnte nicht erstellt werden.',
+ ];
+ }
+
+ $jsonPayload = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+ if ($jsonPayload === false) {
+ @unlink($tempFile);
+ return [
+ 'ok' => false,
+ 'error' => 'Python-Payload konnte nicht als JSON kodiert werden.',
+ ];
+ }
+
+ if (file_put_contents($tempFile, $jsonPayload) === false) {
+ @unlink($tempFile);
+ return [
+ 'ok' => false,
+ 'error' => 'Temporäre Datei für Python-Payload konnte nicht geschrieben werden.',
+ ];
+ }
+
+ try {
+ return runPythonApi($action, ['@' . $tempFile]);
+ } finally {
+ @unlink($tempFile);
+ }
+}
+
function formatDecimalGerman(?float $value, int $decimals = 3): string
{
if ($value === null || !is_finite($value)) {
@@ -25,6 +100,51 @@ function formatDecimalGerman(?float $value, int $decimals = 3): string
return number_format($value, $decimals, ',', '.');
}
+function formatRaHours(?float $hours): string
+{
+ if ($hours === null || !is_finite($hours)) {
+ return '-';
+ }
+
+ $normalized = fmod($hours, 24.0);
+ if ($normalized < 0.0) {
+ $normalized += 24.0;
+ }
+
+ $totalSeconds = (int) round($normalized * 3600.0);
+ $raHours = intdiv($totalSeconds, 3600) % 24;
+ $raMinutes = intdiv($totalSeconds % 3600, 60);
+ $raSeconds = $totalSeconds % 60;
+
+ return sprintf('%02dh %02dm %02ds', $raHours, $raMinutes, $raSeconds);
+}
+
+function formatDeclination(?float $degrees): string
+{
+ if ($degrees === null || !is_finite($degrees)) {
+ return '-';
+ }
+
+ $sign = $degrees < 0.0 ? '-' : '+';
+ $absolute = abs($degrees);
+ $deg = (int) floor($absolute);
+ $minutesFloat = ($absolute - $deg) * 60.0;
+ $minutes = (int) floor($minutesFloat);
+ $seconds = (int) round(($minutesFloat - $minutes) * 60.0);
+
+ if ($seconds >= 60) {
+ $seconds -= 60;
+ $minutes += 1;
+ }
+
+ if ($minutes >= 60) {
+ $minutes -= 60;
+ $deg += 1;
+ }
+
+ return sprintf('%s%02d° %02d\' %02d"', $sign, $deg, $minutes, $seconds);
+}
+
function buildPerihelionDate(array $row): ?DateTimeImmutable
{
$year = isset($row['year_of_perihelion']) ? (int) $row['year_of_perihelion'] : 0;
@@ -64,20 +184,6 @@ function classifyOrbitType(string $orbitType): string
};
}
-function estimatePerihelMagnitude(?float $absoluteMagnitudeH, ?float $perihelionDistanceAu): ?float
-{
- if ($absoluteMagnitudeH === null || !is_finite($absoluteMagnitudeH) || $perihelionDistanceAu === null || !is_finite($perihelionDistanceAu) || $perihelionDistanceAu <= 0.0) {
- return null;
- }
-
- $heliocentricDistance = max(0.05, $perihelionDistanceAu);
- $geocentricDistance = max(0.2, abs(1.0 - $perihelionDistanceAu));
-
- return $absoluteMagnitudeH
- + (5.0 * log10($geocentricDistance))
- + (10.0 * log10($heliocentricDistance));
-}
-
$config = require __DIR__ . '/../config/database.php';
$dsn = sprintf(
'mysql:host=%s;dbname=%s;charset=%s',
@@ -121,36 +227,29 @@ $stmtComets->execute([
':year_min' => $queryYearMin,
':year_max' => $queryYearMax,
]);
+$cometRows = $stmtComets->fetchAll();
$allComets = [];
$trackedComets = [];
$interestingComets = [];
-foreach ($stmtComets as $row) {
+foreach ($cometRows as $row) {
$perihelionDate = buildPerihelionDate($row);
$daysFromToday = $perihelionDate ? (int) floor(($perihelionDate->getTimestamp() - $today->getTimestamp()) / 86400) : null;
$row['perihelion_date_object'] = $perihelionDate;
- $row['perihelion_date_display'] = $perihelionDate ? $perihelionDate->setTimezone(new DateTimeZone('Europe/Berlin'))->format('d.m.Y H:i') . ' UTC' : '-';
+ $row['perihelion_date_display'] = $perihelionDate ? $perihelionDate->format('d.m.Y H:i') . ' UTC' : '-';
$row['days_from_today'] = $daysFromToday;
$row['orbit_type_label'] = classifyOrbitType((string) ($row['orbit_type'] ?? ''));
$row['absolute_magnitude_display'] = isset($row['absolute_magnitude_h']) && $row['absolute_magnitude_h'] !== null
? formatDecimalGerman((float) $row['absolute_magnitude_h'], 1)
: '-';
- $row['slope_parameter_display'] = isset($row['slope_parameter_g']) && $row['slope_parameter_g'] !== null
+ $row['photometric_index_display'] = isset($row['slope_parameter_g']) && $row['slope_parameter_g'] !== null
? formatDecimalGerman((float) $row['slope_parameter_g'], 1)
: '-';
- $row['perihelion_dist_display'] = formatDecimalGerman(isset($row['perihelion_dist_au']) ? (float) $row['perihelion_dist_au'] : null, 3);
- $row['eccentricity_display'] = formatDecimalGerman(isset($row['eccentricity']) ? (float) $row['eccentricity'] : null, 4);
- $row['inclination_display'] = formatDecimalGerman(isset($row['inclination_deg']) ? (float) $row['inclination_deg'] : null, 2);
- $estimatedPerihelMagnitude = estimatePerihelMagnitude(
- isset($row['absolute_magnitude_h']) && $row['absolute_magnitude_h'] !== null ? (float) $row['absolute_magnitude_h'] : null,
- isset($row['perihelion_dist_au']) && $row['perihelion_dist_au'] !== null ? (float) $row['perihelion_dist_au'] : null
- );
- $row['estimated_perihel_magnitude'] = $estimatedPerihelMagnitude;
- $row['estimated_perihel_magnitude_display'] = $estimatedPerihelMagnitude !== null
- ? formatDecimalGerman($estimatedPerihelMagnitude, 1)
- : '-';
+ $row['estimated_magnitude'] = null;
+ $row['estimated_magnitude_display'] = '-';
+ $row['brightness_error'] = null;
$allComets[] = $row;
@@ -183,6 +282,94 @@ usort($interestingComets, static function (array $left, array $right): int {
});
$interestingComets = array_slice($interestingComets, 0, 24);
+$visibleCometIds = [];
+foreach (array_merge($trackedComets, $interestingComets) as $visibleComet) {
+ $visibleId = isset($visibleComet['id']) ? (int) $visibleComet['id'] : 0;
+ if ($visibleId > 0) {
+ $visibleCometIds[$visibleId] = true;
+ }
+}
+
+$calculationMoment = new DateTimeImmutable('now', new DateTimeZone('UTC'));
+$brightnessByCometId = [];
+$brightnessError = null;
+
+if ($visibleCometIds !== []) {
+ $pythonPayload = [
+ 'date' => $calculationMoment->format('Y-m-d\TH:i:s\Z'),
+ 'comets' => array_values(array_map(static function (array $row): array {
+ return [
+ 'id' => $row['id'] ?? null,
+ 'year_of_perihelion' => $row['year_of_perihelion'] ?? null,
+ 'month_of_perihelion' => $row['month_of_perihelion'] ?? null,
+ 'day_of_perihelion' => $row['day_of_perihelion'] ?? null,
+ 'perihelion_dist_au' => $row['perihelion_dist_au'] ?? null,
+ 'eccentricity' => $row['eccentricity'] ?? null,
+ 'arg_perihelion_deg' => $row['arg_perihelion_deg'] ?? null,
+ 'ascending_node_deg' => $row['ascending_node_deg'] ?? null,
+ 'inclination_deg' => $row['inclination_deg'] ?? null,
+ 'absolute_magnitude_h' => $row['absolute_magnitude_h'] ?? null,
+ 'slope_parameter_g' => $row['slope_parameter_g'] ?? null,
+ ];
+ }, array_filter($cometRows, static function (array $row) use ($visibleCometIds): bool {
+ $rowId = isset($row['id']) ? (int) $row['id'] : 0;
+ return $rowId > 0 && isset($visibleCometIds[$rowId]);
+ }))),
+ ];
+
+ $pythonResult = runPythonApiWithJsonPayloadFile('comet_brightnesses', $pythonPayload);
+
+ if (($pythonResult['ok'] ?? false) && isset($pythonResult['data']['results']) && is_array($pythonResult['data']['results'])) {
+ foreach ($pythonResult['data']['results'] as $result) {
+ $resultId = isset($result['id']) ? (int) $result['id'] : 0;
+ if ($resultId > 0) {
+ $brightnessByCometId[$resultId] = $result;
+ }
+ }
+ } else {
+ $brightnessError = (string) ($pythonResult['error'] ?? 'Python-Berechnung nicht verfuegbar.');
+ }
+}
+
+$applyBrightness = static function (array $row) use ($brightnessByCometId): array {
+ $brightnessData = isset($row['id']) ? ($brightnessByCometId[(int) $row['id']] ?? null) : null;
+ $estimatedMagnitude = isset($brightnessData['estimated_magnitude']) && is_numeric($brightnessData['estimated_magnitude'])
+ ? (float) $brightnessData['estimated_magnitude']
+ : null;
+ $raHours = isset($brightnessData['ra_hours']) && is_numeric($brightnessData['ra_hours'])
+ ? (float) $brightnessData['ra_hours']
+ : null;
+ $decDeg = isset($brightnessData['dec_deg']) && is_numeric($brightnessData['dec_deg'])
+ ? (float) $brightnessData['dec_deg']
+ : null;
+ $heliocentricDistance = isset($brightnessData['heliocentric_distance_au']) && is_numeric($brightnessData['heliocentric_distance_au'])
+ ? (float) $brightnessData['heliocentric_distance_au']
+ : null;
+ $geocentricDistance = isset($brightnessData['geocentric_distance_au']) && is_numeric($brightnessData['geocentric_distance_au'])
+ ? (float) $brightnessData['geocentric_distance_au']
+ : null;
+
+ $row['estimated_magnitude'] = $estimatedMagnitude;
+ $row['estimated_magnitude_display'] = $estimatedMagnitude !== null
+ ? formatDecimalGerman($estimatedMagnitude, 1)
+ : '-';
+ $row['ra_hours'] = $raHours;
+ $row['ra_display'] = formatRaHours($raHours);
+ $row['dec_deg'] = $decDeg;
+ $row['dec_display'] = formatDeclination($decDeg);
+ $row['heliocentric_distance_au'] = $heliocentricDistance;
+ $row['heliocentric_distance_display'] = formatDecimalGerman($heliocentricDistance, 3);
+ $row['geocentric_distance_au'] = $geocentricDistance;
+ $row['geocentric_distance_display'] = formatDecimalGerman($geocentricDistance, 3);
+ $row['brightness_error'] = is_array($brightnessData) && !empty($brightnessData['error'])
+ ? (string) $brightnessData['error']
+ : null;
+
+ return $row;
+};
+
+$trackedComets = array_map($applyBrightness, $trackedComets);
+$interestingComets = array_map($applyBrightness, $interestingComets);
$orbitTypeCounts = [];
foreach ($allComets as $comet) {
$orbitKey = (string) ($comet['orbit_type'] ?? '?');
@@ -365,9 +552,15 @@ arsort($orbitTypeCounts);
Nutzer-Favoriten aus app_user_comets werden zusaetzlich separat gezeigt.
- Die Spalte m grob ist eine sehr einfache Helligkeitsschaetzung nahe dem Perihel
- auf Basis von MPC-H und q. Sie ist nur als Orientierung gedacht.
+ Die Spalte m grob nutzt jetzt die uebliche MPC-Kometenformel fuer heute:
+ H + 5 log(Delta) + 2,5 * n * log(r). Sie bleibt trotzdem nur eine Orientierung.
+
+ Position und Helligkeit werden fuer = h($calculationMoment->format('d.m.Y H:i')) ?> UTC berechnet.
+
+
+ Die Python-Berechnung der Helligkeit war nicht verfuegbar: = h($brightnessError) ?>
+
$count): ?>
@@ -410,9 +603,11 @@ arsort($orbitTypeCounts);
| Komet |
Typ |
+ RA |
+ Dec |
Perihel |
Abstand |
- m grob |
+ m grob |
@@ -439,9 +634,11 @@ arsort($orbitTypeCounts);
= h((string) $comet['orbit_type_label']) ?> |
+ = h((string) $comet['ra_display']) ?> |
+ = h((string) $comet['dec_display']) ?> |
= h((string) $comet['perihelion_date_display']) ?> |
= h($daysLabel) ?> |
- = h((string) $comet['estimated_perihel_magnitude_display']) ?> |
+ = h((string) $comet['estimated_magnitude_display']) ?> |
@@ -462,11 +659,11 @@ arsort($orbitTypeCounts);
| Komet |
Typ |
+ RA |
+ Dec |
Perihel |
Abstand |
- m grob |
- H |
- G |
+ m grob |
@@ -494,11 +691,11 @@ arsort($orbitTypeCounts);
= h((string) $comet['orbit_type_label']) ?> |
+ = h((string) $comet['ra_display']) ?> |
+ = h((string) $comet['dec_display']) ?> |
= h((string) $comet['perihelion_date_display']) ?> |
= h($daysLabel) ?> |
- = h((string) $comet['estimated_perihel_magnitude_display']) ?> |
- = h((string) $comet['absolute_magnitude_display']) ?> |
- = h((string) $comet['slope_parameter_display']) ?> |
+ = h((string) $comet['estimated_magnitude_display']) ?> |
diff --git a/public/header.php b/public/header.php
index 205d121..5dcbcc7 100644
--- a/public/header.php
+++ b/public/header.php
@@ -1,8 +1,7 @@
)t2J#5L=+wUg^V}pmM0h`AR#u(c?j1fj2SO=}J8e1SsqFVyYvb(ws
zRcB^%Hy$zx9*#LYshJxyVQZYN-Gp4t4OLg0s#HuEYB$M0
z`Tf3Dw^~Nn?9}Ef>wES0{(kT8`@FHZsE`7eIw6JsX%j_#iwVW)Gm+>21tKZxEalVn
zQV9(8y}BM
!sw$DCZ923_L)j4pShHZI1(101>yxHUf?T)
zxRt~UeKv>}k+{`Y3~@V&+k6g)J4w9A=YqJK#EX3%hM~+n#A3{8i?1DxW`uq@p=;X`g9Fc^P1n{Wc!*8k|h*OFp2OOBNd<@Vj_v4
zWWngQSdU_E(EJvJA-(ptc6tx+~bKme=Tz$#JGKnM`?Ypm&jOQb<
zV4_(kna;=9v0#G3uGUCBox6ulaSY21_4E&Mu_Qap46#f+#tm^7$1jJOE1QB`f{jIE
zV_{}2$uY^XO(A9|o;()`4{wf-4-Lo0m;|>eJ}%h;fnYQmOThF40k#-gx51Aq1usSY
zj@wGy
zNyl}5SfsYMl|{og{JMVQvf_~R>3xRQ46w#^mDG^Bn0}qoQv+2;4Ox`BCMpE&R8h55
zHHGblsXc~jDy(a!`y@)zwMe>6%|^)p3nv-D3&$lhtZ5`T%&=ByLn`zKBT1%#-XLL^0{&9R{SzonUWVgKtl)_ILN*+&g8MIw@G|QwNvy2BT-u?wK6;`Hy%@#qSrb
z-tQ=#(IeU2O9lx459frozbNc5Q(u{_9VLdZygKmzn1IRR+5!+7!cl-lmQ08MBAGFP
z8sgz>RnV~_=zw?EFctc+kVIKK6vz)ce-2;%_@Dz|p@PmburNV~#Jv(M^nEO@Y%|Sz
zAYi@dRilT3p9F#3t
zmIX=3ML=8Td{m2Oye*oqt(jTE?&(G_KXqeMM{|)>2>X>`W-MZaWWXI=12MJ%lk~CR
zMIdJ5Oc03QSS*?tl?}L2z002h=s8HlyD5{w&s=0gh-t`Zz3q>udLyK0=yKmlnbHQ3OXRS$F
zir=-~v|?bpX~UrKW}zmt=w=b7AebsyDBn0&zH#QFSl%vLc0VsN+e}N8*=Wi_k^(O{
zWdccWl2N=qZuks2Od7F*ma)P}cql=NK41rQC_J1XxUmu{?11i7R4CQ$d`5xy@xT%W
z4@Mt)ei0)%*$F^-;#5>90klo!u+@C*)Tc6A6#;ZwR-Ctv$~M(HdJXlY^48HSWtA{)
z@aq_$6jhX8&k%O&H)O;C1F&-aSzQ~T#0U^GEOFM>_CBNlAt7b((QUg`#t)^lEIrG_
z`-$YLEQ=i0LtUkRC5+R4I@6cjI(@bl`;uibAGAK$+M;PIZq)RKe4IGH(PwN?ISBRg
zs+xeqAYmqM&;@;hRD6~iQi(x60^l?4LWT-1ZXPfXAv+;%z~nRU%7CmEsfie)l8hXF
z1@lnF7>ndW{0+p;2jUDH9%UloSX6QoY+jb>1l-Qe>iY*}6mq2O#eNV!O^Iva==m71
zezT12T(gaEH_0%|2!!|AD(WW;pW$D
zLlLjA=%0k%z3|S3sdAyXK`30C>P(vpCr;eBGTAVtf4>Q9dTah!Al0{6?0kRSRPjuy
zShhuQZ54{QrMhKtpsa4%Gc!E9bvF3W`E}dh@BM1;d|CfQ(UOI-mEUcj8Jw@&AzF9v
z^p4+uXQpf=%fv!tJ%4Ae?QEz1uHAajV))vu13yP*KyE?kBCarU*p(F)jq+m1#y0~H
z-p^1)+ZYp!f<_S=3r0Y>V1|bokcYy<;lwy0l`Nv%N)+-qeCLp%ps--pxu2o;zzcL;
z=l+5r!8b;dDHBOmih(MKRAe@zfJ87GosU9dBcuEPb!@#Kl-af~AP3~SDk?jJer+qz
z2)bjAmA^MraSeGqTcZ#j2X*KYAhuz!UQ=I{J><602co4CPc4bj5+y&SP5JbGjry2R
zig}$jwUR@e9$ed^k{=kgen^!o{IoJ7pMj8USlKG-NSp%w$q#(6fT|&3Gd*(W19ZQA
zi*18>W$4J#TmRtv?{ECc2e&WnYc{dSb|rm045T3#9brJp3P&ZwaEy}-!E;=*k?n>8
zY!8+)E5M@*NJgTEb4Y+25!YorFX3X5%S?cYM8fezcvyipb^wYI@Ocj+AZpZXm^nFL
zv*l*w%NE{uYW~2fUnTy{m0w<&ANt|K(D2;QF#m($RQF<$YjWG4pPtaAon;T!e%|z1
z)9fx`!yd76kLcXSGnma+-+
zqO8(QnSW!gP1~Glt1H#_%t{rPE)`NXcd9ST(-ooQkA{5gA(-&0m~|QoshCxSm<8U0
z9ZMK|Z6v`r>qzFym_C~8Ui#zNItm(sIg;-2x
zsZt>p&al^$@R370N$^R*s4^L&M#q80o{NEvNX02!RRTDF1`*U`-ILtBxgzcI-ZVdT
zmE7KNYs1u#SluSL+9%9U6o>-5wW!MlE{t>yRWxod1j8biP$ElMidpr69
z-M$Wg??A^u@A1Blqk+!jeJ2Mx`UYesMst_g1VKd^{VeQRXpIek2Uf%3U<544UE;`mDB05DYDj}#`eVO;c3q^Ct{+5vUwr#
zGHXLAkMN5NWT4+quJqAan9f3dHnv^gQUT3p+-4f~*hqBq?3NIbg5c*)HJd{9dbal_rdjJd)g0GV#m>&wt;rWa6kxTR3RX%c6JU>oK%8P-MR~&*W
zS&W^4t{Jfu^;|G6fe@-l10b3zfIO=6>wWs{Ru0k_hG=C7
zpj+pnz6MQ!b*juYzfd{2UrQ=rTe9VGqn9~tS=B&q98z`aL7$pyf^oqNH0|XvtpV#+
z8K|~x?W$<=&KAtUxBlXeRJy7at$ERuI
z)qQK$RcS+0?-i@Gdf&>*YsV9}4F2hM5a^e|Ty|O%v77u~gt$Q$oLXtdrk!iNKm%_%
ztjS-n0`wWP_74qY6b|0f%r}n-6xO;>SsCqYmW@xtApaO}forr1Txdv=vV=I+)%Qj-
z-@I@^`qr$yGJ8$ESFF?OeXj%;LwM#v+@MY=7q{l%SdCI58^WBe$_Tal6SokmWqmdC
z&naz|VG{^D>Xz5)`(?vch)7`9>fmgXtAk_Da3XxUj$^{nO@yUq82s5470oex{Y)*Pg#G
z{i^hD_6eoE6Sl>wZL@oYsy^OQo_5wvch0nY;rZLLzb@ksoD|vz#0>-D+5ypdYQmH*
zt++dI@64Sui#Er*z3=o+4o{)zBG{TA9Q?fJvmSoOp+|0U$5El>m}oopT7{k)4K;-?
zC_`D{gl)+}IjVSj18-^2%xoTJR@3?=C$=3HYmV@i%Ei*14=cpdPQLpLe>Ny~2gTBJ
zFp26Nvk{^C1f0~Soom73F#gMNzT>2@Yd~xo;0Mo$O=m>s5Aw_*GJR5=L+@w3{LY?7
z+r^!ILd$W{c3hdmiiKV=ho=?WXAcV%gFIcDc9-1s+$+CR&Nu87%6EzGU8$q#qK0XX
ze`Eib_2L_y;`&ap=wRwl+FHt6Yk9i%Wzh~L2O`aYhK|zA_c9r-CfaF)Ji{x5Jjr-9
zzzjzuh@6__V?Wj!Y`hXf?D5zvc@UGGNTiOg4S5{fs2i4W`)O}Cn+b_uSA)RElO6AK(C7KxV1DMqZ>
zDq6PYrk2b!o+;A#&J+=h(4vlT`_
zPGJfB_ksOlJLVurvKP@qq+JOkK8EjHD|o2$tkdm!h8~_vLh!3L5`1m^n)&9`{J?4c
z&}ni18U7yy#r;82PR0jyde90EliQb+$qZ-`R
zI`pdjsJNuoF|JxiHJz(rf4wjf%1|o$d2S3)G{NG$H8G?C>$LLL}MW)5Q02=I0_UW
z%w-@XV@4l~7}8uQpFHn_I62ytiV|>xF%rm}Q4^V!a3}VsSOkT4Szy0`waet==X%Ul
z1$1@-lQ+?O2fa@8hzy^^7z#U5aqg%goik#|su`{1fx=+-ize`#JpwaGBtwj3L0*T`
zlPt*SNX(9T+NyT^q|LBl38g*BhM$$xGOo&@3aAn8GY8~3$K>SVN`PDs$ehjwE;GYu
zk^JXSnXrO;MAl!dZkq9aUVguv-`*`W_XyR!JYBKqu9-^S8~9FtZd`yvZr?U
zyI0@2I#n##*Qa_Gb5kW1w?}V{PW?zI*$6|(*d!hRkZC$bv?Dh+Rb=pje>P2Pw!ko
zGXDu|h1nS5x_g*qc>dFVoXVk<^nt1L_X5d_KDrJFcfCT5jnhjUSK=U1m
z5)=rG_DN1I5{okd)vz5Hk;-!5QP%It35!%R!o(Cf)#Sh4$;&q|3J#TX`8q+r9mQt&9r(o%-X`eRf5Y(pGyf?fC+08zNRE6*fkEsCdNuJwJp&19R3Fj5pr{{0Ah#rM
z1FfzRMXAaFPrCI}nQKtG?5$`p<>>ILlX~%qq%w>jZ#;tmGP}I_m5A(>CoMtNvQ-~k
z#nOITwMA7t($>f5ADbKa^=*&W?*qb`Mlz@3cmHQ(0oK}I3ik)^8F`y|A^Q>GJheua3S&dLIQU0vaV16oT4u3
zj^ietkONykZmh9i|1w6Qq=d55bJQqT$EfTA=it-#J;so9S0&xJq`UTdTJ3LOjNCRd
z(BDyoy6R`>;UOagzv?0ZYPFEcXtj?bV$TmqDS75vDO){sOc73yo!KjZ5v%~(!#p@@
z%t>p32yXqo@|!9(8Q^30z}IAS^9&a4Die+n+KDC4`F#U1*tC)VwyR
zf33EgiCrU?s<`1%hOP567lC!xfkCt`nv6s?WiWG-f|+gmGuH@YyCvTCk<1`Uu?)kp
zF-*~Ty#NYLLxTMXy?=q(7KPYK7B(2bd%zr497Z|pCCz+IRvQlt$6_pqae&uc;5-`}
zQ>2Ug&?*7aKZOWr>A+ye-x;5BPIY|fnbu8j{e=Foa{AIt{U-^re4}7*N%f@7_Q~xx
zeoAbOyM^LCyk!qE>Fe)YzkBfB;X8+?F9}sGg8hwITC}&y#T#z?ByDj_9>hC2mZ!2I
z;BM=^_B-v*93q$;o}SAiru
zmw-!$K{lKm3q-;fnQK)Cf}>F;!l0VR)Fq>rW04444MHt;WRqls3m~9Wsd5mUL*W!y
zqPC){C99~z7+ZsVTYK=zwx8%?5`7>R8kdY+K{hc;UQsNg&n%KtSr0~;so9++G^l05
z{gXgAgii^%WF#SZbHr=;#0*TYX1U;1B{Hr=u94M&3%uBUhJZ^(ki-L5Umpfkst)pWdD>j{*t|Af(Kcb4xN_5;wtFXvp49D_qpMThlOv0@4fn?HjL($L*8ien
zcI4ruUtbWL_kX$TQOCdEEjAw$YWsj)EV`=}+)a<&O%KXHueo2d(7bQ1dEXb;9yJPY
zoedTJ5d0SlH
zB~%|mHM?y0!yS*jLfP>-+MDWmTG=?A{B-=IaeiyJu)asA1Xa9jv8-Xb>(kzkdihNq
z!rB8uStn0>7b{xQmZ~(Ip;_ucn|@wktTe&V&r&h9;ed{}uA8G9)9d%~)`mHHZMwXE
zV)qp%@z%OIx;|}nrjEVL9TDRY`=4(C?+=lPwGYb7@!&W-YYh>(
z7I+~Vt5;%)#Q+E#o~xr0SAyp?;N3mAgu%xHB$XX06eA9qEg9)u;E;a^FWjSBpJ`$Q
z>f>|KtgSiULsZ2Et9iN#ga&T*m@>`0Li*HjpgbH(rF;5gm*D}dU1=*D0#D99fHi1(
zb7adXF|g{YcI-IZZOQj49yReC;A
zmBj0VaT8ov1o_7Yuh@N7n4ijtl-}WGP$rsh^-z{l#^kV_5?-t$
z)6vH+4oD{X^^D|_$$OT#GMxO<9p7`}>t^H)9PlL+U1Yw5TPHv9G(=-pG|zWRa5P0I
z&xg5~{1~0xgN4`Ohr%%E5b~{9iN-O?&wY@VO9g5LFbjQ2a&W!@d^t@{t)#*zK5mbM
zuQBkpFvGhN%or!LwEqZIITp4}f~%EWcfKtLpCPy~qa{w%t7R#9JxX|M=>s*FU-rPmCrGJ#m%a-uT|esZqhT9!?>x-pPv}w=P(l9$TA;
ze$ycoAK)zq{@eZ|Lh;eBth32^J5cxE*tdOUO?C6dN0IA=K(*mrjmYs1EqK<>dDaS^
z#s$x&InSn=;jhx>Oj)SA~lz{
z808Q7`b!F9W9T97$P&wDj2%R806o-t$z>PW(0&7xM4mxGM3zf%^j}Il=Z(-+vcck-%^ezYc}ZMvk1r`+j=W}d2hVS;}`
z@S+BulfS5fXTvYb;KAgJR(KQi!UNA2Uev)2>K8RNy1Ew!bZ~6{q6E$@U-awHy!XNb
T7LOMdpgsN0uLFibhV%abX6^{U
literal 0
HcmV?d00001
diff --git a/public/py/api.py b/public/py/api.py
index aa6b483..d770afd 100644
--- a/public/py/api.py
+++ b/public/py/api.py
@@ -13,6 +13,7 @@ if SCRIPT_DIR not in sys.path:
import astronomy
import astronomical_conversions
+import comets
MOON_RADIUS_KM = 1737.4
SYNODIC_MONTH = 29.530588853
@@ -252,6 +253,31 @@ def action_astronomical_conversions(args: list[str]) -> dict:
fail(str(exc), extra={"kind": kind})
+def action_comet_brightnesses(args: list[str]) -> dict:
+ if len(args) != 1:
+ fail(
+ "Aktion comet_brightnesses erwartet 1 Argument: payloadBase64 oder @payloadDatei",
+ extra={"argv": args},
+ )
+
+ try:
+ payload_arg = args[0]
+ if payload_arg.startswith("@"):
+ payload_path = payload_arg[1:]
+ with open(payload_path, "r", encoding="utf-8") as handle:
+ payload_text = handle.read()
+ else:
+ payload_text = base64.urlsafe_b64decode(payload_arg.encode("ascii")).decode("utf-8")
+ payload = json.loads(payload_text)
+ except Exception as exc:
+ fail("Payload fuer comet_brightnesses ist ungueltig.", extra={"details": str(exc)})
+
+ try:
+ return comets.handle_request(payload)
+ except ValueError as exc:
+ fail(str(exc), extra={"action": "comet_brightnesses"})
+
+
def search_event(
body: astronomy.Body,
direction: astronomy.Direction,
@@ -4173,7 +4199,7 @@ def action_satellite_passes(args: list[str]) -> dict:
def main() -> None:
if len(sys.argv) < 2:
- fail("Es wurde keine Aktion uebergeben.", extra={"available_actions": ["sun_moon_rise_set", "solar_longitude_to_datetime", "current_solar_longitude", "moon_star_occultations", "moon_phase_details", "satellite_passes", "planet_rise_set", "month_sky_context", "moon_phases_for_month", "golden_handle_for_month", "season_changes_for_month", "time_changes_for_month", "moon_planet_approaches", "moon_deep_sky_approaches_for_month", "planet_bright_star_approaches_for_month", "planet_conjunctions_for_month", "eclipses_for_month", "moon_apsides_for_month", "sun_apsides_for_month", "inner_planet_elongations_for_month", "venus_peak_magnitude_for_month", "outer_planet_events_for_month", "outer_planet_stations_for_month", "jupiter_moons_one_side_for_month", "mercury_good_visibility_for_month", "planet_parades_for_month", "planet_constellation_changes_for_month", "moon_star_occultations_for_month"]})
+ fail("Es wurde keine Aktion uebergeben.", extra={"available_actions": ["sun_moon_rise_set", "solar_longitude_to_datetime", "current_solar_longitude", "astronomical_conversions", "comet_brightnesses", "moon_star_occultations", "moon_phase_details", "satellite_passes", "planet_rise_set", "month_sky_context", "moon_phases_for_month", "golden_handle_for_month", "season_changes_for_month", "time_changes_for_month", "moon_planet_approaches", "moon_deep_sky_approaches_for_month", "planet_bright_star_approaches_for_month", "planet_conjunctions_for_month", "eclipses_for_month", "moon_apsides_for_month", "sun_apsides_for_month", "inner_planet_elongations_for_month", "venus_peak_magnitude_for_month", "outer_planet_events_for_month", "outer_planet_stations_for_month", "jupiter_moons_one_side_for_month", "mercury_good_visibility_for_month", "planet_parades_for_month", "planet_constellation_changes_for_month", "moon_star_occultations_for_month"]})
action = sys.argv[1]
args = sys.argv[2:]
@@ -4198,6 +4224,11 @@ def main() -> None:
print(json.dumps(result, ensure_ascii=True))
return
+ if action == "comet_brightnesses":
+ result = action_comet_brightnesses(args)
+ print(json.dumps(result, ensure_ascii=True))
+ return
+
if action == "moon_star_occultations":
result = action_moon_star_occultations(args)
print(json.dumps(result, ensure_ascii=True))
@@ -4323,7 +4354,7 @@ def main() -> None:
print(json.dumps(result, ensure_ascii=True))
return
- fail("Unbekannte Aktion.", extra={"action": action, "available_actions": ["sun_moon_rise_set", "solar_longitude_to_datetime", "current_solar_longitude", "astronomical_conversions", "moon_star_occultations", "moon_phase_details", "satellite_passes", "planet_rise_set", "month_sky_context", "moon_phases_for_month", "golden_handle_for_month", "season_changes_for_month", "time_changes_for_month", "moon_planet_approaches", "moon_deep_sky_approaches_for_month", "planet_bright_star_approaches_for_month", "planet_conjunctions_for_month", "eclipses_for_month", "moon_apsides_for_month", "sun_apsides_for_month", "inner_planet_elongations_for_month", "venus_peak_magnitude_for_month", "outer_planet_events_for_month", "outer_planet_stations_for_month", "jupiter_moons_one_side_for_month", "mercury_good_visibility_for_month", "planet_parades_for_month", "planet_constellation_changes_for_month", "moon_star_occultations_for_month"]})
+ fail("Unbekannte Aktion.", extra={"action": action, "available_actions": ["sun_moon_rise_set", "solar_longitude_to_datetime", "current_solar_longitude", "astronomical_conversions", "comet_brightnesses", "moon_star_occultations", "moon_phase_details", "satellite_passes", "planet_rise_set", "month_sky_context", "moon_phases_for_month", "golden_handle_for_month", "season_changes_for_month", "time_changes_for_month", "moon_planet_approaches", "moon_deep_sky_approaches_for_month", "planet_bright_star_approaches_for_month", "planet_conjunctions_for_month", "eclipses_for_month", "moon_apsides_for_month", "sun_apsides_for_month", "inner_planet_elongations_for_month", "venus_peak_magnitude_for_month", "outer_planet_events_for_month", "outer_planet_stations_for_month", "jupiter_moons_one_side_for_month", "mercury_good_visibility_for_month", "planet_parades_for_month", "planet_constellation_changes_for_month", "moon_star_occultations_for_month"]})
if __name__ == "__main__":
diff --git a/public/py/comets.py b/public/py/comets.py
new file mode 100644
index 0000000..a71f681
--- /dev/null
+++ b/public/py/comets.py
@@ -0,0 +1,292 @@
+#!/usr/bin/env python3
+import math
+from datetime import datetime, timedelta, timezone
+
+import astronomy
+
+GAUSSIAN_GRAVITATIONAL_CONSTANT = 0.01720209895
+J2000_OBLIQUITY_DEG = 23.439279444444445
+PARABOLIC_ECCENTRICITY_TOLERANCE = 1.0e-6
+
+
+def parse_float(value) -> float | None:
+ if value is None:
+ return None
+
+ text = str(value).strip()
+ if text == "":
+ return None
+
+ text = text.replace(",", ".")
+ try:
+ return float(text)
+ except ValueError:
+ return None
+
+
+def parse_int(value) -> int | None:
+ if value is None:
+ return None
+
+ text = str(value).strip()
+ if text == "":
+ return None
+
+ try:
+ return int(text)
+ except ValueError:
+ return None
+
+
+def dt_to_time(dt_utc: datetime) -> astronomy.Time:
+ dt_utc = dt_utc.astimezone(timezone.utc)
+ return astronomy.Time.Make(
+ dt_utc.year,
+ dt_utc.month,
+ dt_utc.day,
+ dt_utc.hour,
+ dt_utc.minute,
+ dt_utc.second + (dt_utc.microsecond / 1_000_000.0),
+ )
+
+
+def build_perihelion_datetime(comet: dict) -> datetime | None:
+ year = parse_int(comet.get("year_of_perihelion"))
+ month = parse_int(comet.get("month_of_perihelion"))
+ day_value = parse_float(comet.get("day_of_perihelion"))
+
+ if year is None or month is None or day_value is None:
+ return None
+
+ if month < 1 or month > 12 or day_value <= 0.0:
+ return None
+
+ day = int(math.floor(day_value))
+ if day < 1 or day > 31:
+ return None
+
+ fractional_day = day_value - day
+ seconds = int(round(fractional_day * 86400.0))
+
+ try:
+ perihelion_dt = datetime(year, month, day, 0, 0, 0, tzinfo=timezone.utc)
+ except ValueError:
+ return None
+
+ return perihelion_dt + timedelta(seconds=seconds)
+
+
+def solve_elliptic_anomaly(mean_anomaly: float, eccentricity: float) -> float:
+ anomaly = mean_anomaly if eccentricity < 0.8 else (math.pi if mean_anomaly >= 0.0 else -math.pi)
+ for _ in range(30):
+ delta = (anomaly - eccentricity * math.sin(anomaly) - mean_anomaly) / (1.0 - eccentricity * math.cos(anomaly))
+ anomaly -= delta
+ if abs(delta) < 1.0e-12:
+ break
+ return anomaly
+
+
+def solve_hyperbolic_anomaly(mean_anomaly: float, eccentricity: float) -> float:
+ if mean_anomaly == 0.0:
+ anomaly = 0.0
+ else:
+ anomaly = math.asinh(mean_anomaly / eccentricity)
+
+ for _ in range(40):
+ sinh_value = math.sinh(anomaly)
+ cosh_value = math.cosh(anomaly)
+ delta = (eccentricity * sinh_value - anomaly - mean_anomaly) / (eccentricity * cosh_value - 1.0)
+ anomaly -= delta
+ if abs(delta) < 1.0e-12:
+ break
+ return anomaly
+
+
+def solve_parabolic_parameter(delta_days: float, perihelion_distance_au: float) -> float:
+ scale = GAUSSIAN_GRAVITATIONAL_CONSTANT * delta_days / math.sqrt(2.0 * perihelion_distance_au**3)
+ parameter = scale
+
+ for _ in range(40):
+ numerator = parameter + (parameter**3) / 3.0 - scale
+ denominator = 1.0 + parameter**2
+ delta = numerator / denominator
+ parameter -= delta
+ if abs(delta) < 1.0e-12:
+ break
+
+ return parameter
+
+
+def true_anomaly_and_radius(delta_days: float, perihelion_distance_au: float, eccentricity: float) -> tuple[float, float]:
+ if perihelion_distance_au <= 0.0:
+ raise ValueError("Periheldistanz muss positiv sein.")
+
+ if eccentricity < 1.0 - PARABOLIC_ECCENTRICITY_TOLERANCE:
+ semi_major_axis = perihelion_distance_au / (1.0 - eccentricity)
+ mean_motion = GAUSSIAN_GRAVITATIONAL_CONSTANT / (semi_major_axis ** 1.5)
+ mean_anomaly = math.fmod(mean_motion * delta_days, 2.0 * math.pi)
+ eccentric_anomaly = solve_elliptic_anomaly(mean_anomaly, eccentricity)
+ radius = semi_major_axis * (1.0 - eccentricity * math.cos(eccentric_anomaly))
+ true_anomaly = 2.0 * math.atan2(
+ math.sqrt(1.0 + eccentricity) * math.sin(eccentric_anomaly / 2.0),
+ math.sqrt(1.0 - eccentricity) * math.cos(eccentric_anomaly / 2.0),
+ )
+ return true_anomaly, radius
+
+ if eccentricity > 1.0 + PARABOLIC_ECCENTRICITY_TOLERANCE:
+ semi_major_axis_abs = perihelion_distance_au / (eccentricity - 1.0)
+ mean_anomaly = GAUSSIAN_GRAVITATIONAL_CONSTANT * delta_days / (semi_major_axis_abs ** 1.5)
+ hyperbolic_anomaly = solve_hyperbolic_anomaly(mean_anomaly, eccentricity)
+ radius = semi_major_axis_abs * (eccentricity * math.cosh(hyperbolic_anomaly) - 1.0)
+ true_anomaly = 2.0 * math.atan2(
+ math.sqrt(eccentricity + 1.0) * math.sinh(hyperbolic_anomaly / 2.0),
+ math.sqrt(eccentricity - 1.0) * math.cosh(hyperbolic_anomaly / 2.0),
+ )
+ return true_anomaly, radius
+
+ parabolic_parameter = solve_parabolic_parameter(delta_days, perihelion_distance_au)
+ true_anomaly = 2.0 * math.atan(parabolic_parameter)
+ radius = perihelion_distance_au * (1.0 + parabolic_parameter**2)
+ return true_anomaly, radius
+
+
+def ecliptic_to_equatorial(x_ecl: float, y_ecl: float, z_ecl: float) -> tuple[float, float, float]:
+ epsilon = math.radians(J2000_OBLIQUITY_DEG)
+ cos_epsilon = math.cos(epsilon)
+ sin_epsilon = math.sin(epsilon)
+ return (
+ x_ecl,
+ y_ecl * cos_epsilon - z_ecl * sin_epsilon,
+ y_ecl * sin_epsilon + z_ecl * cos_epsilon,
+ )
+
+
+def comet_heliocentric_vector(comet: dict, dt_utc: datetime) -> tuple[float, float, float] | None:
+ perihelion_distance_au = parse_float(comet.get("perihelion_dist_au"))
+ eccentricity = parse_float(comet.get("eccentricity"))
+ arg_perihelion_deg = parse_float(comet.get("arg_perihelion_deg"))
+ ascending_node_deg = parse_float(comet.get("ascending_node_deg"))
+ inclination_deg = parse_float(comet.get("inclination_deg"))
+ perihelion_dt = build_perihelion_datetime(comet)
+
+ if None in (
+ perihelion_distance_au,
+ eccentricity,
+ arg_perihelion_deg,
+ ascending_node_deg,
+ inclination_deg,
+ perihelion_dt,
+ ):
+ return None
+
+ delta_days = (dt_utc - perihelion_dt).total_seconds() / 86400.0
+ true_anomaly, radius = true_anomaly_and_radius(delta_days, perihelion_distance_au, eccentricity)
+
+ arg_perihelion = math.radians(arg_perihelion_deg)
+ ascending_node = math.radians(ascending_node_deg)
+ inclination = math.radians(inclination_deg)
+ argument_of_latitude = arg_perihelion + true_anomaly
+
+ cos_node = math.cos(ascending_node)
+ sin_node = math.sin(ascending_node)
+ cos_inclination = math.cos(inclination)
+ sin_inclination = math.sin(inclination)
+ cos_argument = math.cos(argument_of_latitude)
+ sin_argument = math.sin(argument_of_latitude)
+
+ x_ecl = radius * (cos_node * cos_argument - sin_node * sin_argument * cos_inclination)
+ y_ecl = radius * (sin_node * cos_argument + cos_node * sin_argument * cos_inclination)
+ z_ecl = radius * (sin_argument * sin_inclination)
+
+ return ecliptic_to_equatorial(x_ecl, y_ecl, z_ecl)
+
+
+def estimate_magnitude(absolute_magnitude_h: float | None, slope_parameter_g: float | None, heliocentric_distance_au: float, geocentric_distance_au: float) -> float | None:
+ if absolute_magnitude_h is None or slope_parameter_g is None:
+ return None
+
+ if heliocentric_distance_au <= 0.0 or geocentric_distance_au <= 0.0:
+ return None
+
+ return absolute_magnitude_h + (5.0 * math.log10(geocentric_distance_au)) + (2.5 * slope_parameter_g * math.log10(heliocentric_distance_au))
+
+
+def equatorial_coordinates_from_vector(x: float, y: float, z: float) -> tuple[float, float]:
+ distance = math.sqrt(x**2 + y**2 + z**2)
+ if distance <= 0.0:
+ raise ValueError("Geozentrischer Vektor ist null.")
+
+ ra_hours = math.degrees(math.atan2(y, x)) / 15.0
+ if ra_hours < 0.0:
+ ra_hours += 24.0
+
+ dec_deg = math.degrees(math.asin(z / distance))
+ return ra_hours, dec_deg
+
+
+def calculate_brightness(comet: dict, dt_utc: datetime) -> dict:
+ comet_id = parse_int(comet.get("id"))
+ result = {
+ "id": comet_id,
+ "heliocentric_distance_au": None,
+ "geocentric_distance_au": None,
+ "ra_hours": None,
+ "dec_deg": None,
+ "estimated_magnitude": None,
+ "model": "stellarium_like",
+ }
+
+ heliocentric_position = comet_heliocentric_vector(comet, dt_utc)
+ if heliocentric_position is None:
+ result["error"] = "Bahnelemente unvollstaendig."
+ return result
+
+ comet_x, comet_y, comet_z = heliocentric_position
+ heliocentric_distance_au = math.sqrt(comet_x**2 + comet_y**2 + comet_z**2)
+ earth_vector = astronomy.HelioVector(astronomy.Body.Earth, dt_to_time(dt_utc))
+ geo_x = comet_x - earth_vector.x
+ geo_y = comet_y - earth_vector.y
+ geo_z = comet_z - earth_vector.z
+ geocentric_distance_au = math.sqrt(geo_x**2 + geo_y**2 + geo_z**2)
+ ra_hours, dec_deg = equatorial_coordinates_from_vector(geo_x, geo_y, geo_z)
+
+ absolute_magnitude_h = parse_float(comet.get("absolute_magnitude_h"))
+ slope_parameter_g = parse_float(comet.get("slope_parameter_g"))
+ estimated_magnitude = estimate_magnitude(
+ absolute_magnitude_h,
+ slope_parameter_g,
+ heliocentric_distance_au,
+ geocentric_distance_au,
+ )
+
+ result["heliocentric_distance_au"] = heliocentric_distance_au
+ result["geocentric_distance_au"] = geocentric_distance_au
+ result["ra_hours"] = ra_hours
+ result["dec_deg"] = dec_deg
+ result["estimated_magnitude"] = estimated_magnitude
+ return result
+
+
+def handle_request(payload: dict) -> dict:
+ date_text = str(payload.get("date") or "").strip()
+ if date_text == "":
+ dt_utc = datetime.now(timezone.utc)
+ else:
+ normalized = date_text.replace("Z", "+00:00")
+ dt_utc = datetime.fromisoformat(normalized)
+ if dt_utc.tzinfo is None:
+ dt_utc = dt_utc.replace(tzinfo=timezone.utc)
+ else:
+ dt_utc = dt_utc.astimezone(timezone.utc)
+
+ comet_items = payload.get("comets")
+ if not isinstance(comet_items, list):
+ raise ValueError("Kometenliste fehlt oder ist ungueltig.")
+
+ results = [calculate_brightness(comet, dt_utc) for comet in comet_items]
+ return {
+ "ok": True,
+ "action": "comet_brightnesses",
+ "date_utc": dt_utc.isoformat().replace("+00:00", "Z"),
+ "results": results,
+ }