Skip to content
This repository was archived by the owner on Sep 8, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 49 additions & 46 deletions src/wwvb/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,25 +19,21 @@
import enum
import json
import warnings
from typing import TYPE_CHECKING, Any, NamedTuple, TextIO, TypeVar

from typing_extensions import Self
from dataclasses import dataclass
from typing import ClassVar

from . import iersdata
from .tz import Mountain

TYPE_CHECKING = False
if TYPE_CHECKING:
from collections.abc import Generator
from typing import Any, Self, TextIO, TypeVar

T = TypeVar("T")

HOUR = datetime.timedelta(seconds=3600)
SECOND = datetime.timedelta(seconds=1)
T = TypeVar("T")


def _removeprefix(s: str, p: str) -> str:
if s.startswith(p):
return s[len(p) :]
return s


def _date(dt: datetime.date) -> datetime.date:
Expand Down Expand Up @@ -341,8 +337,13 @@ class DstStatus(enum.IntEnum):
"""DST in effect all day today"""


class _WWVBMinute(NamedTuple):
"""(implementation detail)"""
@dataclass(frozen=True)
class WWVBMinute:
"""Uniquely identifies a minute of time in the WWVB system.

To use ``ut1`` and ``ls`` information from IERS, create a `WWVBMinuteIERS`
object instead.
"""

year: int
"""2-digit year within the WWVB epoch"""
Expand All @@ -353,7 +354,7 @@ class _WWVBMinute(NamedTuple):
hour: int
"""UTC hour of day"""

min: int
minute: int
"""Minute of hour"""

dst: DstStatus
Expand All @@ -368,18 +369,10 @@ class _WWVBMinute(NamedTuple):
ly: bool
"""Leap year flag"""

epoch: ClassVar[int] = 1970

class WWVBMinute(_WWVBMinute):
"""Uniquely identifies a minute of time in the WWVB system.

To use ``ut1`` and ``ls`` information from IERS, create a `WWVBMinuteIERS`
object instead.
"""

epoch: int = 1970

