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
+102 -55
View File
@@ -1,41 +1,54 @@
#!/usr/bin/env python3
"""test_moon_composite.py — smoke-test atmospheric overlay at three opacity levels.
"""test_moon_composite.py — render a phase composite using real NASA data.
Generates three output images from the same east camera frame (21-07-00.jpg)
to show how the atmospheric overlay looks across the quality spectrum:
Simulates what moon_phase_monthly.py does on the night of a full moon:
test_composite_clear.jpg — opacity 0.00 (quality ≥ 0.85, clear sky)
test_composite_hazy.jpg — opacity 0.20 (quality ~ 0.70, light haze)
test_composite_cloudy.jpg — opacity 0.65 (overcast fallback)
The moon disk in each image is procedurally generated (clean grey sphere,
no camera timestamp). In production it is replaced by the NASA SVS
Dial-a-Moon render for the exact UTC hour east captured the moon.
1. Round the obs time (22:30 local on 2026-04-29) to the nearest hour.
2. Fetch the NASA SVS Dial-a-Moon render for that UTC hour.
3. Load the east camera frame (21-07-00.jpg, captured at 21:07 UTC that night).
4. Detect atmospheric conditions → compute overlay opacity.
5. Render: NASA moon + east atmosphere overlay → test_composite_out.jpg.
Run from the sky-cam directory:
python3 test_moon_composite.py
Requires internet access to reach svs.gsfc.nasa.gov.
If the API is unreachable a procedural moon disc is used as a fallback
so the atmospheric overlay is still visible and testable.
"""
import os
import pathlib
import sys
import tempfile
from datetime import datetime, timezone
HERE = pathlib.Path(__file__).resolve().parent
sys.path.insert(0, str(HERE))
EAST_FRAME = HERE / '21-07-00.jpg'
OUT_PATH = HERE / 'test_composite_out.jpg'
CASES = [
('test_composite_clear.jpg', 0.00, 'clear sky (quality >= 0.85)'),
('test_composite_hazy.jpg', 0.20, 'light haze (quality ~ 0.70)'),
('test_composite_cloudy.jpg', 0.65, 'heavy overcast fallback'),
]
# The east frame is 2026-04-29 21:07 UTC; obs time is 22:30 local → ~21:30 UTC
# (assuming Eastern time UTC-4 in late April). Round to nearest hour → 22:00 UTC.
NASA_FETCH_UTC = datetime(2026, 4, 29, 22, 0, tzinfo=timezone.utc)
def _make_procedural_moon(size: int = 2048) -> 'Image':
"""Clean grey disc with simplified lunar maria and limb darkening."""
def _fetch_nasa(dt: datetime) -> 'pathlib.Path | None':
try:
import moon_dialamoon
print(f'fetching NASA Dial-a-Moon for {dt.strftime("%Y-%m-%dT%H:00Z")}')
path = moon_dialamoon.fetch_for_time(dt)
print(f' cached at {path}')
return path
except Exception as e:
print(f' NASA fetch failed: {e}')
return None
def _make_procedural_moon(size: int = 2048) -> 'pathlib.Path':
"""Fallback: clean grey disc with maria and limb darkening."""
from PIL import Image, ImageDraw, ImageFilter
import numpy as np
@@ -58,7 +71,26 @@ def _make_procedural_moon(size: int = 2048) -> 'Image':
arr = np.asarray(img).astype(float)
vig = np.asarray(vignette).astype(float) / 255.0
arr = np.clip(arr * (0.75 + 0.25 * vig)[..., None], 0, 255).astype('uint8')
return Image.fromarray(arr)
img = Image.fromarray(arr)
tmp = tempfile.NamedTemporaryFile(suffix='.png', delete=False)
tmp.close()
img.save(tmp.name)
return pathlib.Path(tmp.name)
def _detect_atmosphere(frame_path: str) -> tuple[float | None, float]:
"""Run moon_detect and return (quality, opacity)."""
try:
from moon_detect import detect_moon
from moon_phase_monthly import _atmosphere_opacity_from_quality
det = detect_moon(frame_path)
quality = det.quality if det is not None else None
opacity = _atmosphere_opacity_from_quality(quality)
return quality, opacity
except Exception as e:
print(f' detection failed: {e}')
return None, 0.0
def main():
@@ -66,49 +98,64 @@ def main():
print(f'ERROR: missing {EAST_FRAME}', file=sys.stderr)
sys.exit(1)
print('generating procedural moon disc …')
moon_img = _make_procedural_moon(2048)
tmp = tempfile.NamedTemporaryFile(suffix='.png', delete=False)
tmp_moon = tmp.name
tmp.close()
moon_img.save(tmp_moon)
print('=== moon composite test ===')
print(f'east frame : {EAST_FRAME.name} (2026-04-29 21:07 UTC)')
print(f'NASA time : {NASA_FETCH_UTC.strftime("%Y-%m-%dT%H:00Z")}')
print()
# 1. Try real NASA image
tmp_to_delete = None
nasa_path = _fetch_nasa(NASA_FETCH_UTC)
if nasa_path is None:
print('falling back to procedural moon disc …')
nasa_path = _make_procedural_moon()
tmp_to_delete = str(nasa_path)
print(f' disc saved to {nasa_path}')
print()
# 2. Atmosphere from east frame
print(f'checking atmosphere in {EAST_FRAME.name}')
quality, opacity = _detect_atmosphere(str(EAST_FRAME))
if quality is not None:
print(f' moon quality: {quality:.3f} → atmosphere opacity: {opacity:.2f}')
else:
print(f' no moon detected (overcast) → opacity: {opacity:.2f}')
print()
# 3. Render
from moon_composite import render_phase_closeup
caption = (
f'Full Moon — April 2026 — '
f'sky-cam east 2026-04-29 22:30 local — '
f'NASA SVS Dial-a-Moon'
)
print(f'rendering {OUT_PATH.name}')
try:
for filename, opacity, label in CASES:
out = HERE / filename
caption = (
f'Full Moon — April 2026 — '
f'sky-cam east 2026-04-29 21:07 UTC — '
f'NASA SVS Dial-a-Moon [{label}]'
)
print(f'rendering {filename} (opacity={opacity:.2f}) {label}')
render_phase_closeup(
nasa_render_path=tmp_moon,
out_path=str(out),
output_size=(1920, 1080),
moon_height_pct=0.88,
caption=caption,
east_frame_path=str(EAST_FRAME),
atmosphere_opacity=opacity,
atmosphere_blur=0,
)
print(f' -> {out}')
render_phase_closeup(
nasa_render_path=str(nasa_path),
out_path=str(OUT_PATH),
output_size=(1920, 1080),
moon_height_pct=0.88,
caption=caption,
east_frame_path=str(EAST_FRAME),
atmosphere_opacity=opacity,
atmosphere_blur=0,
)
finally:
os.unlink(tmp_moon)
if tmp_to_delete:
os.unlink(tmp_to_delete)
print(f'done → {OUT_PATH}')
print()
print('done. Three outputs:')
for filename, opacity, label in CASES:
print(f' {filename:35s} opacity={opacity:.2f} {label}')
print()
print('What you should see in each:')
print(' clear — NASA moon disk sharp and unobscured on black background')
print(' hazy — same moon but with a soft grey-blue veil over the disk')
print(' (from the thin cloud visible in 21-07-00.jpg)')
print(' cloudy — moon mostly hidden; visible as a bright glow through')
print(' the cloud texture from east\'s April 29 frame')
print(f'opacity={opacity:.2f}:', end=' ')
if opacity == 0.0:
print('clear sky — pure NASA moon on black background')
elif opacity < 0.15:
print('slight haze — moon visible, softly veiled')
elif opacity < 0.40:
print('cloud cover — moon partially obscured')
else:
print('heavy overcast — moon a glow through cloud')
if __name__ == '__main__':