#!/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()