Use real sunset/sunrise for dark window; pick frame closest to exact phase
moon_phase.py: add sun_events_in_range() — returns actual sunset/sunrise transitions via skyfield almanac, accurate to within a minute moon_phase_monthly.py: - _dark_moon_intervals(): replace sun-altitude threshold sampling with actual sunset/sunrise times; dark window = sunset + DARK_START_MIN to next sunrise; moon-above-horizon check still sampled every 10 min within each night; polar fallback if no sun events found - run_phase(): sort dark-window frames by proximity to exact phase moment before scanning; first clear detection = the frame temporally closest to the moon being precisely full/at quarter, not just the first in time order - Replace DARK_SKY_SUN_ALT_DEG with DARK_START_MIN (default 30 min) sky-cam.conf: replace MOON_DARK_SKY_SUN_ALT_DEG with MOON_DARK_START_MIN=30; update comments to explain ±24h window, sunset+30 start, closest-frame logic, and how the 01:30 and 23:55 edge cases are handled https://claude.ai/code/session_01HkTxpNSTWtViZzxbrbKytR
This commit is contained in:
@@ -179,6 +179,23 @@ def altaz(when: datetime, lat: float | None = None, lon: float | None = None):
|
|||||||
return float(alt.degrees), float(az.degrees)
|
return float(alt.degrees), float(az.degrees)
|
||||||
|
|
||||||
|
|
||||||
|
def sun_events_in_range(
|
||||||
|
search_start: datetime,
|
||||||
|
search_end: datetime,
|
||||||
|
) -> list[tuple[datetime, bool]]:
|
||||||
|
"""Return every sunrise/sunset transition between search_start and search_end.
|
||||||
|
|
||||||
|
Each item is (utc_datetime, is_rise): is_rise=True for sunrise, False for sunset.
|
||||||
|
Uses skyfield's almanac — accurate to within a minute at the configured location.
|
||||||
|
"""
|
||||||
|
_lazy()
|
||||||
|
from skyfield.almanac import find_discrete, sunrise_sunset
|
||||||
|
t0 = _t(search_start)
|
||||||
|
t1 = _t(search_end)
|
||||||
|
times, values = find_discrete(t0, t1, sunrise_sunset(_eph, _observer))
|
||||||
|
return [(t.utc_datetime(), bool(v)) for t, v in zip(times, values)]
|
||||||
|
|
||||||
|
|
||||||
def sun_altaz(when: datetime, lat: float | None = None, lon: float | None = None):
|
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)."""
|
"""Apparent altitude/azimuth of the sun in degrees as seen from (lat, lon)."""
|
||||||
_lazy()
|
_lazy()
|
||||||
|
|||||||
+70
-32
@@ -85,7 +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))
|
MOON_PCT = float(CONF.get('MOON_HEIGHT_PCT', 0.92))
|
||||||
REQUIRE_EAST_VERIFY = CONF.get('MOON_REQUIRE_EAST_VERIFY', 'true').lower() != 'false'
|
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))
|
DARK_START_MIN = int(CONF.get('MOON_DARK_START_MIN', 30))
|
||||||
|
|
||||||
|
|
||||||
PHASE_SPEC = {
|
PHASE_SPEC = {
|
||||||
@@ -212,28 +212,62 @@ def _dark_moon_intervals(
|
|||||||
search_end: datetime,
|
search_end: datetime,
|
||||||
moon_phase_mod,
|
moon_phase_mod,
|
||||||
) -> list[tuple[datetime, datetime]]:
|
) -> list[tuple[datetime, datetime]]:
|
||||||
"""Sample every 10 min; return contiguous blocks where sky is dark
|
"""Return contiguous blocks where the sky is dark and the moon is visible.
|
||||||
(sun < DARK_SKY_SUN_ALT_DEG) and moon is above MIN_ALTITUDE."""
|
|
||||||
|
'Dark' means after (sunset + DARK_START_MIN) and before the following
|
||||||
|
sunrise, based on actual computed sunset/sunrise times. Within each night
|
||||||
|
block we sample every 10 min and keep only the sub-intervals where the
|
||||||
|
moon is above MIN_ALTITUDE_DEG. Falls back to sampling sun altitude
|
||||||
|
directly if skyfield can't find sunrise/sunset events (e.g. polar summer).
|
||||||
|
"""
|
||||||
|
# Get actual sunset/sunrise events; widen the window a bit to catch events
|
||||||
|
# that fall right at the boundary.
|
||||||
|
events = moon_phase_mod.sun_events_in_range(
|
||||||
|
search_start - timedelta(hours=2),
|
||||||
|
search_end + timedelta(hours=2),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build night periods from real sunset/sunrise times.
|
||||||
|
night_periods: list[tuple[datetime, datetime]] = []
|
||||||
|
for i, (evt_t, is_rise) in enumerate(events):
|
||||||
|
if is_rise:
|
||||||
|
continue # only care about sunsets here
|
||||||
|
dark_start = evt_t + timedelta(minutes=DARK_START_MIN)
|
||||||
|
# The night ends at the next sunrise (or search_end if none found).
|
||||||
|
next_rise = next((t for t, r in events[i + 1:] if r), None)
|
||||||
|
dark_end = next_rise if next_rise is not None else search_end
|
||||||
|
if dark_start < dark_end:
|
||||||
|
night_periods.append((dark_start, dark_end))
|
||||||
|
|
||||||
|
# Polar fallback: no sunset/sunrise events found.
|
||||||
|
if not night_periods:
|
||||||
|
night_periods = [(search_start, search_end)]
|
||||||
|
|
||||||
|
# Within each night, sample every 10 min to find where moon is high enough.
|
||||||
step = timedelta(minutes=10)
|
step = timedelta(minutes=10)
|
||||||
t = search_start
|
|
||||||
intervals: list[tuple[datetime, datetime]] = []
|
intervals: list[tuple[datetime, datetime]] = []
|
||||||
seg_start = None
|
for night_start, night_end in night_periods:
|
||||||
while t <= search_end:
|
t = max(night_start, search_start)
|
||||||
try:
|
end = min(night_end, search_end)
|
||||||
sun_alt, _ = moon_phase_mod.sun_altaz(t)
|
if t >= end:
|
||||||
moon_alt, _ = moon_phase_mod.altaz(t)
|
|
||||||
except Exception:
|
|
||||||
t += step
|
|
||||||
continue
|
continue
|
||||||
ok = sun_alt < DARK_SKY_SUN_ALT_DEG and moon_alt >= MIN_ALTITUDE
|
seg_start = None
|
||||||
if ok and seg_start is None:
|
while t <= end:
|
||||||
seg_start = t
|
try:
|
||||||
elif not ok and seg_start is not None:
|
moon_alt, _ = moon_phase_mod.altaz(t)
|
||||||
intervals.append((seg_start, t))
|
except Exception:
|
||||||
seg_start = None
|
t += step
|
||||||
t += step
|
continue
|
||||||
if seg_start is not None:
|
ok = moon_alt >= MIN_ALTITUDE
|
||||||
intervals.append((seg_start, search_end))
|
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, end))
|
||||||
|
|
||||||
return intervals
|
return intervals
|
||||||
|
|
||||||
|
|
||||||
@@ -298,19 +332,23 @@ def run_phase(phase: str, target_utc: datetime | None, cam: str,
|
|||||||
]
|
]
|
||||||
print(f'east frames in dark+moon window: {len(dark_frames)}')
|
print(f'east frames in dark+moon window: {len(dark_frames)}')
|
||||||
|
|
||||||
# Binary go/no-go: first frame with quality >= MIN_QUALITY wins
|
# Sort by proximity to exact phase moment so the first clear frame we find
|
||||||
clear_frame: str | None = None
|
# is the one temporally closest to the moon being precisely full/quarter.
|
||||||
clear_dt: datetime | None = None
|
dark_frames.sort(key=lambda x: abs(x[1] - target_utc))
|
||||||
|
|
||||||
|
best_frame: str | None = None
|
||||||
|
best_dt: datetime | None = None
|
||||||
for fpath, fdt in dark_frames:
|
for fpath, fdt in dark_frames:
|
||||||
det = detect_moon(fpath)
|
det = detect_moon(fpath)
|
||||||
if det is not None and det.quality >= MIN_QUALITY:
|
if det is not None and det.quality >= MIN_QUALITY:
|
||||||
clear_frame = fpath
|
best_frame = fpath
|
||||||
clear_dt = fdt
|
best_dt = fdt
|
||||||
print(f'clear moon: {pathlib.Path(fpath).name} quality={det.quality:.3f} '
|
offset_min = int((fdt - target_utc).total_seconds() / 60)
|
||||||
f'time={_format_local(fdt)}')
|
print(f'best frame: {pathlib.Path(fpath).name} quality={det.quality:.3f} '
|
||||||
|
f'time={_format_local(fdt)} offset={offset_min:+d} min from exact phase')
|
||||||
break
|
break
|
||||||
|
|
||||||
if clear_frame is None:
|
if best_frame is None:
|
||||||
checked = len(dark_frames)
|
checked = len(dark_frames)
|
||||||
print(f'no clear moon in {checked} dark-window frame(s) — skipping (overcast)')
|
print(f'no clear moon in {checked} dark-window frame(s) — skipping (overcast)')
|
||||||
_notify(
|
_notify(
|
||||||
@@ -319,17 +357,17 @@ def run_phase(phase: str, target_utc: datetime | None, cam: str,
|
|||||||
)
|
)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
illum_pct = round(moon_phase.illumination(clear_dt) * 100)
|
illum_pct = round(moon_phase.illumination(best_dt) * 100)
|
||||||
print(f'illumination at detection: {illum_pct}%')
|
print(f'illumination at detection: {illum_pct}%')
|
||||||
|
|
||||||
if dry_run:
|
if dry_run:
|
||||||
print(f'dry-run: would post NASA image for {clear_dt.isoformat()} ({illum_pct}% lit)')
|
print(f'dry-run: would post NASA image for {best_dt.isoformat()} ({illum_pct}% lit)')
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
return _render_and_post(
|
return _render_and_post(
|
||||||
phase, spec, target_utc, clear_dt,
|
phase, spec, target_utc, best_dt,
|
||||||
cam, dry_run, no_upload, out_path,
|
cam, dry_run, no_upload, out_path,
|
||||||
witness_text=f'sky-cam {cam} — {illum_pct}% lit — {_format_local(clear_dt)}',
|
witness_text=f'sky-cam {cam} — {illum_pct}% lit — {_format_local(best_dt)}',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+20
-11
@@ -492,20 +492,29 @@ MOON_TRACK_CRF=24 # output mp4 CRF
|
|||||||
MOON_TRACK_RETENTION_DAYS=90 # delete tracker mp4s older than this; 0 = forever
|
MOON_TRACK_RETENTION_DAYS=90 # delete tracker mp4s older than this; 0 = forever
|
||||||
|
|
||||||
# Dark-sky observation window ─────────────────────────────────────────────────
|
# Dark-sky observation window ─────────────────────────────────────────────────
|
||||||
# The script searches ±24h around the exact phase moment for east frames where:
|
# The script searches ±24h around the exact phase moment using actual computed
|
||||||
# 1. The sun is below MOON_DARK_SKY_SUN_ALT_DEG (sky is dark)
|
# sunset and sunrise times for the configured location. The dark window for
|
||||||
# 2. The moon is above MOON_MIN_ALTITUDE_DEG (moon is visible above trees)
|
# each night starts at (sunset + MOON_DARK_START_MIN) and ends at sunrise.
|
||||||
|
# Within that window, only frames where the moon is above MOON_MIN_ALTITUDE_DEG
|
||||||
|
# are considered.
|
||||||
#
|
#
|
||||||
# Sun altitude thresholds:
|
# The east camera faces east, so the moon is visible to it from moonrise through
|
||||||
# -6 civil twilight — noticeably dark, bright stars visible (default)
|
# roughly south — typically from dusk through midnight for a full moon.
|
||||||
# -12 nautical — horizon barely visible
|
# MOON_DARK_START_MIN=30 means the script starts looking 30 min after the sun
|
||||||
# -18 astronomical — fully dark, no twilight glow
|
# sets, when the sky is dark enough for a clean moon shot but east can still
|
||||||
|
# catch the moon low on the eastern horizon.
|
||||||
#
|
#
|
||||||
# The first east frame in that window with quality >= MOON_MIN_QUALITY is used.
|
# Of all qualifying frames, the one temporally closest to the exact phase moment
|
||||||
# Its exact timestamp drives the NASA Dial-a-Moon fetch (rounded to nearest hour)
|
# is used (not the first one). This picks the frame when the moon was most
|
||||||
# and the parallactic angle rotation. The caption shows illumination% at that
|
# precisely full / at quarter. Its timestamp drives the NASA Dial-a-Moon fetch
|
||||||
|
# and the parallactic angle rotation; the caption shows illumination% at that
|
||||||
# moment. If no clear frame is found, the month is skipped entirely.
|
# moment. If no clear frame is found, the month is skipped entirely.
|
||||||
MOON_DARK_SKY_SUN_ALT_DEG=-6
|
#
|
||||||
|
# If the phase falls early in the day (e.g. 01:30 local), the ±24h window
|
||||||
|
# covers the previous evening through the following dusk — both nights where
|
||||||
|
# the moon is essentially full. If the phase falls just before midnight
|
||||||
|
# (e.g. 23:55), both the same evening and the next morning are included.
|
||||||
|
MOON_DARK_START_MIN=30
|
||||||
|
|
||||||
# Post delay ──────────────────────────────────────────────────────────────────
|
# Post delay ──────────────────────────────────────────────────────────────────
|
||||||
# How many days after the exact phase to run the post. The post delay gives
|
# How many days after the exact phase to run the post. The post delay gives
|
||||||
|
|||||||
Reference in New Issue
Block a user