#!/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 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())