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)
|
||||
|
||||
|
||||
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):
|
||||
"""Apparent altitude/azimuth of the sun in degrees as seen from (lat, lon)."""
|
||||
_lazy()
|
||||
|
||||
+58
-20
@@ -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))
|
||||
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 = {
|
||||
@@ -212,20 +212,53 @@ def _dark_moon_intervals(
|
||||
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."""
|
||||
"""Return contiguous blocks where the sky is dark and the moon is visible.
|
||||
|
||||
'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)
|
||||
t = search_start
|
||||
intervals: list[tuple[datetime, datetime]] = []
|
||||
for night_start, night_end in night_periods:
|
||||
t = max(night_start, search_start)
|
||||
end = min(night_end, search_end)
|
||||
if t >= end:
|
||||
continue
|
||||
seg_start = None
|
||||
while t <= search_end:
|
||||
while t <= 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
|
||||
ok = moon_alt >= MIN_ALTITUDE
|
||||
if ok and seg_start is None:
|
||||
seg_start = t
|
||||
elif not ok and seg_start is not None:
|
||||
@@ -233,7 +266,8 @@ def _dark_moon_intervals(
|
||||
seg_start = None
|
||||
t += step
|
||||
if seg_start is not None:
|
||||
intervals.append((seg_start, search_end))
|
||||
intervals.append((seg_start, end))
|
||||
|
||||
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)}')
|
||||
|
||||
# Binary go/no-go: first frame with quality >= MIN_QUALITY wins
|
||||
clear_frame: str | None = None
|
||||
clear_dt: datetime | None = None
|
||||
# Sort by proximity to exact phase moment so the first clear frame we find
|
||||
# is the one temporally closest to the moon being precisely full/quarter.
|
||||
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:
|
||||
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)}')
|
||||
best_frame = fpath
|
||||
best_dt = fdt
|
||||
offset_min = int((fdt - target_utc).total_seconds() / 60)
|
||||
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
|
||||
|
||||
if clear_frame is None:
|
||||
if best_frame is None:
|
||||
checked = len(dark_frames)
|
||||
print(f'no clear moon in {checked} dark-window frame(s) — skipping (overcast)')
|
||||
_notify(
|
||||
@@ -319,17 +357,17 @@ def run_phase(phase: str, target_utc: datetime | None, cam: str,
|
||||
)
|
||||
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}%')
|
||||
|
||||
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 _render_and_post(
|
||||
phase, spec, target_utc, clear_dt,
|
||||
phase, spec, target_utc, best_dt,
|
||||
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
|
||||
|
||||
# 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)
|
||||
# The script searches ±24h around the exact phase moment using actual computed
|
||||
# sunset and sunrise times for the configured location. The dark window for
|
||||
# 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:
|
||||
# -6 civil twilight — noticeably dark, bright stars visible (default)
|
||||
# -12 nautical — horizon barely visible
|
||||
# -18 astronomical — fully dark, no twilight glow
|
||||
# The east camera faces east, so the moon is visible to it from moonrise through
|
||||
# roughly south — typically from dusk through midnight for a full moon.
|
||||
# MOON_DARK_START_MIN=30 means the script starts looking 30 min after the sun
|
||||
# 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.
|
||||
# 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
|
||||
# Of all qualifying frames, the one temporally closest to the exact phase moment
|
||||
# is used (not the first one). This picks the frame when the moon was most
|
||||
# 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.
|
||||
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 ──────────────────────────────────────────────────────────────────
|
||||
# How many days after the exact phase to run the post. The post delay gives
|
||||
|
||||
Reference in New Issue
Block a user