wwvb 3.0.8__py3-none-any.whl → 4.0.0a0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- uwwvb.py +4 -1
- wwvb/__init__.py +210 -169
- wwvb/__version__.py +2 -2
- wwvb/decode.py +8 -9
- wwvb/gen.py +14 -13
- wwvb/iersdata.py +4 -5
- wwvb/iersdata_dist.py +3 -3
- wwvb/testcli.py +8 -3
- wwvb/testpm.py +2 -2
- wwvb/testuwwvb.py +41 -28
- wwvb/testwwvb.py +51 -39
- wwvb/updateiers.py +34 -31
- wwvb/wwvbtk.py +12 -10
- {wwvb-3.0.8.dist-info → wwvb-4.0.0a0.dist-info}/METADATA +1 -1
- wwvb-4.0.0a0.dist-info/RECORD +23 -0
- {wwvb-3.0.8.dist-info → wwvb-4.0.0a0.dist-info}/WHEEL +1 -1
- wwvb-3.0.8.dist-info/RECORD +0 -23
- {wwvb-3.0.8.dist-info → wwvb-4.0.0a0.dist-info}/entry_points.txt +0 -0
- {wwvb-3.0.8.dist-info → wwvb-4.0.0a0.dist-info}/top_level.txt +0 -0
wwvb/__init__.py
CHANGED
@@ -6,25 +6,24 @@
|
|
6
6
|
#
|
7
7
|
# SPDX-License-Identifier: GPL-3.0-only
|
8
8
|
|
9
|
-
import
|
9
|
+
from __future__ import annotations
|
10
|
+
|
10
11
|
import datetime
|
11
12
|
import enum
|
12
13
|
import json
|
13
14
|
import warnings
|
14
|
-
from typing import
|
15
|
+
from typing import Any, Generator, NamedTuple, TextIO, TypeVar
|
15
16
|
|
16
17
|
from . import iersdata
|
17
18
|
from .tz import Mountain
|
18
19
|
|
19
20
|
HOUR = datetime.timedelta(seconds=3600)
|
20
21
|
SECOND = datetime.timedelta(seconds=1)
|
21
|
-
DateOrDatetime = TypeVar("DateOrDatetime", datetime.date, datetime.datetime)
|
22
22
|
T = TypeVar("T")
|
23
23
|
|
24
24
|
|
25
|
-
def
|
26
|
-
"""
|
27
|
-
giving a hint to the type system."""
|
25
|
+
def _require(x: T | None) -> T:
|
26
|
+
"""Check an Optional item is not None."""
|
28
27
|
assert x is not None
|
29
28
|
return x
|
30
29
|
|
@@ -35,24 +34,28 @@ def _removeprefix(s: str, p: str) -> str:
|
|
35
34
|
return s
|
36
35
|
|
37
36
|
|
38
|
-
def _date(dt:
|
37
|
+
def _date(dt: datetime.date) -> datetime.date:
|
39
38
|
"""Return the date object itself, or the date property of a datetime"""
|
40
39
|
if isinstance(dt, datetime.datetime):
|
41
40
|
return dt.date()
|
42
41
|
return dt
|
43
42
|
|
44
43
|
|
45
|
-
def _maybe_warn_update(dt: datetime.date) -> None:
|
44
|
+
def _maybe_warn_update(dt: datetime.date, stacklevel: int = 1) -> None:
|
46
45
|
"""Maybe print a notice to run updateiers, if it seems useful to do so."""
|
47
46
|
# We already know this date is not covered.
|
48
|
-
# If the date is less than
|
47
|
+
# If the date is less than 300 days after today, there should be (possibly)
|
49
48
|
# prospective available now.
|
50
|
-
today = datetime.
|
49
|
+
today = datetime.datetime.now(tz=datetime.timezone.utc).date()
|
50
|
+
print(f"_mwu {today=!r} {dt=!r} {iersdata.end=!r}")
|
51
51
|
if _date(dt) < today + datetime.timedelta(days=330):
|
52
|
-
warnings.warn(
|
52
|
+
warnings.warn(
|
53
|
+
"Note: Running `updateiers` may provide better DUT1 and LS information",
|
54
|
+
stacklevel=stacklevel + 1,
|
55
|
+
)
|
53
56
|
|
54
57
|
|
55
|
-
def get_dut1(dt:
|
58
|
+
def get_dut1(dt: datetime.date, *, warn_outdated: bool = True) -> float:
|
56
59
|
"""Return the DUT1 number for the given timestamp"""
|
57
60
|
date = _date(dt)
|
58
61
|
i = (date - iersdata.DUT1_DATA_START).days
|
@@ -60,7 +63,7 @@ def get_dut1(dt: DateOrDatetime, *, warn_outdated: bool = True) -> float:
|
|
60
63
|
v = iersdata.DUT1_OFFSETS[0]
|
61
64
|
elif i >= len(iersdata.DUT1_OFFSETS):
|
62
65
|
if warn_outdated:
|
63
|
-
_maybe_warn_update(dt)
|
66
|
+
_maybe_warn_update(dt, stacklevel=2)
|
64
67
|
v = iersdata.DUT1_OFFSETS[-1]
|
65
68
|
else:
|
66
69
|
v = iersdata.DUT1_OFFSETS[i]
|
@@ -74,7 +77,7 @@ def isly(year: int) -> bool:
|
|
74
77
|
return d1.year == d2.year
|
75
78
|
|
76
79
|
|
77
|
-
def isls(t:
|
80
|
+
def isls(t: datetime.date) -> bool:
|
78
81
|
"""Return True if a leap second occurs at the end of this month"""
|
79
82
|
dut1_today = get_dut1(t)
|
80
83
|
month_today = t.month
|
@@ -86,11 +89,11 @@ def isls(t: DateOrDatetime) -> bool:
|
|
86
89
|
|
87
90
|
def isdst(t: datetime.date, tz: datetime.tzinfo = Mountain) -> bool:
|
88
91
|
"""Return true if daylight saving time is active at the start of the given UTC day"""
|
89
|
-
|
90
|
-
return bool(
|
92
|
+
utc_daystart = datetime.datetime(t.year, t.month, t.day, tzinfo=datetime.timezone.utc)
|
93
|
+
return bool(utc_daystart.astimezone(tz).dst())
|
91
94
|
|
92
95
|
|
93
|
-
def
|
96
|
+
def _first_sunday_on_or_after(dt: datetime.date) -> datetime.date:
|
94
97
|
"""Return the first sunday on or after the reference time"""
|
95
98
|
days_to_go = 6 - dt.weekday()
|
96
99
|
if days_to_go:
|
@@ -98,17 +101,17 @@ def first_sunday_on_or_after(dt: DateOrDatetime) -> DateOrDatetime:
|
|
98
101
|
return dt
|
99
102
|
|
100
103
|
|
101
|
-
def
|
104
|
+
def _first_sunday_in_month(y: int, m: int) -> datetime.date:
|
102
105
|
"""Find the first sunday in a given month"""
|
103
|
-
return
|
106
|
+
return _first_sunday_on_or_after(datetime.datetime(y, m, 1, tzinfo=datetime.timezone.utc))
|
104
107
|
|
105
108
|
|
106
|
-
def
|
109
|
+
def _is_dst_change_day(t: datetime.date, tz: datetime.tzinfo = Mountain) -> bool:
|
107
110
|
"""Return True if the day is a DST change day"""
|
108
111
|
return isdst(t, tz) != isdst(t + datetime.timedelta(1), tz)
|
109
112
|
|
110
113
|
|
111
|
-
def
|
114
|
+
def _get_dst_change_hour(t: datetime.date, tz: datetime.tzinfo = Mountain) -> int | None:
|
112
115
|
"""Return the hour when DST changes"""
|
113
116
|
lt0 = datetime.datetime(t.year, t.month, t.day, hour=0, tzinfo=tz)
|
114
117
|
dst0 = lt0.dst()
|
@@ -122,28 +125,29 @@ def get_dst_change_hour(t: DateOrDatetime, tz: datetime.tzinfo = Mountain) -> Op
|
|
122
125
|
return None
|
123
126
|
|
124
127
|
|
125
|
-
def
|
126
|
-
d:
|
127
|
-
|
128
|
+
def _get_dst_change_date_and_row(
|
129
|
+
d: datetime.date,
|
130
|
+
tz: datetime.tzinfo = Mountain,
|
131
|
+
) -> tuple[datetime.date | None, int | None]:
|
128
132
|
"""Classify DST information for the WWVB phase modulation signal"""
|
129
133
|
if isdst(d, tz):
|
130
|
-
n =
|
134
|
+
n = _first_sunday_in_month(d.year, 11)
|
131
135
|
for offset in range(-28, 28, 7):
|
132
136
|
d1 = n + datetime.timedelta(days=offset)
|
133
|
-
if
|
137
|
+
if _is_dst_change_day(d1, tz):
|
134
138
|
return d1, (offset + 28) // 7
|
135
139
|
else:
|
136
|
-
m =
|
140
|
+
m = _first_sunday_in_month(d.year + (d.month > 3), 3)
|
137
141
|
for offset in range(0, 52, 7):
|
138
142
|
d1 = m + datetime.timedelta(days=offset)
|
139
|
-
if
|
143
|
+
if _is_dst_change_day(d1, tz):
|
140
144
|
return d1, offset // 7
|
141
145
|
|
142
146
|
return None, None
|
143
147
|
|
144
148
|
|
145
149
|
# "Table 8", likely with transcrption errors
|
146
|
-
|
150
|
+
_dsttable = [
|
147
151
|
[
|
148
152
|
[
|
149
153
|
0b110001,
|
@@ -211,19 +215,17 @@ dsttable = [
|
|
211
215
|
]
|
212
216
|
|
213
217
|
|
214
|
-
def
|
215
|
-
"""Generate the
|
216
|
-
except generate 255 bits so that we can simply use any range of [x:x+127]
|
217
|
-
bits"""
|
218
|
+
def _lfsr_gen(x: list[int]) -> None:
|
219
|
+
"""Generate the next bit of the 6-minute codes sequence"""
|
218
220
|
x.append(x[-7] ^ x[-6] ^ x[-5] ^ x[-2])
|
219
221
|
|
220
222
|
|
221
|
-
|
222
|
-
while len(
|
223
|
-
|
223
|
+
_lfsr_seq = [1] * 7
|
224
|
+
while len(_lfsr_seq) < 255:
|
225
|
+
_lfsr_gen(_lfsr_seq)
|
224
226
|
|
225
227
|
# Table 12 - Fixed 106-bit timing word
|
226
|
-
|
228
|
+
_ftw = [
|
227
229
|
int(c)
|
228
230
|
for c in "1101000111"
|
229
231
|
"0101100101"
|
@@ -239,11 +241,11 @@ ftw = [
|
|
239
241
|
]
|
240
242
|
|
241
243
|
|
242
|
-
def
|
244
|
+
def _get_dst_next(d: datetime.date, tz: datetime.tzinfo = Mountain) -> int:
|
243
245
|
"""Find the "dst next" value for the phase modulation signal"""
|
244
246
|
dst_now = isdst(d, tz) # dst_on[1]
|
245
|
-
dst_midwinter = isdst(datetime.datetime(d.year, 1, 1), tz)
|
246
|
-
dst_midsummer = isdst(datetime.datetime(d.year, 7, 1), tz)
|
247
|
+
dst_midwinter = isdst(datetime.datetime(d.year, 1, 1, tzinfo=datetime.timezone.utc), tz)
|
248
|
+
dst_midsummer = isdst(datetime.datetime(d.year, 7, 1, tzinfo=datetime.timezone.utc), tz)
|
247
249
|
|
248
250
|
if dst_midwinter and dst_midsummer:
|
249
251
|
return 0b101111
|
@@ -254,19 +256,19 @@ def get_dst_next(d: DateOrDatetime, tz: datetime.tzinfo = Mountain) -> int:
|
|
254
256
|
if dst_midwinter or not dst_midsummer:
|
255
257
|
return 0b100011
|
256
258
|
|
257
|
-
dst_change_date, dst_next_row =
|
259
|
+
dst_change_date, dst_next_row = _get_dst_change_date_and_row(d, tz)
|
258
260
|
if dst_change_date is None:
|
259
261
|
return 0b100011
|
260
262
|
assert dst_next_row is not None
|
261
263
|
|
262
|
-
dst_change_hour =
|
264
|
+
dst_change_hour = _get_dst_change_hour(dst_change_date, tz)
|
263
265
|
if dst_change_hour is None:
|
264
266
|
return 0b100011
|
265
267
|
|
266
|
-
return
|
268
|
+
return _dsttable[dst_now][dst_change_hour][dst_next_row]
|
267
269
|
|
268
270
|
|
269
|
-
|
271
|
+
_hamming_weight = [
|
270
272
|
[23, 21, 20, 17, 16, 15, 14, 13, 9, 8, 6, 5, 4, 2, 0],
|
271
273
|
[24, 22, 21, 18, 17, 16, 15, 14, 10, 9, 7, 6, 5, 3, 1],
|
272
274
|
[25, 23, 22, 19, 18, 17, 16, 15, 11, 10, 8, 7, 6, 4, 2],
|
@@ -281,25 +283,26 @@ SYNC_T = 0x768
|
|
281
283
|
SYNC_M = 0x1A3A
|
282
284
|
|
283
285
|
|
284
|
-
def
|
286
|
+
def _extract_bit(v: int, p: int) -> bool:
|
285
287
|
"""Extract bit 'p' from integer 'v' as a bool"""
|
286
288
|
return bool((v >> p) & 1)
|
287
289
|
|
288
290
|
|
289
|
-
def
|
291
|
+
def _hamming_parity(value: int) -> int:
|
290
292
|
"""Compute the "hamming parity" of a 26-bit number, such as the minute-of-century
|
291
293
|
|
292
|
-
For more details, see Enhanced WWVB Broadcast Format 4.3
|
294
|
+
For more details, see Enhanced WWVB Broadcast Format 4.3
|
295
|
+
"""
|
293
296
|
parity = 0
|
294
297
|
for i in range(4, -1, -1):
|
295
298
|
bit = 0
|
296
|
-
for j in range(
|
297
|
-
bit ^=
|
299
|
+
for j in range(15):
|
300
|
+
bit ^= _extract_bit(value, _hamming_weight[i][j])
|
298
301
|
parity = (parity << 1) | bit
|
299
302
|
return parity
|
300
303
|
|
301
304
|
|
302
|
-
|
305
|
+
_dst_ls_lut = [
|
303
306
|
0b01000,
|
304
307
|
0b10101,
|
305
308
|
0b10110,
|
@@ -318,35 +321,57 @@ dst_ls_lut = [
|
|
318
321
|
0b11111,
|
319
322
|
]
|
320
323
|
|
321
|
-
_WWVBMinute = collections.namedtuple("_WWVBMinute", "year days hour min dst ut1 ls ly")
|
322
324
|
|
323
|
-
|
324
|
-
class WWVBMinute(_WWVBMinute):
|
325
|
+
class _WWVBMinute(NamedTuple):
|
325
326
|
"""Uniquely identifies a minute of time in the WWVB system.
|
326
327
|
|
327
|
-
To use ut1 and ls information from IERS, create a WWVBMinuteIERS value instead.
|
328
|
+
To use ut1 and ls information from IERS, create a WWVBMinuteIERS value instead.
|
329
|
+
"""
|
328
330
|
|
329
331
|
year: int
|
332
|
+
"""2-digit year within the WWVB epoch"""
|
333
|
+
|
334
|
+
days: int
|
335
|
+
"""1-based day of year"""
|
336
|
+
|
330
337
|
hour: int
|
331
|
-
|
338
|
+
"""UTC hour of day"""
|
339
|
+
|
340
|
+
min: int
|
341
|
+
"""Minute of hour"""
|
342
|
+
|
332
343
|
dst: int
|
344
|
+
"""2-bit DST code """
|
345
|
+
|
333
346
|
ut1: int
|
334
|
-
|
347
|
+
"""UT1 offset in units of 100ms, range -900 to +900ms"""
|
348
|
+
|
335
349
|
ls: bool
|
350
|
+
"""Leap second warning flag"""
|
351
|
+
|
352
|
+
ly: bool
|
353
|
+
"""Leap year flag"""
|
354
|
+
|
355
|
+
|
356
|
+
class WWVBMinute(_WWVBMinute):
|
357
|
+
"""Uniquely identifies a minute of time in the WWVB system.
|
358
|
+
|
359
|
+
To use ut1 and ls information from IERS, create a WWVBMinuteIERS value instead.
|
360
|
+
"""
|
336
361
|
|
337
362
|
epoch: int = 1970
|
338
363
|
|
339
|
-
def __new__(
|
364
|
+
def __new__( # noqa: PYI034
|
340
365
|
cls,
|
341
366
|
year: int,
|
342
367
|
days: int,
|
343
368
|
hour: int,
|
344
369
|
minute: int,
|
345
|
-
dst:
|
346
|
-
ut1:
|
347
|
-
ls:
|
348
|
-
ly:
|
349
|
-
) ->
|
370
|
+
dst: int | None = None,
|
371
|
+
ut1: int | None = None,
|
372
|
+
ls: bool | None = None,
|
373
|
+
ly: bool | None = None,
|
374
|
+
) -> WWVBMinute:
|
350
375
|
"""Construct a WWVBMinute"""
|
351
376
|
if dst is None:
|
352
377
|
dst = cls.get_dst(year, days)
|
@@ -383,7 +408,7 @@ class WWVBMinute(_WWVBMinute):
|
|
383
408
|
@staticmethod
|
384
409
|
def get_dst(year: int, days: int) -> int:
|
385
410
|
"""Get the 2-bit WWVB DST value for the given day"""
|
386
|
-
d0 = datetime.datetime(year, 1, 1) + datetime.timedelta(days - 1)
|
411
|
+
d0 = datetime.datetime(year, 1, 1, tzinfo=datetime.timezone.utc) + datetime.timedelta(days - 1)
|
387
412
|
d1 = d0 + datetime.timedelta(1)
|
388
413
|
dst0 = isdst(d0)
|
389
414
|
dst1 = isdst(d1)
|
@@ -405,7 +430,12 @@ class WWVBMinute(_WWVBMinute):
|
|
405
430
|
|
406
431
|
as_datetime = as_datetime_utc
|
407
432
|
|
408
|
-
def as_datetime_local(
|
433
|
+
def as_datetime_local(
|
434
|
+
self,
|
435
|
+
standard_time_offset: int = 7 * 3600,
|
436
|
+
*,
|
437
|
+
dst_observed: bool = True,
|
438
|
+
) -> datetime.datetime:
|
409
439
|
"""Convert to a local datetime according to the DST bits"""
|
410
440
|
u = self.as_datetime_utc()
|
411
441
|
offset = datetime.timedelta(seconds=-standard_time_offset)
|
@@ -426,11 +456,6 @@ class WWVBMinute(_WWVBMinute):
|
|
426
456
|
offset += datetime.timedelta(seconds=3600)
|
427
457
|
return u.astimezone(datetime.timezone(offset))
|
428
458
|
|
429
|
-
def is_ly(self) -> bool:
|
430
|
-
"""Return True if minute is during a leap year"""
|
431
|
-
warnings.warn("Deprecated, use ly property instead", DeprecationWarning)
|
432
|
-
return self.ly
|
433
|
-
|
434
459
|
def _is_end_of_month(self) -> bool:
|
435
460
|
"""Return True if minute is the last minute in a month"""
|
436
461
|
d = self.as_datetime()
|
@@ -449,18 +474,18 @@ class WWVBMinute(_WWVBMinute):
|
|
449
474
|
return 59
|
450
475
|
return 61
|
451
476
|
|
452
|
-
def as_timecode(self) ->
|
477
|
+
def as_timecode(self) -> WWVBTimecode:
|
453
478
|
"""Fill a WWVBTimecode structure representing this minute. Fills both the amplitude and phase codes."""
|
454
479
|
t = WWVBTimecode(self.minute_length())
|
455
480
|
|
456
|
-
self.
|
457
|
-
self.
|
481
|
+
self._fill_am_timecode(t)
|
482
|
+
self._fill_pm_timecode(t)
|
458
483
|
|
459
484
|
return t
|
460
485
|
|
461
486
|
@property
|
462
|
-
def
|
463
|
-
"""Return the 2-bit
|
487
|
+
def _leap_sec(self) -> int:
|
488
|
+
"""Return the 2-bit _leap_sec value used by the PM code"""
|
464
489
|
if not self.ls:
|
465
490
|
return 0
|
466
491
|
if self.ut1 < 0:
|
@@ -477,7 +502,7 @@ class WWVBMinute(_WWVBMinute):
|
|
477
502
|
// 60
|
478
503
|
)
|
479
504
|
|
480
|
-
def
|
505
|
+
def _fill_am_timecode(self, t: WWVBTimecode) -> None:
|
481
506
|
"""Fill the amplitude (AM) portion of a timecode object"""
|
482
507
|
for i in [0, 9, 19, 29, 39, 49]:
|
483
508
|
t.am[i] = AmplitudeModulation.MARK
|
@@ -499,7 +524,7 @@ class WWVBMinute(_WWVBMinute):
|
|
499
524
|
t.am[56] = AmplitudeModulation(self.ls)
|
500
525
|
t._put_am_bcd(self.dst, 57, 58)
|
501
526
|
|
502
|
-
def
|
527
|
+
def _fill_pm_timecode_extended(self, t: WWVBTimecode) -> None:
|
503
528
|
"""During minutes 10..15 and 40..45, the amplitude signal holds 'extended information'"""
|
504
529
|
assert 10 <= self.min < 16 or 40 <= self.min < 46
|
505
530
|
minno = self.min % 10
|
@@ -520,103 +545,99 @@ class WWVBMinute(_WWVBMinute):
|
|
520
545
|
seqno = seqno + 90
|
521
546
|
else:
|
522
547
|
seqno = seqno + 1
|
523
|
-
|
524
|
-
|
525
|
-
|
526
|
-
|
527
|
-
seqno = seqno + 91
|
548
|
+
elif self.hour < 4:
|
549
|
+
seqno = seqno + 1
|
550
|
+
elif self.hour < 11:
|
551
|
+
seqno = seqno + 91
|
528
552
|
|
529
|
-
info_seq =
|
530
|
-
full_seq = info_seq +
|
553
|
+
info_seq = _lfsr_seq[seqno : seqno + 127]
|
554
|
+
full_seq = info_seq + _ftw + info_seq[::-1]
|
531
555
|
assert len(full_seq) == 360
|
532
556
|
|
533
557
|
offset = minno * 60
|
534
558
|
for i in range(60):
|
535
559
|
t._put_pm_bit(i, full_seq[i + offset])
|
536
560
|
|
537
|
-
def
|
561
|
+
def _fill_pm_timecode_regular(self, t: WWVBTimecode) -> None: # noqa: PLR0915
|
538
562
|
"""Except during minutes 10..15 and 40..45, the amplitude signal holds 'regular information'"""
|
539
563
|
t._put_pm_bin(0, 13, SYNC_T)
|
540
564
|
|
541
565
|
moc = self.minute_of_century
|
542
|
-
|
566
|
+
_leap_sec = self._leap_sec
|
543
567
|
dst_on = self.dst
|
544
|
-
dst_ls =
|
545
|
-
dst_next =
|
546
|
-
t._put_pm_bin(13, 5,
|
547
|
-
t._put_pm_bit(18,
|
548
|
-
t._put_pm_bit(19,
|
549
|
-
t._put_pm_bit(20,
|
550
|
-
t._put_pm_bit(21,
|
551
|
-
t._put_pm_bit(22,
|
552
|
-
t._put_pm_bit(23,
|
553
|
-
t._put_pm_bit(24,
|
554
|
-
t._put_pm_bit(25,
|
555
|
-
t._put_pm_bit(26,
|
556
|
-
t._put_pm_bit(27,
|
557
|
-
t._put_pm_bit(28,
|
558
|
-
t._put_pm_bit(29, False) # Reserved
|
559
|
-
t._put_pm_bit(30,
|
560
|
-
t._put_pm_bit(31,
|
561
|
-
t._put_pm_bit(32,
|
562
|
-
t._put_pm_bit(33,
|
563
|
-
t._put_pm_bit(34,
|
564
|
-
t._put_pm_bit(35,
|
565
|
-
t._put_pm_bit(36,
|
566
|
-
t._put_pm_bit(37,
|
567
|
-
t._put_pm_bit(38,
|
568
|
-
t._put_pm_bit(39, True) # Reserved
|
569
|
-
t._put_pm_bit(40,
|
570
|
-
t._put_pm_bit(41,
|
571
|
-
t._put_pm_bit(42,
|
572
|
-
t._put_pm_bit(43,
|
573
|
-
t._put_pm_bit(44,
|
574
|
-
t._put_pm_bit(45,
|
575
|
-
t._put_pm_bit(46,
|
576
|
-
t._put_pm_bit(47,
|
577
|
-
t._put_pm_bit(48,
|
578
|
-
t._put_pm_bit(49, True) # Notice
|
579
|
-
t._put_pm_bit(50,
|
580
|
-
t._put_pm_bit(51,
|
581
|
-
t._put_pm_bit(52,
|
582
|
-
t._put_pm_bit(53,
|
583
|
-
t._put_pm_bit(54,
|
584
|
-
t._put_pm_bit(55,
|
585
|
-
t._put_pm_bit(56,
|
586
|
-
t._put_pm_bit(57,
|
587
|
-
t._put_pm_bit(58,
|
568
|
+
dst_ls = _dst_ls_lut[dst_on | (_leap_sec << 2)]
|
569
|
+
dst_next = _get_dst_next(self.as_datetime())
|
570
|
+
t._put_pm_bin(13, 5, _hamming_parity(moc))
|
571
|
+
t._put_pm_bit(18, _extract_bit(moc, 25))
|
572
|
+
t._put_pm_bit(19, _extract_bit(moc, 0))
|
573
|
+
t._put_pm_bit(20, _extract_bit(moc, 24))
|
574
|
+
t._put_pm_bit(21, _extract_bit(moc, 23))
|
575
|
+
t._put_pm_bit(22, _extract_bit(moc, 22))
|
576
|
+
t._put_pm_bit(23, _extract_bit(moc, 21))
|
577
|
+
t._put_pm_bit(24, _extract_bit(moc, 20))
|
578
|
+
t._put_pm_bit(25, _extract_bit(moc, 19))
|
579
|
+
t._put_pm_bit(26, _extract_bit(moc, 18))
|
580
|
+
t._put_pm_bit(27, _extract_bit(moc, 17))
|
581
|
+
t._put_pm_bit(28, _extract_bit(moc, 16))
|
582
|
+
t._put_pm_bit(29, False) # noqa: FBT003 # Reserved
|
583
|
+
t._put_pm_bit(30, _extract_bit(moc, 15))
|
584
|
+
t._put_pm_bit(31, _extract_bit(moc, 14))
|
585
|
+
t._put_pm_bit(32, _extract_bit(moc, 13))
|
586
|
+
t._put_pm_bit(33, _extract_bit(moc, 12))
|
587
|
+
t._put_pm_bit(34, _extract_bit(moc, 11))
|
588
|
+
t._put_pm_bit(35, _extract_bit(moc, 10))
|
589
|
+
t._put_pm_bit(36, _extract_bit(moc, 9))
|
590
|
+
t._put_pm_bit(37, _extract_bit(moc, 8))
|
591
|
+
t._put_pm_bit(38, _extract_bit(moc, 7))
|
592
|
+
t._put_pm_bit(39, True) # noqa: FBT003 # Reserved
|
593
|
+
t._put_pm_bit(40, _extract_bit(moc, 6))
|
594
|
+
t._put_pm_bit(41, _extract_bit(moc, 5))
|
595
|
+
t._put_pm_bit(42, _extract_bit(moc, 4))
|
596
|
+
t._put_pm_bit(43, _extract_bit(moc, 3))
|
597
|
+
t._put_pm_bit(44, _extract_bit(moc, 2))
|
598
|
+
t._put_pm_bit(45, _extract_bit(moc, 1))
|
599
|
+
t._put_pm_bit(46, _extract_bit(moc, 0))
|
600
|
+
t._put_pm_bit(47, _extract_bit(dst_ls, 4))
|
601
|
+
t._put_pm_bit(48, _extract_bit(dst_ls, 3))
|
602
|
+
t._put_pm_bit(49, True) # noqa: FBT003 # Notice
|
603
|
+
t._put_pm_bit(50, _extract_bit(dst_ls, 2))
|
604
|
+
t._put_pm_bit(51, _extract_bit(dst_ls, 1))
|
605
|
+
t._put_pm_bit(52, _extract_bit(dst_ls, 0))
|
606
|
+
t._put_pm_bit(53, _extract_bit(dst_next, 5))
|
607
|
+
t._put_pm_bit(54, _extract_bit(dst_next, 4))
|
608
|
+
t._put_pm_bit(55, _extract_bit(dst_next, 3))
|
609
|
+
t._put_pm_bit(56, _extract_bit(dst_next, 2))
|
610
|
+
t._put_pm_bit(57, _extract_bit(dst_next, 1))
|
611
|
+
t._put_pm_bit(58, _extract_bit(dst_next, 0))
|
588
612
|
if len(t.phase) > 59:
|
589
613
|
t._put_pm_bit(59, PhaseModulation.ZERO)
|
590
614
|
if len(t.phase) > 60:
|
591
615
|
t._put_pm_bit(60, PhaseModulation.ZERO)
|
592
616
|
|
593
|
-
def
|
617
|
+
def _fill_pm_timecode(self, t: WWVBTimecode) -> None:
|
594
618
|
"""Fill the phase portion of a timecode object"""
|
595
619
|
if 10 <= self.min < 16 or 40 <= self.min < 46:
|
596
|
-
self.
|
620
|
+
self._fill_pm_timecode_extended(t)
|
597
621
|
else:
|
598
|
-
self.
|
622
|
+
self._fill_pm_timecode_regular(t)
|
599
623
|
|
600
|
-
def next_minute(self, newut1:
|
624
|
+
def next_minute(self, newut1: int | None = None, newls: bool | None = None) -> WWVBMinute:
|
601
625
|
"""Return an object representing the next minute"""
|
602
626
|
d = self.as_datetime() + datetime.timedelta(minutes=1)
|
603
627
|
return self.from_datetime(d, newut1, newls, self)
|
604
628
|
|
605
|
-
def previous_minute(self, newut1:
|
629
|
+
def previous_minute(self, newut1: int | None = None, newls: bool | None = None) -> WWVBMinute:
|
606
630
|
"""Return an object representing the previous minute"""
|
607
631
|
d = self.as_datetime() - datetime.timedelta(minutes=1)
|
608
632
|
return self.from_datetime(d, newut1, newls, self)
|
609
633
|
|
610
634
|
@classmethod
|
611
|
-
def _get_dut1_info(cls: type, year: int, days: int, old_time:
|
635
|
+
def _get_dut1_info(cls: type, year: int, days: int, old_time: WWVBMinute | None = None) -> tuple[int, bool]: # noqa: ARG003
|
612
636
|
"""Return the DUT1 information for a given day, possibly propagating information from a previous timestamp"""
|
613
637
|
if old_time is not None:
|
614
638
|
if old_time.minute_length() != 60:
|
615
639
|
newls = False
|
616
|
-
if old_time.ut1 < 0
|
617
|
-
newut1 = old_time.ut1 + 1000
|
618
|
-
else:
|
619
|
-
newut1 = old_time.ut1 - 1000
|
640
|
+
newut1 = old_time.ut1 + 1000 if old_time.ut1 < 0 else old_time.ut1 - 1000
|
620
641
|
else:
|
621
642
|
newls = old_time.ls
|
622
643
|
newut1 = old_time.ut1
|
@@ -624,10 +645,10 @@ class WWVBMinute(_WWVBMinute):
|
|
624
645
|
return 0, False
|
625
646
|
|
626
647
|
@classmethod
|
627
|
-
def fromstring(cls, s: str) ->
|
648
|
+
def fromstring(cls, s: str) -> WWVBMinute:
|
628
649
|
"""Construct a WWVBMinute from a string representation created by print_timecodes"""
|
629
650
|
s = _removeprefix(s, "WWVB timecode: ")
|
630
|
-
d:
|
651
|
+
d: dict[str, int] = {}
|
631
652
|
for part in s.split():
|
632
653
|
k, v = part.split("=")
|
633
654
|
if k == "min":
|
@@ -637,8 +658,8 @@ class WWVBMinute(_WWVBMinute):
|
|
637
658
|
days = d.pop("days")
|
638
659
|
hour = d.pop("hour")
|
639
660
|
minute = d.pop("minute")
|
640
|
-
dst:
|
641
|
-
ut1:
|
661
|
+
dst: int | None = d.pop("dst", None)
|
662
|
+
ut1: int | None = d.pop("ut1", None)
|
642
663
|
ls = d.pop("ls", None)
|
643
664
|
d.pop("ly", None)
|
644
665
|
if d:
|
@@ -649,10 +670,10 @@ class WWVBMinute(_WWVBMinute):
|
|
649
670
|
def from_datetime(
|
650
671
|
cls,
|
651
672
|
d: datetime.datetime,
|
652
|
-
newut1:
|
653
|
-
newls:
|
654
|
-
old_time:
|
655
|
-
) ->
|
673
|
+
newut1: int | None = None,
|
674
|
+
newls: bool | None = None,
|
675
|
+
old_time: WWVBMinute | None = None,
|
676
|
+
) -> WWVBMinute:
|
656
677
|
"""Construct a WWVBMinute from a datetime, possibly specifying ut1/ls data or propagating it from an old time"""
|
657
678
|
u = d.utctimetuple()
|
658
679
|
if newls is None and newut1 is None:
|
@@ -660,7 +681,7 @@ class WWVBMinute(_WWVBMinute):
|
|
660
681
|
return cls(u.tm_year, u.tm_yday, u.tm_hour, u.tm_min, ut1=newut1, ls=newls)
|
661
682
|
|
662
683
|
@classmethod
|
663
|
-
def from_timecode_am(cls, t:
|
684
|
+
def from_timecode_am(cls, t: WWVBTimecode) -> WWVBMinute | None:
|
664
685
|
"""Construct a WWVBMinute from a WWVBTimecode"""
|
665
686
|
for i in (0, 9, 19, 29, 39, 49, 59):
|
666
687
|
if t.am[i] != AmplitudeModulation.MARK:
|
@@ -694,7 +715,7 @@ class WWVBMinute(_WWVBMinute):
|
|
694
715
|
if days > 366 or (not ly and days > 365):
|
695
716
|
return None
|
696
717
|
ls = bool(t.am[56])
|
697
|
-
dst =
|
718
|
+
dst = _require(t._get_am_bcd(57, 58))
|
698
719
|
return cls(year, days, hour, minute, dst, ut1, ls, ly)
|
699
720
|
|
700
721
|
|
@@ -702,12 +723,12 @@ class WWVBMinuteIERS(WWVBMinute):
|
|
702
723
|
"""A WWVBMinute that uses a database of DUT1 information"""
|
703
724
|
|
704
725
|
@classmethod
|
705
|
-
def _get_dut1_info(cls, year: int, days: int, old_time:
|
706
|
-
d = datetime.datetime(year, 1, 1) + datetime.timedelta(days - 1)
|
726
|
+
def _get_dut1_info(cls, year: int, days: int, old_time: WWVBMinute | None = None) -> tuple[int, bool]: # noqa: ARG003
|
727
|
+
d = datetime.datetime(year, 1, 1, tzinfo=datetime.timezone.utc) + datetime.timedelta(days - 1)
|
707
728
|
return int(round(get_dut1(d) * 10)) * 100, isls(d)
|
708
729
|
|
709
730
|
|
710
|
-
def
|
731
|
+
def _bcd_bits(n: int) -> Generator[bool, None, None]:
|
711
732
|
"""Return the bcd representation of n, starting with the least significant bit"""
|
712
733
|
while True:
|
713
734
|
d = n % 10
|
@@ -738,18 +759,23 @@ class PhaseModulation(enum.IntEnum):
|
|
738
759
|
class WWVBTimecode:
|
739
760
|
"""Represent the amplitude and/or phase signal, usually over 1 minute"""
|
740
761
|
|
741
|
-
am:
|
742
|
-
|
762
|
+
am: list[AmplitudeModulation]
|
763
|
+
"""The amplitude modulation data"""
|
764
|
+
|
765
|
+
phase: list[PhaseModulation]
|
766
|
+
"""The phase modulation data"""
|
743
767
|
|
744
768
|
def __init__(self, sz: int) -> None:
|
769
|
+
"""Construct a WWVB timecode ``sz`` seconds long"""
|
745
770
|
self.am = [AmplitudeModulation.UNSET] * sz
|
746
771
|
self.phase = [PhaseModulation.UNSET] * sz
|
747
772
|
|
748
|
-
def _get_am_bcd(self, *poslist: int) ->
|
773
|
+
def _get_am_bcd(self, *poslist: int) -> int | None:
|
749
774
|
"""Convert AM data to BCD
|
750
775
|
|
751
776
|
The the bits ``self.am[poslist[i]]`` in MSB order are converted from
|
752
|
-
BCD to integer
|
777
|
+
BCD to integer
|
778
|
+
"""
|
753
779
|
pos = reversed(poslist)
|
754
780
|
val = [bool(self.am[p]) for p in pos]
|
755
781
|
result = 0
|
@@ -769,28 +795,29 @@ class WWVBTimecode:
|
|
769
795
|
|
770
796
|
The bits at ``self.am[poslist[i]]`` in MSB order are filled with
|
771
797
|
the conversion of `v` to BCD
|
772
|
-
Treating 'poslist' as a sequence of indices, update the AM signal with the value as a BCD number
|
798
|
+
Treating 'poslist' as a sequence of indices, update the AM signal with the value as a BCD number
|
799
|
+
"""
|
773
800
|
pos = list(poslist)[::-1]
|
774
|
-
for p, b in zip(pos,
|
801
|
+
for p, b in zip(pos, _bcd_bits(v)):
|
775
802
|
if b:
|
776
803
|
self.am[p] = AmplitudeModulation.ONE
|
777
804
|
else:
|
778
805
|
self.am[p] = AmplitudeModulation.ZERO
|
779
806
|
|
780
|
-
def _put_pm_bit(self, i: int, v:
|
807
|
+
def _put_pm_bit(self, i: int, v: PhaseModulation | int | bool) -> None:
|
781
808
|
"""Update a bit of the Phase Modulation signal"""
|
782
809
|
self.phase[i] = PhaseModulation(v)
|
783
810
|
|
784
811
|
def _put_pm_bin(self, st: int, n: int, v: int) -> None:
|
785
812
|
"""Update an n-digit binary number in the Phase Modulation signal"""
|
786
813
|
for i in range(n):
|
787
|
-
self._put_pm_bit(st + i,
|
814
|
+
self._put_pm_bit(st + i, _extract_bit(v, (n - i - 1)))
|
788
815
|
|
789
816
|
def __str__(self) -> str:
|
790
|
-
"""
|
817
|
+
"""Implement str()"""
|
791
818
|
undefined = [i for i in range(len(self.am)) if self.am[i] == AmplitudeModulation.UNSET]
|
792
819
|
if undefined:
|
793
|
-
warnings.warn(f"am{undefined} is unset")
|
820
|
+
warnings.warn(f"am{undefined} is unset", stacklevel=1)
|
794
821
|
|
795
822
|
def convert_one(am: AmplitudeModulation, phase: PhaseModulation) -> str:
|
796
823
|
if phase is PhaseModulation.UNSET:
|
@@ -802,20 +829,20 @@ class WWVBTimecode:
|
|
802
829
|
return "".join(convert_one(i, j) for i, j in zip(self.am, self.phase))
|
803
830
|
|
804
831
|
def __repr__(self) -> str:
|
805
|
-
"""
|
832
|
+
"""Implement repr()"""
|
806
833
|
return "<WWVBTimecode " + str(self) + ">"
|
807
834
|
|
808
|
-
def to_am_string(self, charset:
|
835
|
+
def to_am_string(self, charset: list[str]) -> str:
|
809
836
|
"""Convert the amplitude signal to a string"""
|
810
837
|
return "".join(charset[i] for i in self.am)
|
811
838
|
|
812
839
|
to_string = to_am_string
|
813
840
|
|
814
|
-
def to_pm_string(self, charset:
|
841
|
+
def to_pm_string(self, charset: list[str]) -> str:
|
815
842
|
"""Convert the phase signal to a string"""
|
816
843
|
return "".join(charset[i] for i in self.phase)
|
817
844
|
|
818
|
-
def to_both_string(self, charset:
|
845
|
+
def to_both_string(self, charset: list[str]) -> str:
|
819
846
|
"""Convert both channels to a string"""
|
820
847
|
return "".join(charset[i + j * 3] for i, j in zip(self.am, self.phase))
|
821
848
|
|
@@ -847,7 +874,7 @@ def print_timecodes(
|
|
847
874
|
if first or all_timecodes:
|
848
875
|
if not first:
|
849
876
|
print(file=file)
|
850
|
-
print(f"WWVB timecode: {
|
877
|
+
print(f"WWVB timecode: {w!s}{channel_text}{style_text}", file=file)
|
851
878
|
first = False
|
852
879
|
pfx = f"{w.year:04d}-{w.days:03d} {w.hour:02d}:{w.min:02d} "
|
853
880
|
tc = w.as_timecode()
|
@@ -872,10 +899,24 @@ def print_timecodes_json(
|
|
872
899
|
channel: str,
|
873
900
|
file: TextIO,
|
874
901
|
) -> None:
|
875
|
-
"""Print a range of timecodes
|
902
|
+
"""Print a range of timecodes in JSON format.
|
903
|
+
|
904
|
+
The result is a json array of minute data. Each minute data is an object with the following members:
|
905
|
+
|
906
|
+
* year (int)
|
907
|
+
* days (int)
|
908
|
+
* hour (int)
|
909
|
+
* minute (int)
|
910
|
+
* amplitude (string; only if channel is amplitude or both)
|
911
|
+
* phase: (string; only if channel is phase or both)
|
912
|
+
|
913
|
+
The amplitude and phase strings are of length 60 during most minutes, length 61
|
914
|
+
during a minute that includes a (positive) leap second, and theoretically
|
915
|
+
length 59 in the case of a negative leap second.
|
916
|
+
"""
|
876
917
|
result = []
|
877
918
|
for _ in range(minutes):
|
878
|
-
data = {
|
919
|
+
data: dict[str, Any] = {
|
879
920
|
"year": w.year,
|
880
921
|
"days": w.days,
|
881
922
|
"hour": w.hour,
|