#!/usr/bin/env python3 """ season_info.py — output shell variables describing the current (or given) date's astronomical season and movement within that season. Season boundaries are the actual equinoxes/solstices computed via the Jean Meeus approximation (Astronomical Algorithms, Ch. 27 Table 27.a) and converted to the LOCAL timezone so the calendar date matches what the camera sees on the ground. DST transitions and year-to-year variation in the exact equinox moment are both handled correctly. Usage: eval "$(python3 season_info.py [YYYY-MM-DD] [Timezone])" Arguments (both optional): YYYY-MM-DD Date to evaluate; defaults to today. Timezone IANA timezone name, e.g. America/New_York. Can also be set via the TIMEZONE environment variable. Defaults to America/New_York (matches the sunrise scripts). Outputs (shell-sourceable): SEASON Spring | Summer | Autumn | Winter MVT_NUM 1 | 2 | 3 DAY_OF_MVT day number within the movement (1-based) DAYS_IN_MVT total days in this movement MVT_START YYYY-MM-DD of movement start (local date) MVT_END YYYY-MM-DD of movement end (local date) SEASON_START YYYY-MM-DD of season start (local date) SEASON_END YYYY-MM-DD of season end (local date) IS_LAST_DAY true | false ASTRO_YEAR year when the current cycle's Winter solstice occurred (Spring/Summer/Autumn of 2026 all return 2025 if Winter started Dec 2025; Winter 2025 itself also returns 2025) """ import sys import os import datetime DEFAULT_TZ = "America/New_York" # --------------------------------------------------------------------------- # Timezone helpers — prefer pytz (already installed for sunrise.py), # fall back to zoneinfo (Python ≥ 3.9), then to UTC with a warning. # --------------------------------------------------------------------------- def _load_tz(name: str): try: import pytz return pytz.timezone(name) except ImportError: pass try: from zoneinfo import ZoneInfo return ZoneInfo(name) except ImportError: pass print(f"WARNING: neither pytz nor zoneinfo available; using UTC instead of {name}", file=sys.stderr) return datetime.timezone.utc def _utc_datetime_from_jde(jde: float) -> datetime.datetime: """Convert Julian Day Number to a UTC datetime (including time of day).""" jde_s = jde + 0.5 # shift so integer part = calendar day Z = int(jde_s) F = jde_s - Z # fractional day = fraction of 24 h after midnight if Z >= 2299161: alpha = int((Z - 1867216.25) / 36524.25) A = Z + 1 + alpha - alpha // 4 else: A = Z B = A + 1524 C = int((B - 122.1) / 365.25) D = int(365.25 * C) E = int((B - D) / 30.6001) day_frac = B - D - int(30.6001 * E) + F day = int(day_frac) frac_hours = (day_frac - day) * 24 hour = int(frac_hours) frac_mins = (frac_hours - hour) * 60 minute = int(frac_mins) second = int((frac_mins - minute) * 60) month = E - 1 if E < 14 else E - 13 year = C - 4716 if month > 2 else C - 4715 return datetime.datetime(year, month, day, hour, minute, second, tzinfo=datetime.timezone.utc) def _season_starts_local(year: int, tz) -> dict: """ Return the LOCAL calendar date (not UTC date) of each equinox/solstice for the given year. 'tz' is a pytz/zoneinfo timezone object. """ y = (year - 2000) / 1000.0 jdes = { "Spring": 2451623.80984 + 365242.37404*y + 0.05169*y**2 - 0.00411*y**3 - 0.00057*y**4, "Summer": 2451716.56767 + 365241.62603*y + 0.00325*y**2 + 0.00888*y**3 - 0.00030*y**4, "Autumn": 2451810.21715 + 365242.01767*y - 0.11575*y**2 + 0.00337*y**3 + 0.00078*y**4, "Winter": 2451900.05952 + 365242.74049*y - 0.06223*y**2 - 0.00823*y**3 + 0.00032*y**4, } result = {} for name, jde in jdes.items(): utc_dt = _utc_datetime_from_jde(jde) local_dt = utc_dt.astimezone(tz) result[name] = local_dt.date() return result def get_info(date: datetime.date, tz_name: str = DEFAULT_TZ) -> dict: tz = _load_tz(tz_name) year = date.year cur = _season_starts_local(year, tz) prev = _season_starts_local(year - 1, tz) nxt = _season_starts_local(year + 1, tz) # Determine season and its inclusive start/end dates. # Winter straddles the year boundary (Dec solstice → next Mar equinox). if date < cur["Spring"]: season, s_start, s_end = "Winter", prev["Winter"], cur["Spring"] - datetime.timedelta(days=1) elif date < cur["Summer"]: season, s_start, s_end = "Spring", cur["Spring"], cur["Summer"] - datetime.timedelta(days=1) elif date < cur["Autumn"]: season, s_start, s_end = "Summer", cur["Summer"], cur["Autumn"] - datetime.timedelta(days=1) elif date < cur["Winter"]: season, s_start, s_end = "Autumn", cur["Autumn"], cur["Winter"] - datetime.timedelta(days=1) else: season, s_start, s_end = "Winter", cur["Winter"], nxt["Spring"] - datetime.timedelta(days=1) days_in_season = (s_end - s_start).days + 1 # Split season into 3 movements. Mvt 1 and 2 get floor(n/3) days; # Mvt 3 takes the remainder so no days are lost to integer rounding. m1 = days_in_season // 3 m2 = days_in_season // 3 m3 = days_in_season - m1 - m2 m1_end = s_start + datetime.timedelta(days=m1 - 1) m2_end = m1_end + datetime.timedelta(days=m2) m3_end = s_end if date <= m1_end: mvt, mvt_start, mvt_end, days_in_mvt = 1, s_start, m1_end, m1 elif date <= m2_end: mvt, mvt_start, mvt_end, days_in_mvt = 2, m1_end + datetime.timedelta(1), m2_end, m2 else: mvt, mvt_start, mvt_end, days_in_mvt = 3, m2_end + datetime.timedelta(1), m3_end, m3 # Astronomical year: the calendar year in which the current cycle's Winter # solstice occurred. Spring/Summer/Autumn belong to the Winter that preceded # them; Winter belongs to the solstice that started it. astro_year = s_start.year if season == "Winter" else prev["Winter"].year return { "SEASON": season, "MVT_NUM": mvt, "DAY_OF_MVT": (date - mvt_start).days + 1, "DAYS_IN_MVT": days_in_mvt, "MVT_START": mvt_start.isoformat(), "MVT_END": mvt_end.isoformat(), "SEASON_START": s_start.isoformat(), "SEASON_END": s_end.isoformat(), "IS_LAST_DAY": str(date == mvt_end).lower(), "ASTRO_YEAR": str(astro_year), } if __name__ == "__main__": args = [a for a in sys.argv[1:] if not a.startswith("-")] date_s = args[0] if len(args) > 0 else None tz_name = args[1] if len(args) > 1 else os.environ.get("TIMEZONE", DEFAULT_TZ) date = datetime.date.fromisoformat(date_s) if date_s else datetime.date.today() for k, v in get_info(date, tz_name).items(): print(f'{k}="{v}"')