- chmod +x all *.sh and *.py in install.sh so permissions survive git checkouts - Fix daily_sunrise_video.sh and Python scripts missing executable bit - Add [CAM_NAME] prefix to all 4-seasons.sh notify.sh calls so multi-camera setups show which camera each daily clip came from - Include camera name in output filename: YYYY-MM-DD_<cam>_MvtN-DayXofY-final.mp4 https://claude.ai/code/session_01C4jbd3waXG3eKZYbGUjLUQ
182 lines
7.0 KiB
Python
Executable File
182 lines
7.0 KiB
Python
Executable File
#!/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}"')
|