Files
sky-cam/season_info.py
T
Claude 8617248cc0 Add year-based folders, attribution overlay, notifications, crontab/systemd
season_info.py
- Add ASTRO_YEAR: year of the Winter solstice that started the current cycle
  (Spring/Summer/Autumn 2026 → 2025; Winter Dec-2026 → 2026)

4-seasons.sh
- Daily clip output path now includes ASTRO_YEAR:
  movies/$name/$ASTRO_YEAR/$SEASON/Mvt$N/

montage-mvt.sh (full rewrite)
- Attribution is now a drawtext overlay (top 6 s of the video, fade in/out)
  instead of an appended black card — no frame-size matching needed, and
  year-end join requires no special handling
- Output path mirrors the new year-based folder structure
- Sends notification via notify.sh when done
- Automatically triggers year-end-join.sh after Autumn Mvt 3

year-end-join.sh (new)
- Concatenates all 12 movement montages for a given ASTRO_YEAR
- Output: movies/$name/$ASTRO_YEAR/$name-$ASTRO_YEAR.mp4
- Sends notification when done

notify.sh + notify_config.txt (new)
- Generic best-effort notification helper: ntfy, email, Mattermost text post
- All methods disabled by default; user fills in notify_config.txt

sky-cam.crontab + systemd/ (new)
- Crontab file: sunrise at 09:00, 4-seasons at 01:00, fullday at 02:00
- systemd .service + .timer units for all three daily jobs
- montage/year-end triggered internally, not by cron

https://claude.ai/code/session_01C4jbd3waXG3eKZYbGUjLUQ
2026-04-18 13:59:48 +00:00

182 lines
7.0 KiB
Python

#!/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}"')