def __new__(
cls,
def __init__(
self,
year: int,
days: int,
hour: int,
Expand All @@ -389,7 +382,7 @@ def __new__(
*,
ls: bool | None = None,
ly: bool | None = None,
) -> Self:
) -> None:
"""Construct a WWVBMinute

:param year: The 2- or 4-digit year. This parameter is converted by the `full_year` method.
Expand All @@ -403,22 +396,23 @@ def __new__(
:param ls: Leap second warning flag
:param ly: Leap year flag
"""
dst = cls.get_dst(year, days) if dst is None else DstStatus(dst)
dst = self.get_dst(year, days) if dst is None else DstStatus(dst)
if ut1 is None and ls is None:
ut1, ls = cls._get_dut1_info(year, days)
ut1, ls = self._get_dut1_info(year, days)
elif ut1 is None or ls is None:
raise ValueError("sepecify both ut1 and ls or neither one")
year = cls.full_year(year)
year = self.full_year(year)
if ly is None:
ly = isly(year)
return super().__new__(cls, year, days, hour, minute, dst, ut1, ls, ly)

def __init__(self, *args: Any, **kw: Any) -> None:
"""Do-nothing function.

Instance initialization is performed in __new__. This implementation of __init__
works around a pyrefly bug.
"""
super().__setattr__("year", year)
super().__setattr__("days", days)
super().__setattr__("hour", hour)
super().__setattr__("minute", minute)
super().__setattr__("dst", dst)
super().__setattr__("ut1", ut1)
super().__setattr__("ls", ls)
super().__setattr__("ly", ly)

@classmethod
def full_year(cls, year: int) -> int:
Expand Down Expand Up @@ -452,7 +446,7 @@ def __str__(self) -> str:
"""Implement str()"""
return (
f"year={self.year:4d} days={self.days:03d} hour={self.hour:02d} "
f"min={self.min:02d} dst={self.dst} ut1={self.ut1} ly={int(self.ly)} "
f"min={self.minute:02d} dst={self.dst} ut1={self.ut1} ly={int(self.ly)} "
f"ls={int(self.ls)}"
)

Expand All @@ -462,7 +456,7 @@ def as_datetime_utc(self) -> datetime.datetime:
The returned object has ``tzinfo=datetime.timezone.utc``.
"""
d = datetime.datetime(self.year, 1, 1, tzinfo=datetime.timezone.utc)
d += datetime.timedelta(self.days - 1, self.hour * 3600 + self.min * 60)
d += datetime.timedelta(self.days - 1, self.hour * 3600 + self.minute * 60)
return d

as_datetime = as_datetime_utc
Expand Down Expand Up @@ -516,7 +510,7 @@ def minute_length(self) -> int:
return 60
if not self._is_end_of_month():
return 60
if self.hour != 23 or self.min != 59:
if self.hour != 23 or self.minute != 59:
return 60
if self.ut1 > 0:
return 59
Expand Down Expand Up @@ -560,7 +554,7 @@ def _fill_am_timecode(self, t: WWVBTimecode) -> None:
t.am[60] = AmplitudeModulation.MARK
for i in [4, 10, 11, 14, 20, 21, 24, 34, 35, 44, 54]:
t.am[i] = AmplitudeModulation.ZERO
t._put_am_bcd(self.min, 1, 2, 3, 5, 6, 7, 8)
t._put_am_bcd(self.minute, 1, 2, 3, 5, 6, 7, 8)
t._put_am_bcd(self.hour, 12, 13, 15, 16, 17, 18)
t._put_am_bcd(self.days, 22, 23, 25, 26, 27, 28, 30, 31, 32, 33)
ut1_sign = self.ut1 >= 0
Expand All @@ -574,14 +568,14 @@ def _fill_am_timecode(self, t: WWVBTimecode) -> None:

def _fill_pm_timecode_extended(self, t: WWVBTimecode) -> None:
"""During minutes 10..15 and 40..45, the amplitude signal holds 'extended information'"""
assert 10 <= self.min < 16 or 40 <= self.min < 46
minno = self.min % 10
assert 10 <= self.minute < 16 or 40 <= self.minute < 46
minno = self.minute % 10
assert minno < 6

dst = self.dst
# Note that these are 1 different than Table 11
# because our LFSR sequence is zero-based
seqno = (self.min // 30) * 2
seqno = (self.minute // 30) * 2
if dst == 0:
pass
elif dst == 3:
Expand Down Expand Up @@ -664,7 +658,7 @@ def _fill_pm_timecode_regular(self, t: WWVBTimecode) -> None: # noqa: PLR0915

def _fill_pm_timecode(self, t: WWVBTimecode) -> None:
"""Fill the phase portion of a timecode object"""
if 10 <= self.min < 16 or 40 <= self.min < 46:
if 10 <= self.minute < 16 or 40 <= self.minute < 46:
self._fill_pm_timecode_extended(t)
else:
self._fill_pm_timecode_regular(t)
Expand Down Expand Up @@ -695,7 +689,7 @@ def _get_dut1_info(cls: type, year: int, days: int, old_time: WWVBMinute | None
@classmethod
def fromstring(cls, s: str) -> Self:
"""Construct a WWVBMinute from a string representation created by print_timecodes"""
s = _removeprefix(s, "WWVB timecode: ")
s = s.removeprefix("WWVB timecode: ")
d: dict[str, int] = {}
for part in s.split():
k, v = part.split("=")
Expand Down Expand Up @@ -773,6 +767,15 @@ def from_timecode_am(cls, t: WWVBTimecode) -> Self | None: # noqa: PLR0912
return None
return cls(year, days, hour, minute, dst, ut1, ls=ls, ly=ly)

@property
def min(self) -> int:
"""Deprecated alias for `WWVBMinute.minute`

Update your code to use the `minute` property instead of the `min` property.
"""
warnings.warn("WWVBMinute.min property is deprecated", category=DeprecationWarning, stacklevel=1)
return self.minute


class WWVBMinuteIERS(WWVBMinute):
"""A WWVBMinute that uses a database of DUT1 information"""
Expand Down Expand Up @@ -943,7 +946,7 @@ def print_timecodes(
print(file=file)
print(f"WWVB timecode: {w!s}{channel_text}{style_text}", file=file)
first = False
pfx = f"{w.year:04d}-{w.days:03d} {w.hour:02d}:{w.min:02d} "
pfx = f"{w.year:04d}-{w.days:03d} {w.hour:02d}:{w.minute:02d} "
tc = w.as_timecode()
if len(style_chars) == 6:
print(f"{pfx} {tc.to_both_string(style_chars)}", file=file)
Expand Down Expand Up @@ -985,7 +988,7 @@ def print_timecodes_json(
"year": w.year,
"days": w.days,
"hour": w.hour,
"minute": w.min,
"minute": w.minute,
}

tc = w.as_timecode()
Expand Down
5 changes: 5 additions & 0 deletions test/testwwvb.py
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,11 @@ def test_update(self) -> None:
wwvb._maybe_warn_update(datetime.date(1970, 1, 1))
wwvb._maybe_warn_update(datetime.datetime(1970, 1, 1, 0, 0, tzinfo=datetime.timezone.utc))

def test_deprecated_min(self) -> None:
"""Ensure that the 'maybe_warn_update' function is covered"""
with self.assertWarnsRegex(DeprecationWarning, "min property"):
self.assertEqual(wwvb.WWVBMinute(2021, 1, 1, 1).min, wwvb.WWVBMinute(2021, 1, 1, 1).minute)

def test_undefined(self) -> None:
"""Ensure that the check for unset elements in am works"""
with self.assertWarnsRegex(Warning, "is unset"):
Expand Down
Loading