wwvb 4.1.0a0__py3-none-any.whl → 5.0.1__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.
wwvb/__init__.py ADDED
@@ -0,0 +1,937 @@
1
+ #!/usr/bin/python3
2
+ """A library for WWVB timecodes"""
3
+
4
+ # SPDX-FileCopyrightText: 2011-2024 Jeff Epler
5
+ #
6
+ # SPDX-License-Identifier: GPL-3.0-only
7
+
8
+ from __future__ import annotations
9
+
10
+ import datetime
11
+ import enum
12
+ import json
13
+ import warnings
14
+ from typing import TYPE_CHECKING, Any, NamedTuple, TextIO, TypeVar
15
+
16
+ from . import iersdata
17
+ from .tz import Mountain
18
+
19
+ if TYPE_CHECKING:
20
+ from collections.abc import Generator
21
+
22
+ HOUR = datetime.timedelta(seconds=3600)
23
+ SECOND = datetime.timedelta(seconds=1)
24
+ T = TypeVar("T")
25
+
26
+
27
+ def _require(x: T | None) -> T:
28
+ """Check an Optional item is not None."""
29
+ assert x is not None
30
+ return x
31
+
32
+
33
+ def _removeprefix(s: str, p: str) -> str:
34
+ if s.startswith(p):
35
+ return s[len(p) :]
36
+ return s
37
+
38
+
39
+ def _date(dt: datetime.date) -> datetime.date:
40
+ """Return the date object itself, or the date property of a datetime"""
41
+ if isinstance(dt, datetime.datetime):
42
+ return dt.date()
43
+ return dt
44
+
45
+
46
+ def _maybe_warn_update(dt: datetime.date, stacklevel: int = 1) -> None:
47
+ """Maybe print a notice to run updateiers, if it seems useful to do so."""
48
+ # We already know this date is not covered.
49
+ # If the date is less than 300 days after today, there should be (possibly)
50
+ # prospective available now.
51
+ today = datetime.datetime.now(tz=datetime.timezone.utc).date()
52
+ print(f"_mwu {today=!r} {dt=!r} {iersdata.end=!r}")
53
+ if _date(dt) < today + datetime.timedelta(days=330):
54
+ warnings.warn(
55
+ "Note: Running `updateiers` may provide better DUT1 and LS information",
56
+ stacklevel=stacklevel + 1,
57
+ )
58
+
59
+
60
+ def get_dut1(dt: datetime.date, *, warn_outdated: bool = True) -> float:
61
+ """Return the DUT1 number for the given timestamp"""
62
+ date = _date(dt)
63
+ i = (date - iersdata.DUT1_DATA_START).days
64
+ if i < 0:
65
+ v = iersdata.DUT1_OFFSETS[0]
66
+ elif i >= len(iersdata.DUT1_OFFSETS):
67
+ if warn_outdated:
68
+ _maybe_warn_update(dt, stacklevel=2)
69
+ v = iersdata.DUT1_OFFSETS[-1]
70
+ else:
71
+ v = iersdata.DUT1_OFFSETS[i]
72
+ return (ord(v) - ord("k")) / 10.0
73
+
74
+
75
+ def isly(year: int) -> bool:
76
+ """Return True if the year is a leap year"""
77
+ d1 = datetime.date(year, 1, 1)
78
+ d2 = d1 + datetime.timedelta(days=365)
79
+ return d1.year == d2.year
80
+
81
+
82
+ def isls(t: datetime.date) -> bool:
83
+ """Return True if a leap second occurs at the end of this month"""
84
+ dut1_today = get_dut1(t)
85
+ month_today = t.month
86
+ while t.month == month_today:
87
+ t += datetime.timedelta(1)
88
+ dut1_next_month = get_dut1(t)
89
+ return dut1_today * dut1_next_month < 0
90
+
91
+
92
+ def isdst(t: datetime.date, tz: datetime.tzinfo = Mountain) -> bool:
93
+ """Return true if daylight saving time is active at the start of the given UTC day"""
94
+ utc_daystart = datetime.datetime(t.year, t.month, t.day, tzinfo=datetime.timezone.utc)
95
+ return bool(utc_daystart.astimezone(tz).dst())
96
+
97
+
98
+ def _first_sunday_on_or_after(dt: datetime.date) -> datetime.date:
99
+ """Return the first sunday on or after the reference time"""
100
+ days_to_go = 6 - dt.weekday()
101
+ if days_to_go:
102
+ return dt + datetime.timedelta(days_to_go)
103
+ return dt
104
+
105
+
106
+ def _first_sunday_in_month(y: int, m: int) -> datetime.date:
107
+ """Find the first sunday in a given month"""
108
+ return _first_sunday_on_or_after(datetime.datetime(y, m, 1, tzinfo=datetime.timezone.utc))
109
+
110
+
111
+ def _is_dst_change_day(t: datetime.date, tz: datetime.tzinfo = Mountain) -> bool:
112
+ """Return True if the day is a DST change day"""
113
+ return isdst(t, tz) != isdst(t + datetime.timedelta(1), tz)
114
+
115
+
116
+ def _get_dst_change_hour(t: datetime.date, tz: datetime.tzinfo = Mountain) -> int | None:
117
+ """Return the hour when DST changes"""
118
+ lt0 = datetime.datetime(t.year, t.month, t.day, hour=0, tzinfo=tz)
119
+ dst0 = lt0.dst()
120
+ for i in (1, 2, 3):
121
+ lt1 = (lt0.astimezone(datetime.timezone.utc) + HOUR * i).astimezone(tz)
122
+ dst1 = lt1.dst()
123
+ lt2 = lt1 - SECOND
124
+ dst2 = lt2.dst()
125
+ if dst0 == dst2 and dst0 != dst1:
126
+ return i - 1
127
+ return None
128
+
129
+
130
+ def _get_dst_change_date_and_row(
131
+ d: datetime.date,
132
+ tz: datetime.tzinfo = Mountain,
133
+ ) -> tuple[datetime.date | None, int | None]:
134
+ """Classify DST information for the WWVB phase modulation signal"""
135
+ if isdst(d, tz):
136
+ n = _first_sunday_in_month(d.year, 11)
137
+ for offset in range(-28, 28, 7):
138
+ d1 = n + datetime.timedelta(days=offset)
139
+ if _is_dst_change_day(d1, tz):
140
+ return d1, (offset + 28) // 7
141
+ else:
142
+ m = _first_sunday_in_month(d.year + (d.month > 3), 3)
143
+ for offset in range(0, 52, 7):
144
+ d1 = m + datetime.timedelta(days=offset)
145
+ if _is_dst_change_day(d1, tz):
146
+ return d1, offset // 7
147
+
148
+ return None, None
149
+
150
+
151
+ # "Table 8", likely with transcrption errors
152
+ _dsttable = [
153
+ [
154
+ [
155
+ 0b110001,
156
+ 0b100110,
157
+ 0b100101,
158
+ 0b010101,
159
+ 0b111110,
160
+ 0b010110,
161
+ 0b110111,
162
+ 0b111101,
163
+ ],
164
+ [
165
+ 0b101010,
166
+ 0b011011,
167
+ 0b001110,
168
+ 0b000001,
169
+ 0b000010,
170
+ 0b001000,
171
+ 0b001101,
172
+ 0b101001,
173
+ ],
174
+ [
175
+ 0b000100,
176
+ 0b100000,
177
+ 0b110100,
178
+ 0b101100,
179
+ 0b111000,
180
+ 0b010000,
181
+ 0b110010,
182
+ 0b011100,
183
+ ],
184
+ ],
185
+ [
186
+ [
187
+ 0b110111,
188
+ 0b010101,
189
+ 0b110001,
190
+ 0b010110,
191
+ 0b100110,
192
+ 0b111110,
193
+ 0b100101,
194
+ 0b111101,
195
+ ],
196
+ [
197
+ 0b001101,
198
+ 0b000001,
199
+ 0b101010,
200
+ 0b001000,
201
+ 0b011011,
202
+ 0b000010,
203
+ 0b001110,
204
+ 0b101001,
205
+ ],
206
+ [
207
+ 0b110010,
208
+ 0b101100,
209
+ 0b000100,
210
+ 0b010000,
211
+ 0b100000,
212
+ 0b111000,
213
+ 0b110100,
214
+ 0b011100,
215
+ ],
216
+ ],
217
+ ]
218
+
219
+
220
+ def _lfsr_gen(x: list[int]) -> None:
221
+ """Generate the next bit of the 6-minute codes sequence"""
222
+ x.append(x[-7] ^ x[-6] ^ x[-5] ^ x[-2])
223
+
224
+
225
+ _lfsr_seq = [1] * 7
226
+ while len(_lfsr_seq) < 255:
227
+ _lfsr_gen(_lfsr_seq)
228
+
229
+ # Table 12 - Fixed 106-bit timing word
230
+ _ftw = [
231
+ int(c)
232
+ for c in "1101000111"
233
+ "0101100101"
234
+ "1001101110"
235
+ "0011000010"
236
+ "1101001110"
237
+ "1001010100"
238
+ "0010111000"
239
+ "1011010110"
240
+ "1101111111"
241
+ "1000000100"
242
+ "100100"
243
+ ]
244
+
245
+
246
+ def _get_dst_next(d: datetime.date, tz: datetime.tzinfo = Mountain) -> int:
247
+ """Find the "dst next" value for the phase modulation signal"""
248
+ dst_now = isdst(d, tz) # dst_on[1]
249
+ dst_midwinter = isdst(datetime.datetime(d.year, 1, 1, tzinfo=datetime.timezone.utc), tz)
250
+ dst_midsummer = isdst(datetime.datetime(d.year, 7, 1, tzinfo=datetime.timezone.utc), tz)
251
+
252
+ if dst_midwinter and dst_midsummer:
253
+ return 0b101111
254
+ if not (dst_midwinter or dst_midsummer):
255
+ return 0b000111
256
+
257
+ # Southern hemisphere
258
+ if dst_midwinter or not dst_midsummer:
259
+ return 0b100011
260
+
261
+ dst_change_date, dst_next_row = _get_dst_change_date_and_row(d, tz)
262
+ if dst_change_date is None:
263
+ return 0b100011
264
+ assert dst_next_row is not None
265
+
266
+ dst_change_hour = _get_dst_change_hour(dst_change_date, tz)
267
+ if dst_change_hour is None:
268
+ return 0b100011
269
+
270
+ return _dsttable[dst_now][dst_change_hour][dst_next_row]
271
+
272
+
273
+ _hamming_weight = [
274
+ [23, 21, 20, 17, 16, 15, 14, 13, 9, 8, 6, 5, 4, 2, 0],
275
+ [24, 22, 21, 18, 17, 16, 15, 14, 10, 9, 7, 6, 5, 3, 1],
276
+ [25, 23, 22, 19, 18, 17, 16, 15, 11, 10, 8, 7, 6, 4, 2],
277
+ [24, 21, 19, 18, 15, 14, 13, 12, 11, 7, 6, 4, 3, 2, 0],
278
+ [25, 22, 20, 19, 16, 15, 14, 13, 12, 8, 7, 5, 4, 3, 1],
279
+ ]
280
+
281
+ # Identifies the phase data as a time signal (SYNC_T bits present)
282
+ # or a message signal (SYNC_M bits present); No message signals are defined
283
+ # by NIST at this time.
284
+ SYNC_T = 0x768
285
+ SYNC_M = 0x1A3A
286
+
287
+
288
+ def _extract_bit(v: int, p: int) -> bool:
289
+ """Extract bit 'p' from integer 'v' as a bool"""
290
+ return bool((v >> p) & 1)
291
+
292
+
293
+ def _hamming_parity(value: int) -> int:
294
+ """Compute the "hamming parity" of a 26-bit number, such as the minute-of-century
295
+
296
+ For more details, see Enhanced WWVB Broadcast Format 4.3
297
+ """
298
+ parity = 0
299
+ for i in range(4, -1, -1):
300
+ bit = 0
301
+ for j in range(15):
302
+ bit ^= _extract_bit(value, _hamming_weight[i][j])
303
+ parity = (parity << 1) | bit
304
+ return parity
305
+
306
+
307
+ _dst_ls_lut = [
308
+ 0b01000,
309
+ 0b10101,
310
+ 0b10110,
311
+ 0b00011,
312
+ 0b01000,
313
+ 0b10101,
314
+ 0b10110,
315
+ 0b00011,
316
+ 0b00100,
317
+ 0b01110,
318
+ 0b10000,
319
+ 0b01101,
320
+ 0b11001,
321
+ 0b11100,
322
+ 0b11010,
323
+ 0b11111,
324
+ ]
325
+
326
+
327
+ class _WWVBMinute(NamedTuple):
328
+ """Uniquely identifies a minute of time in the WWVB system.
329
+
330
+ To use ut1 and ls information from IERS, create a WWVBMinuteIERS value instead.
331
+ """
332
+
333
+ year: int
334
+ """2-digit year within the WWVB epoch"""
335
+
336
+ days: int
337
+ """1-based day of year"""
338
+
339
+ hour: int
340
+ """UTC hour of day"""
341
+
342
+ min: int
343
+ """Minute of hour"""
344
+
345
+ dst: int
346
+ """2-bit DST code """
347
+
348
+ ut1: int
349
+ """UT1 offset in units of 100ms, range -900 to +900ms"""
350
+
351
+ ls: bool
352
+ """Leap second warning flag"""
353
+
354
+ ly: bool
355
+ """Leap year flag"""
356
+
357
+
358
+ class WWVBMinute(_WWVBMinute):
359
+ """Uniquely identifies a minute of time in the WWVB system.
360
+
361
+ To use ut1 and ls information from IERS, create a WWVBMinuteIERS value instead.
362
+ """
363
+
364
+ epoch: int = 1970
365
+
366
+ def __new__( # noqa: PYI034
367
+ cls,
368
+ year: int,
369
+ days: int,
370
+ hour: int,
371
+ minute: int,
372
+ dst: int | None = None,
373
+ ut1: int | None = None,
374
+ ls: bool | None = None,
375
+ ly: bool | None = None,
376
+ ) -> WWVBMinute:
377
+ """Construct a WWVBMinute"""
378
+ if dst is None:
379
+ dst = cls.get_dst(year, days)
380
+ if dst not in (0, 1, 2, 3):
381
+ raise ValueError("dst value should be 0..3")
382
+ if ut1 is None and ls is None:
383
+ ut1, ls = cls._get_dut1_info(year, days)
384
+ elif ut1 is None or ls is None:
385
+ raise ValueError("sepecify both ut1 and ls or neither one")
386
+ year = cls.full_year(year)
387
+ if ly is None:
388
+ ly = isly(year)
389
+ return _WWVBMinute.__new__(cls, year, days, hour, minute, dst, ut1, ls, ly)
390
+
391
+ @classmethod
392
+ def full_year(cls, year: int) -> int:
393
+ """Convert a (possibly two-digit) year to a full year.
394
+
395
+ If the argument is above 100, it is assumed to be a full year.
396
+ Otherwise, the intuitive method is followed: Say the epoch is 1970,
397
+ then 70..99 means 1970..99 and 00..69 means 2000..2069.
398
+
399
+ To actually use a different epoch, derive a class from WWVBMinute (or
400
+ WWVBMinuteIERS) and give it a different epoch property. Then, create
401
+ instances of that class instead of WWVBMinute.
402
+ """
403
+ century = cls.epoch // 100 * 100
404
+ if year < (cls.epoch % 100):
405
+ return year + century + 100
406
+ if year < 100:
407
+ return year + century
408
+ return year
409
+
410
+ @staticmethod
411
+ def get_dst(year: int, days: int) -> int:
412
+ """Get the 2-bit WWVB DST value for the given day"""
413
+ d0 = datetime.datetime(year, 1, 1, tzinfo=datetime.timezone.utc) + datetime.timedelta(days - 1)
414
+ d1 = d0 + datetime.timedelta(1)
415
+ dst0 = isdst(d0)
416
+ dst1 = isdst(d1)
417
+ return dst1 * 2 + dst0
418
+
419
+ def __str__(self) -> str:
420
+ """Implement str()"""
421
+ return (
422
+ f"year={self.year:4d} days={self.days:03d} hour={self.hour:02d} "
423
+ f"min={self.min:02d} dst={self.dst} ut1={self.ut1} ly={int(self.ly)} "
424
+ f"ls={int(self.ls)}"
425
+ )
426
+
427
+ def as_datetime_utc(self) -> datetime.datetime:
428
+ """Convert to a UTC datetime"""
429
+ d = datetime.datetime(self.year, 1, 1, tzinfo=datetime.timezone.utc)
430
+ d += datetime.timedelta(self.days - 1, self.hour * 3600 + self.min * 60)
431
+ return d
432
+
433
+ as_datetime = as_datetime_utc
434
+
435
+ def as_datetime_local(
436
+ self,
437
+ standard_time_offset: int = 7 * 3600,
438
+ *,
439
+ dst_observed: bool = True,
440
+ ) -> datetime.datetime:
441
+ """Convert to a local datetime according to the DST bits"""
442
+ u = self.as_datetime_utc()
443
+ offset = datetime.timedelta(seconds=-standard_time_offset)
444
+ d = u - datetime.timedelta(seconds=standard_time_offset)
445
+ if not dst_observed:
446
+ dst = False
447
+ elif self.dst == 0b10:
448
+ transition_time = u.replace(hour=2)
449
+ dst = d >= transition_time
450
+ elif self.dst == 0b11:
451
+ dst = True
452
+ elif self.dst == 0b01:
453
+ transition_time = u.replace(hour=1)
454
+ dst = d < transition_time
455
+ else: # self.dst == 0b00
456
+ dst = False
457
+ if dst:
458
+ offset += datetime.timedelta(seconds=3600)
459
+ return u.astimezone(datetime.timezone(offset))
460
+
461
+ def _is_end_of_month(self) -> bool:
462
+ """Return True if minute is the last minute in a month"""
463
+ d = self.as_datetime()
464
+ e = d + datetime.timedelta(1)
465
+ return d.month != e.month
466
+
467
+ def minute_length(self) -> int:
468
+ """Return the length of the minute, 60, 61, or (theoretically) 59 seconds"""
469
+ if not self.ls:
470
+ return 60
471
+ if not self._is_end_of_month():
472
+ return 60
473
+ if self.hour != 23 or self.min != 59:
474
+ return 60
475
+ if self.ut1 > 0:
476
+ return 59
477
+ return 61
478
+
479
+ def as_timecode(self) -> WWVBTimecode:
480
+ """Fill a WWVBTimecode structure representing this minute. Fills both the amplitude and phase codes."""
481
+ t = WWVBTimecode(self.minute_length())
482
+
483
+ self._fill_am_timecode(t)
484
+ self._fill_pm_timecode(t)
485
+
486
+ return t
487
+
488
+ @property
489
+ def _leap_sec(self) -> int:
490
+ """Return the 2-bit _leap_sec value used by the PM code"""
491
+ if not self.ls:
492
+ return 0
493
+ if self.ut1 < 0:
494
+ return 3
495
+ return 2
496
+
497
+ @property
498
+ def minute_of_century(self) -> int:
499
+ """Return the minute of the century"""
500
+ century = (self.year // 100) * 100
501
+ # note: This relies on timedelta seconds never including leapseconds!
502
+ return (
503
+ int((self.as_datetime() - datetime.datetime(century, 1, 1, tzinfo=datetime.timezone.utc)).total_seconds())
504
+ // 60
505
+ )
506
+
507
+ def _fill_am_timecode(self, t: WWVBTimecode) -> None:
508
+ """Fill the amplitude (AM) portion of a timecode object"""
509
+ for i in [0, 9, 19, 29, 39, 49]:
510
+ t.am[i] = AmplitudeModulation.MARK
511
+ if len(t.am) > 59:
512
+ t.am[59] = AmplitudeModulation.MARK
513
+ if len(t.am) > 60:
514
+ t.am[60] = AmplitudeModulation.MARK
515
+ for i in [4, 10, 11, 14, 20, 21, 24, 34, 35, 44, 54]:
516
+ t.am[i] = AmplitudeModulation.ZERO
517
+ t._put_am_bcd(self.min, 1, 2, 3, 5, 6, 7, 8)
518
+ t._put_am_bcd(self.hour, 12, 13, 15, 16, 17, 18)
519
+ t._put_am_bcd(self.days, 22, 23, 25, 26, 27, 28, 30, 31, 32, 33)
520
+ ut1_sign = self.ut1 >= 0
521
+ t.am[36] = t.am[38] = AmplitudeModulation(ut1_sign)
522
+ t.am[37] = AmplitudeModulation(not ut1_sign)
523
+ t._put_am_bcd(abs(self.ut1) // 100, 40, 41, 42, 43)
524
+ t._put_am_bcd(self.year, 45, 46, 47, 48, 50, 51, 52, 53) # Implicitly discards all but lowest 2 digits of year
525
+ t.am[55] = AmplitudeModulation(self.ly)
526
+ t.am[56] = AmplitudeModulation(self.ls)
527
+ t._put_am_bcd(self.dst, 57, 58)
528
+
529
+ def _fill_pm_timecode_extended(self, t: WWVBTimecode) -> None:
530
+ """During minutes 10..15 and 40..45, the amplitude signal holds 'extended information'"""
531
+ assert 10 <= self.min < 16 or 40 <= self.min < 46
532
+ minno = self.min % 10
533
+ assert minno < 6
534
+
535
+ dst = self.dst
536
+ # Note that these are 1 different than Table 11
537
+ # because our LFSR sequence is zero-based
538
+ seqno = (self.min // 30) * 2
539
+ if dst == 0:
540
+ pass
541
+ elif dst == 3:
542
+ seqno = seqno + 1
543
+ elif dst == 2:
544
+ if self.hour < 4:
545
+ pass
546
+ elif self.hour < 11:
547
+ seqno = seqno + 90
548
+ else:
549
+ seqno = seqno + 1
550
+ elif self.hour < 4:
551
+ seqno = seqno + 1
552
+ elif self.hour < 11:
553
+ seqno = seqno + 91
554
+
555
+ info_seq = _lfsr_seq[seqno : seqno + 127]
556
+ full_seq = info_seq + _ftw + info_seq[::-1]
557
+ assert len(full_seq) == 360
558
+
559
+ offset = minno * 60
560
+ for i in range(60):
561
+ t._put_pm_bit(i, full_seq[i + offset])
562
+
563
+ def _fill_pm_timecode_regular(self, t: WWVBTimecode) -> None: # noqa: PLR0915
564
+ """Except during minutes 10..15 and 40..45, the amplitude signal holds 'regular information'"""
565
+ t._put_pm_bin(0, 13, SYNC_T)
566
+
567
+ moc = self.minute_of_century
568
+ _leap_sec = self._leap_sec
569
+ dst_on = self.dst
570
+ dst_ls = _dst_ls_lut[dst_on | (_leap_sec << 2)]
571
+ dst_next = _get_dst_next(self.as_datetime())
572
+ t._put_pm_bin(13, 5, _hamming_parity(moc))
573
+ t._put_pm_bit(18, _extract_bit(moc, 25))
574
+ t._put_pm_bit(19, _extract_bit(moc, 0))
575
+ t._put_pm_bit(20, _extract_bit(moc, 24))
576
+ t._put_pm_bit(21, _extract_bit(moc, 23))
577
+ t._put_pm_bit(22, _extract_bit(moc, 22))
578
+ t._put_pm_bit(23, _extract_bit(moc, 21))
579
+ t._put_pm_bit(24, _extract_bit(moc, 20))
580
+ t._put_pm_bit(25, _extract_bit(moc, 19))
581
+ t._put_pm_bit(26, _extract_bit(moc, 18))
582
+ t._put_pm_bit(27, _extract_bit(moc, 17))
583
+ t._put_pm_bit(28, _extract_bit(moc, 16))
584
+ t._put_pm_bit(29, False) # noqa: FBT003 # Reserved
585
+ t._put_pm_bit(30, _extract_bit(moc, 15))
586
+ t._put_pm_bit(31, _extract_bit(moc, 14))
587
+ t._put_pm_bit(32, _extract_bit(moc, 13))
588
+ t._put_pm_bit(33, _extract_bit(moc, 12))
589
+ t._put_pm_bit(34, _extract_bit(moc, 11))
590
+ t._put_pm_bit(35, _extract_bit(moc, 10))
591
+ t._put_pm_bit(36, _extract_bit(moc, 9))
592
+ t._put_pm_bit(37, _extract_bit(moc, 8))
593
+ t._put_pm_bit(38, _extract_bit(moc, 7))
594
+ t._put_pm_bit(39, True) # noqa: FBT003 # Reserved
595
+ t._put_pm_bit(40, _extract_bit(moc, 6))
596
+ t._put_pm_bit(41, _extract_bit(moc, 5))
597
+ t._put_pm_bit(42, _extract_bit(moc, 4))
598
+ t._put_pm_bit(43, _extract_bit(moc, 3))
599
+ t._put_pm_bit(44, _extract_bit(moc, 2))
600
+ t._put_pm_bit(45, _extract_bit(moc, 1))
601
+ t._put_pm_bit(46, _extract_bit(moc, 0))
602
+ t._put_pm_bit(47, _extract_bit(dst_ls, 4))
603
+ t._put_pm_bit(48, _extract_bit(dst_ls, 3))
604
+ t._put_pm_bit(49, True) # noqa: FBT003 # Notice
605
+ t._put_pm_bit(50, _extract_bit(dst_ls, 2))
606
+ t._put_pm_bit(51, _extract_bit(dst_ls, 1))
607
+ t._put_pm_bit(52, _extract_bit(dst_ls, 0))
608
+ t._put_pm_bit(53, _extract_bit(dst_next, 5))
609
+ t._put_pm_bit(54, _extract_bit(dst_next, 4))
610
+ t._put_pm_bit(55, _extract_bit(dst_next, 3))
611
+ t._put_pm_bit(56, _extract_bit(dst_next, 2))
612
+ t._put_pm_bit(57, _extract_bit(dst_next, 1))
613
+ t._put_pm_bit(58, _extract_bit(dst_next, 0))
614
+ if len(t.phase) > 59:
615
+ t._put_pm_bit(59, PhaseModulation.ZERO)
616
+ if len(t.phase) > 60:
617
+ t._put_pm_bit(60, PhaseModulation.ZERO)
618
+
619
+ def _fill_pm_timecode(self, t: WWVBTimecode) -> None:
620
+ """Fill the phase portion of a timecode object"""
621
+ if 10 <= self.min < 16 or 40 <= self.min < 46:
622
+ self._fill_pm_timecode_extended(t)
623
+ else:
624
+ self._fill_pm_timecode_regular(t)
625
+
626
+ def next_minute(self, newut1: int | None = None, newls: bool | None = None) -> WWVBMinute:
627
+ """Return an object representing the next minute"""
628
+ d = self.as_datetime() + datetime.timedelta(minutes=1)
629
+ return self.from_datetime(d, newut1, newls, self)
630
+
631
+ def previous_minute(self, newut1: int | None = None, newls: bool | None = None) -> WWVBMinute:
632
+ """Return an object representing the previous minute"""
633
+ d = self.as_datetime() - datetime.timedelta(minutes=1)
634
+ return self.from_datetime(d, newut1, newls, self)
635
+
636
+ @classmethod
637
+ def _get_dut1_info(cls: type, year: int, days: int, old_time: WWVBMinute | None = None) -> tuple[int, bool]: # noqa: ARG003
638
+ """Return the DUT1 information for a given day, possibly propagating information from a previous timestamp"""
639
+ if old_time is not None:
640
+ if old_time.minute_length() != 60:
641
+ newls = False
642
+ newut1 = old_time.ut1 + 1000 if old_time.ut1 < 0 else old_time.ut1 - 1000
643
+ else:
644
+ newls = old_time.ls
645
+ newut1 = old_time.ut1
646
+ return newut1, newls
647
+ return 0, False
648
+
649
+ @classmethod
650
+ def fromstring(cls, s: str) -> WWVBMinute:
651
+ """Construct a WWVBMinute from a string representation created by print_timecodes"""
652
+ s = _removeprefix(s, "WWVB timecode: ")
653
+ d: dict[str, int] = {}
654
+ for part in s.split():
655
+ k, v = part.split("=")
656
+ if k == "min":
657
+ k = "minute"
658
+ d[k] = int(v)
659
+ year = d.pop("year")
660
+ days = d.pop("days")
661
+ hour = d.pop("hour")
662
+ minute = d.pop("minute")
663
+ dst: int | None = d.pop("dst", None)
664
+ ut1: int | None = d.pop("ut1", None)
665
+ ls = d.pop("ls", None)
666
+ d.pop("ly", None)
667
+ if d:
668
+ raise ValueError(f"Invalid options: {d}")
669
+ return cls(year, days, hour, minute, dst, ut1, None if ls is None else bool(ls))
670
+
671
+ @classmethod
672
+ def from_datetime(
673
+ cls,
674
+ d: datetime.datetime,
675
+ newut1: int | None = None,
676
+ newls: bool | None = None,
677
+ old_time: WWVBMinute | None = None,
678
+ ) -> WWVBMinute:
679
+ """Construct a WWVBMinute from a datetime, possibly specifying ut1/ls data or propagating it from an old time"""
680
+ u = d.utctimetuple()
681
+ if newls is None and newut1 is None:
682
+ newut1, newls = cls._get_dut1_info(u.tm_year, u.tm_yday, old_time)
683
+ return cls(u.tm_year, u.tm_yday, u.tm_hour, u.tm_min, ut1=newut1, ls=newls)
684
+
685
+ @classmethod
686
+ def from_timecode_am(cls, t: WWVBTimecode) -> WWVBMinute | None:
687
+ """Construct a WWVBMinute from a WWVBTimecode"""
688
+ for i in (0, 9, 19, 29, 39, 49, 59):
689
+ if t.am[i] != AmplitudeModulation.MARK:
690
+ return None
691
+ for i in (4, 10, 11, 14, 20, 21, 24, 34, 35, 44, 54):
692
+ if t.am[i] != AmplitudeModulation.ZERO:
693
+ return None
694
+ if t.am[36] == t.am[37]:
695
+ return None
696
+ if t.am[36] != t.am[38]:
697
+ return None
698
+ minute = t._get_am_bcd(1, 2, 3, 5, 6, 7, 8)
699
+ if minute is None:
700
+ return None
701
+ hour = t._get_am_bcd(12, 13, 15, 16, 17, 18)
702
+ if hour is None:
703
+ return None
704
+ days = t._get_am_bcd(22, 23, 25, 26, 27, 28, 30, 31, 32, 33)
705
+ if days is None:
706
+ return None
707
+ abs_ut1 = t._get_am_bcd(40, 41, 42, 43)
708
+ if abs_ut1 is None:
709
+ return None
710
+ abs_ut1 *= 100
711
+ ut1_sign = t.am[38]
712
+ ut1 = abs_ut1 if ut1_sign else -abs_ut1
713
+ year = t._get_am_bcd(45, 46, 47, 48, 50, 51, 52, 53)
714
+ if year is None:
715
+ return None
716
+ ly = bool(t.am[55])
717
+ if days > 366 or (not ly and days > 365):
718
+ return None
719
+ ls = bool(t.am[56])
720
+ dst = _require(t._get_am_bcd(57, 58))
721
+ return cls(year, days, hour, minute, dst, ut1, ls, ly)
722
+
723
+
724
+ class WWVBMinuteIERS(WWVBMinute):
725
+ """A WWVBMinute that uses a database of DUT1 information"""
726
+
727
+ @classmethod
728
+ def _get_dut1_info(cls, year: int, days: int, old_time: WWVBMinute | None = None) -> tuple[int, bool]: # noqa: ARG003
729
+ d = datetime.datetime(year, 1, 1, tzinfo=datetime.timezone.utc) + datetime.timedelta(days - 1)
730
+ return int(round(get_dut1(d) * 10)) * 100, isls(d)
731
+
732
+
733
+ def _bcd_bits(n: int) -> Generator[bool, None, None]:
734
+ """Return the bcd representation of n, starting with the least significant bit"""
735
+ while True:
736
+ d = n % 10
737
+ n = n // 10
738
+ for i in (1, 2, 4, 8):
739
+ yield bool(d & i)
740
+
741
+
742
+ @enum.unique
743
+ class AmplitudeModulation(enum.IntEnum):
744
+ """Constants that describe an Amplitude Modulation value"""
745
+
746
+ ZERO = 0
747
+ ONE = 1
748
+ MARK = 2
749
+ UNSET = -1
750
+
751
+
752
+ @enum.unique
753
+ class PhaseModulation(enum.IntEnum):
754
+ """Constants that describe a Phase Modulation value"""
755
+
756
+ ZERO = 0
757
+ ONE = 1
758
+ UNSET = -1
759
+
760
+
761
+ class WWVBTimecode:
762
+ """Represent the amplitude and/or phase signal, usually over 1 minute"""
763
+
764
+ am: list[AmplitudeModulation]
765
+ """The amplitude modulation data"""
766
+
767
+ phase: list[PhaseModulation]
768
+ """The phase modulation data"""
769
+
770
+ def __init__(self, sz: int) -> None:
771
+ """Construct a WWVB timecode ``sz`` seconds long"""
772
+ self.am = [AmplitudeModulation.UNSET] * sz
773
+ self.phase = [PhaseModulation.UNSET] * sz
774
+
775
+ def _get_am_bcd(self, *poslist: int) -> int | None:
776
+ """Convert AM data to BCD
777
+
778
+ The the bits ``self.am[poslist[i]]`` in MSB order are converted from
779
+ BCD to integer
780
+ """
781
+ pos = reversed(poslist)
782
+ val = [bool(self.am[p]) for p in pos]
783
+ result = 0
784
+ base = 1
785
+ for i in range(0, len(val), 4):
786
+ digit = 0
787
+ for j, b in enumerate(val[i : i + 4]):
788
+ digit += b << j
789
+ if digit > 9:
790
+ return None
791
+ result += digit * base
792
+ base *= 10
793
+ return result
794
+
795
+ def _put_am_bcd(self, v: int, *poslist: int) -> None:
796
+ """Insert BCD coded data into the AM signal
797
+
798
+ The bits at ``self.am[poslist[i]]`` in MSB order are filled with
799
+ the conversion of `v` to BCD
800
+ Treating 'poslist' as a sequence of indices, update the AM signal with the value as a BCD number
801
+ """
802
+ pos = list(poslist)[::-1]
803
+ for p, b in zip(pos, _bcd_bits(v)):
804
+ if b:
805
+ self.am[p] = AmplitudeModulation.ONE
806
+ else:
807
+ self.am[p] = AmplitudeModulation.ZERO
808
+
809
+ def _put_pm_bit(self, i: int, v: PhaseModulation | int | bool) -> None:
810
+ """Update a bit of the Phase Modulation signal"""
811
+ self.phase[i] = PhaseModulation(v)
812
+
813
+ def _put_pm_bin(self, st: int, n: int, v: int) -> None:
814
+ """Update an n-digit binary number in the Phase Modulation signal"""
815
+ for i in range(n):
816
+ self._put_pm_bit(st + i, _extract_bit(v, (n - i - 1)))
817
+
818
+ def __str__(self) -> str:
819
+ """Implement str()"""
820
+ undefined = [i for i in range(len(self.am)) if self.am[i] == AmplitudeModulation.UNSET]
821
+ if undefined:
822
+ warnings.warn(f"am{undefined} is unset", stacklevel=1)
823
+
824
+ def convert_one(am: AmplitudeModulation, phase: PhaseModulation) -> str:
825
+ if phase is PhaseModulation.UNSET:
826
+ return ("0", "1", "2", "?")[am]
827
+ if phase:
828
+ return ("⁰", "¹", "²", "¿")[am]
829
+ return ("₀", "₁", "₂", "⸮")[am]
830
+
831
+ return "".join(convert_one(i, j) for i, j in zip(self.am, self.phase))
832
+
833
+ def __repr__(self) -> str:
834
+ """Implement repr()"""
835
+ return "<WWVBTimecode " + str(self) + ">"
836
+
837
+ def to_am_string(self, charset: list[str]) -> str:
838
+ """Convert the amplitude signal to a string"""
839
+ return "".join(charset[i] for i in self.am)
840
+
841
+ to_string = to_am_string
842
+
843
+ def to_pm_string(self, charset: list[str]) -> str:
844
+ """Convert the phase signal to a string"""
845
+ return "".join(charset[i] for i in self.phase)
846
+
847
+ def to_both_string(self, charset: list[str]) -> str:
848
+ """Convert both channels to a string"""
849
+ return "".join(charset[i + j * 3] for i, j in zip(self.am, self.phase))
850
+
851
+
852
+ styles = {
853
+ "default": ["0", "1", "2"],
854
+ "duration": ["2", "5", "8"],
855
+ "cradek": ["0", "1", "-"],
856
+ "bar": ["🬍🬎", "🬋🬎", "🬋🬍"],
857
+ "sextant": ["🬍🬎", "🬋🬎", "🬋🬍", "🬩🬹", "🬋🬹", "🬋🬩"],
858
+ }
859
+
860
+
861
+ def print_timecodes(
862
+ w: WWVBMinute,
863
+ minutes: int,
864
+ channel: str,
865
+ style: str,
866
+ file: TextIO,
867
+ *,
868
+ all_timecodes: bool = False,
869
+ ) -> None:
870
+ """Print a range of timecodes with a header. This header is in a format understood by WWVBMinute.fromstring"""
871
+ channel_text = "" if channel == "amplitude" else f" --channel={channel}"
872
+ style_text = "" if style == "default" else f" --style={style}"
873
+ style_chars = styles.get(style, ["0", "1", "2"])
874
+ first = True
875
+ for _ in range(minutes):
876
+ if not first and channel == "both":
877
+ print(file=file)
878
+ if first or all_timecodes:
879
+ if not first:
880
+ print(file=file)
881
+ print(f"WWVB timecode: {w!s}{channel_text}{style_text}", file=file)
882
+ first = False
883
+ pfx = f"{w.year:04d}-{w.days:03d} {w.hour:02d}:{w.min:02d} "
884
+ tc = w.as_timecode()
885
+ if len(style_chars) == 6:
886
+ print(f"{pfx} {tc.to_both_string(style_chars)}", file=file)
887
+ print(file=file)
888
+ pfx = " " * len(pfx)
889
+ else:
890
+ if channel in ("amplitude", "both"):
891
+ print(f"{pfx} {tc.to_am_string(style_chars)}", file=file)
892
+ pfx = " " * len(pfx)
893
+ if channel in ("phase", "both"):
894
+ print(f"{pfx} {tc.to_pm_string(style_chars)}", file=file)
895
+ w = w.next_minute()
896
+
897
+
898
+ def print_timecodes_json(
899
+ w: WWVBMinute,
900
+ minutes: int,
901
+ channel: str,
902
+ file: TextIO,
903
+ ) -> None:
904
+ """Print a range of timecodes in JSON format.
905
+
906
+ The result is a json array of minute data. Each minute data is an object with the following members:
907
+
908
+ * year (int)
909
+ * days (int)
910
+ * hour (int)
911
+ * minute (int)
912
+ * amplitude (string; only if channel is amplitude or both)
913
+ * phase: (string; only if channel is phase or both)
914
+
915
+ The amplitude and phase strings are of length 60 during most minutes, length 61
916
+ during a minute that includes a (positive) leap second, and theoretically
917
+ length 59 in the case of a negative leap second.
918
+ """
919
+ result = []
920
+ for _ in range(minutes):
921
+ data: dict[str, Any] = {
922
+ "year": w.year,
923
+ "days": w.days,
924
+ "hour": w.hour,
925
+ "minute": w.min,
926
+ }
927
+
928
+ tc = w.as_timecode()
929
+ if channel in ("amplitude", "both"):
930
+ data["amplitude"] = tc.to_am_string(["0", "1", "2"])
931
+ if channel in ("phase", "both"):
932
+ data["phase"] = tc.to_pm_string(["0", "1"])
933
+
934
+ result.append(data)
935
+ w = w.next_minute()
936
+ json.dump(result, file)
937
+ print(file=file)