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
557 lines
21 KiB
Python
Executable File
557 lines
21 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""moon_phase_monthly.py — build the monthly moon-phase close-up.
|
|
|
|
Handles three phases, controlled by --phase:
|
|
|
|
full ~100% lit, posted MOON_FULL_POST_DELAY_DAYS after exact full
|
|
first-quarter ~50% lit waxing (right half lit in northern hemisphere)
|
|
third-quarter ~50% lit waning (left half lit in northern hemisphere)
|
|
|
|
Algorithm (per phase):
|
|
1. Find the most recent occurrence of the target phase (or honour --target).
|
|
2. Scan east frames across the collection window (D-Δb .. D+Δa) and pick
|
|
the frame closest in time to exact phase UTC where east successfully
|
|
detected the moon and standard quality / altitude / illumination
|
|
thresholds are met. East is the WITNESS — it confirms you actually had
|
|
a chance to see the moon that night.
|
|
3. Round east's capture timestamp to the nearest hour and fetch the NASA
|
|
SVS Dial-a-Moon render for that hour. This gives a real-physics moon
|
|
image with correct phase, libration and crater shadows.
|
|
4. Render full-screen on a black background (moon fills ~92% of frame
|
|
height), drop a caption naming the phase / month / capture moment /
|
|
attribution, and upload to Mattermost.
|
|
|
|
Geometry note — first-quarter from east is HARD: at first quarter the moon is
|
|
up from noon to midnight, but east only sees the eastern sky, so it captures
|
|
the moon during DAYTIME only (with a bright sky background). The detector is
|
|
brightness-based and may often fail to find a daytime moon. Set
|
|
MOON_FIRST_QUARTER_ENABLED=false in sky-cam.conf if you'd rather not chase it.
|
|
Full moon and third-quarter both rise after dark and stay in east's view —
|
|
those should land cleanly most months.
|
|
|
|
Usage:
|
|
moon_phase_monthly.py # auto: run any phase whose post-day = today
|
|
moon_phase_monthly.py --phase full # force a single phase
|
|
moon_phase_monthly.py --phase third-quarter --target 2026-04-09T11:51:00Z --dry-run
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import os
|
|
import pathlib
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
_here = pathlib.Path(__file__).resolve().parent
|
|
sys.path.insert(0, str(_here))
|
|
|
|
|
|
def _read_conf(path):
|
|
conf = {}
|
|
try:
|
|
with open(path) as f:
|
|
for line in f:
|
|
line = line.strip()
|
|
if not line or line.startswith('#') or '=' not in line:
|
|
continue
|
|
k, v = line.split('=', 1)
|
|
k = k.strip()
|
|
v = v.strip()
|
|
v = re.sub(r'\s+#.*$', '', v)
|
|
v = v.strip('"').strip("'")
|
|
m = re.match(r'^\$\{[^}]+:-([^}]*)\}$', v)
|
|
if m:
|
|
v = m.group(1).strip('"').strip("'")
|
|
conf[k] = v
|
|
except FileNotFoundError:
|
|
pass
|
|
return conf
|
|
|
|
|
|
CONF = _read_conf(_here / 'sky-cam.conf')
|
|
CONF.update(_read_conf(_here / '.env'))
|
|
|
|
BASE_DIR = CONF.get('BASE_DIR') or str(_here / 'data')
|
|
MOVIES_DIR = CONF.get('MOVIES_DIR') or f'{BASE_DIR}/movies'
|
|
SUNRISE_CAM = CONF.get('SUNRISE_CAM', 'east')
|
|
TIMEZONE = CONF.get('TIMEZONE', 'UTC')
|
|
|
|
MIN_QUALITY = float(CONF.get('MOON_MIN_QUALITY', CONF.get('MOON_FULL_MIN_QUALITY', 0.55)))
|
|
MIN_ALTITUDE = float(CONF.get('MOON_MIN_ALTITUDE_DEG', CONF.get('MOON_FULL_MIN_ALTITUDE_DEG', 15.0)))
|
|
OUT_W = int(CONF.get('MOON_OUTPUT_W', CONF.get('MOON_FULL_OUTPUT_W', 1920)))
|
|
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_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': {
|
|
'index': 2,
|
|
'label': 'Full Moon',
|
|
'emoji': '🌕',
|
|
'post_delay': int(CONF.get('MOON_FULL_POST_DELAY_DAYS', 1)),
|
|
'enabled_key': 'MOON_FULL_ENABLED',
|
|
},
|
|
'first-quarter': {
|
|
'index': 1,
|
|
'label': 'First Quarter (Waxing Half)',
|
|
'emoji': '🌓',
|
|
'post_delay': int(CONF.get('MOON_QUARTER_POST_DELAY_DAYS', 1)),
|
|
'enabled_key': 'MOON_FIRST_QUARTER_ENABLED',
|
|
},
|
|
'third-quarter': {
|
|
'index': 3,
|
|
'label': 'Third Quarter (Waning Half)',
|
|
'emoji': '🌗',
|
|
'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$')
|
|
|
|
|
|
def _local_tz():
|
|
try:
|
|
import pytz
|
|
return pytz.timezone(TIMEZONE)
|
|
except Exception:
|
|
return timezone.utc
|
|
|
|
|
|
def _frame_local_dt(date_str: str, fname: str):
|
|
m = _FRAME_RE.match(fname)
|
|
if not m:
|
|
return None
|
|
h, mn, s = (int(x) for x in m.groups())
|
|
y, mo, d = (int(x) for x in date_str.split('-'))
|
|
naive = datetime(y, mo, d, h, mn, s)
|
|
tz = _local_tz()
|
|
if hasattr(tz, 'localize'):
|
|
return tz.localize(naive)
|
|
return naive.replace(tzinfo=tz)
|
|
|
|
|
|
def _candidate_frames(cam: str, dates: list[str]) -> list[tuple[str, datetime]]:
|
|
out = []
|
|
for d in dates:
|
|
fdir = pathlib.Path(BASE_DIR) / cam / d
|
|
if not fdir.is_dir():
|
|
continue
|
|
for fname in sorted(os.listdir(fdir)):
|
|
if not fname.endswith('.jpg'):
|
|
continue
|
|
local_dt = _frame_local_dt(d, fname)
|
|
if local_dt is None:
|
|
continue
|
|
out.append((str(fdir / fname), local_dt.astimezone(timezone.utc)))
|
|
return out
|
|
|
|
|
|
def _format_local(dt_utc: datetime) -> str:
|
|
return dt_utc.astimezone(_local_tz()).strftime('%Y-%m-%d %H:%M:%S %Z')
|
|
|
|
|
|
def _notify(title: str, body: str):
|
|
try:
|
|
subprocess.run([str(_here / 'notify.sh'), title, body], check=False)
|
|
except FileNotFoundError:
|
|
pass
|
|
|
|
|
|
def _post_to_mattermost(image_path: str, message: str) -> bool:
|
|
import requests
|
|
base = CONF.get('mattermost_url', '').rstrip('/')
|
|
token = CONF.get('access_token', '')
|
|
channel_id = CONF.get('channel_id', '')
|
|
if not all([base, token, channel_id]):
|
|
print('mattermost credentials missing — skipping upload', file=sys.stderr)
|
|
return False
|
|
headers = {'Authorization': f'Bearer {token}'}
|
|
with open(image_path, 'rb') as f:
|
|
r = requests.post(
|
|
f'{base}/api/v4/files',
|
|
headers=headers,
|
|
files={'files': f},
|
|
data={'channel_id': channel_id},
|
|
)
|
|
if r.status_code != 201:
|
|
print(f'mattermost upload failed: {r.status_code} {r.text}', file=sys.stderr)
|
|
return False
|
|
file_id = r.json()['file_infos'][0]['id']
|
|
r = requests.post(
|
|
f'{base}/api/v4/posts',
|
|
headers=headers,
|
|
json={'channel_id': channel_id, 'message': message, 'file_ids': [file_id]},
|
|
)
|
|
if r.status_code != 201:
|
|
print(f'mattermost post failed: {r.status_code} {r.text}', file=sys.stderr)
|
|
return False
|
|
return True
|
|
|
|
|
|
def _output_subdir(phase: str) -> str:
|
|
return {
|
|
'full': 'full-moons',
|
|
'first-quarter': 'first-quarter',
|
|
'third-quarter': 'third-quarter',
|
|
}[phase]
|
|
|
|
|
|
def _output_filename(phase: str, target_utc: datetime) -> str:
|
|
slug = {'full': 'full', 'first-quarter': 'first-quarter', 'third-quarter': 'third-quarter'}[phase]
|
|
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]]:
|
|
"""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)
|
|
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 <= end:
|
|
try:
|
|
moon_alt, _ = moon_phase_mod.altaz(t)
|
|
except Exception:
|
|
t += step
|
|
continue
|
|
ok = 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, 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]
|
|
if CONF.get(spec['enabled_key'], 'true').lower() == 'false':
|
|
print(f'{spec["enabled_key"]}=false — skipping {phase}')
|
|
return 0
|
|
|
|
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)
|
|
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', file=sys.stderr)
|
|
return 1
|
|
target_utc = events[-1]
|
|
|
|
print(f'target {phase}: {target_utc.isoformat()} ({_format_local(target_utc)})')
|
|
|
|
tz = _local_tz()
|
|
|
|
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,
|
|
cam, dry_run, no_upload, out_path,
|
|
witness_text='east verification disabled')
|
|
|
|
# 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)
|
|
|
|
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")} — skipped',
|
|
'No dark-sky + moon-above-horizon window found near the phase moment.',
|
|
)
|
|
return 0
|
|
|
|
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)}')
|
|
|
|
# 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))
|
|
|
|
# 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, **det_kwargs)
|
|
if det is not None and det.quality >= min_q:
|
|
best_frame = fpath
|
|
best_det = det
|
|
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 best_frame is None:
|
|
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 {checked} frame(s) during the dark+moon window.',
|
|
)
|
|
return 0
|
|
|
|
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 {best_dt.isoformat()} ({illum_pct}% lit)')
|
|
return 0
|
|
|
|
return _render_and_post(
|
|
phase, spec, target_utc, best_dt,
|
|
cam, dry_run, no_upload, out_path,
|
|
witness_text=f'sky-cam {cam} — {illum_pct}% lit — {_format_local(best_dt)}',
|
|
east_frame=best_frame,
|
|
detection=best_det,
|
|
)
|
|
|
|
|
|
def _render_and_post(phase, spec, target_utc, when_utc, cam,
|
|
dry_run, no_upload, out_path, witness_text,
|
|
east_frame=None, detection=None):
|
|
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))
|
|
|
|
import moon_dialamoon
|
|
import moon_phase as _mp
|
|
try:
|
|
nasa_path = moon_dialamoon.fetch_for_time(when_utc)
|
|
except Exception as e:
|
|
msg = (
|
|
f'NASA SVS Dial-a-Moon fetch failed for '
|
|
f'{when_utc.strftime("%Y-%m-%dT%HZ")}: {e}'
|
|
)
|
|
_notify(f'{spec["emoji"]} {spec["label"]} — dial-a-moon fetch failed', msg)
|
|
print(msg, file=sys.stderr)
|
|
return 4
|
|
print(f'dial-a-moon: {nasa_path}')
|
|
|
|
illum_pct = round(_mp.illumination(when_utc) * 100)
|
|
caption = (
|
|
f"{spec['label']} — {illum_pct}% lit — {target_utc.strftime('%B %Y')} — "
|
|
f"{witness_text} — NASA SVS Dial-a-Moon"
|
|
)
|
|
if east_frame is not None and detection is not None:
|
|
from moon_composite import composite_full_moon
|
|
composite_full_moon(
|
|
east_frame, detection, when_utc,
|
|
str(nasa_path), out_path,
|
|
output_size=(OUT_W, OUT_H),
|
|
moon_height_pct=0.70,
|
|
caption=caption,
|
|
)
|
|
else:
|
|
from moon_composite import render_phase_closeup
|
|
render_phase_closeup(
|
|
str(nasa_path), out_path,
|
|
output_size=(OUT_W, OUT_H), moon_height_pct=MOON_PCT,
|
|
caption=caption,
|
|
when_utc=when_utc,
|
|
)
|
|
print(f'wrote {out_path}')
|
|
|
|
if no_upload:
|
|
_notify(
|
|
f'{spec["emoji"]} {spec["label"]} {target_utc.strftime("%B %Y")} (built, not posted)',
|
|
f'{out_path} — {witness_text}',
|
|
)
|
|
return 0
|
|
|
|
posted = _post_to_mattermost(
|
|
out_path,
|
|
f"{spec['emoji']} {spec['label']} — {target_utc.strftime('%B %Y')}\n"
|
|
f"sky-cam {cam} {witness_text}.\n"
|
|
f"Surface render from NASA SVS Dial-a-Moon for that hour.",
|
|
)
|
|
if posted:
|
|
_notify(
|
|
f'{spec["emoji"]} {spec["label"]} {target_utc.strftime("%B %Y")} posted',
|
|
f'{witness_text} — {out_path}',
|
|
)
|
|
else:
|
|
_notify(
|
|
f'FAILED: {spec["emoji"]} {spec["label"]} {target_utc.strftime("%B %Y")} upload',
|
|
f'Image built at {out_path} but Mattermost upload failed.',
|
|
)
|
|
return 0
|
|
|
|
|
|
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
|
|
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]
|
|
days_since = (today_utc - last_event.date()).days
|
|
if days_since == spec['post_delay']:
|
|
print(f'== running {phase} (last event {last_event.date()}, +{spec["post_delay"]} days = today) ==')
|
|
sub = run_phase(phase, last_event, cam, dry_run, no_upload, None)
|
|
rc = rc or sub
|
|
ran_any = True
|
|
else:
|
|
print(f'-- {phase}: last {last_event.date()}, days_since={days_since}, post_delay={spec["post_delay"]} → skip')
|
|
if not ran_any:
|
|
print('no phase scheduled for today')
|
|
return rc
|
|
|
|
|
|
def main():
|
|
p = argparse.ArgumentParser()
|
|
p.add_argument('--phase', choices=list(PHASE_SPEC.keys()),
|
|
help='Run a single phase regardless of schedule')
|
|
p.add_argument('--target', help='Override phase event UTC, ISO 8601 (requires --phase)')
|
|
p.add_argument('--cam', default=SUNRISE_CAM)
|
|
p.add_argument('--dry-run', action='store_true')
|
|
p.add_argument('--no-upload', action='store_true')
|
|
p.add_argument('--out', help='Override output path (requires --phase)')
|
|
args = p.parse_args()
|
|
|
|
if args.phase:
|
|
target = None
|
|
if args.target:
|
|
target = datetime.fromisoformat(args.target.replace('Z', '+00:00'))
|
|
return run_phase(args.phase, target, args.cam, args.dry_run, args.no_upload, args.out)
|
|
return auto_run(args.cam, args.dry_run, args.no_upload)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
sys.exit(main())
|