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