programs: decode TIMED sunrise/sunset-relative time encoding

The hour byte of a TIMED program is overloaded as a 1-of-3 discriminator:
0..23 means absolute wall-clock time, 25 means sunrise-relative, 26 means
sunset-relative. For the relative forms, the minute byte is signed
(sbyte) -- positive = after, negative = before, zero = at. Source:
frmPopUpEditTime.cs:186-217 (decode) + :241-263 (encode).

This was the canary that tripped our earlier sanity test: slots 182/183
in the live fixture have hour=26 minute=246 and hour=25 minute=10 --
nominally invalid as clock times, but they're "10 min before sunset"
and "10 min after sunrise" respectively. With this commit those decode
cleanly via TimeKind.SUNSET / SUNRISE.

omni_pca.programs:
* New TimeKind IntEnum: ABSOLUTE / SUNRISE / SUNSET.
* New Program.time_kind property (classifies via hour-byte discriminator).
* New Program.time_offset_minutes property (signed minutes-offset for
  sunrise/sunset; 0 for absolute).
* New Program.format_time() -> str: "07:15" | "at sunrise" | "30 min
  before sunset" etc.
* Module-level _classify_time helper + sentinel constants
  _HR_SUNRISE_SENTINEL=25 / _HR_SUNSET_SENTINEL=26.

omni_pca top-level: re-exports TimeKind.

tests/test_programs.py (+13 cases):
* Parametrised TimeKind classification across absolute / sunrise /
  sunset including boundary cases (sbyte ±128, ±1, 0).
* Wire-bytes round-trip preserves TimeKind + offset.

tests/test_pca_file.py: tightened the previously-loosened sanity
invariant. ABSOLUTE-time TIMED programs must hit valid wall-clock
ranges (0-23 / 0-59); relative-time programs must have a valid sbyte
offset (-128..127). Both pass cleanly on the 209 TIMED programs in
the live fixture (207 absolute, 1 sunrise, 1 sunset).

Full suite: 439 passed, 1 skipped (was 426 / 1).
This commit is contained in:
Ryan Malloy 2026-05-11 21:38:28 -06:00
parent 00f0028053
commit eb1a632ef2
4 changed files with 167 additions and 13 deletions

View File

