teraflash-ctrl 1.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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,72 @@
1
+ # TeraFlash Pro Python Package
2
+
3
+ <a href="https://github.com/unibe-icelab/teraflash-ctrl-python/releases"><img src="icons/icon.png" alt=“” width="100" height="100"> </img> </a>
4
+
5
+ This code is developed by the University of Bern and is no official product of Toptica Photonics AG.
6
+ This Python Package allows the configuration and readout of the TeraFlash Pro THz spectrometer.
7
+ The TCP communication protocol is probably incomplete and features may be missing as it was reverse engineered using
8
+ wireshark. The scanning stage is not supported but a list of other features is.
9
+
10
+ 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).
11
+ A complete GUI written in Rust is also available [here](https://github.com/unibe-icelab/teraflash-ctrl).
12
+
13
+ Features:
14
+ - [x] Select begin time for the time window
15
+ - [x] Select range
16
+ - [x] Select average
17
+ - [x] Start/Stop Laser
18
+ - [x] Start/Stop Emitters
19
+ - [x] Start/Stop Acquisition
20
+ - [x] Set transmission
21
+ - [x] Set motion mode
22
+ - [x] Set channel
23
+ - [x] Get status
24
+ - [x] Get data (time domain and frequency domain)
25
+ - [x] auto pulse detection function
26
+ - [X] Set antenna range
27
+ - [ ] ...
28
+
29
+ ## I. Installation
30
+ 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)
31
+ as a zip archive, move it into your working directory and then run:
32
+ ```shell
33
+ pip install teraflash-ctrl-python-v1.4.0.zip
34
+ ```
35
+ for the latest version or
36
+ ```shell
37
+ pip install teraflash-ctrl-python-main.zip
38
+ ```
39
+ for the current state of the main branch to install the package for your virtual python environment or system-wide.
40
+
41
+ ## II. Taking a measurement
42
+ When connected to the device, you can turn on the laser and then the emitter.
43
+ After starting the acquisition, new data should be continuously updated and the most recent dataset can be obtained using `device.get_data()`:
44
+
45
+ ```python
46
+ from teraflash import TeraFlash
47
+
48
+ if __name__ == "__main__":
49
+ ip = "169.254.84.101"
50
+ with TeraFlash(ip) as device:
51
+ print(device.get_status())
52
+ device.set_laser(True)
53
+ device.set_emitter(1, True)
54
+ device.set_acq_start()
55
+ print(device.get_data())
56
+ ```
57
+
58
+ Always use the context manager to ensure that the connection is properly closed upon exiting!
59
+ Consult the [`example.py`](example.py) for usage.
60
+
61
+
62
+ _Disclaimer: This package is provided on a best effort basis with no guarantee as to the functionality and correctness. Use at your
63
+ own risk.
64
+ 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._
65
+
66
+
67
+ Copyright (c) 2026 University of Bern, Space Research & Planetary Sciences, Linus Leo Stöckli.
68
+
69
+ This work is licensed under the Creative Commons
70
+ Attribution-NonCommercial 4.0 International License.
71
+ To view a copy of this license, visit
72
+ https://creativecommons.org/licenses/by-nc/4.0/
@@ -0,0 +1,26 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "teraflash-ctrl"
7
+ version = "1.4.0"
8
+ authors = [
9
+ { name = "Linus Leo Stöckli", email = "linus.stoeckli@unibe.ch" },
10
+ ]
11
+ description = "This Python package allows the configuration and readout of the TeraFlash Pro THz spectrometer."
12
+ readme = "README.md"
13
+ license-files = ["LICENSE"]
14
+ requires-python = ">=3.7"
15
+ classifiers = [
16
+ "Programming Language :: Python :: 3",
17
+ "License :: Other/Proprietary License",
18
+ "Operating System :: OS Independent",
19
+ ]
20
+ dependencies = [
21
+ "numpy",
22
+ "scipy"
23
+ ]
24
+ [project.urls]
25
+ "Homepage" = "https://github.com/unibe-icelab/teraflash-ctrl-python"
26
+ "Bug Tracker" = "https://github.com/unibe-icelab/teraflash-ctrl-python/issues"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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
+ """
@@ -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)=}")