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:
+61
-29
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user