Expand obs window to full dark+moon period; label with illumination%

moon_phase.py: add sun_altaz() to compute sun altitude for dark-sky check

moon_phase_monthly.py:
- Replace fixed 22:00-23:00 window with dynamic dark+moon intervals:
  sample every 10 min across ±24h of the phase moment, keep blocks where
  sun < DARK_SKY_SUN_ALT_DEG and moon > MIN_ALTITUDE — covers the full
  night the moon is actually visible in a dark sky regardless of month
- First east frame with quality >= MIN_QUALITY in that window wins
- Illumination% computed at the detection time and shown in the image
  caption and Mattermost message
- Remove OBS_TIME_H/M; add DARK_SKY_SUN_ALT_DEG config var

sky-cam.conf: replace MOON_OBS_TIME_LOCAL with MOON_DARK_SKY_SUN_ALT_DEG=-6
and explain the sun-altitude threshold options

https://claude.ai/code/session_01HkTxpNSTWtViZzxbrbKytR
This commit is contained in:
Claude
2026-05-01 22:32:16 +00:00
parent a6989a9ea8
commit 3fc77ab75e
3 changed files with 109 additions and 82 deletions
+16
View File
@@ -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.
+79 -70
View File
@@ -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:0023: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,
+14 -12
View File
@@ -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