Compare commits

..

1 commit

Author SHA1 Message Date
6ad9773118
use bulletind, abandoned work 2022-10-08 11:45:11 -05:00
68 changed files with 1505 additions and 1391 deletions

7
.coveragerc Normal file
View file

@ -0,0 +1,7 @@
# SPDX-FileCopyrightText: 2021 Jeff Epler
#
# SPDX-License-Identifier: GPL-3.0-only
[run]
omit =
*/site-packages/*
test*.py

View file

@ -1,4 +1,4 @@
# SPDX-FileCopyrightText: 2021-2024 Jeff Epler
# SPDX-FileCopyrightText: 2021 Jeff Epler
#
# SPDX-License-Identifier: CC0-1.0
@ -11,7 +11,7 @@ on:
jobs:
update-dut1:
runs-on: ubuntu-24.04
runs-on: ubuntu-20.04
if: startswith(github.repository, 'jepler/')
steps:
@ -20,12 +20,10 @@ jobs:
GITHUB_CONTEXT: ${{ toJson(github) }}
run: echo "$GITHUB_CONTEXT"
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/checkout@v2.2.0
- name: Set up Python 3.10
uses: actions/setup-python@v5
uses: actions/setup-python@v1
with:
python-version: "3.10"
@ -39,10 +37,8 @@ jobs:
run: python -munittest
- name: Commit updates
env:
REPO: ${{ github.repository }}
run: |
git config user.name "${GITHUB_ACTOR} (github actions cron)"
git config user.email "${GITHUB_ACTOR}@users.noreply.github.com"
git remote set-url --push origin "https://${GITHUB_ACTOR}:${{ secrets.GITHUB_TOKEN }}@github.com/$REPO"
if git commit -m"update iersdata" src/wwvb/iersdata.json; then git push origin HEAD:main; fi
git remote set-url --push origin https://${GITHUB_ACTOR}:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}
if git commit -m"update iersdata" src/wwvb/iersdata_dist.py; then git push origin HEAD:main; fi

View file

@ -1,4 +1,4 @@
# SPDX-FileCopyrightText: 2021-2024 Jeff Epler
# SPDX-FileCopyrightText: 2021 Jeff Epler
#
# SPDX-License-Identifier: CC0-1.0
@ -11,19 +11,17 @@ on:
jobs:
release:
runs-on: ubuntu-24.04
runs-on: ubuntu-20.04
steps:
- name: Dump GitHub context
env:
GITHUB_CONTEXT: ${{ toJson(github) }}
run: echo "$GITHUB_CONTEXT"
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/checkout@v2.2.0
- name: Set up Python
uses: actions/setup-python@v5
uses: actions/setup-python@v1
with:
python-version: 3.9

View file

@ -1,4 +1,4 @@
# SPDX-FileCopyrightText: 2021-2024 Jeff Epler
# SPDX-FileCopyrightText: 2021 Jeff Epler
#
# SPDX-License-Identifier: CC0-1.0
@ -13,89 +13,33 @@ on:
types: [rerequested]
jobs:
docs:
runs-on: ubuntu-latest
steps:
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- uses: actions/checkout@v4
with:
persist-credentials: false
- name: Install deps
run: python -mpip install -r requirements-dev.txt
- name: Build HTML docs
run: make html
typing:
strategy:
fail-fast: false
matrix:
python-version:
- '3.13'
os-version:
- 'ubuntu-latest'
runs-on: ${{ matrix.os-version }}
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install deps
run: |
python -mpip install wheel
python -mpip install -r requirements-dev.txt
- name: Check stubs with mypy
if: (! startsWith(matrix.python-version, 'pypy-'))
run: make mypy PYTHON=python
- name: Check stubs with pyrefly
if: (! startsWith(matrix.python-version, 'pypy-'))
run: make pyrefly PYTHON=python
- name: Check stubs with pyright
if: (! startsWith(matrix.python-version, 'pypy-'))
run: make pyright PYTHON=python
test:
strategy:
fail-fast: false
matrix:
python-version:
- '3.9'
- '3.10'
- '3.11'
- '3.12'
- '3.13'
- '3.14.0-alpha.0 - 3.14'
- '3.11.0-rc.1 - 3.11'
- 'pypy-3.9'
os-version:
- 'ubuntu-latest'
include:
- os-version: 'macos-latest'
python-version: '3.x'
python-version: '3.10'
- os-version: 'windows-latest'
python-version: '3.x'
- os-version: 'ubuntu-latest'
python-version: 'pypy-3.10'
python-version: '3.10'
runs-on: ${{ matrix.os-version }}
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- name: Dump GitHub context
env:
GITHUB_CONTEXT: ${{ toJson(github) }}
run: echo "$GITHUB_CONTEXT"
- uses: actions/checkout@v2.2.0
- name: Set up Python
uses: actions/setup-python@v5
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
@ -104,30 +48,25 @@ jobs:
python -mpip install wheel
python -mpip install -r requirements-dev.txt
- name: Coverage
- name: Check stubs
if: (! startsWith(matrix.python-version, 'pypy-'))
run: make mypy PYTHON=python
- name: Test
run: make coverage PYTHON=python
- name: Test installed version
run: make test_venv PYTHON=python
- name: Upload Coverage to Codecov
if: always()
uses: codecov/codecov-action@v2
env:
PYTHON: ${{ matrix.python-version }}
with:
env_vars: PYTHON
fail_ci_if_error: true
- name: Upload Coverage as artifact
if: always()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v2
with:
name: coverage for ${{ matrix.python-version }} on ${{ matrix.os-version }}
path: coverage.xml
pre-commit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.x'
- name: pre-commit
run: pip install pre-commit && pre-commit run --all

14
.gitignore vendored
View file

@ -1,16 +1,14 @@
# SPDX-FileCopyrightText: 2021-2024 Jeff Epler
# SPDX-FileCopyrightText: 2021 Jeff Epler
#
# SPDX-License-Identifier: CC0-1.0
*,cover
*.egg-info
/.coverage*
/.reuse
/build
/_build
/.coverage*
/coverage.xml
/dist
/finals2000A.all.csv
*.egg-info
/htmlcov
/src/wwvb/__version__.py
__pycache__
*,cover
/.reuse
/src/wwvb/__version__.py

View file

@ -1,36 +1,35 @@
# SPDX-FileCopyrightText: 2020 Diego Elio Pettenò
# SPDX-FileCopyrightText: 2020-2024 Jeff Epler
#
# SPDX-License-Identifier: Unlicense
default_language_version:
python: python3
repos:
- repo: https://github.com/psf/black
rev: 22.8.0
hooks:
- id: black
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
rev: v4.3.0
hooks:
- id: check-yaml
- id: end-of-file-fixer
exclude: src/wwvb/iersdata.json
exclude: tests
- id: trailing-whitespace
exclude: test/wwvbgen_testcases
exclude: tests
- repo: https://github.com/fsfe/reuse-tool
rev: v5.0.2
rev: v1.0.0
hooks:
- id: reuse
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.12.10
- repo: local
hooks:
# Run the linter.
- id: ruff-check
args: [ --fix ]
# Run the formatter.
- id: ruff-format
- repo: https://github.com/asottile/pyupgrade
rev: v3.20.0
- id: pylint
name: pylint
entry: pylint
language: python
types: [python]
additional_dependencies: [beautifulsoup4, "pylint==2.13.5", requests, adafruit-circuitpython-datetime, click, python-dateutil]
- repo: https://github.com/pycqa/isort
rev: 5.10.1
hooks:
- id: pyupgrade
args: [ --py39-plus ]
exclude: src/uwwvb.py # CircuitPython prevaling standard!
- id: isort
name: isort (python)
args: ['--profile', 'black']

13
.pylintrc Normal file
View file

@ -0,0 +1,13 @@
#SPDX-FileCopyrightText: 2021 Jeff Epler
#
#SPDX-License-Identifier: GPL-3.0-only
[MASTER]
py-version=3.7
[MESSAGES CONTROL]
disable=duplicate-code,line-too-long,protected-access
[BASIC]
argument-rgx=[a-z][a-z0-9_]*
variable-naming-style=any
good_names=dt,t,i,j,k,ex,_

View file

@ -1,17 +0,0 @@
# SPDX-FileCopyrightText: 2024 Jeff Epler
#
# SPDX-License-Identifier: GPL-3.0-only
version: 2
build:
os: ubuntu-lts-latest
tools:
python: "3"
sphinx:
configuration: doc/conf.py
python:
install:
- requirements: requirements-dev.txt

73
LICENSES/Apache-2.0.txt Normal file
View file

@ -0,0 +1,73 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files.
"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions:
(a) You must give any other recipients of the Work or Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License.
You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View file

@ -17,64 +17,28 @@ STEPECHO = @echo
endif
PYTHON ?= python3
ifeq ($(OS),Windows_NT)
ENVPYTHON ?= _env/Scripts/python.exe
else
ENVPYTHON ?= _env/bin/python3
endif
.PHONY: default
default: coverage mypy pyright pyrefly
default: coverage mypy
COVERAGE_INCLUDE=--include "src/**/*.py"
.PHONY: coverage
coverage:
$(Q)$(PYTHON) -mcoverage erase
$(Q)env PYTHONPATH=src $(PYTHON) -mcoverage run -p -m unittest discover -s test
$(Q)env PYTHONPATH=src $(PYTHON) -mcoverage run --branch -p -m unittest discover -s src
$(Q)$(PYTHON) -mcoverage combine -q
$(Q)$(PYTHON) -mcoverage html $(COVERAGE_INCLUDE)
$(Q)$(PYTHON) -mcoverage xml $(COVERAGE_INCLUDE)
$(Q)$(PYTHON) -mcoverage report --fail-under=100 $(COVERAGE_INCLUDE)
.PHONY: test_venv
test_venv:
$(Q)$(PYTHON) -mvenv --clear _env
$(Q)$(ENVPYTHON) -mpip install .
$(Q)$(ENVPYTHON) -m unittest discover -s test
$(Q)$(PYTHON) -mcoverage html
$(Q)$(PYTHON) -mcoverage xml
$(Q)$(PYTHON) -mcoverage report --fail-under=100
.PHONY: mypy
mypy:
$(Q)mypy --strict --no-warn-unused-ignores src test
.PHONY: pyright
pyright:
$(Q)pyright src test
.PHONY: pyrefly
pyrefly:
$(Q)pyrefly check src test
$(Q)mypy --strict --no-warn-unused-ignores src
.PHONY: update
update:
$(Q)env PYTHONPATH=src $(PYTHON) -mwwvb.updateiers --dist
# Minimal makefile for Sphinx documentation
#
# You can set these variables from the command line, and also
# from the environment for the first two.
SPHINXOPTS ?= -a -E -j auto
SPHINXBUILD ?= sphinx-build
SOURCEDIR = doc
BUILDDIR = _build
# Route particular targets to Sphinx using the new
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
.PHONY: html
html:
$(Q)$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
# SPDX-FileCopyrightText: 2024 Jeff Epler
# Copyright (C) 2021 Jeff Epler <jepler@gmail.com>
# SPDX-FileCopyrightText: 2021 Jeff Epler
#
# SPDX-License-Identifier: GPL-3.0-only

View file

