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
+40
View File
@@ -107,6 +107,46 @@ def full_moons_in_range(start: datetime, end: datetime) -> list[datetime]:
return phase_events_in_range(start, end, 2)
def crescent_times_in_range(
start: datetime,
end: datetime,
target_illum: float = 0.28,
waxing_side: bool = True,
) -> list[datetime]:
"""Return UTC datetimes when illumination crosses target_illum on the given side.
Scans in 2-hour steps and records each moment illumination passes through
target_illum while waxing (waxing_side=True) or waning (waxing_side=False).
Yields one event per lunar cycle, used for scheduling just like
phase_events_in_range() is used for quarter/full events.
"""
_lazy()
step = timedelta(hours=2)
results: list[datetime] = []
t = _to_utc(start)
end_dt = _to_utc(end)
prev_illum: float | None = None
prev_t: datetime | None = None
while t <= end_dt:
illum_val = illumination(t)
is_wax = waxing(t)
if is_wax == waxing_side:
if prev_illum is not None:
if (prev_illum - target_illum) * (illum_val - target_illum) < 0:
# Linear interpolation to the crossing moment
frac = (target_illum - prev_illum) / (illum_val - prev_illum)
results.append(prev_t + frac * (t - prev_t))
prev_illum = illum_val
prev_t = t
else:
prev_illum = None
prev_t = None
t += step
return results
def phase_events_in_range(start: datetime, end: datetime, phase_index: int) -> list[datetime]:
"""Return UTC datetimes of every occurrence of `phase_index` between start and end.