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.
@@ -0,0 +1 @@
1
+ * text=auto eol=lf
@@ -0,0 +1 @@
1
+ buy_me_a_coffee: jlianesglrs
@@ -0,0 +1,12 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ venv/
5
+ build/
6
+ dist/
7
+ *.egg-info/
8
+ .pytest_cache/
9
+ .mypy_cache/
10
+ .ruff_cache/
11
+ .coverage
12
+ htmlcov/
@@ -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,3 @@
1
+ # Development dependencies: the package in editable mode plus its dev extras
2
+ # (ruff, mypy, pytest, build, twine). Install with: pip install -r requirements-dev.txt
3
+ -e .[dev]
@@ -0,0 +1,7 @@
1
+ # Local quality gate. Run before every commit. No CI runs in the cloud.
2
+ $ErrorActionPreference = "Stop"
3
+
4
+ ruff format --check src tests
5
+ ruff check src tests
6
+ mypy src tests
7
+ pytest
@@ -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 }