Use fixed obs time for NASA fetch and atmosphere check

Previous approach: scan a 3-day window, find best moon detection,
use that timestamp.  Problems: fuzzy composite (multi-day scan could
pick a frame far from actual full moon), no clear tie between the
NASA render and a specific observable moment.

New approach — MOON_OBS_TIME_LOCAL (default 22:30 local):
  On the night of the exact phase event, look at east frames in a
  ±MOON_OBS_WINDOW_MIN (default 30 min) window around the configured
  time.  The frame closest to that time determines atmosphere opacity;
  the same time (rounded to hour) drives the NASA Dial-a-Moon fetch.
  This gives one definitive moment per phase per month.

moon_phase_monthly.py:
  - PHASE_SPEC stripped to just post_delay + enabled_key (all window/
    illumination filtering removed — obs time is the only selector)
  - run_phase() replaced with obs-time logic; opacity derived from
    detect_moon quality on that single frame
  - MOON_FULL_POST_DELAY_DAYS / MOON_QUARTER_POST_DELAY_DAYS default
    changed to 1 (post morning after the phase, frames already on disk)

moon_composite.py:
  - atmosphere blur default: output_width//10 (192px) → output_width//20
    (96px) so cloud shapes survive the blur

sky-cam.conf:
  - MOON_OBS_TIME_LOCAL=22:30, MOON_OBS_WINDOW_MIN=30
  - Post delay defaults updated to 1

test_moon_composite.py:
  - Tries real NASA SVS Dial-a-Moon API first; procedural disc only as
    fallback if API unreachable
  - Runs detect_moon on the east frame to compute real opacity
  - Single output (test_composite_out.jpg)