@ -7,6 +7,7 @@ from .programs import (
Program, Program,
ProgramCond, ProgramCond,
ProgramType, ProgramType,
TimeKind,
decode_program_table, decode_program_table,
iter_defined, iter_defined,
) )
@ -21,6 +22,7 @@ __all__ = [
"Program", "Program",
"ProgramCond", "ProgramCond",
"ProgramType", "ProgramType",
"TimeKind",
"__version__", "__version__",
"decode_program_table", "decode_program_table",
"iter_defined", "iter_defined",

View File

@ -146,6 +146,44 @@ class Days(IntFlag):
SUNDAY = 0x80 SUNDAY = 0x80
class TimeKind(IntEnum):
"""How the ``hour`` / ``minute`` bytes of a TIMED program are interpreted.
PC Access overloads the ``Hr`` byte as a one-of-three discriminator:
a value in 0..23 means an absolute wall-clock time; ``Hr == 25``
means sunrise-relative; ``Hr == 26`` means sunset-relative. For
the two relative kinds, ``Min`` is read as a **signed** byte
(-128..127): a positive value is minutes *after* sunrise/sunset,
a negative value is minutes *before*, and zero is "at".
Reference: frmPopUpEditTime.cs:186-217 (decode), :241-263 (encode).
"""
ABSOLUTE = 0
SUNRISE = 1
SUNSET = 2
_HR_SUNRISE_SENTINEL = 25
_HR_SUNSET_SENTINEL = 26
def _classify_time(hour: int, minute: int) -> tuple[TimeKind, int]:
"""Decode ``(hour, minute)`` bytes into a ``(kind, value)`` pair.
For ``TimeKind.ABSOLUTE`` the ``value`` is the minute byte 0..59
(caller should also use the ``hour`` field for the full time). For
sunrise / sunset, ``value`` is the signed minutes offset.
"""
if hour == _HR_SUNRISE_SENTINEL:
offset = minute if minute < 0x80 else minute - 0x100
return TimeKind.SUNRISE, offset
if hour == _HR_SUNSET_SENTINEL:
offset = minute if minute < 0x80 else minute - 0x100
return TimeKind.SUNSET, offset
return TimeKind.ABSOLUTE, minute & 0xFF
# Once-per-process warnings — see _warn_unknown. # Once-per-process warnings — see _warn_unknown.
_warned_unknown: set[tuple[str, int]] = set() _warned_unknown: set[tuple[str, int]] = set()
@ -342,6 +380,50 @@ class Program:
# ---- convenience ------------------------------------------------- # ---- convenience -------------------------------------------------
@property
def time_kind(self) -> TimeKind:
"""Classify the ``hour`` byte as absolute / sunrise / sunset.
Only meaningful for TIMED programs; for other ``prog_type``
values the return is still computed mechanically but has no
semantic interpretation.
"""
return _classify_time(self.hour, self.minute)[0]
@property
def time_offset_minutes(self) -> int:
"""Signed minutes-offset for sunrise/sunset-relative TIMED programs.
Returns 0 for absolute-time programs (and for non-TIMED types,
whose ``hour`` / ``minute`` bytes aren't time-of-day at all).
Positive = after sunrise/sunset, negative = before, zero = at.
"""
kind, value = _classify_time(self.hour, self.minute)
return value if kind in (TimeKind.SUNRISE, TimeKind.SUNSET) else 0
def format_time(self) -> str:
"""Human-readable rendering of the TIMED time-of-day.
Examples:
``"07:15"`` for an absolute-time program.
``"at sunrise"`` for ``hour==25, minute==0``.
``"30 min before sunset"`` for ``hour==26, minute==226`` (sbyte -30).
Returns the raw ``"hh:mm"`` form for non-TIMED programs even
though it's semantically meaningless there; callers should
check ``prog_type`` first.
"""
kind, value = _classify_time(self.hour, self.minute)
if kind == TimeKind.SUNRISE:
if value == 0:
return "at sunrise"
return f"{abs(value)} min {'after' if value > 0 else 'before'} sunrise"
if kind == TimeKind.SUNSET:
if value == 0:
return "at sunset"
return f"{abs(value)} min {'after' if value > 0 else 'before'} sunset"
return f"{self.hour:02d}:{self.minute:02d}"
@property @property
def event_id(self) -> int: def event_id(self) -> int:
"""The 16-bit event identifier (only meaningful for EVENT type). """The 16-bit event identifier (only meaningful for EVENT type).

View File

@ -159,21 +159,24 @@ def test_programs_sanity_invariants() -> None:
"""Coarse invariants on the 330 defined programs. """Coarse invariants on the 330 defined programs.
The byte-for-byte round-trip test above is the load-bearing The byte-for-byte round-trip test above is the load-bearing
correctness signal. This adds light coverage to catch a Mon/Day correctness signal. The asserts here are belt-and-suspenders:
swap regression specifically on YEARLY-typed programs (which use
bytes 9/10 as a true calendar date).
What we DON'T assert and why: * **YEARLY** uses bytes 9/10 as a real calendar date.
* **TIMED** programs come in two flavors:
* **EVENT** programs encode a u16 event identifier in bytes 9/10 ABSOLUTE (``hour`` 0..23, ``minute`` 0..59) and
(see ``clsProgram.Evt`` at lines 152-163), not a calendar date. sunrise/sunset-relative (``hour`` == 25 or 26 see
* **TIMED** programs use bytes 12/13 either as absolute hour:minute :class:`omni_pca.programs.TimeKind`). The decoder classifies via
(0-23 : 0-59) *or* as a sunrise/sunset-relative offset ``Program.time_kind``; ABSOLUTE-time programs must hit real
(Owner's Manual: ±0-120 minutes), with a flag we haven't wall-clock ranges.
reverse-engineered yet. So hour=26 / minute=246 are valid wire * **EVENT** encodes a u16 event ID in bytes 9/10 rather than
values in the absence of that flag decoder. a calendar date (see ``clsProgram.Evt``); no calendar assertion.
""" """
from omni_pca.programs import ProgramType, decode_program_table, iter_defined from omni_pca.programs import (
ProgramType,
TimeKind,
decode_program_table,
iter_defined,
)
blob = _load_programs_blob_or_skip() blob = _load_programs_blob_or_skip()
programs = decode_program_table(blob) programs = decode_program_table(blob)
@ -189,6 +192,21 @@ def test_programs_sanity_invariants() -> None:
f"slot {p.slot} YEARLY: day={p.day}" f"slot {p.slot} YEARLY: day={p.day}"
) )
timed = [p for p in defined if p.prog_type == int(ProgramType.TIMED)]
assert timed, "fixture should have TIMED programs"
for p in timed:
assert p.days != 0, f"slot {p.slot}: TIMED with no days mask"
if p.time_kind == TimeKind.ABSOLUTE:
assert 0 <= p.hour <= 23, (
f"slot {p.slot} TIMED-ABSOLUTE: hour={p.hour}"
)
assert 0 <= p.minute <= 59, (
f"slot {p.slot} TIMED-ABSOLUTE: minute={p.minute}"
)
else:
# Sunrise/sunset offsets fit in a signed byte.
assert -128 <= p.time_offset_minutes <= 127
def test_remarks_walker_on_empty_table() -> None: def test_remarks_walker_on_empty_table() -> None:
"""Hand-built minimal tail with zero description entries + zero remarks.""" """Hand-built minimal tail with zero description entries + zero remarks."""

