Files
sky-cam/moon_phase.py
T
Claude 975c9cc2eb Use real sunset/sunrise for dark window; pick frame closest to exact phase
moon_phase.py: add sun_events_in_range() — returns actual sunset/sunrise
transitions via skyfield almanac, accurate to within a minute

moon_phase_monthly.py:
- _dark_moon_intervals(): replace sun-altitude threshold sampling with
  actual sunset/sunrise times; dark window = sunset + DARK_START_MIN to
  next sunrise; moon-above-horizon check still sampled every 10 min within
  each night; polar fallback if no sun events found
- run_phase(): sort dark-window frames by proximity to exact phase moment
  before scanning; first clear detection = the frame temporally closest to
  the moon being precisely full/at quarter, not just the first in time order
- Replace DARK_SKY_SUN_ALT_DEG with DARK_START_MIN (default 30 min)

sky-cam.conf: replace MOON_DARK_SKY_SUN_ALT_DEG with MOON_DARK_START_MIN=30;
update comments to explain ±24h window, sunset+30 start, closest-frame logic,
and how the 01:30 and 23:55 edge cases are handled

https://claude.ai/code/session_01HkTxpNSTWtViZzxbrbKytR
2026-05-01 22:38:04 +00:00

298 lines
10 KiB
Python
Executable File

