taffmat 2.0.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.
- taffmat/__init__.py +582 -0
- taffmat/py.typed +0 -0
- taffmat-2.0.0.dist-info/METADATA +222 -0
- taffmat-2.0.0.dist-info/RECORD +6 -0
- taffmat-2.0.0.dist-info/WHEEL +4 -0
- taffmat-2.0.0.dist-info/licenses/LICENSE.txt +20 -0
taffmat/__init__.py
ADDED
|
@@ -0,0 +1,582 @@
|
|
|
1
|
+
# Copyright (c) 2014-2017 The taffmat developers. All rights reserved.
|
|
2
|
+
# Project site: https://github.com/questrail/taffmat
|
|
3
|
+
# Use of this source code is governed by a MIT-style license that
|
|
4
|
+
# can be found in the LICENSE.txt file for the project.
|
|
5
|
+
"""Read and write Teac TAFFmat files.
|
|
6
|
+
|
|
7
|
+
The .dat file is read into a numpy array.
|
|
8
|
+
The .hdr file is read into an OrderedDict
|
|
9
|
+
|
|
10
|
+
Per the Teac LX-10 Instruction Manual, the A/D-converted data
|
|
11
|
+
is recored as 2-byte integers from -32,768 to +32,767. Negative
|
|
12
|
+
numbers are expressed as 2's complements. The byte order is from
|
|
13
|
+
the lower bytes to the higher bytes.
|
|
14
|
+
|
|
15
|
+
The max ADC values are +/-25,000, which represents +/-100% of
|
|
16
|
+
the input range (i.e., slope = range / 25000):
|
|
17
|
+
|
|
18
|
+
0.5V = 2e-5
|
|
19
|
+
1V = 4e-5
|
|
20
|
+
2V = 8e-5
|
|
21
|
+
5V = 2e-4
|
|
22
|
+
10V = 4e-4
|
|
23
|
+
20V = 8e-4
|
|
24
|
+
50V = 2e-3
|
|
25
|
+
|
|
26
|
+
# Notes on the header file format #
|
|
27
|
+
|
|
28
|
+
* If the voice memo recording is off, then the VOICE_MEMO line will
|
|
29
|
+
be absent from the header file.
|
|
30
|
+
* The MEMO_LENGTH and MEMO lines are *not* related to the voice memo.
|
|
31
|
+
Those are for the memo field. The MEMO_LENGTH line contains
|
|
32
|
+
an integer of the number of characters on the MEMO line and
|
|
33
|
+
then has 7 zeros comma separated afterwards.
|
|
34
|
+
* Some HDR files have two blank lines at the end with the last line containing
|
|
35
|
+
three spaces (line above that contains nothing). Other HDR files appear
|
|
36
|
+
to just have one blank line at the end with no spaces on it.
|
|
37
|
+
* The binary Teac data is stored as int16 (2-bytes) and only
|
|
38
|
+
+25,000 to -25,000 and then it's multiplied by the slope,
|
|
39
|
+
which we know can be 0.5, 1, 2, 5, 10, 20 or 50 V. Note that,
|
|
40
|
+
"a range of +/-131% of the selected range can be obtained for
|
|
41
|
+
A/D conversion value; however, the input margin
|
|
42
|
+
level is approximately +/-120%." [Source p. 4-5 of Teac manual]
|
|
43
|
+
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
# Standard module imports
|
|
47
|
+
import os
|
|
48
|
+
from collections import OrderedDict
|
|
49
|
+
from datetime import datetime
|
|
50
|
+
from importlib.metadata import version
|
|
51
|
+
|
|
52
|
+
# Data analysis related imports
|
|
53
|
+
import numpy as np
|
|
54
|
+
|
|
55
|
+
__version__ = version("taffmat")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _append_windows_newlines(input_list_of_strings: list[str]) -> list[str]:
|
|
59
|
+
"""Append Windows style newlines to list of strings.
|
|
60
|
+
|
|
61
|
+
Takes a list of strings and replaces UNIX style line endings with
|
|
62
|
+
Windows style line endings \\r\\n.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
input_list_of_strings: A list of strings.
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
A list of strings with Windows newline characters \\r\\n.
|
|
69
|
+
|
|
70
|
+
Raies:
|
|
71
|
+
N/A
|
|
72
|
+
"""
|
|
73
|
+
windows_newline_character = "\r\n"
|
|
74
|
+
output_list_of_strings = []
|
|
75
|
+
for line in input_list_of_strings:
|
|
76
|
+
output_list_of_strings.append(line + windows_newline_character)
|
|
77
|
+
|
|
78
|
+
return output_list_of_strings
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _apply_slope_and_offset(data_array, number_of_series, slope, y_offset):
|
|
82
|
+
"""
|
|
83
|
+
Convert from int16 to float64 and apply the slope and offset
|
|
84
|
+
so the data_array contains the measured values.
|
|
85
|
+
"""
|
|
86
|
+
data_array = data_array.astype(np.float64)
|
|
87
|
+
for series in range(0, number_of_series):
|
|
88
|
+
data_array[series] = data_array[series] * slope[series] + y_offset[series]
|
|
89
|
+
|
|
90
|
+
return data_array
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _remove_slope_and_offset(data_array, number_of_series, slope, y_offset):
|
|
94
|
+
"""
|
|
95
|
+
Convert data_array from float64 to int16 by removing the slope and offset
|
|
96
|
+
in preparation to writing the TAFFmat .dat file
|
|
97
|
+
"""
|
|
98
|
+
# FIXME: There's no reason to pass the number_of_series into this function
|
|
99
|
+
# since the data_array's first dimension tells how many series there are.
|
|
100
|
+
for series in range(0, number_of_series):
|
|
101
|
+
data_array[series] = np.around(
|
|
102
|
+
(data_array[series] - y_offset[series]) / slope[series]
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
data_array = data_array.astype("int16")
|
|
106
|
+
|
|
107
|
+
return data_array
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _format_exponent_notation(input_number, precision, num_exponent_digits):
|
|
111
|
+
"""
|
|
112
|
+
Format the exponent notation. Python's exponent notation doesn't allow
|
|
113
|
+
for a user-defined number of exponent digits.
|
|
114
|
+
|
|
115
|
+
Based on [Anurag Uniyal's answer][answer] to the StackOverflow
|
|
116
|
+
question ['Python - number of digits in exponent'][question]
|
|
117
|
+
|
|
118
|
+
[question]: http://stackoverflow.com/q/9910972/95592
|
|
119
|
+
[answer]: http://stackoverflow.com/a/9911741/95592
|
|
120
|
+
"""
|
|
121
|
+
python_exponent_notation = "{number:.{precision}e}".format(
|
|
122
|
+
number=input_number, precision=precision
|
|
123
|
+
)
|
|
124
|
+
mantissa, exponent = python_exponent_notation.split("e")
|
|
125
|
+
# Add 1 to the desired number of exponenent digits to account for the sign
|
|
126
|
+
return "{mantissa}e{exponent:+0{exp_num}d}".format(
|
|
127
|
+
mantissa=mantissa, exponent=int(exponent), exp_num=num_exponent_digits + 1
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# The header format is a flat list of keyword lines, so parsing it is one
|
|
132
|
+
# branch per keyword. Splitting that up would only scatter the format across
|
|
133
|
+
# helpers without making any single keyword easier to follow.
|
|
134
|
+
def _read_taffmat_hdr(input_hdr_file): # noqa: PLR0912, PLR0915
|
|
135
|
+
"""
|
|
136
|
+
Read the TAFFmat .hdr file into a "smart" dictionary containing
|
|
137
|
+
all the header data.
|
|
138
|
+
"""
|
|
139
|
+
|
|
140
|
+
# Read in all lines from the .hdr file
|
|
141
|
+
with open(input_hdr_file) as f_header:
|
|
142
|
+
header_data_all_lines = f_header.readlines()
|
|
143
|
+
|
|
144
|
+
# Read the header file into an ordered dictionary using the first
|
|
145
|
+
# word of each line as the key.
|
|
146
|
+
raw_header_data = OrderedDict()
|
|
147
|
+
for line in header_data_all_lines:
|
|
148
|
+
try:
|
|
149
|
+
[key, data] = line.split(" ", 1)
|
|
150
|
+
if key != "":
|
|
151
|
+
if key.lower() in raw_header_data:
|
|
152
|
+
raw_header_data[key.lower() + "2"] = data.strip()
|
|
153
|
+
else:
|
|
154
|
+
raw_header_data[key.lower()] = data.strip()
|
|
155
|
+
except Exception:
|
|
156
|
+
raw_header_data[line.lower().strip()] = ""
|
|
157
|
+
|
|
158
|
+
# Create a "smarter" dictionary based on the raw_header_data
|
|
159
|
+
header_data = OrderedDict()
|
|
160
|
+
header_data["dataset"] = raw_header_data["dataset"]
|
|
161
|
+
header_data["version"] = int(raw_header_data["version"])
|
|
162
|
+
header_data["series_labels"] = raw_header_data["series"].split(",")
|
|
163
|
+
start_recording_datetime_as_string = (
|
|
164
|
+
raw_header_data["date"] + " " + raw_header_data["time"]
|
|
165
|
+
)
|
|
166
|
+
header_data["recording_start_datetime"] = datetime.strptime(
|
|
167
|
+
start_recording_datetime_as_string, "%m-%d-%Y %H:%M:%S.%f"
|
|
168
|
+
)
|
|
169
|
+
header_data["sampling_frequency_hz"] = int(raw_header_data["rate"])
|
|
170
|
+
header_data["vertical_units"] = raw_header_data["vert_units"].split(",")
|
|
171
|
+
header_data["horizontal_units"] = raw_header_data["horz_units"]
|
|
172
|
+
header_data["comment"] = raw_header_data["comment"]
|
|
173
|
+
header_data["number_of_series"] = int(raw_header_data["num_series"])
|
|
174
|
+
header_data["storage_mode"] = raw_header_data["storage_mode"]
|
|
175
|
+
# The file_type lists how the data was recorded and saved in .dat
|
|
176
|
+
# INTEGER = 16 bit A/D = 2-byte integers
|
|
177
|
+
# LONG = 24 bit A/D = 4-byte integers
|
|
178
|
+
header_data["file_type"] = raw_header_data["file_type"]
|
|
179
|
+
header_data["slope"] = [
|
|
180
|
+
float(slope) for slope in raw_header_data["slope"].split(",")
|
|
181
|
+
]
|
|
182
|
+
header_data["x_offset"] = float(raw_header_data["x_offset"])
|
|
183
|
+
header_data["y_offset"] = [
|
|
184
|
+
float(y_offset) for y_offset in raw_header_data["y_offset"].split(",")
|
|
185
|
+
]
|
|
186
|
+
header_data["number_of_samples"] = int(raw_header_data["num_samps"])
|
|
187
|
+
# The .hdr file will have a row containing just "DATA" to indicate
|
|
188
|
+
# that the entries here on are proprietary to the data recorder.
|
|
189
|
+
# Prior to this point, the header file was in the DADiSP format.
|
|
190
|
+
header_data["device"] = raw_header_data["device"]
|
|
191
|
+
# FIXME: The following information is not recorded when recording to
|
|
192
|
+
# a PC. Should update the reading and writing code to handle
|
|
193
|
+
# that scenario.
|
|
194
|
+
slot1_amp = raw_header_data["slot1_amp"].split(",")
|
|
195
|
+
header_data["slot1_amp"] = {}
|
|
196
|
+
header_data["slot1_amp"]["id_name"] = slot1_amp[0]
|
|
197
|
+
header_data["slot1_amp"]["num_of_channels"] = slot1_amp[1]
|
|
198
|
+
header_data["slot1_amp"]["pld_version"] = slot1_amp[2].strip()
|
|
199
|
+
header_data["slot1_amp"]["firmware_version"] = slot1_amp[3].strip()
|
|
200
|
+
header_data["slot2_amp"] = {}
|
|
201
|
+
slot2_amp = raw_header_data["slot2_amp"].split(",")
|
|
202
|
+
header_data["slot2_amp"]["id_name"] = slot2_amp[0]
|
|
203
|
+
header_data["slot2_amp"]["num_of_channels"] = slot2_amp[1]
|
|
204
|
+
header_data["slot2_amp"]["pld_version"] = slot2_amp[2].strip()
|
|
205
|
+
header_data["slot2_amp"]["firmware_version"] = slot2_amp[3].strip()
|
|
206
|
+
header_data["channel_info"] = []
|
|
207
|
+
for index in range(header_data["number_of_series"]):
|
|
208
|
+
raw_key = "ch{channel_num}_{channel_num}".format(channel_num=index + 1)
|
|
209
|
+
raw_channel_info = raw_header_data[raw_key].split(",")
|
|
210
|
+
header_data["channel_info"].append(
|
|
211
|
+
{
|
|
212
|
+
"channel_num": index + 1,
|
|
213
|
+
"amp_type": raw_channel_info[0],
|
|
214
|
+
"range_setting": raw_channel_info[1],
|
|
215
|
+
"filter_setting": raw_channel_info[2],
|
|
216
|
+
}
|
|
217
|
+
)
|
|
218
|
+
header_data["id_num"] = int(raw_header_data["id_no"])
|
|
219
|
+
start_time, end_time = raw_header_data["time2"].split(",")
|
|
220
|
+
header_data["start_time"] = int(start_time)
|
|
221
|
+
header_data["stop_time"] = int(end_time)
|
|
222
|
+
header_data["recording_destination"] = raw_header_data["rec_mode"]
|
|
223
|
+
# FIXME: Need to properly parse the start trigger and stop condition
|
|
224
|
+
# Right now I'm basically assuming they're not used in the data.
|
|
225
|
+
header_data["start_trigger"] = raw_header_data["start_trigger"]
|
|
226
|
+
header_data["stop_condition"] = raw_header_data["stop_condition"]
|
|
227
|
+
if "voice_memo" in raw_header_data:
|
|
228
|
+
# Voice memo was recorded
|
|
229
|
+
header_data["voice_memo_on"] = True
|
|
230
|
+
voice_memo_temp = raw_header_data["voice_memo"].split(",")
|
|
231
|
+
header_data["voice_memo_bits_per_sample"] = voice_memo_temp[0]
|
|
232
|
+
header_data["voice_memo_size_bytes"] = int(voice_memo_temp[1])
|
|
233
|
+
else:
|
|
234
|
+
# Voice memo was not recored
|
|
235
|
+
header_data["voice_memo_on"] = False
|
|
236
|
+
# Determine the version of data recorder used to capture the data
|
|
237
|
+
# FIXME: Instead of saving the FW and PAL versions for the recorder
|
|
238
|
+
# as a string, I should split them out into their own dictionary.
|
|
239
|
+
if "lx10_version" in raw_header_data:
|
|
240
|
+
header_data["recorder_model"] = "LX10"
|
|
241
|
+
header_data["recorder_version"] = raw_header_data["lx10_version"]
|
|
242
|
+
elif "lx20_version" in raw_header_data:
|
|
243
|
+
header_data["recorder_model"] = "LX20"
|
|
244
|
+
header_data["recorder_version"] = raw_header_data["lx20_version"]
|
|
245
|
+
elif "lx110_version" in raw_header_data:
|
|
246
|
+
header_data["recorder_model"] = "LX110"
|
|
247
|
+
header_data["recorder_version"] = raw_header_data["lx110_version"]
|
|
248
|
+
elif "lx120_version" in raw_header_data:
|
|
249
|
+
header_data["recorder_model"] = "LX120"
|
|
250
|
+
header_data["recorder_version"] = raw_header_data["lx120_version"]
|
|
251
|
+
else:
|
|
252
|
+
header_data["recorder_model"] = "Unrecognized data recorder model"
|
|
253
|
+
header_data["recorder_version"] = "Unknown data recorder version"
|
|
254
|
+
# FIXME: Instead of storing the memo_length as a string, I should strip
|
|
255
|
+
# the first number which is the length of the memo. After the memo
|
|
256
|
+
# length, this field will always have seven comma separated zeros
|
|
257
|
+
# (e.g., ",0,0,0,0,0,0,0")
|
|
258
|
+
header_data["memo_length"] = raw_header_data["memo_length"]
|
|
259
|
+
header_data["memo"] = raw_header_data["memo"]
|
|
260
|
+
|
|
261
|
+
return header_data
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def change_slope(data_array, series: int, gain: int | float):
|
|
265
|
+
"""Apply gain to the desired series in a data_array
|
|
266
|
+
|
|
267
|
+
Args:
|
|
268
|
+
data_array:
|
|
269
|
+
series: integer listing the series to apply the gain (0-based)
|
|
270
|
+
gain: float by which all data for given series will be multiplied
|
|
271
|
+
|
|
272
|
+
Return:
|
|
273
|
+
data_array: Return by reference
|
|
274
|
+
|
|
275
|
+
Raises:
|
|
276
|
+
N/A
|
|
277
|
+
"""
|
|
278
|
+
data_array[series] = gain * data_array[series]
|
|
279
|
+
return data_array
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _read_taffmat_dat(input_dat_file, file_type, number_of_series, slope, y_offset):
|
|
283
|
+
"""Read the TAFFmat binary .dat file
|
|
284
|
+
|
|
285
|
+
Args:
|
|
286
|
+
input_dat_file: Filename of the .dat file
|
|
287
|
+
file_type: INTEGER or LONG as determined by reading the .hdr
|
|
288
|
+
file so we know if the data was recording in 2 or 4-bytes
|
|
289
|
+
number_of_series: Integer from .hdr file stating the number of
|
|
290
|
+
series recorded in the .dat file
|
|
291
|
+
slope: list of floats read from .hdr file (slope = range / 25,000).
|
|
292
|
+
One float per series. The max range of the ADC is +/-25,000
|
|
293
|
+
0.5V = 2e-5
|
|
294
|
+
1V = 4e-5
|
|
295
|
+
2V = 8e-5
|
|
296
|
+
5V = 2e-4
|
|
297
|
+
10V = 4e-4
|
|
298
|
+
20V = 8e-4
|
|
299
|
+
50V = 2e-3
|
|
300
|
+
y_offset: list of floats read from .hdr file. One float per
|
|
301
|
+
series.
|
|
302
|
+
|
|
303
|
+
Returns:
|
|
304
|
+
data_array: ndarray with shape series x num_samples
|
|
305
|
+
|
|
306
|
+
Raises:
|
|
307
|
+
N/A
|
|
308
|
+
"""
|
|
309
|
+
|
|
310
|
+
# Determine if the .dat file saved the data using 2-bytes (int16)
|
|
311
|
+
# or 4-bytes (int32).
|
|
312
|
+
if file_type == "INTEGER":
|
|
313
|
+
data_size = np.int16
|
|
314
|
+
elif file_type == "LONG":
|
|
315
|
+
data_size = np.int32
|
|
316
|
+
else:
|
|
317
|
+
data_size = np.int16
|
|
318
|
+
# Read the entire file and reshape the data so that each channel/series
|
|
319
|
+
# is in its own row
|
|
320
|
+
with open(input_dat_file, "rb") as datfile:
|
|
321
|
+
data_array = np.fromfile(datfile, data_size).reshape((-1, number_of_series)).T
|
|
322
|
+
|
|
323
|
+
data_array = _apply_slope_and_offset(data_array, number_of_series, slope, y_offset)
|
|
324
|
+
|
|
325
|
+
return data_array
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _write_taffmat_hdr(header_data, output_hdr_filename):
|
|
329
|
+
"""
|
|
330
|
+
Write the TAFFmat .hdr file
|
|
331
|
+
"""
|
|
332
|
+
output_hdr_filename_root, _output_hdr_filename_extension = os.path.splitext(
|
|
333
|
+
os.path.basename(output_hdr_filename)
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
# Convert "smart" dictionary items into strings that are
|
|
337
|
+
# ready to be saved to the .hdr text file.
|
|
338
|
+
header_output = []
|
|
339
|
+
header_output.append(f"DATASET {output_hdr_filename_root.upper()}")
|
|
340
|
+
header_output.append("VERSION {}".format(header_data["version"]))
|
|
341
|
+
header_output.append("SERIES " + ",".join(header_data["series_labels"]) + " ")
|
|
342
|
+
header_output.append(
|
|
343
|
+
"DATE " + header_data["recording_start_datetime"].strftime("%m-%d-%Y")
|
|
344
|
+
)
|
|
345
|
+
header_output.append(
|
|
346
|
+
"TIME " + header_data["recording_start_datetime"].strftime("%H:%M:%S.%f")[0:11]
|
|
347
|
+
)
|
|
348
|
+
header_output.append("RATE " + str(header_data["sampling_frequency_hz"]))
|
|
349
|
+
header_output.append("VERT_UNITS " + ",".join(header_data["vertical_units"]) + " ")
|
|
350
|
+
header_output.append("HORZ_UNITS {}".format(header_data["horizontal_units"]))
|
|
351
|
+
header_output.append("COMMENT {}".format(header_data["comment"]))
|
|
352
|
+
header_output.append("NUM_SERIES {}".format(header_data["number_of_series"]))
|
|
353
|
+
header_output.append("STORAGE_MODE {}".format(header_data["storage_mode"]))
|
|
354
|
+
header_output.append("FILE_TYPE {}".format(header_data["file_type"]))
|
|
355
|
+
header_output.append(
|
|
356
|
+
"SLOPE "
|
|
357
|
+
+ ",".join(
|
|
358
|
+
[_format_exponent_notation(slope, 6, 3) for slope in header_data["slope"]]
|
|
359
|
+
)
|
|
360
|
+
+ " "
|
|
361
|
+
)
|
|
362
|
+
header_output.append("X_OFFSET {:1.1f}".format(header_data["x_offset"]))
|
|
363
|
+
header_output.append(
|
|
364
|
+
"Y_OFFSET "
|
|
365
|
+
+ ",".join(
|
|
366
|
+
[
|
|
367
|
+
_format_exponent_notation(y_offset, 6, 3)
|
|
368
|
+
for y_offset in header_data["y_offset"]
|
|
369
|
+
]
|
|
370
|
+
)
|
|
371
|
+
+ " "
|
|
372
|
+
)
|
|
373
|
+
header_output.append("NUM_SAMPS {}".format(header_data["number_of_samples"]))
|
|
374
|
+
header_output.append("DATA")
|
|
375
|
+
header_output.append("DEVICE {}".format(header_data["device"]))
|
|
376
|
+
header_output.append(
|
|
377
|
+
"SLOT1_AMP {id},{num_ch},{pld_ver},{fw_ver}".format(
|
|
378
|
+
id=header_data["slot1_amp"]["id_name"],
|
|
379
|
+
num_ch=header_data["slot1_amp"]["num_of_channels"],
|
|
380
|
+
pld_ver=header_data["slot1_amp"]["pld_version"].ljust(8),
|
|
381
|
+
fw_ver=header_data["slot1_amp"]["firmware_version"].ljust(8),
|
|
382
|
+
)
|
|
383
|
+
)
|
|
384
|
+
header_output.append(
|
|
385
|
+
"SLOT2_AMP {id},{num_ch},{pld_ver},{fw_ver}".format(
|
|
386
|
+
id=header_data["slot2_amp"]["id_name"],
|
|
387
|
+
num_ch=header_data["slot2_amp"]["num_of_channels"],
|
|
388
|
+
pld_ver=header_data["slot2_amp"]["pld_version"].ljust(8),
|
|
389
|
+
fw_ver=header_data["slot2_amp"]["firmware_version"].ljust(8),
|
|
390
|
+
)
|
|
391
|
+
)
|
|
392
|
+
for index in range(header_data["number_of_series"]):
|
|
393
|
+
channel_key = "CH{channel_num}_{channel_num}".format(channel_num=index + 1)
|
|
394
|
+
header_output.append(
|
|
395
|
+
"{channel_key} {amp_type},{range_setting},{filter_setting}".format(
|
|
396
|
+
channel_key=channel_key,
|
|
397
|
+
amp_type=header_data["channel_info"][index]["amp_type"],
|
|
398
|
+
range_setting=header_data["channel_info"][index]["range_setting"],
|
|
399
|
+
filter_setting=header_data["channel_info"][index]["filter_setting"],
|
|
400
|
+
)
|
|
401
|
+
)
|
|
402
|
+
header_output.append("ID_NO {id_num}".format(id_num=header_data["id_num"]))
|
|
403
|
+
header_output.append(
|
|
404
|
+
"TIME {start},{end}".format(
|
|
405
|
+
start=header_data["start_time"], end=header_data["stop_time"]
|
|
406
|
+
)
|
|
407
|
+
)
|
|
408
|
+
header_output.append(
|
|
409
|
+
"REC_MODE {rec_mode} ".format(rec_mode=header_data["recording_destination"])
|
|
410
|
+
)
|
|
411
|
+
header_output.append(
|
|
412
|
+
"START_TRIGGER {trigger} ".format(trigger=header_data["start_trigger"])
|
|
413
|
+
)
|
|
414
|
+
header_output.append(
|
|
415
|
+
"STOP_CONDITION {condition} ".format(condition=header_data["stop_condition"])
|
|
416
|
+
)
|
|
417
|
+
header_output.append("ID_END")
|
|
418
|
+
if header_data["voice_memo_on"]:
|
|
419
|
+
header_output.append(
|
|
420
|
+
"VOICE_MEMO {bits},{size}".format(
|
|
421
|
+
bits=header_data["voice_memo_bits_per_sample"],
|
|
422
|
+
size=header_data["voice_memo_size_bytes"],
|
|
423
|
+
)
|
|
424
|
+
)
|
|
425
|
+
header_output.append(
|
|
426
|
+
"{model}_VERSION {ver}".format(
|
|
427
|
+
model=header_data["recorder_model"], ver=header_data["recorder_version"]
|
|
428
|
+
)
|
|
429
|
+
)
|
|
430
|
+
header_output.append(
|
|
431
|
+
"MEMO_LENGTH {memo_len}".format(memo_len=header_data["memo_length"])
|
|
432
|
+
)
|
|
433
|
+
header_output.append("MEMO {memo}".format(memo=header_data["memo"]))
|
|
434
|
+
header_output.append("")
|
|
435
|
+
|
|
436
|
+
header_output = _append_windows_newlines(header_output)
|
|
437
|
+
|
|
438
|
+
# Write the .hdr file
|
|
439
|
+
with open(output_hdr_filename, "w") as f_header:
|
|
440
|
+
f_header.writelines(header_output)
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _write_taffmat_dat(
|
|
444
|
+
data_array, number_of_series, slope, y_offset, output_dat_filename
|
|
445
|
+
):
|
|
446
|
+
"""
|
|
447
|
+
Write the .dat TAFFmat file
|
|
448
|
+
WARNING: Changes data_array in calling code!!!
|
|
449
|
+
"""
|
|
450
|
+
|
|
451
|
+
# Convert data_array into int16 values by removing the offset
|
|
452
|
+
# and slope, such that +/-100% = +/-25,000 int16
|
|
453
|
+
data_array = _remove_slope_and_offset(data_array, number_of_series, slope, y_offset)
|
|
454
|
+
|
|
455
|
+
# Write the binary data file.
|
|
456
|
+
with open(output_dat_filename, "wb") as datfile:
|
|
457
|
+
data_array.T.reshape((-1, number_of_series)).tofile(datfile)
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def read_taffmat(input_file):
|
|
461
|
+
"""Read the TAFFmat .hdr and .dat files
|
|
462
|
+
|
|
463
|
+
Read the Teac TAFFmat text header file (.hdr) and the binary
|
|
464
|
+
data file (.dat).
|
|
465
|
+
|
|
466
|
+
Args:
|
|
467
|
+
input_file: Filename consisting of either just the base
|
|
468
|
+
filename or can include the .dat or .hdr suffix
|
|
469
|
+
|
|
470
|
+
Returns:
|
|
471
|
+
A tuple containing the data_array (ndarray with shape
|
|
472
|
+
of series x num_samples),
|
|
473
|
+
time_vector (ndarray), and header_data (dictionary)
|
|
474
|
+
|
|
475
|
+
Raises:
|
|
476
|
+
N/A
|
|
477
|
+
"""
|
|
478
|
+
# If the input_file contains the extension .dat or .hdr,
|
|
479
|
+
# strip that off to create the input_file_basename
|
|
480
|
+
# and then create both the .dat and .hdr filenames
|
|
481
|
+
input_file_basename, input_file_extension = os.path.splitext(input_file)
|
|
482
|
+
|
|
483
|
+
if input_file_extension.lower() in [".dat", ".hdr"]:
|
|
484
|
+
# The input_file contained the extension of .dat or .hdr
|
|
485
|
+
input_dat_file = f"{input_file_basename}.DAT"
|
|
486
|
+
input_hdr_file = f"{input_file_basename}.HDR"
|
|
487
|
+
else:
|
|
488
|
+
# The input_file didn't contain an extension, so append .dat and .hdr
|
|
489
|
+
# TODO: Add unit tests to make sure we're properly handling
|
|
490
|
+
# input_file with .dat, .hdr, or no extension
|
|
491
|
+
input_dat_file = f"{input_file}.DAT"
|
|
492
|
+
input_hdr_file = f"{input_file}.HDR"
|
|
493
|
+
|
|
494
|
+
if not os.path.isfile(input_dat_file) or not os.path.isfile(input_hdr_file):
|
|
495
|
+
raise FileNotFoundError("The .dat or .hdr file doesn't exist")
|
|
496
|
+
|
|
497
|
+
# Read the hdr file
|
|
498
|
+
header_data = _read_taffmat_hdr(input_hdr_file)
|
|
499
|
+
|
|
500
|
+
# Read the dat file
|
|
501
|
+
data_array = _read_taffmat_dat(
|
|
502
|
+
input_dat_file,
|
|
503
|
+
header_data["file_type"],
|
|
504
|
+
header_data["number_of_series"],
|
|
505
|
+
header_data["slope"],
|
|
506
|
+
header_data["y_offset"],
|
|
507
|
+
)
|
|
508
|
+
|
|
509
|
+
# Create the time vector
|
|
510
|
+
time_vector = np.linspace(
|
|
511
|
+
0,
|
|
512
|
+
(header_data["number_of_samples"] / header_data["sampling_frequency_hz"]),
|
|
513
|
+
header_data["number_of_samples"],
|
|
514
|
+
)
|
|
515
|
+
|
|
516
|
+
# Return a tuple
|
|
517
|
+
return (data_array, time_vector, header_data)
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def write_taffmat(data_array, header_data, output_base_filename):
|
|
521
|
+
"""
|
|
522
|
+
Write the TAFFmat .dat and .hdr files
|
|
523
|
+
"""
|
|
524
|
+
|
|
525
|
+
# Determine the output file names
|
|
526
|
+
output_hdr_filename = f"{output_base_filename}.HDR"
|
|
527
|
+
output_dat_filename = f"{output_base_filename}.DAT"
|
|
528
|
+
|
|
529
|
+
_write_taffmat_hdr(header_data, output_hdr_filename)
|
|
530
|
+
_write_taffmat_dat(
|
|
531
|
+
data_array,
|
|
532
|
+
header_data["number_of_series"],
|
|
533
|
+
header_data["slope"],
|
|
534
|
+
header_data["y_offset"],
|
|
535
|
+
output_dat_filename,
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def write_taffmat_slice(
|
|
540
|
+
data_array,
|
|
541
|
+
header_data,
|
|
542
|
+
output_base_filename,
|
|
543
|
+
starting_data_index,
|
|
544
|
+
ending_data_index,
|
|
545
|
+
):
|
|
546
|
+
"""
|
|
547
|
+
Write the TAFFmat .dat and .hdr given the starting and ending
|
|
548
|
+
data points to include in the .dat file.
|
|
549
|
+
|
|
550
|
+
The only change to the .hdr file from the given header_data
|
|
551
|
+
dictionary is that the number of samples will be recalculated
|
|
552
|
+
based on the starting and ending data points to be written.
|
|
553
|
+
"""
|
|
554
|
+
|
|
555
|
+
# TODO(mdr): Add a check to determine if the data_array is beyond
|
|
556
|
+
# the range in the header.and if so log it.
|
|
557
|
+
|
|
558
|
+
# Since slices are simply views into the original array, we need
|
|
559
|
+
# to copy the array before performing the ADC conversion required
|
|
560
|
+
# by the LX-10 when storing data as integers.
|
|
561
|
+
data_array_copy = data_array.copy()
|
|
562
|
+
|
|
563
|
+
# Create copies of the originals
|
|
564
|
+
sliced_data_array = data_array_copy[:, starting_data_index : ending_data_index + 1]
|
|
565
|
+
sliced_header_data = header_data
|
|
566
|
+
|
|
567
|
+
# Calculate number of samples
|
|
568
|
+
new_number_of_samples = ending_data_index + 1 - starting_data_index
|
|
569
|
+
|
|
570
|
+
# Update header_data with the new number of samples
|
|
571
|
+
sliced_header_data["number_of_samples"] = new_number_of_samples
|
|
572
|
+
|
|
573
|
+
# Since we're saving a slice, the voice memo will not be the
|
|
574
|
+
# same length, so just disable the voice memo (i.e., remove
|
|
575
|
+
# VOICE_MEMO line from .HDR file)
|
|
576
|
+
sliced_header_data["voice_memo_on"] = False
|
|
577
|
+
|
|
578
|
+
# Rename the DATASET to the new filename
|
|
579
|
+
sliced_header_data["dataset"] = os.path.basename(output_base_filename).upper()
|
|
580
|
+
|
|
581
|
+
# Write the sliced TAFFmat data
|
|
582
|
+
write_taffmat(sliced_data_array, sliced_header_data, output_base_filename)
|
taffmat/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: taffmat
|
|
3
|
+
Version: 2.0.0
|
|
4
|
+
Summary: Read and write Teac TAFFmat files
|
|
5
|
+
Project-URL: Homepage, https://github.com/questrail/taffmat
|
|
6
|
+
Project-URL: Issues, https://github.com/questrail/taffmat/issues
|
|
7
|
+
Author-email: Matthew Rankin <369937+matthewrankin@users.noreply.github.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE.txt
|
|
10
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python
|
|
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: Topic :: Software Development :: Libraries :: Python Modules
|
|
19
|
+
Requires-Python: >=3.12
|
|
20
|
+
Requires-Dist: numpy>=2.2.0
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# taffmat
|
|
24
|
+
|
|
25
|
+
[![PyPI Version][pypi ver image]][pypi ver link]
|
|
26
|
+
[![Python Versions][pyversions image]][pypi ver link]
|
|
27
|
+
[![CI Status][ci image]][ci link]
|
|
28
|
+
[![Coverage Status][coveralls image]][coveralls link]
|
|
29
|
+
[![License Badge][license image]][LICENSE.txt]
|
|
30
|
+
|
|
31
|
+
A Python 3.12+ module for reading and writing Teac TAFFmat files.
|
|
32
|
+
|
|
33
|
+
## About the TAFFmat file format
|
|
34
|
+
|
|
35
|
+
TAFFmat is Teac's proprietary file format used to store data from their
|
|
36
|
+
LX series and other data recorders.
|
|
37
|
+
|
|
38
|
+
According to the Teac "LX Series Recording Unit Instruction Manual":
|
|
39
|
+
|
|
40
|
+
> TAFFmat (an acronym for Teac Data Acquisition File Format) is a
|
|
41
|
+
> file format composed of the following:
|
|
42
|
+
>
|
|
43
|
+
> * a data file containing A/D (analog to digital) converted data. The
|
|
44
|
+
> file is binary format with the extension dat.
|
|
45
|
+
> * a header file containing information such as recording
|
|
46
|
+
> conditions. The file is in text format with the extension hdr.
|
|
47
|
+
|
|
48
|
+
TAFFmat is a trademark of Teac Corporation.
|
|
49
|
+
|
|
50
|
+
### Data Recorders Using TAFFmat
|
|
51
|
+
|
|
52
|
+
The following data recorders store their data in the TAFFmat file format:
|
|
53
|
+
|
|
54
|
+
* Teac [LX-10/20][]
|
|
55
|
+
* Teac [LX-110/120][]
|
|
56
|
+
* Teac [WX-7000 Series][]
|
|
57
|
+
* Teac [es8][]
|
|
58
|
+
|
|
59
|
+
## Installation
|
|
60
|
+
|
|
61
|
+
You can install [taffmat][] either via the Python Package Index (PyPI)
|
|
62
|
+
or from source.
|
|
63
|
+
|
|
64
|
+
To install using pip:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
$ pip install taffmat
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
**Source:** https://github.com/questrail/taffmat
|
|
71
|
+
|
|
72
|
+
## Requirements
|
|
73
|
+
|
|
74
|
+
[taffmat][] requires the following Python packages:
|
|
75
|
+
|
|
76
|
+
* [numpy][]
|
|
77
|
+
|
|
78
|
+
## Public API
|
|
79
|
+
|
|
80
|
+
The following functions are provided:
|
|
81
|
+
|
|
82
|
+
- `change_slope(data_array, series, gain)`
|
|
83
|
+
- `read_taffmat(input_file)`
|
|
84
|
+
- `write_taffmat(data_array, header_data, output_base_filename)`
|
|
85
|
+
- `write_taffmat_slice(data_array, header_data, output_base_filename,
|
|
86
|
+
starting_data_index, ending_data_index`
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
## Contributing
|
|
90
|
+
|
|
91
|
+
Contributions are welcome! To contribute please:
|
|
92
|
+
|
|
93
|
+
1. Fork the repository
|
|
94
|
+
2. Create a feature branch
|
|
95
|
+
3. Add code and tests
|
|
96
|
+
4. Pass lint and tests
|
|
97
|
+
5. Submit a [pull request][]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
## Development Setup
|
|
101
|
+
|
|
102
|
+
The project is managed with [uv][], and the development tasks are [just][]
|
|
103
|
+
recipes.
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
$ brew install uv just
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
`uv sync` creates the virtualenv and installs the dependencies, including
|
|
110
|
+
the development group, and `just` on its own lists the available recipes.
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
$ uv sync
|
|
114
|
+
$ just
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
The most common recipes are:
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
$ just test # Run the tests using pytest
|
|
121
|
+
$ just lint # Check lint, formatting, types, and workflows
|
|
122
|
+
$ just fix # Lint and format the code using ruff, applying fixes
|
|
123
|
+
$ just cov # Run the tests and report coverage
|
|
124
|
+
$ just add X # Add X as a dependency
|
|
125
|
+
$ just out # List the outdated dependencies
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
[ruff][] and [pyright][] are deliberately absent from that `brew install`
|
|
129
|
+
line. Both are dev dependencies pinned in `uv.lock` and reached through
|
|
130
|
+
`uv run`, so every recipe and every CI job uses the same version. A `brew
|
|
131
|
+
install ruff` would put a second, unpinned copy on the path for an editor
|
|
132
|
+
to find, and ruff releases change how code is formatted: the editor would
|
|
133
|
+
then reformat code that `ruff format --check` rejects on the next run.
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
### Releasing to PyPI
|
|
137
|
+
|
|
138
|
+
`just release` cuts the release. It first checks that a release is
|
|
139
|
+
possible at all, then lints, type checks, and tests, then shows the
|
|
140
|
+
entries waiting under Unreleased and the version each kind of bump would
|
|
141
|
+
produce, and asks which to cut. Once answered it bumps the version, closes
|
|
142
|
+
out the CHANGELOG, updates the lock file, commits, and tags. Pushing the
|
|
143
|
+
tag is what publishes.
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
$ just release
|
|
147
|
+
...
|
|
148
|
+
Which release? [1] 1
|
|
149
|
+
|
|
150
|
+
Tagged v1.0.2. Publish it with:
|
|
151
|
+
|
|
152
|
+
git push --follow-tags
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
The tag push runs the [release workflow][], which waits on the whole [CI
|
|
156
|
+
workflow][ci link] before it does anything else: the 3.12, 3.13, and 3.14
|
|
157
|
+
matrix and the dependency floor job. It then checks that the tagged commit is on
|
|
158
|
+
`master`, since a tag is only a pointer and one placed anywhere else would
|
|
159
|
+
otherwise publish whatever it points at, rechecks the tag against the
|
|
160
|
+
version in `pyproject.toml`, and builds.
|
|
161
|
+
|
|
162
|
+
Every check to that point runs against the source tree, so the workflow
|
|
163
|
+
then installs the wheel it just built somewhere `src/` is not on the path
|
|
164
|
+
and exercises it there, which is the only step that can catch a packaging
|
|
165
|
+
mistake. It uploads once that passes. There is no PyPI API token
|
|
166
|
+
anywhere: the workflow authenticates with [trusted publishing][], which
|
|
167
|
+
mints a short lived credential from the GitHub OIDC identity of that run.
|
|
168
|
+
That same identity signs a [PEP 740][] attestation for each distribution,
|
|
169
|
+
which PyPI serves beside the file it attests.
|
|
170
|
+
|
|
171
|
+
Uploading is followed by a [GitHub release][releases] for the tag,
|
|
172
|
+
carrying the CHANGELOG section for that version as its notes and the built
|
|
173
|
+
distributions as its assets.
|
|
174
|
+
|
|
175
|
+
Pushing the tag is the point of no return, since PyPI never lets a version
|
|
176
|
+
number be reused. Everything `just release` does is local and amendable
|
|
177
|
+
until then, and it refuses to start against a dirty working tree, off
|
|
178
|
+
`master`, on a `master` behind its upstream, with a CHANGELOG whose
|
|
179
|
+
Unreleased section is empty, or when the tag it would create already
|
|
180
|
+
exists. `just release-check` runs those refusals on their own.
|
|
181
|
+
|
|
182
|
+
`just build` runs the same checks and produces the same distributions
|
|
183
|
+
without releasing anything, which is the way to inspect what CI would
|
|
184
|
+
upload.
|
|
185
|
+
|
|
186
|
+
This depends on one piece of configuration that lives outside the
|
|
187
|
+
repository. A [trusted publisher][trusted publishing] has to be registered
|
|
188
|
+
for `taffmat` on PyPI, pointing at the `questrail/taffmat` repository, the
|
|
189
|
+
`release.yml` workflow, and the `pypi` environment. It is a one time setup
|
|
190
|
+
per project.
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
## License
|
|
194
|
+
|
|
195
|
+
[taffmat][] is released under the MIT license. Please see the
|
|
196
|
+
[LICENSE.txt][] file for more information.
|
|
197
|
+
|
|
198
|
+
[ci image]: https://github.com/questrail/taffmat/actions/workflows/ci.yml/badge.svg?branch=master
|
|
199
|
+
[ci link]: https://github.com/questrail/taffmat/actions/workflows/ci.yml
|
|
200
|
+
[coveralls image]: https://coveralls.io/repos/github/questrail/taffmat/badge.svg?branch=master
|
|
201
|
+
[coveralls link]: https://coveralls.io/github/questrail/taffmat?branch=master
|
|
202
|
+
[es8]: http://teac-ipd.com/data-recorders/es8/
|
|
203
|
+
[github flow]: http://scottchacon.com/2011/08/31/github-flow.html
|
|
204
|
+
[just]: https://just.systems
|
|
205
|
+
[license image]: https://img.shields.io/pypi/l/taffmat.svg
|
|
206
|
+
[LICENSE.txt]: https://github.com/questrail/taffmat/blob/master/LICENSE.txt
|
|
207
|
+
[LX-10/20]: http://www.teac.co.jp/en/industry/measurement/datarecorder/lx10/index.html
|
|
208
|
+
[LX-110/120]: http://teac-ipd.com/data-recorders/lx-110120/
|
|
209
|
+
[numpy]: http://www.numpy.org
|
|
210
|
+
[PEP 740]: https://peps.python.org/pep-0740/
|
|
211
|
+
[pull request]: https://help.github.com/articles/using-pull-requests
|
|
212
|
+
[pypi ver image]: https://img.shields.io/pypi/v/taffmat.svg
|
|
213
|
+
[pypi ver link]: https://pypi.python.org/pypi/taffmat/
|
|
214
|
+
[pyright]: https://microsoft.github.io/pyright/
|
|
215
|
+
[pyversions image]: https://img.shields.io/pypi/pyversions/taffmat.svg
|
|
216
|
+
[release workflow]: https://github.com/questrail/taffmat/blob/master/.github/workflows/release.yml
|
|
217
|
+
[releases]: https://github.com/questrail/taffmat/releases
|
|
218
|
+
[ruff]: https://docs.astral.sh/ruff/
|
|
219
|
+
[taffmat]: https://github.com/questrail/taffmat
|
|
220
|
+
[trusted publishing]: https://docs.pypi.org/trusted-publishers/
|
|
221
|
+
[uv]: https://docs.astral.sh/uv/
|
|
222
|
+
[WX-7000 Series]: http://teac-ipd.com/wx-7000/
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
taffmat/__init__.py,sha256=akKSM3f7GL39Rp9ZQ8khdhrNxH5O5yWM3ocyLqyKkLg,22746
|
|
2
|
+
taffmat/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
|
+
taffmat-2.0.0.dist-info/METADATA,sha256=5jYDzhYcal-rD-KiN2CjMv8_jRLNz60Wd3EODqGext8,8147
|
|
4
|
+
taffmat-2.0.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
5
|
+
taffmat-2.0.0.dist-info/licenses/LICENSE.txt,sha256=cdK1MlXoHSJJ8Efmg30oNuJFmGll0JZASghFO5ffEs8,1066
|
|
6
|
+
taffmat-2.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Copyright (c) 2013 The taffmat developers
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
4
|
+
a copy of this software and associated documentation files (the
|
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
|
9
|
+
the following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be
|
|
12
|
+
included in all copies or substantial portions of the Software.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|