@ -1,16 +1,20 @@
<!--
SPDX-FileCopyrightText: 2021-2024 Jeff Epler
SPDX-FileCopyrightText: 2021 Jeff Epler
SPDX-License-Identifier: GPL-3.0-only
-->
[![Test wwvbgen](https://github.com/jepler/wwvbpy/actions/workflows/test.yml/badge.svg)](https://github.com/jepler/wwvbpy/actions/workflows/test.yml)
[![codecov](https://codecov.io/gh/jepler/wwvbpy/branch/main/graph/badge.svg?token=Exx0c3Gp65)](https://codecov.io/gh/jepler/wwvbpy)
[![Update DUT1 data](https://github.com/jepler/wwvbpy/actions/workflows/cron.yml/badge.svg)](https://github.com/jepler/wwvbpy/actions/workflows/cron.yml)
[![PyPI](https://img.shields.io/pypi/v/wwvb)](https://pypi.org/project/wwvb)
![Lines of code](https://img.shields.io/tokei/lines/github/jepler/wwvbpy)
[![Language grade: Python](https://img.shields.io/lgtm/grade/python/g/jepler/wwvbpy.svg?logo=lgtm&logoWidth=18)](https://lgtm.com/projects/g/jepler/wwvbpy/context:python)
[![pre-commit.ci status](https://results.pre-commit.ci/badge/github/jepler/wwvbpy/main.svg)](https://results.pre-commit.ci/latest/github/jepler/wwvbpy/main)
# Purpose
Python package and command line programs for interacting with WWVB timecodes.
wwvbpy generates WWVB timecodes for any desired time. These timecodes
may be useful in testing WWVB decoder software.
Where possible, wwvbpy uses existing facilities for calendar and time
manipulation (datetime and dateutil).
@ -36,7 +40,7 @@ The package includes:
# Development status
The author ([@jepler](https://github.com/jepler)) occasionally develops and maintains this project, but
The author (@jepler) occasionally develops and maintains this project, but
issues are not likely to be acted on. They would be interested in adding
co-maintainer(s).
@ -64,7 +68,7 @@ channel.
# Usage
~~~~
Usage: wwvbgen [OPTIONS] [TIMESPEC]...
Usage: python -m wwvb.gen [OPTIONS] [TIMESPEC]...
Generate WWVB timecodes
@ -94,7 +98,7 @@ Options:
For example, to display the leap second that occurred at the end of 1998,
~~~~
$ wwvbgen -m 7 1998 365 23 56
$ python wwvbgen.py -m 7 1998 365 23 56
WWVB timecode: year=98 days=365 hour=23 min=56 dst=0 ut1=-300 ly=0 ls=1
'98+365 23:56 210100110200100001120011001102010100010200110100121000001002
'98+365 23:57 210100111200100001120011001102010100010200110100121000001002
@ -116,7 +120,7 @@ The letters `a` through `u` represent offsets of -1.0s through +1.0s
in 0.1s increments; `k` represents 0s. (In practice, only a smaller range
of values, typically -0.7s to +0.8s, is seen)
For 2001 through 2024, NIST has published the actual DUT1 values broadcast,
For 2001 through 2019, NIST has published the actual DUT1 values broadcast,
and the date of each change, though it in the format of an HTML
table and not designed for machine readability:
@ -161,9 +165,7 @@ by the [other implementation I know of](http://www.leapsecond.com/tools/wwvb_pm.
# Testing wwvbpy
Run the testsuite, check coverage & type annotations with `gmake`.
There are several test suites:
Run the testsuite with `python3 -munittest`. There are several test suites:
* `testwwvb.py`: Check output against expected values. Uses hard coded leap seconds. Tests amplitude and phase data, though the phase testcases are dubious as they were also generated by wwvbpy.
* `testuwwvb.py`: Test the reduced-functionality version against the main version
* `testls.py`: Check the IERS data through 2020-1-1 for expected leap seconds

430
adafruit_datetime.pyi Normal file
View file

@ -0,0 +1,430 @@
# SPDX-FileCopyrightText: 2015-2021 Jukka Lehtosalo and contributors
#
# SPDX-License-Identifier: Apache-2.0
import sys
from time import struct_time
from typing import (
AnyStr,
ClassVar,
NamedTuple,
Optional,
SupportsAbs,
Tuple,
Type,
TypeVar,
Union,
overload,
)
_S = TypeVar("_S")
if sys.version_info >= (3,):
_Text = str
else:
_Text = Union[str, unicode]
MINYEAR: int
MAXYEAR: int
class tzinfo:
def tzname(self, dt: Optional[datetime]) -> Optional[str]: ...
def utcoffset(self, dt: Optional[datetime]) -> Optional[timedelta]: ...
def dst(self, dt: Optional[datetime]) -> Optional[timedelta]: ...
def fromutc(self, dt: datetime) -> datetime: ...
if sys.version_info >= (3, 2):
class timezone(tzinfo):
utc: ClassVar[timezone]
min: ClassVar[timezone]
max: ClassVar[timezone]
def __init__(self, offset: timedelta, name: str = ...) -> None: ...
def __hash__(self) -> int: ...
if sys.version_info >= (3, 9):
class _IsoCalendarDate(NamedTuple):
year: int
week: int
weekday: int
_tzinfo = tzinfo
class date:
min: ClassVar[date]
max: ClassVar[date]
resolution: ClassVar[timedelta]
def __new__(cls: Type[_S], year: int, month: int, day: int) -> _S: ...
@classmethod
def fromtimestamp(cls: Type[_S], __timestamp: float) -> _S: ...
@classmethod
def today(cls: Type[_S]) -> _S: ...
@classmethod
def fromordinal(cls: Type[_S], n: int) -> _S: ...
if sys.version_info >= (3, 7):
@classmethod
def fromisoformat(cls: Type[_S], date_string: str) -> _S: ...
if sys.version_info >= (3, 8):
@classmethod
def fromisocalendar(cls: Type[_S], year: int, week: int, day: int) -> _S: ...
@property
def year(self) -> int: ...
@property
def month(self) -> int: ...
@property
def day(self) -> int: ...
def ctime(self) -> str: ...
def strftime(self, fmt: _Text) -> str: ...
if sys.version_info >= (3,):
def __format__(self, fmt: str) -> str: ...
else:
def __format__(self, fmt: AnyStr) -> AnyStr: ...
def isoformat(self) -> str: ...
def timetuple(self) -> struct_time: ...
def toordinal(self) -> int: ...
def replace(self, year: int = ..., month: int = ..., day: int = ...) -> date: ...
def __le__(self, other: date) -> bool: ...
def __lt__(self, other: date) -> bool: ...
def __ge__(self, other: date) -> bool: ...
def __gt__(self, other: date) -> bool: ...
if sys.version_info >= (3, 8):
def __add__(self: _S, other: timedelta) -> _S: ...
def __radd__(self: _S, other: timedelta) -> _S: ...
else:
def __add__(self, other: timedelta) -> date: ...
def __radd__(self, other: timedelta) -> date: ...
@overload
def __sub__(self, other: timedelta) -> date: ...
@overload
def __sub__(self, other: date) -> timedelta: ...
def __hash__(self) -> int: ...
def weekday(self) -> int: ...
def isoweekday(self) -> int: ...
if sys.version_info >= (3, 9):
def isocalendar(self) -> _IsoCalendarDate: ...
else:
def isocalendar(self) -> Tuple[int, int, int]: ...
class time:
min: ClassVar[time]
max: ClassVar[time]
resolution: ClassVar[timedelta]
if sys.version_info >= (3, 6):
def __init__(
self,
hour: int = ...,
minute: int = ...,
second: int = ...,
microsecond: int = ...,
tzinfo: Optional[_tzinfo] = ...,
*,
fold: int = ...,
) -> None: ...
else:
def __init__(
self,
hour: int = ...,
minute: int = ...,
second: int = ...,
microsecond: int = ...,
tzinfo: Optional[_tzinfo] = ...,
) -> None: ...
@property
def hour(self) -> int: ...
@property
def minute(self) -> int: ...
@property
def second(self) -> int: ...
@property
def microsecond(self) -> int: ...
@property
def tzinfo(self) -> Optional[_tzinfo]: ...
if sys.version_info >= (3, 6):
@property
def fold(self) -> int: ...
def __le__(self, other: time) -> bool: ...
def __lt__(self, other: time) -> bool: ...
def __ge__(self, other: time) -> bool: ...
def __gt__(self, other: time) -> bool: ...
def __hash__(self) -> int: ...
if sys.version_info >= (3, 6):
def isoformat(self, timespec: str = ...) -> str: ...
else:
def isoformat(self) -> str: ...
if sys.version_info >= (3, 7):
@classmethod
def fromisoformat(cls: Type[_S], time_string: str) -> _S: ...
def strftime(self, fmt: _Text) -> str: ...
if sys.version_info >= (3,):
def __format__(self, fmt: str) -> str: ...
else:
def __format__(self, fmt: AnyStr) -> AnyStr: ...
def utcoffset(self) -> Optional[timedelta]: ...
def tzname(self) -> Optional[str]: ...
def dst(self) -> Optional[timedelta]: ...
if sys.version_info >= (3, 6):
def replace(
self,
hour: int = ...,
minute: int = ...,
second: int = ...,
microsecond: int = ...,
tzinfo: Optional[_tzinfo] = ...,
*,
fold: int = ...,
) -> time: ...
else:
def replace(
self,
hour: int = ...,
minute: int = ...,
second: int = ...,
microsecond: int = ...,
tzinfo: Optional[_tzinfo] = ...,
) -> time: ...
_date = date
_time = time
class timedelta(SupportsAbs[timedelta]):
min: ClassVar[timedelta]
max: ClassVar[timedelta]
resolution: ClassVar[timedelta]
if sys.version_info >= (3, 6):
def __init__(
self,
days: float = ...,
seconds: float = ...,
microseconds: float = ...,
milliseconds: float = ...,
minutes: float = ...,
hours: float = ...,
weeks: float = ...,
*,
fold: int = ...,
) -> None: ...
else:
def __init__(
self,
days: float = ...,
seconds: float = ...,
microseconds: float = ...,
milliseconds: float = ...,
minutes: float = ...,
hours: float = ...,
weeks: float = ...,
) -> None: ...
@property
def days(self) -> int: ...
@property
def seconds(self) -> int: ...
@property
def microseconds(self) -> int: ...
def total_seconds(self) -> float: ...
def __add__(self, other: timedelta) -> timedelta: ...
def __radd__(self, other: timedelta) -> timedelta: ...
def __sub__(self, other: timedelta) -> timedelta: ...
def __rsub__(self, other: timedelta) -> timedelta: ...
def __neg__(self) -> timedelta: ...
def __pos__(self) -> timedelta: ...
def __abs__(self) -> timedelta: ...
def __mul__(self, other: float) -> timedelta: ...
def __rmul__(self, other: float) -> timedelta: ...
@overload
def __floordiv__(self, other: timedelta) -> int: ...
@overload
def __floordiv__(self, other: int) -> timedelta: ...
if sys.version_info >= (3,):
@overload
def __truediv__(self, other: timedelta) -> float: ...
@overload
def __truediv__(self, other: float) -> timedelta: ...
def __mod__(self, other: timedelta) -> timedelta: ...
def __divmod__(self, other: timedelta) -> Tuple[int, timedelta]: ...
else:
@overload
def __div__(self, other: timedelta) -> float: ...
@overload
def __div__(self, other: float) -> timedelta: ...
def __le__(self, other: timedelta) -> bool: ...
def __lt__(self, other: timedelta) -> bool: ...
def __ge__(self, other: timedelta) -> bool: ...
def __gt__(self, other: timedelta) -> bool: ...
def __hash__(self) -> int: ...
class datetime(date):
min: ClassVar[datetime]
max: ClassVar[datetime]
resolution: ClassVar[timedelta]
if sys.version_info >= (3, 6):
def __new__(
cls: Type[_S],
year: int,
month: int,
day: int,
hour: int = ...,
minute: int = ...,
second: int = ...,
microsecond: int = ...,
tzinfo: Optional[_tzinfo] = ...,
*,
fold: int = ...,
) -> _S: ...
else:
def __new__(
cls: Type[_S],
year: int,
month: int,
day: int,
hour: int = ...,
minute: int = ...,
second: int = ...,
microsecond: int = ...,
tzinfo: Optional[_tzinfo] = ...,
) -> _S: ...
@property
def year(self) -> int: ...
@property
def month(self) -> int: ...
@property
def day(self) -> int: ...
@property
def hour(self) -> int: ...
@property
def minute(self) -> int: ...
@property
def second(self) -> int: ...
@property
def microsecond(self) -> int: ...
@property
def tzinfo(self) -> Optional[_tzinfo]: ...
if sys.version_info >= (3, 6):
@property
def fold(self) -> int: ...
@classmethod
def fromtimestamp(cls: Type[_S], t: float, tz: Optional[_tzinfo] = ...) -> _S: ...
@classmethod
def utcfromtimestamp(cls: Type[_S], t: float) -> _S: ...
@classmethod
def today(cls: Type[_S]) -> _S: ...
@classmethod
def fromordinal(cls: Type[_S], n: int) -> _S: ...
if sys.version_info >= (3, 8):
@classmethod
def now(cls: Type[_S], tz: Optional[_tzinfo] = ...) -> _S: ...
else:
@overload
@classmethod
def now(cls: Type[_S], tz: None = ...) -> _S: ...
@overload
@classmethod
def now(cls, tz: _tzinfo) -> datetime: ...
@classmethod
def utcnow(cls: Type[_S]) -> _S: ...
if sys.version_info >= (3, 6):
@classmethod
def combine(
cls, date: _date, time: _time, tzinfo: Optional[_tzinfo] = ...
) -> datetime: ...
else:
@classmethod
def combine(cls, date: _date, time: _time) -> datetime: ...
if sys.version_info >= (3, 7):
@classmethod
def fromisoformat(cls: Type[_S], date_string: str) -> _S: ...
def strftime(self, fmt: _Text) -> str: ...
if sys.version_info >= (3,):
def __format__(self, fmt: str) -> str: ...
else:
def __format__(self, fmt: AnyStr) -> AnyStr: ...
def toordinal(self) -> int: ...
def timetuple(self) -> struct_time: ...
if sys.version_info >= (3, 3):
def timestamp(self) -> float: ...
def utctimetuple(self) -> struct_time: ...
def date(self) -> _date: ...
def time(self) -> _time: ...
def timetz(self) -> _time: ...
if sys.version_info >= (3, 6):
def replace(
self,
year: int = ...,
month: int = ...,
day: int = ...,
hour: int = ...,
minute: int = ...,
second: int = ...,
microsecond: int = ...,
tzinfo: Optional[_tzinfo] = ...,
*,
fold: int = ...,
) -> datetime: ...
else:
def replace(
self,
year: int = ...,
month: int = ...,
day: int = ...,
hour: int = ...,
minute: int = ...,
second: int = ...,
microsecond: int = ...,
tzinfo: Optional[_tzinfo] = ...,
) -> datetime: ...
if sys.version_info >= (3, 8):
def astimezone(self: _S, tz: Optional[_tzinfo] = ...) -> _S: ...
elif sys.version_info >= (3, 3):
def astimezone(self, tz: Optional[_tzinfo] = ...) -> datetime: ...
else:
def astimezone(self, tz: _tzinfo) -> datetime: ...
def ctime(self) -> str: ...
if sys.version_info >= (3, 6):
def isoformat(self, sep: str = ..., timespec: str = ...) -> str: ...
else:
def isoformat(self, sep: str = ...) -> str: ...
@classmethod
def strptime(cls, date_string: _Text, format: _Text) -> datetime: ...
def utcoffset(self) -> Optional[timedelta]: ...
def tzname(self) -> Optional[str]: ...
def dst(self) -> Optional[timedelta]: ...
def __le__(self, other: datetime) -> bool: ... # type: ignore
def __lt__(self, other: datetime) -> bool: ... # type: ignore
def __ge__(self, other: datetime) -> bool: ... # type: ignore
def __gt__(self, other: datetime) -> bool: ... # type: ignore
if sys.version_info >= (3, 8):
def __add__(self: _S, other: timedelta) -> _S: ...
def __radd__(self: _S, other: timedelta) -> _S: ...
else:
def __add__(self, other: timedelta) -> datetime: ...
def __radd__(self, other: timedelta) -> datetime: ...
@overload # type: ignore
def __sub__(self, other: datetime) -> timedelta: ...
@overload
def __sub__(self, other: timedelta) -> datetime: ...
def __hash__(self) -> int: ...
def weekday(self) -> int: ...
def isoweekday(self) -> int: ...
if sys.version_info >= (3, 9):
def isocalendar(self) -> _IsoCalendarDate: ...
else:
def isocalendar(self) -> Tuple[int, int, int]: ...

View file

@ -1,92 +0,0 @@
# ruff: noqa
# fmt: off
# Configuration file for the Sphinx documentation builder.
#
# This file only contains a selection of the most common options. For a full
# list see the documentation:
# https://www.sphinx-doc.org/en/master/usage/configuration.html
# -- Path setup --------------------------------------------------------------
# If extensions (or modules to document with autodoc) are in another directory,
# add these directories to sys.path here. If the directory is relative to the
# documentation root, use os.path.abspath to make it absolute, like shown here.
#
import os
import re
import subprocess
import sys
import pathlib
ROOT = pathlib.Path(__file__).absolute().parent.parent
sys.path.insert(0, str(ROOT / "src"))
# -- Project information -----------------------------------------------------
project = "wwvb"
copyright = "2021, Jeff Epler"
author = "Jeff Epler"
# The full version, including alpha/beta/rc tags
final_version = ""
git_describe = subprocess.run(
["git", "describe", "--tags", "--dirty"],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
encoding="utf-8", check=False,
)
if git_describe.returncode == 0:
git_version = re.search(
r"^\d(?:\.\d){0,2}(?:\-(?:alpha|beta|rc)\.\d+){0,1}",
str(git_describe.stdout),
)
if git_version:
final_version = git_version[0]
else:
print("Failed to retrieve git version:", git_describe.stdout)
version = release = final_version
# -- General configuration ---------------------------------------------------
# Add any Sphinx extension module names here, as strings. They can be
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
# ones.
extensions = [
"sphinx.ext.autodoc",
"sphinx_mdinclude",
]
# Add any paths that contain templates here, relative to this directory.
templates_path = ["_templates"]
# List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files.
# This pattern also affects html_static_path and html_extra_path.
exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]
# -- Options for HTML output -------------------------------------------------
# The theme to use for HTML and HTML Help pages. See the documentation for
# a list of builtin themes.
#
html_theme = "sphinx_rtd_theme"
# Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css".
html_static_path = ["_static"]
autodoc_typehints = "description"
autodoc_class_signature = "separated"
default_role = "any"
intersphinx_mapping = {'py': ('https://docs.python.org/3', None)}
# SPDX-FileCopyrightText: 2021-2024 Jeff Epler
#
# SPDX-License-Identifier: GPL-3.0-only

View file

@ -1,24 +0,0 @@
.. SPDX-FileCopyrightText: 2021-2024 Jeff Epler
..
.. SPDX-License-Identifier: GPL-3.0-only
wwvbpy |version|
================
.. mdinclude:: ../README.md
.. toctree::
:maxdepth: 2
:caption: Contents:
wwvb module
===========
.. automodule:: wwvb
:members:
uwwvb module
============
.. automodule:: uwwvb
:members:

10
lgtm.yml Normal file
View file

@ -0,0 +1,10 @@
# SPDX-FileCopyrightText: 2021 Jeff Epler
#
# SPDX-License-Identifier: CC0-1.0
extraction:
python:
python_setup:
requirements_files:
- requirements-dev.py
setup_py: false

View file

@ -1,56 +1,19 @@
# SPDX-FileCopyrightText: 2021-2024 Jeff Epler
# SPDX-FileCopyrightText: 2021 Jeff Epler
#
# SPDX-License-Identifier: GPL-3.0-only
[build-system]
requires = [
"setuptools>=68",
"beautifulsoup4",
"click",
"requests",
"platformdirs",
"python-dateutil",
"setuptools>=45",
"setuptools_scm[toml]>=6.0",
"tzdata",
"wheel",
]
build-backend = "setuptools.build_meta"
[tool.setuptool]
package_dir = {"" = "src"}
include-package-data = true
[tool.setuptools.dynamic]
readme = {file = ["README.md"], content-type="text/markdown"}
dependencies = {file = "requirements.txt"}
[tool.setuptools_scm]
write_to = "src/wwvb/__version__.py"
[tool.ruff.lint]
select = ["E", "F", "D", "I", "N", "UP", "YTT", "BLE", "B", "FBT", "A", "COM", "C4", "DTZ", "FA", "ISC", "ICN", "PIE", "PYI", "Q", "RET", "SIM", "TID", "TCH", "ARG", "PTH", "C", "R", "W", "FLY", "RUF", "PL"]
ignore = ["D203", "D213", "D400", "D415", "ISC001", "E741", "C901", "PLR0911", "PLR2004", "PLR0913", "COM812"]
[tool.ruff]
line-length = 120
[project]
name = "wwvb"
authors = [{name = "Jeff Epler", email = "jepler@gmail.com"}]
description = "Generate WWVB timecodes for any desired time"
dynamic = ["readme","version","dependencies"]
classifiers = [
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: Implementation :: PyPy",
"Programming Language :: Python :: Implementation :: CPython",
"License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
"Operating System :: OS Independent",
]
requires-python = ">=3.9"
[project.urls]
Source = "https://github.com/jepler/wwvbpy"
Documentation = "https://github.com/jepler/wwvbpy"
[project.scripts]
wwvbgen = "wwvb.gen:main"
wwvbdecode = "wwvb.decode:main"
dut1table = "wwvb.dut1table:main"
updateiers = "wwvb.updateiers:main"
[project.gui-scripts]
wwvbtk = "wwvb.wwvbtk:main"
[[tool.mypy.overrides]]
module = ["adafruit_datetime"]
follow_untyped_imports = true
[tool.coverage.run]
patch=["subprocess"]
branch=true

View file

@ -1,29 +1,20 @@
# SPDX-FileCopyrightText: 2021-2024 Jeff Epler
# SPDX-FileCopyrightText: 2021 Jeff Epler
#
# SPDX-License-Identifier: GPL-3.0-only
adafruit-circuitpython-datetime
beautifulsoup4
build
click
coverage >= 7.10.3
coverage
mypy; implementation_name=="cpython"
pyright; implementation_name=="cpython"
pyrefly; implementation_name=="cpython"
click>=8.1.5; implementation_name=="cpython"
leapseconddata
platformdirs
pre-commit
python-dateutil
requests; implementation_name=="cpython"
setuptools>=68; implementation_name=="cpython"
sphinx
sphinx-autodoc-typehints
sphinx-rtd-theme
sphinx-mdinclude
setuptools>=45; implementation_name=="cpython"
twine; implementation_name=="cpython"
types-beautifulsoup4; implementation_name=="cpython"
types-python-dateutil; implementation_name=="cpython"
types-requests; implementation_name=="cpython"
typing-extensions; implementation_name=="cpython"
types-beautifulsoup4
types-python-dateutil
types-requests
tzdata
wheel

View file

@ -1,11 +0,0 @@
# SPDX-FileCopyrightText: 2021-2024 Jeff Epler
#
# SPDX-License-Identifier: GPL-3.0-only
adafruit-circuitpython-datetime
beautifulsoup4
click
leapseconddata
platformdirs
python-dateutil
requests
tzdata

49
setup.cfg Normal file
View file

@ -0,0 +1,49 @@
# SPDX-FileCopyrightText: 2021 Jeff Epler
#
# SPDX-License-Identifier: GPL-3.0-only
[metadata]
name = wwvb
author = Jeff Epler
author_email = jepler@gmail.com
description = Generate WWVB timecodes for any desired time
long_description = file: README.md
long_description_content_type = text/markdown
url = https://github.com/jepler/wwvbpy
classifiers =
Programming Language :: Python :: 3
Programming Language :: Python :: 3.7
Programming Language :: Python :: 3.8
Programming Language :: Python :: 3.9
Programming Language :: Python :: 3.10
Programming Language :: Python :: Implementation :: PyPy
Programming Language :: Python :: Implementation :: CPython
License :: OSI Approved :: GNU General Public License v3 (GPLv3)
Operating System :: OS Independent
[options]
package_dir =
=src
packages = wwvb
python_requires = >=3.7
py_modules = uwwvb
install_requires =
adafruit-circuitpython-datetime
beautifulsoup4
click
platformdirs
requests
python-dateutil
bulletind
[options.entry_points]
console_scripts =
wwvbgen = wwvb.gen:main
wwvbdecode = wwvb.decode:main
dut1table = wwvb.dut1table:main
updateiers = wwvb.updateiers:main
gui_scripts =
wwvbtk = wwvb.wwvbtk:main
[options.package_data]
wwvb = py.typed

View file

@ -1,13 +1,8 @@
# SPDX-FileCopyrightText: 2021-2024 Jeff Epler
# SPDX-FileCopyrightText: 2021 Jeff Epler
#
# SPDX-License-Identifier: GPL-3.0-only
# ruff: noqa: C405, PYI024, FBT001, FBT002
"""Implementation of a WWVB state machine & decoder for resource-constrained systems
This version is intended for use with MicroPython & CircuitPython.
"""
"""Implementation of a WWVB state machine & decoder for resource-constrained systems"""
from __future__ import annotations
@ -21,7 +16,9 @@ always_mark = set((0, 9, 19, 29, 39, 49, 59))
always_zero = set((4, 10, 11, 14, 20, 21, 34, 35, 44, 54))
bcd_weights = (1, 2, 4, 8, 10, 20, 40, 80, 100, 200, 400, 800)
WWVBMinute = namedtuple("WWVBMinute", ["year", "days", "hour", "minute", "dst", "ut1", "ls", "ly"])
WWVBMinute = namedtuple(
"WWVBMinute", ["year", "days", "hour", "minute", "dst", "ut1", "ls", "ly"]
)
class WWVBDecoder:
@ -33,10 +30,7 @@ class WWVBDecoder:
self.state = 1
def update(self, value: int) -> list[int] | None:
"""Update the _state machine when a new symbol is received.
If a possible complete _minute is received, return it; otherwise, return None
"""
"""Update the _state machine when a new symbol is received. If a possible complete _minute is received, return it; otherwise, return None"""
result = None
if self.state == 1:
self.minute = []
@ -93,7 +87,7 @@ def get_am_bcd(seq: list[int], *poslist: int) -> int | None:
return result
def decode_wwvb(
def decode_wwvb( # pylint: disable=too-many-return-statements
t: list[int] | None,
) -> WWVBMinute | None:
"""Convert a received minute of wwvb symbols to a WWVBMinute. Returns None if any error is detected."""

File diff suppressed because it is too large Load diff

View file

@ -3,16 +3,11 @@
# SPDX-License-Identifier: GPL-3.0-only
"""A stateful decoder of WWVB signals"""
from __future__ import annotations
import sys
from typing import Generator, List, Optional
import wwvb
TYPE_CHECKING = False
if TYPE_CHECKING:
from collections.abc import Generator
# State 1: Unsync'd
# Marker: State 2
# Other: State 1
@ -25,12 +20,14 @@ if TYPE_CHECKING:
# State 4: Decoding a minute, starting in second 1
# Second
always_zero = {4, 10, 11, 14, 20, 21, 34, 35, 44, 54}
always_zero = set((4, 10, 11, 14, 20, 21, 34, 35, 44, 54))
def wwvbreceive() -> Generator[wwvb.WWVBTimecode | None, wwvb.AmplitudeModulation, None]:
"""Decode WWVB signals statefully."""
minute: list[wwvb.AmplitudeModulation] = []
def wwvbreceive() -> Generator[ # pylint: disable=too-many-branches
Optional[wwvb.WWVBTimecode], wwvb.AmplitudeModulation, None
]:
"""A stateful decoder of WWVB signals"""
minute: List[wwvb.AmplitudeModulation] = []
state = 1
value = yield None
@ -43,7 +40,10 @@ def wwvbreceive() -> Generator[wwvb.WWVBTimecode | None, wwvb.AmplitudeModulatio
value = yield None
elif state == 2:
state = 3 if value == wwvb.AmplitudeModulation.MARK else 1
if value == wwvb.AmplitudeModulation.MARK:
state = 3
else:
state = 1
value = yield None
elif state == 3:
@ -60,7 +60,10 @@ def wwvbreceive() -> Generator[wwvb.WWVBTimecode | None, wwvb.AmplitudeModulatio
elif len(minute) % 10 and value == wwvb.AmplitudeModulation.MARK:
# print("UNEXPECTED MARK")
state = 1
elif len(minute) - 1 in always_zero and value != wwvb.AmplitudeModulation.ZERO:
elif (
len(minute) - 1 in always_zero
and value != wwvb.AmplitudeModulation.ZERO
):
# print("UNEXPECTED NONZERO")
state = 1
elif len(minute) == 60:
@ -89,5 +92,5 @@ def main() -> None:
print(w)
if __name__ == "__main__":
if __name__ == "__main__": # pragma no cover
main()

View file

@ -1,11 +1,10 @@
#!/usr/bin/env python3
# SPDX-FileCopyrightText: 2021-2024 Jeff Epler
# SPDX-FileCopyrightText: 2021 Jeff Epler
#
# SPDX-License-Identifier: GPL-3.0-only
"""Print the table of historical DUT1 values"""
from datetime import timedelta
from itertools import groupby
@ -20,13 +19,12 @@ def main() -> None:
for key, it in groupby(DUT1_OFFSETS):
dut1_ms = (ord(key) - ord("k")) / 10.0
count = len(list(it))
end = date + timedelta(days=count - 1)
dut1_next = wwvb.get_dut1(date + timedelta(days=count), warn_outdated=False)
ls = f" LS on {end:%F} 23:59:60 UTC" if dut1_ms * dut1_next < 0 else ""
ls = " LS" if dut1_ms * dut1_next < 0 else ""
print(f"{date:%F} {dut1_ms: 3.1f} {count:4d}{ls}")
date += timedelta(days=count)
print(date)
if __name__ == "__main__":
if __name__ == "__main__": # pragma no branch
main()

View file

@ -1,40 +1,38 @@
#!/usr/bin/python3
"""A command-line program for generating wwvb timecodes"""
# SPDX-FileCopyrightText: 2011-2024 Jeff Epler
# Copyright (C) 2011-2020 Jeff Epler <jepler@gmail.com>
# SPDX-FileCopyrightText: 2021 Jeff Epler
#
# SPDX-License-Identifier: GPL-3.0-only
from __future__ import annotations
import datetime
import sys
from typing import Any, List, Type
import click
import dateutil.parser
from . import WWVBMinute, WWVBMinuteIERS, print_timecodes, print_timecodes_json, styles
TYPE_CHECKING = False
if TYPE_CHECKING:
from . import WWVBChannel
def parse_timespec(ctx: click.Context, param: click.Parameter, value: list[str]) -> datetime.datetime: # noqa: ARG001
def parse_timespec( # pylint: disable=unused-argument
ctx: Any, param: Any, value: List[str]
) -> datetime.datetime:
"""Parse a time specifier from the commandline"""
try:
if len(value) == 5:
year, month, day, hour, minute = map(int, value)
return datetime.datetime(year, month, day, hour, minute, tzinfo=datetime.timezone.utc)
return datetime.datetime(year, month, day, hour, minute)
if len(value) == 4:
year, yday, hour, minute = map(int, value)
return datetime.datetime(year, 1, 1, hour, minute, tzinfo=datetime.timezone.utc) + datetime.timedelta(
days=yday - 1,
return datetime.datetime(year, 1, 1, hour, minute) + datetime.timedelta(
days=yday - 1
)
if len(value) == 1:
return dateutil.parser.parse(value[0])
if len(value) == 0:
return datetime.datetime.now(datetime.timezone.utc)
if len(value) == 0: # pragma no cover
return datetime.datetime.utcnow()
raise ValueError("Unexpected number of arguments")
except ValueError as e:
raise click.UsageError(f"Could not parse timespec: {e}") from e
@ -70,11 +68,13 @@ def parse_timespec(ctx: click.Context, param: click.Parameter, value: list[str])
help="Force no leap second at the end of the month (Implies --no-iers)",
)
@click.option("--dut1", "-d", type=int, help="Force the DUT1 value (Implies --no-iers)")
@click.option("--minutes", "-m", default=10, help="Number of minutes to show (default: 10)")
@click.option(
"--minutes", "-m", default=10, help="Number of minutes to show (default: 10)"
)
@click.option(
"--style",
default="default",
type=click.Choice(sorted(["json", *list(styles.keys())])),
type=click.Choice(sorted(["json"] + list(styles.keys()))),
help="Style of output",
)
@click.option(
@ -91,21 +91,21 @@ def parse_timespec(ctx: click.Context, param: click.Parameter, value: list[str])
help="Modulation to show (default: amplitude)",
)
@click.argument("timespec", type=str, nargs=-1, callback=parse_timespec)
# pylint: disable=too-many-arguments, too-many-locals
def main(
*,
iers: bool,
leap_second: bool,
dut1: int,
minutes: int,
style: str,
channel: WWVBChannel,
channel: str,
all_timecodes: bool,
timespec: datetime.datetime,
) -> None:
"""Generate WWVB timecodes
TIMESPEC: one of "year yday hour minute" or "year month day hour minute", or else the current minute
"""
TIMESPEC: one of "year yday hour minute" or "year month day hour minute", or else the current minute"""
if (leap_second is not None) or (dut1 is not None):
iers = False
@ -113,18 +113,23 @@ def main(
newls = None
if iers:
constructor: type[WWVBMinute] = WWVBMinuteIERS
Constructor: Type[WWVBMinute] = WWVBMinuteIERS
else:
constructor = WWVBMinute
newut1 = -500 * (leap_second or 0) if dut1 is None else dut1
Constructor = WWVBMinute
if dut1 is None:
newut1 = -500 * (leap_second or 0)
else:
newut1 = dut1
newls = bool(leap_second)
w = constructor.from_datetime(timespec, newls=newls, newut1=newut1)
w = Constructor.from_datetime(timespec, newls=newls, newut1=newut1)
if style == "json":
print_timecodes_json(w, minutes, channel, file=sys.stdout)
else:
print_timecodes(w, minutes, channel, style, all_timecodes=all_timecodes, file=sys.stdout)
print_timecodes(
w, minutes, channel, style, all_timecodes=all_timecodes, file=sys.stdout
)
if __name__ == "__main__":
main()
if __name__ == "__main__": # pragma no branch
main() # pylint: disable=no-value-for-parameter

View file

@ -1 +0,0 @@
{"START": "1972-01-01", "OFFSETS_GZ": "H4sIAOvijWgC/+2aa3LDMAiEL5uHLTuxnN5/pn/aTmfSSiAWhGy+E2SWZQE58zwiH/1YivB/96vMXiIX2Io8CTyIrDSWGqlMRdrpDa6aJFnr0m4wYZkCE2UmSF0V+13vBveStK6JTfQyW3O86HLJf0RvDgy5u4FCI+WVKTsVoUdHzsrRoWRfYHIItZ5EEgu0Beu58EgEpMpO9zf4/s3iNO4y7/hqEwOZIPu3+PuO2T7Ic5E8GxsnZHvUYOtELxW1WP+0yx/caFxpyAooq6lq06UEr+UkLeXOIDPZ6EBrqb5K8Tvu6/B9CdnZqFQL05s2KauWy/IeF/tJGAisjK9MgGyDuUkRq4G1gRE+VjA30uZNPsdantkgMq58QO4fw+sqzj+A2/16mmvnyy9UzDvMktDgKYlnkFeB2rx+wNANG40aA4OgsY03AWoDCVs/XMmkyQ0+0jWaUqPdwA0m/MRuccGjCwirHToWzbcs8P7U1nZZLSYdHapWu5HqVg1YjK2fPEwvPZPzLPUF848tyid2u7dh8B7h+wVQ923Q+kqxZe3JclSSB+YTM3nnHrjgFth/vzgZzw6cbOMYa4bHFPU/DR3mp/ubKM4cgwMnHZW4GFxFprOVcevAKGva6oExn1MOmyGDJQPm0rpU8bjqdOo993O6Xz9ofToZela5vwrWoTn4l4o5CIIaKejCEgSnJv784V+zUyyvbb/gE8h8bi3oTQAA"}

View file

@ -1,2 +0,0 @@
SPDX-FileCopyrightText: Public domain
SPDX-License-Identifier: CC0-1.0

View file

@ -1,37 +1,24 @@
# -*- python3 -*-
"""Retrieve iers data, possibly from user or site data or from the wwvbpy distribution"""
# SPDX-FileCopyrightText: 2011-2024 Jeff Epler
# Copyright (C) 2021 Jeff Epler <jepler@gmail.com>
# SPDX-FileCopyrightText: 2021 Jeff Epler
#
# SPDX-License-Identifier: GPL-3.0-only
import binascii
import datetime
import gzip
import importlib.resources
import json
import os
import platformdirs
__all__ = ["DUT1_DATA_START", "DUT1_OFFSETS", "end", "span", "start"]
__all__ = ["DUT1_DATA_START", "DUT1_OFFSETS"]
from .iersdata_dist import DUT1_DATA_START, DUT1_OFFSETS
content: dict[str, str] = {"START": "1970-01-01", "OFFSETS_GZ": "H4sIAFNx1mYC/wMAAAAAAAAAAAA="}
path = importlib.resources.files("wwvb") / "iersdata.json"
content = json.loads(path.read_text(encoding="utf-8"))
for location in [ # pragma no cover
platformdirs.user_data_path("wwvbpy", "unpythonic.net"),
platformdirs.site_data_path("wwvbpy", "unpythonic.net"),
]:
path = location / "iersdata.json"
if path.exists():
content = json.loads(path.read_text(encoding="utf-8"))
for location in [
platformdirs.user_data_dir("wwvbpy", "unpythonic.net"),
platformdirs.site_data_dir("wwvbpy", "unpythonic.net"),
]: # pragma no cover
filename = os.path.join(location, "wwvbpy_iersdata.py")
if os.path.exists(filename):
with open(filename, encoding="utf-8") as f:
exec(f.read(), globals(), globals()) # pylint: disable=exec-used
break
DUT1_DATA_START = datetime.date.fromisoformat(content["START"])
DUT1_OFFSETS = gzip.decompress(binascii.a2b_base64(content["OFFSETS_GZ"])).decode("ascii")
start = datetime.datetime.combine(DUT1_DATA_START, datetime.time(), tzinfo=datetime.timezone.utc)
span = datetime.timedelta(days=len(DUT1_OFFSETS))
end = start + span

37
src/wwvb/iersdata_dist.py Normal file
View file

@ -0,0 +1,37 @@
# -*- python3 -*-
"""File generated from public data - not subject to copyright"""
# SPDX-FileCopyrightText: Public domain
# SPDX-License-Identifier: CC0-1.0
# fmt: off
# isort: skip_file
# pylint: disable=invalid-name
import datetime
__all__ = ['DUT1_DATA_START', 'DUT1_OFFSETS']
DUT1_DATA_START = datetime.date(1972, 6, 1)
d,e,f,g,h,i,j,k,l,m,n,o,p,q,r,s = tuple('defghijklmnopqrs')
DUT1_OFFSETS = str( # 19720601
i*30+s*203+r*31+q*29+p*28+o*30+n*36+m*40+l*39+k*33+j*31+i*31 # 19731231
+h*18+r*19+q*38+p*32+o*31+n*33+m*48+l*45+k*37+j*33+i*34+h*15 # 19750122
+r*22+q*34+p*33+o*34+n*37+m*49+l*45+k*36+j*32+i*36+h*7+r*28 # 19760301
+q*33+p*32+o*30+n*33+m*42+l*42+k*34+j*29+i*33+h*30+r*6+q*36 # 19770317
+p*34+o*31+n*32+m*42+l*51+k*37+j*32+i*33+h*31+q*32+p*29+o*29 # 19780430
+n*30+m*32+l*47+k*47+j*36+i*33+h*32+g*18+q*16+p*35+o*33+n*32 # 19790531
+m*35+l*45+k*51+j*39+i*39+h*38+g*2+q*40+p*39+o*38+n*43+m*57 # 19800923
+l*50+k*39+j*42+i*41+h*43+g*37+f*39+e*39+o*19+n*62+m*43+l*45 # 19820202
+k*48+j*44+i*39+h*44+g*21+q*44+p*48+o*43+n*41+m*36+l*34+k*34 # 19830514
+j*38+i*47+s+r*64+q*50+p*42+o*56+n*57+m*52+l*100+k*61+j*62 # 19850302
+i*66+h*52+g*67+f+p*103+o*56+n*68+m*69+l*107+k*82+j*72+i*67 # 19870518
+h*63+g*113+f*63+e*51+o*11+n*60+m*59+l*121+k*71+j*71+i*67 # 19890531
+h*57+g*93+f*61+e*48+d*12+n*41+m*44+l*46+k*61+j*66+i*47+h*45 # 19901231
+g*15+q*32+p*44+o*41+n*48+m*68+l*56+k*35+j*168+f*119+n*49 # 19921125
+m*35+l*49+k*112+h*56+q*98+o*42+n*42+m*77+k*35+j*71+s*237 # 19950315
+n*21+m*28+l*45+k*46+j*56+i*49+h*35+g*32+p*52+o*49+n*35+m*84 # 19961002
+l*56+k*63+j*63+i*42+h*49+g*49+f*5+p*79+o*42+n*49+m*63+l*35 # 19980506
+k*42+j*98+i*105+h*36+r*62+q*84+p*140+o*84+n*98+m*189+l*133 # 20011003
+k*217+j*133+i*252+h*161+g*392+f*322+e*290+n*116+m*154+l*85 # 20070314
+k*83+j*91+i*168+h*105+g*147+f*105+e*112+n*91+m*154+l*119 # 20100602
+k*84+j*217+i*126+h*176+g*97+f*91+e*52+o*116+n*98+m*70+l*133 # 20131120
+k*91+j*91+i*77+h*140+g*91+f*84+e*70+d*34+n*78+m*70+l*66 # 20160323
+k*53+j*56+i*105+h*77+g*45+q*25+p*63+o*91+n*154+m*105+l*190 # 20190116
+k*118+j*105+i*807+j*376+k*346+l*98 # 20231014
)

View file

@ -1,77 +1,55 @@
#!/usr/bin/python3
"""Test most wwvblib commandline programs"""
# ruff: noqa: N802 D102
# SPDX-FileCopyrightText: 2021-2024 Jeff Epler
# Copyright (C) 2011-2020 Jeff Epler <jepler@gmail.com>
# SPDX-FileCopyrightText: 2021 Jeff Epler
#
# SPDX-License-Identifier: GPL-3.0-only
from __future__ import annotations
# pylint: disable=invalid-name
import json
import os
import subprocess
import sys
import unittest
# These imports must remain, even though the module contents are not used directly!
import wwvb.dut1table
import wwvb.gen
# The asserts below are to help prevent their removal by a linter.
assert wwvb.dut1table.__name__ == "wwvb.dut1table"
assert wwvb.gen.__name__ == "wwvb.gen"
TYPE_CHECKING = False
if TYPE_CHECKING:
from collections.abc import Sequence
from wwvb import JsonMinute
coverage_add = (
("-m", "coverage", "run", "--branch", "-p") if "COVERAGE_RUN" in os.environ else ()
)
class CLITestCase(unittest.TestCase):
"""Test various CLI commands within wwvbpy"""
def programOutput(self, *args: str) -> str:
env = os.environ.copy()
env["PYTHONIOENCODING"] = "utf-8"
return subprocess.check_output(args, stdin=subprocess.DEVNULL, encoding="utf-8", env=env)
def moduleArgs(self, *args: str) -> Sequence[str]:
return (sys.executable, "-m", *args)
def moduleOutput(self, *args: str) -> str:
return self.programOutput(sys.executable, "-m", *args)
def assertProgramOutput(self, expected: str, *args: str) -> None:
"""Check the output from invoking a program matches the expected"""
actual = self.programOutput(*args)
self.assertMultiLineEqual(expected, actual, f"args={args}")
env = os.environ.copy()
env["PYTHONIOENCODING"] = "utf-8"
actual = subprocess.check_output(
args, stdin=subprocess.DEVNULL, encoding="utf-8", env=env
)
self.assertMultiLineEqual(expected, actual, "args={args}")
def assertProgramOutputStarts(self, expected: str, *args: str) -> None:
"""Check the output from invoking a program matches the expected"""
actual = self.programOutput(*args)
self.assertMultiLineEqual(expected, actual[: len(expected)], f"args={args}")
env = os.environ.copy()
env["PYTHONIOENCODING"] = "utf-8"
actual = subprocess.check_output(
args, stdin=subprocess.DEVNULL, encoding="utf-8", env=env
)
self.assertMultiLineEqual(expected, actual[: len(expected)], "args={args}")
def assertModuleOutput(self, expected: str, *args: str) -> None:
"""Check the output from invoking a `python -m modulename` program matches the expected"""
actual = self.moduleOutput(*args)
self.assertMultiLineEqual(expected, actual, f"args={args}")
def assertStarts(self, expected: str, actual: str, *args: str) -> None:
self.assertMultiLineEqual(expected, actual[: len(expected)], f"args={args}")
def assertModuleJson(self, expected: list[JsonMinute], *args: str) -> None:
"""Check the output from invoking a `python -m modulename` program matches the expected"""
actual = self.moduleOutput(*args)
# Note: in mypy, revealed type of json.loads is typing.Any!
self.assertEqual(json.loads(actual), expected)
return self.assertProgramOutput(
expected, sys.executable, *coverage_add, "-m", *args
)
def assertModuleOutputStarts(self, expected: str, *args: str) -> None:
"""Check the output from invoking a `python -m modulename` program matches the expected"""
actual = self.moduleOutput(*args)
self.assertStarts(expected, actual, *args)
return self.assertProgramOutputStarts(
expected, sys.executable, *coverage_add, "-m", *args
)
def assertProgramError(self, *args: str) -> None:
"""Check the output from invoking a program fails"""
@ -88,10 +66,10 @@ class CLITestCase(unittest.TestCase):
def assertModuleError(self, *args: str) -> None:
"""Check the output from invoking a `python -m modulename` program fails"""
self.assertProgramError(*self.moduleArgs(*args))
return self.assertProgramError(sys.executable, *coverage_add, "-m", *args)
def test_gen(self) -> None:
"""Test wwvb.gen"""
"""test wwvb.gen"""
self.assertModuleOutput(
"""\
WWVB timecode: year=2020 days=001 hour=12 min=30 dst=0 ut1=-200 ly=1 ls=0
@ -165,35 +143,19 @@ WWVB timecode: year=2020 days=001 hour=12 min=30 dst=0 ut1=-300 ly=1 ls=0
"""Test the dut1table program"""
self.assertModuleOutputStarts(
"""\
1972-01-01 -0.2 182 LS on 1972-06-30 23:59:60 UTC
1972-07-01 0.8 123
1972-11-01 0.0 30
1972-12-01 -0.2 31 LS on 1972-12-31 23:59:60 UTC
1972-06-01 -0.2 30 LS
1972-07-01 0.8 203
1973-01-20 0.7 31
""",
"wwvb.dut1table",
)
def test_json(self) -> None:
"""Test the JSON output format"""
self.assertModuleJson(
[
{
"year": 2021,
"days": 340,
"hour": 3,
"minute": 40,
"amplitude": "210000000200000001120011001002000000010200010001020001000002",
"phase": "111110011011010101000100100110011110001110111010111101001011",
},
{
"year": 2021,
"days": 340,
"hour": 3,
"minute": 41,
"amplitude": "210000001200000001120011001002000000010200010001020001000002",
"phase": "001010011100100011000101110000100001101000001111101100000010",
},
],
self.assertModuleOutput(
"""\
[{"year": 2021, "days": 340, "hour": 3, "minute": 40, "amplitude": "210000000200000001120011001002000000010200010001020001000002", "phase": "111110011011010101000100100110011110001110111010111101001011"}, {"year": 2021, "days": 340, "hour": 3, "minute": 41, "amplitude": "210000001200000001120011001002000000010200010001020001000002", "phase": "001010011100100011000101110000100001101000001111101100000010"}]
""",
"wwvb.gen",
"-m",
"2",
@ -203,23 +165,10 @@ WWVB timecode: year=2020 days=001 hour=12 min=30 dst=0 ut1=-300 ly=1 ls=0
"both",
"2021-12-6 3:40",
)
self.assertModuleJson(
[
{
"year": 2021,
"days": 340,
"hour": 3,
"minute": 40,
"amplitude": "210000000200000001120011001002000000010200010001020001000002",
},
{
"year": 2021,
"days": 340,
"hour": 3,
"minute": 41,
"amplitude": "210000001200000001120011001002000000010200010001020001000002",
},
],
self.assertModuleOutput(
"""\
[{"year": 2021, "days": 340, "hour": 3, "minute": 40, "amplitude": "210000000200000001120011001002000000010200010001020001000002"}, {"year": 2021, "days": 340, "hour": 3, "minute": 41, "amplitude": "210000001200000001120011001002000000010200010001020001000002"}]
""",
"wwvb.gen",
"-m",
"2",
@ -229,23 +178,10 @@ WWVB timecode: year=2020 days=001 hour=12 min=30 dst=0 ut1=-300 ly=1 ls=0
"amplitude",
"2021-12-6 3:40",
)
self.assertModuleJson(
[
{
"year": 2021,
"days": 340,
"hour": 3,
"minute": 40,
"phase": "111110011011010101000100100110011110001110111010111101001011",
},
{
"year": 2021,
"days": 340,
"hour": 3,
"minute": 41,
"phase": "001010011100100011000101110000100001101000001111101100000010",
},
],
self.assertModuleOutput(
"""\
[{"year": 2021, "days": 340, "hour": 3, "minute": 40, "phase": "111110011011010101000100100110011110001110111010111101001011"}, {"year": 2021, "days": 340, "hour": 3, "minute": 41, "phase": "001010011100100011000101110000100001101000001111101100000010"}]
""",
"wwvb.gen",
"-m",
"2",
@ -261,11 +197,9 @@ WWVB timecode: year=2020 days=001 hour=12 min=30 dst=0 ut1=-300 ly=1 ls=0
self.assertModuleOutput(
"""\
WWVB timecode: year=2021 days=340 hour=03 min=40 dst=0 ut1=-100 ly=0 ls=0 --style=sextant
2021-340 03:40 \
🬋🬩🬋🬹🬩🬹🬩🬹🬩🬹🬍🬎🬍🬎🬩🬹🬩🬹🬋🬍🬩🬹🬩🬹🬍🬎🬩🬹🬍🬎🬩🬹🬍🬎🬋🬹🬋🬎🬋🬍🬍🬎🬩🬹🬋🬎🬋🬎🬩🬹🬍🬎🬋🬎🬩🬹🬩🬹🬋🬍🬍🬎🬩🬹🬩🬹🬩🬹🬩🬹🬍🬎🬍🬎🬋🬎🬩🬹🬋🬩🬩🬹🬍🬎🬩🬹🬋🬹🬩🬹🬍🬎🬩🬹🬋🬎🬩🬹🬋🬩🬩🬹🬩🬹🬍🬎🬋🬹🬍🬎🬍🬎🬩🬹🬍🬎🬩🬹🬋🬩
2021-340 03:40 🬋🬩🬋🬹🬩🬹🬩🬹🬩🬹🬍🬎🬍🬎🬩🬹🬩🬹🬋🬍🬩🬹🬩🬹🬍🬎🬩🬹🬍🬎🬩🬹🬍🬎🬋🬹🬋🬎🬋🬍🬍🬎🬩🬹🬋🬎🬋🬎🬩🬹🬍🬎🬋🬎🬩🬹🬩🬹🬋🬍🬍🬎🬩🬹🬩🬹🬩🬹🬩🬹🬍🬎🬍🬎🬋🬎🬩🬹🬋🬩🬩🬹🬍🬎🬩🬹🬋🬹🬩🬹🬍🬎🬩🬹🬋🬎🬩🬹🬋🬩🬩🬹🬩🬹🬍🬎🬋🬹🬍🬎🬍🬎🬩🬹🬍🬎🬩🬹🬋🬩
2021-340 03:41 \
🬋🬍🬋🬎🬩🬹🬍🬎🬩🬹🬍🬎🬍🬎🬩🬹🬋🬹🬋🬩🬍🬎🬍🬎🬩🬹🬍🬎🬍🬎🬍🬎🬩🬹🬋🬹🬋🬎🬋🬍🬍🬎🬩🬹🬋🬎🬋🬹🬩🬹🬩🬹🬋🬎🬍🬎🬍🬎🬋🬍🬩🬹🬍🬎🬍🬎🬍🬎🬍🬎🬩🬹🬩🬹🬋🬎🬩🬹🬋🬍🬍🬎🬍🬎🬍🬎🬋🬎🬩🬹🬩🬹🬩🬹🬋🬹🬩🬹🬋🬍🬩🬹🬩🬹🬍🬎🬋🬎🬍🬎🬍🬎🬍🬎🬍🬎🬩🬹🬋🬍
2021-340 03:41 🬋🬍🬋🬎🬩🬹🬍🬎🬩🬹🬍🬎🬍🬎🬩🬹🬋🬹🬋🬩🬍🬎🬍🬎🬩🬹🬍🬎🬍🬎🬍🬎🬩🬹🬋🬹🬋🬎🬋🬍🬍🬎🬩🬹🬋🬎🬋🬹🬩🬹🬩🬹🬋🬎🬍🬎🬍🬎🬋🬍🬩🬹🬍🬎🬍🬎🬍🬎🬍🬎🬩🬹🬩🬹🬋🬎🬩🬹🬋🬍🬍🬎🬍🬎🬍🬎🬋🬎🬩🬹🬩🬹🬩🬹🬋🬹🬩🬹🬋🬍🬩🬹🬩🬹🬍🬎🬋🬎🬍🬎🬍🬎🬍🬎🬍🬎🬩🬹🬋🬍
""",
"wwvb.gen",
@ -276,15 +210,6 @@ WWVB timecode: year=2021 days=340 hour=03 min=40 dst=0 ut1=-100 ly=0 ls=0 --styl
"2021-12-6 3:40",
)
def test_now(self) -> None:
"""Test outputting timecodes for 'now'"""
self.assertModuleOutputStarts(
"WWVB timecode: year=",
"wwvb.gen",
"-m",
"1",
)
def test_decode(self) -> None:
"""Test the commandline decoder"""
self.assertModuleOutput(

View file

@ -1,7 +1,7 @@
#!/usr/bin/python3
"""Test of daylight saving time calculations"""
# SPDX-FileCopyrightText: 2021-2024 Jeff Epler
# SPDX-FileCopyrightText: 2021 Jeff Epler
#
# SPDX-License-Identifier: GPL-3.0-only
@ -19,7 +19,9 @@ class TestDaylight(unittest.TestCase):
"""Test that the onset of DST is the same in Mountain and WWVBMinute (which uses ls bits)"""
for h in [8, 9, 10]:
for dm in range(-1441, 1442):
d = datetime.datetime(2021, 3, 14, h, 0, tzinfo=datetime.timezone.utc) + datetime.timedelta(minutes=dm)
d = datetime.datetime(
2021, 3, 14, h, 0, tzinfo=datetime.timezone.utc
) + datetime.timedelta(minutes=dm)
m = wwvb.WWVBMinute.from_datetime(d)
self.assertEqual(
m.as_datetime_local().replace(tzinfo=Mountain),
@ -30,7 +32,9 @@ class TestDaylight(unittest.TestCase):
"""Test that the end of DST is the same in Mountain and WWVBMinute (which uses ls bits)"""
for h in [7, 8, 9]:
for dm in range(-1441, 1442):
d = datetime.datetime(2021, 11, 7, h, 0, tzinfo=datetime.timezone.utc) + datetime.timedelta(minutes=dm)
d = datetime.datetime(
2021, 11, 7, h, 0, tzinfo=datetime.timezone.utc
) + datetime.timedelta(minutes=dm)
m = wwvb.WWVBMinute.from_datetime(d)
self.assertEqual(
m.as_datetime_local().replace(tzinfo=Mountain),
@ -41,7 +45,9 @@ class TestDaylight(unittest.TestCase):
"""Test that middle of DST is the same in Mountain and WWVBMinute (which uses ls bits)"""
for h in [7, 8, 9]:
for dm in (-1, 0, 1):
d = datetime.datetime(2021, 7, 7, h, 0, tzinfo=datetime.timezone.utc) + datetime.timedelta(minutes=dm)
d = datetime.datetime(
2021, 7, 7, h, 0, tzinfo=datetime.timezone.utc
) + datetime.timedelta(minutes=dm)
m = wwvb.WWVBMinute.from_datetime(d)
self.assertEqual(
m.as_datetime_local().replace(tzinfo=Mountain),
@ -52,7 +58,9 @@ class TestDaylight(unittest.TestCase):
"""Test that middle of standard time is the same in Mountain and WWVBMinute (which uses ls bits)"""
for h in [7, 8, 9]:
for dm in (-1, 0, 1):
d = datetime.datetime(2021, 12, 25, h, 0, tzinfo=datetime.timezone.utc) + datetime.timedelta(minutes=dm)
d = datetime.datetime(
2021, 12, 25, h, 0, tzinfo=datetime.timezone.utc
) + datetime.timedelta(minutes=dm)
m = wwvb.WWVBMinute.from_datetime(d)
self.assertEqual(
m.as_datetime_local().replace(tzinfo=Mountain),

80
src/wwvb/testls.py Executable file
View file

@ -0,0 +1,80 @@
#!/usr/bin/python3
"""Leap seconds tests"""
# SPDX-FileCopyrightText: 2021 Jeff Epler
#
# SPDX-License-Identifier: GPL-3.0-only
import datetime
import unittest
import wwvb
from . import iersdata
ONE_DAY = datetime.timedelta(days=1)
def end_of_month(d: datetime.date) -> datetime.date:
"""Return the end of the month containing the day 'd'"""
d = d.replace(day=28)
while True:
d0 = d
d = d + ONE_DAY
if d.month != d0.month:
return d0
class TestLeapSecond(unittest.TestCase):
"""Leap second tests"""
def test_leap(self) -> None:
"""Tests that the expected leap seconds all occur."""
d = iersdata.DUT1_DATA_START
e = datetime.date(2022, 1, 1)
leap = []
while d < e:
eom = end_of_month(d)
nm = eom + ONE_DAY
if wwvb.isls(d):
month_ends_dut1 = wwvb.get_dut1(eom)
month_starts_dut1 = wwvb.get_dut1(nm)
self.assertLess(month_ends_dut1, 0)
self.assertGreater(month_starts_dut1, 0)
leap.append(d.strftime("%b %Y"))
d = nm
self.assertEqual(
leap,
[
"Jun 1972",
"Dec 1973",
"Dec 1974",
"Dec 1975",
"Dec 1976",
"Dec 1977",
"Dec 1978",
"Dec 1979",
"Jun 1981",
"Jun 1982",
"Jun 1983",
"Jun 1985",
"Dec 1987",
"Dec 1989",
"Dec 1990",
"Jun 1992",
"Jun 1993",
"Jun 1994",
"Dec 1995",
"Jun 1997",
"Dec 1998",
"Dec 2005",
"Dec 2008",
"Jun 2012",
"Jun 2015",
"Dec 2016",
],
)
if __name__ == "__main__": # pragma: no cover
unittest.main()

View file

@ -1,7 +1,7 @@
#!/usr/bin/python3
"""Test Phase Modulation Signal"""
# SPDX-FileCopyrightText: 2021-2024 Jeff Epler
# SPDX-FileCopyrightText: 2021 Jeff Epler
#
# SPDX-License-Identifier: GPL-3.0-only
@ -15,9 +15,23 @@ class TestPhaseModulation(unittest.TestCase):
def test_pm(self) -> None:
"""Compare the generated signal from a reference minute in NIST docs"""
ref_am = "201100000200010011120001010002011000101201000000120010010112"
ref_am = (
"2011000002"
"0001001112"
"0001010002"
"0110001012"
"0100000012"
"0010010112"
)
ref_pm = "001110110100010010000011001000011000110100110100010110110110"
ref_pm = (
"0011101101"
"0001001000"
"0011001000"
"0110001101"
"0011010001"
"0110110110"
)
ref_minute = wwvb.WWVBMinuteIERS(2012, 186, 17, 30, dst=3)
ref_time = ref_minute.as_timecode()
@ -27,3 +41,7 @@ class TestPhaseModulation(unittest.TestCase):
self.assertEqual(ref_am, test_am)
self.assertEqual(ref_pm, test_pm)
if __name__ == "__main__": # pragma: no cover
unittest.main()

View file

@ -1,15 +1,13 @@
#!/usr/bin/python3
"""Test of uwwvb.py"""
# SPDX-FileCopyrightText: 2021-2024 Jeff Epler
# SPDX-FileCopyrightText: 2021 Jeff Epler
#
# SPDX-License-Identifier: GPL-3.0-only
# ruff: noqa: N802
import datetime
import random
import sys
import unittest
import zoneinfo
from typing import Union
import adafruit_datetime
@ -23,11 +21,10 @@ EitherDatetimeOrNone = Union[None, datetime.datetime, adafruit_datetime.datetime
class WWVBRoundtrip(unittest.TestCase):
"""tests of uwwvb.py"""
def assertDateTimeEqualExceptTzInfo(self, a: EitherDatetimeOrNone, b: EitherDatetimeOrNone) -> None:
"""Test two datetime objects for equality
This equality test excludes tzinfo, and allows adafruit_datetime and core datetime modules to compare equal
"""
def assertDateTimeEqualExceptTzInfo( # pylint: disable=invalid-name
self, a: EitherDatetimeOrNone, b: EitherDatetimeOrNone
) -> None:
"""Test two datetime objects for equality, excluding tzinfo, and allowing adafruit_datetime and core datetime modules to compare equal"""
assert a
assert b
self.assertEqual(
@ -37,17 +34,17 @@ class WWVBRoundtrip(unittest.TestCase):
def test_decode(self) -> None:
"""Test decoding of some minutes including a leap second.
Each minute must decode and match the primary decoder.
"""
minute = wwvb.WWVBMinuteIERS.from_datetime(datetime.datetime(2012, 6, 30, 23, 50, tzinfo=datetime.timezone.utc))
Each minute must decode and match the primary decoder."""
minute = wwvb.WWVBMinuteIERS.from_datetime(
datetime.datetime(2012, 6, 30, 23, 50)
)
assert minute
decoder = uwwvb.WWVBDecoder()
decoder.update(uwwvb.MARK)
any_leap_second = False
for _ in range(20):
timecode = minute.as_timecode()
decoded: uwwvb.WWVBMinute | None = None
decoded = None
if len(timecode.am) == 61:
any_leap_second = True
for code in timecode.am:
@ -62,37 +59,43 @@ class WWVBRoundtrip(unittest.TestCase):
def test_roundtrip(self) -> None:
"""Test that some big range of times all decode the same as the primary decoder"""
dt = datetime.datetime(2002, 1, 1, 0, 0, tzinfo=datetime.timezone.utc)
delta = datetime.timedelta(minutes=7182 if sys.implementation.name == "cpython" else 86400 - 7182)
dt = datetime.datetime(2002, 1, 1, 0, 0)
delta = datetime.timedelta(
minutes=7182 if sys.implementation.name == "cpython" else 86400 - 7182
)
while dt.year < 2013:
minute = wwvb.WWVBMinuteIERS.from_datetime(dt)
assert minute
decoded = uwwvb.decode_wwvb([int(i) for i in minute.as_timecode().am])
assert decoded
self.assertDateTimeEqualExceptTzInfo(minute.as_datetime_utc(), uwwvb.as_datetime_utc(decoded))
self.assertDateTimeEqualExceptTzInfo(
minute.as_datetime_utc(), uwwvb.as_datetime_utc(decoded)
)
dt = dt + delta
def test_dst(self) -> None:
"""Test of DST as handled by the small decoder"""
for dt in (
datetime.datetime(2021, 3, 14, 8, 59, tzinfo=datetime.timezone.utc),
datetime.datetime(2021, 3, 14, 9, 00, tzinfo=datetime.timezone.utc),
datetime.datetime(2021, 3, 14, 9, 1, tzinfo=datetime.timezone.utc),
datetime.datetime(2021, 3, 15, 8, 59, tzinfo=datetime.timezone.utc),
datetime.datetime(2021, 3, 15, 9, 00, tzinfo=datetime.timezone.utc),
datetime.datetime(2021, 3, 15, 9, 1, tzinfo=datetime.timezone.utc),
datetime.datetime(2021, 11, 7, 8, 59, tzinfo=datetime.timezone.utc),
datetime.datetime(2021, 11, 7, 9, 00, tzinfo=datetime.timezone.utc),
datetime.datetime(2021, 11, 7, 9, 1, tzinfo=datetime.timezone.utc),
datetime.datetime(2021, 11, 8, 8, 59, tzinfo=datetime.timezone.utc),
datetime.datetime(2021, 11, 8, 9, 00, tzinfo=datetime.timezone.utc),
datetime.datetime(2021, 11, 8, 9, 1, tzinfo=datetime.timezone.utc),
datetime.datetime(2021, 7, 7, 9, 1, tzinfo=datetime.timezone.utc),
datetime.datetime(2021, 3, 14, 8, 59),
datetime.datetime(2021, 3, 14, 9, 00),
datetime.datetime(2021, 3, 14, 9, 1),
datetime.datetime(2021, 3, 15, 8, 59),
datetime.datetime(2021, 3, 15, 9, 00),
datetime.datetime(2021, 3, 15, 9, 1),
datetime.datetime(2021, 11, 7, 8, 59),
datetime.datetime(2021, 11, 7, 9, 00),
datetime.datetime(2021, 11, 7, 9, 1),
datetime.datetime(2021, 11, 8, 8, 59),
datetime.datetime(2021, 11, 8, 9, 00),
datetime.datetime(2021, 11, 8, 9, 1),
datetime.datetime(2021, 7, 7, 9, 1),
):
minute = wwvb.WWVBMinuteIERS.from_datetime(dt)
decoded = uwwvb.decode_wwvb([int(i) for i in minute.as_timecode().am])
assert decoded
self.assertDateTimeEqualExceptTzInfo(minute.as_datetime_local(), uwwvb.as_datetime_local(decoded))
self.assertDateTimeEqualExceptTzInfo(
minute.as_datetime_local(), uwwvb.as_datetime_local(decoded)
)
decoded = uwwvb.decode_wwvb([int(i) for i in minute.as_timecode().am])
assert decoded
@ -104,7 +107,7 @@ class WWVBRoundtrip(unittest.TestCase):
def test_noise(self) -> None:
"""Test of the state-machine decoder when faced with pseudorandom noise"""
minute = wwvb.WWVBMinuteIERS.from_datetime(
datetime.datetime(2012, 6, 30, 23, 50, tzinfo=datetime.timezone.utc),
datetime.datetime(2012, 6, 30, 23, 50)
)
r = random.Random(408)
junk = [
@ -113,12 +116,12 @@ class WWVBRoundtrip(unittest.TestCase):
wwvb.AmplitudeModulation.MARK,
wwvb.AmplitudeModulation.ONE,
wwvb.AmplitudeModulation.ZERO,
],
]
)
for _ in range(480)
]
timecode = minute.as_timecode()
test_input = [*junk, wwvb.AmplitudeModulation.MARK, *timecode.am]
test_input = junk + [wwvb.AmplitudeModulation.MARK] + timecode.am
decoder = uwwvb.WWVBDecoder()
for code in test_input[:-1]:
decoded = decoder.update(code)
@ -139,7 +142,7 @@ class WWVBRoundtrip(unittest.TestCase):
def test_noise2(self) -> None:
"""Test of the full minute decoder with targeted errors to get full coverage"""
minute = wwvb.WWVBMinuteIERS.from_datetime(
datetime.datetime(2012, 6, 30, 23, 50, tzinfo=datetime.timezone.utc),
datetime.datetime(2012, 6, 30, 23, 50)
)
timecode = minute.as_timecode()
decoded = uwwvb.decode_wwvb([int(i) for i in timecode.am])
@ -176,7 +179,7 @@ class WWVBRoundtrip(unittest.TestCase):
def test_noise3(self) -> None:
"""Test impossible BCD values"""
minute = wwvb.WWVBMinuteIERS.from_datetime(
datetime.datetime(2012, 6, 30, 23, 50, tzinfo=datetime.timezone.utc),
datetime.datetime(2012, 6, 30, 23, 50)
)
timecode = minute.as_timecode()
@ -202,16 +205,18 @@ class WWVBRoundtrip(unittest.TestCase):
self.assertEqual(str(uwwvb.WWVBDecoder()), "<WWVBDecoder 1 []>")
def test_near_year_bug(self) -> None:
"""Test for a bug seen in another WWVB implementaiton
.. in which the hours after UTC midnight on 12-31 of a leap year would
be shown incorrectly. Check that we don't have that bug.
"""
minute = wwvb.WWVBMinuteIERS.from_datetime(datetime.datetime(2021, 1, 1, 0, 0, tzinfo=datetime.timezone.utc))
"""Chris's WWVB software had a bug where the hours after UTC
midnight on 12-31 of a leap year would be shown incorrectly. Check that we
don't have that bug."""
minute = wwvb.WWVBMinuteIERS.from_datetime(datetime.datetime(2021, 1, 1, 0, 0))
timecode = minute.as_timecode()
decoded = uwwvb.decode_wwvb([int(i) for i in timecode.am])
assert decoded
self.assertDateTimeEqualExceptTzInfo(
datetime.datetime(2020, 12, 31, 17, 00, tzinfo=zoneinfo.ZoneInfo("America/Denver")), # Mountain time!
datetime.datetime(2020, 12, 31, 17, 00), # Mountain time!
uwwvb.as_datetime_local(decoded),
)
if __name__ == "__main__": # pragma no cover
unittest.main()

View file

@ -1,24 +1,24 @@
#!/usr/bin/python3
"""Test most wwvblib functionality"""
# SPDX-FileCopyrightText: 2011-2024 Jeff Epler
# Copyright (C) 2011-2020 Jeff Epler <jepler@gmail.com>
# SPDX-FileCopyrightText: 2021 Jeff Epler
#
# SPDX-License-Identifier: GPL-3.0-only
from __future__ import annotations
import copy
import datetime
import glob
import io
import pathlib
import random
import sys
import unittest
from typing import Optional
import uwwvb
import wwvb
from wwvb import WWVBChannel, decode, iersdata, tz
from . import decode, iersdata, tz
class WWVBMinute2k(wwvb.WWVBMinute):
@ -27,16 +27,18 @@ class WWVBMinute2k(wwvb.WWVBMinute):
epoch = 2000
# pylint: disable=too-many-locals
class WWVBTestCase(unittest.TestCase):
"""Test each expected output in wwvbgen_testcases/. Some outputs are from another program, some are from us"""
"""Test each expected output in tests/. Some outputs are from another program, some are from us"""
maxDiff = 131072
def test_cases(self) -> None:
"""Generate a test case for each expected output in tests/"""
for test in ((pathlib.Path(__file__).parent) / "wwvbgen_testcases").glob("*"):
for test in glob.glob("tests/*"):
with self.subTest(test=test):
text = test.read_text(encoding="utf-8")
with open(test, "rt", encoding="utf-8") as f:
text = f.read()
lines = [line for line in text.split("\n") if not line.startswith("#")]
while not lines[0]:
del lines[0]
@ -44,19 +46,15 @@ class WWVBTestCase(unittest.TestCase):
header = lines[0].split()
timestamp = " ".join(header[:10])
options = header[10:]
channel: WWVBChannel = "amplitude"
channel = "amplitude"
style = "default"
for o in options:
if o == "--channel=both":
channel = "both"
elif o == "--channel=amplitude":
channel = "amplitude"
elif o == "--channel=phase":
channel = "phase"
if o.startswith("--channel="):
channel = o[10:]
elif o.startswith("--style="):
style = o[8:]
else:
raise ValueError(f"Unknown option {o!r}")
else: # pragma: no cover
raise ValueError(f"Unknown option {repr(o)}")
num_minutes = len(lines) - 2
if channel == "both":
num_minutes = len(lines) // 3
@ -87,14 +85,16 @@ class WWVBRoundtrip(unittest.TestCase):
def test_decode(self) -> None:
"""Test that a range of minutes including a leap second are correctly decoded by the state-based decoder"""
minute = wwvb.WWVBMinuteIERS.from_datetime(datetime.datetime(1992, 6, 30, 23, 50, tzinfo=datetime.timezone.utc))
minute = wwvb.WWVBMinuteIERS.from_datetime(
datetime.datetime(1992, 6, 30, 23, 50)
)
decoder = decode.wwvbreceive()
next(decoder)
decoder.send(wwvb.AmplitudeModulation.MARK)
any_leap_second = False
for _ in range(20):
timecode = minute.as_timecode()
decoded: wwvb.WWVBTimecode | None = None
decoded: Optional[wwvb.WWVBTimecode] = None
if len(timecode.am) == 61:
any_leap_second = True
for code in timecode.am:
@ -108,31 +108,37 @@ class WWVBRoundtrip(unittest.TestCase):
minute = minute.next_minute()
self.assertTrue(any_leap_second)
def test_cover_fill_pm_timecode_extended(self) -> None:
def test_cover_fill_pm_timecode_extended( # pylint: disable=no-self-use
self,
) -> None:
"""Get full coverage of the function pm_timecode_extended"""
for dt in (
datetime.datetime(1992, 1, 1, tzinfo=datetime.timezone.utc),
datetime.datetime(1992, 4, 5, tzinfo=datetime.timezone.utc),
datetime.datetime(1992, 6, 1, tzinfo=datetime.timezone.utc),
datetime.datetime(1992, 10, 25, tzinfo=datetime.timezone.utc),
datetime.datetime(1992, 1, 1),
datetime.datetime(1992, 4, 5),
datetime.datetime(1992, 6, 1),
datetime.datetime(1992, 10, 25),
):
for hour in (0, 4, 11):
dt1 = dt.replace(hour=hour, minute=10)
minute = wwvb.WWVBMinuteIERS.from_datetime(dt1)
dt = dt.replace(hour=hour, minute=10)
minute = wwvb.WWVBMinuteIERS.from_datetime(dt)
assert minute is not None
timecode = minute.as_timecode().am
assert timecode
def test_roundtrip(self) -> None:
"""Test that a wide of minutes are correctly decoded by the state-based decoder"""
dt = datetime.datetime(1992, 1, 1, 0, 0, tzinfo=datetime.timezone.utc)
delta = datetime.timedelta(minutes=915 if sys.implementation.name == "cpython" else 86400 - 915)
dt = datetime.datetime(1992, 1, 1, 0, 0)
delta = datetime.timedelta(
minutes=915 if sys.implementation.name == "cpython" else 86400 - 915
)
while dt.year < 1993:
minute = wwvb.WWVBMinuteIERS.from_datetime(dt)
assert minute is not None
timecode = minute.as_timecode().am
assert timecode
decoded_minute: wwvb.WWVBMinute | None = wwvb.WWVBMinuteIERS.from_timecode_am(minute.as_timecode())
decoded_minute: Optional[
wwvb.WWVBMinute
] = wwvb.WWVBMinuteIERS.from_timecode_am(minute.as_timecode())
assert decoded_minute
decoded = decoded_minute.as_timecode().am
self.assertEqual(
@ -144,7 +150,9 @@ class WWVBRoundtrip(unittest.TestCase):
def test_noise(self) -> None:
"""Test against pseudorandom noise"""
minute = wwvb.WWVBMinuteIERS.from_datetime(datetime.datetime(1992, 6, 30, 23, 50, tzinfo=datetime.timezone.utc))
minute = wwvb.WWVBMinuteIERS.from_datetime(
datetime.datetime(1992, 6, 30, 23, 50)
)
r = random.Random(408)
junk = [
r.choice(
@ -152,12 +160,12 @@ class WWVBRoundtrip(unittest.TestCase):
wwvb.AmplitudeModulation.MARK,
wwvb.AmplitudeModulation.ONE,
wwvb.AmplitudeModulation.ZERO,
],
]
)
for _ in range(480)
]
timecode = minute.as_timecode()
test_input = [*junk, wwvb.AmplitudeModulation.MARK, *timecode.am]
test_input = junk + [wwvb.AmplitudeModulation.MARK] + timecode.am
decoder = decode.wwvbreceive()
next(decoder)
for code in test_input[:-1]:
@ -174,7 +182,9 @@ class WWVBRoundtrip(unittest.TestCase):
def test_noise2(self) -> None:
"""Test of the full minute decoder with targeted errors to get full coverage"""
minute = wwvb.WWVBMinuteIERS.from_datetime(datetime.datetime(2012, 6, 30, 23, 50, tzinfo=datetime.timezone.utc))
minute = wwvb.WWVBMinuteIERS.from_datetime(
datetime.datetime(2012, 6, 30, 23, 50)
)
timecode = minute.as_timecode()
decoded = wwvb.WWVBMinute.from_timecode_am(timecode)
self.assertIsNotNone(decoded)
@ -209,7 +219,9 @@ class WWVBRoundtrip(unittest.TestCase):
def test_noise3(self) -> None:
"""Test impossible BCD values"""
minute = wwvb.WWVBMinuteIERS.from_datetime(datetime.datetime(2012, 6, 30, 23, 50, tzinfo=datetime.timezone.utc))
minute = wwvb.WWVBMinuteIERS.from_datetime(
datetime.datetime(2012, 6, 30, 23, 50)
)
timecode = minute.as_timecode()
for poslist in [
@ -231,12 +243,16 @@ class WWVBRoundtrip(unittest.TestCase):
def test_previous_next_minute(self) -> None:
"""Test that previous minute and next minute are inverses"""
minute = wwvb.WWVBMinuteIERS.from_datetime(datetime.datetime(1992, 6, 30, 23, 50, tzinfo=datetime.timezone.utc))
minute = wwvb.WWVBMinuteIERS.from_datetime(
datetime.datetime(1992, 6, 30, 23, 50)
)
self.assertEqual(minute, minute.next_minute().previous_minute())
def test_timecode_str(self) -> None:
"""Test the str() and repr() methods"""
minute = wwvb.WWVBMinuteIERS.from_datetime(datetime.datetime(1992, 6, 30, 23, 50, tzinfo=datetime.timezone.utc))
minute = wwvb.WWVBMinuteIERS.from_datetime(
datetime.datetime(1992, 6, 30, 23, 50)
)
timecode = minute.as_timecode()
self.assertEqual(
str(timecode),
@ -254,14 +270,13 @@ class WWVBRoundtrip(unittest.TestCase):
sm1 = s - datetime.timedelta(days=1)
self.assertEqual(wwvb.get_dut1(s), wwvb.get_dut1(sm1))
e = iersdata.DUT1_DATA_START + datetime.timedelta(days=len(iersdata.DUT1_OFFSETS) - 1)
e = iersdata.DUT1_DATA_START + datetime.timedelta(
days=len(iersdata.DUT1_OFFSETS) - 1
)
ep1 = e + datetime.timedelta(days=1)
self.assertEqual(wwvb.get_dut1(e), wwvb.get_dut1(ep1))
ep2 = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(days=340)
wwvb.get_dut1(ep2)
def test_epoch(self) -> None:
"""Test the 1970-to-2069 epoch"""
m = wwvb.WWVBMinute(69, 1, 1, 0, 0)
@ -274,17 +289,24 @@ class WWVBRoundtrip(unittest.TestCase):
def test_fromstring(self) -> None:
"""Test the fromstring() classmethod"""
s = "WWVB timecode: year=1998 days=365 hour=23 min=56 dst=0 ut1=-300 ly=0 ls=1"
t = "year=1998 days=365 hour=23 min=56 dst=0 ut1=-300 ly=0 ls=1"
self.assertEqual(wwvb.WWVBMinuteIERS.fromstring(s), wwvb.WWVBMinuteIERS.fromstring(t))
self.assertEqual(
wwvb.WWVBMinuteIERS.fromstring(s), wwvb.WWVBMinuteIERS.fromstring(t)
)
t = "year=1998 days=365 hour=23 min=56 dst=0 ut1=-300 ls=1"
self.assertEqual(wwvb.WWVBMinuteIERS.fromstring(s), wwvb.WWVBMinuteIERS.fromstring(t))
self.assertEqual(
wwvb.WWVBMinuteIERS.fromstring(s), wwvb.WWVBMinuteIERS.fromstring(t)
)
t = "year=1998 days=365 hour=23 min=56 dst=0"
self.assertEqual(wwvb.WWVBMinuteIERS.fromstring(s), wwvb.WWVBMinuteIERS.fromstring(t))
self.assertEqual(
wwvb.WWVBMinuteIERS.fromstring(s), wwvb.WWVBMinuteIERS.fromstring(t)
)
def test_from_datetime(self) -> None:
"""Test the from_datetime() classmethod"""
d = datetime.datetime(1998, 12, 31, 23, 56, 0, tzinfo=datetime.timezone.utc)
d = datetime.datetime(1998, 12, 31, 23, 56, 0)
self.assertEqual(
wwvb.WWVBMinuteIERS.from_datetime(d),
wwvb.WWVBMinuteIERS.from_datetime(d, newls=True, newut1=-300),
@ -302,18 +324,19 @@ class WWVBRoundtrip(unittest.TestCase):
wwvb.WWVBMinute(2021, 1, 1, 1, ls=False)
with self.assertRaises(ValueError):
wwvb.WWVBMinute.fromstring("year=1998 days=365 hour=23 min=56 dst=0 ut1=-300 ly=0 ls=1 boo=1")
wwvb.WWVBMinute.fromstring(
"year=1998 days=365 hour=23 min=56 dst=0 ut1=-300 ly=0 ls=1 boo=1"
)
def test_deprecated(self) -> None:
"""Ensure that the 'maybe_warn_update' function is covered"""
with self.assertWarnsRegex(DeprecationWarning, "use ly"):
wwvb.WWVBMinute(2020, 1, 1, 1).is_ly()
def test_update(self) -> None:
"""Ensure that the 'maybe_warn_update' function is covered"""
with self.assertWarnsRegex(Warning, "updateiers"):
wwvb._maybe_warn_update(datetime.date(1970, 1, 1))
wwvb._maybe_warn_update(datetime.datetime(1970, 1, 1, 0, 0, tzinfo=datetime.timezone.utc))
def test_deprecated_min(self) -> None:
"""Ensure that the 'maybe_warn_update' function is covered"""
with self.assertWarnsRegex(DeprecationWarning, "min property"):
self.assertEqual(wwvb.WWVBMinute(2021, 1, 1, 1).min, wwvb.WWVBMinute(2021, 1, 1, 1).minute)
def test_undefined(self) -> None:
"""Ensure that the check for unset elements in am works"""
@ -322,40 +345,37 @@ class WWVBRoundtrip(unittest.TestCase):
def test_tz(self) -> None:
"""Get a little more coverage in the dst change functions"""
date, row = wwvb._get_dst_change_date_and_row(datetime.datetime(1960, 1, 1, tzinfo=datetime.timezone.utc))
date, row = wwvb.get_dst_change_date_and_row(datetime.datetime(1960, 1, 1))
self.assertIsNone(date)
self.assertIsNone(row)
self.assertIsNone(wwvb._get_dst_change_hour(datetime.datetime(1960, 1, 1, tzinfo=datetime.timezone.utc)))
self.assertIsNone(wwvb.get_dst_change_hour(datetime.datetime(1960, 1, 1)))
self.assertEqual(wwvb._get_dst_next(datetime.datetime(1960, 1, 1, tzinfo=datetime.timezone.utc)), 0b000111)
self.assertEqual(wwvb.get_dst_next(datetime.datetime(1960, 1, 1)), 0b000111)
# Cuba followed year-round DST for several years
self.assertEqual(
wwvb._get_dst_next(datetime.datetime(2005, 1, 1, tzinfo=datetime.timezone.utc), tz=tz.ZoneInfo("Cuba")),
wwvb.get_dst_next(datetime.datetime(2005, 1, 1), tz=tz.ZoneInfo("Cuba")),
0b101111,
)
date, row = wwvb._get_dst_change_date_and_row(
datetime.datetime(2005, 1, 1, tzinfo=datetime.timezone.utc),
tz=tz.ZoneInfo("Cuba"),
date, row = wwvb.get_dst_change_date_and_row(
datetime.datetime(2005, 1, 1), tz=tz.ZoneInfo("Cuba")
)
self.assertIsNone(date)
self.assertIsNone(row)
# California was weird in 1948
self.assertEqual(
wwvb._get_dst_next(
datetime.datetime(1948, 1, 1, tzinfo=datetime.timezone.utc),
tz=tz.ZoneInfo("America/Los_Angeles"),
wwvb.get_dst_next(
datetime.datetime(1948, 1, 1), tz=tz.ZoneInfo("America/Los_Angeles")
),
0b100011,
)
# Berlin had DST changes on Monday in 1917
self.assertEqual(
wwvb._get_dst_next(
datetime.datetime(1917, 1, 1, tzinfo=datetime.timezone.utc),
tz=tz.ZoneInfo("Europe/Berlin"),
wwvb.get_dst_next(
datetime.datetime(1917, 1, 1), tz=tz.ZoneInfo("Europe/Berlin")
),
0b100011,
)
@ -364,9 +384,8 @@ class WWVBRoundtrip(unittest.TestCase):
# Australia observes DST in the other half of the year compared to the
# Northern hemisphere
self.assertEqual(
wwvb._get_dst_next(
datetime.datetime(2005, 1, 1, tzinfo=datetime.timezone.utc),
tz=tz.ZoneInfo("Australia/Melbourne"),
wwvb.get_dst_next(
datetime.datetime(2005, 1, 1), tz=tz.ZoneInfo("Australia/Melbourne")
),
0b100011,
)
@ -403,34 +422,6 @@ class WWVBRoundtrip(unittest.TestCase):
self.assertEqual(WWVBMinute2k(2070, 1, 1, 0, 0).year, 2070)
self.assertEqual(WWVBMinute2k(2099, 1, 1, 0, 0).year, 2099)
def test_invalid_minute(self) -> None:
"""Check that minute 61 is not valid in an AM timecode"""
base_minute = wwvb.WWVBMinute(2021, 1, 1, 0, 0)
minute = base_minute.as_timecode()
minute._put_am_bcd(61, 1, 2, 3, 5, 6, 7, 8) # valid BCD, invalid minute
decoded_minute = wwvb.WWVBMinute.from_timecode_am(minute)
assert decoded_minute is None
def test_invalid_hour(self) -> None:
"""Check that hour 25 is not valid in an AM timecode"""
base_minute = wwvb.WWVBMinute(2021, 1, 1, 0, 0)
minute = base_minute.as_timecode()
minute._put_am_bcd(29, 12, 13, 15, 16, 17, 18) # valid BCD, invalid hour
decoded_minute = wwvb.WWVBMinute.from_timecode_am(minute)
assert decoded_minute is None
def test_invalid_bcd_day(self) -> None:
"""Check that invalid BCD is detected in AM timecode"""
base_minute = wwvb.WWVBMinute(2021, 1, 1, 0, 0)
minute = base_minute.as_timecode()
minute.am[30:34] = [wwvb.AmplitudeModulation.ONE] * 4 # invalid BCD 0xf
decoded_minute = wwvb.WWVBMinute.from_timecode_am(minute)
assert decoded_minute is None
def test_invalid_mark(self) -> None:
"""Check that invalid presence of MARK in a data field is detected"""
base_minute = wwvb.WWVBMinute(2021, 1, 1, 0, 0)
minute = base_minute.as_timecode()
minute.am[57] = wwvb.AmplitudeModulation.MARK
decoded_minute = wwvb.WWVBMinute.from_timecode_am(minute)
assert decoded_minute is None
if __name__ == "__main__": # pragma no cover
unittest.main()

View file

@ -1,7 +1,8 @@
# -*- python -*-
"""A library for WWVB timecodes"""
# SPDX-FileCopyrightText: 2021-2024 Jeff Epler
# Copyright (C) 2011-2020 Jeff Epler <jepler@gmail.com>
# SPDX-FileCopyrightText: 2021 Jeff Epler
#
# SPDX-License-Identifier: GPL-3.0-only

View file

@ -1,165 +1,169 @@
#!/usr/bin/python3
# SPDX-FileCopyrightText: 2021-2024 Jeff Epler
# SPDX-FileCopyrightText: 2021 Jeff Epler
#
# SPDX-License-Identifier: GPL-3.0-only
"""Update the DUT1 and LS data based on online sources"""
from __future__ import annotations
import binascii
import csv
import datetime
import gzip
import io
import json
import itertools
import os
import pathlib
from typing import Callable
from typing import Callable, List, Optional
import bs4
import bulletind
import click
import platformdirs
import requests
DIST_PATH = pathlib.Path(__file__).parent / "iersdata.json"
DIST_PATH = str(pathlib.Path(__file__).parent / "iersdata_dist.py")
OLD_TABLE_START: Optional[datetime.date] = None
OLD_TABLE_END: Optional[datetime.date] = None
try:
import wwvb.iersdata_dist
OLD_TABLE_START = wwvb.iersdata_dist.DUT1_DATA_START
OLD_TABLE_END = OLD_TABLE_START + datetime.timedelta(
days=len(wwvb.iersdata_dist.DUT1_OFFSETS) - 1
)
except (ImportError, NameError) as e:
pass
IERS_URL = "https://datacenter.iers.org/data/csv/finals2000A.all.csv"
IERS_PATH = pathlib.Path("finals2000A.all.csv")
if IERS_PATH.exists():
IERS_URL = str(IERS_PATH)
print("using local", IERS_URL)
NIST_URL = "https://www.nist.gov/pml/time-and-frequency-division/atomic-standards/leap-second-and-ut1-utc-information"
def _get_text(url: str) -> str:
"""Get a local file or a http/https URL"""
if url.startswith("http"):
with requests.get(url, timeout=30) as response:
return response.text
else:
return pathlib.Path(url).read_text(encoding="utf-8")
def update_iersdata( # noqa: PLR0915
target_path: pathlib.Path,
def update_iersdata( # pylint: disable=too-many-locals, too-many-branches, too-many-statements
target_file: str,
) -> None:
"""Update iersdata.py"""
offsets: list[int] = []
iersdata_text = _get_text(IERS_URL)
table_start: datetime.date | None = None
for r in csv.DictReader(io.StringIO(iersdata_text), delimiter=";"):
jd = float(r["MJD"])
offs_str = r["UT1-UTC"]
if not offs_str:
break
offs = round(float(offs_str) * 10)
if not offsets:
table_start = datetime.date(1858, 11, 17) + datetime.timedelta(jd)
when = min(datetime.date(1972, 1, 1), table_start)
# iers bulletin A doesn't cover 1972, so fake data for those
# leap seconds
while when < datetime.date(1972, 7, 1):
offsets.append(-2)
when = when + datetime.timedelta(days=1)
while when < datetime.date(1972, 11, 1):
offsets.append(8)
when = when + datetime.timedelta(days=1)
while when < datetime.date(1972, 12, 1):
offsets.append(0)
when = when + datetime.timedelta(days=1)
while when < datetime.date(1973, 1, 1):
offsets.append(-2)
when = when + datetime.timedelta(days=1)
while when < table_start:
offsets.append(8)
when = when + datetime.timedelta(days=1)
table_start = min(datetime.date(1972, 1, 1), table_start)
offsets.append(offs)
assert table_start is not None
wwvb_text = _get_text(NIST_URL)
wwvb_data = bs4.BeautifulSoup(wwvb_text, features="html.parser")
wwvb_dut1_table = wwvb_data.findAll("table")[2]
assert wwvb_dut1_table
meta = wwvb_data.find("meta", property="article:modified_time")
assert isinstance(meta, bs4.Tag)
wwvb_data_stamp = datetime.datetime.fromisoformat(meta.attrs["content"]).replace(tzinfo=None).date()
offsets: List[int] = []
with requests.get(IERS_URL) as iers_data:
for r in csv.DictReader(io.StringIO(iers_data.text), delimiter=";"):
jd = float(r["MJD"])
offs_str = r["UT1-UTC"]
if not offs_str:
break
offs = int(round(float(offs_str) * 10))
if not offsets:
table_start = datetime.date(1858, 11, 17) + datetime.timedelta(jd)
# If the table starts after June 1, 1972 (it does), add the 1972 leap second
if table_start > datetime.date(1972, 6, 1):
when = datetime.date(1972, 6, 1)
while when < datetime.date(1972, 7, 1):
offsets.append(-2)
when = when + datetime.timedelta(days=1)
while when < table_start:
offsets.append(8)
when = when + datetime.timedelta(days=1)
table_start = datetime.date(1972, 6, 1)
offsets.append(offs)
def patch(patch_start: datetime.date, patch_end: datetime.date, val: int) -> None:
assert table_start is not None
off_start = (patch_start - table_start).days
off_end = (patch_end - table_start).days
offsets[off_start:off_end] = [val] * (off_end - off_start)
wwvb_dut1: int | None = None
wwvb_start: datetime.date | None = None
for row in wwvb_dut1_table.findAll("tr")[1:][::-1]:
cells = row.findAll("td")
when = datetime.datetime.strptime(cells[0].text + "+0000", "%Y-%m-%d%z").date()
dut1 = cells[2].text.replace("s", "").replace(" ", "")
dut1 = round(float(dut1) * 10)
wwvb_dut1: Optional[int] = None
wwvb_start: Optional[datetime.date] = None
for data in bulletind.get_cached_bulletin_d_data():
when = data.start_date
dut1 = round(10 * data.dut1)
if wwvb_dut1 is not None:
assert wwvb_start is not None
patch(wwvb_start, when, wwvb_dut1)
wwvb_dut1 = dut1
wwvb_start = when
# As of 2021-06-14, NIST website incorrectly indicates the offset of -600ms
# persisted through 2009-03-12, causing an incorrect leap second inference.
# Assume instead that NIST started broadcasting +400ms on January 1, 2009,
# causing the leap second to occur on 2008-12-31.
patch(datetime.date(2009, 1, 1), datetime.date(2009, 3, 12), 4)
# this is the final (most recent) Bulletin D DUT1 value specified. We
# might want to extend it some distance into the future, but how far? The
# textual Bulletin D says something like "Bulletin D 136 should be issued
# in August 2017" or "Bulletin D 143 should be issued in 2023" but this is
# not in the xml format and thus not in the data from the bulletind
# package. Historically, bulletins have even been published AFTER their
# effective date. As such, transition immediately back to the IERS Bulletin
# A data immediately after the last Bulletin D data and do _NOT_ patch any
# additonal days. This is in contrast to previous behavior, where the NIST
# data was propagated until the reported modification date of the scraped
# web page.
# this is the final (most recent) wwvb DUT1 value broadcast. We want to
# extend it some distance into the future, but how far? We will use the
# modified timestamp of the NIST data.
assert wwvb_dut1 is not None
assert wwvb_start is not None
patch(wwvb_start, wwvb_data_stamp + datetime.timedelta(days=1), wwvb_dut1)
with open(target_file, "wt", encoding="utf-8") as output:
def code(*args: str) -> None:
"""Print to the output file"""
print(*args, file=output)
code("# -*- python3 -*-")
code('"""File generated from public data - not subject to copyright"""')
code("# SPDX" "-FileCopyrightText: Public domain")
code("# SPDX" "-License-Identifier: CC0-1.0")
code("# fmt: off")
code("# isort: skip_file")
code("# pylint: disable=invalid-name")
code("import datetime")
code("__all__ = ['DUT1_DATA_START', 'DUT1_OFFSETS']")
code(f"DUT1_DATA_START = {repr(table_start)}")
c = sorted(chr(ord("a") + ch + 10) for ch in set(offsets))
code(f"{','.join(c)} = tuple({repr(''.join(c))})")
code(
f"DUT1_OFFSETS = str( # {table_start.year:04d}{table_start.month:02d}{table_start.day:02d}"
)
line = ""
j = 0
for val, it in itertools.groupby(offsets):
part = ""
ch = chr(ord("a") + val + 10)
sz = len(list(it))
if j:
part = part + "+"
if sz < 2:
part = part + ch
else:
part = part + f"{ch}*{sz}"
j += sz
if len(line + part) > 60:
d = table_start + datetime.timedelta(j - 1)
code(f" {line:<60s} # {d.year:04d}{d.month:02d}{d.day:02d}")
line = part
else:
line = line + part
d = table_start + datetime.timedelta(j - 1)
code(f" {line:<60s} # {d.year:04d}{d.month:02d}{d.day:02d}")
code(")")
table_end = table_start + datetime.timedelta(len(offsets) - 1)
base = ord("a") + 10
offsets_bin = bytes(base + ch for ch in offsets)
target_path.write_text(
json.dumps(
{
"START": table_start.isoformat(),
"OFFSETS_GZ": binascii.b2a_base64(gzip.compress(offsets_bin)).decode("ascii").strip(),
},
),
)
if OLD_TABLE_START:
print(f"old iersdata covered {OLD_TABLE_START} .. {OLD_TABLE_END}")
print(f"iersdata covers {table_start} .. {table_end}")
def iersdata_path(callback: Callable[[str, str], pathlib.Path]) -> pathlib.Path:
def iersdata_path(callback: Callable[[str, str], str]) -> str:
"""Find out the path for this directory"""
return callback("wwvbpy", "unpythonic.net") / "iersdata.json"
return os.path.join(callback("wwvbpy", "unpythonic.net"), "wwvb_iersdata.py")
@click.command()
@click.option(
"--user",
"location",
flag_value=iersdata_path(platformdirs.user_data_path),
default=iersdata_path(platformdirs.user_data_path),
type=pathlib.Path,
flag_value=iersdata_path(platformdirs.user_data_dir),
default=iersdata_path(platformdirs.user_data_dir),
)
@click.option("--dist", "location", flag_value=DIST_PATH)
@click.option("--site", "location", flag_value=iersdata_path(platformdirs.site_data_path))
@click.option(
"--site", "location", flag_value=iersdata_path(platformdirs.site_data_dir)
)
def main(location: str) -> None:
"""Update DUT1 data"""
path = pathlib.Path(location)
print(f"will write to {location!r}")
path.parent.mkdir(parents=True, exist_ok=True)
update_iersdata(path)
print("will write to", location)
os.makedirs(os.path.dirname(location), exist_ok=True)
update_iersdata(location)
if __name__ == "__main__":
main()
main() # pylint: disable=no-value-for-parameter

View file

@ -1,112 +1,66 @@
#!/usr/bin/python3
"""Visualize the WWVB signal in realtime"""
# SPDX-FileCopyrightText: 2021-2024 Jeff Epler
# Copyright (C) 2011-2020 Jeff Epler <jepler@gmail.com>
# SPDX-FileCopyrightText: 2021 Jeff Epler
#
# SPDX-License-Identifier: GPL-3.0-only
from __future__ import annotations
import datetime
import functools
from tkinter import Canvas, Event, TclError, Tk
import click
import threading
import time
from tkinter import Canvas, Tk # pylint: disable=import-error
from typing import Any, Generator, Tuple
import wwvb
TYPE_CHECKING = False
if TYPE_CHECKING:
from collections.abc import Generator
@functools.cache
def _app() -> Tk:
"""Create the Tk application object lazily"""
return Tk()
def validate_colors(ctx: click.Context, param: click.Parameter, value: str) -> list[str]: # noqa: ARG001
"""Check that all colors in a string are valid, splitting it to a list"""
app = _app()
colors = value.split()
if len(colors) not in (2, 3, 4, 6):
raise click.BadParameter(f"Give 2, 3, 4 or 6 colors (not {len(colors)})")
for c in colors:
try:
app.winfo_rgb(c)
except TclError as e:
raise click.BadParameter(f"Invalid color {c}") from e
if len(colors) == 2:
off, on = colors
return [off, off, off, on, on, on]
if len(colors) == 3:
return colors + colors
if len(colors) == 4:
off, c1, c2, c3 = colors
return [off, off, off, c1, c2, c3]
return colors
DEFAULT_COLORS = "#3c3c3c #3c3c3c #3c3c3c #cc3c3c #88883c #3ccc3c"
@click.command
@click.option(
"--colors",
callback=validate_colors,
default=DEFAULT_COLORS,
metavar="COLORS",
help="2, 3, 4, or 6 Tk color values",
)
@click.option("--size", default=48, help="initial size in pixels")
@click.option("--min-size", default=None, type=int, help="minimum size in pixels (default: same as initial size)")
def main(colors: list[str], size: int, min_size: int | None) -> None: # noqa: PLR0915
def main() -> None:
"""Visualize the WWVB signal in realtime"""
if min_size is None:
min_size = size
def deadline_ms(deadline: datetime.datetime) -> int:
"""Compute the number of ms until a deadline"""
now = datetime.datetime.now(datetime.timezone.utc)
return int(max(0.0, (deadline - now).total_seconds()) * 1000)
def sleep_deadline(deadline: float) -> None:
"""Sleep until a deadline"""
now = time.time()
if deadline > now:
time.sleep(deadline - now)
def wwvbtick() -> Generator[tuple[datetime.datetime, wwvb.AmplitudeModulation]]:
def wwvbtick() -> Generator[Tuple[float, wwvb.AmplitudeModulation], None, None]:
"""Yield consecutive values of the WWVB amplitude signal, going from minute to minute"""
timestamp = datetime.datetime.now(datetime.timezone.utc).replace(second=0, microsecond=0)
timestamp = time.time() // 60 * 60
while True:
timecode = wwvb.WWVBMinuteIERS.from_datetime(timestamp).as_timecode()
tt = time.gmtime(timestamp)
key = tt.tm_year, tt.tm_yday, tt.tm_hour, tt.tm_min
timecode = wwvb.WWVBMinuteIERS(*key).as_timecode()
for i, code in enumerate(timecode.am):
yield timestamp + datetime.timedelta(seconds=i), code
timestamp = timestamp + datetime.timedelta(seconds=60)
yield timestamp + i, code
timestamp = timestamp + 60
def wwvbsmarttick() -> Generator[tuple[datetime.datetime, wwvb.AmplitudeModulation]]:
"""Yield consecutive values of the WWVB amplitude signal
.. but deal with time progressing unexpectedly, such as when the
computer is suspended or NTP steps the clock backwards
def wwvbsmarttick() -> Generator[
Tuple[float, wwvb.AmplitudeModulation], None, None
]:
"""Yield consecutive values of the WWVB amplitude signal but deal with time
progressing unexpectedly, such as when the computer is suspended or NTP steps
the clock backwards
When time goes backwards or advances by more than a minute, get a fresh
wwvbtick object; otherwise, discard time signals more than 1s in the past.
"""
wwvbtick object; otherwise, discard time signals more than 1s in the past."""
while True:
for stamp, code in wwvbtick():
now = datetime.datetime.now(datetime.timezone.utc)
if stamp < now - datetime.timedelta(seconds=60):
now = time.time()
if stamp < now - 60:
break
if stamp < now - datetime.timedelta(seconds=1):
if stamp < now - 1:
continue
yield stamp, code
app = _app()
app.wm_minsize(min_size, min_size)
canvas = Canvas(app, width=size, height=size, highlightthickness=0)
circle = canvas.create_oval(4, 4, 44, 44, outline="black", fill=colors[0])
colors = ["#3c3c3c", "#cc3c3c", "#88883c", "#3ccc3c"]
app = Tk()
app.wm_minsize(48, 48)
canvas = Canvas(app, width=48, height=48, highlightthickness=0)
canvas.pack(fill="both", expand=True)
app.wm_deiconify()
circle = canvas.create_oval(4, 4, 44, 44, outline="black", fill=colors[0])
def resize_canvas(event: Event) -> None:
def resize_canvas(event: Any) -> None:
"""Keep the circle filling the window when it is resized"""
sz = min(event.width, event.height) - 8
if sz < 0:
@ -123,31 +77,24 @@ def main(colors: list[str], size: int, min_size: int | None) -> None: # noqa: P
def led_on(i: int) -> None:
"""Turn the canvas's virtual LED on"""
canvas.itemconfigure(circle, fill=colors[i + 3])
canvas.itemconfigure(circle, fill=colors[i + 1])
def led_off(i: int) -> None:
def led_off() -> None:
"""Turn the canvas's virtual LED off"""
canvas.itemconfigure(circle, fill=colors[i])
canvas.itemconfigure(circle, fill=colors[0])
def controller_func() -> Generator[int]:
"""Update the canvas virtual LED, yielding the number of ms until the next change"""
def thread_func() -> None:
"""Update the canvas virtual LED"""
for stamp, code in wwvbsmarttick():
yield deadline_ms(stamp)
sleep_deadline(stamp)
led_on(code)
app.update()
yield deadline_ms(stamp + datetime.timedelta(seconds=0.2 + 0.3 * int(code)))
led_off(code)
sleep_deadline(stamp + 0.2 + 0.3 * int(code))
led_off()
app.update()
controller = controller_func().__next__
# pyrefly: ignore # bad-assignment
def after_func() -> None:
"""Repeatedly run the controller after the desired interval"""
app.after(controller(), after_func)
# pyrefly: ignore # bad-argument-type
app.after_idle(after_func)
thread = threading.Thread(target=thread_func, daemon=True)
thread.start()
app.mainloop()

View file

@ -1,58 +0,0 @@
#!/usr/bin/python3
"""Leap seconds tests"""
# SPDX-FileCopyrightText: 2021-2024 Jeff Epler
#
# SPDX-License-Identifier: GPL-3.0-only
import datetime
import unittest
import leapseconddata
import wwvb
from wwvb import iersdata
ONE_DAY = datetime.timedelta(days=1)
def next_month(d: datetime.date) -> datetime.date:
"""Return the start of the next month after the day 'd'"""
d = d.replace(day=28)
while True:
d0 = d
d = d + ONE_DAY
if d.month != d0.month:
return d
class TestLeapSecond(unittest.TestCase):
"""Leap second tests"""
maxDiff = 9999
def test_leap(self) -> None:
"""Tests that the expected leap seconds all occur."""
ls = leapseconddata.LeapSecondData.from_standard_source()
assert ls.valid_until is not None
d = iersdata.start
e = min(iersdata.end, ls.valid_until)
bench = [ts.start for ts in ls.leap_seconds[1:]]
bench = [ts for ts in bench if d <= ts < e]
leap = []
while d < e:
nm = next_month(d)
eom = nm - ONE_DAY
month_ends_dut1 = wwvb.get_dut1(eom)
month_starts_dut1 = wwvb.get_dut1(nm)
our_is_ls = month_ends_dut1 * month_starts_dut1 < 0
if wwvb.isls(eom):
assert our_is_ls
self.assertLess(month_ends_dut1, 0)
self.assertGreater(month_starts_dut1, 0)
leap.append(nm)
else:
assert not our_is_ls
d = datetime.datetime.combine(nm, datetime.time(), tzinfo=datetime.timezone.utc)
self.assertEqual(leap, bench)

View file

@ -1,15 +0,0 @@
# SPDX-FileCopyrightText: 2022 Jeff Epler
#
# SPDX-License-Identifier: CC0-1.0
WWVB timecode: year=2021 days=313 hour=18 min=02 dst=0 ut1=-100 ly=0 ls=0 --style=bar
2021-313 18:02 🬋🬍🬍🬎🬍🬎🬍🬎🬍🬎🬍🬎🬍🬎🬋🬎🬍🬎🬋🬍🬍🬎🬍🬎🬍🬎🬋🬎🬍🬎🬋🬎🬍🬎🬍🬎🬍🬎🬋🬍🬍🬎🬍🬎🬋🬎🬋🬎🬍🬎🬍🬎🬍🬎🬍🬎🬋🬎🬋🬍🬍🬎🬍🬎🬋🬎🬋🬎🬍🬎🬍🬎🬍🬎🬋🬎🬍🬎🬋🬍🬍🬎🬍🬎🬍🬎🬋🬎🬍🬎🬍🬎🬍🬎🬋🬎🬍🬎🬋🬍🬍🬎🬍🬎🬍🬎🬋🬎🬍🬎🬍🬎🬍🬎🬍🬎🬍🬎🬋🬍
2021-313 18:03 🬋🬍🬍🬎🬍🬎🬍🬎🬍🬎🬍🬎🬍🬎🬋🬎🬋🬎🬋🬍🬍🬎🬍🬎🬍🬎🬋🬎🬍🬎🬋🬎🬍🬎🬍🬎🬍🬎🬋🬍🬍🬎🬍🬎🬋🬎🬋🬎🬍🬎🬍🬎🬍🬎🬍🬎🬋🬎🬋🬍🬍🬎🬍🬎🬋🬎🬋🬎🬍🬎🬍🬎🬍🬎🬋🬎🬍🬎🬋🬍🬍🬎🬍🬎🬍🬎🬋🬎🬍🬎🬍🬎🬍🬎🬋🬎🬍🬎🬋🬍🬍🬎🬍🬎🬍🬎🬋🬎🬍🬎🬍🬎🬍🬎🬍🬎🬍🬎🬋🬍
2021-313 18:04 🬋🬍🬍🬎🬍🬎🬍🬎🬍🬎🬍🬎🬋🬎🬍🬎🬍🬎🬋🬍🬍🬎🬍🬎🬍🬎🬋🬎🬍🬎🬋🬎🬍🬎🬍🬎🬍🬎🬋🬍🬍🬎🬍🬎🬋🬎🬋🬎🬍🬎🬍🬎🬍🬎🬍🬎🬋🬎🬋🬍🬍🬎🬍🬎🬋🬎🬋🬎🬍🬎🬍🬎🬍🬎🬋🬎🬍🬎🬋🬍🬍🬎🬍🬎🬍🬎🬋🬎🬍🬎🬍🬎🬍🬎🬋🬎🬍🬎🬋🬍🬍🬎🬍🬎🬍🬎🬋🬎🬍🬎🬍🬎🬍🬎🬍🬎🬍🬎🬋🬍
2021-313 18:05 🬋🬍🬍🬎🬍🬎🬍🬎🬍🬎🬍🬎🬋🬎🬍🬎🬋🬎🬋🬍🬍🬎🬍🬎🬍🬎🬋🬎🬍🬎🬋🬎🬍🬎🬍🬎🬍🬎🬋🬍🬍🬎🬍🬎🬋🬎🬋🬎🬍🬎🬍🬎🬍🬎🬍🬎🬋🬎🬋🬍🬍🬎🬍🬎🬋🬎🬋🬎🬍🬎🬍🬎🬍🬎🬋🬎🬍🬎🬋🬍🬍🬎🬍🬎🬍🬎🬋🬎🬍🬎🬍🬎🬍🬎🬋🬎🬍🬎🬋🬍🬍🬎🬍🬎🬍🬎🬋🬎🬍🬎🬍🬎🬍🬎🬍🬎🬍🬎🬋🬍
2021-313 18:06 🬋🬍🬍🬎🬍🬎🬍🬎🬍🬎🬍🬎🬋🬎🬋🬎🬍🬎🬋🬍🬍🬎🬍🬎🬍🬎🬋🬎🬍🬎🬋🬎🬍🬎🬍🬎🬍🬎🬋🬍🬍🬎🬍🬎🬋🬎🬋🬎🬍🬎🬍🬎🬍🬎🬍🬎🬋🬎🬋🬍🬍🬎🬍🬎🬋🬎🬋🬎🬍🬎🬍🬎🬍🬎🬋🬎🬍🬎🬋🬍🬍🬎🬍🬎🬍🬎🬋🬎🬍🬎🬍🬎🬍🬎🬋🬎🬍🬎🬋🬍🬍🬎🬍🬎🬍🬎🬋🬎🬍🬎🬍🬎🬍🬎🬍🬎🬍🬎🬋🬍
2021-313 18:07 🬋🬍🬍🬎🬍🬎🬍🬎🬍🬎🬍🬎🬋🬎🬋🬎🬋🬎🬋🬍🬍🬎🬍🬎🬍🬎🬋🬎🬍🬎🬋🬎🬍🬎🬍🬎🬍🬎🬋🬍🬍🬎🬍🬎🬋🬎🬋🬎🬍🬎🬍🬎🬍🬎🬍🬎🬋🬎🬋🬍🬍🬎🬍🬎🬋🬎🬋🬎🬍🬎🬍🬎🬍🬎🬋🬎🬍🬎🬋🬍🬍🬎🬍🬎🬍🬎🬋🬎🬍🬎🬍🬎🬍🬎🬋🬎🬍🬎🬋🬍🬍🬎🬍🬎🬍🬎🬋🬎🬍🬎🬍🬎🬍🬎🬍🬎🬍🬎🬋🬍
2021-313 18:08 🬋🬍🬍🬎🬍🬎🬍🬎🬍🬎🬋🬎🬍🬎🬍🬎🬍🬎🬋🬍🬍🬎🬍🬎🬍🬎🬋🬎🬍🬎🬋🬎🬍🬎🬍🬎🬍🬎🬋🬍🬍🬎🬍🬎🬋🬎🬋🬎🬍🬎🬍🬎🬍🬎🬍🬎🬋🬎🬋🬍🬍🬎🬍🬎🬋🬎🬋🬎🬍🬎🬍🬎🬍🬎🬋🬎🬍🬎🬋🬍🬍🬎🬍🬎🬍🬎🬋🬎🬍🬎🬍🬎🬍🬎🬋🬎🬍🬎🬋🬍🬍🬎🬍🬎🬍🬎🬋🬎🬍🬎🬍🬎🬍🬎🬍🬎🬍🬎🬋🬍
2021-313 18:09 🬋🬍🬍🬎🬍🬎🬍🬎🬍🬎🬋🬎🬍🬎🬍🬎🬋🬎🬋🬍🬍🬎🬍🬎🬍🬎🬋🬎🬍🬎🬋🬎🬍🬎🬍🬎🬍🬎🬋🬍🬍🬎🬍🬎🬋🬎🬋🬎🬍🬎🬍🬎🬍🬎🬍🬎🬋🬎🬋🬍🬍🬎🬍🬎🬋🬎🬋🬎🬍🬎🬍🬎🬍🬎🬋🬎🬍🬎🬋🬍🬍🬎🬍🬎🬍🬎🬋🬎🬍🬎🬍🬎🬍🬎🬋🬎🬍🬎🬋🬍🬍🬎🬍🬎🬍🬎🬋🬎🬍🬎🬍🬎🬍🬎🬍🬎🬍🬎🬋🬍
2021-313 18:10 🬋🬍🬍🬎🬍🬎🬋🬎🬍🬎🬍🬎🬍🬎🬍🬎🬍🬎🬋🬍🬍🬎🬍🬎🬍🬎🬋🬎🬍🬎🬋🬎🬍🬎🬍🬎🬍🬎🬋🬍🬍🬎🬍🬎🬋🬎🬋🬎🬍🬎🬍🬎🬍🬎🬍🬎🬋🬎🬋🬍🬍🬎🬍🬎🬋🬎🬋🬎🬍🬎🬍🬎🬍🬎🬋🬎🬍🬎🬋🬍🬍🬎🬍🬎🬍🬎🬋🬎🬍🬎🬍🬎🬍🬎🬋🬎🬍🬎🬋🬍🬍🬎🬍🬎🬍🬎🬋🬎🬍🬎🬍🬎🬍🬎🬍🬎🬍🬎🬋🬍
2021-313 18:11 🬋🬍🬍🬎🬍🬎🬋🬎🬍🬎🬍🬎🬍🬎🬍🬎🬋🬎🬋🬍🬍🬎🬍🬎🬍🬎🬋🬎🬍🬎🬋🬎🬍🬎🬍🬎🬍🬎🬋🬍🬍🬎🬍🬎🬋🬎🬋🬎🬍🬎🬍🬎🬍🬎🬍🬎🬋🬎🬋🬍🬍🬎🬍🬎🬋🬎🬋🬎🬍🬎🬍🬎🬍🬎🬋🬎🬍🬎🬋🬍🬍🬎🬍🬎🬍🬎🬋🬎🬍🬎🬍🬎🬍🬎🬋🬎🬍🬎🬋🬍🬍🬎🬍🬎🬍🬎🬋🬎🬍🬎🬍🬎🬍🬎🬍🬎🬍🬎🬋🬍

15
tests/bar Normal file
View file

@ -0,0 +1,15 @@
# SPDX-FileCopyrightText: 2021 Jeff Epler
#
# SPDX-License-Identifier: CC0-1.0
WWVB timecode: year=2021 days=313 hour=18 min=02 dst=0 ut1=-100 ly=0 ls=0 --style=bar
2021-313 18:02 ▄▟▟█▟█▟█▟█▟█▟█▄█▟█▄▟▟█▟█▟█▄█▟█▄█▟█▟█▟█▄▟▟█▟█▄█▄█▟█▟█▟█▟█▄█▄▟▟█▟█▄█▄█▟█▟█▟█▄█▟█▄▟▟█▟█▟█▄█▟█▟█▟█▄█▟█▄▟▟█▟█▟█▄█▟█▟█▟█▟█▟█▄▟
2021-313 18:03 ▄▟▟█▟█▟█▟█▟█▟█▄█▄█▄▟▟█▟█▟█▄█▟█▄█▟█▟█▟█▄▟▟█▟█▄█▄█▟█▟█▟█▟█▄█▄▟▟█▟█▄█▄█▟█▟█▟█▄█▟█▄▟▟█▟█▟█▄█▟█▟█▟█▄█▟█▄▟▟█▟█▟█▄█▟█▟█▟█▟█▟█▄▟
2021-313 18:04 ▄▟▟█▟█▟█▟█▟█▄█▟█▟█▄▟▟█▟█▟█▄█▟█▄█▟█▟█▟█▄▟▟█▟█▄█▄█▟█▟█▟█▟█▄█▄▟▟█▟█▄█▄█▟█▟█▟█▄█▟█▄▟▟█▟█▟█▄█▟█▟█▟█▄█▟█▄▟▟█▟█▟█▄█▟█▟█▟█▟█▟█▄▟
2021-313 18:05 ▄▟▟█▟█▟█▟█▟█▄█▟█▄█▄▟▟█▟█▟█▄█▟█▄█▟█▟█▟█▄▟▟█▟█▄█▄█▟█▟█▟█▟█▄█▄▟▟█▟█▄█▄█▟█▟█▟█▄█▟█▄▟▟█▟█▟█▄█▟█▟█▟█▄█▟█▄▟▟█▟█▟█▄█▟█▟█▟█▟█▟█▄▟
2021-313 18:06 ▄▟▟█▟█▟█▟█▟█▄█▄█▟█▄▟▟█▟█▟█▄█▟█▄█▟█▟█▟█▄▟▟█▟█▄█▄█▟█▟█▟█▟█▄█▄▟▟█▟█▄█▄█▟█▟█▟█▄█▟█▄▟▟█▟█▟█▄█▟█▟█▟█▄█▟█▄▟▟█▟█▟█▄█▟█▟█▟█▟█▟█▄▟
2021-313 18:07 ▄▟▟█▟█▟█▟█▟█▄█▄█▄█▄▟▟█▟█▟█▄█▟█▄█▟█▟█▟█▄▟▟█▟█▄█▄█▟█▟█▟█▟█▄█▄▟▟█▟█▄█▄█▟█▟█▟█▄█▟█▄▟▟█▟█▟█▄█▟█▟█▟█▄█▟█▄▟▟█▟█▟█▄█▟█▟█▟█▟█▟█▄▟
2021-313 18:08 ▄▟▟█▟█▟█▟█▄█▟█▟█▟█▄▟▟█▟█▟█▄█▟█▄█▟█▟█▟█▄▟▟█▟█▄█▄█▟█▟█▟█▟█▄█▄▟▟█▟█▄█▄█▟█▟█▟█▄█▟█▄▟▟█▟█▟█▄█▟█▟█▟█▄█▟█▄▟▟█▟█▟█▄█▟█▟█▟█▟█▟█▄▟
2021-313 18:09 ▄▟▟█▟█▟█▟█▄█▟█▟█▄█▄▟▟█▟█▟█▄█▟█▄█▟█▟█▟█▄▟▟█▟█▄█▄█▟█▟█▟█▟█▄█▄▟▟█▟█▄█▄█▟█▟█▟█▄█▟█▄▟▟█▟█▟█▄█▟█▟█▟█▄█▟█▄▟▟█▟█▟█▄█▟█▟█▟█▟█▟█▄▟
2021-313 18:10 ▄▟▟█▟█▄█▟█▟█▟█▟█▟█▄▟▟█▟█▟█▄█▟█▄█▟█▟█▟█▄▟▟█▟█▄█▄█▟█▟█▟█▟█▄█▄▟▟█▟█▄█▄█▟█▟█▟█▄█▟█▄▟▟█▟█▟█▄█▟█▟█▟█▄█▟█▄▟▟█▟█▟█▄█▟█▟█▟█▟█▟█▄▟
2021-313 18:11 ▄▟▟█▟█▄█▟█▟█▟█▟█▄█▄▟▟█▟█▟█▄█▟█▄█▟█▟█▟█▄▟▟█▟█▄█▄█▟█▟█▟█▟█▄█▄▟▟█▟█▄█▄█▟█▟█▟█▄█▟█▄▟▟█▟█▟█▄█▟█▟█▟█▄█▟█▄▟▟█▟█▟█▄█▟█▟█▟█▟█▟█▄▟

View file

@ -32,3 +32,4 @@ WWVB timecode: year=2020 days=001 hour=00 min=00 dst=0 ut1=-200 ly=1 ls=0 --chan
2020-001 00:09 200001001200000000020000000002000100010200100001020000010002
001110110100001100010101000000100000101101010010110000110110

View file

@ -1,14 +1,7 @@
# SPDX-FileCopyrightText: 2021 Jeff Epler
#
# SPDX-License-Identifier: CC0-1.0
#
# "For six minutes each half hour, from 1016 and 4046 minutes past each hour,
# one-minute frames are replaced by a special extended time frame. Rather than
# transmitting 35 bits of information in one minute, this transmits 7 bits
# (time of day and DST status only) over 6 minutes, giving 30 times as much
# energy per transmitted bit, a 14.8 dB improvement in the link budget compared
# to the standard one-minute time code." (wikipedia)
#
WWVB timecode: year=2021 days=311 hour=08 min=10 dst=1 ut1=-100 ly=0 ls=0 --channel=phase
2021-311 08:10 010000110100000111110110000001010110111111100110110101010001
2021-311 08:11 001001100111100011101110101111010010110010100111001000110001