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
265 lines
8.9 KiB
Python
Executable File
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())
|