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