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:
parent
00f0028053
commit
eb1a632ef2
@ -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",
|
||||||
|
|||||||
@ -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).
|
||||||
|
|||||||
@ -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."""
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user