View File

@ -245,3 +245,55 @@ def test_days_bitmask_values() -> None:
assert Days.MONDAY == 0x02 assert Days.MONDAY == 0x02
assert Days.SUNDAY == 0x80 assert Days.SUNDAY == 0x80
assert Days.MONDAY | Days.WEDNESDAY | Days.FRIDAY == 0x2A assert Days.MONDAY | Days.WEDNESDAY | Days.FRIDAY == 0x2A
# ---- TimeKind classification + sunrise/sunset offsets ----------------------
from omni_pca.programs import TimeKind # noqa: E402
@pytest.mark.parametrize(
"hour, minute, expected_kind, expected_offset, expected_label",
[
# Absolute times — hour 0..23, minute 0..59.
(0, 0, TimeKind.ABSOLUTE, 0, "00:00"),
(7, 15, TimeKind.ABSOLUTE, 0, "07:15"),
(23, 59, TimeKind.ABSOLUTE, 0, "23:59"),
# Sunrise-relative.
(25, 0, TimeKind.SUNRISE, 0, "at sunrise"),
(25, 30, TimeKind.SUNRISE, 30, "30 min after sunrise"),
(25, 226, TimeKind.SUNRISE, -30, "30 min before sunrise"),
(25, 255, TimeKind.SUNRISE, -1, "1 min before sunrise"),
(25, 127, TimeKind.SUNRISE, 127, "127 min after sunrise"),
# Sunset-relative.
(26, 0, TimeKind.SUNSET, 0, "at sunset"),
(26, 10, TimeKind.SUNSET, 10, "10 min after sunset"),
(26, 246, TimeKind.SUNSET, -10, "10 min before sunset"),
(26, 128, TimeKind.SUNSET, -128, "128 min before sunset"),
],
)
def test_time_kind_classification(
hour, minute, expected_kind, expected_offset, expected_label
) -> None:
p = Program(
prog_type=int(ProgramType.TIMED),
hour=hour, minute=minute, days=int(Days.MONDAY),
)
assert p.time_kind == expected_kind
assert p.time_offset_minutes == expected_offset
assert p.format_time() == expected_label
def test_time_kind_round_trip_through_wire() -> None:
"""Build a sunset-relative program, encode → decode → assert preserved."""
p = Program(
prog_type=int(ProgramType.TIMED),
hour=26, minute=246, # 10 min before sunset
days=int(Days.FRIDAY | Days.SATURDAY),
)
body = p.encode_wire_bytes()
p2 = Program.from_wire_bytes(body)
assert p2.time_kind == TimeKind.SUNSET
assert p2.time_offset_minutes == -10
assert p2.format_time() == "10 min before sunset"