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 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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.