wwvb 4.1.0a0__py3-none-any.whl → 5.0.0__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 +193 -0
- wwvb/__init__.py +937 -0
- {leapseconddata → wwvb}/__version__.py +2 -2
- wwvb/decode.py +93 -0
- wwvb/dut1table.py +32 -0
- wwvb/gen.py +127 -0
- wwvb/iersdata.json +1 -0
- wwvb/iersdata.json.license +2 -0
- wwvb/iersdata.py +37 -0
- wwvb/tz.py +12 -0
- wwvb/updateiers.py +161 -0
- wwvb/wwvbtk.py +146 -0
- wwvb-5.0.0.dist-info/METADATA +200 -0
- wwvb-5.0.0.dist-info/RECORD +18 -0
- {wwvb-4.1.0a0.dist-info → wwvb-5.0.0.dist-info}/WHEEL +1 -1
- wwvb-5.0.0.dist-info/entry_points.txt +8 -0
- wwvb-5.0.0.dist-info/top_level.txt +2 -0
- leapseconddata/__init__.py +0 -342
- leapseconddata/__main__.py +0 -169
- wwvb-4.1.0a0.dist-info/METADATA +0 -60
- wwvb-4.1.0a0.dist-info/RECORD +0 -9
- wwvb-4.1.0a0.dist-info/entry_points.txt +0 -2
- wwvb-4.1.0a0.dist-info/top_level.txt +0 -1
- {leapseconddata → wwvb}/py.typed +0 -0
wwvb/decode.py
ADDED
@@ -0,0 +1,93 @@
|
|
1
|
+
# SPDX-FileCopyrightText: 2021 Jeff Epler
|
2
|
+
#
|
3
|
+
# SPDX-License-Identifier: GPL-3.0-only
|
4
|
+
"""A stateful decoder of WWVB signals"""
|
5
|
+
|
6
|
+
from __future__ import annotations
|
7
|
+
|
8
|
+
import sys
|
9
|
+
from typing import TYPE_CHECKING
|
10
|
+
|
11
|
+
import wwvb
|
12
|
+
|
13
|
+
if TYPE_CHECKING:
|
14
|
+
from collections.abc import Generator
|
15
|
+
|
16
|
+
# State 1: Unsync'd
|
17
|
+
# Marker: State 2
|
18
|
+
# Other: State 1
|
19
|
+
# State 2: One marker
|
20
|
+
# Marker: State 3
|
21
|
+
# Other: State 1
|
22
|
+
# State 3: Two markers
|
23
|
+
# Marker: State 3
|
24
|
+
# Other: State 4
|
25
|
+
# State 4: Decoding a minute, starting in second 1
|
26
|
+
# Second
|
27
|
+
|
28
|
+
always_zero = {4, 10, 11, 14, 20, 21, 34, 35, 44, 54}
|
29
|
+
|
30
|
+
|
31
|
+
def wwvbreceive() -> Generator[wwvb.WWVBTimecode | None, wwvb.AmplitudeModulation, None]:
|
32
|
+
"""Decode WWVB signals statefully."""
|
33
|
+
minute: list[wwvb.AmplitudeModulation] = []
|
34
|
+
state = 1
|
35
|
+
|
36
|
+
value = yield None
|
37
|
+
while True:
|
38
|
+
# print(state, value, len(minute), "".join(str(int(i)) for i in minute))
|
39
|
+
if state == 1:
|
40
|
+
minute = []
|
41
|
+
if value == wwvb.AmplitudeModulation.MARK:
|
42
|
+
state = 2
|
43
|
+
value = yield None
|
44
|
+
|
45
|
+
elif state == 2:
|
46
|
+
state = 3 if value == wwvb.AmplitudeModulation.MARK else 1
|
47
|
+
value = yield None
|
48
|
+
|
49
|
+
elif state == 3:
|
50
|
+
if value != wwvb.AmplitudeModulation.MARK:
|
51
|
+
state = 4
|
52
|
+
minute = [wwvb.AmplitudeModulation.MARK, value]
|
53
|
+
value = yield None
|
54
|
+
|
55
|
+
else: # state == 4:
|
56
|
+
minute.append(value)
|
57
|
+
if len(minute) % 10 == 0 and value != wwvb.AmplitudeModulation.MARK:
|
58
|
+
# print("MISSING MARK", len(minute), "".join(str(int(i)) for i in minute))
|
59
|
+
state = 1
|
60
|
+
elif len(minute) % 10 and value == wwvb.AmplitudeModulation.MARK:
|
61
|
+
# print("UNEXPECTED MARK")
|
62
|
+
state = 1
|
63
|
+
elif len(minute) - 1 in always_zero and value != wwvb.AmplitudeModulation.ZERO:
|
64
|
+
# print("UNEXPECTED NONZERO")
|
65
|
+
state = 1
|
66
|
+
elif len(minute) == 60:
|
67
|
+
# print("FULL MINUTE")
|
68
|
+
tc = wwvb.WWVBTimecode(60)
|
69
|
+
tc.am[:] = minute
|
70
|
+
minute = []
|
71
|
+
state = 2
|
72
|
+
value = yield tc
|
73
|
+
else:
|
74
|
+
value = yield None
|
75
|
+
|
76
|
+
|
77
|
+
def main() -> None:
|
78
|
+
"""Read symbols on stdin and print any successfully-decoded minutes"""
|
79
|
+
decoder = wwvbreceive()
|
80
|
+
next(decoder)
|
81
|
+
decoder.send(wwvb.AmplitudeModulation.MARK)
|
82
|
+
for s in sys.argv[1:]:
|
83
|
+
for c in s:
|
84
|
+
decoded = decoder.send(wwvb.AmplitudeModulation(int(c)))
|
85
|
+
if decoded:
|
86
|
+
print(decoded)
|
87
|
+
w = wwvb.WWVBMinute.from_timecode_am(decoded)
|
88
|
+
if w:
|
89
|
+
print(w)
|
90
|
+
|
91
|
+
|
92
|
+
if __name__ == "__main__":
|
93
|
+
main()
|
wwvb/dut1table.py
ADDED
@@ -0,0 +1,32 @@
|
|
1
|
+
#!/usr/bin/env python3
|
2
|
+
|
3
|
+
# SPDX-FileCopyrightText: 2021-2024 Jeff Epler
|
4
|
+
#
|
5
|
+
# SPDX-License-Identifier: GPL-3.0-only
|
6
|
+
|
7
|
+
"""Print the table of historical DUT1 values"""
|
8
|
+
|
9
|
+
from datetime import timedelta
|
10
|
+
from itertools import groupby
|
11
|
+
|
12
|
+
import wwvb
|
13
|
+
|
14
|
+
from .iersdata import DUT1_DATA_START, DUT1_OFFSETS
|
15
|
+
|
16
|
+
|
17
|
+
def main() -> None:
|
18
|
+
"""Print the table of historical DUT1 values"""
|
19
|
+
date = DUT1_DATA_START
|
20
|
+
for key, it in groupby(DUT1_OFFSETS):
|
21
|
+
dut1_ms = (ord(key) - ord("k")) / 10.0
|
22
|
+
count = len(list(it))
|
23
|
+
end = date + timedelta(days=count - 1)
|
24
|
+
dut1_next = wwvb.get_dut1(date + timedelta(days=count), warn_outdated=False)
|
25
|
+
ls = f" LS on {end:%F} 23:59:60 UTC" if dut1_ms * dut1_next < 0 else ""
|
26
|
+
print(f"{date:%F} {dut1_ms: 3.1f} {count:4d}{ls}")
|
27
|
+
date += timedelta(days=count)
|
28
|
+
print(date)
|
29
|
+
|
30
|
+
|
31
|
+
if __name__ == "__main__":
|
32
|
+
main()
|
wwvb/gen.py
ADDED
@@ -0,0 +1,127 @@
|
|
1
|
+
#!/usr/bin/python3
|
2
|
+
"""A command-line program for generating 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 sys
|
12
|
+
from typing import Any
|
13
|
+
|
14
|
+
import click
|
15
|
+
import dateutil.parser
|
16
|
+
|
17
|
+
from . import WWVBMinute, WWVBMinuteIERS, print_timecodes, print_timecodes_json, styles
|
18
|
+
|
19
|
+
|
20
|
+
def parse_timespec(ctx: Any, param: Any, value: list[str]) -> datetime.datetime: # noqa: ARG001
|
21
|
+
"""Parse a time specifier from the commandline"""
|
22
|
+
try:
|
23
|
+
if len(value) == 5:
|
24
|
+
year, month, day, hour, minute = map(int, value)
|
25
|
+
return datetime.datetime(year, month, day, hour, minute, tzinfo=datetime.timezone.utc)
|
26
|
+
if len(value) == 4:
|
27
|
+
year, yday, hour, minute = map(int, value)
|
28
|
+
return datetime.datetime(year, 1, 1, hour, minute, tzinfo=datetime.timezone.utc) + datetime.timedelta(
|
29
|
+
days=yday - 1,
|
30
|
+
)
|
31
|
+
if len(value) == 1:
|
32
|
+
return dateutil.parser.parse(value[0])
|
33
|
+
if len(value) == 0:
|
34
|
+
return datetime.datetime.now(datetime.timezone.utc)
|
35
|
+
raise ValueError("Unexpected number of arguments")
|
36
|
+
except ValueError as e:
|
37
|
+
raise click.UsageError(f"Could not parse timespec: {e}") from e
|
38
|
+
|
39
|
+
|
40
|
+
@click.command()
|
41
|
+
@click.option(
|
42
|
+
"--iers/--no-iers",
|
43
|
+
"-i/-I",
|
44
|
+
default=True,
|
45
|
+
help="Whether to use IESR data for DUT1 and LS. (Default: --iers)",
|
46
|
+
)
|
47
|
+
@click.option(
|
48
|
+
"--leap-second",
|
49
|
+
"-s",
|
50
|
+
"leap_second",
|
51
|
+
flag_value=1,
|
52
|
+
default=None,
|
53
|
+
help="Force a positive leap second at the end of the GMT month (Implies --no-iers)",
|
54
|
+
)
|
55
|
+
@click.option(
|
56
|
+
"--negative-leap-second",
|
57
|
+
"-n",
|
58
|
+
"leap_second",
|
59
|
+
flag_value=-1,
|
60
|
+
help="Force a negative leap second at the end of the GMT month (Implies --no-iers)",
|
61
|
+
)
|
62
|
+
@click.option(
|
63
|
+
"--no-leap-second",
|
64
|
+
"-S",
|
65
|
+
"leap_second",
|
66
|
+
flag_value=0,
|
67
|
+
help="Force no leap second at the end of the month (Implies --no-iers)",
|
68
|
+
)
|
69
|
+
@click.option("--dut1", "-d", type=int, help="Force the DUT1 value (Implies --no-iers)")
|
70
|
+
@click.option("--minutes", "-m", default=10, help="Number of minutes to show (default: 10)")
|
71
|
+
@click.option(
|
72
|
+
"--style",
|
73
|
+
default="default",
|
74
|
+
type=click.Choice(sorted(["json", *list(styles.keys())])),
|
75
|
+
help="Style of output",
|
76
|
+
)
|
77
|
+
@click.option(
|
78
|
+
"--all-timecodes/--no-all-timecodes",
|
79
|
+
"-t/-T",
|
80
|
+
default=False,
|
81
|
+
type=bool,
|
82
|
+
help="Show the 'WWVB timecode' line before each minute",
|
83
|
+
)
|
84
|
+
@click.option(
|
85
|
+
"--channel",
|
86
|
+
type=click.Choice(["amplitude", "phase", "both"]),
|
87
|
+
default="amplitude",
|
88
|
+
help="Modulation to show (default: amplitude)",
|
89
|
+
)
|
90
|
+
@click.argument("timespec", type=str, nargs=-1, callback=parse_timespec)
|
91
|
+
def main(
|
92
|
+
*,
|
93
|
+
iers: bool,
|
94
|
+
leap_second: bool,
|
95
|
+
dut1: int,
|
96
|
+
minutes: int,
|
97
|
+
style: str,
|
98
|
+
channel: str,
|
99
|
+
all_timecodes: bool,
|
100
|
+
timespec: datetime.datetime,
|
101
|
+
) -> None:
|
102
|
+
"""Generate WWVB timecodes
|
103
|
+
|
104
|
+
TIMESPEC: one of "year yday hour minute" or "year month day hour minute", or else the current minute
|
105
|
+
"""
|
106
|
+
if (leap_second is not None) or (dut1 is not None):
|
107
|
+
iers = False
|
108
|
+
|
109
|
+
newut1 = None
|
110
|
+
newls = None
|
111
|
+
|
112
|
+
if iers:
|
113
|
+
constructor: type[WWVBMinute] = WWVBMinuteIERS
|
114
|
+
else:
|
115
|
+
constructor = WWVBMinute
|
116
|
+
newut1 = -500 * (leap_second or 0) if dut1 is None else dut1
|
117
|
+
newls = bool(leap_second)
|
118
|
+
|
119
|
+
w = constructor.from_datetime(timespec, newls=newls, newut1=newut1)
|
120
|
+
if style == "json":
|
121
|
+
print_timecodes_json(w, minutes, channel, file=sys.stdout)
|
122
|
+
else:
|
123
|
+
print_timecodes(w, minutes, channel, style, all_timecodes=all_timecodes, file=sys.stdout)
|
124
|
+
|
125
|
+
|
126
|
+
if __name__ == "__main__":
|
127
|
+
main()
|
wwvb/iersdata.json
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
{"START": "1972-01-01", "OFFSETS_GZ": "H4sIAFEb/WYC/+2aa3LDMAiEL5uHLDuxnN5/pn/aTmfSSiAWhGR9J8gsywJylqVHPtqxZuH/7leeI0fKsGd5EngQ2WisJWKegrThDa6aJFnL0u4wYZkCE2UmSF0U+13vCveStC6JTfQyW3O86HLJf0SvDgy5u4FCI+WVKRuy0KMjJeXoULIvMDmEWgeRxAJtwXquPCIBqbLh/gbfv0mcxk3mHV9tYiATZP8W/zgw2wd5LpJnY+WErI8abJ3opaIW6592+YMbjSsNWQFlNVVtuhjhtQzSUh4MEpOdDrSW6qsUv+O+Dt+XkIONSrUwvWmTsmq5LO9xsZ+EgcDK+MIESDaYmxSxGlgbGOFjBXMjbV7lc6zlmQ0i48oH5P4+vK7i/AHc7tfTXDtffqFi3m6WhApPSTyDvArU5vUDhm7YaNQYGASVbbwLUBtI2PrhSiZNbvCRrtGUGu0GbjDhJ3aLCx5dQFjt0LFovmWB96e6tktqMenoULXajVS3asBibP3kYXrpmZxnsS2Yf2xRPrHbvQ2D9wjfL4C6b4PWV4otW0vWUYkeWE5M8M594oLbxP77xcl4NuBkG0dfM3xOUf/T0GF+ur+J5pljcODEUZkXg6vIdLYy7g3oZU3bPNDnc8qwGdJZMmAurUsRj6tOo95zP6fb9YPWp5OuZ5X7q2DrmsG/VCyTyaREnDRhnUxOjcmKM9b/R7R0+gQ5cs/LtEwAAA=="}
|
wwvb/iersdata.py
ADDED
@@ -0,0 +1,37 @@
|
|
1
|
+
# -*- python3 -*-
|
2
|
+
"""Retrieve iers data, possibly from user or site data or from the wwvbpy distribution"""
|
3
|
+
|
4
|
+
# SPDX-FileCopyrightText: 2011-2024 Jeff Epler
|
5
|
+
#
|
6
|
+
# SPDX-License-Identifier: GPL-3.0-only
|
7
|
+
|
8
|
+
import binascii
|
9
|
+
import datetime
|
10
|
+
import gzip
|
11
|
+
import importlib.resources
|
12
|
+
import json
|
13
|
+
|
14
|
+
import platformdirs
|
15
|
+
|
16
|
+
__all__ = ["DUT1_DATA_START", "DUT1_OFFSETS", "start", "span", "end"]
|
17
|
+
|
18
|
+
content: dict[str, str] = {"START": "1970-01-01", "OFFSETS_GZ": "H4sIAFNx1mYC/wMAAAAAAAAAAAA="}
|
19
|
+
|
20
|
+
path = importlib.resources.files("wwvb") / "iersdata.json"
|
21
|
+
content = json.loads(path.read_text(encoding="utf-8"))
|
22
|
+
|
23
|
+
for location in [ # pragma no cover
|
24
|
+
platformdirs.user_data_path("wwvbpy", "unpythonic.net"),
|
25
|
+
platformdirs.site_data_path("wwvbpy", "unpythonic.net"),
|
26
|
+
]:
|
27
|
+
path = location / "iersdata.json"
|
28
|
+
if path.exists():
|
29
|
+
content = json.loads(path.read_text(encoding="utf-8"))
|
30
|
+
break
|
31
|
+
|
32
|
+
DUT1_DATA_START = datetime.date.fromisoformat(content["START"])
|
33
|
+
DUT1_OFFSETS = gzip.decompress(binascii.a2b_base64(content["OFFSETS_GZ"])).decode("ascii")
|
34
|
+
|
35
|
+
start = datetime.datetime.combine(DUT1_DATA_START, datetime.time(), tzinfo=datetime.timezone.utc)
|
36
|
+
span = datetime.timedelta(days=len(DUT1_OFFSETS))
|
37
|
+
end = start + span
|
wwvb/tz.py
ADDED
@@ -0,0 +1,12 @@
|
|
1
|
+
# -*- python -*-
|
2
|
+
"""A library for WWVB timecodes"""
|
3
|
+
|
4
|
+
# SPDX-FileCopyrightText: 2021-2024 Jeff Epler
|
5
|
+
#
|
6
|
+
# SPDX-License-Identifier: GPL-3.0-only
|
7
|
+
|
8
|
+
from zoneinfo import ZoneInfo
|
9
|
+
|
10
|
+
Mountain = ZoneInfo("America/Denver")
|
11
|
+
|
12
|
+
__all__ = ["Mountain", "ZoneInfo"]
|
wwvb/updateiers.py
ADDED
@@ -0,0 +1,161 @@
|
|
1
|
+
#!/usr/bin/python3
|
2
|
+
|
3
|
+
# SPDX-FileCopyrightText: 2021-2024 Jeff Epler
|
4
|
+
#
|
5
|
+
# SPDX-License-Identifier: GPL-3.0-only
|
6
|
+
|
7
|
+
"""Update the DUT1 and LS data based on online sources"""
|
8
|
+
|
9
|
+
from __future__ import annotations
|
10
|
+
|
11
|
+
import binascii
|
12
|
+
import csv
|
13
|
+
import datetime
|
14
|
+
import gzip
|
15
|
+
import io
|
16
|
+
import json
|
17
|
+
import pathlib
|
18
|
+
from typing import Callable
|
19
|
+
|
20
|
+
import bs4
|
21
|
+
import click
|
22
|
+
import platformdirs
|
23
|
+
import requests
|
24
|
+
|
25
|
+
DIST_PATH = pathlib.Path(__file__).parent / "iersdata.json"
|
26
|
+
|
27
|
+
IERS_URL = "https://datacenter.iers.org/data/csv/finals2000A.all.csv"
|
28
|
+
IERS_PATH = pathlib.Path("finals2000A.all.csv")
|
29
|
+
if IERS_PATH.exists():
|
30
|
+
IERS_URL = str(IERS_PATH)
|
31
|
+
print("using local", IERS_URL)
|
32
|
+
NIST_URL = "https://www.nist.gov/pml/time-and-frequency-division/atomic-standards/leap-second-and-ut1-utc-information"
|
33
|
+
|
34
|
+
|
35
|
+
def _get_text(url: str) -> str:
|
36
|
+
"""Get a local file or a http/https URL"""
|
37
|
+
if url.startswith("http"):
|
38
|
+
with requests.get(url, timeout=30) as response:
|
39
|
+
return response.text
|
40
|
+
else:
|
41
|
+
return pathlib.Path(url).read_text(encoding="utf-8")
|
42
|
+
|
43
|
+
|
44
|
+
def update_iersdata( # noqa: PLR0915
|
45
|
+
target_path: pathlib.Path,
|
46
|
+
) -> None:
|
47
|
+
"""Update iersdata.py"""
|
48
|
+
offsets: list[int] = []
|
49
|
+
iersdata_text = _get_text(IERS_URL)
|
50
|
+
for r in csv.DictReader(io.StringIO(iersdata_text), delimiter=";"):
|
51
|
+
jd = float(r["MJD"])
|
52
|
+
offs_str = r["UT1-UTC"]
|
53
|
+
if not offs_str:
|
54
|
+
break
|
55
|
+
offs = int(round(float(offs_str) * 10))
|
56
|
+
if not offsets:
|
57
|
+
table_start = datetime.date(1858, 11, 17) + datetime.timedelta(jd)
|
58
|
+
|
59
|
+
when = min(datetime.date(1972, 1, 1), table_start)
|
60
|
+
# iers bulletin A doesn't cover 1972, so fake data for those
|
61
|
+
# leap seconds
|
62
|
+
while when < datetime.date(1972, 7, 1):
|
63
|
+
offsets.append(-2)
|
64
|
+
when = when + datetime.timedelta(days=1)
|
65
|
+
while when < datetime.date(1972, 11, 1):
|
66
|
+
offsets.append(8)
|
67
|
+
when = when + datetime.timedelta(days=1)
|
68
|
+
while when < datetime.date(1972, 12, 1):
|
69
|
+
offsets.append(0)
|
70
|
+
when = when + datetime.timedelta(days=1)
|
71
|
+
while when < datetime.date(1973, 1, 1):
|
72
|
+
offsets.append(-2)
|
73
|
+
when = when + datetime.timedelta(days=1)
|
74
|
+
while when < table_start:
|
75
|
+
offsets.append(8)
|
76
|
+
when = when + datetime.timedelta(days=1)
|
77
|
+
|
78
|
+
table_start = min(datetime.date(1972, 1, 1), table_start)
|
79
|
+
|
80
|
+
offsets.append(offs)
|
81
|
+
|
82
|
+
wwvb_text = _get_text(NIST_URL)
|
83
|
+
wwvb_data = bs4.BeautifulSoup(wwvb_text, features="html.parser")
|
84
|
+
wwvb_dut1_table = wwvb_data.findAll("table")[2]
|
85
|
+
assert wwvb_dut1_table
|
86
|
+
meta = wwvb_data.find("meta", property="article:modified_time")
|
87
|
+
assert isinstance(meta, bs4.Tag)
|
88
|
+
wwvb_data_stamp = datetime.datetime.fromisoformat(meta.attrs["content"]).replace(tzinfo=None).date()
|
89
|
+
|
90
|
+
def patch(patch_start: datetime.date, patch_end: datetime.date, val: int) -> None:
|
91
|
+
off_start = (patch_start - table_start).days
|
92
|
+
off_end = (patch_end - table_start).days
|
93
|
+
offsets[off_start:off_end] = [val] * (off_end - off_start)
|
94
|
+
|
95
|
+
wwvb_dut1: int | None = None
|
96
|
+
wwvb_start: datetime.date | None = None
|
97
|
+
for row in wwvb_dut1_table.findAll("tr")[1:][::-1]:
|
98
|
+
cells = row.findAll("td")
|
99
|
+
when = datetime.datetime.strptime(cells[0].text + "+0000", "%Y-%m-%d%z").date()
|
100
|
+
dut1 = cells[2].text.replace("s", "").replace(" ", "")
|
101
|
+
dut1 = int(round(float(dut1) * 10))
|
102
|
+
if wwvb_dut1 is not None:
|
103
|
+
assert wwvb_start is not None
|
104
|
+
patch(wwvb_start, when, wwvb_dut1)
|
105
|
+
wwvb_dut1 = dut1
|
106
|
+
wwvb_start = when
|
107
|
+
|
108
|
+
# As of 2021-06-14, NIST website incorrectly indicates the offset of -600ms
|
109
|
+
# persisted through 2009-03-12, causing an incorrect leap second inference.
|
110
|
+
# Assume instead that NIST started broadcasting +400ms on January 1, 2009,
|
111
|
+
# causing the leap second to occur on 2008-12-31.
|
112
|
+
patch(datetime.date(2009, 1, 1), datetime.date(2009, 3, 12), 4)
|
113
|
+
|
114
|
+
# this is the final (most recent) wwvb DUT1 value broadcast. We want to
|
115
|
+
# extend it some distance into the future, but how far? We will use the
|
116
|
+
# modified timestamp of the NIST data.
|
117
|
+
assert wwvb_dut1 is not None
|
118
|
+
assert wwvb_start is not None
|
119
|
+
patch(wwvb_start, wwvb_data_stamp + datetime.timedelta(days=1), wwvb_dut1)
|
120
|
+
|
121
|
+
table_end = table_start + datetime.timedelta(len(offsets) - 1)
|
122
|
+
base = ord("a") + 10
|
123
|
+
offsets_bin = bytes(base + ch for ch in offsets)
|
124
|
+
|
125
|
+
target_path.write_text(
|
126
|
+
json.dumps(
|
127
|
+
{
|
128
|
+
"START": table_start.isoformat(),
|
129
|
+
"OFFSETS_GZ": binascii.b2a_base64(gzip.compress(offsets_bin)).decode("ascii").strip(),
|
130
|
+
},
|
131
|
+
),
|
132
|
+
)
|
133
|
+
|
134
|
+
print(f"iersdata covers {table_start} .. {table_end}")
|
135
|
+
|
136
|
+
|
137
|
+
def iersdata_path(callback: Callable[[str, str], pathlib.Path]) -> pathlib.Path:
|
138
|
+
"""Find out the path for this directory"""
|
139
|
+
return callback("wwvbpy", "unpythonic.net") / "iersdata.json"
|
140
|
+
|
141
|
+
|
142
|
+
@click.command()
|
143
|
+
@click.option(
|
144
|
+
"--user",
|
145
|
+
"location",
|
146
|
+
flag_value=iersdata_path(platformdirs.user_data_path),
|
147
|
+
default=iersdata_path(platformdirs.user_data_path),
|
148
|
+
type=pathlib.Path,
|
149
|
+
)
|
150
|
+
@click.option("--dist", "location", flag_value=DIST_PATH)
|
151
|
+
@click.option("--site", "location", flag_value=iersdata_path(platformdirs.site_data_path))
|
152
|
+
def main(location: str) -> None:
|
153
|
+
"""Update DUT1 data"""
|
154
|
+
path = pathlib.Path(location)
|
155
|
+
print(f"will write to {location!r}")
|
156
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
157
|
+
update_iersdata(path)
|
158
|
+
|
159
|
+
|
160
|
+
if __name__ == "__main__":
|
161
|
+
main()
|
wwvb/wwvbtk.py
ADDED
@@ -0,0 +1,146 @@
|
|
1
|
+
#!/usr/bin/python3
|
2
|
+
"""Visualize the WWVB signal in realtime"""
|
3
|
+
|
4
|
+
# SPDX-FileCopyrightText: 2021-2024 Jeff Epler
|
5
|
+
#
|
6
|
+
# SPDX-License-Identifier: GPL-3.0-only
|
7
|
+
from __future__ import annotations
|
8
|
+
|
9
|
+
import functools
|
10
|
+
import threading
|
11
|
+
import time
|
12
|
+
from tkinter import Canvas, TclError, Tk
|
13
|
+
from typing import TYPE_CHECKING, Any
|
14
|
+
|
15
|
+
import click
|
16
|
+
|
17
|
+
import wwvb
|
18
|
+
|
19
|
+
if TYPE_CHECKING:
|
20
|
+
from collections.abc import Generator
|
21
|
+
|
22
|
+
|
23
|
+
@functools.cache
|
24
|
+
def _app() -> Tk:
|
25
|
+
"""Create the Tk application object lazily"""
|
26
|
+
return Tk()
|
27
|
+
|
28
|
+
|
29
|
+
def validate_colors(ctx: Any, param: Any, value: str) -> list[str]: # noqa: ARG001
|
30
|
+
"""Check that all colors in a string are valid, splitting it to a list"""
|
31
|
+
app = _app()
|
32
|
+
colors = value.split()
|
33
|
+
if len(colors) not in (2, 3, 4, 6):
|
34
|
+
raise click.BadParameter(f"Give 2, 3, 4 or 6 colors (not {len(colors)}")
|
35
|
+
for c in colors:
|
36
|
+
try:
|
37
|
+
app.winfo_rgb(c)
|
38
|
+
except TclError as e:
|
39
|
+
raise click.BadParameter(f"Invalid color {c}") from e
|
40
|
+
|
41
|
+
if len(colors) == 2:
|
42
|
+
off, on = colors
|
43
|
+
return [off, off, off, on, on, on]
|
44
|
+
if len(colors) == 3:
|
45
|
+
return colors + colors
|
46
|
+
if len(colors) == 4:
|
47
|
+
off, c1, c2, c3 = colors
|
48
|
+
return [off, off, off, c1, c2, c3]
|
49
|
+
return colors
|
50
|
+
|
51
|
+
|
52
|
+
DEFAULT_COLORS = "#3c3c3c #3c3c3c #3c3c3c #cc3c3c #88883c #3ccc3c"
|
53
|
+
|
54
|
+
|
55
|
+
@click.command
|
56
|
+
@click.option("--colors", callback=validate_colors, default=DEFAULT_COLORS)
|
57
|
+
@click.option("--size", default=48)
|
58
|
+
@click.option("--min-size", default=None)
|
59
|
+
def main(colors: list[str], size: int, min_size: int | None) -> None: # noqa: PLR0915
|
60
|
+
"""Visualize the WWVB signal in realtime"""
|
61
|
+
if min_size is None:
|
62
|
+
min_size = size
|
63
|
+
|
64
|
+
def sleep_deadline(deadline: float) -> None:
|
65
|
+
"""Sleep until a deadline"""
|
66
|
+
now = time.time()
|
67
|
+
if deadline > now:
|
68
|
+
time.sleep(deadline - now)
|
69
|
+
|
70
|
+
def wwvbtick() -> Generator[tuple[float, wwvb.AmplitudeModulation], None, None]:
|
71
|
+
"""Yield consecutive values of the WWVB amplitude signal, going from minute to minute"""
|
72
|
+
timestamp = time.time() // 60 * 60
|
73
|
+
|
74
|
+
while True:
|
75
|
+
tt = time.gmtime(timestamp)
|
76
|
+
key = tt.tm_year, tt.tm_yday, tt.tm_hour, tt.tm_min
|
77
|
+
timecode = wwvb.WWVBMinuteIERS(*key).as_timecode()
|
78
|
+
for i, code in enumerate(timecode.am):
|
79
|
+
yield timestamp + i, code
|
80
|
+
timestamp = timestamp + 60
|
81
|
+
|
82
|
+
def wwvbsmarttick() -> Generator[tuple[float, wwvb.AmplitudeModulation], None, None]:
|
83
|
+
"""Yield consecutive values of the WWVB amplitude signal
|
84
|
+
|
85
|
+
.. but deal with time progressing unexpectedly, such as when the
|
86
|
+
computer is suspended or NTP steps the clock backwards
|
87
|
+
|
88
|
+
When time goes backwards or advances by more than a minute, get a fresh
|
89
|
+
wwvbtick object; otherwise, discard time signals more than 1s in the past.
|
90
|
+
"""
|
91
|
+
while True:
|
92
|
+
for stamp, code in wwvbtick():
|
93
|
+
now = time.time()
|
94
|
+
if stamp < now - 60:
|
95
|
+
break
|
96
|
+
if stamp < now - 1:
|
97
|
+
continue
|
98
|
+
yield stamp, code
|
99
|
+
|
100
|
+
app = _app()
|
101
|
+
app.wm_minsize(min_size, min_size)
|
102
|
+
canvas = Canvas(app, width=size, height=size, highlightthickness=0)
|
103
|
+
circle = canvas.create_oval(4, 4, 44, 44, outline="black", fill=colors[0])
|
104
|
+
canvas.pack(fill="both", expand=True)
|
105
|
+
app.wm_deiconify()
|
106
|
+
|
107
|
+
def resize_canvas(event: Any) -> None:
|
108
|
+
"""Keep the circle filling the window when it is resized"""
|
109
|
+
sz = min(event.width, event.height) - 8
|
110
|
+
if sz < 0:
|
111
|
+
return
|
112
|
+
canvas.coords(
|
113
|
+
circle,
|
114
|
+
event.width // 2 - sz // 2,
|
115
|
+
event.height // 2 - sz // 2,
|
116
|
+
event.width // 2 + sz // 2,
|
117
|
+
event.height // 2 + sz // 2,
|
118
|
+
)
|
119
|
+
|
120
|
+
canvas.bind("<Configure>", resize_canvas)
|
121
|
+
|
122
|
+
def led_on(i: int) -> None:
|
123
|
+
"""Turn the canvas's virtual LED on"""
|
124
|
+
canvas.itemconfigure(circle, fill=colors[i + 3])
|
125
|
+
|
126
|
+
def led_off(i: int) -> None:
|
127
|
+
"""Turn the canvas's virtual LED off"""
|
128
|
+
canvas.itemconfigure(circle, fill=colors[i])
|
129
|
+
|
130
|
+
def thread_func() -> None:
|
131
|
+
"""Update the canvas virtual LED"""
|
132
|
+
for stamp, code in wwvbsmarttick():
|
133
|
+
sleep_deadline(stamp)
|
134
|
+
led_on(code)
|
135
|
+
app.update()
|
136
|
+
sleep_deadline(stamp + 0.2 + 0.3 * int(code))
|
137
|
+
led_off(code)
|
138
|
+
app.update()
|
139
|
+
|
140
|
+
thread = threading.Thread(target=thread_func, daemon=True)
|
141
|
+
thread.start()
|
142
|
+
app.mainloop()
|
143
|
+
|
144
|
+
|
145
|
+
if __name__ == "__main__":
|
146
|
+
main()
|