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