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
+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)}',
)