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
97 lines
3.4 KiB
Python
Executable File
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'))
|