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:
parent
2cc28b0e50
commit
d6205cd330
@ -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."""
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user