#!/usr/bin/env python3 """moon_phase_monthly.py — build the monthly moon-phase close-up. Handles three phases, controlled by --phase: full ~100% lit, posted MOON_FULL_POST_DELAY_DAYS after exact full first-quarter ~50% lit waxing (right half lit in northern hemisphere) third-quarter ~50% lit waning (left half lit in northern hemisphere) Algorithm (per phase): 1. Find the most recent occurrence of the target phase (or honour --target). 2. Scan east frames across the collection window (D-Δb .. D+Δa) and pick the frame closest in time to exact phase UTC where east successfully detected the moon and standard quality / altitude / illumination thresholds are met. East is the WITNESS — it confirms you actually had a chance to see the moon that night. 3. Round east's capture timestamp to the nearest hour and fetch the NASA SVS Dial-a-Moon render for that hour. This gives a real-physics moon image with correct phase, libration and crater shadows. 4. Render full-screen on a black background (moon fills ~92% of frame height), drop a caption naming the phase / month / capture moment / attribution, and upload to Mattermost. Geometry note — first-quarter from east is HARD: at first quarter the moon is up from noon to midnight, but east only sees the eastern sky, so it captures the moon during DAYTIME only (with a bright sky background). The detector is brightness-based and may often fail to find a daytime moon. Set MOON_FIRST_QUARTER_ENABLED=false in sky-cam.conf if you'd rather not chase it. Full moon and third-quarter both rise after dark and stay in east's view — those should land cleanly most months. Usage: moon_phase_monthly.py # auto: run any phase whose post-day = today moon_phase_monthly.py --phase full # force a single phase moon_phase_monthly.py --phase third-quarter --target 2026-04-09T11:51:00Z --dry-run """ from __future__ import annotations import argparse import os import pathlib import re import subprocess import sys from datetime import datetime, timedelta, timezone _here = pathlib.Path(__file__).resolve().parent sys.path.insert(0, str(_here)) 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')) BASE_DIR = CONF.get('BASE_DIR') or str(_here / 'data') MOVIES_DIR = CONF.get('MOVIES_DIR') or f'{BASE_DIR}/movies' SUNRISE_CAM = CONF.get('SUNRISE_CAM', 'east') TIMEZONE = CONF.get('TIMEZONE', 'UTC') MIN_QUALITY = float(CONF.get('MOON_MIN_QUALITY', CONF.get('MOON_FULL_MIN_QUALITY', 0.55))) MIN_ALTITUDE = float(CONF.get('MOON_MIN_ALTITUDE_DEG', CONF.get('MOON_FULL_MIN_ALTITUDE_DEG', 15.0))) OUT_W = int(CONF.get('MOON_OUTPUT_W', CONF.get('MOON_FULL_OUTPUT_W', 1920))) OUT_H = int(CONF.get('MOON_OUTPUT_H', CONF.get('MOON_FULL_OUTPUT_H', 1080))) MOON_PCT = float(CONF.get('MOON_HEIGHT_PCT', 0.92)) REQUIRE_EAST_VERIFY = CONF.get('MOON_REQUIRE_EAST_VERIFY', 'true').lower() != 'false' DARK_START_MIN = int(CONF.get('MOON_DARK_START_MIN', 30)) PHASE_SPEC = { 'full': { 'index': 2, 'label': 'Full Moon', 'emoji': '🌕', 'post_delay': int(CONF.get('MOON_FULL_POST_DELAY_DAYS', 1)), 'enabled_key': 'MOON_FULL_ENABLED', }, 'first-quarter': { 'index': 1, 'label': 'First Quarter (Waxing Half)', 'emoji': '🌓', 'post_delay': int(CONF.get('MOON_QUARTER_POST_DELAY_DAYS', 1)), 'enabled_key': 'MOON_FIRST_QUARTER_ENABLED', }, 'third-quarter': { 'index': 3, 'label': 'Third Quarter (Waning Half)', 'emoji': '🌗', 'post_delay': int(CONF.get('MOON_QUARTER_POST_DELAY_DAYS', 1)), 'enabled_key': 'MOON_THIRD_QUARTER_ENABLED', }, } _FRAME_RE = re.compile(r'^(\d{2})-(\d{2})-(\d{2})\.jpg$') def _local_tz(): try: import pytz return pytz.timezone(TIMEZONE) except Exception: return timezone.utc def _frame_local_dt(date_str: str, fname: str): m = _FRAME_RE.match(fname) if not m: return None h, mn, s = (int(x) for x in m.groups()) y, mo, d = (int(x) for x in date_str.split('-')) naive = datetime(y, mo, d, h, mn, s) tz = _local_tz() if hasattr(tz, 'localize'): return tz.localize(naive) return naive.replace(tzinfo=tz) def _candidate_frames(cam: str, dates: list[str]) -> list[tuple[str, datetime]]: out = [] for d in dates: fdir = pathlib.Path(BASE_DIR) / cam / d if not fdir.is_dir(): continue for fname in sorted(os.listdir(fdir)): if not fname.endswith('.jpg'): continue local_dt = _frame_local_dt(d, fname) if local_dt is None: continue out.append((str(fdir / fname), local_dt.astimezone(timezone.utc))) return out def _format_local(dt_utc: datetime) -> str: return dt_utc.astimezone(_local_tz()).strftime('%Y-%m-%d %H:%M:%S %Z') def _notify(title: str, body: str): try: subprocess.run([str(_here / 'notify.sh'), title, body], check=False) except FileNotFoundError: pass def _post_to_mattermost(image_path: str, message: str) -> bool: import requests base = CONF.get('mattermost_url', '').rstrip('/') token = CONF.get('access_token', '') channel_id = CONF.get('channel_id', '') if not all([base, token, channel_id]): print('mattermost credentials missing — skipping upload', file=sys.stderr) return False headers = {'Authorization': f'Bearer {token}'} with open(image_path, 'rb') as f: r = requests.post( f'{base}/api/v4/files', headers=headers, files={'files': f}, data={'channel_id': channel_id}, ) if r.status_code != 201: print(f'mattermost upload failed: {r.status_code} {r.text}', file=sys.stderr) return False file_id = r.json()['file_infos'][0]['id'] r = requests.post( f'{base}/api/v4/posts', headers=headers, json={'channel_id': channel_id, 'message': message, 'file_ids': [file_id]}, ) if r.status_code != 201: print(f'mattermost post failed: {r.status_code} {r.text}', file=sys.stderr) return False return True def _output_subdir(phase: str) -> str: return { 'full': 'full-moons', 'first-quarter': 'first-quarter', 'third-quarter': 'third-quarter', }[phase] def _output_filename(phase: str, target_utc: datetime) -> str: slug = {'full': 'full', 'first-quarter': 'first-quarter', 'third-quarter': 'third-quarter'}[phase] return f"{target_utc.strftime('%Y-%m')}-{slug}.jpg" def _dark_moon_intervals( search_start: datetime, search_end: datetime, moon_phase_mod, ) -> list[tuple[datetime, datetime]]: """Return contiguous blocks where the sky is dark and the moon is visible. 'Dark' means after (sunset + DARK_START_MIN) and before the following sunrise, based on actual computed sunset/sunrise times. Within each night block we sample every 10 min and keep only the sub-intervals where the moon is above MIN_ALTITUDE_DEG. Falls back to sampling sun altitude directly if skyfield can't find sunrise/sunset events (e.g. polar summer). """ # Get actual sunset/sunrise events; widen the window a bit to catch events # that fall right at the boundary. events = moon_phase_mod.sun_events_in_range( search_start - timedelta(hours=2), search_end + timedelta(hours=2), ) # Build night periods from real sunset/sunrise times. night_periods: list[tuple[datetime, datetime]] = [] for i, (evt_t, is_rise) in enumerate(events): if is_rise: continue # only care about sunsets here dark_start = evt_t + timedelta(minutes=DARK_START_MIN) # The night ends at the next sunrise (or search_end if none found). next_rise = next((t for t, r in events[i + 1:] if r), None) dark_end = next_rise if next_rise is not None else search_end if dark_start < dark_end: night_periods.append((dark_start, dark_end)) # Polar fallback: no sunset/sunrise events found. if not night_periods: night_periods = [(search_start, search_end)] # Within each night, sample every 10 min to find where moon is high enough. step = timedelta(minutes=10) intervals: list[tuple[datetime, datetime]] = [] for night_start, night_end in night_periods: t = max(night_start, search_start) end = min(night_end, search_end) if t >= end: continue seg_start = None while t <= end: try: moon_alt, _ = moon_phase_mod.altaz(t) except Exception: t += step continue ok = moon_alt >= MIN_ALTITUDE if ok and seg_start is None: seg_start = t elif not ok and seg_start is not None: intervals.append((seg_start, t)) seg_start = None t += step if seg_start is not None: intervals.append((seg_start, end)) return intervals def run_phase(phase: str, target_utc: datetime | None, cam: str, dry_run: bool, no_upload: bool, out_path: str | None) -> int: spec = PHASE_SPEC[phase] if CONF.get(spec['enabled_key'], 'true').lower() == 'false': print(f'{spec["enabled_key"]}=false — skipping {phase}') return 0 import moon_phase from moon_detect import detect_moon if target_utc is None: now = datetime.now(timezone.utc) events = moon_phase.phase_events_in_range( now - timedelta(days=45), now, spec['index']) if not events: print(f'no recent {phase} found in past 45 days', file=sys.stderr) return 1 target_utc = events[-1] print(f'target {phase}: {target_utc.isoformat()} ({_format_local(target_utc)})') tz = _local_tz() if not REQUIRE_EAST_VERIFY: print('MOON_REQUIRE_EAST_VERIFY=false — skipping east scan, using exact phase UTC') return _render_and_post(phase, spec, target_utc, target_utc, cam, dry_run, no_upload, out_path, witness_text='east verification disabled') # Find every interval within ±24h of the phase moment where the sky is # dark (sun below civil twilight) and the moon is above the horizon. search_start = target_utc - timedelta(hours=24) search_end = target_utc + timedelta(hours=24) intervals = _dark_moon_intervals(search_start, search_end, moon_phase) if not intervals: print('no dark-sky + moon window in ±24h of phase — skipping') _notify( f'{spec["emoji"]} {spec["label"]} {target_utc.strftime("%B %Y")} — skipped', 'No dark-sky + moon-above-horizon window found near the phase moment.', ) return 0 total_h = sum((e - s).total_seconds() / 3600 for s, e in intervals) print(f'dark+moon window: {len(intervals)} segment(s), {total_h:.1f}h total') for s, e in intervals: print(f' {_format_local(s)} -> {_format_local(e)}') # Collect east frames that fall inside those intervals search_dates: list[str] = [] d = search_start.astimezone(tz).date() while d <= search_end.astimezone(tz).date(): search_dates.append(d.strftime('%Y-%m-%d')) d += timedelta(days=1) all_frames = _candidate_frames(cam, search_dates) dark_frames = [ (p, dt) for p, dt in all_frames if any(s <= dt <= e for s, e in intervals) ] print(f'east frames in dark+moon window: {len(dark_frames)}') # Sort by proximity to exact phase moment so the first clear frame we find # is the one temporally closest to the moon being precisely full/quarter. dark_frames.sort(key=lambda x: abs(x[1] - target_utc)) best_frame: str | None = None best_det = None best_dt: datetime | None = None for fpath, fdt in dark_frames: det = detect_moon(fpath) if det is not None and det.quality >= MIN_QUALITY: best_frame = fpath best_det = det best_dt = fdt offset_min = int((fdt - target_utc).total_seconds() / 60) print(f'best frame: {pathlib.Path(fpath).name} quality={det.quality:.3f} ' f'time={_format_local(fdt)} offset={offset_min:+d} min from exact phase') break if best_frame is None: checked = len(dark_frames) print(f'no clear moon in {checked} dark-window frame(s) — skipping (overcast)') _notify( f'{spec["emoji"]} {spec["label"]} {target_utc.strftime("%B %Y")} — skipped', f'No clear moon detection in {checked} frame(s) during the dark+moon window.', ) return 0 illum_pct = round(moon_phase.illumination(best_dt) * 100) print(f'illumination at detection: {illum_pct}%') if dry_run: print(f'dry-run: would post NASA image for {best_dt.isoformat()} ({illum_pct}% lit)') return 0 return _render_and_post( phase, spec, target_utc, best_dt, cam, dry_run, no_upload, out_path, witness_text=f'sky-cam {cam} — {illum_pct}% lit — {_format_local(best_dt)}', east_frame=best_frame, detection=best_det, ) def _render_and_post(phase, spec, target_utc, when_utc, cam, dry_run, no_upload, out_path, witness_text, east_frame=None, detection=None): if out_path is None: out_dir = pathlib.Path(MOVIES_DIR) / cam / _output_subdir(phase) out_dir.mkdir(parents=True, exist_ok=True) out_path = str(out_dir / _output_filename(phase, target_utc)) import moon_dialamoon import moon_phase as _mp try: nasa_path = moon_dialamoon.fetch_for_time(when_utc) except Exception as e: msg = ( f'NASA SVS Dial-a-Moon fetch failed for ' f'{when_utc.strftime("%Y-%m-%dT%HZ")}: {e}' ) _notify(f'{spec["emoji"]} {spec["label"]} — dial-a-moon fetch failed', msg) print(msg, file=sys.stderr) return 4 print(f'dial-a-moon: {nasa_path}') illum_pct = round(_mp.illumination(when_utc) * 100) caption = ( f"{spec['label']} — {illum_pct}% lit — {target_utc.strftime('%B %Y')} — " f"{witness_text} — NASA SVS Dial-a-Moon" ) if east_frame is not None and detection is not None: from moon_composite import composite_full_moon composite_full_moon( east_frame, detection, when_utc, str(nasa_path), out_path, output_size=(OUT_W, OUT_H), moon_height_pct=0.70, caption=caption, ) else: from moon_composite import render_phase_closeup render_phase_closeup( str(nasa_path), out_path, output_size=(OUT_W, OUT_H), moon_height_pct=MOON_PCT, caption=caption, when_utc=when_utc, ) print(f'wrote {out_path}') if no_upload: _notify( f'{spec["emoji"]} {spec["label"]} {target_utc.strftime("%B %Y")} (built, not posted)', f'{out_path} — {witness_text}', ) return 0 posted = _post_to_mattermost( out_path, f"{spec['emoji']} {spec['label']} — {target_utc.strftime('%B %Y')}\n" f"sky-cam {cam} {witness_text}.\n" f"Surface render from NASA SVS Dial-a-Moon for that hour.", ) if posted: _notify( f'{spec["emoji"]} {spec["label"]} {target_utc.strftime("%B %Y")} posted', f'{witness_text} — {out_path}', ) else: _notify( f'FAILED: {spec["emoji"]} {spec["label"]} {target_utc.strftime("%B %Y")} upload', f'Image built at {out_path} but Mattermost upload failed.', ) return 0 def auto_run(cam: str, dry_run: bool, no_upload: bool) -> int: """Daily check: run any phase whose post-day equals today (UTC).""" import moon_phase today_utc = datetime.now(timezone.utc).date() ran_any = False rc = 0 for phase, spec in PHASE_SPEC.items(): if CONF.get(spec['enabled_key'], 'true').lower() == 'false': print(f'-- {phase}: {spec["enabled_key"]}=false → skip') continue events = moon_phase.phase_events_in_range( datetime.combine(today_utc - timedelta(days=45), datetime.min.time(), tzinfo=timezone.utc), datetime.now(timezone.utc), spec['index']) if not events: continue last_event = events[-1] days_since = (today_utc - last_event.date()).days if days_since == spec['post_delay']: print(f'== running {phase} (last event {last_event.date()}, +{spec["post_delay"]} days = today) ==') sub = run_phase(phase, last_event, cam, dry_run, no_upload, None) rc = rc or sub ran_any = True else: print(f'-- {phase}: last {last_event.date()}, days_since={days_since}, post_delay={spec["post_delay"]} → skip') if not ran_any: print('no phase scheduled for today') return rc def main(): p = argparse.ArgumentParser() p.add_argument('--phase', choices=list(PHASE_SPEC.keys()), help='Run a single phase regardless of schedule') p.add_argument('--target', help='Override phase event UTC, ISO 8601 (requires --phase)') p.add_argument('--cam', default=SUNRISE_CAM) p.add_argument('--dry-run', action='store_true') p.add_argument('--no-upload', action='store_true') p.add_argument('--out', help='Override output path (requires --phase)') args = p.parse_args() if args.phase: target = None if args.target: target = datetime.fromisoformat(args.target.replace('Z', '+00:00')) return run_phase(args.phase, target, args.cam, args.dry_run, args.no_upload, args.out) return auto_run(args.cam, args.dry_run, args.no_upload) if __name__ == '__main__': sys.exit(main())