tdmspy 0.1.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.
- tdmspy/__init__.py +31 -0
- tdmspy/_format.py +61 -0
- tdmspy/_metadata.py +205 -0
- tdmspy/_scaling.py +69 -0
- tdmspy/_scan.py +269 -0
- tdmspy/errors.py +17 -0
- tdmspy/file.py +236 -0
- tdmspy/py.typed +0 -0
- tdmspy/timestamp.py +57 -0
- tdmspy-0.1.0.dist-info/METADATA +76 -0
- tdmspy-0.1.0.dist-info/RECORD +13 -0
- tdmspy-0.1.0.dist-info/WHEEL +4 -0
- tdmspy-0.1.0.dist-info/licenses/LICENSE +21 -0
tdmspy/__init__.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""tdmspy: a pure Python reader for National Instruments TDMS files (MIT licensed).
|
|
2
|
+
|
|
3
|
+
import tdmspy
|
|
4
|
+
|
|
5
|
+
file = tdmspy.read("measurement.tdms")
|
|
6
|
+
channel = file["rawdata"]["Channel1"]
|
|
7
|
+
values = channel.read()
|
|
8
|
+
|
|
9
|
+
The contract of the reader (what it reads, what it refuses, and which special cases of the format it
|
|
10
|
+
handles) is in `doc/design.md` and `doc/special-cases.md`; the tests in `tests/` check every case.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from tdmspy.errors import TdmsError, TdmsFormatError, UnsupportedFeatureError, UnsupportedScalingError
|
|
16
|
+
from tdmspy.file import Channel, Group, TdmsFile, read
|
|
17
|
+
from tdmspy.timestamp import Timestamp
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"Channel",
|
|
21
|
+
"Group",
|
|
22
|
+
"TdmsError",
|
|
23
|
+
"TdmsFile",
|
|
24
|
+
"TdmsFormatError",
|
|
25
|
+
"Timestamp",
|
|
26
|
+
"UnsupportedFeatureError",
|
|
27
|
+
"UnsupportedScalingError",
|
|
28
|
+
"read",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
__implemented__ = True
|
tdmspy/_format.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Constants and the data type table of the TDMS format (see doc/design.md for what is supported)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
LEAD_IN_SIZE = 28
|
|
8
|
+
SIGNATURE = b"TDSm"
|
|
9
|
+
INDEX_SIGNATURE = b"TDSh"
|
|
10
|
+
VERSIONS = (4712, 4713)
|
|
11
|
+
UNKNOWN_NEXT_OFFSET = 0xFFFF_FFFF_FFFF_FFFF
|
|
12
|
+
|
|
13
|
+
# table of contents flags
|
|
14
|
+
TOC_METADATA = 1 << 1
|
|
15
|
+
TOC_NEW_OBJECT_LIST = 1 << 2
|
|
16
|
+
TOC_RAW_DATA = 1 << 3
|
|
17
|
+
TOC_INTERLEAVED = 1 << 5
|
|
18
|
+
TOC_BIG_ENDIAN = 1 << 6
|
|
19
|
+
TOC_DAQMX = 1 << 7
|
|
20
|
+
|
|
21
|
+
# raw data index values
|
|
22
|
+
INDEX_NONE = 0xFFFF_FFFF
|
|
23
|
+
INDEX_SAME = 0
|
|
24
|
+
INDEX_DAQMX = frozenset({0x1269, 0x126A, 0x1369, 0x136A})
|
|
25
|
+
|
|
26
|
+
TYPE_STRING = 0x20
|
|
27
|
+
TYPE_BOOL = 0x21
|
|
28
|
+
TYPE_TIMESTAMP = 0x44
|
|
29
|
+
TYPE_EXTENDED = 0x0B
|
|
30
|
+
TYPE_COMPLEX_SINGLE = 0x0800_000C
|
|
31
|
+
TYPE_COMPLEX_DOUBLE = 0x1000_000D
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class DataType:
|
|
36
|
+
"""A type a channel can have: the numpy type code of one element (without byte order) and its size in the file."""
|
|
37
|
+
|
|
38
|
+
code: int
|
|
39
|
+
name: str
|
|
40
|
+
numpy: str
|
|
41
|
+
size: int
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
_NUMERIC = {
|
|
45
|
+
1: ("int8", "i1", 1), 2: ("int16", "i2", 2), 3: ("int32", "i4", 4), 4: ("int64", "i8", 8),
|
|
46
|
+
5: ("uint8", "u1", 1), 6: ("uint16", "u2", 2), 7: ("uint32", "u4", 4), 8: ("uint64", "u8", 8),
|
|
47
|
+
9: ("float32", "f4", 4), 10: ("float64", "f8", 8), 0x19: ("float32", "f4", 4), 0x1A: ("float64", "f8", 8),
|
|
48
|
+
TYPE_BOOL: ("bool", "u1", 1), TYPE_TIMESTAMP: ("timestamp", "", 16),
|
|
49
|
+
TYPE_COMPLEX_SINGLE: ("complex64", "c8", 8), TYPE_COMPLEX_DOUBLE: ("complex128", "c16", 16),
|
|
50
|
+
} # fmt: skip
|
|
51
|
+
|
|
52
|
+
DATA_TYPES: dict[int, DataType] = {
|
|
53
|
+
code: DataType(code, name, np_code, size) for code, (name, np_code, size) in _NUMERIC.items()
|
|
54
|
+
}
|
|
55
|
+
DATA_TYPES[TYPE_STRING] = DataType(TYPE_STRING, "string", "", 0)
|
|
56
|
+
|
|
57
|
+
# property value types: code -> (struct code, size); strings, timestamps and complex numbers are handled apart
|
|
58
|
+
PROPERTY_STRUCT = {
|
|
59
|
+
1: ("b", 1), 2: ("h", 2), 3: ("i", 4), 4: ("q", 8), 5: ("B", 1), 6: ("H", 2), 7: ("I", 4), 8: ("Q", 8),
|
|
60
|
+
9: ("f", 4), 10: ("d", 8), 0x19: ("f", 4), 0x1A: ("d", 8), TYPE_BOOL: ("B", 1),
|
|
61
|
+
} # fmt: skip
|
tdmspy/_metadata.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Parsing the metadata section of a segment into objects, with every count and length checked against the bytes
|
|
2
|
+
that are there before anything is read or allocated."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import struct
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from tdmspy._format import (
|
|
11
|
+
DATA_TYPES,
|
|
12
|
+
INDEX_DAQMX,
|
|
13
|
+
INDEX_NONE,
|
|
14
|
+
INDEX_SAME,
|
|
15
|
+
PROPERTY_STRUCT,
|
|
16
|
+
TYPE_COMPLEX_DOUBLE,
|
|
17
|
+
TYPE_COMPLEX_SINGLE,
|
|
18
|
+
TYPE_EXTENDED,
|
|
19
|
+
TYPE_STRING,
|
|
20
|
+
TYPE_TIMESTAMP,
|
|
21
|
+
)
|
|
22
|
+
from tdmspy.errors import TdmsFormatError, UnsupportedFeatureError
|
|
23
|
+
from tdmspy.timestamp import Timestamp
|
|
24
|
+
|
|
25
|
+
_MIN_OBJECT = 12 # path length, raw data index, property count
|
|
26
|
+
_MIN_PROPERTY = 8 # name length, type
|
|
27
|
+
|
|
28
|
+
NONE = "none"
|
|
29
|
+
SAME = "same"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class Index:
|
|
34
|
+
"""What an object says about its raw data in a segment."""
|
|
35
|
+
|
|
36
|
+
kind: str # NONE, SAME or "explicit"
|
|
37
|
+
type_code: int = 0
|
|
38
|
+
count: int = 0
|
|
39
|
+
total_size: int = 0 # strings: bytes of the offsets and the text of one chunk
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class ObjectMeta:
|
|
44
|
+
path: tuple[str, ...]
|
|
45
|
+
index: Index
|
|
46
|
+
properties: list[tuple[str, Any]]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def parse_path(path: str) -> tuple[str, ...]:
|
|
50
|
+
"""`/` -> (), `/'g'` -> ('g',), `/'g'/'c'` -> ('g', 'c'); `''` inside a name is one apostrophe."""
|
|
51
|
+
bad = TdmsFormatError(f"invalid object path {path!r}")
|
|
52
|
+
if not path.startswith("/"):
|
|
53
|
+
raise bad
|
|
54
|
+
names: list[str] = []
|
|
55
|
+
i, n = 1, len(path)
|
|
56
|
+
while i < n:
|
|
57
|
+
if path[i] != "'":
|
|
58
|
+
raise bad
|
|
59
|
+
i += 1
|
|
60
|
+
name = []
|
|
61
|
+
while True:
|
|
62
|
+
if i >= n:
|
|
63
|
+
raise bad
|
|
64
|
+
if path[i] == "'":
|
|
65
|
+
if i + 1 < n and path[i + 1] == "'":
|
|
66
|
+
name.append("'")
|
|
67
|
+
i += 2
|
|
68
|
+
continue
|
|
69
|
+
i += 1
|
|
70
|
+
break
|
|
71
|
+
name.append(path[i])
|
|
72
|
+
i += 1
|
|
73
|
+
names.append("".join(name))
|
|
74
|
+
if i < n:
|
|
75
|
+
if path[i] != "/" or i + 1 >= n:
|
|
76
|
+
raise bad
|
|
77
|
+
i += 1
|
|
78
|
+
if len(names) > 2:
|
|
79
|
+
raise bad
|
|
80
|
+
return tuple(names)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class _Reader:
|
|
84
|
+
def __init__(self, buffer: bytes | memoryview, big_endian: bool) -> None:
|
|
85
|
+
self.buffer = buffer
|
|
86
|
+
self.pos = 0
|
|
87
|
+
self.end = len(buffer)
|
|
88
|
+
self.e = ">" if big_endian else "<"
|
|
89
|
+
|
|
90
|
+
def remaining(self) -> int:
|
|
91
|
+
return self.end - self.pos
|
|
92
|
+
|
|
93
|
+
def need(self, count: int, what: str) -> None:
|
|
94
|
+
if count > self.end - self.pos:
|
|
95
|
+
raise TdmsFormatError(f"metadata ends inside {what}")
|
|
96
|
+
|
|
97
|
+
def u32(self, what: str) -> int:
|
|
98
|
+
self.need(4, what)
|
|
99
|
+
(value,) = struct.unpack_from(self.e + "I", self.buffer, self.pos)
|
|
100
|
+
self.pos += 4
|
|
101
|
+
return value
|
|
102
|
+
|
|
103
|
+
def u64(self, what: str) -> int:
|
|
104
|
+
self.need(8, what)
|
|
105
|
+
(value,) = struct.unpack_from(self.e + "Q", self.buffer, self.pos)
|
|
106
|
+
self.pos += 8
|
|
107
|
+
return value
|
|
108
|
+
|
|
109
|
+
def string(self, what: str) -> str:
|
|
110
|
+
length = self.u32(what)
|
|
111
|
+
self.need(length, what)
|
|
112
|
+
raw = bytes(self.buffer[self.pos : self.pos + length])
|
|
113
|
+
self.pos += length
|
|
114
|
+
try:
|
|
115
|
+
return raw.decode("utf-8")
|
|
116
|
+
except UnicodeDecodeError:
|
|
117
|
+
raise TdmsFormatError(f"{what} is not valid UTF-8") from None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _property_value(reader: _Reader, type_code: int, name: str) -> Any:
|
|
121
|
+
if type_code in PROPERTY_STRUCT:
|
|
122
|
+
code, size = PROPERTY_STRUCT[type_code]
|
|
123
|
+
reader.need(size, f"property {name!r}")
|
|
124
|
+
(value,) = struct.unpack_from(reader.e + code, reader.buffer, reader.pos)
|
|
125
|
+
reader.pos += size
|
|
126
|
+
return bool(value) if type_code == 0x21 else value
|
|
127
|
+
if type_code == TYPE_STRING:
|
|
128
|
+
return reader.string(f"property {name!r}")
|
|
129
|
+
if type_code == TYPE_TIMESTAMP:
|
|
130
|
+
reader.need(16, f"property {name!r}")
|
|
131
|
+
if reader.e == ">":
|
|
132
|
+
seconds, fraction = struct.unpack_from(">qQ", reader.buffer, reader.pos)
|
|
133
|
+
else:
|
|
134
|
+
fraction, seconds = struct.unpack_from("<Qq", reader.buffer, reader.pos)
|
|
135
|
+
reader.pos += 16
|
|
136
|
+
return Timestamp(seconds, fraction)
|
|
137
|
+
if type_code in (TYPE_COMPLEX_SINGLE, TYPE_COMPLEX_DOUBLE):
|
|
138
|
+
code, size = ("f", 8) if type_code == TYPE_COMPLEX_SINGLE else ("d", 16)
|
|
139
|
+
reader.need(size, f"property {name!r}")
|
|
140
|
+
real, imag = struct.unpack_from(reader.e + code * 2, reader.buffer, reader.pos)
|
|
141
|
+
reader.pos += size
|
|
142
|
+
return complex(real, imag)
|
|
143
|
+
if type_code == 0:
|
|
144
|
+
return None
|
|
145
|
+
if type_code in (TYPE_EXTENDED, 0x1B):
|
|
146
|
+
raise UnsupportedFeatureError(f"property {name!r} has the extended float type, which is not supported")
|
|
147
|
+
raise TdmsFormatError(f"property {name!r} has the unknown type {type_code:#x}")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _index(reader: _Reader, path: str) -> Index:
|
|
151
|
+
value = reader.u32(f"the raw data index of {path!r}")
|
|
152
|
+
if value == INDEX_NONE:
|
|
153
|
+
return Index(NONE)
|
|
154
|
+
if value == INDEX_SAME:
|
|
155
|
+
return Index(SAME)
|
|
156
|
+
if value in INDEX_DAQMX:
|
|
157
|
+
raise UnsupportedFeatureError(f"{path!r} holds DAQmx raw data, which is not supported")
|
|
158
|
+
if value < 20 or value > reader.remaining() + 4:
|
|
159
|
+
raise TdmsFormatError(f"the raw data index of {path!r} has the length {value}, which does not fit the metadata")
|
|
160
|
+
following = value - 4
|
|
161
|
+
start = reader.pos
|
|
162
|
+
type_code = reader.u32("a raw data index")
|
|
163
|
+
dimension = reader.u32("a raw data index")
|
|
164
|
+
count = reader.u64("a raw data index")
|
|
165
|
+
total = 0
|
|
166
|
+
if type_code == TYPE_STRING:
|
|
167
|
+
total = reader.u64("a raw data index")
|
|
168
|
+
if reader.pos - start > following:
|
|
169
|
+
raise TdmsFormatError(f"the raw data index of {path!r} is longer than its length says")
|
|
170
|
+
reader.pos = start + following
|
|
171
|
+
if type_code == TYPE_STRING and total < 4 * count:
|
|
172
|
+
raise TdmsFormatError(
|
|
173
|
+
f"{path!r}: {count} strings need {4 * count} bytes of offsets, the index says {total} bytes in all"
|
|
174
|
+
)
|
|
175
|
+
if dimension != 1:
|
|
176
|
+
raise TdmsFormatError(f"{path!r} has an array dimension of {dimension}; only 1 is valid")
|
|
177
|
+
if type_code not in DATA_TYPES:
|
|
178
|
+
if type_code == TYPE_EXTENDED or type_code == 0x1B:
|
|
179
|
+
raise UnsupportedFeatureError(f"{path!r} has the extended float data type, which is not supported")
|
|
180
|
+
raise TdmsFormatError(f"{path!r} has the unknown data type {type_code:#x}")
|
|
181
|
+
return Index("explicit", type_code, count, total)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def parse_metadata(buffer: bytes | memoryview, big_endian: bool) -> list[ObjectMeta]:
|
|
185
|
+
reader = _Reader(buffer, big_endian)
|
|
186
|
+
count = reader.u32("the object count")
|
|
187
|
+
if count * _MIN_OBJECT > reader.remaining():
|
|
188
|
+
raise TdmsFormatError(f"the metadata announces {count} objects but is only {reader.remaining()} bytes long")
|
|
189
|
+
objects: list[ObjectMeta] = []
|
|
190
|
+
for _ in range(count):
|
|
191
|
+
text = reader.string("an object path")
|
|
192
|
+
path = parse_path(text)
|
|
193
|
+
index = _index(reader, text)
|
|
194
|
+
if len(path) < 2 and index.kind == "explicit":
|
|
195
|
+
raise TdmsFormatError(f"{text!r} is not a channel but has raw data")
|
|
196
|
+
n_props = reader.u32(f"the properties of {text!r}")
|
|
197
|
+
if n_props * _MIN_PROPERTY > reader.remaining():
|
|
198
|
+
raise TdmsFormatError(f"{text!r} announces {n_props} properties but the metadata ends sooner")
|
|
199
|
+
properties = []
|
|
200
|
+
for _ in range(n_props):
|
|
201
|
+
name = reader.string("a property name")
|
|
202
|
+
type_code = reader.u32(f"property {name!r}")
|
|
203
|
+
properties.append((name, _property_value(reader, type_code, name)))
|
|
204
|
+
objects.append(ObjectMeta(path, index, properties))
|
|
205
|
+
return objects
|
tdmspy/_scaling.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""NI scaling: the scales declared in the properties of a channel, its group or the file (see doc/design.md)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
from tdmspy.errors import UnsupportedScalingError
|
|
11
|
+
|
|
12
|
+
RAW_DATA_INPUT_SOURCE = 0xFFFF_FFFF
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def scale_count(properties: Mapping[str, Any]) -> int | None:
|
|
16
|
+
"""How many scales these properties declare, `None` if none or the data is already scaled."""
|
|
17
|
+
count = properties.get("NI_Number_Of_Scales")
|
|
18
|
+
if isinstance(count, bool) or not isinstance(count, int) or count <= 0:
|
|
19
|
+
return None
|
|
20
|
+
if properties.get("NI_Scaling_Status", "unscaled") == "scaled":
|
|
21
|
+
return None
|
|
22
|
+
return count
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _number(properties: Mapping[str, Any], key: str) -> float:
|
|
26
|
+
value = properties.get(key)
|
|
27
|
+
if isinstance(value, bool) or not isinstance(value, int | float):
|
|
28
|
+
raise UnsupportedScalingError(f"the scaling property {key!r} is missing or not a number")
|
|
29
|
+
return float(value)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def apply_scaling(raw: np.ndarray, *levels: Mapping[str, Any]) -> np.ndarray:
|
|
33
|
+
"""`raw` scaled by the first of `levels` (channel, group, file properties) that declares scales, else `raw` itself."""
|
|
34
|
+
for properties in levels:
|
|
35
|
+
count = scale_count(properties)
|
|
36
|
+
if count is not None:
|
|
37
|
+
return _scale(raw, properties, count)
|
|
38
|
+
return raw
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _scale(raw: np.ndarray, properties: Mapping[str, Any], count: int) -> np.ndarray:
|
|
42
|
+
def compute(index: int, depth: int) -> np.ndarray:
|
|
43
|
+
if index == RAW_DATA_INPUT_SOURCE:
|
|
44
|
+
return raw.astype(np.float64)
|
|
45
|
+
if not 0 <= index < count or depth > count:
|
|
46
|
+
raise UnsupportedScalingError(
|
|
47
|
+
f"NI scale {index} does not exist or the scales refer to each other in a circle"
|
|
48
|
+
)
|
|
49
|
+
prefix = f"NI_Scale[{index}]_"
|
|
50
|
+
kind = properties.get(prefix + "Scale_Type")
|
|
51
|
+
if kind not in ("Linear", "Polynomial"):
|
|
52
|
+
what = "a DAQmx scaler" if kind is None else f"the scale type {kind!r}"
|
|
53
|
+
raise UnsupportedScalingError(f"{what} is not supported (only Linear and Polynomial scales are)")
|
|
54
|
+
source = properties.get(f"{prefix}{kind}_Input_Source", RAW_DATA_INPUT_SOURCE)
|
|
55
|
+
values = compute(int(source), depth + 1)
|
|
56
|
+
if kind == "Linear":
|
|
57
|
+
return values * _number(properties, prefix + "Linear_Slope") + _number(
|
|
58
|
+
properties, prefix + "Linear_Y_Intercept"
|
|
59
|
+
)
|
|
60
|
+
size = properties.get(prefix + "Polynomial_Coefficients_Size", 4)
|
|
61
|
+
if isinstance(size, bool) or not isinstance(size, int) or not 0 <= size <= 1024:
|
|
62
|
+
raise UnsupportedScalingError(f"the polynomial of scale {index} has an invalid number of coefficients")
|
|
63
|
+
coefficients = [_number(properties, f"{prefix}Polynomial_Coefficients[{k}]") for k in range(size)]
|
|
64
|
+
result = np.zeros(len(values))
|
|
65
|
+
for coefficient in reversed(coefficients): # Horner's rule
|
|
66
|
+
result = result * values + coefficient
|
|
67
|
+
return result
|
|
68
|
+
|
|
69
|
+
return compute(count - 1, 0)
|
tdmspy/_scan.py
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""Scanning a file: every segment's lead-in and metadata become, per channel, a list of data blocks.
|
|
2
|
+
|
|
3
|
+
The result of a scan holds no channel data. A *block* says where a run of a channel's values lies in the file:
|
|
4
|
+
`[offset, per, repeat, stride, big_endian]` is `repeat` units of `per` consecutive values, the units `stride`
|
|
5
|
+
bytes apart, the first starting at `offset`. A segment of one chunk is one unit; several chunks, or interleaved
|
|
6
|
+
data, are many units; the identical segments of an acquisition are merged into one block.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import mmap
|
|
12
|
+
import struct
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from tdmspy._format import (
|
|
17
|
+
DATA_TYPES,
|
|
18
|
+
INDEX_SIGNATURE,
|
|
19
|
+
LEAD_IN_SIZE,
|
|
20
|
+
SIGNATURE,
|
|
21
|
+
TOC_BIG_ENDIAN,
|
|
22
|
+
TOC_DAQMX,
|
|
23
|
+
TOC_INTERLEAVED,
|
|
24
|
+
TOC_METADATA,
|
|
25
|
+
TOC_NEW_OBJECT_LIST,
|
|
26
|
+
TOC_RAW_DATA,
|
|
27
|
+
TYPE_STRING,
|
|
28
|
+
UNKNOWN_NEXT_OFFSET,
|
|
29
|
+
VERSIONS,
|
|
30
|
+
)
|
|
31
|
+
from tdmspy._metadata import NONE, SAME, Index, ObjectMeta, parse_metadata
|
|
32
|
+
from tdmspy.errors import TdmsFormatError, UnsupportedFeatureError
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(eq=False)
|
|
36
|
+
class ChannelIndex:
|
|
37
|
+
"""Everything the scan learned about one channel."""
|
|
38
|
+
|
|
39
|
+
name: str
|
|
40
|
+
properties: dict[str, Any] = field(default_factory=dict)
|
|
41
|
+
type_code: int | None = None
|
|
42
|
+
last: Index | None = None # the last explicit raw data index
|
|
43
|
+
current: Index | None = None # the index in force now (None: no data in the current segments)
|
|
44
|
+
blocks: list[list[int]] = field(default_factory=list)
|
|
45
|
+
string_chunks: list[tuple[int, int, int, bool]] = field(default_factory=list) # offset, count, bytes, big endian
|
|
46
|
+
length: int = 0
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(eq=False)
|
|
50
|
+
class GroupIndex:
|
|
51
|
+
name: str
|
|
52
|
+
properties: dict[str, Any] = field(default_factory=dict)
|
|
53
|
+
channels: dict[str, ChannelIndex] = field(default_factory=dict)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class Scan:
|
|
58
|
+
properties: dict[str, Any] = field(default_factory=dict)
|
|
59
|
+
groups: dict[str, GroupIndex] = field(default_factory=dict)
|
|
60
|
+
truncated: bool = False
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _item_size(channel: ChannelIndex) -> int:
|
|
64
|
+
assert channel.type_code is not None
|
|
65
|
+
return DATA_TYPES[channel.type_code].size
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class _State:
|
|
69
|
+
def __init__(self) -> None:
|
|
70
|
+
self.scan = Scan()
|
|
71
|
+
self.active: list[ChannelIndex] = []
|
|
72
|
+
self.layout: tuple[list[ChannelIndex], list[int], int] | None = None # members, sizes, chunk size
|
|
73
|
+
|
|
74
|
+
def group(self, name: str) -> GroupIndex:
|
|
75
|
+
group = self.scan.groups.get(name)
|
|
76
|
+
if group is None:
|
|
77
|
+
group = self.scan.groups[name] = GroupIndex(name)
|
|
78
|
+
return group
|
|
79
|
+
|
|
80
|
+
def apply(self, objects: list[ObjectMeta], new_list: bool) -> None:
|
|
81
|
+
listed: list[ChannelIndex] = []
|
|
82
|
+
for obj in objects:
|
|
83
|
+
if len(obj.path) == 0:
|
|
84
|
+
self.scan.properties.update(obj.properties)
|
|
85
|
+
elif len(obj.path) == 1:
|
|
86
|
+
self.group(obj.path[0]).properties.update(obj.properties)
|
|
87
|
+
else:
|
|
88
|
+
group = self.group(obj.path[0])
|
|
89
|
+
channel = group.channels.get(obj.path[1])
|
|
90
|
+
if channel is None:
|
|
91
|
+
channel = group.channels[obj.path[1]] = ChannelIndex(obj.path[1])
|
|
92
|
+
channel.properties.update(obj.properties)
|
|
93
|
+
self._index(channel, obj.index, obj.path)
|
|
94
|
+
if channel not in listed:
|
|
95
|
+
listed.append(channel)
|
|
96
|
+
if new_list:
|
|
97
|
+
self.active = listed
|
|
98
|
+
else:
|
|
99
|
+
known = {id(c) for c in self.active}
|
|
100
|
+
self.active += [c for c in listed if id(c) not in known]
|
|
101
|
+
self.layout = None
|
|
102
|
+
|
|
103
|
+
@staticmethod
|
|
104
|
+
def _index(channel: ChannelIndex, index: Index, path: tuple[str, ...]) -> None:
|
|
105
|
+
name = "/".join(path)
|
|
106
|
+
if index.kind == NONE:
|
|
107
|
+
channel.current = None
|
|
108
|
+
elif index.kind == SAME:
|
|
109
|
+
if channel.last is None:
|
|
110
|
+
raise TdmsFormatError(f"{name!r} has the raw data index 'same as before' but never had one")
|
|
111
|
+
channel.current = channel.last
|
|
112
|
+
else:
|
|
113
|
+
if channel.type_code is not None and channel.type_code != index.type_code:
|
|
114
|
+
raise TdmsFormatError(
|
|
115
|
+
f"the data type of {name!r} changes from {channel.type_code:#x} to {index.type_code:#x}"
|
|
116
|
+
)
|
|
117
|
+
channel.type_code = index.type_code
|
|
118
|
+
channel.last = channel.current = index
|
|
119
|
+
|
|
120
|
+
def members(self) -> tuple[list[ChannelIndex], list[int], int]:
|
|
121
|
+
if self.layout is None:
|
|
122
|
+
members = [c for c in self.active if c.current is not None]
|
|
123
|
+
sizes = []
|
|
124
|
+
for channel in members:
|
|
125
|
+
index = channel.current
|
|
126
|
+
assert index is not None
|
|
127
|
+
sizes.append(index.total_size if index.type_code == TYPE_STRING else index.count * _item_size(channel))
|
|
128
|
+
self.layout = (members, sizes, sum(sizes))
|
|
129
|
+
return self.layout
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _add_block(channel: ChannelIndex, offset: int, per: int, repeat: int, stride: int, big: bool) -> None:
|
|
133
|
+
"""Append a block, merging it into the previous one when it continues it at a regular distance."""
|
|
134
|
+
blocks = channel.blocks
|
|
135
|
+
if blocks:
|
|
136
|
+
last = blocks[-1]
|
|
137
|
+
if repeat == 1 and last[1] == per and last[4] == int(big):
|
|
138
|
+
if last[2] == 1:
|
|
139
|
+
last[2], last[3] = 2, offset - last[0]
|
|
140
|
+
channel.length += per
|
|
141
|
+
return
|
|
142
|
+
if offset - (last[0] + (last[2] - 1) * last[3]) == last[3]:
|
|
143
|
+
last[2] += 1
|
|
144
|
+
channel.length += per
|
|
145
|
+
return
|
|
146
|
+
blocks.append([offset, per, repeat, stride, int(big)])
|
|
147
|
+
channel.length += per * repeat
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _segment_layout(
|
|
151
|
+
state: _State, raw_start: int, raw_len: int, interleaved: bool, big: bool, partial_allowed: bool
|
|
152
|
+
) -> bool:
|
|
153
|
+
"""Add the blocks of one segment's raw data; returns whether the data was cut inside a chunk."""
|
|
154
|
+
members, sizes, chunk = state.members()
|
|
155
|
+
if chunk == 0:
|
|
156
|
+
if raw_len > 0:
|
|
157
|
+
raise TdmsFormatError("a segment has raw data but no channel accounts for it")
|
|
158
|
+
return False
|
|
159
|
+
whole, remainder = divmod(raw_len, chunk)
|
|
160
|
+
if remainder and not partial_allowed or whole == 0 and not partial_allowed:
|
|
161
|
+
raise TdmsFormatError(
|
|
162
|
+
f"the raw data of a segment is {raw_len} bytes, which is not a whole number of chunks of {chunk} bytes"
|
|
163
|
+
)
|
|
164
|
+
if interleaved:
|
|
165
|
+
counts = {c.current.count for c in members if c.current is not None}
|
|
166
|
+
if len(counts) > 1 or any(c.type_code == TYPE_STRING for c in members):
|
|
167
|
+
raise TdmsFormatError("interleaved channels must all have the same count and cannot be strings")
|
|
168
|
+
count = counts.pop() if counts else 0
|
|
169
|
+
row = sum(_item_size(c) for c in members)
|
|
170
|
+
rows = whole * count + remainder // row
|
|
171
|
+
offset = raw_start
|
|
172
|
+
for channel in members:
|
|
173
|
+
if rows:
|
|
174
|
+
_add_block(channel, offset, 1, rows, row, big)
|
|
175
|
+
offset += _item_size(channel)
|
|
176
|
+
return remainder > 0
|
|
177
|
+
offset = 0
|
|
178
|
+
left = remainder
|
|
179
|
+
for channel, size in zip(members, sizes, strict=True):
|
|
180
|
+
index = channel.current
|
|
181
|
+
assert index is not None
|
|
182
|
+
if channel.type_code == TYPE_STRING:
|
|
183
|
+
for c in range(whole):
|
|
184
|
+
channel.string_chunks.append((raw_start + c * chunk + offset, index.count, size, big))
|
|
185
|
+
channel.length += index.count
|
|
186
|
+
else:
|
|
187
|
+
if whole and index.count:
|
|
188
|
+
_add_block(channel, raw_start + offset, index.count, whole, chunk, big)
|
|
189
|
+
if left > 0:
|
|
190
|
+
complete = min(left, size) // _item_size(channel)
|
|
191
|
+
if complete:
|
|
192
|
+
_add_block(channel, raw_start + whole * chunk + offset, complete, 1, 0, big)
|
|
193
|
+
left -= size
|
|
194
|
+
offset += size
|
|
195
|
+
return remainder > 0
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def scan_file(path: str, *, strict: bool) -> Scan:
|
|
199
|
+
with open(path, "rb") as handle:
|
|
200
|
+
handle.seek(0, 2)
|
|
201
|
+
size = handle.tell()
|
|
202
|
+
if size < LEAD_IN_SIZE:
|
|
203
|
+
raise TdmsFormatError(f"{path}: {size} bytes are too few for a TDMS file (a segment starts with 28 bytes)")
|
|
204
|
+
with mmap.mmap(handle.fileno(), 0, access=mmap.ACCESS_READ) as data:
|
|
205
|
+
return _scan(data, size, strict)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _cut(strict: bool, message: str) -> None:
|
|
209
|
+
if strict:
|
|
210
|
+
raise TdmsFormatError(message)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _scan(data: mmap.mmap, size: int, strict: bool) -> Scan:
|
|
214
|
+
state = _State()
|
|
215
|
+
scan = state.scan
|
|
216
|
+
pos = 0
|
|
217
|
+
segments = 0
|
|
218
|
+
cache_key: tuple | None = None
|
|
219
|
+
while pos < size:
|
|
220
|
+
if size - pos < LEAD_IN_SIZE:
|
|
221
|
+
_cut(strict, f"the file ends inside the lead-in of a segment (byte {pos})")
|
|
222
|
+
scan.truncated = True
|
|
223
|
+
break
|
|
224
|
+
tag = data[pos : pos + 4]
|
|
225
|
+
if tag != SIGNATURE:
|
|
226
|
+
if tag == INDEX_SIGNATURE:
|
|
227
|
+
raise TdmsFormatError("this is a TDMS index file (.tdms_index): it holds no data, open the .tdms file")
|
|
228
|
+
raise TdmsFormatError(f"no TDMS segment at byte {pos} (found {bytes(tag)!r})")
|
|
229
|
+
(toc,) = struct.unpack_from("<I", data, pos + 4)
|
|
230
|
+
big = bool(toc & TOC_BIG_ENDIAN)
|
|
231
|
+
version, next_offset, raw_offset = struct.unpack_from((">" if big else "<") + "IQQ", data, pos + 8)
|
|
232
|
+
if version not in VERSIONS:
|
|
233
|
+
raise TdmsFormatError(f"unknown TDMS version {version} in the segment at byte {pos} (known: {VERSIONS})")
|
|
234
|
+
unknown_end = next_offset == UNKNOWN_NEXT_OFFSET
|
|
235
|
+
end = size if unknown_end else pos + LEAD_IN_SIZE + next_offset
|
|
236
|
+
cut = end > size
|
|
237
|
+
if cut:
|
|
238
|
+
end = size
|
|
239
|
+
raw_start = pos + LEAD_IN_SIZE + raw_offset
|
|
240
|
+
if raw_start > end:
|
|
241
|
+
if cut:
|
|
242
|
+
_cut(strict, f"the file ends inside the metadata of the segment at byte {pos}")
|
|
243
|
+
scan.truncated = True
|
|
244
|
+
break
|
|
245
|
+
raise TdmsFormatError(f"the raw data offset of the segment at byte {pos} points beyond the segment")
|
|
246
|
+
|
|
247
|
+
if toc & TOC_METADATA:
|
|
248
|
+
metadata = bytes(data[pos + LEAD_IN_SIZE : raw_start])
|
|
249
|
+
key = (metadata, toc & (TOC_NEW_OBJECT_LIST | TOC_BIG_ENDIAN))
|
|
250
|
+
if key != cache_key: # identical metadata twice in a row changes nothing the second time
|
|
251
|
+
objects = parse_metadata(metadata, big)
|
|
252
|
+
state.apply(objects, bool(toc & TOC_NEW_OBJECT_LIST))
|
|
253
|
+
cache_key = key
|
|
254
|
+
raw_len = end - raw_start
|
|
255
|
+
if toc & TOC_DAQMX and raw_len:
|
|
256
|
+
raise UnsupportedFeatureError("DAQmx raw data is not supported")
|
|
257
|
+
partial = False
|
|
258
|
+
if raw_len or toc & TOC_RAW_DATA:
|
|
259
|
+
partial = _segment_layout(
|
|
260
|
+
state, raw_start, raw_len, bool(toc & TOC_INTERLEAVED), big, partial_allowed=cut or unknown_end
|
|
261
|
+
)
|
|
262
|
+
if cut or partial:
|
|
263
|
+
_cut(strict, f"the file ends inside the segment at byte {pos}")
|
|
264
|
+
scan.truncated = True
|
|
265
|
+
segments += 1
|
|
266
|
+
pos = end
|
|
267
|
+
if segments == 0:
|
|
268
|
+
raise TdmsFormatError("the file has no complete segment")
|
|
269
|
+
return scan
|
tdmspy/errors.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""The exceptions of tdmspy. Everything the package raises on purpose is a `TdmsError`."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class TdmsError(Exception):
|
|
5
|
+
"""Base class of every error raised for a file's content."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TdmsFormatError(TdmsError, ValueError):
|
|
9
|
+
"""The file is not valid TDMS (wrong signature, corrupt or inconsistent segments, ...)."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class UnsupportedFeatureError(TdmsError, NotImplementedError):
|
|
13
|
+
"""The file is valid TDMS but uses something this package does not read (DAQmx raw data, ...)."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class UnsupportedScalingError(UnsupportedFeatureError):
|
|
17
|
+
"""A channel's NI scaling uses a scale type that is not implemented (`read(scaled=False)` still works)."""
|
tdmspy/file.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""The objects `tdmspy.read` returns: `TdmsFile`, `Group` and `Channel` (see doc/design.md)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import mmap
|
|
6
|
+
import os
|
|
7
|
+
from collections.abc import Iterator
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
|
|
13
|
+
from tdmspy._format import DATA_TYPES, TYPE_BOOL, TYPE_STRING, TYPE_TIMESTAMP
|
|
14
|
+
from tdmspy._scaling import apply_scaling
|
|
15
|
+
from tdmspy._scan import ChannelIndex, GroupIndex, Scan, scan_file
|
|
16
|
+
from tdmspy.errors import TdmsFormatError
|
|
17
|
+
from tdmspy.timestamp import UNIX_EPOCH_IN_TDMS_SECONDS
|
|
18
|
+
|
|
19
|
+
_LE_STAMP = np.dtype([("fraction", "<u8"), ("seconds", "<i8")])
|
|
20
|
+
_BE_STAMP = np.dtype([("seconds", ">i8"), ("fraction", ">u8")])
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _microseconds(seconds: np.ndarray, fraction: np.ndarray) -> np.ndarray:
|
|
24
|
+
"""`datetime64[us]` from TDMS seconds and 2**-64 fractions: the fraction is truncated, exactly (no floats).
|
|
25
|
+
|
|
26
|
+
floor(fraction * 10**6 / 2**64) with the fraction split in two 32 bit halves, so nothing overflows 64 bits.
|
|
27
|
+
"""
|
|
28
|
+
thirty_two = np.uint64(32)
|
|
29
|
+
high, low = fraction >> thirty_two, fraction & np.uint64(0xFFFF_FFFF)
|
|
30
|
+
a, b = high * np.uint64(1_000_000), low * np.uint64(1_000_000)
|
|
31
|
+
micro = ((a + (b >> thirty_two)) >> thirty_two).astype(np.int64)
|
|
32
|
+
ticks = (seconds.astype(np.int64) - UNIX_EPOCH_IN_TDMS_SECONDS) * 1_000_000 + micro
|
|
33
|
+
return ticks.astype("datetime64[us]")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _decode(raw: bytes) -> str:
|
|
37
|
+
try:
|
|
38
|
+
return raw.decode("utf-8")
|
|
39
|
+
except UnicodeDecodeError:
|
|
40
|
+
raise TdmsFormatError("a string value is not valid UTF-8") from None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Channel:
|
|
44
|
+
"""A channel: its properties, data type, length, and `read` for its values."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, index: ChannelIndex, group: Group, file: TdmsFile) -> None:
|
|
47
|
+
self.name = index.name
|
|
48
|
+
self.properties: dict[str, Any] = index.properties
|
|
49
|
+
self._group = group
|
|
50
|
+
self._file = file
|
|
51
|
+
self._type_code = index.type_code
|
|
52
|
+
self._length = index.length
|
|
53
|
+
self._strings = index.string_chunks
|
|
54
|
+
self._blocks = np.array(index.blocks, dtype=np.int64).reshape(-1, 5)
|
|
55
|
+
self._starts = np.concatenate(([0], np.cumsum(self._blocks[:, 1] * self._blocks[:, 2])))
|
|
56
|
+
self._dtype = None if index.type_code is None else self._native_dtype(index.type_code)
|
|
57
|
+
|
|
58
|
+
@staticmethod
|
|
59
|
+
def _native_dtype(type_code: int) -> np.dtype:
|
|
60
|
+
if type_code == TYPE_STRING:
|
|
61
|
+
return np.dtype(object)
|
|
62
|
+
if type_code == TYPE_BOOL:
|
|
63
|
+
return np.dtype(bool)
|
|
64
|
+
if type_code == TYPE_TIMESTAMP:
|
|
65
|
+
return np.dtype("datetime64[us]")
|
|
66
|
+
return np.dtype("=" + DATA_TYPES[type_code].numpy)
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def dtype(self) -> np.dtype | None:
|
|
70
|
+
"""The numpy dtype of the values (native byte order); `None` for a channel that never has a data type."""
|
|
71
|
+
return self._dtype
|
|
72
|
+
|
|
73
|
+
def __len__(self) -> int:
|
|
74
|
+
return self._length
|
|
75
|
+
|
|
76
|
+
def __repr__(self) -> str:
|
|
77
|
+
return f"<Channel {self.name!r}: {self._dtype}, {self._length} values>"
|
|
78
|
+
|
|
79
|
+
def read(self, start: int = 0, stop: int | None = None, *, scaled: bool = True) -> np.ndarray:
|
|
80
|
+
"""The values `start` up to `stop` (all by default) as a new array; NI scaling is applied unless `scaled=False`."""
|
|
81
|
+
if start < 0 or (stop is not None and stop < 0):
|
|
82
|
+
raise ValueError("start and stop must not be negative")
|
|
83
|
+
stop = self._length if stop is None else min(stop, self._length)
|
|
84
|
+
start = min(start, self._length)
|
|
85
|
+
stop = max(stop, start)
|
|
86
|
+
if self._dtype is None:
|
|
87
|
+
return np.empty(0)
|
|
88
|
+
raw = self._read_strings(start, stop) if self._type_code == TYPE_STRING else self._read_numbers(start, stop)
|
|
89
|
+
if scaled and raw.dtype.kind in "iuf":
|
|
90
|
+
return apply_scaling(raw, self.properties, self._group.properties, self._file.properties)
|
|
91
|
+
return raw
|
|
92
|
+
|
|
93
|
+
# -- numbers ---------------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
def _read_numbers(self, start: int, stop: int) -> np.ndarray:
|
|
96
|
+
assert self._dtype is not None
|
|
97
|
+
out = np.empty(stop - start, dtype=self._dtype)
|
|
98
|
+
if stop == start:
|
|
99
|
+
return out
|
|
100
|
+
first = int(np.searchsorted(self._starts, start, side="right")) - 1
|
|
101
|
+
last = int(np.searchsorted(self._starts, stop, side="left"))
|
|
102
|
+
with open(self._file.path, "rb") as handle, mmap.mmap(handle.fileno(), 0, access=mmap.ACCESS_READ) as data:
|
|
103
|
+
for i in range(first, last):
|
|
104
|
+
base = int(self._starts[i])
|
|
105
|
+
lo, hi = max(start, base), min(stop, int(self._starts[i + 1]))
|
|
106
|
+
if lo < hi:
|
|
107
|
+
offset, per, repeat, stride, big = (int(v) for v in self._blocks[i])
|
|
108
|
+
self._read_block(
|
|
109
|
+
data,
|
|
110
|
+
(offset, per, repeat, stride, bool(big)),
|
|
111
|
+
lo - base,
|
|
112
|
+
hi - base,
|
|
113
|
+
out[lo - start : hi - start],
|
|
114
|
+
)
|
|
115
|
+
return out
|
|
116
|
+
|
|
117
|
+
def _read_block(
|
|
118
|
+
self, data: mmap.mmap, block: tuple[int, int, int, int, bool], v0: int, v1: int, out: np.ndarray
|
|
119
|
+
) -> None:
|
|
120
|
+
"""Values `v0` to `v1` of one block into `out`."""
|
|
121
|
+
assert self._type_code is not None
|
|
122
|
+
offset, per, repeat, stride, big = block
|
|
123
|
+
info = DATA_TYPES[self._type_code]
|
|
124
|
+
if self._type_code == TYPE_TIMESTAMP:
|
|
125
|
+
dtype, size = (_BE_STAMP if big else _LE_STAMP), 16
|
|
126
|
+
else:
|
|
127
|
+
dtype, size = np.dtype(("<>"[big]) + info.numpy), info.size
|
|
128
|
+
if offset + (repeat - 1) * stride + per * size > len(data):
|
|
129
|
+
raise TdmsFormatError("the file is shorter than when it was opened")
|
|
130
|
+
|
|
131
|
+
def copy(unit: int, count: int, within: int, into: np.ndarray, units: int = 1) -> None:
|
|
132
|
+
view = np.ndarray(
|
|
133
|
+
(units, count),
|
|
134
|
+
dtype=dtype,
|
|
135
|
+
buffer=data,
|
|
136
|
+
offset=offset + unit * stride + within * size,
|
|
137
|
+
strides=(stride, size),
|
|
138
|
+
)
|
|
139
|
+
self._convert(view, into.reshape(units, count))
|
|
140
|
+
|
|
141
|
+
wanted = v1 - v0
|
|
142
|
+
position = 0
|
|
143
|
+
unit, within = divmod(v0, per)
|
|
144
|
+
if within: # the tail of a first unit that is only partly wanted
|
|
145
|
+
count = min(per - within, wanted)
|
|
146
|
+
copy(unit, count, within, out[:count])
|
|
147
|
+
position, unit = count, unit + 1
|
|
148
|
+
full = (v1 // per) - unit
|
|
149
|
+
if full > 0 and position < wanted:
|
|
150
|
+
copy(unit, per, 0, out[position : position + full * per], units=full)
|
|
151
|
+
position, unit = position + full * per, unit + full
|
|
152
|
+
if position < wanted: # the head of a last unit that is only partly wanted
|
|
153
|
+
copy(unit, wanted - position, 0, out[position:])
|
|
154
|
+
|
|
155
|
+
def _convert(self, view: np.ndarray, into: np.ndarray) -> None:
|
|
156
|
+
if self._type_code == TYPE_TIMESTAMP:
|
|
157
|
+
into[...] = _microseconds(view["seconds"], view["fraction"].astype(np.uint64))
|
|
158
|
+
elif self._type_code == TYPE_BOOL:
|
|
159
|
+
into[...] = view != 0
|
|
160
|
+
else:
|
|
161
|
+
into[...] = view
|
|
162
|
+
|
|
163
|
+
# -- strings ---------------------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
def _read_strings(self, start: int, stop: int) -> np.ndarray:
|
|
166
|
+
out = np.empty(stop - start, dtype=object)
|
|
167
|
+
position = 0
|
|
168
|
+
with open(self._file.path, "rb") as handle:
|
|
169
|
+
for offset, count, total, big in self._strings:
|
|
170
|
+
lo, hi = max(start, position), min(stop, position + count)
|
|
171
|
+
if lo < hi:
|
|
172
|
+
handle.seek(offset)
|
|
173
|
+
chunk = handle.read(total)
|
|
174
|
+
if len(chunk) < total or total < 4 * count:
|
|
175
|
+
raise TdmsFormatError("a string channel's data is shorter than its index says")
|
|
176
|
+
ends = np.frombuffer(chunk, dtype=("<>"[big]) + "u4", count=count)
|
|
177
|
+
text = chunk[4 * count :]
|
|
178
|
+
for i in range(lo - position, hi - position):
|
|
179
|
+
begin, end = (int(ends[i - 1]) if i else 0), int(ends[i])
|
|
180
|
+
if not begin <= end <= len(text):
|
|
181
|
+
raise TdmsFormatError("the offsets of a string channel are inconsistent")
|
|
182
|
+
out[lo - start + i - (lo - position)] = _decode(text[begin:end])
|
|
183
|
+
position += count
|
|
184
|
+
if position >= stop:
|
|
185
|
+
break
|
|
186
|
+
return out
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class Group:
|
|
190
|
+
"""A group: its properties and its channels, in file order."""
|
|
191
|
+
|
|
192
|
+
def __init__(self, index: GroupIndex, file: TdmsFile) -> None:
|
|
193
|
+
self.name = index.name
|
|
194
|
+
self.properties: dict[str, Any] = index.properties
|
|
195
|
+
self.channels: list[Channel] = [Channel(c, self, file) for c in index.channels.values()]
|
|
196
|
+
self._by_name = {c.name: c for c in self.channels}
|
|
197
|
+
|
|
198
|
+
def __getitem__(self, name: str) -> Channel:
|
|
199
|
+
return self._by_name[name]
|
|
200
|
+
|
|
201
|
+
def __contains__(self, name: object) -> bool:
|
|
202
|
+
return name in self._by_name
|
|
203
|
+
|
|
204
|
+
def __iter__(self) -> Iterator[Channel]:
|
|
205
|
+
return iter(self.channels)
|
|
206
|
+
|
|
207
|
+
def __repr__(self) -> str:
|
|
208
|
+
return f"<Group {self.name!r}: {len(self.channels)} channels>"
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class TdmsFile:
|
|
212
|
+
"""A TDMS file: its properties and groups. Channel data is read when asked for."""
|
|
213
|
+
|
|
214
|
+
def __init__(self, path: str | os.PathLike[str], scan: Scan) -> None:
|
|
215
|
+
self.path = Path(path)
|
|
216
|
+
self.properties: dict[str, Any] = scan.properties
|
|
217
|
+
self.truncated = scan.truncated
|
|
218
|
+
self.groups: list[Group] = [Group(g, self) for g in scan.groups.values()]
|
|
219
|
+
self._by_name = {g.name: g for g in self.groups}
|
|
220
|
+
|
|
221
|
+
def __getitem__(self, name: str) -> Group:
|
|
222
|
+
return self._by_name[name]
|
|
223
|
+
|
|
224
|
+
def __contains__(self, name: object) -> bool:
|
|
225
|
+
return name in self._by_name
|
|
226
|
+
|
|
227
|
+
def __iter__(self) -> Iterator[Group]:
|
|
228
|
+
return iter(self.groups)
|
|
229
|
+
|
|
230
|
+
def __repr__(self) -> str:
|
|
231
|
+
return f"<TdmsFile {self.path.name!r}: {len(self.groups)} groups>"
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def read(path: str | os.PathLike[str], *, strict: bool = False) -> TdmsFile:
|
|
235
|
+
"""Open the TDMS file at `path`: its structure is read, the channel data is not (see doc/design.md)."""
|
|
236
|
+
return TdmsFile(path, scan_file(os.fspath(path), strict=strict))
|
tdmspy/py.typed
ADDED
|
File without changes
|
tdmspy/timestamp.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""The TDMS timestamp: seconds since 1904-01-01 00:00:00 UTC plus a fraction of a second.
|
|
2
|
+
|
|
3
|
+
A property or channel value of type "timestamp" keeps both parts exactly, as the file stores them.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
|
|
12
|
+
FRACTIONS_PER_SECOND = 1 << 64
|
|
13
|
+
"""The fraction counts units of 2**-64 seconds."""
|
|
14
|
+
|
|
15
|
+
UNIX_EPOCH_IN_TDMS_SECONDS = 2_082_844_800
|
|
16
|
+
"""Seconds between 1904-01-01 and 1970-01-01."""
|
|
17
|
+
|
|
18
|
+
_UNITS_PER_SECOND = {"s": 1, "ms": 1_000, "us": 1_000_000, "ns": 1_000_000_000}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True, order=True)
|
|
22
|
+
class Timestamp:
|
|
23
|
+
"""`seconds` since 1904-01-01 UTC (may be negative) and `fraction` of a second in units of 2**-64 s."""
|
|
24
|
+
|
|
25
|
+
seconds: int
|
|
26
|
+
fraction: int = 0
|
|
27
|
+
|
|
28
|
+
def __post_init__(self) -> None:
|
|
29
|
+
if not 0 <= self.fraction < FRACTIONS_PER_SECOND:
|
|
30
|
+
raise ValueError(f"fraction must be in [0, 2**64), got {self.fraction}")
|
|
31
|
+
|
|
32
|
+
def to_datetime64(self, unit: str = "us") -> np.datetime64:
|
|
33
|
+
"""UTC time as a `numpy.datetime64` of `unit` (`s`, `ms`, `us` or `ns`), truncated (never rounded up).
|
|
34
|
+
|
|
35
|
+
The fraction is converted with integer arithmetic, so the result is exact.
|
|
36
|
+
"""
|
|
37
|
+
try:
|
|
38
|
+
per_second = _UNITS_PER_SECOND[unit]
|
|
39
|
+
except KeyError:
|
|
40
|
+
raise ValueError(f"unit must be one of {sorted(_UNITS_PER_SECOND)}, got {unit!r}") from None
|
|
41
|
+
ticks = (
|
|
42
|
+
self.seconds - UNIX_EPOCH_IN_TDMS_SECONDS
|
|
43
|
+
) * per_second + self.fraction * per_second // FRACTIONS_PER_SECOND
|
|
44
|
+
return np.datetime64(ticks, unit)
|
|
45
|
+
|
|
46
|
+
@classmethod
|
|
47
|
+
def from_datetime64(cls, value: np.datetime64) -> Timestamp:
|
|
48
|
+
"""The timestamp of a `numpy.datetime64` (any unit up to `ns`), exact for the value given."""
|
|
49
|
+
unit = np.datetime_data(value.dtype)[0]
|
|
50
|
+
if unit not in _UNITS_PER_SECOND:
|
|
51
|
+
raise ValueError(f"unsupported datetime64 unit {unit!r}")
|
|
52
|
+
per_second = _UNITS_PER_SECOND[unit]
|
|
53
|
+
ticks = int(value.astype(f"datetime64[{unit}]").astype(np.int64))
|
|
54
|
+
seconds, remainder = divmod(ticks, per_second)
|
|
55
|
+
# round up so that to_datetime64 of the result gives the same value back
|
|
56
|
+
fraction = -(-remainder * FRACTIONS_PER_SECOND // per_second)
|
|
57
|
+
return cls(seconds + UNIX_EPOCH_IN_TDMS_SECONDS, fraction)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: tdmspy
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Pure Python reader for National Instruments TDMS files, MIT licensed
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Requires-Python: >=3.13
|
|
8
|
+
Requires-Dist: numpy>=2.5.1
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
# tdmspy
|
|
12
|
+
|
|
13
|
+
A pure Python reader for National Instruments **TDMS** files, MIT licensed. Its only runtime dependency is numpy.
|
|
14
|
+
|
|
15
|
+
It is written from the format specification and tested against real files and
|
|
16
|
+
synthetic test cases, one for every special case of the format.
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
uv venv --allow-existing
|
|
22
|
+
uv sync --all-extras
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Usage
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
import tdmspy
|
|
29
|
+
|
|
30
|
+
file = tdmspy.read("measurement.tdms") # indexes the file; no channel data is read yet
|
|
31
|
+
for group in file.groups: # in file order, not sorted
|
|
32
|
+
for channel in group.channels:
|
|
33
|
+
print(group.name, channel.name, channel.dtype, len(channel), channel.properties)
|
|
34
|
+
|
|
35
|
+
values = file["rawdata"]["Channel1"].read() # numpy array, native byte order, NI scaling applied
|
|
36
|
+
raw = file["rawdata"]["Channel1"].read(scaled=False)
|
|
37
|
+
part = file["rawdata"]["Channel1"].read(start=1000, stop=2000)
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
The details (what is returned for each data type, what is refused, how truncated files are treated) are in
|
|
41
|
+
[`doc/design.md`](doc/design.md); every special case of the format the reader has to handle is listed with the files
|
|
42
|
+
that exercise it in [`doc/special-cases.md`](doc/special-cases.md).
|
|
43
|
+
|
|
44
|
+
## Tests
|
|
45
|
+
|
|
46
|
+
```sh
|
|
47
|
+
just test # lint, then everything except the slow tests
|
|
48
|
+
just test-all # including large files and long fuzz runs
|
|
49
|
+
just testcases # rewrite the stored synthetic files in tests/data/synthetic
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
The tests are the specification of the reader:
|
|
53
|
+
|
|
54
|
+
- `tests/catalogue.py` lists every special case of the format. Add a case there first.
|
|
55
|
+
- `tests/builder.py` is a byte-level TDMS writer for the tests (interleaved, big-endian, several chunks, truncated,
|
|
56
|
+
corrupt, every property type, ...). It does not use tdmspy.
|
|
57
|
+
- `tests/testcases.py` has one small file per special case, each with what it must read as (`Truth`). The files are
|
|
58
|
+
stored in `tests/data/synthetic`; the long ones are built when a test needs them.
|
|
59
|
+
- `tests/data/real` are real files, with their identifying strings replaced by
|
|
60
|
+
placeholders; `tests/test_real_files.py` checks them and `tests/test_privacy.py` keeps identifiers out of the test data.
|
|
61
|
+
- `tests/test_reader_*.py` are the tests of `tdmspy.read`: all test cases, the API, strict mode and scaling.
|
|
62
|
+
- `tests/test_fuzz.py` and `tests/test_robust.py` damage files and read large ones.
|
|
63
|
+
|
|
64
|
+
`just catalogue` regenerates `doc/special-cases.md`.
|
|
65
|
+
|
|
66
|
+
## Inspired by
|
|
67
|
+
|
|
68
|
+
[npTDMS](https://github.com/adamreeve/npTDMS), [pyTDMS](https://github.com/petebachant/pyTDMS) and
|
|
69
|
+
[ICOschema](https://github.com/ift-tuwien/ICOschema), which tdmspy was created to aid: ICOschema reads TDMS files
|
|
70
|
+
through it. No code of any of these projects is used here.
|
|
71
|
+
|
|
72
|
+
TDMS, LabVIEW and NI are trademarks of National Instruments. tdmspy is not affiliated with or endorsed by NI.
|
|
73
|
+
|
|
74
|
+
## Licence
|
|
75
|
+
|
|
76
|
+
MIT, see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
tdmspy/__init__.py,sha256=XUuRR04aC2K5xjy0uVt7kqu3DOpDSMDILT3t1E4CY7Y,870
|
|
2
|
+
tdmspy/_format.py,sha256=5Kfb6xi1Z-dKuv5LGtC9z1ur0fQ61eo-l3LIlQOuvlc,2010
|
|
3
|
+
tdmspy/_metadata.py,sha256=oTAJ7qekHPC7i2M-EKLaaq71d1KboiRcUUgRs8Q0hWI,7543
|
|
4
|
+
tdmspy/_scaling.py,sha256=KjAkhAGkzyR8y1iTo2IQ_BgS2hwfbEgxvHcQHCyvgZQ,3077
|
|
5
|
+
tdmspy/_scan.py,sha256=zaDr5epjrjsHw5srrHpmOsVq8Yp7gq5bZzeVWvRZZ7I,11024
|
|
6
|
+
tdmspy/errors.py,sha256=520q0a5VfloULbgqUG0-nFdwzhcZTPmrXXEIeXv_kco,665
|
|
7
|
+
tdmspy/file.py,sha256=IvcTQk1iqWGXlZziyINByQ6IQUPjipLD7WbSCF6-ia8,10220
|
|
8
|
+
tdmspy/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
tdmspy/timestamp.py,sha256=VSYZykzYy173MKDttY6XkZeRPJFeXj5-G7O53wgYQwQ,2317
|
|
10
|
+
tdmspy-0.1.0.dist-info/METADATA,sha256=1dLjfO5LymjFAOo4tyPmaHLJP-GbIg1vAWtQT1dNPZY,3048
|
|
11
|
+
tdmspy-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
12
|
+
tdmspy-0.1.0.dist-info/licenses/LICENSE,sha256=pGTOdcwdvrXSTL8WKTZQxYlYVikuc6srBYve_adrc0s,1147
|
|
13
|
+
tdmspy-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Moritz Breurather, Institute of Production Engineering and Photonic Technologies TU Wien
|
|
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.
|