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}")