From 318af0dbf54bc0851c618b5eecfdc1a586db663b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 2 May 2026 12:43:03 +0000 Subject: [PATCH 1/3] fix: pass bare topos to sunrise_sunset(), not earth+topos observer skyfield's sunrise_sunset() internally computes ephemeris['earth'] + topos, so passing the already-combined _observer caused a double-add ValueError. Store _topos separately and use it only for almanac calls; _observer (earth + topos) continues to be used for direct .at() observations. https://claude.ai/code/session_01JieSeNQZ3X6fhsb11YyJrK --- moon_phase.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/moon_phase.py b/moon_phase.py index 626e242..2f15187 100755 --- a/moon_phase.py +++ b/moon_phase.py @@ -67,6 +67,7 @@ _EPH_PATH = _here / 'de421.bsp' _ts = None _eph = None _observer = None +_topos = None # bare geographic position; sunrise_sunset() adds earth itself def _lazy(): @@ -75,7 +76,7 @@ def _lazy(): Lets the module be imported by tests and by --help paths even when skyfield is missing or the ephemeris hasn't been downloaded yet. """ - global _ts, _eph, _observer + global _ts, _eph, _observer, _topos if _ts is not None: return from skyfield.api import Loader, wgs84 @@ -85,7 +86,8 @@ def _lazy(): _eph = loader('de421.bsp') else: _eph = loader('de421.bsp') # downloads on first run - _observer = _eph['earth'] + wgs84.latlon(LATITUDE, LONGITUDE) + _topos = wgs84.latlon(LATITUDE, LONGITUDE) + _observer = _eph['earth'] + _topos def _to_utc(when: datetime) -> datetime: @@ -192,7 +194,7 @@ def sun_events_in_range( 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)) + times, values = find_discrete(t0, t1, sunrise_sunset(_eph, _topos)) return [(t.utc_datetime(), bool(v)) for t, v in zip(times, values)] From aa4f699b4335df89124645ebf87939b59f566be6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 2 May 2026 12:48:39 +0000 Subject: [PATCH 2/3] =?UTF-8?q?add=20test=5Flast=5Ffull=5Fmoon.py=20?= =?UTF-8?q?=E2=80=94=20smoke-test=20for=20moon=20phase=20+=20sun=20events?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Finds the most recent full moon (within 35 days), prints illumination, phase angle, alt/az, and parallactic angle, then calls sun_events_in_range() for the surrounding day. The sun_events call is the direct regression path for the sunrise_sunset() observer fix (bare topos, not earth+topos). https://claude.ai/code/session_01JieSeNQZ3X6fhsb11YyJrK --- test_last_full_moon.py | 66 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 test_last_full_moon.py diff --git a/test_last_full_moon.py b/test_last_full_moon.py new file mode 100644 index 0000000..f03a577 --- /dev/null +++ b/test_last_full_moon.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +"""test_last_full_moon.py — smoke-test moon_phase.py against 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() + +Run from the sky-cam directory: + + python3 test_last_full_moon.py +""" + +import sys +import pathlib +from datetime import datetime, timedelta, timezone + +HERE = pathlib.Path(__file__).resolve().parent +sys.path.insert(0, str(HERE)) + +import moon_phase as mp + + +def main() -> int: + now = datetime.now(timezone.utc) + + # Last full moon: search back up to 35 days + 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) + 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}°") + print() + + # Sunrise/sunset for the 24-hour window around the full moon — exercises the 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 found on {day_start.strftime('%Y-%m-%d')} (polar location?)") + + print() + print("OK") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 4f61589ca6f423545c34e82a49564ba93e36cfdd Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 2 May 2026 12:48:50 +0000 Subject: [PATCH 3/3] docs: document test_last_full_moon.py in README Added to the Moon jobs section of Manual operations with a brief description of what the script checks and when it's useful. https://claude.ai/code/session_01JieSeNQZ3X6fhsb11YyJrK --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index d552cc8..f2c378f 100644 --- a/README.md +++ b/README.md @@ -295,8 +295,16 @@ Replace the date list with whatever range you need. Each run produces one `*-fi # Inspect any moon-related stats for a frame 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 +python3 test_last_full_moon.py ``` +`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. + **Posting schedule**: | Job | When the timer fires | When the artifact actually appears |