#!/usr/bin/env python3
"""moon_phase.py — moon ephemeris lookups for sky-cam.
Provides:
- full_moons_in_range(start, end) list of UTC datetimes of full moons
- nearest_full_moon(when) UTC datetime of full moon nearest 'when'
- illumination(when) 0.0..1.0 fraction lit
- phase_angle(when) degrees, 0=full, 180=new
- waxing(when) True if moon is waxing
- altaz(when, lat, lon) (altitude_deg, azimuth_deg)
- parallactic_angle(when, lat, lon) degrees, rotation to put celestial north up
Used as a library by other scripts and as a CLI:
python3 moon_phase.py next-full # next full moon UTC + local
python3 moon_phase.py altaz <ISO-UTC> # alt/az from configured location
python3 moon_phase.py info <ISO-UTC> # everything for one timestamp
All times assume UTC unless tagged otherwise. Local timezone comes from
sky-cam.conf TIMEZONE for display only.
"""
from __future__ import annotations
import math
import os
import pathlib
import re
import sys
from datetime import datetime, timedelta, timezone
_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)
v = v.strip('"').strip("'")
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')
_conf.update(_read_conf(_here / '.env'))
LATITUDE = float(_conf.get('LATITUDE') or 0.0)
LONGITUDE = float(_conf.get('LONGITUDE') or 0.0)
TIMEZONE = _conf.get('TIMEZONE', 'UTC')
# Cached ephemeris — bootstrap.sh pre-downloads de421.bsp into SCRIPT_DIR so
# this never needs to hit the network at run time.
_EPH_PATH = _here / 'de421.bsp'
_ts = None
_eph = None
_observer = None
def _lazy():
"""Defer skyfield import + ephemeris load until first use.
Lets the module be imported by tests and by --help paths even when
skyfield is missing or the ephemeris hasn't been downloaded yet.
"""
global _ts, _eph, _observer
if _ts is not None:
return
from skyfield.api import Loader, wgs84
loader = Loader(str(_here), verbose=False)
_ts = loader.timescale()
if _EPH_PATH.exists():
_eph = loader('de421.bsp')
else:
_eph = loader('de421.bsp') # downloads on first run
_observer = _eph['earth'] + wgs84.latlon(LATITUDE, LONGITUDE)
def _to_utc(when: datetime) -> datetime:
if when.tzinfo is None:
return when.replace(tzinfo=timezone.utc)
return when.astimezone(timezone.utc)
def _t(when: datetime):
_lazy()
w = _to_utc(when)
return _ts.from_datetime(w)
def full_moons_in_range(start: datetime, end: datetime) -> list[datetime]:
"""Return UTC datetimes of every full moon between start and end (inclusive)."""
return phase_events_in_range(start, end, 2)
def phase_events_in_range(start: datetime, end: datetime, phase_index: int) -> list[datetime]:
"""Return UTC datetimes of every occurrence of `phase_index` between start and end.
skyfield phase indices: 0 = new, 1 = first quarter, 2 = full, 3 = last quarter.
"""
_lazy()
from skyfield.almanac import find_discrete, moon_phases
t0 = _t(start)
t1 = _t(end)
times, phases = find_discrete(t0, t1, moon_phases(_eph))
return [t.utc_datetime() for t, p in zip(times, phases) if p == phase_index]
def nearest_full_moon(when: datetime) -> datetime:
"""Full moon UTC nearest 'when' — searches a 45-day window centred on it."""
w = _to_utc(when)
candidates = full_moons_in_range(w - timedelta(days=45), w + timedelta(days=45))
return min(candidates, key=lambda d: abs(d - w))
def illumination(when: datetime) -> float:
"""Fraction of the moon's disk that is illuminated (0..1)."""
_lazy()
from skyfield.almanac import fraction_illuminated
return float(fraction_illuminated(_eph, 'moon', _t(when)))
def phase_angle(when: datetime) -> float:
"""Sun-Moon-Earth phase angle in degrees: 0=full, 90=quarter, 180=new."""
_lazy()
earth = _eph['earth']
sun = _eph['sun']
moon = _eph['moon']
t = _t(when)
e = earth.at(t)
s_from_moon = (sun - moon).at(t)
e_from_moon = (e.position.au - moon.at(t).position.au)
# angle between sun→moon→earth
a = s_from_moon.position.au
b = e_from_moon
cosang = (a[0]*b[0] + a[1]*b[1] + a[2]*b[2]) / (
math.sqrt(a[0]**2 + a[1]**2 + a[2]**2)
* math.sqrt(b[0]**2 + b[1]**2 + b[2]**2)
)
cosang = max(-1.0, min(1.0, cosang))
return math.degrees(math.acos(cosang))
def waxing(when: datetime) -> bool:
"""True if moon is waxing (illumination growing)."""
now = illumination(when)
later = illumination(when + timedelta(hours=6))
return later > now
def altaz(when: datetime, lat: float | None = None, lon: float | None = None):
"""Apparent altitude/azimuth in degrees as seen from (lat, lon).
Falls back to configured LATITUDE/LONGITUDE if not specified.
"""
_lazy()
from skyfield.api import wgs84
if lat is None and lon is None:
obs = _observer
else:
obs = _eph['earth'] + wgs84.latlon(
LATITUDE if lat is None else lat,
LONGITUDE if lon is None else lon,
)
t = _t(when)
alt, az, _ = obs.at(t).observe(_eph['moon']).apparent().altaz()
return float(alt.degrees), float(az.degrees)
def sun_events_in_range(
search_start: datetime,
search_end: datetime,
) -> list[tuple[datetime, bool]]:
"""Return every sunrise/sunset transition between search_start and search_end.
Each item is (utc_datetime, is_rise): is_rise=True for sunrise, False for sunset.
Uses skyfield's almanac — accurate to within a minute at the configured location.
"""
_lazy()
from skyfield.almanac import find_discrete, sunrise_sunset
t0 = _t(search_start)
t1 = _t(search_end)
times, values = find_discrete(t0, t1, sunrise_sunset(_eph, _observer))
return [(t.utc_datetime(), bool(v)) for t, v in zip(times, values)]
def sun_altaz(when: datetime, lat: float | None = None, lon: float | None = None):
"""Apparent altitude/azimuth of the sun in degrees as seen from (lat, lon)."""
_lazy()
from skyfield.api import wgs84
if lat is None and lon is None:
obs = _observer
else:
obs = _eph['earth'] + wgs84.latlon(
LATITUDE if lat is None else lat,
LONGITUDE if lon is None else lon,
)
t = _t(when)
alt, az, _ = obs.at(t).observe(_eph['sun']).apparent().altaz()
return float(alt.degrees), float(az.degrees)
def parallactic_angle(when: datetime, lat: float | None = None, lon: float | None = None) -> float:
"""Parallactic angle in degrees — rotates a moon image so celestial north is up.
sin(q) = sin(H) * cos(lat) / cos(alt)
where H is the hour angle and alt is the altitude.
Returned value is the angle to rotate the lunar texture clockwise (in the
image sense, y-down) so celestial north points up in the camera frame.
"""
_lazy()
from skyfield.api import wgs84
if lat is None:
lat = LATITUDE
if lon is None:
lon = LONGITUDE
obs = _eph['earth'] + wgs84.latlon(lat, lon)
t = _t(when)
apparent = obs.at(t).observe(_eph['moon']).apparent()
alt, _, _ = apparent.altaz()
ra, dec, _ = apparent.radec(epoch='date')
# local sidereal time at observer's longitude → hour angle
lst = t.gast * 15.0 + lon # gast in hours → degrees, plus longitude
H = math.radians(lst - ra._degrees)
phi = math.radians(lat)
delta = math.radians(dec.degrees)
# Standard parallactic angle formula
q = math.atan2(math.sin(H), math.tan(phi) * math.cos(delta) - math.sin(delta) * math.cos(H))
return math.degrees(q)
def _format_local(dt_utc: datetime) -> str:
try:
import pytz
tz = pytz.timezone(TIMEZONE)
return dt_utc.astimezone(tz).strftime('%Y-%m-%d %H:%M:%S %Z')
except Exception:
return dt_utc.strftime('%Y-%m-%d %H:%M:%S UTC')
def _cli():
if len(sys.argv) < 2:
print(__doc__.strip())
return 1
cmd = sys.argv[1]
if cmd == 'next-full':
now = datetime.now(timezone.utc)
fm = full_moons_in_range(now, now + timedelta(days=45))
if not fm:
print('No full moon found in the next 45 days?', file=sys.stderr)
return 1
next_fm = fm[0]
print(f'next_full_moon_utc={next_fm.strftime("%Y-%m-%dT%H:%M:%SZ")}')
print(f'next_full_moon_local={_format_local(next_fm)}')
return 0
if cmd == 'nearest-full':
when = datetime.fromisoformat(sys.argv[2].replace('Z', '+00:00'))
fm = nearest_full_moon(when)
print(f'nearest_full_moon_utc={fm.strftime("%Y-%m-%dT%H:%M:%SZ")}')
return 0
if cmd == 'altaz':
when = datetime.fromisoformat(sys.argv[2].replace('Z', '+00:00'))
alt, az = altaz(when)
print(f'altitude_deg={alt:.3f}')
print(f'azimuth_deg={az:.3f}')
return 0
if cmd == 'info':
when = datetime.fromisoformat(sys.argv[2].replace('Z', '+00:00'))
alt, az = altaz(when)
print(f'utc={when.strftime("%Y-%m-%dT%H:%M:%SZ")}')
print(f'local={_format_local(when)}')
print(f'illumination={illumination(when):.4f}')
print(f'phase_angle_deg={phase_angle(when):.2f}')
print(f'waxing={waxing(when)}')
print(f'altitude_deg={alt:.3f}')
print(f'azimuth_deg={az:.3f}')
print(f'parallactic_angle_deg={parallactic_angle(when):.3f}')
return 0
print(f'unknown command: {cmd}', file=sys.stderr)
return 1
if __name__ == '__main__':
sys.exit(_cli())