https://claude.ai/code/session_01HkTxpNSTWtViZzxbrbKytR
This commit is contained in:
Claude
2026-05-01 22:03:57 +00:00
parent c09b872f52
commit 9e2852fd73
4 changed files with 208 additions and 170 deletions
+81 -104
View File
@@ -88,6 +88,11 @@ REQUIRE_EAST_VERIFY = CONF.get('MOON_REQUIRE_EAST_VERIFY', 'true').lower() != 'f
ATMOSPHERE_BLUR = int(CONF.get('MOON_ATMOSPHERE_BLUR', 0))
CLOUDY_POST_ENABLED = CONF.get('MOON_CLOUDY_POST_ENABLED', 'true').lower() != 'false'
_obs_parts = CONF.get('MOON_OBS_TIME_LOCAL', '22:30').split(':')
OBS_TIME_H = int(_obs_parts[0])
OBS_TIME_M = int(_obs_parts[1]) if len(_obs_parts) > 1 else 0
OBS_WINDOW_MIN = int(CONF.get('MOON_OBS_WINDOW_MIN', 30))
def _atmosphere_opacity_from_quality(quality: float | None) -> float:
"""Map moon detection quality to atmospheric overlay opacity.
@@ -115,39 +120,21 @@ PHASE_SPEC = {
'index': 2,
'label': 'Full Moon',
'emoji': '🌕',
# ±4% of target (100%): accept 96100% illumination
'illum_min': float(CONF.get('MOON_FULL_MIN_ILLUMINATION', 0.96)),
'illum_max': 1.01,
'waxing': None,
'window_before': int(CONF.get('MOON_FULL_WINDOW_BEFORE_DAYS', 1)),
'window_after': int(CONF.get('MOON_FULL_WINDOW_AFTER_DAYS', 2)),
'post_delay': int(CONF.get('MOON_FULL_POST_DELAY_DAYS', 3)),
'post_delay': int(CONF.get('MOON_FULL_POST_DELAY_DAYS', 1)),
'enabled_key': 'MOON_FULL_ENABLED',
},
'first-quarter': {
'index': 1,
'label': 'First Quarter (Waxing Half)',
'emoji': '🌓',
# ±4% of target (50%): accept 4654% illumination, waxing only
'illum_min': float(CONF.get('MOON_QUARTER_MIN_ILLUMINATION', 0.46)),
'illum_max': float(CONF.get('MOON_QUARTER_MAX_ILLUMINATION', 0.54)),
'waxing': True,
'window_before': int(CONF.get('MOON_QUARTER_WINDOW_BEFORE_DAYS', 1)),
'window_after': int(CONF.get('MOON_QUARTER_WINDOW_AFTER_DAYS', 1)),
'post_delay': int(CONF.get('MOON_QUARTER_POST_DELAY_DAYS', 2)),
'post_delay': int(CONF.get('MOON_QUARTER_POST_DELAY_DAYS', 1)),
'enabled_key': 'MOON_FIRST_QUARTER_ENABLED',
},
'third-quarter': {
'index': 3,
'label': 'Third Quarter (Waning Half)',
'emoji': '🌗',
# ±4% of target (50%): accept 4654% illumination, waning only
'illum_min': float(CONF.get('MOON_QUARTER_MIN_ILLUMINATION', 0.46)),
'illum_max': float(CONF.get('MOON_QUARTER_MAX_ILLUMINATION', 0.54)),
'waxing': False,
'window_before': int(CONF.get('MOON_QUARTER_WINDOW_BEFORE_DAYS', 1)),
'window_after': int(CONF.get('MOON_QUARTER_WINDOW_AFTER_DAYS', 1)),
'post_delay': int(CONF.get('MOON_QUARTER_POST_DELAY_DAYS', 2)),
'post_delay': int(CONF.get('MOON_QUARTER_POST_DELAY_DAYS', 1)),
'enabled_key': 'MOON_THIRD_QUARTER_ENABLED',
},
}
@@ -280,99 +267,89 @@ def run_phase(phase: str, target_utc: datetime | None, cam: str,
cam, dry_run, no_upload, out_path,
witness_text='not requiring east verification (set MOON_REQUIRE_EAST_VERIFY=true to require)')
dates = []
for i in range(-spec['window_before'], spec['window_after'] + 1):
d = (target_local + timedelta(days=i)).date()
dates.append(d.strftime('%Y-%m-%d'))
print(f'scanning dates: {dates}')
# ── 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)})')
candidates = _candidate_frames(cam, dates)
print(f'frame count in window: {len(candidates)}')
if not candidates:
msg = (
f'No frames for {cam} in window {dates[0]}..{dates[-1]} '
f'around {phase} {target_utc.strftime("%Y-%m-%d %H:%MZ")}.'
# 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}°')
# Gather east frames in the ±OBS_WINDOW_MIN window
obs_start = obs_utc - timedelta(minutes=OBS_WINDOW_MIN)
obs_end = obs_utc + timedelta(minutes=OBS_WINDOW_MIN)
obs_dates: list[str] = []
d = obs_start.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_start <= dt <= obs_end]
print(f'east frames in ±{OBS_WINDOW_MIN} min window: {len(window_frames)}')
# Determine atmosphere opacity from the frame closest to obs_utc
east_frame: str | None = None
opacity = 0.0
if alt_at_obs < MIN_ALTITUDE:
print('moon below horizon at obs time — posting clean NASA image')
_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. Posting clean NASA render.',
)
_notify(f'{spec["emoji"]} {spec["label"]} — no frames available', msg)
print(msg)
return 0
elif window_frames:
best_f = min(window_frames, key=lambda t: abs(t[1] - obs_utc))
east_frame, best_dt = best_f
det = detect_moon(east_frame)
quality = det.quality if det is not None else None
opacity = _atmosphere_opacity_from_quality(quality)
offset_min = (best_dt - obs_utc).total_seconds() / 60.0
print(f'atmosphere frame: {east_frame}')
print(f' quality={quality} opacity={opacity:.2f} '
f'offset={offset_min:+.1f} min from obs time')
else:
print('no east frames in obs window — posting clean NASA image')
qualifying = []
above_horizon = [] # frames where moon is up but quality/illum check failed
for path, utc_dt in candidates:
try:
alt, _ = moon_phase.altaz(utc_dt)
except Exception as e:
print(f'ERROR: moon_phase.altaz failed: {e}', file=sys.stderr)
return 2
if alt < MIN_ALTITUDE:
continue
det = detect_moon(path)
if det is None or det.quality < MIN_QUALITY:
above_horizon.append((path, utc_dt, det))
continue
illum = moon_phase.illumination(utc_dt)
if illum < spec['illum_min'] or illum > spec['illum_max']:
continue
if spec['waxing'] is not None:
if moon_phase.waxing(utc_dt) != spec['waxing']:
continue
qualifying.append((path, utc_dt, det, alt, illum))
# Label atmospheric conditions for the caption
if opacity == 0.0:
atm_label = 'clear sky'
elif opacity < 0.15:
atm_label = 'slight haze'
elif opacity < 0.40:
atm_label = 'cloud cover'
else:
atm_label = 'heavy overcast'
print(f'qualifying frames: {len(qualifying)} above-horizon fallback pool: {len(above_horizon)}')
if not qualifying:
# No frame met quality + illumination thresholds — likely overcast.
# If MOON_CLOUDY_POST_ENABLED, use the above-horizon frame closest to
# the exact phase moment as the atmosphere source and post with a
# heavy cloud overlay so the month is still represented.
if above_horizon and CLOUDY_POST_ENABLED:
best_cloudy = min(above_horizon, key=lambda t: abs(t[1] - target_utc))
path, _when, det = best_cloudy
quality = det.quality if det is not None else None
opacity = _atmosphere_opacity_from_quality(quality)
print(f'overcast fallback: {path} quality={quality} opacity={opacity:.2f}')
witness = f'cloud cover — {target_utc.strftime("%Y-%m-%d")} — NASA SVS Dial-a-Moon'
if dry_run:
return 0
return _render_and_post(
phase, spec, target_utc, target_utc,
target_utc.astimezone(tz),
cam, dry_run, no_upload, out_path,
witness_text=witness,
east_frame_path=path,
atmosphere_opacity=opacity,
)
msg = (
f'No {phase} frame in window {dates[0]}..{dates[-1]} '
f'(quality≥{MIN_QUALITY}, altitude≥{MIN_ALTITUDE}°, '
f'illumination {spec["illum_min"]:.2f}-{spec["illum_max"]:.2f}) '
f'and no above-horizon frames for cloudy fallback.'
)
_notify(f'{spec["emoji"]} {spec["label"]} — skipped {target_utc.strftime("%B %Y")}', msg)
print(msg)
return 0
best = min(qualifying, key=lambda t: abs(t[1] - target_utc))
path, when_utc, det, alt, illum = best
local_dt = when_utc.astimezone(tz)
delta_min = (when_utc - target_utc).total_seconds() / 60.0
opacity = _atmosphere_opacity_from_quality(det.quality)
print(f'picked: {path}')
print(f' when_utc={when_utc.isoformat()} local={local_dt} '
f'altitude={alt:.1f} illum={illum:.4f} quality={det.quality:.3f} '
f'opacity={opacity:.2f} delta={delta_min:+.1f} min')
obs_local_str = _format_local(obs_utc)
witness_text = (
f'sky-cam {cam}{atm_label} at {obs_local_str} — NASA SVS Dial-a-Moon'
)
if dry_run:
print(f'dry-run: would post with opacity={opacity:.2f} ({atm_label})')
return 0
return _render_and_post(
phase, spec, target_utc, when_utc, local_dt,
phase, spec, target_utc, obs_utc, obs_utc.astimezone(tz),
cam, dry_run, no_upload, out_path,
witness_text=f'witnessed at {local_dt.strftime("%Y-%m-%d %H:%M:%S %Z")} '
f'({delta_min:+.0f} min from exact {phase})',
east_frame_path=path,
witness_text=witness_text,
east_frame_path=east_frame,
atmosphere_opacity=opacity,
)