Files
Claude 2f15ce47c9 sunrise.py: replace suntime with skyfield for accurate sunrise calculation
The suntime library uses a simplified approximation that degrades at high
latitudes as sunrise approaches very early hours near the summer solstice.
The computed sunrise drifted progressively earlier than the actual event,
shrinking the capture window until today it ended before the sun rose.

Switch to skyfield's sunrise_sunset almanac (DE421 ephemeris), which is
accurate to within a second at any latitude — the same library already
used by moon_phase.py for sun events. The search window is anchored to
local midnight→midnight UTC so the correct calendar-day sunrise is
always returned regardless of timezone offset.

Also promote skyfield to the core pip install in bootstrap.sh (dropping
suntime, which is no longer needed) and tidy the stale "suntime" hints
in 4-seasons.sh and montage-mvt.sh error messages.

https://claude.ai/code/session_01S4oUbU9xNj91cbV5bj5NRn
2026-05-14 03:17:45 +00:00

95 lines
3.2 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)
midnight_local = now_local.replace(hour=0, minute=0, second=0, microsecond=0)
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'))