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
|
# 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
|
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:
|
def _command_payload(program: Program) -> bytes:
|
||||||
"""Build the 4-byte Command wire payload from a Program record.
|
"""Build the 4-byte Command wire payload from a Program record.
|
||||||
|
|
||||||
@ -318,9 +453,16 @@ class ProgramEngine:
|
|||||||
explicit start.
|
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._panel = panel
|
||||||
self._clock = clock or RealClock()
|
self._clock = clock or RealClock()
|
||||||
|
self._location = location
|
||||||
# Decode raw bytes from MockState.programs into Program objects
|
# Decode raw bytes from MockState.programs into Program objects
|
||||||
# once, at construction. Reclassifying on every start would be
|
# once, at construction. Reclassifying on every start would be
|
||||||
# wasteful and would also lose the slot indices.
|
# wasteful and would also lose the slot indices.
|
||||||
@ -373,19 +515,39 @@ class ProgramEngine:
|
|||||||
name=f"omni-pca-timed-slot-{program.slot}",
|
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:
|
async def _run_timed_program(self, program: Program) -> None:
|
||||||
"""Sleep-until-next-fire loop for one TIMED program.
|
"""Sleep-until-next-fire loop for one TIMED program.
|
||||||
|
|
||||||
Wakes at the program's scheduled hour:minute on the next
|
Handles both ABSOLUTE (wall-clock hour:minute) and sunrise /
|
||||||
matching weekday, fires the command, then loops to the next
|
sunset-relative time kinds. Sun-relative programs only run if
|
||||||
occurrence. Returns when the engine stops (task cancellation).
|
the engine was given a :class:`PanelLocation`; without one they
|
||||||
|
return immediately, the same way an empty Days mask would.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
while self._running:
|
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:
|
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)
|
await self._clock.sleep_until(next_fire)
|
||||||
if not self._running:
|
if not self._running:
|
||||||
return
|
return
|
||||||
@ -398,6 +560,25 @@ class ProgramEngine:
|
|||||||
)
|
)
|
||||||
self.metrics.errors += 1
|
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:
|
async def _fire(self, program: Program) -> None:
|
||||||
"""Execute one program by feeding its command through the same
|
"""Execute one program by feeding its command through the same
|
||||||
wire-handler path the v2 Command opcode uses."""
|
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 clock.advance_to(t0 + timedelta(days=7))
|
||||||
await asyncio.sleep(0)
|
await asyncio.sleep(0)
|
||||||
assert engine.metrics.timed_fired == 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