Files
skyview.astronomiemuseum.de/public/py/api.py
T

4722 lines
176 KiB
Python

#!/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
MOON_RADIUS_KM = 1737.4
SYNODIC_MONTH = 29.530588853
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 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]
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 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),
]
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 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 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_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_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_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 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))
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 = [
("pleiades", "Plejaden", 3.7833, 24.1167),
("praesepe", "Praesepe", 8.6667, 19.9833),
]
approaches = []
for key, label, ra_hours, dec_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))
approaches.append({
"target_key": key,
"target_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_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 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_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))
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:
events.append({
"planet_key": "venus",
"planet_label": "Venus",
"kind": "peak_magnitude",
"label": "Venus in größter Helligkeit",
"mag": float(event.mag),
"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]]:
time_value = dt_to_time(dt_utc)
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 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_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 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 calculate_moon_axis_latitudes(time_value: astronomy.Time) -> dict:
axis = astronomy.RotationAxis(astronomy.Body.Moon, time_value)
moon_vector = astronomy.GeoVector(astronomy.Body.Moon, time_value, True)
sun_vector = astronomy.GeoVector(astronomy.Body.Sun, time_value, True)
north = vector_normalize(vector_from_astronomy(axis.north))
moon_xyz = vector_from_astronomy(moon_vector)
sun_xyz = vector_from_astronomy(sun_vector)
earth_from_moon = vector_normalize((-moon_xyz[0], -moon_xyz[1], -moon_xyz[2]))
sun_from_moon = vector_normalize(vector_subtract(sun_xyz, moon_xyz))
return {
"subearth_latitude": math.degrees(math.asin(max(-1.0, min(1.0, vector_dot(earth_from_moon, north))))),
"subsolar_latitude": math.degrees(math.asin(max(-1.0, min(1.0, vector_dot(sun_from_moon, north))))),
}
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
)
libration = astronomy.Libration(time_value)
axis_latitudes = calculate_moon_axis_latitudes(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": -normalize_signed_degrees(float(libration.elon)),
"latitude": float(libration.elat),
"subsolarLatitude": float(axis_latitudes["subsolar_latitude"]),
"subearthLatitude": float(axis_latitudes["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,
}
# ── 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,
}
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", "moon_star_occultations", "moon_phase_details", "satellite_passes", "planet_rise_set", "month_sky_context", "moon_phases_for_month", "golden_handle_for_month", "season_changes_for_month", "time_changes_for_month", "moon_planet_approaches", "moon_deep_sky_approaches_for_month", "planet_bright_star_approaches_for_month", "planet_conjunctions_for_month", "eclipses_for_month", "moon_apsides_for_month", "sun_apsides_for_month", "inner_planet_elongations_for_month", "venus_peak_magnitude_for_month", "outer_planet_events_for_month", "outer_planet_stations_for_month", "jupiter_moons_one_side_for_month", "mercury_good_visibility_for_month", "planet_parades_for_month", "planet_constellation_changes_for_month", "moon_star_occultations_for_month"]})
action = sys.argv[1]
args = sys.argv[2:]
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 == "comet_brightnesses":
result = action_comet_brightnesses(args)
print(json.dumps(result, ensure_ascii=True))
return
if action == "moon_star_occultations":
result = action_moon_star_occultations(args)
print(json.dumps(result, ensure_ascii=True))
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_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 == "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 == "moon_star_occultations_for_month":
result = action_moon_star_occultations_for_month(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", "comet_brightnesses", "moon_star_occultations", "moon_phase_details", "satellite_passes", "planet_rise_set", "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", "planet_conjunctions_for_month", "eclipses_for_month", "moon_apsides_for_month", "sun_apsides_for_month", "inner_planet_elongations_for_month", "venus_peak_magnitude_for_month", "outer_planet_events_for_month", "outer_planet_stations_for_month", "jupiter_moons_one_side_for_month", "mercury_good_visibility_for_month", "planet_parades_for_month", "planet_constellation_changes_for_month", "moon_star_occultations_for_month"]})
if __name__ == "__main__":
main()