From 9e2852fd737be7d91a27c9abf88a0e3685bf6083 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 1 May 2026 22:03:57 +0000 Subject: [PATCH 1/4] Use fixed obs time for NASA fetch and atmosphere check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous approach: scan a 3-day window, find best moon detection, use that timestamp. Problems: fuzzy composite (multi-day scan could pick a frame far from actual full moon), no clear tie between the NASA render and a specific observable moment. New approach — MOON_OBS_TIME_LOCAL (default 22:30 local): On the night of the exact phase event, look at east frames in a ±MOON_OBS_WINDOW_MIN (default 30 min) window around the configured time. The frame closest to that time determines atmosphere opacity; the same time (rounded to hour) drives the NASA Dial-a-Moon fetch. This gives one definitive moment per phase per month. moon_phase_monthly.py: - PHASE_SPEC stripped to just post_delay + enabled_key (all window/ illumination filtering removed — obs time is the only selector) - run_phase() replaced with obs-time logic; opacity derived from detect_moon quality on that single frame - MOON_FULL_POST_DELAY_DAYS / MOON_QUARTER_POST_DELAY_DAYS default changed to 1 (post morning after the phase, frames already on disk) moon_composite.py: - atmosphere blur default: output_width//10 (192px) → output_width//20 (96px) so cloud shapes survive the blur sky-cam.conf: - MOON_OBS_TIME_LOCAL=22:30, MOON_OBS_WINDOW_MIN=30 - Post delay defaults updated to 1 test_moon_composite.py: - Tries real NASA SVS Dial-a-Moon API first; procedural disc only as fallback if API unreachable - Runs detect_moon on the east frame to compute real opacity - Single output (test_composite_out.jpg) https://claude.ai/code/session_01HkTxpNSTWtViZzxbrbKytR --- moon_composite.py | 7 +- moon_phase_monthly.py | 185 ++++++++++++++++++----------------------- sky-cam.conf | 29 +++++-- test_moon_composite.py | 157 ++++++++++++++++++++++------------ 4 files changed, 208 insertions(+), 170 deletions(-) diff --git a/moon_composite.py b/moon_composite.py index bae2fba..f893d25 100755 --- a/moon_composite.py +++ b/moon_composite.py @@ -253,12 +253,13 @@ def _make_atmosphere_layer( it reads as "clouds between the observer and the moon" — which is physically correct. - blur_radius=0 → auto: output_width // 10, which smooths pixel-level - detail but keeps cloud-scale gradients visible. + blur_radius=0 → auto: output_width // 20 (96 px at 1920 wide). + This smooths pixel-level RTSP noise and OSD text while keeping + recognisable cloud shapes and sky-colour gradients intact. """ src = Image.open(east_frame_path).convert('RGB') layer = src.resize(output_size, LANCZOS) - r = blur_radius if blur_radius > 0 else output_size[0] // 10 + r = blur_radius if blur_radius > 0 else output_size[0] // 20 return layer.filter(ImageFilter.GaussianBlur(radius=r)) diff --git a/moon_phase_monthly.py b/moon_phase_monthly.py index b908cc1..ea37a29 100755 --- a/moon_phase_monthly.py +++ b/moon_phase_monthly.py @@ -88,6 +88,11 @@ REQUIRE_EAST_VERIFY = CONF.get('MOON_REQUIRE_EAST_VERIFY', 'true').lower() != 'f ATMOSPHERE_BLUR = int(CONF.get('MOON_ATMOSPHERE_BLUR', 0)) CLOUDY_POST_ENABLED = CONF.get('MOON_CLOUDY_POST_ENABLED', 'true').lower() != 'false' +_obs_parts = CONF.get('MOON_OBS_TIME_LOCAL', '22:30').split(':') +OBS_TIME_H = int(_obs_parts[0]) +OBS_TIME_M = int(_obs_parts[1]) if len(_obs_parts) > 1 else 0 +OBS_WINDOW_MIN = int(CONF.get('MOON_OBS_WINDOW_MIN', 30)) + def _atmosphere_opacity_from_quality(quality: float | None) -> float: """Map moon detection quality to atmospheric overlay opacity. @@ -115,39 +120,21 @@ PHASE_SPEC = { 'index': 2, 'label': 'Full Moon', 'emoji': '🌕', - # ±4% of target (100%): accept 96–100% illumination - 'illum_min': float(CONF.get('MOON_FULL_MIN_ILLUMINATION', 0.96)), - 'illum_max': 1.01, - 'waxing': None, - 'window_before': int(CONF.get('MOON_FULL_WINDOW_BEFORE_DAYS', 1)), - 'window_after': int(CONF.get('MOON_FULL_WINDOW_AFTER_DAYS', 2)), - 'post_delay': int(CONF.get('MOON_FULL_POST_DELAY_DAYS', 3)), + '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': '🌓', - # ±4% of target (50%): accept 46–54% illumination, waxing only - 'illum_min': float(CONF.get('MOON_QUARTER_MIN_ILLUMINATION', 0.46)), - 'illum_max': float(CONF.get('MOON_QUARTER_MAX_ILLUMINATION', 0.54)), - 'waxing': True, - 'window_before': int(CONF.get('MOON_QUARTER_WINDOW_BEFORE_DAYS', 1)), - 'window_after': int(CONF.get('MOON_QUARTER_WINDOW_AFTER_DAYS', 1)), - 'post_delay': int(CONF.get('MOON_QUARTER_POST_DELAY_DAYS', 2)), + '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': '🌗', - # ±4% of target (50%): accept 46–54% illumination, waning only - 'illum_min': float(CONF.get('MOON_QUARTER_MIN_ILLUMINATION', 0.46)), - 'illum_max': float(CONF.get('MOON_QUARTER_MAX_ILLUMINATION', 0.54)), - 'waxing': False, - 'window_before': int(CONF.get('MOON_QUARTER_WINDOW_BEFORE_DAYS', 1)), - 'window_after': int(CONF.get('MOON_QUARTER_WINDOW_AFTER_DAYS', 1)), - 'post_delay': int(CONF.get('MOON_QUARTER_POST_DELAY_DAYS', 2)), + 'post_delay': int(CONF.get('MOON_QUARTER_POST_DELAY_DAYS', 1)), 'enabled_key': 'MOON_THIRD_QUARTER_ENABLED', }, } @@ -280,99 +267,89 @@ def run_phase(phase: str, target_utc: datetime | None, cam: str, cam, dry_run, no_upload, out_path, witness_text='not requiring east verification (set MOON_REQUIRE_EAST_VERIFY=true to require)') - dates = [] - for i in range(-spec['window_before'], spec['window_after'] + 1): - d = (target_local + timedelta(days=i)).date() - dates.append(d.strftime('%Y-%m-%d')) - print(f'scanning dates: {dates}') + # ── Fixed observation time on the night of the phase event ────────────── + # Instead of scanning a multi-day window for the best detection, we look + # at a short window around a configured local time (default 22:30) on the + # night of the exact phase. That single timestamp drives both the NASA + # Dial-a-Moon fetch (rounded to the nearest hour) and the atmosphere check. + obs_naive = datetime( + target_local.year, target_local.month, target_local.day, + OBS_TIME_H, OBS_TIME_M, 0, + ) + if hasattr(tz, 'localize'): + obs_utc = tz.localize(obs_naive).astimezone(timezone.utc) + else: + obs_utc = obs_naive.replace(tzinfo=tz).astimezone(timezone.utc) + print(f'obs time: {obs_utc.isoformat()} ({_format_local(obs_utc)})') - candidates = _candidate_frames(cam, dates) - print(f'frame count in window: {len(candidates)}') - if not candidates: - msg = ( - f'No frames for {cam} in window {dates[0]}..{dates[-1]} ' - f'around {phase} {target_utc.strftime("%Y-%m-%d %H:%MZ")}.' + # Check moon altitude at the observation time + try: + alt_at_obs, _ = moon_phase.altaz(obs_utc) + except Exception as e: + print(f'ERROR: moon_phase.altaz: {e}', file=sys.stderr) + return 2 + print(f'moon altitude at obs time: {alt_at_obs:.1f}°') + + # Gather east frames in the ±OBS_WINDOW_MIN window + obs_start = obs_utc - timedelta(minutes=OBS_WINDOW_MIN) + obs_end = obs_utc + timedelta(minutes=OBS_WINDOW_MIN) + obs_dates: list[str] = [] + d = obs_start.astimezone(tz).date() + while d <= obs_end.astimezone(tz).date(): + obs_dates.append(d.strftime('%Y-%m-%d')) + d += timedelta(days=1) + all_frames = _candidate_frames(cam, obs_dates) + window_frames = [(p, dt) for p, dt in all_frames if obs_start <= dt <= obs_end] + print(f'east frames in ±{OBS_WINDOW_MIN} min window: {len(window_frames)}') + + # Determine atmosphere opacity from the frame closest to obs_utc + east_frame: str | None = None + opacity = 0.0 + + if alt_at_obs < MIN_ALTITUDE: + print('moon below horizon at obs time — posting clean NASA image') + _notify( + f'{spec["emoji"]} {spec["label"]} {target_utc.strftime("%B %Y")} ' + f'— moon below horizon at {OBS_TIME_H:02d}:{OBS_TIME_M:02d} local', + f'Adjust MOON_OBS_TIME_LOCAL in sky-cam.conf. Posting clean NASA render.', ) - _notify(f'{spec["emoji"]} {spec["label"]} — no frames available', msg) - print(msg) - return 0 + elif window_frames: + best_f = min(window_frames, key=lambda t: abs(t[1] - obs_utc)) + east_frame, best_dt = best_f + det = detect_moon(east_frame) + quality = det.quality if det is not None else None + opacity = _atmosphere_opacity_from_quality(quality) + offset_min = (best_dt - obs_utc).total_seconds() / 60.0 + print(f'atmosphere frame: {east_frame}') + print(f' quality={quality} opacity={opacity:.2f} ' + f'offset={offset_min:+.1f} min from obs time') + else: + print('no east frames in obs window — posting clean NASA image') - qualifying = [] - above_horizon = [] # frames where moon is up but quality/illum check failed - for path, utc_dt in candidates: - try: - alt, _ = moon_phase.altaz(utc_dt) - except Exception as e: - print(f'ERROR: moon_phase.altaz failed: {e}', file=sys.stderr) - return 2 - if alt < MIN_ALTITUDE: - continue - det = detect_moon(path) - if det is None or det.quality < MIN_QUALITY: - above_horizon.append((path, utc_dt, det)) - continue - illum = moon_phase.illumination(utc_dt) - if illum < spec['illum_min'] or illum > spec['illum_max']: - continue - if spec['waxing'] is not None: - if moon_phase.waxing(utc_dt) != spec['waxing']: - continue - qualifying.append((path, utc_dt, det, alt, illum)) + # Label atmospheric conditions for the caption + if opacity == 0.0: + atm_label = 'clear sky' + elif opacity < 0.15: + atm_label = 'slight haze' + elif opacity < 0.40: + atm_label = 'cloud cover' + else: + atm_label = 'heavy overcast' - print(f'qualifying frames: {len(qualifying)} above-horizon fallback pool: {len(above_horizon)}') - - if not qualifying: - # No frame met quality + illumination thresholds — likely overcast. - # If MOON_CLOUDY_POST_ENABLED, use the above-horizon frame closest to - # the exact phase moment as the atmosphere source and post with a - # heavy cloud overlay so the month is still represented. - if above_horizon and CLOUDY_POST_ENABLED: - best_cloudy = min(above_horizon, key=lambda t: abs(t[1] - target_utc)) - path, _when, det = best_cloudy - quality = det.quality if det is not None else None - opacity = _atmosphere_opacity_from_quality(quality) - print(f'overcast fallback: {path} quality={quality} opacity={opacity:.2f}') - witness = f'cloud cover — {target_utc.strftime("%Y-%m-%d")} — NASA SVS Dial-a-Moon' - if dry_run: - return 0 - return _render_and_post( - phase, spec, target_utc, target_utc, - target_utc.astimezone(tz), - cam, dry_run, no_upload, out_path, - witness_text=witness, - east_frame_path=path, - atmosphere_opacity=opacity, - ) - - msg = ( - f'No {phase} frame in window {dates[0]}..{dates[-1]} ' - f'(quality≥{MIN_QUALITY}, altitude≥{MIN_ALTITUDE}°, ' - f'illumination {spec["illum_min"]:.2f}-{spec["illum_max"]:.2f}) ' - f'and no above-horizon frames for cloudy fallback.' - ) - _notify(f'{spec["emoji"]} {spec["label"]} — skipped {target_utc.strftime("%B %Y")}', msg) - print(msg) - return 0 - - best = min(qualifying, key=lambda t: abs(t[1] - target_utc)) - path, when_utc, det, alt, illum = best - local_dt = when_utc.astimezone(tz) - delta_min = (when_utc - target_utc).total_seconds() / 60.0 - opacity = _atmosphere_opacity_from_quality(det.quality) - print(f'picked: {path}') - print(f' when_utc={when_utc.isoformat()} local={local_dt} ' - f'altitude={alt:.1f} illum={illum:.4f} quality={det.quality:.3f} ' - f'opacity={opacity:.2f} delta={delta_min:+.1f} min') + obs_local_str = _format_local(obs_utc) + witness_text = ( + f'sky-cam {cam} — {atm_label} at {obs_local_str} — NASA SVS Dial-a-Moon' + ) if dry_run: + print(f'dry-run: would post with opacity={opacity:.2f} ({atm_label})') return 0 return _render_and_post( - phase, spec, target_utc, when_utc, local_dt, + phase, spec, target_utc, obs_utc, obs_utc.astimezone(tz), cam, dry_run, no_upload, out_path, - witness_text=f'witnessed at {local_dt.strftime("%Y-%m-%d %H:%M:%S %Z")} ' - f'({delta_min:+.0f} min from exact {phase})', - east_frame_path=path, + witness_text=witness_text, + east_frame_path=east_frame, atmosphere_opacity=opacity, ) diff --git a/sky-cam.conf b/sky-cam.conf index 8f54113..bdb2a8c 100644 --- a/sky-cam.conf +++ b/sky-cam.conf @@ -491,15 +491,28 @@ MOON_TRACK_FPS=12 # output mp4 framerate MOON_TRACK_CRF=24 # output mp4 CRF MOON_TRACK_RETENTION_DAYS=90 # delete tracker mp4s older than this; 0 = forever -# Full-moon monthly tuning ──────────────────────────────────────────────────── -# How many days after the exact full moon to post. 3 = waits for D-1..D+2 -# nights to be on disk, then runs the morning of D+3. -MOON_FULL_POST_DELAY_DAYS=3 +# Observation time ──────────────────────────────────────────────────────────── +# Local time used to select the east camera frame and the NASA Dial-a-Moon +# render for each phase post. The script looks at east frames within +# ±MOON_OBS_WINDOW_MIN of this time on the night of the exact phase event, +# picks the one closest to the target time, and uses it to: +# 1. Determine atmospheric conditions (clear / hazy / overcast) +# 2. Set the atmosphere overlay opacity on the NASA moon image +# 3. Round to the nearest hour for the NASA API call +# +# 22:30 works well for full moon and first quarter (both visible after dark). +# For third quarter (rises after midnight), consider 02:30 or leave at 22:30 +# and accept that the moon may be below the horizon — the script will warn +# and post a clean NASA render instead. +MOON_OBS_TIME_LOCAL=22:30 +MOON_OBS_WINDOW_MIN=30 # ±minutes around obs time to check east frames -# Quarter (half-moon) tuning ────────────────────────────────────────────────── -# 2 = waits for D-1, D, D+1 nights, runs the morning of D+2. -MOON_QUARTER_POST_DELAY_DAYS=2 -MOON_QUARTER_MIN_ILLUMINATION=0.46 # ±4% of 50%: waxing/waning within 4% of exact quarter +# Post delay ────────────────────────────────────────────────────────────────── +# How many days after the exact phase to run the post. The post delay gives +# time for the obs-night frames to land on disk before the job runs. +MOON_FULL_POST_DELAY_DAYS=1 # post the morning after the full moon +MOON_QUARTER_POST_DELAY_DAYS=1 # post the morning after each quarter +MOON_QUARTER_MIN_ILLUMINATION=0.46 MOON_QUARTER_MAX_ILLUMINATION=0.54 # Frame-acceptance thresholds — a candidate must beat all three to qualify. diff --git a/test_moon_composite.py b/test_moon_composite.py index 197dab4..a8ea69d 100644 --- a/test_moon_composite.py +++ b/test_moon_composite.py @@ -1,41 +1,54 @@ #!/usr/bin/env python3 -"""test_moon_composite.py — smoke-test atmospheric overlay at three opacity levels. +"""test_moon_composite.py — render a phase composite using real NASA data. -Generates three output images from the same east camera frame (21-07-00.jpg) -to show how the atmospheric overlay looks across the quality spectrum: +Simulates what moon_phase_monthly.py does on the night of a full moon: - test_composite_clear.jpg — opacity 0.00 (quality ≥ 0.85, clear sky) - test_composite_hazy.jpg — opacity 0.20 (quality ~ 0.70, light haze) - test_composite_cloudy.jpg — opacity 0.65 (overcast fallback) - -The moon disk in each image is procedurally generated (clean grey sphere, -no camera timestamp). In production it is replaced by the NASA SVS -Dial-a-Moon render for the exact UTC hour east captured the moon. + 1. Round the obs time (22:30 local on 2026-04-29) to the nearest hour. + 2. Fetch the NASA SVS Dial-a-Moon render for that UTC hour. + 3. Load the east camera frame (21-07-00.jpg, captured at 21:07 UTC that night). + 4. Detect atmospheric conditions → compute overlay opacity. + 5. Render: NASA moon + east atmosphere overlay → test_composite_out.jpg. Run from the sky-cam directory: python3 test_moon_composite.py + +Requires internet access to reach svs.gsfc.nasa.gov. +If the API is unreachable a procedural moon disc is used as a fallback +so the atmospheric overlay is still visible and testable. """ import os import pathlib import sys import tempfile +from datetime import datetime, timezone HERE = pathlib.Path(__file__).resolve().parent sys.path.insert(0, str(HERE)) EAST_FRAME = HERE / '21-07-00.jpg' +OUT_PATH = HERE / 'test_composite_out.jpg' -CASES = [ - ('test_composite_clear.jpg', 0.00, 'clear sky (quality >= 0.85)'), - ('test_composite_hazy.jpg', 0.20, 'light haze (quality ~ 0.70)'), - ('test_composite_cloudy.jpg', 0.65, 'heavy overcast fallback'), -] +# The east frame is 2026-04-29 21:07 UTC; obs time is 22:30 local → ~21:30 UTC +# (assuming Eastern time UTC-4 in late April). Round to nearest hour → 22:00 UTC. +NASA_FETCH_UTC = datetime(2026, 4, 29, 22, 0, tzinfo=timezone.utc) -def _make_procedural_moon(size: int = 2048) -> 'Image': - """Clean grey disc with simplified lunar maria and limb darkening.""" +def _fetch_nasa(dt: datetime) -> 'pathlib.Path | None': + try: + import moon_dialamoon + print(f'fetching NASA Dial-a-Moon for {dt.strftime("%Y-%m-%dT%H:00Z")} …') + path = moon_dialamoon.fetch_for_time(dt) + print(f' cached at {path}') + return path + except Exception as e: + print(f' NASA fetch failed: {e}') + return None + + +def _make_procedural_moon(size: int = 2048) -> 'pathlib.Path': + """Fallback: clean grey disc with maria and limb darkening.""" from PIL import Image, ImageDraw, ImageFilter import numpy as np @@ -58,7 +71,26 @@ def _make_procedural_moon(size: int = 2048) -> 'Image': arr = np.asarray(img).astype(float) vig = np.asarray(vignette).astype(float) / 255.0 arr = np.clip(arr * (0.75 + 0.25 * vig)[..., None], 0, 255).astype('uint8') - return Image.fromarray(arr) + img = Image.fromarray(arr) + + tmp = tempfile.NamedTemporaryFile(suffix='.png', delete=False) + tmp.close() + img.save(tmp.name) + return pathlib.Path(tmp.name) + + +def _detect_atmosphere(frame_path: str) -> tuple[float | None, float]: + """Run moon_detect and return (quality, opacity).""" + try: + from moon_detect import detect_moon + from moon_phase_monthly import _atmosphere_opacity_from_quality + det = detect_moon(frame_path) + quality = det.quality if det is not None else None + opacity = _atmosphere_opacity_from_quality(quality) + return quality, opacity + except Exception as e: + print(f' detection failed: {e}') + return None, 0.0 def main(): @@ -66,49 +98,64 @@ def main(): print(f'ERROR: missing {EAST_FRAME}', file=sys.stderr) sys.exit(1) - print('generating procedural moon disc …') - moon_img = _make_procedural_moon(2048) - tmp = tempfile.NamedTemporaryFile(suffix='.png', delete=False) - tmp_moon = tmp.name - tmp.close() - moon_img.save(tmp_moon) + print('=== moon composite test ===') + print(f'east frame : {EAST_FRAME.name} (2026-04-29 21:07 UTC)') + print(f'NASA time : {NASA_FETCH_UTC.strftime("%Y-%m-%dT%H:00Z")}') + print() + # 1. Try real NASA image + tmp_to_delete = None + nasa_path = _fetch_nasa(NASA_FETCH_UTC) + if nasa_path is None: + print('falling back to procedural moon disc …') + nasa_path = _make_procedural_moon() + tmp_to_delete = str(nasa_path) + print(f' disc saved to {nasa_path}') + print() + + # 2. Atmosphere from east frame + print(f'checking atmosphere in {EAST_FRAME.name} …') + quality, opacity = _detect_atmosphere(str(EAST_FRAME)) + if quality is not None: + print(f' moon quality: {quality:.3f} → atmosphere opacity: {opacity:.2f}') + else: + print(f' no moon detected (overcast) → opacity: {opacity:.2f}') + print() + + # 3. Render from moon_composite import render_phase_closeup - + caption = ( + f'Full Moon — April 2026 — ' + f'sky-cam east 2026-04-29 22:30 local — ' + f'NASA SVS Dial-a-Moon' + ) + print(f'rendering {OUT_PATH.name} …') try: - for filename, opacity, label in CASES: - out = HERE / filename - caption = ( - f'Full Moon — April 2026 — ' - f'sky-cam east 2026-04-29 21:07 UTC — ' - f'NASA SVS Dial-a-Moon [{label}]' - ) - print(f'rendering {filename} (opacity={opacity:.2f}) {label} …') - render_phase_closeup( - nasa_render_path=tmp_moon, - out_path=str(out), - output_size=(1920, 1080), - moon_height_pct=0.88, - caption=caption, - east_frame_path=str(EAST_FRAME), - atmosphere_opacity=opacity, - atmosphere_blur=0, - ) - print(f' -> {out}') + render_phase_closeup( + nasa_render_path=str(nasa_path), + out_path=str(OUT_PATH), + output_size=(1920, 1080), + moon_height_pct=0.88, + caption=caption, + east_frame_path=str(EAST_FRAME), + atmosphere_opacity=opacity, + atmosphere_blur=0, + ) finally: - os.unlink(tmp_moon) + if tmp_to_delete: + os.unlink(tmp_to_delete) + print(f'done → {OUT_PATH}') print() - print('done. Three outputs:') - for filename, opacity, label in CASES: - print(f' {filename:35s} opacity={opacity:.2f} {label}') - print() - print('What you should see in each:') - print(' clear — NASA moon disk sharp and unobscured on black background') - print(' hazy — same moon but with a soft grey-blue veil over the disk') - print(' (from the thin cloud visible in 21-07-00.jpg)') - print(' cloudy — moon mostly hidden; visible as a bright glow through') - print(' the cloud texture from east\'s April 29 frame') + print(f'opacity={opacity:.2f}:', end=' ') + if opacity == 0.0: + print('clear sky — pure NASA moon on black background') + elif opacity < 0.15: + print('slight haze — moon visible, softly veiled') + elif opacity < 0.40: + print('cloud cover — moon partially obscured') + else: + print('heavy overcast — moon a glow through cloud') if __name__ == '__main__': From a6989a9ea8cd13ec6060047963c51e2613d6072c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 1 May 2026 22:27:34 +0000 Subject: [PATCH 2/4] Simplify moon phase: binary go/no-go, parallactic angle rotation, no atmosphere overlay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - moon_composite.py: remove _make_atmosphere_layer() and all atmosphere parameters from render_phase_closeup(); add when_utc parameter and apply parallactic angle rotation so the NASA disk is oriented to match east's sky - moon_phase_monthly.py: change obs time default to 22:00 (aligns with NASA hourly renders); scan east frames in 22:00-23:00 window; binary go/no-go (quality >= MIN_QUALITY = post, no clear shot = skip entirely); remove _atmosphere_opacity_from_quality(), ATMOSPHERE_BLUR, CLOUDY_POST_ENABLED - sky-cam.conf: update MOON_OBS_TIME_LOCAL to 22:00, remove MOON_OBS_WINDOW_MIN, remove the atmospheric overlay and cloudy fallback sections and their config variables - test_moon_composite.py: single clean test — fetch NASA for 22:00 UTC on 2026-04-29, verify east frame quality, render with parallactic angle; no procedural fallback https://claude.ai/code/session_01HkTxpNSTWtViZzxbrbKytR --- moon_composite.py | 57 +++------------ moon_phase_monthly.py | 104 +++++++++------------------- sky-cam.conf | 51 +++----------- test_moon_composite.py | 154 +++++++++++------------------------------ 4 files changed, 98 insertions(+), 268 deletions(-) diff --git a/moon_composite.py b/moon_composite.py index f893d25..315ba38 100755 --- a/moon_composite.py +++ b/moon_composite.py @@ -240,29 +240,6 @@ def composite_full_moon( return out_path -def _make_atmosphere_layer( - east_frame_path: str, - output_size: tuple[int, int], - blur_radius: int = 0, -) -> Image.Image: - """Scale the full east frame to output_size and blur to atmospheric haze. - - The blur removes wide-angle camera detail (RTSP artefacts, OSD text, - pixel noise) while preserving real sky colour and large-scale cloud - structure. The resulting layer is applied OVER the NASA moon disk so - it reads as "clouds between the observer and the moon" — which is - physically correct. - - blur_radius=0 → auto: output_width // 20 (96 px at 1920 wide). - This smooths pixel-level RTSP noise and OSD text while keeping - recognisable cloud shapes and sky-colour gradients intact. - """ - src = Image.open(east_frame_path).convert('RGB') - layer = src.resize(output_size, LANCZOS) - r = blur_radius if blur_radius > 0 else output_size[0] // 20 - return layer.filter(ImageFilter.GaussianBlur(radius=r)) - - def render_phase_closeup( nasa_render_path: str, out_path: str, @@ -270,28 +247,16 @@ def render_phase_closeup( moon_height_pct: float = 0.92, caption: str | None = None, background: tuple[int, int, int] = (0, 0, 0), - east_frame_path: str | None = None, - atmosphere_opacity: float = 0.0, - atmosphere_blur: int = 0, + when_utc: datetime | None = None, ): """Full-screen close-up rendering using a NASA SVS Dial-a-Moon image. - Rendering pipeline: - 1. Black background (outer space). - 2. NASA moon disk — correct phase, libration, crater shadows — centred - and scaled to moon_height_pct of frame height. - 3. Atmospheric layer (optional): east's camera frame, scaled to output - size and blurred, composited OVER the moon at atmosphere_opacity. - This is physically correct — clouds are between the observer and the - moon so they occlude the disk, not sit behind it. - - atmosphere_opacity controls what the viewer sees through east's sky: - 0.00 — perfectly clear: pure NASA render, no overlay - 0.10 — slight haze: moon is sharp but slightly softened - 0.35 — noticeable cloud cover: moon partially obscured - 0.65 — heavy overcast: moon a faint glow through thick cloud + Renders the NASA moon disk centred on a black background, rotated by + the parallactic angle so its orientation matches what east's camera sees + from its geographic location at the given UTC time. """ - # ── Background + NASA moon ──────────────────────────────────────────── + import moon_phase + bg = Image.new('RGB', output_size, background) moon = Image.open(nasa_render_path).convert('RGB') moon = _square_crop_to_disk(moon) @@ -300,6 +265,11 @@ def render_phase_closeup( target += target % 2 moon_resized = moon.resize((target, target), LANCZOS) + # Rotate by parallactic angle so "up" on the moon matches east's sky + if when_utc is not None: + par = moon_phase.parallactic_angle(when_utc) + moon_resized = moon_resized.rotate(-par, resample=BICUBIC, expand=False) + feather = max(3, target // 240) mask = _disk_mask(target, feather_px=feather) @@ -307,11 +277,6 @@ def render_phase_closeup( py = (output_size[1] - target) // 2 bg.paste(moon_resized, (px, py), mask) - # ── Atmospheric layer from east frame — applied OVER the moon ───────── - if east_frame_path and atmosphere_opacity > 0.0: - atm = _make_atmosphere_layer(east_frame_path, output_size, atmosphere_blur) - bg = Image.blend(bg, atm, alpha=atmosphere_opacity) - if caption: draw = ImageDraw.Draw(bg) _draw_caption(draw, caption, (22, output_size[1] - 48), output_size[0]) diff --git a/moon_phase_monthly.py b/moon_phase_monthly.py index ea37a29..308f826 100755 --- a/moon_phase_monthly.py +++ b/moon_phase_monthly.py @@ -85,34 +85,9 @@ 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' -ATMOSPHERE_BLUR = int(CONF.get('MOON_ATMOSPHERE_BLUR', 0)) -CLOUDY_POST_ENABLED = CONF.get('MOON_CLOUDY_POST_ENABLED', 'true').lower() != 'false' - -_obs_parts = CONF.get('MOON_OBS_TIME_LOCAL', '22:30').split(':') +_obs_parts = CONF.get('MOON_OBS_TIME_LOCAL', '22:00').split(':') OBS_TIME_H = int(_obs_parts[0]) OBS_TIME_M = int(_obs_parts[1]) if len(_obs_parts) > 1 else 0 -OBS_WINDOW_MIN = int(CONF.get('MOON_OBS_WINDOW_MIN', 30)) - - -def _atmosphere_opacity_from_quality(quality: float | None) -> float: - """Map moon detection quality to atmospheric overlay opacity. - - quality >= 0.85 → 0.00 clear sky, no overlay - quality 0.70 → 0.20 light haze - quality 0.55 → 0.40 noticeable cloud, moon still detected - quality < 0.55 → up to 0.65 (overcast fallback frames) - quality is None → 0.68 no detection at all, heavy overcast - - The overlay is applied OVER the NASA moon disk, so higher opacity means - more of the disk is obscured — physically correct for clouds above us. - """ - if quality is None: - return 0.68 - if quality >= 0.85: - return 0.0 - # Linear: 0.0 at quality=0.85, 0.40 at quality=0.55 - raw = (0.85 - quality) / 0.30 * 0.40 - return min(0.65, raw) PHASE_SPEC = { @@ -290,73 +265,64 @@ def run_phase(phase: str, target_utc: datetime | None, cam: str, return 2 print(f'moon altitude at obs time: {alt_at_obs:.1f}°') - # Gather east frames in the ±OBS_WINDOW_MIN window - obs_start = obs_utc - timedelta(minutes=OBS_WINDOW_MIN) - obs_end = obs_utc + timedelta(minutes=OBS_WINDOW_MIN) + # Scan east frames in the 22:00–23:00 window for any clear moon detection + obs_end = obs_utc + timedelta(hours=1) obs_dates: list[str] = [] - d = obs_start.astimezone(tz).date() + d = obs_utc.astimezone(tz).date() while d <= obs_end.astimezone(tz).date(): obs_dates.append(d.strftime('%Y-%m-%d')) d += timedelta(days=1) all_frames = _candidate_frames(cam, obs_dates) - window_frames = [(p, dt) for p, dt in all_frames if obs_start <= dt <= obs_end] - print(f'east frames in ±{OBS_WINDOW_MIN} min window: {len(window_frames)}') - - # Determine atmosphere opacity from the frame closest to obs_utc - east_frame: str | None = None - opacity = 0.0 + window_frames = [(p, dt) for p, dt in all_frames if obs_utc <= dt <= obs_end] + print(f'east frames in 22:00-23:00 window: {len(window_frames)}') if alt_at_obs < MIN_ALTITUDE: - print('moon below horizon at obs time — posting clean NASA image') + print(f'moon below horizon at obs time ({alt_at_obs:.1f}deg) — skipping') _notify( f'{spec["emoji"]} {spec["label"]} {target_utc.strftime("%B %Y")} ' f'— moon below horizon at {OBS_TIME_H:02d}:{OBS_TIME_M:02d} local', - f'Adjust MOON_OBS_TIME_LOCAL in sky-cam.conf. Posting clean NASA render.', + f'Adjust MOON_OBS_TIME_LOCAL in sky-cam.conf.', ) - elif window_frames: - best_f = min(window_frames, key=lambda t: abs(t[1] - obs_utc)) - east_frame, best_dt = best_f - det = detect_moon(east_frame) - quality = det.quality if det is not None else None - opacity = _atmosphere_opacity_from_quality(quality) - offset_min = (best_dt - obs_utc).total_seconds() / 60.0 - print(f'atmosphere frame: {east_frame}') - print(f' quality={quality} opacity={opacity:.2f} ' - f'offset={offset_min:+.1f} min from obs time') - else: - print('no east frames in obs window — posting clean NASA image') + return 0 - # Label atmospheric conditions for the caption - if opacity == 0.0: - atm_label = 'clear sky' - elif opacity < 0.15: - atm_label = 'slight haze' - elif opacity < 0.40: - atm_label = 'cloud cover' - else: - atm_label = 'heavy overcast' + # Binary go/no-go: any frame in the window with quality >= MIN_QUALITY? + clear_frame: str | None = None + for fpath, fdt in window_frames: + det = detect_moon(fpath) + if det is not None and det.quality >= MIN_QUALITY: + clear_frame = fpath + offset_min = (fdt - obs_utc).total_seconds() / 60.0 + print(f'clear moon detected: {fpath}') + print(f' quality={det.quality:.3f} offset=+{offset_min:.1f} min') + break + else: + q = det.quality if det is not None else None + print(f' {fpath}: quality={q} — not clear enough') + + if clear_frame is None: + print('no clear moon detection in window — skipping (overcast or moon absent)') + _notify( + f'{spec["emoji"]} {spec["label"]} {target_utc.strftime("%B %Y")} — skipped', + f'No clear moon detection in the 22:00-23:00 window.', + ) + return 0 obs_local_str = _format_local(obs_utc) - witness_text = ( - f'sky-cam {cam} — {atm_label} at {obs_local_str} — NASA SVS Dial-a-Moon' - ) + witness_text = f'sky-cam {cam} — clear at {obs_local_str} — NASA SVS Dial-a-Moon' if dry_run: - print(f'dry-run: would post with opacity={opacity:.2f} ({atm_label})') + print(f'dry-run: would post using NASA image for {obs_utc.isoformat()}') return 0 return _render_and_post( phase, spec, target_utc, obs_utc, obs_utc.astimezone(tz), cam, dry_run, no_upload, out_path, witness_text=witness_text, - east_frame_path=east_frame, - atmosphere_opacity=opacity, ) def _render_and_post(phase, spec, target_utc, when_utc, local_dt, cam, - dry_run, no_upload, out_path, witness_text, - east_frame_path=None, atmosphere_opacity=0.0): + dry_run, no_upload, out_path, witness_text): if out_path is None: out_dir = pathlib.Path(MOVIES_DIR) / cam / _output_subdir(phase) out_dir.mkdir(parents=True, exist_ok=True) @@ -389,9 +355,7 @@ def _render_and_post(phase, spec, target_utc, when_utc, local_dt, cam, str(nasa_path), out_path, output_size=(OUT_W, OUT_H), moon_height_pct=MOON_PCT, caption=caption, - east_frame_path=east_frame_path, - atmosphere_opacity=atmosphere_opacity, - atmosphere_blur=ATMOSPHERE_BLUR, + when_utc=when_utc, ) print(f'wrote {out_path}') diff --git a/sky-cam.conf b/sky-cam.conf index bdb2a8c..5a70f7b 100644 --- a/sky-cam.conf +++ b/sky-cam.conf @@ -492,20 +492,18 @@ MOON_TRACK_CRF=24 # output mp4 CRF MOON_TRACK_RETENTION_DAYS=90 # delete tracker mp4s older than this; 0 = forever # Observation time ──────────────────────────────────────────────────────────── -# Local time used to select the east camera frame and the NASA Dial-a-Moon -# render for each phase post. The script looks at east frames within -# ±MOON_OBS_WINDOW_MIN of this time on the night of the exact phase event, -# picks the one closest to the target time, and uses it to: -# 1. Determine atmospheric conditions (clear / hazy / overcast) -# 2. Set the atmosphere overlay opacity on the NASA moon image -# 3. Round to the nearest hour for the NASA API call +# Local time that defines the observation window: MOON_OBS_TIME_LOCAL to +# MOON_OBS_TIME_LOCAL+1h. East frames in this window are checked for a clear +# moon detection (quality >= MOON_MIN_QUALITY). If any frame qualifies, the +# NASA Dial-a-Moon image for MOON_OBS_TIME_LOCAL UTC is fetched and posted. +# If no frame shows a clear moon, the month is skipped entirely. # -# 22:30 works well for full moon and first quarter (both visible after dark). -# For third quarter (rises after midnight), consider 02:30 or leave at 22:30 -# and accept that the moon may be below the horizon — the script will warn -# and post a clean NASA render instead. -MOON_OBS_TIME_LOCAL=22:30 -MOON_OBS_WINDOW_MIN=30 # ±minutes around obs time to check east frames +# 22:00 aligns directly with NASA's hourly renders. Full moon and third +# quarter both rise after dark and are visible at this hour. First quarter +# is up only until ~midnight from a new-moon start, so it may not be visible +# at 22:00 depending on the exact date — set MOON_FIRST_QUARTER_ENABLED=false +# if first-quarter posts are consistently missed. +MOON_OBS_TIME_LOCAL=22:00 # Post delay ────────────────────────────────────────────────────────────────── # How many days after the exact phase to run the post. The post delay gives @@ -537,33 +535,6 @@ MOON_HEIGHT_PCT=0.92 # moon disk fills this fraction of frame heig MOON_DIALAMOON_TARGET_PX=2048 # cached PNG longest side; downsampled on save MOON_DIALAMOON_TIMEOUT_SEC=30 -# Atmospheric overlay ───────────────────────────────────────────────────────── -# East's camera frame is scaled to output size, blurred, and composited OVER -# the NASA moon disk. This is physically correct: clouds sit between the -# observer and the moon, so they occlude the disk rather than appear behind it. -# -# Opacity is derived from the moon detection quality in that frame: -# quality ≥ 0.85 → 0% (clear sky — pure NASA render) -# quality 0.70 → 20% (light haze) -# quality 0.55 → 40% (notable cloud, moon still detected) -# quality < 0.55 → 40–65% (overcast fallback — see MOON_CLOUDY_POST_ENABLED) -# no detection → 68% (heavy overcast) -# -# blur radius: 0 = auto (output_width / 10); set in px to override. -#MOON_ATMOSPHERE_BLUR=0 -# -# Cloudy-month fallback ─────────────────────────────────────────────────────── -# If no frame in the collection window passes the quality + illumination -# thresholds, the script normally skips posting that month. With -# MOON_CLOUDY_POST_ENABLED=true it instead finds the above-horizon frame -# closest to the exact phase moment, applies a heavy atmospheric overlay -# (opacity 0.45–0.68), and posts the month anyway — the moon shows as a faint -# glow behind cloud rather than being absent entirely. -# -# This applies to all three phases: full, first-quarter, third-quarter. -# Caption will read "cloud cover — YYYY-MM-DD — NASA SVS Dial-a-Moon". -MOON_CLOUDY_POST_ENABLED=true - # ── Mattermost — daily sunrise upload ───────────────────────────────────────── # mattermost_url, access_token, channel_id go in .env (see bottom of this file). diff --git a/test_moon_composite.py b/test_moon_composite.py index a8ea69d..2d0a3f4 100644 --- a/test_moon_composite.py +++ b/test_moon_composite.py @@ -3,25 +3,20 @@ Simulates what moon_phase_monthly.py does on the night of a full moon: - 1. Round the obs time (22:30 local on 2026-04-29) to the nearest hour. - 2. Fetch the NASA SVS Dial-a-Moon render for that UTC hour. - 3. Load the east camera frame (21-07-00.jpg, captured at 21:07 UTC that night). - 4. Detect atmospheric conditions → compute overlay opacity. - 5. Render: NASA moon + east atmosphere overlay → test_composite_out.jpg. + 1. Fetch the NASA SVS Dial-a-Moon render for 2026-04-29T22:00Z. + 2. Load the east camera frame (21-07-00.jpg) and check for a clear moon. + 3. If clear: render — NASA moon with parallactic angle rotation → test_composite_out.jpg. + 4. If not clear: exit with a message (no fallback image). Run from the sky-cam directory: python3 test_moon_composite.py Requires internet access to reach svs.gsfc.nasa.gov. -If the API is unreachable a procedural moon disc is used as a fallback -so the atmospheric overlay is still visible and testable. """ -import os import pathlib import sys -import tempfile from datetime import datetime, timezone HERE = pathlib.Path(__file__).resolve().parent @@ -30,67 +25,10 @@ sys.path.insert(0, str(HERE)) EAST_FRAME = HERE / '21-07-00.jpg' OUT_PATH = HERE / 'test_composite_out.jpg' -# The east frame is 2026-04-29 21:07 UTC; obs time is 22:30 local → ~21:30 UTC -# (assuming Eastern time UTC-4 in late April). Round to nearest hour → 22:00 UTC. +# Obs time: 22:00 UTC on 2026-04-29 — aligns directly with NASA hourly renders. NASA_FETCH_UTC = datetime(2026, 4, 29, 22, 0, tzinfo=timezone.utc) - -def _fetch_nasa(dt: datetime) -> 'pathlib.Path | None': - try: - import moon_dialamoon - print(f'fetching NASA Dial-a-Moon for {dt.strftime("%Y-%m-%dT%H:00Z")} …') - path = moon_dialamoon.fetch_for_time(dt) - print(f' cached at {path}') - return path - except Exception as e: - print(f' NASA fetch failed: {e}') - return None - - -def _make_procedural_moon(size: int = 2048) -> 'pathlib.Path': - """Fallback: clean grey disc with maria and limb darkening.""" - from PIL import Image, ImageDraw, ImageFilter - import numpy as np - - img = Image.new('RGB', (size, size), (0, 0, 0)) - draw = ImageDraw.Draw(img) - cx = cy = size // 2 - r = int(size * 0.47) - - draw.ellipse([cx - r, cy - r, cx + r, cy + r], fill=(218, 214, 200)) - draw.ellipse([cx - r//3, cy - r//3, cx + r//6, cy + r//5], fill=(170, 167, 154)) - draw.ellipse([cx + r//8, cy - r//5, cx + r//3, cy + r//8], fill=(182, 179, 166)) - draw.ellipse([cx - r//4, cy + r//6, cx + r//8, cy + r//3], fill=(175, 172, 159)) - draw.ellipse([cx - r//2, cy - r//10, cx - r//5, cy + r//4], fill=(185, 182, 169)) - img = img.filter(ImageFilter.GaussianBlur(radius=size // 80)) - - vignette = Image.new('L', (size, size), 0) - vd = ImageDraw.Draw(vignette) - vd.ellipse([cx - r, cy - r, cx + r, cy + r], fill=255) - vignette = vignette.filter(ImageFilter.GaussianBlur(radius=size // 30)) - arr = np.asarray(img).astype(float) - vig = np.asarray(vignette).astype(float) / 255.0 - arr = np.clip(arr * (0.75 + 0.25 * vig)[..., None], 0, 255).astype('uint8') - img = Image.fromarray(arr) - - tmp = tempfile.NamedTemporaryFile(suffix='.png', delete=False) - tmp.close() - img.save(tmp.name) - return pathlib.Path(tmp.name) - - -def _detect_atmosphere(frame_path: str) -> tuple[float | None, float]: - """Run moon_detect and return (quality, opacity).""" - try: - from moon_detect import detect_moon - from moon_phase_monthly import _atmosphere_opacity_from_quality - det = detect_moon(frame_path) - quality = det.quality if det is not None else None - opacity = _atmosphere_opacity_from_quality(quality) - return quality, opacity - except Exception as e: - print(f' detection failed: {e}') - return None, 0.0 +MIN_QUALITY = 0.55 def main(): @@ -103,59 +41,51 @@ def main(): print(f'NASA time : {NASA_FETCH_UTC.strftime("%Y-%m-%dT%H:00Z")}') print() - # 1. Try real NASA image - tmp_to_delete = None - nasa_path = _fetch_nasa(NASA_FETCH_UTC) - if nasa_path is None: - print('falling back to procedural moon disc …') - nasa_path = _make_procedural_moon() - tmp_to_delete = str(nasa_path) - print(f' disc saved to {nasa_path}') + # 1. Check east frame for clear moon detection + print(f'checking moon in {EAST_FRAME.name} ...') + try: + from moon_detect import detect_moon + det = detect_moon(str(EAST_FRAME)) + quality = det.quality if det is not None else None + except Exception as e: + print(f' detection failed: {e}', file=sys.stderr) + sys.exit(2) + + if quality is None or quality < MIN_QUALITY: + print(f' quality={quality} — no clear moon detection — skipping (no fallback)') + sys.exit(0) + + print(f' quality={quality:.3f} — clear shot confirmed') print() - # 2. Atmosphere from east frame - print(f'checking atmosphere in {EAST_FRAME.name} …') - quality, opacity = _detect_atmosphere(str(EAST_FRAME)) - if quality is not None: - print(f' moon quality: {quality:.3f} → atmosphere opacity: {opacity:.2f}') - else: - print(f' no moon detected (overcast) → opacity: {opacity:.2f}') + # 2. Fetch NASA Dial-a-Moon + print(f'fetching NASA Dial-a-Moon for {NASA_FETCH_UTC.strftime("%Y-%m-%dT%H:00Z")} ...') + try: + import moon_dialamoon + nasa_path = moon_dialamoon.fetch_for_time(NASA_FETCH_UTC) + print(f' cached at {nasa_path}') + except Exception as e: + print(f' NASA fetch failed: {e}', file=sys.stderr) + sys.exit(3) print() - # 3. Render + # 3. Render with parallactic angle rotation from moon_composite import render_phase_closeup caption = ( f'Full Moon — April 2026 — ' - f'sky-cam east 2026-04-29 22:30 local — ' + f'sky-cam east 2026-04-29 22:00 UTC — ' f'NASA SVS Dial-a-Moon' ) - print(f'rendering {OUT_PATH.name} …') - try: - render_phase_closeup( - nasa_render_path=str(nasa_path), - out_path=str(OUT_PATH), - output_size=(1920, 1080), - moon_height_pct=0.88, - caption=caption, - east_frame_path=str(EAST_FRAME), - atmosphere_opacity=opacity, - atmosphere_blur=0, - ) - finally: - if tmp_to_delete: - os.unlink(tmp_to_delete) - - print(f'done → {OUT_PATH}') - print() - print(f'opacity={opacity:.2f}:', end=' ') - if opacity == 0.0: - print('clear sky — pure NASA moon on black background') - elif opacity < 0.15: - print('slight haze — moon visible, softly veiled') - elif opacity < 0.40: - print('cloud cover — moon partially obscured') - else: - print('heavy overcast — moon a glow through cloud') + print(f'rendering {OUT_PATH.name} ...') + render_phase_closeup( + nasa_render_path=str(nasa_path), + out_path=str(OUT_PATH), + output_size=(1920, 1080), + moon_height_pct=0.88, + caption=caption, + when_utc=NASA_FETCH_UTC, + ) + print(f'done -> {OUT_PATH}') if __name__ == '__main__': From 3fc77ab75e140cbd4647fa50589d7697ea0ac73a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 1 May 2026 22:32:16 +0000 Subject: [PATCH 3/4] Expand obs window to full dark+moon period; label with illumination% MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit moon_phase.py: add sun_altaz() to compute sun altitude for dark-sky check moon_phase_monthly.py: - Replace fixed 22:00-23:00 window with dynamic dark+moon intervals: sample every 10 min across ±24h of the phase moment, keep blocks where sun < DARK_SKY_SUN_ALT_DEG and moon > MIN_ALTITUDE — covers the full night the moon is actually visible in a dark sky regardless of month - First east frame with quality >= MIN_QUALITY in that window wins - Illumination% computed at the detection time and shown in the image caption and Mattermost message - Remove OBS_TIME_H/M; add DARK_SKY_SUN_ALT_DEG config var sky-cam.conf: replace MOON_OBS_TIME_LOCAL with MOON_DARK_SKY_SUN_ALT_DEG=-6 and explain the sun-altitude threshold options https://claude.ai/code/session_01HkTxpNSTWtViZzxbrbKytR --- moon_phase.py | 16 +++++ moon_phase_monthly.py | 149 ++++++++++++++++++++++-------------------- sky-cam.conf | 26 ++++---- 3 files changed, 109 insertions(+), 82 deletions(-) diff --git a/moon_phase.py b/moon_phase.py index 605a280..9672548 100755 --- a/moon_phase.py +++ b/moon_phase.py @@ -179,6 +179,22 @@ def altaz(when: datetime, lat: float | None = None, lon: float | None = None): return float(alt.degrees), float(az.degrees) +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. diff --git a/moon_phase_monthly.py b/moon_phase_monthly.py index 308f826..6f46a25 100755 --- a/moon_phase_monthly.py +++ b/moon_phase_monthly.py @@ -85,9 +85,7 @@ 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' -_obs_parts = CONF.get('MOON_OBS_TIME_LOCAL', '22:00').split(':') -OBS_TIME_H = int(_obs_parts[0]) -OBS_TIME_M = int(_obs_parts[1]) if len(_obs_parts) > 1 else 0 +DARK_SKY_SUN_ALT_DEG = float(CONF.get('MOON_DARK_SKY_SUN_ALT_DEG', -6.0)) PHASE_SPEC = { @@ -209,6 +207,36 @@ def _output_filename(phase: str, target_utc: datetime) -> str: 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]]: + """Sample every 10 min; return contiguous blocks where sky is dark + (sun < DARK_SKY_SUN_ALT_DEG) and moon is above MIN_ALTITUDE.""" + step = timedelta(minutes=10) + t = search_start + intervals: list[tuple[datetime, datetime]] = [] + seg_start = None + while t <= search_end: + try: + sun_alt, _ = moon_phase_mod.sun_altaz(t) + moon_alt, _ = moon_phase_mod.altaz(t) + except Exception: + t += step + continue + ok = sun_alt < DARK_SKY_SUN_ALT_DEG and 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, search_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] @@ -231,109 +259,89 @@ def run_phase(phase: str, target_utc: datetime | None, cam: str, print(f'target {phase}: {target_utc.isoformat()} ({_format_local(target_utc)})') tz = _local_tz() - target_local = target_utc.astimezone(tz) - # If east-verification is disabled the user wants a post regardless of - # whether east could see the moon that night. Fetch dial-a-moon for the - # exact phase moment, render full-screen, post. Skips all east scanning. 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, target_local, + return _render_and_post(phase, spec, target_utc, target_utc, cam, dry_run, no_upload, out_path, - witness_text='not requiring east verification (set MOON_REQUIRE_EAST_VERIFY=true to require)') + witness_text='east verification disabled') - # ── Fixed observation time on the night of the phase event ────────────── - # Instead of scanning a multi-day window for the best detection, we look - # at a short window around a configured local time (default 22:30) on the - # night of the exact phase. That single timestamp drives both the NASA - # Dial-a-Moon fetch (rounded to the nearest hour) and the atmosphere check. - obs_naive = datetime( - target_local.year, target_local.month, target_local.day, - OBS_TIME_H, OBS_TIME_M, 0, - ) - if hasattr(tz, 'localize'): - obs_utc = tz.localize(obs_naive).astimezone(timezone.utc) - else: - obs_utc = obs_naive.replace(tzinfo=tz).astimezone(timezone.utc) - print(f'obs time: {obs_utc.isoformat()} ({_format_local(obs_utc)})') + # 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) - # Check moon altitude at the observation time - try: - alt_at_obs, _ = moon_phase.altaz(obs_utc) - except Exception as e: - print(f'ERROR: moon_phase.altaz: {e}', file=sys.stderr) - return 2 - print(f'moon altitude at obs time: {alt_at_obs:.1f}°') - - # Scan east frames in the 22:00–23:00 window for any clear moon detection - obs_end = obs_utc + timedelta(hours=1) - obs_dates: list[str] = [] - d = obs_utc.astimezone(tz).date() - while d <= obs_end.astimezone(tz).date(): - obs_dates.append(d.strftime('%Y-%m-%d')) - d += timedelta(days=1) - all_frames = _candidate_frames(cam, obs_dates) - window_frames = [(p, dt) for p, dt in all_frames if obs_utc <= dt <= obs_end] - print(f'east frames in 22:00-23:00 window: {len(window_frames)}') - - if alt_at_obs < MIN_ALTITUDE: - print(f'moon below horizon at obs time ({alt_at_obs:.1f}deg) — skipping') + 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")} ' - f'— moon below horizon at {OBS_TIME_H:02d}:{OBS_TIME_M:02d} local', - f'Adjust MOON_OBS_TIME_LOCAL in sky-cam.conf.', + 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 - # Binary go/no-go: any frame in the window with quality >= MIN_QUALITY? + 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)}') + + # Binary go/no-go: first frame with quality >= MIN_QUALITY wins clear_frame: str | None = None - for fpath, fdt in window_frames: + clear_dt: datetime | None = None + for fpath, fdt in dark_frames: det = detect_moon(fpath) if det is not None and det.quality >= MIN_QUALITY: clear_frame = fpath - offset_min = (fdt - obs_utc).total_seconds() / 60.0 - print(f'clear moon detected: {fpath}') - print(f' quality={det.quality:.3f} offset=+{offset_min:.1f} min') + clear_dt = fdt + print(f'clear moon: {pathlib.Path(fpath).name} quality={det.quality:.3f} ' + f'time={_format_local(fdt)}') break - else: - q = det.quality if det is not None else None - print(f' {fpath}: quality={q} — not clear enough') if clear_frame is None: - print('no clear moon detection in window — skipping (overcast or moon absent)') + 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 the 22:00-23:00 window.', + f'No clear moon detection in {checked} frame(s) during the dark+moon window.', ) return 0 - obs_local_str = _format_local(obs_utc) - witness_text = f'sky-cam {cam} — clear at {obs_local_str} — NASA SVS Dial-a-Moon' + illum_pct = round(moon_phase.illumination(clear_dt) * 100) + print(f'illumination at detection: {illum_pct}%') if dry_run: - print(f'dry-run: would post using NASA image for {obs_utc.isoformat()}') + print(f'dry-run: would post NASA image for {clear_dt.isoformat()} ({illum_pct}% lit)') return 0 return _render_and_post( - phase, spec, target_utc, obs_utc, obs_utc.astimezone(tz), + phase, spec, target_utc, clear_dt, cam, dry_run, no_upload, out_path, - witness_text=witness_text, + witness_text=f'sky-cam {cam} — {illum_pct}% lit — {_format_local(clear_dt)}', ) -def _render_and_post(phase, spec, target_utc, when_utc, local_dt, cam, +def _render_and_post(phase, spec, target_utc, when_utc, cam, dry_run, no_upload, out_path, witness_text): 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)) - # Fetch the NASA SVS Dial-a-Moon render for the hour east captured the - # moon (or the exact phase moment if east-verification is off). The - # render carries the correct phase, libration and crater shadows for - # that UTC moment — the strongest possible match for what east "saw," - # and free of the white-blob limitation. import moon_dialamoon + import moon_phase as _mp try: nasa_path = moon_dialamoon.fetch_for_time(when_utc) except Exception as e: @@ -346,10 +354,11 @@ def _render_and_post(phase, spec, target_utc, when_utc, local_dt, cam, return 4 print(f'dial-a-moon: {nasa_path}') + illum_pct = round(_mp.illumination(when_utc) * 100) from moon_composite import render_phase_closeup caption = ( - f"{spec['label']} — {target_utc.strftime('%B %Y')} — " - f"sky-cam {cam} {witness_text} — render: NASA SVS Dial-a-Moon" + f"{spec['label']} — {illum_pct}% lit — {target_utc.strftime('%B %Y')} — " + f"{witness_text} — NASA SVS Dial-a-Moon" ) render_phase_closeup( str(nasa_path), out_path, diff --git a/sky-cam.conf b/sky-cam.conf index 5a70f7b..a4b4227 100644 --- a/sky-cam.conf +++ b/sky-cam.conf @@ -491,19 +491,21 @@ MOON_TRACK_FPS=12 # output mp4 framerate MOON_TRACK_CRF=24 # output mp4 CRF MOON_TRACK_RETENTION_DAYS=90 # delete tracker mp4s older than this; 0 = forever -# Observation time ──────────────────────────────────────────────────────────── -# Local time that defines the observation window: MOON_OBS_TIME_LOCAL to -# MOON_OBS_TIME_LOCAL+1h. East frames in this window are checked for a clear -# moon detection (quality >= MOON_MIN_QUALITY). If any frame qualifies, the -# NASA Dial-a-Moon image for MOON_OBS_TIME_LOCAL UTC is fetched and posted. -# If no frame shows a clear moon, the month is skipped entirely. +# Dark-sky observation window ───────────────────────────────────────────────── +# The script searches ±24h around the exact phase moment for east frames where: +# 1. The sun is below MOON_DARK_SKY_SUN_ALT_DEG (sky is dark) +# 2. The moon is above MOON_MIN_ALTITUDE_DEG (moon is visible above trees) # -# 22:00 aligns directly with NASA's hourly renders. Full moon and third -# quarter both rise after dark and are visible at this hour. First quarter -# is up only until ~midnight from a new-moon start, so it may not be visible -# at 22:00 depending on the exact date — set MOON_FIRST_QUARTER_ENABLED=false -# if first-quarter posts are consistently missed. -MOON_OBS_TIME_LOCAL=22:00 +# Sun altitude thresholds: +# -6 civil twilight — noticeably dark, bright stars visible (default) +# -12 nautical — horizon barely visible +# -18 astronomical — fully dark, no twilight glow +# +# The first east frame in that window with quality >= MOON_MIN_QUALITY is used. +# Its exact timestamp drives the NASA Dial-a-Moon fetch (rounded to nearest hour) +# and the parallactic angle rotation. The caption shows illumination% at that +# moment. If no clear frame is found, the month is skipped entirely. +MOON_DARK_SKY_SUN_ALT_DEG=-6 # Post delay ────────────────────────────────────────────────────────────────── # How many days after the exact phase to run the post. The post delay gives From 975c9cc2ebff98ede70af5fd4988e0ba4ca9f14b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 1 May 2026 22:38:04 +0000 Subject: [PATCH 4/4] Use real sunset/sunrise for dark window; pick frame closest to exact phase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit moon_phase.py: add sun_events_in_range() — returns actual sunset/sunrise transitions via skyfield almanac, accurate to within a minute moon_phase_monthly.py: - _dark_moon_intervals(): replace sun-altitude threshold sampling with actual sunset/sunrise times; dark window = sunset + DARK_START_MIN to next sunrise; moon-above-horizon check still sampled every 10 min within each night; polar fallback if no sun events found - run_phase(): sort dark-window frames by proximity to exact phase moment before scanning; first clear detection = the frame temporally closest to the moon being precisely full/at quarter, not just the first in time order - Replace DARK_SKY_SUN_ALT_DEG with DARK_START_MIN (default 30 min) sky-cam.conf: replace MOON_DARK_SKY_SUN_ALT_DEG with MOON_DARK_START_MIN=30; update comments to explain ±24h window, sunset+30 start, closest-frame logic, and how the 01:30 and 23:55 edge cases are handled https://claude.ai/code/session_01HkTxpNSTWtViZzxbrbKytR --- moon_phase.py | 17 +++++++ moon_phase_monthly.py | 102 +++++++++++++++++++++++++++++------------- sky-cam.conf | 31 ++++++++----- 3 files changed, 107 insertions(+), 43 deletions(-) diff --git a/moon_phase.py b/moon_phase.py index 9672548..626e242 100755 --- a/moon_phase.py +++ b/moon_phase.py @@ -179,6 +179,23 @@ def altaz(when: datetime, lat: float | None = None, lon: float | None = None): 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, _observer)) + 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() diff --git a/moon_phase_monthly.py b/moon_phase_monthly.py index 6f46a25..5c180ea 100755 --- a/moon_phase_monthly.py +++ b/moon_phase_monthly.py @@ -85,7 +85,7 @@ 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_SKY_SUN_ALT_DEG = float(CONF.get('MOON_DARK_SKY_SUN_ALT_DEG', -6.0)) +DARK_START_MIN = int(CONF.get('MOON_DARK_START_MIN', 30)) PHASE_SPEC = { @@ -212,28 +212,62 @@ def _dark_moon_intervals( search_end: datetime, moon_phase_mod, ) -> list[tuple[datetime, datetime]]: - """Sample every 10 min; return contiguous blocks where sky is dark - (sun < DARK_SKY_SUN_ALT_DEG) and moon is above MIN_ALTITUDE.""" + """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) - t = search_start intervals: list[tuple[datetime, datetime]] = [] - seg_start = None - while t <= search_end: - try: - sun_alt, _ = moon_phase_mod.sun_altaz(t) - moon_alt, _ = moon_phase_mod.altaz(t) - except Exception: - t += step + for night_start, night_end in night_periods: + t = max(night_start, search_start) + end = min(night_end, search_end) + if t >= end: continue - ok = sun_alt < DARK_SKY_SUN_ALT_DEG and 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, search_end)) + 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 @@ -298,19 +332,23 @@ def run_phase(phase: str, target_utc: datetime | None, cam: str, ] print(f'east frames in dark+moon window: {len(dark_frames)}') - # Binary go/no-go: first frame with quality >= MIN_QUALITY wins - clear_frame: str | None = None - clear_dt: datetime | None = None + # 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_dt: datetime | None = None for fpath, fdt in dark_frames: det = detect_moon(fpath) if det is not None and det.quality >= MIN_QUALITY: - clear_frame = fpath - clear_dt = fdt - print(f'clear moon: {pathlib.Path(fpath).name} quality={det.quality:.3f} ' - f'time={_format_local(fdt)}') + best_frame = fpath + 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 clear_frame is None: + if best_frame is None: checked = len(dark_frames) print(f'no clear moon in {checked} dark-window frame(s) — skipping (overcast)') _notify( @@ -319,17 +357,17 @@ def run_phase(phase: str, target_utc: datetime | None, cam: str, ) return 0 - illum_pct = round(moon_phase.illumination(clear_dt) * 100) + 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 {clear_dt.isoformat()} ({illum_pct}% lit)') + 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, clear_dt, + phase, spec, target_utc, best_dt, cam, dry_run, no_upload, out_path, - witness_text=f'sky-cam {cam} — {illum_pct}% lit — {_format_local(clear_dt)}', + witness_text=f'sky-cam {cam} — {illum_pct}% lit — {_format_local(best_dt)}', ) diff --git a/sky-cam.conf b/sky-cam.conf index a4b4227..6737abe 100644 --- a/sky-cam.conf +++ b/sky-cam.conf @@ -492,20 +492,29 @@ MOON_TRACK_CRF=24 # output mp4 CRF MOON_TRACK_RETENTION_DAYS=90 # delete tracker mp4s older than this; 0 = forever # Dark-sky observation window ───────────────────────────────────────────────── -# The script searches ±24h around the exact phase moment for east frames where: -# 1. The sun is below MOON_DARK_SKY_SUN_ALT_DEG (sky is dark) -# 2. The moon is above MOON_MIN_ALTITUDE_DEG (moon is visible above trees) +# The script searches ±24h around the exact phase moment using actual computed +# sunset and sunrise times for the configured location. The dark window for +# each night starts at (sunset + MOON_DARK_START_MIN) and ends at sunrise. +# Within that window, only frames where the moon is above MOON_MIN_ALTITUDE_DEG +# are considered. # -# Sun altitude thresholds: -# -6 civil twilight — noticeably dark, bright stars visible (default) -# -12 nautical — horizon barely visible -# -18 astronomical — fully dark, no twilight glow +# The east camera faces east, so the moon is visible to it from moonrise through +# roughly south — typically from dusk through midnight for a full moon. +# MOON_DARK_START_MIN=30 means the script starts looking 30 min after the sun +# sets, when the sky is dark enough for a clean moon shot but east can still +# catch the moon low on the eastern horizon. # -# The first east frame in that window with quality >= MOON_MIN_QUALITY is used. -# Its exact timestamp drives the NASA Dial-a-Moon fetch (rounded to nearest hour) -# and the parallactic angle rotation. The caption shows illumination% at that +# Of all qualifying frames, the one temporally closest to the exact phase moment +# is used (not the first one). This picks the frame when the moon was most +# precisely full / at quarter. Its timestamp drives the NASA Dial-a-Moon fetch +# and the parallactic angle rotation; the caption shows illumination% at that # moment. If no clear frame is found, the month is skipped entirely. -MOON_DARK_SKY_SUN_ALT_DEG=-6 +# +# If the phase falls early in the day (e.g. 01:30 local), the ±24h window +# covers the previous evening through the following dusk — both nights where +# the moon is essentially full. If the phase falls just before midnight +# (e.g. 23:55), both the same evening and the next morning are included. +MOON_DARK_START_MIN=30 # Post delay ────────────────────────────────────────────────────────────────── # How many days after the exact phase to run the post. The post delay gives