Merge branch 'main' of https://git.daheim.eskimue.de/Eskimue/skyview.astronomiemuseum.de
This commit is contained in:
@@ -84,6 +84,10 @@
|
||||
"label": "Sternenhimmel",
|
||||
"menu_group": "Himmel & Planeten"
|
||||
},
|
||||
"beobachtungsvorschlaege.php": {
|
||||
"label": "Beobachtungsvorschläge",
|
||||
"menu_group": "Sternenhimmel & Karten"
|
||||
},
|
||||
"drehbare_sternkarte.php": {
|
||||
"label": "Drehbare Sternkarte",
|
||||
"menu_group": "Himmel & Planeten"
|
||||
@@ -146,6 +150,7 @@
|
||||
"jupitersystem.php",
|
||||
"tagbogen.php",
|
||||
"sternenhimmel.php",
|
||||
"beobachtungsvorschlaege.php",
|
||||
"drehbare_sternkarte.php",
|
||||
"meteorshowers.php",
|
||||
"geocron/index.php",
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
# Berechnung und Filter für Beobachtungsvorschläge
|
||||
|
||||
Stand: 15. September 2026. Die GUI ist mit einer eigenständigen Berechnung verbunden. Grundlage sind die Projektstruktur und lesende Abfragen der konfigurierten Datenbank.
|
||||
|
||||
## Aufbau
|
||||
|
||||
- `public/beobachtungsvorschlaege.php`: Formular, Standardstandort und Sitzungstoken.
|
||||
- `public/js/observation_suggestions.js`: Anfrage und Ergebnisliste mit 50 Zielen pro Seite.
|
||||
- `public/ajax/observation_suggestions.php`: POST-Endpunkt mit Anmeldung, derselben Seitenberechtigung wie die GUI, CSRF-Prüfung und erneuter serverseitiger Abfrage des Standardstandorts.
|
||||
- `public/observation_suggestions_helpers.php`: Katalogauswahl und Python-Prozess mit temporärer JSON-Datei, Shell-freiem Aufruf und 90-Sekunden-Zeitlimit.
|
||||
- `public/py/observation_suggestions_api.py`: eigenständige Berechnung mit der lokalen Astronomy-Engine-Bibliothek und dem vorhandenen Kometen-Bahnhelfer `comets.py`. Kein Import und kein Aufruf von `api.py`.
|
||||
|
||||
Der Browser darf Filter senden, aber weder fremde Standorte noch Katalogdaten vorgeben. Die Berechnung schreibt keine Daten in die Datenbank. Der Python-Prozess benötigt Python 3.10+ und eine verfügbare Zeitzonendatenbank (`zoneinfo`, auf Windows gegebenenfalls das Paket `tzdata`).
|
||||
|
||||
## Zuordnung der Checkboxen
|
||||
|
||||
| GUI-Wert | Quelle / Auswahl |
|
||||
| --- | --- |
|
||||
| `open_clusters` | `dso_objects.object_type_abrev = 'OCl'` |
|
||||
| `globular_clusters` | `GCl` |
|
||||
| `galaxies` | `G`, `GPair`, `GTrpl`, `GGroup` |
|
||||
| `nebulae` | `Neb`, `EmN`, `HII`, `RfN`, `SNR`, `Cl+N` |
|
||||
| `planetary_nebulae` | `PN` |
|
||||
| `double_stars` | Zunächst `dso_objects` mit `**`; kein vollständiger Doppelsternkatalog |
|
||||
| `variable_stars` | Kandidaten aus `star_hipparcos` mit nicht leerem `var`; Periodizität und Ereignisvorhersagen benötigen zusätzliche geprüfte Daten |
|
||||
| `comets` | Bahnelemente aus `comets_mpc`; Position und scheinbare Helligkeit zeitabhängig berechnen |
|
||||
| `moon`, `planets` | Bestehende astronomische Berechnungen |
|
||||
|
||||
`Cl+N` wird eindeutig der Nebel-Auswahl zugeordnet. Mehrere ausgewählte Typen werden vereinigt; ein Objekt soll nur einmal erscheinen.
|
||||
|
||||
Nur explizit zugeordnete DSO-Typen berücksichtigen. Insbesondere `Dup` (Dubletten), `NonEx` (nicht existente Objekte), `Other`, einfache Sterne (`*`) und Novae (`Nova`) nicht automatisch übernehmen. Sternassoziationen (`*Ass`) sind vorerst keiner Checkbox zugeordnet.
|
||||
|
||||
## Größe und Helligkeit
|
||||
|
||||
- Der Größenfilter gilt ausschließlich für die fünf Deep-Sky-Gruppen in der Tabelle, niemals für Einzelsterne, Doppelsterne, Veränderliche, Planeten, Mond oder Kometen.
|
||||
- Vorgabe für die maximale Deep-Sky-Größe: 500 Bogensekunden; im Formular änderbar oder durch Leeren aufhebbar.
|
||||
- Als Größenmaß dient die große Achse `major_axis`: Bogenminuten × 60 = Bogensekunden. Der Import übernimmt diesen Wert unverändert; die Einheit ist in der [OpenNGC-Katalogbeschreibung](https://github.com/mattiaverga/OpenNGC/blob/master/NGC_guide.txt) dokumentiert. Fehlende oder nicht positive Größen werden bei aktiver Größengrenze ausgelassen.
|
||||
- Für Deep Sky die visuelle Helligkeit `v_mag` verwenden. Andere Filterbänder nicht stillschweigend als V-Helligkeit einsetzen. `cstar_v_mag` ist die Helligkeit des Zentralsterns, nicht des gesamten Nebels.
|
||||
- Vorgabe: 12 mag oder heller, also numerisch `v_mag <= 12`.
|
||||
- Bei aktiver Helligkeitsgrenze Objekte ohne Helligkeitsangabe standardmäßig auslassen. Die neue, zunächst nicht ausgewählte Checkbox `include_unknown_magnitude` erlaubt ihre gesonderte Aufnahme als „Helligkeit unbekannt“. Alle anderen anwendbaren Filter gelten weiterhin.
|
||||
- Ohne Helligkeitsgrenze schränkt ein fehlender Helligkeitswert die Auswahl nicht ein.
|
||||
- Bei Sternen und Kometen die jeweils passende scheinbare Helligkeit verwenden; die absolute Kometenhelligkeit ist kein direkter Ersatz.
|
||||
|
||||
## Beobachtungsnacht
|
||||
|
||||
- Standardstandort des Benutzers aus dem gemeinsamen Header verwenden.
|
||||
- Datum bezeichnet den Abend bis zum folgenden Morgen; Vorgabe ist heute in der Standortzeitzone.
|
||||
- Sonnenhöhe höchstens −12° als Vorgabe. Erste und zweite Hälfte des passenden nächtlichen Zeitfensters sind separat auswählbar, zunächst beide.
|
||||
- Höhe, Azimut, Sonnenhöhe und optionalen Mondabstand für den jeweiligen Zeitpunkt berechnen. Die Auswahl der Nachtabschnitte und die Bedingungen müssen gleichzeitig erfüllt sein.
|
||||
|
||||
## Verfahren und Genauigkeit
|
||||
|
||||
1. Suche zwischen 12 Uhr am gewählten Datum und 12 Uhr am Folgetag in Standortzeit nach Unterschreitungen der Sonnenhöhen-Grenze. Sonnenhöhe: topozentrischer Sonnenmittelpunkt, ohne atmosphärische Refraktion. Übergänge werden im Fünf-Minuten-Raster gesucht und per Intervallhalbierung auf etwa eine Sekunde eingegrenzt.
|
||||
2. Teile jeden gefundenen Dunkelabschnitt in zwei gleich lange Hälften. Zeitrechnung erfolgt intern in UTC; dadurch werden Sommerzeitwechsel berücksichtigt. Bei durchgängiger Dunkelheit wird die Begrenzung auf 12 Uhr bis 12 Uhr ausdrücklich angezeigt; bei fehlender Dunkelphase gibt es eine entsprechende Meldung.
|
||||
3. Prüfe die ausgewählten Abschnitte alle fünf Minuten einschließlich der Randpunkte. DSO- und Sternkoordinaten werden vom J2000-System in das horizontale System des Beobachters gedreht, einschließlich Präzession/Nutation. Sterneigenbewegung und jährliche Aberration der Katalogsterne sind in dieser Planungsversion nicht enthalten. Höhen sind geometrisch, ohne Refraktion; Azimut zählt von Nord über Ost.
|
||||
4. Für Mond und Planeten liefert Astronomy Engine topozentrische Positionen mit Lichtlaufzeit und Aberration sowie modellierte Helligkeiten. Kometen verwenden den vorhandenen Zweikörper-Bahnhelfer, mit iterierter Lichtlaufzeit und Beobachterparallaxe; Bahnstörungen sind nicht enthalten. Bahnepoche und Modellcharakter werden angezeigt.
|
||||
5. Prüfe alle Filter gemeinsam an jedem Zeitpunkt. Der Mondabstand greift nur bei Mond über dem geometrischen Horizont und niemals für den Mond selbst. Zusammenhängende passende Messpunkte bilden ungefähre Zeitfenster. Sehr kurze Fenster oder kurze Unterbrechungen zwischen Messpunkten können unentdeckt bleiben.
|
||||
6. Die Spalte „Zeit der größten Höhe (ca.)“ zeigt den passenden Messpunkt mit der größten Höhe innerhalb der ausgewählten Filter und Nachtabschnitte. Das muss nicht die Kulmination der gesamten Nacht sein. Höhe, Azimut und Helligkeit der Ergebniszeile beziehen sich auf diesen Zeitpunkt.
|
||||
7. Die GUI sortiert zunächst nach Höhe absteigend. Alle acht Spalten sind per Überschrift auf- und absteigend sortierbar, jeweils über die komplette Ergebnisliste vor der Paginierung. Höhe und Azimut sind getrennte Spalten. Zeiten werden nach ihrem tatsächlichen Zeitpunkt, Zahlen numerisch und Objektnamen mit natürlicher Zahlensortierung verglichen. Zeitfenster werden nach ihrem ersten Beginn sortiert. Fehlende Werte der gewählten Sortierspalte bleiben in beiden Richtungen am Ende. Ein Sortierwechsel springt zur ersten Seite; eine neue Berechnung behält die Sortierauswahl bei.
|
||||
|
||||
Sichtbarkeit bedeutet hier Erfüllung der gewählten geometrischen und Helligkeitsfilter, keine Garantie für visuelle Erkennbarkeit. Wetter, Seeing, lokale Horizonthindernisse, Teleskopöffnung und Flächenhelligkeit gehen noch nicht in eine Bewertung ein. Veränderliche werden anhand der Kataloghelligkeit gefiltert; aktuelle Helligkeiten und Extremzeiten werden nicht behauptet.
|
||||
|
||||
## Prüfung
|
||||
|
||||
Ergebnisbegrenzung: Das Feld „Maximal pro Objektgruppe“ ist mit 30 vorbelegt. Der Button „Hellste je Gruppe anzeigen“ wählt aus sämtlichen berechneten Treffern je Checkbox-Gruppe maximal X Objekte nach aufsteigender mag-Helligkeit aus. Bei gleicher Helligkeit entscheidet die größere Höhe, danach Name und Kennung. Unbekannte Helligkeiten werden in dieser Auswahl ausgelassen. Anschließend greifen die gewählten Sortierkriterien und die Paginierung. Die Begrenzung bleibt bei erneuter Berechnung aktiv; „Alle Treffer anzeigen“ hebt sie ohne Neuberechnung auf. Beim ersten Laden ist die Begrenzung noch nicht angewendet.
|
||||
|
||||
Mehrfachsortierung in der GUI: Ein normaler Überschriftenklick ersetzt die Sortierkriterien. Umschalt + Klick oder der aktivierte Modus „Weitere Sortierkriterien hinzufügen“ ergänzt ein Kriterium bzw. kehrt die Richtung eines vorhandenen Kriteriums um. Nummerierte Pfeile und ein lesbarer Statustext zeigen die Priorität. Folge-Kriterien entscheiden bei gleichen Werten der vorherigen Kriterien; fehlende Werte folgen innerhalb der jeweiligen Gruppe am Ende. Die Kriterien bleiben bei einer neuen Berechnung erhalten.
|
||||
|
||||
`python -m unittest discover -s public/py -p test_observation_suggestions_api.py`
|
||||
|
||||
Die Tests prüfen unter anderem Azimut über Norden, Größenausnahmen, Helligkeitsrichtung, Mondabstand, Nachthälften, Sommerzeitwechsel, Polartag/-nacht und die Koordinatentransformation gegen den separaten Horizont-Aufruf der Bibliothek. Zusätzlich wurde ein vollständiger Browserdurchlauf mit echten Katalogdaten, Paginierung und schmalem Bildschirm geprüft; API-Prüfungen deckten fehlende Anmeldung, ungültigen CSRF-Token und unzulässige Filterwerte ab.
|
||||
|
||||
## Datenlücken
|
||||
|
||||
Bei der Prüfung hatten nur 1.379 von 10.489 einzelnen Galaxien und 9 von 245 DSO-Doppelsternen eine V-Helligkeit. Daher die Option für unbekannte Helligkeiten.
|
||||
|
||||
In `star_hipparcos` waren 5.990 Sterne mit nicht leerem `var` vorhanden. Das allein belegt weder eine stabile Periode noch ein bevorstehendes Minimum oder Maximum. Dazu siehe [Datenquellen für veränderliche Sterne](DATENQUELLEN_VERAENDERLICHE_STERNE.md).
|
||||
@@ -0,0 +1,98 @@
|
||||
# Datenquellen für Minima und Maxima veränderlicher Sterne
|
||||
|
||||
Recherchestand: 15. September 2026. Überlegungen für die Seite „Beobachtungsvorschläge“; noch keine Implementierung oder Datenübernahme.
|
||||
|
||||
## Empfehlung
|
||||
|
||||
Für einen ersten, überschaubaren Sternbestand würde ich **BAV-Vorhersagen als Ausgangspunkt**, **AAVSO VSX für Stammdaten und Referenzen** und **VarAstro beziehungsweise AAVSO-Beobachtungen zur Überprüfung** verwenden. Eine einzige Quelle sollte nicht ungeprüft alle Sterntypen abdecken.
|
||||
|
||||
Dabei drei unterschiedliche Datenarten getrennt behandeln:
|
||||
|
||||
- **Vorhersagen:** bereits berechnete zukünftige Minima oder Maxima.
|
||||
- **Ephemeridenelemente:** Referenzzeitpunkt und Periode, aus denen wir Ereignisse berechnen können.
|
||||
- **Beobachtungen:** tatsächlich gemessene Helligkeiten oder Extremzeiten; damit lassen sich Vorhersagen prüfen und aktualisieren.
|
||||
|
||||
## 1. BAV – fertige Jahresvorhersagen
|
||||
|
||||
Die Bundesdeutsche Arbeitsgemeinschaft für Veränderliche Sterne veröffentlicht das BAV Circular. Heft 1 beschreibt Programme und Anwendung; Heft 2 enthält Ephemeriden unter anderem für Bedeckungsveränderliche, kurzperiodische Pulsatoren und Mirasterne. Auf der Übersichtsseite ist die Ausgabe 2026 verlinkt.
|
||||
|
||||
Links:
|
||||
|
||||
- [BAV Circular mit Jahresausgaben](https://www.bav-astro.de/index.php/veroeffentlichungen/bav-circular)
|
||||
- [BAV: Vorhersagen](https://www.bav-astro.eu/index.php/materialien-beobachter/134-vorhersagen)
|
||||
- [BAV Circular 2026, Heft 1: Erläuterungen (PDF)](https://www.bav-astro.eu/images/BAV-Veroeffentlichungen/BAV%20Circular/BAVC_2026_Heft_1.pdf)
|
||||
|
||||
**Einschätzung:** Besonders geeignet für den fachlichen Einstieg und als Vergleich für eigene Berechnungen. Die konkrete Abdeckung von Algol und Mira muss im jeweiligen Heft geprüft werden; nicht für jeden Katalogstern gibt es eine Vorhersage.
|
||||
|
||||
**Zu beachten:** Laut Heft 1 sind die Vorhersagen in Heft 2 in **MEZ** angegeben, auch während der Sommerzeit. Zur Anzeige als MESZ muss dann eine Stunde hinzukommen. Die Übersicht nennt auch eine Korrektur zu einer früheren Ausgabe: immer Jahr und Berichtigungen beachten. PDF-Zugriff ist belegt; eine dokumentierte maschinenlesbare Schnittstelle wurde hier nicht verifiziert.
|
||||
|
||||
## 2. AAVSO VSX – Sternkatalog und Ephemeridenelemente
|
||||
|
||||
Der International Variable Star Index liefert unter anderem Veränderlichentyp, Helligkeitsbereich und Periode sowie Literaturhinweise. Bei geeigneten Einträgen steht eine Epoche als Referenz für die Phase zur Verfügung.
|
||||
|
||||
Links:
|
||||
|
||||
- [VSX-Suche](https://vsx.aavso.org/index.php?ql=1&view=search.top)
|
||||
- [VSX-Handbuch (PDF)](https://vsx.aavso.org/_images/Manual.pdf)
|
||||
- [VSX: Fragen und Erläuterungen](https://vsx.aavso.org/index.php?view=about.faq)
|
||||
|
||||
**Einschätzung:** Geeignet als Grundlage einer kleinen lokalen Sternliste. Suchnamen wären beispielsweise `bet Per` / Algol und `omi Cet` / Mira; Identität und Komponenten müssen am Eintrag geprüft werden.
|
||||
|
||||
**Zu beachten:** Die Bedeutung der Epoche hängt vom Sterntyp ab: bei Bedeckungsveränderlichen ein Minimum, bei den meisten Pulsatoren ein Maximum; es gibt Ausnahmen. Die Spalten für maximale und minimale **Helligkeit** sind keine Ereignistermine. Das Handbuch verwendet heliocentrische julianische Tage (HJD) und Perioden in Tagen. Aktualität und Herkunft der Elemente pro Stern prüfen. Eine aktuelle API mit garantierten Feldern und Abrufgrenzen wurde nicht verifiziert.
|
||||
|
||||
## 3. VarAstro / tschechische Fachsektion – Bedeckungsminima
|
||||
|
||||
VarAstro wird von der Sektion für veränderliche Sterne und Exoplaneten der Tschechischen Astronomischen Gesellschaft betrieben. Das Portal führt die früheren Angebote unter var.astro.cz / var2.astro.cz und das O–C-Gateway fort. Die Fachsektion verweist für Bedeckungsveränderliche ausdrücklich auf Minima-Vorhersagen im Portal.
|
||||
|
||||
Links:
|
||||
|
||||
- [VarAstro, englischer Einstieg](https://var3.astro.cz/en)
|
||||
- [VarAstro, Hauptportal](https://var.astro.cz/cs/Home/)
|
||||
- [Fachsektion: Beobachtungsprogramme und Minima-Vorhersagen](https://sphe.astro.cz/en/observation-programs/)
|
||||
|
||||
**Einschätzung:** Gute ergänzende Quelle für Algol-artige Sterne: Vorhersagen und gemessene Minima ermöglichen einen Vergleich. Ein O–C-Diagramm zeigt die Differenz zwischen beobachtetem und berechnetem Ereigniszeitpunkt und kann veraltete Elemente sichtbar machen.
|
||||
|
||||
**Zu beachten:** Laut Portal stehen die meisten Funktionen erst nach Anmeldung zur Verfügung. Sternabdeckung, Export und automatisierter Abruf wurden nicht geprüft. Alte BRNO-Links sollten nicht ungeprüft als zukünftige Schnittstelle eingeplant werden.
|
||||
|
||||
## 4. AAVSO-Beobachtungen – besonders für Mira-Sterne
|
||||
|
||||
Die AAVSO International Database enthält Helligkeitsmessungen, die sich über die Datensuche herunterladen lassen. Das ist eine Grundlage, um den aktuellen Verlauf einer Lichtkurve zu beurteilen; sie liefert nicht automatisch fertige Zukunftstermine.
|
||||
|
||||
Links:
|
||||
|
||||
- [AAVSO: Daten suchen und herunterladen, mit Nutzungshinweisen](https://aavso.org/search-and-download-data/)
|
||||
- [AAVSO: Sammlung zu gemessenen LPV-Maxima und -Minima](https://www.aavso.org/index.php/category/tags/maxmin)
|
||||
- [Fachartikel: Maxima und O–C-Diagramme für 489 Mirasterne (2013, PDF)](https://www.aavso.org/sites/default/files/jaavso/v41n2/348.pdf)
|
||||
|
||||
**Einschätzung:** Für Mira-Vorhersagen sind jüngere Beobachtungen ein wertvoller Abgleich. Historische Extremzeiten helfen, Schwankungen zwischen den Zyklen einzuschätzen.
|
||||
|
||||
**Zu beachten:** Bei der Maxima-/Minima-Sammlung unterscheidet AAVSO veröffentlichte Daten bis 2008 und vorläufige spätere Daten. Die tatsächliche zeitliche Abdeckung ist je Stern zu prüfen. Einen laufenden **LPV-Vorhersagedienst für 2026** konnte diese Recherche nicht bestätigen. Der ältere [AAVSO-Bulletin-Link](https://www.aavso.org/aavso-bulletin) ließ sich beim Abruf nicht verifizieren; deshalb vorerst keine zentrale Abhängigkeit darauf aufbauen.
|
||||
|
||||
## Wie daraus später Beobachtungsvorschläge werden könnten
|
||||
|
||||
Die folgenden Punkte sind ein eigener Umsetzungsvorschlag, keine bereits vorhandene Funktion.
|
||||
|
||||
### Algol und andere Bedeckungsveränderliche
|
||||
|
||||
Mit einem passenden Referenzminimum `T0` und einer Periode `P` ergibt ein lineares Modell die folgenden gleichartigen Minima als `T(E) = T0 + E × P`, wobei `E` eine ganze Zykluszahl ist. Das reicht nur, solange die Elemente im betrachteten Zeitraum brauchbar sind; jüngere Minima und O–C-Verläufe dienen als Kontrolle. Fachlicher Hintergrund: [SPHE-Beobachtungsprogramme](https://sphe.astro.cz/en/observation-programs/).
|
||||
|
||||
Haupt- und Nebenminimum getrennt führen. Andere Extremphasen nicht pauschal durch eine halbe Periode ergänzen: Dafür muss die Lichtkurve beziehungsweise das Modell des Sterns bekannt sein. Für den Einstieg würde ich bei Algol zunächst nur geprüfte Hauptminima vorschlagen.
|
||||
|
||||
### Mira und andere langperiodische Pulsatoren
|
||||
|
||||
Das VSX-Handbuch weist ausdrücklich auf Zyklusschwankungen bei Miras hin. Eine mittlere Periode ist daher keine Garantie für ein exakt eintretendes Maximum. Quelle: [VSX-Handbuch](https://vsx.aavso.org/_images/Manual.pdf).
|
||||
|
||||
Ich würde ein **ungefähres Zeitfenster** um ein erwartetes Maximum ausgeben, möglichst anhand jüngerer Beobachtungen aktualisiert. Breite und Qualität des Fensters müssen aus den konkreten Daten folgen; keine allgemeine Genauigkeit behaupten. Ein Minimum sollte nur erscheinen, wenn dafür eigene belastbare Angaben vorliegen.
|
||||
|
||||
### Gemeinsame Anforderungen
|
||||
|
||||
- Pro Stern: eindeutige Kennung, Koordinaten, Typ, Helligkeitsbereich mit Filterband und Quellenlink.
|
||||
- Pro Ephemeride: Referenzzeit, Periode, Ereignistyp, Zeitstandard, Veröffentlichungs-/Aktualisierungsstand und gegebenenfalls Unsicherheit.
|
||||
- Pro Ereignis: „beobachtet“ oder „vorhergesagt“, Herkunft und Genauigkeit kenntlich machen.
|
||||
- HJD/BJD und die zugehörige Zeitskala nicht ungeprüft als UTC interpretieren; die Zeitumrechnung vor einer minutengenauen Anzeige fachlich festlegen.
|
||||
- Anschließend für den Benutzerstandort prüfen: Stern über dem Horizont, ausreichende Höhe und dunkler Himmel. Ein Ereignistermin allein bedeutet noch keine Beobachtbarkeit.
|
||||
- Vor einer Datenanbindung die tatsächlichen Exportmöglichkeiten, Nutzungsbedingungen und Aktualisierungsintervalle der ausgewählten Quellen prüfen.
|
||||
|
||||
## Sinnvoller nächster Schritt
|
||||
|
||||
Zunächst exemplarisch **Algol und Mira** anhand der aktuellen Einträge und Jahresvorhersagen prüfen: Welche Ereignisse sind vorhanden, wie frisch sind die Grundlagen und stimmen unabhängige Quellen überein? Erst danach eine kleine Auswahl weiterer Sterne aufnehmen und das Importformat entscheiden. Damit bleibt die spätere Umsetzung fachlich prüfbar und überschaubar.
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
session_start();
|
||||
require_once __DIR__ . '/../auth_helpers.php';
|
||||
require_once __DIR__ . '/../observation_suggestions_helpers.php';
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('Cache-Control: no-store');
|
||||
|
||||
function observationReply(array $data, int $status = 200): never
|
||||
{
|
||||
http_response_code($status);
|
||||
echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
header('Allow: POST');
|
||||
observationReply(['ok' => false, 'error' => 'Bitte die Berechnung über das Formular starten.'], 405);
|
||||
}
|
||||
if (!isset($_SESSION['user_id'])) {
|
||||
observationReply(['ok' => false, 'error' => 'Bitte erneut anmelden.'], 401);
|
||||
}
|
||||
$token = $_POST['csrf_token'] ?? null;
|
||||
if (!is_string($token) || !isset($_SESSION['observation_csrf']) || !hash_equals($_SESSION['observation_csrf'], $token)) {
|
||||
observationReply(['ok' => false, 'error' => 'Sitzung abgelaufen. Bitte die Seite neu laden.'], 403);
|
||||
}
|
||||
|
||||
try {
|
||||
$cfg = require __DIR__ . '/../../config/database.php';
|
||||
$pdo = new PDO(sprintf('mysql:host=%s;dbname=%s;charset=%s', $cfg['host'], $cfg['dbname'], $cfg['charset'] ?? 'utf8mb4'),
|
||||
$cfg['user'], $cfg['pass'], [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]);
|
||||
$userId = (int) $_SESSION['user_id'];
|
||||
if (!currentUserIsAdminLike()) {
|
||||
$permission = $pdo->prepare('SELECT 1 FROM app_user_page_permissions WHERE user_id = ? AND page_key = ? LIMIT 1');
|
||||
$permission->execute([$userId, 'beobachtungsvorschlaege.php']);
|
||||
if (!$permission->fetchColumn()) {
|
||||
observationReply(['ok' => false, 'error' => 'Keine Berechtigung für Beobachtungsvorschläge.'], 403);
|
||||
}
|
||||
}
|
||||
$stmt = $pdo->prepare('SELECT name, latitude, longitude, elevation, timezone FROM app_user_locations
|
||||
WHERE user_id = ? AND is_default = 1 LIMIT 1');
|
||||
$stmt->execute([$userId]);
|
||||
$location = $stmt->fetch();
|
||||
if (!$location) {
|
||||
observationReply(['ok' => false, 'error' => 'Bitte zuerst einen Standardstandort in den Favoriten hinterlegen.'], 422);
|
||||
}
|
||||
$filters = [];
|
||||
foreach (['observation_date', 'object_size_min', 'object_size_max', 'object_magnitude', 'object_altitude_min',
|
||||
'object_altitude_max', 'object_azimuth_start', 'object_azimuth_end', 'sun_altitude_max', 'moon_separation_min',
|
||||
'include_unknown_magnitude'] as $key) {
|
||||
$value = $_POST[$key] ?? '';
|
||||
if (!is_string($value) || strlen($value) > 32) {
|
||||
observationReply(['ok' => false, 'error' => 'Ungültige Filterangabe.'], 422);
|
||||
}
|
||||
$filters[$key] = $value;
|
||||
}
|
||||
foreach (['object_types' => ['moon', 'planets', 'open_clusters', 'globular_clusters', 'galaxies', 'nebulae',
|
||||
'planetary_nebulae', 'double_stars', 'variable_stars', 'comets'], 'night_halves' => ['first', 'second']] as $key => $allowed) {
|
||||
$values = $_POST[$key] ?? [];
|
||||
if (!is_array($values) || !$values || count($values) > count($allowed)) {
|
||||
observationReply(['ok' => false, 'error' => 'Bitte mindestens einen Objekttyp und eine Nachthälfte auswählen.'], 422);
|
||||
}
|
||||
foreach ($values as $value) {
|
||||
if (!is_string($value) || !in_array($value, $allowed, true)) {
|
||||
observationReply(['ok' => false, 'error' => 'Ungültige Auswahl.'], 422);
|
||||
}
|
||||
}
|
||||
$filters[$key] = array_values(array_unique($values));
|
||||
}
|
||||
// No session lock while Python is working; other pages remain usable.
|
||||
session_write_close();
|
||||
set_time_limit(120);
|
||||
$payload = ['filters' => $filters, 'location' => $location, 'objects' => observationCatalog($pdo, $filters['object_types'])];
|
||||
$result = runObservationSuggestions($payload);
|
||||
observationReply($result, $result['ok'] ? 200 : 422);
|
||||
} catch (Throwable $e) {
|
||||
error_log('Observation suggestions: ' . $e->getMessage());
|
||||
observationReply(['ok' => false, 'error' => $e instanceof RuntimeException && !($e instanceof PDOException)
|
||||
? $e->getMessage() : 'Die Beobachtungsvorschläge konnten nicht berechnet werden. Bitte später erneut versuchen.'], 500);
|
||||
}
|
||||
@@ -125,6 +125,7 @@ function manageablePublicPages(): array
|
||||
'tagbogen.php' => 'Tagbogen',
|
||||
'daemmerungszeiten.php' => 'Dämmerungszeiten',
|
||||
'sternenhimmel.php' => 'Sternenhimmel',
|
||||
'beobachtungsvorschlaege.php' => 'Beobachtungsvorschläge',
|
||||
'drehbare_sternkarte.php' => 'Drehbare Sternkarte',
|
||||
'meteorshowers.php' => 'Meteorströme',
|
||||
'geocron/index.php' => 'Geocron',
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
session_start();
|
||||
|
||||
$pageTitle = 'Beobachtungsvorschläge';
|
||||
$headerIntroPre = 'Beobachtung planen';
|
||||
$headerIntroTitle = 'Beobachtungsvorschläge';
|
||||
$headerIntroSub = 'Beobachtungsziele für eine Nacht am eigenen Teleskop eingrenzen.';
|
||||
|
||||
require __DIR__ . '/header.php';
|
||||
|
||||
try {
|
||||
$observationTimezone = new DateTimeZone((string) ($currentLocation['timezone'] ?? 'Europe/Berlin'));
|
||||
} catch (Exception $e) {
|
||||
$observationTimezone = new DateTimeZone('Europe/Berlin');
|
||||
}
|
||||
$observationToday = (new DateTimeImmutable('now', $observationTimezone))->format('Y-m-d');
|
||||
$_SESSION['observation_csrf'] ??= bin2hex(random_bytes(32));
|
||||
?>
|
||||
|
||||
<style>
|
||||
.observation-gui { display: grid; gap: 1.5rem; }
|
||||
.observation-gui .card { margin-bottom: 0; min-width: 0; }
|
||||
.observation-notice { padding: 0.85rem 1rem; border-left: 3px solid var(--gold); background: var(--bg-card2); border-radius: var(--radius); }
|
||||
.observation-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1.5rem; }
|
||||
.observation-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1rem; }
|
||||
.observation-gui .form-group { min-width: 0; margin-bottom: 0; }
|
||||
.observation-gui .form-group input,
|
||||
.observation-gui .form-group select { width: 100%; min-width: 0; box-sizing: border-box; color-scheme: dark; }
|
||||
.observation-gui fieldset { min-width: 0; border: 0; padding: 0; margin: 0 0 1.5rem; }
|
||||
.observation-gui fieldset:last-child { margin-bottom: 0; }
|
||||
.observation-gui legend { font-weight: 600; margin-bottom: 0.65rem; }
|
||||
.observation-help { color: var(--text-dim); font-size: 0.85rem; line-height: 1.6; margin: 0.6rem 0 0; }
|
||||
.observation-wide { grid-column: 1 / -1; }
|
||||
.observation-types { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0.75rem; }
|
||||
.observation-choice { display: flex; align-items: center; gap: 0.65rem; cursor: pointer; }
|
||||
.observation-choice input { accent-color: var(--gold); width: 1.1rem; height: 1.1rem; flex-shrink: 0; }
|
||||
.observation-gui input:focus-visible,
|
||||
.observation-gui select:focus-visible { outline: 2px solid var(--gold); outline-offset: 3px; }
|
||||
.observation-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 1rem; margin-top: 1.5rem; }
|
||||
.observation-actions button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.observation-empty { padding: 1.5rem; text-align: center; border: 1px dashed var(--border); border-radius: var(--radius); }
|
||||
.observation-table-wrap { overflow-x: auto; margin-top: 1rem; }
|
||||
.observation-results-table { width: 100%; }
|
||||
.observation-results-table td { vertical-align: top; }
|
||||
.observation-results-table small { display: block; color: var(--text-dim); max-width: 22rem; }
|
||||
.observation-results-table td:nth-child(n+3) { white-space: nowrap; }
|
||||
.observation-sort { border: 0; background: transparent; color: inherit; font: inherit; text-transform: inherit; letter-spacing: inherit; text-align: left; cursor: pointer; padding: 0.35rem 0; }
|
||||
.observation-sort:hover { color: var(--gold); }
|
||||
.observation-sort:focus-visible { outline: 2px solid var(--gold); outline-offset: 3px; }
|
||||
.observation-sort-indicator { margin-left: 0.35rem; }
|
||||
.observation-gui [hidden] { display: none; }
|
||||
@media (max-width: 760px) {
|
||||
.observation-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
@media (max-width: 440px) {
|
||||
.observation-fields, .observation-types { grid-template-columns: 1fr; }
|
||||
.observation-gui .card { padding: 1.1rem; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<form class="observation-gui" id="observation-form" action="ajax/observation_suggestions.php" method="post">
|
||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['observation_csrf'], ENT_QUOTES, 'UTF-8') ?>">
|
||||
<p class="observation-notice">Finde Ziele für deine Beobachtungsnacht. Die Sichtbarkeit wird in Schritten von fünf Minuten geprüft; Zeitfenster und beste Zeiten sind Näherungen.</p>
|
||||
|
||||
<section class="card" aria-labelledby="observation-selection-title">
|
||||
<h2 id="observation-selection-title">Beobachtungsnacht und Standort</h2>
|
||||
<div class="observation-fields">
|
||||
<div class="form-group">
|
||||
<label for="observation-date">Datum der Beobachtungsnacht</label>
|
||||
<input type="date" id="observation-date" name="observation_date" value="<?= $observationToday ?>" min="1900-01-01" max="2100-12-31" required aria-describedby="observation-time-help">
|
||||
</div>
|
||||
<fieldset aria-describedby="observation-time-help">
|
||||
<legend>Nachthälften</legend>
|
||||
<div class="observation-types">
|
||||
<label class="observation-choice"><input type="checkbox" name="night_halves[]" value="first" checked> Erste Nachthälfte</label>
|
||||
<label class="observation-choice"><input type="checkbox" name="night_halves[]" value="second" checked> Zweite Nachthälfte</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
<div class="form-group">
|
||||
<label for="observation-location">Beobachtungsstandort</label>
|
||||
<input type="text" id="observation-location" value="<?= htmlspecialchars((string) ($currentLocation['name'] ?? 'Kein Standardstandort hinterlegt'), ENT_QUOTES, 'UTF-8') ?>" readonly aria-describedby="observation-location-help">
|
||||
<p class="observation-help" id="observation-location-help">Es gilt immer dein Standardstandort aus dem Header.</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="observation-help" id="observation-time-help">Das Datum bezeichnet die Nacht vom gewählten Abend bis zum folgenden Morgen. Die eingestellte Sonnenhöhe begrenzt den Beobachtungszeitraum; die Nachthälften teilen diesen in zwei gleich lange Abschnitte. Beide auswählen für die ganze Nacht.</p>
|
||||
</section>
|
||||
|
||||
<div class="observation-grid">
|
||||
<section class="card" aria-labelledby="observation-object-title">
|
||||
<h2 id="observation-object-title">Eigenschaften der Objekte</h2>
|
||||
<fieldset>
|
||||
<legend>Scheinbare Größe – nur Deep Sky (Bogensekunden)</legend>
|
||||
<div class="observation-fields">
|
||||
<div class="form-group">
|
||||
<label for="object-size-min">Minimum (″)</label>
|
||||
<input type="number" id="object-size-min" name="object_size_min" min="0" step="any" placeholder="Keine Untergrenze" aria-describedby="object-size-help">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="object-size-max">Maximum (″)</label>
|
||||
<input type="number" id="object-size-max" name="object_size_max" min="0" step="any" value="500" placeholder="Keine Obergrenze" aria-describedby="object-size-help">
|
||||
</div>
|
||||
</div>
|
||||
<p class="observation-help" id="object-size-help">Dieser Filter gilt nur für Galaxien, Sternhaufen und Nebel einschließlich planetarischer Nebel. Verglichen wird die größte Katalogausdehnung. Bei gesetzter Größengrenze werden Deep-Sky-Objekte ohne Größenangabe ausgelassen. 60″ = 1′.</p>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>Helligkeit</legend>
|
||||
<div class="form-group">
|
||||
<label for="object-magnitude">Grenzgröße / schwächste Helligkeit (mag)</label>
|
||||
<input type="number" id="object-magnitude" name="object_magnitude" step="0.1" value="12" placeholder="z. B. 12,0" aria-describedby="object-magnitude-help">
|
||||
</div>
|
||||
<p class="observation-help" id="object-magnitude-help">12 mag bedeutet: 12 mag oder heller. Kleinere und negative mag-Werte sind heller. Leer lassen für keine Grenze. Bei Galaxien und Nebeln sagt die Gesamthelligkeit allein wenig über die Erkennbarkeit aus.</p>
|
||||
<label class="observation-choice" style="margin-top: 0.85rem;"><input type="checkbox" name="include_unknown_magnitude" value="1" aria-describedby="unknown-magnitude-help"> Auch Objekte ohne Helligkeitsangabe</label>
|
||||
<p class="observation-help" id="unknown-magnitude-help">Bei gesetzter Grenzgröße werden diese sonst ausgelassen. Wenn ausgewählt, erscheinen sie am Ende als „Helligkeit unbekannt“; die Grenzgröße lässt sich für sie nicht prüfen.</p>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>Objekttypen</legend>
|
||||
<div class="observation-types">
|
||||
<label class="observation-choice"><input type="checkbox" name="object_types[]" value="moon" checked> Mond</label>
|
||||
<label class="observation-choice"><input type="checkbox" name="object_types[]" value="planets" checked> Planeten</label>
|
||||
<label class="observation-choice"><input type="checkbox" name="object_types[]" value="open_clusters" checked> Offene Sternhaufen</label>
|
||||
<label class="observation-choice"><input type="checkbox" name="object_types[]" value="globular_clusters" checked> Kugelsternhaufen</label>
|
||||
<label class="observation-choice"><input type="checkbox" name="object_types[]" value="galaxies" checked> Galaxien & Galaxiengruppen</label>
|
||||
<label class="observation-choice"><input type="checkbox" name="object_types[]" value="nebulae" checked aria-describedby="nebulae-help"> Nebel</label>
|
||||
<label class="observation-choice"><input type="checkbox" name="object_types[]" value="planetary_nebulae" checked> Planetarische Nebel</label>
|
||||
<label class="observation-choice"><input type="checkbox" name="object_types[]" value="double_stars" checked> Doppelsterne</label>
|
||||
<label class="observation-choice"><input type="checkbox" name="object_types[]" value="variable_stars" checked> Veränderliche Sterne</label>
|
||||
<label class="observation-choice"><input type="checkbox" name="object_types[]" value="comets" checked> Kometen</label>
|
||||
</div>
|
||||
<p class="observation-help" id="nebulae-help">„Nebel“ umfasst Emissions- und Reflexionsnebel, H-II-Regionen, Supernovaüberreste und Sternhaufen mit Nebel. Planetarische Nebel sind separat auswählbar.</p>
|
||||
</fieldset>
|
||||
</section>
|
||||
|
||||
<section class="card" aria-labelledby="observation-sky-title">
|
||||
<h2 id="observation-sky-title">Himmelsbereich und Dunkelheit</h2>
|
||||
<fieldset>
|
||||
<legend>Höhe über dem Horizont</legend>
|
||||
<div class="observation-fields">
|
||||
<div class="form-group">
|
||||
<label for="object-altitude-min">Minimum (°)</label>
|
||||
<input type="number" id="object-altitude-min" name="object_altitude_min" min="0" max="90" step="1" value="20" aria-describedby="object-altitude-help">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="object-altitude-max">Maximum (°)</label>
|
||||
<input type="number" id="object-altitude-max" name="object_altitude_max" min="0" max="90" step="1" value="90" aria-describedby="object-altitude-help">
|
||||
</div>
|
||||
</div>
|
||||
<p class="observation-help" id="object-altitude-help">0° = Horizont · 90° = Zenit. Eine Obergrenze kann bei eingeschränktem Einblick oder einer azimutalen Montierung hilfreich sein.</p>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>Azimut / Himmelsrichtung</legend>
|
||||
<div class="observation-fields">
|
||||
<div class="form-group">
|
||||
<label for="object-azimuth-start">Von (°)</label>
|
||||
<input type="number" id="object-azimuth-start" name="object_azimuth_start" min="0" max="360" step="1" value="0" aria-describedby="object-azimuth-help">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="object-azimuth-end">Bis (°)</label>
|
||||
<input type="number" id="object-azimuth-end" name="object_azimuth_end" min="0" max="360" step="1" value="360" aria-describedby="object-azimuth-help">
|
||||
</div>
|
||||
</div>
|
||||
<p class="observation-help" id="object-azimuth-help">Nord 0°/360° · Ost 90° · Süd 180° · West 270°.<br>Der Bereich verläuft über Ost, Süd und West. 0° bis 360° umfasst alle Richtungen; 300° bis 60° führt über Norden.</p>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>Dunkelheit</legend>
|
||||
<div class="form-group">
|
||||
<label for="sun-altitude-max">Sonne höchstens auf Höhe (°)</label>
|
||||
<input type="number" id="sun-altitude-max" name="sun_altitude_max" min="-90" max="0" step="1" value="-12" aria-describedby="sun-altitude-help">
|
||||
</div>
|
||||
<p class="observation-help" id="sun-altitude-help">Negative Werte liegen unter dem Horizont. −6°: Ende der bürgerlichen, −12°: Ende der nautischen, −18°: Ende der astronomischen Abenddämmerung.</p>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>Mondlicht berücksichtigen (optional)</legend>
|
||||
<div class="form-group">
|
||||
<label for="moon-separation-min">Mindestabstand zum Mond (°)</label>
|
||||
<input type="number" id="moon-separation-min" name="moon_separation_min" min="0" max="180" step="1" placeholder="Keine Einschränkung" aria-describedby="moon-separation-help">
|
||||
</div>
|
||||
<p class="observation-help" id="moon-separation-help">Für andere Ziele bei Mond über dem Horizont gedacht; gilt nicht für den Mond als Beobachtungsziel.</p>
|
||||
</fieldset>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="card" aria-labelledby="observation-suggestions-title">
|
||||
<h2 id="observation-suggestions-title">Beobachtungsvorschläge</h2>
|
||||
<div class="observation-actions">
|
||||
<button type="submit" class="btn btn-primary" id="observation-submit">Vorschläge berechnen</button>
|
||||
<span class="observation-help" id="observation-status" role="status" aria-live="polite">Noch keine Vorschläge berechnet.</span>
|
||||
</div>
|
||||
<p id="observation-error" role="alert" hidden></p>
|
||||
<div id="observation-results" hidden>
|
||||
<p class="observation-help" id="observation-summary"></p>
|
||||
<ul class="observation-help" id="observation-warnings"></ul>
|
||||
<div class="observation-actions">
|
||||
<label class="observation-choice"><input type="checkbox" id="observation-limit-enabled" checked aria-describedby="observation-group-limit-help"> Auf die hellsten je Gruppe begrenzen</label>
|
||||
<div class="form-group">
|
||||
<label for="observation-group-limit">Maximal pro Objektgruppe</label>
|
||||
<input type="number" id="observation-group-limit" form="observation-display-options" min="1" step="1" value="30" required aria-describedby="observation-group-limit-help">
|
||||
</div>
|
||||
</div>
|
||||
<p class="observation-help" id="observation-group-limit-help">Je Objekttyp aus der Auswahl bleiben die X hellsten Treffer nach der angezeigten mag-Helligkeit. Bei gleicher Helligkeit zählt die größere Höhe. Objekte ohne Helligkeitsangabe werden bei dieser Begrenzung ausgelassen.</p>
|
||||
<p class="observation-help" id="observation-limit-status" role="status" aria-live="polite"></p>
|
||||
<label class="observation-choice"><input type="checkbox" id="observation-multi-sort"> Weitere Sortierkriterien hinzufügen (auch ohne Umschalttaste)</label>
|
||||
<p class="observation-help" id="observation-sort-summary" role="status" aria-live="polite"></p>
|
||||
<div class="observation-table-wrap">
|
||||
<table class="data-table observation-results-table">
|
||||
<caption>Klick auf eine Überschrift setzt das Hauptkriterium. Umschalt + Klick ergänzt weitere Kriterien; erneuter Klick kehrt die Richtung um. Das nächste Kriterium entscheidet bei gleichen Werten. Fehlende Werte folgen innerhalb der jeweiligen Sortiergruppe am Ende.</caption>
|
||||
<thead><tr>
|
||||
<th scope="col"><button type="button" class="observation-sort" data-sort="name">Objekt<span class="observation-sort-indicator" aria-hidden="true">↕</span></button></th>
|
||||
<th scope="col"><button type="button" class="observation-sort" data-sort="type">Typ<span class="observation-sort-indicator" aria-hidden="true">↕</span></button></th>
|
||||
<th scope="col"><button type="button" class="observation-sort" data-sort="magnitude">Helligkeit<span class="observation-sort-indicator" aria-hidden="true">↕</span></button></th>
|
||||
<th scope="col"><button type="button" class="observation-sort" data-sort="size">Größe<span class="observation-sort-indicator" aria-hidden="true">↕</span></button></th>
|
||||
<th scope="col"><button type="button" class="observation-sort" data-sort="window" title="Nach Beginn des ersten passenden Zeitfensters sortieren">Passendes Zeitfenster (ca.)<span class="observation-sort-indicator" aria-hidden="true">↕</span></button></th>
|
||||
<th scope="col"><button type="button" class="observation-sort" data-sort="time" title="Zeitpunkt der größten geprüften Höhe innerhalb deiner Filter">Zeit der größten Höhe (ca.)<span class="observation-sort-indicator" aria-hidden="true">↕</span></button></th>
|
||||
<th scope="col" aria-sort="descending"><button type="button" class="observation-sort" data-sort="altitude">Höhe<span class="observation-sort-indicator" aria-hidden="true">↓</span></button></th>
|
||||
<th scope="col"><button type="button" class="observation-sort" data-sort="azimuth">Azimut<span class="observation-sort-indicator" aria-hidden="true">↕</span></button></th>
|
||||
</tr></thead>
|
||||
<tbody id="observation-rows"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="observation-actions">
|
||||
<button type="button" class="btn btn-secondary" id="observation-previous">Zurück</button>
|
||||
<span id="observation-pagination"></span>
|
||||
<button type="button" class="btn btn-secondary" id="observation-next">Weiter</button>
|
||||
</div>
|
||||
</div>
|
||||
<noscript><p>Bitte JavaScript aktivieren, um die Vorschläge auf dieser Seite anzuzeigen.</p></noscript>
|
||||
</section>
|
||||
</form>
|
||||
<form id="observation-display-options"></form>
|
||||
<script src="js/observation_suggestions.js" defer></script>
|
||||
|
||||
<?php require __DIR__ . '/footer.php'; ?>
|
||||
@@ -166,6 +166,7 @@ $menuGroups = [
|
||||
'title' => 'Sternenhimmel & Karten',
|
||||
'links' => [
|
||||
['href' => 'sternenhimmel.php', 'label' => 'Sternenhimmel'],
|
||||
['href' => 'beobachtungsvorschlaege.php', 'label' => 'Beobachtungsvorschläge'],
|
||||
['href' => 'drehbare_sternkarte.php', 'label' => 'Drehbare Sternkarte'],
|
||||
['href' => 'satellitenhimmel.php', 'label' => 'Satellitenhimmel'],
|
||||
['href' => 'tagbogen.php', 'label' => 'Tagbogen'],
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
'use strict';
|
||||
|
||||
(() => {
|
||||
const form = document.getElementById('observation-form');
|
||||
const submit = document.getElementById('observation-submit');
|
||||
const status = document.getElementById('observation-status');
|
||||
const error = document.getElementById('observation-error');
|
||||
const results = document.getElementById('observation-results');
|
||||
const tbody = document.getElementById('observation-rows');
|
||||
const previous = document.getElementById('observation-previous');
|
||||
const next = document.getElementById('observation-next');
|
||||
const pageSize = 50;
|
||||
let data = null;
|
||||
let displayedRows = [];
|
||||
let groupLimit = 30;
|
||||
let page = 0;
|
||||
let formatter = null;
|
||||
let sortCriteria = [{key: 'altitude', direction: -1}];
|
||||
const collator = new Intl.Collator('de-DE', {numeric: true, sensitivity: 'base'});
|
||||
const sortButtons = [...document.querySelectorAll('.observation-sort')];
|
||||
const sortLabels = Object.fromEntries(sortButtons.map(button => [button.dataset.sort, button.firstChild.textContent.trim()]));
|
||||
const sortValue = {
|
||||
name: row => row.name,
|
||||
type: row => row.type,
|
||||
magnitude: row => row.best.magnitude,
|
||||
size: row => row.size_arcsec,
|
||||
window: row => row.intervals.length ? Date.parse(row.intervals[0].start) : null,
|
||||
time: row => Date.parse(row.best.time),
|
||||
altitude: row => row.best.altitude,
|
||||
azimuth: row => row.best.azimuth,
|
||||
};
|
||||
function selectRows() {
|
||||
if (groupLimit === null) {
|
||||
displayedRows = [...data.rows];
|
||||
} else {
|
||||
const counts = new Map();
|
||||
displayedRows = data.rows.filter(row => Number.isFinite(row.best.magnitude))
|
||||
.sort((a, b) => a.best.magnitude - b.best.magnitude || b.best.altitude - a.best.altitude
|
||||
|| collator.compare(a.name, b.name) || collator.compare(String(a.id), String(b.id)))
|
||||
.filter(row => {
|
||||
const count = counts.get(row.group) || 0;
|
||||
if (count >= groupLimit) return false;
|
||||
counts.set(row.group, count + 1);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
document.getElementById('observation-limit-status').textContent = groupLimit === null
|
||||
? `Alle ${data.rows.length} Treffer werden angezeigt.`
|
||||
: `${displayedRows.length} von ${data.rows.length} Treffern: maximal ${groupLimit} hellste je Objektgruppe. Die Tabellensortierung gilt für diese Auswahl.`;
|
||||
page = 0;
|
||||
applySort();
|
||||
render();
|
||||
}
|
||||
function applyGroupLimit() {
|
||||
const input = document.getElementById('observation-group-limit');
|
||||
const enabled = document.getElementById('observation-limit-enabled').checked;
|
||||
input.disabled = !enabled;
|
||||
if (!enabled) {
|
||||
groupLimit = null;
|
||||
if (data) selectRows();
|
||||
return true;
|
||||
}
|
||||
if (!input.reportValidity()) return false;
|
||||
const value = input.valueAsNumber;
|
||||
if (!Number.isSafeInteger(value) || value < 1) {
|
||||
input.setCustomValidity('Bitte eine positive ganze Zahl eingeben.');
|
||||
input.reportValidity();
|
||||
return false;
|
||||
}
|
||||
groupLimit = value;
|
||||
if (data) selectRows();
|
||||
return true;
|
||||
}
|
||||
document.getElementById('observation-group-limit').addEventListener('input', event => event.target.setCustomValidity(''));
|
||||
document.getElementById('observation-group-limit').addEventListener('change', applyGroupLimit);
|
||||
document.getElementById('observation-limit-enabled').addEventListener('change', applyGroupLimit);
|
||||
document.getElementById('observation-display-options').addEventListener('submit', event => {
|
||||
event.preventDefault();
|
||||
applyGroupLimit();
|
||||
});
|
||||
function applySort() {
|
||||
// Sort the complete result set before slicing it into display pages.
|
||||
displayedRows.sort((a, b) => {
|
||||
for (const {key, direction} of sortCriteria) {
|
||||
const av = sortValue[key](a);
|
||||
const bv = sortValue[key](b);
|
||||
const missingA = av == null || (typeof av === 'number' && !Number.isFinite(av));
|
||||
const missingB = bv == null || (typeof bv === 'number' && !Number.isFinite(bv));
|
||||
if (missingA !== missingB) return missingA ? 1 : -1;
|
||||
const comparison = missingA ? 0 : (typeof av === 'string' ? collator.compare(av, bv) : av - bv);
|
||||
if (comparison) return comparison * direction;
|
||||
}
|
||||
return collator.compare(a.name, b.name) || collator.compare(String(a.id), String(b.id));
|
||||
});
|
||||
for (const button of sortButtons) {
|
||||
const index = sortCriteria.findIndex(item => item.key === button.dataset.sort);
|
||||
const criterion = sortCriteria[index];
|
||||
if (index === 0) button.closest('th').setAttribute('aria-sort', criterion.direction === 1 ? 'ascending' : 'descending');
|
||||
else button.closest('th').removeAttribute('aria-sort');
|
||||
button.querySelector('.observation-sort-indicator').textContent = criterion ? `${index + 1} ${criterion.direction === 1 ? '↑' : '↓'}` : '↕';
|
||||
button.setAttribute('aria-label', sortLabels[button.dataset.sort] + (criterion
|
||||
? `, Sortierkriterium ${index + 1}, ${criterion.direction === 1 ? 'aufsteigend' : 'absteigend'}`
|
||||
: ', nicht sortiert'));
|
||||
}
|
||||
document.getElementById('observation-sort-summary').textContent = 'Sortierung: ' + sortCriteria.map(({key, direction}, index) =>
|
||||
`${index + 1}. ${sortLabels[key]} ${direction === 1 ? 'aufsteigend' : 'absteigend'}`).join(' → ');
|
||||
}
|
||||
const number = (value, digits = 1) => new Intl.NumberFormat('de-DE', {maximumFractionDigits: digits}).format(value);
|
||||
const time = value => formatter.format(new Date(value));
|
||||
function cell(row, text, note = '') {
|
||||
const td = document.createElement('td');
|
||||
td.textContent = text;
|
||||
if (note) {
|
||||
const small = document.createElement('small');
|
||||
small.textContent = note;
|
||||
td.append(small);
|
||||
}
|
||||
row.append(td);
|
||||
}
|
||||
function render() {
|
||||
tbody.replaceChildren();
|
||||
const fragment = document.createDocumentFragment();
|
||||
for (const item of displayedRows.slice(page * pageSize, (page + 1) * pageSize)) {
|
||||
const row = document.createElement('tr');
|
||||
row.dataset.objectId = item.id;
|
||||
cell(row, item.name, item.note);
|
||||
cell(row, item.type);
|
||||
cell(row, item.best.magnitude === null ? 'Helligkeit unbekannt' : `${number(item.best.magnitude)} mag`, item.magnitude_note);
|
||||
cell(row, item.size_arcsec === null ? '—' : `${number(item.size_arcsec)}″`);
|
||||
const windows = document.createElement('td');
|
||||
for (const interval of item.intervals) {
|
||||
const line = document.createElement('div');
|
||||
line.textContent = interval.start === interval.end
|
||||
? `${time(interval.start)} (ein Messpunkt)`
|
||||
: `${time(interval.start)} – ${time(interval.end)}`;
|
||||
windows.append(line);
|
||||
}
|
||||
row.append(windows);
|
||||
cell(row, time(item.best.time));
|
||||
cell(row, `${number(item.best.altitude)}°`);
|
||||
cell(row, `${number(item.best.azimuth)}°`);
|
||||
fragment.append(row);
|
||||
}
|
||||
tbody.append(fragment);
|
||||
const pages = Math.max(1, Math.ceil(displayedRows.length / pageSize));
|
||||
document.getElementById('observation-pagination').textContent = `Seite ${page + 1} von ${pages} · ${displayedRows.length} Ziele`;
|
||||
previous.disabled = page === 0;
|
||||
next.disabled = page + 1 >= pages;
|
||||
}
|
||||
previous.addEventListener('click', () => { if (page > 0) { page--; render(); } });
|
||||
next.addEventListener('click', () => { if ((page + 1) * pageSize < displayedRows.length) { page++; render(); } });
|
||||
for (const button of sortButtons) {
|
||||
button.addEventListener('click', event => {
|
||||
if (!data) return;
|
||||
const key = button.dataset.sort;
|
||||
const existing = sortCriteria.find(item => item.key === key);
|
||||
const direction = existing ? -existing.direction : (key === 'altitude' ? -1 : 1);
|
||||
if (event.shiftKey || document.getElementById('observation-multi-sort').checked) {
|
||||
if (existing) existing.direction = direction;
|
||||
else sortCriteria.push({key, direction});
|
||||
} else {
|
||||
sortCriteria = [{key, direction}];
|
||||
}
|
||||
page = 0;
|
||||
applySort();
|
||||
render();
|
||||
});
|
||||
}
|
||||
form.addEventListener('submit', async event => {
|
||||
event.preventDefault();
|
||||
if (submit.disabled) return;
|
||||
if (!applyGroupLimit()) return;
|
||||
error.hidden = true;
|
||||
const payload = new FormData(form);
|
||||
if (!payload.getAll('object_types[]').length || !payload.getAll('night_halves[]').length) {
|
||||
error.textContent = 'Bitte mindestens einen Objekttyp und eine Nachthälfte auswählen.';
|
||||
error.hidden = false;
|
||||
return;
|
||||
}
|
||||
for (const prefix of ['object-size', 'object-altitude']) {
|
||||
const min = document.getElementById(`${prefix}-min`);
|
||||
const max = document.getElementById(`${prefix}-max`);
|
||||
if (min.value !== '' && max.value !== '' && Number(min.value) > Number(max.value)) {
|
||||
error.textContent = 'Das Minimum darf nicht größer als das Maximum sein.';
|
||||
error.hidden = false;
|
||||
min.focus();
|
||||
return;
|
||||
}
|
||||
}
|
||||
submit.disabled = true;
|
||||
results.hidden = true;
|
||||
form.setAttribute('aria-busy', 'true');
|
||||
status.textContent = 'Die Beobachtungsnacht und passende Ziele werden berechnet …';
|
||||
try {
|
||||
const response = await fetch(form.action, {method: 'POST', body: payload, credentials: 'same-origin'});
|
||||
if (!(response.headers.get('content-type') || '').includes('application/json')) {
|
||||
throw new Error('Keine gültige Antwort. Bitte die Seite neu laden und erneut anmelden.');
|
||||
}
|
||||
const result = await response.json();
|
||||
if (!response.ok || !result.ok) throw new Error(result.error || 'Die Berechnung ist fehlgeschlagen.');
|
||||
data = result;
|
||||
page = 0;
|
||||
formatter = new Intl.DateTimeFormat('de-DE', {timeZone: data.timezone, day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit', timeZoneName: 'short'});
|
||||
status.textContent = data.message || (data.count ? `${data.count} passende Ziele gefunden.` : 'Keine Ziele erfüllen die gewählten Bedingungen.');
|
||||
const summary = document.getElementById('observation-summary');
|
||||
summary.textContent = `Nacht ab ${payload.get('observation_date')} · ${data.timezone}. `
|
||||
+ (data.windows.length ? `Gewählte Nachtabschnitte: ${data.windows.map(w => `${time(w.start)} – ${time(w.end)}`).join('; ')}. ` : '')
|
||||
+ '„Zeit der größten Höhe“ bezeichnet den höchsten geprüften Punkt innerhalb deiner Filter und der gewählten Nachtabschnitte. Höhe, Azimut und Helligkeit beziehen sich auf diesen Zeitpunkt. Fünf-Minuten-Raster: sehr kurze Sichtfenster können fehlen.';
|
||||
const warnings = document.getElementById('observation-warnings');
|
||||
warnings.replaceChildren();
|
||||
for (const warning of data.warnings) {
|
||||
const item = document.createElement('li');
|
||||
item.textContent = warning;
|
||||
warnings.append(item);
|
||||
}
|
||||
selectRows();
|
||||
results.hidden = false;
|
||||
} catch (failure) {
|
||||
status.textContent = 'Berechnung nicht abgeschlossen.';
|
||||
error.textContent = failure.message || 'Verbindungsfehler. Bitte erneut versuchen.';
|
||||
error.hidden = false;
|
||||
} finally {
|
||||
submit.disabled = false;
|
||||
form.removeAttribute('aria-busy');
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/** Catalog values are server-owned; the client only supplies selection filters. */
|
||||
function observationCatalog(PDO $pdo, array $types): array
|
||||
{
|
||||
$mapping = [
|
||||
'open_clusters' => ['OCl'], 'globular_clusters' => ['GCl'],
|
||||
'galaxies' => ['G', 'GPair', 'GTrpl', 'GGroup'],
|
||||
'nebulae' => ['Neb', 'EmN', 'HII', 'RfN', 'SNR', 'Cl+N'],
|
||||
'planetary_nebulae' => ['PN'], 'double_stars' => ['**'],
|
||||
];
|
||||
$codes = [];
|
||||
foreach ($mapping as $group => $groupCodes) {
|
||||
if (in_array($group, $types, true)) {
|
||||
foreach ($groupCodes as $code) {
|
||||
$codes[$code] = $group;
|
||||
}
|
||||
}
|
||||
}
|
||||
$objects = [];
|
||||
if ($codes !== []) {
|
||||
$placeholders = implode(',', array_fill(0, count($codes), '?'));
|
||||
$stmt = $pdo->prepare("SELECT id, name, messier, object_type_abrev, ra_deg, dec_deg, major_axis, v_mag
|
||||
FROM dso_objects WHERE object_type_abrev IN ($placeholders)
|
||||
AND ra_deg IS NOT NULL AND dec_deg IS NOT NULL");
|
||||
$stmt->execute(array_keys($codes));
|
||||
foreach ($stmt as $row) {
|
||||
$messier = trim((string) ($row['messier'] ?? ''));
|
||||
$objects[] = [
|
||||
'id' => 'dso-' . $row['id'],
|
||||
'name' => $row['name'] . ($messier !== '' ? ' · M ' . $messier : ''),
|
||||
'group' => $codes[$row['object_type_abrev']],
|
||||
'ra_deg' => (float) $row['ra_deg'], 'dec_deg' => (float) $row['dec_deg'],
|
||||
// OpenNGC MajAx is in arcminutes; GUI and Python use arcseconds.
|
||||
'size_arcsec' => $row['major_axis'] !== null && (float) $row['major_axis'] > 0
|
||||
? (float) $row['major_axis'] * 60 : null,
|
||||
'magnitude' => $row['v_mag'] !== null ? (float) $row['v_mag'] : null,
|
||||
];
|
||||
}
|
||||
}
|
||||
if (in_array('variable_stars', $types, true)) {
|
||||
$stmt = $pdo->query("SELECT id, hip, proper, var, con, ra, `dec`, mag FROM star_hipparcos
|
||||
WHERE var IS NOT NULL AND TRIM(var) <> '' AND ra IS NOT NULL AND `dec` IS NOT NULL");
|
||||
foreach ($stmt as $row) {
|
||||
$name = trim((string) ($row['proper'] ?? ''));
|
||||
$objects[] = [
|
||||
'id' => 'star-' . $row['id'], 'name' => $name !== '' ? $name : trim($row['var'] . ' ' . ($row['con'] ?? '')),
|
||||
'group' => 'variable_stars', 'ra_deg' => (float) $row['ra'] * 15,
|
||||
'dec_deg' => (float) $row['dec'], 'size_arcsec' => null,
|
||||
'magnitude' => $row['mag'] !== null ? (float) $row['mag'] : null,
|
||||
'note' => 'HIP ' . $row['hip'] . ' · Keine Minima-/Maxima-Vorhersage',
|
||||
];
|
||||
}
|
||||
}
|
||||
if (in_array('comets', $types, true)) {
|
||||
$stmt = $pdo->query('SELECT id, designation_and_name, year_of_perihelion, month_of_perihelion,
|
||||
day_of_perihelion, perihelion_dist_au, eccentricity, arg_perihelion_deg, ascending_node_deg,
|
||||
inclination_deg, absolute_magnitude_h, slope_parameter_g, epoch_date FROM comets_mpc');
|
||||
foreach ($stmt as $row) {
|
||||
$objects[] = ['id' => 'comet-' . $row['id'], 'name' => $row['designation_and_name'],
|
||||
'group' => 'comets', 'size_arcsec' => null, 'orbit' => $row,
|
||||
'note' => 'Bahnepoche: ' . ($row['epoch_date'] ?? 'unbekannt')];
|
||||
}
|
||||
}
|
||||
return $objects;
|
||||
}
|
||||
|
||||
function runObservationSuggestions(array $payload): array
|
||||
{
|
||||
$file = tempnam(sys_get_temp_dir(), 'skyview_observation_');
|
||||
if ($file === false) {
|
||||
throw new RuntimeException('Temporäre Datei konnte nicht angelegt werden.');
|
||||
}
|
||||
try {
|
||||
if (file_put_contents($file, json_encode($payload, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE)) === false) {
|
||||
throw new RuntimeException('Berechnungsdaten konnten nicht geschrieben werden.');
|
||||
}
|
||||
$candidates = PHP_OS_FAMILY === 'Windows' ? ['python', 'py'] : ['/usr/bin/python3', 'python3', 'python'];
|
||||
foreach ($candidates as $binary) {
|
||||
// Array command bypasses the shell; no user input is interpolated into a command.
|
||||
$process = @proc_open([$binary, __DIR__ . '/py/observation_suggestions_api.py', $file],
|
||||
[0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
|
||||
if (!is_resource($process)) {
|
||||
continue;
|
||||
}
|
||||
fclose($pipes[0]);
|
||||
stream_set_blocking($pipes[1], false);
|
||||
stream_set_blocking($pipes[2], false);
|
||||
$output = '';
|
||||
$errors = '';
|
||||
$deadline = microtime(true) + 90;
|
||||
$timedOut = false;
|
||||
do {
|
||||
$output .= stream_get_contents($pipes[1]);
|
||||
$errors .= stream_get_contents($pipes[2]);
|
||||
$status = proc_get_status($process);
|
||||
if (!$status['running']) {
|
||||
break;
|
||||
}
|
||||
if (microtime(true) > $deadline) {
|
||||
$timedOut = true;
|
||||
proc_terminate($process);
|
||||
break;
|
||||
}
|
||||
usleep(20000);
|
||||
} while (true);
|
||||
$output .= stream_get_contents($pipes[1]);
|
||||
$errors .= stream_get_contents($pipes[2]);
|
||||
fclose($pipes[1]);
|
||||
fclose($pipes[2]);
|
||||
proc_close($process);
|
||||
if ($timedOut) {
|
||||
throw new RuntimeException('Die Berechnung dauert zu lange. Bitte weniger Objekttypen auswählen.');
|
||||
}
|
||||
$decoded = json_decode($output, true);
|
||||
if (is_array($decoded) && array_key_exists('ok', $decoded)) {
|
||||
return $decoded;
|
||||
}
|
||||
error_log('Observation suggestions Python: ' . substr($errors, 0, 2000));
|
||||
}
|
||||
throw new RuntimeException('Die Berechnung ist derzeit nicht verfügbar. Bitte die Python-Installation prüfen.');
|
||||
} finally {
|
||||
@unlink($file);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Independent observation planner. JSON file in, JSON out; no dependency on api.py."""
|
||||
import json
|
||||
import math
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import astronomy
|
||||
import comets
|
||||
|
||||
UTC = timezone.utc
|
||||
STEP = timedelta(minutes=5)
|
||||
GROUPS = {
|
||||
'open_clusters': ('OCl',), 'globular_clusters': ('GCl',),
|
||||
'galaxies': ('G', 'GPair', 'GTrpl', 'GGroup'),
|
||||
'nebulae': ('Neb', 'EmN', 'HII', 'RfN', 'SNR', 'Cl+N'),
|
||||
'planetary_nebulae': ('PN',), 'double_stars': ('**',),
|
||||
}
|
||||
LABELS = {
|
||||
'open_clusters': 'Offener Sternhaufen', 'globular_clusters': 'Kugelsternhaufen',
|
||||
'galaxies': 'Galaxie / Galaxiengruppe', 'nebulae': 'Nebel',
|
||||
'planetary_nebulae': 'Planetarischer Nebel', 'double_stars': 'Doppelstern',
|
||||
'variable_stars': 'Veränderlicher Stern', 'comets': 'Komet',
|
||||
'moon': 'Mond', 'planets': 'Planet',
|
||||
}
|
||||
BODIES = {'Moon': 'Mond', 'Mercury': 'Merkur', 'Venus': 'Venus', 'Mars': 'Mars',
|
||||
'Jupiter': 'Jupiter', 'Saturn': 'Saturn', 'Uranus': 'Uranus', 'Neptune': 'Neptun'}
|
||||
|
||||
|
||||
def number(value, label, minimum=None, maximum=None, optional=False):
|
||||
if optional and (value is None or value == ''):
|
||||
return None
|
||||
try:
|
||||
result = float(value)
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError(f'{label}: Bitte eine Zahl eingeben.')
|
||||
if not math.isfinite(result) or (minimum is not None and result < minimum) or (maximum is not None and result > maximum):
|
||||
raise ValueError(f'{label}: Wert außerhalb des gültigen Bereichs.')
|
||||
return result
|
||||
|
||||
|
||||
def validate(payload):
|
||||
raw = payload['filters']
|
||||
result = {}
|
||||
for key, low, high, optional in (
|
||||
('object_size_min', 0, 1296000, True), ('object_size_max', 0, 1296000, True),
|
||||
('object_magnitude', -40, 40, True), ('object_altitude_min', 0, 90, False),
|
||||
('object_altitude_max', 0, 90, False), ('object_azimuth_start', 0, 360, False),
|
||||
('object_azimuth_end', 0, 360, False), ('sun_altitude_max', -90, 0, False),
|
||||
('moon_separation_min', 0, 180, True),
|
||||
):
|
||||
result[key] = number(raw.get(key), key, low, high, optional)
|
||||
for prefix in ('object_size', 'object_altitude'):
|
||||
lo, hi = result[prefix + '_min'], result[prefix + '_max']
|
||||
if lo is not None and hi is not None and lo > hi:
|
||||
raise ValueError('Das Minimum darf nicht größer als das Maximum sein.')
|
||||
result['types'] = raw.get('object_types', [])
|
||||
result['halves'] = raw.get('night_halves', [])
|
||||
if not isinstance(result['types'], list) or not result['types'] or any(t not in LABELS for t in result['types']):
|
||||
raise ValueError('Bitte mindestens einen gültigen Objekttyp auswählen.')
|
||||
if not isinstance(result['halves'], list) or not result['halves'] or any(h not in ('first', 'second') for h in result['halves']):
|
||||
raise ValueError('Bitte mindestens eine Nachthälfte auswählen.')
|
||||
result['unknown'] = raw.get('include_unknown_magnitude') in (True, 1, '1')
|
||||
date = datetime.strptime(raw.get('observation_date', ''), '%Y-%m-%d')
|
||||
if not 1900 <= date.year <= 2100:
|
||||
raise ValueError('Bitte ein Datum zwischen 1900 und 2100 wählen.')
|
||||
loc = payload['location']
|
||||
zone = ZoneInfo(loc['timezone'])
|
||||
observer = astronomy.Observer(number(loc['latitude'], 'Breitengrad', -90, 90),
|
||||
number(loc['longitude'], 'Längengrad', -180, 180),
|
||||
number(loc.get('elevation') or 0, 'Standorthöhe', -500, 10000))
|
||||
start = date.replace(hour=12, tzinfo=zone).astimezone(UTC)
|
||||
end = (date + timedelta(days=1)).replace(hour=12, tzinfo=zone).astimezone(UTC)
|
||||
return result, observer, zone, start, end
|
||||
|
||||
|
||||
def astro_time(dt):
|
||||
return comets.dt_to_time(dt)
|
||||
|
||||
|
||||
def unit(vector):
|
||||
length = math.sqrt(sum(v * v for v in vector))
|
||||
return tuple(v / length for v in vector)
|
||||
|
||||
|
||||
def xyz(vector):
|
||||
return vector.x, vector.y, vector.z
|
||||
|
||||
|
||||
def horizontal(vector, rotation):
|
||||
x, y, z = (sum(rotation[j][i] * vector[j] for j in range(3)) for i in range(3))
|
||||
return math.degrees(math.atan2(z, math.hypot(x, y))), math.degrees(math.atan2(-y, x)) % 360
|
||||
|
||||
|
||||
def azimuth_matches(azimuth, start, end):
|
||||
if abs(end - start) == 360:
|
||||
return True
|
||||
start, end = start % 360, end % 360
|
||||
return start <= azimuth <= end if start <= end else azimuth >= start or azimuth <= end
|
||||
|
||||
|
||||
def magnitude_matches(mag, filters):
|
||||
limit = filters['object_magnitude']
|
||||
return limit is None or (filters['unknown'] if mag is None else mag <= limit)
|
||||
|
||||
|
||||
def size_matches(size, group, filters):
|
||||
if group not in GROUPS or group == 'double_stars':
|
||||
return True
|
||||
lo, hi = filters['object_size_min'], filters['object_size_max']
|
||||
if lo is None and hi is None:
|
||||
return True
|
||||
return size is not None and (lo is None or size >= lo) and (hi is None or size <= hi)
|
||||
|
||||
|
||||
def sun_altitude(dt, observer):
|
||||
t = astro_time(dt)
|
||||
eq = astronomy.Equator(astronomy.Body.Sun, t, observer, True, True)
|
||||
return astronomy.Horizon(t, observer, eq.ra, eq.dec, astronomy.Refraction.Airless).altitude
|
||||
|
||||
|
||||
def night_windows(start, end, observer, limit):
|
||||
"""Bracket solar threshold crossings in UTC, refine to one second."""
|
||||
windows = []
|
||||
previous = start
|
||||
inside = sun_altitude(start, observer) <= limit
|
||||
opened = start if inside else None
|
||||
while previous < end:
|
||||
current = min(previous + STEP, end)
|
||||
next_inside = sun_altitude(current, observer) <= limit
|
||||
if inside != next_inside:
|
||||
lo, hi = previous, current
|
||||
while (hi - lo).total_seconds() > 1:
|
||||
mid = lo + (hi - lo) / 2
|
||||
if (sun_altitude(mid, observer) <= limit) == inside:
|
||||
lo = mid
|
||||
else:
|
||||
hi = mid
|
||||
crossing = hi if next_inside else lo
|
||||
if next_inside:
|
||||
opened = crossing
|
||||
else:
|
||||
windows.append((opened, crossing))
|
||||
opened = None
|
||||
inside, previous = next_inside, current
|
||||
if opened is not None:
|
||||
windows.append((opened, end))
|
||||
return windows
|
||||
|
||||
|
||||
def selected_windows(windows, halves):
|
||||
selected = []
|
||||
for start, end in windows:
|
||||
midpoint = start + (end - start) / 2
|
||||
if 'first' in halves and 'second' in halves:
|
||||
selected.append((start, end))
|
||||
elif 'first' in halves:
|
||||
selected.append((start, midpoint))
|
||||
else:
|
||||
selected.append((midpoint, end))
|
||||
return selected
|
||||
|
||||
|
||||
def frames_for(windows, observer):
|
||||
frames = []
|
||||
for segment, (start, end) in enumerate(windows):
|
||||
dt = start
|
||||
while True:
|
||||
t = astro_time(dt)
|
||||
rotation = astronomy.Rotation_EQJ_HOR(t, observer).rot
|
||||
moon = astronomy.Equator(astronomy.Body.Moon, t, observer, False, True)
|
||||
moon_vector = unit(xyz(moon.vec))
|
||||
frames.append({'dt': dt, 'time': t, 'rotation': rotation, 'segment': segment,
|
||||
'moon': moon_vector, 'moon_alt': horizontal(moon_vector, rotation)[0],
|
||||
'earth': xyz(astronomy.HelioVector(astronomy.Body.Earth, t)),
|
||||
'observer': xyz(astronomy.ObserverVector(t, observer, False))})
|
||||
if dt == end:
|
||||
break
|
||||
dt = min(dt + STEP, end)
|
||||
return frames
|
||||
|
||||
|
||||
def fixed_vector(obj):
|
||||
ra = math.radians(number(obj['ra_deg'], 'Rektaszension', 0, 360))
|
||||
dec = math.radians(number(obj['dec_deg'], 'Deklination', -90, 90))
|
||||
return math.cos(dec) * math.cos(ra), math.cos(dec) * math.sin(ra), math.sin(dec)
|
||||
|
||||
|
||||
def comet_state(obj, frame):
|
||||
orbit = obj['orbit']
|
||||
# Iterated light time; shared two-body orbit helper, no api.py import.
|
||||
emitted = frame['dt']
|
||||
for _ in range(3):
|
||||
helio = comets.comet_heliocentric_vector(orbit, emitted)
|
||||
if helio is None:
|
||||
raise ValueError('Unvollständige Kometenbahn')
|
||||
geo = tuple(helio[i] - frame['earth'][i] for i in range(3))
|
||||
distance = math.sqrt(sum(v * v for v in geo))
|
||||
emitted = frame['dt'] - timedelta(days=distance / astronomy.C_AUDAY)
|
||||
topocentric = tuple(geo[i] - frame['observer'][i] for i in range(3))
|
||||
mag = comets.estimate_magnitude(comets.parse_float(orbit.get('absolute_magnitude_h')),
|
||||
comets.parse_float(orbit.get('slope_parameter_g')),
|
||||
math.sqrt(sum(v * v for v in helio)), distance)
|
||||
return unit(topocentric), mag
|
||||
|
||||
|
||||
def sample_matches(vector, mag, obj, frame, filters):
|
||||
altitude, azimuth = horizontal(vector, frame['rotation'])
|
||||
if not filters['object_altitude_min'] <= altitude <= filters['object_altitude_max']:
|
||||
return None
|
||||
if not azimuth_matches(azimuth, filters['object_azimuth_start'], filters['object_azimuth_end']):
|
||||
return None
|
||||
if not magnitude_matches(mag, filters):
|
||||
return None
|
||||
separation = math.degrees(math.acos(max(-1, min(1, sum(a * b for a, b in zip(vector, frame['moon']))))))
|
||||
if obj['group'] != 'moon' and frame['moon_alt'] > 0 and filters['moon_separation_min'] is not None and separation < filters['moon_separation_min']:
|
||||
return None
|
||||
return {'altitude': altitude, 'azimuth': azimuth, 'magnitude': mag, 'moon_separation': separation}
|
||||
|
||||
|
||||
def calculate(payload):
|
||||
filters, observer, zone, start, end = validate(payload)
|
||||
windows = night_windows(start, end, observer, filters['sun_altitude_max'])
|
||||
selected = selected_windows(windows, filters['halves'])
|
||||
frames = frames_for(selected, observer)
|
||||
stamp = lambda dt: dt.astimezone(zone).isoformat(timespec='seconds')
|
||||
warnings = []
|
||||
if windows and (windows[0][0] == start or windows[-1][1] == end):
|
||||
warnings.append('Die Dunkelphase reicht über die Nacht hinaus. Berechnet wird von 12 Uhr bis 12 Uhr am Folgetag in Standortzeit.')
|
||||
objects = list(payload.get('objects', []))
|
||||
if len(objects) > 30000:
|
||||
raise ValueError('Zu viele Katalogobjekte für eine Anfrage.')
|
||||
for body, name in BODIES.items():
|
||||
group = 'moon' if body == 'Moon' else 'planets'
|
||||
if group in filters['types']:
|
||||
objects.append({'id': body, 'name': name, 'group': group, 'body': body, 'size_arcsec': None})
|
||||
results, invalid = [], 0
|
||||
for obj in objects if frames else []:
|
||||
group = obj['group']
|
||||
if group not in filters['types'] or not size_matches(obj.get('size_arcsec'), group, filters):
|
||||
continue
|
||||
moving = 'body' in obj or group == 'comets'
|
||||
if not moving and not magnitude_matches(obj.get('magnitude'), filters):
|
||||
continue
|
||||
try:
|
||||
vector = None if moving else fixed_vector(obj)
|
||||
intervals, opened, last, best = [], None, None, None
|
||||
for frame in frames:
|
||||
mag = obj.get('magnitude')
|
||||
if 'body' in obj:
|
||||
body = getattr(astronomy.Body, obj['body'])
|
||||
vector = unit(xyz(astronomy.Equator(body, frame['time'], observer, False, True).vec))
|
||||
mag = astronomy.Illumination(body, frame['time']).mag
|
||||
elif group == 'comets':
|
||||
vector, mag = comet_state(obj, frame)
|
||||
match = sample_matches(vector, mag, obj, frame, filters)
|
||||
if last is not None and (match is None or last['segment'] != frame['segment']):
|
||||
intervals.append({'start': stamp(opened['dt']), 'end': stamp(last['dt'])})
|
||||
opened, last = None, None
|
||||
if match is not None:
|
||||
if opened is None:
|
||||
opened = frame
|
||||
last = frame
|
||||
if best is None or match['altitude'] > best['altitude']:
|
||||
best = dict(match, time=stamp(frame['dt']))
|
||||
if last is not None:
|
||||
intervals.append({'start': stamp(opened['dt']), 'end': stamp(last['dt'])})
|
||||
if best:
|
||||
results.append({'id': obj['id'], 'name': obj['name'], 'group': group, 'type': LABELS[group],
|
||||
'size_arcsec': obj.get('size_arcsec'), 'best': best, 'intervals': intervals,
|
||||
'magnitude_note': 'Modellschätzung' if moving else 'Katalogwert',
|
||||
'note': obj.get('note', '')})
|
||||
except (ValueError, TypeError, ZeroDivisionError, OverflowError):
|
||||
invalid += 1
|
||||
if 'variable_stars' in filters['types']:
|
||||
warnings.append('Veränderliche: Sichtbarkeit und Kataloghelligkeit, keine aktuelle Helligkeit oder Minima-/Maxima-Vorhersage.')
|
||||
if 'comets' in filters['types']:
|
||||
warnings.append('Kometen: Näherung aus gespeicherten Bahnelementen; Helligkeiten sind unsichere Modellschätzungen.')
|
||||
if invalid:
|
||||
warnings.append(f'{invalid} Objekte wegen unbrauchbarer Koordinaten oder Bahnelemente ausgelassen.')
|
||||
results.sort(key=lambda r: (r['best']['magnitude'] is None, -r['best']['altitude'], r['name']))
|
||||
return {'ok': True, 'rows': results, 'count': len(results), 'timezone': str(zone),
|
||||
'windows': [{'start': stamp(a), 'end': stamp(b)} for a, b in selected],
|
||||
'step_minutes': 5, 'warnings': warnings,
|
||||
'message': 'Die Sonne erreicht in dieser Nacht die gewählte Höhe nicht.' if not frames else ''}
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
with open(sys.argv[1], encoding='utf-8-sig') as source:
|
||||
response = calculate(json.load(source))
|
||||
print(json.dumps(response, ensure_ascii=True, allow_nan=False))
|
||||
except (ValueError, KeyError, TypeError) as exc:
|
||||
print(json.dumps({'ok': False, 'error': str(exc)}, ensure_ascii=True))
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Run: python -m unittest discover -s public/py -p test_observation_suggestions_api.py"""
|
||||
import unittest
|
||||
|
||||
import astronomy
|
||||
import observation_suggestions_api as api
|
||||
|
||||
|
||||
def payload():
|
||||
return {'location': {'latitude': 50.3774, 'longitude': 11.1899, 'elevation': 640, 'timezone': 'Europe/Berlin'},
|
||||
'filters': {'observation_date': '2026-09-15', 'object_types': ['moon', 'planets', 'galaxies'],
|
||||
'night_halves': ['first', 'second'], 'object_size_min': '', 'object_size_max': '',
|
||||
'object_magnitude': '12', 'object_altitude_min': '20', 'object_altitude_max': '90',
|
||||
'object_azimuth_start': '0', 'object_azimuth_end': '360', 'sun_altitude_max': '-12',
|
||||
'moon_separation_min': '', 'include_unknown_magnitude': ''},
|
||||
'objects': [{'id': 'm31', 'name': 'M31', 'group': 'galaxies', 'ra_deg': 10.6847,
|
||||
'dec_deg': 41.269, 'magnitude': 3.44, 'size_arcsec': 10680}]}
|
||||
|
||||
|
||||
class ObservationTests(unittest.TestCase):
|
||||
def test_azimuth_wrap_and_full_circle(self):
|
||||
for value in (0, 20, 60, 300, 350):
|
||||
self.assertTrue(api.azimuth_matches(value, 300, 60))
|
||||
self.assertFalse(api.azimuth_matches(180, 300, 60))
|
||||
for value in (0, 90, 180, 359.9):
|
||||
self.assertTrue(api.azimuth_matches(value, 0, 360))
|
||||
self.assertFalse(api.azimuth_matches(0, 90, 270))
|
||||
|
||||
def test_size_exempts_non_deep_sky(self):
|
||||
f = api.validate(payload())[0]
|
||||
f.update(object_size_min=60, object_size_max=600)
|
||||
for group in ('moon', 'planets', 'double_stars', 'variable_stars', 'comets'):
|
||||
self.assertTrue(api.size_matches(None, group, f))
|
||||
self.assertFalse(api.size_matches(None, 'galaxies', f))
|
||||
self.assertFalse(api.size_matches(601, 'galaxies', f))
|
||||
self.assertTrue(api.size_matches(60, 'galaxies', f))
|
||||
|
||||
def test_magnitude_direction_unknown_and_zero(self):
|
||||
f = api.validate(payload())[0]
|
||||
self.assertTrue(api.magnitude_matches(-2, f))
|
||||
self.assertTrue(api.magnitude_matches(12, f))
|
||||
self.assertFalse(api.magnitude_matches(12.1, f))
|
||||
self.assertFalse(api.magnitude_matches(None, f))
|
||||
f['unknown'] = True
|
||||
self.assertTrue(api.magnitude_matches(None, f))
|
||||
f['object_magnitude'] = 0
|
||||
self.assertFalse(api.magnitude_matches(1, f))
|
||||
|
||||
def test_invalid_input_rejected(self):
|
||||
for key, value in [('object_altitude_min', '91'), ('sun_altitude_max', 'NaN'),
|
||||
('object_size_min', '-1'), ('observation_date', '2026-02-30'),
|
||||
('object_types', []), ('night_halves', [])]:
|
||||
p = payload()
|
||||
p['filters'][key] = value
|
||||
with self.subTest(key=key), self.assertRaises(ValueError):
|
||||
api.validate(p)
|
||||
p = payload()
|
||||
p['filters'].update(object_size_min='600', object_size_max='60')
|
||||
with self.assertRaises(ValueError):
|
||||
api.validate(p)
|
||||
|
||||
def test_frame_matches_library_horizon(self):
|
||||
_, observer, _, start, _ = api.validate(payload())
|
||||
t = api.astro_time(start)
|
||||
rotation = astronomy.Rotation_EQJ_HOR(t, observer).rot
|
||||
for body in (astronomy.Body.Moon, astronomy.Body.Mars, astronomy.Body.Sun):
|
||||
j2000 = astronomy.Equator(body, t, observer, False, True)
|
||||
ofdate = astronomy.Equator(body, t, observer, True, True)
|
||||
expected = astronomy.Horizon(t, observer, ofdate.ra, ofdate.dec, astronomy.Refraction.Airless)
|
||||
altitude, azimuth = api.horizontal(api.xyz(j2000.vec), rotation)
|
||||
self.assertAlmostEqual(altitude, expected.altitude, places=7)
|
||||
self.assertAlmostEqual(azimuth, expected.azimuth, places=7)
|
||||
|
||||
def test_night_bounds_and_half_split(self):
|
||||
f, observer, _, start, end = api.validate(payload())
|
||||
windows = api.night_windows(start, end, observer, f['sun_altitude_max'])
|
||||
self.assertEqual(len(windows), 1)
|
||||
a, b = windows[0]
|
||||
for instant in (a, b):
|
||||
self.assertLessEqual(api.sun_altitude(instant, observer), -12)
|
||||
self.assertAlmostEqual(api.sun_altitude(instant, observer), -12, delta=0.01)
|
||||
first = api.selected_windows(windows, ['first'])[0]
|
||||
second = api.selected_windows(windows, ['second'])[0]
|
||||
self.assertEqual(first[1], second[0])
|
||||
self.assertEqual(first[1] - first[0], second[1] - second[0])
|
||||
|
||||
def test_dst_night_duration(self):
|
||||
for date, hours in [('2026-03-28', 23), ('2026-10-24', 25)]:
|
||||
p = payload()
|
||||
p['filters']['observation_date'] = date
|
||||
_, _, _, a, b = api.validate(p)
|
||||
self.assertEqual((b - a).total_seconds() / 3600, hours)
|
||||
|
||||
def test_polar_day_empty_and_polar_night_explained(self):
|
||||
p = payload()
|
||||
p['location'].update(latitude=80, longitude=15, timezone='Arctic/Longyearbyen')
|
||||
p['filters']['observation_date'] = '2026-06-21'
|
||||
result = api.calculate(p)
|
||||
self.assertEqual(result['rows'], [])
|
||||
self.assertTrue(result['message'])
|
||||
p['filters']['observation_date'] = '2026-12-21'
|
||||
result = api.calculate(p)
|
||||
self.assertTrue(any('12 Uhr' in warning for warning in result['warnings']))
|
||||
|
||||
def test_moon_separation_exemption_and_below_horizon(self):
|
||||
f = api.validate(payload())[0]
|
||||
f['moon_separation_min'] = 60
|
||||
frame = {'rotation': [[1, 0, 0], [0, 1, 0], [0, 0, 1]], 'moon': (0, 0, 1), 'moon_alt': 90}
|
||||
self.assertIsNone(api.sample_matches((0, 0, 1), 5, {'group': 'galaxies'}, frame, f))
|
||||
self.assertIsNotNone(api.sample_matches((0, 0, 1), 5, {'group': 'moon'}, frame, f))
|
||||
frame['moon_alt'] = -5
|
||||
self.assertIsNotNone(api.sample_matches((0, 0, 1), 5, {'group': 'galaxies'}, frame, f))
|
||||
|
||||
def test_real_night_returns_m31_and_matching_positions(self):
|
||||
p = payload()
|
||||
result = api.calculate(p)
|
||||
self.assertTrue(result['ok'])
|
||||
self.assertIn('m31', [row['id'] for row in result['rows']])
|
||||
for row in result['rows']:
|
||||
self.assertGreaterEqual(row['best']['altitude'], 20)
|
||||
self.assertLessEqual(row['best']['magnitude'], 12)
|
||||
self.assertTrue(row['intervals'])
|
||||
p['filters'].update(object_size_max='1')
|
||||
small = api.calculate(p)
|
||||
self.assertNotIn('m31', [row['id'] for row in small['rows']])
|
||||
self.assertEqual({r['id'] for r in result['rows'] if r['group'] in ('moon', 'planets')},
|
||||
{r['id'] for r in small['rows'] if r['group'] in ('moon', 'planets')})
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user