Switch monthly phase close-ups to NASA SVS Dial-a-Moon at fullscreen

The previous east-flavored composite (tight-crop east region + lunar
texture overlay) was honest but visually limited: 38-px source moon, no
real terminator shadows on quarters, and a faint upscaled-halo
background that was less compelling than just a clean lunar render.

New flow:
- East stays the witness (verifies the moon was visible during the
  collection window) and supplies the timestamp.
- moon_dialamoon.py fetches the NASA SVS Dial-a-Moon render for that
  exact UTC hour. Free, public-domain, real-physics: correct phase,
  libration, and crater shadows for any timestamp.
- moon_composite.render_phase_closeup() places that render at ~92% of
  frame height on a 1920x1080 black background -- the long-telephoto
  look the user asked for.
- One API call per phase event (~36/year), cached forever in
  moon-ref/dialamoon/.

Quarter moons now show real 3D crater shadows along the terminator,
which the prior algorithmic phase-shadow couldn't simulate from a
full-moon reference.

Adds MOON_REQUIRE_EAST_VERIFY=true|false toggle so the user can choose
"only post when east saw it" (default, current behavior) vs "post every
cycle regardless of weather over east."

Drops the install-time lunar reference download (no longer needed) and
the now-unused per-phase phase-shadow path stays in moon_composite.py
for reference / future reuse.

