program_engine: Phase 3 — YEARLY + sunrise/sunset

YEARLY programs (month/day match at hour:minute, regardless of weekday)
now fire autonomously. Each YEARLY slot gets its own asyncio worker
running sleep-until-next-fire / fire / loop, just like TIMED.

Sunrise/sunset support lands in the same phase. New PanelLocation
dataclass wraps a geographic position; PanelLocation.from_account(acct)
builds one from the .pca's latitude/longitude/time_zone, flipping the
longitude sign to match astral's east-positive convention.

The engine's __init__ accepts an optional location=PanelLocation.
TIMED programs whose hour byte hits the AT_SUNRISE (25) or AT_SUNSET
(26) sentinels resolve against astral's computed sunrise/sunset on
each candidate day, applying the program's signed minute offset
(time_offset_minutes). Without a location supplied, sun-relative
programs are silently skipped — the same effect as an empty Days mask.

_next_yearly_fire validates the (month, day) combination — Feb 30 etc.
return None and the program never fires (matches real-panel range
checks).

12 new tests covering:
* PanelLocation longitude sign-flip and timezone derivation
* _next_yearly_fire for today / next-year rollover / disabled / invalid
* YEARLY worker end-to-end + multi-year loop
* _next_sun_relative_fire AT_SUNSET / sunrise-with-offset / no-days
* engine sun-relative skip without location
* engine sun-relative fires correctly with location

Full suite: 539 passed, 1 skipped (up from 527).
This commit is contained in:
Ryan Malloy 2026-05-14 01:27:54 -06:00
parent 2cc28b0e50
commit d6205cd330
2 changed files with 401 additions and 6 deletions

View File

