Files
sky-cam/moon_phase.py
T
Claude 29a864375b Add waxing/waning crescent phases; fix phase-shadow double-application
moon_detect.py
- detect_moon() accepts saturated_threshold / min_roundness / max_halo_ratio
  so crescent phases (dim, non-circular arc) can be detected with relaxed
  thresholds without changing defaults for full/quarter detection.

moon_phase.py
- crescent_times_in_range(): 2-hour scan returning the UTC moment illumination
  crosses a target fraction (default 28%) on the waxing or waning side.
  Used for scheduling just like phase_events_in_range() for named phases.

moon_composite.py
- Remove _apply_phase_shadow() from composite_full_moon.  NASA SVS Dial-a-Moon
  renders already include the correct terminator, libration and earthshine for
  the exact hour; applying the shadow on top double-darkened the unlit limb on
  every non-full phase.  The function stays available for callers that need it.

moon_phase_monthly.py
- Add waxing-crescent (🌒) and waning-crescent (🌘) to PHASE_SPEC.
  Timing via crescent_times_in_range(); scheduling identical to other phases.
- CRESCENT_* tuning constants (all overridable from sky-cam.conf):
    MOON_CRESCENT_TARGET_ILLUM   default 0.28
    MOON_CRESCENT_MIN_QUALITY    default 0.35
    MOON_CRESCENT_SATURATED_THR  default 180
    MOON_CRESCENT_MIN_ROUNDNESS  default 0.15
    MOON_CRESCENT_MAX_HALO_RATIO default 12.0
- run_phase() and auto_run() branch on spec['crescent'] to use crescent
  timing and detection params.
- Atmospheric background naturally captures twilight colours (dawn for waning
  crescent, dusk/dawn for waxing) from the real east frame.

https://claude.ai/code/session_01HuJ83KvMvshiY6HxJbtMsc
2026-05-03 23:21:44 +00:00

340 lines
12 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
_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())