Add waxing/waning crescent phases; fix phase-shadow double-application

moon_detect.py
- detect_moon() accepts saturated_threshold / min_roundness / max_halo_ratio
  so crescent phases (dim, non-circular arc) can be detected with relaxed
  thresholds without changing defaults for full/quarter detection.

moon_phase.py
- crescent_times_in_range(): 2-hour scan returning the UTC moment illumination
  crosses a target fraction (default 28%) on the waxing or waning side.
  Used for scheduling just like phase_events_in_range() for named phases.

moon_composite.py
- Remove _apply_phase_shadow() from composite_full_moon.  NASA SVS Dial-a-Moon
  renders already include the correct terminator, libration and earthshine for
  the exact hour; applying the shadow on top double-darkened the unlit limb on
  every non-full phase.  The function stays available for callers that need it.

moon_phase_monthly.py
- Add waxing-crescent (🌒) and waning-crescent (🌘) to PHASE_SPEC.
  Timing via crescent_times_in_range(); scheduling identical to other phases.
- CRESCENT_* tuning constants (all overridable from sky-cam.conf):
    MOON_CRESCENT_TARGET_ILLUM   default 0.28
    MOON_CRESCENT_MIN_QUALITY    default 0.35
    MOON_CRESCENT_SATURATED_THR  default 180
    MOON_CRESCENT_MIN_ROUNDNESS  default 0.15
    MOON_CRESCENT_MAX_HALO_RATIO default 12.0
- run_phase() and auto_run() branch on spec['crescent'] to use crescent
  timing and detection params.
- Atmospheric background naturally captures twilight colours (dawn for waning
  crescent, dusk/dawn for waxing) from the real east frame.

https://claude.ai/code/session_01HuJ83KvMvshiY6HxJbtMsc
This commit is contained in:
Claude
2026-05-03 23:21:44 +00:00
parent b21803151b
commit 29a864375b
4 changed files with 131 additions and 24 deletions
+65 -9
View File
@@ -87,6 +87,14 @@ REQUIRE_EAST_VERIFY = CONF.get('MOON_REQUIRE_EAST_VERIFY', 'true').lower() != 'f
DARK_START_MIN = int(CONF.get('MOON_DARK_START_MIN', 30))
# Crescent detection uses relaxed thresholds: a crescent arc scores low on
# roundness and is dimmer than a full disk.
CRESCENT_TARGET_ILLUM = float(CONF.get('MOON_CRESCENT_TARGET_ILLUM', 0.28))
CRESCENT_MIN_QUALITY = float(CONF.get('MOON_CRESCENT_MIN_QUALITY', 0.35))
CRESCENT_SATURATED_THR = int(CONF.get('MOON_CRESCENT_SATURATED_THR', 180))
CRESCENT_MIN_ROUNDNESS = float(CONF.get('MOON_CRESCENT_MIN_ROUNDNESS', 0.15))
CRESCENT_MAX_HALO_RATIO = float(CONF.get('MOON_CRESCENT_MAX_HALO_RATIO', 12.0))
PHASE_SPEC = {
'full': {
@@ -110,6 +118,28 @@ PHASE_SPEC = {
'post_delay': int(CONF.get('MOON_QUARTER_POST_DELAY_DAYS', 1)),
'enabled_key': 'MOON_THIRD_QUARTER_ENABLED',
},
# Crescents have no fixed skyfield phase index — timing is computed via
# crescent_times_in_range() at the configured illumination target (~28%).
# Detection uses relaxed thresholds since a crescent arc is non-circular
# and dimmer than a full disk. The atmospheric background naturally picks
# up twilight colours from the real east frame (dawn for waning, dusk/dawn
# depending on the month for waxing).
'waxing-crescent': {
'crescent': True,
'waxing': True,
'label': 'Waxing Crescent',
'emoji': '🌒',
'post_delay': int(CONF.get('MOON_CRESCENT_POST_DELAY_DAYS', 1)),
'enabled_key': 'MOON_WAXING_CRESCENT_ENABLED',
},
'waning-crescent': {
'crescent': True,
'waxing': False,
'label': 'Waning Crescent',
'emoji': '🌘',
'post_delay': int(CONF.get('MOON_CRESCENT_POST_DELAY_DAYS', 1)),
'enabled_key': 'MOON_WANING_CRESCENT_ENABLED',
},
}
_FRAME_RE = re.compile(r'^(\d{2})-(\d{2})-(\d{2})\.jpg$')
@@ -281,12 +311,21 @@ def run_phase(phase: str, target_utc: datetime | None, cam: str,
import moon_phase
from moon_detect import detect_moon
is_crescent = spec.get('crescent', False)
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 is_crescent:
events = moon_phase.crescent_times_in_range(
now - timedelta(days=35), now,
target_illum=CRESCENT_TARGET_ILLUM,
waxing_side=spec['waxing'],
)
else:
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)
print(f'no recent {phase} found', file=sys.stderr)
return 1
target_utc = events[-1]
@@ -336,12 +375,21 @@ def run_phase(phase: str, target_utc: datetime | None, cam: str,
# is the one temporally closest to the moon being precisely full/quarter.
dark_frames.sort(key=lambda x: abs(x[1] - target_utc))
# Crescent arcs are non-circular and dimmer — use relaxed detection params.
det_kwargs = (
dict(saturated_threshold=CRESCENT_SATURATED_THR,
min_roundness=CRESCENT_MIN_ROUNDNESS,
max_halo_ratio=CRESCENT_MAX_HALO_RATIO)
if is_crescent else {}
)
min_q = CRESCENT_MIN_QUALITY if is_crescent else MIN_QUALITY
best_frame: str | None = None
best_det = 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:
det = detect_moon(fpath, **det_kwargs)
if det is not None and det.quality >= min_q:
best_frame = fpath
best_det = det
best_dt = fdt
@@ -451,16 +499,24 @@ 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()
window_start = datetime.combine(
today_utc - timedelta(days=45), datetime.min.time(), tzinfo=timezone.utc)
now = datetime.now(timezone.utc)
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 spec.get('crescent'):
events = moon_phase.crescent_times_in_range(
window_start, now,
target_illum=CRESCENT_TARGET_ILLUM,
waxing_side=spec['waxing'],
)
else:
events = moon_phase.phase_events_in_range(
window_start, now, spec['index'])
if not events:
continue
last_event = events[-1]