#!/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 # alt/az from configured location python3 moon_phase.py info # 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 _topos = None # bare geographic position; sunrise_sunset() adds earth itself 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, _topos 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 _topos = wgs84.latlon(LATITUDE, LONGITUDE) _observer = _eph['earth'] + _topos 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 crescent_times_in_range( start: datetime, end: datetime, target_illum: float = 0.28, waxing_side: bool = True, ) -> list[datetime]: """Return UTC datetimes when illumination crosses target_illum on the given side. Scans in 2-hour steps and records each moment illumination passes through target_illum while waxing (waxing_side=True) or waning (waxing_side=False). Yields one event per lunar cycle, used for scheduling just like phase_events_in_range() is used for quarter/full events. """ _lazy() step = timedelta(hours=2) results: list[datetime] = [] t = _to_utc(start) end_dt = _to_utc(end) prev_illum: float | None = None prev_t: datetime | None = None while t <= end_dt: illum_val = illumination(t) is_wax = waxing(t) if is_wax == waxing_side: if prev_illum is not None: if (prev_illum - target_illum) * (illum_val - target_illum) < 0: # Linear interpolation to the crossing moment frac = (target_illum - prev_illum) / (illum_val - prev_illum) results.append(prev_t + frac * (t - prev_t)) prev_illum = illum_val prev_t = t else: prev_illum = None prev_t = None t += step return results 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, _topos)) 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())