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
This commit is contained in:
@@ -179,6 +179,22 @@ def altaz(when: datetime, lat: float | None = None, lon: float | None = None):
|
||||
return float(alt.degrees), float(az.degrees)
|
||||
|
||||
|
||||
def sun_altaz(when: datetime, lat: float | None = None, lon: float | None = None):
|
||||
"""Apparent altitude/azimuth of the sun in degrees as seen from (lat, lon)."""
|
||||
_lazy()
|
||||
from skyfield.api import wgs84
|
||||
if lat is None and lon is None:
|
||||
obs = _observer
|
||||
else:
|
||||
obs = _eph['earth'] + wgs84.latlon(
|
||||
LATITUDE if lat is None else lat,
|
||||
LONGITUDE if lon is None else lon,
|
||||
)
|
||||
t = _t(when)
|
||||
alt, az, _ = obs.at(t).observe(_eph['sun']).apparent().altaz()
|
||||
return float(alt.degrees), float(az.degrees)
|
||||
|
||||
|
||||
def parallactic_angle(when: datetime, lat: float | None = None, lon: float | None = None) -> float:
|
||||
"""Parallactic angle in degrees — rotates a moon image so celestial north is up.
|
||||
|
||||
|
||||
+79
-70
@@ -85,9 +85,7 @@ 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'
|
||||
|
||||
_obs_parts = CONF.get('MOON_OBS_TIME_LOCAL', '22:00').split(':')
|
||||
OBS_TIME_H = int(_obs_parts[0])
|
||||
OBS_TIME_M = int(_obs_parts[1]) if len(_obs_parts) > 1 else 0
|
||||
DARK_SKY_SUN_ALT_DEG = float(CONF.get('MOON_DARK_SKY_SUN_ALT_DEG', -6.0))
|
||||
|
||||
|
||||
PHASE_SPEC = {
|
||||
@@ -209,6 +207,36 @@ def _output_filename(phase: str, target_utc: datetime) -> str:
|
||||
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]
|
||||
@@ -231,109 +259,89 @@ def run_phase(phase: str, target_utc: datetime | None, cam: str,
|
||||
print(f'target {phase}: {target_utc.isoformat()} ({_format_local(target_utc)})')
|
||||
|
||||
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,
|
||||
return _render_and_post(phase, spec, target_utc, target_utc,
|
||||
cam, dry_run, no_upload, out_path,
|
||||
witness_text='not requiring east verification (set MOON_REQUIRE_EAST_VERIFY=true to require)')
|
||||
witness_text='east verification disabled')
|
||||
|
||||
# ── Fixed observation time on the night of the phase event ──────────────
|
||||
# Instead of scanning a multi-day window for the best detection, we look
|
||||
# at a short window around a configured local time (default 22:30) on the
|
||||
# night of the exact phase. That single timestamp drives both the NASA
|
||||
# Dial-a-Moon fetch (rounded to the nearest hour) and the atmosphere check.
|
||||
obs_naive = datetime(
|
||||
target_local.year, target_local.month, target_local.day,
|
||||
OBS_TIME_H, OBS_TIME_M, 0,
|
||||
)
|
||||
if hasattr(tz, 'localize'):
|
||||
obs_utc = tz.localize(obs_naive).astimezone(timezone.utc)
|
||||
else:
|
||||
obs_utc = obs_naive.replace(tzinfo=tz).astimezone(timezone.utc)
|
||||
print(f'obs time: {obs_utc.isoformat()} ({_format_local(obs_utc)})')
|
||||
# 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)
|
||||
|
||||
# Check moon altitude at the observation time
|
||||
try:
|
||||
alt_at_obs, _ = moon_phase.altaz(obs_utc)
|
||||
except Exception as e:
|
||||
print(f'ERROR: moon_phase.altaz: {e}', file=sys.stderr)
|
||||
return 2
|
||||
print(f'moon altitude at obs time: {alt_at_obs:.1f}°')
|
||||
|
||||
# Scan east frames in the 22:00–23:00 window for any clear moon detection
|
||||
obs_end = obs_utc + timedelta(hours=1)
|
||||
obs_dates: list[str] = []
|
||||
d = obs_utc.astimezone(tz).date()
|
||||
while d <= obs_end.astimezone(tz).date():
|
||||
obs_dates.append(d.strftime('%Y-%m-%d'))
|
||||
d += timedelta(days=1)
|
||||
all_frames = _candidate_frames(cam, obs_dates)
|
||||
window_frames = [(p, dt) for p, dt in all_frames if obs_utc <= dt <= obs_end]
|
||||
print(f'east frames in 22:00-23:00 window: {len(window_frames)}')
|
||||
|
||||
if alt_at_obs < MIN_ALTITUDE:
|
||||
print(f'moon below horizon at obs time ({alt_at_obs:.1f}deg) — skipping')
|
||||
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")} '
|
||||
f'— moon below horizon at {OBS_TIME_H:02d}:{OBS_TIME_M:02d} local',
|
||||
f'Adjust MOON_OBS_TIME_LOCAL in sky-cam.conf.',
|
||||
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
|
||||
|
||||
# Binary go/no-go: any frame in the window with quality >= MIN_QUALITY?
|
||||
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
|
||||
for fpath, fdt in window_frames:
|
||||
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
|
||||
offset_min = (fdt - obs_utc).total_seconds() / 60.0
|
||||
print(f'clear moon detected: {fpath}')
|
||||
print(f' quality={det.quality:.3f} offset=+{offset_min:.1f} min')
|
||||
clear_dt = fdt
|
||||
print(f'clear moon: {pathlib.Path(fpath).name} quality={det.quality:.3f} '
|
||||
f'time={_format_local(fdt)}')
|
||||
break
|
||||
else:
|
||||
q = det.quality if det is not None else None
|
||||
print(f' {fpath}: quality={q} — not clear enough')
|
||||
|
||||
if clear_frame is None:
|
||||
print('no clear moon detection in window — skipping (overcast or moon absent)')
|
||||
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 the 22:00-23:00 window.',
|
||||
f'No clear moon detection in {checked} frame(s) during the dark+moon window.',
|
||||
)
|
||||
return 0
|
||||
|
||||
obs_local_str = _format_local(obs_utc)
|
||||
witness_text = f'sky-cam {cam} — clear at {obs_local_str} — NASA SVS Dial-a-Moon'
|
||||
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 using NASA image for {obs_utc.isoformat()}')
|
||||
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, obs_utc, obs_utc.astimezone(tz),
|
||||
phase, spec, target_utc, clear_dt,
|
||||
cam, dry_run, no_upload, out_path,
|
||||
witness_text=witness_text,
|
||||
witness_text=f'sky-cam {cam} — {illum_pct}% lit — {_format_local(clear_dt)}',
|
||||
)
|
||||
|
||||
|
||||
def _render_and_post(phase, spec, target_utc, when_utc, local_dt, cam,
|
||||
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))
|
||||
|
||||
# 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
|
||||
import moon_phase as _mp
|
||||
try:
|
||||
nasa_path = moon_dialamoon.fetch_for_time(when_utc)
|
||||
except Exception as e:
|
||||
@@ -346,10 +354,11 @@ def _render_and_post(phase, spec, target_utc, when_utc, local_dt, cam,
|
||||
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']} — {target_utc.strftime('%B %Y')} — "
|
||||
f"sky-cam {cam} {witness_text} — render: NASA SVS Dial-a-Moon"
|
||||
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,
|
||||
|
||||
+14
-12
@@ -491,19 +491,21 @@ MOON_TRACK_FPS=12 # output mp4 framerate
|
||||
MOON_TRACK_CRF=24 # output mp4 CRF
|
||||
MOON_TRACK_RETENTION_DAYS=90 # delete tracker mp4s older than this; 0 = forever
|
||||
|
||||
# Observation time ────────────────────────────────────────────────────────────
|
||||
# Local time that defines the observation window: MOON_OBS_TIME_LOCAL to
|
||||
# MOON_OBS_TIME_LOCAL+1h. East frames in this window are checked for a clear
|
||||
# moon detection (quality >= MOON_MIN_QUALITY). If any frame qualifies, the
|
||||
# NASA Dial-a-Moon image for MOON_OBS_TIME_LOCAL UTC is fetched and posted.
|
||||
# If no frame shows a clear moon, the month is skipped entirely.
|
||||
# Dark-sky observation window ─────────────────────────────────────────────────
|
||||
# The script searches ±24h around the exact phase moment for east frames where:
|
||||
# 1. The sun is below MOON_DARK_SKY_SUN_ALT_DEG (sky is dark)
|
||||
# 2. The moon is above MOON_MIN_ALTITUDE_DEG (moon is visible above trees)
|
||||
#
|
||||
# 22:00 aligns directly with NASA's hourly renders. Full moon and third
|
||||
# quarter both rise after dark and are visible at this hour. First quarter
|
||||
# is up only until ~midnight from a new-moon start, so it may not be visible
|
||||
# at 22:00 depending on the exact date — set MOON_FIRST_QUARTER_ENABLED=false
|
||||
# if first-quarter posts are consistently missed.
|
||||
MOON_OBS_TIME_LOCAL=22:00
|
||||
# Sun altitude thresholds:
|
||||
# -6 civil twilight — noticeably dark, bright stars visible (default)
|
||||
# -12 nautical — horizon barely visible
|
||||
# -18 astronomical — fully dark, no twilight glow
|
||||
#
|
||||
# The first east frame in that window with quality >= MOON_MIN_QUALITY is used.
|
||||
# Its exact timestamp drives the NASA Dial-a-Moon fetch (rounded to nearest hour)
|
||||
# and the parallactic angle rotation. The caption shows illumination% at that
|
||||
# moment. If no clear frame is found, the month is skipped entirely.
|
||||
MOON_DARK_SKY_SUN_ALT_DEG=-6
|
||||
|
||||
# Post delay ──────────────────────────────────────────────────────────────────
|
||||
# How many days after the exact phase to run the post. The post delay gives
|
||||
|
||||
Reference in New Issue
Block a user