Files
sky-cam/moon_phase.py
T
Claude 5fb7ff09a3 Add nightly moon-track timelapse and monthly moon-phase composites
Two new jobs operate on the SUNRISE_CAM, sharing three Python helpers
(moon_phase, moon_detect, moon_composite) and one cached lunar texture:

- moon-track.sh: nightly batch detects the moon in each east frame,
  crops a 480x480 box around it, stitches into an mp4 that holds the
  moon roughly centred while clouds and stars drift past.

- moon-phase-monthly.sh: daily check that runs whichever phase composite
  is due that day. Handles full moon (posts D+3), first quarter (D+2,
  best-effort due to daytime-only geometry from east), and third quarter
  (D+2). Picks the frame closest in time to the exact phase moment that
  meets quality / altitude / illumination thresholds, then composites
  the cached lunar texture into it -- sky/halo/parallactic-angle/timing
  real from east, surface detail borrowed from the reference image.

Honest-by-design: a 38-px white blob from a wide-field IP camera cannot
be enhanced into crater detail by software. The composite makes the
borrowing explicit and constrains everything else (when, where, sky,
orientation) to match what east actually saw.

install.sh now downloads the skyfield ephemeris (de421.bsp) and the
default lunar reference (Wikipedia CC BY-SA full-moon photo) on first
run. Both can be overridden via .env.

https://claude.ai/code/session_015PBVDESC3KLMbq1LpA6qLn
2026-04-30 11:40:11 +00:00

265 lines
8.9 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 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())