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:
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
Executable
+228
@@ -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
@@ -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
@@ -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).
|
||||||
|
|||||||
Reference in New Issue
Block a user