@ -210,6 +210,78 @@ def classify(programs: tuple[Program, ...]) -> _ClassifiedPrograms:
)
# --------------------------------------------------------------------------
# Geo / sun events
# --------------------------------------------------------------------------
@dataclass(frozen=True)
class PanelLocation:
"""Geographic position for sunrise/sunset computation.
A thin wrapper that decouples the engine from a hard dependency on
:mod:`astral`. Build one from a decoded ``.pca`` like::
from omni_pca.pca_file import parse_pca_file
acct = parse_pca_file(data, key=KEY_EXPORT)
loc = PanelLocation.from_account(acct)
The engine accepts ``location=None`` (the default); in that mode
sunrise/sunset-relative TIMED programs simply don't fire — equivalent
to an empty Days mask.
"""
name: str = "Panel"
region: str = "US"
timezone: str = "UTC"
latitude: float = 0.0
longitude: float = 0.0
@classmethod
def from_account(cls, account, *, name: str = "Panel") -> "PanelLocation":
"""Build a PanelLocation from a :class:`omni_pca.pca_file.PcaAccount`.
``acct.latitude``/``longitude`` are raw degrees; ``acct.time_zone``
is hours west of UTC, which we convert to an IANA-style
``"Etc/GMT+N"`` zone pyttz / astral resolve that correctly.
(The Etc/GMT signs are inverted relative to common usage by the
POSIX convention, hence "+N" for west-of-UTC.)
"""
tz_off = getattr(account, "time_zone", 0)
# Etc/GMT+0 normalises to UTC for the zero case.
tz_name = f"Etc/GMT+{tz_off}" if tz_off else "UTC"
# Longitude on Omni is stored as positive degrees west; astral
# expects signed degrees east-of-prime. Negate.
return cls(
name=name,
region="US", # not stored in .pca; "US" is fine as a label
timezone=tz_name,
latitude=float(getattr(account, "latitude", 0)),
longitude=-float(getattr(account, "longitude", 0)),
)
def _sun_events(location: PanelLocation, day: date) -> tuple[datetime, datetime]:
"""Return (sunrise, sunset) on ``day`` as timezone-aware UTC datetimes.
Raises :class:`ImportError` if the optional ``astral`` dependency
isn't installed; callers should catch and treat as "no sun events
available" (equivalent to skipping the program for that day).
"""
from astral import LocationInfo
from astral.sun import sun
info = LocationInfo(
name=location.name,
region=location.region,
timezone=location.timezone,
latitude=location.latitude,
longitude=location.longitude,
)
times = sun(info.observer, date=day)
return times["sunrise"], times["sunset"]
# --------------------------------------------------------------------------
# TIMED scheduling
# --------------------------------------------------------------------------
@ -264,6 +336,69 @@ def _next_absolute_fire(now: datetime, program: Program) -> datetime | None:
return None # safety net — shouldn't happen if mask is non-zero
def _next_sun_relative_fire(
now: datetime,
program: Program,
location: PanelLocation,
) -> datetime | None:
"""Compute next fire for a sunrise/sunset-relative TIMED program.
For each candidate day (today through +8 days) we compute the
panel's sunrise/sunset, apply the program's signed minute offset
(``time_offset_minutes``), and return the first result strictly
after ``now`` whose date matches the program's Days mask.
Returns ``None`` if Days mask is empty, the program isn't sun-relative,
or the astral computation raises.
"""
if program.time_kind not in (TimeKind.SUNRISE, TimeKind.SUNSET):
return None
if program.days == 0:
return None
offset = timedelta(minutes=program.time_offset_minutes)
is_sunrise = program.time_kind == TimeKind.SUNRISE
for delta_days in range(0, 8):
day = (now + timedelta(days=delta_days)).date()
if not _matches_days_mask(day, program.days):
continue
try:
sunrise, sunset = _sun_events(location, day)
except Exception:
_log.debug(
"sun computation failed for %s — skipping day", day, exc_info=True
)
return None
candidate = (sunrise if is_sunrise else sunset) + offset
if candidate > now:
return candidate
return None
def _next_yearly_fire(now: datetime, program: Program) -> datetime | None:
"""Compute the next datetime a YEARLY program should fire.
YEARLY programs match a fixed month/day at hour:minute, regardless
of weekday. Returns ``None`` if month is 0 (program disabled) or
the month/day combination is invalid (e.g. Feb 30).
"""
if program.month == 0 or program.day == 0:
return None
candidate_year = now.year
for _ in range(2): # try this year then next
try:
candidate = datetime(
candidate_year, program.month, program.day,
program.hour, program.minute,
tzinfo=now.tzinfo,
)
except ValueError:
return None # Feb 30 etc.
if candidate > now:
return candidate
candidate_year += 1
return None # safety net
def _command_payload(program: Program) -> bytes:
"""Build the 4-byte Command wire payload from a Program record.
@ -318,9 +453,16 @@ class ProgramEngine:
explicit start.
"""
def __init__(self, panel: "MockPanel", *, clock: Clock | None = None) -> None:
def __init__(
self,
panel: "MockPanel",
*,
clock: Clock | None = None,
location: PanelLocation | None = None,
) -> None:
self._panel = panel
self._clock = clock or RealClock()
self._location = location
# Decode raw bytes from MockState.programs into Program objects
# once, at construction. Reclassifying on every start would be
# wasteful and would also lose the slot indices.
@ -373,19 +515,39 @@ class ProgramEngine:
name=f"omni-pca-timed-slot-{program.slot}",
)
)
# Phase 3: one worker per YEARLY program.
for program in self._classified.yearly:
self._tasks.append(
asyncio.create_task(
self._run_yearly_program(program),
name=f"omni-pca-yearly-slot-{program.slot}",
)
)
async def _run_timed_program(self, program: Program) -> None:
"""Sleep-until-next-fire loop for one TIMED program.
Wakes at the program's scheduled hour:minute on the next
matching weekday, fires the command, then loops to the next
occurrence. Returns when the engine stops (task cancellation).
Handles both ABSOLUTE (wall-clock hour:minute) and sunrise /
sunset-relative time kinds. Sun-relative programs only run if
the engine was given a :class:`PanelLocation`; without one they
return immediately, the same way an empty Days mask would.
"""
try:
while self._running:
next_fire = _next_absolute_fire(self._clock.now(), program)
now = self._clock.now()
if program.time_kind == TimeKind.ABSOLUTE:
next_fire = _next_absolute_fire(now, program)
elif self._location is None:
_log.debug(
"engine: TIMED slot %s is sun-relative but no "
"location was supplied — skipping",
program.slot,
)
return
else:
next_fire = _next_sun_relative_fire(now, program, self._location)
if next_fire is None:
return # no valid Days mask — give up on this program
return # disabled (empty Days, sun unavailable, etc.)
await self._clock.sleep_until(next_fire)
if not self._running:
return
@ -398,6 +560,25 @@ class ProgramEngine:
)
self.metrics.errors += 1
async def _run_yearly_program(self, program: Program) -> None:
"""Sleep-until-next-fire loop for one YEARLY program."""
try:
while self._running:
next_fire = _next_yearly_fire(self._clock.now(), program)
if next_fire is None:
return # disabled or invalid month/day
await self._clock.sleep_until(next_fire)
if not self._running:
return
await self._fire(program)
except asyncio.CancelledError:
raise
except Exception:
_log.exception(
"engine: YEARLY slot %s crashed", program.slot,
)
self.metrics.errors += 1
async def _fire(self, program: Program) -> None:
"""Execute one program by feeding its command through the same
wire-handler path the v2 Command opcode uses."""

