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
+14 -4
View File
@@ -6,7 +6,7 @@ Automated sky / timelapse camera scripts that produce:
- **Four Seasons timelapse** — daily clips sized to each Vivaldi movement's music duration, assembled automatically into per-movement montages (with music + attribution overlay) and a full-year video - **Four Seasons timelapse** — daily clips sized to each Vivaldi movement's music duration, assembled automatically into per-movement montages (with music + attribution overlay) and a full-year video
- **Full-day timelapse** — a fixed-fps timelapse of every image captured that day, kept for a configurable retention window - **Full-day timelapse** — a fixed-fps timelapse of every image captured that day, kept for a configurable retention window
- **Nightly moon-track timelapse** — every east night frame where the moon is visible is cropped around the moon and stitched into a short mp4 (the moon roughly held still while clouds and stars drift past) - **Nightly moon-track timelapse** — every east night frame where the moon is visible is cropped around the moon and stitched into a short mp4 (the moon roughly held still while clouds and stars drift past)
- **Monthly moon-phase close-ups** — one composite per phase (full moon, first quarter, third quarter), framed as if east took it through a 65× telephoto. The sky/halo/parallactic-angle/timing are real-from-east; the lunar surface texture is borrowed from a cached high-res reference (no software can recover detail your camera didn't capture). Posted to Mattermost. - **Monthly moon-phase close-ups** — one full-screen image per phase (full moon, first quarter, third quarter). East acts as the witness, confirming the moon was visible in your sky during the collection window and supplying the timestamp. The image itself is a NASA SVS Dial-a-Moon render for that exact UTC hour — accurate phase, real libration, correct crater shadows — sized to fill the frame (~92% of height) on a black background, the way a long-telephoto shot looks. Posted to Mattermost.
--- ---
@@ -303,12 +303,22 @@ python3 moon_phase.py info 2026-04-29T21:07:00Z
|---|---|---| |---|---|---|
| Sunrise video | `SCHEDULE_SUNRISE` (default 03:00 local) | A few minutes after sunrise + `SUNRISE_POST_MIN` | | Sunrise video | `SCHEDULE_SUNRISE` (default 03:00 local) | A few minutes after sunrise + `SUNRISE_POST_MIN` |
| Moon-track timelapse | `SCHEDULE_MOON_TRACK` (default 02:30 local) | ~5 min after the timer, covers the previous night | | Moon-track timelapse | `SCHEDULE_MOON_TRACK` (default 02:30 local) | ~5 min after the timer, covers the previous night |
| 🌕 Full Moon composite | `SCHEDULE_MOON_PHASE` (default 09:30 local) | 3 days after exact full moon (configurable: `MOON_FULL_POST_DELAY_DAYS`) | | 🌕 Full Moon close-up | `SCHEDULE_MOON_PHASE` (default 09:30 local) | 3 days after exact full moon (configurable: `MOON_FULL_POST_DELAY_DAYS`) |
| 🌓 First Quarter composite | same timer | 2 days after exact first quarter (configurable: `MOON_QUARTER_POST_DELAY_DAYS`) — best-effort, see geometry note below | | 🌓 First Quarter close-up | same timer | 2 days after exact first quarter (configurable: `MOON_QUARTER_POST_DELAY_DAYS`) — best-effort, see geometry note below |
| 🌗 Third Quarter composite | same timer | 2 days after exact third quarter | | 🌗 Third Quarter close-up | same timer | 2 days after exact third quarter |
The moon-phase timer fires every day; the script no-ops on days that aren't a post-day for any phase, so you'll see exactly three posts per lunar cycle in Mattermost (sometimes only two if first quarter detection fails — see geometry note in `sky-cam.conf`). The moon-phase timer fires every day; the script no-ops on days that aren't a post-day for any phase, so you'll see exactly three posts per lunar cycle in Mattermost (sometimes only two if first quarter detection fails — see geometry note in `sky-cam.conf`).
### How the moon close-ups work
The phase posts are **honest composites**: east is the witness, NASA is the photographer.
- East scans the collection window (D-1 .. D+2 for full, D-1 .. D+1 for quarters), runs moon detection on each frame, and picks the moment closest to the exact phase that meets quality / altitude / illumination thresholds.
- That picked moment becomes the timestamp we send to NASA's [SVS Dial-a-Moon](https://svs.gsfc.nasa.gov/api/dialamoon/) — a free public service that returns a real-physics moon render for any UTC hour, complete with correct phase, libration, and crater shadows.
- The render is sized to fill ~92% of a 1920×1080 frame on a black background and posted to Mattermost with a caption listing the witnessed-by-east moment and the NASA attribution.
- One API call per phase event (~36/year), cached forever locally.
- If you'd rather post the NASA render every cycle regardless of weather over east, set `MOON_REQUIRE_EAST_VERIFY=false` in `sky-cam.conf`.
**Check capture status**: **Check capture status**:
```bash ```bash
systemctl --user status sky-cam-capture-east.service systemctl --user status sky-cam-capture-east.service
+5 -19
View File
@@ -243,25 +243,11 @@ if [ "${MOON_TRACK_ENABLED:-true}" = "true" ] || [ "${MOON_FULL_ENABLED:-true}"
fi fi
fi fi
# Lunar reference texture for moon_phase_monthly.py. Default URL is the # NASA SVS Dial-a-Moon images are fetched on demand by moon_dialamoon.py
# Wikipedia "FullMoon2010" by Gregory H. Revera (CC BY-SA 3.0), 3500×3500. # the first time each phase event is processed (~once per phase per month,
# Override MOON_REFERENCE_URL in .env to use a different source — any # ~36 calls/year), cached forever in moon-ref/dialamoon/. No bulk
# high-res photo of a full moon on a black background works. # download needed at install time.
ref_dir="${MOON_REFERENCE_DIR:-$HERE/moon-ref}" mkdir -p "${MOON_DIALAMOON_CACHE_DIR:-$HERE/moon-ref/dialamoon}"
ref_path="${MOON_REFERENCE_PATH:-$ref_dir/full-moon.jpg}"
ref_url="${MOON_REFERENCE_URL:-https://upload.wikimedia.org/wikipedia/commons/e/e1/FullMoon2010.jpg}"
if [ ! -f "$ref_path" ]; then
mkdir -p "$(dirname "$ref_path")"
echo "Downloading lunar reference texture..."
if curl -fsSL -o "$ref_path" "$ref_url"; then
echo " ok → $ref_path"
else
echo " WARNING: lunar reference download failed"
echo " Drop a high-res full-moon JPEG at $ref_path manually,"
echo " or set MOON_REFERENCE_URL in .env and re-run install.sh."
rm -f "$ref_path"
fi
fi
fi fi
# Nightly moon-track job — runs on the SUNRISE_CAM only. # Nightly moon-track job — runs on the SUNRISE_CAM only.
+41
View File
@@ -209,6 +209,47 @@ def composite_full_moon(
return out_path return out_path
def render_phase_closeup(
nasa_render_path: str,
out_path: str,
output_size: tuple[int, int] = (1920, 1080),
moon_height_pct: float = 0.92,
caption: str | None = None,
background: tuple[int, int, int] = (0, 0, 0),
):
"""Full-screen close-up rendering using a NASA SVS Dial-a-Moon image.
The dial-a-moon render already has the correct phase, libration and
crater shadows for the requested timestamp, so we simply size it to fill
the output frame on a black background and add a caption. No east
compositing — the moon dominates the frame the way a 65× telephoto shot
would.
"""
bg = Image.new('RGB', output_size, background)
moon = Image.open(nasa_render_path).convert('RGB')
moon = _square_crop_to_disk(moon)
target = int(round(output_size[1] * moon_height_pct))
target += target % 2
moon_resized = moon.resize((target, target), LANCZOS)
feather = max(3, target // 240)
mask = _disk_mask(target, feather_px=feather)
px = (output_size[0] - target) // 2
py = (output_size[1] - target) // 2
bg.paste(moon_resized, (px, py), mask)
if caption:
draw = ImageDraw.Draw(bg)
draw.text((24, output_size[1] - 44), caption, fill=(0, 0, 0))
draw.text((22, output_size[1] - 46), caption, fill=(220, 220, 220))
Path(out_path).parent.mkdir(parents=True, exist_ok=True)
bg.save(out_path, quality=92)
return out_path
def _cli(): def _cli():
p = argparse.ArgumentParser() p = argparse.ArgumentParser()
p.add_argument('source') p.add_argument('source')
+228
View File
@@ -0,0 +1,228 @@
#!/usr/bin/env python3
"""moon_dialamoon.py — fetch + cache NASA SVS Dial-a-Moon renders.
Dial-a-Moon publishes hourly pre-rendered moon images for the current year
that include:
- exact phase (terminator with real crater shadows)
- real libration (the moon "wobbles" up to ~7° at the limb)
- exact illumination fraction
- apparent diameter
For our purpose we want the image as it would appear to a ground observer at
the requested UTC moment, downloaded once per phase event and cached forever
under $MOON_DIALAMOON_CACHE_DIR.
API: https://svs.gsfc.nasa.gov/api/dialamoon/<ISO 8601 UTC, hour precision>
The response is JSON with an "image" object that carries one or more URLs
(varies by year — keys seen include "url", "tif", "1024", etc.). We pick the
largest reasonable JPEG/PNG variant, downsample to MOON_DIALAMOON_TARGET_PX
on save to keep the cache tidy.
Usage as a library:
from moon_dialamoon import fetch_for_time
path = fetch_for_time(datetime(2026, 4, 29, 21, 0, tzinfo=timezone.utc))
# path is a local cached PNG sized to MOON_DIALAMOON_TARGET_PX
CLI:
python3 moon_dialamoon.py 2026-04-29T21:00 # fetch and print path
python3 moon_dialamoon.py 2026-04-29T21:00 --info # also print metadata
"""
from __future__ import annotations
import argparse
import json
import os
import pathlib
import re
import sys
import urllib.request
from datetime import datetime, timezone
_here = pathlib.Path(__file__).resolve().parent
def _read_conf(path):
conf = {}
try:
with open(path) as f:
for line in f:
line = line.strip()
if not line or line.startswith('#') or '=' not in line:
continue
k, v = line.split('=', 1)
k = k.strip()
v = v.strip()
v = re.sub(r'\s+#.*$', '', v)
v = v.strip('"').strip("'")
m = re.match(r'^\$\{[^}]+:-([^}]*)\}$', v)
if m:
v = m.group(1).strip('"').strip("'")
conf[k] = v
except FileNotFoundError:
pass
return conf
_CONF = _read_conf(_here / 'sky-cam.conf')
_CONF.update(_read_conf(_here / '.env'))
API_BASE = _CONF.get('MOON_DIALAMOON_API', 'https://svs.gsfc.nasa.gov/api/dialamoon')
CACHE_DIR = pathlib.Path(_CONF.get('MOON_DIALAMOON_CACHE_DIR') or (_here / 'moon-ref' / 'dialamoon'))
TARGET_PX = int(_CONF.get('MOON_DIALAMOON_TARGET_PX', 2048))
USER_AGENT = _CONF.get('MOON_DIALAMOON_USER_AGENT', 'sky-cam/1.0 (+https://github.com/outis1one/sky-cam)')
TIMEOUT = int(_CONF.get('MOON_DIALAMOON_TIMEOUT_SEC', 30))
def _hour_key(dt: datetime) -> str:
"""Cache key — ISO hour, no minutes/seconds. Matches API resolution."""
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
dt = dt.astimezone(timezone.utc)
return dt.strftime('%Y-%m-%dT%H:00')
def _cache_path(dt: datetime) -> pathlib.Path:
return CACHE_DIR / f'{_hour_key(dt)}.png'
def _meta_path(dt: datetime) -> pathlib.Path:
return CACHE_DIR / f'{_hour_key(dt)}.json'
def _http_get(url: str, accept: str = '*/*') -> bytes:
req = urllib.request.Request(url, headers={
'User-Agent': USER_AGENT,
'Accept': accept,
})
with urllib.request.urlopen(req, timeout=TIMEOUT) as r:
return r.read()
def _api_query(dt: datetime) -> dict:
url = f'{API_BASE}/{_hour_key(dt)}'
data = _http_get(url, accept='application/json')
return json.loads(data)
def _pick_image_url(meta: dict) -> str | None:
"""Walk the response to find the largest JPG/PNG image URL.
Dial-a-Moon's response shape has shifted over time — different years
publish slightly different keys. We accept any of the documented forms.
"""
img = meta.get('image') or {}
# Candidate URLs in priority order: explicit png/jpg, then anything ending
# in those extensions inside nested fields.
candidates: list[str] = []
if isinstance(img, dict):
for key in ('png', 'jpg', 'jpeg', 'url', '4096', '2048', '1024', '512'):
v = img.get(key)
if isinstance(v, str):
candidates.append(v)
elif isinstance(v, dict):
for vv in v.values():
if isinstance(vv, str):
candidates.append(vv)
# walk all string leaves once more in case the schema changed
for v in img.values():
if isinstance(v, str):
candidates.append(v)
elif isinstance(img, str):
candidates.append(img)
# Prefer png > jpg > tif (we can't decode tif with stdlib, but Pillow can)
def score(u: str) -> int:
u = u.lower()
if u.endswith('.png'):
return 3
if u.endswith('.jpg') or u.endswith('.jpeg'):
return 2
if u.endswith('.tif') or u.endswith('.tiff'):
return 1
return 0
candidates = [c for c in candidates if score(c) > 0]
if not candidates:
return None
candidates.sort(key=score, reverse=True)
return candidates[0]
def fetch_for_time(dt: datetime, force: bool = False) -> pathlib.Path:
"""Return the path to a cached dial-a-moon PNG for hour-of-`dt`.
Downloads + downsamples on first call, cached forever after.
Raises RuntimeError on network / parsing failure.
"""
CACHE_DIR.mkdir(parents=True, exist_ok=True)
cache = _cache_path(dt)
if cache.exists() and not force:
return cache
meta = _api_query(dt)
# Persist the metadata next to the image so we can include phase
# description, libration, etc. in captions later if we want.
_meta_path(dt).write_text(json.dumps(meta, indent=2))
img_url = _pick_image_url(meta)
if not img_url:
raise RuntimeError(
f'Dial-a-Moon API for {_hour_key(dt)} returned no usable image URL.\n'
f'Response keys: {sorted(meta.keys())}'
)
raw = _http_get(img_url)
# Decode + downsample to TARGET_PX so the cache stays manageable
from io import BytesIO
from PIL import Image
try:
LANCZOS = Image.Resampling.LANCZOS
except AttributeError:
LANCZOS = Image.LANCZOS
im = Image.open(BytesIO(raw)).convert('RGB')
if max(im.size) > TARGET_PX:
scale = TARGET_PX / max(im.size)
new_size = (int(im.size[0] * scale), int(im.size[1] * scale))
im = im.resize(new_size, LANCZOS)
im.save(cache, 'PNG')
return cache
def get_metadata(dt: datetime) -> dict | None:
p = _meta_path(dt)
if p.exists():
try:
return json.loads(p.read_text())
except Exception:
return None
return None
def _cli():
p = argparse.ArgumentParser()
p.add_argument('when_utc', help='ISO 8601 UTC, e.g. 2026-04-29T21:00')
p.add_argument('--force', action='store_true')
p.add_argument('--info', action='store_true', help='print metadata too')
args = p.parse_args()
dt = datetime.fromisoformat(args.when_utc.replace('Z', '+00:00'))
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
try:
path = fetch_for_time(dt, force=args.force)
except Exception as e:
print(f'ERROR: {e}', file=sys.stderr)
return 1
print(path)
if args.info:
meta = get_metadata(dt) or {}
for k in ('phase', 'subsolar_lon', 'subsolar_lat', 'subearth_lon',
'subearth_lat', 'posangle', 'distance', 'j2000_ra', 'j2000_dec'):
if k in meta:
print(f' {k}: {meta[k]}')
return 0
if __name__ == '__main__':
sys.exit(_cli())
+61 -29
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/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: Handles three phases, controlled by --phase:
@@ -9,16 +9,17 @@ Handles three phases, controlled by --phase:
Algorithm (per phase): Algorithm (per phase):
1. Find the most recent occurrence of the target phase (or honour --target). 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). 2. Scan east frames across the collection window (D-Δb .. D+Δa) and pick
3. Filter to frames where: the frame closest in time to exact phase UTC where east successfully
quality >= MOON_MIN_QUALITY detected the moon and standard quality / altitude / illumination
altitude_deg >= MOON_MIN_ALTITUDE_DEG thresholds are met. East is the WITNESS — it confirms you actually had
illumination is within the phase's illumination band a chance to see the moon that night.
waxing-state matches the phase target 3. Round east's capture timestamp to the nearest hour and fetch the NASA
4. Pick the qualifying frame closest in time to exact phase UTC. SVS Dial-a-Moon render for that hour. This gives a real-physics moon
5. Composite the cached lunar texture into it (real sky + halo from east, image with correct phase, libration and crater shadows.
borrowed surface detail from the cached reference). 4. Render full-screen on a black background (moon fills ~92% of frame
6. Optionally upload to Mattermost. 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 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 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))) 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_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))) 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))) MOON_PCT = float(CONF.get('MOON_HEIGHT_PCT', 0.92))
REF_PATH = CONF.get('MOON_REFERENCE_PATH') or str(_here / 'moon-ref' / 'full-moon.jpg') REQUIRE_EAST_VERIFY = CONF.get('MOON_REQUIRE_EAST_VERIFY', 'true').lower() != 'false'
PHASE_SPEC = { PHASE_SPEC = {
@@ -242,6 +243,16 @@ def run_phase(phase: str, target_utc: datetime | None, cam: str,
tz = _local_tz() tz = _local_tz()
target_local = target_utc.astimezone(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 = [] dates = []
for i in range(-spec['window_before'], spec['window_after'] + 1): for i in range(-spec['window_before'], spec['window_after'] + 1):
d = (target_local + timedelta(days=i)).date() 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)) best = min(qualifying, key=lambda t: abs(t[1] - target_utc))
path, when_utc, det, alt, illum = best path, when_utc, det, alt, illum = best
delta_min = (when_utc - target_utc).total_seconds() / 60.0
local_dt = when_utc.astimezone(tz) local_dt = when_utc.astimezone(tz)
delta_min = (when_utc - target_utc).total_seconds() / 60.0
print(f'picked: {path}') print(f'picked: {path}')
print(f' when_utc={when_utc.isoformat()} local={local_dt} ' print(f' when_utc={when_utc.isoformat()} local={local_dt} '
f'altitude={alt:.1f}° illum={illum:.4f} quality={det.quality:.3f} ' 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: if dry_run:
return 0 return 0
if not pathlib.Path(REF_PATH).is_file(): return _render_and_post(
msg = (f'Lunar reference image missing at {REF_PATH}. Re-run install.sh ' phase, spec, target_utc, when_utc, local_dt,
f'or drop a high-res full moon JPEG there manually.') cam, dry_run, no_upload, out_path,
_notify(f'{spec["emoji"]} {spec["label"]} — reference missing', msg) witness_text=f'witnessed at {local_dt.strftime("%Y-%m-%d %H:%M:%S %Z")} '
print(msg, file=sys.stderr) f'({delta_min:+.0f} min from exact {phase})',
return 3 )
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: if out_path is None:
out_dir = pathlib.Path(MOVIES_DIR) / cam / _output_subdir(phase) out_dir = pathlib.Path(MOVIES_DIR) / cam / _output_subdir(phase)
out_dir.mkdir(parents=True, exist_ok=True) out_dir.mkdir(parents=True, exist_ok=True)
out_path = str(out_dir / _output_filename(phase, target_utc)) 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 = ( caption = (
f"{spec['label']}{target_utc.strftime('%B %Y')}" 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( render_phase_closeup(
path, det, when_utc, REF_PATH, out_path, str(nasa_path), out_path,
output_size=(OUT_W, OUT_H), moon_height_pct=MOON_PCT, output_size=(OUT_W, OUT_H), moon_height_pct=MOON_PCT,
caption=caption, caption=caption,
) )
@@ -336,25 +368,25 @@ def run_phase(phase: str, target_utc: datetime | None, cam: str,
if no_upload: if no_upload:
_notify( _notify(
f'{spec["emoji"]} {spec["label"]} {target_utc.strftime("%B %Y")} (built, not posted)', 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 return 0
posted = _post_to_mattermost( posted = _post_to_mattermost(
out_path, out_path,
f"{spec['emoji']} {spec['label']}{target_utc.strftime('%B %Y')}\n" 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"sky-cam {cam} {witness_text}.\n"
f"({delta_min:+.0f} min from exact {phase}).", f"Surface render from NASA SVS Dial-a-Moon for that hour.",
) )
if posted: if posted:
_notify( _notify(
f'{spec["emoji"]} {spec["label"]} {target_utc.strftime("%B %Y")} posted', 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: else:
_notify( _notify(
f'FAILED: {spec["emoji"]} {spec["label"]} {target_utc.strftime("%B %Y")} upload', 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 return 0
+45 -27
View File
@@ -52,12 +52,13 @@
# a tracked sequence, stitches into an mp4 # a tracked sequence, stitches into an mp4
# #
# moon-phase-monthly.sh — daily at SCHEDULE_MOON_PHASE (SUNRISE_CAM only) # moon-phase-monthly.sh — daily at SCHEDULE_MOON_PHASE (SUNRISE_CAM only)
# no-ops except on phase post-days, when it picks # no-ops except on phase post-days, when it
# the best east frame from the collection window, # confirms east saw the moon during the
# composites a high-res lunar texture into it, # collection window, fetches NASA SVS Dial-a-Moon
# and posts to Mattermost. Handles full moon, # for that exact UTC hour, renders the moon
# first quarter (waxing half), and third quarter # full-screen on a black background, and posts to
# (waning half). # Mattermost. Handles full moon, first quarter
# (waxing half), and third quarter (waning half).
# #
# #
# install.sh — run once at setup, and again if this file changes # install.sh — run once at setup, and again if this file changes
@@ -448,19 +449,23 @@ AMBIENT_RETENTION_DAYS=30 # global default; 0 = keep forever
# 🌕 Full Moon posts D + MOON_FULL_POST_DELAY_DAYS # 🌕 Full Moon posts D + MOON_FULL_POST_DELAY_DAYS
# 🌓 First Quarter posts D + MOON_QUARTER_POST_DELAY_DAYS # 🌓 First Quarter posts D + MOON_QUARTER_POST_DELAY_DAYS
# 🌗 Third Quarter posts D + MOON_QUARTER_POST_DELAY_DAYS # 🌗 Third Quarter posts D + MOON_QUARTER_POST_DELAY_DAYS
# Each picks the frame closest in time to the # East acts as the WITNESS — it confirms the moon
# exact phase moment that meets quality / altitude # was actually visible in your sky during the
# / illumination thresholds, composites the # collection window and supplies the timestamp.
# cached lunar texture into it (sky/halo/angle # The image itself is a NASA SVS Dial-a-Moon
# real from east, surface detail borrowed), # render for that exact UTC hour, sized to fill
# uploads to Mattermost. Output: # the frame on a black background (like the look
# of a long-telephoto shot). Output:
# $MOVIES_DIR/<cam>/full-moons/YYYY-MM-full.jpg # $MOVIES_DIR/<cam>/full-moons/YYYY-MM-full.jpg
# $MOVIES_DIR/<cam>/first-quarter/YYYY-MM-first-quarter.jpg # $MOVIES_DIR/<cam>/first-quarter/YYYY-MM-first-quarter.jpg
# $MOVIES_DIR/<cam>/third-quarter/YYYY-MM-third-quarter.jpg # $MOVIES_DIR/<cam>/third-quarter/YYYY-MM-third-quarter.jpg
# #
# Honest-by-design: the surface texture is not from your camera (no software # Honest-by-design: a 38-px white blob from a wide-field IP camera cannot be
# can recover detail your camera didn't capture). The framing — sky color, # enhanced into crater detail. The image you see in Mattermost is a NASA
# halo, parallactic angle, exact moment of capture — is all real-from-east. # render for the exact moment east captured the moon — the strongest possible
# match for what your sky actually looked like, with full real-physics
# crater shadows, libration, and phase. East's role is verifying that you
# had a clear view of the moon that night.
# #
# Geometry caveat: east only sees the eastern sky. At first quarter the moon # Geometry caveat: east only sees the eastern sky. At first quarter the moon
# is up only from noon to midnight, transiting south at sunset, so east only # is up only from noon to midnight, transiting south at sunset, so east only
@@ -475,6 +480,11 @@ MOON_FULL_ENABLED=true
MOON_FIRST_QUARTER_ENABLED=true # set false if daytime-detection misses are noisy MOON_FIRST_QUARTER_ENABLED=true # set false if daytime-detection misses are noisy
MOON_THIRD_QUARTER_ENABLED=true MOON_THIRD_QUARTER_ENABLED=true
# Set false to skip east verification entirely and post the NASA render for
# the exact phase moment regardless of clouds / camera outage. Default true
# means "only post when east actually saw the moon that month."
MOON_REQUIRE_EAST_VERIFY=true
# Nightly tracker tuning ────────────────────────────────────────────────────── # Nightly tracker tuning ──────────────────────────────────────────────────────
MOON_TRACK_CROP_PX=480 # pixels — box size around the moon (source coords) MOON_TRACK_CROP_PX=480 # pixels — box size around the moon (source coords)
MOON_TRACK_FPS=12 # output mp4 framerate MOON_TRACK_FPS=12 # output mp4 framerate
@@ -486,25 +496,33 @@ MOON_TRACK_RETENTION_DAYS=90 # delete tracker mp4s older than this; 0 = fo
# nights to be on disk, then runs the morning of D+3. # nights to be on disk, then runs the morning of D+3.
MOON_FULL_POST_DELAY_DAYS=3 MOON_FULL_POST_DELAY_DAYS=3
# Quarter (half-moon) tuning ──────────────────────────────────────────────────
# 2 = waits for D-1, D, D+1 nights, runs the morning of D+2.
MOON_QUARTER_POST_DELAY_DAYS=2
MOON_QUARTER_MIN_ILLUMINATION=0.40 # ~50% ± 10% covers the day around exact quarter
MOON_QUARTER_MAX_ILLUMINATION=0.65
# Frame-acceptance thresholds — a candidate must beat all three to qualify. # Frame-acceptance thresholds — a candidate must beat all three to qualify.
# Lower = more permissive (accept hazier nights / lower moon). If you find # Lower = more permissive (accept hazier nights / lower moon). If you find
# the script never finds a clear shot, loosen these. # the script never finds a clear shot, loosen these.
MOON_FULL_MIN_QUALITY=0.55 # 0..1 from moon_detect (roundness × halo × isolation) MOON_MIN_QUALITY=0.55 # 0..1 from moon_detect (roundness × halo × isolation)
MOON_FULL_MIN_ALTITUDE_DEG=15 # below this the moon is in trees / on the horizon MOON_MIN_ALTITUDE_DEG=15 # below this the moon is in trees / on the horizon
MOON_FULL_MIN_ILLUMINATION=0.95 # 0..1 — ~95% lit covers ±2 days from exact full MOON_FULL_MIN_ILLUMINATION=0.95 # 0..1 — ~95% lit covers ±2 days from exact full
# Output frame. Default 1920×1080 to match the sunrise videos. # Output frame. Default 1920×1080 to match the sunrise videos.
MOON_FULL_OUTPUT_W=1920 MOON_OUTPUT_W=1920
MOON_FULL_OUTPUT_H=1080 MOON_OUTPUT_H=1080
MOON_FULL_HEIGHT_PCT=0.70 # moon disk fills this fraction of frame height MOON_HEIGHT_PCT=0.92 # moon disk fills this fraction of frame height (full-screen feel)
# Lunar reference texture — downloaded once by install.sh, reused forever. # NASA SVS Dial-a-Moon ────────────────────────────────────────────────────────
# Override MOON_REFERENCE_URL in .env to use a different image. Any high-res # Hourly pre-rendered moon images, free, public domain, real physics
# photo of a full moon on a black background works. Default is the Wikipedia # (correct phase, libration, crater shadows for any UTC hour).
# "FullMoon2010" by Gregory H. Revera (CC BY-SA 3.0), 3500×3500. # https://svs.gsfc.nasa.gov/api/dialamoon/<ISO-DATE>
#MOON_REFERENCE_URL=https://upload.wikimedia.org/wikipedia/commons/e/e1/FullMoon2010.jpg # We hit it once per phase event (~36 calls/year), cache the result forever.
#MOON_REFERENCE_DIR="$SCRIPT_DIR/moon-ref" #MOON_DIALAMOON_API=https://svs.gsfc.nasa.gov/api/dialamoon
#MOON_REFERENCE_PATH="$MOON_REFERENCE_DIR/full-moon.jpg" #MOON_DIALAMOON_CACHE_DIR="$SCRIPT_DIR/moon-ref/dialamoon"
MOON_DIALAMOON_TARGET_PX=2048 # cached PNG longest side; downsampled on save
MOON_DIALAMOON_TIMEOUT_SEC=30
# ── Mattermost — daily sunrise upload ───────────────────────────────────────── # ── Mattermost — daily sunrise upload ─────────────────────────────────────────
# mattermost_url, access_token, channel_id go in .env (see bottom of this file). # mattermost_url, access_token, channel_id go in .env (see bottom of this file).