diff --git a/BERECHNUNGSGRUNDLAGEN.md b/BERECHNUNGSGRUNDLAGEN.md
new file mode 100644
index 0000000..f9e11de
--- /dev/null
+++ b/BERECHNUNGSGRUNDLAGEN.md
@@ -0,0 +1,79 @@
+# Berechnungsgrundlagen
+
+Stand: 12.09.2026
+
+Diese Datei dokumentiert, welche Berechnungsbibliothek beziehungsweise welche
+Datenquelle die einzelnen Seiten verwenden.
+
+## Die drei wichtigsten Grundlagen
+
+### Astronomy Engine
+
+Die lokale Python-Version liegt in `public/py/astronomy.py`. Im Browser wird
+`public/js/astronomy.browser.min.js` verwendet.
+
+Astronomy Engine liefert auf vielen Seiten die Positionen von Sonne, Mond und
+Planeten sowie Ereigniszeiten wie Mondphasen, Finsternisse, Bedeckungen und
+Konjunktionen.
+
+### Skyfield mit `de421.bsp`
+
+Skyfield liegt lokal in `public/py/vendor` beziehungsweise
+`public/py/vendor_linux`. Die verwendete JPL-Ephemeride ist:
+
+`public/py/vendor_data/de421.bsp`
+
+Die zentrale Einrichtung erfolgt in `public/py/astronomical_conversions.py`.
+`de421.bsp` wird derzeit für die Planet-Ephemeriden in `ephemeriden_api.py`
+und für die Dämmerungsgrafik verwendet.
+
+Die Datei enthält Jupiter bis Neptun als Systemschwerpunkte. Die lokale
+`jup365`-Datei wird von den hier dokumentierten Seiten derzeit nicht verwendet.
+
+### Weitere Berechnungen
+
+Nicht jede Berechnung ist Skyfield oder Astronomy Engine:
+
+- Satellitenbahnen werden mit SGP4 aus TLE-Daten berechnet.
+- Kometen und Kleinplaneten werden derzeit aus ihren Bahnelementen mit eigenen
+ Kepler-Berechnungen bestimmt.
+
+## Seitenübersicht
+
+| Seite | Berechnungsgrundlage |
+|---|---|
+| `ephemeriden.php` | **Skyfield + `de421.bsp`** für Sonne, Mond und Planeten. Auf- und Untergang sowie Horizontkoordinaten werden ebenfalls mit Skyfield berechnet. Kometen und Kleinplaneten bleiben bei den eigenen Bahnelement-Berechnungen. Die interaktive Karte nutzt im Browser zusätzlich Astronomy Engine für Horizontberechnungen und Sterne. |
+| `monatsvorhersage.php` | Überwiegend **Astronomy Engine** über `public/py/api.py`. Satellitenereignisse verwenden SGP4. Die Dämmerungsgrafik verwendet Skyfield + `de421.bsp`. Jupiter- und Jupitermond-Ereignisse verwenden aktuell Astronomy Engine. |
+| `wochenvorhersage.php` | **Astronomy Engine** über `public/py/api.py`; je nach Bereich zusätzlich SGP4 für Satelliten. |
+| `jupitersystem.php` | **Astronomy Engine**. Die Darstellung im Browser und die Galileischen Monde werden über Astronomy Engine berechnet. `jup365.bsp` wird hier aktuell nicht geladen. |
+| `sonnenfinsternis.php` | **Astronomy Engine**, einschließlich lokaler Berechnung für den Standardstandort. |
+| `mondfinsternis.php` | **Astronomy Engine**. Der Zeitpunkt der Finsternis ist global; eine lokale Sichtbarkeitsbewertung ist davon getrennt. |
+| `sternbedeckungen.php` | **Astronomy Engine** über die lokale Python-API. Topozentrische Koordinaten und der Standardstandort werden berücksichtigt. |
+| `moonphase.php` | **Astronomy Engine** im Browser beziehungsweise über die lokale Python-API. |
+| `moonyear.php` | **Astronomy Engine** für Mondphasen und Mondpositionen. |
+| `satellitenhimmel.php` | **SGP4** für Satellitenbahnen; Astronomy Engine für astronomischen Kontext wie Sonne, Mond und Dämmerung. |
+| `drehbare_sternkarte.php` | **Astronomy Engine** im Browser für Horizont- und Koordinatenberechnungen. |
+| `sternenhimmel.php` | **Astronomy Engine** im Browser beziehungsweise über die lokale API. |
+| `tagbogen.php` | **Astronomy Engine** für Sonnen-, Mond- und Planetenbahnen. |
+| `solarsystem.php` | **Astronomy Engine** im Browser für das Sonnensystemmodell. |
+| `geocron3d.php` | **Astronomy Engine** im Browser. |
+| `geocron/index.php` | **Astronomy Engine** über das Geocron-Plugin. |
+
+## Zentrale Python-Dateien
+
+| Datei | Aufgabe |
+|---|---|
+| `public/py/api.py` | Allgemeine astronomische Ereignisse; überwiegend Astronomy Engine. Einzelne Dämmerungsberechnungen verwenden Skyfield. |
+| `public/py/ephemeriden_api.py` | Planet-Ephemeriden mit Skyfield + `de421.bsp`; Kleinplaneten und Kometen mit eigenen Bahnelement-Berechnungen. |
+| `public/py/astronomical_conversions.py` | Gemeinsame Skyfield-Konfiguration und Zugriff auf `de421.bsp`. |
+| `public/py/astronomy.py` | Lokale Python-Version der Astronomy Engine. |
+
+## Standort und Lichtlaufzeit
+
+Wo eine Seite einen Standort benötigt, wird grundsätzlich der persönliche
+Standardstandort des angemeldeten Benutzers verwendet. Fehlt dieser, greift
+der konfigurierte Fallback-Standort.
+
+Die Verwendung von Skyfield oder Astronomy Engine sagt allein noch nicht, ob
+eine Berechnung topozentrisch, geozentrisch oder mit Lichtlaufzeit erfolgt.
+Das ist je Ereignis in der jeweiligen Funktion festgelegt.
diff --git a/public/ephemeriden.php b/public/ephemeriden.php
index de83a54..c9a8c70 100644
--- a/public/ephemeriden.php
+++ b/public/ephemeriden.php
@@ -819,6 +819,7 @@ require __DIR__ . '/header.php';
+
diff --git a/public/py/ephemeriden_api.py b/public/py/ephemeriden_api.py
index e34d287..a09acc6 100644
--- a/public/py/ephemeriden_api.py
+++ b/public/py/ephemeriden_api.py
@@ -12,6 +12,8 @@ if SCRIPT_DIR not in sys.path:
sys.path.insert(0, SCRIPT_DIR)
import astronomy
+from skyfield import almanac as skyfield_almanac
+from astronomical_conversions import skyfield_context, skyfield_wgs84
EPHEMERIS_BODIES = {
@@ -26,6 +28,21 @@ EPHEMERIS_BODIES = {
"Neptune": ("Neptun", astronomy.Body.Neptune),
}
+# DE421 enthält die äußeren Planeten als Systemschwerpunkte, nicht als
+# separate Planetenzentren. Für Ephemeriden ist das die vorgesehene
+# Zielkörperdefinition dieser Datei.
+SKYFIELD_EPHEMERIS_TARGETS = {
+ "Sun": "sun",
+ "Moon": "moon",
+ "Mercury": "mercury",
+ "Venus": "venus",
+ "Mars": "mars",
+ "Jupiter": 5,
+ "Saturn": 6,
+ "Uranus": 7,
+ "Neptune": 8,
+}
+
J2000_OBLIQUITY_DEG = 23.439279444444445
GAUSSIAN_GRAVITATIONAL_CONSTANT = 0.01720209895
PARABOLIC_ECCENTRICITY_TOLERANCE = 1.0e-6
@@ -118,6 +135,45 @@ def search_event(
return result
+def skyfield_observer(latitude: float, longitude: float, elevation: float):
+ _timescale, planets = skyfield_context()
+ return planets["earth"] + skyfield_wgs84.latlon(latitude, longitude, elevation_m=elevation)
+
+
+def skyfield_planet_state(body_name: str, observer, dt_utc: datetime, timescale=None, planets=None):
+ if timescale is None or planets is None:
+ timescale, planets = skyfield_context()
+ time_value = timescale.from_datetime(dt_utc.astimezone(timezone.utc))
+ apparent = observer.at(time_value).observe(planets[SKYFIELD_EPHEMERIS_TARGETS[body_name]]).apparent()
+ ra, dec, _distance = apparent.radec(epoch=timescale.J2000)
+ altitude, azimuth, _distance = apparent.altaz("standard")
+ return float(ra.hours), float(dec.degrees), float(azimuth.degrees), float(altitude.degrees)
+
+
+def search_skyfield_rise_set(observer, body_name: str, local_day_start: datetime, timescale, planets) -> tuple[datetime | None, datetime | None]:
+ """Sucht Auf- und Untergang gemeinsam über Skyfields diskrete Ereignissuche."""
+ target = planets[SKYFIELD_EPHEMERIS_TARGETS[body_name]]
+ event_function = skyfield_almanac.risings_and_settings(
+ planets,
+ target,
+ observer - planets["earth"],
+ )
+ local_day_end = local_day_start + timedelta(days=1)
+ start_time = timescale.from_datetime(local_day_start.astimezone(timezone.utc))
+ end_time = timescale.from_datetime(local_day_end.astimezone(timezone.utc))
+ event_times, event_states = skyfield_almanac.find_discrete(start_time, end_time, event_function)
+
+ rise = None
+ set_ = None
+ for event_time, state in zip(event_times.utc_datetime(), event_states):
+ event_dt = event_time.astimezone(timezone.utc)
+ if bool(state) and rise is None:
+ rise = event_dt
+ elif not bool(state) and set_ is None:
+ set_ = event_dt
+ return rise, set_
+
+
def format_ra_hours(ra_hours: float) -> str:
total_seconds = int(round(float(ra_hours) * 3600.0))
total_seconds %= 24 * 3600
@@ -522,21 +578,28 @@ def action_planet_ephemeris(args: list[str]) -> dict:
label, body = EPHEMERIS_BODIES[body_name]
observer = astronomy.Observer(latitude, longitude, elevation)
+ skyfield_timescale, skyfield_planets = skyfield_context()
+ skyfield_topos = skyfield_observer(latitude, longitude, elevation)
def row_builder(current_local: datetime, time_value: astronomy.Time, rise_label: str | None, set_label: str | None) -> dict:
- eq = astronomy.Equator(body, time_value, observer, False, True)
- horizontal = astronomy.Horizon(time_value, observer, float(eq.ra), float(eq.dec), astronomy.Refraction.Normal)
+ ra_hours, dec_deg, azimuth_deg, altitude_deg = skyfield_planet_state(
+ body_name,
+ skyfield_topos,
+ current_local.astimezone(timezone.utc),
+ skyfield_timescale,
+ skyfield_planets,
+ )
return {
"object_name": label,
"date_local": current_local.strftime("%d.%m.%Y"),
"time_local": current_local.strftime("%H:%M"),
"local_iso": current_local.isoformat(),
- "ra": format_ra_hours(float(eq.ra)),
- "ra_decimal_hours": round(float(eq.ra), 8),
- "dec": format_dec_deg(float(eq.dec)),
- "dec_decimal_deg": round(float(eq.dec), 8),
- "azimuth_deg": round(float(horizontal.azimuth), 6),
- "altitude_deg": round(float(horizontal.altitude), 6),
+ "ra": format_ra_hours(ra_hours),
+ "ra_decimal_hours": round(ra_hours, 8),
+ "dec": format_dec_deg(dec_deg),
+ "dec_decimal_deg": round(dec_deg, 8),
+ "azimuth_deg": round(azimuth_deg, 6),
+ "altitude_deg": round(altitude_deg, 6),
"rise": rise_label,
"set": set_label,
}
@@ -547,13 +610,15 @@ def action_planet_ephemeris(args: list[str]) -> dict:
except Exception as exc:
fail("Zeitzone ist ungueltig.", extra={"details": str(exc)})
- local_day_end = local_day_start + timedelta(days=1)
- day_start_time = dt_to_time(local_day_start.astimezone(timezone.utc))
- day_end_utc = local_day_end.astimezone(timezone.utc)
- rise = search_event(body, astronomy.Direction.Rise, observer, day_start_time, day_end_utc)
- set_ = search_event(body, astronomy.Direction.Set, observer, day_start_time, day_end_utc)
- rise_label = serialize_event("Aufgang", rise, tz).get("local_time") if rise is not None else None
- set_label = serialize_event("Untergang", set_, tz).get("local_time") if set_ is not None else None
+ rise, set_ = search_skyfield_rise_set(
+ skyfield_topos,
+ body_name,
+ local_day_start,
+ skyfield_timescale,
+ skyfield_planets,
+ )
+ rise_label = rise.astimezone(tz).strftime("%H:%M") if rise is not None else None
+ set_label = set_.astimezone(tz).strftime("%H:%M") if set_ is not None else None
return rise_label, set_label
return build_rows_response(