From 985f4883a88db3e8cec85e0879c75100d8308f3a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 2 May 2026 12:52:08 +0000 Subject: [PATCH 1/4] update test_last_full_moon.py to produce the full moon image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Now runs the complete production pipeline without east-frame verification: find last full moon → fetch NASA Dial-a-Moon render → render_phase_closeup → test_last_full_moon_out.jpg with caption (phase, % lit, local time, NASA attribution). Also retains the sun_events_in_range() call as a regression check for the sunrise_sunset() bare-topos fix. https://claude.ai/code/session_01JieSeNQZ3X6fhsb11YyJrK --- test_last_full_moon.py | 73 +++++++++++++++++++++++++++++++----------- 1 file changed, 54 insertions(+), 19 deletions(-) diff --git a/test_last_full_moon.py b/test_last_full_moon.py index f03a577..24dbe73 100644 --- a/test_last_full_moon.py +++ b/test_last_full_moon.py @@ -1,10 +1,15 @@ #!/usr/bin/env python3 -"""test_last_full_moon.py — smoke-test moon_phase.py against the most recent full moon. +"""test_last_full_moon.py — produce a full-moon close-up for the most recent full moon. -Exercises: - - full_moons_in_range() — phase detection - - sun_events_in_range() — sunrise/sunset almanac (requires bare topos, not earth+topos) - - illumination() / altaz() / parallactic_angle() +Runs the same NASA Dial-a-Moon fetch + render_phase_closeup pipeline that +moon-phase-monthly.sh uses, but bypasses the east-frame witness step so it +works any time without needing captured frames. + +Also exercises sun_events_in_range() (the sunrise/sunset almanac call that +requires a bare topos, not an earth+topos observer) as a regression check +for that fix. + +Output: test_last_full_moon_out.jpg in the sky-cam directory. Run from the sky-cam directory: @@ -18,45 +23,75 @@ from datetime import datetime, timedelta, timezone HERE = pathlib.Path(__file__).resolve().parent sys.path.insert(0, str(HERE)) -import moon_phase as mp +OUT_PATH = HERE / 'test_last_full_moon_out.jpg' def main() -> int: + import moon_phase as mp + import moon_dialamoon + from moon_composite import render_phase_closeup + now = datetime.now(timezone.utc) - # Last full moon: search back up to 35 days + # ── Find the most recent full moon ──────────────────────────────────────── candidates = mp.full_moons_in_range(now - timedelta(days=35), now) if not candidates: print("ERROR: no full moon found in the last 35 days", file=sys.stderr) return 1 fm = candidates[-1] - print(f"last full moon (UTC) : {fm.strftime('%Y-%m-%dT%H:%M:%SZ')}") - print(f"local : {mp._format_local(fm)}") - - # Moon stats at exact full moon moment illum = mp.illumination(fm) + illum_pct = round(illum * 100) pa = mp.phase_angle(fm) alt, az = mp.altaz(fm) para = mp.parallactic_angle(fm) - print(f"illumination : {illum:.4f} ({illum*100:.1f}%)") - print(f"phase angle : {pa:.2f}° (0° = full)") - print(f"altitude / azimuth : {alt:.2f}° / {az:.2f}°") - print(f"parallactic angle : {para:.2f}°") + local_str = mp._format_local(fm) + + print(f"last full moon (UTC) : {fm.strftime('%Y-%m-%dT%H:%M:%SZ')}") + print(f"local : {local_str}") + print(f"illumination : {illum:.4f} ({illum_pct}%)") + print(f"phase angle : {pa:.2f}° (0° = full)") + print(f"altitude / azimuth : {alt:.2f}° / {az:.2f}°") + print(f"parallactic angle : {para:.2f}°") print() - # Sunrise/sunset for the 24-hour window around the full moon — exercises the fix + # ── Sunrise/sunset for that day (regression check for topos fix) ────────── day_start = fm.replace(hour=0, minute=0, second=0, microsecond=0) day_end = day_start + timedelta(days=1) events = mp.sun_events_in_range(day_start, day_end) if events: - print(f"sun events on {day_start.strftime('%Y-%m-%d')} (UTC):") + print(f"sun events on {day_start.strftime('%Y-%m-%d')} UTC:") for t, is_rise in events: label = "sunrise" if is_rise else "sunset " - print(f" {label} {t.strftime('%H:%M:%S')} UTC ({mp._format_local(t)})") + print(f" {label} {t.strftime('%H:%M:%S')} UTC ({mp._format_local(t)})") else: - print(f"no sun events found on {day_start.strftime('%Y-%m-%d')} (polar location?)") + print(f"no sun events on {day_start.strftime('%Y-%m-%d')} (polar location?)") + print() + # ── Fetch NASA Dial-a-Moon render for that hour ─────────────────────────── + print(f"fetching NASA Dial-a-Moon for {fm.strftime('%Y-%m-%dT%HZ')} ...") + try: + nasa_path = moon_dialamoon.fetch_for_time(fm) + except Exception as e: + print(f"ERROR: dial-a-moon fetch failed: {e}", file=sys.stderr) + return 2 + print(f"dial-a-moon cache : {nasa_path}") + print() + + # ── Render full-screen composite ────────────────────────────────────────── + caption = ( + f"Full Moon — {illum_pct}% lit — {fm.strftime('%B %Y')} — " + f"{local_str} — NASA SVS Dial-a-Moon" + ) + render_phase_closeup( + str(nasa_path), + str(OUT_PATH), + output_size=(1920, 1080), + moon_height_pct=0.92, + caption=caption, + when_utc=fm, + ) + print(f"wrote {OUT_PATH}") print() print("OK") return 0 From d8bf01f86fc2089f335b3bc36524699fd7cbd598 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 2 May 2026 12:52:21 +0000 Subject: [PATCH 2/4] docs: update test_last_full_moon.py entry to match full image pipeline Describes all six steps the script now performs: full moon lookup, moon stats, sun events regression check, NASA fetch, composite render, and output path. https://claude.ai/code/session_01JieSeNQZ3X6fhsb11YyJrK --- README.md | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index f2c378f..f1842fe 100644 --- a/README.md +++ b/README.md @@ -296,14 +296,22 @@ Replace the date list with whatever range you need. Each run produces one `*-fi python3 moon_detect.py BASE_DIR/east/2026-04-29/21-07-00.jpg --debug /tmp/dbg.png python3 moon_phase.py info 2026-04-29T21:07:00Z -# Smoke-test moon ephemeris + sunrise/sunset almanac against the last full moon +# End-to-end test: fetch NASA render + produce composite for the last full moon python3 test_last_full_moon.py +# Output: test_last_full_moon_out.jpg ``` -`test_last_full_moon.py` finds the most recent full moon, prints illumination, phase angle, -altitude/azimuth, and parallactic angle, then lists the sunrise and sunset for that day. -It exercises the full skyfield stack including the `sun_events_in_range()` almanac call — -useful after updating skyfield or changing observer config. +`test_last_full_moon.py` runs the same pipeline as `moon-phase-monthly.sh --phase full` but +skips the east-frame witness step, so it works any time without captured frames. It: + +1. Finds the most recent full moon (within 35 days) +2. Prints illumination, phase angle, alt/az, and parallactic angle +3. Lists sunrise/sunset for that day via `sun_events_in_range()` — regression check for the bare-topos fix +4. Fetches the NASA SVS Dial-a-Moon render for that hour (cached under `moon-ref/dialamoon/`) +5. Renders a 1920×1080 composite with the phase label, % lit, local time, and NASA attribution +6. Writes `test_last_full_moon_out.jpg` to the sky-cam directory + +Useful after updating skyfield, changing `LATITUDE`/`LONGITUDE`, or touching the compositing code. **Posting schedule**: From a470879d1ec8bc32fe9d53f166d098b65715d375 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 2 May 2026 12:53:14 +0000 Subject: [PATCH 3/4] simplify test_last_full_moon.py: delegate to run_phase() with no_upload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes the bypass of east-frame verification — the script now runs the complete production pipeline (dark-window filtering, moon detection on east frames, NASA Dial-a-Moon fetch, render_phase_closeup) by calling mpm.run_phase() with no_upload=True and a fixed test output path. Retains the explicit sun_events_in_range() call as a regression check for the bare-topos fix before the main pipeline runs. https://claude.ai/code/session_01JieSeNQZ3X6fhsb11YyJrK --- test_last_full_moon.py | 86 +++++++++++------------------------------- 1 file changed, 22 insertions(+), 64 deletions(-) diff --git a/test_last_full_moon.py b/test_last_full_moon.py index 24dbe73..583c941 100644 --- a/test_last_full_moon.py +++ b/test_last_full_moon.py @@ -1,15 +1,14 @@ #!/usr/bin/env python3 -"""test_last_full_moon.py — produce a full-moon close-up for the most recent full moon. +"""test_last_full_moon.py — run the full moon pipeline for the most recent full moon. -Runs the same NASA Dial-a-Moon fetch + render_phase_closeup pipeline that -moon-phase-monthly.sh uses, but bypasses the east-frame witness step so it -works any time without needing captured frames. +Identical to `moon-phase-monthly.sh --phase full --no-upload` except the +output is written to test_last_full_moon_out.jpg in the sky-cam directory +instead of the normal movies tree. -Also exercises sun_events_in_range() (the sunrise/sunset almanac call that -requires a bare topos, not an earth+topos observer) as a regression check -for that fix. - -Output: test_last_full_moon_out.jpg in the sky-cam directory. +Includes east-frame detection, dark-window filtering, NASA Dial-a-Moon +fetch, and render_phase_closeup — the complete production path. +Also calls sun_events_in_range() explicitly as a regression check for +the sunrise_sunset() bare-topos fix. Run from the sky-cam directory: @@ -28,73 +27,32 @@ OUT_PATH = HERE / 'test_last_full_moon_out.jpg' def main() -> int: import moon_phase as mp - import moon_dialamoon - from moon_composite import render_phase_closeup + import moon_phase_monthly as mpm + # ── Regression check: sun_events_in_range() (bare-topos fix) ───────────── now = datetime.now(timezone.utc) - - # ── Find the most recent full moon ──────────────────────────────────────── candidates = mp.full_moons_in_range(now - timedelta(days=35), now) if not candidates: print("ERROR: no full moon found in the last 35 days", file=sys.stderr) return 1 fm = candidates[-1] - illum = mp.illumination(fm) - illum_pct = round(illum * 100) - pa = mp.phase_angle(fm) - alt, az = mp.altaz(fm) - para = mp.parallactic_angle(fm) - local_str = mp._format_local(fm) - - print(f"last full moon (UTC) : {fm.strftime('%Y-%m-%dT%H:%M:%SZ')}") - print(f"local : {local_str}") - print(f"illumination : {illum:.4f} ({illum_pct}%)") - print(f"phase angle : {pa:.2f}° (0° = full)") - print(f"altitude / azimuth : {alt:.2f}° / {az:.2f}°") - print(f"parallactic angle : {para:.2f}°") - print() - - # ── Sunrise/sunset for that day (regression check for topos fix) ────────── day_start = fm.replace(hour=0, minute=0, second=0, microsecond=0) - day_end = day_start + timedelta(days=1) - events = mp.sun_events_in_range(day_start, day_end) - if events: - print(f"sun events on {day_start.strftime('%Y-%m-%d')} UTC:") - for t, is_rise in events: - label = "sunrise" if is_rise else "sunset " - print(f" {label} {t.strftime('%H:%M:%S')} UTC ({mp._format_local(t)})") - else: - print(f"no sun events on {day_start.strftime('%Y-%m-%d')} (polar location?)") + events = mp.sun_events_in_range(day_start, day_start + timedelta(days=1)) + print(f"sun events on {day_start.strftime('%Y-%m-%d')} UTC (topos fix check):") + for t, is_rise in events: + print(f" {'sunrise' if is_rise else 'sunset '} {t.strftime('%H:%M:%S')} UTC") print() - # ── Fetch NASA Dial-a-Moon render for that hour ─────────────────────────── - print(f"fetching NASA Dial-a-Moon for {fm.strftime('%Y-%m-%dT%HZ')} ...") - try: - nasa_path = moon_dialamoon.fetch_for_time(fm) - except Exception as e: - print(f"ERROR: dial-a-moon fetch failed: {e}", file=sys.stderr) - return 2 - print(f"dial-a-moon cache : {nasa_path}") - print() - - # ── Render full-screen composite ────────────────────────────────────────── - caption = ( - f"Full Moon — {illum_pct}% lit — {fm.strftime('%B %Y')} — " - f"{local_str} — NASA SVS Dial-a-Moon" + # ── Full production pipeline ────────────────────────────────────────────── + return mpm.run_phase( + phase='full', + target_utc=fm, + cam=mpm.SUNRISE_CAM, + dry_run=False, + no_upload=True, + out_path=str(OUT_PATH), ) - render_phase_closeup( - str(nasa_path), - str(OUT_PATH), - output_size=(1920, 1080), - moon_height_pct=0.92, - caption=caption, - when_utc=fm, - ) - print(f"wrote {OUT_PATH}") - print() - print("OK") - return 0 if __name__ == "__main__": From 54f342224a8e1dd6deef393d98a6543d5810f0f6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 2 May 2026 12:53:25 +0000 Subject: [PATCH 4/4] docs: update test_last_full_moon.py README entry for full pipeline Clarifies the script runs the complete production path including east verification, and is equivalent to moon-phase-monthly.sh --phase full --no-upload with a fixed output path. https://claude.ai/code/session_01JieSeNQZ3X6fhsb11YyJrK --- README.md | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index f1842fe..09794a1 100644 --- a/README.md +++ b/README.md @@ -301,15 +301,12 @@ python3 test_last_full_moon.py # Output: test_last_full_moon_out.jpg ``` -`test_last_full_moon.py` runs the same pipeline as `moon-phase-monthly.sh --phase full` but -skips the east-frame witness step, so it works any time without captured frames. It: - -1. Finds the most recent full moon (within 35 days) -2. Prints illumination, phase angle, alt/az, and parallactic angle -3. Lists sunrise/sunset for that day via `sun_events_in_range()` — regression check for the bare-topos fix -4. Fetches the NASA SVS Dial-a-Moon render for that hour (cached under `moon-ref/dialamoon/`) -5. Renders a 1920×1080 composite with the phase label, % lit, local time, and NASA attribution -6. Writes `test_last_full_moon_out.jpg` to the sky-cam directory +`test_last_full_moon.py` is equivalent to `moon-phase-monthly.sh --phase full --no-upload` for +the most recent full moon, with the output redirected to `test_last_full_moon_out.jpg`. It runs +the complete production pipeline: dark-window filtering, east-frame moon detection, NASA +Dial-a-Moon fetch, and `render_phase_closeup` with the full caption. It also calls +`sun_events_in_range()` explicitly before the main pipeline as a regression check for the +bare-topos fix. Useful after updating skyfield, changing `LATITUDE`/`LONGITUDE`, or touching the compositing code.