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 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,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,6 @@
1
+ __init__
2
+ interface
3
+ io
4
+ math_utils
5
+ pulse_detection
6
+ teraflash