lvtime 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lvtime-0.3.0/.gitattributes +1 -0
- lvtime-0.3.0/.github/FUNDING.yml +1 -0
- lvtime-0.3.0/.gitignore +12 -0
- lvtime-0.3.0/CHANGELOG.md +50 -0
- lvtime-0.3.0/CONTRIBUTING.md +19 -0
- lvtime-0.3.0/IMPROVEMENTS-round2.md +70 -0
- lvtime-0.3.0/LICENSE +21 -0
- lvtime-0.3.0/PKG-INFO +206 -0
- lvtime-0.3.0/README.md +179 -0
- lvtime-0.3.0/pyproject.toml +54 -0
- lvtime-0.3.0/requirements-dev.txt +3 -0
- lvtime-0.3.0/scripts/check.ps1 +7 -0
- lvtime-0.3.0/scripts/release.ps1 +28 -0
- lvtime-0.3.0/src/lvtime/__init__.py +49 -0
- lvtime-0.3.0/src/lvtime/__main__.py +10 -0
- lvtime-0.3.0/src/lvtime/_cli.py +97 -0
- lvtime-0.3.0/src/lvtime/_codec.py +61 -0
- lvtime-0.3.0/src/lvtime/_convert.py +98 -0
- lvtime-0.3.0/src/lvtime/_exact.py +66 -0
- lvtime-0.3.0/src/lvtime/_timestamp.py +120 -0
- lvtime-0.3.0/src/lvtime/py.typed +0 -0
- lvtime-0.3.0/tests/test_cli.py +91 -0
- lvtime-0.3.0/tests/test_contract.py +44 -0
- lvtime-0.3.0/tests/test_convert.py +120 -0
- lvtime-0.3.0/tests/test_e2e.py +94 -0
- lvtime-0.3.0/tests/test_exact.py +84 -0
- lvtime-0.3.0/tests/test_ni_golden.py +79 -0
- lvtime-0.3.0/tests/test_timestamp.py +154 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
* text=auto eol=lf
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
buy_me_a_coffee: jlianesglrs
|
lvtime-0.3.0/.gitignore
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here. The format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses
|
|
5
|
+
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
|
+
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
## [0.3.0]
|
|
10
|
+
|
|
11
|
+
- Exact conversions through integer and `Decimal` arithmetic, never a float:
|
|
12
|
+
`to_decimal` / `from_decimal`, `to_unix_decimal` / `from_unix_decimal` and the matching
|
|
13
|
+
`LVTimestamp` methods. These reproduce NI's exact documented bytes for values a double
|
|
14
|
+
cannot hold, such as -5.2 s (`0xCCCCCCCCCCCCCCCD`), and keep the full 2**-64 s resolution.
|
|
15
|
+
`from_decimal` accepts `Decimal`, `int` or `str` and refuses `float` so a lossy value
|
|
16
|
+
cannot slip in.
|
|
17
|
+
- `LVTimestamp.hex` / `LVTimestamp.from_hex`: round trip the 16-byte on-disk form as a
|
|
18
|
+
32-character hex string, byte-order aware.
|
|
19
|
+
- CLI: `--version`, a `decode --format decimal` output (exact seconds since 1904 and since
|
|
20
|
+
the Unix epoch), and the console script entry point now resolves to `lvtime._cli:main`.
|
|
21
|
+
- CLI errors now exit with code 1 (was 2) and still print to stderr.
|
|
22
|
+
- Documented two NI caveats: the 0.8 fraction only survives in fixed point, and NI's own
|
|
23
|
+
page mislabels `{3092601600, 0}` as 2002 when the byte value is 2001-12-31 UTC.
|
|
24
|
+
|
|
25
|
+
## [0.2.0]
|
|
26
|
+
|
|
27
|
+
- `to_unix` / `from_unix` and `LVTimestamp.to_unix` / `LVTimestamp.from_unix`: convert
|
|
28
|
+
to and from Unix time (float seconds since 1970-01-01 UTC).
|
|
29
|
+
- `to_float` / `from_float` and the matching `LVTimestamp` methods: seconds since the
|
|
30
|
+
1904 epoch as a float, the LabVIEW "coerce timestamp to double" view.
|
|
31
|
+
- Little-endian support: every byte function and `LVTimestamp.to_bytes` / `from_bytes`
|
|
32
|
+
takes a `byteorder` argument (`"big"`, the default, or `"little"`).
|
|
33
|
+
- `read_timestamps` / `write_timestamps`: batch read and write of back-to-back 16-byte
|
|
34
|
+
records.
|
|
35
|
+
- `ByteOrder`: the `Literal["big", "little"]` type alias used by the byte functions.
|
|
36
|
+
- `lvtime` command line (console script and `python -m lvtime`): `decode` a hex
|
|
37
|
+
timestamp to an ISO datetime (or `fields` / `json`) and `encode` an ISO datetime back,
|
|
38
|
+
reading from an argument or stdin.
|
|
39
|
+
|
|
40
|
+
## [0.1.0]
|
|
41
|
+
|
|
42
|
+
- First release.
|
|
43
|
+
- `LVTimestamp`: a lossless, immutable integer pair (signed 64-bit seconds, unsigned
|
|
44
|
+
64-bit fraction of 2**-64 s) with range-checked construction.
|
|
45
|
+
- `to_datetime` and `from_datetime`: convert between the integer pair and a UTC-aware
|
|
46
|
+
`datetime`, rounded to the microsecond, with the correct 1904 epoch and signedness.
|
|
47
|
+
- `read_timestamp` and `write_timestamp`: read and write the 16-byte big-endian on-disk
|
|
48
|
+
form used by TDMS and "Flatten To String".
|
|
49
|
+
- `LABVIEW_UNIX_EPOCH_OFFSET`: the exact 2082844800 s epoch constant.
|
|
50
|
+
- Zero runtime dependencies; typed (`py.typed`), tested on the success and failure paths.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Contributing
|
|
2
|
+
|
|
3
|
+
Thanks for taking the time. This project keeps a small, predictable shape.
|
|
4
|
+
|
|
5
|
+
## Setup
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
pip install -r requirements-dev.txt
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Before you open a pull request
|
|
12
|
+
|
|
13
|
+
- Keep public API in `src/lvtime/__init__.py`; internals live in `_`-prefixed modules.
|
|
14
|
+
- Add a test for the behaviour you change. Tests inject time, disk and network; they
|
|
15
|
+
never touch the real thing.
|
|
16
|
+
- Run `scripts/check.ps1` (format, lint, types, tests) and make sure it passes.
|
|
17
|
+
- One logical change per commit, imperative subject line.
|
|
18
|
+
|
|
19
|
+
Open an issue first if the change is large or shifts the public API.
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# lvtime round 2: ranked improvements
|
|
2
|
+
|
|
3
|
+
A ranked plan for the second substantial round on top of 0.2.0, driving the
|
|
4
|
+
library toward a perfect, lossless LabVIEW timestamp codec. Every claim below was
|
|
5
|
+
checked in a real Python REPL against the byte values published by NI before it
|
|
6
|
+
was written down.
|
|
7
|
+
|
|
8
|
+
## Ground truth
|
|
9
|
+
|
|
10
|
+
The authoritative reference is NI's own documented value table (decimal and hex
|
|
11
|
+
byte pairs), not the human-readable labels next to them:
|
|
12
|
+
|
|
13
|
+
| Instant (value) | seconds | fraction (hex) |
|
|
14
|
+
| --- | --- | --- |
|
|
15
|
+
| epoch, 1904-01-01 00:00:00 UTC | 0 | 0x0 |
|
|
16
|
+
| 0xB8555B00, 0x0 | 3092601600 | 0x0 |
|
|
17
|
+
| -0.5 s | -1 (0xFFFFFFFFFFFFFFFF) | 0x8000000000000000 |
|
|
18
|
+
| -5.2 s | -6 (0xFFFFFFFFFFFFFFFA) | 0xCCCCCCCCCCCCCCCD |
|
|
19
|
+
|
|
20
|
+
Two caveats that fall straight out of this table drive the work:
|
|
21
|
+
|
|
22
|
+
1. **NI's doc has an off-by-one-day label error.** The page labels
|
|
23
|
+
{3092601600, 0} as "01/01/2002 00:00:00.000 UTC", but 3092601600 seconds
|
|
24
|
+
after 1904-01-01 UTC is 2001-12-31 00:00:00 UTC, one day earlier. The byte
|
|
25
|
+
value is the truth; the label is wrong. `lvtime` already decodes this value to
|
|
26
|
+
2001-12-31, which is correct, and the contract test pins the byte value, not
|
|
27
|
+
NI's mislabel.
|
|
28
|
+
2. **The 0.8 fraction only survives in fixed point.** 0.8 s is exactly
|
|
29
|
+
0xCCCCCCCCCCCCCCCD in 2**-64 units. Build that same fraction through a C
|
|
30
|
+
double and it collapses to 0xCCCCCCCCCCCCC000, losing the low bits. NI's page
|
|
31
|
+
says as much. So a float round trip cannot reproduce the real NI bytes; only
|
|
32
|
+
an exact integer or Decimal path can.
|
|
33
|
+
|
|
34
|
+
## Ranked improvements
|
|
35
|
+
|
|
36
|
+
1. **Exact Decimal path (no float in the round trip).** New `to_decimal`,
|
|
37
|
+
`from_decimal`, `to_unix_decimal`, `from_unix_decimal` and the matching
|
|
38
|
+
`LVTimestamp` methods. These convert through integer and `Decimal` arithmetic
|
|
39
|
+
only, so `from_decimal("-5.2")` returns the exact NI pair
|
|
40
|
+
(-6, 0xCCCCCCCCCCCCCCCD) that `from_float(-5.2)` cannot. This is the headline
|
|
41
|
+
feature: it is the only way to reproduce NI's published bytes for a value like
|
|
42
|
+
-5.2, and it keeps the full 2**-64 s resolution end to end. `from_decimal`
|
|
43
|
+
accepts `Decimal`, `int` or `str` on purpose and refuses `float`, so a lossy
|
|
44
|
+
float can never sneak in unnoticed.
|
|
45
|
+
2. **Hex convenience on the type: `LVTimestamp.hex()` and `from_hex()`.** People
|
|
46
|
+
hold the flattened timestamp as a hex string far more often than as raw bytes
|
|
47
|
+
(logs, protocol dumps, the clipboard). A one-call round trip from and to hex,
|
|
48
|
+
byte-order aware, removes the `bytes.fromhex` / `.hex()` dance and backs the
|
|
49
|
+
CLI.
|
|
50
|
+
3. **The NI rounding caveat, documented and tested.** A README section and a
|
|
51
|
+
contract test that assert both facts above: the exact-vs-double fraction for
|
|
52
|
+
0.8 and the real instant behind the mislabelled 2002 row. This turns a
|
|
53
|
+
footgun that bites LabVIEW users into a guarantee.
|
|
54
|
+
4. **CLI polish.** Move the entry point to `lvtime._cli:main`, add `--version`,
|
|
55
|
+
and add a `--format decimal` output to `decode` that prints the exact Decimal
|
|
56
|
+
seconds since 1904 and since the Unix epoch. The console script already
|
|
57
|
+
decodes and encodes the raw 16-byte hex form; this rounds it out.
|
|
58
|
+
5. **Golden contract and real end-to-end tests.** A contract test that asserts
|
|
59
|
+
the complete promise against the NI byte vectors, and an end-to-end test that
|
|
60
|
+
pip-installs the package editable and drives the real `lvtime` console script
|
|
61
|
+
through `subprocess`, asserting stdout and exit codes and that the NI values
|
|
62
|
+
round-trip exactly.
|
|
63
|
+
|
|
64
|
+
## Already covered in 0.2.0 (kept, not re-done)
|
|
65
|
+
|
|
66
|
+
- **Flatten / unflatten of the 16-byte big-endian form.** `read_timestamp` /
|
|
67
|
+
`write_timestamp` and `LVTimestamp.to_bytes` / `from_bytes` already do exactly
|
|
68
|
+
this, big-endian by default, matching "Flatten To String" and TDMS.
|
|
69
|
+
- **Little-endian option, batch helpers, datetime and Unix and float views,
|
|
70
|
+
honest range errors, immutable typed dataclass.** All present since 0.2.0.
|
lvtime-0.3.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Javier Lianes
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
lvtime-0.3.0/PKG-INFO
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: lvtime
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Lossless LabVIEW 128-bit timestamp (1904 epoch) to and from datetime, UTC, sub-microsecond preserved.
|
|
5
|
+
Project-URL: Homepage, https://lianes8studio.com
|
|
6
|
+
Project-URL: Repository, https://github.com/JaviLianes8/lvtime
|
|
7
|
+
Project-URL: Issues, https://github.com/JaviLianes8/lvtime/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/JaviLianes8/lvtime/blob/main/CHANGELOG.md
|
|
9
|
+
Project-URL: LinkedIn, https://www.linkedin.com/in/jlianes/
|
|
10
|
+
Project-URL: Funding, https://buymeacoffee.com/jlianesglrs
|
|
11
|
+
Author-email: Javier Lianes <javier@lianes8studio.com>
|
|
12
|
+
License-Expression: MIT
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Keywords: 1904,datetime,epoch,labview,ni,tdms,timestamp
|
|
15
|
+
Classifier: Development Status :: 4 - Beta
|
|
16
|
+
Classifier: Intended Audience :: Developers
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: build; extra == 'dev'
|
|
22
|
+
Requires-Dist: mypy; extra == 'dev'
|
|
23
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
24
|
+
Requires-Dist: ruff; extra == 'dev'
|
|
25
|
+
Requires-Dist: twine; extra == 'dev'
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# lvtime
|
|
29
|
+
|
|
30
|
+
Lossless LabVIEW 128-bit timestamp (1904 epoch) to and from datetime, UTC, sub-microsecond preserved.
|
|
31
|
+
|
|
32
|
+
## Install
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
pip install lvtime
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Usage
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
from datetime import datetime, timezone
|
|
42
|
+
from lvtime import LVTimestamp, to_datetime, from_datetime, read_timestamp, write_timestamp
|
|
43
|
+
|
|
44
|
+
# A LabVIEW absolute time is a signed 64-bit second count since 1904-01-01 UTC
|
|
45
|
+
# plus an unsigned 64-bit fraction in units of 2**-64 s.
|
|
46
|
+
ts = LVTimestamp(seconds=3810888000, frac=1 << 63) # 2024-10-04 12:00:00.5 UTC
|
|
47
|
+
ts.to_datetime() # datetime(2024, 10, 4, 12, 0, 0, 500000, tzinfo=UTC)
|
|
48
|
+
|
|
49
|
+
# Build one from an aware datetime (naive input is rejected).
|
|
50
|
+
LVTimestamp.from_datetime(datetime(2024, 10, 4, tzinfo=timezone.utc))
|
|
51
|
+
|
|
52
|
+
# Read and write the 16-byte big-endian on-disk form (TDMS / "Flatten To String").
|
|
53
|
+
raw = write_timestamp(ts) # 16 bytes: 8 signed seconds + 8 unsigned fraction
|
|
54
|
+
read_timestamp(raw) == ts # True, every bit preserved
|
|
55
|
+
|
|
56
|
+
# The plain functions take and return the integer pair directly.
|
|
57
|
+
to_datetime(3810888000, 1 << 63)
|
|
58
|
+
seconds, frac = from_datetime(datetime(2024, 10, 4, tzinfo=timezone.utc))
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
`LVTimestamp` is the lossless carrier: it keeps the full 2**-64 s fraction, so two
|
|
62
|
+
instants a single fraction unit apart stay distinct even though they collapse to the
|
|
63
|
+
same microsecond when you ask for a `datetime`.
|
|
64
|
+
|
|
65
|
+
### Unix time and float seconds
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
ts.to_unix() # 1728043200.5, float seconds since 1970-01-01 UTC
|
|
69
|
+
LVTimestamp.from_unix(1728043200.5)
|
|
70
|
+
ts.to_float() # 3810888000.5, seconds since 1904 (LabVIEW coerce-to-double)
|
|
71
|
+
LVTimestamp.from_float(3810888000.5)
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Floats hold about 52 bits of mantissa, so these views are convenient but lossy below
|
|
75
|
+
the microsecond. The integer pair stays exact.
|
|
76
|
+
|
|
77
|
+
### Exact values with Decimal
|
|
78
|
+
|
|
79
|
+
When you need the real value and not a float, convert through `Decimal` (or plain `int`
|
|
80
|
+
or a `str`). This is the only way to reproduce NI's own documented bytes for a value
|
|
81
|
+
like -5.2 s, which a float cannot represent:
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
from decimal import Decimal
|
|
85
|
+
from lvtime import LVTimestamp, to_decimal, from_decimal
|
|
86
|
+
|
|
87
|
+
from_decimal("-5.2") # (-6, 14757395258967641293) == {0xFFFFFFFFFFFFFFFA, 0xCCCCCCCCCCCCCCCD}
|
|
88
|
+
LVTimestamp.from_decimal("-5.2").hex() # 'fffffffffffffffacccccccccccccccd', the exact NI bytes
|
|
89
|
+
|
|
90
|
+
LVTimestamp(10, 1 << 63).to_decimal() # Decimal('10.5'), exact seconds since 1904
|
|
91
|
+
to_decimal(0, 1) # Decimal('5.42...E-20'), one 2**-64 fraction unit, held exactly
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
`from_decimal` refuses a `float` on purpose, so a lossy value cannot slip in unnoticed;
|
|
95
|
+
reach for `from_float` when a float is really what you have. There are `to_unix_decimal`
|
|
96
|
+
and `from_unix_decimal` for the Unix-epoch view, and `LVTimestamp.hex()` /
|
|
97
|
+
`LVTimestamp.from_hex()` for the hex string you usually hold the flattened form as.
|
|
98
|
+
|
|
99
|
+
### Endianness and batches
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
ts.to_bytes("little") # byte-reversed 16-byte form
|
|
103
|
+
LVTimestamp.from_bytes(raw, "little")
|
|
104
|
+
|
|
105
|
+
from lvtime import read_timestamps, write_timestamps
|
|
106
|
+
|
|
107
|
+
blob = write_timestamps([ts, LVTimestamp(0, 0)]) # back-to-back 16-byte records
|
|
108
|
+
read_timestamps(blob) # [LVTimestamp(...), LVTimestamp(0, 0)]
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## Command line
|
|
112
|
+
|
|
113
|
+
Installing the package puts an `lvtime` command on the path (also runnable as
|
|
114
|
+
`python -m lvtime`):
|
|
115
|
+
|
|
116
|
+
```
|
|
117
|
+
$ lvtime decode 00000000e32589408000000000000000
|
|
118
|
+
2024-10-04T12:00:00.500000+00:00
|
|
119
|
+
|
|
120
|
+
$ lvtime decode --format fields 00000000e32589408000000000000000
|
|
121
|
+
seconds=3810888000 frac=9223372036854775808 iso=2024-10-04T12:00:00.500000+00:00 unix=1728043200.5
|
|
122
|
+
|
|
123
|
+
$ lvtime encode 2024-10-04T12:00:00.5+00:00
|
|
124
|
+
00000000e32589408000000000000000
|
|
125
|
+
|
|
126
|
+
$ lvtime decode --format decimal 00000000e32589408000000000000000
|
|
127
|
+
epoch1904=3810888000.5000... unix=1728043200.5000...
|
|
128
|
+
|
|
129
|
+
$ echo 00000000e32589408000000000000000 | lvtime decode
|
|
130
|
+
2024-10-04T12:00:00.500000+00:00
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
`decode` also takes `--format json`, `--format decimal` (exact seconds, no float) and
|
|
134
|
+
`--byteorder little`; `encode` reads the ISO datetime from an argument or stdin and treats
|
|
135
|
+
a naive datetime as UTC. `lvtime --version` prints the version, and a bad input goes to
|
|
136
|
+
stderr with exit code 1. See `lvtime --help`.
|
|
137
|
+
|
|
138
|
+
## How it works
|
|
139
|
+
|
|
140
|
+
A LabVIEW absolute time is 128 bits of fixed point: a **signed** 64-bit count of whole
|
|
141
|
+
seconds since **1904-01-01 00:00:00 UTC** followed by an **unsigned** 64-bit fraction in
|
|
142
|
+
units of 2**-64 s. On disk ("Flatten To String", TDMS timestamp channels) the two fields
|
|
143
|
+
are stored big-endian, most significant byte first, for 16 bytes total. Converting to a
|
|
144
|
+
`datetime` means scaling the fraction to microseconds and adding to the 1904 epoch;
|
|
145
|
+
converting back maps the microsecond field to the nearest fraction unit. The full integer
|
|
146
|
+
pair is the only representation that keeps every bit.
|
|
147
|
+
|
|
148
|
+
## Two caveats worth knowing
|
|
149
|
+
|
|
150
|
+
The [NI timestamp overview](https://www.ni.com/en/support/documentation/supplemental/08/labview-timestamp-overview.html)
|
|
151
|
+
lists example values. Two of them trip people up, and `lvtime` is built around the byte
|
|
152
|
+
values rather than the labels:
|
|
153
|
+
|
|
154
|
+
- **The 0.8 fraction only survives in fixed point.** 0.8 s is exactly
|
|
155
|
+
`0xCCCCCCCCCCCCCCCD` in 2**-64 units (so -5.2 s is `{0xFFFFFFFFFFFFFFFA, 0xCCCCCCCCCCCCCCCD}`).
|
|
156
|
+
Build that same fraction from a C `double` and it collapses to `0xCCCCCCCCCCCCC000`, losing
|
|
157
|
+
the low bits, which NI's own page notes. `from_float(-5.2)` reproduces the lossy
|
|
158
|
+
`0xCCCCCCCCCCCCC000`; `from_decimal("-5.2")` reproduces the exact `0xCCCCCCCCCCCCCCCD`. Use
|
|
159
|
+
the Decimal path when the bytes have to match.
|
|
160
|
+
- **NI's page mislabels the 2002 row by a day.** It labels `{3092601600, 0}` (`0xB8555B00`)
|
|
161
|
+
as "01/01/2002 00:00:00 UTC", but 3092601600 seconds after the 1904 epoch is
|
|
162
|
+
2001-12-31 00:00:00 UTC, one day earlier. `lvtime` decodes the byte value, so it returns
|
|
163
|
+
the correct 2001-12-31.
|
|
164
|
+
|
|
165
|
+
## Why
|
|
166
|
+
|
|
167
|
+
LabVIEW stores absolute time as a 128-bit fixed-point value: a **signed** 64-bit count
|
|
168
|
+
of whole seconds since **1904-01-01 00:00:00 UTC** and an **unsigned** 64-bit fraction in
|
|
169
|
+
units of 2**-64 s ([NI, LabVIEW Timestamp Overview](https://www.ni.com/en/support/documentation/supplemental/08/labview-timestamp-overview.html)).
|
|
170
|
+
Two things trip people up every time:
|
|
171
|
+
|
|
172
|
+
- **The 1904 epoch.** The Unix offset is exactly `2082844800` seconds. Hand-rolled code
|
|
173
|
+
that re-derives or mistypes this constant lands "off by 300 years" or by a timezone, a
|
|
174
|
+
recurring complaint on the NI and LAVA forums.
|
|
175
|
+
- **The precision.** The format resolves far below a nanosecond. Round-tripping through a
|
|
176
|
+
C `double` or a `datetime` throws most of that away (about a 0.5 microsecond step through
|
|
177
|
+
a double). npTDMS's default read path surfaces timestamps as NumPy `datetime64` truncated
|
|
178
|
+
to microseconds, which loses the fraction; it does expose the lossless pair with
|
|
179
|
+
`raw_timestamps=True`, but only for timestamps read from inside a TDMS file.
|
|
180
|
+
|
|
181
|
+
Inside a TDMS file, then, npTDMS already covers the lossless case. What is missing is a
|
|
182
|
+
standalone codec for the raw 16-byte form that is **not** part of a TDMS file: Flatten To
|
|
183
|
+
String blobs, protocol payloads, anything where you hold the bytes directly. The LabVIEW-side
|
|
184
|
+
toolkits (OpenG, VIPM/JKI) run inside LabVIEW and ship no Python reader. `lvtime` fills that
|
|
185
|
+
gap: a tiny, typed, zero-dependency (stdlib only) library that keeps the exact integer pair
|
|
186
|
+
with the correct signedness, reads and writes the 16-byte on-disk form (big- or little-endian)
|
|
187
|
+
independent of any file container, and converts to a UTC-aware `datetime` only when you ask
|
|
188
|
+
(rounded to the microsecond, which is all `datetime` can hold).
|
|
189
|
+
|
|
190
|
+
## Scope
|
|
191
|
+
|
|
192
|
+
`lvtime` handles the absolute-time value itself: the integer pair, the 16-byte record, the
|
|
193
|
+
`datetime` conversion, and the Unix and float-seconds views. It does not parse whole TDMS or
|
|
194
|
+
flattened-data files; it is the piece you reach for once you have the 16 raw bytes in hand, or
|
|
195
|
+
the seconds-and-fraction pair you need to turn into a real instant. LabVIEW follows the
|
|
196
|
+
Gregorian calendar and ignores leap seconds, and so does this library.
|
|
197
|
+
|
|
198
|
+
## License
|
|
199
|
+
|
|
200
|
+
MIT. See [LICENSE](LICENSE).
|
|
201
|
+
|
|
202
|
+
## Links
|
|
203
|
+
|
|
204
|
+
- Website: https://lianes8studio.com
|
|
205
|
+
- GitHub: https://github.com/JaviLianes8
|
|
206
|
+
- LinkedIn: https://www.linkedin.com/in/jlianes/
|
lvtime-0.3.0/README.md
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# lvtime
|
|
2
|
+
|
|
3
|
+
Lossless LabVIEW 128-bit timestamp (1904 epoch) to and from datetime, UTC, sub-microsecond preserved.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
pip install lvtime
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
from lvtime import LVTimestamp, to_datetime, from_datetime, read_timestamp, write_timestamp
|
|
16
|
+
|
|
17
|
+
# A LabVIEW absolute time is a signed 64-bit second count since 1904-01-01 UTC
|
|
18
|
+
# plus an unsigned 64-bit fraction in units of 2**-64 s.
|
|
19
|
+
ts = LVTimestamp(seconds=3810888000, frac=1 << 63) # 2024-10-04 12:00:00.5 UTC
|
|
20
|
+
ts.to_datetime() # datetime(2024, 10, 4, 12, 0, 0, 500000, tzinfo=UTC)
|
|
21
|
+
|
|
22
|
+
# Build one from an aware datetime (naive input is rejected).
|
|
23
|
+
LVTimestamp.from_datetime(datetime(2024, 10, 4, tzinfo=timezone.utc))
|
|
24
|
+
|
|
25
|
+
# Read and write the 16-byte big-endian on-disk form (TDMS / "Flatten To String").
|
|
26
|
+
raw = write_timestamp(ts) # 16 bytes: 8 signed seconds + 8 unsigned fraction
|
|
27
|
+
read_timestamp(raw) == ts # True, every bit preserved
|
|
28
|
+
|
|
29
|
+
# The plain functions take and return the integer pair directly.
|
|
30
|
+
to_datetime(3810888000, 1 << 63)
|
|
31
|
+
seconds, frac = from_datetime(datetime(2024, 10, 4, tzinfo=timezone.utc))
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
`LVTimestamp` is the lossless carrier: it keeps the full 2**-64 s fraction, so two
|
|
35
|
+
instants a single fraction unit apart stay distinct even though they collapse to the
|
|
36
|
+
same microsecond when you ask for a `datetime`.
|
|
37
|
+
|
|
38
|
+
### Unix time and float seconds
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
ts.to_unix() # 1728043200.5, float seconds since 1970-01-01 UTC
|
|
42
|
+
LVTimestamp.from_unix(1728043200.5)
|
|
43
|
+
ts.to_float() # 3810888000.5, seconds since 1904 (LabVIEW coerce-to-double)
|
|
44
|
+
LVTimestamp.from_float(3810888000.5)
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Floats hold about 52 bits of mantissa, so these views are convenient but lossy below
|
|
48
|
+
the microsecond. The integer pair stays exact.
|
|
49
|
+
|
|
50
|
+
### Exact values with Decimal
|
|
51
|
+
|
|
52
|
+
When you need the real value and not a float, convert through `Decimal` (or plain `int`
|
|
53
|
+
or a `str`). This is the only way to reproduce NI's own documented bytes for a value
|
|
54
|
+
like -5.2 s, which a float cannot represent:
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
from decimal import Decimal
|
|
58
|
+
from lvtime import LVTimestamp, to_decimal, from_decimal
|
|
59
|
+
|
|
60
|
+
from_decimal("-5.2") # (-6, 14757395258967641293) == {0xFFFFFFFFFFFFFFFA, 0xCCCCCCCCCCCCCCCD}
|
|
61
|
+
LVTimestamp.from_decimal("-5.2").hex() # 'fffffffffffffffacccccccccccccccd', the exact NI bytes
|
|
62
|
+
|
|
63
|
+
LVTimestamp(10, 1 << 63).to_decimal() # Decimal('10.5'), exact seconds since 1904
|
|
64
|
+
to_decimal(0, 1) # Decimal('5.42...E-20'), one 2**-64 fraction unit, held exactly
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
`from_decimal` refuses a `float` on purpose, so a lossy value cannot slip in unnoticed;
|
|
68
|
+
reach for `from_float` when a float is really what you have. There are `to_unix_decimal`
|
|
69
|
+
and `from_unix_decimal` for the Unix-epoch view, and `LVTimestamp.hex()` /
|
|
70
|
+
`LVTimestamp.from_hex()` for the hex string you usually hold the flattened form as.
|
|
71
|
+
|
|
72
|
+
### Endianness and batches
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
ts.to_bytes("little") # byte-reversed 16-byte form
|
|
76
|
+
LVTimestamp.from_bytes(raw, "little")
|
|
77
|
+
|
|
78
|
+
from lvtime import read_timestamps, write_timestamps
|
|
79
|
+
|
|
80
|
+
blob = write_timestamps([ts, LVTimestamp(0, 0)]) # back-to-back 16-byte records
|
|
81
|
+
read_timestamps(blob) # [LVTimestamp(...), LVTimestamp(0, 0)]
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Command line
|
|
85
|
+
|
|
86
|
+
Installing the package puts an `lvtime` command on the path (also runnable as
|
|
87
|
+
`python -m lvtime`):
|
|
88
|
+
|
|
89
|
+
```
|
|
90
|
+
$ lvtime decode 00000000e32589408000000000000000
|
|
91
|
+
2024-10-04T12:00:00.500000+00:00
|
|
92
|
+
|
|
93
|
+
$ lvtime decode --format fields 00000000e32589408000000000000000
|
|
94
|
+
seconds=3810888000 frac=9223372036854775808 iso=2024-10-04T12:00:00.500000+00:00 unix=1728043200.5
|
|
95
|
+
|
|
96
|
+
$ lvtime encode 2024-10-04T12:00:00.5+00:00
|
|
97
|
+
00000000e32589408000000000000000
|
|
98
|
+
|
|
99
|
+
$ lvtime decode --format decimal 00000000e32589408000000000000000
|
|
100
|
+
epoch1904=3810888000.5000... unix=1728043200.5000...
|
|
101
|
+
|
|
102
|
+
$ echo 00000000e32589408000000000000000 | lvtime decode
|
|
103
|
+
2024-10-04T12:00:00.500000+00:00
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
`decode` also takes `--format json`, `--format decimal` (exact seconds, no float) and
|
|
107
|
+
`--byteorder little`; `encode` reads the ISO datetime from an argument or stdin and treats
|
|
108
|
+
a naive datetime as UTC. `lvtime --version` prints the version, and a bad input goes to
|
|
109
|
+
stderr with exit code 1. See `lvtime --help`.
|
|
110
|
+
|
|
111
|
+
## How it works
|
|
112
|
+
|
|
113
|
+
A LabVIEW absolute time is 128 bits of fixed point: a **signed** 64-bit count of whole
|
|
114
|
+
seconds since **1904-01-01 00:00:00 UTC** followed by an **unsigned** 64-bit fraction in
|
|
115
|
+
units of 2**-64 s. On disk ("Flatten To String", TDMS timestamp channels) the two fields
|
|
116
|
+
are stored big-endian, most significant byte first, for 16 bytes total. Converting to a
|
|
117
|
+
`datetime` means scaling the fraction to microseconds and adding to the 1904 epoch;
|
|
118
|
+
converting back maps the microsecond field to the nearest fraction unit. The full integer
|
|
119
|
+
pair is the only representation that keeps every bit.
|
|
120
|
+
|
|
121
|
+
## Two caveats worth knowing
|
|
122
|
+
|
|
123
|
+
The [NI timestamp overview](https://www.ni.com/en/support/documentation/supplemental/08/labview-timestamp-overview.html)
|
|
124
|
+
lists example values. Two of them trip people up, and `lvtime` is built around the byte
|
|
125
|
+
values rather than the labels:
|
|
126
|
+
|
|
127
|
+
- **The 0.8 fraction only survives in fixed point.** 0.8 s is exactly
|
|
128
|
+
`0xCCCCCCCCCCCCCCCD` in 2**-64 units (so -5.2 s is `{0xFFFFFFFFFFFFFFFA, 0xCCCCCCCCCCCCCCCD}`).
|
|
129
|
+
Build that same fraction from a C `double` and it collapses to `0xCCCCCCCCCCCCC000`, losing
|
|
130
|
+
the low bits, which NI's own page notes. `from_float(-5.2)` reproduces the lossy
|
|
131
|
+
`0xCCCCCCCCCCCCC000`; `from_decimal("-5.2")` reproduces the exact `0xCCCCCCCCCCCCCCCD`. Use
|
|
132
|
+
the Decimal path when the bytes have to match.
|
|
133
|
+
- **NI's page mislabels the 2002 row by a day.** It labels `{3092601600, 0}` (`0xB8555B00`)
|
|
134
|
+
as "01/01/2002 00:00:00 UTC", but 3092601600 seconds after the 1904 epoch is
|
|
135
|
+
2001-12-31 00:00:00 UTC, one day earlier. `lvtime` decodes the byte value, so it returns
|
|
136
|
+
the correct 2001-12-31.
|
|
137
|
+
|
|
138
|
+
## Why
|
|
139
|
+
|
|
140
|
+
LabVIEW stores absolute time as a 128-bit fixed-point value: a **signed** 64-bit count
|
|
141
|
+
of whole seconds since **1904-01-01 00:00:00 UTC** and an **unsigned** 64-bit fraction in
|
|
142
|
+
units of 2**-64 s ([NI, LabVIEW Timestamp Overview](https://www.ni.com/en/support/documentation/supplemental/08/labview-timestamp-overview.html)).
|
|
143
|
+
Two things trip people up every time:
|
|
144
|
+
|
|
145
|
+
- **The 1904 epoch.** The Unix offset is exactly `2082844800` seconds. Hand-rolled code
|
|
146
|
+
that re-derives or mistypes this constant lands "off by 300 years" or by a timezone, a
|
|
147
|
+
recurring complaint on the NI and LAVA forums.
|
|
148
|
+
- **The precision.** The format resolves far below a nanosecond. Round-tripping through a
|
|
149
|
+
C `double` or a `datetime` throws most of that away (about a 0.5 microsecond step through
|
|
150
|
+
a double). npTDMS's default read path surfaces timestamps as NumPy `datetime64` truncated
|
|
151
|
+
to microseconds, which loses the fraction; it does expose the lossless pair with
|
|
152
|
+
`raw_timestamps=True`, but only for timestamps read from inside a TDMS file.
|
|
153
|
+
|
|
154
|
+
Inside a TDMS file, then, npTDMS already covers the lossless case. What is missing is a
|
|
155
|
+
standalone codec for the raw 16-byte form that is **not** part of a TDMS file: Flatten To
|
|
156
|
+
String blobs, protocol payloads, anything where you hold the bytes directly. The LabVIEW-side
|
|
157
|
+
toolkits (OpenG, VIPM/JKI) run inside LabVIEW and ship no Python reader. `lvtime` fills that
|
|
158
|
+
gap: a tiny, typed, zero-dependency (stdlib only) library that keeps the exact integer pair
|
|
159
|
+
with the correct signedness, reads and writes the 16-byte on-disk form (big- or little-endian)
|
|
160
|
+
independent of any file container, and converts to a UTC-aware `datetime` only when you ask
|
|
161
|
+
(rounded to the microsecond, which is all `datetime` can hold).
|
|
162
|
+
|
|
163
|
+
## Scope
|
|
164
|
+
|
|
165
|
+
`lvtime` handles the absolute-time value itself: the integer pair, the 16-byte record, the
|
|
166
|
+
`datetime` conversion, and the Unix and float-seconds views. It does not parse whole TDMS or
|
|
167
|
+
flattened-data files; it is the piece you reach for once you have the 16 raw bytes in hand, or
|
|
168
|
+
the seconds-and-fraction pair you need to turn into a real instant. LabVIEW follows the
|
|
169
|
+
Gregorian calendar and ignores leap seconds, and so does this library.
|
|
170
|
+
|
|
171
|
+
## License
|
|
172
|
+
|
|
173
|
+
MIT. See [LICENSE](LICENSE).
|
|
174
|
+
|
|
175
|
+
## Links
|
|
176
|
+
|
|
177
|
+
- Website: https://lianes8studio.com
|
|
178
|
+
- GitHub: https://github.com/JaviLianes8
|
|
179
|
+
- LinkedIn: https://www.linkedin.com/in/jlianes/
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "lvtime"
|
|
7
|
+
version = "0.3.0"
|
|
8
|
+
description = "Lossless LabVIEW 128-bit timestamp (1904 epoch) to and from datetime, UTC, sub-microsecond preserved."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "Javier Lianes", email = "javier@lianes8studio.com" }]
|
|
14
|
+
keywords = ["labview", "timestamp", "datetime", "tdms", "ni", "1904", "epoch"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Typing :: Typed",
|
|
20
|
+
]
|
|
21
|
+
dependencies = []
|
|
22
|
+
|
|
23
|
+
[project.scripts]
|
|
24
|
+
lvtime = "lvtime._cli:main"
|
|
25
|
+
|
|
26
|
+
[project.optional-dependencies]
|
|
27
|
+
dev = ["ruff", "mypy", "pytest", "build", "twine"]
|
|
28
|
+
|
|
29
|
+
[project.urls]
|
|
30
|
+
Homepage = "https://lianes8studio.com"
|
|
31
|
+
Repository = "https://github.com/JaviLianes8/lvtime"
|
|
32
|
+
Issues = "https://github.com/JaviLianes8/lvtime/issues"
|
|
33
|
+
Changelog = "https://github.com/JaviLianes8/lvtime/blob/main/CHANGELOG.md"
|
|
34
|
+
LinkedIn = "https://www.linkedin.com/in/jlianes/"
|
|
35
|
+
Funding = "https://buymeacoffee.com/jlianesglrs"
|
|
36
|
+
|
|
37
|
+
[tool.hatch.build.targets.wheel]
|
|
38
|
+
packages = ["src/lvtime"]
|
|
39
|
+
|
|
40
|
+
[tool.ruff]
|
|
41
|
+
line-length = 100
|
|
42
|
+
target-version = "py310"
|
|
43
|
+
|
|
44
|
+
[tool.ruff.lint]
|
|
45
|
+
select = ["E", "F", "I", "UP", "B", "SIM", "RUF"]
|
|
46
|
+
|
|
47
|
+
[tool.mypy]
|
|
48
|
+
python_version = "3.10"
|
|
49
|
+
strict = true
|
|
50
|
+
|
|
51
|
+
[tool.pytest.ini_options]
|
|
52
|
+
testpaths = ["tests"]
|
|
53
|
+
pythonpath = ["src"]
|
|
54
|
+
addopts = "-q"
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Build and upload to PyPI. Run scripts/check.ps1 first.
|
|
2
|
+
# The name must not already exist on PyPI; the token is read from a secret store, never git.
|
|
3
|
+
$ErrorActionPreference = "Stop"
|
|
4
|
+
|
|
5
|
+
# Works on both Windows PowerShell 5.1 and pwsh 7 (generic catch, no PS7-only types).
|
|
6
|
+
$taken = $true
|
|
7
|
+
try {
|
|
8
|
+
Invoke-WebRequest -UseBasicParsing "https://pypi.org/pypi/lvtime/json" | Out-Null
|
|
9
|
+
} catch {
|
|
10
|
+
if ($_.Exception.Response.StatusCode.value__ -eq 404) { $taken = $false } else { throw }
|
|
11
|
+
}
|
|
12
|
+
if ($taken) { throw "The name 'lvtime' already exists on PyPI. Choose another before publishing." }
|
|
13
|
+
|
|
14
|
+
$token = $env:PYPI_TOKEN
|
|
15
|
+
if (-not $token -and (Test-Path "C:\secrets\pypi-token.txt")) {
|
|
16
|
+
$token = (Get-Content "C:\secrets\pypi-token.txt" -Raw).Trim()
|
|
17
|
+
}
|
|
18
|
+
if (-not $token) {
|
|
19
|
+
throw "No PyPI token. Set `$env:PYPI_TOKEN or save it to C:\secrets\pypi-token.txt (never commit it)."
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
Remove-Item -Recurse -Force dist -ErrorAction SilentlyContinue
|
|
23
|
+
python -m build
|
|
24
|
+
python -m twine check dist/*
|
|
25
|
+
|
|
26
|
+
$env:TWINE_USERNAME = "__token__"
|
|
27
|
+
$env:TWINE_PASSWORD = $token
|
|
28
|
+
try { python -m twine upload dist/* } finally { Remove-Item Env:\TWINE_PASSWORD -ErrorAction SilentlyContinue }
|