Files
EskimueandClaude Sonnet 5 5652cc7e40 Neue Seite fuer Mond-Planeten-Bedeckungen mit Jahresberechnung hinzufuegen
Berechnet Bedeckungen der Planeten Merkur bis Neptun durch den Mond fuer
ein ganzes Jahr am Standardstandort, inklusive Detailkarte. Ereignisse
unter dem Horizont werden weiterhin angezeigt statt gefiltert, dafuer
mit eigenem Sichtbarkeits-Hinweis.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 09:35:36 +02:00

6776 lines
260 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
import base64
import json
import math
import os
import sys
from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
if SCRIPT_DIR not in sys.path:
sys.path.insert(0, SCRIPT_DIR)
import astronomy
import astronomical_conversions
import comets
from skyfield import almanac
MOON_RADIUS_KM = 1737.4
SYNODIC_MONTH = 29.530588853
PLANET_RADIUS_KM = {
astronomy.Body.Mercury: 2439.7,
astronomy.Body.Venus: 6051.8,
astronomy.Body.Mars: 3396.2,
astronomy.Body.Jupiter: astronomy.JUPITER_EQUATORIAL_RADIUS_KM,
astronomy.Body.Saturn: 60268.0,
astronomy.Body.Uranus: 25559.0,
astronomy.Body.Neptune: 24764.0,
}
STAR_BODIES = [
astronomy.Body.Star1,
astronomy.Body.Star2,
astronomy.Body.Star3,
astronomy.Body.Star4,
astronomy.Body.Star5,
astronomy.Body.Star6,
astronomy.Body.Star7,
astronomy.Body.Star8,
]
PHASE_LABELS = [
"Neumond",
"Erstes Viertel / zunehmende Sichel",
"Zunehmender Halbmond",
"Zweites Viertel",
"Vollmond",
"Drittes Viertel",
"Abnehmender Halbmond",
"Letztes Viertel / abnehmende Sichel",
]
def fail(message: str, *, extra: dict | None = None, code: int = 1) -> None:
payload = {"ok": False, "error": message}
if extra:
payload.update(extra)
print(json.dumps(payload, ensure_ascii=True))
raise SystemExit(code)
def parse_float(value: str, label: str) -> float:
try:
return float(value)
except ValueError as exc:
fail(f"{label} ist ungueltig.", extra={"details": str(exc), "value": value})
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 jupiter_emission_datetime(observation_dt: datetime) -> datetime:
"""Berechnet den Emissionszeitpunkt für eine Beobachtung von der Erde."""
observation_dt = observation_dt.astimezone(timezone.utc)
emission_dt = observation_dt
# Die Jupiterentfernung wird am zunächst geschätzten Emissionszeitpunkt
# neu bestimmt. Zwei Durchläufe reichen für die Lichtlaufzeit im
# Jupiter-System deutlich aus.
for _ in range(2):
geo = astronomy.GeoVector(
astronomy.Body.Jupiter,
dt_to_time(emission_dt),
True,
)
distance_au = math.sqrt(
(float(geo.x) * float(geo.x))
+ (float(geo.y) * float(geo.y))
+ (float(geo.z) * float(geo.z))
)
emission_dt = observation_dt - timedelta(days=distance_au / astronomy.C_AUDAY)
return emission_dt
def time_to_datetime(time_value: astronomy.Time) -> datetime:
year, month, day, hour, minute, second = time_value.Calendar()
second_int = int(second)
microsecond = int(round((second - second_int) * 1_000_000))
if microsecond >= 1_000_000:
second_int += 1
microsecond -= 1_000_000
return datetime(year, month, day, hour, minute, second_int, microsecond, tzinfo=timezone.utc)
def serialize_event(label: str, event_time: astronomy.Time | None, tz: ZoneInfo) -> dict:
if event_time is None:
return {"label": label, "found": False}
utc_dt = time_to_datetime(event_time)
local_dt = utc_dt.astimezone(tz)
return {
"label": label,
"found": True,
"utc_iso": utc_dt.isoformat().replace("+00:00", "Z"),
"local_iso": local_dt.isoformat(),
"local_time": local_dt.strftime("%H:%M"),
"local_date": local_dt.strftime("%Y-%m-%d"),
}
def normalize_longitude_deg(value: float) -> float:
value = math.fmod(value, 360.0)
if value < 0.0:
value += 360.0
return value
def action_solar_longitude_to_datetime(args: list[str]) -> dict:
if len(args) != 3:
fail(
"Aktion solar_longitude_to_datetime erwartet 3 Argumente: lambda_target_deg year timezone",
extra={"argv": args},
)
lambda_target_deg = parse_float(args[0], "Solare Laenge")
lambda_target_deg = normalize_longitude_deg(lambda_target_deg)
try:
year = int(args[1])
except ValueError as exc:
fail("Jahr ist ungueltig.", extra={"details": str(exc), "argv": args})
timezone_name = args[2]
try:
tz = ZoneInfo(timezone_name)
except Exception as exc: # pragma: no cover
fail("Zeitzone ist ungueltig oder auf dem Server nicht verfuegbar.", extra={"details": str(exc)})
start_dt_utc = datetime(year, 1, 1, 0, 0, 0, tzinfo=timezone.utc)
start_time = dt_to_time(start_dt_utc)
lambda0_deg = float(astronomy.SunPosition(start_time).elon)
delta_lambda_deg = normalize_longitude_deg(lambda_target_deg - lambda0_deg)
delta_t_days = delta_lambda_deg / 0.98564736
estimated_dt_utc = start_dt_utc + timedelta(days=delta_t_days)
search_start_dt_utc = estimated_dt_utc - timedelta(days=5)
search_start_time = dt_to_time(search_start_dt_utc)
result_time = astronomy.SearchSunLongitude(lambda_target_deg, search_start_time, 10.0)
if result_time is None:
fail(
"Kein Zeitpunkt fuer die angegebene solare Laenge gefunden.",
extra={
"lambda_target_deg": lambda_target_deg,
"year": year,
"timezone": timezone_name,
},
)
utc_dt = time_to_datetime(result_time)
local_dt = utc_dt.astimezone(tz)
return {
"ok": True,
"action": "solar_longitude_to_datetime",
"input": {
"lambda_target_deg": lambda_target_deg,
"year": year,
"timezone": timezone_name,
},
"reference": {
"utc_iso": start_dt_utc.isoformat().replace("+00:00", "Z"),
"lambda0_deg": lambda0_deg,
},
"estimate": {
"delta_lambda_deg": delta_lambda_deg,
"delta_t_days": delta_t_days,
"estimated_utc_iso": estimated_dt_utc.isoformat().replace("+00:00", "Z"),
},
"result": {
"utc_iso": utc_dt.isoformat().replace("+00:00", "Z"),
"local_iso": local_dt.isoformat(),
"local_date": local_dt.strftime("%Y-%m-%d"),
"local_time": local_dt.strftime("%H:%M:%S"),
"jd_tt": float(result_time.tt) + 2451545.0,
"jd_ut": float(result_time.ut) + 2451545.0,
},
}
def action_current_solar_longitude(args: list[str]) -> dict:
if len(args) != 1:
fail(
"Aktion current_solar_longitude erwartet 1 Argument: timezone",
extra={"argv": args},
)
timezone_name = args[0]
try:
tz = ZoneInfo(timezone_name)
except Exception as exc: # pragma: no cover
fail("Zeitzone ist ungueltig oder auf dem Server nicht verfuegbar.", extra={"details": str(exc)})
utc_dt = datetime.now(timezone.utc)
time_value = dt_to_time(utc_dt)
solar_longitude_deg = float(astronomy.SunPosition(time_value).elon)
local_dt = utc_dt.astimezone(tz)
return {
"ok": True,
"action": "current_solar_longitude",
"result": {
"solar_longitude_deg": solar_longitude_deg,
"utc_iso": utc_dt.isoformat().replace("+00:00", "Z"),
"local_iso": local_dt.isoformat(),
"local_date": local_dt.strftime("%Y-%m-%d"),
"local_time": local_dt.strftime("%H:%M:%S"),
},
}
def action_astronomical_conversions(args: list[str]) -> dict:
if len(args) < 1:
fail(
"Aktion astronomical_conversions erwartet mindestens 1 Argument: kind",
extra={"argv": args},
)
kind = str(args[0]).strip().lower()
payload: dict[str, str | None] = {"kind": kind}
if kind == "distance":
if len(args) != 3:
fail(
"Aktion astronomical_conversions fuer distance erwartet 3 Argumente: kind sourceUnit sourceValue",
extra={"argv": args},
)
payload["sourceUnit"] = args[1]
payload["sourceValue"] = args[2]
elif kind == "redshift":
if len(args) != 3:
fail(
"Aktion astronomical_conversions fuer redshift erwartet 3 Argumente: kind z restNm",
extra={"argv": args},
)
payload["z"] = args[1]
payload["restNm"] = args[2]
elif kind == "time":
if len(args) != 4:
fail(
"Aktion astronomical_conversions fuer time erwartet 4 Argumente: kind sourceUnit sourceValue longitude",
extra={"argv": args},
)
payload["sourceUnit"] = args[1]
payload["sourceValue"] = args[2]
payload["longitude"] = args[3]
elif kind == "coordinates":
if len(args) != 4:
fail(
"Aktion astronomical_conversions fuer coordinates erwartet 4 Argumente: kind sourceSystem value1 value2",
extra={"argv": args},
)
payload["sourceSystem"] = args[1]
payload["value1"] = args[2]
payload["value2"] = args[3]
elif kind == "angle":
if len(args) != 3:
fail(
"Aktion astronomical_conversions fuer angle erwartet 3 Argumente: kind sourceUnit sourceValue",
extra={"argv": args},
)
payload["sourceUnit"] = args[1]
payload["sourceValue"] = args[2]
elif kind == "tools":
if len(args) != 6:
fail(
"Aktion astronomical_conversions fuer tools erwartet 6 Argumente: kind tool dateTimeLocal latitude longitude timeZone",
extra={"argv": args},
)
payload["tool"] = args[1]
payload["dateTimeLocal"] = args[2]
payload["latitude"] = args[3]
payload["longitude"] = args[4]
payload["timeZone"] = args[5]
else:
fail(
"Unbekannter Umrechnungsbereich fuer astronomical_conversions.",
extra={"kind": kind, "argv": args},
)
try:
return astronomical_conversions.handle_request(payload)
except ValueError as exc:
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 parse_comet_payload_arg(payload_arg: str, action_name: str) -> list[dict]:
try:
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(f"Payload fuer {action_name} ist ungueltig.", extra={"details": str(exc)})
if not isinstance(payload, list):
fail(f"Payload fuer {action_name} muss eine JSON-Liste sein.")
return [item for item in payload if isinstance(item, dict)]
def parse_json_payload_arg(payload_arg: str, action_name: str) -> dict:
try:
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(f"Payload fuer {action_name} ist ungueltig.", extra={"details": str(exc)})
if not isinstance(payload, dict):
fail(f"Payload fuer {action_name} muss ein JSON-Objekt sein.")
return payload
def iso_date_from_local_datetime(local_dt: datetime) -> str:
return f"{local_dt.year:04d}-{local_dt.month:02d}-{local_dt.day:02d}"
def build_segments_from_mask(mask: list[bool], step_minutes: int) -> list[list[float]]:
segments: list[list[float]] = []
start_index: int | None = None
for index, is_active in enumerate(mask):
if is_active and start_index is None:
start_index = index
elif not is_active and start_index is not None:
segments.append([start_index * step_minutes / 60.0, index * step_minutes / 60.0])
start_index = None
if start_index is not None:
segments.append([start_index * step_minutes / 60.0, len(mask) * step_minutes / 60.0])
return segments
def serialize_interval_from_segments(segments: list[list[float]]) -> dict:
if not segments:
return {"start": None, "end": None, "full": False, "empty": True, "segments": []}
if len(segments) == 1 and segments[0][0] <= 0.0 and segments[0][1] >= 24.0:
return {"start": 0.0, "end": 24.0, "full": True, "empty": False, "segments": [[0.0, 24.0]]}
return {
"start": segments[0][0],
"end": segments[-1][1],
"full": False,
"empty": False,
"segments": segments,
}
def merge_segments(segments: list[list[float]], tolerance_hours: float = 1e-9) -> list[list[float]]:
if not segments:
return []
ordered = sorted(
[[float(segment[0]), float(segment[1])] for segment in segments if segment[1] - segment[0] > tolerance_hours],
key=lambda segment: segment[0],
)
if not ordered:
return []
merged = [ordered[0]]
for start, end in ordered[1:]:
previous = merged[-1]
if start <= previous[1] + tolerance_hours:
previous[1] = max(previous[1], end)
else:
merged.append([start, end])
return merged
def action_twilight_chart(args: list[str]) -> dict:
if len(args) != 1:
fail(
"Aktion twilight_chart erwartet 1 Argument: payloadBase64 oder @payloadDatei",
extra={"argv": args},
)
payload = parse_json_payload_arg(args[0], "twilight_chart")
try:
latitude = float(payload.get("latitude"))
longitude = float(payload.get("longitude"))
except Exception as exc:
fail("Breitengrad oder Laengengrad sind ungueltig.", extra={"details": str(exc)})
if not math.isfinite(latitude) or latitude < -90.0 or latitude > 90.0:
fail("Breitengrad ist ungueltig.")
if not math.isfinite(longitude) or longitude < -180.0 or longitude > 180.0:
fail("Laengengrad ist ungueltig.")
time_zone_name = str(payload.get("timeZone") or "").strip()
if time_zone_name == "":
fail("Zeitzone fehlt.")
try:
tz = ZoneInfo(time_zone_name)
except Exception as exc:
fail("Zeitzone ist ungueltig oder auf dem Server nicht verfuegbar.", extra={"details": str(exc)})
start_date_text = str(payload.get("startDate") or "").strip()
end_date_text = str(payload.get("endDate") or "").strip()
try:
start_date = datetime.strptime(start_date_text, "%Y-%m-%d").date()
end_date = datetime.strptime(end_date_text, "%Y-%m-%d").date()
except ValueError as exc:
fail("Start- oder Enddatum sind ungueltig.", extra={"details": str(exc)})
if start_date > end_date:
fail("Das Startdatum muss vor oder gleich dem Enddatum liegen.")
total_days = (end_date - start_date).days + 1
# Fuer die Dämmerungsgrafik verwenden wir bewusst Skyfield plus lokale JPL-Ephemeriden,
# damit die Zustandswechsel zwischen Nacht, Dämmerung und Tag konsistent mit der
# genaueren Sonnenstandsberechnung im Tool-Bereich bleiben.
timescale, planets = astronomical_conversions.skyfield_context()
observer = astronomical_conversions.skyfield_wgs84.latlon(latitude, longitude)
twilight_function = almanac.dark_twilight_day(planets, observer)
range_start_local = datetime(start_date.year, start_date.month, start_date.day, 0, 0, 0, tzinfo=tz)
range_end_date = end_date + timedelta(days=1)
range_end_local = datetime(range_end_date.year, range_end_date.month, range_end_date.day, 0, 0, 0, tzinfo=tz)
range_start_time = timescale.from_datetime(range_start_local.astimezone(timezone.utc))
range_end_time = timescale.from_datetime(range_end_local.astimezone(timezone.utc))
transition_times, transition_states = almanac.find_discrete(range_start_time, range_end_time, twilight_function)
transition_utc_datetimes = [moment.astimezone(timezone.utc) for moment in transition_times.utc_datetime()]
day_curves: list[dict] = []
longest_day_minutes = -1
longest_day_index = 0
longest_night_minutes = -1
longest_night_index = 0
transition_index = 0
for day_index in range(total_days):
local_day = start_date + timedelta(days=day_index)
day_start_local = datetime(local_day.year, local_day.month, local_day.day, 0, 0, 0, tzinfo=tz)
next_day = local_day + timedelta(days=1)
day_end_local = datetime(next_day.year, next_day.month, next_day.day, 0, 0, 0, tzinfo=tz)
day_start_utc = day_start_local.astimezone(timezone.utc)
day_end_utc = day_end_local.astimezone(timezone.utc)
day_start_time = timescale.from_datetime(day_start_utc)
start_state = int(twilight_function(day_start_time))
state_segments: dict[int, list[list[float]]] = {phase: [] for phase in range(5)}
current_state = start_state
current_hour = 0.0
while transition_index < len(transition_utc_datetimes) and transition_utc_datetimes[transition_index] < day_start_utc:
transition_index += 1
day_event_index = transition_index
while day_event_index < len(transition_utc_datetimes):
event_utc = transition_utc_datetimes[day_event_index]
if event_utc >= day_end_utc:
break
event_local = event_utc.astimezone(tz)
event_hour = (
event_local.hour
+ (event_local.minute / 60.0)
+ (event_local.second / 3600.0)
+ (event_local.microsecond / 3_600_000_000.0)
)
event_hour = min(24.0, max(0.0, event_hour))
if event_hour > current_hour:
state_segments[current_state].append([current_hour, event_hour])
current_state = int(transition_states[day_event_index])
current_hour = event_hour
day_event_index += 1
if current_hour < 24.0:
state_segments[current_state].append([current_hour, 24.0])
transition_index = day_event_index
day_segments = merge_segments(state_segments[4])
civil_band_segments = merge_segments(state_segments[3])
nautical_band_segments = merge_segments(state_segments[2])
astronomical_band_segments = merge_segments(state_segments[1])
night_segments = merge_segments(state_segments[0])
civil_segments = merge_segments(day_segments + civil_band_segments)
nautical_segments = merge_segments(civil_segments + nautical_band_segments)
astronomical_segments = merge_segments(nautical_segments + astronomical_band_segments)
intervals = {
"day": serialize_interval_from_segments(day_segments),
"civil": serialize_interval_from_segments(civil_segments),
"nautical": serialize_interval_from_segments(nautical_segments),
"astronomical": serialize_interval_from_segments(astronomical_segments),
"bands": {
"day": day_segments,
"civil": civil_band_segments,
"nautical": nautical_band_segments,
"astronomical": astronomical_band_segments,
},
}
day_curves.append(intervals)
day_minutes = int(round(sum((segment[1] - segment[0]) * 60.0 for segment in day_segments)))
night_minutes = int(round(sum((segment[1] - segment[0]) * 60.0 for segment in night_segments)))
if day_minutes > longest_day_minutes:
longest_day_minutes = day_minutes
longest_day_index = day_index
if night_minutes > longest_night_minutes:
longest_night_minutes = night_minutes
longest_night_index = day_index
return {
"ok": True,
"action": "twilight_chart",
"result": {
"totalDays": total_days,
"dayCurves": day_curves,
"longestDayMinutes": longest_day_minutes,
"longestDayIndex": longest_day_index,
"longestNightMinutes": longest_night_minutes,
"longestNightIndex": longest_night_index,
"startDate": start_date_text,
"endDate": end_date_text,
"timeZone": time_zone_name,
},
}
def search_event(
body: astronomy.Body,
direction: astronomy.Direction,
observer: astronomy.Observer,
start_time: astronomy.Time,
end_utc: datetime,
) -> astronomy.Time | None:
search_limit_days = (end_utc - time_to_datetime(start_time)).total_seconds() / 86400.0
if search_limit_days <= 0:
return None
result = astronomy.SearchRiseSet(body, observer, direction, start_time, search_limit_days)
if result is None:
return None
result_dt = time_to_datetime(result)
if result_dt >= end_utc:
return None
return result
def action_sun_moon_rise_set(args: list[str]) -> dict:
if len(args) != 5:
fail(
"Aktion sun_moon_rise_set erwartet 5 Argumente: latitude longitude elevation date timezone",
extra={"argv": args},
)
latitude = parse_float(args[0], "Latitude")
longitude = parse_float(args[1], "Longitude")
elevation = parse_float(args[2], "Elevation")
date_text = args[3]
timezone_name = args[4]
try:
datetime.strptime(date_text, "%Y-%m-%d")
except ValueError:
fail("Datum muss im Format YYYY-MM-DD uebergeben werden.", extra={"date": date_text})
try:
tz = ZoneInfo(timezone_name)
except Exception as exc: # pragma: no cover
fail("Zeitzone ist ungueltig oder auf dem Server nicht verfuegbar.", extra={"details": str(exc)})
observer = astronomy.Observer(latitude, longitude, elevation)
local_start = datetime.strptime(date_text, "%Y-%m-%d").replace(tzinfo=tz)
local_end = local_start + timedelta(days=1)
utc_start = local_start.astimezone(timezone.utc)
utc_end = local_end.astimezone(timezone.utc)
start_time = dt_to_time(utc_start)
sun_rise = search_event(astronomy.Body.Sun, astronomy.Direction.Rise, observer, start_time, utc_end)
sun_set = search_event(astronomy.Body.Sun, astronomy.Direction.Set, observer, start_time, utc_end)
moon_rise = search_event(astronomy.Body.Moon, astronomy.Direction.Rise, observer, start_time, utc_end)
moon_set = search_event(astronomy.Body.Moon, astronomy.Direction.Set, observer, start_time, utc_end)
def search_altitude(direction: astronomy.Direction, altitude_deg: float) -> astronomy.Time | None:
limit_days = (utc_end - time_to_datetime(start_time)).total_seconds() / 86400.0
result = astronomy.SearchAltitude(astronomy.Body.Sun, observer, direction, start_time, limit_days, altitude_deg)
if result is None:
return None
if time_to_datetime(result) >= utc_end:
return None
return result
# Morgens: Sonne steigt auf -> Direction.Rise (Hoehe wird groesser)
# Abends: Sonne sinkt ab -> Direction.Set (Hoehe wird kleiner)
astro_dawn = search_altitude(astronomy.Direction.Rise, -18.0)
nautical_dawn = search_altitude(astronomy.Direction.Rise, -12.0)
civil_dawn = search_altitude(astronomy.Direction.Rise, -6.0)
civil_dusk = search_altitude(astronomy.Direction.Set, -6.0)
nautical_dusk = search_altitude(astronomy.Direction.Set, -12.0)
astro_dusk = search_altitude(astronomy.Direction.Set, -18.0)
return {
"ok": True,
"action": "sun_moon_rise_set",
"date": date_text,
"timezone": timezone_name,
"observer": {
"latitude": latitude,
"longitude": longitude,
"elevation": elevation,
},
"window": {
"local_start": local_start.isoformat(),
"local_end": local_end.isoformat(),
"utc_start": utc_start.isoformat().replace("+00:00", "Z"),
"utc_end": utc_end.isoformat().replace("+00:00", "Z"),
},
"events": {
"astro_dawn": serialize_event("Astronomische Morgendämmerung", astro_dawn, tz),
"nautical_dawn": serialize_event("Nautische Morgendämmerung", nautical_dawn, tz),
"civil_dawn": serialize_event("Bürgerliche Morgendämmerung", civil_dawn, tz),
"sunrise": serialize_event("Sonnenaufgang", sun_rise, tz),
"sunset": serialize_event("Sonnenuntergang", sun_set, tz),
"civil_dusk": serialize_event("Bürgerliche Abenddämmerung", civil_dusk, tz),
"nautical_dusk": serialize_event("Nautische Abenddämmerung", nautical_dusk, tz),
"astro_dusk": serialize_event("Astronomische Abenddämmerung", astro_dusk, tz),
"moonrise": serialize_event("Mondaufgang", moon_rise, tz),
"moonset": serialize_event("Monduntergang", moon_set, tz),
},
}
PLANETS = [
("Merkur", astronomy.Body.Mercury),
("Venus", astronomy.Body.Venus),
("Mars", astronomy.Body.Mars),
("Jupiter", astronomy.Body.Jupiter),
("Saturn", astronomy.Body.Saturn),
("Uranus", astronomy.Body.Uranus),
("Neptun", astronomy.Body.Neptune),
]
GOLDEN_GATE_PLANETS = [
("Merkur", astronomy.Body.Mercury, "mercury"),
("Venus", astronomy.Body.Venus, "venus"),
("Mars", astronomy.Body.Mars, "mars"),
("Jupiter", astronomy.Body.Jupiter, "jupiter"),
("Saturn", astronomy.Body.Saturn, "saturn"),
]
# Das "Goldene Tor der Ekliptik" wird hier pragmatisch als Korridor
# zwischen Plejaden und Aldebaran/Hyaden modelliert.
GOLDEN_GATE_PLEIADES_RA_HOURS = 3.7833
GOLDEN_GATE_PLEIADES_DEC_DEG = 24.1167
GOLDEN_GATE_HYADES_RA_HOURS = 4.5987
GOLDEN_GATE_HYADES_DEC_DEG = 16.5093
GOLDEN_GATE_HALF_WIDTH_DEG = 3.6
GOLDEN_GATE_MIN_PLANET_ALTITUDE_DEG = 5.0
GOLDEN_GATE_MAX_SUN_ALTITUDE_DEG = -6.0
GOLDEN_GATE_MIN_SOLAR_SEPARATION_DEG = 15.0
EPHEMERIS_BODIES = {
"Sun": ("Sonne", astronomy.Body.Sun),
"Moon": ("Mond", astronomy.Body.Moon),
"Mercury": ("Merkur", astronomy.Body.Mercury),
"Venus": ("Venus", astronomy.Body.Venus),
"Earth": ("Erde", astronomy.Body.Earth),
"Mars": ("Mars", astronomy.Body.Mars),
"Jupiter": ("Jupiter", astronomy.Body.Jupiter),
"Saturn": ("Saturn", astronomy.Body.Saturn),
"Uranus": ("Uranus", astronomy.Body.Uranus),
"Neptune": ("Neptun", astronomy.Body.Neptune),
}
PLANET_VISIBILITY_ROWS = [
{
"key": "mercury",
"name": "MERKUR",
"body": astronomy.Body.Mercury,
"symbol": "Me",
"color": "#b0b0c8",
"glow": "rgba(176,176,200,.45)",
"type": "planet",
},
{
"key": "venus",
"name": "VENUS",
"body": astronomy.Body.Venus,
"symbol": "Ve",
"color": "#f5e090",
"glow": "rgba(245,224,144,.55)",
"type": "planet",
},
{
"key": "mars",
"name": "MARS",
"body": astronomy.Body.Mars,
"symbol": "Ma",
"color": "#e8602a",
"glow": "rgba(232,96,42,.5)",
"type": "planet",
},
{
"key": "jupiter",
"name": "JUPITER",
"body": astronomy.Body.Jupiter,
"symbol": "Ju",
"color": "#d4b880",
"glow": "rgba(212,184,128,.5)",
"type": "planet",
},
{
"key": "saturn",
"name": "SATURN",
"body": astronomy.Body.Saturn,
"symbol": "Sa",
"color": "#e8d898",
"glow": "rgba(232,216,152,.45)",
"type": "planet",
},
{
"key": "uranus",
"name": "URANUS",
"body": astronomy.Body.Uranus,
"symbol": "Ur",
"color": "#70e0e8",
"glow": "rgba(112,224,232,.4)",
"type": "planet",
},
{
"key": "neptune",
"name": "NEPTUN",
"body": astronomy.Body.Neptune,
"symbol": "Ne",
"color": "#4070e0",
"glow": "rgba(64,112,224,.4)",
"type": "planet",
},
]
def hour_from_utc_start(event_time: astronomy.Time | None, utc_start: datetime) -> float | None:
if event_time is None:
return None
return (time_to_datetime(event_time) - utc_start).total_seconds() / 3600.0
def build_visibility_segments(
body: astronomy.Body,
observer: astronomy.Observer,
utc_start: datetime,
utc_end: datetime,
) -> tuple[list[dict], float | None, float | None]:
start_time = dt_to_time(utc_start)
start_eq = astronomy.Equator(body, start_time, observer, True, True)
start_hor = astronomy.Horizon(start_time, observer, start_eq.ra, start_eq.dec, astronomy.Refraction.Normal)
is_up = float(start_hor.altitude) > 0.0
events: list[dict] = []
def gather(direction: astronomy.Direction) -> None:
search_dt = utc_start - timedelta(minutes=15)
while True:
limit_days = max(0.01, min(3.0, (utc_end + timedelta(hours=2) - search_dt).total_seconds() / 86400.0))
event_time = astronomy.SearchRiseSet(body, observer, direction, dt_to_time(search_dt), limit_days, 1.05)
if event_time is None:
break
hour_value = hour_from_utc_start(event_time, utc_start)
if hour_value is None or hour_value > 24.1:
break
events.append({
"hour": hour_value,
"type": "rise" if direction == astronomy.Direction.Rise else "set",
})
search_dt = time_to_datetime(event_time) + timedelta(minutes=1)
gather(astronomy.Direction.Rise)
gather(astronomy.Direction.Set)
events.sort(key=lambda item: item["hour"])
segments: list[dict] = []
segment_start = 0.0 if is_up else None
for event in events:
clamped_hour = max(-0.1, min(24.1, float(event["hour"])))
if event["type"] == "rise" and not is_up:
segment_start = max(0.0, clamped_hour)
is_up = True
elif event["type"] == "set" and is_up:
segment_end = min(24.0, clamped_hour)
if segment_start is not None and segment_end > segment_start:
segments.append({"from": segment_start, "to": segment_end})
segment_start = None
is_up = False
if is_up and segment_start is not None:
segments.append({"from": segment_start, "to": 24.0})
actual_rise_hour = segments[0]["from"] if segments else None
actual_set_hour = segments[-1]["to"] if segments else None
if segments and segments[0]["from"] < 0.08:
search_dt = utc_start - timedelta(hours=36)
previous_rise: astronomy.Time | None = None
while True:
limit_days = max(0.01, min(4.0, (utc_start - search_dt).total_seconds() / 86400.0 + 1.0))
rise_time = astronomy.SearchRiseSet(body, observer, astronomy.Direction.Rise, dt_to_time(search_dt), limit_days, 2.5)
if rise_time is None:
break
rise_dt = time_to_datetime(rise_time)
if rise_dt >= utc_start:
break
previous_rise = rise_time
search_dt = rise_dt + timedelta(minutes=1)
previous_rise_hour = hour_from_utc_start(previous_rise, utc_start)
if previous_rise_hour is not None:
actual_rise_hour = previous_rise_hour
if segments and segments[-1]["to"] > 23.95:
limit_days = 2.0
next_set = astronomy.SearchRiseSet(
body,
observer,
astronomy.Direction.Set,
dt_to_time(utc_start + timedelta(hours=23)),
limit_days,
2.0,
)
next_set_hour = hour_from_utc_start(next_set, utc_start)
if next_set_hour is not None and next_set_hour > 23.95:
actual_set_hour = next_set_hour
return segments, actual_rise_hour, actual_set_hour
def compute_best_segments(
segments: list[dict],
astro_dawn_hour: float | None,
astro_dusk_hour: float | None,
) -> list[dict]:
if astro_dawn_hour is None or astro_dusk_hour is None:
return []
result: list[dict] = []
for segment in segments:
start_hour = float(segment["from"])
end_hour = float(segment["to"])
if start_hour < astro_dawn_hour:
best_end = min(end_hour, astro_dawn_hour)
if best_end - start_hour > 0.05:
result.append({"from": start_hour, "to": best_end})
if end_hour > astro_dusk_hour:
best_start = max(start_hour, astro_dusk_hour)
best_end = min(end_hour, 24.0)
if best_end - best_start > 0.05:
result.append({"from": best_start, "to": best_end})
return result
def moon_phase_code(phase_angle: float) -> tuple[str, bool]:
waning = phase_angle > 180.0
if phase_angle < 22.5 or phase_angle >= 337.5:
return "NM", waning
if not waning and phase_angle < 67.5:
return "ZS", waning
if not waning and phase_angle < 112.5:
return "ZH", waning
if not waning and phase_angle < 157.5:
return "ZG", waning
if 157.5 <= phase_angle < 202.5:
return "VM", waning
if waning and phase_angle < 247.5:
return "AG", waning
if waning and phase_angle < 292.5:
return "AH", waning
return "AS", waning
def action_planet_visibility_chart(args: list[str]) -> dict:
if len(args) != 5:
fail(
"Aktion planet_visibility_chart erwartet 5 Argumente: latitude longitude elevation date timezone",
extra={"argv": args},
)
latitude = parse_float(args[0], "Latitude")
longitude = parse_float(args[1], "Longitude")
elevation = parse_float(args[2], "Elevation")
date_text = args[3]
timezone_name = args[4]
try:
local_start = datetime.strptime(date_text, "%Y-%m-%d")
except ValueError:
fail("Datum muss im Format YYYY-MM-DD uebergeben werden.", extra={"date": date_text})
try:
tz = ZoneInfo(timezone_name)
except Exception as exc:
fail("Zeitzone ist ungueltig oder auf dem Server nicht verfuegbar.", extra={"details": str(exc)})
local_start = local_start.replace(tzinfo=tz)
local_noon = local_start + timedelta(hours=12)
local_end = local_start + timedelta(days=1)
utc_start = local_start.astimezone(timezone.utc)
utc_end = local_end.astimezone(timezone.utc)
observer = astronomy.Observer(latitude, longitude, elevation)
start_time = dt_to_time(utc_start)
def search_altitude(direction: astronomy.Direction, altitude_deg: float) -> astronomy.Time | None:
limit_days = (utc_end - utc_start).total_seconds() / 86400.0
result = astronomy.SearchAltitude(astronomy.Body.Sun, observer, direction, start_time, limit_days, altitude_deg)
if result is None:
return None
if time_to_datetime(result) >= utc_end:
return None
return result
sun_rise = search_event(astronomy.Body.Sun, astronomy.Direction.Rise, observer, start_time, utc_end)
sun_set = search_event(astronomy.Body.Sun, astronomy.Direction.Set, observer, start_time, utc_end)
civil_dawn = search_altitude(astronomy.Direction.Rise, -6.0)
civil_dusk = search_altitude(astronomy.Direction.Set, -6.0)
nautical_dawn = search_altitude(astronomy.Direction.Rise, -12.0)
nautical_dusk = search_altitude(astronomy.Direction.Set, -12.0)
astro_dawn = search_altitude(astronomy.Direction.Rise, -18.0)
astro_dusk = search_altitude(astronomy.Direction.Set, -18.0)
twilight = {
"sunRiseH": hour_from_utc_start(sun_rise, utc_start),
"sunSetH": hour_from_utc_start(sun_set, utc_start),
"civDawnH": hour_from_utc_start(civil_dawn, utc_start),
"civDuskH": hour_from_utc_start(civil_dusk, utc_start),
"nautDawnH": hour_from_utc_start(nautical_dawn, utc_start),
"nautDuskH": hour_from_utc_start(nautical_dusk, utc_start),
"astroDawnH": hour_from_utc_start(astro_dawn, utc_start),
"astroDuskH": hour_from_utc_start(astro_dusk, utc_start),
}
moon_time = dt_to_time(local_noon.astimezone(timezone.utc))
moon_phase = float(astronomy.MoonPhase(moon_time))
moon_illumination = astronomy.Illumination(astronomy.Body.Moon, moon_time)
moon_label, moon_waning = moon_phase_code(moon_phase)
moon_segments, moon_rise_hour, moon_set_hour = build_visibility_segments(
astronomy.Body.Moon,
observer,
utc_start,
utc_end,
)
moon_best_segments = compute_best_segments(
moon_segments,
twilight["astroDawnH"],
twilight["astroDuskH"],
)
rows: list[dict] = []
for row_def in PLANET_VISIBILITY_ROWS:
midday_time = dt_to_time(local_noon.astimezone(timezone.utc))
illumination = astronomy.Illumination(row_def["body"], midday_time)
segments, rise_hour, set_hour = build_visibility_segments(
row_def["body"],
observer,
utc_start,
utc_end,
)
rows.append({
"key": row_def["key"],
"type": row_def["type"],
"name": row_def["name"],
"symbol": row_def["symbol"],
"color": row_def["color"],
"glow": row_def["glow"],
"segs": segments,
"bestSegs": compute_best_segments(
segments,
twilight["astroDawnH"],
twilight["astroDuskH"],
),
"actualRiseH": rise_hour,
"actualSetH": set_hour,
"magnitude": float(illumination.mag),
})
rows.append({
"key": "moon",
"type": "moon",
"name": "MOND",
"symbol": moon_label,
"color": "#d0d8f0",
"glow": "rgba(208,216,240,.55)",
"segs": moon_segments,
"bestSegs": moon_best_segments,
"actualRiseH": moon_rise_hour,
"actualSetH": moon_set_hour,
"illum": float(moon_illumination.phase_fraction),
"waning": moon_waning,
"phase": moon_phase,
"label": moon_label,
})
utc_offset_hours = local_noon.utcoffset().total_seconds() / 3600.0
timezone_abbr = local_noon.tzname() or timezone_name
return {
"ok": True,
"action": "planet_visibility_chart",
"date": date_text,
"timezone": timezone_name,
"timezone_abbr": timezone_abbr,
"utc_offset_hours": utc_offset_hours,
"location": {
"latitude": latitude,
"longitude": longitude,
"elevation": elevation,
},
"twilight": twilight,
"rows": rows,
"moon": {
"illum": float(moon_illumination.phase_fraction),
"waning": moon_waning,
"phase": moon_phase,
"label": moon_label,
},
}
def action_planet_rise_set(args: list[str]) -> dict:
if len(args) != 5:
fail(
"Aktion planet_rise_set erwartet 5 Argumente: latitude longitude elevation date timezone",
extra={"argv": args},
)
latitude = parse_float(args[0], "Latitude")
longitude = parse_float(args[1], "Longitude")
elevation = parse_float(args[2], "Elevation")
date_text = args[3]
timezone_name = args[4]
try:
datetime.strptime(date_text, "%Y-%m-%d")
except ValueError:
fail("Datum muss im Format YYYY-MM-DD uebergeben werden.", extra={"date": date_text})
try:
tz = ZoneInfo(timezone_name)
except Exception as exc:
fail("Zeitzone ist ungueltig.", extra={"details": str(exc)})
observer = astronomy.Observer(latitude, longitude, elevation)
local_start = datetime.strptime(date_text, "%Y-%m-%d").replace(tzinfo=tz)
local_end = local_start + timedelta(days=1)
utc_start = local_start.astimezone(timezone.utc)
utc_end = local_end.astimezone(timezone.utc)
start_time = dt_to_time(utc_start)
planets = []
for name, body in PLANETS:
rise = search_event(body, astronomy.Direction.Rise, observer, start_time, utc_end)
set_ = search_event(body, astronomy.Direction.Set, observer, start_time, utc_end)
planets.append({
"name": name,
"rise": serialize_event(name + "aufgang", rise, tz),
"set": serialize_event(name + "untergang", set_, tz),
})
return {
"ok": True,
"action": "planet_rise_set",
"date": date_text,
"timezone": timezone_name,
"planets": planets,
}
def format_ra_hours(ra_hours: float) -> str:
total_seconds = int(round(float(ra_hours) * 3600.0))
total_seconds %= 24 * 3600
hours = total_seconds // 3600
minutes = (total_seconds % 3600) // 60
seconds = total_seconds % 60
return f"{hours:02d}:{minutes:02d}:{seconds:02d}"
def format_dec_deg(dec_deg: float) -> str:
sign = "+" if dec_deg >= 0 else "-"
total_seconds = int(round(abs(float(dec_deg)) * 3600.0))
degrees = total_seconds // 3600
minutes = (total_seconds % 3600) // 60
seconds = total_seconds % 60
return f"{sign}{degrees:02d}:{minutes:02d}:{seconds:02d}"
def days_in_month(year: int, month: int) -> int:
if month == 12:
next_month = datetime(year + 1, 1, 1)
else:
next_month = datetime(year, month + 1, 1)
this_month = datetime(year, month, 1)
return (next_month - this_month).days
def add_calendar_unit(base: datetime, amount: int, unit: str) -> datetime:
if unit == "minutes":
return base + timedelta(minutes=amount)
if unit == "hours":
return base + timedelta(hours=amount)
if unit == "days":
return base + timedelta(days=amount)
if unit == "weeks":
return base + timedelta(weeks=amount)
if unit == "months":
month_index = (base.month - 1) + amount
year = base.year + (month_index // 12)
month = (month_index % 12) + 1
day = min(base.day, days_in_month(year, month))
return base.replace(year=year, month=month, day=day)
if unit == "years":
year = base.year + amount
day = base.day
if base.month == 2 and base.day == 29:
day = min(day, days_in_month(year, base.month))
return base.replace(year=year, day=day)
fail("Zeitraum-Einheit ist ungueltig.", extra={"unit": unit})
def action_planet_ephemeris(args: list[str]) -> dict:
if len(args) != 8:
fail(
"Aktion planet_ephemeris erwartet 8 Argumente: latitude longitude elevation timezone body intervalMinutes rangeValue rangeUnit",
extra={"argv": args},
)
latitude = parse_float(args[0], "Latitude")
longitude = parse_float(args[1], "Longitude")
elevation = parse_float(args[2], "Elevation")
timezone_name = args[3]
body_name = str(args[4]).strip()
try:
interval_minutes = int(args[5])
range_value = int(args[6])
except ValueError as exc:
fail("Intervall oder Zeitraum ist ungueltig.", extra={"details": str(exc), "argv": args})
range_unit = str(args[7]).strip()
if interval_minutes <= 0:
fail("Intervall muss groesser als 0 sein.", extra={"interval_minutes": interval_minutes})
if range_value <= 0:
fail("Zeitraum muss groesser als 0 sein.", extra={"range_value": range_value})
if body_name not in EPHEMERIS_BODIES:
fail("Planet ist ungueltig.", extra={"body": body_name})
if range_unit not in {"minutes", "hours", "days", "weeks", "months", "years"}:
fail("Zeitraum-Einheit ist ungueltig.", extra={"range_unit": range_unit})
try:
tz = ZoneInfo(timezone_name)
except Exception as exc:
fail("Zeitzone ist ungueltig.", extra={"details": str(exc)})
label, body = EPHEMERIS_BODIES[body_name]
if body == astronomy.Body.Earth:
fail("Fuer die Erde kann von einem Standort auf der Erde keine sinnvolle geozentrische Ephemeride berechnet werden.")
observer = astronomy.Observer(latitude, longitude, elevation)
local_start = datetime.now(tz).replace(second=0, microsecond=0)
local_end = add_calendar_unit(local_start, range_value, range_unit)
rows = []
current_local = local_start
step = timedelta(minutes=interval_minutes)
rise_set_cache: dict[str, tuple[str | None, str | None]] = {}
max_rows = 5000
while current_local <= local_end:
if len(rows) >= max_rows:
fail(
"Die Anfrage erzeugt zu viele Tabellenzeilen. Bitte Zeitraum verkuerzen oder groesseres Intervall waehlen.",
extra={
"max_rows": max_rows,
"interval_minutes": interval_minutes,
"range_value": range_value,
"range_unit": range_unit,
},
)
current_utc = current_local.astimezone(timezone.utc)
time_value = dt_to_time(current_utc)
eq = astronomy.Equator(body, time_value, observer, True, True)
day_key = current_local.strftime("%Y-%m-%d")
if day_key not in rise_set_cache:
local_day_start = current_local.replace(hour=0, minute=0, second=0, microsecond=0)
local_day_end = local_day_start + timedelta(days=1)
day_start_time = dt_to_time(local_day_start.astimezone(timezone.utc))
day_end_utc = local_day_end.astimezone(timezone.utc)
rise = search_event(body, astronomy.Direction.Rise, observer, day_start_time, day_end_utc)
set_ = search_event(body, astronomy.Direction.Set, observer, day_start_time, day_end_utc)
rise_label = serialize_event("Aufgang", rise, tz).get("local_time") if rise is not None else None
set_label = serialize_event("Untergang", set_, tz).get("local_time") if set_ is not None else None
rise_set_cache[day_key] = (rise_label, set_label)
rise_label, set_label = rise_set_cache[day_key]
rows.append({
"object_name": label,
"datetime_local": current_local.strftime("%d.%m.%Y %H:%M"),
"ra": format_ra_hours(float(eq.ra)),
"dec": format_dec_deg(float(eq.dec)),
"rise": rise_label,
"set": set_label,
})
current_local += step
return {
"ok": True,
"action": "planet_ephemeris",
"observer": {
"latitude": latitude,
"longitude": longitude,
"elevation": elevation,
"timezone": timezone_name,
},
"object": {
"key": body_name,
"label": label,
},
"window": {
"local_start": local_start.isoformat(),
"local_end": local_end.isoformat(),
"interval_minutes": interval_minutes,
"range_value": range_value,
"range_unit": range_unit,
},
"rows": rows,
}
def body_altitude_deg(
body: astronomy.Body,
observer: astronomy.Observer,
dt_utc: datetime,
) -> float:
time_value = dt_to_time(dt_utc)
eq = astronomy.Equator(body, time_value, observer, True, True)
hor = astronomy.Horizon(time_value, observer, eq.ra, eq.dec, astronomy.Refraction.Normal)
return float(hor.altitude)
def body_horizontal_coords(
body: astronomy.Body,
observer: astronomy.Observer,
dt_utc: datetime,
) -> tuple[float, float]:
time_value = dt_to_time(dt_utc)
eq = astronomy.Equator(body, time_value, observer, True, True)
hor = astronomy.Horizon(time_value, observer, eq.ra, eq.dec, astronomy.Refraction.Normal)
return float(hor.altitude), float(hor.azimuth)
def comet_horizontal_coords(
comet: dict,
observer: astronomy.Observer,
dt_utc: datetime,
) -> dict | None:
brightness = comets.calculate_brightness(comet, dt_utc)
ra_hours = brightness.get("ra_hours")
dec_deg = brightness.get("dec_deg")
if ra_hours is None or dec_deg is None:
return None
time_value = dt_to_time(dt_utc)
hor = astronomy.Horizon(time_value, observer, float(ra_hours), float(dec_deg), astronomy.Refraction.Normal)
return {
"altitude_deg": float(hor.altitude),
"azimuth_deg": float(hor.azimuth),
"estimated_magnitude": brightness.get("estimated_magnitude"),
"heliocentric_distance_au": brightness.get("heliocentric_distance_au"),
"geocentric_distance_au": brightness.get("geocentric_distance_au"),
"ra_hours": float(ra_hours),
"dec_deg": float(dec_deg),
}
def body_constellation_info(
body: astronomy.Body,
dt_utc: datetime,
) -> astronomy.ConstellationInfo:
time_value = dt_to_time(dt_utc)
equ = astronomy.EquatorFromVector(astronomy.GeoVector(body, time_value, True))
return astronomy.Constellation(equ.ra, equ.dec)
def serialize_body_position(
key: str,
label: str,
body: astronomy.Body,
time_value: astronomy.Time,
observer: astronomy.Observer,
) -> dict:
eq = astronomy.Equator(body, time_value, observer, True, True)
hor = astronomy.Horizon(time_value, observer, eq.ra, eq.dec, astronomy.Refraction.Normal)
payload = {
"key": key,
"label": label,
"ra_hours": float(eq.ra),
"dec_deg": float(eq.dec),
"azimuth_deg": float(hor.azimuth),
"altitude_deg": float(hor.altitude),
"visible": float(hor.altitude) >= 0.0,
}
try:
illum = astronomy.Illumination(body, time_value)
payload["distance_au"] = float(illum.geo_dist)
payload["phase_fraction"] = float(illum.phase_fraction)
payload["magnitude"] = float(illum.mag)
except Exception:
pass
return payload
def action_favorite_comet_events_for_month(args: list[str]) -> dict:
if len(args) != 12:
fail(
"Aktion favorite_comet_events_for_month erwartet 12 Argumente: latitude longitude elevation year month timezone sample_offset_minutes min_comet_alt_deg max_sun_alt_deg max_comet_magnitude min_consecutive_days payloadBase64Oder@Datei",
extra={"argv": args},
)
latitude = parse_float(args[0], "Latitude")
longitude = parse_float(args[1], "Longitude")
elevation = parse_float(args[2], "Elevation")
try:
year = int(args[3])
month = int(args[4])
sample_offset_minutes = int(args[6])
min_consecutive_days = int(args[10])
except ValueError as exc:
fail("Jahr, Monat oder Kometenparameter sind ungueltig.", extra={"details": str(exc), "argv": args})
if month < 1 or month > 12:
fail("Monat muss zwischen 1 und 12 liegen.", extra={"month": month})
timezone_name = args[5]
min_comet_alt_deg = parse_float(args[7], "Minimale Kometenhoehe")
max_sun_alt_deg = parse_float(args[8], "Maximale Sonnenhoehe")
max_comet_magnitude = parse_float(args[9], "Maximale Kometenhelligkeit")
comet_payload = parse_comet_payload_arg(args[11], "favorite_comet_events_for_month")
try:
tz = ZoneInfo(timezone_name)
except Exception as exc:
fail("Zeitzone ist ungueltig oder auf dem Server nicht verfuegbar.", extra={"details": str(exc)})
observer = astronomy.Observer(latitude, longitude, elevation)
local_start = datetime(year, month, 1, 0, 0, 0, tzinfo=tz)
if month == 12:
local_end = datetime(year + 1, 1, 1, 0, 0, 0, tzinfo=tz)
else:
local_end = datetime(year, month + 1, 1, 0, 0, 0, tzinfo=tz)
utc_start = local_start.astimezone(timezone.utc)
utc_end = local_end.astimezone(timezone.utc)
utc_start_padded = utc_start - timedelta(days=1)
utc_end_padded = utc_end + timedelta(days=1)
def comet_name(comet: dict) -> str:
designation = str(comet.get("designation_and_name") or "").strip()
if designation != "":
return designation
packed = str(comet.get("designation_packed") or "").strip()
if packed != "":
return packed
comet_id = comet.get("id")
return f"Komet {comet_id}" if comet_id is not None else "Komet"
def append_perihelion_event(events: list[dict], comet: dict, name: str) -> None:
perihelion_utc = comets.build_perihelion_datetime(comet)
if perihelion_utc is None:
return
local_dt = perihelion_utc.astimezone(tz)
if not (local_start <= local_dt < local_end):
return
events.append({
"kind": "perihelion",
"comet_id": comets.parse_int(comet.get("id")),
"comet_name": name,
"label": f"Perihel von {name}",
"utc_iso": perihelion_utc.isoformat().replace("+00:00", "Z"),
"local_iso": local_dt.isoformat(),
"local_date": local_dt.strftime("%d.%m.%Y"),
"local_time": local_dt.strftime("%H:%M"),
})
def append_geocentric_minimum_event(events: list[dict], comet: dict, name: str) -> None:
sample_step = timedelta(hours=2)
sample_times: list[datetime] = []
sample_distances: list[float] = []
current_utc = utc_start_padded
while current_utc <= utc_end_padded:
brightness = comets.calculate_brightness(comet, current_utc)
distance_au = brightness.get("geocentric_distance_au")
if isinstance(distance_au, (int, float)):
sample_times.append(current_utc)
sample_distances.append(float(distance_au))
current_utc += sample_step
candidate_index = None
candidate_distance = None
for index in range(1, len(sample_distances) - 1):
current_distance = sample_distances[index]
if current_distance > sample_distances[index - 1] or current_distance > sample_distances[index + 1]:
continue
candidate_utc = sample_times[index]
if not (utc_start <= candidate_utc < utc_end):
continue
if candidate_distance is None or current_distance < candidate_distance:
candidate_index = index
candidate_distance = current_distance
if candidate_index is None:
return
rough_utc = sample_times[candidate_index]
best_utc = rough_utc
best_distance = candidate_distance if candidate_distance is not None else sample_distances[candidate_index]
refine_start = rough_utc - sample_step
refine_end = rough_utc + sample_step
refine_step = timedelta(minutes=5)
current_utc = refine_start
while current_utc <= refine_end:
brightness = comets.calculate_brightness(comet, current_utc)
distance_au = brightness.get("geocentric_distance_au")
if isinstance(distance_au, (int, float)) and float(distance_au) < best_distance:
best_distance = float(distance_au)
best_utc = current_utc
current_utc += refine_step
if not (utc_start <= best_utc < utc_end):
return
local_dt = best_utc.astimezone(tz)
events.append({
"kind": "geocentric_minimum",
"comet_id": comets.parse_int(comet.get("id")),
"comet_name": name,
"label": f"{name} in Erdnähe ({best_distance:.3f} AE)",
"distance_au": best_distance,
"utc_iso": best_utc.isoformat().replace("+00:00", "Z"),
"local_iso": local_dt.isoformat(),
"local_date": local_dt.strftime("%d.%m.%Y"),
"local_time": local_dt.strftime("%H:%M"),
})
def append_visibility_events(events: list[dict], comet: dict, name: str) -> None:
daily_checks: list[dict] = []
current_local = local_start
while current_local < local_end:
day_start_utc = current_local.astimezone(timezone.utc)
next_day_utc = (current_local + timedelta(days=1)).astimezone(timezone.utc)
start_time = dt_to_time(day_start_utc)
sunrise = search_event(astronomy.Body.Sun, astronomy.Direction.Rise, observer, start_time, next_day_utc)
sunset = search_event(astronomy.Body.Sun, astronomy.Direction.Set, observer, start_time, next_day_utc)
def build_period(sample_local: datetime | None) -> dict:
if sample_local is None:
return {
"ok": False,
"sample_local": None,
"comet_alt_deg": None,
"sun_alt_deg": None,
"estimated_magnitude": None,
}
sample_utc = sample_local.astimezone(timezone.utc)
comet_coords = comet_horizontal_coords(comet, observer, sample_utc)
if comet_coords is None:
return {
"ok": False,
"sample_local": sample_local,
"comet_alt_deg": None,
"sun_alt_deg": None,
"estimated_magnitude": None,
}
comet_alt_deg = float(comet_coords["altitude_deg"])
sun_alt_deg = body_altitude_deg(astronomy.Body.Sun, observer, sample_utc)
estimated_magnitude = comet_coords.get("estimated_magnitude")
is_ok = (
comet_alt_deg >= min_comet_alt_deg
and sun_alt_deg <= max_sun_alt_deg
and isinstance(estimated_magnitude, (int, float))
and float(estimated_magnitude) <= max_comet_magnitude
)
return {
"ok": is_ok,
"sample_local": sample_local,
"comet_alt_deg": comet_alt_deg,
"sun_alt_deg": sun_alt_deg,
"estimated_magnitude": float(estimated_magnitude) if isinstance(estimated_magnitude, (int, float)) else None,
}
morning_local = None
if sunrise is not None:
sunrise_local = time_to_datetime(sunrise).astimezone(tz)
morning_local = sunrise_local - timedelta(minutes=sample_offset_minutes)
evening_local = None
if sunset is not None:
sunset_local = time_to_datetime(sunset).astimezone(tz)
evening_local = sunset_local + timedelta(minutes=sample_offset_minutes)
daily_checks.append({
"date": current_local.strftime("%d.%m.%Y"),
"morning": build_period(morning_local),
"evening": build_period(evening_local),
})
current_local += timedelta(days=1)
def build_windows(period_key: str, label_text: str) -> list[dict]:
comet_events = []
start_index = None
for index, day in enumerate(daily_checks):
period = day[period_key]
is_ok = bool(period["ok"])
if is_ok and start_index is None:
start_index = index
is_last = index == len(daily_checks) - 1
if start_index is not None and (not is_ok or is_last):
end_index = index if (is_ok and is_last) else index - 1
duration_days = end_index - start_index + 1
if duration_days >= min_consecutive_days:
window_days = daily_checks[start_index:end_index + 1]
start_day = window_days[0]
end_day = window_days[-1]
start_local = start_day[period_key]["sample_local"]
if start_local is not None:
range_text = (
start_day["date"]
if start_index == end_index
else f'{start_day["date"]} bis {end_day["date"]}'
)
best_magnitude = None
for window_day in window_days:
magnitude = window_day[period_key]["estimated_magnitude"]
if magnitude is None:
continue
best_magnitude = magnitude if best_magnitude is None else min(best_magnitude, magnitude)
label = f"Gute Sichtbarkeit von {name} am {label_text} ({range_text})"
if best_magnitude is not None:
label += f" bis ca. {best_magnitude:.1f} mag"
end_local = end_day[period_key]["sample_local"] or start_local
comet_events.append({
"kind": "good_visibility",
"period": period_key,
"comet_id": comets.parse_int(comet.get("id")),
"comet_name": name,
"label": label,
"best_magnitude": best_magnitude,
"utc_iso": start_local.astimezone(timezone.utc).isoformat().replace("+00:00", "Z"),
"local_iso": start_local.isoformat(),
"local_date": start_local.strftime("%d.%m.%Y"),
"local_time": start_local.strftime("%H:%M"),
"end_local_iso": end_local.isoformat(),
"end_local_date": end_day["date"],
"end_local_time": end_local.strftime("%H:%M"),
"duration_minutes": max(1, int(round((end_local - start_local).total_seconds() / 60.0))),
"duration_days": duration_days,
})
start_index = None
return comet_events
events.extend(build_windows("morning", "Morgen"))
events.extend(build_windows("evening", "Abend"))
events: list[dict] = []
for comet in comet_payload:
name = comet_name(comet)
append_perihelion_event(events, comet, name)
append_geocentric_minimum_event(events, comet, name)
append_visibility_events(events, comet, name)
events.sort(key=lambda item: item["local_iso"])
return {
"ok": True,
"action": "favorite_comet_events_for_month",
"selected": {
"year": year,
"month": month,
"timezone": timezone_name,
"sample_offset_minutes": sample_offset_minutes,
"min_comet_alt_deg": min_comet_alt_deg,
"max_sun_alt_deg": max_sun_alt_deg,
"max_comet_magnitude": max_comet_magnitude,
"min_consecutive_days": min_consecutive_days,
"comet_count": len(comet_payload),
},
"events": events,
}
def action_mercury_good_visibility_for_month(args: list[str]) -> dict:
if len(args) != 10:
fail(
"Aktion mercury_good_visibility_for_month erwartet 10 Argumente: latitude longitude elevation year month timezone sample_offset_minutes min_mercury_alt_deg max_sun_alt_deg min_consecutive_days",
extra={"argv": args},
)
latitude = parse_float(args[0], "Latitude")
longitude = parse_float(args[1], "Longitude")
elevation = parse_float(args[2], "Elevation")
try:
year = int(args[3])
month = int(args[4])
sample_offset_minutes = int(args[6])
min_consecutive_days = int(args[9])
except ValueError as exc:
fail("Jahr, Monat oder Sichtbarkeitsparameter sind ungueltig.", extra={"details": str(exc), "argv": args})
if month < 1 or month > 12:
fail("Monat muss zwischen 1 und 12 liegen.", extra={"month": month})
timezone_name = args[5]
min_mercury_alt_deg = parse_float(args[7], "Minimale Merkurhoehe")
max_sun_alt_deg = parse_float(args[8], "Maximale Sonnenhoehe")
try:
tz = ZoneInfo(timezone_name)
except Exception as exc:
fail("Zeitzone ist ungueltig oder auf dem Server nicht verfuegbar.", extra={"details": str(exc)})
observer = astronomy.Observer(latitude, longitude, elevation)
local_start = datetime(year, month, 1, 0, 0, 0, tzinfo=tz)
if month == 12:
local_end = datetime(year + 1, 1, 1, 0, 0, 0, tzinfo=tz)
else:
local_end = datetime(year, month + 1, 1, 0, 0, 0, tzinfo=tz)
daily_checks: list[dict] = []
current_local = local_start
while current_local < local_end:
day_start_utc = current_local.astimezone(timezone.utc)
next_day_utc = (current_local + timedelta(days=1)).astimezone(timezone.utc)
start_time = dt_to_time(day_start_utc)
sunrise = search_event(astronomy.Body.Sun, astronomy.Direction.Rise, observer, start_time, next_day_utc)
sunset = search_event(astronomy.Body.Sun, astronomy.Direction.Set, observer, start_time, next_day_utc)
morning_ok = False
morning_dt_local = None
morning_mercury_alt = None
morning_sun_alt = None
if sunrise is not None:
sunrise_local = time_to_datetime(sunrise).astimezone(tz)
morning_dt_local = sunrise_local - timedelta(minutes=sample_offset_minutes)
morning_dt_utc = morning_dt_local.astimezone(timezone.utc)
morning_mercury_alt = body_altitude_deg(astronomy.Body.Mercury, observer, morning_dt_utc)
morning_sun_alt = body_altitude_deg(astronomy.Body.Sun, observer, morning_dt_utc)
morning_ok = morning_mercury_alt >= min_mercury_alt_deg and morning_sun_alt <= max_sun_alt_deg
evening_ok = False
evening_dt_local = None
evening_mercury_alt = None
evening_sun_alt = None
if sunset is not None:
sunset_local = time_to_datetime(sunset).astimezone(tz)
evening_dt_local = sunset_local + timedelta(minutes=sample_offset_minutes)
evening_dt_utc = evening_dt_local.astimezone(timezone.utc)
evening_mercury_alt = body_altitude_deg(astronomy.Body.Mercury, observer, evening_dt_utc)
evening_sun_alt = body_altitude_deg(astronomy.Body.Sun, observer, evening_dt_utc)
evening_ok = evening_mercury_alt >= min_mercury_alt_deg and evening_sun_alt <= max_sun_alt_deg
daily_checks.append({
"date": current_local.strftime("%d.%m.%Y"),
"morning_ok": morning_ok,
"morning_local": morning_dt_local,
"morning_mercury_alt_deg": morning_mercury_alt,
"morning_sun_alt_deg": morning_sun_alt,
"evening_ok": evening_ok,
"evening_local": evening_dt_local,
"evening_mercury_alt_deg": evening_mercury_alt,
"evening_sun_alt_deg": evening_sun_alt,
})
current_local += timedelta(days=1)
def build_windows(period_key: str, label_text: str) -> list[dict]:
events = []
start_index = None
for index, day in enumerate(daily_checks):
is_ok = bool(day[f"{period_key}_ok"])
if is_ok and start_index is None:
start_index = index
is_last = index == len(daily_checks) - 1
if start_index is not None and (not is_ok or is_last):
end_index = index if (is_ok and is_last) else index - 1
duration_days = end_index - start_index + 1
if duration_days >= min_consecutive_days:
start_day = daily_checks[start_index]
end_day = daily_checks[end_index]
start_local = start_day[f"{period_key}_local"]
if start_local is not None:
range_text = (
start_day["date"]
if start_index == end_index
else f'{start_day["date"]} bis {end_day["date"]}'
)
events.append({
"period": period_key,
"label": f"Gute Merkur-Sichtbarkeit am {label_text} ({range_text})",
"utc_iso": start_local.astimezone(timezone.utc).isoformat().replace("+00:00", "Z"),
"local_iso": start_local.isoformat(),
"local_date": start_local.strftime("%d.%m.%Y"),
"local_time": start_local.strftime("%H:%M"),
"end_local_iso": end_day[f"{period_key}_local"].isoformat() if end_day[f"{period_key}_local"] is not None else start_local.isoformat(),
"end_local_time": end_day[f"{period_key}_local"].strftime("%H:%M") if end_day[f"{period_key}_local"] is not None else start_local.strftime("%H:%M"),
"end_local_date": end_day["date"],
"duration_minutes": max(
1,
int(round((((end_day[f"{period_key}_local"] or start_local) - start_local).total_seconds()) / 60.0)),
),
"duration_days": duration_days,
})
start_index = None
return events
events = build_windows("morning", "Morgen")
events.extend(build_windows("evening", "Abend"))
events.sort(key=lambda item: item["local_iso"])
return {
"ok": True,
"action": "mercury_good_visibility_for_month",
"selected": {
"year": year,
"month": month,
"timezone": timezone_name,
"sample_offset_minutes": sample_offset_minutes,
"min_mercury_alt_deg": min_mercury_alt_deg,
"max_sun_alt_deg": max_sun_alt_deg,
"min_consecutive_days": min_consecutive_days,
},
"events": events,
}
def action_planet_parades_for_month(args: list[str]) -> dict:
if len(args) != 12:
fail(
"Aktion planet_parades_for_month erwartet 12 Argumente: latitude longitude elevation year month timezone sample_offset_minutes min_planet_alt_deg max_sun_alt_deg max_azimuth_span_deg min_planet_count min_consecutive_days",
extra={"argv": args},
)
latitude = parse_float(args[0], "Latitude")
longitude = parse_float(args[1], "Longitude")
elevation = parse_float(args[2], "Elevation")
try:
year = int(args[3])
month = int(args[4])
sample_offset_minutes = int(args[6])
min_planet_count = int(args[10])
min_consecutive_days = int(args[11])
except ValueError as exc:
fail("Jahr, Monat oder Parade-Parameter sind ungueltig.", extra={"details": str(exc), "argv": args})
if month < 1 or month > 12:
fail("Monat muss zwischen 1 und 12 liegen.", extra={"month": month})
timezone_name = args[5]
min_planet_alt_deg = parse_float(args[7], "Minimale Planetenhoehe")
max_sun_alt_deg = parse_float(args[8], "Maximale Sonnenhoehe")
max_azimuth_span_deg = parse_float(args[9], "Maximale Azimutspanne")
try:
tz = ZoneInfo(timezone_name)
except Exception as exc:
fail("Zeitzone ist ungueltig oder auf dem Server nicht verfuegbar.", extra={"details": str(exc)})
observer = astronomy.Observer(latitude, longitude, elevation)
local_start = datetime(year, month, 1, 0, 0, 0, tzinfo=tz)
if month == 12:
local_end = datetime(year + 1, 1, 1, 0, 0, 0, tzinfo=tz)
else:
local_end = datetime(year, month + 1, 1, 0, 0, 0, tzinfo=tz)
planet_defs = [
("Merkur", astronomy.Body.Mercury, "mercury"),
("Venus", astronomy.Body.Venus, "venus"),
("Mars", astronomy.Body.Mars, "mars"),
("Jupiter", astronomy.Body.Jupiter, "jupiter"),
("Saturn", astronomy.Body.Saturn, "saturn"),
]
daily_checks: list[dict] = []
current_local = local_start
while current_local < local_end:
day_start_utc = current_local.astimezone(timezone.utc)
next_day_utc = (current_local + timedelta(days=1)).astimezone(timezone.utc)
start_time = dt_to_time(day_start_utc)
sunrise = search_event(astronomy.Body.Sun, astronomy.Direction.Rise, observer, start_time, next_day_utc)
sunset = search_event(astronomy.Body.Sun, astronomy.Direction.Set, observer, start_time, next_day_utc)
def build_period(period_key: str, sample_local: datetime | None) -> dict:
if sample_local is None:
return {
"ok": False,
"sample_local": None,
"sun_alt_deg": None,
"visible_planets": [],
"azimuth_span_deg": None,
}
sample_utc = sample_local.astimezone(timezone.utc)
sun_alt_deg = body_altitude_deg(astronomy.Body.Sun, observer, sample_utc)
visible_planets = []
for label, body, key in planet_defs:
planet_alt_deg, planet_az_deg = body_horizontal_coords(body, observer, sample_utc)
if planet_alt_deg >= min_planet_alt_deg:
visible_planets.append({
"key": key,
"label": label,
"altitude_deg": planet_alt_deg,
"azimuth_deg": planet_az_deg,
})
azimuth_span_deg = None
if visible_planets:
azimuths = sorted(planet["azimuth_deg"] for planet in visible_planets)
if len(azimuths) == 1:
azimuth_span_deg = 0.0
else:
wrap_gaps = [
azimuths[index + 1] - azimuths[index]
for index in range(len(azimuths) - 1)
]
wrap_gaps.append((azimuths[0] + 360.0) - azimuths[-1])
azimuth_span_deg = 360.0 - max(wrap_gaps)
is_ok = (
sun_alt_deg <= max_sun_alt_deg
and len(visible_planets) >= min_planet_count
and azimuth_span_deg is not None
and azimuth_span_deg <= max_azimuth_span_deg
)
return {
"ok": is_ok,
"sample_local": sample_local,
"sun_alt_deg": sun_alt_deg,
"visible_planets": visible_planets,
"azimuth_span_deg": azimuth_span_deg,
}
morning_local = None
if sunrise is not None:
sunrise_local = time_to_datetime(sunrise).astimezone(tz)
morning_local = sunrise_local - timedelta(minutes=sample_offset_minutes)
evening_local = None
if sunset is not None:
sunset_local = time_to_datetime(sunset).astimezone(tz)
evening_local = sunset_local + timedelta(minutes=sample_offset_minutes)
daily_checks.append({
"date": current_local.strftime("%d.%m.%Y"),
"morning": build_period("morning", morning_local),
"evening": build_period("evening", evening_local),
})
current_local += timedelta(days=1)
def build_windows(period_key: str, label_text: str) -> list[dict]:
events = []
start_index = None
for index, day in enumerate(daily_checks):
period = day[period_key]
is_ok = bool(period["ok"])
if is_ok and start_index is None:
start_index = index
is_last = index == len(daily_checks) - 1
if start_index is not None and (not is_ok or is_last):
end_index = index if (is_ok and is_last) else index - 1
duration_days = end_index - start_index + 1
if duration_days >= min_consecutive_days:
window_days = daily_checks[start_index:end_index + 1]
start_day = window_days[0]
end_day = window_days[-1]
start_period = start_day[period_key]
start_local = start_period["sample_local"]
if start_local is not None:
range_text = (
start_day["date"]
if start_index == end_index
else f'{start_day["date"]} bis {end_day["date"]}'
)
common_labels: set[str] | None = None
max_visible_labels: list[str] = []
max_visible_count = 0
min_span_deg = None
for window_day in window_days:
day_period = window_day[period_key]
visible_labels = [planet["label"] for planet in day_period["visible_planets"]]
visible_set = set(visible_labels)
common_labels = visible_set if common_labels is None else (common_labels & visible_set)
if len(visible_labels) > max_visible_count:
max_visible_count = len(visible_labels)
max_visible_labels = visible_labels
span_value = day_period["azimuth_span_deg"]
if span_value is not None:
min_span_deg = span_value if min_span_deg is None else min(min_span_deg, span_value)
parade_labels = sorted(common_labels) if common_labels else max_visible_labels
planet_text = ", ".join(parade_labels)
count_text = f"{max_visible_count} Planeten"
detail_parts = [count_text]
if planet_text:
detail_parts.append(planet_text)
detail_parts.append(range_text)
events.append({
"period": period_key,
"label": f"Planetenparade am {label_text} ({'; '.join(detail_parts)})",
"planet_count": max_visible_count,
"planet_labels": parade_labels,
"best_azimuth_span_deg": min_span_deg,
"utc_iso": start_local.astimezone(timezone.utc).isoformat().replace("+00:00", "Z"),
"local_iso": start_local.isoformat(),
"local_date": start_local.strftime("%d.%m.%Y"),
"local_time": start_local.strftime("%H:%M"),
"end_local_iso": end_day[period_key]["sample_local"].isoformat() if end_day[period_key]["sample_local"] is not None else start_local.isoformat(),
"end_local_time": end_day[period_key]["sample_local"].strftime("%H:%M") if end_day[period_key]["sample_local"] is not None else start_local.strftime("%H:%M"),
"end_local_date": end_day["date"],
"duration_minutes": max(
1,
int(round((((end_day[period_key]["sample_local"] or start_local) - start_local).total_seconds()) / 60.0)),
),
"duration_days": duration_days,
})
start_index = None
return events
events = build_windows("morning", "Morgen")
events.extend(build_windows("evening", "Abend"))
events.sort(key=lambda item: item["local_iso"])
return {
"ok": True,
"action": "planet_parades_for_month",
"selected": {
"year": year,
"month": month,
"timezone": timezone_name,
"sample_offset_minutes": sample_offset_minutes,
"min_planet_alt_deg": min_planet_alt_deg,
"max_sun_alt_deg": max_sun_alt_deg,
"max_azimuth_span_deg": max_azimuth_span_deg,
"min_planet_count": min_planet_count,
"min_consecutive_days": min_consecutive_days,
},
"events": events,
}
def action_planet_constellation_changes_for_month(args: list[str]) -> dict:
if len(args) != 3:
fail(
"Aktion planet_constellation_changes_for_month erwartet 3 Argumente: year month timezone",
extra={"argv": args},
)
try:
year = int(args[0])
month = int(args[1])
except ValueError as exc:
fail("Jahr oder Monat sind ungueltig.", extra={"details": str(exc), "argv": args})
if month < 1 or month > 12:
fail("Monat muss zwischen 1 und 12 liegen.", extra={"month": month})
timezone_name = args[2]
try:
tz = ZoneInfo(timezone_name)
except Exception as exc:
fail("Zeitzone ist ungueltig oder auf dem Server nicht verfuegbar.", extra={"details": str(exc)})
local_start = datetime(year, month, 1, 0, 0, 0, tzinfo=tz)
if month == 12:
local_end = datetime(year + 1, 1, 1, 0, 0, 0, tzinfo=tz)
utc_next_month = datetime(year + 1, 1, 1, 0, 0, 0, tzinfo=timezone.utc)
else:
local_end = datetime(year, month + 1, 1, 0, 0, 0, tzinfo=tz)
utc_next_month = datetime(year, month + 1, 1, 0, 0, 0, tzinfo=timezone.utc)
utc_start = datetime(year, month, 1, 0, 0, 0, tzinfo=timezone.utc)
utc_end = utc_next_month - timedelta(seconds=1)
constellation_names = {
"Aries": "Widder",
"Taurus": "Stier",
"Gemini": "Zwillinge",
"Cancer": "Krebs",
"Leo": "Löwe",
"Virgo": "Jungfrau",
"Libra": "Waage",
"Scorpius": "Skorpion",
"Sagittarius": "Schütze",
"Capricornus": "Steinbock",
"Aquarius": "Wassermann",
"Pisces": "Fische",
"Ophiuchus": "Schlangenträger",
"Cetus": "Walfisch",
}
planet_defs = [
("Merkur", astronomy.Body.Mercury, "mercury"),
("Venus", astronomy.Body.Venus, "venus"),
("Mars", astronomy.Body.Mars, "mars"),
("Jupiter", astronomy.Body.Jupiter, "jupiter"),
("Saturn", astronomy.Body.Saturn, "saturn"),
("Uranus", astronomy.Body.Uranus, "uranus"),
("Neptun", astronomy.Body.Neptune, "neptune"),
]
def display_constellation_name(info: astronomy.ConstellationInfo) -> str:
return constellation_names.get(info.name, info.name)
def refine_change_time(body: astronomy.Body, left_utc: datetime, right_utc: datetime, left_symbol: str) -> datetime:
left = left_utc
right = right_utc
for _ in range(40):
mid = left + (right - left) / 2
mid_symbol = body_constellation_info(body, mid).symbol
if mid_symbol == left_symbol:
left = mid
else:
right = mid
return right
events: list[dict] = []
scan_step = timedelta(hours=6)
for label, body, key in planet_defs:
left_utc = utc_start
left_info = body_constellation_info(body, left_utc)
current_utc = min(utc_end, left_utc + scan_step)
while current_utc <= utc_end:
current_info = body_constellation_info(body, current_utc)
if current_info.symbol != left_info.symbol:
change_utc = refine_change_time(body, left_utc, current_utc, left_info.symbol)
before_info = body_constellation_info(body, change_utc - timedelta(seconds=1))
after_info = body_constellation_info(body, change_utc)
local_dt = change_utc.astimezone(tz)
if local_start <= local_dt < local_end:
from_name = display_constellation_name(before_info)
to_name = display_constellation_name(after_info)
events.append({
"planet_key": key,
"planet_label": label,
"from_constellation_symbol": before_info.symbol,
"from_constellation_name": from_name,
"to_constellation_symbol": after_info.symbol,
"to_constellation_name": to_name,
"label": f"{label} wechselt von {from_name} nach {to_name}",
"utc_iso": change_utc.isoformat().replace("+00:00", "Z"),
"local_iso": local_dt.isoformat(),
"local_date": local_dt.strftime("%d.%m.%Y"),
"local_time": local_dt.strftime("%H:%M"),
})
left_utc = change_utc + timedelta(seconds=1)
left_info = body_constellation_info(body, left_utc)
current_utc = min(utc_end, left_utc + scan_step)
continue
left_utc = current_utc
left_info = current_info
current_utc = min(utc_end, current_utc + scan_step)
if current_utc == left_utc:
break
events.sort(key=lambda item: item["local_iso"])
return {
"ok": True,
"action": "planet_constellation_changes_for_month",
"selected": {
"year": year,
"month": month,
"timezone": timezone_name,
"utc_scan_start": utc_start.isoformat().replace("+00:00", "Z"),
"utc_scan_end": utc_end.isoformat().replace("+00:00", "Z"),
},
"events": events,
}
def action_sun_constellation_changes_for_year(args: list[str]) -> dict:
if len(args) != 2:
fail(
"Aktion sun_constellation_changes_for_year erwartet 2 Argumente: year timezone",
extra={"argv": args},
)
try:
year = int(args[0])
except ValueError as exc:
fail("Jahr ist ungueltig.", extra={"details": str(exc), "argv": args})
if year < 1600 or year > 2200:
fail("Jahr muss zwischen 1600 und 2200 liegen.", extra={"year": year})
timezone_name = args[1]
try:
tz = ZoneInfo(timezone_name)
except Exception as exc:
fail("Zeitzone ist ungueltig oder auf dem Server nicht verfuegbar.", extra={"details": str(exc)})
local_start = datetime(year, 1, 1, 0, 0, 0, tzinfo=tz)
local_end = datetime(year + 1, 1, 1, 0, 0, 0, tzinfo=tz)
utc_start = local_start.astimezone(timezone.utc)
utc_end = local_end.astimezone(timezone.utc)
constellation_names = {
"Aries": "Widder",
"Taurus": "Stier",
"Gemini": "Zwillinge",
"Cancer": "Krebs",
"Leo": "Löwe",
"Virgo": "Jungfrau",
"Libra": "Waage",
"Scorpius": "Skorpion",
"Sagittarius": "Schütze",
"Capricornus": "Steinbock",
"Aquarius": "Wassermann",
"Pisces": "Fische",
"Ophiuchus": "Schlangenträger",
"Cetus": "Walfisch",
}
def display_constellation_name(info: astronomy.ConstellationInfo) -> str:
return constellation_names.get(info.name, info.name)
def refine_change_time(left_utc: datetime, right_utc: datetime, left_symbol: str) -> datetime:
left = left_utc
right = right_utc
for _ in range(40):
mid = left + (right - left) / 2
if body_constellation_info(astronomy.Body.Sun, mid).symbol == left_symbol:
left = mid
else:
right = mid
return right
events: list[dict] = []
intervals: list[dict] = []
interval_start_utc = utc_start
left_utc = utc_start
left_info = body_constellation_info(astronomy.Body.Sun, left_utc)
scan_step = timedelta(hours=12)
while left_utc < utc_end:
current_utc = min(utc_end, left_utc + scan_step)
current_info = body_constellation_info(astronomy.Body.Sun, current_utc)
if current_info.symbol == left_info.symbol:
left_utc = current_utc
left_info = current_info
continue
change_utc = refine_change_time(left_utc, current_utc, left_info.symbol)
before_info = body_constellation_info(astronomy.Body.Sun, change_utc - timedelta(seconds=1))
after_info = body_constellation_info(astronomy.Body.Sun, change_utc)
change_local = change_utc.astimezone(tz)
interval_start_local = interval_start_utc.astimezone(tz)
intervals.append({
"constellation_symbol": before_info.symbol,
"constellation_name": display_constellation_name(before_info),
"start_local_iso": interval_start_local.isoformat(),
"start_local_date": interval_start_local.strftime("%d.%m.%Y"),
"start_local_time": interval_start_local.strftime("%H:%M"),
"end_local_iso": change_local.isoformat(),
"end_local_date": change_local.strftime("%d.%m.%Y"),
"end_local_time": change_local.strftime("%H:%M"),
"duration_days": round((change_utc - interval_start_utc).total_seconds() / 86400.0, 2),
})
events.append({
"from_constellation_symbol": before_info.symbol,
"from_constellation_name": display_constellation_name(before_info),
"to_constellation_symbol": after_info.symbol,
"to_constellation_name": display_constellation_name(after_info),
"utc_iso": change_utc.isoformat().replace("+00:00", "Z"),
"local_iso": change_local.isoformat(),
"local_date": change_local.strftime("%d.%m.%Y"),
"local_time": change_local.strftime("%H:%M"),
})
interval_start_utc = change_utc
left_utc = change_utc + timedelta(seconds=1)
left_info = body_constellation_info(astronomy.Body.Sun, left_utc)
interval_start_local = interval_start_utc.astimezone(tz)
end_info = body_constellation_info(astronomy.Body.Sun, utc_end - timedelta(seconds=1))
intervals.append({
"constellation_symbol": end_info.symbol,
"constellation_name": display_constellation_name(end_info),
"start_local_iso": interval_start_local.isoformat(),
"start_local_date": interval_start_local.strftime("%d.%m.%Y"),
"start_local_time": interval_start_local.strftime("%H:%M"),
"end_local_iso": local_end.isoformat(),
"end_local_date": local_end.strftime("%d.%m.%Y"),
"end_local_time": local_end.strftime("%H:%M"),
"duration_days": round((utc_end - interval_start_utc).total_seconds() / 86400.0, 2),
})
return {
"ok": True,
"action": "sun_constellation_changes_for_year",
"selected": {
"year": year,
"timezone": timezone_name,
"local_start": local_start.isoformat(),
"local_end": local_end.isoformat(),
},
"events": events,
"intervals": intervals,
}
def action_sun_constellation_offset_statistics(args: list[str]) -> dict:
if len(args) != 3:
fail(
"Aktion sun_constellation_offset_statistics erwartet 3 Argumente: start_year constellation_symbol timezone",
extra={"argv": args},
)
try:
start_year = int(args[0])
except ValueError as exc:
fail("Startjahr ist ungueltig.", extra={"details": str(exc), "argv": args})
end_year = start_year + 99
if start_year < 1600 or end_year > 2200:
fail("Der Zeitraum von 100 Jahren muss zwischen 1600 und 2200 liegen.", extra={"start_year": start_year})
constellation_symbol = args[1]
astrological_starts = {
"Cap": ("Steinbock", -1, 12, 22),
"Aqr": ("Wassermann", 0, 1, 20),
"Psc": ("Fische", 0, 2, 19),
"Ari": ("Widder", 0, 3, 21),
"Tau": ("Stier", 0, 4, 20),
"Gem": ("Zwillinge", 0, 5, 21),
"Cnc": ("Krebs", 0, 6, 21),
"Leo": ("Löwe", 0, 7, 23),
"Vir": ("Jungfrau", 0, 8, 23),
"Lib": ("Waage", 0, 9, 23),
"Sco": ("Skorpion", 0, 10, 23),
"Sgr": ("Schütze", 0, 11, 22),
}
if constellation_symbol not in astrological_starts:
fail("Sternbild ist fuer den astrologischen Vergleich nicht verfuegbar.", extra={"constellation_symbol": constellation_symbol})
timezone_name = args[2]
try:
tz = ZoneInfo(timezone_name)
except Exception as exc:
fail("Zeitzone ist ungueltig oder auf dem Server nicht verfuegbar.", extra={"details": str(exc)})
constellation_name, year_offset, month, day = astrological_starts[constellation_symbol]
yearly_values: list[dict] = []
differences: list[float] = []
for year in range(start_year, end_year + 1):
yearly_data = action_sun_constellation_changes_for_year([str(year), timezone_name])
event = next(
(item for item in yearly_data["events"] if item["to_constellation_symbol"] == constellation_symbol),
None,
)
if event is None:
fail("Kein Sternbildwechsel im Berechnungszeitraum gefunden.", extra={"year": year, "constellation_symbol": constellation_symbol})
astronomical_change = datetime.fromisoformat(event["local_iso"]).astimezone(tz)
astrological_start = datetime(year + year_offset, month, day, 0, 0, 0, tzinfo=tz)
difference_days = (astronomical_change - astrological_start).total_seconds() / 86400.0
differences.append(difference_days)
yearly_values.append({
"year": year,
"astronomical_date": astronomical_change.strftime("%d.%m.%Y"),
"astrological_date": astrological_start.strftime("%d.%m.%Y"),
"difference_days": round(difference_days, 2),
})
mean_days = sum(differences) / len(differences)
variance = sum((value - mean_days) ** 2 for value in differences) / len(differences)
return {
"ok": True,
"action": "sun_constellation_offset_statistics",
"selected": {
"constellation_symbol": constellation_symbol,
"constellation_name": constellation_name,
"start_year": start_year,
"end_year": end_year,
"timezone": timezone_name,
},
"statistics": {
"min_days": round(min(differences), 2),
"max_days": round(max(differences), 2),
"standard_deviation_days": round(math.sqrt(variance), 2),
"change_over_period_days": round(differences[-1] - differences[0], 2),
},
"years": yearly_values,
}
def action_month_sky_context(args: list[str]) -> dict:
if len(args) != 8:
fail(
"Aktion month_sky_context erwartet 8 Argumente: latitude longitude elevation year month hour minute timezone",
extra={"argv": args},
)
latitude = parse_float(args[0], "Latitude")
longitude = parse_float(args[1], "Longitude")
elevation = parse_float(args[2], "Elevation")
try:
year = int(args[3])
month = int(args[4])
hour = int(args[5])
minute = int(args[6])
except ValueError as exc:
fail("Jahr, Monat oder Uhrzeit sind ungueltig.", extra={"details": str(exc), "argv": args})
if month < 1 or month > 12:
fail("Monat muss zwischen 1 und 12 liegen.", extra={"month": month})
if hour < 0 or hour > 23:
fail("Stunde muss zwischen 0 und 23 liegen.", extra={"hour": hour})
if minute < 0 or minute > 59:
fail("Minute muss zwischen 0 und 59 liegen.", extra={"minute": minute})
timezone_name = args[7]
try:
tz = ZoneInfo(timezone_name)
except Exception as exc:
fail("Zeitzone ist ungueltig oder auf dem Server nicht verfuegbar.", extra={"details": str(exc)})
local_dt = datetime(year, month, 15, hour, minute, 0, tzinfo=tz)
utc_dt = local_dt.astimezone(timezone.utc)
time_value = dt_to_time(utc_dt)
observer = astronomy.Observer(latitude, longitude, elevation)
gst_hours = float(astronomy.SiderealTime(time_value))
lst_hours = normalize_degrees(gst_hours * 15.0 + longitude) / 15.0
sun = serialize_body_position("sun", "Sonne", astronomy.Body.Sun, time_value, observer)
moon = serialize_body_position("moon", "Mond", astronomy.Body.Moon, time_value, observer)
moon_phase_angle = normalize_degrees(astronomy.MoonPhase(time_value))
moon_age_days = moon_phase_angle / 360.0 * SYNODIC_MONTH
moon["phase_angle_deg"] = float(moon_phase_angle)
moon["phase_label"] = get_phase_label(moon_age_days)
moon["age_days"] = float(moon_age_days)
moon["waxing"] = moon_phase_angle <= 180.0
planets = [
serialize_body_position("mercury", "Merkur", astronomy.Body.Mercury, time_value, observer),
serialize_body_position("venus", "Venus", astronomy.Body.Venus, time_value, observer),
serialize_body_position("mars", "Mars", astronomy.Body.Mars, time_value, observer),
serialize_body_position("jupiter", "Jupiter", astronomy.Body.Jupiter, time_value, observer),
serialize_body_position("saturn", "Saturn", astronomy.Body.Saturn, time_value, observer),
serialize_body_position("uranus", "Uranus", astronomy.Body.Uranus, time_value, observer),
serialize_body_position("neptune", "Neptun", astronomy.Body.Neptune, time_value, observer),
]
return {
"ok": True,
"action": "month_sky_context",
"selected": {
"year": year,
"month": month,
"day": 15,
"hour": hour,
"minute": minute,
"timezone": timezone_name,
"local_iso": local_dt.isoformat(),
"utc_iso": utc_dt.isoformat().replace("+00:00", "Z"),
"local_label": local_dt.strftime("%d.%m.%Y %H:%M") + f" {timezone_name}",
},
"observer": {
"latitude": latitude,
"longitude": longitude,
"elevation": elevation,
},
"sidereal": {
"greenwich_hours": gst_hours,
"local_hours": lst_hours,
},
"bodies": {
"sun": sun,
"moon": moon,
"planets": planets,
},
}
def action_moon_phases_for_month(args: list[str]) -> dict:
if len(args) != 3:
fail(
"Aktion moon_phases_for_month erwartet 3 Argumente: year month timezone",
extra={"argv": args},
)
try:
year = int(args[0])
month = int(args[1])
except ValueError as exc:
fail("Jahr oder Monat sind ungueltig.", extra={"details": str(exc), "argv": args})
if month < 1 or month > 12:
fail("Monat muss zwischen 1 und 12 liegen.", extra={"month": month})
timezone_name = args[2]
try:
tz = ZoneInfo(timezone_name)
except Exception as exc:
fail("Zeitzone ist ungueltig oder auf dem Server nicht verfuegbar.", extra={"details": str(exc)})
local_start = datetime(year, month, 1, 0, 0, 0, tzinfo=tz)
if month == 12:
local_end = datetime(year + 1, 1, 1, 0, 0, 0, tzinfo=tz)
else:
local_end = datetime(year, month + 1, 1, 0, 0, 0, tzinfo=tz)
search_start = dt_to_time(local_start.astimezone(timezone.utc) - timedelta(days=3))
search_limit_days = ((local_end - local_start).total_seconds() / 86400.0) + 10.0
phase_defs = [
(0.0, "Neumond"),
(90.0, "Erstes Viertel"),
(180.0, "Vollmond"),
(270.0, "Letztes Viertel"),
]
phases = []
for target_lon, label in phase_defs:
probe = search_start
found = None
for _ in range(3):
result = astronomy.SearchMoonPhase(target_lon, probe, search_limit_days)
if result is None:
break
dt_utc = time_to_datetime(result)
local_dt = dt_utc.astimezone(tz)
if local_dt.year == year and local_dt.month == month:
found = {
"label": label,
"utc_iso": dt_utc.isoformat().replace("+00:00", "Z"),
"local_iso": local_dt.isoformat(),
"local_date": local_dt.strftime("%d.%m.%Y"),
"local_time": local_dt.strftime("%H:%M"),
"sort_iso": local_dt.isoformat(),
}
break
probe = dt_to_time(dt_utc + timedelta(days=1))
if found is not None:
phases.append(found)
phases.sort(key=lambda item: item["sort_iso"])
for item in phases:
item.pop("sort_iso", None)
return {
"ok": True,
"action": "moon_phases_for_month",
"selected": {
"year": year,
"month": month,
"timezone": timezone_name,
},
"phases": phases,
}
def action_golden_handle_for_month(args: list[str]) -> dict:
if len(args) != 3:
fail(
"Aktion golden_handle_for_month erwartet 3 Argumente: year month timezone",
extra={"argv": args},
)
try:
year = int(args[0])
month = int(args[1])
except ValueError as exc:
fail("Jahr oder Monat sind ungueltig.", extra={"details": str(exc), "argv": args})
if month < 1 or month > 12:
fail("Monat muss zwischen 1 und 12 liegen.", extra={"month": month})
timezone_name = args[2]
try:
tz = ZoneInfo(timezone_name)
except Exception as exc:
fail("Zeitzone ist ungueltig oder auf dem Server nicht verfuegbar.", extra={"details": str(exc)})
local_start = datetime(year, month, 1, 0, 0, 0, tzinfo=tz)
if month == 12:
local_end = datetime(year + 1, 1, 1, 0, 0, 0, tzinfo=tz)
else:
local_end = datetime(year, month + 1, 1, 0, 0, 0, tzinfo=tz)
# Minimal physical model for the Golden Handle:
# solve the time when the floor of Sinus Iridum is still below sunrise
# by half of the topographic advance angle of the Jura rim.
moon_radius_km = 1737.4
floor_latitude_deg = 42.0
floor_longitude_west_deg = 35.5
relief_height_difference_km = 1.18
def relief_advance_angle_deg() -> float:
return math.degrees(
math.acos(moon_radius_km / (moon_radius_km + relief_height_difference_km))
)
def solar_selenographic_coordinates(dt_utc: datetime) -> tuple[float, float]:
time_value = dt_to_time(dt_utc)
phase_deg = normalize_degrees(astronomy.MoonPhase(time_value))
libration = astronomy.Libration(time_value)
axis_latitudes = calculate_moon_axis_latitudes(time_value)
# Approximate selenographic solar longitude from colongitude using
# west-positive longitude for the lunar surface model.
colongitude_deg = normalize_degrees(phase_deg - 90.0 - float(libration.elon))
solar_longitude_west_deg = normalize_signed_degrees(colongitude_deg - 90.0)
solar_latitude_deg = float(axis_latitudes["subsolar_latitude"])
return solar_longitude_west_deg, solar_latitude_deg
def floor_solar_altitude_deg(dt_utc: datetime) -> float:
solar_longitude_west_deg, solar_latitude_deg = solar_selenographic_coordinates(dt_utc)
phi = math.radians(floor_latitude_deg)
lam_f = math.radians(floor_longitude_west_deg)
lam_s = math.radians(solar_longitude_west_deg)
b_s = math.radians(solar_latitude_deg)
x = (
math.sin(phi) * math.sin(b_s)
+ math.cos(phi) * math.cos(b_s) * math.cos(lam_f - lam_s)
)
x = max(-1.0, min(1.0, x))
return math.degrees(math.asin(x))
def golden_handle_metric(dt_utc: datetime) -> float:
return floor_solar_altitude_deg(dt_utc) + relief_advance_angle_deg() / 2.0
def refine_peak_time(left_utc: datetime, right_utc: datetime) -> datetime:
left = left_utc
right = right_utc
f_left = golden_handle_metric(left)
f_right = golden_handle_metric(right)
for _ in range(40):
mid = left + (right - left) / 2
f_mid = golden_handle_metric(mid)
if abs(f_mid) < 1e-5:
return mid
if f_left == 0:
return left
if f_right == 0:
return right
if f_left * f_mid <= 0:
right = mid
f_right = f_mid
else:
left = mid
f_left = f_mid
return left + (right - left) / 2
search_start = dt_to_time(local_start.astimezone(timezone.utc) - timedelta(days=20))
events = []
probe = search_start
for _ in range(3):
first_quarter = astronomy.SearchMoonPhase(90.0, probe, 40.0)
if first_quarter is None:
break
full_moon = astronomy.SearchMoonPhase(180.0, first_quarter, 12.0)
if full_moon is None:
break
interval_start = time_to_datetime(first_quarter)
interval_end = time_to_datetime(full_moon)
step = timedelta(hours=1)
previous_time = interval_start
previous_value = golden_handle_metric(previous_time)
current = interval_start + step
peak_utc = None
while current <= interval_end:
current_value = golden_handle_metric(current)
if previous_value == 0 or current_value == 0 or previous_value * current_value < 0:
peak_utc = refine_peak_time(previous_time, current)
break
previous_time = current
previous_value = current_value
current += step
if peak_utc is not None:
peak_local = peak_utc.astimezone(tz)
if peak_local.year == year and peak_local.month == month:
events.append({
"kind": "golden_handle",
"label": "Goldener Henkel am Mond",
"utc_iso": peak_utc.isoformat().replace("+00:00", "Z"),
"local_iso": peak_local.isoformat(),
"local_date": peak_local.strftime("%d.%m.%Y"),
"local_time": peak_local.strftime("%H:%M"),
"peak_local_iso": peak_local.isoformat(),
"peak_local_date": peak_local.strftime("%d.%m.%Y"),
"peak_local_time": peak_local.strftime("%H:%M"),
})
probe = dt_to_time(interval_end + timedelta(days=10))
events.sort(key=lambda item: item["local_iso"])
return {
"ok": True,
"action": "golden_handle_for_month",
"selected": {
"year": year,
"month": month,
"timezone": timezone_name,
},
"events": events,
}
def action_season_changes_for_month(args: list[str]) -> dict:
if len(args) != 3:
fail(
"Aktion season_changes_for_month erwartet 3 Argumente: year month timezone",
extra={"argv": args},
)
try:
year = int(args[0])
month = int(args[1])
except ValueError as exc:
fail("Jahr oder Monat sind ungueltig.", extra={"details": str(exc), "argv": args})
if month < 1 or month > 12:
fail("Monat muss zwischen 1 und 12 liegen.", extra={"month": month})
timezone_name = args[2]
try:
tz = ZoneInfo(timezone_name)
except Exception as exc:
fail("Zeitzone ist ungueltig oder auf dem Server nicht verfuegbar.", extra={"details": str(exc)})
season_info = astronomy.Seasons(year)
season_defs = [
("Fruehlingsanfang", season_info.mar_equinox, "march_equinox"),
("Sommeranfang", season_info.jun_solstice, "june_solstice"),
("Herbstanfang", season_info.sep_equinox, "september_equinox"),
("Winteranfang", season_info.dec_solstice, "december_solstice"),
]
changes = []
for label, time_value, key in season_defs:
dt_utc = time_to_datetime(time_value)
local_dt = dt_utc.astimezone(tz)
if local_dt.year != year or local_dt.month != month:
continue
changes.append({
"key": key,
"label": label,
"utc_iso": dt_utc.isoformat().replace("+00:00", "Z"),
"local_iso": local_dt.isoformat(),
"local_date": local_dt.strftime("%d.%m.%Y"),
"local_time": local_dt.strftime("%H:%M"),
})
changes.sort(key=lambda item: item["local_iso"])
return {
"ok": True,
"action": "season_changes_for_month",
"selected": {
"year": year,
"month": month,
"timezone": timezone_name,
},
"changes": changes,
}
def action_time_changes_for_month(args: list[str]) -> dict:
if len(args) != 3:
fail(
"Aktion time_changes_for_month erwartet 3 Argumente: year month timezone",
extra={"argv": args},
)
try:
year = int(args[0])
month = int(args[1])
except ValueError as exc:
fail("Jahr oder Monat sind ungueltig.", extra={"details": str(exc), "argv": args})
if month < 1 or month > 12:
fail("Monat muss zwischen 1 und 12 liegen.", extra={"month": month})
timezone_name = args[2]
try:
tz = ZoneInfo(timezone_name)
except Exception as exc:
fail("Zeitzone ist ungueltig oder auf dem Server nicht verfuegbar.", extra={"details": str(exc)})
local_start = datetime(year, month, 1, 0, 0, 0, tzinfo=tz)
if month == 12:
local_end = datetime(year + 1, 1, 1, 0, 0, 0, tzinfo=tz)
else:
local_end = datetime(year, month + 1, 1, 0, 0, 0, tzinfo=tz)
utc_start = local_start.astimezone(timezone.utc) - timedelta(days=2)
utc_end = local_end.astimezone(timezone.utc) + timedelta(days=2)
def utc_offset_seconds(dt_utc: datetime) -> int:
return int(dt_utc.astimezone(tz).utcoffset().total_seconds())
def refine_transition(left_utc: datetime, right_utc: datetime) -> datetime:
left = left_utc
right = right_utc
left_offset = utc_offset_seconds(left)
while (right - left) > timedelta(seconds=1):
mid = left + (right - left) / 2
if utc_offset_seconds(mid) == left_offset:
left = mid
else:
right = mid
return right
events = []
previous_utc = utc_start
previous_offset = utc_offset_seconds(previous_utc)
current_utc = previous_utc + timedelta(hours=1)
while current_utc <= utc_end:
current_offset = utc_offset_seconds(current_utc)
if current_offset != previous_offset:
transition_utc = refine_transition(previous_utc, current_utc)
local_dt = transition_utc.astimezone(tz)
if local_dt.year == year and local_dt.month == month:
delta_seconds = current_offset - previous_offset
kind = "dst_start" if delta_seconds > 0 else "dst_end"
events.append({
"kind": kind,
"offset_before_hours": previous_offset / 3600.0,
"offset_after_hours": current_offset / 3600.0,
"utc_iso": transition_utc.isoformat().replace("+00:00", "Z"),
"local_iso": local_dt.isoformat(),
"local_date": local_dt.strftime("%d.%m.%Y"),
"local_time": local_dt.strftime("%H:%M"),
})
previous_utc = current_utc
previous_offset = current_offset
current_utc += timedelta(hours=1)
events.sort(key=lambda item: item["local_iso"])
return {
"ok": True,
"action": "time_changes_for_month",
"selected": {
"year": year,
"month": month,
"timezone": timezone_name,
},
"events": events,
}
def moon_planet_separation_deg(
body: astronomy.Body,
observer: astronomy.Observer,
dt_utc: datetime,
) -> float:
time_value = dt_to_time(dt_utc)
moon_eq = astronomy.Equator(astronomy.Body.Moon, time_value, observer, True, True)
body_eq = astronomy.Equator(body, time_value, observer, True, True)
return spherical_separation_deg(float(moon_eq.ra), float(moon_eq.dec), float(body_eq.ra), float(body_eq.dec))
def moon_planet_occultation_margin_deg(
body: astronomy.Body,
observer: astronomy.Observer,
dt_utc: datetime,
) -> float:
"""Negativ bedeutet Überlappung der scheinbaren Mond- und Planetenscheiben."""
time_value = dt_to_time(dt_utc)
moon_eq = astronomy.Equator(astronomy.Body.Moon, time_value, observer, True, True)
body_eq = astronomy.Equator(body, time_value, observer, True, True)
moon_radius = moon_angular_radius_deg(float(moon_eq.dist))
planet_radius = planet_angular_radius_deg(body, float(body_eq.dist))
separation = spherical_separation_deg(
float(moon_eq.ra), float(moon_eq.dec), float(body_eq.ra), float(body_eq.dec)
)
return separation - moon_radius - planet_radius
def moon_planet_overlap_fraction(
body: astronomy.Body,
observer: astronomy.Observer,
dt_utc: datetime,
) -> float:
time_value = dt_to_time(dt_utc)
moon_eq = astronomy.Equator(astronomy.Body.Moon, time_value, observer, True, True)
body_eq = astronomy.Equator(body, time_value, observer, True, True)
return circle_overlap_fraction(
moon_angular_radius_deg(float(moon_eq.dist)),
planet_angular_radius_deg(body, float(body_eq.dist)),
moon_planet_separation_deg(body, observer, dt_utc),
)
def refine_occultation_contact(
body: astronomy.Body,
observer: astronomy.Observer,
center_utc: datetime,
direction: int,
) -> datetime | None:
"""Findet den Scheibenkontakt vor oder nach der größten Bedeckung."""
step = timedelta(minutes=5)
inner = center_utc
inner_margin = moon_planet_occultation_margin_deg(body, observer, inner)
if inner_margin > 0.0:
return None
outer = inner
for _ in range(288): # maximal 24 Stunden vom Maximum entfernt suchen
outer = outer + (step if direction > 0 else -step)
outer_margin = moon_planet_occultation_margin_deg(body, observer, outer)
if outer_margin >= 0.0:
left, right = (inner, outer) if direction > 0 else (outer, inner)
for _ in range(40):
middle = left + (right - left) / 2
middle_margin = moon_planet_occultation_margin_deg(body, observer, middle)
if direction > 0:
if middle_margin < 0.0:
left = middle
else:
right = middle
else:
if middle_margin < 0.0:
right = middle
else:
left = middle
return right if direction > 0 else left
inner = outer
inner_margin = outer_margin
return None
def moon_fixed_equatorial_separation_deg(
ra_hours: float,
dec_deg: float,
observer: astronomy.Observer,
dt_utc: datetime,
) -> float:
time_value = dt_to_time(dt_utc)
moon_eq = astronomy.Equator(astronomy.Body.Moon, time_value, observer, True, True)
return spherical_separation_deg(float(moon_eq.ra), float(moon_eq.dec), float(ra_hours), float(dec_deg))
def planet_pair_separation_deg(
body_a: astronomy.Body,
body_b: astronomy.Body,
observer: astronomy.Observer,
dt_utc: datetime,
) -> float:
time_value = dt_to_time(dt_utc)
eq_a = astronomy.Equator(body_a, time_value, observer, True, True)
eq_b = astronomy.Equator(body_b, time_value, observer, True, True)
return spherical_separation_deg(float(eq_a.ra), float(eq_a.dec), float(eq_b.ra), float(eq_b.dec))
def planet_fixed_equatorial_separation_deg(
body: astronomy.Body,
ra_hours: float,
dec_deg: float,
observer: astronomy.Observer,
dt_utc: datetime,
) -> float:
time_value = dt_to_time(dt_utc)
body_eq = astronomy.Equator(body, time_value, observer, True, True)
return spherical_separation_deg(float(body_eq.ra), float(body_eq.dec), float(ra_hours), float(dec_deg))
def equatorial_unit_vector(ra_hours: float, dec_deg: float) -> tuple[float, float, float]:
ra_rad = math.radians(ra_hours * 15.0)
dec_rad = math.radians(dec_deg)
cos_dec = math.cos(dec_rad)
return (
cos_dec * math.cos(ra_rad),
cos_dec * math.sin(ra_rad),
math.sin(dec_rad),
)
def vector_dot(a: tuple[float, float, float], b: tuple[float, float, float]) -> float:
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
def vector_cross(a: tuple[float, float, float], b: tuple[float, float, float]) -> tuple[float, float, float]:
return (
a[1] * b[2] - a[2] * b[1],
a[2] * b[0] - a[0] * b[2],
a[0] * b[1] - a[1] * b[0],
)
def vector_scale(v: tuple[float, float, float], factor: float) -> tuple[float, float, float]:
return (v[0] * factor, v[1] * factor, v[2] * factor)
def vector_add(a: tuple[float, float, float], b: tuple[float, float, float]) -> tuple[float, float, float]:
return (a[0] + b[0], a[1] + b[1], a[2] + b[2])
def vector_normalize(v: tuple[float, float, float]) -> tuple[float, float, float]:
length = math.sqrt(vector_dot(v, v))
if length <= 0.0:
return (0.0, 0.0, 1.0)
return (v[0] / length, v[1] / length, v[2] / length)
def build_golden_gate_geometry() -> dict:
pleiades_vec = equatorial_unit_vector(GOLDEN_GATE_PLEIADES_RA_HOURS, GOLDEN_GATE_PLEIADES_DEC_DEG)
hyades_vec = equatorial_unit_vector(GOLDEN_GATE_HYADES_RA_HOURS, GOLDEN_GATE_HYADES_DEC_DEG)
center_vec = vector_normalize(vector_add(pleiades_vec, hyades_vec))
north_vec = (0.0, 0.0, 1.0)
east_vec = vector_normalize(vector_cross(north_vec, center_vec))
north_tangent_vec = vector_normalize(vector_cross(center_vec, east_vec))
def project(ra_hours: float, dec_deg: float) -> tuple[float, float]:
vec = equatorial_unit_vector(ra_hours, dec_deg)
return (
math.degrees(math.asin(max(-1.0, min(1.0, vector_dot(vec, east_vec))))),
math.degrees(math.asin(max(-1.0, min(1.0, vector_dot(vec, north_tangent_vec))))),
)
pleiades_xy = project(GOLDEN_GATE_PLEIADES_RA_HOURS, GOLDEN_GATE_PLEIADES_DEC_DEG)
hyades_xy = project(GOLDEN_GATE_HYADES_RA_HOURS, GOLDEN_GATE_HYADES_DEC_DEG)
return {
"center_vec": center_vec,
"east_vec": east_vec,
"north_vec": north_tangent_vec,
"pleiades_xy": pleiades_xy,
"hyades_xy": hyades_xy,
}
GOLDEN_GATE_GEOMETRY = build_golden_gate_geometry()
def golden_gate_planet_state(
body: astronomy.Body,
observer: astronomy.Observer,
dt_utc: datetime,
) -> dict:
time_value = dt_to_time(dt_utc)
body_eq = astronomy.Equator(body, time_value, observer, True, True)
planet_vec = equatorial_unit_vector(float(body_eq.ra), float(body_eq.dec))
x = math.degrees(math.asin(max(-1.0, min(1.0, vector_dot(planet_vec, GOLDEN_GATE_GEOMETRY["east_vec"])))))
y = math.degrees(math.asin(max(-1.0, min(1.0, vector_dot(planet_vec, GOLDEN_GATE_GEOMETRY["north_vec"])))))
ax, ay = GOLDEN_GATE_GEOMETRY["pleiades_xy"]
bx, by = GOLDEN_GATE_GEOMETRY["hyades_xy"]
sx = bx - ax
sy = by - ay
seg_len_sq = sx * sx + sy * sy
if seg_len_sq <= 0.0:
return {
"inside": False,
"center_distance_deg": 999.0,
"offset_deg": 999.0,
"track_fraction": -1.0,
"entry_margin_deg": 999.0,
}
px = x - ax
py = y - ay
track_fraction = (px * sx + py * sy) / seg_len_sq
closest_x = ax + track_fraction * sx
closest_y = ay + track_fraction * sy
offset_deg = math.hypot(x - closest_x, y - closest_y)
center_distance_deg = math.hypot(x, y)
seg_len_deg = math.sqrt(seg_len_sq)
before_start_deg = max(0.0, -track_fraction * seg_len_deg)
after_end_deg = max(0.0, (track_fraction - 1.0) * seg_len_deg)
along_excess_deg = max(before_start_deg, after_end_deg)
width_excess_deg = max(0.0, offset_deg - GOLDEN_GATE_HALF_WIDTH_DEG)
inside = 0.0 <= track_fraction <= 1.0 and offset_deg <= GOLDEN_GATE_HALF_WIDTH_DEG
entry_margin_deg = max(width_excess_deg, along_excess_deg)
if inside:
entry_margin_deg = -min(
GOLDEN_GATE_HALF_WIDTH_DEG - offset_deg,
track_fraction * seg_len_deg,
(1.0 - track_fraction) * seg_len_deg,
)
return {
"inside": inside,
"center_distance_deg": float(center_distance_deg),
"offset_deg": float(offset_deg),
"track_fraction": float(track_fraction),
"entry_margin_deg": float(entry_margin_deg),
}
def golden_gate_visibility_state(
body: astronomy.Body,
observer: astronomy.Observer,
dt_utc: datetime,
) -> dict:
planet_altitude_deg = body_altitude_deg(body, observer, dt_utc)
sun_altitude_deg = body_altitude_deg(astronomy.Body.Sun, observer, dt_utc)
solar_separation_deg = planet_pair_separation_deg(body, astronomy.Body.Sun, observer, dt_utc)
observable = (
planet_altitude_deg >= GOLDEN_GATE_MIN_PLANET_ALTITUDE_DEG
and sun_altitude_deg <= GOLDEN_GATE_MAX_SUN_ALTITUDE_DEG
and solar_separation_deg >= GOLDEN_GATE_MIN_SOLAR_SEPARATION_DEG
)
return {
"observable": bool(observable),
"planet_altitude_deg": float(planet_altitude_deg),
"sun_altitude_deg": float(sun_altitude_deg),
"solar_separation_deg": float(solar_separation_deg),
}
def refine_golden_gate_offset_minimum(
body: astronomy.Body,
observer: astronomy.Observer,
left_utc: datetime,
right_utc: datetime,
) -> tuple[datetime, dict]:
left = left_utc
right = right_utc
for _ in range(32):
span = (right - left) / 3
m1 = left + span
m2 = right - span
f1 = golden_gate_planet_state(body, observer, m1)["offset_deg"]
f2 = golden_gate_planet_state(body, observer, m2)["offset_deg"]
if f1 <= f2:
right = m2
else:
left = m1
best = left + (right - left) / 2
return best, golden_gate_planet_state(body, observer, best)
def refine_golden_gate_observable_minimum(
body: astronomy.Body,
observer: astronomy.Observer,
left_utc: datetime,
right_utc: datetime,
) -> tuple[datetime | None, dict | None, dict | None]:
step = timedelta(minutes=5)
current = left_utc
best_time: datetime | None = None
best_gate_state: dict | None = None
best_visibility_state: dict | None = None
while current <= right_utc:
gate_state = golden_gate_planet_state(body, observer, current)
visibility_state = golden_gate_visibility_state(body, observer, current)
if bool(gate_state["inside"]) and bool(visibility_state["observable"]):
if best_gate_state is None or float(gate_state["offset_deg"]) < float(best_gate_state["offset_deg"]):
best_time = current
best_gate_state = gate_state
best_visibility_state = visibility_state
current += step
if best_time is None or best_gate_state is None or best_visibility_state is None:
return None, None, None
return best_time, best_gate_state, best_visibility_state
def refine_minimum_separation(
body: astronomy.Body,
observer: astronomy.Observer,
left_utc: datetime,
right_utc: datetime,
) -> tuple[datetime, float]:
left = left_utc
right = right_utc
for _ in range(32):
span = (right - left) / 3
m1 = left + span
m2 = right - span
f1 = moon_planet_separation_deg(body, observer, m1)
f2 = moon_planet_separation_deg(body, observer, m2)
if f1 <= f2:
right = m2
else:
left = m1
best = left + (right - left) / 2
return best, moon_planet_separation_deg(body, observer, best)
def refine_planet_pair_minimum_separation(
body_a: astronomy.Body,
body_b: astronomy.Body,
observer: astronomy.Observer,
left_utc: datetime,
right_utc: datetime,
) -> tuple[datetime, float]:
left = left_utc
right = right_utc
for _ in range(32):
span = (right - left) / 3
m1 = left + span
m2 = right - span
f1 = planet_pair_separation_deg(body_a, body_b, observer, m1)
f2 = planet_pair_separation_deg(body_a, body_b, observer, m2)
if f1 <= f2:
right = m2
else:
left = m1
best = left + (right - left) / 2
return best, planet_pair_separation_deg(body_a, body_b, observer, best)
def refine_fixed_target_minimum_separation(
ra_hours: float,
dec_deg: float,
observer: astronomy.Observer,
left_utc: datetime,
right_utc: datetime,
) -> tuple[datetime, float]:
left = left_utc
right = right_utc
for _ in range(32):
span = (right - left) / 3
m1 = left + span
m2 = right - span
f1 = moon_fixed_equatorial_separation_deg(ra_hours, dec_deg, observer, m1)
f2 = moon_fixed_equatorial_separation_deg(ra_hours, dec_deg, observer, m2)
if f1 <= f2:
right = m2
else:
left = m1
best = left + (right - left) / 2
return best, moon_fixed_equatorial_separation_deg(ra_hours, dec_deg, observer, best)
def refine_planet_fixed_target_minimum_separation(
body: astronomy.Body,
ra_hours: float,
dec_deg: float,
observer: astronomy.Observer,
left_utc: datetime,
right_utc: datetime,
) -> tuple[datetime, float]:
left = left_utc
right = right_utc
for _ in range(32):
span = (right - left) / 3
m1 = left + span
m2 = right - span
f1 = planet_fixed_equatorial_separation_deg(body, ra_hours, dec_deg, observer, m1)
f2 = planet_fixed_equatorial_separation_deg(body, ra_hours, dec_deg, observer, m2)
if f1 <= f2:
right = m2
else:
left = m1
best = left + (right - left) / 2
return best, planet_fixed_equatorial_separation_deg(body, ra_hours, dec_deg, observer, best)
def action_moon_planet_approaches(args: list[str]) -> dict:
if len(args) != 7:
fail(
"Aktion moon_planet_approaches erwartet 7 Argumente: latitude longitude elevation year month timezone max_sep_deg",
extra={"argv": args},
)
latitude = parse_float(args[0], "Latitude")
longitude = parse_float(args[1], "Longitude")
elevation = parse_float(args[2], "Elevation")
try:
year = int(args[3])
month = int(args[4])
except ValueError as exc:
fail("Jahr oder Monat sind ungueltig.", extra={"details": str(exc), "argv": args})
if month < 1 or month > 12:
fail("Monat muss zwischen 1 und 12 liegen.", extra={"month": month})
timezone_name = args[5]
max_sep_deg = parse_float(args[6], "Maximalabstand")
try:
tz = ZoneInfo(timezone_name)
except Exception as exc:
fail("Zeitzone ist ungueltig oder auf dem Server nicht verfuegbar.", extra={"details": str(exc)})
observer = astronomy.Observer(latitude, longitude, elevation)
local_start = datetime(year, month, 1, 0, 0, 0, tzinfo=tz)
if month == 12:
local_end = datetime(year + 1, 1, 1, 0, 0, 0, tzinfo=tz)
else:
local_end = datetime(year, month + 1, 1, 0, 0, 0, tzinfo=tz)
utc_start = local_start.astimezone(timezone.utc)
utc_end = local_end.astimezone(timezone.utc)
scan_step = timedelta(hours=1)
coarse_threshold = max_sep_deg + 1.0
planet_defs = [
("Merkur", astronomy.Body.Mercury, "mercury"),
("Venus", astronomy.Body.Venus, "venus"),
("Mars", astronomy.Body.Mars, "mars"),
("Jupiter", astronomy.Body.Jupiter, "jupiter"),
("Saturn", astronomy.Body.Saturn, "saturn"),
("Uranus", astronomy.Body.Uranus, "uranus"),
("Neptun", astronomy.Body.Neptune, "neptune"),
]
approaches = []
for label, body, key in planet_defs:
samples: list[tuple[datetime, float]] = []
current = utc_start
while current <= utc_end:
samples.append((current, moon_planet_separation_deg(body, observer, current)))
current += scan_step
if samples[-1][0] < utc_end:
samples.append((utc_end, moon_planet_separation_deg(body, observer, utc_end)))
seen_ranges: list[tuple[datetime, datetime]] = []
for index in range(1, len(samples) - 1):
prev_t, prev_sep = samples[index - 1]
curr_t, curr_sep = samples[index]
next_t, next_sep = samples[index + 1]
if curr_sep > coarse_threshold:
continue
if curr_sep > prev_sep or curr_sep > next_sep:
continue
left = max(utc_start, curr_t - scan_step)
right = min(utc_end, curr_t + scan_step)
if any(not (right <= seen_left or left >= seen_right) for seen_left, seen_right in seen_ranges):
continue
min_time_utc, min_sep = refine_minimum_separation(body, observer, left, right)
local_dt = min_time_utc.astimezone(tz)
if local_dt.year != year or local_dt.month != month:
continue
if min_sep > max_sep_deg:
continue
seen_ranges.append((left, right))
overlap_fraction = moon_planet_overlap_fraction(body, observer, min_time_utc)
if overlap_fraction >= (1.0 / 3.0):
contact_times = [
("start", refine_occultation_contact(body, observer, min_time_utc, -1), "Beginn der Bedeckung"),
("maximum", min_time_utc, "Größte Bedeckung"),
("end", refine_occultation_contact(body, observer, min_time_utc, 1), "Ende der Bedeckung"),
]
if all(contact_time is not None for _, contact_time, _ in contact_times):
for event_kind, contact_time, event_label in contact_times:
assert contact_time is not None
contact_local = contact_time.astimezone(tz)
approaches.append({
"planet_key": key,
"planet_label": label,
"label": f"Mond bedeckt {label} – {event_label}",
"event_kind": event_kind,
"separation_deg": float(min_sep),
"utc_iso": contact_time.isoformat().replace("+00:00", "Z"),
"local_iso": contact_local.isoformat(),
"local_date": contact_local.strftime("%d.%m.%Y"),
"local_time": contact_local.strftime("%H:%M"),
})
continue
if overlap_fraction > 0.0:
approaches.append({
"planet_key": key,
"planet_label": label,
"label": f"Mond streift {label}",
"separation_deg": float(min_sep),
"utc_iso": min_time_utc.isoformat().replace("+00:00", "Z"),
"local_iso": local_dt.isoformat(),
"local_date": local_dt.strftime("%d.%m.%Y"),
"local_time": local_dt.strftime("%H:%M"),
})
continue
approaches.append({
"planet_key": key,
"planet_label": label,
"label": f"Mond nahe {label}",
"separation_deg": float(min_sep),
"utc_iso": min_time_utc.isoformat().replace("+00:00", "Z"),
"local_iso": local_dt.isoformat(),
"local_date": local_dt.strftime("%d.%m.%Y"),
"local_time": local_dt.strftime("%H:%M"),
})
approaches.sort(key=lambda item: item["local_iso"])
return {
"ok": True,
"action": "moon_planet_approaches",
"selected": {
"year": year,
"month": month,
"timezone": timezone_name,
"max_separation_deg": max_sep_deg,
},
"approaches": approaches,
}
def action_planet_bright_star_approaches_for_month(args: list[str]) -> dict:
if len(args) != 7:
fail(
"Aktion planet_bright_star_approaches_for_month erwartet 7 Argumente: latitude longitude elevation year month timezone max_sep_deg",
extra={"argv": args},
)
latitude = parse_float(args[0], "Latitude")
longitude = parse_float(args[1], "Longitude")
elevation = parse_float(args[2], "Elevation")
try:
year = int(args[3])
month = int(args[4])
except ValueError as exc:
fail("Jahr oder Monat sind ungueltig.", extra={"details": str(exc), "argv": args})
if month < 1 or month > 12:
fail("Monat muss zwischen 1 und 12 liegen.", extra={"month": month})
timezone_name = args[5]
max_sep_deg = parse_float(args[6], "Maximalabstand")
try:
tz = ZoneInfo(timezone_name)
except Exception as exc:
fail("Zeitzone ist ungueltig oder auf dem Server nicht verfuegbar.", extra={"details": str(exc)})
observer = astronomy.Observer(latitude, longitude, elevation)
local_start = datetime(year, month, 1, 0, 0, 0, tzinfo=tz)
if month == 12:
local_end = datetime(year + 1, 1, 1, 0, 0, 0, tzinfo=tz)
else:
local_end = datetime(year, month + 1, 1, 0, 0, 0, tzinfo=tz)
utc_start = local_start.astimezone(timezone.utc)
utc_end = local_end.astimezone(timezone.utc)
scan_step = timedelta(hours=1)
coarse_threshold = max_sep_deg + 1.0
planet_defs = [
("Merkur", astronomy.Body.Mercury, "mercury"),
("Venus", astronomy.Body.Venus, "venus"),
("Mars", astronomy.Body.Mars, "mars"),
("Jupiter", astronomy.Body.Jupiter, "jupiter"),
("Saturn", astronomy.Body.Saturn, "saturn"),
]
# Helle, auffaellige Sterne nahe der Ekliptik, die regelmaessig
# attraktive Begegnungen mit den hellen Planeten liefern.
star_defs = [
("Alrescha", 2.0341, 2.7638),
("Hamal", 2.1196, 23.4624),
("Menkar", 3.0380, 4.0897),
("Aldebaran", 4.5987, 16.5093),
("Elnath", 5.4382, 28.6075),
("Pollux", 7.7553, 28.0262),
("Alphard", 9.4598, -8.6586),
("Regulus", 10.1395, 11.9672),
("Denebola", 11.8177, 14.5721),
("Spica", 13.4199, -11.1613),
("Zubenelgenubi", 14.8479, -16.0418),
("Antares", 16.4901, -26.4319),
]
approaches = []
for planet_label, planet_body, planet_key in planet_defs:
for star_label, ra_hours, dec_deg in star_defs:
samples: list[tuple[datetime, float]] = []
current = utc_start
while current <= utc_end:
samples.append((current, planet_fixed_equatorial_separation_deg(planet_body, ra_hours, dec_deg, observer, current)))
current += scan_step
if samples[-1][0] < utc_end:
samples.append((utc_end, planet_fixed_equatorial_separation_deg(planet_body, ra_hours, dec_deg, observer, utc_end)))
seen_ranges: list[tuple[datetime, datetime]] = []
for index in range(1, len(samples) - 1):
curr_t, curr_sep = samples[index]
prev_sep = samples[index - 1][1]
next_sep = samples[index + 1][1]
if curr_sep > coarse_threshold:
continue
if curr_sep > prev_sep or curr_sep > next_sep:
continue
left = max(utc_start, curr_t - scan_step)
right = min(utc_end, curr_t + scan_step)
if any(not (right <= seen_left or left >= seen_right) for seen_left, seen_right in seen_ranges):
continue
min_time_utc, min_sep = refine_planet_fixed_target_minimum_separation(
planet_body,
ra_hours,
dec_deg,
observer,
left,
right,
)
local_dt = min_time_utc.astimezone(tz)
if local_dt.year != year or local_dt.month != month:
continue
if min_sep > max_sep_deg:
continue
seen_ranges.append((left, right))
approaches.append({
"planet_key": planet_key,
"planet_label": planet_label,
"star_label": star_label,
"label": f"{planet_label} nahe {star_label}",
"separation_deg": float(min_sep),
"utc_iso": min_time_utc.isoformat().replace("+00:00", "Z"),
"local_iso": local_dt.isoformat(),
"local_date": local_dt.strftime("%d.%m.%Y"),
"local_time": local_dt.strftime("%H:%M"),
})
approaches.sort(key=lambda item: item["local_iso"])
deduplicated_approaches: list[dict] = []
for approach in approaches:
if deduplicated_approaches:
previous = deduplicated_approaches[-1]
same_pair = (
previous["planet_key"] == approach["planet_key"]
and previous["star_label"] == approach["star_label"]
)
previous_local = datetime.fromisoformat(previous["local_iso"])
current_local = datetime.fromisoformat(approach["local_iso"])
if same_pair and abs((current_local - previous_local).total_seconds()) <= 36 * 3600:
if float(approach["separation_deg"]) < float(previous["separation_deg"]):
deduplicated_approaches[-1] = approach
continue
deduplicated_approaches.append(approach)
return {
"ok": True,
"action": "planet_bright_star_approaches_for_month",
"selected": {
"year": year,
"month": month,
"timezone": timezone_name,
"max_separation_deg": max_sep_deg,
},
"approaches": deduplicated_approaches,
}
def action_moon_deep_sky_approaches_for_month(args: list[str]) -> dict:
if len(args) != 7:
fail(
"Aktion moon_deep_sky_approaches_for_month erwartet 7 Argumente: latitude longitude elevation year month timezone max_sep_deg",
extra={"argv": args},
)
latitude = parse_float(args[0], "Latitude")
longitude = parse_float(args[1], "Longitude")
elevation = parse_float(args[2], "Elevation")
try:
year = int(args[3])
month = int(args[4])
except ValueError as exc:
fail("Jahr oder Monat sind ungueltig.", extra={"details": str(exc), "argv": args})
if month < 1 or month > 12:
fail("Monat muss zwischen 1 und 12 liegen.", extra={"month": month})
timezone_name = args[5]
max_sep_deg = parse_float(args[6], "Maximalabstand")
try:
tz = ZoneInfo(timezone_name)
except Exception as exc:
fail("Zeitzone ist ungueltig oder auf dem Server nicht verfuegbar.", extra={"details": str(exc)})
observer = astronomy.Observer(latitude, longitude, elevation)
local_start = datetime(year, month, 1, 0, 0, 0, tzinfo=tz)
if month == 12:
local_end = datetime(year + 1, 1, 1, 0, 0, 0, tzinfo=tz)
else:
local_end = datetime(year, month + 1, 1, 0, 0, 0, tzinfo=tz)
utc_start = local_start.astimezone(timezone.utc)
utc_end = local_end.astimezone(timezone.utc)
scan_step = timedelta(hours=1)
coarse_threshold = max_sep_deg + 1.0
target_defs = [
# Katalogisierte major_axis-Werte aus dso_objects, in Grad.
("pleiades", "Plejaden", 3.7833, 24.1167, 60.0 / 60.0),
("praesepe", "Praesepe", 8.6667, 19.9833, 108.6 / 60.0),
]
approaches = []
for key, label, ra_hours, dec_deg, diameter_deg in target_defs:
samples: list[tuple[datetime, float]] = []
current = utc_start
while current <= utc_end:
samples.append((current, moon_fixed_equatorial_separation_deg(ra_hours, dec_deg, observer, current)))
current += scan_step
if samples[-1][0] < utc_end:
samples.append((utc_end, moon_fixed_equatorial_separation_deg(ra_hours, dec_deg, observer, utc_end)))
seen_ranges: list[tuple[datetime, datetime]] = []
for index in range(1, len(samples) - 1):
curr_t, curr_sep = samples[index]
prev_sep = samples[index - 1][1]
next_sep = samples[index + 1][1]
if curr_sep > coarse_threshold:
continue
if curr_sep > prev_sep or curr_sep > next_sep:
continue
left = max(utc_start, curr_t - scan_step)
right = min(utc_end, curr_t + scan_step)
if any(not (right <= seen_left or left >= seen_right) for seen_left, seen_right in seen_ranges):
continue
min_time_utc, min_sep = refine_fixed_target_minimum_separation(ra_hours, dec_deg, observer, left, right)
local_dt = min_time_utc.astimezone(tz)
if local_dt.year != year or local_dt.month != month:
continue
if min_sep > max_sep_deg:
continue
seen_ranges.append((left, right))
moon_eq = astronomy.Equator(astronomy.Body.Moon, dt_to_time(min_time_utc), observer, True, True)
overlap_fraction = moon_disk_overlap_fraction(
diameter_deg / 2.0,
moon_angular_radius_deg(float(moon_eq.dist)),
min_sep,
)
if overlap_fraction >= (1.0 / 3.0):
event_label = f"Mond bedeckt {label}"
elif overlap_fraction > 0.0:
event_label = f"Mond streift {label}"
else:
event_label = f"Mond nahe {label}"
approaches.append({
"target_key": key,
"target_label": label,
"label": event_label,
"separation_deg": float(min_sep),
"utc_iso": min_time_utc.isoformat().replace("+00:00", "Z"),
"local_iso": local_dt.isoformat(),
"local_date": local_dt.strftime("%d.%m.%Y"),
"local_time": local_dt.strftime("%H:%M"),
})
approaches.sort(key=lambda item: item["local_iso"])
return {
"ok": True,
"action": "moon_deep_sky_approaches_for_month",
"selected": {
"year": year,
"month": month,
"timezone": timezone_name,
"max_separation_deg": max_sep_deg,
},
"approaches": approaches,
}
def action_planet_conjunctions_for_month(args: list[str]) -> dict:
if len(args) != 7:
fail(
"Aktion planet_conjunctions_for_month erwartet 7 Argumente: latitude longitude elevation year month timezone max_sep_deg",
extra={"argv": args},
)
latitude = parse_float(args[0], "Latitude")
longitude = parse_float(args[1], "Longitude")
elevation = parse_float(args[2], "Elevation")
try:
year = int(args[3])
month = int(args[4])
except ValueError as exc:
fail("Jahr oder Monat sind ungueltig.", extra={"details": str(exc), "argv": args})
if month < 1 or month > 12:
fail("Monat muss zwischen 1 und 12 liegen.", extra={"month": month})
timezone_name = args[5]
max_sep_deg = parse_float(args[6], "Maximalabstand")
try:
tz = ZoneInfo(timezone_name)
except Exception as exc:
fail("Zeitzone ist ungueltig oder auf dem Server nicht verfuegbar.", extra={"details": str(exc)})
observer = astronomy.Observer(latitude, longitude, elevation)
local_start = datetime(year, month, 1, 0, 0, 0, tzinfo=tz)
if month == 12:
local_end = datetime(year + 1, 1, 1, 0, 0, 0, tzinfo=tz)
else:
local_end = datetime(year, month + 1, 1, 0, 0, 0, tzinfo=tz)
utc_start = local_start.astimezone(timezone.utc)
utc_end = local_end.astimezone(timezone.utc)
scan_step = timedelta(hours=1)
coarse_threshold = max_sep_deg + 1.0
planet_defs = [
("Mars", astronomy.Body.Mars, "mars"),
("Jupiter", astronomy.Body.Jupiter, "jupiter"),
("Saturn", astronomy.Body.Saturn, "saturn"),
("Uranus", astronomy.Body.Uranus, "uranus"),
("Neptun", astronomy.Body.Neptune, "neptune"),
]
conjunctions = []
for left_index in range(len(planet_defs) - 1):
left_label, left_body, left_key = planet_defs[left_index]
for right_index in range(left_index + 1, len(planet_defs)):
right_label, right_body, right_key = planet_defs[right_index]
samples: list[tuple[datetime, float]] = []
current = utc_start
while current <= utc_end:
samples.append((current, planet_pair_separation_deg(left_body, right_body, observer, current)))
current += scan_step
if samples[-1][0] < utc_end:
samples.append((utc_end, planet_pair_separation_deg(left_body, right_body, observer, utc_end)))
seen_ranges: list[tuple[datetime, datetime]] = []
for index in range(1, len(samples) - 1):
curr_t, curr_sep = samples[index]
prev_sep = samples[index - 1][1]
next_sep = samples[index + 1][1]
if curr_sep > coarse_threshold:
continue
if curr_sep > prev_sep or curr_sep > next_sep:
continue
left = max(utc_start, curr_t - scan_step)
right = min(utc_end, curr_t + scan_step)
if any(not (right <= seen_left or left >= seen_right) for seen_left, seen_right in seen_ranges):
continue
min_time_utc, min_sep = refine_planet_pair_minimum_separation(left_body, right_body, observer, left, right)
local_dt = min_time_utc.astimezone(tz)
if local_dt.year != year or local_dt.month != month:
continue
if min_sep > max_sep_deg:
continue
seen_ranges.append((left, right))
conjunctions.append({
"planet_a_key": left_key,
"planet_a_label": left_label,
"planet_b_key": right_key,
"planet_b_label": right_label,
"label": f"{left_label} nahe {right_label}",
"separation_deg": float(min_sep),
"utc_iso": min_time_utc.isoformat().replace("+00:00", "Z"),
"local_iso": local_dt.isoformat(),
"local_date": local_dt.strftime("%d.%m.%Y"),
"local_time": local_dt.strftime("%H:%M"),
})
conjunctions.sort(key=lambda item: item["local_iso"])
return {
"ok": True,
"action": "planet_conjunctions_for_month",
"selected": {
"year": year,
"month": month,
"timezone": timezone_name,
"max_separation_deg": max_sep_deg,
},
"conjunctions": conjunctions,
}
def action_golden_gate_of_ecliptic_for_month(args: list[str]) -> dict:
if len(args) != 6:
fail(
"Aktion golden_gate_of_ecliptic_for_month erwartet 6 Argumente: latitude longitude elevation year month timezone",
extra={"argv": args},
)
latitude = parse_float(args[0], "Latitude")
longitude = parse_float(args[1], "Longitude")
elevation = parse_float(args[2], "Elevation")
try:
year = int(args[3])
month = int(args[4])
except ValueError as exc:
fail("Jahr oder Monat sind ungueltig.", extra={"details": str(exc), "argv": args})
if month < 1 or month > 12:
fail("Monat muss zwischen 1 und 12 liegen.", extra={"month": month})
timezone_name = args[5]
try:
tz = ZoneInfo(timezone_name)
except Exception as exc:
fail("Zeitzone ist ungueltig oder auf dem Server nicht verfuegbar.", extra={"details": str(exc)})
observer = astronomy.Observer(latitude, longitude, elevation)
local_start = datetime(year, month, 1, 0, 0, 0, tzinfo=tz)
if month == 12:
local_end = datetime(year + 1, 1, 1, 0, 0, 0, tzinfo=tz)
else:
local_end = datetime(year, month + 1, 1, 0, 0, 0, tzinfo=tz)
utc_start = local_start.astimezone(timezone.utc)
utc_end = local_end.astimezone(timezone.utc)
scan_step = timedelta(hours=1)
passages: list[dict] = []
for planet_label, planet_body, planet_key in GOLDEN_GATE_PLANETS:
samples: list[tuple[datetime, dict]] = []
current = utc_start
while current <= utc_end:
samples.append((current, golden_gate_planet_state(planet_body, observer, current)))
current += scan_step
if samples[-1][0] < utc_end:
samples.append((utc_end, golden_gate_planet_state(planet_body, observer, utc_end)))
segment_start: datetime | None = None
best_sample_time: datetime | None = None
best_sample_state: dict | None = None
for sample_time, sample_state in samples:
if bool(sample_state["inside"]):
if segment_start is None:
segment_start = sample_time
best_sample_time = sample_time
best_sample_state = sample_state
elif best_sample_state is None or float(sample_state["offset_deg"]) < float(best_sample_state["offset_deg"]):
best_sample_time = sample_time
best_sample_state = sample_state
elif segment_start is not None:
segment_end = sample_time
refine_left = max(utc_start, segment_start - scan_step)
refine_right = min(utc_end, segment_end)
event_time_utc, event_state, visibility_state = refine_golden_gate_observable_minimum(
planet_body,
observer,
refine_left,
refine_right,
)
if (
event_time_utc is not None
and event_state is not None
and visibility_state is not None
):
local_dt = event_time_utc.astimezone(tz)
if local_dt.year == year and local_dt.month == month:
passages.append({
"planet_key": planet_key,
"planet_label": planet_label,
"label": f"{planet_label} im Goldenen Tor der Ekliptik",
"center_distance_deg": float(event_state["center_distance_deg"]),
"offset_deg": float(event_state["offset_deg"]),
"track_fraction": float(event_state["track_fraction"]),
"planet_altitude_deg": float(visibility_state["planet_altitude_deg"]),
"sun_altitude_deg": float(visibility_state["sun_altitude_deg"]),
"solar_separation_deg": float(visibility_state["solar_separation_deg"]),
"utc_iso": event_time_utc.isoformat().replace("+00:00", "Z"),
"local_iso": local_dt.isoformat(),
"local_date": local_dt.strftime("%d.%m.%Y"),
"local_time": local_dt.strftime("%H:%M"),
})
segment_start = None
best_sample_time = None
best_sample_state = None
if segment_start is not None:
refine_left = max(utc_start, segment_start - scan_step)
refine_right = utc_end
event_time_utc, event_state, visibility_state = refine_golden_gate_observable_minimum(
planet_body,
observer,
refine_left,
refine_right,
)
if (
event_time_utc is not None
and event_state is not None
and visibility_state is not None
):
local_dt = event_time_utc.astimezone(tz)
if local_dt.year == year and local_dt.month == month:
passages.append({
"planet_key": planet_key,
"planet_label": planet_label,
"label": f"{planet_label} im Goldenen Tor der Ekliptik",
"center_distance_deg": float(event_state["center_distance_deg"]),
"offset_deg": float(event_state["offset_deg"]),
"track_fraction": float(event_state["track_fraction"]),
"planet_altitude_deg": float(visibility_state["planet_altitude_deg"]),
"sun_altitude_deg": float(visibility_state["sun_altitude_deg"]),
"solar_separation_deg": float(visibility_state["solar_separation_deg"]),
"utc_iso": event_time_utc.isoformat().replace("+00:00", "Z"),
"local_iso": local_dt.isoformat(),
"local_date": local_dt.strftime("%d.%m.%Y"),
"local_time": local_dt.strftime("%H:%M"),
})
passages.sort(key=lambda item: item["local_iso"])
return {
"ok": True,
"action": "golden_gate_of_ecliptic_for_month",
"selected": {
"year": year,
"month": month,
"timezone": timezone_name,
},
"gate": {
"pleiades": {
"ra_hours": GOLDEN_GATE_PLEIADES_RA_HOURS,
"dec_deg": GOLDEN_GATE_PLEIADES_DEC_DEG,
},
"hyades": {
"ra_hours": GOLDEN_GATE_HYADES_RA_HOURS,
"dec_deg": GOLDEN_GATE_HYADES_DEC_DEG,
},
"half_width_deg": GOLDEN_GATE_HALF_WIDTH_DEG,
"min_planet_altitude_deg": GOLDEN_GATE_MIN_PLANET_ALTITUDE_DEG,
"max_sun_altitude_deg": GOLDEN_GATE_MAX_SUN_ALTITUDE_DEG,
"min_solar_separation_deg": GOLDEN_GATE_MIN_SOLAR_SEPARATION_DEG,
},
"events": passages,
}
def eclipse_kind_label(kind: astronomy.EclipseKind) -> str:
if kind == astronomy.EclipseKind.Penumbral:
return "Halbschatten"
if kind == astronomy.EclipseKind.Partial:
return "Partielle"
if kind == astronomy.EclipseKind.Annular:
return "Ringfoermige"
if kind == astronomy.EclipseKind.Total:
return "Totale"
return "Unbekannte"
def serialize_time_only(time_value: astronomy.Time, tz: ZoneInfo) -> dict:
dt_utc = time_to_datetime(time_value)
local_dt = dt_utc.astimezone(tz)
return {
"utc_iso": dt_utc.isoformat().replace("+00:00", "Z"),
"local_iso": local_dt.isoformat(),
"local_date": local_dt.strftime("%d.%m.%Y"),
"local_time": local_dt.strftime("%H:%M"),
}
def action_lunar_eclipses_for_year(args: list[str]) -> dict:
if len(args) != 2:
fail("Aktion lunar_eclipses_for_year erwartet 2 Argumente: year timezone", extra={"argv": args})
try:
year = int(args[0])
except ValueError as exc:
fail("Jahr ist ungueltig.", extra={"details": str(exc)})
timezone_name = args[1]
try:
tz = ZoneInfo(timezone_name)
except Exception as exc:
fail("Zeitzone ist ungueltig.", extra={"details": str(exc)})
year_start = datetime(year, 1, 1, 0, 0, 0, tzinfo=timezone.utc)
year_end = datetime(year + 1, 1, 1, 0, 0, 0, tzinfo=timezone.utc)
scan_start = dt_to_time(year_start - timedelta(days=20))
eclipses = []
lunar = astronomy.SearchLunarEclipse(scan_start)
while True:
peak_utc = time_to_datetime(lunar.peak)
if peak_utc >= year_end:
break
if peak_utc >= year_start:
local_peak = peak_utc.astimezone(tz)
eclipses.append({
"kind": lunar.kind.name.lower(),
"month": local_peak.month,
"day": local_peak.day,
"local_date": local_peak.strftime("%d.%m.%Y"),
"local_time": local_peak.strftime("%H:%M"),
"utc_iso": peak_utc.isoformat().replace("+00:00", "Z"),
})
lunar = astronomy.NextLunarEclipse(lunar.peak)
return {"ok": True, "action": "lunar_eclipses_for_year", "year": year, "eclipses": eclipses}
def action_eclipses_for_month(args: list[str]) -> dict:
if len(args) != 6:
fail(
"Aktion eclipses_for_month erwartet 6 Argumente: latitude longitude elevation year month timezone",
extra={"argv": args},
)
latitude = parse_float(args[0], "Latitude")
longitude = parse_float(args[1], "Longitude")
elevation = parse_float(args[2], "Elevation")
try:
year = int(args[3])
month = int(args[4])
except ValueError as exc:
fail("Jahr oder Monat sind ungueltig.", extra={"details": str(exc), "argv": args})
if month < 1 or month > 12:
fail("Monat muss zwischen 1 und 12 liegen.", extra={"month": month})
timezone_name = args[5]
try:
tz = ZoneInfo(timezone_name)
except Exception as exc:
fail("Zeitzone ist ungueltig oder auf dem Server nicht verfuegbar.", extra={"details": str(exc)})
observer = astronomy.Observer(latitude, longitude, elevation)
local_start = datetime(year, month, 1, 0, 0, 0, tzinfo=tz)
if month == 12:
local_end = datetime(year + 1, 1, 1, 0, 0, 0, tzinfo=tz)
else:
local_end = datetime(year, month + 1, 1, 0, 0, 0, tzinfo=tz)
utc_start = local_start.astimezone(timezone.utc)
utc_end = local_end.astimezone(timezone.utc)
scan_start = dt_to_time(utc_start - timedelta(days=40))
events: list[dict] = []
lunar = astronomy.SearchLunarEclipse(scan_start)
while True:
peak_dt_utc = time_to_datetime(lunar.peak)
if peak_dt_utc >= utc_end:
break
local_peak = peak_dt_utc.astimezone(tz)
if local_peak >= local_start:
kind_label = eclipse_kind_label(lunar.kind)
penum_begin = lunar.peak.AddDays(-lunar.sd_penum / 1440.0)
penum_end = lunar.peak.AddDays(+lunar.sd_penum / 1440.0)
events.append({
"category": "lunar_eclipse",
"stage": "peak",
"kind": lunar.kind.name.lower(),
"label": f"{kind_label} Mondfinsternis - Maximum",
**serialize_time_only(lunar.peak, tz),
})
if lunar.sd_penum > 0:
events.append({
"category": "lunar_eclipse",
"stage": "penumbral_begin",
"kind": lunar.kind.name.lower(),
"label": f"{kind_label} Mondfinsternis - Halbschatten beginnt",
**serialize_time_only(penum_begin, tz),
})
events.append({
"category": "lunar_eclipse",
"stage": "penumbral_end",
"kind": lunar.kind.name.lower(),
"label": f"{kind_label} Mondfinsternis - Halbschatten endet",
**serialize_time_only(penum_end, tz),
})
if lunar.sd_partial > 0:
partial_begin = lunar.peak.AddDays(-lunar.sd_partial / 1440.0)
partial_end = lunar.peak.AddDays(+lunar.sd_partial / 1440.0)
events.append({
"category": "lunar_eclipse",
"stage": "partial_begin",
"kind": lunar.kind.name.lower(),
"label": f"{kind_label} Mondfinsternis - Partielle Phase beginnt",
**serialize_time_only(partial_begin, tz),
})
events.append({
"category": "lunar_eclipse",
"stage": "partial_end",
"kind": lunar.kind.name.lower(),
"label": f"{kind_label} Mondfinsternis - Partielle Phase endet",
**serialize_time_only(partial_end, tz),
})
if lunar.sd_total > 0:
total_begin = lunar.peak.AddDays(-lunar.sd_total / 1440.0)
total_end = lunar.peak.AddDays(+lunar.sd_total / 1440.0)
events.append({
"category": "lunar_eclipse",
"stage": "total_begin",
"kind": lunar.kind.name.lower(),
"label": "Totale Mondfinsternis - Totalitaet beginnt",
**serialize_time_only(total_begin, tz),
})
events.append({
"category": "lunar_eclipse",
"stage": "total_end",
"kind": lunar.kind.name.lower(),
"label": "Totale Mondfinsternis - Totalitaet endet",
**serialize_time_only(total_end, tz),
})
lunar = astronomy.NextLunarEclipse(lunar.peak)
solar = astronomy.SearchLocalSolarEclipse(scan_start, observer)
while True:
peak_dt_utc = time_to_datetime(solar.peak.time)
if peak_dt_utc >= utc_end:
break
local_peak = peak_dt_utc.astimezone(tz)
if local_peak >= local_start:
kind_label = eclipse_kind_label(solar.kind)
def solar_event_payload(label: str, event: astronomy.EclipseEvent, stage: str) -> dict:
payload = {
"category": "solar_eclipse",
"stage": stage,
"kind": solar.kind.name.lower(),
"label": label,
"sun_altitude_deg": float(event.altitude),
"above_horizon": float(event.altitude) >= 0.0,
}
payload.update(serialize_time_only(event.time, tz))
return payload
events.append(solar_event_payload(f"{kind_label} Sonnenfinsternis - Beginn", solar.partial_begin, "partial_begin"))
events.append(solar_event_payload(f"{kind_label} Sonnenfinsternis - Maximum", solar.peak, "peak"))
if solar.total_begin is not None:
phase_label = "Ringphase beginnt" if solar.kind == astronomy.EclipseKind.Annular else "Totalitaet beginnt"
events.append(solar_event_payload(f"{kind_label} Sonnenfinsternis - {phase_label}", solar.total_begin, "central_begin"))
if solar.total_end is not None:
phase_label = "Ringphase endet" if solar.kind == astronomy.EclipseKind.Annular else "Totalitaet endet"
events.append(solar_event_payload(f"{kind_label} Sonnenfinsternis - {phase_label}", solar.total_end, "central_end"))
events.append(solar_event_payload(f"{kind_label} Sonnenfinsternis - Ende", solar.partial_end, "partial_end"))
solar = astronomy.NextLocalSolarEclipse(solar.peak.time, observer)
events = [
event for event in events
if local_start <= datetime.fromisoformat(event["local_iso"]) < local_end
]
events.sort(key=lambda item: item["local_iso"])
return {
"ok": True,
"action": "eclipses_for_month",
"selected": {
"year": year,
"month": month,
"timezone": timezone_name,
},
"events": events,
}
def action_moon_apsides_for_month(args: list[str]) -> dict:
if len(args) != 3:
fail(
"Aktion moon_apsides_for_month erwartet 3 Argumente: year month timezone",
extra={"argv": args},
)
try:
year = int(args[0])
month = int(args[1])
except ValueError as exc:
fail("Jahr oder Monat sind ungueltig.", extra={"details": str(exc), "argv": args})
if month < 1 or month > 12:
fail("Monat muss zwischen 1 und 12 liegen.", extra={"month": month})
timezone_name = args[2]
try:
tz = ZoneInfo(timezone_name)
except Exception as exc:
fail("Zeitzone ist ungueltig oder auf dem Server nicht verfuegbar.", extra={"details": str(exc)})
local_start = datetime(year, month, 1, 0, 0, 0, tzinfo=tz)
if month == 12:
local_end = datetime(year + 1, 1, 1, 0, 0, 0, tzinfo=tz)
else:
local_end = datetime(year, month + 1, 1, 0, 0, 0, tzinfo=tz)
utc_start = local_start.astimezone(timezone.utc)
scan_start = dt_to_time(utc_start - timedelta(days=10))
events: list[dict] = []
apsis = astronomy.SearchLunarApsis(scan_start)
while True:
dt_utc = time_to_datetime(apsis.time)
local_dt = dt_utc.astimezone(tz)
if local_dt >= local_end:
break
if local_dt >= local_start:
is_perigee = apsis.kind == astronomy.ApsisKind.Pericenter
label = "Mondnaehe (Perigaeum)" if is_perigee else "Mondferne (Apogaeum)"
events.append({
"kind": "perigee" if is_perigee else "apogee",
"label": label,
"distance_km": float(apsis.dist_au * astronomy.KM_PER_AU),
"utc_iso": dt_utc.isoformat().replace("+00:00", "Z"),
"local_iso": local_dt.isoformat(),
"local_date": local_dt.strftime("%d.%m.%Y"),
"local_time": local_dt.strftime("%H:%M"),
})
apsis = astronomy.NextLunarApsis(apsis)
events.sort(key=lambda item: item["local_iso"])
return {
"ok": True,
"action": "moon_apsides_for_month",
"selected": {
"year": year,
"month": month,
"timezone": timezone_name,
},
"events": events,
}
def action_sun_apsides_for_month(args: list[str]) -> dict:
if len(args) != 3:
fail(
"Aktion sun_apsides_for_month erwartet 3 Argumente: year month timezone",
extra={"argv": args},
)
try:
year = int(args[0])
month = int(args[1])
except ValueError as exc:
fail("Jahr oder Monat sind ungueltig.", extra={"details": str(exc), "argv": args})
if month < 1 or month > 12:
fail("Monat muss zwischen 1 und 12 liegen.", extra={"month": month})
timezone_name = args[2]
try:
tz = ZoneInfo(timezone_name)
except Exception as exc:
fail("Zeitzone ist ungueltig oder auf dem Server nicht verfuegbar.", extra={"details": str(exc)})
local_start = datetime(year, month, 1, 0, 0, 0, tzinfo=tz)
if month == 12:
local_end = datetime(year + 1, 1, 1, 0, 0, 0, tzinfo=tz)
else:
local_end = datetime(year, month + 1, 1, 0, 0, 0, tzinfo=tz)
utc_start = local_start.astimezone(timezone.utc)
scan_start = dt_to_time(utc_start - timedelta(days=40))
events: list[dict] = []
apsis = astronomy.SearchPlanetApsis(astronomy.Body.Earth, scan_start)
while True:
dt_utc = time_to_datetime(apsis.time)
local_dt = dt_utc.astimezone(tz)
if local_dt >= local_end:
break
if local_dt >= local_start:
is_perihelion = apsis.kind == astronomy.ApsisKind.Pericenter
label = (
"Erde erreicht sonnennachsten Punkt (Perihel)"
if is_perihelion
else "Erde erreicht sonnenfernsten Punkt (Aphel)"
)
events.append({
"kind": "perihelion" if is_perihelion else "aphelion",
"label": label,
"distance_km": float(apsis.dist_au * astronomy.KM_PER_AU),
"utc_iso": dt_utc.isoformat().replace("+00:00", "Z"),
"local_iso": local_dt.isoformat(),
"local_date": local_dt.strftime("%d.%m.%Y"),
"local_time": local_dt.strftime("%H:%M"),
})
apsis = astronomy.NextPlanetApsis(astronomy.Body.Earth, apsis)
events.sort(key=lambda item: item["local_iso"])
return {
"ok": True,
"action": "sun_apsides_for_month",
"selected": {
"year": year,
"month": month,
"timezone": timezone_name,
},
"events": events,
}
def action_inner_planet_elongations_for_month(args: list[str]) -> dict:
if len(args) != 3:
fail(
"Aktion inner_planet_elongations_for_month erwartet 3 Argumente: year month timezone",
extra={"argv": args},
)
try:
year = int(args[0])
month = int(args[1])
except ValueError as exc:
fail("Jahr oder Monat sind ungueltig.", extra={"details": str(exc), "argv": args})
if month < 1 or month > 12:
fail("Monat muss zwischen 1 und 12 liegen.", extra={"month": month})
timezone_name = args[2]
try:
tz = ZoneInfo(timezone_name)
except Exception as exc:
fail("Zeitzone ist ungueltig oder auf dem Server nicht verfuegbar.", extra={"details": str(exc)})
local_start = datetime(year, month, 1, 0, 0, 0, tzinfo=tz)
if month == 12:
local_end = datetime(year + 1, 1, 1, 0, 0, 0, tzinfo=tz)
else:
local_end = datetime(year, month + 1, 1, 0, 0, 0, tzinfo=tz)
utc_start = local_start.astimezone(timezone.utc)
scan_start = dt_to_time(utc_start - timedelta(days=80))
planet_defs = [
("Merkur", astronomy.Body.Mercury, "mercury"),
("Venus", astronomy.Body.Venus, "venus"),
]
events: list[dict] = []
for label, body, key in planet_defs:
event = astronomy.SearchMaxElongation(body, scan_start)
while event is not None:
dt_utc = time_to_datetime(event.time)
local_dt = dt_utc.astimezone(tz)
if local_dt >= local_end:
break
if local_dt >= local_start:
visibility_label = "westliche" if event.visibility == astronomy.Visibility.Morning else "östliche"
events.append({
"planet_key": key,
"planet_label": label,
"kind": "western" if event.visibility == astronomy.Visibility.Morning else "eastern",
"label": f"{label} größte {visibility_label} Elongation",
"elongation_deg": float(event.elongation),
"ecliptic_separation_deg": float(event.ecliptic_separation),
"utc_iso": dt_utc.isoformat().replace("+00:00", "Z"),
"local_iso": local_dt.isoformat(),
"local_date": local_dt.strftime("%d.%m.%Y"),
"local_time": local_dt.strftime("%H:%M"),
})
event = astronomy.SearchMaxElongation(body, event.time.AddDays(1.0))
for event_kind, target_lon, event_label in [
("inferior_conjunction", 0.0, "unterer Konjunktion"),
("superior_conjunction", 180.0, "oberer Konjunktion"),
]:
event_time = astronomy.SearchRelativeLongitude(body, target_lon, scan_start)
while True:
event_dt_utc = time_to_datetime(event_time)
local_dt = event_dt_utc.astimezone(tz)
if local_dt >= local_end:
break
if local_dt >= local_start:
events.append({
"planet_key": key,
"planet_label": label,
"kind": event_kind,
"label": f"{label} in {event_label}",
"utc_iso": event_dt_utc.isoformat().replace("+00:00", "Z"),
"local_iso": local_dt.isoformat(),
"local_date": local_dt.strftime("%d.%m.%Y"),
"local_time": local_dt.strftime("%H:%M"),
})
event_time = astronomy.SearchRelativeLongitude(body, target_lon, event_time.AddDays(10.0))
events.sort(key=lambda item: item["local_iso"])
return {
"ok": True,
"action": "inner_planet_elongations_for_month",
"selected": {
"year": year,
"month": month,
"timezone": timezone_name,
},
"events": events,
}
def action_venus_peak_magnitude_for_month(args: list[str]) -> dict:
if len(args) != 3:
fail(
"Aktion venus_peak_magnitude_for_month erwartet 3 Argumente: year month timezone",
extra={"argv": args},
)
try:
year = int(args[0])
month = int(args[1])
except ValueError as exc:
fail("Jahr oder Monat sind ungueltig.", extra={"details": str(exc), "argv": args})
if month < 1 or month > 12:
fail("Monat muss zwischen 1 und 12 liegen.", extra={"month": month})
timezone_name = args[2]
try:
tz = ZoneInfo(timezone_name)
except Exception as exc:
fail("Zeitzone ist ungueltig oder auf dem Server nicht verfuegbar.", extra={"details": str(exc)})
local_start = datetime(year, month, 1, 0, 0, 0, tzinfo=tz)
if month == 12:
local_end = datetime(year + 1, 1, 1, 0, 0, 0, tzinfo=tz)
else:
local_end = datetime(year, month + 1, 1, 0, 0, 0, tzinfo=tz)
utc_start = local_start.astimezone(timezone.utc)
scan_start = dt_to_time(utc_start - timedelta(days=80))
events: list[dict] = []
event = astronomy.SearchPeakMagnitude(astronomy.Body.Venus, scan_start)
while event is not None:
dt_utc = time_to_datetime(event.time)
local_dt = dt_utc.astimezone(tz)
if local_dt >= local_end:
break
if local_dt >= local_start:
elongation_info = astronomy.Elongation(astronomy.Body.Venus, event.time)
events.append({
"planet_key": "venus",
"planet_label": "Venus",
"kind": "peak_magnitude",
"label": "Venus in größter Helligkeit",
"mag": float(event.mag),
"visibility": "morning" if elongation_info.visibility == astronomy.Visibility.Morning else "evening",
"utc_iso": dt_utc.isoformat().replace("+00:00", "Z"),
"local_iso": local_dt.isoformat(),
"local_date": local_dt.strftime("%d.%m.%Y"),
"local_time": local_dt.strftime("%H:%M"),
})
event = astronomy.SearchPeakMagnitude(astronomy.Body.Venus, event.time.AddDays(30.0))
events.sort(key=lambda item: item["local_iso"])
return {
"ok": True,
"action": "venus_peak_magnitude_for_month",
"selected": {
"year": year,
"month": month,
"timezone": timezone_name,
},
"events": events,
}
def action_outer_planet_events_for_month(args: list[str]) -> dict:
if len(args) != 3:
fail(
"Aktion outer_planet_events_for_month erwartet 3 Argumente: year month timezone",
extra={"argv": args},
)
try:
year = int(args[0])
month = int(args[1])
except ValueError as exc:
fail("Jahr oder Monat sind ungueltig.", extra={"details": str(exc), "argv": args})
if month < 1 or month > 12:
fail("Monat muss zwischen 1 und 12 liegen.", extra={"month": month})
timezone_name = args[2]
try:
tz = ZoneInfo(timezone_name)
except Exception as exc:
fail("Zeitzone ist ungueltig oder auf dem Server nicht verfuegbar.", extra={"details": str(exc)})
local_start = datetime(year, month, 1, 0, 0, 0, tzinfo=tz)
if month == 12:
local_end = datetime(year + 1, 1, 1, 0, 0, 0, tzinfo=tz)
else:
local_end = datetime(year, month + 1, 1, 0, 0, 0, tzinfo=tz)
utc_start = local_start.astimezone(timezone.utc)
scan_start = dt_to_time(utc_start - timedelta(days=450))
planet_defs = [
("Mars", astronomy.Body.Mars, "mars"),
("Jupiter", astronomy.Body.Jupiter, "jupiter"),
("Saturn", astronomy.Body.Saturn, "saturn"),
("Uranus", astronomy.Body.Uranus, "uranus"),
("Neptun", astronomy.Body.Neptune, "neptune"),
]
events: list[dict] = []
for label, body, key in planet_defs:
for event_kind, target_lon, event_label in [
("opposition", 0.0, "Opposition"),
("conjunction", 180.0, "Konjunktion"),
]:
event_time = astronomy.SearchRelativeLongitude(body, target_lon, scan_start)
while True:
event_dt_utc = time_to_datetime(event_time)
local_dt = event_dt_utc.astimezone(tz)
if local_dt >= local_end:
break
if local_dt >= local_start:
events.append({
"planet_key": key,
"planet_label": label,
"kind": event_kind,
"label": f"{label} in {event_label}",
"utc_iso": event_dt_utc.isoformat().replace("+00:00", "Z"),
"local_iso": local_dt.isoformat(),
"local_date": local_dt.strftime("%d.%m.%Y"),
"local_time": local_dt.strftime("%H:%M"),
})
event_time = astronomy.SearchRelativeLongitude(body, target_lon, event_time.AddDays(10.0))
events.sort(key=lambda item: item["local_iso"])
return {
"ok": True,
"action": "outer_planet_events_for_month",
"selected": {
"year": year,
"month": month,
"timezone": timezone_name,
},
"events": events,
}
def geocentric_ecliptic_longitude_deg(body: astronomy.Body, dt_utc: datetime) -> float:
time_value = dt_to_time(dt_utc)
vector = astronomy.GeoVector(body, time_value, True)
ecliptic = astronomy.Ecliptic(vector)
return float(ecliptic.elon)
def longitude_delta_deg(left_deg: float, right_deg: float) -> float:
delta = right_deg - left_deg
while delta <= -180.0:
delta += 360.0
while delta > 180.0:
delta -= 360.0
return delta
def planet_longitude_rate_deg_per_day(body: astronomy.Body, dt_utc: datetime) -> float:
step = timedelta(hours=12)
left = geocentric_ecliptic_longitude_deg(body, dt_utc - step)
right = geocentric_ecliptic_longitude_deg(body, dt_utc + step)
return longitude_delta_deg(left, right)
def refine_stationary_time(body: astronomy.Body, left_utc: datetime, right_utc: datetime) -> datetime:
left = left_utc
right = right_utc
f_left = planet_longitude_rate_deg_per_day(body, left)
f_right = planet_longitude_rate_deg_per_day(body, right)
for _ in range(40):
mid = left + (right - left) / 2
f_mid = planet_longitude_rate_deg_per_day(body, mid)
if f_left == 0:
return left
if f_right == 0:
return right
if f_left * f_mid <= 0:
right = mid
f_right = f_mid
else:
left = mid
f_left = f_mid
return left + (right - left) / 2
def action_outer_planet_stations_for_month(args: list[str]) -> dict:
if len(args) != 3:
fail(
"Aktion outer_planet_stations_for_month erwartet 3 Argumente: year month timezone",
extra={"argv": args},
)
try:
year = int(args[0])
month = int(args[1])
except ValueError as exc:
fail("Jahr oder Monat sind ungueltig.", extra={"details": str(exc), "argv": args})
if month < 1 or month > 12:
fail("Monat muss zwischen 1 und 12 liegen.", extra={"month": month})
timezone_name = args[2]
try:
tz = ZoneInfo(timezone_name)
except Exception as exc:
fail("Zeitzone ist ungueltig oder auf dem Server nicht verfuegbar.", extra={"details": str(exc)})
local_start = datetime(year, month, 1, 0, 0, 0, tzinfo=tz)
if month == 12:
local_end = datetime(year + 1, 1, 1, 0, 0, 0, tzinfo=tz)
else:
local_end = datetime(year, month + 1, 1, 0, 0, 0, tzinfo=tz)
utc_start = local_start.astimezone(timezone.utc)
utc_end = local_end.astimezone(timezone.utc)
scan_start = utc_start - timedelta(days=70)
scan_end = utc_end + timedelta(days=70)
scan_step = timedelta(hours=12)
planet_defs = [
("Merkur", astronomy.Body.Mercury, "mercury"),
("Venus", astronomy.Body.Venus, "venus"),
("Mars", astronomy.Body.Mars, "mars"),
("Jupiter", astronomy.Body.Jupiter, "jupiter"),
("Saturn", astronomy.Body.Saturn, "saturn"),
("Uranus", astronomy.Body.Uranus, "uranus"),
("Neptun", astronomy.Body.Neptune, "neptune"),
]
events: list[dict] = []
for label, body, key in planet_defs:
current = scan_start
previous_time = current
previous_rate = planet_longitude_rate_deg_per_day(body, current)
current += scan_step
while current <= scan_end:
current_rate = planet_longitude_rate_deg_per_day(body, current)
if previous_rate == 0 or current_rate == 0 or previous_rate * current_rate < 0:
station_time_utc = refine_stationary_time(body, previous_time, current)
local_dt = station_time_utc.astimezone(tz)
if local_start <= local_dt < local_end:
rate_before = planet_longitude_rate_deg_per_day(body, station_time_utc - timedelta(days=3))
rate_after = planet_longitude_rate_deg_per_day(body, station_time_utc + timedelta(days=3))
if rate_before > 0 and rate_after < 0:
kind = "retrograde"
text = "stationär, dann rückläufig"
elif rate_before < 0 and rate_after > 0:
kind = "prograde"
text = "stationär, dann rechtläufig"
else:
kind = "stationary"
text = "stationär"
if not any(
item["planet_key"] == key and abs(datetime.fromisoformat(item["local_iso"]).timestamp() - local_dt.timestamp()) < 3600
for item in events
):
events.append({
"planet_key": key,
"planet_label": label,
"kind": kind,
"label": f"{label} {text}",
"utc_iso": station_time_utc.isoformat().replace("+00:00", "Z"),
"local_iso": local_dt.isoformat(),
"local_date": local_dt.strftime("%d.%m.%Y"),
"local_time": local_dt.strftime("%H:%M"),
})
previous_time = current
previous_rate = current_rate
current += scan_step
events.sort(key=lambda item: item["local_iso"])
return {
"ok": True,
"action": "outer_planet_stations_for_month",
"selected": {
"year": year,
"month": month,
"timezone": timezone_name,
},
"events": events,
}
def action_jupiter_moons_one_side_for_month(args: list[str]) -> dict:
if len(args) != 3:
fail(
"Aktion jupiter_moons_one_side_for_month erwartet 3 Argumente: year month timezone",
extra={"argv": args},
)
try:
year = int(args[0])
month = int(args[1])
except ValueError as exc:
fail("Jahr oder Monat sind ungueltig.", extra={"details": str(exc), "argv": args})
if month < 1 or month > 12:
fail("Monat muss zwischen 1 und 12 liegen.", extra={"month": month})
timezone_name = args[2]
try:
tz = ZoneInfo(timezone_name)
except Exception as exc:
fail("Zeitzone ist ungueltig oder auf dem Server nicht verfuegbar.", extra={"details": str(exc)})
local_start = datetime(year, month, 1, 0, 0, 0, tzinfo=tz)
if month == 12:
local_end = datetime(year + 1, 1, 1, 0, 0, 0, tzinfo=tz)
else:
local_end = datetime(year, month + 1, 1, 0, 0, 0, tzinfo=tz)
utc_start = local_start.astimezone(timezone.utc)
utc_end = local_end.astimezone(timezone.utc)
scan_step = timedelta(minutes=10)
x_tolerance_au = 1.0e-6
jupiter_radius_au = astronomy.JUPITER_EQUATORIAL_RADIUS_KM / astronomy.KM_PER_AU
moon_keys = ["io", "europa", "ganymede", "callisto"]
def classify_side(dt_utc: datetime) -> tuple[str | None, str | None, dict[str, float], dict[str, float]]:
# Die ausgegebenen Ereigniszeiten bleiben Beobachtungszeiten in UTC.
# Für die tatsächliche Stellung der Monde rechnen wir auf den
# Emissionszeitpunkt des Jupiter-Lichts zurück.
emission_dt = jupiter_emission_datetime(dt_utc)
time_value = dt_to_time(emission_dt)
rotation_eqj_to_ecl = astronomy.Rotation_EQJ_ECL()
jupiter_geo = astronomy.GeoVector(astronomy.Body.Jupiter, time_value, True)
jupiter_helio = astronomy.HelioVector(astronomy.Body.Jupiter, time_value)
moon_states = astronomy.JupiterMoons(time_value)
to_earth_eqj = astronomy.Vector(
-float(jupiter_geo.x),
-float(jupiter_geo.y),
-float(jupiter_geo.z),
time_value,
)
to_earth_ecl = astronomy.RotateVector(rotation_eqj_to_ecl, to_earth_eqj)
camera_x = float(to_earth_ecl.x)
camera_y = float(to_earth_ecl.z)
camera_z = -float(to_earth_ecl.y)
camera_len = math.sqrt(
(camera_x * camera_x) + (camera_y * camera_y) + (camera_z * camera_z)
)
if camera_len <= 1.0e-12:
return None, None, {}, {}
# Dieselbe Welt-/Kameraorientierung wie in jupitersystem.php:
# Display-Koordinaten sind (x, z, -y), die Kamera schaut aus der Richtung "toEarth".
forward_x = -camera_x / camera_len
forward_y = -camera_y / camera_len
forward_z = -camera_z / camera_len
up_x = 0.0
up_y = 1.0
up_z = 0.0
right_x = (up_y * forward_z) - (up_z * forward_y)
right_y = (up_z * forward_x) - (up_x * forward_z)
right_z = (up_x * forward_y) - (up_y * forward_x)
right_len = math.sqrt(
(right_x * right_x) + (right_y * right_y) + (right_z * right_z)
)
if right_len <= 1.0e-12:
return None, None, {}, {}
right_x /= right_len
right_y /= right_len
right_z /= right_len
# Himmlische Ostrichtung am Jupiter-Ort fuer die textliche Richtung oestlich/westlich.
jx = float(jupiter_geo.x)
jy = float(jupiter_geo.y)
east_eqj_x = -jy
east_eqj_y = jx
east_eqj_z = 0.0
east_eqj_len = math.sqrt(
(east_eqj_x * east_eqj_x) + (east_eqj_y * east_eqj_y) + (east_eqj_z * east_eqj_z)
)
if east_eqj_len <= 1.0e-12:
return None, None, {}, {}
east_eqj_x /= east_eqj_len
east_eqj_y /= east_eqj_len
east_eqj_z /= east_eqj_len
east_ecl = astronomy.RotateVector(
rotation_eqj_to_ecl,
astronomy.Vector(east_eqj_x, east_eqj_y, east_eqj_z, time_value),
)
east_display_x = float(east_ecl.x)
east_display_y = float(east_ecl.z)
east_display_z = -float(east_ecl.y)
east_screen_x = (
(east_display_x * right_x)
+ (east_display_y * right_y)
+ (east_display_z * right_z)
)
sun_to_jupiter_x = float(jupiter_helio.x)
sun_to_jupiter_y = float(jupiter_helio.y)
sun_to_jupiter_z = float(jupiter_helio.z)
sun_to_jupiter_len = math.sqrt(
(sun_to_jupiter_x * sun_to_jupiter_x)
+ (sun_to_jupiter_y * sun_to_jupiter_y)
+ (sun_to_jupiter_z * sun_to_jupiter_z)
)
if sun_to_jupiter_len <= 1.0e-12:
return None, None, {}, {}
sun_to_jupiter_x /= sun_to_jupiter_len
sun_to_jupiter_y /= sun_to_jupiter_len
sun_to_jupiter_z /= sun_to_jupiter_len
projected_x_positions = {}
east_offsets = {}
shadow_flags = {}
for key in moon_keys:
moon_state = getattr(moon_states, key)
moon_ecl = astronomy.RotateVector(rotation_eqj_to_ecl, moon_state)
moon_display_x = float(moon_ecl.x)
moon_display_y = float(moon_ecl.z)
moon_display_z = -float(moon_ecl.y)
projected_x = (
(moon_display_x * right_x)
+ (moon_display_y * right_y)
+ (moon_display_z * right_z)
)
projected_x_positions[key] = projected_x
east_offsets[key] = projected_x * east_screen_x
moon_x = float(moon_state.x)
moon_y = float(moon_state.y)
moon_z = float(moon_state.z)
shadow_axis_distance = (
(moon_x * sun_to_jupiter_x)
+ (moon_y * sun_to_jupiter_y)
+ (moon_z * sun_to_jupiter_z)
)
perp_x = moon_x - (shadow_axis_distance * sun_to_jupiter_x)
perp_y = moon_y - (shadow_axis_distance * sun_to_jupiter_y)
perp_z = moon_z - (shadow_axis_distance * sun_to_jupiter_z)
shadow_flags[key] = (
shadow_axis_distance > 0.0
and math.sqrt((perp_x * perp_x) + (perp_y * perp_y) + (perp_z * perp_z)) < jupiter_radius_au
)
projected_side_kind = None
minimum_positive_x = jupiter_radius_au + x_tolerance_au
if (
all(x_value > minimum_positive_x for x_value in projected_x_positions.values())
and not any(shadow_flags.values())
):
projected_side_kind = "right"
elif (
all(x_value < -minimum_positive_x for x_value in projected_x_positions.values())
and not any(shadow_flags.values())
):
projected_side_kind = "left"
east_west_kind = None
if all(offset > x_tolerance_au for offset in east_offsets.values()):
east_west_kind = "east"
elif all(offset < -x_tolerance_au for offset in east_offsets.values()):
east_west_kind = "west"
return projected_side_kind, east_west_kind, projected_x_positions, east_offsets
def refine_transition(
left_utc: datetime,
right_utc: datetime,
target_kind: str,
*,
find_start: bool,
) -> datetime:
left = left_utc
right = right_utc
for _ in range(32):
mid = left + (right - left) / 2
mid_kind, _, _, _ = classify_side(mid)
if find_start:
if mid_kind == target_kind:
right = mid
else:
left = mid
else:
if mid_kind == target_kind:
left = mid
else:
right = mid
return left + (right - left) / 2
def format_duration_label(duration_minutes: int) -> str:
hours, minutes = divmod(max(0, duration_minutes), 60)
if hours > 0 and minutes > 0:
return f"{hours} h {minutes:02d} min"
if hours > 0:
return f"{hours} h"
return f"{minutes} min"
def direction_text_from_kinds(raw_kind: str | None, east_west_kind: str | None) -> str:
if east_west_kind == "east":
return "östlich"
if east_west_kind == "west":
return "westlich"
return "westlich" if raw_kind == "left" else "östlich"
events: list[dict] = []
current = utc_start
active_kind, active_east_west_kind, _, _ = classify_side(current)
active_start_utc = utc_start if active_kind is not None else None
previous_time = current
previous_kind = active_kind
current += scan_step
while current <= utc_end:
current_kind, current_east_west_kind, _, _ = classify_side(current)
if active_kind is None and previous_kind != current_kind and current_kind is not None:
active_kind = current_kind
active_east_west_kind = current_east_west_kind
active_start_utc = refine_transition(previous_time, current, current_kind, find_start=True)
elif active_kind is not None and current_kind != active_kind:
active_end_utc = refine_transition(previous_time, current, active_kind, find_start=False)
clamped_start_utc = max(active_start_utc or utc_start, utc_start)
clamped_end_utc = min(active_end_utc, utc_end)
if clamped_end_utc > clamped_start_utc:
start_local = clamped_start_utc.astimezone(tz)
end_local = clamped_end_utc.astimezone(tz)
duration_minutes = max(
1,
int(round((clamped_end_utc - clamped_start_utc).total_seconds() / 60.0)),
)
side_text = direction_text_from_kinds(active_kind, active_east_west_kind)
events.append({
"kind": active_kind,
"direction": active_east_west_kind,
"label": f"Alle 4 Jupitermonde {side_text} von Jupiter",
"duration_label": format_duration_label(duration_minutes),
"duration_minutes": duration_minutes,
"utc_iso": clamped_start_utc.isoformat().replace("+00:00", "Z"),
"local_iso": start_local.isoformat(),
"local_date": start_local.strftime("%d.%m.%Y"),
"local_time": start_local.strftime("%H:%M"),
"end_utc_iso": clamped_end_utc.isoformat().replace("+00:00", "Z"),
"end_local_iso": end_local.isoformat(),
"end_local_date": end_local.strftime("%d.%m.%Y"),
"end_local_time": end_local.strftime("%H:%M"),
})
active_kind = None
active_east_west_kind = None
active_start_utc = None
if current_kind is not None:
active_kind = current_kind
active_east_west_kind = current_east_west_kind
active_start_utc = refine_transition(previous_time, current, current_kind, find_start=True)
previous_time = current
previous_kind = current_kind
current += scan_step
if active_kind is not None and active_start_utc is not None:
clamped_start_utc = max(active_start_utc, utc_start)
clamped_end_utc = utc_end
if clamped_end_utc > clamped_start_utc:
start_local = clamped_start_utc.astimezone(tz)
end_local = clamped_end_utc.astimezone(tz)
duration_minutes = max(
1,
int(round((clamped_end_utc - clamped_start_utc).total_seconds() / 60.0)),
)
side_text = direction_text_from_kinds(active_kind, active_east_west_kind)
events.append({
"kind": active_kind,
"direction": active_east_west_kind,
"label": f"Alle 4 Jupitermonde {side_text} von Jupiter",
"duration_label": format_duration_label(duration_minutes),
"duration_minutes": duration_minutes,
"utc_iso": clamped_start_utc.isoformat().replace("+00:00", "Z"),
"local_iso": start_local.isoformat(),
"local_date": start_local.strftime("%d.%m.%Y"),
"local_time": start_local.strftime("%H:%M"),
"end_utc_iso": clamped_end_utc.isoformat().replace("+00:00", "Z"),
"end_local_iso": end_local.isoformat(),
"end_local_date": end_local.strftime("%d.%m.%Y"),
"end_local_time": end_local.strftime("%H:%M"),
})
events.sort(key=lambda item: item["local_iso"])
return {
"ok": True,
"action": "jupiter_moons_one_side_for_month",
"selected": {
"year": year,
"month": month,
"timezone": timezone_name,
},
"events": events,
}
def action_moon_star_occultations_for_month(args: list[str]) -> dict:
if len(args) != 7:
fail(
"Aktion moon_star_occultations_for_month erwartet 7 Argumente: latitude longitude elevation year month timezone star_file",
extra={"argv": args},
)
latitude = parse_float(args[0], "Latitude")
longitude = parse_float(args[1], "Longitude")
elevation = parse_float(args[2], "Elevation")
try:
year = int(args[3])
month = int(args[4])
except ValueError as exc:
fail("Jahr oder Monat sind ungueltig.", extra={"details": str(exc), "argv": args})
if month < 1 or month > 12:
fail("Monat muss zwischen 1 und 12 liegen.", extra={"month": month})
timezone_name = args[5]
star_file_path = args[6]
if not star_file_path:
fail("Sterndatei fehlt.")
try:
tz = ZoneInfo(timezone_name)
except Exception as exc:
fail("Zeitzone ist ungueltig oder auf dem Server nicht verfuegbar.", extra={"details": str(exc)})
observer = astronomy.Observer(latitude, longitude, elevation)
star_rows = load_star_file(star_file_path)
local_start = datetime(year, month, 1, 0, 0, 0, tzinfo=tz)
if month == 12:
local_end = datetime(year + 1, 1, 1, 0, 0, 0, tzinfo=tz)
else:
local_end = datetime(year, month + 1, 1, 0, 0, 0, tzinfo=tz)
results, debug_profile = compute_star_occultation(star_rows, observer, local_start, local_end, tz)
return {
"ok": True,
"action": "moon_star_occultations_for_month",
"selected": {
"year": year,
"month": month,
"timezone": timezone_name,
},
"star_count": len(star_rows),
"debug": debug_profile,
"results": results,
}
def classify_visibility(sun_alt_deg: float) -> dict:
if sun_alt_deg > -5:
return {"key": "day", "label": "Tag"}
if sun_alt_deg >= -10:
return {"key": "twilight", "label": "Dämmerung"}
return {"key": "night", "label": "Nacht"}
def spherical_separation_deg(ra_hours_a: float, dec_deg_a: float, ra_hours_b: float, dec_deg_b: float) -> float:
deg2rad = math.pi / 180.0
ra_a = ra_hours_a * 15.0 * deg2rad
dec_a = dec_deg_a * deg2rad
ra_b = ra_hours_b * 15.0 * deg2rad
dec_b = dec_deg_b * deg2rad
cos_sep = (
(math.sin(dec_a) * math.sin(dec_b))
+ (math.cos(dec_a) * math.cos(dec_b) * math.cos(ra_a - ra_b))
)
cos_sep = max(-1.0, min(1.0, cos_sep))
return math.degrees(math.acos(cos_sep))
def moon_angular_radius_deg(distance_au: float) -> float:
if not math.isfinite(distance_au) or distance_au <= 0:
return 0.0
ratio = MOON_RADIUS_KM / (distance_au * astronomy.KM_PER_AU)
ratio = max(-1.0, min(1.0, ratio))
return math.degrees(math.asin(ratio))
def planet_angular_radius_deg(body: astronomy.Body, distance_au: float) -> float:
if not math.isfinite(distance_au) or distance_au <= 0:
return 0.0
ratio = PLANET_RADIUS_KM[body] / (distance_au * astronomy.KM_PER_AU)
ratio = max(-1.0, min(1.0, ratio))
return math.degrees(math.asin(ratio))
def circle_overlap_fraction(moon_radius_deg: float, target_radius_deg: float, separation_deg: float) -> float:
"""Berechnet den Anteil der Zielscheibe, den der Mond überdeckt."""
if moon_radius_deg <= 0.0 or target_radius_deg <= 0.0:
return 0.0
if separation_deg >= moon_radius_deg + target_radius_deg:
return 0.0
if separation_deg <= abs(moon_radius_deg - target_radius_deg):
overlap_area = math.pi * min(moon_radius_deg, target_radius_deg) ** 2
else:
moon_r2 = moon_radius_deg ** 2
target_r2 = target_radius_deg ** 2
moon_angle = math.acos((separation_deg ** 2 + moon_r2 - target_r2) / (2.0 * separation_deg * moon_radius_deg))
target_angle = math.acos((separation_deg ** 2 + target_r2 - moon_r2) / (2.0 * separation_deg * target_radius_deg))
triangle = 0.5 * math.sqrt(max(0.0, (
-separation_deg + moon_radius_deg + target_radius_deg
) * (
separation_deg + moon_radius_deg - target_radius_deg
) * (
separation_deg - moon_radius_deg + target_radius_deg
) * (
separation_deg + moon_radius_deg + target_radius_deg
)))
overlap_area = moon_r2 * moon_angle + target_r2 * target_angle - triangle
return max(0.0, min(1.0, overlap_area / (math.pi * target_radius_deg ** 2)))
def moon_disk_overlap_fraction(target_radius_deg: float, moon_radius_deg: float, separation_deg: float) -> float:
"""Berechnet bei Sternhaufen den überdeckten Anteil der Mondscheibe."""
return circle_overlap_fraction(target_radius_deg, moon_radius_deg, separation_deg)
def normalize_delta_ra_hours(delta_ra_hours: float) -> float:
while delta_ra_hours > 12.0:
delta_ra_hours -= 24.0
while delta_ra_hours < -12.0:
delta_ra_hours += 24.0
return delta_ra_hours
def star_ecliptic_latitude_deg(ra_hours: float, dec_deg: float, reference_time: astronomy.Time) -> float:
sphere = astronomy.Spherical(dec_deg, ra_hours * 15.0, 1.0)
vector = astronomy.VectorFromSphere(sphere, reference_time)
ecliptic = astronomy.Ecliptic(vector)
return float(ecliptic.elat)
def build_moon_track_samples(
observer: astronomy.Observer,
utc_start: datetime,
utc_end: datetime,
step_minutes: int = 30,
) -> list[dict]:
samples: list[dict] = []
current = utc_start
step = timedelta(minutes=step_minutes)
while current <= utc_end:
time_value = dt_to_time(current)
moon_eq = astronomy.Equator(astronomy.Body.Moon, time_value, observer, True, True)
samples.append(
{
"dt_utc": current,
"ra": float(moon_eq.ra),
"dec": float(moon_eq.dec),
"radius_deg": moon_angular_radius_deg(float(moon_eq.dist)),
}
)
current += step
if not samples or samples[-1]["dt_utc"] < utc_end:
time_value = dt_to_time(utc_end)
moon_eq = astronomy.Equator(astronomy.Body.Moon, time_value, observer, True, True)
samples.append(
{
"dt_utc": utc_end,
"ra": float(moon_eq.ra),
"dec": float(moon_eq.dec),
"radius_deg": moon_angular_radius_deg(float(moon_eq.dist)),
}
)
return samples
def interpolate_track_value(left: float, right: float, fraction: float, *, wrap_hours: bool = False) -> float:
if wrap_hours:
delta = normalize_delta_ra_hours(right - left)
value = left + delta * fraction
while value < 0.0:
value += 24.0
while value >= 24.0:
value -= 24.0
return value
return left + (right - left) * fraction
def build_moon_track_segments(samples: list[dict]) -> list[dict]:
segments: list[dict] = []
for index in range(len(samples) - 1):
left = samples[index]
right = samples[index + 1]
duration_seconds = (right["dt_utc"] - left["dt_utc"]).total_seconds()
if duration_seconds <= 0:
continue
segments.append(
{
"start": left["dt_utc"],
"end": right["dt_utc"],
"duration_seconds": duration_seconds,
"start_ra": left["ra"],
"end_ra": right["ra"],
"start_dec": left["dec"],
"end_dec": right["dec"],
"start_radius_deg": left["radius_deg"],
"end_radius_deg": right["radius_deg"],
}
)
return segments
def minimum_interpolated_moon_distance_deg(
star_ra_hours: float,
star_dec_deg: float,
track_segments: list[dict],
candidate_padding_deg: float,
) -> float:
minimum_distance = float("inf")
for segment in track_segments:
for sample_index in range(7):
fraction = sample_index / 6.0
moon_ra = interpolate_track_value(segment["start_ra"], segment["end_ra"], fraction, wrap_hours=True)
moon_dec = interpolate_track_value(segment["start_dec"], segment["end_dec"], fraction)
moon_radius = interpolate_track_value(segment["start_radius_deg"], segment["end_radius_deg"], fraction)
if abs(star_dec_deg - moon_dec) > (candidate_padding_deg + moon_radius):
continue
distance_deg = spherical_separation_deg(star_ra_hours, star_dec_deg, moon_ra, moon_dec)
margin_deg = distance_deg - moon_radius
if margin_deg < minimum_distance:
minimum_distance = margin_deg
return minimum_distance
def evaluate_occultation_geometry(body: astronomy.Body, observer: astronomy.Observer, dt_utc: datetime) -> dict:
time_value = dt_to_time(dt_utc)
moon_eq = astronomy.Equator(astronomy.Body.Moon, time_value, observer, True, True)
star_eq = astronomy.Equator(body, time_value, observer, True, True)
sun_eq = astronomy.Equator(astronomy.Body.Sun, time_value, observer, True, True)
moon_hor = astronomy.Horizon(time_value, observer, moon_eq.ra, moon_eq.dec, astronomy.Refraction.Normal)
star_hor = astronomy.Horizon(time_value, observer, star_eq.ra, star_eq.dec, astronomy.Refraction.Normal)
sun_hor = astronomy.Horizon(time_value, observer, sun_eq.ra, sun_eq.dec, astronomy.Refraction.Normal)
separation_deg = spherical_separation_deg(moon_eq.ra, moon_eq.dec, star_eq.ra, star_eq.dec)
radius_deg = moon_angular_radius_deg(moon_eq.dist)
margin_deg = separation_deg - radius_deg
return {
"time": time_value,
"moon_alt_deg": float(moon_hor.altitude),
"star_alt_deg": float(star_hor.altitude),
"sun_alt_deg": float(sun_hor.altitude),
"margin_deg": float(margin_deg),
}
def find_root_time(body: astronomy.Body, observer: astronomy.Observer, start_utc: datetime, end_utc: datetime) -> datetime:
left = start_utc
right = end_utc
for _ in range(40):
mid = left + (right - left) / 2
geom = evaluate_occultation_geometry(body, observer, mid)
if geom["margin_deg"] <= 0:
right = mid
else:
left = mid
return left + (right - left) / 2
def find_minimum_time(body: astronomy.Body, observer: astronomy.Observer, start_utc: datetime, end_utc: datetime) -> datetime:
left = start_utc
right = end_utc
for _ in range(40):
span = (right - left) / 3
m1 = left + span
m2 = right - span
f1 = evaluate_occultation_geometry(body, observer, m1)["margin_deg"]
f2 = evaluate_occultation_geometry(body, observer, m2)["margin_deg"]
if f1 <= f2:
right = m2
else:
left = m1
return left + (right - left) / 2
def serialize_occultation_time(dt_utc: datetime | None, tz: ZoneInfo) -> dict | None:
if dt_utc is None:
return None
local_dt = dt_utc.astimezone(tz)
return {
"utc_iso": dt_utc.isoformat().replace("+00:00", "Z"),
"local_iso": local_dt.isoformat(),
"local_time": local_dt.strftime("%H:%M"),
"local_date": local_dt.strftime("%Y-%m-%d"),
}
def load_star_file(star_file_path: str) -> list[dict]:
try:
with open(star_file_path, "r", encoding="utf-8-sig") as handle:
payload = json.load(handle)
except OSError as exc:
fail("Sterndatei konnte nicht gelesen werden.", extra={"details": str(exc), "path": star_file_path})
except json.JSONDecodeError as exc:
fail("Sterndatei enthaelt kein gueltiges JSON.", extra={"details": str(exc), "path": star_file_path})
if not isinstance(payload, list):
fail("Sterndatei muss eine JSON-Liste sein.", extra={"path": star_file_path})
return payload
def normalize_degrees(value: float) -> float:
return ((value % 360.0) + 360.0) % 360.0
def normalize_signed_degrees(value: float) -> float:
return normalize_degrees(value + 180.0) - 180.0
def get_phase_label(age_days: float) -> str:
segment = SYNODIC_MONTH / 8.0
index = int(math.floor((age_days + segment / 2.0) / segment)) % 8
return PHASE_LABELS[index]
def vector_from_astronomy(vector: astronomy.Vector) -> tuple[float, float, float]:
return float(vector.x), float(vector.y), float(vector.z)
def vector_subtract(a: tuple[float, float, float], b: tuple[float, float, float]) -> tuple[float, float, float]:
return a[0] - b[0], a[1] - b[1], a[2] - b[2]
def vector_dot(a: tuple[float, float, float], b: tuple[float, float, float]) -> float:
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
def vector_cross(a: tuple[float, float, float], b: tuple[float, float, float]) -> tuple[float, float, float]:
return (
a[1] * b[2] - a[2] * b[1],
a[2] * b[0] - a[0] * b[2],
a[0] * b[1] - a[1] * b[0],
)
def vector_normalize(v: tuple[float, float, float]) -> tuple[float, float, float]:
length = math.sqrt(vector_dot(v, v))
if length <= 0:
return 0.0, 0.0, 0.0
return v[0] / length, v[1] / length, v[2] / length
def rotate_vector_around_axis(
v: tuple[float, float, float], axis: tuple[float, float, float], angle_rad: float
) -> tuple[float, float, float]:
"""Rodrigues-Rotationsformel: dreht v um die (Einheits-)Achse axis."""
cos_a = math.cos(angle_rad)
sin_a = math.sin(angle_rad)
axis_cross_v = vector_cross(axis, v)
axis_dot_v = vector_dot(axis, v)
return (
v[0] * cos_a + axis_cross_v[0] * sin_a + axis[0] * axis_dot_v * (1.0 - cos_a),
v[1] * cos_a + axis_cross_v[1] * sin_a + axis[1] * axis_dot_v * (1.0 - cos_a),
v[2] * cos_a + axis_cross_v[2] * sin_a + axis[2] * axis_dot_v * (1.0 - cos_a),
)
def calculate_moon_position_angle(time_value: astronomy.Time) -> float:
moon_vector = astronomy.GeoVector(astronomy.Body.Moon, time_value, True)
moon_equator = astronomy.EquatorFromVector(moon_vector)
axis = astronomy.RotationAxis(astronomy.Body.Moon, time_value)
pole_equator = astronomy.EquatorFromVector(axis.north)
delta_ra = math.radians((float(pole_equator.ra) - float(moon_equator.ra)) * 15.0)
moon_dec = math.radians(float(moon_equator.dec))
pole_dec = math.radians(float(pole_equator.dec))
return math.degrees(
math.atan2(
math.cos(pole_dec) * math.sin(delta_ra),
math.sin(pole_dec) * math.cos(moon_dec)
- math.cos(pole_dec) * math.sin(moon_dec) * math.cos(delta_ra),
)
)
def moon_body_frame(time_value: astronomy.Time) -> tuple[
tuple[float, float, float], tuple[float, float, float], tuple[float, float, float]
]:
"""Baut eine orthonormale Basis (body_x = Nullmeridian-Richtung, body_y,
north = Rotationsachse) des mondfesten Koordinatensystems in EQJ, nach dem
offiziellen IAU-Rotationsachsen-Standard (RotationAxis: Pol-RA/Dec und
Rotationswinkel W, siehe IAU WGCCRE 2015)."""
axis = astronomy.RotationAxis(astronomy.Body.Moon, time_value)
north = vector_normalize(vector_from_astronomy(axis.north))
ra_rad = math.radians(axis.ra * 15.0)
# Aufsteigender Knoten der Mondaequatorebene auf der EQJ-Ebene (RA+90 Grad, Dec=0).
ascending_node = (-math.sin(ra_rad), math.cos(ra_rad), 0.0)
body_x = vector_normalize(rotate_vector_around_axis(ascending_node, north, math.radians(axis.spin)))
body_y = vector_normalize(vector_cross(north, body_x))
return body_x, body_y, north
def selenographic_lonlat(
vector: tuple[float, float, float],
body_x: tuple[float, float, float],
body_y: tuple[float, float, float],
north: tuple[float, float, float],
) -> tuple[float, float]:
"""Selenographische Laenge/Breite eines geozentrischen Richtungsvektors,
Ost-positiv (kalibriert gegen die IAU-Kraterdatenbank, z. B. Mare Crisium
bei +59,1 Grad Ost) und gegen astronomy.Libration() validiert.
HINWEIS: Vorzeichen wird gerade per Browsertest verifiziert (Ost/West-Problem)."""
v = vector_normalize(vector)
longitude = math.degrees(math.atan2(vector_dot(v, body_y), vector_dot(v, body_x)))
latitude = math.degrees(math.asin(max(-1.0, min(1.0, vector_dot(v, north)))))
return longitude, latitude
def calculate_moon_orientation(time_value: astronomy.Time) -> dict:
"""Sub-Erd- und Sub-Sonnen-Punkt (selenographische Laenge/Breite) aus der
offiziellen IAU-Rotationsachse des Mondes - eine einzige, in sich
konsistente Grundlage fuer Mondausrichtung (Libration) UND Sonnenrichtung,
anstelle der frueheren, nur die Breite abdeckenden Berechnung."""
body_x, body_y, north = moon_body_frame(time_value)
moon_vector = astronomy.GeoVector(astronomy.Body.Moon, time_value, True)
sun_vector = astronomy.GeoVector(astronomy.Body.Sun, time_value, True)
moon_xyz = vector_from_astronomy(moon_vector)
sun_xyz = vector_from_astronomy(sun_vector)
earth_from_moon = (-moon_xyz[0], -moon_xyz[1], -moon_xyz[2])
sun_from_moon = vector_subtract(sun_xyz, moon_xyz)
subearth_longitude, subearth_latitude = selenographic_lonlat(earth_from_moon, body_x, body_y, north)
subsolar_longitude, subsolar_latitude = selenographic_lonlat(sun_from_moon, body_x, body_y, north)
return {
"subearth_longitude": subearth_longitude,
"subearth_latitude": subearth_latitude,
"subsolar_longitude": subsolar_longitude,
"subsolar_latitude": subsolar_latitude,
}
def action_moon_phase_details(args: list[str]) -> dict:
if len(args) != 2:
fail(
"Aktion moon_phase_details erwartet 2 Argumente: utc_iso timezone",
extra={"argv": args},
)
utc_iso = args[0]
timezone_name = args[1]
try:
tz = ZoneInfo(timezone_name)
except Exception as exc: # pragma: no cover
fail("Zeitzone ist ungueltig oder auf dem Server nicht verfuegbar.", extra={"details": str(exc)})
try:
dt_utc = datetime.fromisoformat(utc_iso.replace("Z", "+00:00")).astimezone(timezone.utc)
except ValueError as exc:
fail("utc_iso ist ungueltig.", extra={"details": str(exc), "utc_iso": utc_iso})
time_value = dt_to_time(dt_utc)
illumination = astronomy.Illumination(astronomy.Body.Moon, time_value)
elongation = normalize_degrees(astronomy.MoonPhase(time_value))
phase = elongation / 360.0
previous_new_moon = astronomy.SearchMoonPhase(0.0, time_value, -35.0)
age_days = (
(dt_utc - time_to_datetime(previous_new_moon)).total_seconds() / 86400.0
if previous_new_moon is not None
else phase * SYNODIC_MONTH
)
# Mondausrichtung (Libration) und Sonnenrichtung aus einer einzigen,
# konsistenten Grundlage: der offiziellen IAU-Rotationsachse des Mondes.
# distKm bleibt bewusst von astronomy.Libration() (bereits gegen PyEphem
# validiert), da hierfuer kein Wechsel noetig ist.
libration = astronomy.Libration(time_value)
orientation = calculate_moon_orientation(time_value)
position_angle = calculate_moon_position_angle(time_value)
local_dt = dt_utc.astimezone(tz)
return {
"ok": True,
"action": "moon_phase_details",
"selected": {
"utc_iso": dt_utc.isoformat().replace("+00:00", "Z"),
"local_iso": local_dt.isoformat(),
"timezone": timezone_name,
},
"phase": {
"age": age_days,
"phase": phase,
"illumination": float(illumination.phase_fraction),
"waxing": elongation <= 180.0,
"label": get_phase_label(age_days),
},
"libration": {
"longitude": float(orientation["subearth_longitude"]),
"latitude": float(orientation["subearth_latitude"]),
"subsolarLongitude": float(orientation["subsolar_longitude"]),
"subsolarLatitude": float(orientation["subsolar_latitude"]),
"subearthLatitude": float(orientation["subearth_latitude"]),
"positionAngle": float(position_angle),
"distKm": float(libration.dist_km),
},
}
def compute_star_occultation(
star_rows: list[dict],
observer: astronomy.Observer,
local_start: datetime,
local_end: datetime,
tz: ZoneInfo,
) -> tuple[list[dict], dict]:
utc_start = local_start.astimezone(timezone.utc)
utc_end = local_end.astimezone(timezone.utc)
search_margin = timedelta(hours=8)
search_start = utc_start - search_margin
search_end = utc_end + search_margin
results: list[dict] = []
coarse_step = timedelta(minutes=2)
candidate_margin_deg = 0.32
ecliptic_band_deg = 8.0
reference_time = dt_to_time(utc_start)
moon_track_samples = build_moon_track_samples(observer, search_start, search_end, step_minutes=30)
moon_track_segments = build_moon_track_segments(moon_track_samples)
geometry_candidates: list[dict] = []
for star in star_rows:
star_ra = float(star["ra"])
star_dec = float(star["dec"])
ecliptic_lat_deg = abs(star_ecliptic_latitude_deg(star_ra, star_dec, reference_time))
if ecliptic_lat_deg > ecliptic_band_deg:
continue
estimated_min_margin_deg = minimum_interpolated_moon_distance_deg(
star_ra,
star_dec,
moon_track_segments,
candidate_margin_deg,
)
if estimated_min_margin_deg > candidate_margin_deg:
continue
geometry_candidates.append(star)
if not geometry_candidates:
debug = {
"input_star_count": len(star_rows),
"after_ecliptic_filter": 0,
"after_track_filter": 0,
"ecliptic_band_deg": ecliptic_band_deg,
"candidate_margin_deg": candidate_margin_deg,
}
return results, debug
ecliptic_count = 0
for star in star_rows:
star_ra = float(star["ra"])
star_dec = float(star["dec"])
if abs(star_ecliptic_latitude_deg(star_ra, star_dec, reference_time)) <= ecliptic_band_deg:
ecliptic_count += 1
for chunk_start in range(0, len(geometry_candidates), len(STAR_BODIES)):
chunk = geometry_candidates[chunk_start:chunk_start + len(STAR_BODIES)]
for body, star in zip(STAR_BODIES, chunk):
astronomy.DefineStar(
body,
float(star["ra"]),
float(star["dec"]),
max(1.0, float(star.get("distLy", 1000.0))),
)
for index, star in enumerate(chunk):
body = STAR_BODIES[index]
previous_margin = None
previous_time = None
ingress_bracket = None
egress_bracket = None
minimum_margin = float("inf")
minimum_time = search_start
start_margin = None
end_margin = None
ever_negative = False
near_hit = False
current = search_start
while current <= search_end:
geometry = evaluate_occultation_geometry(body, observer, current)
margin = geometry["margin_deg"]
if margin < minimum_margin:
minimum_margin = margin
minimum_time = current
if margin <= 0:
ever_negative = True
if margin <= candidate_margin_deg:
near_hit = True
if start_margin is None:
start_margin = margin
end_margin = margin
if previous_margin is not None and previous_time is not None:
if (previous_margin > 0 >= margin) and ingress_bracket is None:
ingress_bracket = (previous_time, current)
near_hit = True
if (previous_margin <= 0 < margin) and egress_bracket is None:
egress_bracket = (previous_time, current)
near_hit = True
previous_margin = margin
previous_time = current
current += coarse_step
if (not ever_negative) and (not near_hit):
continue
if (not ever_negative) or minimum_margin > 0:
continue
ingress_time = find_root_time(body, observer, ingress_bracket[0], ingress_bracket[1]) if ingress_bracket else None
egress_time = find_root_time(body, observer, egress_bracket[0], egress_bracket[1]) if egress_bracket else None
minimum_search_start = max(search_start, minimum_time - coarse_step)
minimum_search_end = min(search_end, minimum_time + coarse_step)
if ingress_time is not None and egress_time is not None:
minimum_search_start = ingress_time
minimum_search_end = egress_time
max_time = find_minimum_time(body, observer, minimum_search_start, minimum_search_end)
max_geometry = evaluate_occultation_geometry(body, observer, max_time)
if max_geometry["margin_deg"] > 0:
continue
if max_geometry["moon_alt_deg"] <= 0 or max_geometry["star_alt_deg"] <= 0:
continue
event_start = ingress_time or search_start
event_end = egress_time or search_end
if event_end < utc_start or event_start >= utc_end:
continue
duration_seconds = None
if ingress_time is not None and egress_time is not None:
duration_seconds = int(round((egress_time - ingress_time).total_seconds()))
results.append({
"star": {
"hip": int(star.get("hip", 0)),
"label": str(star.get("label", "Unbenannter Stern")),
"constellation": str(star.get("constellation", "")),
"mag": float(star.get("mag", 99.0)),
"ra": float(star.get("ra", 0.0)),
"dec": float(star.get("dec", 0.0)),
},
"ingress": serialize_occultation_time(ingress_time, tz),
"maximum": serialize_occultation_time(max_time, tz),
"egress": serialize_occultation_time(egress_time, tz),
"partial_start": ingress_time is None and start_margin is not None and start_margin <= 0,
"partial_end": egress_time is None and end_margin is not None and end_margin <= 0,
"duration_seconds": duration_seconds,
"limb_distance_arcmin": abs(max_geometry["margin_deg"]) * 60.0,
"moon_alt_deg": max_geometry["moon_alt_deg"],
"star_alt_deg": max_geometry["star_alt_deg"],
"sun_alt_deg": max_geometry["sun_alt_deg"],
"visibility": classify_visibility(max_geometry["sun_alt_deg"]),
})
results.sort(
key=lambda item: item["maximum"]["utc_iso"] if item["maximum"] else "9999-99-99T99:99:99Z"
)
debug = {
"input_star_count": len(star_rows),
"after_ecliptic_filter": ecliptic_count,
"after_track_filter": len(geometry_candidates),
"ecliptic_band_deg": ecliptic_band_deg,
"candidate_margin_deg": candidate_margin_deg,
"search_margin_hours": search_margin.total_seconds() / 3600.0,
}
return results, debug
def action_moon_star_occultations(args: list[str]) -> dict:
if len(args) != 6:
fail(
"Aktion moon_star_occultations erwartet 6 Argumente: latitude longitude elevation date timezone star_file",
extra={"argv": args},
)
latitude = parse_float(args[0], "Latitude")
longitude = parse_float(args[1], "Longitude")
elevation = parse_float(args[2], "Elevation")
date_text = args[3]
timezone_name = args[4]
star_file_path = args[5]
try:
datetime.strptime(date_text, "%Y-%m-%d")
except ValueError:
fail("Datum muss im Format YYYY-MM-DD uebergeben werden.", extra={"date": date_text})
try:
tz = ZoneInfo(timezone_name)
except Exception as exc: # pragma: no cover
fail("Zeitzone ist ungueltig oder auf dem Server nicht verfuegbar.", extra={"details": str(exc)})
star_rows = load_star_file(star_file_path)
observer = astronomy.Observer(latitude, longitude, elevation)
local_start = datetime.strptime(date_text, "%Y-%m-%d").replace(tzinfo=tz)
local_end = local_start + timedelta(days=1)
results, debug_profile = compute_star_occultation(star_rows, observer, local_start, local_end, tz)
return {
"ok": True,
"action": "moon_star_occultations",
"date": date_text,
"timezone": timezone_name,
"observer": {
"latitude": latitude,
"longitude": longitude,
"elevation": elevation,
},
"star_count": len(star_rows),
"window": {
"local_start": local_start.isoformat(),
"local_end": local_end.isoformat(),
"utc_start": local_start.astimezone(timezone.utc).isoformat().replace("+00:00", "Z"),
"utc_end": local_end.astimezone(timezone.utc).isoformat().replace("+00:00", "Z"),
},
"debug_profile": debug_profile,
"results": results,
}
PLANET_OCCULTATION_DEFS = [
("Merkur", astronomy.Body.Mercury, "mercury"),
("Venus", astronomy.Body.Venus, "venus"),
("Mars", astronomy.Body.Mars, "mars"),
("Jupiter", astronomy.Body.Jupiter, "jupiter"),
("Saturn", astronomy.Body.Saturn, "saturn"),
("Uranus", astronomy.Body.Uranus, "uranus"),
("Neptun", astronomy.Body.Neptune, "neptune"),
]
def compute_moon_planet_occultations_for_range(
observer: astronomy.Observer,
utc_start: datetime,
utc_end: datetime,
tz: ZoneInfo,
) -> list[dict]:
scan_step = timedelta(hours=1)
coarse_threshold_deg = 3.0
results: list[dict] = []
for label, body, key in PLANET_OCCULTATION_DEFS:
samples: list[tuple[datetime, float]] = []
current = utc_start
while current <= utc_end:
samples.append((current, moon_planet_separation_deg(body, observer, current)))
current += scan_step
if samples[-1][0] < utc_end:
samples.append((utc_end, moon_planet_separation_deg(body, observer, utc_end)))
seen_ranges: list[tuple[datetime, datetime]] = []
for index in range(1, len(samples) - 1):
prev_t, prev_sep = samples[index - 1]
curr_t, curr_sep = samples[index]
next_t, next_sep = samples[index + 1]
if curr_sep > coarse_threshold_deg:
continue
if curr_sep > prev_sep or curr_sep > next_sep:
continue
left = max(utc_start, curr_t - scan_step)
right = min(utc_end, curr_t + scan_step)
if any(not (right <= seen_left or left >= seen_right) for seen_left, seen_right in seen_ranges):
continue
min_time_utc, min_sep = refine_minimum_separation(body, observer, left, right)
seen_ranges.append((left, right))
overlap_fraction = moon_planet_overlap_fraction(body, observer, min_time_utc)
if overlap_fraction <= 0.0:
continue
time_value = dt_to_time(min_time_utc)
moon_eq = astronomy.Equator(astronomy.Body.Moon, time_value, observer, True, True)
planet_eq = astronomy.Equator(body, time_value, observer, True, True)
sun_eq = astronomy.Equator(astronomy.Body.Sun, time_value, observer, True, True)
moon_hor = astronomy.Horizon(time_value, observer, moon_eq.ra, moon_eq.dec, astronomy.Refraction.Normal)
planet_hor = astronomy.Horizon(time_value, observer, planet_eq.ra, planet_eq.dec, astronomy.Refraction.Normal)
sun_hor = astronomy.Horizon(time_value, observer, sun_eq.ra, sun_eq.dec, astronomy.Refraction.Normal)
below_horizon = float(moon_hor.altitude) <= 0.0 or float(planet_hor.altitude) <= 0.0
ingress_time = refine_occultation_contact(body, observer, min_time_utc, -1)
egress_time = refine_occultation_contact(body, observer, min_time_utc, 1)
duration_seconds = None
if ingress_time is not None and egress_time is not None:
duration_seconds = int(round((egress_time - ingress_time).total_seconds()))
moon_radius_deg = moon_angular_radius_deg(float(moon_eq.dist))
planet_radius_deg = planet_angular_radius_deg(body, float(planet_eq.dist))
results.append({
"planet_key": key,
"planet_label": label,
"ingress": serialize_occultation_time(ingress_time, tz),
"maximum": serialize_occultation_time(min_time_utc, tz),
"egress": serialize_occultation_time(egress_time, tz),
"partial_start": ingress_time is None,
"partial_end": egress_time is None,
"duration_seconds": duration_seconds,
"separation_arcmin": float(min_sep) * 60.0,
"moon_radius_arcmin": moon_radius_deg * 60.0,
"planet_radius_arcsec": planet_radius_deg * 3600.0,
"overlap_fraction": float(overlap_fraction),
"is_total": overlap_fraction >= 0.999,
"moon_alt_deg": float(moon_hor.altitude),
"planet_alt_deg": float(planet_hor.altitude),
"sun_alt_deg": float(sun_hor.altitude),
"below_horizon": below_horizon,
"visibility": {"key": "below_horizon", "label": "Unter Horizont"} if below_horizon
else classify_visibility(float(sun_hor.altitude)),
})
results.sort(
key=lambda item: item["maximum"]["utc_iso"] if item["maximum"] else "9999-99-99T99:99:99Z"
)
return results
def action_moon_planet_occultations_for_year(args: list[str]) -> dict:
if len(args) != 5:
fail(
"Aktion moon_planet_occultations_for_year erwartet 5 Argumente: latitude longitude elevation year timezone",
extra={"argv": args},
)
latitude = parse_float(args[0], "Latitude")
longitude = parse_float(args[1], "Longitude")
elevation = parse_float(args[2], "Elevation")
try:
year = int(args[3])
except ValueError as exc:
fail("Jahr ist ungueltig.", extra={"details": str(exc), "argv": args})
timezone_name = args[4]
try:
tz = ZoneInfo(timezone_name)
except Exception as exc:
fail("Zeitzone ist ungueltig oder auf dem Server nicht verfuegbar.", extra={"details": str(exc)})
observer = astronomy.Observer(latitude, longitude, elevation)
local_start = datetime(year, 1, 1, 0, 0, 0, tzinfo=tz)
local_end = datetime(year + 1, 1, 1, 0, 0, 0, tzinfo=tz)
utc_start = local_start.astimezone(timezone.utc)
utc_end = local_end.astimezone(timezone.utc)
results = compute_moon_planet_occultations_for_range(observer, utc_start, utc_end, tz)
return {
"ok": True,
"action": "moon_planet_occultations_for_year",
"selected": {
"year": year,
"timezone": timezone_name,
},
"observer": {
"latitude": latitude,
"longitude": longitude,
"elevation": elevation,
},
"results": results,
}
# ── satellite_passes ──────────────────────────────────────────────────────────
import math as _math
_WGS84_A = 6378.137 # km
_WGS84_E2 = 0.00669437999014
_R_EARTH = 6371.0 # km (shadow check)
_KM_PER_AU_SAT = 149597870.7
_K_EXT = 0.18
_PHI90 = 1.0 / _math.pi
def _jday_from_dt(dt_utc: datetime) -> tuple[float, float]:
"""Return (jd_int, jd_fraction) from a UTC datetime."""
from sgp4.functions import jday as _jday_fn
return _jday_fn(dt_utc.year, dt_utc.month, dt_utc.day,
dt_utc.hour, dt_utc.minute,
dt_utc.second + dt_utc.microsecond / 1e6)
def _gmst_rad(jd_full: float) -> float:
T = (jd_full - 2451545.0) / 36525.0
gmst_deg = (280.46061837
+ 360.98564736629 * (jd_full - 2451545.0)
+ 0.000387933 * T * T
- T * T * T / 38710000.0)
return _math.radians(gmst_deg % 360.0)
def _eci_to_ecef(r_eci: tuple, gmst: float) -> tuple:
x, y, z = r_eci
c, s = _math.cos(gmst), _math.sin(gmst)
return (x * c + y * s, -x * s + y * c, z)
def _observer_ecef(lat_rad: float, lon_rad: float, elev_km: float) -> tuple:
N = _WGS84_A / _math.sqrt(1.0 - _WGS84_E2 * _math.sin(lat_rad) ** 2)
x = (N + elev_km) * _math.cos(lat_rad) * _math.cos(lon_rad)
y = (N + elev_km) * _math.cos(lat_rad) * _math.sin(lon_rad)
z = (N * (1.0 - _WGS84_E2) + elev_km) * _math.sin(lat_rad)
return (x, y, z)
def _ecef_to_altaz(r_ecef: tuple, obs_ecef: tuple, lat_rad: float, lon_rad: float) -> tuple[float, float, float]:
dx = r_ecef[0] - obs_ecef[0]
dy = r_ecef[1] - obs_ecef[1]
dz = r_ecef[2] - obs_ecef[2]
rng = _math.sqrt(dx*dx + dy*dy + dz*dz)
if rng < 1e-9:
return 0.0, 0.0, 0.0
# Topocentric South-East-Z (SEZ)
slat, clat = _math.sin(lat_rad), _math.cos(lat_rad)
slon, clon = _math.sin(lon_rad), _math.cos(lon_rad)
s = slat * clon * dx + slat * slon * dy - clat * dz
e = -slon * dx + clon * dy
z_top = clat * clon * dx + clat * slon * dy + slat * dz
el_rad = _math.asin(max(-1.0, min(1.0, z_top / rng)))
az_rad = _math.atan2(e, -s)
az_deg = (_math.degrees(az_rad) + 360.0) % 360.0
el_deg = _math.degrees(el_rad)
return el_deg, az_deg, rng
def _sun_eci_km(dt_utc: datetime) -> tuple:
t = dt_to_time(dt_utc)
vec = astronomy.GeoVector(astronomy.Body.Sun, t, True)
return (float(vec.x) * _KM_PER_AU_SAT,
float(vec.y) * _KM_PER_AU_SAT,
float(vec.z) * _KM_PER_AU_SAT)
def _in_shadow(r_eci: tuple, sun_eci_km: tuple) -> bool:
# Sun vector from Earth
sx, sy, sz = sun_eci_km
sun_len = _math.sqrt(sx*sx + sy*sy + sz*sz)
if sun_len < 1:
return False
# Satellite-to-sun vector direction
rx, ry, rz = r_eci
# Project satellite onto anti-sun direction
dot = -(rx*sx + ry*sy + rz*sz) / sun_len
if dot < 0:
return False # satellite on sun-side
perp2 = (rx*rx + ry*ry + rz*rz) - dot*dot
return perp2 < _R_EARTH * _R_EARTH
def _phi_lambert(alpha_rad: float) -> float:
if alpha_rad >= _math.pi:
return 1e-10
v = (_math.sin(alpha_rad) + (_math.pi - alpha_rad) * _math.cos(alpha_rad)) / _math.pi
return max(1e-10, v)
def _airmass(h_deg: float) -> float:
if h_deg <= 0:
return 40.0
sin_h = _math.sin(_math.radians(h_deg))
return 1.0 / (sin_h + 0.50572 * (h_deg + 6.07995) ** -1.6364)
def _apparent_magnitude(std_mag, rcs, rcs_size, r_eci, sun_eci_km, obs_ecef_km, lat_rad, lon_rad, gmst):
if std_mag is not None:
m1000 = float(std_mag)
estimated = False
elif rcs is not None and rcs > 0:
m1000 = 7.5 - 2.5 * _math.log10(rcs)
estimated = True
else:
size_map = {"LARGE": 3.5, "MEDIUM": 6.0, "SMALL": 8.5}
key = (rcs_size or "").upper()
if key not in size_map:
return None
m1000 = size_map[key]
estimated = True
rx, ry, rz = r_eci
r_ecef = _eci_to_ecef(r_eci, gmst)
dx = r_ecef[0] - obs_ecef_km[0]
dy = r_ecef[1] - obs_ecef_km[1]
dz = r_ecef[2] - obs_ecef_km[2]
range_km = _math.sqrt(dx*dx + dy*dy + dz*dz)
if range_km < 1:
return None
# Phase angle (sun → sat → observer)
sx, sy, sz = sun_eci_km
ox = -rx + obs_ecef_km[0] # obs ECI ≈ ECEF for this approximation
oy = -ry + obs_ecef_km[1]
oz = -rz + obs_ecef_km[2]
# to-sun from sat
ts_x, ts_y, ts_z = sx - rx, sy - ry, sz - rz
to_x, to_y, to_z = -rx, -ry, -rz # observer ≈ Earth center approx
ts_len = _math.sqrt(ts_x*ts_x + ts_y*ts_y + ts_z*ts_z)
to_len = _math.sqrt(to_x*to_x + to_y*to_y + to_z*to_z)
if ts_len < 1 or to_len < 1:
return None
cos_a = (ts_x*to_x + ts_y*to_y + ts_z*to_z) / (ts_len * to_len)
alpha_rad = _math.acos(max(-1.0, min(1.0, cos_a)))
el_deg, _, _ = _ecef_to_altaz(r_ecef, obs_ecef_km, lat_rad, lon_rad)
dist_corr = 5.0 * _math.log10(range_km / 1000.0)
phase_corr = -2.5 * _math.log10(_phi_lambert(alpha_rad) / _PHI90)
extinction = _K_EXT * _airmass(max(1.0, el_deg))
mag = m1000 + dist_corr + phase_corr + extinction
if not _math.isfinite(mag):
return None
return {"mag": round(mag, 2), "estimated": estimated}
def _bisect_crossing(satrec, obs_ecef, lat_rad, lon_rad, t1: datetime, t2: datetime, rising: bool) -> datetime:
for _ in range(30):
mid = t1 + (t2 - t1) / 2
jd, fr = _jday_from_dt(mid)
e, r, _ = satrec.sgp4(jd, fr)
if e != 0:
break
gmst = _gmst_rad(jd + fr)
r_ecef = _eci_to_ecef(r, gmst)
el, _, _ = _ecef_to_altaz(r_ecef, obs_ecef, lat_rad, lon_rad)
above = el >= 0
if rising:
if above:
t2 = mid
else:
t1 = mid
else:
if above:
t1 = mid
else:
t2 = mid
if (t2 - t1).total_seconds() < 0.5:
break
return t1 + (t2 - t1) / 2
def action_satellite_passes(args: list[str]) -> dict:
if len(args) != 7:
fail(
"Aktion satellite_passes erwartet 7 Argumente: latitude longitude elevation timezone window_start_iso window_end_iso satellites_payload",
extra={"argv": args},
)
latitude = parse_float(args[0], "Latitude")
longitude = parse_float(args[1], "Longitude")
elevation_m = parse_float(args[2], "Elevation")
timezone_name = args[3]
window_start_iso = args[4]
window_end_iso = args[5]
satellites_payload = args[6]
try:
tz = ZoneInfo(timezone_name)
except Exception as exc:
fail("Zeitzone ungueltig.", extra={"details": str(exc)})
try:
window_start = datetime.fromisoformat(window_start_iso.replace("Z", "+00:00")).astimezone(timezone.utc)
window_end = datetime.fromisoformat(window_end_iso.replace("Z", "+00:00")).astimezone(timezone.utc)
except ValueError as exc:
fail("Zeitfenster-ISO ungueltig.", extra={"details": str(exc)})
try:
satellites_json = base64.b64decode(satellites_payload.encode("ascii")).decode("utf-8")
satellites = json.loads(satellites_json)
except (ValueError, UnicodeDecodeError, json.JSONDecodeError) as exc:
fail("satellites_payload ist ungueltig.", extra={"details": str(exc)})
try:
import os as _os
_sgp4_dir = _os.path.join(SCRIPT_DIR, "sgp4")
if _sgp4_dir not in sys.path:
sys.path.insert(0, _sgp4_dir)
from sgp4.api import Satrec
except ImportError as exc:
fail("sgp4-Bibliothek konnte nicht importiert werden.", extra={"details": str(exc)})
lat_rad = _math.radians(latitude)
lon_rad = _math.radians(longitude)
elev_km = elevation_m / 1000.0
obs_ecef = _observer_ecef(lat_rad, lon_rad, elev_km)
SCAN_STEP = timedelta(seconds=30)
TRAJ_STEP = timedelta(seconds=5)
COLORS = ['#f0d990', '#4fc3d8', '#ff9f68', '#9ad77d', '#c7b0ff',
'#ffd166', '#7bdff2', '#ff7b9c', '#b8f2e6', '#f7a072']
results = []
for idx, sat_item in enumerate(satellites):
tle1 = str(sat_item.get("tle_line1", ""))
tle2 = str(sat_item.get("tle_line2", ""))
if not tle1 or not tle2:
continue
try:
satrec = Satrec.twoline2rv(tle1, tle2)
except Exception:
continue
color = COLORS[idx % len(COLORS)]
passes = []
# ── Phase 1: coarse scan to find pass windows ──
current = window_start
prev_el = None
in_pass = False
aos_time = None
aos_az = None
max_el = -999.0
max_el_time = None
prev_time = None
while current <= window_end:
jd, fr = _jday_from_dt(current)
e, r, _ = satrec.sgp4(jd, fr)
if e != 0:
current += SCAN_STEP
prev_el = None
continue
gmst = _gmst_rad(jd + fr)
r_ecef = _eci_to_ecef(r, gmst)
el, az, _ = _ecef_to_altaz(r_ecef, obs_ecef, lat_rad, lon_rad)
if prev_el is not None:
was_above = prev_el >= 0
is_above = el >= 0
if not in_pass and not was_above and is_above:
aos_time = _bisect_crossing(satrec, obs_ecef, lat_rad, lon_rad, prev_time, current, True)
jd2, fr2 = _jday_from_dt(aos_time)
e2, r2, _ = satrec.sgp4(jd2, fr2)
if e2 == 0:
gmst2 = _gmst_rad(jd2 + fr2)
r_ecef2 = _eci_to_ecef(r2, gmst2)
_, aos_az_v, _ = _ecef_to_altaz(r_ecef2, obs_ecef, lat_rad, lon_rad)
aos_az = aos_az_v
in_pass = True
max_el = el
max_el_time = current
if in_pass:
if el > max_el:
max_el = el
max_el_time = current
if was_above and not is_above:
los_time = _bisect_crossing(satrec, obs_ecef, lat_rad, lon_rad, prev_time, current, False)
jd3, fr3 = _jday_from_dt(los_time)
e3, r3, _ = satrec.sgp4(jd3, fr3)
los_az = None
if e3 == 0:
gmst3 = _gmst_rad(jd3 + fr3)
r_ecef3 = _eci_to_ecef(r3, gmst3)
_, los_az_v, _ = _ecef_to_altaz(r_ecef3, obs_ecef, lat_rad, lon_rad)
los_az = los_az_v
# ── Phase 2: fine trajectory (5s steps) ──
path = []
shadow_entry = None
t = aos_time
while t <= los_time + timedelta(seconds=5):
jd4, fr4 = _jday_from_dt(t)
e4, r4, _ = satrec.sgp4(jd4, fr4)
if e4 == 0:
gmst4 = _gmst_rad(jd4 + fr4)
r_ecef4 = _eci_to_ecef(r4, gmst4)
el4, az4, _ = _ecef_to_altaz(r_ecef4, obs_ecef, lat_rad, lon_rad)
if el4 >= -1:
sun_km = _sun_eci_km(t)
shad = _in_shadow(r4, sun_km)
path.append({
"t": t.isoformat().replace("+00:00", "Z"),
"az": round(az4, 2),
"el": round(el4, 2),
"shadow": shad,
})
t += TRAJ_STEP
# Find shadow entry in path
for pi in range(1, len(path)):
if not path[pi-1]["shadow"] and path[pi]["shadow"] and path[pi]["el"] >= 0:
shadow_entry = {
"t": path[pi]["t"],
"az": path[pi]["az"],
"el": path[pi]["el"],
}
break
# Refine peak
peak_time = max_el_time
peak_el = max_el
peak_az = None
for pt in path:
if pt["el"] > peak_el:
peak_el = pt["el"]
peak_time = datetime.fromisoformat(pt["t"].replace("Z", "+00:00"))
peak_az = pt["az"]
if peak_az is None and path:
mid_pt = path[len(path)//2]
peak_az = mid_pt["az"]
# Magnitude at peak
peak_mag = None
peak_sun_altitude_deg = None
if peak_time:
jd5, fr5 = _jday_from_dt(peak_time)
e5, r5, _ = satrec.sgp4(jd5, fr5)
if e5 == 0:
sun5 = _sun_eci_km(peak_time)
peak_mag = _apparent_magnitude(
sat_item.get("std_mag"),
sat_item.get("rcs"),
sat_item.get("rcs_size"),
r5, sun5, obs_ecef, lat_rad, lon_rad,
_gmst_rad(jd5 + fr5)
)
peak_sun_altitude_deg = body_altitude_deg(
astronomy.Body.Sun,
astronomy.Observer(latitude, longitude, elevation_m),
peak_time,
)
duration_s = int(round((los_time - aos_time).total_seconds()))
local_tz = tz
def _iso(dt):
if dt is None:
return None
return dt.isoformat().replace("+00:00", "Z")
passes.append({
"aos_utc": _iso(aos_time),
"los_utc": _iso(los_time),
"peak_utc": _iso(peak_time),
"aos_az": round(aos_az, 2) if aos_az is not None else None,
"los_az": round(los_az, 2) if los_az is not None else None,
"peak_az": round(peak_az, 2) if peak_az is not None else None,
"peak_el": round(peak_el, 2),
"peak_sun_altitude_deg": round(float(peak_sun_altitude_deg), 2) if peak_sun_altitude_deg is not None else None,
"duration_s": duration_s,
"path": path,
"shadow_entry": shadow_entry,
"peak_mag": peak_mag,
})
in_pass = False
aos_time = None
max_el = -999.0
max_el_time = None
prev_el = el
prev_time = current
current += SCAN_STEP
results.append({
"id": sat_item.get("id"),
"norad_cat_id": sat_item.get("norad_cat_id"),
"name": sat_item.get("object_name", ""),
"is_favorite": bool(sat_item.get("is_favorite", False)),
"color": color,
"passes": passes,
})
return {
"ok": True,
"action": "satellite_passes",
"observer": {"latitude": latitude, "longitude": longitude, "elevation": elevation_m},
"timezone": timezone_name,
"window_start": window_start.isoformat().replace("+00:00", "Z"),
"window_end": window_end.isoformat().replace("+00:00", "Z"),
"results": results,
}
_SUN_RADIUS_AU = 0.00465047 # 1 Sonnenradius in AU
def action_barycenter(args: list[str]) -> dict:
"""Berechnet SSB-Position relativ zur Sonne: aktuell, Zeitreihe, Planetenbeiträge."""
# Argumente: years_back, steps_per_year, years_forward
years_back = int(args[0]) if len(args) > 0 and args[0].isdigit() else 20
steps_per_year = int(args[1]) if len(args) > 1 and args[1].isdigit() else 12
years_forward = int(args[2]) if len(args) > 2 and args[2].isdigit() else 0
years_back = max(1, min(years_back, 100))
steps_per_year = max(4, min(steps_per_year, 366))
years_forward = max(0, min(years_forward, 100))
now = datetime.now(timezone.utc)
now_time = dt_to_time(now)
# -- Aktuelle Position --
sun_state = astronomy.BaryState(astronomy.Body.Sun, now_time)
# BaryState(Sun) = Position der Sonne relativ zum SSB → negieren = SSB relativ zur Sonne
cur_x = -sun_state.x
cur_y = -sun_state.y
cur_dist_au = math.hypot(cur_x, cur_y, -sun_state.z)
cur_dist_sr = cur_dist_au / _SUN_RADIUS_AU
cur_proj_dist_au = math.hypot(cur_x, cur_y)
cur_proj_dist_sr = cur_proj_dist_au / _SUN_RADIUS_AU
# -- Zeitreihe --
total_steps = (years_back + years_forward) * steps_per_year
days_per_step = 365.25 / steps_per_year
start_jd = now_time.tt - years_back * 365.25
series = []
threshold_events = []
max_dist_au = 0.0
max_proj_dist_au = 0.0
previous_time_tt = None
previous_inside = None
for i in range(total_steps + 1):
t = astronomy.Time(start_jd + i * days_per_step)
s = astronomy.BaryState(astronomy.Body.Sun, t)
tx, ty = -s.x, -s.y
d = math.hypot(tx, ty, -s.z)
dp = math.hypot(tx, ty)
if d > max_dist_au:
max_dist_au = d
if dp > max_proj_dist_au:
max_proj_dist_au = dp
inside_sun = dp <= _SUN_RADIUS_AU
if previous_time_tt is not None and inside_sun != previous_inside:
left_tt = previous_time_tt
right_tt = t.tt
for _ in range(24):
middle_tt = (left_tt + right_tt) / 2
middle_sun = astronomy.BaryState(astronomy.Body.Sun, astronomy.Time(middle_tt))
middle_inside = math.hypot(-middle_sun.x, -middle_sun.y) <= _SUN_RADIUS_AU
if middle_inside == previous_inside:
left_tt = middle_tt
else:
right_tt = middle_tt
event_dt = datetime(2000, 1, 1, 12, tzinfo=timezone.utc) + timedelta(days=(left_tt + right_tt) / 2)
threshold_events.append({
"date_utc": event_dt.strftime("%Y-%m-%dT%H:%M:%SZ"),
"state": "inside" if inside_sun else "outside",
})
previous_time_tt = t.tt
previous_inside = inside_sun
dt_utc = datetime(2000, 1, 1, 12, tzinfo=timezone.utc) + timedelta(days=t.tt - 0.0)
# Epoche J2000.0 = 2000-01-01 12:00 TT ≈ UTC (Differenz <1 min hier vernachlässigt)
series.append({
"x": round(tx, 6),
"y": round(ty, 6),
"d": round(d / _SUN_RADIUS_AU, 3),
"dp": round(dp / _SUN_RADIUS_AU, 3),
})
# Zeitstempel für Animationslabel als echte UTC-Kalenderdaten
series_dates = []
for i in range(total_steps + 1):
t_days = start_jd + i * days_per_step
dt_utc = datetime(2000, 1, 1, 12, tzinfo=timezone.utc) + timedelta(days=t_days)
series_dates.append(dt_utc.strftime("%Y-%m-%d"))
# -- Planetenmassen (GM relativ zur Sonne = 1) für gewichteten Beitrag --
# Quellen: IAU 2012 / astronomy-engine Konstanten
_PLANET_MASS_FRACTION = {
astronomy.Body.Mercury: 1.6601e-7,
astronomy.Body.Venus: 2.4478e-6,
astronomy.Body.Earth: 3.0034e-6, # inkl. Mond
astronomy.Body.Mars: 3.2272e-7,
astronomy.Body.Jupiter: 9.5479e-4,
astronomy.Body.Saturn: 2.8589e-4,
astronomy.Body.Uranus: 4.3662e-5,
astronomy.Body.Neptune: 5.1514e-5,
}
contrib_bodies = [
(astronomy.Body.Mercury, "Merkur"),
(astronomy.Body.Venus, "Venus"),
(astronomy.Body.Earth, "Erde"),
(astronomy.Body.Mars, "Mars"),
(astronomy.Body.Jupiter, "Jupiter"),
(astronomy.Body.Saturn, "Saturn"),
(astronomy.Body.Uranus, "Uranus"),
(astronomy.Body.Neptune, "Neptun"),
]
contributions = []
for body, name in contrib_bodies:
s = astronomy.BaryState(body, now_time)
hx = s.x - sun_state.x
hy = s.y - sun_state.y
hz = s.z - sun_state.z
helio_dist = math.hypot(hx, hy, hz)
bary_dist = math.hypot(s.x, s.y, s.z)
mass_frac = _PLANET_MASS_FRACTION.get(body, 0.0)
# Beitrag zum SSB-Offset = Masse × heliozentrischer Abstand (in AU)
ssb_contribution_au = mass_frac * helio_dist
contributions.append({
"name": name,
"helio_dist_au": round(helio_dist, 4),
"bary_dist_au": round(bary_dist, 4),
"bary_x": round(s.x, 6),
"bary_y": round(s.y, 6),
"mass_fraction": mass_frac,
"ssb_contribution_au": round(ssb_contribution_au, 8),
})
now_index = years_back * steps_per_year
return {
"ok": True,
"current": {
"x": round(cur_x, 6),
"y": round(cur_y, 6),
"dist_au": round(cur_dist_au, 6),
"dist_solar_radii": round(cur_dist_sr, 4),
"proj_dist_au": round(cur_proj_dist_au, 6),
"proj_dist_solar_radii": round(cur_proj_dist_sr, 4),
"inside_sun": cur_proj_dist_sr <= 1.0,
"date_utc": now.strftime("%Y-%m-%dT%H:%M:%SZ"),
},
"series": series,
"series_dates": series_dates,
"threshold_events": threshold_events,
"now_index": now_index,
"max_dist_solar_radii": round(max_dist_au / _SUN_RADIUS_AU, 3),
"max_proj_dist_solar_radii": round(max_proj_dist_au / _SUN_RADIUS_AU, 3),
"sun_radius_au": _SUN_RADIUS_AU,
"contributions": contributions,
"years_back": years_back,
"years_forward": years_forward,
}
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", "astronomical_conversions", "comet_brightnesses", "favorite_comet_events_for_month", "moon_star_occultations", "moon_phase_details", "satellite_passes", "planet_rise_set", "planet_ephemeris", "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", "golden_gate_of_ecliptic_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", "sun_constellation_changes_for_year", "sun_constellation_offset_statistics", "moon_star_occultations_for_month", "moon_planet_occultations_for_year"]})
action = sys.argv[1]
args = sys.argv[2:]
if action == "sun_moon_rise_set":
result = action_sun_moon_rise_set(args)
print(json.dumps(result, ensure_ascii=True))
return
if action == "solar_longitude_to_datetime":
result = action_solar_longitude_to_datetime(args)
print(json.dumps(result, ensure_ascii=True))
return
if action == "current_solar_longitude":
result = action_current_solar_longitude(args)
print(json.dumps(result, ensure_ascii=True))
return
if action == "astronomical_conversions":
result = action_astronomical_conversions(args)
print(json.dumps(result, ensure_ascii=True))
return
if action == "twilight_chart":
result = action_twilight_chart(args)
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 == "favorite_comet_events_for_month":
result = action_favorite_comet_events_for_month(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))
return
if action == "season_changes_for_month":
result = action_season_changes_for_month(args)
print(json.dumps(result, ensure_ascii=True))
return
if action == "time_changes_for_month":
result = action_time_changes_for_month(args)
print(json.dumps(result, ensure_ascii=True))
return
if action == "moon_phase_details":
result = action_moon_phase_details(args)
print(json.dumps(result, ensure_ascii=True))
return
if action == "satellite_passes":
result = action_satellite_passes(args)
print(json.dumps(result, ensure_ascii=True))
return
if action == "planet_rise_set":
result = action_planet_rise_set(args)
print(json.dumps(result, ensure_ascii=True))
return
if action == "planet_ephemeris":
result = action_planet_ephemeris(args)
print(json.dumps(result, ensure_ascii=True))
return
if action == "planet_visibility_chart":
result = action_planet_visibility_chart(args)
print(json.dumps(result, ensure_ascii=True))
return
if action == "month_sky_context":
result = action_month_sky_context(args)
print(json.dumps(result, ensure_ascii=True))
return
if action == "moon_phases_for_month":
result = action_moon_phases_for_month(args)
print(json.dumps(result, ensure_ascii=True))
return
if action == "golden_handle_for_month":
result = action_golden_handle_for_month(args)
print(json.dumps(result, ensure_ascii=True))
return
if action == "moon_planet_approaches":
result = action_moon_planet_approaches(args)
print(json.dumps(result, ensure_ascii=True))
return
if action == "moon_deep_sky_approaches_for_month":
result = action_moon_deep_sky_approaches_for_month(args)
print(json.dumps(result, ensure_ascii=True))
return
if action == "planet_bright_star_approaches_for_month":
result = action_planet_bright_star_approaches_for_month(args)
print(json.dumps(result, ensure_ascii=True))
return
if action == "golden_gate_of_ecliptic_for_month":
result = action_golden_gate_of_ecliptic_for_month(args)
print(json.dumps(result, ensure_ascii=True))
return
if action == "planet_conjunctions_for_month":
result = action_planet_conjunctions_for_month(args)
print(json.dumps(result, ensure_ascii=True))
return
if action == "eclipses_for_month":
result = action_eclipses_for_month(args)
print(json.dumps(result, ensure_ascii=True))
return
if action == "moon_apsides_for_month":
result = action_moon_apsides_for_month(args)
print(json.dumps(result, ensure_ascii=True))
return
if action == "sun_apsides_for_month":
result = action_sun_apsides_for_month(args)
print(json.dumps(result, ensure_ascii=True))
return
if action == "inner_planet_elongations_for_month":
result = action_inner_planet_elongations_for_month(args)
print(json.dumps(result, ensure_ascii=True))
return
if action == "venus_peak_magnitude_for_month":
result = action_venus_peak_magnitude_for_month(args)
print(json.dumps(result, ensure_ascii=True))
return
if action == "outer_planet_events_for_month":
result = action_outer_planet_events_for_month(args)
print(json.dumps(result, ensure_ascii=True))
return
if action == "outer_planet_stations_for_month":
result = action_outer_planet_stations_for_month(args)
print(json.dumps(result, ensure_ascii=True))
return
if action == "jupiter_moons_one_side_for_month":
result = action_jupiter_moons_one_side_for_month(args)
print(json.dumps(result, ensure_ascii=True))
return
if action == "mercury_good_visibility_for_month":
result = action_mercury_good_visibility_for_month(args)
print(json.dumps(result, ensure_ascii=True))
return
if action == "planet_parades_for_month":
result = action_planet_parades_for_month(args)
print(json.dumps(result, ensure_ascii=True))
return
if action == "planet_constellation_changes_for_month":
result = action_planet_constellation_changes_for_month(args)
print(json.dumps(result, ensure_ascii=True))
return
if action == "sun_constellation_changes_for_year":
result = action_sun_constellation_changes_for_year(args)
print(json.dumps(result, ensure_ascii=True))
return
if action == "sun_constellation_offset_statistics":
result = action_sun_constellation_offset_statistics(args)
print(json.dumps(result, ensure_ascii=True))
return
if action == "moon_star_occultations_for_month":
result = action_moon_star_occultations_for_month(args)
print(json.dumps(result, ensure_ascii=True))
return
if action == "moon_planet_occultations_for_year":
result = action_moon_planet_occultations_for_year(args)
print(json.dumps(result, ensure_ascii=True))
return
if action == "lunar_eclipses_for_year":
result = action_lunar_eclipses_for_year(args)
print(json.dumps(result, ensure_ascii=True))
return
if action == "barycenter":
result = action_barycenter(args)
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", "twilight_chart", "comet_brightnesses", "favorite_comet_events_for_month", "moon_star_occultations", "moon_phase_details", "satellite_passes", "planet_rise_set", "planet_ephemeris", "planet_visibility_chart", "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", "golden_gate_of_ecliptic_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", "sun_constellation_changes_for_year", "sun_constellation_offset_statistics", "moon_star_occultations_for_month", "moon_planet_occultations_for_year"]})
if __name__ == "__main__":
main()