Use real sunset/sunrise for dark window; pick frame closest to exact phase

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
This commit is contained in:
Claude
2026-05-01 22:38:04 +00:00
parent 3fc77ab75e
commit 975c9cc2eb
3 changed files with 107 additions and 43 deletions
+17
View File
@@ -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()
+70 -32
View File
@@ -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)}',
)
+20 -11
View File
@@ -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