Fix season dates, resolution, attribution, retention policies, montage trigger
season_info.py (new): - Computes actual equinox/solstice dates via Jean Meeus approximation - Converts to LOCAL timezone (pytz / zoneinfo) so the calendar date reflects what the camera sees on the ground, including DST transitions - Timezone defaults to America/New_York; override via $TIMEZONE env or 2nd arg - Divides each season into 3 equal movements; handles Winter's year boundary - Outputs IS_LAST_DAY=true on the final day of each movement 4-seasons.sh: - Uses season_info.py for all date/season logic (no hardcoded day-of-year) - Passes $yesterday to season_info.py so astronomical dates are correct - Removed forced 1280x720 scale; clips stay at native camera resolution - Auto-triggers montage-mvt.sh "$yesterday" on last day of movement so the correct movement (not the next day's) is compiled montage-mvt.sh: - Accepts optional YYYY-MM-DD argument (passed by 4-seasons.sh) so it resolves the correct season/movement when run the morning after last day - Detects source resolution from first daily clip via ffprobe; attribution card is generated at that exact resolution — no resizing of main video - Font sizes proportional to frame height so text looks right at any res - License corrected to CC BY-SA 3.0 (was CC BY-NC-SA 4.0) fullday-video.sh: - Removed 30-minute target; video length is natural (num_images / FPS) - Default FULLDAY_FPS=5 (configurable at top of script) - Retains RETENTION_DAYS=10 auto-delete for full-day videos only - Sunrise 10s uploads and montage videos are never auto-deleted https://claude.ai/code/session_01C4jbd3waXG3eKZYbGUjLUQ
This commit is contained in:
+172
@@ -0,0 +1,172 @@
|
||||
#!/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
|
||||
"""
|
||||
|
||||
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
|
||||
|
||||
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(),
|
||||
}
|
||||
|
||||
|
||||
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}"')
|
||||
Reference in New Issue
Block a user