Files
jardin/calendrier_lunaire/lunar_calendar.py
2026-02-22 15:05:40 +01:00

397 lines
13 KiB
Python

from __future__ import annotations
from dataclasses import dataclass, asdict, field
from datetime import date, datetime, timedelta
import math
import json
from pathlib import Path
import pytz
from skyfield.api import load, wgs84, load_constellation_map
from skyfield import almanac
TZ = pytz.timezone("Europe/Paris")
SCRIPT_DIR = Path(__file__).resolve().parent
LATITUDE = 48.8566
LONGITUDE = 2.3522
# --- Mapping "jour racine/feuille/fleur/fruit" ---
# We align with a sidereal approach using the Moon's constellation.
CONSTELLATION_TO_SIGN = {
"Ari": "Bélier",
"Tau": "Taureau",
"Gem": "Gémeaux",
"Cnc": "Cancer",
"Leo": "Lion",
"Vir": "Vierge",
"Lib": "Balance",
"Sco": "Scorpion",
"Sgr": "Sagittaire",
"Cap": "Capricorne",
"Aqr": "Verseau",
"Psc": "Poissons",
# The Moon can cross Ophiuchus in official IAU boundaries.
# We map it to Scorpion for gardening day continuity.
"Oph": "Scorpion",
}
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",
}
@dataclass
class DayInfo:
date: str
phase: str
illumination: float
croissante_decroissante: str
montante_descendante: str
signe: str
type_jour: str
soleil_lever: str
soleil_coucher: str
duree_jour: str
lune_lever: str
lune_coucher: str
duree_presence_lune: str
saint_du_jour: str
saint_de_glace: bool
perigee: bool
apogee: bool
noeud_lunaire: bool
transitions_type_jour: list[dict[str, str]] = field(default_factory=list)
transitions_montante_descendante: list[dict[str, str]] = field(default_factory=list)
def _zodiac_sign_from_constellation(constellation_at, position) -> str:
abbr = constellation_at(position)
return CONSTELLATION_TO_SIGN.get(abbr, "Scorpion")
def _local_noon(d: date) -> datetime:
return TZ.localize(datetime(d.year, d.month, d.day, 12, 0, 0))
def _default_saints_france() -> dict[str, str]:
# Core gardening references in France; full calendar can be provided via saints_france.json.
return {
"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",
}
def _load_saints_france() -> dict[str, str]:
path = SCRIPT_DIR / "saints_dictons" / "saints_france.json"
if not path.exists():
return _default_saints_france()
with path.open("r", encoding="utf-8") as f:
data = json.load(f)
saints: dict[str, str] = {}
for key, value in data.items():
if isinstance(key, str) and isinstance(value, str):
saints[key] = value.strip()
return saints
def _compute_perigee_apogee_days(ts, earth, moon, start: date, end: date) -> tuple[set[date], set[date]]:
# Hourly sampling + one-day padding on each side gives stable local extrema detection.
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: list[tuple[date, float]] = []
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: set[date] = set()
apogee_days: set[date] = 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 _to_local_dt(t) -> datetime:
return t.utc_datetime().replace(tzinfo=pytz.utc).astimezone(TZ)
def _pick_first_event_within_window(
ts,
observer,
target,
start_local: datetime,
end_local: datetime,
event_kind: str,
) -> tuple[datetime | None, int | None]:
if event_kind == "rise":
event_func = almanac.find_risings
else:
event_func = almanac.find_settings
t0 = ts.utc(start_local.astimezone(pytz.utc))
t1 = ts.utc(end_local.astimezone(pytz.utc))
times, flags = event_func(observer, target, t0, t1)
for t, ok in zip(times, flags):
if not ok:
continue
dt_local = _to_local_dt(t)
if start_local <= dt_local < end_local:
day_offset = (dt_local.date() - start_local.date()).days
return dt_local, day_offset
return None, None
def _format_time(dt_local: datetime | None, day_offset: int | None) -> str:
if dt_local is None:
return ""
base = dt_local.strftime("%H:%M")
if day_offset and day_offset > 0:
return f"{base} (+{day_offset}j)"
return base
def _format_duration(start_dt: datetime | None, end_dt: datetime | None) -> str:
if start_dt is None or end_dt is None:
return ""
delta = end_dt - start_dt
if delta.total_seconds() < 0:
return ""
total_minutes = int(round(delta.total_seconds() / 60))
hours, minutes = divmod(total_minutes, 60)
return f"{hours:02d}h{minutes:02d}"
def _moon_type_jour_at(ts, earth, moon, constellation_at, local_dt: datetime) -> str:
t = ts.utc(local_dt.astimezone(pytz.utc))
v_moon = earth.at(t).observe(moon).apparent()
signe = _zodiac_sign_from_constellation(constellation_at, v_moon)
return SIGN_TO_TYPE[signe]
def _moon_montante_descendante_at(ts, earth, moon, local_dt: datetime) -> str:
t = ts.utc(local_dt.astimezone(pytz.utc))
t2 = ts.utc((local_dt + timedelta(minutes=30)).astimezone(pytz.utc))
v_moon = earth.at(t).observe(moon).apparent()
v_moon2 = earth.at(t2).observe(moon).apparent()
dec = v_moon.radec()[1].degrees
dec2 = v_moon2.radec()[1].degrees
return "Montante" if dec2 >= dec else "Descendante"
def _find_transition_time(
value_at,
left_dt: datetime,
right_dt: datetime,
left_value: str,
) -> datetime:
# Binary search at minute precision for the first instant where value changes.
while (right_dt - left_dt) > timedelta(minutes=1):
mid = left_dt + (right_dt - left_dt) / 2
if value_at(mid) == left_value:
left_dt = mid
else:
right_dt = mid
return right_dt.replace(second=0, microsecond=0)
def _compute_daily_transitions(
value_at,
day_start: datetime,
day_end: datetime,
step_minutes: int = 20,
) -> list[dict[str, str]]:
transitions: list[dict[str, str]] = []
step = timedelta(minutes=step_minutes)
t = day_start
current_value = value_at(t)
while t < day_end:
probe = min(t + step, day_end)
probe_value = value_at(probe)
if probe_value != current_value:
transition_dt = _find_transition_time(value_at, t, probe, current_value)
transitions.append(
{
"heure": transition_dt.strftime("%H:%M"),
"avant": current_value,
"apres": probe_value,
}
)
current_value = probe_value
t = probe
return transitions
def build_calendar(start: date, end: date) -> list[DayInfo]:
if end < start:
raise ValueError(f"Invalid date range: start ({start}) is after end ({end}).")
ts = load.timescale()
eph = load("de421.bsp")
constellation_at = load_constellation_map()
saints_by_mmdd = _load_saints_france()
saints_de_glace = {"05-11", "05-12", "05-13", "05-14", "05-25"}
earth, moon, sun = eph["earth"], eph["moon"], eph["sun"]
observer = earth + wgs84.latlon(LATITUDE, LONGITUDE)
t0 = ts.utc(start.year, start.month, start.day)
t1 = ts.utc(end.year, end.month, end.day + 1)
# --- Phases exactes ---
f_phase = almanac.moon_phases(eph)
phase_times, phase_events = almanac.find_discrete(t0, t1, f_phase)
phase_by_day: dict[date, str] = {}
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)]
# --- Nœuds lunaires (instants) ---
f_nodes = almanac.moon_nodes(eph)
node_times, _ = almanac.find_discrete(t0, t1, f_nodes)
node_days: set[date] = set()
for t in node_times:
local_day = t.utc_datetime().replace(tzinfo=pytz.utc).astimezone(TZ).date()
node_days.add(local_day)
# --- Périgée / apogée : calcul manuel via distance Terre->Lune (min/max locaux) ---
perigee_days, apogee_days = _compute_perigee_apogee_days(ts, earth, moon, start, end)
# --- Boucle jour par jour ---
result: list[DayInfo] = []
d = start
while d <= end:
# midi local : stabilise signe du jour + évite bascules UTC
local_noon = _local_noon(d)
local_day_start = TZ.localize(datetime(d.year, d.month, d.day, 0, 0, 0))
local_day_end = local_day_start + timedelta(days=1)
local_moon_window_end = local_day_start + timedelta(days=2)
t = ts.utc(local_noon.astimezone(pytz.utc))
e = earth.at(t)
v_sun = e.observe(sun).apparent()
v_moon = e.observe(moon).apparent()
# illumination (0..1) via séparation soleil-lune
sep = v_sun.separation_from(v_moon).radians
illum = (1 - math.cos(sep)) / 2
# lendemain (pour croissante/décroissante + montante/descendante)
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"
# sidereal sign via Moon constellation
signe = _zodiac_sign_from_constellation(constellation_at, v_moon)
type_jour = SIGN_TO_TYPE[signe]
mmdd = f"{d.month:02d}-{d.day:02d}"
sun_rise_dt, sun_rise_offset = _pick_first_event_within_window(
ts, observer, sun, local_day_start, local_day_end, "rise"
)
sun_set_dt, sun_set_offset = _pick_first_event_within_window(
ts, observer, sun, local_day_start, local_day_end, "set"
)
moon_rise_dt, moon_rise_offset = _pick_first_event_within_window(
ts, observer, moon, local_day_start, local_moon_window_end, "rise"
)
moon_set_dt, moon_set_offset = _pick_first_event_within_window(
ts, observer, moon, local_day_start, local_moon_window_end, "set"
)
transitions_type_jour = _compute_daily_transitions(
lambda dt: _moon_type_jour_at(ts, earth, moon, constellation_at, dt),
local_day_start,
local_day_end,
)
transitions_montante_descendante = _compute_daily_transitions(
lambda dt: _moon_montante_descendante_at(ts, earth, moon, dt),
local_day_start,
local_day_end,
)
result.append(DayInfo(
date=d.isoformat(),
phase=phase_by_day.get(d, ""),
illumination=round(illum * 100.0, 2), # %
croissante_decroissante=croissante,
montante_descendante=montante,
signe=signe,
type_jour=type_jour,
soleil_lever=_format_time(sun_rise_dt, sun_rise_offset),
soleil_coucher=_format_time(sun_set_dt, sun_set_offset),
duree_jour=_format_duration(sun_rise_dt, sun_set_dt),
lune_lever=_format_time(moon_rise_dt, moon_rise_offset),
lune_coucher=_format_time(moon_set_dt, moon_set_offset),
duree_presence_lune=_format_duration(moon_rise_dt, moon_set_dt),
transitions_type_jour=transitions_type_jour,
transitions_montante_descendante=transitions_montante_descendante,
saint_du_jour=saints_by_mmdd.get(mmdd, ""),
saint_de_glace=(mmdd in saints_de_glace),
perigee=(d in perigee_days),
apogee=(d in apogee_days),
noeud_lunaire=(d in node_days),
))
d += timedelta(days=1)
return result
if __name__ == "__main__":
data = build_calendar(date(2026, 1, 1), date(2026, 12, 31))
out_path = Path(__file__).with_name("calendrier_lunaire_2026.json")
with out_path.open("w", encoding="utf-8") as f:
json.dump([asdict(x) for x in data], f, ensure_ascii=False, indent=2)
print(f"Calendrier lunaire généré : {out_path}")