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