wwvbpy/test/testuwwvb.py

232 lines
9.6 KiB
Python

#!/usr/bin/python3
"""Test of uwwvb.py"""
# SPDX-FileCopyrightText: 2021-2024 Jeff Epler
#
# SPDX-License-Identifier: GPL-3.0-only
# ruff: noqa: N802
import datetime
import random
import sys
import unittest
import zoneinfo
from typing import Union
import adafruit_datetime
import uwwvb
import wwvb
EitherDatetimeOrNone = Union[None, datetime.datetime, adafruit_datetime.datetime]
class WWVBRoundtrip(unittest.TestCase):
"""tests of uwwvb.py"""
def assertDateTimeEqualExceptTzInfo(self, a: EitherDatetimeOrNone, b: EitherDatetimeOrNone) -> None:
"""Test two datetime objects for equality
This equality test excludes tzinfo, and allows adafruit_datetime and core datetime modules to compare equal
"""
assert a
assert b
self.assertEqual(
(a.year, a.month, a.day, a.hour, a.minute, a.second, a.microsecond),
(b.year, b.month, b.day, b.hour, b.minute, b.second, b.microsecond),
)
def test_decode(self) -> None:
"""Test decoding of some minutes including a leap second.
Each minute must decode and match the primary decoder.
"""
minute = wwvb.WWVBMinuteIERS.from_datetime(datetime.datetime(2012, 6, 30, 23, 50, tzinfo=datetime.timezone.utc))
assert minute
decoder = uwwvb.WWVBDecoder()
decoder.update(uwwvb.MARK)
any_leap_second = False
for _ in range(20):
timecode = minute.as_timecode()
decoded: uwwvb.WWVBMinute | None = None
if len(timecode.am) == 61:
any_leap_second = True
for code in timecode.am:
decoded = uwwvb.decode_wwvb(decoder.update(int(code))) or decoded
assert decoded
self.assertDateTimeEqualExceptTzInfo(
minute.as_datetime_utc(),
uwwvb.as_datetime_utc(decoded),
)
minute = minute.next_minute()
self.assertTrue(any_leap_second)
def test_roundtrip(self) -> None:
"""Test that some big range of times all decode the same as the primary decoder"""
dt = datetime.datetime(2002, 1, 1, 0, 0, tzinfo=datetime.timezone.utc)
delta = datetime.timedelta(minutes=7182 if sys.implementation.name == "cpython" else 86400 - 7182)
while dt.year < 2013:
minute = wwvb.WWVBMinuteIERS.from_datetime(dt)
assert minute
decoded = uwwvb.decode_wwvb([int(i) for i in minute.as_timecode().am])
assert decoded
self.assertDateTimeEqualExceptTzInfo(minute.as_datetime_utc(), uwwvb.as_datetime_utc(decoded))
dt = dt + delta
def test_dst(self) -> None:
"""Test of DST as handled by the small decoder"""
for dt in (
datetime.datetime(2021, 3, 14, 8, 59, tzinfo=datetime.timezone.utc),
datetime.datetime(2021, 3, 14, 9, 00, tzinfo=datetime.timezone.utc),
datetime.datetime(2021, 3, 14, 9, 1, tzinfo=datetime.timezone.utc),
datetime.datetime(2021, 3, 15, 8, 59, tzinfo=datetime.timezone.utc),
datetime.datetime(2021, 3, 15, 9, 00, tzinfo=datetime.timezone.utc),
datetime.datetime(2021, 3, 15, 9, 1, tzinfo=datetime.timezone.utc),
datetime.datetime(2021, 11, 7, 8, 59, tzinfo=datetime.timezone.utc),
datetime.datetime(2021, 11, 7, 9, 00, tzinfo=datetime.timezone.utc),
datetime.datetime(2021, 11, 7, 9, 1, tzinfo=datetime.timezone.utc),
datetime.datetime(2021, 11, 8, 8, 59, tzinfo=datetime.timezone.utc),
datetime.datetime(2021, 11, 8, 9, 00, tzinfo=datetime.timezone.utc),
datetime.datetime(2021, 11, 8, 9, 1, tzinfo=datetime.timezone.utc),
datetime.datetime(2021, 7, 7, 9, 1, tzinfo=datetime.timezone.utc),
):
minute = wwvb.WWVBMinuteIERS.from_datetime(dt)
decoded = uwwvb.decode_wwvb([int(i) for i in minute.as_timecode().am])
assert decoded
self.assertDateTimeEqualExceptTzInfo(minute.as_datetime_local(), uwwvb.as_datetime_local(decoded))
decoded = uwwvb.decode_wwvb([int(i) for i in minute.as_timecode().am])
assert decoded
self.assertDateTimeEqualExceptTzInfo(
minute.as_datetime_local(dst_observed=False),
uwwvb.as_datetime_local(decoded, dst_observed=False),
)
def test_noise(self) -> None:
"""Test of the state-machine decoder when faced with pseudorandom noise"""
minute = wwvb.WWVBMinuteIERS.from_datetime(
datetime.datetime(2012, 6, 30, 23, 50, tzinfo=datetime.timezone.utc),
)
r = random.Random(408)
junk = [
r.choice(
[
wwvb.AmplitudeModulation.MARK,
wwvb.AmplitudeModulation.ONE,
wwvb.AmplitudeModulation.ZERO,
],
)
for _ in range(480)
]
timecode = minute.as_timecode()
test_input = [*junk, wwvb.AmplitudeModulation.MARK, *timecode.am]
decoder = uwwvb.WWVBDecoder()
for code in test_input[:-1]:
decoded = decoder.update(code)
self.assertIsNone(decoded)
minute_maybe = decoder.update(wwvb.AmplitudeModulation.MARK)
assert minute_maybe
decoded_minute = uwwvb.decode_wwvb(minute_maybe)
assert decoded_minute
self.assertDateTimeEqualExceptTzInfo(
minute.as_datetime_utc(),
uwwvb.as_datetime_utc(decoded_minute),
)
self.assertDateTimeEqualExceptTzInfo(
minute.as_datetime_local(),
uwwvb.as_datetime_local(decoded_minute),
)
def test_noise2(self) -> None:
"""Test of the full minute decoder with targeted errors to get full coverage"""
minute = wwvb.WWVBMinuteIERS.from_datetime(
datetime.datetime(2012, 6, 30, 23, 50, tzinfo=datetime.timezone.utc),
)
timecode = minute.as_timecode()
decoded = uwwvb.decode_wwvb([int(i) for i in timecode.am])
self.assertIsNotNone(decoded)
for position in uwwvb.always_mark:
test_input = [int(i) for i in timecode.am]
for noise in (0, 1):
test_input[position] = noise
decoded = uwwvb.decode_wwvb(test_input)
self.assertIsNone(decoded)
for position in uwwvb.always_zero:
test_input = [int(i) for i in timecode.am]
for noise in (1, 2):
test_input[position] = noise
decoded = uwwvb.decode_wwvb(test_input)
self.assertIsNone(decoded)
for i in range(8):
if i in (0b101, 0b010): # Test the 6 impossible bit-combos
continue
test_input = [int(i) for i in timecode.am]
test_input[36] = i & 1
test_input[37] = (i >> 1) & 1
test_input[38] = (i >> 2) & 1
decoded = uwwvb.decode_wwvb(test_input)
self.assertIsNone(decoded)
# Invalid year-day
test_input = [int(i) for i in timecode.am]
test_input[22] = 1
test_input[23] = 1
test_input[25] = 1
decoded = uwwvb.decode_wwvb(test_input)
self.assertIsNone(decoded)
def test_noise3(self) -> None:
"""Test impossible BCD values"""
minute = wwvb.WWVBMinuteIERS.from_datetime(
datetime.datetime(2012, 6, 30, 23, 50, tzinfo=datetime.timezone.utc),
)
timecode = minute.as_timecode()
for poslist in [
[1, 2, 3, 4], # tens minutes
[5, 6, 7, 8], # ones minutes
[15, 16, 17, 18], # tens hours
[25, 26, 27, 28], # tens days
[30, 31, 32, 33], # ones days
[40, 41, 42, 43], # tens years
[45, 46, 47, 48], # ones years
[50, 51, 52, 53], # ones dut1
]:
with self.subTest(test=poslist):
test_input = [int(i) for i in timecode.am]
for pi in poslist:
test_input[pi] = 1
decoded = uwwvb.decode_wwvb(test_input)
self.assertIsNone(decoded)
def test_noise4(self) -> None:
"""Test of the full minute decoder with marks at every never-mark position"""
minute = wwvb.WWVBMinuteIERS.from_datetime(
datetime.datetime(2012, 6, 30, 23, 50, tzinfo=datetime.timezone.utc),
)
timecode = minute.as_timecode()
for position in range(60):
if position in uwwvb.always_mark:
continue
with self.subTest(test=position):
test_input = [int(i) for i in timecode.am]
test_input[position] = uwwvb.MARK
decoded = uwwvb.decode_wwvb(test_input)
self.assertIsNone(decoded)
def test_str(self) -> None:
"""Test the str() of a WWVBDecoder"""
self.assertEqual(str(uwwvb.WWVBDecoder()), "<WWVBDecoder 1 []>")
def test_near_year_bug(self) -> None:
"""Test for a bug seen in another WWVB implementaiton
.. in which the hours after UTC midnight on 12-31 of a leap year would
be shown incorrectly. Check that we don't have that bug.
"""
minute = wwvb.WWVBMinuteIERS.from_datetime(datetime.datetime(2021, 1, 1, 0, 0, tzinfo=datetime.timezone.utc))
timecode = minute.as_timecode()
decoded = uwwvb.decode_wwvb([int(i) for i in timecode.am])
assert decoded
self.assertDateTimeEqualExceptTzInfo(
datetime.datetime(2020, 12, 31, 17, 00, tzinfo=zoneinfo.ZoneInfo("America/Denver")), # Mountain time!
uwwvb.as_datetime_local(decoded),
)