pyglaze 0.1.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.
- pyglaze/__init__.py +1 -0
- pyglaze/datamodels/__init__.py +4 -0
- pyglaze/datamodels/pulse.py +551 -0
- pyglaze/datamodels/waveform.py +165 -0
- pyglaze/device/__init__.py +15 -0
- pyglaze/device/_delayunit_data/carmen-nonuniform-2023-10-20.pickle +0 -0
- pyglaze/device/_delayunit_data/g1-linearized-2023-04-04.pickle +0 -0
- pyglaze/device/_delayunit_data/g2-linearized-2023-04-04.pickle +0 -0
- pyglaze/device/_delayunit_data/g2-nonuniform-2023-04-04.pickle +0 -0
- pyglaze/device/_delayunit_data/mock_delay.pickle +0 -0
- pyglaze/device/ampcom.py +447 -0
- pyglaze/device/configuration.py +266 -0
- pyglaze/device/delayunit.py +151 -0
- pyglaze/device/identifiers.py +41 -0
- pyglaze/devtools/__init__.py +3 -0
- pyglaze/devtools/mock_device.py +367 -0
- pyglaze/devtools/thz_pulse.py +35 -0
- pyglaze/helpers/__init__.py +0 -0
- pyglaze/helpers/types.py +20 -0
- pyglaze/helpers/utilities.py +80 -0
- pyglaze/interpolation/__init__.py +3 -0
- pyglaze/interpolation/interpolation.py +24 -0
- pyglaze/py.typed +0 -0
- pyglaze/scanning/__init__.py +4 -0
- pyglaze/scanning/_asyncscanner.py +146 -0
- pyglaze/scanning/client.py +59 -0
- pyglaze/scanning/scanner.py +256 -0
- pyglaze-0.1.0.dist-info/LICENSE +28 -0
- pyglaze-0.1.0.dist-info/METADATA +82 -0
- pyglaze-0.1.0.dist-info/RECORD +32 -0
- pyglaze-0.1.0.dist-info/WHEEL +5 -0
- pyglaze-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import struct
|
|
5
|
+
import time
|
|
6
|
+
from abc import ABC, abstractmethod
|
|
7
|
+
from enum import Enum, auto
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from time import sleep
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
from serial import serialutil
|
|
13
|
+
|
|
14
|
+
from pyglaze.device.configuration import (
|
|
15
|
+
DeviceConfiguration,
|
|
16
|
+
ForceDeviceConfiguration,
|
|
17
|
+
LeDeviceConfiguration,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class MockDevice(ABC):
|
|
22
|
+
"""Base class for Mock devices for testing purposes."""
|
|
23
|
+
|
|
24
|
+
@abstractmethod
|
|
25
|
+
def __init__(
|
|
26
|
+
self: MockDevice,
|
|
27
|
+
fail_after: float = np.inf,
|
|
28
|
+
n_fails: float = np.inf,
|
|
29
|
+
) -> None:
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ForceMockDevice(MockDevice):
|
|
34
|
+
"""Mock device for devices using a FORCE lockin for testing purposes."""
|
|
35
|
+
|
|
36
|
+
CONFIG_PATH = Path(__file__).parents[1] / "devtools" / "mockup_config.json"
|
|
37
|
+
ENCODING: str = "utf-8"
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self: ForceMockDevice,
|
|
41
|
+
fail_after: float = np.inf,
|
|
42
|
+
n_fails: float = np.inf,
|
|
43
|
+
) -> None:
|
|
44
|
+
self.fail_after = fail_after
|
|
45
|
+
self.fails_wanted = n_fails
|
|
46
|
+
self.n_failures = 0
|
|
47
|
+
self.n_scans = 0
|
|
48
|
+
self.rng = np.random.default_rng()
|
|
49
|
+
self.valid_input = True
|
|
50
|
+
self.experiment_running = False
|
|
51
|
+
|
|
52
|
+
self._periods = None
|
|
53
|
+
self._frequency = None
|
|
54
|
+
self._sweep_length = None
|
|
55
|
+
if not self.CONFIG_PATH.exists():
|
|
56
|
+
conf = {"periods": None, "frequency": None, "sweep_length_ms": None}
|
|
57
|
+
self.__save(conf)
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def periods(self: ForceMockDevice) -> int:
|
|
61
|
+
"""Number of integration periods."""
|
|
62
|
+
return int(self.__get_val("periods"))
|
|
63
|
+
|
|
64
|
+
@periods.setter
|
|
65
|
+
def periods(self: ForceMockDevice, value: int) -> None:
|
|
66
|
+
self.__set_val("periods", value)
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def frequency(self: ForceMockDevice) -> int:
|
|
70
|
+
"""Modulation frequency in Hz."""
|
|
71
|
+
return int(self.__get_val("frequency"))
|
|
72
|
+
|
|
73
|
+
@frequency.setter
|
|
74
|
+
def frequency(self: ForceMockDevice, value: int) -> None:
|
|
75
|
+
self.__set_val("frequency", value)
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def sweep_length(self: ForceMockDevice) -> int:
|
|
79
|
+
"""Total sweep length for one pulse."""
|
|
80
|
+
return int(self.__get_val("sweep_length"))
|
|
81
|
+
|
|
82
|
+
@sweep_length.setter
|
|
83
|
+
def sweep_length(self: ForceMockDevice, value: int) -> None:
|
|
84
|
+
self.__set_val("sweep_length", value)
|
|
85
|
+
|
|
86
|
+
def close(self: ForceMockDevice) -> None:
|
|
87
|
+
"""Mock-close the serial connection."""
|
|
88
|
+
|
|
89
|
+
def write(self: ForceMockDevice, input_string: bytes) -> None:
|
|
90
|
+
"""Mock-write to the serial connection."""
|
|
91
|
+
decoded_input_string = input_string.decode("utf-8")
|
|
92
|
+
split_input_string = decoded_input_string.split(",")
|
|
93
|
+
cmd = split_input_string[0]
|
|
94
|
+
if cmd == "!set timing":
|
|
95
|
+
self.periods = int(split_input_string[1])
|
|
96
|
+
self.frequency = int(split_input_string[2])
|
|
97
|
+
elif cmd == "!set sweep length":
|
|
98
|
+
self.sweep_length = int(split_input_string[1])
|
|
99
|
+
elif cmd == "!s":
|
|
100
|
+
time_pr_point = self.periods / self.frequency
|
|
101
|
+
self.in_waiting = int(self.sweep_length * 1e-3 / time_pr_point)
|
|
102
|
+
self.experiment_running = True
|
|
103
|
+
elif cmd in ["!lut", "!set wave", "!set generator"]:
|
|
104
|
+
pass
|
|
105
|
+
elif cmd != "!dat":
|
|
106
|
+
self.valid_input = False
|
|
107
|
+
|
|
108
|
+
def readline(self: ForceMockDevice) -> bytes:
|
|
109
|
+
"""Mock-readline from the serial connection."""
|
|
110
|
+
if self.valid_input:
|
|
111
|
+
return self.__run_experiment() if self.experiment_running else b"!A,OK\r"
|
|
112
|
+
|
|
113
|
+
return b"A,FAULT\r"
|
|
114
|
+
|
|
115
|
+
def read_until(self: ForceMockDevice, expected: bytes = b"\r") -> bytes: # noqa: ARG002
|
|
116
|
+
"""Mock-read_until from the serial connection."""
|
|
117
|
+
random_datapoint = self.__create_random_datapoint
|
|
118
|
+
return random_datapoint.encode(self.ENCODING)
|
|
119
|
+
|
|
120
|
+
def __run_experiment(self: ForceMockDevice) -> bytes:
|
|
121
|
+
return_string = "!A,OK\\r"
|
|
122
|
+
return_string += (
|
|
123
|
+
f"!S,ip: {self.periods}, "
|
|
124
|
+
f"Freq: {self.frequency}, "
|
|
125
|
+
f"sl: {self.sweep_length}, "
|
|
126
|
+
f"from: 0.0000, "
|
|
127
|
+
f"to:1.0000\r"
|
|
128
|
+
)
|
|
129
|
+
for _ in range(self.in_waiting):
|
|
130
|
+
return_string += self.__create_random_datapoint
|
|
131
|
+
return_string += "!D,DONE\\r"
|
|
132
|
+
sleep(self.sweep_length * 1e-3)
|
|
133
|
+
self.n_scans += 1
|
|
134
|
+
if self.n_scans > self.fail_after and self.n_failures < self.fails_wanted:
|
|
135
|
+
self.n_failures += 1
|
|
136
|
+
msg = "MOCK_DEVICE: scan failed"
|
|
137
|
+
raise serialutil.SerialException(msg)
|
|
138
|
+
|
|
139
|
+
return return_string.encode("utf-8")
|
|
140
|
+
|
|
141
|
+
@property
|
|
142
|
+
def __create_random_datapoint(self: ForceMockDevice) -> str:
|
|
143
|
+
radius = self.rng.random() * 10
|
|
144
|
+
theta = self.rng.random() * 360
|
|
145
|
+
return f"!R,{radius},{theta}\r"
|
|
146
|
+
|
|
147
|
+
def __save(self: ForceMockDevice, conf: dict) -> None:
|
|
148
|
+
with self.CONFIG_PATH.open("w") as f:
|
|
149
|
+
json.dump(conf, f)
|
|
150
|
+
|
|
151
|
+
def __get_val(self: ForceMockDevice, key: str) -> int:
|
|
152
|
+
val: int = getattr(self, f"_{key}")
|
|
153
|
+
if not val:
|
|
154
|
+
with self.CONFIG_PATH.open() as f:
|
|
155
|
+
val = json.load(f)[key]
|
|
156
|
+
return val
|
|
157
|
+
|
|
158
|
+
def __set_val(self: ForceMockDevice, key: str, val: str | float) -> None:
|
|
159
|
+
with self.CONFIG_PATH.open() as f:
|
|
160
|
+
config = json.load(f)
|
|
161
|
+
config[key] = val
|
|
162
|
+
setattr(self, f"_{key}", val)
|
|
163
|
+
self.__save(config)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class _LeMockState(Enum):
|
|
167
|
+
IDLE = auto()
|
|
168
|
+
WAITING_FOR_SETTINGS = auto()
|
|
169
|
+
WAITING_FOR_LIST = auto()
|
|
170
|
+
RECEIVED_SETTINGS = auto()
|
|
171
|
+
RECEIVED_LIST = auto()
|
|
172
|
+
RECEIVED_STATUS_REQUEST = auto()
|
|
173
|
+
STARTING_SCAN = auto()
|
|
174
|
+
SCANNING = auto()
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class LeMockDevice(MockDevice):
|
|
178
|
+
"""Mock device for devices using a Le lockin for testing purposes."""
|
|
179
|
+
|
|
180
|
+
ENCODING = "utf-8"
|
|
181
|
+
LI_MODULATION_FREQUENCY = 10000
|
|
182
|
+
TIME_WINDOW = 100e-12
|
|
183
|
+
DAC_BITWIDTH = 2**12
|
|
184
|
+
|
|
185
|
+
def __init__(
|
|
186
|
+
self: LeMockDevice, fail_after: float = np.inf, n_fails: float = np.inf
|
|
187
|
+
) -> None:
|
|
188
|
+
self.fail_after = fail_after
|
|
189
|
+
self.fails_wanted = n_fails
|
|
190
|
+
self.n_failures = 0
|
|
191
|
+
self.n_scans = 0
|
|
192
|
+
self.rng = np.random.default_rng()
|
|
193
|
+
self.state = _LeMockState.IDLE
|
|
194
|
+
self.is_scanning = False
|
|
195
|
+
self.n_scanning_points: int | None = None
|
|
196
|
+
self.integration_periods: int | None = None
|
|
197
|
+
self.use_ema: bool | None = None
|
|
198
|
+
self.scanning_list: list[int] | None = None
|
|
199
|
+
self._scan_start_time: float | None = None
|
|
200
|
+
|
|
201
|
+
def write(self: LeMockDevice, input_bytes: bytes) -> None:
|
|
202
|
+
"""Mock-write to the serial connection."""
|
|
203
|
+
if self.state == _LeMockState.WAITING_FOR_SETTINGS:
|
|
204
|
+
self._handle_waiting_for_settings(input_bytes)
|
|
205
|
+
return
|
|
206
|
+
if self.state == _LeMockState.WAITING_FOR_LIST:
|
|
207
|
+
self._handle_waiting_for_list(input_bytes)
|
|
208
|
+
return
|
|
209
|
+
if self.state == _LeMockState.IDLE:
|
|
210
|
+
self._handle_idle(input_bytes)
|
|
211
|
+
return
|
|
212
|
+
if self.state == _LeMockState.SCANNING:
|
|
213
|
+
self._handle_scanning(input_bytes)
|
|
214
|
+
return
|
|
215
|
+
|
|
216
|
+
raise NotImplementedError
|
|
217
|
+
|
|
218
|
+
def read(self: LeMockDevice, size: int) -> bytes:
|
|
219
|
+
"""Mock-read from the serial connection."""
|
|
220
|
+
if self.state == _LeMockState.IDLE:
|
|
221
|
+
return self._create_scan_bytes(n_bytes=size)
|
|
222
|
+
raise NotImplementedError
|
|
223
|
+
|
|
224
|
+
def read_until(self: LeMockDevice, _: bytes = b"\r") -> bytes: # noqa: PLR0911
|
|
225
|
+
"""Mock-read_until from the serial connection."""
|
|
226
|
+
if self.state == _LeMockState.WAITING_FOR_SETTINGS:
|
|
227
|
+
return "ACK: Ready to receive settings.".encode(self.ENCODING)
|
|
228
|
+
if self.state == _LeMockState.RECEIVED_SETTINGS:
|
|
229
|
+
self.state = _LeMockState.IDLE
|
|
230
|
+
return "ACK: Settings received.".encode(self.ENCODING)
|
|
231
|
+
if self.state == _LeMockState.WAITING_FOR_LIST:
|
|
232
|
+
return "ACK: Ready to receive list.".encode(self.ENCODING)
|
|
233
|
+
if self.state == _LeMockState.RECEIVED_LIST:
|
|
234
|
+
self.state = _LeMockState.IDLE
|
|
235
|
+
return "ACK: List received.".encode(self.ENCODING)
|
|
236
|
+
if self.state == _LeMockState.STARTING_SCAN:
|
|
237
|
+
self.state = _LeMockState.SCANNING
|
|
238
|
+
self.is_scanning = True
|
|
239
|
+
return "ACK: Scan started.".encode(self.ENCODING)
|
|
240
|
+
if self.state == _LeMockState.RECEIVED_STATUS_REQUEST:
|
|
241
|
+
if self._scan_has_finished():
|
|
242
|
+
self.state = _LeMockState.IDLE
|
|
243
|
+
return "ACK: Idle.".encode(self.ENCODING)
|
|
244
|
+
|
|
245
|
+
self.state = _LeMockState.SCANNING
|
|
246
|
+
return "Error: Scan is ongoing.".encode(self.ENCODING)
|
|
247
|
+
|
|
248
|
+
msg = f"Unknown state: {self.state}"
|
|
249
|
+
raise NotImplementedError(msg)
|
|
250
|
+
|
|
251
|
+
def close(self: LeMockDevice) -> None:
|
|
252
|
+
"""Mock-close the serial connection."""
|
|
253
|
+
|
|
254
|
+
@property
|
|
255
|
+
def _scanning_time(self: LeMockDevice) -> float:
|
|
256
|
+
if self.n_scanning_points and self.integration_periods:
|
|
257
|
+
return (
|
|
258
|
+
self.n_scanning_points
|
|
259
|
+
* self.integration_periods
|
|
260
|
+
/ self.LI_MODULATION_FREQUENCY
|
|
261
|
+
)
|
|
262
|
+
msg = "Cannot calculate scanning time when n_scanning_points or integration_periods is None"
|
|
263
|
+
raise ValueError(msg)
|
|
264
|
+
|
|
265
|
+
def _handle_idle(self: LeMockDevice, input_bytes: bytes) -> None:
|
|
266
|
+
msg = input_bytes.decode("utf-8")
|
|
267
|
+
if msg == "S":
|
|
268
|
+
self.state = _LeMockState.WAITING_FOR_SETTINGS
|
|
269
|
+
elif msg == "L":
|
|
270
|
+
self.state = _LeMockState.WAITING_FOR_LIST
|
|
271
|
+
elif msg == "G":
|
|
272
|
+
self.state = _LeMockState.STARTING_SCAN
|
|
273
|
+
self._scan_start_time = time.time()
|
|
274
|
+
elif msg == "R":
|
|
275
|
+
self._scan_has_finished()
|
|
276
|
+
else:
|
|
277
|
+
msg = f"Unknown message: {msg}"
|
|
278
|
+
raise NotImplementedError(msg)
|
|
279
|
+
|
|
280
|
+
def _handle_scanning(self: LeMockDevice, input_bytes: bytes) -> None:
|
|
281
|
+
msg = input_bytes.decode("utf-8")
|
|
282
|
+
if msg == "H":
|
|
283
|
+
self.state = _LeMockState.RECEIVED_STATUS_REQUEST
|
|
284
|
+
return
|
|
285
|
+
if msg == "R":
|
|
286
|
+
if self._scan_has_finished():
|
|
287
|
+
self.state = _LeMockState.IDLE
|
|
288
|
+
return
|
|
289
|
+
|
|
290
|
+
raise NotImplementedError
|
|
291
|
+
|
|
292
|
+
def _handle_waiting_for_settings(self: LeMockDevice, input_bytes: bytes) -> None:
|
|
293
|
+
ints = self._decode_ints(input_bytes)
|
|
294
|
+
self.n_scanning_points = ints[0]
|
|
295
|
+
self.integration_periods = ints[1]
|
|
296
|
+
self.use_ema = bool(ints[2])
|
|
297
|
+
self.state = _LeMockState.RECEIVED_SETTINGS
|
|
298
|
+
|
|
299
|
+
def _handle_waiting_for_list(self: LeMockDevice, input_bytes: bytes) -> None:
|
|
300
|
+
self.scanning_list = self._decode_ints(input_bytes)
|
|
301
|
+
self.state = _LeMockState.RECEIVED_LIST
|
|
302
|
+
|
|
303
|
+
def _decode_ints(self: LeMockDevice, input_bytes: bytes) -> list[int]:
|
|
304
|
+
# Convert every two bytes to a 16-bit integer (assuming little-endian format)
|
|
305
|
+
return [
|
|
306
|
+
struct.unpack("<H", input_bytes[i : i + 2])[0]
|
|
307
|
+
for i in range(0, len(input_bytes), 2)
|
|
308
|
+
]
|
|
309
|
+
|
|
310
|
+
def _scan_has_finished(self: LeMockDevice) -> bool:
|
|
311
|
+
if not self.is_scanning:
|
|
312
|
+
return True
|
|
313
|
+
if self._scan_start_time is None:
|
|
314
|
+
msg = "Scan start time is None"
|
|
315
|
+
raise ValueError(msg)
|
|
316
|
+
scan_finished = time.time() - self._scan_start_time > self._scanning_time
|
|
317
|
+
if scan_finished:
|
|
318
|
+
self.is_scanning = False
|
|
319
|
+
self._scan_start_time = None
|
|
320
|
+
return scan_finished
|
|
321
|
+
|
|
322
|
+
def _create_scan_bytes(self: LeMockDevice, n_bytes: int) -> bytes: # noqa: ARG002
|
|
323
|
+
if self.scanning_list is None:
|
|
324
|
+
msg = "Scanning list is None"
|
|
325
|
+
raise ValueError(msg)
|
|
326
|
+
|
|
327
|
+
self.n_scans += 1
|
|
328
|
+
if self.n_scans > self.fail_after and self.n_failures < self.fails_wanted:
|
|
329
|
+
self.n_failures += 1
|
|
330
|
+
numbers = np.array([])
|
|
331
|
+
else:
|
|
332
|
+
numbers = self.rng.random(2 * len(self.scanning_list))
|
|
333
|
+
|
|
334
|
+
# Each scanning point will generate an X and a Y value (lockin detection)
|
|
335
|
+
return struct.pack("<" + "f" * len(numbers), *numbers)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def list_mock_devices() -> list[str]:
|
|
339
|
+
"""List all available mock devices."""
|
|
340
|
+
return [
|
|
341
|
+
"mock_device",
|
|
342
|
+
"mock_device_scan_should_fail",
|
|
343
|
+
"mock_device_fail_first_scan",
|
|
344
|
+
]
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _mock_device_factory(config: DeviceConfiguration) -> MockDevice:
|
|
348
|
+
mock_class = _get_mock_class(config)
|
|
349
|
+
if config.amp_port == "mock_device_scan_should_fail":
|
|
350
|
+
return mock_class(fail_after=0)
|
|
351
|
+
if config.amp_port == "mock_device":
|
|
352
|
+
return mock_class()
|
|
353
|
+
if config.amp_port == "mock_device_fail_first_scan":
|
|
354
|
+
return mock_class(fail_after=0, n_fails=1)
|
|
355
|
+
|
|
356
|
+
msg = f"Unknown mock device requested: {config.amp_port}. Valid options are: {list_mock_devices()}"
|
|
357
|
+
raise ValueError(msg)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _get_mock_class(config: DeviceConfiguration) -> type[MockDevice]:
|
|
361
|
+
if isinstance(config, ForceDeviceConfiguration):
|
|
362
|
+
return ForceMockDevice
|
|
363
|
+
if isinstance(config, LeDeviceConfiguration):
|
|
364
|
+
return LeMockDevice
|
|
365
|
+
|
|
366
|
+
msg = f"Unsupported configuration type: {type(config).__name__}"
|
|
367
|
+
raise ValueError(msg)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import cast
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
from pyglaze.helpers.types import FloatArray
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def gaussian_derivative_pulse(
|
|
11
|
+
time: FloatArray,
|
|
12
|
+
t0: float,
|
|
13
|
+
sigma: float,
|
|
14
|
+
signal_to_noise: float | None = None,
|
|
15
|
+
) -> FloatArray:
|
|
16
|
+
"""Simulates a THz pulse as the derivative of a Gaussian.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
time: Times to evaluate pulse at
|
|
20
|
+
t0: Center position of pulse
|
|
21
|
+
sigma: Standard deviation of Gaussian
|
|
22
|
+
signal_to_noise: Ratio between peak of pulse and standard deviation of noise
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
Simulated pulse
|
|
26
|
+
"""
|
|
27
|
+
signal: np.ndarray = (time - t0) * np.exp(-0.5 * ((time - t0) / sigma) ** 2)
|
|
28
|
+
noise = (
|
|
29
|
+
0.0
|
|
30
|
+
if signal_to_noise is None
|
|
31
|
+
else np.random.default_rng().normal(
|
|
32
|
+
scale=1.0 / signal_to_noise, size=len(signal)
|
|
33
|
+
)
|
|
34
|
+
)
|
|
35
|
+
return cast(FloatArray, signal / np.max(signal) + noise)
|
|
File without changes
|
pyglaze/helpers/types.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from typing import Any, TypeVar, Union
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
from typing_extensions import ParamSpec, TypeAlias
|
|
5
|
+
|
|
6
|
+
# numpy typing does not work with pipe, hence Union instead
|
|
7
|
+
FloatArray: TypeAlias = np.ndarray[Any, np.dtype[Union[np.float64, np.float32]]]
|
|
8
|
+
ComplexArray: TypeAlias = Union[
|
|
9
|
+
np.ndarray[Any, np.dtype[Union[np.complex128, np.complex64]]], FloatArray
|
|
10
|
+
]
|
|
11
|
+
F = TypeVar("F", FloatArray, float)
|
|
12
|
+
C = TypeVar("C", ComplexArray, complex)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
P = ParamSpec("P")
|
|
16
|
+
T = TypeVar("T")
|
|
17
|
+
|
|
18
|
+
JSONConvertible: TypeAlias = Union[
|
|
19
|
+
list["JSONConvertible"], dict[str, "JSONConvertible"], int, float, str, None
|
|
20
|
+
]
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from functools import wraps
|
|
6
|
+
from typing import TYPE_CHECKING, Callable, cast
|
|
7
|
+
|
|
8
|
+
import serial
|
|
9
|
+
import serial.tools.list_ports
|
|
10
|
+
|
|
11
|
+
from pyglaze.helpers.types import P, T
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
import logging
|
|
15
|
+
|
|
16
|
+
APP_NAME = "Glaze"
|
|
17
|
+
LOGGER_NAME = "glaze-logger"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def list_serial_ports() -> list[str]:
|
|
21
|
+
"""Lists available serial ports for device conneciton.
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
list[str]: Paths to available ports.
|
|
25
|
+
"""
|
|
26
|
+
skip_ports_substrings = ["Bluetooth", "debug"]
|
|
27
|
+
|
|
28
|
+
ports = []
|
|
29
|
+
for port in serial.tools.list_ports.comports():
|
|
30
|
+
if any(substring in port.device for substring in skip_ports_substrings):
|
|
31
|
+
continue
|
|
32
|
+
ports.append(port.device)
|
|
33
|
+
return ports
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class _BackoffRetry:
|
|
38
|
+
"""Decorator for retrying a function, using exponential backoff, if it fails.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
max_tries: The maximum number of times the function should be tried.
|
|
42
|
+
max_backoff: The maximum backoff time in seconds.
|
|
43
|
+
backoff_base: The base of the exponential backoff.
|
|
44
|
+
logger: A Logger class to use for logging. If None, messages are printed.
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
The function that is decorated.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
max_tries: int = 5
|
|
51
|
+
max_backoff: float = 5
|
|
52
|
+
backoff_base: float = 0.01
|
|
53
|
+
logger: logging.Logger | None = None
|
|
54
|
+
|
|
55
|
+
def __call__(self: _BackoffRetry, func: Callable[P, T]) -> Callable[P, T]:
|
|
56
|
+
"""Try the function `max_tries` times, with exponential backoff if it fails."""
|
|
57
|
+
|
|
58
|
+
@wraps(func)
|
|
59
|
+
def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
|
|
60
|
+
for tries in range(self.max_tries - 1):
|
|
61
|
+
try:
|
|
62
|
+
return cast(T, func(*args, **kwargs))
|
|
63
|
+
except (KeyboardInterrupt, SystemExit):
|
|
64
|
+
raise
|
|
65
|
+
except Exception as e: # noqa: BLE001
|
|
66
|
+
self._log(
|
|
67
|
+
f"{func.__name__} failed {tries+1} time(s) with: '{e}'. Trying again"
|
|
68
|
+
)
|
|
69
|
+
backoff = min(self.backoff_base * 2**tries, self.max_backoff)
|
|
70
|
+
time.sleep(backoff)
|
|
71
|
+
self._log(f"{func.__name__}: Last try ({tries+2}).")
|
|
72
|
+
return cast(T, func(*args, **kwargs))
|
|
73
|
+
|
|
74
|
+
return wrapper
|
|
75
|
+
|
|
76
|
+
def _log(self: _BackoffRetry, msg: str) -> None:
|
|
77
|
+
if self.logger:
|
|
78
|
+
self.logger.warning(msg)
|
|
79
|
+
else:
|
|
80
|
+
pass
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
|
|
3
|
+
from pyglaze.helpers.types import FloatArray
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def ws_interpolate(
|
|
7
|
+
times: FloatArray, pulse: FloatArray, interp_times: FloatArray
|
|
8
|
+
) -> FloatArray:
|
|
9
|
+
"""Performs Whittaker-Shannon interpolation at the supplied times given a pulse.
|
|
10
|
+
|
|
11
|
+
Args:
|
|
12
|
+
times: Sampling times
|
|
13
|
+
pulse: A sampled pulse satisfying the Nyquist criterion
|
|
14
|
+
interp_times: Array of times at which to interpolate
|
|
15
|
+
|
|
16
|
+
Returns:
|
|
17
|
+
FloatArray: Interpolated values
|
|
18
|
+
"""
|
|
19
|
+
dt = times[1] - times[0]
|
|
20
|
+
_range = np.arange(len(pulse))
|
|
21
|
+
# times must be zero-centered for formula to work
|
|
22
|
+
sinc = np.sinc((interp_times[:, np.newaxis] - times[0] - dt * _range) / dt)
|
|
23
|
+
|
|
24
|
+
return np.asarray(np.sum(pulse * sinc, axis=1))
|
pyglaze/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from multiprocessing import Event, Pipe, Process, Queue, synchronize
|
|
6
|
+
from queue import Empty, Full
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from serial import serialutil
|
|
10
|
+
|
|
11
|
+
from pyglaze.datamodels.waveform import UnprocessedWaveform, _TimestampedWaveform
|
|
12
|
+
from pyglaze.scanning.scanner import Scanner
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
import logging
|
|
16
|
+
from multiprocessing.connection import Connection
|
|
17
|
+
|
|
18
|
+
from pyglaze.device.configuration import DeviceConfiguration
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class _ScannerHealth:
|
|
23
|
+
is_alive: bool
|
|
24
|
+
is_healthy: bool
|
|
25
|
+
error: Exception | None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class _AsyncScanner:
|
|
30
|
+
"""Used by GlazeClient to starts a scanner in a new process and read scans from shared memory."""
|
|
31
|
+
|
|
32
|
+
queue_maxsize: int = 10
|
|
33
|
+
startup_timeout: float = 30.0
|
|
34
|
+
logger: logging.Logger | None = None
|
|
35
|
+
is_scanning: bool = False
|
|
36
|
+
_child_process: Process = field(init=False)
|
|
37
|
+
_shared_mem: Queue[_TimestampedWaveform] = field(init=False)
|
|
38
|
+
_SCAN_TIMEOUT: float = field(init=False)
|
|
39
|
+
_stop_signal: synchronize.Event = field(init=False)
|
|
40
|
+
_scanner_conn: Connection = field(init=False)
|
|
41
|
+
|
|
42
|
+
def start_scan(self: _AsyncScanner, config: DeviceConfiguration) -> None:
|
|
43
|
+
"""Starts continuously scanning in new process.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
config: Device configurtaion
|
|
47
|
+
"""
|
|
48
|
+
self._SCAN_TIMEOUT = config._sweep_length_ms * 2e-3 + 1 # noqa: SLF001, access to private attribute for backwards compatibility
|
|
49
|
+
self._shared_mem = Queue(maxsize=self.queue_maxsize)
|
|
50
|
+
self._stop_signal = Event()
|
|
51
|
+
self._scanner_conn, child_conn = Pipe()
|
|
52
|
+
self._child_process = Process(
|
|
53
|
+
target=_AsyncScanner._run_scanner,
|
|
54
|
+
args=[config, self._shared_mem, self._stop_signal, child_conn],
|
|
55
|
+
)
|
|
56
|
+
self._child_process.start()
|
|
57
|
+
|
|
58
|
+
# Wait for scanner to start
|
|
59
|
+
if not self._scanner_conn.poll(timeout=self.startup_timeout):
|
|
60
|
+
self.stop_scan()
|
|
61
|
+
err_msg = "Scanner timed out"
|
|
62
|
+
raise TimeoutError(err_msg)
|
|
63
|
+
|
|
64
|
+
msg: _ScannerHealth = self._scanner_conn.recv()
|
|
65
|
+
if msg.is_healthy and msg.is_alive:
|
|
66
|
+
self.is_scanning = True
|
|
67
|
+
else:
|
|
68
|
+
self.stop_scan()
|
|
69
|
+
|
|
70
|
+
if msg.error:
|
|
71
|
+
if self.logger:
|
|
72
|
+
self.logger.error(str(msg.error))
|
|
73
|
+
raise msg.error
|
|
74
|
+
|
|
75
|
+
def stop_scan(self: _AsyncScanner) -> None:
|
|
76
|
+
self._stop_signal.set()
|
|
77
|
+
self._child_process.join()
|
|
78
|
+
self._child_process.close()
|
|
79
|
+
self.is_scanning = False
|
|
80
|
+
|
|
81
|
+
def get_scans(self: _AsyncScanner, n_pulses: int) -> list[UnprocessedWaveform]:
|
|
82
|
+
call_time = datetime.now() # noqa: DTZ005
|
|
83
|
+
stamped_pulse = self._get_scan()
|
|
84
|
+
|
|
85
|
+
while stamped_pulse.timestamp < call_time:
|
|
86
|
+
stamped_pulse = self._get_scan()
|
|
87
|
+
|
|
88
|
+
return [self._get_scan().waveform for _ in range(n_pulses)]
|
|
89
|
+
|
|
90
|
+
def get_next(self: _AsyncScanner, averaged_over_n: int = 1) -> UnprocessedWaveform:
|
|
91
|
+
return UnprocessedWaveform.average(
|
|
92
|
+
[self._get_scan().waveform for _ in range(averaged_over_n)]
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
def _get_scan(self: _AsyncScanner) -> _TimestampedWaveform:
|
|
96
|
+
try:
|
|
97
|
+
return self._shared_mem.get(timeout=self._SCAN_TIMEOUT)
|
|
98
|
+
except Empty as err:
|
|
99
|
+
if self._scanner_conn.poll(timeout=self.startup_timeout):
|
|
100
|
+
msg: _ScannerHealth = self._scanner_conn.recv()
|
|
101
|
+
if not msg.is_alive:
|
|
102
|
+
self.is_scanning = False
|
|
103
|
+
if msg.error:
|
|
104
|
+
raise msg.error from err
|
|
105
|
+
raise
|
|
106
|
+
|
|
107
|
+
@staticmethod
|
|
108
|
+
def _run_scanner(
|
|
109
|
+
config: DeviceConfiguration,
|
|
110
|
+
shared_mem: Queue[_TimestampedWaveform],
|
|
111
|
+
stop_signal: synchronize.Event,
|
|
112
|
+
parent_conn: Connection,
|
|
113
|
+
) -> None:
|
|
114
|
+
try:
|
|
115
|
+
scanner = Scanner(config=config)
|
|
116
|
+
parent_conn.send(_ScannerHealth(is_alive=True, is_healthy=True, error=None))
|
|
117
|
+
except (serialutil.SerialException, TimeoutError) as e:
|
|
118
|
+
parent_conn.send(_ScannerHealth(is_alive=False, is_healthy=False, error=e))
|
|
119
|
+
return
|
|
120
|
+
|
|
121
|
+
while not stop_signal.is_set():
|
|
122
|
+
try:
|
|
123
|
+
waveform = _TimestampedWaveform(datetime.now(), scanner.scan()) # noqa: DTZ005
|
|
124
|
+
except (serialutil.SerialException, TimeoutError) as e:
|
|
125
|
+
parent_conn.send(
|
|
126
|
+
_ScannerHealth(is_alive=False, is_healthy=False, error=e)
|
|
127
|
+
)
|
|
128
|
+
break
|
|
129
|
+
|
|
130
|
+
try:
|
|
131
|
+
shared_mem.put_nowait(waveform)
|
|
132
|
+
except Full:
|
|
133
|
+
# when full, remove the oldest scan from the list before putting a new
|
|
134
|
+
shared_mem.get_nowait()
|
|
135
|
+
shared_mem.put_nowait(waveform)
|
|
136
|
+
|
|
137
|
+
# Empty queue before shutting down
|
|
138
|
+
try:
|
|
139
|
+
while 1:
|
|
140
|
+
shared_mem.get_nowait()
|
|
141
|
+
except Empty:
|
|
142
|
+
# this call required - see https://docs.python.org/3.9/library/multiprocessing.html#programming-guidelines
|
|
143
|
+
shared_mem.cancel_join_thread()
|
|
144
|
+
parent_conn.close()
|
|
145
|
+
shared_mem.cancel_join_thread()
|
|
146
|
+
parent_conn.close()
|