https://claude.ai/code/session_015PBVDESC3KLMbq1LpA6qLn
This commit is contained in:
Claude
2026-05-01 11:54:46 +00:00
parent 5fb7ff09a3
commit 28c3c1dc63
6 changed files with 394 additions and 79 deletions
+61 -29
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""moon_phase_monthly.py — build the monthly moon-phase composite.
"""moon_phase_monthly.py — build the monthly moon-phase close-up.
Handles three phases, controlled by --phase:
@@ -9,16 +9,17 @@ Handles three phases, controlled by --phase:
Algorithm (per phase):
1. Find the most recent occurrence of the target phase (or honour --target).
2. Scan east frames across the collection window (D-Δb .. D+Δa).
3. Filter to frames where:
quality >= MOON_MIN_QUALITY
altitude_deg >= MOON_MIN_ALTITUDE_DEG
illumination is within the phase's illumination band
waxing-state matches the phase target
4. Pick the qualifying frame closest in time to exact phase UTC.
5. Composite the cached lunar texture into it (real sky + halo from east,
borrowed surface detail from the cached reference).
6. Optionally upload to Mattermost.
2. Scan east frames across the collection window (D-Δb .. D+Δa) and pick
the frame closest in time to exact phase UTC where east successfully
detected the moon and standard quality / altitude / illumination
thresholds are met. East is the WITNESS — it confirms you actually had
a chance to see the moon that night.
3. Round east's capture timestamp to the nearest hour and fetch the NASA
SVS Dial-a-Moon render for that hour. This gives a real-physics moon
image with correct phase, libration and crater shadows.
4. Render full-screen on a black background (moon fills ~92% of frame
height), drop a caption naming the phase / month / capture moment /
attribution, and upload to Mattermost.
Geometry note — first-quarter from east is HARD: at first quarter the moon is
up from noon to midnight, but east only sees the eastern sky, so it captures
@@ -81,8 +82,8 @@ MIN_QUALITY = float(CONF.get('MOON_MIN_QUALITY', CONF.get('MOON_FULL_MIN_QUALITY
MIN_ALTITUDE = float(CONF.get('MOON_MIN_ALTITUDE_DEG', CONF.get('MOON_FULL_MIN_ALTITUDE_DEG', 15.0)))
OUT_W = int(CONF.get('MOON_OUTPUT_W', CONF.get('MOON_FULL_OUTPUT_W', 1920)))
OUT_H = int(CONF.get('MOON_OUTPUT_H', CONF.get('MOON_FULL_OUTPUT_H', 1080)))
MOON_PCT = float(CONF.get('MOON_HEIGHT_PCT', CONF.get('MOON_FULL_HEIGHT_PCT', 0.70)))
REF_PATH = CONF.get('MOON_REFERENCE_PATH') or str(_here / 'moon-ref' / 'full-moon.jpg')
MOON_PCT = float(CONF.get('MOON_HEIGHT_PCT', 0.92))
REQUIRE_EAST_VERIFY = CONF.get('MOON_REQUIRE_EAST_VERIFY', 'true').lower() != 'false'
PHASE_SPEC = {
@@ -242,6 +243,16 @@ def run_phase(phase: str, target_utc: datetime | None, cam: str,
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,
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()
@@ -299,8 +310,8 @@ def run_phase(phase: str, target_utc: datetime | None, cam: str,
best = min(qualifying, key=lambda t: abs(t[1] - target_utc))
path, when_utc, det, alt, illum = best
delta_min = (when_utc - target_utc).total_seconds() / 60.0
local_dt = when_utc.astimezone(tz)
delta_min = (when_utc - target_utc).total_seconds() / 60.0
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} '
@@ -309,25 +320,46 @@ def run_phase(phase: str, target_utc: datetime | None, cam: str,
if dry_run:
return 0
if not pathlib.Path(REF_PATH).is_file():
msg = (f'Lunar reference image missing at {REF_PATH}. Re-run install.sh '
f'or drop a high-res full moon JPEG there manually.')
_notify(f'{spec["emoji"]} {spec["label"]} — reference missing', msg)
print(msg, file=sys.stderr)
return 3
return _render_and_post(
phase, spec, target_utc, when_utc, local_dt,
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})',
)
def _render_and_post(phase, spec, target_utc, when_utc, local_dt, 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))
from moon_composite import composite_full_moon
# 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
try:
nasa_path = moon_dialamoon.fetch_for_time(when_utc)
except Exception as e:
msg = (
f'NASA SVS Dial-a-Moon fetch failed for '
f'{when_utc.strftime("%Y-%m-%dT%HZ")}: {e}'
)
_notify(f'{spec["emoji"]} {spec["label"]} — dial-a-moon fetch failed', msg)
print(msg, file=sys.stderr)
return 4
print(f'dial-a-moon: {nasa_path}')
from moon_composite import render_phase_closeup
caption = (
f"{spec['label']}{target_utc.strftime('%B %Y')}"
f"sky-cam {cam} {local_dt.strftime('%Y-%m-%d %H:%M:%S %Z')}"
f"sky-cam {cam} {witness_text} — render: NASA SVS Dial-a-Moon"
)
composite_full_moon(
path, det, when_utc, REF_PATH, out_path,
render_phase_closeup(
str(nasa_path), out_path,
output_size=(OUT_W, OUT_H), moon_height_pct=MOON_PCT,
caption=caption,
)
@@ -336,25 +368,25 @@ def run_phase(phase: str, target_utc: datetime | None, cam: str,
if no_upload:
_notify(
f'{spec["emoji"]} {spec["label"]} {target_utc.strftime("%B %Y")} (built, not posted)',
f'{out_path} picked {local_dt}, Δtarget {delta_min:+.0f} min',
f'{out_path}{witness_text}',
)
return 0
posted = _post_to_mattermost(
out_path,
f"{spec['emoji']} {spec['label']}{target_utc.strftime('%B %Y')}\n"
f"Captured by sky-cam {cam} at {local_dt.strftime('%Y-%m-%d %H:%M:%S %Z')} "
f"({delta_min:+.0f} min from exact {phase}).",
f"sky-cam {cam} {witness_text}.\n"
f"Surface render from NASA SVS Dial-a-Moon for that hour.",
)
if posted:
_notify(
f'{spec["emoji"]} {spec["label"]} {target_utc.strftime("%B %Y")} posted',
f'Picked {local_dt} — Δtarget {delta_min:+.0f} min{out_path}',
f'{witness_text}{out_path}',
)
else:
_notify(
f'FAILED: {spec["emoji"]} {spec["label"]} {target_utc.strftime("%B %Y")} upload',
f'Composite built at {out_path} but Mattermost upload failed.',
f'Image built at {out_path} but Mattermost upload failed.',
)
return 0