lvtime 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
lvtime/__init__.py ADDED
@@ -0,0 +1,49 @@
1
+ """Lossless LabVIEW 128-bit timestamp to and from datetime, UTC, sub-microsecond preserved."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ._codec import ByteOrder
6
+ from ._convert import (
7
+ LABVIEW_UNIX_EPOCH_OFFSET,
8
+ from_datetime,
9
+ from_float,
10
+ from_unix,
11
+ to_datetime,
12
+ to_float,
13
+ to_unix,
14
+ )
15
+ from ._exact import (
16
+ from_decimal,
17
+ from_unix_decimal,
18
+ to_decimal,
19
+ to_unix_decimal,
20
+ )
21
+ from ._timestamp import (
22
+ LVTimestamp,
23
+ read_timestamp,
24
+ read_timestamps,
25
+ write_timestamp,
26
+ write_timestamps,
27
+ )
28
+
29
+ __version__ = "0.3.0"
30
+
31
+ __all__ = [
32
+ "LABVIEW_UNIX_EPOCH_OFFSET",
33
+ "ByteOrder",
34
+ "LVTimestamp",
35
+ "from_datetime",
36
+ "from_decimal",
37
+ "from_float",
38
+ "from_unix",
39
+ "from_unix_decimal",
40
+ "read_timestamp",
41
+ "read_timestamps",
42
+ "to_datetime",
43
+ "to_decimal",
44
+ "to_float",
45
+ "to_unix",
46
+ "to_unix_decimal",
47
+ "write_timestamp",
48
+ "write_timestamps",
49
+ ]
lvtime/__main__.py ADDED
@@ -0,0 +1,10 @@
1
+ """Run the command line with ``python -m lvtime``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ from ._cli import main
8
+
9
+ if __name__ == "__main__":
10
+ sys.exit(main())
lvtime/_cli.py ADDED
@@ -0,0 +1,97 @@
1
+ """The ``lvtime`` command line: decode and encode the 16-byte on-disk form."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from datetime import datetime, timezone
9
+
10
+ from . import __version__
11
+ from ._codec import ByteOrder
12
+ from ._timestamp import LVTimestamp, read_timestamp, write_timestamp
13
+
14
+ _DESCRIPTION = "Decode and encode LabVIEW 128-bit timestamps (1904 epoch)."
15
+ _EPILOG = """\
16
+ examples:
17
+ lvtime decode 00000000e32589408000000000000000
18
+ echo 00000000e32589408000000000000000 | lvtime decode
19
+ lvtime decode --format fields 00000000e32589408000000000000000
20
+ lvtime decode --format decimal 00000000e32589408000000000000000
21
+ lvtime encode 2024-10-04T12:00:00.5+00:00
22
+ lvtime encode --byteorder little 2024-10-04T12:00:00Z
23
+ """
24
+
25
+
26
+ def _read_arg(value: str | None) -> str:
27
+ text = sys.stdin.read() if value is None else value
28
+ text = text.strip()
29
+ if not text:
30
+ raise ValueError("no input given (pass an argument or pipe it on stdin)")
31
+ return text
32
+
33
+
34
+ def _parse_iso(text: str) -> datetime:
35
+ dt = datetime.fromisoformat(text.replace("Z", "+00:00"))
36
+ if dt.tzinfo is None:
37
+ dt = dt.replace(tzinfo=timezone.utc)
38
+ return dt
39
+
40
+
41
+ def _decode(hex_text: str, byteorder: ByteOrder, fmt: str) -> str:
42
+ ts = read_timestamp(bytes.fromhex(hex_text), byteorder)
43
+ if fmt == "iso":
44
+ return ts.to_datetime().isoformat()
45
+ if fmt == "fields":
46
+ dt = ts.to_datetime()
47
+ return f"seconds={ts.seconds} frac={ts.frac} iso={dt.isoformat()} unix={ts.to_unix()!r}"
48
+ if fmt == "decimal":
49
+ return f"epoch1904={ts.to_decimal()} unix={ts.to_unix_decimal()}"
50
+ payload = {
51
+ "seconds": ts.seconds,
52
+ "frac": ts.frac,
53
+ "iso": ts.to_datetime().isoformat(),
54
+ "unix": ts.to_unix(),
55
+ }
56
+ return json.dumps(payload)
57
+
58
+
59
+ def _encode(iso_text: str, byteorder: ByteOrder) -> str:
60
+ return write_timestamp(LVTimestamp.from_datetime(_parse_iso(iso_text)), byteorder).hex()
61
+
62
+
63
+ def _build_parser() -> argparse.ArgumentParser:
64
+ parser = argparse.ArgumentParser(
65
+ prog="lvtime",
66
+ description=_DESCRIPTION,
67
+ epilog=_EPILOG,
68
+ formatter_class=argparse.RawDescriptionHelpFormatter,
69
+ )
70
+ parser.add_argument("--version", action="version", version=f"lvtime {__version__}")
71
+ subs = parser.add_subparsers(dest="command", required=True)
72
+
73
+ decode = subs.add_parser("decode", help="16-byte hex timestamp to an ISO datetime")
74
+ decode.add_argument("hex", nargs="?", help="32 hex chars; read from stdin if omitted")
75
+ decode.add_argument("--byteorder", choices=["big", "little"], default="big")
76
+ decode.add_argument("--format", choices=["iso", "fields", "json", "decimal"], default="iso")
77
+
78
+ encode = subs.add_parser("encode", help="ISO datetime to a 16-byte hex timestamp")
79
+ encode.add_argument("iso", nargs="?", help="ISO datetime (UTC if naive); stdin if omitted")
80
+ encode.add_argument("--byteorder", choices=["big", "little"], default="big")
81
+ return parser
82
+
83
+
84
+ def main(argv: list[str] | None = None) -> int:
85
+ """Entry point for the ``lvtime`` console script. Returns a process exit code."""
86
+ parser = _build_parser()
87
+ args = parser.parse_args(argv)
88
+ try:
89
+ if args.command == "decode":
90
+ line = _decode(_read_arg(args.hex), args.byteorder, args.format)
91
+ else:
92
+ line = _encode(_read_arg(args.iso), args.byteorder)
93
+ except ValueError as error:
94
+ print(f"lvtime: {error}", file=sys.stderr)
95
+ return 1
96
+ print(line)
97
+ return 0
lvtime/_codec.py ADDED
@@ -0,0 +1,61 @@
1
+ """The 16-byte on-disk form of a LabVIEW timestamp, big- or little-endian.
2
+
3
+ The default layout is the one LabVIEW "Flatten To String" and the timestamp
4
+ channels in TDMS write: eight bytes of signed whole seconds followed by eight
5
+ bytes of unsigned fraction, each most significant byte first. Pass ``"little"``
6
+ for the byte-reversed form some payloads use.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import struct
12
+ from typing import Literal
13
+
14
+ ByteOrder = Literal["big", "little"]
15
+
16
+ _LAYOUTS: dict[str, struct.Struct] = {
17
+ "big": struct.Struct(">qQ"),
18
+ "little": struct.Struct("<qQ"),
19
+ }
20
+
21
+ TIMESTAMP_SIZE = _LAYOUTS["big"].size
22
+
23
+
24
+ def _layout(byteorder: ByteOrder) -> struct.Struct:
25
+ try:
26
+ return _LAYOUTS[byteorder]
27
+ except KeyError:
28
+ raise ValueError(f"byteorder must be 'big' or 'little', got {byteorder!r}") from None
29
+
30
+
31
+ def pack(seconds: int, frac: int, byteorder: ByteOrder = "big") -> bytes:
32
+ """Encode a ``(seconds, frac)`` pair as 16 bytes in the given byte order."""
33
+ return _layout(byteorder).pack(seconds, frac)
34
+
35
+
36
+ def unpack(data: bytes, byteorder: ByteOrder = "big") -> tuple[int, int]:
37
+ """Decode 16 bytes into a ``(seconds, frac)`` pair.
38
+
39
+ Raises :class:`ValueError` if the buffer is not exactly 16 bytes long.
40
+ """
41
+ if len(data) != TIMESTAMP_SIZE:
42
+ raise ValueError(f"expected {TIMESTAMP_SIZE} bytes, got {len(data)}")
43
+ seconds, frac = _layout(byteorder).unpack(data)
44
+ return seconds, frac
45
+
46
+
47
+ def pack_many(pairs: list[tuple[int, int]], byteorder: ByteOrder = "big") -> bytes:
48
+ """Encode a list of ``(seconds, frac)`` pairs into one contiguous buffer."""
49
+ layout = _layout(byteorder)
50
+ return b"".join(layout.pack(seconds, frac) for seconds, frac in pairs)
51
+
52
+
53
+ def unpack_many(data: bytes, byteorder: ByteOrder = "big") -> list[tuple[int, int]]:
54
+ """Decode a buffer of back-to-back 16-byte records into pairs.
55
+
56
+ Raises :class:`ValueError` if the length is not a multiple of 16 bytes.
57
+ """
58
+ if len(data) % TIMESTAMP_SIZE != 0:
59
+ raise ValueError(f"length must be a multiple of {TIMESTAMP_SIZE} bytes, got {len(data)}")
60
+ layout = _layout(byteorder)
61
+ return [layout.unpack_from(data, offset) for offset in range(0, len(data), TIMESTAMP_SIZE)]
lvtime/_convert.py ADDED
@@ -0,0 +1,98 @@
1
+ """Convert LabVIEW absolute time to and from datetime, Unix time and float seconds.
2
+
3
+ LabVIEW absolute time counts from 1904-01-01 00:00:00 UTC. A value is a signed
4
+ 64-bit count of whole seconds plus an unsigned 64-bit fraction in units of
5
+ 2**-64 seconds. :mod:`datetime` resolves only to microseconds and a float holds
6
+ about 52 bits of mantissa, so those two views round; the lossless path is the
7
+ integer pair itself.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import math
13
+ from datetime import datetime, timedelta, timezone
14
+
15
+ # Seconds between the LabVIEW epoch (1904-01-01) and the Unix epoch (1970-01-01).
16
+ LABVIEW_UNIX_EPOCH_OFFSET = 2082844800
17
+
18
+ I64_MIN = -(2**63)
19
+ I64_MAX = 2**63 - 1
20
+ U64_MAX = 2**64 - 1
21
+
22
+ _EPOCH = datetime(1904, 1, 1, tzinfo=timezone.utc)
23
+ _MICROS_PER_SECOND = 1_000_000
24
+ _FRAC_UNIT = 1 << 64
25
+ _HALF_FRAC_UNIT = 1 << 63
26
+
27
+
28
+ def check_seconds(seconds: int) -> None:
29
+ """Reject whole-second counts that do not fit a signed 64-bit integer."""
30
+ if not I64_MIN <= seconds <= I64_MAX:
31
+ raise ValueError(f"seconds must fit a signed 64-bit integer, got {seconds}")
32
+
33
+
34
+ def check_frac(frac: int) -> None:
35
+ """Reject fractions outside the unsigned 64-bit range."""
36
+ if not 0 <= frac <= U64_MAX:
37
+ raise ValueError(f"frac must be in [0, 2**64), got {frac}")
38
+
39
+
40
+ def to_datetime(seconds: int, frac: int = 0) -> datetime:
41
+ """Convert a LabVIEW timestamp to a UTC-aware :class:`datetime.datetime`.
42
+
43
+ ``seconds`` is whole seconds since 1904-01-01 UTC and ``frac`` the 2**-64 s
44
+ fraction. The fraction is rounded to the nearest microsecond. Raises
45
+ :class:`ValueError` if ``frac`` is out of range and :class:`OverflowError`
46
+ if the instant falls outside the range of :class:`datetime.datetime`.
47
+ """
48
+ check_frac(frac)
49
+ micros = (frac * _MICROS_PER_SECOND + _HALF_FRAC_UNIT) >> 64
50
+ return _EPOCH + timedelta(microseconds=seconds * _MICROS_PER_SECOND + micros)
51
+
52
+
53
+ def from_datetime(dt: datetime) -> tuple[int, int]:
54
+ """Convert a timezone-aware :class:`datetime.datetime` to ``(seconds, frac)``.
55
+
56
+ The input must carry a timezone so the instant is unambiguous; a naive
57
+ datetime raises :class:`ValueError`. The microsecond field maps back to the
58
+ nearest 2**-64 s fraction, so a round trip is exact to the microsecond.
59
+ """
60
+ if dt.tzinfo is None or dt.tzinfo.utcoffset(dt) is None:
61
+ raise ValueError("dt must be timezone-aware")
62
+ delta = dt - _EPOCH
63
+ total_micros = (delta.days * 86400 + delta.seconds) * _MICROS_PER_SECOND + delta.microseconds
64
+ seconds, micro_remainder = divmod(total_micros, _MICROS_PER_SECOND)
65
+ frac = (micro_remainder * _FRAC_UNIT + _MICROS_PER_SECOND // 2) // _MICROS_PER_SECOND
66
+ check_seconds(seconds)
67
+ return seconds, frac
68
+
69
+
70
+ def to_float(seconds: int, frac: int = 0) -> float:
71
+ """Return seconds since the 1904 epoch as a float (LabVIEW DBL coercion).
72
+
73
+ This matches wiring a LabVIEW timestamp into a double. A float keeps only
74
+ about 52 bits of mantissa, so sub-microsecond detail is lost.
75
+ """
76
+ check_frac(frac)
77
+ return seconds + frac / _FRAC_UNIT
78
+
79
+
80
+ def from_float(value: float) -> tuple[int, int]:
81
+ """Convert seconds since the 1904 epoch (as a float) to ``(seconds, frac)``."""
82
+ seconds = math.floor(value)
83
+ frac = round((value - seconds) * _FRAC_UNIT)
84
+ if frac >= _FRAC_UNIT:
85
+ seconds += 1
86
+ frac -= _FRAC_UNIT
87
+ check_seconds(seconds)
88
+ return seconds, frac
89
+
90
+
91
+ def to_unix(seconds: int, frac: int = 0) -> float:
92
+ """Return Unix time (float seconds since 1970-01-01 UTC), like :func:`time.time`."""
93
+ return to_float(seconds, frac) - LABVIEW_UNIX_EPOCH_OFFSET
94
+
95
+
96
+ def from_unix(value: float) -> tuple[int, int]:
97
+ """Convert Unix time (float seconds since 1970-01-01 UTC) to ``(seconds, frac)``."""
98
+ return from_float(value + LABVIEW_UNIX_EPOCH_OFFSET)
lvtime/_exact.py ADDED
@@ -0,0 +1,66 @@
1
+ """Exact conversions through integers and :class:`~decimal.Decimal`, no float.
2
+
3
+ A float keeps only about 52 bits of mantissa, so 0.8 s lands on
4
+ ``0xCCCCCCCCCCCCC000`` in 2**-64 units instead of its true ``0xCCCCCCCCCCCCCCCD``.
5
+ These helpers never touch a float, so they reproduce the exact fixed-point value
6
+ NI documents and keep the full 2**-64 s resolution end to end.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from decimal import ROUND_FLOOR, ROUND_HALF_EVEN, Context, Decimal, localcontext
12
+
13
+ from ._convert import LABVIEW_UNIX_EPOCH_OFFSET, check_frac, check_seconds
14
+
15
+ # 2**-64 = 5**64 * 10**-64 exactly, so a fraction scaled this way is exact.
16
+ _FRAC_UNIT = 1 << 64
17
+ _POW5_64 = 5**64
18
+ # Enough precision for a 19-digit second count plus 64 fractional digits.
19
+ _CONTEXT = Context(prec=96)
20
+
21
+ ExactInput = Decimal | int | str
22
+
23
+
24
+ def _as_decimal(value: ExactInput) -> Decimal:
25
+ if isinstance(value, float): # pragma: no cover - guarded by typing too
26
+ raise TypeError("pass a Decimal, int or str, not a float, for an exact value")
27
+ return Decimal(value)
28
+
29
+
30
+ def to_decimal(seconds: int, frac: int = 0) -> Decimal:
31
+ """Return seconds since the 1904 epoch as an exact :class:`~decimal.Decimal`."""
32
+ check_frac(frac)
33
+ with localcontext(_CONTEXT):
34
+ return Decimal(seconds) + Decimal(frac * _POW5_64).scaleb(-64)
35
+
36
+
37
+ def from_decimal(value: ExactInput) -> tuple[int, int]:
38
+ """Convert exact seconds since the 1904 epoch to a ``(seconds, frac)`` pair.
39
+
40
+ Accepts a :class:`~decimal.Decimal`, :class:`int` or :class:`str`. A
41
+ :class:`float` is refused so a lossy value cannot slip in; use
42
+ :func:`lvtime.from_float` for the float path.
43
+ """
44
+ with localcontext(_CONTEXT):
45
+ total = _as_decimal(value)
46
+ seconds = int(total.to_integral_value(rounding=ROUND_FLOOR))
47
+ remainder = total - seconds
48
+ frac = int((remainder * _FRAC_UNIT).to_integral_value(rounding=ROUND_HALF_EVEN))
49
+ if frac == _FRAC_UNIT:
50
+ seconds += 1
51
+ frac = 0
52
+ check_seconds(seconds)
53
+ check_frac(frac)
54
+ return seconds, frac
55
+
56
+
57
+ def to_unix_decimal(seconds: int, frac: int = 0) -> Decimal:
58
+ """Return exact Unix time (seconds since 1970-01-01 UTC) as a :class:`~decimal.Decimal`."""
59
+ with localcontext(_CONTEXT):
60
+ return to_decimal(seconds, frac) - LABVIEW_UNIX_EPOCH_OFFSET
61
+
62
+
63
+ def from_unix_decimal(value: ExactInput) -> tuple[int, int]:
64
+ """Convert exact Unix time (seconds since 1970-01-01 UTC) to a ``(seconds, frac)`` pair."""
65
+ with localcontext(_CONTEXT):
66
+ return from_decimal(_as_decimal(value) + LABVIEW_UNIX_EPOCH_OFFSET)
lvtime/_timestamp.py ADDED
@@ -0,0 +1,120 @@
1
+ """The lossless :class:`LVTimestamp` integer pair and its byte and stream helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from datetime import datetime
7
+ from decimal import Decimal
8
+
9
+ from . import _codec, _convert, _exact
10
+ from ._codec import ByteOrder
11
+ from ._exact import ExactInput
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class LVTimestamp:
16
+ """A LabVIEW absolute time held without loss as two integers.
17
+
18
+ ``seconds`` is the signed 64-bit count of whole seconds since 1904-01-01
19
+ 00:00:00 UTC; ``frac`` is the unsigned 64-bit fraction in units of 2**-64 s.
20
+ Both fields are validated on construction.
21
+ """
22
+
23
+ seconds: int
24
+ frac: int = 0
25
+
26
+ def __post_init__(self) -> None:
27
+ _convert.check_seconds(self.seconds)
28
+ _convert.check_frac(self.frac)
29
+
30
+ def to_datetime(self) -> datetime:
31
+ """Return this instant as a UTC-aware datetime, rounded to the microsecond."""
32
+ return _convert.to_datetime(self.seconds, self.frac)
33
+
34
+ @classmethod
35
+ def from_datetime(cls, dt: datetime) -> LVTimestamp:
36
+ """Build a timestamp from a timezone-aware datetime."""
37
+ seconds, frac = _convert.from_datetime(dt)
38
+ return cls(seconds, frac)
39
+
40
+ def to_unix(self) -> float:
41
+ """Return this instant as Unix time (float seconds since 1970-01-01 UTC)."""
42
+ return _convert.to_unix(self.seconds, self.frac)
43
+
44
+ @classmethod
45
+ def from_unix(cls, value: float) -> LVTimestamp:
46
+ """Build a timestamp from Unix time (float seconds since 1970-01-01 UTC)."""
47
+ seconds, frac = _convert.from_unix(value)
48
+ return cls(seconds, frac)
49
+
50
+ def to_float(self) -> float:
51
+ """Return seconds since the 1904 epoch as a float (LabVIEW DBL coercion)."""
52
+ return _convert.to_float(self.seconds, self.frac)
53
+
54
+ @classmethod
55
+ def from_float(cls, value: float) -> LVTimestamp:
56
+ """Build a timestamp from float seconds since the 1904 epoch."""
57
+ seconds, frac = _convert.from_float(value)
58
+ return cls(seconds, frac)
59
+
60
+ def to_decimal(self) -> Decimal:
61
+ """Return seconds since the 1904 epoch as an exact Decimal (no float)."""
62
+ return _exact.to_decimal(self.seconds, self.frac)
63
+
64
+ @classmethod
65
+ def from_decimal(cls, value: ExactInput) -> LVTimestamp:
66
+ """Build a timestamp from exact seconds since the epoch (Decimal, int or str)."""
67
+ seconds, frac = _exact.from_decimal(value)
68
+ return cls(seconds, frac)
69
+
70
+ def to_unix_decimal(self) -> Decimal:
71
+ """Return this instant as exact Unix time (Decimal seconds since 1970-01-01 UTC)."""
72
+ return _exact.to_unix_decimal(self.seconds, self.frac)
73
+
74
+ @classmethod
75
+ def from_unix_decimal(cls, value: ExactInput) -> LVTimestamp:
76
+ """Build a timestamp from exact Unix time (Decimal, int or str)."""
77
+ seconds, frac = _exact.from_unix_decimal(value)
78
+ return cls(seconds, frac)
79
+
80
+ def to_bytes(self, byteorder: ByteOrder = "big") -> bytes:
81
+ """Return the 16-byte on-disk form in the given byte order (default big)."""
82
+ return _codec.pack(self.seconds, self.frac, byteorder)
83
+
84
+ @classmethod
85
+ def from_bytes(cls, data: bytes, byteorder: ByteOrder = "big") -> LVTimestamp:
86
+ """Build a timestamp from its 16-byte on-disk form (default big-endian)."""
87
+ seconds, frac = _codec.unpack(data, byteorder)
88
+ return cls(seconds, frac)
89
+
90
+ def hex(self, byteorder: ByteOrder = "big") -> str:
91
+ """Return the 16-byte on-disk form as a 32-character hex string (default big)."""
92
+ return self.to_bytes(byteorder).hex()
93
+
94
+ @classmethod
95
+ def from_hex(cls, text: str, byteorder: ByteOrder = "big") -> LVTimestamp:
96
+ """Build a timestamp from a 32-character hex string (default big-endian)."""
97
+ return cls.from_bytes(bytes.fromhex(text), byteorder)
98
+
99
+
100
+ def read_timestamp(data: bytes, byteorder: ByteOrder = "big") -> LVTimestamp:
101
+ """Read a :class:`LVTimestamp` from its 16-byte on-disk form (default big-endian)."""
102
+ return LVTimestamp.from_bytes(data, byteorder)
103
+
104
+
105
+ def write_timestamp(timestamp: LVTimestamp, byteorder: ByteOrder = "big") -> bytes:
106
+ """Write a :class:`LVTimestamp` as its 16-byte on-disk form (default big-endian)."""
107
+ return timestamp.to_bytes(byteorder)
108
+
109
+
110
+ def read_timestamps(data: bytes, byteorder: ByteOrder = "big") -> list[LVTimestamp]:
111
+ """Read back-to-back 16-byte records into a list of :class:`LVTimestamp`.
112
+
113
+ Raises :class:`ValueError` if the length is not a multiple of 16 bytes.
114
+ """
115
+ return [LVTimestamp(seconds, frac) for seconds, frac in _codec.unpack_many(data, byteorder)]
116
+
117
+
118
+ def write_timestamps(timestamps: list[LVTimestamp], byteorder: ByteOrder = "big") -> bytes:
119
+ """Write a list of :class:`LVTimestamp` as one contiguous buffer of 16-byte records."""
120
+ return _codec.pack_many([(ts.seconds, ts.frac) for ts in timestamps], byteorder)
lvtime/py.typed ADDED
File without changes
@@ -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/
@@ -0,0 +1,13 @@
1
+ lvtime/__init__.py,sha256=Vehc5E1RveI-BoMy3hKl7rGxfkiiQLwtMDyHpSVvAFk,935
2
+ lvtime/__main__.py,sha256=GrBOs3F9Jhl8hc0WZuuZas1n2nc-S7ln-iNHvWSpg_w,175
3
+ lvtime/_cli.py,sha256=AYVCrMvUoYRXSyT6Sx71odl9bDlcEA3paP6RDjnbFZ0,3528
4
+ lvtime/_codec.py,sha256=i1K3ciulqh_ZMIGxo113AJvDoUdt3I-f9J1u3udsHdw,2232
5
+ lvtime/_convert.py,sha256=gpqNefQ5ORC_XJzB1fct7Mem-Jc5AoeENmFlzAzAOJo,3820
6
+ lvtime/_exact.py,sha256=liZRB92qdDi3YwZcpkYnuD_uVo08xXeycCJ-HfW0ojM,2594
7
+ lvtime/_timestamp.py,sha256=XeDUoYm1uZL2vGLZbiOuDd0iWtMgGMPdDGAQSWHMAlU,4892
8
+ lvtime/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ lvtime-0.3.0.dist-info/METADATA,sha256=IvEXQd8cIuzSWuypPI6sc-jfrBI5suK3wIcVhb7piho,9237
10
+ lvtime-0.3.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
11
+ lvtime-0.3.0.dist-info/entry_points.txt,sha256=9ZbJqIXEQRtw_P78u_g4HOkW300hRCZb8uP73fvrZ44,44
12
+ lvtime-0.3.0.dist-info/licenses/LICENSE,sha256=wirD5nCA2aQa5UusK_1YHLBogI3CGcw23xAnulp3L_U,1091
13
+ lvtime-0.3.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ lvtime = lvtime._cli:main
@@ -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.