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.
@@ -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
@@ -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,3 @@
1
+ from .interpolation import ws_interpolate
2
+
3
+ __all__ = ["ws_interpolate"]
@@ -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,4 @@
1
+ from .client import GlazeClient
2
+ from .scanner import Scanner
3
+
4
+ __all__ = ["GlazeClient", "Scanner"]
@@ -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()