teraflash-ctrl 1.4.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.
- __init__.py +8 -0
- interface.py +356 -0
- io.py +139 -0
- math_utils.py +115 -0
- pulse_detection.py +37 -0
- teraflash.py +561 -0
- teraflash_ctrl-1.4.0.dist-info/METADATA +87 -0
- teraflash_ctrl-1.4.0.dist-info/RECORD +10 -0
- teraflash_ctrl-1.4.0.dist-info/WHEEL +5 -0
- teraflash_ctrl-1.4.0.dist-info/top_level.txt +6 -0
__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Copyright (c) 2026 University of Bern, Space Research & Planetary Sciences, Linus Leo Stöckli.
|
|
3
|
+
|
|
4
|
+
This work is licensed under the Creative Commons
|
|
5
|
+
Attribution-NonCommercial 4.0 International License.
|
|
6
|
+
To view a copy of this license, visit
|
|
7
|
+
https://creativecommons.org/licenses/by-nc/4.0/
|
|
8
|
+
"""
|
interface.py
ADDED
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Copyright (c) 2026 University of Bern, Space Research & Planetary Sciences, Linus Leo Stöckli.
|
|
3
|
+
|
|
4
|
+
This work is licensed under the Creative Commons
|
|
5
|
+
Attribution-NonCommercial 4.0 International License.
|
|
6
|
+
To view a copy of this license, visit
|
|
7
|
+
https://creativecommons.org/licenses/by-nc/4.0/
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import time
|
|
11
|
+
from queue import Queue, Empty
|
|
12
|
+
import threading
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
import platform
|
|
16
|
+
import socket
|
|
17
|
+
import subprocess
|
|
18
|
+
import logging
|
|
19
|
+
|
|
20
|
+
from math_utils import get_fft
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class DataContainer:
|
|
24
|
+
|
|
25
|
+
def __init__(self, n=1000):
|
|
26
|
+
"""
|
|
27
|
+
Data container that stores the data sent by the instrument
|
|
28
|
+
Args:
|
|
29
|
+
n: length of dataset (depends on the selected range)
|
|
30
|
+
"""
|
|
31
|
+
self.time = np.zeros(n)
|
|
32
|
+
self.freq = np.zeros(n)
|
|
33
|
+
|
|
34
|
+
self.signal_1 = np.zeros(n)
|
|
35
|
+
self.fft_1_amp = np.zeros(n)
|
|
36
|
+
self.fft_1_phase = np.zeros(n)
|
|
37
|
+
|
|
38
|
+
self.signal_2 = np.zeros(n)
|
|
39
|
+
self.fft_2_amp = np.zeros(n)
|
|
40
|
+
self.fft_2_phase = np.zeros(n)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# global variables for thread communication
|
|
44
|
+
data = DataContainer()
|
|
45
|
+
n_avg = 0
|
|
46
|
+
status = ""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class TopticaSocket:
|
|
50
|
+
def __init__(self,
|
|
51
|
+
ip: str,
|
|
52
|
+
running: threading.Event,
|
|
53
|
+
connected: threading.Event,
|
|
54
|
+
cmd_ack: threading.Event,
|
|
55
|
+
buffer_emptied: threading.Event,
|
|
56
|
+
range_changed: threading.Event,
|
|
57
|
+
acq_running: threading.Event,
|
|
58
|
+
avg_data: threading.Event):
|
|
59
|
+
"""
|
|
60
|
+
TCP Socket struct, used for the communication between the server (Computer) and the client (instrument)
|
|
61
|
+
with two TCP connections (one for configuration and one for data transmission)
|
|
62
|
+
Args:
|
|
63
|
+
ip: ip address
|
|
64
|
+
running: threading event that signals if the application is running
|
|
65
|
+
connected: threading event that signals if a client is connected
|
|
66
|
+
cmd_ack: threading event that signals when a command is acknowledged
|
|
67
|
+
range_changed: threading event that signals when the range has been changed in the configuration
|
|
68
|
+
"""
|
|
69
|
+
self.send_header = b'\xcd\xef\x124x\x9a\xfe\xdc\x00\x00\x00\x02\x00\x00\x00\x00\x00\x00\x00'
|
|
70
|
+
self.r_stat_header = b'\xcd\xef\x124x\x9a\xfe\xdc\x00\x00\x00\x03\x00\x00\x00\x00\x00\x00\x02'
|
|
71
|
+
self.r_dat_header = b'\xcd\xef\x124x\x9a\xfe\xdc\x00\x00\x00\x01\x00\t\x1a\xe6\x03\xe8\x00\x00\x04L\x00\x00\x0c\xcc\xcc\xcc\x00\x00\x16\x0b\x00\x00]\xd8\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00'
|
|
72
|
+
self.r_dat_header = b'\xcd\xef\x124x\x9a\xfe'
|
|
73
|
+
self.full_data_header_len = 52
|
|
74
|
+
self.read_header_len = 19
|
|
75
|
+
self.data_header_len = 7
|
|
76
|
+
|
|
77
|
+
self.avg_countdown = 0
|
|
78
|
+
|
|
79
|
+
self.config_server_address = (ip, 6341)
|
|
80
|
+
self.data_server_address = (ip, 6342)
|
|
81
|
+
|
|
82
|
+
self.t_begin = 1000.00
|
|
83
|
+
self.range = 50
|
|
84
|
+
self.antenna_range = 1000.0
|
|
85
|
+
|
|
86
|
+
self.running = running
|
|
87
|
+
self.connected = connected
|
|
88
|
+
self.cmd_ack = cmd_ack
|
|
89
|
+
self.range_changed = range_changed
|
|
90
|
+
self.buffer_emptied = buffer_emptied
|
|
91
|
+
self.acq_running = acq_running
|
|
92
|
+
self.avg_data = avg_data
|
|
93
|
+
|
|
94
|
+
if not self.ping(ip):
|
|
95
|
+
raise ConnectionError
|
|
96
|
+
|
|
97
|
+
@staticmethod
|
|
98
|
+
def ping(host: str):
|
|
99
|
+
"""
|
|
100
|
+
determines whether a device is connected or not
|
|
101
|
+
Args:
|
|
102
|
+
host: IP address as a string
|
|
103
|
+
|
|
104
|
+
Returns: True for connected, False for not connected
|
|
105
|
+
|
|
106
|
+
"""
|
|
107
|
+
res = False
|
|
108
|
+
ping_param = "-n" if platform.system().lower() == "windows" else "-c"
|
|
109
|
+
command = ['ping', ping_param, '1', host]
|
|
110
|
+
result = subprocess.call(command)
|
|
111
|
+
if result == 0:
|
|
112
|
+
res = True
|
|
113
|
+
return res
|
|
114
|
+
|
|
115
|
+
def wait_for_answer(self, client: socket, length: int = 1024):
|
|
116
|
+
"""
|
|
117
|
+
waits for the device to acknowledge the previously sent command
|
|
118
|
+
Args:
|
|
119
|
+
client: socket object
|
|
120
|
+
length: length of the buffer
|
|
121
|
+
|
|
122
|
+
Returns: True for valid response, False for error
|
|
123
|
+
|
|
124
|
+
"""
|
|
125
|
+
while self.running.is_set():
|
|
126
|
+
_data = client.recv(length)[self.read_header_len:]
|
|
127
|
+
if _data:
|
|
128
|
+
_data_decoded = _data.decode("utf-8", "ignore")
|
|
129
|
+
if "OK" in _data_decoded:
|
|
130
|
+
logging.debug(f"[TCP CONF] received: {_data_decoded}")
|
|
131
|
+
return True
|
|
132
|
+
elif "MON" in _data_decoded:
|
|
133
|
+
return True
|
|
134
|
+
logging.debug(f"[TCP CONF] received: {_data_decoded}")
|
|
135
|
+
else:
|
|
136
|
+
logging.error(f"[TCP CONF] No valid reply from device: {_data.decode('utf-8', 'ignore')}")
|
|
137
|
+
return False
|
|
138
|
+
|
|
139
|
+
def run_conf_tcp(self, cmd_queue: Queue):
|
|
140
|
+
"""
|
|
141
|
+
Config TCP thread on port 6341. This handles all the configurations for the device.
|
|
142
|
+
Args:
|
|
143
|
+
cmd_queue: Queue from main thread with all the interactive
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
|
|
147
|
+
"""
|
|
148
|
+
global status
|
|
149
|
+
global data
|
|
150
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
151
|
+
# Set the SO_REUSEADDR option to allow reuse of the port
|
|
152
|
+
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
153
|
+
|
|
154
|
+
# bind server to the address (only works when the address exists)
|
|
155
|
+
s.bind(self.config_server_address)
|
|
156
|
+
logging.info(f"[TCP CONF] Starting server at address {self.config_server_address}")
|
|
157
|
+
# wait for connections
|
|
158
|
+
s.listen()
|
|
159
|
+
# accept connection from device
|
|
160
|
+
client, addr = s.accept()
|
|
161
|
+
logging.info(f"[TCP CONF] Connected by client with address {addr}")
|
|
162
|
+
# now we are connected
|
|
163
|
+
self.connected.set()
|
|
164
|
+
|
|
165
|
+
while self.running.is_set():
|
|
166
|
+
# check the queue for commands
|
|
167
|
+
try:
|
|
168
|
+
cmd = cmd_queue.get(block=False)
|
|
169
|
+
logging.debug(f"[TCP CONF] sending: {cmd}")
|
|
170
|
+
(b, c) = cmd
|
|
171
|
+
message = self.send_header + b + c.encode()
|
|
172
|
+
# send command
|
|
173
|
+
client.send(message)
|
|
174
|
+
if b == b'\x14':
|
|
175
|
+
# if we request the status, save the response
|
|
176
|
+
status = client.recv(2048)[self.read_header_len:].decode("utf-8", "ignore")
|
|
177
|
+
self.cmd_ack.set()
|
|
178
|
+
elif "RANGE" in c:
|
|
179
|
+
# if we change the range, also change it for the data thread
|
|
180
|
+
parts = c.split(" ")
|
|
181
|
+
self.range = float(parts[-1])
|
|
182
|
+
data.time = np.linspace(self.t_begin, self.t_begin + self.range, 20 * int(self.range) + 1)
|
|
183
|
+
# wait for acknowledge
|
|
184
|
+
if not self.wait_for_answer(client):
|
|
185
|
+
return
|
|
186
|
+
self.cmd_ack.set()
|
|
187
|
+
elif "BEGIN" in c:
|
|
188
|
+
# if we change the range, also change it for the data thread
|
|
189
|
+
parts = c.split(" ")
|
|
190
|
+
self.t_begin = float(parts[-1])
|
|
191
|
+
data.time = np.linspace(self.t_begin, self.t_begin + self.range, 20 * int(self.range) + 1)
|
|
192
|
+
# wait for acknowledge
|
|
193
|
+
if not self.wait_for_answer(client):
|
|
194
|
+
return
|
|
195
|
+
self.cmd_ack.set()
|
|
196
|
+
else:
|
|
197
|
+
# wait for acknowledge
|
|
198
|
+
if not self.wait_for_answer(client):
|
|
199
|
+
return
|
|
200
|
+
self.cmd_ack.set()
|
|
201
|
+
except Empty:
|
|
202
|
+
# just to a simple heartbeat
|
|
203
|
+
(b, c) = (b'\x12', "SYSTEM : MONITOR 1")
|
|
204
|
+
message = self.send_header + b + c.encode()
|
|
205
|
+
client.send(message)
|
|
206
|
+
# wait for acknowledge
|
|
207
|
+
if not self.wait_for_answer(client):
|
|
208
|
+
return
|
|
209
|
+
time.sleep(0.25)
|
|
210
|
+
|
|
211
|
+
def run_tcp_dat(self, cmd_queue: Queue, config_queue: Queue):
|
|
212
|
+
"""
|
|
213
|
+
Data TCP thread on port 6342. This handles receives all the streamed data from the device and decodes it.
|
|
214
|
+
"""
|
|
215
|
+
global data
|
|
216
|
+
global n_avg
|
|
217
|
+
types = np.dtype([
|
|
218
|
+
("signal_1", np.int32),
|
|
219
|
+
("signal_2", np.int32),
|
|
220
|
+
])
|
|
221
|
+
antenna_range = 1000.0
|
|
222
|
+
|
|
223
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
224
|
+
# Set the SO_REUSEADDR option to allow reuse of the port
|
|
225
|
+
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
226
|
+
|
|
227
|
+
# bind server to the address (only works when the address exists)
|
|
228
|
+
s.bind(self.data_server_address)
|
|
229
|
+
logging.info(f"[TCP DAT] Starting server at address {self.data_server_address}")
|
|
230
|
+
# wait for connections
|
|
231
|
+
s.listen()
|
|
232
|
+
# accept connection from device
|
|
233
|
+
client, addr = s.accept()
|
|
234
|
+
client.settimeout(2)
|
|
235
|
+
|
|
236
|
+
logging.info(f"[TCP DAT] Connected by client with address {addr}")
|
|
237
|
+
# now we are connected
|
|
238
|
+
while self.running.is_set():
|
|
239
|
+
if self.range_changed.is_set():
|
|
240
|
+
# range has changed and needs to be adjusted
|
|
241
|
+
# need to empty the read buffer
|
|
242
|
+
while self.running.is_set():
|
|
243
|
+
logging.debug(f"emptying buffer...")
|
|
244
|
+
try:
|
|
245
|
+
client.recv(32100)
|
|
246
|
+
except socket.timeout:
|
|
247
|
+
break
|
|
248
|
+
self.buffer_emptied.set()
|
|
249
|
+
self.range_changed.clear()
|
|
250
|
+
|
|
251
|
+
# data always comes in the shape of 4 datasets each as 16bit ints with length of
|
|
252
|
+
# (20 * self.range + 1)
|
|
253
|
+
# the header is 52 8 bit ints and since we read 8 bit ints we need to multiply the data by 2
|
|
254
|
+
if self.range_changed.is_set():
|
|
255
|
+
continue
|
|
256
|
+
if not self.acq_running.is_set():
|
|
257
|
+
# no data received
|
|
258
|
+
time.sleep(1.0)
|
|
259
|
+
continue
|
|
260
|
+
try:
|
|
261
|
+
raw_data = client.recv(2 * 4 * (20 * int(self.range) + 1) + self.full_data_header_len)
|
|
262
|
+
except socket.timeout:
|
|
263
|
+
continue
|
|
264
|
+
if not raw_data:
|
|
265
|
+
# no data received
|
|
266
|
+
time.sleep(1.0)
|
|
267
|
+
continue
|
|
268
|
+
|
|
269
|
+
if raw_data[:self.data_header_len] != self.r_dat_header:
|
|
270
|
+
continue
|
|
271
|
+
|
|
272
|
+
if len(raw_data) != 2 * 4 * (20 * int(self.range) + 1) + self.full_data_header_len:
|
|
273
|
+
skip_this = False
|
|
274
|
+
while self.running.is_set():
|
|
275
|
+
try:
|
|
276
|
+
to_append = client.recv(
|
|
277
|
+
2 * 4 * (20 * int(self.range) + 1) + self.full_data_header_len - len(raw_data))
|
|
278
|
+
raw_data += to_append
|
|
279
|
+
if len(raw_data) == 2 * 4 * (20 * int(self.range) + 1) + self.full_data_header_len:
|
|
280
|
+
break
|
|
281
|
+
if len(raw_data) == 2 * 4 * (20 * int(self.range) + 1) + self.full_data_header_len:
|
|
282
|
+
skip_this = True
|
|
283
|
+
break
|
|
284
|
+
except socket.timeout:
|
|
285
|
+
skip_this = True
|
|
286
|
+
break
|
|
287
|
+
if skip_this:
|
|
288
|
+
continue
|
|
289
|
+
|
|
290
|
+
# TODO: the following code is not properly implemented yet, we need
|
|
291
|
+
# to check how we handle it, if the data comes not in the proper packet length
|
|
292
|
+
|
|
293
|
+
_data = raw_data[self.full_data_header_len:]
|
|
294
|
+
|
|
295
|
+
# check if header is at the beginning of the received payload
|
|
296
|
+
# if raw_data[:self.data_header_len] == self.r_dat_header:
|
|
297
|
+
# # remove the header
|
|
298
|
+
# _data = raw_data[self.data_header_len:]
|
|
299
|
+
# else:
|
|
300
|
+
# _data = raw_data[self.data_header_len:]
|
|
301
|
+
# # print("header not in the beginning")
|
|
302
|
+
# while self.running.is_set():
|
|
303
|
+
# logging.info("data does not have correct length...")
|
|
304
|
+
#
|
|
305
|
+
# # check if the data is of the correct length
|
|
306
|
+
# if len(_data) != 2 * 4 * (20 * self.range + 1):
|
|
307
|
+
# _data = _data + client.recv(2 * 4 * (20 * int(self.range) + 1) - len(_data))
|
|
308
|
+
# else:
|
|
309
|
+
# break
|
|
310
|
+
try:
|
|
311
|
+
# decode received payload to 32 bit ints
|
|
312
|
+
types = types.newbyteorder('>')
|
|
313
|
+
arr = np.frombuffer(_data, dtype=types)
|
|
314
|
+
|
|
315
|
+
if not config_queue.empty():
|
|
316
|
+
antenna_range = config_queue.get()
|
|
317
|
+
|
|
318
|
+
scale_factor = 20.0 * 1000.0 / antenna_range;
|
|
319
|
+
|
|
320
|
+
# TODO: make subtracting offset optional.
|
|
321
|
+
signal_1 = arr['signal_1'] / scale_factor / 2 ** 16 - arr['signal_1'][0] / scale_factor / 2 ** 16
|
|
322
|
+
signal_2 = arr['signal_2'] / scale_factor / 2 ** 16 - arr['signal_2'][0] / scale_factor / 2 ** 16
|
|
323
|
+
|
|
324
|
+
if self.avg_data.is_set():
|
|
325
|
+
if data.signal_1.shape == signal_1.shape:
|
|
326
|
+
data.signal_1 += signal_1
|
|
327
|
+
else:
|
|
328
|
+
data.signal_1 = signal_1
|
|
329
|
+
if data.signal_2.shape == signal_2.shape:
|
|
330
|
+
data.signal_2 += signal_2
|
|
331
|
+
n_avg += 1
|
|
332
|
+
else:
|
|
333
|
+
data.signal_1 = signal_1
|
|
334
|
+
data.signal_2 = signal_2
|
|
335
|
+
n_avg = 0
|
|
336
|
+
|
|
337
|
+
# do fft of signal 1
|
|
338
|
+
pulse = data.signal_1
|
|
339
|
+
f, a, arg = get_fft(data.time, pulse)
|
|
340
|
+
data.freq = f
|
|
341
|
+
data.fft_1_amp = a / np.max(a)
|
|
342
|
+
data.fft_1_phase = arg
|
|
343
|
+
|
|
344
|
+
# do fft of signal 2
|
|
345
|
+
pulse = data.signal_2
|
|
346
|
+
f, a, arg = get_fft(data.time, pulse)
|
|
347
|
+
data.fft_2_amp = a / np.max(a)
|
|
348
|
+
data.fft_2_phase = arg
|
|
349
|
+
|
|
350
|
+
# update avg countdown
|
|
351
|
+
if self.avg_countdown > 0:
|
|
352
|
+
self.avg_countdown -= 1
|
|
353
|
+
|
|
354
|
+
except Exception as e:
|
|
355
|
+
logging.error(e)
|
|
356
|
+
logging.error(f"{len(data.signal_1)=}")
|
io.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Copyright (c) 2026 University of Bern, Space Research & Planetary Sciences, Linus Leo Stöckli.
|
|
3
|
+
|
|
4
|
+
This work is licensed under the Creative Commons
|
|
5
|
+
Attribution-NonCommercial 4.0 International License.
|
|
6
|
+
To view a copy of this license, visit
|
|
7
|
+
https://creativecommons.org/licenses/by-nc/4.0/
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
import h5py
|
|
14
|
+
import numpy as np
|
|
15
|
+
from datetime import datetime
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def save_thz(filename: Path, time: np.ndarray, pulse: np.ndarray, reference: Optional[np.ndarray] = None,
|
|
19
|
+
coordinates: np.ndarray = np.array([0.0, 0.0]), thickness: float = 1.0, temperature: float = 298.15,
|
|
20
|
+
mode: str = 'THz-TDS/Transmission', meta: dict = dict(),
|
|
21
|
+
user: str = "0000-0000-1111-2222 / J Doe / J.Doe @ thz.ac.uk / University of Terawick, UK"):
|
|
22
|
+
# Create and save to HDF5 file
|
|
23
|
+
# Get current date and time
|
|
24
|
+
now = datetime.now()
|
|
25
|
+
# Format date as YYYY-MM-DD
|
|
26
|
+
date = now.strftime("%Y-%m-%d")
|
|
27
|
+
# Format time as HH:MM:SS
|
|
28
|
+
timestamp = now.strftime("%H:%M:%S")
|
|
29
|
+
with h5py.File(filename, 'w') as f:
|
|
30
|
+
# Save main datasets
|
|
31
|
+
# Better to only save the time array once, than for every pixel, right?
|
|
32
|
+
f.create_dataset('ds1', data=np.array([time, pulse]))
|
|
33
|
+
if reference:
|
|
34
|
+
f.create_dataset('ds2', data=np.array([time, reference]))
|
|
35
|
+
f.attrs['dsDescription'] = ["ds1:Sample", "ds2:Ref"]
|
|
36
|
+
else:
|
|
37
|
+
f.attrs['dsDescription'] = ["ds1:Sample"]
|
|
38
|
+
|
|
39
|
+
# Save metadata
|
|
40
|
+
|
|
41
|
+
f.attrs['date'] = date
|
|
42
|
+
f.attrs['description'] = 'sample material name or description'
|
|
43
|
+
f.attrs['instrument'] = 'TeraFlash Pro / Toptica'
|
|
44
|
+
f.attrs['md1'] = thickness # thickness in mm
|
|
45
|
+
f.attrs['md2'] = temperature # temperature
|
|
46
|
+
|
|
47
|
+
# how do we save coordinates?
|
|
48
|
+
f.attrs['coordinates'] = coordinates
|
|
49
|
+
|
|
50
|
+
meta_data_counter = 3
|
|
51
|
+
for k in meta.keys():
|
|
52
|
+
f.attrs[f"md{meta_data_counter}"] = meta[k]
|
|
53
|
+
meta_data_counter += 1
|
|
54
|
+
|
|
55
|
+
f.attrs['mdDescription'] = ["Thickness (mm)", "Temperature (K)"] + list(meta.keys())
|
|
56
|
+
f.attrs['mode'] = mode
|
|
57
|
+
f.attrs["thzVer"] = "1.00"
|
|
58
|
+
f.attrs["time"] = timestamp
|
|
59
|
+
f.attrs["user"] = user
|
|
60
|
+
|
|
61
|
+
# Open the HDF5 file in read mode
|
|
62
|
+
with h5py.File('test.thz', 'r') as f:
|
|
63
|
+
# Load the datasets
|
|
64
|
+
print(f.keys())
|
|
65
|
+
print(f.attrs.keys())
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def save_thz_image(filename: Path, time: np.ndarray, image: np.ndarray,
|
|
69
|
+
x_min: float, x_max: float, dx: float, y_min: float, y_max: float, dy: float,
|
|
70
|
+
mode: str = 'THz-TDS/Transmission', meta: dict = dict(),
|
|
71
|
+
user: str = "0000-0000-1111-2222 / J Doe / J.Doe @ thz.ac.uk / University of Terawick, UK"):
|
|
72
|
+
# Create and save to HDF5 file
|
|
73
|
+
# Get current date and time
|
|
74
|
+
now = datetime.now()
|
|
75
|
+
# Format date as YYYY-MM-DD
|
|
76
|
+
date = now.strftime("%Y-%m-%d")
|
|
77
|
+
# Format time as HH:MM:SS
|
|
78
|
+
timestamp = now.strftime("%H:%M:%S")
|
|
79
|
+
with h5py.File(filename, 'w') as f:
|
|
80
|
+
# Save main datasets
|
|
81
|
+
# Better to only save the time array once, than for every pixel, right?
|
|
82
|
+
f.create_dataset('ds1', data=time)
|
|
83
|
+
f.create_dataset('ds2', data=image)
|
|
84
|
+
f.attrs['dsDescription'] = ["ds1:Time", "ds2:Image"]
|
|
85
|
+
|
|
86
|
+
# Save metadata
|
|
87
|
+
f.attrs['date'] = date
|
|
88
|
+
f.attrs['description'] = 'sample material name or description'
|
|
89
|
+
f.attrs['instrument'] = 'TeraFlash Pro / Toptica'
|
|
90
|
+
f.attrs['md1'] = 1 # thickness in mm
|
|
91
|
+
f.attrs['md2'] = 298.15 # temperature
|
|
92
|
+
# might seem better to just save x_min, x_max and dx (vice versa for y) instead of a full array?
|
|
93
|
+
f.attrs['md3'] = x_min
|
|
94
|
+
f.attrs['md4'] = x_max
|
|
95
|
+
f.attrs['md5'] = dx
|
|
96
|
+
f.attrs['md6'] = y_min
|
|
97
|
+
f.attrs['md7'] = y_max
|
|
98
|
+
f.attrs['md8'] = dy
|
|
99
|
+
# how do we save coordinates?
|
|
100
|
+
meta_data_counter = 9
|
|
101
|
+
for k in meta.keys():
|
|
102
|
+
f.attrs[f"md{meta_data_counter}"] = meta[k]
|
|
103
|
+
meta_data_counter += 1
|
|
104
|
+
|
|
105
|
+
f.attrs['mdDescription'] = ["Thickness (mm)", "Temperature (K)", "x_min (mm)", "x_max (mm)", "dx (mm)",
|
|
106
|
+
"y_min (mm)", "y_max (mm)", "dy (mm)"] + list(meta.keys())
|
|
107
|
+
f.attrs['mode'] = mode
|
|
108
|
+
f.attrs["thzVer"] = "1.00"
|
|
109
|
+
f.attrs["time"] = timestamp
|
|
110
|
+
f.attrs["user"] = user
|
|
111
|
+
|
|
112
|
+
# Open the HDF5 file in read mode
|
|
113
|
+
with h5py.File('test.thz', 'r') as f:
|
|
114
|
+
# Load the datasets
|
|
115
|
+
print(f.keys())
|
|
116
|
+
print(f.attrs.keys())
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
if __name__ == '__main__':
|
|
120
|
+
|
|
121
|
+
time = np.linspace(0, 1, 100) # your time array
|
|
122
|
+
data = np.random.rand(100) # example 3D data array
|
|
123
|
+
|
|
124
|
+
print(time.shape, data.shape)
|
|
125
|
+
|
|
126
|
+
meta = {
|
|
127
|
+
"test value": 0.0,
|
|
128
|
+
"content": "nothing",
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
save_thz(Path("test.thz"), time, data)
|
|
132
|
+
|
|
133
|
+
with h5py.File("test.thz", 'r') as f:
|
|
134
|
+
# Load the datasets
|
|
135
|
+
print(f.keys())
|
|
136
|
+
print(f.attrs.keys())
|
|
137
|
+
data = f['ds1'][()]
|
|
138
|
+
for key in f.attrs.keys():
|
|
139
|
+
print(key, f.attrs[key])
|
math_utils.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Copyright (c) 2026 University of Bern, Space Research & Planetary Sciences, Linus Leo Stöckli.
|
|
3
|
+
|
|
4
|
+
This work is licensed under the Creative Commons
|
|
5
|
+
Attribution-NonCommercial 4.0 International License.
|
|
6
|
+
To view a copy of this license, visit
|
|
7
|
+
https://creativecommons.org/licenses/by-nc/4.0/
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
from scipy.fft import rfft, rfftfreq, irfft
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def blackman_func(n, M):
|
|
15
|
+
return 0.42 - 0.5 * np.cos(2 * np.pi * n / M) + 0.08 * np.cos(4 * np.pi * n / M)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def toptica_window(t, start=1, end=7):
|
|
19
|
+
window = np.ones(t.shape)
|
|
20
|
+
a = t[t <= (t[0] + start)]
|
|
21
|
+
b = t[t >= (t[-1] - end)]
|
|
22
|
+
a = blackman_func(a - a[0], 2 * (a[-1] - a[0]))
|
|
23
|
+
b = blackman_func(b + b[-1] - b[0] - b[0], 2 * (b[-1] - b[0]))
|
|
24
|
+
window[t <= (t[0] + start)] = a
|
|
25
|
+
window[t >= (t[-1] - end)] = b
|
|
26
|
+
return window
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def zero_padding(time, pulse, df_padded=0.01):
|
|
30
|
+
# Calculate the total time span of the original data
|
|
31
|
+
T = time[-1] - time[0]
|
|
32
|
+
|
|
33
|
+
# Find the length of the original signal
|
|
34
|
+
N_original = len(pulse)
|
|
35
|
+
|
|
36
|
+
# Calculate the original time step (assuming uniform sampling in the time array)
|
|
37
|
+
dt = time[1] - time[0]
|
|
38
|
+
|
|
39
|
+
N_padded = (1 / df_padded - T) / dt
|
|
40
|
+
|
|
41
|
+
N_padded = int(N_padded) + N_original
|
|
42
|
+
|
|
43
|
+
# If padding is needed, apply zero-padding and extend the time array
|
|
44
|
+
if N_padded > N_original:
|
|
45
|
+
# Pad the pulse array with zeros to match the required length
|
|
46
|
+
padded_pulse = np.pad(pulse, (0, N_padded - N_original), mode='constant')
|
|
47
|
+
# Create an extended time array with the same timestep (dt)
|
|
48
|
+
extended_time = np.arange(time[0], time[0] + N_padded * dt, dt)
|
|
49
|
+
else:
|
|
50
|
+
# If no padding is needed, return the original arrays
|
|
51
|
+
padded_pulse = pulse
|
|
52
|
+
extended_time = time
|
|
53
|
+
return extended_time, padded_pulse
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def unwrap_phase(phase):
|
|
57
|
+
threshold = np.pi
|
|
58
|
+
N = len(phase)
|
|
59
|
+
|
|
60
|
+
for i in range(N - 1):
|
|
61
|
+
if phase[i + 1] - phase[i] > threshold:
|
|
62
|
+
for j in range(i + 1, N):
|
|
63
|
+
phase[j] -= 2 * np.pi
|
|
64
|
+
elif phase[i + 1] - phase[i] < -threshold:
|
|
65
|
+
for j in range(i + 1, N):
|
|
66
|
+
phase[j] += 2 * np.pi
|
|
67
|
+
return phase
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def get_fft(t, p, df=0.01, window_start=1, window_end=2, return_td=False):
|
|
71
|
+
t = np.array(t)
|
|
72
|
+
p = np.array(p) * toptica_window(t, window_start, window_end)
|
|
73
|
+
t, p = zero_padding(t, p, df_padded=df)
|
|
74
|
+
|
|
75
|
+
sample_rate = 1 / (t[1] - t[0]) * 1e12
|
|
76
|
+
n = len(p)
|
|
77
|
+
fft = rfft(p)
|
|
78
|
+
a = np.abs(fft)
|
|
79
|
+
angle = np.angle(fft)
|
|
80
|
+
arg = np.unwrap(angle)
|
|
81
|
+
f = rfftfreq(n, 1 / sample_rate) / 1e12
|
|
82
|
+
# a = a[f >= 0.1]
|
|
83
|
+
# arg = arg[f >= 0.1]
|
|
84
|
+
# f = f[f >= 0.1]
|
|
85
|
+
if return_td:
|
|
86
|
+
return t, p, f, a, np.abs(arg)
|
|
87
|
+
else:
|
|
88
|
+
return f, a, np.abs(arg)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def get_ifft(frequencies, amplitudes, phases, t0=0):
|
|
92
|
+
fft = amplitudes * np.exp(1j * phases)
|
|
93
|
+
signal = irfft(fft)
|
|
94
|
+
delta_f = frequencies[1] - frequencies[0]
|
|
95
|
+
sample_rate = 2 * (len(frequencies) - 1) * delta_f
|
|
96
|
+
N = len(signal) # Number of samples
|
|
97
|
+
T = 1.0 / sample_rate # Sample spacing (inverse of the sampling rate)
|
|
98
|
+
time = np.linspace(0.0, N * T, N, endpoint=False)
|
|
99
|
+
return t0 - time + time[-1], signal
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
if __name__ == '__main__':
|
|
103
|
+
import matplotlib.pyplot as plt
|
|
104
|
+
|
|
105
|
+
t = np.arange(1935, 1965.05, 0.05, )
|
|
106
|
+
p = np.ones(t.shape)
|
|
107
|
+
print(f"{t=}")
|
|
108
|
+
print(f"{p=}")
|
|
109
|
+
|
|
110
|
+
plt.plot(t, p)
|
|
111
|
+
t_padded, p_padded = zero_padding(t, p)
|
|
112
|
+
p_windowed = p * toptica_window(t)
|
|
113
|
+
t_windowed, p_windowed = zero_padding(t, p_windowed)
|
|
114
|
+
plt.plot(t_windowed, p_windowed)
|
|
115
|
+
plt.show()
|
pulse_detection.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Copyright (c) 2026 University of Bern, Space Research & Planetary Sciences, Linus Leo Stöckli.
|
|
3
|
+
|
|
4
|
+
This work is licensed under the Creative Commons
|
|
5
|
+
Attribution-NonCommercial 4.0 International License.
|
|
6
|
+
To view a copy of this license, visit
|
|
7
|
+
https://creativecommons.org/licenses/by-nc/4.0/
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
from scipy.signal import hilbert
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def detect_pulse(time: np.array, signal: np.array) -> Optional[int]:
|
|
16
|
+
# Compute the analytic signal using Hilbert transform
|
|
17
|
+
analytic_signal = hilbert(signal)
|
|
18
|
+
envelope = np.abs(analytic_signal)
|
|
19
|
+
|
|
20
|
+
# Detect the main pulse in the envelope
|
|
21
|
+
max_index = np.argmax(envelope)
|
|
22
|
+
main_pulse_time = time[max_index]
|
|
23
|
+
|
|
24
|
+
# Define a threshold to find the start and end of the pulse
|
|
25
|
+
threshold = envelope[max_index] * 0.5
|
|
26
|
+
pulse_indices = np.where(envelope > threshold)[0]
|
|
27
|
+
_pulse_start_time = time[pulse_indices[0]]
|
|
28
|
+
_pulse_end_time = time[pulse_indices[-1]]
|
|
29
|
+
|
|
30
|
+
# always start 15 ps before pulse begins
|
|
31
|
+
main_pulse_time -= 15.0
|
|
32
|
+
|
|
33
|
+
# only return the pulse if it is above the noise level
|
|
34
|
+
if envelope[max_index] > 7.0:
|
|
35
|
+
return int(main_pulse_time)
|
|
36
|
+
else:
|
|
37
|
+
return None
|
teraflash.py
ADDED
|
@@ -0,0 +1,561 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Copyright (c) 2026 University of Bern, Space Research & Planetary Sciences, Linus Leo Stöckli.
|
|
3
|
+
|
|
4
|
+
This work is licensed under the Creative Commons
|
|
5
|
+
Attribution-NonCommercial 4.0 International License.
|
|
6
|
+
To view a copy of this license, visit
|
|
7
|
+
https://creativecommons.org/licenses/by-nc/4.0/
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import queue
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
import re
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
import os
|
|
18
|
+
|
|
19
|
+
from interface import TopticaSocket
|
|
20
|
+
import interface
|
|
21
|
+
from pulse_detection import detect_pulse
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TeraFlash:
|
|
25
|
+
|
|
26
|
+
def __init__(self,
|
|
27
|
+
ip: str = "169.254.84.101",
|
|
28
|
+
rng: int = 50,
|
|
29
|
+
t_begin: float = 1000.0,
|
|
30
|
+
antenna_range: float = 1000.0,
|
|
31
|
+
avg: int = 2,
|
|
32
|
+
log_file=None):
|
|
33
|
+
"""
|
|
34
|
+
TeraFlash object used to handle all top level interactions with the user
|
|
35
|
+
Args:
|
|
36
|
+
ip: ip-address of the device (instrument)
|
|
37
|
+
rng: initial range in ps
|
|
38
|
+
t_begin: initial start time of the window in ps
|
|
39
|
+
avg: initial number of measurements to average
|
|
40
|
+
log_file: name of the logfile, if required
|
|
41
|
+
"""
|
|
42
|
+
self.r_dat_header = b'\xcd\xef\x124x\x9a\xfe\xdc\x00\x00\x00\x01\x00\xff\x91\xe7\x03\xe8\x00\x00'
|
|
43
|
+
self.send_header = b'\xcd\xef\x124x\x9a\xfe\xdc\x00\x00\x00\x02\x00\x00\x00\x00\x00\x00\x00'
|
|
44
|
+
self.r_stat_header = b'\xcd\xef\x124x\x9a\xfe\xdc\x00\x00\x00\x03\x00\x00\x00\x00\x00\x00\x02'
|
|
45
|
+
self.r_dat_header = b'\xcd\xef\x124x\x9a\xfe\xdc\x00\x00\x00\x01\x00\xff\x91\xe7\x03\xe8\x00'
|
|
46
|
+
|
|
47
|
+
if log_file:
|
|
48
|
+
if not os.path.isdir("logs"):
|
|
49
|
+
os.mkdir("logs")
|
|
50
|
+
if not log_file.endswith(".log"):
|
|
51
|
+
log_file += ".log"
|
|
52
|
+
if os.path.exists(f"logs/{log_file}"):
|
|
53
|
+
log_file = f"{int(time.time())}" + log_file
|
|
54
|
+
logging.basicConfig(filename=f"logs/{log_file}", level=logging.DEBUG)
|
|
55
|
+
logging.getLogger().addHandler(logging.StreamHandler())
|
|
56
|
+
|
|
57
|
+
self.laser = False
|
|
58
|
+
self.emitter = [False, False]
|
|
59
|
+
self.acquisition = False
|
|
60
|
+
self.allowed_antenna_ranges = [str(antenna_range)]
|
|
61
|
+
self.antenna_range = antenna_range
|
|
62
|
+
self.range = rng
|
|
63
|
+
self.t_begin = t_begin
|
|
64
|
+
self.avg = avg
|
|
65
|
+
|
|
66
|
+
self.ip = ip
|
|
67
|
+
|
|
68
|
+
self.cmd_queue = queue.Queue()
|
|
69
|
+
self.config_queue = queue.Queue()
|
|
70
|
+
self.running = threading.Event()
|
|
71
|
+
self.connected = threading.Event()
|
|
72
|
+
self.cmd_ack = threading.Event()
|
|
73
|
+
self.buffer_emptied = threading.Event()
|
|
74
|
+
self.range_changed = threading.Event()
|
|
75
|
+
self.acq_running = threading.Event()
|
|
76
|
+
self.avg_data = threading.Event()
|
|
77
|
+
self.cmd_ack.clear()
|
|
78
|
+
self.connected.clear()
|
|
79
|
+
self.running.set()
|
|
80
|
+
self.buffer_emptied.set()
|
|
81
|
+
self.range_changed.clear()
|
|
82
|
+
self.acq_running.clear()
|
|
83
|
+
self.avg_data.clear()
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
self.socket = TopticaSocket(self.ip, self.running, self.connected, self.cmd_ack, self.buffer_emptied,
|
|
87
|
+
self.range_changed, self.acq_running, self.avg_data)
|
|
88
|
+
except ConnectionError:
|
|
89
|
+
logging.error("[INIT] Device is not connected. Check cabling")
|
|
90
|
+
exit()
|
|
91
|
+
|
|
92
|
+
# configure tcp config socket
|
|
93
|
+
self.config_thread = threading.Thread(target=self.socket.run_conf_tcp, args=(self.cmd_queue,))
|
|
94
|
+
|
|
95
|
+
# configure tcp data socket
|
|
96
|
+
self.data_thread = threading.Thread(target=self.socket.run_tcp_dat, args=(self.cmd_queue, self.config_queue))
|
|
97
|
+
|
|
98
|
+
# launch threads
|
|
99
|
+
self.data_thread.start()
|
|
100
|
+
self.config_thread.start()
|
|
101
|
+
|
|
102
|
+
# wait until a device is connected
|
|
103
|
+
self.connected.wait()
|
|
104
|
+
|
|
105
|
+
# start setup sequence
|
|
106
|
+
self.setup()
|
|
107
|
+
|
|
108
|
+
# wait some time to gather data
|
|
109
|
+
time.sleep(3)
|
|
110
|
+
|
|
111
|
+
def __enter__(self):
|
|
112
|
+
"""
|
|
113
|
+
Entry point of the context manager
|
|
114
|
+
Returns: self
|
|
115
|
+
|
|
116
|
+
"""
|
|
117
|
+
return self
|
|
118
|
+
|
|
119
|
+
def __exit__(self, tp, value, traceback):
|
|
120
|
+
"""
|
|
121
|
+
Exit point of the context manager, closes the TCP connection properly
|
|
122
|
+
Args:
|
|
123
|
+
tp: -
|
|
124
|
+
value: -
|
|
125
|
+
traceback: -
|
|
126
|
+
"""
|
|
127
|
+
# disconnect routine
|
|
128
|
+
self.disconnect()
|
|
129
|
+
time.sleep(1)
|
|
130
|
+
self.running.clear()
|
|
131
|
+
# if a device was connected, wait for the TCP threads to finish
|
|
132
|
+
if self.connected.is_set():
|
|
133
|
+
self.data_thread.join()
|
|
134
|
+
self.config_thread.join()
|
|
135
|
+
logging.debug("[EXIT] disconnected from device")
|
|
136
|
+
|
|
137
|
+
@staticmethod
|
|
138
|
+
def reset_tcp_avg():
|
|
139
|
+
# reset global variable avg
|
|
140
|
+
shape = interface.data.signal_1.shape
|
|
141
|
+
interface.data.signal_1 = np.zeros(shape)
|
|
142
|
+
interface.data.signal_2 = np.zeros(shape)
|
|
143
|
+
interface.n_avg = 0
|
|
144
|
+
|
|
145
|
+
@staticmethod
|
|
146
|
+
def get_n_avg():
|
|
147
|
+
# return global variable
|
|
148
|
+
return interface.n_avg
|
|
149
|
+
|
|
150
|
+
@staticmethod
|
|
151
|
+
def get_data():
|
|
152
|
+
# return global variable
|
|
153
|
+
return interface.data
|
|
154
|
+
|
|
155
|
+
@staticmethod
|
|
156
|
+
def get_status():
|
|
157
|
+
# return global variable
|
|
158
|
+
return interface.status
|
|
159
|
+
|
|
160
|
+
def setup(self):
|
|
161
|
+
"""
|
|
162
|
+
performs the setup sequence of the device as reconstructed
|
|
163
|
+
by using wireshark on the official application
|
|
164
|
+
"""
|
|
165
|
+
|
|
166
|
+
logging.info("[INIT] setting up the device...")
|
|
167
|
+
self.get_sys_status()
|
|
168
|
+
time.sleep(1)
|
|
169
|
+
|
|
170
|
+
# wait for status to be available
|
|
171
|
+
while "TIA-Sens(nA)" not in self.get_status():
|
|
172
|
+
self.get_sys_status()
|
|
173
|
+
time.sleep(1)
|
|
174
|
+
|
|
175
|
+
self.allowed_antenna_ranges = self.extract_tia_sens(self.get_status())
|
|
176
|
+
self.set_channel()
|
|
177
|
+
self.set_mode()
|
|
178
|
+
self.set_transmission()
|
|
179
|
+
self.set_antenna_range(1000.0) # always set to +/- 1000 nA first
|
|
180
|
+
self.set_acq_begin(self.t_begin)
|
|
181
|
+
self.set_acq_avg()
|
|
182
|
+
self.set_acq_stop()
|
|
183
|
+
self.set_acq_range(self.range)
|
|
184
|
+
self.get_sys_monitor()
|
|
185
|
+
self.set_acq_avg(self.avg)
|
|
186
|
+
self.set_acq_range(self.range)
|
|
187
|
+
self.get_sys_monitor()
|
|
188
|
+
self.get_sys_status()
|
|
189
|
+
self.range_changed.clear()
|
|
190
|
+
logging.info("[INIT] device is ready.")
|
|
191
|
+
|
|
192
|
+
def disconnect(self):
|
|
193
|
+
"""
|
|
194
|
+
disconnects from the device, order of calls is important!
|
|
195
|
+
"""
|
|
196
|
+
logging.debug("[CMD] disconnecting from device")
|
|
197
|
+
self.set_acq_stop()
|
|
198
|
+
self.set_emitter(1, False)
|
|
199
|
+
self.set_emitter(2, False)
|
|
200
|
+
self.set_laser(False)
|
|
201
|
+
|
|
202
|
+
def get_sys_status(self):
|
|
203
|
+
"""
|
|
204
|
+
request the system status string
|
|
205
|
+
"""
|
|
206
|
+
logging.debug("[CMD] requesting status")
|
|
207
|
+
cmd = (b'\x14', "SYSTEM : TELL STATUS")
|
|
208
|
+
self.cmd_queue.put(cmd)
|
|
209
|
+
self.cmd_ack.wait()
|
|
210
|
+
self.cmd_ack.clear()
|
|
211
|
+
|
|
212
|
+
def extract_tia_sens(self, text: str) -> list[float]:
|
|
213
|
+
"""
|
|
214
|
+
Extracts TIA-Sens(nA) values from the given string.
|
|
215
|
+
Returns a list of floats, or None if not found.
|
|
216
|
+
"""
|
|
217
|
+
|
|
218
|
+
match = re.search(r"TIA-Sens\(nA\):\s*([0-9.,\s]+)", text)
|
|
219
|
+
if not match:
|
|
220
|
+
logging.error(f"[INIT] no supported ranges found: {text}")
|
|
221
|
+
return []
|
|
222
|
+
|
|
223
|
+
values_str = match.group(1)
|
|
224
|
+
|
|
225
|
+
# Split by comma, trim, and convert to floats
|
|
226
|
+
values = [str(float(v.strip())) for v in values_str.split(",") if v.strip()]
|
|
227
|
+
logging.debug(f"[INIT] supported antenna ranges: {values}")
|
|
228
|
+
|
|
229
|
+
return values
|
|
230
|
+
|
|
231
|
+
def get_sys_monitor(self):
|
|
232
|
+
"""
|
|
233
|
+
this is repeatedly called in the official application
|
|
234
|
+
"""
|
|
235
|
+
cmd = (b'\x12', "SYSTEM : MONITOR 1")
|
|
236
|
+
self.cmd_queue.put(cmd)
|
|
237
|
+
self.cmd_ack.wait()
|
|
238
|
+
self.cmd_ack.clear()
|
|
239
|
+
|
|
240
|
+
def set_channel(self, channel: str = "D"):
|
|
241
|
+
"""
|
|
242
|
+
sets dual or single channel mode
|
|
243
|
+
Args:
|
|
244
|
+
channel: The desired mode
|
|
245
|
+
"""
|
|
246
|
+
logging.debug(f"[CMD] setting channel: {channel}")
|
|
247
|
+
cmd = (b'\x0b', f"CHANNEL : {channel}")
|
|
248
|
+
self.cmd_queue.put(cmd)
|
|
249
|
+
self.cmd_ack.wait()
|
|
250
|
+
self.cmd_ack.clear()
|
|
251
|
+
|
|
252
|
+
def set_mode(self, motion: str = "NORMAL"):
|
|
253
|
+
"""
|
|
254
|
+
sets the motion mode
|
|
255
|
+
Args:
|
|
256
|
+
motion: The desired motion mode
|
|
257
|
+
"""
|
|
258
|
+
logging.debug(f"[CMD] setting motion: {motion}")
|
|
259
|
+
cmd = (b'\x0f', f"MOTION : {motion}")
|
|
260
|
+
self.cmd_queue.put(cmd)
|
|
261
|
+
self.cmd_ack.wait()
|
|
262
|
+
self.cmd_ack.clear()
|
|
263
|
+
|
|
264
|
+
def set_transmission(self, transmission: str = "SLIDING"):
|
|
265
|
+
"""
|
|
266
|
+
sets the desired transmission mode
|
|
267
|
+
Args:
|
|
268
|
+
transmission: the desired transmission mode. "SLIDING" or "BLOCK"
|
|
269
|
+
"""
|
|
270
|
+
logging.debug(f"[CMD] setting transmission: {transmission}")
|
|
271
|
+
cmd = (b'\x16', f"TRANSMISSION : {transmission}")
|
|
272
|
+
self.cmd_queue.put(cmd)
|
|
273
|
+
self.cmd_ack.wait()
|
|
274
|
+
self.cmd_ack.clear()
|
|
275
|
+
|
|
276
|
+
def set_antenna_range(self, antenna_range: float):
|
|
277
|
+
"""
|
|
278
|
+
sets the antenna range by value (needs to be an allowed value of the instrument)
|
|
279
|
+
"""
|
|
280
|
+
antenna_range = float(antenna_range)
|
|
281
|
+
|
|
282
|
+
print(self.allowed_antenna_ranges)
|
|
283
|
+
print(antenna_range)
|
|
284
|
+
i = self.allowed_antenna_ranges.index(str(antenna_range))
|
|
285
|
+
self.antenna_range = antenna_range
|
|
286
|
+
self.config_queue.put(self.antenna_range)
|
|
287
|
+
|
|
288
|
+
if i == 0:
|
|
289
|
+
string = "FULL"
|
|
290
|
+
else:
|
|
291
|
+
string = f"ATN{i}"
|
|
292
|
+
|
|
293
|
+
logging.debug(f"[CMD] setting antenna: TIA {string}")
|
|
294
|
+
cmd = (b'\x11', f"SYSTEM : TIA {string}")
|
|
295
|
+
|
|
296
|
+
self.cmd_queue.put(cmd)
|
|
297
|
+
self.cmd_ack.wait()
|
|
298
|
+
self.cmd_ack.clear()
|
|
299
|
+
|
|
300
|
+
def set_antenna_range_index(self, i):
|
|
301
|
+
"""
|
|
302
|
+
sets the antenna range by index
|
|
303
|
+
"""
|
|
304
|
+
|
|
305
|
+
self.antenna_range = float(self.allowed_antenna_ranges[i])
|
|
306
|
+
self.config_queue.put(self.antenna_range)
|
|
307
|
+
|
|
308
|
+
if i == 0:
|
|
309
|
+
string = "FULL"
|
|
310
|
+
else:
|
|
311
|
+
string = f"ATN{i}"
|
|
312
|
+
|
|
313
|
+
logging.debug(f"[CMD] setting antenna: TIA {string}")
|
|
314
|
+
cmd = (b'\x11', f"SYSTEM : TIA {string}")
|
|
315
|
+
|
|
316
|
+
self.cmd_queue.put(cmd)
|
|
317
|
+
self.cmd_ack.wait()
|
|
318
|
+
self.cmd_ack.clear()
|
|
319
|
+
|
|
320
|
+
def set_acq_begin(self, t_begin: float = 1000.0):
|
|
321
|
+
"""
|
|
322
|
+
sets the start time of the time domain window.
|
|
323
|
+
Args:
|
|
324
|
+
t_begin: The desired start time
|
|
325
|
+
"""
|
|
326
|
+
logging.debug(f"[CMD] setting acq begin: {t_begin}")
|
|
327
|
+
if t_begin < 10:
|
|
328
|
+
b = b'\x18'
|
|
329
|
+
elif t_begin < 100:
|
|
330
|
+
b = b'\x19'
|
|
331
|
+
else:
|
|
332
|
+
b = b'\x1a'
|
|
333
|
+
cmd = (b, f"ACQUISITION : BEGIN {t_begin:.1f}")
|
|
334
|
+
measurement_was_running = self.acquisition
|
|
335
|
+
# need to stop the measurement before changing the range
|
|
336
|
+
logging.debug(f"stopping acquisition because of t_begin change! will restart late: {measurement_was_running}")
|
|
337
|
+
self.range_changed.set()
|
|
338
|
+
logging.debug(f"range changed set")
|
|
339
|
+
self.set_acq_stop()
|
|
340
|
+
logging.debug(f"stopped acq")
|
|
341
|
+
self.cmd_queue.put(cmd)
|
|
342
|
+
logging.debug(f"put cmd")
|
|
343
|
+
self.cmd_ack.wait()
|
|
344
|
+
logging.debug(f"wait for cmd ack")
|
|
345
|
+
self.cmd_ack.clear()
|
|
346
|
+
logging.debug(f"set t_begin")
|
|
347
|
+
self.t_begin = t_begin
|
|
348
|
+
logging.debug(f"waiting for buffer to be emptied")
|
|
349
|
+
self.buffer_emptied.wait()
|
|
350
|
+
self.buffer_emptied.clear()
|
|
351
|
+
logging.debug(f"buffer is emptied")
|
|
352
|
+
if measurement_was_running:
|
|
353
|
+
# if the measurement was running, restart it
|
|
354
|
+
self.set_acq_start()
|
|
355
|
+
|
|
356
|
+
@staticmethod
|
|
357
|
+
def nearest_entry(t_range: float, available_ranges: list):
|
|
358
|
+
"""
|
|
359
|
+
|
|
360
|
+
Args:
|
|
361
|
+
t_range: range provided by the user
|
|
362
|
+
available_ranges: available ranges
|
|
363
|
+
|
|
364
|
+
Returns: the nearest available range to the provided range
|
|
365
|
+
|
|
366
|
+
"""
|
|
367
|
+
nearest = available_ranges[0]
|
|
368
|
+
for num in available_ranges:
|
|
369
|
+
if abs(t_range - num) < abs(t_range - nearest):
|
|
370
|
+
nearest = num
|
|
371
|
+
return nearest
|
|
372
|
+
|
|
373
|
+
def set_acq_range(self, t_range: float = 50.0):
|
|
374
|
+
"""
|
|
375
|
+
sets the range/width in the time domain window.
|
|
376
|
+
available: 5, 10, 15, 20, 35, 50, 70, 100, 120, 150 or 200
|
|
377
|
+
Args:
|
|
378
|
+
t_range: The desired range
|
|
379
|
+
"""
|
|
380
|
+
available_ranges = [5, 10, 15, 20, 35, 50, 70, 100, 120, 150, 200]
|
|
381
|
+
if t_range not in available_ranges:
|
|
382
|
+
logging.info(f"[CMD] {t_range} is not supported. Only {available_ranges} are supported.")
|
|
383
|
+
t_range = self.nearest_entry(t_range, available_ranges)
|
|
384
|
+
logging.debug(f"[CMD] setting acq range: {t_range}")
|
|
385
|
+
if t_range <= 5.0:
|
|
386
|
+
b = b'\x18'
|
|
387
|
+
elif t_range <= 70.0:
|
|
388
|
+
b = b'\x19'
|
|
389
|
+
else:
|
|
390
|
+
b = b'\x1a'
|
|
391
|
+
cmd = (b, f"ACQUISITION : RANGE {t_range:.2f}")
|
|
392
|
+
measurement_was_running = self.acquisition
|
|
393
|
+
# need to stop the measurement before changing the range
|
|
394
|
+
logging.debug(f"stopping acquisition because of range change! will restart late: {measurement_was_running}")
|
|
395
|
+
self.range_changed.set()
|
|
396
|
+
self.set_acq_stop()
|
|
397
|
+
self.cmd_queue.put(cmd)
|
|
398
|
+
self.cmd_ack.wait()
|
|
399
|
+
self.cmd_ack.clear()
|
|
400
|
+
self.range = t_range
|
|
401
|
+
self.buffer_emptied.wait()
|
|
402
|
+
self.buffer_emptied.clear()
|
|
403
|
+
if measurement_was_running:
|
|
404
|
+
# if the measurement was running, restart it
|
|
405
|
+
self.set_acq_start()
|
|
406
|
+
|
|
407
|
+
def set_acq_avg(self, avg: int = 2):
|
|
408
|
+
"""
|
|
409
|
+
sets the width of the moving average to be performed by the device
|
|
410
|
+
Args:
|
|
411
|
+
avg: The desired averages
|
|
412
|
+
"""
|
|
413
|
+
logging.debug(f"[CMD] setting acq avg: {avg}")
|
|
414
|
+
if avg < 10:
|
|
415
|
+
b = b'\x17'
|
|
416
|
+
elif avg < 100:
|
|
417
|
+
b = b'\x18'
|
|
418
|
+
elif avg < 1000:
|
|
419
|
+
b = b'\x19'
|
|
420
|
+
else:
|
|
421
|
+
b = b'\x1a'
|
|
422
|
+
cmd = (b, f"ACQUISITION : AVERAGE {avg}")
|
|
423
|
+
self.cmd_queue.put(cmd)
|
|
424
|
+
self.cmd_ack.wait()
|
|
425
|
+
self.cmd_ack.clear()
|
|
426
|
+
# reset the average after it was changed
|
|
427
|
+
self.reset_acq_avg()
|
|
428
|
+
self.avg = avg
|
|
429
|
+
self.socket.avg_countdown = avg
|
|
430
|
+
|
|
431
|
+
def reset_acq_avg(self):
|
|
432
|
+
"""
|
|
433
|
+
resets the acquisition moving average to be performed by the device
|
|
434
|
+
"""
|
|
435
|
+
logging.debug("[CMD] resetting acq avg")
|
|
436
|
+
|
|
437
|
+
cmd = (b'\x17', "ACQUISITION : RESET AVG")
|
|
438
|
+
self.cmd_queue.put(cmd)
|
|
439
|
+
self.cmd_ack.wait()
|
|
440
|
+
self.cmd_ack.clear()
|
|
441
|
+
self.socket.avg_countdown = self.avg
|
|
442
|
+
|
|
443
|
+
def wait_for_avg(self):
|
|
444
|
+
while self.socket.avg_countdown > 0:
|
|
445
|
+
time.sleep(0.1)
|
|
446
|
+
|
|
447
|
+
def set_laser(self, state: bool):
|
|
448
|
+
"""
|
|
449
|
+
sets the laser mode ON or OFF
|
|
450
|
+
Args:
|
|
451
|
+
state: True for ON, False for OFF
|
|
452
|
+
"""
|
|
453
|
+
if state:
|
|
454
|
+
logging.debug("[CMD] setting laser on")
|
|
455
|
+
cmd = (b'\x0a', "LASER : ON")
|
|
456
|
+
else:
|
|
457
|
+
# emitter bias must be turned off before we turn of the laser
|
|
458
|
+
if self.emitter:
|
|
459
|
+
self.set_emitter(1, False)
|
|
460
|
+
self.set_emitter(2, False)
|
|
461
|
+
logging.debug("[CMD] setting laser off")
|
|
462
|
+
cmd = (b'\x0b', "LASER : OFF")
|
|
463
|
+
self.cmd_queue.put(cmd)
|
|
464
|
+
self.cmd_ack.wait()
|
|
465
|
+
self.cmd_ack.clear()
|
|
466
|
+
self.laser = state
|
|
467
|
+
|
|
468
|
+
def set_emitter(self, emitter: int, state: bool):
|
|
469
|
+
"""
|
|
470
|
+
sets the state of the emitter ON or OFF
|
|
471
|
+
Args:
|
|
472
|
+
emitter: Emitter 1 or 2
|
|
473
|
+
state: True for ON, False for OFF
|
|
474
|
+
"""
|
|
475
|
+
if emitter not in [1, 2]:
|
|
476
|
+
logging.info(f"emitter {emitter} is invalid, please use 1 or 2 as emitter value")
|
|
477
|
+
return
|
|
478
|
+
if state:
|
|
479
|
+
# laser must be running before we turn on the emitter bias
|
|
480
|
+
if not self.laser:
|
|
481
|
+
self.set_laser(True)
|
|
482
|
+
logging.debug(f"[CMD] setting emitter {emitter} on")
|
|
483
|
+
cmd = (b'\x0a', f"VOLT{emitter} : ON")
|
|
484
|
+
else:
|
|
485
|
+
logging.debug(f"[CMD] setting emitter {emitter} off")
|
|
486
|
+
cmd = (b'\x0b', f"VOLT{emitter} : OFF")
|
|
487
|
+
self.cmd_queue.put(cmd)
|
|
488
|
+
self.cmd_ack.wait()
|
|
489
|
+
self.cmd_ack.clear()
|
|
490
|
+
self.emitter[emitter - 1] = state
|
|
491
|
+
|
|
492
|
+
def set_acq_start(self):
|
|
493
|
+
"""
|
|
494
|
+
start the acquisition (data streaming)
|
|
495
|
+
"""
|
|
496
|
+
logging.debug("[CMD] starting acquisition")
|
|
497
|
+
cmd = (b'\x13', "ACQUISITION : START")
|
|
498
|
+
self.cmd_queue.put(cmd)
|
|
499
|
+
self.cmd_ack.wait()
|
|
500
|
+
self.cmd_ack.clear()
|
|
501
|
+
self.acq_running.set()
|
|
502
|
+
self.acquisition = True
|
|
503
|
+
|
|
504
|
+
def set_acq_stop(self):
|
|
505
|
+
"""
|
|
506
|
+
stop the acquisition (data streaming)
|
|
507
|
+
"""
|
|
508
|
+
logging.debug("[CMD] stopping acquisition")
|
|
509
|
+
cmd = (b'\x12', "ACQUISITION : STOP")
|
|
510
|
+
self.cmd_queue.put(cmd)
|
|
511
|
+
self.cmd_ack.wait()
|
|
512
|
+
self.cmd_ack.clear()
|
|
513
|
+
self.acq_running.clear()
|
|
514
|
+
self.acquisition = False
|
|
515
|
+
|
|
516
|
+
def auto_pulse_detection(self, lower: int, upper: int, detection_window: int = 100, detection_avg: int = 10):
|
|
517
|
+
"""
|
|
518
|
+
|
|
519
|
+
Auto detection function of pulse.
|
|
520
|
+
Spectrometer needs to be running (laser, emitter, acq) before calling this
|
|
521
|
+
|
|
522
|
+
:param lower: lower end of detection window
|
|
523
|
+
:param upper: upper end of detection window
|
|
524
|
+
:param detection_window: detection window width
|
|
525
|
+
:param detection_avg: detection avg to smooth out noise
|
|
526
|
+
:return: detected pulse or None
|
|
527
|
+
"""
|
|
528
|
+
|
|
529
|
+
previous_range = self.range
|
|
530
|
+
previous_avg = self.avg
|
|
531
|
+
logging.info(f"Searching pulses in range from {lower} to {upper}")
|
|
532
|
+
|
|
533
|
+
# setup
|
|
534
|
+
self.set_acq_range(detection_window)
|
|
535
|
+
self.set_acq_avg(detection_avg)
|
|
536
|
+
self.reset_acq_avg()
|
|
537
|
+
detected_pulse = None
|
|
538
|
+
|
|
539
|
+
# search in range
|
|
540
|
+
for t_begin in range(lower, upper, detection_window):
|
|
541
|
+
self.set_acq_begin(t_begin)
|
|
542
|
+
self.reset_acq_avg()
|
|
543
|
+
self.wait_for_avg()
|
|
544
|
+
|
|
545
|
+
timestamp = self.get_data().time.astype(np.float32)
|
|
546
|
+
pulse = self.get_data().signal_1.astype(np.float32)
|
|
547
|
+
detected_pulse = detect_pulse(timestamp, pulse)
|
|
548
|
+
if detected_pulse:
|
|
549
|
+
# found a pulse
|
|
550
|
+
logging.info(f"Found pulse at {detected_pulse}")
|
|
551
|
+
self.set_acq_begin(detected_pulse)
|
|
552
|
+
break
|
|
553
|
+
|
|
554
|
+
# revert to previous settings
|
|
555
|
+
self.set_acq_avg(previous_avg)
|
|
556
|
+
self.set_acq_range(previous_range)
|
|
557
|
+
|
|
558
|
+
# if no pulse has been detected, raise error
|
|
559
|
+
if not detected_pulse:
|
|
560
|
+
logging.error(f"No pulse detected in range from {lower} to {upper}")
|
|
561
|
+
raise Exception(f"No pulse detected in the range from {lower} to {upper}")
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: teraflash-ctrl
|
|
3
|
+
Version: 1.4.0
|
|
4
|
+
Summary: This Python package allows the configuration and readout of the TeraFlash Pro THz spectrometer.
|
|
5
|
+
Author-email: Linus Leo Stöckli <linus.stoeckli@unibe.ch>
|
|
6
|
+
Project-URL: Homepage, https://github.com/unibe-icelab/teraflash-ctrl-python
|
|
7
|
+
Project-URL: Bug Tracker, https://github.com/unibe-icelab/teraflash-ctrl-python/issues
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: License :: Other/Proprietary License
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Requires-Python: >=3.7
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
Requires-Dist: numpy
|
|
14
|
+
Requires-Dist: scipy
|
|
15
|
+
|
|
16
|
+
# TeraFlash Pro Python Package
|
|
17
|
+
|
|
18
|
+
<a href="https://github.com/unibe-icelab/teraflash-ctrl-python/releases"><img src="icons/icon.png" alt=“” width="100" height="100"> </img> </a>
|
|
19
|
+
|
|
20
|
+
This code is developed by the University of Bern and is no official product of Toptica Photonics AG.
|
|
21
|
+
This Python Package allows the configuration and readout of the TeraFlash Pro THz spectrometer.
|
|
22
|
+
The TCP communication protocol is probably incomplete and features may be missing as it was reverse engineered using
|
|
23
|
+
wireshark. The scanning stage is not supported but a list of other features is.
|
|
24
|
+
|
|
25
|
+
This is a simple library with no persistence settings (the configurations of the previous run will not be stored when the python session is closed).
|
|
26
|
+
A complete GUI written in Rust is also available [here](https://github.com/unibe-icelab/teraflash-ctrl).
|
|
27
|
+
|
|
28
|
+
Features:
|
|
29
|
+
- [x] Select begin time for the time window
|
|
30
|
+
- [x] Select range
|
|
31
|
+
- [x] Select average
|
|
32
|
+
- [x] Start/Stop Laser
|
|
33
|
+
- [x] Start/Stop Emitters
|
|
34
|
+
- [x] Start/Stop Acquisition
|
|
35
|
+
- [x] Set transmission
|
|
36
|
+
- [x] Set motion mode
|
|
37
|
+
- [x] Set channel
|
|
38
|
+
- [x] Get status
|
|
39
|
+
- [x] Get data (time domain and frequency domain)
|
|
40
|
+
- [x] auto pulse detection function
|
|
41
|
+
- [X] Set antenna range
|
|
42
|
+
- [ ] ...
|
|
43
|
+
|
|
44
|
+
## I. Installation
|
|
45
|
+
Download the [latest release](https://github.com/unibe-icelab/teraflash-ctrl-python/releases) or the [current state](https://github.com/unibe-icelab/teraflash-ctrl-python/archive/refs/heads/main.zip)
|
|
46
|
+
as a zip archive, move it into your working directory and then run:
|
|
47
|
+
```shell
|
|
48
|
+
pip install teraflash-ctrl-python-v1.4.0.zip
|
|
49
|
+
```
|
|
50
|
+
for the latest version or
|
|
51
|
+
```shell
|
|
52
|
+
pip install teraflash-ctrl-python-main.zip
|
|
53
|
+
```
|
|
54
|
+
for the current state of the main branch to install the package for your virtual python environment or system-wide.
|
|
55
|
+
|
|
56
|
+
## II. Taking a measurement
|
|
57
|
+
When connected to the device, you can turn on the laser and then the emitter.
|
|
58
|
+
After starting the acquisition, new data should be continuously updated and the most recent dataset can be obtained using `device.get_data()`:
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
from teraflash import TeraFlash
|
|
62
|
+
|
|
63
|
+
if __name__ == "__main__":
|
|
64
|
+
ip = "169.254.84.101"
|
|
65
|
+
with TeraFlash(ip) as device:
|
|
66
|
+
print(device.get_status())
|
|
67
|
+
device.set_laser(True)
|
|
68
|
+
device.set_emitter(1, True)
|
|
69
|
+
device.set_acq_start()
|
|
70
|
+
print(device.get_data())
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Always use the context manager to ensure that the connection is properly closed upon exiting!
|
|
74
|
+
Consult the [`example.py`](example.py) for usage.
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
_Disclaimer: This package is provided on a best effort basis with no guarantee as to the functionality and correctness. Use at your
|
|
78
|
+
own risk.
|
|
79
|
+
Users are encouraged to contribute by submitting [issues](https://github.com/unibe-icelab/teraflash-ctrl-python/issues) and/or [pull requests](https://github.com/unibe-icelab/teraflash-ctrl-python/pulls) for bug reporting or feature requests._
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
Copyright (c) 2026 University of Bern, Space Research & Planetary Sciences, Linus Leo Stöckli.
|
|
83
|
+
|
|
84
|
+
This work is licensed under the Creative Commons
|
|
85
|
+
Attribution-NonCommercial 4.0 International License.
|
|
86
|
+
To view a copy of this license, visit
|
|
87
|
+
https://creativecommons.org/licenses/by-nc/4.0/
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
__init__.py,sha256=Kidj6FQez2ufYQrtqvK3JEIIP2JOB87rVi8AWBM6mn0,292
|
|
2
|
+
interface.py,sha256=ARUpyLKe78A--m5wkMUYpZKBGxNYqYtEsMVX8hmzT2M,14718
|
|
3
|
+
io.py,sha256=pnMGiXGNv4Y6AYplfXy2P9LZigxmM_nvv5mSIvHcDwk,5057
|
|
4
|
+
math_utils.py,sha256=ggw-_n6sHJFYRSJEz-H414gFgUHsJKtot0k6LuXyd0g,3528
|
|
5
|
+
pulse_detection.py,sha256=RbilZCaN54KJgIVXpIZyT-8i6dazoj4rislbLlzTp38,1187
|
|
6
|
+
teraflash.py,sha256=hCtJ_LrHFGElH13Tpytw8oBeC5JF3pCWLv_dTW9Ldu4,18660
|
|
7
|
+
teraflash_ctrl-1.4.0.dist-info/METADATA,sha256=9RtKVs1WUczogZYN3mDB2Z8_SgmhbFYtrxsprh_yrsY,3768
|
|
8
|
+
teraflash_ctrl-1.4.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
9
|
+
teraflash_ctrl-1.4.0.dist-info/top_level.txt,sha256=9rQxSaNdCCnCem0znghBa3xxZozxLtKmDFOXDASglas,59
|
|
10
|
+
teraflash_ctrl-1.4.0.dist-info/RECORD,,
|