tdmslite 2026.10.31__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,2 @@
1
+ # Auto detect text files and perform LF normalization
2
+ * text=auto
@@ -0,0 +1,35 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+
7
+ jobs:
8
+ pypi-publish:
9
+ name: Build and publish to PyPI
10
+ runs-on: ubuntu-latest
11
+ environment:
12
+ name: pypi
13
+ url: https://pypi.org/p/tdmslite
14
+ permissions:
15
+ id-token: write
16
+
17
+ steps:
18
+ - name: Checkout repository
19
+ uses: actions/checkout@v4
20
+
21
+ - name: Set up Python
22
+ uses: actions/setup-python@v5
23
+ with:
24
+ python-version: "3.12"
25
+
26
+ - name: Install build dependencies
27
+ run: |
28
+ python -m pip install --upgrade pip
29
+ python -m pip install build
30
+
31
+ - name: Build sdist and wheel
32
+ run: python -m build
33
+
34
+ - name: Publish package to PyPI
35
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,181 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ share/python-wheels/
24
+ *.egg-info/
25
+ .installed.cfg
26
+ *.egg
27
+ MANIFEST
28
+
29
+ # PyInstaller
30
+ # Usually these files are written by a python script from a template
31
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
32
+ *.manifest
33
+ *.spec
34
+
35
+ # Installer logs
36
+ pip-log.txt
37
+ pip-delete-this-directory.txt
38
+
39
+ # Unit test / coverage reports
40
+ htmlcov/
41
+ .tox/
42
+ .nox/
43
+ .coverage
44
+ .coverage.*
45
+ .cache
46
+ nosetests.xml
47
+ coverage.xml
48
+ *.cover
49
+ *.py,cover
50
+ .hypothesis/
51
+ .pytest_cache/
52
+ cover/
53
+
54
+ # Translations
55
+ *.mo
56
+ *.pot
57
+
58
+ # Django stuff:
59
+ *.log
60
+ local_settings.py
61
+ db.sqlite3
62
+ db.sqlite3-journal
63
+
64
+ # Flask stuff:
65
+ instance/
66
+ .webassets-cache
67
+
68
+ # Scrapy stuff:
69
+ .scrapy
70
+
71
+ # Sphinx documentation
72
+ docs/_build/
73
+
74
+ # PyBuilder
75
+ .pybuilder/
76
+ target/
77
+
78
+ # Jupyter Notebook
79
+ .ipynb_checkpoints
80
+
81
+ # IPython
82
+ profile_default/
83
+ ipython_config.py
84
+
85
+ # pyenv
86
+ # For a library or package, you might want to ignore these files since the code is
87
+ # intended to run in multiple environments; otherwise, check them in:
88
+ # .python-version
89
+
90
+ # pipenv
91
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
93
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
94
+ # install all needed dependencies.
95
+ #Pipfile.lock
96
+
97
+ # UV
98
+ # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
99
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
100
+ # commonly ignored for libraries.
101
+ #uv.lock
102
+
103
+ # poetry
104
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
105
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
106
+ # commonly ignored for libraries.
107
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
108
+ #poetry.lock
109
+
110
+ # pdm
111
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
112
+ #pdm.lock
113
+ # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
114
+ # in version control.
115
+ # https://pdm.fming.dev/latest/usage/project/#working-with-version-control
116
+ .pdm.toml
117
+ .pdm-python
118
+ .pdm-build/
119
+
120
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
121
+ __pypackages__/
122
+
123
+ # Celery stuff
124
+ celerybeat-schedule
125
+ celerybeat.pid
126
+
127
+ # SageMath parsed files
128
+ *.sage.py
129
+
130
+ # Environments
131
+ .env
132
+ .venv
133
+ env/
134
+ venv/
135
+ ENV/
136
+ env.bak/
137
+ venv.bak/
138
+
139
+ # Spyder project settings
140
+ .spyderproject
141
+ .spyproject
142
+
143
+ # Rope project settings
144
+ .ropeproject
145
+
146
+ # mkdocs documentation
147
+ /site
148
+
149
+ # mypy
150
+ .mypy_cache/
151
+ .dmypy.json
152
+ dmypy.json
153
+
154
+ # Pyre type checker
155
+ .pyre/
156
+
157
+ # pytype static type analyzer
158
+ .pytype/
159
+
160
+ # Cython debug symbols
161
+ cython_debug/
162
+
163
+ # PyCharm
164
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
165
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
166
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
167
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
168
+ #.idea/
169
+
170
+ # Ruff stuff:
171
+ .ruff_cache/
172
+
173
+ # PyPI configuration file
174
+ .pypirc
175
+
176
+ # Cursor
177
+ # Cursor is an AI-powered code editor.`.cursorignore` specifies files/directories to
178
+ # exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
179
+ # refer to https://docs.cursor.com/context/ignore-files
180
+ .cursorignore
181
+ .cursorindexingignore
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ajbenjam-purdue
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.
@@ -0,0 +1,65 @@
1
+ Metadata-Version: 2.5
2
+ Name: tdmslite
3
+ Version: 2026.10.31
4
+ Summary: A lightweight python package to write to and read from National Instruments TDMS files with no external dependencies.
5
+ Project-URL: Homepage, https://github.com/ajben/TDMSlite
6
+ Project-URL: Repository, https://github.com/ajben/TDMSlite
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Keywords: data,labview,measurement,national-instruments,ni,tdms
10
+ Classifier: Development Status :: 2 - Pre-Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Intended Audience :: Science/Research
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Programming Language :: Python :: 3.14
18
+ Classifier: Programming Language :: Python :: 3.15
19
+ Classifier: Programming Language :: Python :: 3.16
20
+ Classifier: Topic :: File Formats
21
+ Classifier: Topic :: Scientific/Engineering
22
+ Classifier: Typing :: Typed
23
+ Requires-Python: >=3.12
24
+ Description-Content-Type: text/markdown
25
+
26
+ # TDMSlite
27
+
28
+ TDMSlite is a lightweight python package to write to and read from National Instruments TDMS files with no external dependencies. TDMSlite is available under an MIT license and was originally written by Aidan Benjamin.
29
+
30
+ ### Usage
31
+
32
+ TDMSlite is zero-dependency and allows for platform-agnostic read interface with NI's open standard. Write capability is planned. TDMSlite is hosted on PyPi:
33
+ ```cmd
34
+ pip install tdmslite
35
+ ```
36
+
37
+ The core object is `TDMSDocument`, which can be created using a binary stream or with an in-place method (`.from_file`).
38
+
39
+ ```python
40
+ import tdmslite, pathlib
41
+
42
+ path = pathlib.Path("tdms_example.tdms")
43
+
44
+ document = tdmslite.TDMSDocument.from_file(path)
45
+ group = document['group name']
46
+ channel = group.channels['channel name']
47
+ raw_data = channel.data # easy!
48
+ ```
49
+
50
+ ### Limitations
51
+
52
+ While TDMS is an open standard, it is strangely not opinionated and allows nonstandard formats like 80-bit floats and fixed point numbers. These types are largely relics of old hardware or byproducts of FPGAs (which cannot write fixed point numbers to the filetype regardless...), but are seldom supported by third parties or NI's own extensions.
53
+
54
+ TDMSlite aims to mostly support these niche features. Floating point numbers are all coerced into vanilla python floats, and any integer (aside from a boolean) is coerced into a python integer. Strings and booleans are preserved as independant data types, and complex casting automatically occurs where applicable.
55
+
56
+ Data storage is done on a per-channel basis. If a channel's data cannot be embedded in an instance of Python's `array.array`, a costly list is necessary. Very large datasets full of strings will take signficantly more memory than the equivalent space on disk in the TDMS file by virtue of Python's lists and strings. The price we pay for convenience!
57
+
58
+ ### References
59
+ - [128-bit floating point numbers](https://en.wikipedia.org/wiki/Quadruple-precision_floating-point_format)
60
+ - [80-bit floating point numbers](https://en.wikipedia.org/wiki/Extended_precision)
61
+ - [Fixed point numbers](https://en.wikipedia.org/wiki/Fixed-point_arithmetic) (wikipedia)
62
+ - [Fixed point numbers](https://www.ni.com/docs/en-US/bundle/labview/page/fixed-point-numbers.html) (NI)
63
+ - [TDMS Versions](https://www.ni.com/docs/en-US/bundle/labview/page/file-format-versions.html)
64
+ - [TDMS Standard](https://www.ni.com/en/support/documentation/supplemental/07/tdms-file-format-internal-structure.html)
65
+ - [NI Type Descriptors](https://www.ni.com/docs/en-US/bundle/labview/page/type-descriptors.html)
@@ -0,0 +1,40 @@
1
+ # TDMSlite
2
+
3
+ TDMSlite is a lightweight python package to write to and read from National Instruments TDMS files with no external dependencies. TDMSlite is available under an MIT license and was originally written by Aidan Benjamin.
4
+
5
+ ### Usage
6
+
7
+ TDMSlite is zero-dependency and allows for platform-agnostic read interface with NI's open standard. Write capability is planned. TDMSlite is hosted on PyPi:
8
+ ```cmd
9
+ pip install tdmslite
10
+ ```
11
+
12
+ The core object is `TDMSDocument`, which can be created using a binary stream or with an in-place method (`.from_file`).
13
+
14
+ ```python
15
+ import tdmslite, pathlib
16
+
17
+ path = pathlib.Path("tdms_example.tdms")
18
+
19
+ document = tdmslite.TDMSDocument.from_file(path)
20
+ group = document['group name']
21
+ channel = group.channels['channel name']
22
+ raw_data = channel.data # easy!
23
+ ```
24
+
25
+ ### Limitations
26
+
27
+ While TDMS is an open standard, it is strangely not opinionated and allows nonstandard formats like 80-bit floats and fixed point numbers. These types are largely relics of old hardware or byproducts of FPGAs (which cannot write fixed point numbers to the filetype regardless...), but are seldom supported by third parties or NI's own extensions.
28
+
29
+ TDMSlite aims to mostly support these niche features. Floating point numbers are all coerced into vanilla python floats, and any integer (aside from a boolean) is coerced into a python integer. Strings and booleans are preserved as independant data types, and complex casting automatically occurs where applicable.
30
+
31
+ Data storage is done on a per-channel basis. If a channel's data cannot be embedded in an instance of Python's `array.array`, a costly list is necessary. Very large datasets full of strings will take signficantly more memory than the equivalent space on disk in the TDMS file by virtue of Python's lists and strings. The price we pay for convenience!
32
+
33
+ ### References
34
+ - [128-bit floating point numbers](https://en.wikipedia.org/wiki/Quadruple-precision_floating-point_format)
35
+ - [80-bit floating point numbers](https://en.wikipedia.org/wiki/Extended_precision)
36
+ - [Fixed point numbers](https://en.wikipedia.org/wiki/Fixed-point_arithmetic) (wikipedia)
37
+ - [Fixed point numbers](https://www.ni.com/docs/en-US/bundle/labview/page/fixed-point-numbers.html) (NI)
38
+ - [TDMS Versions](https://www.ni.com/docs/en-US/bundle/labview/page/file-format-versions.html)
39
+ - [TDMS Standard](https://www.ni.com/en/support/documentation/supplemental/07/tdms-file-format-internal-structure.html)
40
+ - [NI Type Descriptors](https://www.ni.com/docs/en-US/bundle/labview/page/type-descriptors.html)
@@ -0,0 +1,36 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "tdmslite"
7
+ version = "2026.10.31"
8
+ description = "A lightweight python package to write to and read from National Instruments TDMS files with no external dependencies."
9
+ readme = "README.md"
10
+ requires-python = ">=3.12"
11
+ dependencies = []
12
+ license = "MIT"
13
+ keywords = [
14
+ "tdms", "labview",
15
+ "national-instruments",
16
+ "ni", "measurement",
17
+ "data"]
18
+ classifiers = [
19
+ "Development Status :: 2 - Pre-Alpha",
20
+ "Intended Audience :: Developers",
21
+ "Intended Audience :: Science/Research",
22
+ "Topic :: File Formats",
23
+ "Topic :: Scientific/Engineering",
24
+ "Operating System :: OS Independent",
25
+ "Programming Language :: Python :: 3",
26
+ "Programming Language :: Python :: 3.12",
27
+ "Programming Language :: Python :: 3.13",
28
+ "Programming Language :: Python :: 3.14",
29
+ "Programming Language :: Python :: 3.15",
30
+ "Programming Language :: Python :: 3.16",
31
+ "Typing :: Typed"
32
+ ]
33
+
34
+ [project.urls]
35
+ Homepage = "https://github.com/ajben/TDMSlite"
36
+ Repository = "https://github.com/ajben/TDMSlite"
@@ -0,0 +1,8 @@
1
+ from .models import TDMSSegment, \
2
+ TDMSDocument, TDMSGroup, TDMSChannel
3
+
4
+ from .structs import TDMSDataTypes
5
+
6
+ __all__ = ["TDMSSegment", "TDMSDocument",
7
+ "TDMSGroup", "TDMSChannel",
8
+ "TDMSDataTypes"]
@@ -0,0 +1,281 @@
1
+ from typing import BinaryIO
2
+ from array import array
3
+ from pathlib import Path
4
+ from .structs import TOCFlag, TOCVersion, Number, \
5
+ TDMSLeadInIntegrity, String, TDMSDataTypes, \
6
+ TDMSMetadataObject, TDMSRawDataLayout, \
7
+ TDMSMetadataObjectValue, TDMSDataTypesArrayTypes, \
8
+ TDMSDataTypesListTypes
9
+ from logging import getLogger
10
+ from sys import byteorder as SYSTEM_BYTEORDER
11
+
12
+ logger = getLogger(__name__)
13
+
14
+ class TDMSLeadIn:
15
+ def __init__(self, stream:BinaryIO) -> None:
16
+
17
+ # Set defaults
18
+ self.integrity:TDMSLeadInIntegrity = TDMSLeadInIntegrity.OK
19
+ self.big_endian:bool = False
20
+ self.version:TOCVersion = TOCVersion.UNKNOWN
21
+ self.next_segment:int = 0
22
+ self.raw_data_offset:int = 0
23
+
24
+ # Check header
25
+ initial_position:int = stream.tell()
26
+ magic_sequence:bytes = stream.read(4)
27
+
28
+ if magic_sequence == b'':
29
+ stream.seek(initial_position)
30
+ self.integrity = TDMSLeadInIntegrity.DONE
31
+ return
32
+ if magic_sequence != b'TDSm':
33
+ stream.seek(initial_position)
34
+ self.integrity = TDMSLeadInIntegrity.ERROR
35
+ logger.error(f"Invalid magic sequence (Lead-in started with {magic_sequence})")
36
+ return
37
+
38
+ # Load data
39
+ members:list[TOCFlag] = TOCFlag.members(stream.read(4))
40
+ self.big_endian = TOCFlag.kTocBigEndian in members
41
+ self.interleaved = TOCFlag.kTocInterleavedData in members
42
+ self.has_metadata = TOCFlag.kTocMetaData in members
43
+ self.has_rawdata = TOCFlag.kTocRawData in members
44
+ self.version = TOCVersion(Number.unpack_uint(stream.read(4), self.big_endian))
45
+ self.next_segment = Number.unpack_uint(stream.read(8), self.big_endian)
46
+ self.raw_data_offset = Number.unpack_uint(stream.read(8), self.big_endian)
47
+ # Not sure on kTocNewObjList or kTocDAQmxRawData
48
+
49
+ # Data checks
50
+ if self.version == TOCVersion.UNKNOWN:
51
+ logger.error("Unknown segment version")
52
+ self.integrity |= TDMSLeadInIntegrity.ERROR # This redundant af
53
+ elif self.version != TOCVersion.TDMS_2:
54
+ logger.warning(f"Loading old TDMS data ({self.version.name})")
55
+ self.integrity |= TDMSLeadInIntegrity.WARN
56
+ if self.next_segment == 4294967295:
57
+ logger.warning("Likely corrupted data in segment")
58
+ self.integrity |= TDMSLeadInIntegrity.WARN
59
+
60
+ class TDMSMetadata:
61
+
62
+ def __init__(self, stream:BinaryIO, big_endian:bool, channel_states:dict[str,TDMSRawDataLayout]) -> None:
63
+
64
+ n_objects = Number.unpack_uint(stream.read(4), big_endian=big_endian)
65
+
66
+ self.objects:list[TDMSMetadataObject] = []
67
+ for _ in range(n_objects):
68
+
69
+ object_path = String.unpack(stream, big_endian=big_endian)
70
+ raw_data_index = Number.unpack_uint(stream.read(4), big_endian=big_endian)
71
+
72
+ # Check the index
73
+ if raw_data_index == 0xFFFFFFFF: # None attached
74
+ raw_layout = None
75
+ elif raw_data_index == 0x00000000: # Reuse
76
+ raw_layout = channel_states.get(object_path)
77
+ else:
78
+ raw_data_type = TDMSDataTypes(
79
+ Number.unpack_uint(stream.read(4), big_endian=big_endian))
80
+ dim = Number.unpack_uint(stream.read(4), big_endian=big_endian)
81
+ n_values = Number.unpack_uint(stream.read(8), big_endian=big_endian)
82
+
83
+ total_size:int = Number.unpack_uint(stream.read(8), big_endian=big_endian) \
84
+ if raw_data_type == TDMSDataTypes.tdsTypeString else 0
85
+
86
+ raw_layout = TDMSRawDataLayout(raw_data_type, dim, n_values, total_size)
87
+
88
+ if raw_layout: channel_states[object_path] = raw_layout
89
+ n_properties = Number.unpack_uint(stream.read(4), big_endian=big_endian)
90
+ properties:dict[str, TDMSMetadataObjectValue] = {}
91
+
92
+ for _ in range(n_properties):
93
+
94
+ property_name = String.unpack(stream, big_endian=big_endian)
95
+ property_type = TDMSDataTypes(Number.unpack_uint(stream.read(4), big_endian=big_endian))
96
+
97
+ property_value = property_type.unpack(stream, big_endian=big_endian)
98
+ logger.info(f"Read Property(name=\"{property_name}\", type={property_type.name}, val={property_value})")
99
+ properties[property_name] = property_value
100
+
101
+ self.objects.append(TDMSMetadataObject(object_path, raw_layout, properties))
102
+
103
+ class TDMSChannel:
104
+
105
+ def __init__(self, name:str, path:str, data_type:TDMSDataTypes|None=None) -> None:
106
+ self.name = name
107
+ self.path = path
108
+ self.data_type = data_type
109
+ self.properties:dict[str,TDMSMetadataObjectValue] = {}
110
+ self.data:array[TDMSDataTypesArrayTypes]|list[TDMSDataTypesListTypes] = []
111
+
112
+ def add_data(self, data:list[TDMSDataTypesListTypes]|array[TDMSDataTypesArrayTypes]) -> None:
113
+ if len(self.data) == 0:
114
+ self.data = data.copy() if isinstance(data, list) else data
115
+ else:
116
+ if isinstance(self.data, list) and isinstance(data, list):
117
+ self.data.extend(data)
118
+ elif isinstance(self.data, array) and isinstance(data, array):
119
+ if self.data.typecode == data.typecode:
120
+ self.data.extend(data)
121
+
122
+ def __len__(self) -> int:
123
+ return len(self.data)
124
+
125
+ def __repr__(self) -> str:
126
+ return (f"Channel(name={self.name.replace("\n", "\\n")}, path={self.path.replace("\n", "\\n")}, data_type={self.data_type}, "
127
+ f"data={"None" if len(self) == 0 else len(self)})")
128
+
129
+ class TDMSGroup:
130
+ def __init__(self, name: str) -> None:
131
+ self.name: str = name
132
+ self.properties: dict[str,TDMSMetadataObjectValue] = {}
133
+ self.channels: dict[str, TDMSChannel] = {}
134
+
135
+ def __getitem__(self, channel_name: str) -> TDMSChannel:
136
+ return self.channels[channel_name]
137
+
138
+ def __repr__(self) -> str:
139
+ return f"Group(name={self.name}, " \
140
+ f"properties={list(self.properties.keys())}, " \
141
+ f"channels={list(self.channels.keys())})"
142
+
143
+ class TDMSSegment:
144
+
145
+ def __init__(self, stream: BinaryIO,
146
+ channel_states: dict[str, TDMSRawDataLayout],
147
+ active_objects: list[TDMSMetadataObject]) -> None:
148
+ self.stream = stream
149
+ self.start_pos = stream.tell()
150
+
151
+ # Lead-in
152
+ self.lead_in = TDMSLeadIn(self.stream)
153
+ if self.lead_in.integrity == TDMSLeadInIntegrity.ERROR or \
154
+ self.lead_in.integrity == TDMSLeadInIntegrity.DONE : return
155
+
156
+ # Metadata
157
+ if self.lead_in.has_metadata:
158
+ self.metadata = TDMSMetadata(self.stream, self.lead_in.big_endian, channel_states)
159
+ self.objects = self.metadata.objects
160
+ else:
161
+ self.metadata = None
162
+ self.objects = active_objects # keep previous active objects
163
+
164
+ # Rawdata
165
+ self.raw_chunks: dict[str, array[TDMSDataTypesArrayTypes] | list[TDMSDataTypesListTypes]] = {}
166
+ if self.lead_in.has_rawdata:
167
+
168
+ self.stream.seek(self.start_pos + 28 + self.lead_in.raw_data_offset)
169
+ total_bytes_read:int = self._read_raw_data()
170
+ logger.info(f"Read {total_bytes_read} bytes of raw data")
171
+
172
+ def _read_raw_data(self) -> int:
173
+ total_read_bytes:int = 0
174
+ for obj in self.objects:
175
+ if not obj.raw_layout or obj.raw_layout.n_vals == 0:
176
+ continue
177
+
178
+ layout = obj.raw_layout
179
+ dtype = layout.data_type
180
+ arr_state = dtype.array_state()
181
+
182
+ if arr_state:
183
+ typecode, itemsize = arr_state
184
+ n_bytes = layout.n_vals * itemsize
185
+ raw_bytes = self.stream.read(n_bytes)
186
+ total_read_bytes += n_bytes
187
+
188
+ arr:array[TDMSDataTypesArrayTypes] = array(typecode)
189
+ arr.frombytes(raw_bytes)
190
+
191
+ # Endian conversion if needed
192
+ if self.lead_in.big_endian != (SYSTEM_BYTEORDER == 'big'):
193
+ arr.byteswap()
194
+
195
+ self.raw_chunks[obj.path] = arr
196
+ else:
197
+ offsets = [Number.unpack_uint(self.stream.read(4), self.lead_in.big_endian) for _ in range(layout.n_vals)]
198
+
199
+ # Read chars
200
+ data_len = layout.total_bytes - (layout.n_vals * 4)
201
+ char_bytes = self.stream.read(data_len)
202
+ total_read_bytes += data_len
203
+
204
+ strings: list[TDMSDataTypesListTypes] = []
205
+ prev_offset = 0
206
+ for off in offsets:
207
+ strings.append(char_bytes[prev_offset:off].decode('utf-8', errors='replace'))
208
+ prev_offset = off
209
+
210
+ self.raw_chunks[obj.path] = strings
211
+ return total_read_bytes
212
+
213
+ class TDMSDocument:
214
+ def __init__(self, stream: BinaryIO) -> None:
215
+ self.properties: dict[str, TDMSMetadataObjectValue] = {}
216
+ self.groups: dict[str, TDMSGroup] = {}
217
+
218
+ # Persistant state
219
+ channel_states: dict[str, TDMSRawDataLayout] = {}
220
+ active_objects: list[TDMSMetadataObject] = []
221
+
222
+ while True:
223
+ seg_start = stream.tell()
224
+
225
+ # Delegate parsing
226
+ segment = TDMSSegment(stream, channel_states, active_objects)
227
+ if segment.lead_in.integrity == TDMSLeadInIntegrity.ERROR or \
228
+ segment.lead_in.integrity == TDMSLeadInIntegrity.DONE:
229
+ break
230
+
231
+ active_objects = segment.objects
232
+
233
+ # Update Document hierarchy
234
+ for obj in segment.objects:
235
+ group_name, chan_name = String.parse_path(obj.path)
236
+
237
+ if group_name is None and chan_name is None:
238
+ self.properties.update(obj.properties)
239
+ elif group_name and not chan_name:
240
+ group = self.groups.setdefault(group_name, TDMSGroup(group_name))
241
+ group.properties.update(obj.properties)
242
+ elif group_name and chan_name:
243
+ group = self.groups.setdefault(group_name, TDMSGroup(group_name))
244
+ dtype = obj.raw_layout.data_type if obj.raw_layout else None
245
+ if chan_name not in group.channels:
246
+ group.channels[chan_name] = TDMSChannel(chan_name, obj.path, dtype)
247
+
248
+ group.channels[chan_name].properties.update(obj.properties)
249
+
250
+ # Add segment's chunks
251
+ for path, chunk in segment.raw_chunks.items():
252
+ group_name, chan_name = String.parse_path(path)
253
+ if group_name and chan_name:
254
+ self.groups[group_name].channels[chan_name].add_data(chunk)
255
+
256
+ # EOF?
257
+ if segment.lead_in.next_segment == 0xFFFFFFFFFFFFFFFF:
258
+ break
259
+ stream.seek(seg_start + 28 + segment.lead_in.next_segment)
260
+
261
+ @classmethod
262
+ def from_file(cls, path:str|Path) -> TDMSDocument:
263
+
264
+ path = Path(path).resolve()
265
+ if path.suffix.lower() != '.tdms':
266
+ raise ValueError(f"Unsupported filetype: {path.suffix} (should be .tdms)")
267
+ if not path.exists():
268
+ raise FileNotFoundError(f"File at path {path} does not exist")
269
+
270
+ with open(Path(path).resolve(), 'rb') as f:
271
+ file = TDMSDocument(f)
272
+ return file
273
+
274
+ def __getitem__(self, key:str) -> TDMSGroup | None:
275
+ if key in self.groups:
276
+ return self.groups[key]
277
+ return None
278
+
279
+ def __repr__(self) -> str:
280
+ return f"TDMSDocument(Properties={self.properties}, " \
281
+ f"Groups=[{", ".join([repr(group) for group in self.groups.values()])}])"
File without changes
@@ -0,0 +1,341 @@
1
+ from enum import Enum, IntEnum
2
+ from typing import Iterable, Self, BinaryIO
3
+ from dataclasses import dataclass, field
4
+ from datetime import datetime, timezone, timedelta
5
+ from math import nan, inf, ldexp
6
+ import struct
7
+
8
+ type TDMSDataTypesArrayTypes = float | int
9
+ type TDMSDataTypesListTypes = str | datetime | bool
10
+ type TDMSMetadataObjectValue = int | float | str | bool | complex | datetime
11
+
12
+ class TOCFlag(Enum):
13
+ kTocMetaData = 0x00000002
14
+ kTocNewObjList = 0x00000004
15
+ kTocRawData = 0x00000008
16
+ kTocDAQmxRawData = 0x00000080
17
+ kTocInterleavedData = 0x00000020
18
+ kTocBigEndian = 0x00000040
19
+
20
+ @classmethod
21
+ def members(cls, value:int|bytes) -> list[TOCFlag]:
22
+ if isinstance(value, bytes) and len(value) != 4:
23
+ raise ValueError("Incorrect bytes object length")
24
+ return [flag for flag in TOCFlag if (int.from_bytes(value, 'little') if isinstance(value, bytes) else value) & flag.value]
25
+
26
+ @classmethod
27
+ def pack(cls, flags:Iterable[TOCFlag]) -> int:
28
+ return sum([flag.value for flag in flags])
29
+
30
+ class TOCVersion(IntEnum):
31
+ TDMS_2 = 4713
32
+ TDMS_1 = 4712
33
+ UNKNOWN = 0
34
+
35
+ @classmethod
36
+ def _missing_(cls, value: object) -> TOCVersion:
37
+ return TOCVersion.UNKNOWN
38
+
39
+ class TDMSLeadInIntegrity(Enum):
40
+ DONE = 0 # EOF
41
+ OK = 1 # good
42
+ WARN = 2 # suspicious
43
+ ERROR = 3 # block loading
44
+
45
+ def __or__(self: Self, value: TDMSLeadInIntegrity) -> TDMSLeadInIntegrity:
46
+ return self if self.value > value.value else value
47
+
48
+ class TDMSDataTypes(IntEnum):
49
+
50
+ # Named to match NI's asinine naming schema
51
+ tdsTypeVoid = 0x00
52
+ tdsTypeI8 = 0x01
53
+ tdsTypeI16 = 0x02
54
+ tdsTypeI32 = 0x03
55
+ tdsTypeI64 = 0x04
56
+ tdsTypeU8 = 0x05
57
+ tdsTypeU16 = 0x06
58
+ tdsTypeU32 = 0x07
59
+ tdsTypeU64 = 0x08
60
+ tdsTypeSingleFloat = 0x09
61
+ tdsTypeDoubleFloat = 0x0A
62
+ tdsTypeExtendedFloat = 0x0B
63
+ tdsTypeSingleFloatWithUnit = 0x19
64
+ tdsTypeDoubleFloatWithUnit = 0x1A
65
+ tdsTypeExtendedFloatWithUnit = 0x1B
66
+ tdsTypeString = 0x20
67
+ tdsTypeBoolean = 0x21
68
+ tdsTypeTimeStamp = 0x44
69
+ tdsTypeFixedPoint = 0x4F
70
+ tdsTypeComplexSingleFloat = 0x08000C
71
+ tdsTypeComplexDoubleFloat = 0x10000D
72
+ tdsTypeDAQmxRawData = 0xFFFFFFFF
73
+
74
+ def unpack(self, stream:BinaryIO, big_endian:bool) -> TDMSMetadataObjectValue:
75
+ match self:
76
+ case TDMSDataTypes.tdsTypeString:
77
+ return(String.unpack(stream, big_endian=big_endian))
78
+ case TDMSDataTypes.tdsTypeBoolean:
79
+ return(Boolean.unpack(stream))
80
+ case TDMSDataTypes.tdsTypeI8:
81
+ return(Number.unpack_int(stream.read(1), big_endian=big_endian))
82
+ case TDMSDataTypes.tdsTypeU8:
83
+ return(Number.unpack_uint(stream.read(1), big_endian=big_endian))
84
+ case TDMSDataTypes.tdsTypeI16:
85
+ return(Number.unpack_int(stream.read(2), big_endian=big_endian))
86
+ case TDMSDataTypes.tdsTypeU16:
87
+ return(Number.unpack_uint(stream.read(2), big_endian=big_endian))
88
+ case TDMSDataTypes.tdsTypeI32:
89
+ return(Number.unpack_int(stream.read(4), big_endian=big_endian))
90
+ case TDMSDataTypes.tdsTypeU32:
91
+ return(Number.unpack_uint(stream.read(4), big_endian=big_endian))
92
+ case TDMSDataTypes.tdsTypeI64:
93
+ return(Number.unpack_int(stream.read(8), big_endian=big_endian))
94
+ case TDMSDataTypes.tdsTypeU64:
95
+ return(Number.unpack_uint(stream.read(8), big_endian=big_endian))
96
+ case TDMSDataTypes.tdsTypeSingleFloat | TDMSDataTypes.tdsTypeSingleFloatWithUnit:
97
+ return(Number.unpack_sfloat(stream.read(4), big_endian=big_endian))
98
+ case TDMSDataTypes.tdsTypeComplexSingleFloat:
99
+ return(Number.unpack_csfloat(stream.read(8), big_endian=big_endian))
100
+ case TDMSDataTypes.tdsTypeDoubleFloat | TDMSDataTypes.tdsTypeDoubleFloatWithUnit:
101
+ return(Number.unpack_dfloat(stream.read(8), big_endian=big_endian))
102
+ case TDMSDataTypes.tdsTypeComplexDoubleFloat:
103
+ return(Number.unpack_cdfloat(stream.read(16), big_endian=big_endian))
104
+ case TDMSDataTypes.tdsTypeExtendedFloat | TDMSDataTypes.tdsTypeExtendedFloatWithUnit:
105
+ return(Number.unpack_efloat(stream.read(10), big_endian=big_endian))
106
+ case TDMSDataTypes.tdsTypeTimeStamp:
107
+ return(Timestamp.from_bytes(stream.read(16), big_endian=big_endian))
108
+ case TDMSDataTypes.tdsTypeFixedPoint:
109
+ raise NotImplementedError("Fixed point values are not supported by NI in TDMS")
110
+ case _:
111
+ raise NotImplementedError(f"Type {self} has not yet been added")
112
+
113
+ def array_state(self) -> tuple[str, int] | None:
114
+ match self:
115
+ case TDMSDataTypes.tdsTypeI8:
116
+ return ('b', 1)
117
+ case TDMSDataTypes.tdsTypeU8:
118
+ return ('B', 1)
119
+ case TDMSDataTypes.tdsTypeI16:
120
+ return ('h', 2)
121
+ case TDMSDataTypes.tdsTypeU16:
122
+ return ('H', 2)
123
+ case TDMSDataTypes.tdsTypeI32:
124
+ return ('i', 4)
125
+ case TDMSDataTypes.tdsTypeU32:
126
+ return ('I', 4)
127
+ case TDMSDataTypes.tdsTypeI64:
128
+ return ('q', 8)
129
+ case TDMSDataTypes.tdsTypeU64:
130
+ return ('Q', 8)
131
+ case TDMSDataTypes.tdsTypeSingleFloat:
132
+ return ('f', 4)
133
+ case TDMSDataTypes.tdsTypeSingleFloatWithUnit:
134
+ return ('f', 4)
135
+ case TDMSDataTypes.tdsTypeDoubleFloat:
136
+ return ('d', 8)
137
+ case TDMSDataTypes.tdsTypeDoubleFloatWithUnit:
138
+ return ('d', 8)
139
+ case TDMSDataTypes.tdsTypeBoolean:
140
+ return ('b', 1)
141
+ case _:
142
+ return None
143
+
144
+ @dataclass
145
+ class TDMSRawDataLayout:
146
+ data_type: TDMSDataTypes
147
+ dim: int
148
+ n_vals: int
149
+ total_bytes:int
150
+
151
+ @dataclass
152
+ class TDMSMetadataObject:
153
+ path: str
154
+ raw_layout: TDMSRawDataLayout | None = None
155
+ properties: dict[str, TDMSMetadataObjectValue] = \
156
+ field(default_factory=dict[str, TDMSMetadataObjectValue])
157
+
158
+ class String:
159
+
160
+ @classmethod
161
+ def pack(cls, content:str, big_endian:bool=False) -> bytes:
162
+
163
+ # per https://www.ni.com/en/support/documentation/supplemental/07/tdms-file-format-internal-structure.html
164
+ content_stripped:str = content.rstrip()
165
+ return len(content_stripped).to_bytes(length=4, byteorder='big' if big_endian else 'little') + content_stripped.encode('utf-8')
166
+
167
+ @classmethod
168
+ def unpack(cls, stream:BinaryIO, big_endian:bool=False) -> str:
169
+
170
+ # see String.pack
171
+ str_length = Number.unpack_uint(stream.read(4), big_endian=big_endian)
172
+ return stream.read(str_length).decode('utf-8')
173
+
174
+ @classmethod
175
+ def parse_path(cls, path:str) -> tuple[str|None, str|None]:
176
+ if path == "/": return (None, None)
177
+
178
+ escaped, skip = False, False
179
+ current:list[str] = []
180
+ parts:list[str] = []
181
+
182
+ for i, char in enumerate(path):
183
+
184
+ if skip:
185
+ skip = False
186
+ continue
187
+
188
+ if char == "'": # Escape char
189
+ if escaped and i + 1 < len(path) and path[i+1] == "'":
190
+ current.append("'") # Doubled quotation to embed a '
191
+ skip = True
192
+
193
+ else:
194
+ escaped = not escaped
195
+ if not escaped:
196
+ parts.append("".join(current))
197
+ current = []
198
+
199
+ elif escaped: current.append(char) # Valid char
200
+
201
+ return (parts[0] if len(parts) > 0 else None, parts[1] if len(parts) > 1 else None)
202
+
203
+ class Number:
204
+
205
+ max_u32_bytes:bytes = b'\xFF\xFF\xFF\xFF'
206
+ max_u32:int = int.from_bytes(max_u32_bytes)
207
+ max_u64_bytes:bytes = b'\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF'
208
+ max_u64:int = int.from_bytes(max_u64_bytes)
209
+
210
+ @classmethod
211
+ def pack_u32(cls, content:int, big_endian:bool=False) -> bytes:
212
+ return content.to_bytes(byteorder='big' if big_endian else 'little', signed=False, length=4)
213
+
214
+ @classmethod
215
+ def pack_i32(cls, content:int, big_endian:bool=False) -> bytes:
216
+ return content.to_bytes(byteorder='big' if big_endian else 'little', signed=True, length=4)
217
+
218
+ @classmethod
219
+ def pack_u64(cls, content:int, big_endian:bool=False) -> bytes:
220
+ return content.to_bytes(byteorder='big' if big_endian else 'little', signed=False, length=8)
221
+
222
+ @classmethod
223
+ def pack_i64(cls, content:int, big_endian:bool=False) -> bytes:
224
+ return content.to_bytes(byteorder='big' if big_endian else 'little', signed=True, length=8)
225
+
226
+ @classmethod
227
+ def unpack_uint(cls, content:bytes, big_endian:bool=False) -> int:
228
+ return int.from_bytes(content, 'big' if big_endian else 'little', signed=False)
229
+
230
+ @classmethod
231
+ def unpack_int(cls, content:bytes, big_endian:bool=False) -> int:
232
+ return int.from_bytes(content, 'big' if big_endian else 'little', signed=True)
233
+
234
+ @classmethod
235
+ def unpack_hfloat(cls, content:bytes, big_endian:bool=False) -> float:
236
+ return struct.unpack(('>' if big_endian else '<')+'e', content)[0]
237
+
238
+ @classmethod
239
+ def unpack_sfloat(cls, content:bytes, big_endian:bool=False) -> float:
240
+ return struct.unpack(('>' if big_endian else '<')+'f', content)[0]
241
+
242
+ @classmethod
243
+ def unpack_dfloat(cls, content:bytes, big_endian:bool=False) -> float:
244
+ return struct.unpack(('>' if big_endian else '<')+'d', content)[0]
245
+
246
+ @classmethod
247
+ def unpack_efloat(cls, content:bytes, big_endian:bool=False) -> float:
248
+
249
+ # https://en.wikipedia.org/wiki/Extended_precision
250
+
251
+ if len(content) != 10:
252
+ raise ValueError(f"Incorrect number of bytes ({len(content)}) provided to unpack_efloat (reqd: 10)")
253
+
254
+ if big_endian:
255
+ biased_exp, mantissa = struct.unpack('>HQ', content)
256
+ else:
257
+ mantissa, biased_exp = struct.unpack('<QH', content)
258
+
259
+ sign = -1.0 if biased_exp & 0x8000 else 1.0
260
+ exponent = biased_exp & 0x7FFF
261
+
262
+ # inf/nan
263
+ if exponent == 0x7FFF:
264
+
265
+ # lower 63 bits are set or bit 63 is 0
266
+ if (mantissa & 0x7FFFFFFFFFFFFFFF) or not (mantissa & 0x8000000000000000):
267
+ return nan
268
+
269
+ return inf if sign > 0 else -inf
270
+
271
+ # Zero check
272
+ if exponent == 0 and mantissa == 0:
273
+ return sign * 0.0
274
+
275
+ if exponent == 0:
276
+ # Subnormal numbers
277
+ exponent = -16382
278
+ else:
279
+ # Normal numbers
280
+ exponent -= 16383
281
+
282
+ frac = mantissa / (1 << 63)
283
+ try:
284
+ return ldexp(sign * frac, exponent)
285
+ except:
286
+ return inf if sign > 0 else -inf
287
+
288
+ @classmethod
289
+ def unpack_fixed(cls, content:bytes, word_length:int, int_word_length:int, signed:bool, big_endian:bool=False) -> float:
290
+ integer = cls.unpack_int(content=content, big_endian=big_endian)
291
+
292
+ fraction = word_length - int_word_length
293
+ if signed and (integer & (1 << (word_length - 1))):
294
+ integer -= (1 << word_length)
295
+
296
+ return integer / (2 ** fraction)
297
+
298
+ @classmethod
299
+ def unpack_csfloat(cls, content:bytes, big_endian:bool=False) -> complex:
300
+ real = cls.unpack_sfloat(content[0:4], big_endian=big_endian)
301
+ imag = cls.unpack_sfloat(content[4:8], big_endian=big_endian)
302
+
303
+ return real + imag * 1j
304
+
305
+ @classmethod
306
+ def unpack_cdfloat(cls, content:bytes, big_endian:bool=False) -> complex:
307
+ real = cls.unpack_dfloat(content[0:8], big_endian=big_endian)
308
+ imag = cls.unpack_dfloat(content[8:16], big_endian=big_endian)
309
+
310
+ return real + imag * 1j
311
+
312
+ class Boolean:
313
+
314
+ @classmethod
315
+ def unpack(cls, stream:BinaryIO) -> bool:
316
+ return stream.read(1) == b'\x01'
317
+
318
+ class Timestamp:
319
+
320
+ EPOCH = datetime(1904, 1, 1, tzinfo=timezone.utc)
321
+ TWO_POW_64 = 1 << 64
322
+
323
+ @classmethod
324
+ def from_bytes(cls, content: bytes, big_endian: bool) -> datetime:
325
+ fraction = Number.unpack_uint(content[0:8], big_endian=big_endian)
326
+ seconds = Number.unpack_int(content[8:16], big_endian=big_endian)
327
+ microseconds = round(fraction * 1_000_000 / cls.TWO_POW_64)
328
+
329
+ return cls.EPOCH + timedelta(seconds=seconds, microseconds=microseconds)
330
+
331
+ @classmethod
332
+ def to_bytes(cls, dt: datetime, big_endian: bool) -> bytes:
333
+ delta = dt.astimezone(timezone.utc) - cls.EPOCH
334
+
335
+ # int seconds elapsed
336
+ seconds = delta.days * 86400 + delta.seconds
337
+ fraction = (delta.microseconds * cls.TWO_POW_64) // 1_000_000
338
+
339
+ content_0_8 = Number.pack_u64(fraction, big_endian=big_endian)
340
+ content_8_16 = Number.pack_i64(seconds, big_endian=big_endian)
341
+ return content_0_8 + content_8_16