Files
sky-cam/moon_phase_monthly.py
T
Claude 3fc77ab75e Expand obs window to full dark+moon period; label with illumination%
moon_phase.py: add sun_altaz() to compute sun altitude for dark-sky check

moon_phase_monthly.py:
- Replace fixed 22:00-23:00 window with dynamic dark+moon intervals:
  sample every 10 min across ±24h of the phase moment, keep blocks where
  sun < DARK_SKY_SUN_ALT_DEG and moon > MIN_ALTITUDE — covers the full
  night the moon is actually visible in a dark sky regardless of month
- First east frame with quality >= MIN_QUALITY in that window wins
- Illumination% computed at the detection time and shown in the image
  caption and Mattermost message
- Remove OBS_TIME_H/M; add DARK_SKY_SUN_ALT_DEG config var

sky-cam.conf: replace MOON_OBS_TIME_LOCAL with MOON_DARK_SKY_SUN_ALT_DEG=-6
and explain the sun-altitude threshold options

https://claude.ai/code/session_01HkTxpNSTWtViZzxbrbKytR
2026-05-01 22:32:16 +00:00

448 lines
16 KiB
Python
Executable File

#!/usr/bin/env python3
"""moon_phase_monthly.py — build the monthly moon-phase close-up.
Handles three phases, controlled by --phase:
full ~100% lit, posted MOON_FULL_POST_DELAY_DAYS after exact full
first-quarter ~50% lit waxing (right half lit in northern hemisphere)
third-quarter ~50% lit waning (left half lit in northern hemisphere)
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) 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
the moon during DAYTIME only (with a bright sky background). The detector is
brightness-based and may often fail to find a daytime moon. Set
MOON_FIRST_QUARTER_ENABLED=false in sky-cam.conf if you'd rather not chase it.
Full moon and third-quarter both rise after dark and stay in east's view —
those should land cleanly most months.
Usage:
moon_phase_monthly.py # auto: run any phase whose post-day = today
moon_phase_monthly.py --phase full # force a single phase
moon_phase_monthly.py --phase third-quarter --target 2026-04-09T11:51:00Z --dry-run
"""
from __future__ import annotations
import argparse
import os
import pathlib
import re
import subprocess
import sys
from datetime import datetime, timedelta, timezone
_here = pathlib.Path(__file__).resolve().parent
sys.path.insert(0, str(_here))
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'))
BASE_DIR = CONF.get('BASE_DIR') or str(_here / 'data')
MOVIES_DIR = CONF.get('MOVIES_DIR') or f'{BASE_DIR}/movies'
SUNRISE_CAM = CONF.get('SUNRISE_CAM', 'east')
TIMEZONE = CONF.get('TIMEZONE', 'UTC')
MIN_QUALITY = float(CONF.get('MOON_MIN_QUALITY', CONF.get('MOON_FULL_MIN_QUALITY', 0.55)))
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', 0.92))
REQUIRE_EAST_VERIFY = CONF.get('MOON_REQUIRE_EAST_VERIFY', 'true').lower() != 'false'
DARK_SKY_SUN_ALT_DEG = float(CONF.get('MOON_DARK_SKY_SUN_ALT_DEG', -6.0))
PHASE_SPEC = {
'full': {
'index': 2,
'label': 'Full Moon',
'emoji': '🌕',
'post_delay': int(CONF.get('MOON_FULL_POST_DELAY_DAYS', 1)),
'enabled_key': 'MOON_FULL_ENABLED',
},
'first-quarter': {
'index': 1,
'label': 'First Quarter (Waxing Half)',
'emoji': '🌓',
'post_delay': int(CONF.get('MOON_QUARTER_POST_DELAY_DAYS', 1)),
'enabled_key': 'MOON_FIRST_QUARTER_ENABLED',
},
'third-quarter': {
'index': 3,
'label': 'Third Quarter (Waning Half)',
'emoji': '🌗',
'post_delay': int(CONF.get('MOON_QUARTER_POST_DELAY_DAYS', 1)),
'enabled_key': 'MOON_THIRD_QUARTER_ENABLED',
},
}
_FRAME_RE = re.compile(r'^(\d{2})-(\d{2})-(\d{2})\.jpg$')
def _local_tz():
try:
import pytz
return pytz.timezone(TIMEZONE)
except Exception:
return timezone.utc
def _frame_local_dt(date_str: str, fname: str):
m = _FRAME_RE.match(fname)
if not m:
return None
h, mn, s = (int(x) for x in m.groups())
y, mo, d = (int(x) for x in date_str.split('-'))
naive = datetime(y, mo, d, h, mn, s)
tz = _local_tz()
if hasattr(tz, 'localize'):
return tz.localize(naive)
return naive.replace(tzinfo=tz)
def _candidate_frames(cam: str, dates: list[str]) -> list[tuple[str, datetime]]:
out = []
for d in dates:
fdir = pathlib.Path(BASE_DIR) / cam / d
if not fdir.is_dir():
continue
for fname in sorted(os.listdir(fdir)):
if not fname.endswith('.jpg'):
continue
local_dt = _frame_local_dt(d, fname)
if local_dt is None:
continue
out.append((str(fdir / fname), local_dt.astimezone(timezone.utc)))
return out
def _format_local(dt_utc: datetime) -> str:
return dt_utc.astimezone(_local_tz()).strftime('%Y-%m-%d %H:%M:%S %Z')
def _notify(title: str, body: str):
try:
subprocess.run([str(_here / 'notify.sh'), title, body], check=False)
except FileNotFoundError:
pass
def _post_to_mattermost(image_path: str, message: str) -> bool:
import requests
base = CONF.get('mattermost_url', '').rstrip('/')
token = CONF.get('access_token', '')
channel_id = CONF.get('channel_id', '')
if not all([base, token, channel_id]):
print('mattermost credentials missing — skipping upload', file=sys.stderr)
return False
headers = {'Authorization': f'Bearer {token}'}
with open(image_path, 'rb') as f:
r = requests.post(
f'{base}/api/v4/files',
headers=headers,
files={'files': f},
data={'channel_id': channel_id},
)
if r.status_code != 201:
print(f'mattermost upload failed: {r.status_code} {r.text}', file=sys.stderr)
return False
file_id = r.json()['file_infos'][0]['id']
r = requests.post(
f'{base}/api/v4/posts',
headers=headers,
json={'channel_id': channel_id, 'message': message, 'file_ids': [file_id]},
)
if r.status_code != 201:
print(f'mattermost post failed: {r.status_code} {r.text}', file=sys.stderr)
return False
return True
def _output_subdir(phase: str) -> str:
return {
'full': 'full-moons',
'first-quarter': 'first-quarter',
'third-quarter': 'third-quarter',
}[phase]
def _output_filename(phase: str, target_utc: datetime) -> str:
slug = {'full': 'full', 'first-quarter': 'first-quarter', 'third-quarter': 'third-quarter'}[phase]
return f"{target_utc.strftime('%Y-%m')}-{slug}.jpg"
def _dark_moon_intervals(
search_start: datetime,
search_end: datetime,
moon_phase_mod,
) -> list[tuple[datetime, datetime]]:
"""Sample every 10 min; return contiguous blocks where sky is dark
(sun < DARK_SKY_SUN_ALT_DEG) and moon is above MIN_ALTITUDE."""
step = timedelta(minutes=10)
t = search_start
intervals: list[tuple[datetime, datetime]] = []
seg_start = None
while t <= search_end:
try:
sun_alt, _ = moon_phase_mod.sun_altaz(t)
moon_alt, _ = moon_phase_mod.altaz(t)
except Exception:
t += step
continue
ok = sun_alt < DARK_SKY_SUN_ALT_DEG and moon_alt >= MIN_ALTITUDE
if ok and seg_start is None:
seg_start = t
elif not ok and seg_start is not None:
intervals.append((seg_start, t))
seg_start = None
t += step
if seg_start is not None:
intervals.append((seg_start, search_end))
return intervals
def run_phase(phase: str, target_utc: datetime | None, cam: str,
dry_run: bool, no_upload: bool, out_path: str | None) -> int:
spec = PHASE_SPEC[phase]
if CONF.get(spec['enabled_key'], 'true').lower() == 'false':
print(f'{spec["enabled_key"]}=false — skipping {phase}')
return 0
import moon_phase
from moon_detect import detect_moon
if target_utc is None:
now = datetime.now(timezone.utc)
events = moon_phase.phase_events_in_range(
now - timedelta(days=45), now, spec['index'])
if not events:
print(f'no recent {phase} found in past 45 days', file=sys.stderr)
return 1
target_utc = events[-1]
print(f'target {phase}: {target_utc.isoformat()} ({_format_local(target_utc)})')
tz = _local_tz()
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,
cam, dry_run, no_upload, out_path,
witness_text='east verification disabled')
# Find every interval within ±24h of the phase moment where the sky is
# dark (sun below civil twilight) and the moon is above the horizon.
search_start = target_utc - timedelta(hours=24)
search_end = target_utc + timedelta(hours=24)
intervals = _dark_moon_intervals(search_start, search_end, moon_phase)
if not intervals:
print('no dark-sky + moon window in ±24h of phase — skipping')
_notify(
f'{spec["emoji"]} {spec["label"]} {target_utc.strftime("%B %Y")} — skipped',
'No dark-sky + moon-above-horizon window found near the phase moment.',
)
return 0
total_h = sum((e - s).total_seconds() / 3600 for s, e in intervals)
print(f'dark+moon window: {len(intervals)} segment(s), {total_h:.1f}h total')
for s, e in intervals:
print(f' {_format_local(s)} -> {_format_local(e)}')
# Collect east frames that fall inside those intervals
search_dates: list[str] = []
d = search_start.astimezone(tz).date()
while d <= search_end.astimezone(tz).date():
search_dates.append(d.strftime('%Y-%m-%d'))
d += timedelta(days=1)
all_frames = _candidate_frames(cam, search_dates)
dark_frames = [
(p, dt) for p, dt in all_frames
if any(s <= dt <= e for s, e in intervals)
]
print(f'east frames in dark+moon window: {len(dark_frames)}')
# Binary go/no-go: first frame with quality >= MIN_QUALITY wins
clear_frame: str | None = None
clear_dt: datetime | None = None
for fpath, fdt in dark_frames:
det = detect_moon(fpath)
if det is not None and det.quality >= MIN_QUALITY:
clear_frame = fpath
clear_dt = fdt
print(f'clear moon: {pathlib.Path(fpath).name} quality={det.quality:.3f} '
f'time={_format_local(fdt)}')
break
if clear_frame is None:
checked = len(dark_frames)
print(f'no clear moon in {checked} dark-window frame(s) — skipping (overcast)')
_notify(
f'{spec["emoji"]} {spec["label"]} {target_utc.strftime("%B %Y")} — skipped',
f'No clear moon detection in {checked} frame(s) during the dark+moon window.',
)
return 0
illum_pct = round(moon_phase.illumination(clear_dt) * 100)
print(f'illumination at detection: {illum_pct}%')
if dry_run:
print(f'dry-run: would post NASA image for {clear_dt.isoformat()} ({illum_pct}% lit)')
return 0
return _render_and_post(
phase, spec, target_utc, clear_dt,
cam, dry_run, no_upload, out_path,
witness_text=f'sky-cam {cam}{illum_pct}% lit — {_format_local(clear_dt)}',
)
def _render_and_post(phase, spec, target_utc, when_utc, 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))
import moon_dialamoon
import moon_phase as _mp
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}')
illum_pct = round(_mp.illumination(when_utc) * 100)
from moon_composite import render_phase_closeup
caption = (
f"{spec['label']}{illum_pct}% lit — {target_utc.strftime('%B %Y')} — "
f"{witness_text} — NASA SVS Dial-a-Moon"
)
render_phase_closeup(
str(nasa_path), out_path,
output_size=(OUT_W, OUT_H), moon_height_pct=MOON_PCT,
caption=caption,
when_utc=when_utc,
)
print(f'wrote {out_path}')
if no_upload:
_notify(
f'{spec["emoji"]} {spec["label"]} {target_utc.strftime("%B %Y")} (built, not posted)',
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"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'{witness_text}{out_path}',
)
else:
_notify(
f'FAILED: {spec["emoji"]} {spec["label"]} {target_utc.strftime("%B %Y")} upload',
f'Image built at {out_path} but Mattermost upload failed.',
)
return 0
def auto_run(cam: str, dry_run: bool, no_upload: bool) -> int:
"""Daily check: run any phase whose post-day equals today (UTC)."""
import moon_phase
today_utc = datetime.now(timezone.utc).date()
ran_any = False
rc = 0
for phase, spec in PHASE_SPEC.items():
if CONF.get(spec['enabled_key'], 'true').lower() == 'false':
print(f'-- {phase}: {spec["enabled_key"]}=false → skip')
continue
events = moon_phase.phase_events_in_range(
datetime.combine(today_utc - timedelta(days=45), datetime.min.time(), tzinfo=timezone.utc),
datetime.now(timezone.utc),
spec['index'])
if not events:
continue
last_event = events[-1]
days_since = (today_utc - last_event.date()).days
if days_since == spec['post_delay']:
print(f'== running {phase} (last event {last_event.date()}, +{spec["post_delay"]} days = today) ==')
sub = run_phase(phase, last_event, cam, dry_run, no_upload, None)
rc = rc or sub
ran_any = True
else:
print(f'-- {phase}: last {last_event.date()}, days_since={days_since}, post_delay={spec["post_delay"]} → skip')
if not ran_any:
print('no phase scheduled for today')
return rc
def main():
p = argparse.ArgumentParser()
p.add_argument('--phase', choices=list(PHASE_SPEC.keys()),
help='Run a single phase regardless of schedule')
p.add_argument('--target', help='Override phase event UTC, ISO 8601 (requires --phase)')
p.add_argument('--cam', default=SUNRISE_CAM)
p.add_argument('--dry-run', action='store_true')
p.add_argument('--no-upload', action='store_true')
p.add_argument('--out', help='Override output path (requires --phase)')
args = p.parse_args()
if args.phase:
target = None
if args.target:
target = datetime.fromisoformat(args.target.replace('Z', '+00:00'))
return run_phase(args.phase, target, args.cam, args.dry_run, args.no_upload, args.out)
return auto_run(args.cam, args.dry_run, args.no_upload)
if __name__ == '__main__':
sys.exit(main())