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