Files
jardin/backend/app/services/lunar.py
2026-02-22 18:34:50 +01:00

159 lines
5.3 KiB
Python

from __future__ import annotations
from dataclasses import dataclass, asdict
from datetime import date, datetime, timedelta
import math
from pathlib import Path
import pytz
from skyfield.api import load
from skyfield import almanac
TZ = pytz.timezone("Europe/Paris")
SIGN_NAMES = [
"Bélier", "Taureau", "Gémeaux", "Cancer", "Lion", "Vierge",
"Balance", "Scorpion", "Sagittaire", "Capricorne", "Verseau", "Poissons",
]
SIGN_TO_TYPE = {
"Taureau": "Racine", "Vierge": "Racine", "Capricorne": "Racine",
"Cancer": "Feuille", "Scorpion": "Feuille", "Poissons": "Feuille",
"Gémeaux": "Fleur", "Balance": "Fleur", "Verseau": "Fleur",
"Bélier": "Fruit", "Lion": "Fruit", "Sagittaire": "Fruit",
}
SAINTS_BY_MMDD = {
"04-23": "Saint Georges",
"04-25": "Saint Marc",
"05-11": "Saint Mamert",
"05-12": "Saint Pancrace",
"05-13": "Saint Servais",
"05-14": "Saint Boniface",
"05-19": "Saint Yves",
"05-25": "Saint Urbain",
}
@dataclass
class DayInfo:
date: str
phase: str
illumination: float
croissante_decroissante: str
montante_descendante: str
signe: str
type_jour: str
saint_du_jour: str
perigee: bool
apogee: bool
noeud_lunaire: bool
def zodiac_sign_from_lon(lon_deg: float) -> str:
return SIGN_NAMES[int(lon_deg // 30) % 12]
def _local_noon(d: date) -> datetime:
return TZ.localize(datetime(d.year, d.month, d.day, 12, 0, 0))
def _compute_perigee_apogee_days(
ts, earth, moon, start: date, end: date
) -> tuple[set[date], set[date]]:
sample_start = datetime.combine(start - timedelta(days=1), datetime.min.time())
sample_end = datetime.combine(
end + timedelta(days=1), datetime.max.time().replace(microsecond=0)
)
samples = []
current = TZ.localize(sample_start)
end_local = TZ.localize(sample_end)
step = timedelta(hours=1)
while current <= end_local:
t = ts.utc(current.astimezone(pytz.utc))
dist_km = earth.at(t).observe(moon).distance().km
samples.append((current.date(), dist_km))
current += step
perigee_days, apogee_days = set(), set()
for i in range(1, len(samples) - 1):
day, dist = samples[i]
if not (start <= day <= end):
continue
prev_dist = samples[i - 1][1]
next_dist = samples[i + 1][1]
if dist < prev_dist and dist < next_dist:
perigee_days.add(day)
if dist > prev_dist and dist > next_dist:
apogee_days.add(day)
return perigee_days, apogee_days
def build_calendar(start: date, end: date) -> list[DayInfo]:
if end < start:
raise ValueError(f"Invalid date range: {start} > {end}")
ts = load.timescale()
eph = load("de421.bsp")
earth, moon, sun = eph["earth"], eph["moon"], eph["sun"]
t0 = ts.utc(start.year, start.month, start.day)
t1 = ts.utc(end.year, end.month, end.day + 1)
f_phase = almanac.moon_phases(eph)
phase_times, phase_events = almanac.find_discrete(t0, t1, f_phase)
phase_by_day = {}
for t, ev in zip(phase_times, phase_events):
local_day = (
t.utc_datetime().replace(tzinfo=pytz.utc).astimezone(TZ).date()
)
phase_by_day[local_day] = [
"Nouvelle Lune", "Premier Quartier", "Pleine Lune", "Dernier Quartier"
][int(ev)]
f_nodes = almanac.moon_nodes(eph)
node_times, _ = almanac.find_discrete(t0, t1, f_nodes)
node_days = set()
for t in node_times:
local_day = (
t.utc_datetime().replace(tzinfo=pytz.utc).astimezone(TZ).date()
)
node_days.add(local_day)
perigee_days, apogee_days = _compute_perigee_apogee_days(
ts, earth, moon, start, end
)
result = []
d = start
while d <= end:
local_noon = _local_noon(d)
t = ts.utc(local_noon.astimezone(pytz.utc))
e = earth.at(t)
v_sun = e.observe(sun).apparent()
v_moon = e.observe(moon).apparent()
sep = v_sun.separation_from(v_moon).radians
illum = (1 - math.cos(sep)) / 2
d2 = d + timedelta(days=1)
local_noon2 = _local_noon(d2)
t2 = ts.utc(local_noon2.astimezone(pytz.utc))
e2 = earth.at(t2)
v_sun2 = e2.observe(sun).apparent()
v_moon2 = e2.observe(moon).apparent()
sep2 = v_sun2.separation_from(v_moon2).radians
illum2 = (1 - math.cos(sep2)) / 2
croissante = "Croissante" if illum2 >= illum else "Décroissante"
dec = v_moon.radec()[1].degrees
dec2 = v_moon2.radec()[1].degrees
montante = "Montante" if dec2 >= dec else "Descendante"
lat, lon, dist = v_moon.ecliptic_latlon()
signe = zodiac_sign_from_lon(lon.degrees % 360.0)
type_jour = SIGN_TO_TYPE[signe]
saint_du_jour = SAINTS_BY_MMDD.get(d.strftime("%m-%d"), "")
result.append(
DayInfo(
date=d.isoformat(),
phase=phase_by_day.get(d, ""),
illumination=round(illum * 100, 2),
croissante_decroissante=croissante,
montante_descendante=montante,
signe=signe,
type_jour=type_jour,
saint_du_jour=saint_du_jour,
perigee=(d in perigee_days),
apogee=(d in apogee_days),
noeud_lunaire=(d in node_days),
)
)
d += timedelta(days=1)
return result