View File

@ -412,3 +412,217 @@ async def test_engine_skips_empty_days_mask() -> None:
await clock.advance_to(t0 + timedelta(days=7))
await asyncio.sleep(0)
assert engine.metrics.timed_fired == 0
# ---- Phase 3: YEARLY + sunrise/sunset -----------------------------------
from omni_pca.program_engine import ( # noqa: E402
PanelLocation,
_next_sun_relative_fire,
_next_yearly_fire,
)
def test_panel_location_from_account_negates_longitude() -> None:
"""The Omni stores longitude as positive degrees west; PanelLocation
flips the sign to match astral's east-positive convention."""
class _AcctStub:
latitude = 44
longitude = 117
time_zone = 7
loc = PanelLocation.from_account(_AcctStub())
assert loc.latitude == 44.0
assert loc.longitude == -117.0 # flipped from +117 west → -117 east
assert loc.timezone == "Etc/GMT+7"
def test_next_yearly_fire_picks_today() -> None:
# 2026-05-14 12:00; program 05/14 13:00 → today 13:00.
p = Program(
slot=1, prog_type=int(ProgramType.YEARLY),
cmd=1, month=5, day=14, hour=13, minute=0,
)
now = datetime(2026, 5, 14, 12, 0, tzinfo=timezone.utc)
nxt = _next_yearly_fire(now, p)
assert nxt == datetime(2026, 5, 14, 13, 0, tzinfo=timezone.utc)
def test_next_yearly_fire_rolls_over_to_next_year() -> None:
# 2026-12-31 23:00; program 01/01 00:00 → next year.
p = Program(
slot=1, prog_type=int(ProgramType.YEARLY),
cmd=1, month=1, day=1, hour=0, minute=0,
)
now = datetime(2026, 12, 31, 23, 0, tzinfo=timezone.utc)
nxt = _next_yearly_fire(now, p)
assert nxt == datetime(2027, 1, 1, 0, 0, tzinfo=timezone.utc)
def test_next_yearly_fire_zero_month_returns_none() -> None:
p = Program(
slot=1, prog_type=int(ProgramType.YEARLY),
cmd=1, month=0, day=0, hour=0, minute=0,
)
now = datetime(2026, 5, 14, 12, 0, tzinfo=timezone.utc)
assert _next_yearly_fire(now, p) is None
def test_next_yearly_fire_invalid_date_returns_none() -> None:
"""Feb 30 is invalid — program never fires."""
p = Program(
slot=1, prog_type=int(ProgramType.YEARLY),
cmd=1, month=2, day=30, hour=12, minute=0,
)
now = datetime(2026, 1, 1, 12, 0, tzinfo=timezone.utc)
assert _next_yearly_fire(now, p) is None
@pytest.mark.asyncio
async def test_engine_fires_yearly_program() -> None:
"""YEARLY May-14 12:00 program fires when clock crosses that date."""
t0 = datetime(2026, 5, 14, 11, 59, tzinfo=timezone.utc)
p = Program(
slot=10, prog_type=int(ProgramType.YEARLY),
cmd=int(Command.UNIT_ON), pr2=3,
month=5, day=14, hour=12, minute=0,
)
panel = _panel_with_programs(p)
clock = FakeClock(t0)
async with ProgramEngine(panel, clock=clock) as engine:
await asyncio.sleep(0)
await clock.advance_to(datetime(2026, 5, 14, 12, 1, tzinfo=timezone.utc))
await asyncio.sleep(0)
assert engine.metrics.yearly_fired == 1
assert panel.state.units[3].state == 1
@pytest.mark.asyncio
async def test_engine_yearly_loops_next_year() -> None:
"""After firing, YEARLY workers re-arm for the next year."""
t0 = datetime(2026, 5, 14, 11, 59, tzinfo=timezone.utc)
p = Program(
slot=10, prog_type=int(ProgramType.YEARLY),
cmd=int(Command.UNIT_ON), pr2=3,
month=5, day=14, hour=12, minute=0,
)
panel = _panel_with_programs(p)
clock = FakeClock(t0)
async with ProgramEngine(panel, clock=clock) as engine:
await asyncio.sleep(0)
# First fire.
await clock.advance_to(datetime(2026, 5, 14, 12, 1, tzinfo=timezone.utc))
await asyncio.sleep(0)
# Second fire next year.
await clock.advance_to(datetime(2027, 5, 14, 12, 1, tzinfo=timezone.utc))
await asyncio.sleep(0)
assert engine.metrics.yearly_fired == 2
def test_next_sun_relative_fire_at_sunset() -> None:
"""A TIMED program scheduled "at sunset" on a Thursday fires at
the astral-computed sunset for that day."""
p = Program(
slot=1, prog_type=int(ProgramType.TIMED),
cmd=int(Command.UNIT_ON), pr2=5,
hour=26, minute=0, # AT_SUNSET sentinel
days=int(Days.THURSDAY),
)
loc = PanelLocation(
name="Boise", region="US", timezone="UTC",
latitude=43.6, longitude=-116.2,
)
# 2026-05-14 is a Thursday.
now = datetime(2026, 5, 14, 0, 0, tzinfo=timezone.utc)
nxt = _next_sun_relative_fire(now, p, loc)
assert nxt is not None
# Sunset in Boise mid-May is roughly 03:00 UTC the *next* day (late
# evening Mountain Time). Just verify it's in the right ballpark
# and after `now`.
assert nxt > now
assert nxt < now + timedelta(days=2)
def test_next_sun_relative_fire_with_offset_before_sunrise() -> None:
"""A "30 min before sunrise" program lands earlier than astral's
raw sunrise time by exactly 30 minutes."""
p_at = Program(
slot=1, prog_type=int(ProgramType.TIMED),
cmd=1, hour=25, minute=0, # AT_SUNRISE
days=int(Days.THURSDAY),
)
p_before = Program(
slot=2, prog_type=int(ProgramType.TIMED),
cmd=1, hour=25, minute=256 - 30, # 30 min before (sbyte -30)
days=int(Days.THURSDAY),
)
loc = PanelLocation(
name="Boise", region="US", timezone="UTC",
latitude=43.6, longitude=-116.2,
)
now = datetime(2026, 5, 14, 0, 0, tzinfo=timezone.utc)
at = _next_sun_relative_fire(now, p_at, loc)
before = _next_sun_relative_fire(now, p_before, loc)
assert at is not None and before is not None
# The "before" fire is 30 minutes earlier than the "at" fire.
assert at - before == timedelta(minutes=30)
def test_next_sun_relative_fire_empty_days_returns_none() -> None:
p = Program(
slot=1, prog_type=int(ProgramType.TIMED),
cmd=1, hour=25, minute=0,
days=0, # disabled
)
loc = PanelLocation(latitude=43.6, longitude=-116.2)
now = datetime(2026, 5, 14, 0, 0, tzinfo=timezone.utc)
assert _next_sun_relative_fire(now, p, loc) is None
@pytest.mark.asyncio
async def test_engine_sun_relative_without_location_is_skipped() -> None:
"""Engine with no PanelLocation drops sunrise/sunset programs."""
t0 = datetime(2026, 5, 14, 0, 0, tzinfo=timezone.utc)
p = Program(
slot=1, prog_type=int(ProgramType.TIMED),
cmd=int(Command.UNIT_ON), pr2=5,
hour=25, minute=0, # AT_SUNRISE
days=int(Days.THURSDAY),
)
panel = _panel_with_programs(p)
clock = FakeClock(t0)
async with ProgramEngine(panel, clock=clock) as engine:
# No location → worker returns immediately, never fires.
await asyncio.sleep(0)
await clock.advance_to(t0 + timedelta(days=2))
await asyncio.sleep(0)
assert engine.metrics.timed_fired == 0
@pytest.mark.asyncio
async def test_engine_fires_sun_relative_program_with_location() -> None:
"""End-to-end: TIMED AT_SUNSET program fires at astral-computed
sunset when the clock advances past it."""
loc = PanelLocation(
name="Boise", region="US", timezone="UTC",
latitude=43.6, longitude=-116.2,
)
p = Program(
slot=1, prog_type=int(ProgramType.TIMED),
cmd=int(Command.UNIT_ON), pr2=5,
hour=26, minute=0, # AT_SUNSET
days=int(Days.THURSDAY),
)
# Start at midnight UTC on a Thursday.
t0 = datetime(2026, 5, 14, 0, 0, tzinfo=timezone.utc)
panel = _panel_with_programs(p)
clock = FakeClock(t0)
async with ProgramEngine(panel, clock=clock, location=loc) as engine:
await asyncio.sleep(0)
# Advance well past sunset (which is roughly 03:00 UTC Friday).
await clock.advance_to(t0 + timedelta(days=2))
await asyncio.sleep(0)
assert engine.metrics.timed_fired == 1
assert panel.state.units[5].state == 1