5670 lines
214 KiB
Python
5670 lines
214 KiB
Python
#!/usr/bin/env python3
|
|
import base64
|
|
import json
|
|
import math
|
|
import os
|
|
import sys
|
|
from datetime import datetime, timedelta, timezone
|
|
from zoneinfo import ZoneInfo
|
|
|
|
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
if SCRIPT_DIR not in sys.path:
|
|
sys.path.insert(0, SCRIPT_DIR)
|
|
|
|
import astronomy
|
|
import astronomical_conversions
|
|
import comets
|
|
|
|
MOON_RADIUS_KM = 1737.4
|
|
SYNODIC_MONTH = 29.530588853
|
|
STAR_BODIES = [
|
|
astronomy.Body.Star1,
|
|
astronomy.Body.Star2,
|
|
astronomy.Body.Star3,
|
|
astronomy.Body.Star4,
|
|
astronomy.Body.Star5,
|
|
astronomy.Body.Star6,
|
|
astronomy.Body.Star7,
|
|
astronomy.Body.Star8,
|
|
]
|
|
PHASE_LABELS = [
|
|
"Neumond",
|
|
"Erstes Viertel / zunehmende Sichel",
|
|
"Zunehmender Halbmond",
|
|
"Zweites Viertel",
|
|
"Vollmond",
|
|
"Drittes Viertel",
|
|
"Abnehmender Halbmond",
|
|
"Letztes Viertel / abnehmende Sichel",
|
|
]
|
|
|
|
|
|
def fail(message: str, *, extra: dict | None = None, code: int = 1) -> None:
|
|
payload = {"ok": False, "error": message}
|
|
if extra:
|
|
payload.update(extra)
|
|
print(json.dumps(payload, ensure_ascii=True))
|
|
raise SystemExit(code)
|
|
|
|
|
|
def parse_float(value: str, label: str) -> float:
|
|
try:
|
|
return float(value)
|
|
except ValueError as exc:
|
|
fail(f"{label} ist ungueltig.", extra={"details": str(exc), "value": value})
|
|
|
|
|
|
def dt_to_time(dt_utc: datetime) -> astronomy.Time:
|
|
dt_utc = dt_utc.astimezone(timezone.utc)
|
|
return astronomy.Time.Make(
|
|
dt_utc.year,
|
|
dt_utc.month,
|
|
dt_utc.day,
|
|
dt_utc.hour,
|
|
dt_utc.minute,
|
|
dt_utc.second + (dt_utc.microsecond / 1_000_000.0),
|
|
)
|
|
|
|
|
|
def time_to_datetime(time_value: astronomy.Time) -> datetime:
|
|
year, month, day, hour, minute, second = time_value.Calendar()
|
|
second_int = int(second)
|
|
microsecond = int(round((second - second_int) * 1_000_000))
|
|
|
|
if microsecond >= 1_000_000:
|
|
second_int += 1
|
|
microsecond -= 1_000_000
|
|
|
|
return datetime(year, month, day, hour, minute, second_int, microsecond, tzinfo=timezone.utc)
|
|
|
|
|
|
def serialize_event(label: str, event_time: astronomy.Time | None, tz: ZoneInfo) -> dict:
|
|
if event_time is None:
|
|
return {"label": label, "found": False}
|
|
|
|
utc_dt = time_to_datetime(event_time)
|
|
local_dt = utc_dt.astimezone(tz)
|
|
|
|
return {
|
|
"label": label,
|
|
"found": True,
|
|
"utc_iso": utc_dt.isoformat().replace("+00:00", "Z"),
|
|
"local_iso": local_dt.isoformat(),
|
|
"local_time": local_dt.strftime("%H:%M"),
|
|
"local_date": local_dt.strftime("%Y-%m-%d"),
|
|
}
|
|
|
|
|
|
def normalize_longitude_deg(value: float) -> float:
|
|
value = math.fmod(value, 360.0)
|
|
if value < 0.0:
|
|
value += 360.0
|
|
return value
|
|
|
|
|
|
def action_solar_longitude_to_datetime(args: list[str]) -> dict:
|
|
if len(args) != 3:
|
|
fail(
|
|
"Aktion solar_longitude_to_datetime erwartet 3 Argumente: lambda_target_deg year timezone",
|
|
extra={"argv": args},
|
|
)
|
|
|
|
lambda_target_deg = parse_float(args[0], "Solare Laenge")
|
|
lambda_target_deg = normalize_longitude_deg(lambda_target_deg)
|
|
|
|
try:
|
|
year = int(args[1])
|
|
except ValueError as exc:
|
|
fail("Jahr ist ungueltig.", extra={"details": str(exc), "argv": args})
|
|
|
|
timezone_name = args[2]
|
|
try:
|
|
tz = ZoneInfo(timezone_name)
|
|
except Exception as exc: # pragma: no cover
|
|
fail("Zeitzone ist ungueltig oder auf dem Server nicht verfuegbar.", extra={"details": str(exc)})
|
|
|
|
start_dt_utc = datetime(year, 1, 1, 0, 0, 0, tzinfo=timezone.utc)
|
|
start_time = dt_to_time(start_dt_utc)
|
|
lambda0_deg = float(astronomy.SunPosition(start_time).elon)
|
|
delta_lambda_deg = normalize_longitude_deg(lambda_target_deg - lambda0_deg)
|
|
delta_t_days = delta_lambda_deg / 0.98564736
|
|
|
|
estimated_dt_utc = start_dt_utc + timedelta(days=delta_t_days)
|
|
search_start_dt_utc = estimated_dt_utc - timedelta(days=5)
|
|
search_start_time = dt_to_time(search_start_dt_utc)
|
|
result_time = astronomy.SearchSunLongitude(lambda_target_deg, search_start_time, 10.0)
|
|
|
|
if result_time is None:
|
|
fail(
|
|
"Kein Zeitpunkt fuer die angegebene solare Laenge gefunden.",
|
|
extra={
|
|
"lambda_target_deg": lambda_target_deg,
|
|
"year": year,
|
|
"timezone": timezone_name,
|
|
},
|
|
)
|
|
|
|
utc_dt = time_to_datetime(result_time)
|
|
local_dt = utc_dt.astimezone(tz)
|
|
|
|
return {
|
|
"ok": True,
|
|
"action": "solar_longitude_to_datetime",
|
|
"input": {
|
|
"lambda_target_deg": lambda_target_deg,
|
|
"year": year,
|
|
"timezone": timezone_name,
|
|
},
|
|
"reference": {
|
|
"utc_iso": start_dt_utc.isoformat().replace("+00:00", "Z"),
|
|
"lambda0_deg": lambda0_deg,
|
|
},
|
|
"estimate": {
|
|
"delta_lambda_deg": delta_lambda_deg,
|
|
"delta_t_days": delta_t_days,
|
|
"estimated_utc_iso": estimated_dt_utc.isoformat().replace("+00:00", "Z"),
|
|
},
|
|
"result": {
|
|
"utc_iso": utc_dt.isoformat().replace("+00:00", "Z"),
|
|
"local_iso": local_dt.isoformat(),
|
|
"local_date": local_dt.strftime("%Y-%m-%d"),
|
|
"local_time": local_dt.strftime("%H:%M:%S"),
|
|
"jd_tt": float(result_time.tt) + 2451545.0,
|
|
"jd_ut": float(result_time.ut) + 2451545.0,
|
|
},
|
|
}
|
|
|
|
|
|
def action_current_solar_longitude(args: list[str]) -> dict:
|
|
if len(args) != 1:
|
|
fail(
|
|
"Aktion current_solar_longitude erwartet 1 Argument: timezone",
|
|
extra={"argv": args},
|
|
)
|
|
|
|
timezone_name = args[0]
|
|
try:
|
|
tz = ZoneInfo(timezone_name)
|
|
except Exception as exc: # pragma: no cover
|
|
fail("Zeitzone ist ungueltig oder auf dem Server nicht verfuegbar.", extra={"details": str(exc)})
|
|
|
|
utc_dt = datetime.now(timezone.utc)
|
|
time_value = dt_to_time(utc_dt)
|
|
solar_longitude_deg = float(astronomy.SunPosition(time_value).elon)
|
|
local_dt = utc_dt.astimezone(tz)
|
|
|
|
return {
|
|
"ok": True,
|
|
"action": "current_solar_longitude",
|
|
"result": {
|
|
"solar_longitude_deg": solar_longitude_deg,
|
|
"utc_iso": utc_dt.isoformat().replace("+00:00", "Z"),
|
|
"local_iso": local_dt.isoformat(),
|
|
"local_date": local_dt.strftime("%Y-%m-%d"),
|
|
"local_time": local_dt.strftime("%H:%M:%S"),
|
|
},
|
|
}
|
|
|
|
|
|
def action_astronomical_conversions(args: list[str]) -> dict:
|
|
if len(args) < 1:
|
|
fail(
|
|
"Aktion astronomical_conversions erwartet mindestens 1 Argument: kind",
|
|
extra={"argv": args},
|
|
)
|
|
|
|
kind = str(args[0]).strip().lower()
|
|
payload: dict[str, str | None] = {"kind": kind}
|
|
|
|
if kind == "distance":
|
|
if len(args) != 3:
|
|
fail(
|
|
"Aktion astronomical_conversions fuer distance erwartet 3 Argumente: kind sourceUnit sourceValue",
|
|
extra={"argv": args},
|
|
)
|
|
payload["sourceUnit"] = args[1]
|
|
payload["sourceValue"] = args[2]
|
|
elif kind == "redshift":
|
|
if len(args) != 3:
|
|
fail(
|
|
"Aktion astronomical_conversions fuer redshift erwartet 3 Argumente: kind z restNm",
|
|
extra={"argv": args},
|
|
)
|
|
payload["z"] = args[1]
|
|
payload["restNm"] = args[2]
|
|
elif kind == "time":
|
|
if len(args) != 4:
|
|
fail(
|
|
"Aktion astronomical_conversions fuer time erwartet 4 Argumente: kind sourceUnit sourceValue longitude",
|
|
extra={"argv": args},
|
|
)
|
|
payload["sourceUnit"] = args[1]
|
|
payload["sourceValue"] = args[2]
|
|
payload["longitude"] = args[3]
|
|
else:
|
|
fail(
|
|
"Unbekannter Umrechnungsbereich fuer astronomical_conversions.",
|
|
extra={"kind": kind, "argv": args},
|
|
)
|
|
|
|
try:
|
|
return astronomical_conversions.handle_request(payload)
|
|
except ValueError as exc:
|
|
fail(str(exc), extra={"kind": kind})
|
|
|
|
|
|
def action_comet_brightnesses(args: list[str]) -> dict:
|
|
if len(args) != 1:
|
|
fail(
|
|
"Aktion comet_brightnesses erwartet 1 Argument: payloadBase64 oder @payloadDatei",
|
|
extra={"argv": args},
|
|
)
|
|
|
|
try:
|
|
payload_arg = args[0]
|
|
if payload_arg.startswith("@"):
|
|
payload_path = payload_arg[1:]
|
|
with open(payload_path, "r", encoding="utf-8") as handle:
|
|
payload_text = handle.read()
|
|
else:
|
|
payload_text = base64.urlsafe_b64decode(payload_arg.encode("ascii")).decode("utf-8")
|
|
payload = json.loads(payload_text)
|
|
except Exception as exc:
|
|
fail("Payload fuer comet_brightnesses ist ungueltig.", extra={"details": str(exc)})
|
|
|
|
try:
|
|
return comets.handle_request(payload)
|
|
except ValueError as exc:
|
|
fail(str(exc), extra={"action": "comet_brightnesses"})
|
|
|
|
|
|
def 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 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_month_sky_context(args: list[str]) -> dict:
|
|
if len(args) != 8:
|
|
fail(
|
|
"Aktion month_sky_context erwartet 8 Argumente: latitude longitude elevation year month hour minute timezone",
|
|
extra={"argv": args},
|
|
)
|
|
|
|
latitude = parse_float(args[0], "Latitude")
|
|
longitude = parse_float(args[1], "Longitude")
|
|
elevation = parse_float(args[2], "Elevation")
|
|
|
|
try:
|
|
year = int(args[3])
|
|
month = int(args[4])
|
|
hour = int(args[5])
|
|
minute = int(args[6])
|
|
except ValueError as exc:
|
|
fail("Jahr, Monat oder Uhrzeit sind ungueltig.", extra={"details": str(exc), "argv": args})
|
|
|
|
if month < 1 or month > 12:
|
|
fail("Monat muss zwischen 1 und 12 liegen.", extra={"month": month})
|
|
if hour < 0 or hour > 23:
|
|
fail("Stunde muss zwischen 0 und 23 liegen.", extra={"hour": hour})
|
|
if minute < 0 or minute > 59:
|
|
fail("Minute muss zwischen 0 und 59 liegen.", extra={"minute": minute})
|
|
|
|
timezone_name = args[7]
|
|
try:
|
|
tz = ZoneInfo(timezone_name)
|
|
except Exception as exc:
|
|
fail("Zeitzone ist ungueltig oder auf dem Server nicht verfuegbar.", extra={"details": str(exc)})
|
|
|
|
local_dt = datetime(year, month, 15, hour, minute, 0, tzinfo=tz)
|
|
utc_dt = local_dt.astimezone(timezone.utc)
|
|
time_value = dt_to_time(utc_dt)
|
|
observer = astronomy.Observer(latitude, longitude, elevation)
|
|
|
|
gst_hours = float(astronomy.SiderealTime(time_value))
|
|
lst_hours = normalize_degrees(gst_hours * 15.0 + longitude) / 15.0
|
|
|
|
sun = serialize_body_position("sun", "Sonne", astronomy.Body.Sun, time_value, observer)
|
|
moon = serialize_body_position("moon", "Mond", astronomy.Body.Moon, time_value, observer)
|
|
|
|
moon_phase_angle = normalize_degrees(astronomy.MoonPhase(time_value))
|
|
moon_age_days = moon_phase_angle / 360.0 * SYNODIC_MONTH
|
|
moon["phase_angle_deg"] = float(moon_phase_angle)
|
|
moon["phase_label"] = get_phase_label(moon_age_days)
|
|
moon["age_days"] = float(moon_age_days)
|
|
moon["waxing"] = moon_phase_angle <= 180.0
|
|
|
|
planets = [
|
|
serialize_body_position("mercury", "Merkur", astronomy.Body.Mercury, time_value, observer),
|
|
serialize_body_position("venus", "Venus", astronomy.Body.Venus, time_value, observer),
|
|
serialize_body_position("mars", "Mars", astronomy.Body.Mars, time_value, observer),
|
|
serialize_body_position("jupiter", "Jupiter", astronomy.Body.Jupiter, time_value, observer),
|
|
serialize_body_position("saturn", "Saturn", astronomy.Body.Saturn, time_value, observer),
|
|
serialize_body_position("uranus", "Uranus", astronomy.Body.Uranus, time_value, observer),
|
|
serialize_body_position("neptune", "Neptun", astronomy.Body.Neptune, time_value, observer),
|
|
]
|
|
|
|
return {
|
|
"ok": True,
|
|
"action": "month_sky_context",
|
|
"selected": {
|
|
"year": year,
|
|
"month": month,
|
|
"day": 15,
|
|
"hour": hour,
|
|
"minute": minute,
|
|
"timezone": timezone_name,
|
|
"local_iso": local_dt.isoformat(),
|
|
"utc_iso": utc_dt.isoformat().replace("+00:00", "Z"),
|
|
"local_label": local_dt.strftime("%d.%m.%Y %H:%M") + f" {timezone_name}",
|
|
},
|
|
"observer": {
|
|
"latitude": latitude,
|
|
"longitude": longitude,
|
|
"elevation": elevation,
|
|
},
|
|
"sidereal": {
|
|
"greenwich_hours": gst_hours,
|
|
"local_hours": lst_hours,
|
|
},
|
|
"bodies": {
|
|
"sun": sun,
|
|
"moon": moon,
|
|
"planets": planets,
|
|
},
|
|
}
|
|
|
|
|
|
def action_moon_phases_for_month(args: list[str]) -> dict:
|
|
if len(args) != 3:
|
|
fail(
|
|
"Aktion moon_phases_for_month erwartet 3 Argumente: year month timezone",
|
|
extra={"argv": args},
|
|
)
|
|
|
|
try:
|
|
year = int(args[0])
|
|
month = int(args[1])
|
|
except ValueError as exc:
|
|
fail("Jahr oder Monat sind ungueltig.", extra={"details": str(exc), "argv": args})
|
|
|
|
if month < 1 or month > 12:
|
|
fail("Monat muss zwischen 1 und 12 liegen.", extra={"month": month})
|
|
|
|
timezone_name = args[2]
|
|
try:
|
|
tz = ZoneInfo(timezone_name)
|
|
except Exception as exc:
|
|
fail("Zeitzone ist ungueltig oder auf dem Server nicht verfuegbar.", extra={"details": str(exc)})
|
|
|
|
local_start = datetime(year, month, 1, 0, 0, 0, tzinfo=tz)
|
|
if month == 12:
|
|
local_end = datetime(year + 1, 1, 1, 0, 0, 0, tzinfo=tz)
|
|
else:
|
|
local_end = datetime(year, month + 1, 1, 0, 0, 0, tzinfo=tz)
|
|
|
|
search_start = dt_to_time(local_start.astimezone(timezone.utc) - timedelta(days=3))
|
|
search_limit_days = ((local_end - local_start).total_seconds() / 86400.0) + 10.0
|
|
|
|
phase_defs = [
|
|
(0.0, "Neumond"),
|
|
(90.0, "Erstes Viertel"),
|
|
(180.0, "Vollmond"),
|
|
(270.0, "Letztes Viertel"),
|
|
]
|
|
|
|
phases = []
|
|
for target_lon, label in phase_defs:
|
|
probe = search_start
|
|
found = None
|
|
|
|
for _ in range(3):
|
|
result = astronomy.SearchMoonPhase(target_lon, probe, search_limit_days)
|
|
if result is None:
|
|
break
|
|
|
|
dt_utc = time_to_datetime(result)
|
|
local_dt = dt_utc.astimezone(tz)
|
|
if local_dt.year == year and local_dt.month == month:
|
|
found = {
|
|
"label": label,
|
|
"utc_iso": dt_utc.isoformat().replace("+00:00", "Z"),
|
|
"local_iso": local_dt.isoformat(),
|
|
"local_date": local_dt.strftime("%d.%m.%Y"),
|
|
"local_time": local_dt.strftime("%H:%M"),
|
|
"sort_iso": local_dt.isoformat(),
|
|
}
|
|
break
|
|
|
|
probe = dt_to_time(dt_utc + timedelta(days=1))
|
|
|
|
if found is not None:
|
|
phases.append(found)
|
|
|
|
phases.sort(key=lambda item: item["sort_iso"])
|
|
for item in phases:
|
|
item.pop("sort_iso", None)
|
|
|
|
return {
|
|
"ok": True,
|
|
"action": "moon_phases_for_month",
|
|
"selected": {
|
|
"year": year,
|
|
"month": month,
|
|
"timezone": timezone_name,
|
|
},
|
|
"phases": phases,
|
|
}
|
|
|
|
|
|
def action_golden_handle_for_month(args: list[str]) -> dict:
|
|
if len(args) != 3:
|
|
fail(
|
|
"Aktion golden_handle_for_month erwartet 3 Argumente: year month timezone",
|
|
extra={"argv": args},
|
|
)
|
|
|
|
try:
|
|
year = int(args[0])
|
|
month = int(args[1])
|
|
except ValueError as exc:
|
|
fail("Jahr oder Monat sind ungueltig.", extra={"details": str(exc), "argv": args})
|
|
|
|
if month < 1 or month > 12:
|
|
fail("Monat muss zwischen 1 und 12 liegen.", extra={"month": month})
|
|
|
|
timezone_name = args[2]
|
|
try:
|
|
tz = ZoneInfo(timezone_name)
|
|
except Exception as exc:
|
|
fail("Zeitzone ist ungueltig oder auf dem Server nicht verfuegbar.", extra={"details": str(exc)})
|
|
|
|
local_start = datetime(year, month, 1, 0, 0, 0, tzinfo=tz)
|
|
if month == 12:
|
|
local_end = datetime(year + 1, 1, 1, 0, 0, 0, tzinfo=tz)
|
|
else:
|
|
local_end = datetime(year, month + 1, 1, 0, 0, 0, tzinfo=tz)
|
|
|
|
# Minimal physical model for the Golden Handle:
|
|
# solve the time when the floor of Sinus Iridum is still below sunrise
|
|
# by half of the topographic advance angle of the Jura rim.
|
|
moon_radius_km = 1737.4
|
|
floor_latitude_deg = 42.0
|
|
floor_longitude_west_deg = 35.5
|
|
relief_height_difference_km = 1.18
|
|
|
|
def relief_advance_angle_deg() -> float:
|
|
return math.degrees(
|
|
math.acos(moon_radius_km / (moon_radius_km + relief_height_difference_km))
|
|
)
|
|
|
|
def solar_selenographic_coordinates(dt_utc: datetime) -> tuple[float, float]:
|
|
time_value = dt_to_time(dt_utc)
|
|
phase_deg = normalize_degrees(astronomy.MoonPhase(time_value))
|
|
libration = astronomy.Libration(time_value)
|
|
axis_latitudes = calculate_moon_axis_latitudes(time_value)
|
|
|
|
# Approximate selenographic solar longitude from colongitude using
|
|
# west-positive longitude for the lunar surface model.
|
|
colongitude_deg = normalize_degrees(phase_deg - 90.0 - float(libration.elon))
|
|
solar_longitude_west_deg = normalize_signed_degrees(colongitude_deg - 90.0)
|
|
solar_latitude_deg = float(axis_latitudes["subsolar_latitude"])
|
|
return solar_longitude_west_deg, solar_latitude_deg
|
|
|
|
def floor_solar_altitude_deg(dt_utc: datetime) -> float:
|
|
solar_longitude_west_deg, solar_latitude_deg = solar_selenographic_coordinates(dt_utc)
|
|
phi = math.radians(floor_latitude_deg)
|
|
lam_f = math.radians(floor_longitude_west_deg)
|
|
lam_s = math.radians(solar_longitude_west_deg)
|
|
b_s = math.radians(solar_latitude_deg)
|
|
x = (
|
|
math.sin(phi) * math.sin(b_s)
|
|
+ math.cos(phi) * math.cos(b_s) * math.cos(lam_f - lam_s)
|
|
)
|
|
x = max(-1.0, min(1.0, x))
|
|
return math.degrees(math.asin(x))
|
|
|
|
def golden_handle_metric(dt_utc: datetime) -> float:
|
|
return floor_solar_altitude_deg(dt_utc) + relief_advance_angle_deg() / 2.0
|
|
|
|
def refine_peak_time(left_utc: datetime, right_utc: datetime) -> datetime:
|
|
left = left_utc
|
|
right = right_utc
|
|
f_left = golden_handle_metric(left)
|
|
f_right = golden_handle_metric(right)
|
|
for _ in range(40):
|
|
mid = left + (right - left) / 2
|
|
f_mid = golden_handle_metric(mid)
|
|
if abs(f_mid) < 1e-5:
|
|
return mid
|
|
if f_left == 0:
|
|
return left
|
|
if f_right == 0:
|
|
return right
|
|
if f_left * f_mid <= 0:
|
|
right = mid
|
|
f_right = f_mid
|
|
else:
|
|
left = mid
|
|
f_left = f_mid
|
|
return left + (right - left) / 2
|
|
|
|
search_start = dt_to_time(local_start.astimezone(timezone.utc) - timedelta(days=20))
|
|
events = []
|
|
probe = search_start
|
|
for _ in range(3):
|
|
first_quarter = astronomy.SearchMoonPhase(90.0, probe, 40.0)
|
|
if first_quarter is None:
|
|
break
|
|
|
|
full_moon = astronomy.SearchMoonPhase(180.0, first_quarter, 12.0)
|
|
if full_moon is None:
|
|
break
|
|
|
|
interval_start = time_to_datetime(first_quarter)
|
|
interval_end = time_to_datetime(full_moon)
|
|
step = timedelta(hours=1)
|
|
previous_time = interval_start
|
|
previous_value = golden_handle_metric(previous_time)
|
|
current = interval_start + step
|
|
peak_utc = None
|
|
|
|
while current <= interval_end:
|
|
current_value = golden_handle_metric(current)
|
|
if previous_value == 0 or current_value == 0 or previous_value * current_value < 0:
|
|
peak_utc = refine_peak_time(previous_time, current)
|
|
break
|
|
previous_time = current
|
|
previous_value = current_value
|
|
current += step
|
|
|
|
if peak_utc is not None:
|
|
peak_local = peak_utc.astimezone(tz)
|
|
if peak_local.year == year and peak_local.month == month:
|
|
events.append({
|
|
"kind": "golden_handle",
|
|
"label": "Goldener Henkel am Mond",
|
|
"utc_iso": peak_utc.isoformat().replace("+00:00", "Z"),
|
|
"local_iso": peak_local.isoformat(),
|
|
"local_date": peak_local.strftime("%d.%m.%Y"),
|
|
"local_time": peak_local.strftime("%H:%M"),
|
|
"peak_local_iso": peak_local.isoformat(),
|
|
"peak_local_date": peak_local.strftime("%d.%m.%Y"),
|
|
"peak_local_time": peak_local.strftime("%H:%M"),
|
|
})
|
|
|
|
probe = dt_to_time(interval_end + timedelta(days=10))
|
|
|
|
events.sort(key=lambda item: item["local_iso"])
|
|
|
|
return {
|
|
"ok": True,
|
|
"action": "golden_handle_for_month",
|
|
"selected": {
|
|
"year": year,
|
|
"month": month,
|
|
"timezone": timezone_name,
|
|
},
|
|
"events": events,
|
|
}
|
|
|
|
|
|
def action_season_changes_for_month(args: list[str]) -> dict:
|
|
if len(args) != 3:
|
|
fail(
|
|
"Aktion season_changes_for_month erwartet 3 Argumente: year month timezone",
|
|
extra={"argv": args},
|
|
)
|
|
|
|
try:
|
|
year = int(args[0])
|
|
month = int(args[1])
|
|
except ValueError as exc:
|
|
fail("Jahr oder Monat sind ungueltig.", extra={"details": str(exc), "argv": args})
|
|
|
|
if month < 1 or month > 12:
|
|
fail("Monat muss zwischen 1 und 12 liegen.", extra={"month": month})
|
|
|
|
timezone_name = args[2]
|
|
try:
|
|
tz = ZoneInfo(timezone_name)
|
|
except Exception as exc:
|
|
fail("Zeitzone ist ungueltig oder auf dem Server nicht verfuegbar.", extra={"details": str(exc)})
|
|
|
|
season_info = astronomy.Seasons(year)
|
|
season_defs = [
|
|
("Fruehlingsanfang", season_info.mar_equinox, "march_equinox"),
|
|
("Sommeranfang", season_info.jun_solstice, "june_solstice"),
|
|
("Herbstanfang", season_info.sep_equinox, "september_equinox"),
|
|
("Winteranfang", season_info.dec_solstice, "december_solstice"),
|
|
]
|
|
|
|
changes = []
|
|
for label, time_value, key in season_defs:
|
|
dt_utc = time_to_datetime(time_value)
|
|
local_dt = dt_utc.astimezone(tz)
|
|
if local_dt.year != year or local_dt.month != month:
|
|
continue
|
|
|
|
changes.append({
|
|
"key": key,
|
|
"label": label,
|
|
"utc_iso": dt_utc.isoformat().replace("+00:00", "Z"),
|
|
"local_iso": local_dt.isoformat(),
|
|
"local_date": local_dt.strftime("%d.%m.%Y"),
|
|
"local_time": local_dt.strftime("%H:%M"),
|
|
})
|
|
|
|
changes.sort(key=lambda item: item["local_iso"])
|
|
|
|
return {
|
|
"ok": True,
|
|
"action": "season_changes_for_month",
|
|
"selected": {
|
|
"year": year,
|
|
"month": month,
|
|
"timezone": timezone_name,
|
|
},
|
|
"changes": changes,
|
|
}
|
|
|
|
|
|
def action_time_changes_for_month(args: list[str]) -> dict:
|
|
if len(args) != 3:
|
|
fail(
|
|
"Aktion time_changes_for_month erwartet 3 Argumente: year month timezone",
|
|
extra={"argv": args},
|
|
)
|
|
|
|
try:
|
|
year = int(args[0])
|
|
month = int(args[1])
|
|
except ValueError as exc:
|
|
fail("Jahr oder Monat sind ungueltig.", extra={"details": str(exc), "argv": args})
|
|
|
|
if month < 1 or month > 12:
|
|
fail("Monat muss zwischen 1 und 12 liegen.", extra={"month": month})
|
|
|
|
timezone_name = args[2]
|
|
try:
|
|
tz = ZoneInfo(timezone_name)
|
|
except Exception as exc:
|
|
fail("Zeitzone ist ungueltig oder auf dem Server nicht verfuegbar.", extra={"details": str(exc)})
|
|
|
|
local_start = datetime(year, month, 1, 0, 0, 0, tzinfo=tz)
|
|
if month == 12:
|
|
local_end = datetime(year + 1, 1, 1, 0, 0, 0, tzinfo=tz)
|
|
else:
|
|
local_end = datetime(year, month + 1, 1, 0, 0, 0, tzinfo=tz)
|
|
|
|
utc_start = local_start.astimezone(timezone.utc) - timedelta(days=2)
|
|
utc_end = local_end.astimezone(timezone.utc) + timedelta(days=2)
|
|
|
|
def utc_offset_seconds(dt_utc: datetime) -> int:
|
|
return int(dt_utc.astimezone(tz).utcoffset().total_seconds())
|
|
|
|
def refine_transition(left_utc: datetime, right_utc: datetime) -> datetime:
|
|
left = left_utc
|
|
right = right_utc
|
|
left_offset = utc_offset_seconds(left)
|
|
while (right - left) > timedelta(seconds=1):
|
|
mid = left + (right - left) / 2
|
|
if utc_offset_seconds(mid) == left_offset:
|
|
left = mid
|
|
else:
|
|
right = mid
|
|
return right
|
|
|
|
events = []
|
|
previous_utc = utc_start
|
|
previous_offset = utc_offset_seconds(previous_utc)
|
|
current_utc = previous_utc + timedelta(hours=1)
|
|
while current_utc <= utc_end:
|
|
current_offset = utc_offset_seconds(current_utc)
|
|
if current_offset != previous_offset:
|
|
transition_utc = refine_transition(previous_utc, current_utc)
|
|
local_dt = transition_utc.astimezone(tz)
|
|
if local_dt.year == year and local_dt.month == month:
|
|
delta_seconds = current_offset - previous_offset
|
|
kind = "dst_start" if delta_seconds > 0 else "dst_end"
|
|
events.append({
|
|
"kind": kind,
|
|
"offset_before_hours": previous_offset / 3600.0,
|
|
"offset_after_hours": current_offset / 3600.0,
|
|
"utc_iso": transition_utc.isoformat().replace("+00:00", "Z"),
|
|
"local_iso": local_dt.isoformat(),
|
|
"local_date": local_dt.strftime("%d.%m.%Y"),
|
|
"local_time": local_dt.strftime("%H:%M"),
|
|
})
|
|
previous_utc = current_utc
|
|
previous_offset = current_offset
|
|
current_utc += timedelta(hours=1)
|
|
|
|
events.sort(key=lambda item: item["local_iso"])
|
|
|
|
return {
|
|
"ok": True,
|
|
"action": "time_changes_for_month",
|
|
"selected": {
|
|
"year": year,
|
|
"month": month,
|
|
"timezone": timezone_name,
|
|
},
|
|
"events": events,
|
|
}
|
|
|
|
|
|
def moon_planet_separation_deg(
|
|
body: astronomy.Body,
|
|
observer: astronomy.Observer,
|
|
dt_utc: datetime,
|
|
) -> float:
|
|
time_value = dt_to_time(dt_utc)
|
|
moon_eq = astronomy.Equator(astronomy.Body.Moon, time_value, observer, True, True)
|
|
body_eq = astronomy.Equator(body, time_value, observer, True, True)
|
|
return spherical_separation_deg(float(moon_eq.ra), float(moon_eq.dec), float(body_eq.ra), float(body_eq.dec))
|
|
|
|
|
|
def moon_fixed_equatorial_separation_deg(
|
|
ra_hours: float,
|
|
dec_deg: float,
|
|
observer: astronomy.Observer,
|
|
dt_utc: datetime,
|
|
) -> float:
|
|
time_value = dt_to_time(dt_utc)
|
|
moon_eq = astronomy.Equator(astronomy.Body.Moon, time_value, observer, True, True)
|
|
return spherical_separation_deg(float(moon_eq.ra), float(moon_eq.dec), float(ra_hours), float(dec_deg))
|
|
|
|
|
|
def planet_pair_separation_deg(
|
|
body_a: astronomy.Body,
|
|
body_b: astronomy.Body,
|
|
observer: astronomy.Observer,
|
|
dt_utc: datetime,
|
|
) -> float:
|
|
time_value = dt_to_time(dt_utc)
|
|
eq_a = astronomy.Equator(body_a, time_value, observer, True, True)
|
|
eq_b = astronomy.Equator(body_b, time_value, observer, True, True)
|
|
return spherical_separation_deg(float(eq_a.ra), float(eq_a.dec), float(eq_b.ra), float(eq_b.dec))
|
|
|
|
|
|
def planet_fixed_equatorial_separation_deg(
|
|
body: astronomy.Body,
|
|
ra_hours: float,
|
|
dec_deg: float,
|
|
observer: astronomy.Observer,
|
|
dt_utc: datetime,
|
|
) -> float:
|
|
time_value = dt_to_time(dt_utc)
|
|
body_eq = astronomy.Equator(body, time_value, observer, True, True)
|
|
return spherical_separation_deg(float(body_eq.ra), float(body_eq.dec), float(ra_hours), float(dec_deg))
|
|
|
|
|
|
def 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))
|
|
approaches.append({
|
|
"planet_key": key,
|
|
"planet_label": label,
|
|
"label": f"Mond nahe {label}",
|
|
"separation_deg": float(min_sep),
|
|
"utc_iso": min_time_utc.isoformat().replace("+00:00", "Z"),
|
|
"local_iso": local_dt.isoformat(),
|
|
"local_date": local_dt.strftime("%d.%m.%Y"),
|
|
"local_time": local_dt.strftime("%H:%M"),
|
|
})
|
|
|
|
approaches.sort(key=lambda item: item["local_iso"])
|
|
|
|
return {
|
|
"ok": True,
|
|
"action": "moon_planet_approaches",
|
|
"selected": {
|
|
"year": year,
|
|
"month": month,
|
|
"timezone": timezone_name,
|
|
"max_separation_deg": max_sep_deg,
|
|
},
|
|
"approaches": approaches,
|
|
}
|
|
|
|
|
|
def action_planet_bright_star_approaches_for_month(args: list[str]) -> dict:
|
|
if len(args) != 7:
|
|
fail(
|
|
"Aktion planet_bright_star_approaches_for_month erwartet 7 Argumente: latitude longitude elevation year month timezone max_sep_deg",
|
|
extra={"argv": args},
|
|
)
|
|
|
|
latitude = parse_float(args[0], "Latitude")
|
|
longitude = parse_float(args[1], "Longitude")
|
|
elevation = parse_float(args[2], "Elevation")
|
|
|
|
try:
|
|
year = int(args[3])
|
|
month = int(args[4])
|
|
except ValueError as exc:
|
|
fail("Jahr oder Monat sind ungueltig.", extra={"details": str(exc), "argv": args})
|
|
|
|
if month < 1 or month > 12:
|
|
fail("Monat muss zwischen 1 und 12 liegen.", extra={"month": month})
|
|
|
|
timezone_name = args[5]
|
|
max_sep_deg = parse_float(args[6], "Maximalabstand")
|
|
|
|
try:
|
|
tz = ZoneInfo(timezone_name)
|
|
except Exception as exc:
|
|
fail("Zeitzone ist ungueltig oder auf dem Server nicht verfuegbar.", extra={"details": str(exc)})
|
|
|
|
observer = astronomy.Observer(latitude, longitude, elevation)
|
|
local_start = datetime(year, month, 1, 0, 0, 0, tzinfo=tz)
|
|
if month == 12:
|
|
local_end = datetime(year + 1, 1, 1, 0, 0, 0, tzinfo=tz)
|
|
else:
|
|
local_end = datetime(year, month + 1, 1, 0, 0, 0, tzinfo=tz)
|
|
|
|
utc_start = local_start.astimezone(timezone.utc)
|
|
utc_end = local_end.astimezone(timezone.utc)
|
|
scan_step = timedelta(hours=1)
|
|
coarse_threshold = max_sep_deg + 1.0
|
|
|
|
planet_defs = [
|
|
("Merkur", astronomy.Body.Mercury, "mercury"),
|
|
("Venus", astronomy.Body.Venus, "venus"),
|
|
("Mars", astronomy.Body.Mars, "mars"),
|
|
("Jupiter", astronomy.Body.Jupiter, "jupiter"),
|
|
("Saturn", astronomy.Body.Saturn, "saturn"),
|
|
]
|
|
|
|
# Helle, auffaellige Sterne nahe der Ekliptik, die regelmaessig
|
|
# attraktive Begegnungen mit den hellen Planeten liefern.
|
|
star_defs = [
|
|
("Alrescha", 2.0341, 2.7638),
|
|
("Hamal", 2.1196, 23.4624),
|
|
("Menkar", 3.0380, 4.0897),
|
|
("Aldebaran", 4.5987, 16.5093),
|
|
("Elnath", 5.4382, 28.6075),
|
|
("Pollux", 7.7553, 28.0262),
|
|
("Alphard", 9.4598, -8.6586),
|
|
("Regulus", 10.1395, 11.9672),
|
|
("Denebola", 11.8177, 14.5721),
|
|
("Spica", 13.4199, -11.1613),
|
|
("Zubenelgenubi", 14.8479, -16.0418),
|
|
("Antares", 16.4901, -26.4319),
|
|
]
|
|
|
|
approaches = []
|
|
for planet_label, planet_body, planet_key in planet_defs:
|
|
for star_label, ra_hours, dec_deg in star_defs:
|
|
samples: list[tuple[datetime, float]] = []
|
|
current = utc_start
|
|
while current <= utc_end:
|
|
samples.append((current, planet_fixed_equatorial_separation_deg(planet_body, ra_hours, dec_deg, observer, current)))
|
|
current += scan_step
|
|
if samples[-1][0] < utc_end:
|
|
samples.append((utc_end, planet_fixed_equatorial_separation_deg(planet_body, ra_hours, dec_deg, observer, utc_end)))
|
|
|
|
seen_ranges: list[tuple[datetime, datetime]] = []
|
|
for index in range(1, len(samples) - 1):
|
|
curr_t, curr_sep = samples[index]
|
|
prev_sep = samples[index - 1][1]
|
|
next_sep = samples[index + 1][1]
|
|
|
|
if curr_sep > coarse_threshold:
|
|
continue
|
|
if curr_sep > prev_sep or curr_sep > next_sep:
|
|
continue
|
|
|
|
left = max(utc_start, curr_t - scan_step)
|
|
right = min(utc_end, curr_t + scan_step)
|
|
|
|
if any(not (right <= seen_left or left >= seen_right) for seen_left, seen_right in seen_ranges):
|
|
continue
|
|
|
|
min_time_utc, min_sep = refine_planet_fixed_target_minimum_separation(
|
|
planet_body,
|
|
ra_hours,
|
|
dec_deg,
|
|
observer,
|
|
left,
|
|
right,
|
|
)
|
|
local_dt = min_time_utc.astimezone(tz)
|
|
if local_dt.year != year or local_dt.month != month:
|
|
continue
|
|
if min_sep > max_sep_deg:
|
|
continue
|
|
|
|
seen_ranges.append((left, right))
|
|
approaches.append({
|
|
"planet_key": planet_key,
|
|
"planet_label": planet_label,
|
|
"star_label": star_label,
|
|
"label": f"{planet_label} nahe {star_label}",
|
|
"separation_deg": float(min_sep),
|
|
"utc_iso": min_time_utc.isoformat().replace("+00:00", "Z"),
|
|
"local_iso": local_dt.isoformat(),
|
|
"local_date": local_dt.strftime("%d.%m.%Y"),
|
|
"local_time": local_dt.strftime("%H:%M"),
|
|
})
|
|
|
|
approaches.sort(key=lambda item: item["local_iso"])
|
|
|
|
deduplicated_approaches: list[dict] = []
|
|
for approach in approaches:
|
|
if deduplicated_approaches:
|
|
previous = deduplicated_approaches[-1]
|
|
same_pair = (
|
|
previous["planet_key"] == approach["planet_key"]
|
|
and previous["star_label"] == approach["star_label"]
|
|
)
|
|
previous_local = datetime.fromisoformat(previous["local_iso"])
|
|
current_local = datetime.fromisoformat(approach["local_iso"])
|
|
if same_pair and abs((current_local - previous_local).total_seconds()) <= 36 * 3600:
|
|
if float(approach["separation_deg"]) < float(previous["separation_deg"]):
|
|
deduplicated_approaches[-1] = approach
|
|
continue
|
|
deduplicated_approaches.append(approach)
|
|
|
|
return {
|
|
"ok": True,
|
|
"action": "planet_bright_star_approaches_for_month",
|
|
"selected": {
|
|
"year": year,
|
|
"month": month,
|
|
"timezone": timezone_name,
|
|
"max_separation_deg": max_sep_deg,
|
|
},
|
|
"approaches": deduplicated_approaches,
|
|
}
|
|
|
|
|
|
def action_moon_deep_sky_approaches_for_month(args: list[str]) -> dict:
|
|
if len(args) != 7:
|
|
fail(
|
|
"Aktion moon_deep_sky_approaches_for_month erwartet 7 Argumente: latitude longitude elevation year month timezone max_sep_deg",
|
|
extra={"argv": args},
|
|
)
|
|
|
|
latitude = parse_float(args[0], "Latitude")
|
|
longitude = parse_float(args[1], "Longitude")
|
|
elevation = parse_float(args[2], "Elevation")
|
|
|
|
try:
|
|
year = int(args[3])
|
|
month = int(args[4])
|
|
except ValueError as exc:
|
|
fail("Jahr oder Monat sind ungueltig.", extra={"details": str(exc), "argv": args})
|
|
|
|
if month < 1 or month > 12:
|
|
fail("Monat muss zwischen 1 und 12 liegen.", extra={"month": month})
|
|
|
|
timezone_name = args[5]
|
|
max_sep_deg = parse_float(args[6], "Maximalabstand")
|
|
|
|
try:
|
|
tz = ZoneInfo(timezone_name)
|
|
except Exception as exc:
|
|
fail("Zeitzone ist ungueltig oder auf dem Server nicht verfuegbar.", extra={"details": str(exc)})
|
|
|
|
observer = astronomy.Observer(latitude, longitude, elevation)
|
|
local_start = datetime(year, month, 1, 0, 0, 0, tzinfo=tz)
|
|
if month == 12:
|
|
local_end = datetime(year + 1, 1, 1, 0, 0, 0, tzinfo=tz)
|
|
else:
|
|
local_end = datetime(year, month + 1, 1, 0, 0, 0, tzinfo=tz)
|
|
|
|
utc_start = local_start.astimezone(timezone.utc)
|
|
utc_end = local_end.astimezone(timezone.utc)
|
|
scan_step = timedelta(hours=1)
|
|
coarse_threshold = max_sep_deg + 1.0
|
|
|
|
target_defs = [
|
|
("pleiades", "Plejaden", 3.7833, 24.1167),
|
|
("praesepe", "Praesepe", 8.6667, 19.9833),
|
|
]
|
|
|
|
approaches = []
|
|
for key, label, ra_hours, dec_deg in target_defs:
|
|
samples: list[tuple[datetime, float]] = []
|
|
current = utc_start
|
|
while current <= utc_end:
|
|
samples.append((current, moon_fixed_equatorial_separation_deg(ra_hours, dec_deg, observer, current)))
|
|
current += scan_step
|
|
if samples[-1][0] < utc_end:
|
|
samples.append((utc_end, moon_fixed_equatorial_separation_deg(ra_hours, dec_deg, observer, utc_end)))
|
|
|
|
seen_ranges: list[tuple[datetime, datetime]] = []
|
|
for index in range(1, len(samples) - 1):
|
|
curr_t, curr_sep = samples[index]
|
|
prev_sep = samples[index - 1][1]
|
|
next_sep = samples[index + 1][1]
|
|
|
|
if curr_sep > coarse_threshold:
|
|
continue
|
|
if curr_sep > prev_sep or curr_sep > next_sep:
|
|
continue
|
|
|
|
left = max(utc_start, curr_t - scan_step)
|
|
right = min(utc_end, curr_t + scan_step)
|
|
|
|
if any(not (right <= seen_left or left >= seen_right) for seen_left, seen_right in seen_ranges):
|
|
continue
|
|
|
|
min_time_utc, min_sep = refine_fixed_target_minimum_separation(ra_hours, dec_deg, observer, left, right)
|
|
local_dt = min_time_utc.astimezone(tz)
|
|
if local_dt.year != year or local_dt.month != month:
|
|
continue
|
|
if min_sep > max_sep_deg:
|
|
continue
|
|
|
|
seen_ranges.append((left, right))
|
|
approaches.append({
|
|
"target_key": key,
|
|
"target_label": label,
|
|
"label": f"Mond nahe {label}",
|
|
"separation_deg": float(min_sep),
|
|
"utc_iso": min_time_utc.isoformat().replace("+00:00", "Z"),
|
|
"local_iso": local_dt.isoformat(),
|
|
"local_date": local_dt.strftime("%d.%m.%Y"),
|
|
"local_time": local_dt.strftime("%H:%M"),
|
|
})
|
|
|
|
approaches.sort(key=lambda item: item["local_iso"])
|
|
|
|
return {
|
|
"ok": True,
|
|
"action": "moon_deep_sky_approaches_for_month",
|
|
"selected": {
|
|
"year": year,
|
|
"month": month,
|
|
"timezone": timezone_name,
|
|
"max_separation_deg": max_sep_deg,
|
|
},
|
|
"approaches": approaches,
|
|
}
|
|
|
|
|
|
def action_planet_conjunctions_for_month(args: list[str]) -> dict:
|
|
if len(args) != 7:
|
|
fail(
|
|
"Aktion planet_conjunctions_for_month erwartet 7 Argumente: latitude longitude elevation year month timezone max_sep_deg",
|
|
extra={"argv": args},
|
|
)
|
|
|
|
latitude = parse_float(args[0], "Latitude")
|
|
longitude = parse_float(args[1], "Longitude")
|
|
elevation = parse_float(args[2], "Elevation")
|
|
|
|
try:
|
|
year = int(args[3])
|
|
month = int(args[4])
|
|
except ValueError as exc:
|
|
fail("Jahr oder Monat sind ungueltig.", extra={"details": str(exc), "argv": args})
|
|
|
|
if month < 1 or month > 12:
|
|
fail("Monat muss zwischen 1 und 12 liegen.", extra={"month": month})
|
|
|
|
timezone_name = args[5]
|
|
max_sep_deg = parse_float(args[6], "Maximalabstand")
|
|
|
|
try:
|
|
tz = ZoneInfo(timezone_name)
|
|
except Exception as exc:
|
|
fail("Zeitzone ist ungueltig oder auf dem Server nicht verfuegbar.", extra={"details": str(exc)})
|
|
|
|
observer = astronomy.Observer(latitude, longitude, elevation)
|
|
local_start = datetime(year, month, 1, 0, 0, 0, tzinfo=tz)
|
|
if month == 12:
|
|
local_end = datetime(year + 1, 1, 1, 0, 0, 0, tzinfo=tz)
|
|
else:
|
|
local_end = datetime(year, month + 1, 1, 0, 0, 0, tzinfo=tz)
|
|
|
|
utc_start = local_start.astimezone(timezone.utc)
|
|
utc_end = local_end.astimezone(timezone.utc)
|
|
scan_step = timedelta(hours=1)
|
|
coarse_threshold = max_sep_deg + 1.0
|
|
|
|
planet_defs = [
|
|
("Mars", astronomy.Body.Mars, "mars"),
|
|
("Jupiter", astronomy.Body.Jupiter, "jupiter"),
|
|
("Saturn", astronomy.Body.Saturn, "saturn"),
|
|
("Uranus", astronomy.Body.Uranus, "uranus"),
|
|
("Neptun", astronomy.Body.Neptune, "neptune"),
|
|
]
|
|
|
|
conjunctions = []
|
|
for left_index in range(len(planet_defs) - 1):
|
|
left_label, left_body, left_key = planet_defs[left_index]
|
|
for right_index in range(left_index + 1, len(planet_defs)):
|
|
right_label, right_body, right_key = planet_defs[right_index]
|
|
|
|
samples: list[tuple[datetime, float]] = []
|
|
current = utc_start
|
|
while current <= utc_end:
|
|
samples.append((current, planet_pair_separation_deg(left_body, right_body, observer, current)))
|
|
current += scan_step
|
|
if samples[-1][0] < utc_end:
|
|
samples.append((utc_end, planet_pair_separation_deg(left_body, right_body, observer, utc_end)))
|
|
|
|
seen_ranges: list[tuple[datetime, datetime]] = []
|
|
for index in range(1, len(samples) - 1):
|
|
curr_t, curr_sep = samples[index]
|
|
prev_sep = samples[index - 1][1]
|
|
next_sep = samples[index + 1][1]
|
|
|
|
if curr_sep > coarse_threshold:
|
|
continue
|
|
if curr_sep > prev_sep or curr_sep > next_sep:
|
|
continue
|
|
|
|
left = max(utc_start, curr_t - scan_step)
|
|
right = min(utc_end, curr_t + scan_step)
|
|
|
|
if any(not (right <= seen_left or left >= seen_right) for seen_left, seen_right in seen_ranges):
|
|
continue
|
|
|
|
min_time_utc, min_sep = refine_planet_pair_minimum_separation(left_body, right_body, observer, left, right)
|
|
local_dt = min_time_utc.astimezone(tz)
|
|
if local_dt.year != year or local_dt.month != month:
|
|
continue
|
|
if min_sep > max_sep_deg:
|
|
continue
|
|
|
|
seen_ranges.append((left, right))
|
|
conjunctions.append({
|
|
"planet_a_key": left_key,
|
|
"planet_a_label": left_label,
|
|
"planet_b_key": right_key,
|
|
"planet_b_label": right_label,
|
|
"label": f"{left_label} nahe {right_label}",
|
|
"separation_deg": float(min_sep),
|
|
"utc_iso": min_time_utc.isoformat().replace("+00:00", "Z"),
|
|
"local_iso": local_dt.isoformat(),
|
|
"local_date": local_dt.strftime("%d.%m.%Y"),
|
|
"local_time": local_dt.strftime("%H:%M"),
|
|
})
|
|
|
|
conjunctions.sort(key=lambda item: item["local_iso"])
|
|
|
|
return {
|
|
"ok": True,
|
|
"action": "planet_conjunctions_for_month",
|
|
"selected": {
|
|
"year": year,
|
|
"month": month,
|
|
"timezone": timezone_name,
|
|
"max_separation_deg": max_sep_deg,
|
|
},
|
|
"conjunctions": conjunctions,
|
|
}
|
|
|
|
|
|
def 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))
|
|
|
|
events.sort(key=lambda item: item["local_iso"])
|
|
|
|
return {
|
|
"ok": True,
|
|
"action": "inner_planet_elongations_for_month",
|
|
"selected": {
|
|
"year": year,
|
|
"month": month,
|
|
"timezone": timezone_name,
|
|
},
|
|
"events": events,
|
|
}
|
|
|
|
|
|
def action_venus_peak_magnitude_for_month(args: list[str]) -> dict:
|
|
if len(args) != 3:
|
|
fail(
|
|
"Aktion venus_peak_magnitude_for_month erwartet 3 Argumente: year month timezone",
|
|
extra={"argv": args},
|
|
)
|
|
|
|
try:
|
|
year = int(args[0])
|
|
month = int(args[1])
|
|
except ValueError as exc:
|
|
fail("Jahr oder Monat sind ungueltig.", extra={"details": str(exc), "argv": args})
|
|
|
|
if month < 1 or month > 12:
|
|
fail("Monat muss zwischen 1 und 12 liegen.", extra={"month": month})
|
|
|
|
timezone_name = args[2]
|
|
try:
|
|
tz = ZoneInfo(timezone_name)
|
|
except Exception as exc:
|
|
fail("Zeitzone ist ungueltig oder auf dem Server nicht verfuegbar.", extra={"details": str(exc)})
|
|
|
|
local_start = datetime(year, month, 1, 0, 0, 0, tzinfo=tz)
|
|
if month == 12:
|
|
local_end = datetime(year + 1, 1, 1, 0, 0, 0, tzinfo=tz)
|
|
else:
|
|
local_end = datetime(year, month + 1, 1, 0, 0, 0, tzinfo=tz)
|
|
|
|
utc_start = local_start.astimezone(timezone.utc)
|
|
scan_start = dt_to_time(utc_start - timedelta(days=80))
|
|
|
|
events: list[dict] = []
|
|
event = astronomy.SearchPeakMagnitude(astronomy.Body.Venus, scan_start)
|
|
while event is not None:
|
|
dt_utc = time_to_datetime(event.time)
|
|
local_dt = dt_utc.astimezone(tz)
|
|
if local_dt >= local_end:
|
|
break
|
|
|
|
if local_dt >= local_start:
|
|
events.append({
|
|
"planet_key": "venus",
|
|
"planet_label": "Venus",
|
|
"kind": "peak_magnitude",
|
|
"label": "Venus in größter Helligkeit",
|
|
"mag": float(event.mag),
|
|
"utc_iso": dt_utc.isoformat().replace("+00:00", "Z"),
|
|
"local_iso": local_dt.isoformat(),
|
|
"local_date": local_dt.strftime("%d.%m.%Y"),
|
|
"local_time": local_dt.strftime("%H:%M"),
|
|
})
|
|
|
|
event = astronomy.SearchPeakMagnitude(astronomy.Body.Venus, event.time.AddDays(30.0))
|
|
|
|
events.sort(key=lambda item: item["local_iso"])
|
|
|
|
return {
|
|
"ok": True,
|
|
"action": "venus_peak_magnitude_for_month",
|
|
"selected": {
|
|
"year": year,
|
|
"month": month,
|
|
"timezone": timezone_name,
|
|
},
|
|
"events": events,
|
|
}
|
|
|
|
|
|
def action_outer_planet_events_for_month(args: list[str]) -> dict:
|
|
if len(args) != 3:
|
|
fail(
|
|
"Aktion outer_planet_events_for_month erwartet 3 Argumente: year month timezone",
|
|
extra={"argv": args},
|
|
)
|
|
|
|
try:
|
|
year = int(args[0])
|
|
month = int(args[1])
|
|
except ValueError as exc:
|
|
fail("Jahr oder Monat sind ungueltig.", extra={"details": str(exc), "argv": args})
|
|
|
|
if month < 1 or month > 12:
|
|
fail("Monat muss zwischen 1 und 12 liegen.", extra={"month": month})
|
|
|
|
timezone_name = args[2]
|
|
try:
|
|
tz = ZoneInfo(timezone_name)
|
|
except Exception as exc:
|
|
fail("Zeitzone ist ungueltig oder auf dem Server nicht verfuegbar.", extra={"details": str(exc)})
|
|
|
|
local_start = datetime(year, month, 1, 0, 0, 0, tzinfo=tz)
|
|
if month == 12:
|
|
local_end = datetime(year + 1, 1, 1, 0, 0, 0, tzinfo=tz)
|
|
else:
|
|
local_end = datetime(year, month + 1, 1, 0, 0, 0, tzinfo=tz)
|
|
|
|
utc_start = local_start.astimezone(timezone.utc)
|
|
scan_start = dt_to_time(utc_start - timedelta(days=450))
|
|
|
|
planet_defs = [
|
|
("Mars", astronomy.Body.Mars, "mars"),
|
|
("Jupiter", astronomy.Body.Jupiter, "jupiter"),
|
|
("Saturn", astronomy.Body.Saturn, "saturn"),
|
|
("Uranus", astronomy.Body.Uranus, "uranus"),
|
|
("Neptun", astronomy.Body.Neptune, "neptune"),
|
|
]
|
|
|
|
events: list[dict] = []
|
|
for label, body, key in planet_defs:
|
|
for event_kind, target_lon, event_label in [
|
|
("opposition", 0.0, "Opposition"),
|
|
("conjunction", 180.0, "Konjunktion"),
|
|
]:
|
|
event_time = astronomy.SearchRelativeLongitude(body, target_lon, scan_start)
|
|
while True:
|
|
event_dt_utc = time_to_datetime(event_time)
|
|
local_dt = event_dt_utc.astimezone(tz)
|
|
if local_dt >= local_end:
|
|
break
|
|
if local_dt >= local_start:
|
|
events.append({
|
|
"planet_key": key,
|
|
"planet_label": label,
|
|
"kind": event_kind,
|
|
"label": f"{label} in {event_label}",
|
|
"utc_iso": event_dt_utc.isoformat().replace("+00:00", "Z"),
|
|
"local_iso": local_dt.isoformat(),
|
|
"local_date": local_dt.strftime("%d.%m.%Y"),
|
|
"local_time": local_dt.strftime("%H:%M"),
|
|
})
|
|
event_time = astronomy.SearchRelativeLongitude(body, target_lon, event_time.AddDays(10.0))
|
|
|
|
events.sort(key=lambda item: item["local_iso"])
|
|
|
|
return {
|
|
"ok": True,
|
|
"action": "outer_planet_events_for_month",
|
|
"selected": {
|
|
"year": year,
|
|
"month": month,
|
|
"timezone": timezone_name,
|
|
},
|
|
"events": events,
|
|
}
|
|
|
|
|
|
def geocentric_ecliptic_longitude_deg(body: astronomy.Body, dt_utc: datetime) -> float:
|
|
time_value = dt_to_time(dt_utc)
|
|
vector = astronomy.GeoVector(body, time_value, True)
|
|
ecliptic = astronomy.Ecliptic(vector)
|
|
return float(ecliptic.elon)
|
|
|
|
|
|
def longitude_delta_deg(left_deg: float, right_deg: float) -> float:
|
|
delta = right_deg - left_deg
|
|
while delta <= -180.0:
|
|
delta += 360.0
|
|
while delta > 180.0:
|
|
delta -= 360.0
|
|
return delta
|
|
|
|
|
|
def planet_longitude_rate_deg_per_day(body: astronomy.Body, dt_utc: datetime) -> float:
|
|
step = timedelta(hours=12)
|
|
left = geocentric_ecliptic_longitude_deg(body, dt_utc - step)
|
|
right = geocentric_ecliptic_longitude_deg(body, dt_utc + step)
|
|
return longitude_delta_deg(left, right)
|
|
|
|
|
|
def refine_stationary_time(body: astronomy.Body, left_utc: datetime, right_utc: datetime) -> datetime:
|
|
left = left_utc
|
|
right = right_utc
|
|
f_left = planet_longitude_rate_deg_per_day(body, left)
|
|
f_right = planet_longitude_rate_deg_per_day(body, right)
|
|
|
|
for _ in range(40):
|
|
mid = left + (right - left) / 2
|
|
f_mid = planet_longitude_rate_deg_per_day(body, mid)
|
|
if f_left == 0:
|
|
return left
|
|
if f_right == 0:
|
|
return right
|
|
if f_left * f_mid <= 0:
|
|
right = mid
|
|
f_right = f_mid
|
|
else:
|
|
left = mid
|
|
f_left = f_mid
|
|
|
|
return left + (right - left) / 2
|
|
|
|
|
|
def action_outer_planet_stations_for_month(args: list[str]) -> dict:
|
|
if len(args) != 3:
|
|
fail(
|
|
"Aktion outer_planet_stations_for_month erwartet 3 Argumente: year month timezone",
|
|
extra={"argv": args},
|
|
)
|
|
|
|
try:
|
|
year = int(args[0])
|
|
month = int(args[1])
|
|
except ValueError as exc:
|
|
fail("Jahr oder Monat sind ungueltig.", extra={"details": str(exc), "argv": args})
|
|
|
|
if month < 1 or month > 12:
|
|
fail("Monat muss zwischen 1 und 12 liegen.", extra={"month": month})
|
|
|
|
timezone_name = args[2]
|
|
try:
|
|
tz = ZoneInfo(timezone_name)
|
|
except Exception as exc:
|
|
fail("Zeitzone ist ungueltig oder auf dem Server nicht verfuegbar.", extra={"details": str(exc)})
|
|
|
|
local_start = datetime(year, month, 1, 0, 0, 0, tzinfo=tz)
|
|
if month == 12:
|
|
local_end = datetime(year + 1, 1, 1, 0, 0, 0, tzinfo=tz)
|
|
else:
|
|
local_end = datetime(year, month + 1, 1, 0, 0, 0, tzinfo=tz)
|
|
|
|
utc_start = local_start.astimezone(timezone.utc)
|
|
utc_end = local_end.astimezone(timezone.utc)
|
|
scan_start = utc_start - timedelta(days=70)
|
|
scan_end = utc_end + timedelta(days=70)
|
|
scan_step = timedelta(hours=12)
|
|
|
|
planet_defs = [
|
|
("Merkur", astronomy.Body.Mercury, "mercury"),
|
|
("Venus", astronomy.Body.Venus, "venus"),
|
|
("Mars", astronomy.Body.Mars, "mars"),
|
|
("Jupiter", astronomy.Body.Jupiter, "jupiter"),
|
|
("Saturn", astronomy.Body.Saturn, "saturn"),
|
|
("Uranus", astronomy.Body.Uranus, "uranus"),
|
|
("Neptun", astronomy.Body.Neptune, "neptune"),
|
|
]
|
|
|
|
events: list[dict] = []
|
|
for label, body, key in planet_defs:
|
|
current = scan_start
|
|
previous_time = current
|
|
previous_rate = planet_longitude_rate_deg_per_day(body, current)
|
|
current += scan_step
|
|
|
|
while current <= scan_end:
|
|
current_rate = planet_longitude_rate_deg_per_day(body, current)
|
|
if previous_rate == 0 or current_rate == 0 or previous_rate * current_rate < 0:
|
|
station_time_utc = refine_stationary_time(body, previous_time, current)
|
|
local_dt = station_time_utc.astimezone(tz)
|
|
if local_start <= local_dt < local_end:
|
|
rate_before = planet_longitude_rate_deg_per_day(body, station_time_utc - timedelta(days=3))
|
|
rate_after = planet_longitude_rate_deg_per_day(body, station_time_utc + timedelta(days=3))
|
|
if rate_before > 0 and rate_after < 0:
|
|
kind = "retrograde"
|
|
text = "stationär, dann rückläufig"
|
|
elif rate_before < 0 and rate_after > 0:
|
|
kind = "prograde"
|
|
text = "stationär, dann rechtläufig"
|
|
else:
|
|
kind = "stationary"
|
|
text = "stationär"
|
|
|
|
if not any(
|
|
item["planet_key"] == key and abs(datetime.fromisoformat(item["local_iso"]).timestamp() - local_dt.timestamp()) < 3600
|
|
for item in events
|
|
):
|
|
events.append({
|
|
"planet_key": key,
|
|
"planet_label": label,
|
|
"kind": kind,
|
|
"label": f"{label} {text}",
|
|
"utc_iso": station_time_utc.isoformat().replace("+00:00", "Z"),
|
|
"local_iso": local_dt.isoformat(),
|
|
"local_date": local_dt.strftime("%d.%m.%Y"),
|
|
"local_time": local_dt.strftime("%H:%M"),
|
|
})
|
|
|
|
previous_time = current
|
|
previous_rate = current_rate
|
|
current += scan_step
|
|
|
|
events.sort(key=lambda item: item["local_iso"])
|
|
|
|
return {
|
|
"ok": True,
|
|
"action": "outer_planet_stations_for_month",
|
|
"selected": {
|
|
"year": year,
|
|
"month": month,
|
|
"timezone": timezone_name,
|
|
},
|
|
"events": events,
|
|
}
|
|
|
|
|
|
def action_jupiter_moons_one_side_for_month(args: list[str]) -> dict:
|
|
if len(args) != 3:
|
|
fail(
|
|
"Aktion jupiter_moons_one_side_for_month erwartet 3 Argumente: year month timezone",
|
|
extra={"argv": args},
|
|
)
|
|
|
|
try:
|
|
year = int(args[0])
|
|
month = int(args[1])
|
|
except ValueError as exc:
|
|
fail("Jahr oder Monat sind ungueltig.", extra={"details": str(exc), "argv": args})
|
|
|
|
if month < 1 or month > 12:
|
|
fail("Monat muss zwischen 1 und 12 liegen.", extra={"month": month})
|
|
|
|
timezone_name = args[2]
|
|
try:
|
|
tz = ZoneInfo(timezone_name)
|
|
except Exception as exc:
|
|
fail("Zeitzone ist ungueltig oder auf dem Server nicht verfuegbar.", extra={"details": str(exc)})
|
|
|
|
local_start = datetime(year, month, 1, 0, 0, 0, tzinfo=tz)
|
|
if month == 12:
|
|
local_end = datetime(year + 1, 1, 1, 0, 0, 0, tzinfo=tz)
|
|
else:
|
|
local_end = datetime(year, month + 1, 1, 0, 0, 0, tzinfo=tz)
|
|
|
|
utc_start = local_start.astimezone(timezone.utc)
|
|
utc_end = local_end.astimezone(timezone.utc)
|
|
scan_step = timedelta(minutes=10)
|
|
x_tolerance_au = 1.0e-6
|
|
jupiter_radius_au = astronomy.JUPITER_EQUATORIAL_RADIUS_KM / astronomy.KM_PER_AU
|
|
moon_keys = ["io", "europa", "ganymede", "callisto"]
|
|
|
|
def classify_side(dt_utc: datetime) -> tuple[str | None, str | None, dict[str, float], dict[str, float]]:
|
|
time_value = dt_to_time(dt_utc)
|
|
rotation_eqj_to_ecl = astronomy.Rotation_EQJ_ECL()
|
|
jupiter_geo = astronomy.GeoVector(astronomy.Body.Jupiter, time_value, True)
|
|
jupiter_helio = astronomy.HelioVector(astronomy.Body.Jupiter, time_value)
|
|
moon_states = astronomy.JupiterMoons(time_value)
|
|
to_earth_eqj = astronomy.Vector(
|
|
-float(jupiter_geo.x),
|
|
-float(jupiter_geo.y),
|
|
-float(jupiter_geo.z),
|
|
time_value,
|
|
)
|
|
to_earth_ecl = astronomy.RotateVector(rotation_eqj_to_ecl, to_earth_eqj)
|
|
|
|
camera_x = float(to_earth_ecl.x)
|
|
camera_y = float(to_earth_ecl.z)
|
|
camera_z = -float(to_earth_ecl.y)
|
|
camera_len = math.sqrt(
|
|
(camera_x * camera_x) + (camera_y * camera_y) + (camera_z * camera_z)
|
|
)
|
|
if camera_len <= 1.0e-12:
|
|
return None, None, {}, {}
|
|
|
|
# Dieselbe Welt-/Kameraorientierung wie in jupitersystem.php:
|
|
# Display-Koordinaten sind (x, z, -y), die Kamera schaut aus der Richtung "toEarth".
|
|
forward_x = -camera_x / camera_len
|
|
forward_y = -camera_y / camera_len
|
|
forward_z = -camera_z / camera_len
|
|
|
|
up_x = 0.0
|
|
up_y = 1.0
|
|
up_z = 0.0
|
|
|
|
right_x = (up_y * forward_z) - (up_z * forward_y)
|
|
right_y = (up_z * forward_x) - (up_x * forward_z)
|
|
right_z = (up_x * forward_y) - (up_y * forward_x)
|
|
right_len = math.sqrt(
|
|
(right_x * right_x) + (right_y * right_y) + (right_z * right_z)
|
|
)
|
|
if right_len <= 1.0e-12:
|
|
return None, None, {}, {}
|
|
|
|
right_x /= right_len
|
|
right_y /= right_len
|
|
right_z /= right_len
|
|
|
|
# Himmlische Ostrichtung am Jupiter-Ort fuer die textliche Richtung oestlich/westlich.
|
|
jx = float(jupiter_geo.x)
|
|
jy = float(jupiter_geo.y)
|
|
east_eqj_x = -jy
|
|
east_eqj_y = jx
|
|
east_eqj_z = 0.0
|
|
east_eqj_len = math.sqrt(
|
|
(east_eqj_x * east_eqj_x) + (east_eqj_y * east_eqj_y) + (east_eqj_z * east_eqj_z)
|
|
)
|
|
if east_eqj_len <= 1.0e-12:
|
|
return None, None, {}, {}
|
|
|
|
east_eqj_x /= east_eqj_len
|
|
east_eqj_y /= east_eqj_len
|
|
east_eqj_z /= east_eqj_len
|
|
|
|
east_ecl = astronomy.RotateVector(
|
|
rotation_eqj_to_ecl,
|
|
astronomy.Vector(east_eqj_x, east_eqj_y, east_eqj_z, time_value),
|
|
)
|
|
east_display_x = float(east_ecl.x)
|
|
east_display_y = float(east_ecl.z)
|
|
east_display_z = -float(east_ecl.y)
|
|
east_screen_x = (
|
|
(east_display_x * right_x)
|
|
+ (east_display_y * right_y)
|
|
+ (east_display_z * right_z)
|
|
)
|
|
|
|
sun_to_jupiter_x = float(jupiter_helio.x)
|
|
sun_to_jupiter_y = float(jupiter_helio.y)
|
|
sun_to_jupiter_z = float(jupiter_helio.z)
|
|
sun_to_jupiter_len = math.sqrt(
|
|
(sun_to_jupiter_x * sun_to_jupiter_x)
|
|
+ (sun_to_jupiter_y * sun_to_jupiter_y)
|
|
+ (sun_to_jupiter_z * sun_to_jupiter_z)
|
|
)
|
|
if sun_to_jupiter_len <= 1.0e-12:
|
|
return None, None, {}, {}
|
|
|
|
sun_to_jupiter_x /= sun_to_jupiter_len
|
|
sun_to_jupiter_y /= sun_to_jupiter_len
|
|
sun_to_jupiter_z /= sun_to_jupiter_len
|
|
|
|
projected_x_positions = {}
|
|
east_offsets = {}
|
|
shadow_flags = {}
|
|
for key in moon_keys:
|
|
moon_state = getattr(moon_states, key)
|
|
moon_ecl = astronomy.RotateVector(rotation_eqj_to_ecl, moon_state)
|
|
moon_display_x = float(moon_ecl.x)
|
|
moon_display_y = float(moon_ecl.z)
|
|
moon_display_z = -float(moon_ecl.y)
|
|
projected_x = (
|
|
(moon_display_x * right_x)
|
|
+ (moon_display_y * right_y)
|
|
+ (moon_display_z * right_z)
|
|
)
|
|
projected_x_positions[key] = projected_x
|
|
east_offsets[key] = projected_x * east_screen_x
|
|
|
|
moon_x = float(moon_state.x)
|
|
moon_y = float(moon_state.y)
|
|
moon_z = float(moon_state.z)
|
|
shadow_axis_distance = (
|
|
(moon_x * sun_to_jupiter_x)
|
|
+ (moon_y * sun_to_jupiter_y)
|
|
+ (moon_z * sun_to_jupiter_z)
|
|
)
|
|
perp_x = moon_x - (shadow_axis_distance * sun_to_jupiter_x)
|
|
perp_y = moon_y - (shadow_axis_distance * sun_to_jupiter_y)
|
|
perp_z = moon_z - (shadow_axis_distance * sun_to_jupiter_z)
|
|
shadow_flags[key] = (
|
|
shadow_axis_distance > 0.0
|
|
and math.sqrt((perp_x * perp_x) + (perp_y * perp_y) + (perp_z * perp_z)) < jupiter_radius_au
|
|
)
|
|
|
|
projected_side_kind = None
|
|
minimum_positive_x = jupiter_radius_au + x_tolerance_au
|
|
|
|
if (
|
|
all(x_value > minimum_positive_x for x_value in projected_x_positions.values())
|
|
and not any(shadow_flags.values())
|
|
):
|
|
projected_side_kind = "right"
|
|
elif (
|
|
all(x_value < -minimum_positive_x for x_value in projected_x_positions.values())
|
|
and not any(shadow_flags.values())
|
|
):
|
|
projected_side_kind = "left"
|
|
|
|
east_west_kind = None
|
|
if all(offset > x_tolerance_au for offset in east_offsets.values()):
|
|
east_west_kind = "east"
|
|
elif all(offset < -x_tolerance_au for offset in east_offsets.values()):
|
|
east_west_kind = "west"
|
|
|
|
return projected_side_kind, east_west_kind, projected_x_positions, east_offsets
|
|
|
|
def refine_transition(
|
|
left_utc: datetime,
|
|
right_utc: datetime,
|
|
target_kind: str,
|
|
*,
|
|
find_start: bool,
|
|
) -> datetime:
|
|
left = left_utc
|
|
right = right_utc
|
|
|
|
for _ in range(32):
|
|
mid = left + (right - left) / 2
|
|
mid_kind, _, _, _ = classify_side(mid)
|
|
if find_start:
|
|
if mid_kind == target_kind:
|
|
right = mid
|
|
else:
|
|
left = mid
|
|
else:
|
|
if mid_kind == target_kind:
|
|
left = mid
|
|
else:
|
|
right = mid
|
|
|
|
return left + (right - left) / 2
|
|
|
|
def format_duration_label(duration_minutes: int) -> str:
|
|
hours, minutes = divmod(max(0, duration_minutes), 60)
|
|
if hours > 0 and minutes > 0:
|
|
return f"{hours} h {minutes:02d} min"
|
|
if hours > 0:
|
|
return f"{hours} h"
|
|
return f"{minutes} min"
|
|
|
|
def direction_text_from_kinds(raw_kind: str | None, east_west_kind: str | None) -> str:
|
|
if east_west_kind == "east":
|
|
return "östlich"
|
|
if east_west_kind == "west":
|
|
return "westlich"
|
|
return "westlich" if raw_kind == "left" else "östlich"
|
|
|
|
events: list[dict] = []
|
|
current = utc_start
|
|
active_kind, active_east_west_kind, _, _ = classify_side(current)
|
|
active_start_utc = utc_start if active_kind is not None else None
|
|
previous_time = current
|
|
previous_kind = active_kind
|
|
current += scan_step
|
|
|
|
while current <= utc_end:
|
|
current_kind, current_east_west_kind, _, _ = classify_side(current)
|
|
|
|
if active_kind is None and previous_kind != current_kind and current_kind is not None:
|
|
active_kind = current_kind
|
|
active_east_west_kind = current_east_west_kind
|
|
active_start_utc = refine_transition(previous_time, current, current_kind, find_start=True)
|
|
|
|
elif active_kind is not None and current_kind != active_kind:
|
|
active_end_utc = refine_transition(previous_time, current, active_kind, find_start=False)
|
|
clamped_start_utc = max(active_start_utc or utc_start, utc_start)
|
|
clamped_end_utc = min(active_end_utc, utc_end)
|
|
|
|
if clamped_end_utc > clamped_start_utc:
|
|
start_local = clamped_start_utc.astimezone(tz)
|
|
end_local = clamped_end_utc.astimezone(tz)
|
|
duration_minutes = max(
|
|
1,
|
|
int(round((clamped_end_utc - clamped_start_utc).total_seconds() / 60.0)),
|
|
)
|
|
side_text = direction_text_from_kinds(active_kind, active_east_west_kind)
|
|
events.append({
|
|
"kind": active_kind,
|
|
"direction": active_east_west_kind,
|
|
"label": f"Alle 4 Jupitermonde {side_text} von Jupiter",
|
|
"duration_label": format_duration_label(duration_minutes),
|
|
"duration_minutes": duration_minutes,
|
|
"utc_iso": clamped_start_utc.isoformat().replace("+00:00", "Z"),
|
|
"local_iso": start_local.isoformat(),
|
|
"local_date": start_local.strftime("%d.%m.%Y"),
|
|
"local_time": start_local.strftime("%H:%M"),
|
|
"end_utc_iso": clamped_end_utc.isoformat().replace("+00:00", "Z"),
|
|
"end_local_iso": end_local.isoformat(),
|
|
"end_local_date": end_local.strftime("%d.%m.%Y"),
|
|
"end_local_time": end_local.strftime("%H:%M"),
|
|
})
|
|
|
|
active_kind = None
|
|
active_east_west_kind = None
|
|
active_start_utc = None
|
|
|
|
if current_kind is not None:
|
|
active_kind = current_kind
|
|
active_east_west_kind = current_east_west_kind
|
|
active_start_utc = refine_transition(previous_time, current, current_kind, find_start=True)
|
|
|
|
previous_time = current
|
|
previous_kind = current_kind
|
|
current += scan_step
|
|
|
|
if active_kind is not None and active_start_utc is not None:
|
|
clamped_start_utc = max(active_start_utc, utc_start)
|
|
clamped_end_utc = utc_end
|
|
if clamped_end_utc > clamped_start_utc:
|
|
start_local = clamped_start_utc.astimezone(tz)
|
|
end_local = clamped_end_utc.astimezone(tz)
|
|
duration_minutes = max(
|
|
1,
|
|
int(round((clamped_end_utc - clamped_start_utc).total_seconds() / 60.0)),
|
|
)
|
|
side_text = direction_text_from_kinds(active_kind, active_east_west_kind)
|
|
events.append({
|
|
"kind": active_kind,
|
|
"direction": active_east_west_kind,
|
|
"label": f"Alle 4 Jupitermonde {side_text} von Jupiter",
|
|
"duration_label": format_duration_label(duration_minutes),
|
|
"duration_minutes": duration_minutes,
|
|
"utc_iso": clamped_start_utc.isoformat().replace("+00:00", "Z"),
|
|
"local_iso": start_local.isoformat(),
|
|
"local_date": start_local.strftime("%d.%m.%Y"),
|
|
"local_time": start_local.strftime("%H:%M"),
|
|
"end_utc_iso": clamped_end_utc.isoformat().replace("+00:00", "Z"),
|
|
"end_local_iso": end_local.isoformat(),
|
|
"end_local_date": end_local.strftime("%d.%m.%Y"),
|
|
"end_local_time": end_local.strftime("%H:%M"),
|
|
})
|
|
|
|
events.sort(key=lambda item: item["local_iso"])
|
|
|
|
return {
|
|
"ok": True,
|
|
"action": "jupiter_moons_one_side_for_month",
|
|
"selected": {
|
|
"year": year,
|
|
"month": month,
|
|
"timezone": timezone_name,
|
|
},
|
|
"events": events,
|
|
}
|
|
|
|
|
|
def action_moon_star_occultations_for_month(args: list[str]) -> dict:
|
|
if len(args) != 7:
|
|
fail(
|
|
"Aktion moon_star_occultations_for_month erwartet 7 Argumente: latitude longitude elevation year month timezone star_file",
|
|
extra={"argv": args},
|
|
)
|
|
|
|
latitude = parse_float(args[0], "Latitude")
|
|
longitude = parse_float(args[1], "Longitude")
|
|
elevation = parse_float(args[2], "Elevation")
|
|
|
|
try:
|
|
year = int(args[3])
|
|
month = int(args[4])
|
|
except ValueError as exc:
|
|
fail("Jahr oder Monat sind ungueltig.", extra={"details": str(exc), "argv": args})
|
|
|
|
if month < 1 or month > 12:
|
|
fail("Monat muss zwischen 1 und 12 liegen.", extra={"month": month})
|
|
|
|
timezone_name = args[5]
|
|
star_file_path = args[6]
|
|
if not star_file_path:
|
|
fail("Sterndatei fehlt.")
|
|
|
|
try:
|
|
tz = ZoneInfo(timezone_name)
|
|
except Exception as exc:
|
|
fail("Zeitzone ist ungueltig oder auf dem Server nicht verfuegbar.", extra={"details": str(exc)})
|
|
|
|
observer = astronomy.Observer(latitude, longitude, elevation)
|
|
star_rows = load_star_file(star_file_path)
|
|
|
|
local_start = datetime(year, month, 1, 0, 0, 0, tzinfo=tz)
|
|
if month == 12:
|
|
local_end = datetime(year + 1, 1, 1, 0, 0, 0, tzinfo=tz)
|
|
else:
|
|
local_end = datetime(year, month + 1, 1, 0, 0, 0, tzinfo=tz)
|
|
|
|
results, debug_profile = compute_star_occultation(star_rows, observer, local_start, local_end, tz)
|
|
|
|
return {
|
|
"ok": True,
|
|
"action": "moon_star_occultations_for_month",
|
|
"selected": {
|
|
"year": year,
|
|
"month": month,
|
|
"timezone": timezone_name,
|
|
},
|
|
"star_count": len(star_rows),
|
|
"debug": debug_profile,
|
|
"results": results,
|
|
}
|
|
|
|
|
|
def classify_visibility(sun_alt_deg: float) -> dict:
|
|
if sun_alt_deg > -5:
|
|
return {"key": "day", "label": "Tag"}
|
|
if sun_alt_deg >= -10:
|
|
return {"key": "twilight", "label": "Dämmerung"}
|
|
return {"key": "night", "label": "Nacht"}
|
|
|
|
|
|
def spherical_separation_deg(ra_hours_a: float, dec_deg_a: float, ra_hours_b: float, dec_deg_b: float) -> float:
|
|
deg2rad = math.pi / 180.0
|
|
ra_a = ra_hours_a * 15.0 * deg2rad
|
|
dec_a = dec_deg_a * deg2rad
|
|
ra_b = ra_hours_b * 15.0 * deg2rad
|
|
dec_b = dec_deg_b * deg2rad
|
|
cos_sep = (
|
|
(math.sin(dec_a) * math.sin(dec_b))
|
|
+ (math.cos(dec_a) * math.cos(dec_b) * math.cos(ra_a - ra_b))
|
|
)
|
|
cos_sep = max(-1.0, min(1.0, cos_sep))
|
|
return math.degrees(math.acos(cos_sep))
|
|
|
|
|
|
def moon_angular_radius_deg(distance_au: float) -> float:
|
|
if not math.isfinite(distance_au) or distance_au <= 0:
|
|
return 0.0
|
|
ratio = MOON_RADIUS_KM / (distance_au * astronomy.KM_PER_AU)
|
|
ratio = max(-1.0, min(1.0, ratio))
|
|
return math.degrees(math.asin(ratio))
|
|
|
|
|
|
def normalize_delta_ra_hours(delta_ra_hours: float) -> float:
|
|
while delta_ra_hours > 12.0:
|
|
delta_ra_hours -= 24.0
|
|
while delta_ra_hours < -12.0:
|
|
delta_ra_hours += 24.0
|
|
return delta_ra_hours
|
|
|
|
|
|
def star_ecliptic_latitude_deg(ra_hours: float, dec_deg: float, reference_time: astronomy.Time) -> float:
|
|
sphere = astronomy.Spherical(dec_deg, ra_hours * 15.0, 1.0)
|
|
vector = astronomy.VectorFromSphere(sphere, reference_time)
|
|
ecliptic = astronomy.Ecliptic(vector)
|
|
return float(ecliptic.elat)
|
|
|
|
|
|
def build_moon_track_samples(
|
|
observer: astronomy.Observer,
|
|
utc_start: datetime,
|
|
utc_end: datetime,
|
|
step_minutes: int = 30,
|
|
) -> list[dict]:
|
|
samples: list[dict] = []
|
|
current = utc_start
|
|
step = timedelta(minutes=step_minutes)
|
|
|
|
while current <= utc_end:
|
|
time_value = dt_to_time(current)
|
|
moon_eq = astronomy.Equator(astronomy.Body.Moon, time_value, observer, True, True)
|
|
samples.append(
|
|
{
|
|
"dt_utc": current,
|
|
"ra": float(moon_eq.ra),
|
|
"dec": float(moon_eq.dec),
|
|
"radius_deg": moon_angular_radius_deg(float(moon_eq.dist)),
|
|
}
|
|
)
|
|
current += step
|
|
|
|
if not samples or samples[-1]["dt_utc"] < utc_end:
|
|
time_value = dt_to_time(utc_end)
|
|
moon_eq = astronomy.Equator(astronomy.Body.Moon, time_value, observer, True, True)
|
|
samples.append(
|
|
{
|
|
"dt_utc": utc_end,
|
|
"ra": float(moon_eq.ra),
|
|
"dec": float(moon_eq.dec),
|
|
"radius_deg": moon_angular_radius_deg(float(moon_eq.dist)),
|
|
}
|
|
)
|
|
|
|
return samples
|
|
|
|
|
|
def interpolate_track_value(left: float, right: float, fraction: float, *, wrap_hours: bool = False) -> float:
|
|
if wrap_hours:
|
|
delta = normalize_delta_ra_hours(right - left)
|
|
value = left + delta * fraction
|
|
while value < 0.0:
|
|
value += 24.0
|
|
while value >= 24.0:
|
|
value -= 24.0
|
|
return value
|
|
return left + (right - left) * fraction
|
|
|
|
|
|
def build_moon_track_segments(samples: list[dict]) -> list[dict]:
|
|
segments: list[dict] = []
|
|
|
|
for index in range(len(samples) - 1):
|
|
left = samples[index]
|
|
right = samples[index + 1]
|
|
duration_seconds = (right["dt_utc"] - left["dt_utc"]).total_seconds()
|
|
if duration_seconds <= 0:
|
|
continue
|
|
|
|
segments.append(
|
|
{
|
|
"start": left["dt_utc"],
|
|
"end": right["dt_utc"],
|
|
"duration_seconds": duration_seconds,
|
|
"start_ra": left["ra"],
|
|
"end_ra": right["ra"],
|
|
"start_dec": left["dec"],
|
|
"end_dec": right["dec"],
|
|
"start_radius_deg": left["radius_deg"],
|
|
"end_radius_deg": right["radius_deg"],
|
|
}
|
|
)
|
|
|
|
return segments
|
|
|
|
|
|
def minimum_interpolated_moon_distance_deg(
|
|
star_ra_hours: float,
|
|
star_dec_deg: float,
|
|
track_segments: list[dict],
|
|
candidate_padding_deg: float,
|
|
) -> float:
|
|
minimum_distance = float("inf")
|
|
|
|
for segment in track_segments:
|
|
for sample_index in range(7):
|
|
fraction = sample_index / 6.0
|
|
moon_ra = interpolate_track_value(segment["start_ra"], segment["end_ra"], fraction, wrap_hours=True)
|
|
moon_dec = interpolate_track_value(segment["start_dec"], segment["end_dec"], fraction)
|
|
moon_radius = interpolate_track_value(segment["start_radius_deg"], segment["end_radius_deg"], fraction)
|
|
|
|
if abs(star_dec_deg - moon_dec) > (candidate_padding_deg + moon_radius):
|
|
continue
|
|
|
|
distance_deg = spherical_separation_deg(star_ra_hours, star_dec_deg, moon_ra, moon_dec)
|
|
margin_deg = distance_deg - moon_radius
|
|
if margin_deg < minimum_distance:
|
|
minimum_distance = margin_deg
|
|
|
|
return minimum_distance
|
|
|
|
|
|
def evaluate_occultation_geometry(body: astronomy.Body, observer: astronomy.Observer, dt_utc: datetime) -> dict:
|
|
time_value = dt_to_time(dt_utc)
|
|
moon_eq = astronomy.Equator(astronomy.Body.Moon, time_value, observer, True, True)
|
|
star_eq = astronomy.Equator(body, time_value, observer, True, True)
|
|
sun_eq = astronomy.Equator(astronomy.Body.Sun, time_value, observer, True, True)
|
|
|
|
moon_hor = astronomy.Horizon(time_value, observer, moon_eq.ra, moon_eq.dec, astronomy.Refraction.Normal)
|
|
star_hor = astronomy.Horizon(time_value, observer, star_eq.ra, star_eq.dec, astronomy.Refraction.Normal)
|
|
sun_hor = astronomy.Horizon(time_value, observer, sun_eq.ra, sun_eq.dec, astronomy.Refraction.Normal)
|
|
|
|
separation_deg = spherical_separation_deg(moon_eq.ra, moon_eq.dec, star_eq.ra, star_eq.dec)
|
|
radius_deg = moon_angular_radius_deg(moon_eq.dist)
|
|
margin_deg = separation_deg - radius_deg
|
|
|
|
return {
|
|
"time": time_value,
|
|
"moon_alt_deg": float(moon_hor.altitude),
|
|
"star_alt_deg": float(star_hor.altitude),
|
|
"sun_alt_deg": float(sun_hor.altitude),
|
|
"margin_deg": float(margin_deg),
|
|
}
|
|
|
|
|
|
def find_root_time(body: astronomy.Body, observer: astronomy.Observer, start_utc: datetime, end_utc: datetime) -> datetime:
|
|
left = start_utc
|
|
right = end_utc
|
|
for _ in range(40):
|
|
mid = left + (right - left) / 2
|
|
geom = evaluate_occultation_geometry(body, observer, mid)
|
|
if geom["margin_deg"] <= 0:
|
|
right = mid
|
|
else:
|
|
left = mid
|
|
return left + (right - left) / 2
|
|
|
|
|
|
def find_minimum_time(body: astronomy.Body, observer: astronomy.Observer, start_utc: datetime, end_utc: datetime) -> datetime:
|
|
left = start_utc
|
|
right = end_utc
|
|
for _ in range(40):
|
|
span = (right - left) / 3
|
|
m1 = left + span
|
|
m2 = right - span
|
|
f1 = evaluate_occultation_geometry(body, observer, m1)["margin_deg"]
|
|
f2 = evaluate_occultation_geometry(body, observer, m2)["margin_deg"]
|
|
if f1 <= f2:
|
|
right = m2
|
|
else:
|
|
left = m1
|
|
return left + (right - left) / 2
|
|
|
|
|
|
def serialize_occultation_time(dt_utc: datetime | None, tz: ZoneInfo) -> dict | None:
|
|
if dt_utc is None:
|
|
return None
|
|
local_dt = dt_utc.astimezone(tz)
|
|
return {
|
|
"utc_iso": dt_utc.isoformat().replace("+00:00", "Z"),
|
|
"local_iso": local_dt.isoformat(),
|
|
"local_time": local_dt.strftime("%H:%M"),
|
|
"local_date": local_dt.strftime("%Y-%m-%d"),
|
|
}
|
|
|
|
|
|
def load_star_file(star_file_path: str) -> list[dict]:
|
|
try:
|
|
with open(star_file_path, "r", encoding="utf-8-sig") as handle:
|
|
payload = json.load(handle)
|
|
except OSError as exc:
|
|
fail("Sterndatei konnte nicht gelesen werden.", extra={"details": str(exc), "path": star_file_path})
|
|
except json.JSONDecodeError as exc:
|
|
fail("Sterndatei enthaelt kein gueltiges JSON.", extra={"details": str(exc), "path": star_file_path})
|
|
|
|
if not isinstance(payload, list):
|
|
fail("Sterndatei muss eine JSON-Liste sein.", extra={"path": star_file_path})
|
|
return payload
|
|
|
|
|
|
def normalize_degrees(value: float) -> float:
|
|
return ((value % 360.0) + 360.0) % 360.0
|
|
|
|
|
|
def normalize_signed_degrees(value: float) -> float:
|
|
return normalize_degrees(value + 180.0) - 180.0
|
|
|
|
|
|
def get_phase_label(age_days: float) -> str:
|
|
segment = SYNODIC_MONTH / 8.0
|
|
index = int(math.floor((age_days + segment / 2.0) / segment)) % 8
|
|
return PHASE_LABELS[index]
|
|
|
|
|
|
def vector_from_astronomy(vector: astronomy.Vector) -> tuple[float, float, float]:
|
|
return float(vector.x), float(vector.y), float(vector.z)
|
|
|
|
|
|
def vector_subtract(a: tuple[float, float, float], b: tuple[float, float, float]) -> tuple[float, float, float]:
|
|
return a[0] - b[0], a[1] - b[1], a[2] - b[2]
|
|
|
|
|
|
def vector_dot(a: tuple[float, float, float], b: tuple[float, float, float]) -> float:
|
|
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
|
|
|
|
|
|
def vector_normalize(v: tuple[float, float, float]) -> tuple[float, float, float]:
|
|
length = math.sqrt(vector_dot(v, v))
|
|
if length <= 0:
|
|
return 0.0, 0.0, 0.0
|
|
return v[0] / length, v[1] / length, v[2] / length
|
|
|
|
|
|
def calculate_moon_position_angle(time_value: astronomy.Time) -> float:
|
|
moon_vector = astronomy.GeoVector(astronomy.Body.Moon, time_value, True)
|
|
moon_equator = astronomy.EquatorFromVector(moon_vector)
|
|
axis = astronomy.RotationAxis(astronomy.Body.Moon, time_value)
|
|
pole_equator = astronomy.EquatorFromVector(axis.north)
|
|
|
|
delta_ra = math.radians((float(pole_equator.ra) - float(moon_equator.ra)) * 15.0)
|
|
moon_dec = math.radians(float(moon_equator.dec))
|
|
pole_dec = math.radians(float(pole_equator.dec))
|
|
|
|
return math.degrees(
|
|
math.atan2(
|
|
math.cos(pole_dec) * math.sin(delta_ra),
|
|
math.sin(pole_dec) * math.cos(moon_dec)
|
|
- math.cos(pole_dec) * math.sin(moon_dec) * math.cos(delta_ra),
|
|
)
|
|
)
|
|
|
|
|
|
def calculate_moon_axis_latitudes(time_value: astronomy.Time) -> dict:
|
|
axis = astronomy.RotationAxis(astronomy.Body.Moon, time_value)
|
|
moon_vector = astronomy.GeoVector(astronomy.Body.Moon, time_value, True)
|
|
sun_vector = astronomy.GeoVector(astronomy.Body.Sun, time_value, True)
|
|
|
|
north = vector_normalize(vector_from_astronomy(axis.north))
|
|
moon_xyz = vector_from_astronomy(moon_vector)
|
|
sun_xyz = vector_from_astronomy(sun_vector)
|
|
|
|
earth_from_moon = vector_normalize((-moon_xyz[0], -moon_xyz[1], -moon_xyz[2]))
|
|
sun_from_moon = vector_normalize(vector_subtract(sun_xyz, moon_xyz))
|
|
|
|
return {
|
|
"subearth_latitude": math.degrees(math.asin(max(-1.0, min(1.0, vector_dot(earth_from_moon, north))))),
|
|
"subsolar_latitude": math.degrees(math.asin(max(-1.0, min(1.0, vector_dot(sun_from_moon, north))))),
|
|
}
|
|
|
|
|
|
def action_moon_phase_details(args: list[str]) -> dict:
|
|
if len(args) != 2:
|
|
fail(
|
|
"Aktion moon_phase_details erwartet 2 Argumente: utc_iso timezone",
|
|
extra={"argv": args},
|
|
)
|
|
|
|
utc_iso = args[0]
|
|
timezone_name = args[1]
|
|
|
|
try:
|
|
tz = ZoneInfo(timezone_name)
|
|
except Exception as exc: # pragma: no cover
|
|
fail("Zeitzone ist ungueltig oder auf dem Server nicht verfuegbar.", extra={"details": str(exc)})
|
|
|
|
try:
|
|
dt_utc = datetime.fromisoformat(utc_iso.replace("Z", "+00:00")).astimezone(timezone.utc)
|
|
except ValueError as exc:
|
|
fail("utc_iso ist ungueltig.", extra={"details": str(exc), "utc_iso": utc_iso})
|
|
|
|
time_value = dt_to_time(dt_utc)
|
|
illumination = astronomy.Illumination(astronomy.Body.Moon, time_value)
|
|
elongation = normalize_degrees(astronomy.MoonPhase(time_value))
|
|
phase = elongation / 360.0
|
|
previous_new_moon = astronomy.SearchMoonPhase(0.0, time_value, -35.0)
|
|
age_days = (
|
|
(dt_utc - time_to_datetime(previous_new_moon)).total_seconds() / 86400.0
|
|
if previous_new_moon is not None
|
|
else phase * SYNODIC_MONTH
|
|
)
|
|
|
|
libration = astronomy.Libration(time_value)
|
|
axis_latitudes = calculate_moon_axis_latitudes(time_value)
|
|
position_angle = calculate_moon_position_angle(time_value)
|
|
local_dt = dt_utc.astimezone(tz)
|
|
|
|
return {
|
|
"ok": True,
|
|
"action": "moon_phase_details",
|
|
"selected": {
|
|
"utc_iso": dt_utc.isoformat().replace("+00:00", "Z"),
|
|
"local_iso": local_dt.isoformat(),
|
|
"timezone": timezone_name,
|
|
},
|
|
"phase": {
|
|
"age": age_days,
|
|
"phase": phase,
|
|
"illumination": float(illumination.phase_fraction),
|
|
"waxing": elongation <= 180.0,
|
|
"label": get_phase_label(age_days),
|
|
},
|
|
"libration": {
|
|
"longitude": -normalize_signed_degrees(float(libration.elon)),
|
|
"latitude": float(libration.elat),
|
|
"subsolarLatitude": float(axis_latitudes["subsolar_latitude"]),
|
|
"subearthLatitude": float(axis_latitudes["subearth_latitude"]),
|
|
"positionAngle": float(position_angle),
|
|
"distKm": float(libration.dist_km),
|
|
},
|
|
}
|
|
|
|
|
|
def compute_star_occultation(
|
|
star_rows: list[dict],
|
|
observer: astronomy.Observer,
|
|
local_start: datetime,
|
|
local_end: datetime,
|
|
tz: ZoneInfo,
|
|
) -> tuple[list[dict], dict]:
|
|
utc_start = local_start.astimezone(timezone.utc)
|
|
utc_end = local_end.astimezone(timezone.utc)
|
|
search_margin = timedelta(hours=8)
|
|
search_start = utc_start - search_margin
|
|
search_end = utc_end + search_margin
|
|
results: list[dict] = []
|
|
coarse_step = timedelta(minutes=2)
|
|
candidate_margin_deg = 0.32
|
|
ecliptic_band_deg = 8.0
|
|
reference_time = dt_to_time(utc_start)
|
|
moon_track_samples = build_moon_track_samples(observer, search_start, search_end, step_minutes=30)
|
|
moon_track_segments = build_moon_track_segments(moon_track_samples)
|
|
geometry_candidates: list[dict] = []
|
|
|
|
for star in star_rows:
|
|
star_ra = float(star["ra"])
|
|
star_dec = float(star["dec"])
|
|
ecliptic_lat_deg = abs(star_ecliptic_latitude_deg(star_ra, star_dec, reference_time))
|
|
|
|
if ecliptic_lat_deg > ecliptic_band_deg:
|
|
continue
|
|
|
|
estimated_min_margin_deg = minimum_interpolated_moon_distance_deg(
|
|
star_ra,
|
|
star_dec,
|
|
moon_track_segments,
|
|
candidate_margin_deg,
|
|
)
|
|
|
|
if estimated_min_margin_deg > candidate_margin_deg:
|
|
continue
|
|
|
|
geometry_candidates.append(star)
|
|
|
|
if not geometry_candidates:
|
|
debug = {
|
|
"input_star_count": len(star_rows),
|
|
"after_ecliptic_filter": 0,
|
|
"after_track_filter": 0,
|
|
"ecliptic_band_deg": ecliptic_band_deg,
|
|
"candidate_margin_deg": candidate_margin_deg,
|
|
}
|
|
return results, debug
|
|
|
|
ecliptic_count = 0
|
|
for star in star_rows:
|
|
star_ra = float(star["ra"])
|
|
star_dec = float(star["dec"])
|
|
if abs(star_ecliptic_latitude_deg(star_ra, star_dec, reference_time)) <= ecliptic_band_deg:
|
|
ecliptic_count += 1
|
|
|
|
for chunk_start in range(0, len(geometry_candidates), len(STAR_BODIES)):
|
|
chunk = geometry_candidates[chunk_start:chunk_start + len(STAR_BODIES)]
|
|
for body, star in zip(STAR_BODIES, chunk):
|
|
astronomy.DefineStar(
|
|
body,
|
|
float(star["ra"]),
|
|
float(star["dec"]),
|
|
max(1.0, float(star.get("distLy", 1000.0))),
|
|
)
|
|
|
|
for index, star in enumerate(chunk):
|
|
body = STAR_BODIES[index]
|
|
previous_margin = None
|
|
previous_time = None
|
|
ingress_bracket = None
|
|
egress_bracket = None
|
|
minimum_margin = float("inf")
|
|
minimum_time = search_start
|
|
start_margin = None
|
|
end_margin = None
|
|
ever_negative = False
|
|
near_hit = False
|
|
|
|
current = search_start
|
|
while current <= search_end:
|
|
geometry = evaluate_occultation_geometry(body, observer, current)
|
|
margin = geometry["margin_deg"]
|
|
|
|
if margin < minimum_margin:
|
|
minimum_margin = margin
|
|
minimum_time = current
|
|
|
|
if margin <= 0:
|
|
ever_negative = True
|
|
|
|
if margin <= candidate_margin_deg:
|
|
near_hit = True
|
|
|
|
if start_margin is None:
|
|
start_margin = margin
|
|
end_margin = margin
|
|
|
|
if previous_margin is not None and previous_time is not None:
|
|
if (previous_margin > 0 >= margin) and ingress_bracket is None:
|
|
ingress_bracket = (previous_time, current)
|
|
near_hit = True
|
|
if (previous_margin <= 0 < margin) and egress_bracket is None:
|
|
egress_bracket = (previous_time, current)
|
|
near_hit = True
|
|
|
|
previous_margin = margin
|
|
previous_time = current
|
|
current += coarse_step
|
|
|
|
if (not ever_negative) and (not near_hit):
|
|
continue
|
|
if (not ever_negative) or minimum_margin > 0:
|
|
continue
|
|
|
|
ingress_time = find_root_time(body, observer, ingress_bracket[0], ingress_bracket[1]) if ingress_bracket else None
|
|
egress_time = find_root_time(body, observer, egress_bracket[0], egress_bracket[1]) if egress_bracket else None
|
|
|
|
minimum_search_start = max(search_start, minimum_time - coarse_step)
|
|
minimum_search_end = min(search_end, minimum_time + coarse_step)
|
|
if ingress_time is not None and egress_time is not None:
|
|
minimum_search_start = ingress_time
|
|
minimum_search_end = egress_time
|
|
|
|
max_time = find_minimum_time(body, observer, minimum_search_start, minimum_search_end)
|
|
max_geometry = evaluate_occultation_geometry(body, observer, max_time)
|
|
|
|
if max_geometry["margin_deg"] > 0:
|
|
continue
|
|
if max_geometry["moon_alt_deg"] <= 0 or max_geometry["star_alt_deg"] <= 0:
|
|
continue
|
|
|
|
event_start = ingress_time or search_start
|
|
event_end = egress_time or search_end
|
|
if event_end < utc_start or event_start >= utc_end:
|
|
continue
|
|
|
|
duration_seconds = None
|
|
if ingress_time is not None and egress_time is not None:
|
|
duration_seconds = int(round((egress_time - ingress_time).total_seconds()))
|
|
|
|
results.append({
|
|
"star": {
|
|
"hip": int(star.get("hip", 0)),
|
|
"label": str(star.get("label", "Unbenannter Stern")),
|
|
"constellation": str(star.get("constellation", "")),
|
|
"mag": float(star.get("mag", 99.0)),
|
|
"ra": float(star.get("ra", 0.0)),
|
|
"dec": float(star.get("dec", 0.0)),
|
|
},
|
|
"ingress": serialize_occultation_time(ingress_time, tz),
|
|
"maximum": serialize_occultation_time(max_time, tz),
|
|
"egress": serialize_occultation_time(egress_time, tz),
|
|
"partial_start": ingress_time is None and start_margin is not None and start_margin <= 0,
|
|
"partial_end": egress_time is None and end_margin is not None and end_margin <= 0,
|
|
"duration_seconds": duration_seconds,
|
|
"limb_distance_arcmin": abs(max_geometry["margin_deg"]) * 60.0,
|
|
"moon_alt_deg": max_geometry["moon_alt_deg"],
|
|
"star_alt_deg": max_geometry["star_alt_deg"],
|
|
"sun_alt_deg": max_geometry["sun_alt_deg"],
|
|
"visibility": classify_visibility(max_geometry["sun_alt_deg"]),
|
|
})
|
|
|
|
results.sort(
|
|
key=lambda item: item["maximum"]["utc_iso"] if item["maximum"] else "9999-99-99T99:99:99Z"
|
|
)
|
|
debug = {
|
|
"input_star_count": len(star_rows),
|
|
"after_ecliptic_filter": ecliptic_count,
|
|
"after_track_filter": len(geometry_candidates),
|
|
"ecliptic_band_deg": ecliptic_band_deg,
|
|
"candidate_margin_deg": candidate_margin_deg,
|
|
"search_margin_hours": search_margin.total_seconds() / 3600.0,
|
|
}
|
|
return results, debug
|
|
|
|
|
|
def action_moon_star_occultations(args: list[str]) -> dict:
|
|
if len(args) != 6:
|
|
fail(
|
|
"Aktion moon_star_occultations erwartet 6 Argumente: latitude longitude elevation date timezone star_file",
|
|
extra={"argv": args},
|
|
)
|
|
|
|
latitude = parse_float(args[0], "Latitude")
|
|
longitude = parse_float(args[1], "Longitude")
|
|
elevation = parse_float(args[2], "Elevation")
|
|
date_text = args[3]
|
|
timezone_name = args[4]
|
|
star_file_path = args[5]
|
|
|
|
try:
|
|
datetime.strptime(date_text, "%Y-%m-%d")
|
|
except ValueError:
|
|
fail("Datum muss im Format YYYY-MM-DD uebergeben werden.", extra={"date": date_text})
|
|
|
|
try:
|
|
tz = ZoneInfo(timezone_name)
|
|
except Exception as exc: # pragma: no cover
|
|
fail("Zeitzone ist ungueltig oder auf dem Server nicht verfuegbar.", extra={"details": str(exc)})
|
|
|
|
star_rows = load_star_file(star_file_path)
|
|
observer = astronomy.Observer(latitude, longitude, elevation)
|
|
local_start = datetime.strptime(date_text, "%Y-%m-%d").replace(tzinfo=tz)
|
|
local_end = local_start + timedelta(days=1)
|
|
|
|
results, debug_profile = compute_star_occultation(star_rows, observer, local_start, local_end, tz)
|
|
|
|
return {
|
|
"ok": True,
|
|
"action": "moon_star_occultations",
|
|
"date": date_text,
|
|
"timezone": timezone_name,
|
|
"observer": {
|
|
"latitude": latitude,
|
|
"longitude": longitude,
|
|
"elevation": elevation,
|
|
},
|
|
"star_count": len(star_rows),
|
|
"window": {
|
|
"local_start": local_start.isoformat(),
|
|
"local_end": local_end.isoformat(),
|
|
"utc_start": local_start.astimezone(timezone.utc).isoformat().replace("+00:00", "Z"),
|
|
"utc_end": local_end.astimezone(timezone.utc).isoformat().replace("+00:00", "Z"),
|
|
},
|
|
"debug_profile": debug_profile,
|
|
"results": results,
|
|
}
|
|
|
|
|
|
# ── satellite_passes ──────────────────────────────────────────────────────────
|
|
|
|
import math as _math
|
|
|
|
_WGS84_A = 6378.137 # km
|
|
_WGS84_E2 = 0.00669437999014
|
|
_R_EARTH = 6371.0 # km (shadow check)
|
|
_KM_PER_AU_SAT = 149597870.7
|
|
_K_EXT = 0.18
|
|
_PHI90 = 1.0 / _math.pi
|
|
|
|
|
|
def _jday_from_dt(dt_utc: datetime) -> tuple[float, float]:
|
|
"""Return (jd_int, jd_fraction) from a UTC datetime."""
|
|
from sgp4.functions import jday as _jday_fn
|
|
return _jday_fn(dt_utc.year, dt_utc.month, dt_utc.day,
|
|
dt_utc.hour, dt_utc.minute,
|
|
dt_utc.second + dt_utc.microsecond / 1e6)
|
|
|
|
|
|
def _gmst_rad(jd_full: float) -> float:
|
|
T = (jd_full - 2451545.0) / 36525.0
|
|
gmst_deg = (280.46061837
|
|
+ 360.98564736629 * (jd_full - 2451545.0)
|
|
+ 0.000387933 * T * T
|
|
- T * T * T / 38710000.0)
|
|
return _math.radians(gmst_deg % 360.0)
|
|
|
|
|
|
def _eci_to_ecef(r_eci: tuple, gmst: float) -> tuple:
|
|
x, y, z = r_eci
|
|
c, s = _math.cos(gmst), _math.sin(gmst)
|
|
return (x * c + y * s, -x * s + y * c, z)
|
|
|
|
|
|
def _observer_ecef(lat_rad: float, lon_rad: float, elev_km: float) -> tuple:
|
|
N = _WGS84_A / _math.sqrt(1.0 - _WGS84_E2 * _math.sin(lat_rad) ** 2)
|
|
x = (N + elev_km) * _math.cos(lat_rad) * _math.cos(lon_rad)
|
|
y = (N + elev_km) * _math.cos(lat_rad) * _math.sin(lon_rad)
|
|
z = (N * (1.0 - _WGS84_E2) + elev_km) * _math.sin(lat_rad)
|
|
return (x, y, z)
|
|
|
|
|
|
def _ecef_to_altaz(r_ecef: tuple, obs_ecef: tuple, lat_rad: float, lon_rad: float) -> tuple[float, float, float]:
|
|
dx = r_ecef[0] - obs_ecef[0]
|
|
dy = r_ecef[1] - obs_ecef[1]
|
|
dz = r_ecef[2] - obs_ecef[2]
|
|
rng = _math.sqrt(dx*dx + dy*dy + dz*dz)
|
|
if rng < 1e-9:
|
|
return 0.0, 0.0, 0.0
|
|
|
|
# Topocentric South-East-Z (SEZ)
|
|
slat, clat = _math.sin(lat_rad), _math.cos(lat_rad)
|
|
slon, clon = _math.sin(lon_rad), _math.cos(lon_rad)
|
|
s = slat * clon * dx + slat * slon * dy - clat * dz
|
|
e = -slon * dx + clon * dy
|
|
z_top = clat * clon * dx + clat * slon * dy + slat * dz
|
|
|
|
el_rad = _math.asin(max(-1.0, min(1.0, z_top / rng)))
|
|
az_rad = _math.atan2(e, -s)
|
|
az_deg = (_math.degrees(az_rad) + 360.0) % 360.0
|
|
el_deg = _math.degrees(el_rad)
|
|
return el_deg, az_deg, rng
|
|
|
|
|
|
def _sun_eci_km(dt_utc: datetime) -> tuple:
|
|
t = dt_to_time(dt_utc)
|
|
vec = astronomy.GeoVector(astronomy.Body.Sun, t, True)
|
|
return (float(vec.x) * _KM_PER_AU_SAT,
|
|
float(vec.y) * _KM_PER_AU_SAT,
|
|
float(vec.z) * _KM_PER_AU_SAT)
|
|
|
|
|
|
def _in_shadow(r_eci: tuple, sun_eci_km: tuple) -> bool:
|
|
# Sun vector from Earth
|
|
sx, sy, sz = sun_eci_km
|
|
sun_len = _math.sqrt(sx*sx + sy*sy + sz*sz)
|
|
if sun_len < 1:
|
|
return False
|
|
# Satellite-to-sun vector direction
|
|
rx, ry, rz = r_eci
|
|
# Project satellite onto anti-sun direction
|
|
dot = -(rx*sx + ry*sy + rz*sz) / sun_len
|
|
if dot < 0:
|
|
return False # satellite on sun-side
|
|
perp2 = (rx*rx + ry*ry + rz*rz) - dot*dot
|
|
return perp2 < _R_EARTH * _R_EARTH
|
|
|
|
|
|
def _phi_lambert(alpha_rad: float) -> float:
|
|
if alpha_rad >= _math.pi:
|
|
return 1e-10
|
|
v = (_math.sin(alpha_rad) + (_math.pi - alpha_rad) * _math.cos(alpha_rad)) / _math.pi
|
|
return max(1e-10, v)
|
|
|
|
|
|
def _airmass(h_deg: float) -> float:
|
|
if h_deg <= 0:
|
|
return 40.0
|
|
sin_h = _math.sin(_math.radians(h_deg))
|
|
return 1.0 / (sin_h + 0.50572 * (h_deg + 6.07995) ** -1.6364)
|
|
|
|
|
|
def _apparent_magnitude(std_mag, rcs, rcs_size, r_eci, sun_eci_km, obs_ecef_km, lat_rad, lon_rad, gmst):
|
|
if std_mag is not None:
|
|
m1000 = float(std_mag)
|
|
estimated = False
|
|
elif rcs is not None and rcs > 0:
|
|
m1000 = 7.5 - 2.5 * _math.log10(rcs)
|
|
estimated = True
|
|
else:
|
|
size_map = {"LARGE": 3.5, "MEDIUM": 6.0, "SMALL": 8.5}
|
|
key = (rcs_size or "").upper()
|
|
if key not in size_map:
|
|
return None
|
|
m1000 = size_map[key]
|
|
estimated = True
|
|
|
|
rx, ry, rz = r_eci
|
|
r_ecef = _eci_to_ecef(r_eci, gmst)
|
|
dx = r_ecef[0] - obs_ecef_km[0]
|
|
dy = r_ecef[1] - obs_ecef_km[1]
|
|
dz = r_ecef[2] - obs_ecef_km[2]
|
|
range_km = _math.sqrt(dx*dx + dy*dy + dz*dz)
|
|
if range_km < 1:
|
|
return None
|
|
|
|
# Phase angle (sun → sat → observer)
|
|
sx, sy, sz = sun_eci_km
|
|
ox = -rx + obs_ecef_km[0] # obs ECI ≈ ECEF for this approximation
|
|
oy = -ry + obs_ecef_km[1]
|
|
oz = -rz + obs_ecef_km[2]
|
|
# to-sun from sat
|
|
ts_x, ts_y, ts_z = sx - rx, sy - ry, sz - rz
|
|
to_x, to_y, to_z = -rx, -ry, -rz # observer ≈ Earth center approx
|
|
ts_len = _math.sqrt(ts_x*ts_x + ts_y*ts_y + ts_z*ts_z)
|
|
to_len = _math.sqrt(to_x*to_x + to_y*to_y + to_z*to_z)
|
|
if ts_len < 1 or to_len < 1:
|
|
return None
|
|
cos_a = (ts_x*to_x + ts_y*to_y + ts_z*to_z) / (ts_len * to_len)
|
|
alpha_rad = _math.acos(max(-1.0, min(1.0, cos_a)))
|
|
|
|
el_deg, _, _ = _ecef_to_altaz(r_ecef, obs_ecef_km, lat_rad, lon_rad)
|
|
dist_corr = 5.0 * _math.log10(range_km / 1000.0)
|
|
phase_corr = -2.5 * _math.log10(_phi_lambert(alpha_rad) / _PHI90)
|
|
extinction = _K_EXT * _airmass(max(1.0, el_deg))
|
|
mag = m1000 + dist_corr + phase_corr + extinction
|
|
if not _math.isfinite(mag):
|
|
return None
|
|
return {"mag": round(mag, 2), "estimated": estimated}
|
|
|
|
|
|
def _bisect_crossing(satrec, obs_ecef, lat_rad, lon_rad, t1: datetime, t2: datetime, rising: bool) -> datetime:
|
|
for _ in range(30):
|
|
mid = t1 + (t2 - t1) / 2
|
|
jd, fr = _jday_from_dt(mid)
|
|
e, r, _ = satrec.sgp4(jd, fr)
|
|
if e != 0:
|
|
break
|
|
gmst = _gmst_rad(jd + fr)
|
|
r_ecef = _eci_to_ecef(r, gmst)
|
|
el, _, _ = _ecef_to_altaz(r_ecef, obs_ecef, lat_rad, lon_rad)
|
|
above = el >= 0
|
|
if rising:
|
|
if above:
|
|
t2 = mid
|
|
else:
|
|
t1 = mid
|
|
else:
|
|
if above:
|
|
t1 = mid
|
|
else:
|
|
t2 = mid
|
|
if (t2 - t1).total_seconds() < 0.5:
|
|
break
|
|
return t1 + (t2 - t1) / 2
|
|
|
|
|
|
def action_satellite_passes(args: list[str]) -> dict:
|
|
if len(args) != 7:
|
|
fail(
|
|
"Aktion satellite_passes erwartet 7 Argumente: latitude longitude elevation timezone window_start_iso window_end_iso satellites_payload",
|
|
extra={"argv": args},
|
|
)
|
|
|
|
latitude = parse_float(args[0], "Latitude")
|
|
longitude = parse_float(args[1], "Longitude")
|
|
elevation_m = parse_float(args[2], "Elevation")
|
|
timezone_name = args[3]
|
|
window_start_iso = args[4]
|
|
window_end_iso = args[5]
|
|
satellites_payload = args[6]
|
|
|
|
try:
|
|
tz = ZoneInfo(timezone_name)
|
|
except Exception as exc:
|
|
fail("Zeitzone ungueltig.", extra={"details": str(exc)})
|
|
|
|
try:
|
|
window_start = datetime.fromisoformat(window_start_iso.replace("Z", "+00:00")).astimezone(timezone.utc)
|
|
window_end = datetime.fromisoformat(window_end_iso.replace("Z", "+00:00")).astimezone(timezone.utc)
|
|
except ValueError as exc:
|
|
fail("Zeitfenster-ISO ungueltig.", extra={"details": str(exc)})
|
|
|
|
try:
|
|
satellites_json = base64.b64decode(satellites_payload.encode("ascii")).decode("utf-8")
|
|
satellites = json.loads(satellites_json)
|
|
except (ValueError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
fail("satellites_payload ist ungueltig.", extra={"details": str(exc)})
|
|
|
|
try:
|
|
import os as _os
|
|
_sgp4_dir = _os.path.join(SCRIPT_DIR, "sgp4")
|
|
if _sgp4_dir not in sys.path:
|
|
sys.path.insert(0, _sgp4_dir)
|
|
from sgp4.api import Satrec
|
|
except ImportError as exc:
|
|
fail("sgp4-Bibliothek konnte nicht importiert werden.", extra={"details": str(exc)})
|
|
|
|
lat_rad = _math.radians(latitude)
|
|
lon_rad = _math.radians(longitude)
|
|
elev_km = elevation_m / 1000.0
|
|
obs_ecef = _observer_ecef(lat_rad, lon_rad, elev_km)
|
|
|
|
SCAN_STEP = timedelta(seconds=30)
|
|
TRAJ_STEP = timedelta(seconds=5)
|
|
COLORS = ['#f0d990', '#4fc3d8', '#ff9f68', '#9ad77d', '#c7b0ff',
|
|
'#ffd166', '#7bdff2', '#ff7b9c', '#b8f2e6', '#f7a072']
|
|
|
|
results = []
|
|
|
|
for idx, sat_item in enumerate(satellites):
|
|
tle1 = str(sat_item.get("tle_line1", ""))
|
|
tle2 = str(sat_item.get("tle_line2", ""))
|
|
if not tle1 or not tle2:
|
|
continue
|
|
try:
|
|
satrec = Satrec.twoline2rv(tle1, tle2)
|
|
except Exception:
|
|
continue
|
|
|
|
color = COLORS[idx % len(COLORS)]
|
|
passes = []
|
|
|
|
# ── Phase 1: coarse scan to find pass windows ──
|
|
current = window_start
|
|
prev_el = None
|
|
in_pass = False
|
|
aos_time = None
|
|
aos_az = None
|
|
max_el = -999.0
|
|
max_el_time = None
|
|
prev_time = None
|
|
|
|
while current <= window_end:
|
|
jd, fr = _jday_from_dt(current)
|
|
e, r, _ = satrec.sgp4(jd, fr)
|
|
if e != 0:
|
|
current += SCAN_STEP
|
|
prev_el = None
|
|
continue
|
|
gmst = _gmst_rad(jd + fr)
|
|
r_ecef = _eci_to_ecef(r, gmst)
|
|
el, az, _ = _ecef_to_altaz(r_ecef, obs_ecef, lat_rad, lon_rad)
|
|
|
|
if prev_el is not None:
|
|
was_above = prev_el >= 0
|
|
is_above = el >= 0
|
|
|
|
if not in_pass and not was_above and is_above:
|
|
aos_time = _bisect_crossing(satrec, obs_ecef, lat_rad, lon_rad, prev_time, current, True)
|
|
jd2, fr2 = _jday_from_dt(aos_time)
|
|
e2, r2, _ = satrec.sgp4(jd2, fr2)
|
|
if e2 == 0:
|
|
gmst2 = _gmst_rad(jd2 + fr2)
|
|
r_ecef2 = _eci_to_ecef(r2, gmst2)
|
|
_, aos_az_v, _ = _ecef_to_altaz(r_ecef2, obs_ecef, lat_rad, lon_rad)
|
|
aos_az = aos_az_v
|
|
in_pass = True
|
|
max_el = el
|
|
max_el_time = current
|
|
|
|
if in_pass:
|
|
if el > max_el:
|
|
max_el = el
|
|
max_el_time = current
|
|
|
|
if was_above and not is_above:
|
|
los_time = _bisect_crossing(satrec, obs_ecef, lat_rad, lon_rad, prev_time, current, False)
|
|
jd3, fr3 = _jday_from_dt(los_time)
|
|
e3, r3, _ = satrec.sgp4(jd3, fr3)
|
|
los_az = None
|
|
if e3 == 0:
|
|
gmst3 = _gmst_rad(jd3 + fr3)
|
|
r_ecef3 = _eci_to_ecef(r3, gmst3)
|
|
_, los_az_v, _ = _ecef_to_altaz(r_ecef3, obs_ecef, lat_rad, lon_rad)
|
|
los_az = los_az_v
|
|
|
|
# ── Phase 2: fine trajectory (5s steps) ──
|
|
path = []
|
|
shadow_entry = None
|
|
t = aos_time
|
|
while t <= los_time + timedelta(seconds=5):
|
|
jd4, fr4 = _jday_from_dt(t)
|
|
e4, r4, _ = satrec.sgp4(jd4, fr4)
|
|
if e4 == 0:
|
|
gmst4 = _gmst_rad(jd4 + fr4)
|
|
r_ecef4 = _eci_to_ecef(r4, gmst4)
|
|
el4, az4, _ = _ecef_to_altaz(r_ecef4, obs_ecef, lat_rad, lon_rad)
|
|
if el4 >= -1:
|
|
sun_km = _sun_eci_km(t)
|
|
shad = _in_shadow(r4, sun_km)
|
|
path.append({
|
|
"t": t.isoformat().replace("+00:00", "Z"),
|
|
"az": round(az4, 2),
|
|
"el": round(el4, 2),
|
|
"shadow": shad,
|
|
})
|
|
t += TRAJ_STEP
|
|
|
|
# Find shadow entry in path
|
|
for pi in range(1, len(path)):
|
|
if not path[pi-1]["shadow"] and path[pi]["shadow"] and path[pi]["el"] >= 0:
|
|
shadow_entry = {
|
|
"t": path[pi]["t"],
|
|
"az": path[pi]["az"],
|
|
"el": path[pi]["el"],
|
|
}
|
|
break
|
|
|
|
# Refine peak
|
|
peak_time = max_el_time
|
|
peak_el = max_el
|
|
peak_az = None
|
|
for pt in path:
|
|
if pt["el"] > peak_el:
|
|
peak_el = pt["el"]
|
|
peak_time = datetime.fromisoformat(pt["t"].replace("Z", "+00:00"))
|
|
peak_az = pt["az"]
|
|
if peak_az is None and path:
|
|
mid_pt = path[len(path)//2]
|
|
peak_az = mid_pt["az"]
|
|
|
|
# Magnitude at peak
|
|
peak_mag = None
|
|
peak_sun_altitude_deg = None
|
|
if peak_time:
|
|
jd5, fr5 = _jday_from_dt(peak_time)
|
|
e5, r5, _ = satrec.sgp4(jd5, fr5)
|
|
if e5 == 0:
|
|
sun5 = _sun_eci_km(peak_time)
|
|
peak_mag = _apparent_magnitude(
|
|
sat_item.get("std_mag"),
|
|
sat_item.get("rcs"),
|
|
sat_item.get("rcs_size"),
|
|
r5, sun5, obs_ecef, lat_rad, lon_rad,
|
|
_gmst_rad(jd5 + fr5)
|
|
)
|
|
peak_sun_altitude_deg = body_altitude_deg(
|
|
astronomy.Body.Sun,
|
|
astronomy.Observer(latitude, longitude, elevation_m),
|
|
peak_time,
|
|
)
|
|
|
|
duration_s = int(round((los_time - aos_time).total_seconds()))
|
|
local_tz = tz
|
|
|
|
def _iso(dt):
|
|
if dt is None:
|
|
return None
|
|
return dt.isoformat().replace("+00:00", "Z")
|
|
|
|
passes.append({
|
|
"aos_utc": _iso(aos_time),
|
|
"los_utc": _iso(los_time),
|
|
"peak_utc": _iso(peak_time),
|
|
"aos_az": round(aos_az, 2) if aos_az is not None else None,
|
|
"los_az": round(los_az, 2) if los_az is not None else None,
|
|
"peak_az": round(peak_az, 2) if peak_az is not None else None,
|
|
"peak_el": round(peak_el, 2),
|
|
"peak_sun_altitude_deg": round(float(peak_sun_altitude_deg), 2) if peak_sun_altitude_deg is not None else None,
|
|
"duration_s": duration_s,
|
|
"path": path,
|
|
"shadow_entry": shadow_entry,
|
|
"peak_mag": peak_mag,
|
|
})
|
|
|
|
in_pass = False
|
|
aos_time = None
|
|
max_el = -999.0
|
|
max_el_time = None
|
|
|
|
prev_el = el
|
|
prev_time = current
|
|
current += SCAN_STEP
|
|
|
|
results.append({
|
|
"id": sat_item.get("id"),
|
|
"norad_cat_id": sat_item.get("norad_cat_id"),
|
|
"name": sat_item.get("object_name", ""),
|
|
"is_favorite": bool(sat_item.get("is_favorite", False)),
|
|
"color": color,
|
|
"passes": passes,
|
|
})
|
|
|
|
return {
|
|
"ok": True,
|
|
"action": "satellite_passes",
|
|
"observer": {"latitude": latitude, "longitude": longitude, "elevation": elevation_m},
|
|
"timezone": timezone_name,
|
|
"window_start": window_start.isoformat().replace("+00:00", "Z"),
|
|
"window_end": window_end.isoformat().replace("+00:00", "Z"),
|
|
"results": results,
|
|
}
|
|
|
|
|
|
def main() -> None:
|
|
if len(sys.argv) < 2:
|
|
fail("Es wurde keine Aktion uebergeben.", extra={"available_actions": ["sun_moon_rise_set", "solar_longitude_to_datetime", "current_solar_longitude", "astronomical_conversions", "comet_brightnesses", "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", "moon_star_occultations_for_month"]})
|
|
|
|
action = sys.argv[1]
|
|
args = sys.argv[2:]
|
|
|
|
if action == "sun_moon_rise_set":
|
|
result = action_sun_moon_rise_set(args)
|
|
print(json.dumps(result, ensure_ascii=True))
|
|
return
|
|
|
|
if action == "solar_longitude_to_datetime":
|
|
result = action_solar_longitude_to_datetime(args)
|
|
print(json.dumps(result, ensure_ascii=True))
|
|
return
|
|
|
|
if action == "current_solar_longitude":
|
|
result = action_current_solar_longitude(args)
|
|
print(json.dumps(result, ensure_ascii=True))
|
|
return
|
|
|
|
if action == "astronomical_conversions":
|
|
result = action_astronomical_conversions(args)
|
|
print(json.dumps(result, ensure_ascii=True))
|
|
return
|
|
|
|
if action == "comet_brightnesses":
|
|
result = action_comet_brightnesses(args)
|
|
print(json.dumps(result, ensure_ascii=True))
|
|
return
|
|
|
|
if action == "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 == "moon_star_occultations_for_month":
|
|
result = action_moon_star_occultations_for_month(args)
|
|
print(json.dumps(result, ensure_ascii=True))
|
|
return
|
|
|
|
if action == "lunar_eclipses_for_year":
|
|
result = action_lunar_eclipses_for_year(args)
|
|
print(json.dumps(result, ensure_ascii=True))
|
|
return
|
|
|
|
fail("Unbekannte Aktion.", extra={"action": action, "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", "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", "moon_star_occultations_for_month"]})
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|