Files
sky-cam/sunrise.py
Claude 63bbe5c2ab Fix sunrise wait windows on DST change days
daily_sunrise_video.sh and sunrise-audio-capture.sh added wall-clock
seconds-since-midnight to a midnight epoch, which is 3600s off on the two
days a year the clocks change. Resolve targets with date -d "today HH:MM:SS"
instead. sunrise.py now builds local midnight with tz.localize().

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wi3ZRpfenCe2TRwT4JFMG
2026-11-02 01:21:57 +00:00

97 lines
3.4 KiB
Python
Executable File

#!/usr/bin/env python3
"""sunrise.py — compute today's local sunrise time using skyfield.
Outputs: YYYY-MM-DD HH:MM:SS (local time, no timezone suffix)
Exits non-zero with a message on stderr if the sun does not rise today
(polar night or midnight sun).
Reads LATITUDE, LONGITUDE, TIMEZONE from sky-cam.conf / .env.
Requires skyfield and the de421.bsp ephemeris alongside this script
(skyfield downloads de421.bsp automatically on first use if absent).
"""
from datetime import datetime, timezone, timedelta
import pathlib
import re
import pytz
_here = pathlib.Path(__file__).resolve().parent
def _read_conf(path):
conf = {}
try:
with open(path) as f:
for line in f:
line = line.strip()
if not line or line.startswith('#') or '=' not in line:
continue
k, v = line.split('=', 1)
k = k.strip()
v = v.strip()
v = re.sub(r'\s+#.*$', '', v) # strip inline comments
v = v.strip('"').strip("'")
# Extract default from bash ${VAR:-default} pattern
m = re.match(r'^\$\{[^}]+:-([^}]*)\}$', v)
if m:
v = m.group(1).strip('"').strip("'")
conf[k] = v
except FileNotFoundError:
pass
return conf
conf = _read_conf(_here / 'sky-cam.conf')
# .env overrides sky-cam.conf — mirrors the sourcing order at the bottom of sky-cam.conf
conf.update(_read_conf(_here / '.env'))
for _key in ('LATITUDE', 'LONGITUDE', 'TIMEZONE'):
if not conf.get(_key):
raise SystemExit(f"sunrise.py: {_key} not set in sky-cam.conf or .env")
latitude = float(conf['LATITUDE'])
longitude = float(conf['LONGITUDE'])
tz = pytz.timezone(conf['TIMEZONE'])
# Search the full local calendar day (midnight→midnight) expressed in UTC.
# This bracket is timezone-correct and handles any UTC offset, including large
# positive offsets (UTC+12/+14) where local midnight falls on the previous UTC day.
now_local = datetime.now(tz)
# tz.localize (not .replace) so the UTC offset is re-resolved for midnight —
# on a DST-change day now_local's offset differs from midnight's.
midnight_local = tz.localize(datetime(now_local.year, now_local.month, now_local.day))
t0_utc = midnight_local.astimezone(timezone.utc)
t1_utc = t0_utc + timedelta(hours=25) # 25 h covers any timezone's full day
from skyfield.api import Loader, wgs84
from skyfield.almanac import find_discrete, sunrise_sunset
loader = Loader(str(_here), verbose=False)
ts = loader.timescale()
eph = loader('de421.bsp') # downloaded automatically on first run
topos = wgs84.latlon(latitude, longitude)
t0 = ts.from_datetime(t0_utc)
t1 = ts.from_datetime(t1_utc)
times, events = find_discrete(t0, t1, sunrise_sunset(eph, topos))
# events: True = sunrise, False = sunset — pick the first sunrise on today's date
sunrise_local = None
for t, is_rise in zip(times, events):
if is_rise:
candidate = t.utc_datetime().astimezone(tz)
if candidate.date() == now_local.date():
sunrise_local = candidate
break
if sunrise_local is None:
raise SystemExit(
"sunrise.py: no sunrise found for today "
f"({now_local.date()} at {latitude:.4f}°, {longitude:.4f}°) — "
"polar night or midnight sun?"
)
print(sunrise_local.strftime('%Y-%m-%d %H:%M:%S'))