pyglaze 0.3.0__tar.gz → 0.4.1__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.
- {pyglaze-0.3.0/src/pyglaze.egg-info → pyglaze-0.4.1}/PKG-INFO +5 -4
- {pyglaze-0.3.0 → pyglaze-0.4.1}/pyproject.toml +4 -4
- pyglaze-0.4.1/src/pyglaze/__init__.py +1 -0
- {pyglaze-0.3.0 → pyglaze-0.4.1}/src/pyglaze/datamodels/pulse.py +35 -13
- pyglaze-0.4.1/src/pyglaze/device/__init__.py +6 -0
- {pyglaze-0.3.0 → pyglaze-0.4.1}/src/pyglaze/device/ampcom.py +24 -198
- {pyglaze-0.3.0 → pyglaze-0.4.1}/src/pyglaze/device/configuration.py +7 -99
- {pyglaze-0.3.0 → pyglaze-0.4.1}/src/pyglaze/devtools/mock_device.py +15 -150
- {pyglaze-0.3.0 → pyglaze-0.4.1}/src/pyglaze/devtools/thz_pulse.py +4 -3
- {pyglaze-0.3.0 → pyglaze-0.4.1}/src/pyglaze/helpers/utilities.py +4 -4
- {pyglaze-0.3.0 → pyglaze-0.4.1}/src/pyglaze/interpolation/interpolation.py +2 -2
- {pyglaze-0.3.0 → pyglaze-0.4.1}/src/pyglaze/scanning/_asyncscanner.py +30 -1
- {pyglaze-0.3.0 → pyglaze-0.4.1}/src/pyglaze/scanning/client.py +17 -1
- {pyglaze-0.3.0 → pyglaze-0.4.1}/src/pyglaze/scanning/scanner.py +40 -88
- {pyglaze-0.3.0 → pyglaze-0.4.1/src/pyglaze.egg-info}/PKG-INFO +5 -4
- {pyglaze-0.3.0 → pyglaze-0.4.1}/src/pyglaze.egg-info/requires.txt +1 -1
- pyglaze-0.3.0/src/pyglaze/__init__.py +0 -1
- pyglaze-0.3.0/src/pyglaze/device/__init__.py +0 -7
- {pyglaze-0.3.0 → pyglaze-0.4.1}/LICENSE +0 -0
- {pyglaze-0.3.0 → pyglaze-0.4.1}/MANIFEST.in +0 -0
- {pyglaze-0.3.0 → pyglaze-0.4.1}/README.md +0 -0
- {pyglaze-0.3.0 → pyglaze-0.4.1}/setup.cfg +0 -0
- {pyglaze-0.3.0 → pyglaze-0.4.1}/src/pyglaze/datamodels/__init__.py +0 -0
- {pyglaze-0.3.0 → pyglaze-0.4.1}/src/pyglaze/datamodels/waveform.py +0 -0
- {pyglaze-0.3.0 → pyglaze-0.4.1}/src/pyglaze/devtools/__init__.py +0 -0
- {pyglaze-0.3.0 → pyglaze-0.4.1}/src/pyglaze/helpers/__init__.py +0 -0
- {pyglaze-0.3.0 → pyglaze-0.4.1}/src/pyglaze/helpers/_types.py +0 -0
- {pyglaze-0.3.0 → pyglaze-0.4.1}/src/pyglaze/interpolation/__init__.py +0 -0
- {pyglaze-0.3.0 → pyglaze-0.4.1}/src/pyglaze/py.typed +0 -0
- {pyglaze-0.3.0 → pyglaze-0.4.1}/src/pyglaze/scanning/__init__.py +0 -0
- {pyglaze-0.3.0 → pyglaze-0.4.1}/src/pyglaze/scanning/_exceptions.py +0 -0
- {pyglaze-0.3.0 → pyglaze-0.4.1}/src/pyglaze.egg-info/SOURCES.txt +0 -0
- {pyglaze-0.3.0 → pyglaze-0.4.1}/src/pyglaze.egg-info/dependency_links.txt +0 -0
- {pyglaze-0.3.0 → pyglaze-0.4.1}/src/pyglaze.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: pyglaze
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.1
|
|
4
4
|
Summary: Pyglaze is a library used to operate the devices of Glaze Technologies
|
|
5
5
|
Author: GLAZE Technologies ApS
|
|
6
6
|
License: BSD 3-Clause License
|
|
@@ -35,14 +35,15 @@ Project-URL: Homepage, https://www.glazetech.dk/
|
|
|
35
35
|
Project-URL: Documentation, https://glazetech.github.io/pyglaze/latest
|
|
36
36
|
Project-URL: Repository, https://github.com/GlazeTech/pyglaze
|
|
37
37
|
Project-URL: Issues, https://github.com/GlazeTech/pyglaze/issues
|
|
38
|
-
Requires-Python: <3.
|
|
38
|
+
Requires-Python: <3.14,>=3.9
|
|
39
39
|
Description-Content-Type: text/markdown
|
|
40
40
|
License-File: LICENSE
|
|
41
|
-
Requires-Dist: numpy
|
|
41
|
+
Requires-Dist: numpy>=1.26.4
|
|
42
42
|
Requires-Dist: pyserial>=3.5
|
|
43
43
|
Requires-Dist: scipy>=1.7.3
|
|
44
44
|
Requires-Dist: bitstring>=4.1.2
|
|
45
45
|
Requires-Dist: typing_extensions>=4.12.2
|
|
46
|
+
Dynamic: license-file
|
|
46
47
|
|
|
47
48
|
# Pyglaze
|
|
48
49
|
Pyglaze is a python library used to operate the devices of [Glaze Technologies](https://www.glazetech.dk/).
|
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "pyglaze"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.4.1"
|
|
4
4
|
description = "Pyglaze is a library used to operate the devices of Glaze Technologies"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = { file = "LICENSE" }
|
|
7
7
|
authors = [
|
|
8
8
|
{name = "GLAZE Technologies ApS"},
|
|
9
9
|
]
|
|
10
|
-
requires-python = ">=3.9,<3.
|
|
10
|
+
requires-python = ">=3.9,<3.14"
|
|
11
11
|
|
|
12
12
|
dependencies = [
|
|
13
|
-
"numpy>=1.26.4
|
|
13
|
+
"numpy>=1.26.4",
|
|
14
14
|
"pyserial>=3.5",
|
|
15
15
|
"scipy>=1.7.3",
|
|
16
16
|
"bitstring>=4.1.2",
|
|
@@ -74,7 +74,7 @@ convention = "google"
|
|
|
74
74
|
]
|
|
75
75
|
|
|
76
76
|
[tool.bumpver]
|
|
77
|
-
current_version = "0.
|
|
77
|
+
current_version = "0.4.1"
|
|
78
78
|
version_pattern = "MAJOR.MINOR.PATCH[-TAG]"
|
|
79
79
|
commit_message = "BUMP VERSION {old_version} -> {new_version}"
|
|
80
80
|
tag_message = "v{new_version}"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.4.1"
|
|
@@ -1,20 +1,22 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
from dataclasses import dataclass
|
|
4
|
-
from typing import Callable, Literal, cast
|
|
4
|
+
from typing import TYPE_CHECKING, Callable, Literal, cast
|
|
5
5
|
|
|
6
6
|
import numpy as np
|
|
7
7
|
from scipy import optimize as opt
|
|
8
8
|
from scipy import signal
|
|
9
9
|
from scipy.stats import linregress
|
|
10
10
|
|
|
11
|
-
from pyglaze.helpers._types import ComplexArray, FloatArray
|
|
12
11
|
from pyglaze.interpolation import ws_interpolate
|
|
13
12
|
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from pyglaze.helpers._types import ComplexArray, FloatArray
|
|
15
|
+
|
|
14
16
|
__all__ = ["Pulse"]
|
|
15
17
|
|
|
16
18
|
|
|
17
|
-
@dataclass
|
|
19
|
+
@dataclass(frozen=True)
|
|
18
20
|
class Pulse:
|
|
19
21
|
"""Data class for a THz pulse. The pulse is expected to be preprocessed such that times are uniformly spaced.
|
|
20
22
|
|
|
@@ -39,6 +41,24 @@ class Pulse:
|
|
|
39
41
|
and np.array_equal(self.signal, obj.signal)
|
|
40
42
|
)
|
|
41
43
|
|
|
44
|
+
def __hash__(self: Pulse) -> int:
|
|
45
|
+
"""Return a hash based on the contents of ``time`` and ``signal``.
|
|
46
|
+
|
|
47
|
+
The hash combines shape, dtype and raw bytes of both arrays, ensuring that
|
|
48
|
+
two :class:`Pulse` instances that compare equal also have identical hashes.
|
|
49
|
+
|
|
50
|
+
"""
|
|
51
|
+
return hash(
|
|
52
|
+
(
|
|
53
|
+
self.time.shape,
|
|
54
|
+
self.time.dtype.str,
|
|
55
|
+
self.time.tobytes(),
|
|
56
|
+
self.signal.shape,
|
|
57
|
+
self.signal.dtype.str,
|
|
58
|
+
self.signal.tobytes(),
|
|
59
|
+
)
|
|
60
|
+
)
|
|
61
|
+
|
|
42
62
|
@property
|
|
43
63
|
def fft(self: Pulse) -> ComplexArray:
|
|
44
64
|
"""Return the Fourier Transform of a signal."""
|
|
@@ -95,7 +115,7 @@ class Pulse:
|
|
|
95
115
|
|
|
96
116
|
Note that the energy is not the same as the physical energy of the pulse, but rather the integral of the square of the pulse.
|
|
97
117
|
"""
|
|
98
|
-
return cast(float, np.
|
|
118
|
+
return cast("float", np.trapezoid(self.signal * self.signal, x=self.time)) # type: ignore[attr-defined, unused-ignore]
|
|
99
119
|
|
|
100
120
|
@classmethod
|
|
101
121
|
def from_dict(
|
|
@@ -161,10 +181,12 @@ class Pulse:
|
|
|
161
181
|
]
|
|
162
182
|
|
|
163
183
|
if translate_to_zero:
|
|
164
|
-
|
|
165
|
-
|
|
184
|
+
roughly_aligned = [
|
|
185
|
+
s.timeshift(scale=1.0, offset=-s.time[0]) for s in roughly_aligned
|
|
186
|
+
]
|
|
187
|
+
|
|
166
188
|
zerocrossings = [p.estimate_zero_crossing() for p in roughly_aligned]
|
|
167
|
-
mean_zerocrossing = cast(float, np.mean(zerocrossings))
|
|
189
|
+
mean_zerocrossing = cast("float", np.mean(zerocrossings))
|
|
168
190
|
|
|
169
191
|
return [
|
|
170
192
|
p.propagate(mean_zerocrossing - zc)
|
|
@@ -195,7 +217,7 @@ class Pulse:
|
|
|
195
217
|
Returns:
|
|
196
218
|
complex: Fourier Transform at the given frequency
|
|
197
219
|
"""
|
|
198
|
-
return cast(complex, self.fft[np.searchsorted(self.frequency, f)])
|
|
220
|
+
return cast("complex", self.fft[np.searchsorted(self.frequency, f)])
|
|
199
221
|
|
|
200
222
|
def timeshift(self: Pulse, scale: float, offset: float = 0) -> Pulse:
|
|
201
223
|
"""Rescales and offsets the time axis as.
|
|
@@ -255,7 +277,7 @@ class Pulse:
|
|
|
255
277
|
Returns:
|
|
256
278
|
Signal at the given time
|
|
257
279
|
"""
|
|
258
|
-
return cast(float, ws_interpolate(self.time, self.signal, np.array([t]))[0])
|
|
280
|
+
return cast("float", ws_interpolate(self.time, self.signal, np.array([t]))[0])
|
|
259
281
|
|
|
260
282
|
def subtract_mean(self: Pulse, fraction: float = 0.99) -> Pulse:
|
|
261
283
|
"""Subtracts the mean of the pulse.
|
|
@@ -427,7 +449,7 @@ class Pulse:
|
|
|
427
449
|
|
|
428
450
|
# Combine signal before spectrum maximum with interpolated values
|
|
429
451
|
y_values = cast(
|
|
430
|
-
FloatArray,
|
|
452
|
+
"FloatArray",
|
|
431
453
|
np.concatenate(
|
|
432
454
|
[
|
|
433
455
|
self.spectrum_dB()[:_from],
|
|
@@ -487,7 +509,7 @@ class Pulse:
|
|
|
487
509
|
),
|
|
488
510
|
)
|
|
489
511
|
|
|
490
|
-
return cast(float, np.max(max_estimate) - np.min(min_estimate))
|
|
512
|
+
return cast("float", np.max(max_estimate) - np.min(min_estimate))
|
|
491
513
|
|
|
492
514
|
def estimate_zero_crossing(self: Pulse) -> float:
|
|
493
515
|
"""Estimates the zero crossing of the pulse between the maximum and minimum value.
|
|
@@ -505,7 +527,7 @@ class Pulse:
|
|
|
505
527
|
# To find the zero crossing, solve 0 = s1 + a * (t - t1) for t: t = t1 - s1 / a
|
|
506
528
|
t1, s1 = self.time[idx], self.signal[idx]
|
|
507
529
|
a = (self.signal[idx + 1] - self.signal[idx]) / self.dt
|
|
508
|
-
return cast(float, t1 - s1 / a)
|
|
530
|
+
return cast("float", t1 - s1 / a)
|
|
509
531
|
|
|
510
532
|
def propagate(self: Pulse, time: float) -> Pulse:
|
|
511
533
|
"""Propagates the pulse in time by a given amount.
|
|
@@ -579,7 +601,7 @@ def _estimate_bw_idx(x: FloatArray, y: FloatArray, segments: int) -> int:
|
|
|
579
601
|
fun=model, x0=[x[len(x) // 2]], bounds=[(x[0], x[-1])], method="Nelder-Mead"
|
|
580
602
|
).x[0]
|
|
581
603
|
|
|
582
|
-
return cast(int, x.searchsorted(BW_estimate))
|
|
604
|
+
return cast("int", x.searchsorted(BW_estimate))
|
|
583
605
|
|
|
584
606
|
|
|
585
607
|
def _fit_linear_segments(x: FloatArray, y: FloatArray, n_segments: int) -> FloatArray:
|
|
@@ -7,7 +7,7 @@ from dataclasses import dataclass, field
|
|
|
7
7
|
from enum import Enum
|
|
8
8
|
from functools import cached_property
|
|
9
9
|
from math import modf
|
|
10
|
-
from typing import TYPE_CHECKING, Callable, ClassVar
|
|
10
|
+
from typing import TYPE_CHECKING, Callable, ClassVar
|
|
11
11
|
|
|
12
12
|
import numpy as np
|
|
13
13
|
import serial
|
|
@@ -16,7 +16,6 @@ from serial import serialutil
|
|
|
16
16
|
|
|
17
17
|
from pyglaze.device.configuration import (
|
|
18
18
|
DeviceConfiguration,
|
|
19
|
-
ForceDeviceConfiguration,
|
|
20
19
|
Interval,
|
|
21
20
|
LeDeviceConfiguration,
|
|
22
21
|
)
|
|
@@ -24,11 +23,7 @@ from pyglaze.devtools.mock_device import _mock_device_factory
|
|
|
24
23
|
from pyglaze.helpers.utilities import LOGGER_NAME, _BackoffRetry
|
|
25
24
|
|
|
26
25
|
if TYPE_CHECKING:
|
|
27
|
-
from pyglaze.devtools.mock_device import
|
|
28
|
-
ForceMockDevice,
|
|
29
|
-
LeMockDevice,
|
|
30
|
-
MockDevice,
|
|
31
|
-
)
|
|
26
|
+
from pyglaze.devtools.mock_device import LeMockDevice
|
|
32
27
|
from pyglaze.helpers._types import FloatArray
|
|
33
28
|
|
|
34
29
|
|
|
@@ -39,186 +34,6 @@ class DeviceComError(Exception):
|
|
|
39
34
|
super().__init__(message)
|
|
40
35
|
|
|
41
36
|
|
|
42
|
-
@dataclass
|
|
43
|
-
class _ForceAmpCom:
|
|
44
|
-
config: ForceDeviceConfiguration
|
|
45
|
-
CONT_SCAN_UPDATE_FREQ: float = 1 # seconds
|
|
46
|
-
__ser: ForceMockDevice | serial.Serial = field(init=False)
|
|
47
|
-
|
|
48
|
-
ENCODING: ClassVar[str] = "utf-8"
|
|
49
|
-
OK_RESPONSE: ClassVar[str] = "!A,OK"
|
|
50
|
-
N_POINTS: ClassVar[int] = 10000
|
|
51
|
-
DAC_BITWIDTH: ClassVar[int] = 65535 # bit-width of amp DAC
|
|
52
|
-
# DO NOT change - antennas will break.
|
|
53
|
-
MIN_ALLOWED_MOD_VOLTAGE: ClassVar[float] = -1.0
|
|
54
|
-
MAX_ALLOWED_MOD_VOLTAGE: ClassVar[float] = 0.5
|
|
55
|
-
|
|
56
|
-
@cached_property
|
|
57
|
-
def scanning_points(self: _ForceAmpCom) -> int:
|
|
58
|
-
time_pr_point = (
|
|
59
|
-
self.config.integration_periods / self.config.modulation_frequency
|
|
60
|
-
)
|
|
61
|
-
return int(self.config.sweep_length_ms * 1e-3 / time_pr_point)
|
|
62
|
-
|
|
63
|
-
@cached_property
|
|
64
|
-
def _squished_intervals(self: _ForceAmpCom) -> list[Interval]:
|
|
65
|
-
"""Intervals squished into effective DAC range."""
|
|
66
|
-
return _squish_intervals(
|
|
67
|
-
intervals=self.config.scan_intervals or [Interval(lower=0.0, upper=1.0)],
|
|
68
|
-
lower_bound=self.config.dac_lower_bound,
|
|
69
|
-
upper_bound=self.config.dac_upper_bound,
|
|
70
|
-
bitwidth=self.DAC_BITWIDTH,
|
|
71
|
-
)
|
|
72
|
-
|
|
73
|
-
@cached_property
|
|
74
|
-
def times(self: _ForceAmpCom) -> FloatArray:
|
|
75
|
-
return _delay_from_intervals(
|
|
76
|
-
delayunit=lambda x: x,
|
|
77
|
-
intervals=self.config.scan_intervals,
|
|
78
|
-
points_per_interval=_points_per_interval(
|
|
79
|
-
self.scanning_points, self._squished_intervals
|
|
80
|
-
),
|
|
81
|
-
)
|
|
82
|
-
|
|
83
|
-
@cached_property
|
|
84
|
-
def scanning_list(self: _ForceAmpCom) -> list[float]:
|
|
85
|
-
scanning_list: list[float] = []
|
|
86
|
-
for interval, n_points in zip(
|
|
87
|
-
self._squished_intervals,
|
|
88
|
-
_points_per_interval(self.N_POINTS, self._squished_intervals),
|
|
89
|
-
):
|
|
90
|
-
scanning_list.extend(
|
|
91
|
-
np.linspace(interval.lower, interval.upper, n_points, endpoint=False)
|
|
92
|
-
)
|
|
93
|
-
|
|
94
|
-
return scanning_list
|
|
95
|
-
|
|
96
|
-
@property
|
|
97
|
-
def datapoints_per_update(self: _ForceAmpCom) -> int:
|
|
98
|
-
return int(
|
|
99
|
-
self.CONT_SCAN_UPDATE_FREQ
|
|
100
|
-
/ (self.config.integration_periods / self.config.modulation_frequency)
|
|
101
|
-
)
|
|
102
|
-
|
|
103
|
-
def __post_init__(self: _ForceAmpCom) -> None:
|
|
104
|
-
self.__ser = _serial_factory(self.config)
|
|
105
|
-
|
|
106
|
-
def __del__(self: _ForceAmpCom) -> None:
|
|
107
|
-
"""Closes connection when class instance goes out of scope."""
|
|
108
|
-
self.disconnect()
|
|
109
|
-
|
|
110
|
-
def write_all(self: _ForceAmpCom) -> list[str]:
|
|
111
|
-
responses = []
|
|
112
|
-
responses.append(self.write_period_and_frequency())
|
|
113
|
-
responses.append(self.write_sweep_length())
|
|
114
|
-
responses.append(self.write_waveform())
|
|
115
|
-
responses.append(self.write_modulation_voltage())
|
|
116
|
-
responses.extend(self.write_list())
|
|
117
|
-
return responses
|
|
118
|
-
|
|
119
|
-
def write_period_and_frequency(self: _ForceAmpCom) -> str:
|
|
120
|
-
s = f"!set timing,{self.config.integration_periods},{self.config.modulation_frequency}\r"
|
|
121
|
-
return self._encode_send_response(s)
|
|
122
|
-
|
|
123
|
-
def write_sweep_length(self: _ForceAmpCom) -> str:
|
|
124
|
-
s = f"!set sweep length,{self.config.sweep_length_ms}\r"
|
|
125
|
-
return self._encode_send_response(s)
|
|
126
|
-
|
|
127
|
-
def write_waveform(self: _ForceAmpCom) -> str:
|
|
128
|
-
s = f"!set wave,{self.config.modulation_waveform}\r"
|
|
129
|
-
return self._encode_send_response(s)
|
|
130
|
-
|
|
131
|
-
def write_modulation_voltage(self: _ForceAmpCom) -> str:
|
|
132
|
-
min_v = self.config.min_modulation_voltage
|
|
133
|
-
max_v = self.config.max_modulation_voltage
|
|
134
|
-
crit1 = self.MIN_ALLOWED_MOD_VOLTAGE <= min_v <= self.MAX_ALLOWED_MOD_VOLTAGE
|
|
135
|
-
crit2 = self.MIN_ALLOWED_MOD_VOLTAGE <= max_v <= self.MAX_ALLOWED_MOD_VOLTAGE
|
|
136
|
-
|
|
137
|
-
if crit1 and crit2:
|
|
138
|
-
s = f"!set generator,{min_v},{max_v}\r"
|
|
139
|
-
return self._encode_send_response(s)
|
|
140
|
-
|
|
141
|
-
msg = f"Modulation voltages min: {min_v:.1f}, max: {max_v:.1f} not allowed."
|
|
142
|
-
raise ValueError(msg)
|
|
143
|
-
|
|
144
|
-
def write_list(self: _ForceAmpCom) -> list[str]:
|
|
145
|
-
for iteration, entry in enumerate(self.scanning_list):
|
|
146
|
-
string = f"!lut,{iteration},{entry}\r"
|
|
147
|
-
self._encode_and_send(string)
|
|
148
|
-
return self._get_response().split("\r")
|
|
149
|
-
|
|
150
|
-
def start_scan(self: _ForceAmpCom) -> tuple[str, np.ndarray]:
|
|
151
|
-
start_command = "!s,\r"
|
|
152
|
-
self._encode_and_send(start_command)
|
|
153
|
-
responses = self._get_response().split("\r")
|
|
154
|
-
output_array = np.zeros((self.scanning_points, 3))
|
|
155
|
-
output_array[:, 0] = self.times
|
|
156
|
-
iteration = 0
|
|
157
|
-
for entry in responses:
|
|
158
|
-
if "!R" in entry:
|
|
159
|
-
radius, angle = self._format_output(entry)
|
|
160
|
-
output_array[iteration, 1] = radius
|
|
161
|
-
output_array[iteration, 2] = angle
|
|
162
|
-
iteration += 1
|
|
163
|
-
elif "!D" in entry:
|
|
164
|
-
break
|
|
165
|
-
return start_command, output_array
|
|
166
|
-
|
|
167
|
-
def start_continuous_scan(self: _ForceAmpCom) -> tuple[str, list[str]]:
|
|
168
|
-
start_command = "!dat,1\r"
|
|
169
|
-
self._encode_and_send(start_command)
|
|
170
|
-
# Call self._read_until() twice, because amp returns !A,OK twice for
|
|
171
|
-
# continuous output (for some unknown reason)
|
|
172
|
-
responses = [self._read_until(expected=b"\r") for _ in range(2)]
|
|
173
|
-
return start_command, responses
|
|
174
|
-
|
|
175
|
-
def stop_continuous_scan(self: _ForceAmpCom) -> tuple[str, str]:
|
|
176
|
-
start_command = "!dat,0\r"
|
|
177
|
-
self._encode_and_send(start_command)
|
|
178
|
-
response = self._read_until(expected=b"!A,OK\r")
|
|
179
|
-
return start_command, response
|
|
180
|
-
|
|
181
|
-
def read_continuous_data(self: _ForceAmpCom) -> FloatArray:
|
|
182
|
-
output_array = np.zeros((self.datapoints_per_update, 3))
|
|
183
|
-
output_array[:, 0] = np.linspace(0, 1, self.datapoints_per_update)
|
|
184
|
-
for iteration in range(self.datapoints_per_update):
|
|
185
|
-
amp_output = self._read_until(expected=b"\r")
|
|
186
|
-
radius, angle = self._format_output(amp_output)
|
|
187
|
-
output_array[iteration, 1] = radius
|
|
188
|
-
output_array[iteration, 2] = angle
|
|
189
|
-
return output_array
|
|
190
|
-
|
|
191
|
-
def disconnect(self: _ForceAmpCom) -> None:
|
|
192
|
-
"""Closes connection."""
|
|
193
|
-
with contextlib.suppress(AttributeError):
|
|
194
|
-
# If the serial device does not exist, self.__ser is never created - hence catch
|
|
195
|
-
self.__ser.close()
|
|
196
|
-
|
|
197
|
-
def _encode_send_response(self: _ForceAmpCom, command: str) -> str:
|
|
198
|
-
self._encode_and_send(command)
|
|
199
|
-
return self._get_response()
|
|
200
|
-
|
|
201
|
-
def _encode_and_send(self: _ForceAmpCom, command: str) -> None:
|
|
202
|
-
self.__ser.write(command.encode(self.ENCODING))
|
|
203
|
-
|
|
204
|
-
@_BackoffRetry(backoff_base=0.2, logger=logging.getLogger(LOGGER_NAME))
|
|
205
|
-
def _get_response(self: _ForceAmpCom) -> str:
|
|
206
|
-
r = self.__ser.readline().decode(self.ENCODING).strip()
|
|
207
|
-
if r[: len(self.OK_RESPONSE)] != self.OK_RESPONSE:
|
|
208
|
-
msg = f"Expected response '{self.OK_RESPONSE}', received: '{r}'"
|
|
209
|
-
raise serialutil.SerialException(msg)
|
|
210
|
-
|
|
211
|
-
return r
|
|
212
|
-
|
|
213
|
-
def _read_until(self: _ForceAmpCom, expected: bytes) -> str:
|
|
214
|
-
return self.__ser.read_until(expected=expected).decode(self.ENCODING).strip()
|
|
215
|
-
|
|
216
|
-
def _format_output(self: _ForceAmpCom, amp_output: str) -> tuple[float, float]:
|
|
217
|
-
"""Format output from Force LIA to radius and angle."""
|
|
218
|
-
response_list = amp_output.split(",")
|
|
219
|
-
return float(response_list[1]), float(response_list[2])
|
|
220
|
-
|
|
221
|
-
|
|
222
37
|
@dataclass
|
|
223
38
|
class _LeAmpCom:
|
|
224
39
|
config: LeDeviceConfiguration
|
|
@@ -233,6 +48,8 @@ class _LeAmpCom:
|
|
|
233
48
|
STATUS_COMMAND: ClassVar[str] = "H"
|
|
234
49
|
SEND_LIST_COMMAND: ClassVar[str] = "L"
|
|
235
50
|
SEND_SETTINGS_COMMAND: ClassVar[str] = "S"
|
|
51
|
+
SERIAL_NUMBER_COMMAND: ClassVar[str] = "s"
|
|
52
|
+
FIRMWARE_VERSION_COMMAND: ClassVar[str] = "v"
|
|
236
53
|
|
|
237
54
|
@cached_property
|
|
238
55
|
def scanning_points(self: _LeAmpCom) -> int:
|
|
@@ -263,6 +80,14 @@ class _LeAmpCom:
|
|
|
263
80
|
"""
|
|
264
81
|
return self.scanning_points * 12
|
|
265
82
|
|
|
83
|
+
@property
|
|
84
|
+
def serial_number_bytes(self: _LeAmpCom) -> int:
|
|
85
|
+
"""Number of bytes to receive for a serial number.
|
|
86
|
+
|
|
87
|
+
Serial number has the form "<CHARACTER>-<4_DIGITS>, hence expect 6 bytes."
|
|
88
|
+
"""
|
|
89
|
+
return 6
|
|
90
|
+
|
|
266
91
|
def __post_init__(self: _LeAmpCom) -> None:
|
|
267
92
|
self.__ser = _serial_factory(self.config)
|
|
268
93
|
|
|
@@ -302,6 +127,17 @@ class _LeAmpCom:
|
|
|
302
127
|
# If the serial device does not exist, self.__ser is never created - hence catch
|
|
303
128
|
self.__ser.close()
|
|
304
129
|
|
|
130
|
+
def get_serial_number(self: _LeAmpCom) -> str:
|
|
131
|
+
"""Get the serial number of the connected device."""
|
|
132
|
+
return "X-9999"
|
|
133
|
+
# self._encode_and_send(self.SERIAL_NUMBER_COMMAND) # noqa: ERA001
|
|
134
|
+
# return self.__ser.read(self.serial_number_bytes).decode(self.ENCODING) # noqa: ERA001
|
|
135
|
+
|
|
136
|
+
def get_firmware_version(self: _LeAmpCom) -> str:
|
|
137
|
+
"""Get the firmware version of the connected device."""
|
|
138
|
+
self._encode_and_send(self.FIRMWARE_VERSION_COMMAND)
|
|
139
|
+
return self.__ser.read_until().decode(self.ENCODING).strip()
|
|
140
|
+
|
|
305
141
|
@cached_property
|
|
306
142
|
def _intervals(self: _LeAmpCom) -> list[Interval]:
|
|
307
143
|
"""Intervals squished into effective DAC range."""
|
|
@@ -398,17 +234,7 @@ class _LeStatus(Enum):
|
|
|
398
234
|
IDLE = "ACK: Idle."
|
|
399
235
|
|
|
400
236
|
|
|
401
|
-
|
|
402
|
-
def _serial_factory(
|
|
403
|
-
config: ForceDeviceConfiguration,
|
|
404
|
-
) -> serial.Serial | ForceMockDevice: ...
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
@overload
|
|
408
|
-
def _serial_factory(config: LeDeviceConfiguration) -> serial.Serial | LeMockDevice: ...
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
def _serial_factory(config: DeviceConfiguration) -> serial.Serial | MockDevice:
|
|
237
|
+
def _serial_factory(config: DeviceConfiguration) -> serial.Serial | LeMockDevice:
|
|
412
238
|
if "mock_device" in config.amp_port:
|
|
413
239
|
return _mock_device_factory(config)
|
|
414
240
|
|
|
@@ -74,91 +74,6 @@ class DeviceConfiguration(ABC):
|
|
|
74
74
|
"""Load a DeviceConfiguration from a file."""
|
|
75
75
|
|
|
76
76
|
|
|
77
|
-
@dataclass
|
|
78
|
-
class ForceDeviceConfiguration(DeviceConfiguration):
|
|
79
|
-
"""Represents a configuration that can be sent to the lock-in amp for scans.
|
|
80
|
-
|
|
81
|
-
Args:
|
|
82
|
-
amp_port: The name of the serial port the amp is connected to.
|
|
83
|
-
sweep_length_ms: The length of the sweep in milliseconds.
|
|
84
|
-
scan_intervals: The intervals to scan.
|
|
85
|
-
integration_periods: The number of integration periods to use.
|
|
86
|
-
modulation_frequency: The frequency of the modulation in Hz.
|
|
87
|
-
dac_lower_bound: The lower bound of the modulation voltage in bits.
|
|
88
|
-
dac_upper_bound: The upper bound of the modulation voltage in bits.
|
|
89
|
-
min_modulation_voltage: The minimum modulation voltage in volts.
|
|
90
|
-
max_modulation_voltage: The maximum modulation voltage in volts.
|
|
91
|
-
modulation_waveform: The waveform to use for modulation.
|
|
92
|
-
amp_timeout_seconds: The timeout for the amp in seconds.
|
|
93
|
-
"""
|
|
94
|
-
|
|
95
|
-
amp_port: str
|
|
96
|
-
sweep_length_ms: float
|
|
97
|
-
scan_intervals: list[Interval] = field(default_factory=lambda: [Interval(0.0, 1.0)])
|
|
98
|
-
integration_periods: int = 100
|
|
99
|
-
modulation_frequency: int = 10000 # Hz
|
|
100
|
-
dac_lower_bound: int = 6400
|
|
101
|
-
dac_upper_bound: int = 59300
|
|
102
|
-
min_modulation_voltage: float = -1.0 # V
|
|
103
|
-
max_modulation_voltage: float = 0.5 # V
|
|
104
|
-
modulation_waveform: str = "square"
|
|
105
|
-
amp_timeout_seconds: float = 0.05
|
|
106
|
-
|
|
107
|
-
amp_baudrate: ClassVar[int] = 1200000 # bit/s
|
|
108
|
-
|
|
109
|
-
@property
|
|
110
|
-
def _sweep_length_ms(self: ForceDeviceConfiguration) -> float:
|
|
111
|
-
return self.sweep_length_ms
|
|
112
|
-
|
|
113
|
-
def save(self: ForceDeviceConfiguration, path: Path) -> str:
|
|
114
|
-
"""Save a DeviceConfiguration to a file.
|
|
115
|
-
|
|
116
|
-
Args:
|
|
117
|
-
path: The path to save the configuration to.
|
|
118
|
-
|
|
119
|
-
Returns:
|
|
120
|
-
str: Final path component of the saved file, without the extension.
|
|
121
|
-
|
|
122
|
-
"""
|
|
123
|
-
with path.open("w") as f:
|
|
124
|
-
json.dump(asdict(self), f, indent=4, sort_keys=True)
|
|
125
|
-
|
|
126
|
-
return path.stem
|
|
127
|
-
|
|
128
|
-
@classmethod
|
|
129
|
-
def from_dict(
|
|
130
|
-
cls: type[ForceDeviceConfiguration], amp_config: dict
|
|
131
|
-
) -> ForceDeviceConfiguration:
|
|
132
|
-
"""Create a DeviceConfiguration from a dict.
|
|
133
|
-
|
|
134
|
-
Args:
|
|
135
|
-
amp_config: An amp configuration in dict form.
|
|
136
|
-
|
|
137
|
-
Raises:
|
|
138
|
-
ValueError: If the dictionary is empty.
|
|
139
|
-
|
|
140
|
-
Returns:
|
|
141
|
-
DeviceConfiguration: A DeviceConfiguration object.
|
|
142
|
-
"""
|
|
143
|
-
return _config_w_intervals_from_dict(cls, amp_config)
|
|
144
|
-
|
|
145
|
-
@classmethod
|
|
146
|
-
def load(
|
|
147
|
-
cls: type[ForceDeviceConfiguration], file_path: Path
|
|
148
|
-
) -> ForceDeviceConfiguration:
|
|
149
|
-
"""Load a DeviceConfiguration from a file.
|
|
150
|
-
|
|
151
|
-
Args:
|
|
152
|
-
file_path: The path to the file to load.
|
|
153
|
-
|
|
154
|
-
Returns:
|
|
155
|
-
DeviceConfiguration: A DeviceConfiguration object.
|
|
156
|
-
"""
|
|
157
|
-
with file_path.open() as f:
|
|
158
|
-
configuration_dict = json.load(f)
|
|
159
|
-
return cls.from_dict(configuration_dict)
|
|
160
|
-
|
|
161
|
-
|
|
162
77
|
@dataclass
|
|
163
78
|
class LeDeviceConfiguration(DeviceConfiguration):
|
|
164
79
|
"""Represents a configuration that can be sent to a Le-type lock-in amp for scans.
|
|
@@ -220,7 +135,13 @@ class LeDeviceConfiguration(DeviceConfiguration):
|
|
|
220
135
|
Returns:
|
|
221
136
|
DeviceConfiguration: A DeviceConfiguration object.
|
|
222
137
|
"""
|
|
223
|
-
|
|
138
|
+
if not amp_config:
|
|
139
|
+
msg = "'amp_config' is empty."
|
|
140
|
+
raise ValueError(msg)
|
|
141
|
+
|
|
142
|
+
config = cls(**amp_config)
|
|
143
|
+
config.scan_intervals = [Interval.from_dict(d) for d in config.scan_intervals] # type: ignore[arg-type]
|
|
144
|
+
return config
|
|
224
145
|
|
|
225
146
|
@classmethod
|
|
226
147
|
def load(
|
|
@@ -237,16 +158,3 @@ class LeDeviceConfiguration(DeviceConfiguration):
|
|
|
237
158
|
with file_path.open() as f:
|
|
238
159
|
configuration_dict = json.load(f)
|
|
239
160
|
return cls.from_dict(configuration_dict)
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
C = TypeVar("C", LeDeviceConfiguration, ForceDeviceConfiguration)
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
def _config_w_intervals_from_dict(cls: type[C], amp_config: dict) -> C:
|
|
246
|
-
if amp_config:
|
|
247
|
-
config = cls(**amp_config)
|
|
248
|
-
config.scan_intervals = [Interval.from_dict(d) for d in config.scan_intervals] # type: ignore[arg-type]
|
|
249
|
-
return config
|
|
250
|
-
|
|
251
|
-
msg = "'amp_config' is empty."
|
|
252
|
-
raise ValueError(msg)
|
|
@@ -1,21 +1,13 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
import json
|
|
4
3
|
import struct
|
|
5
4
|
import time
|
|
6
5
|
from abc import ABC, abstractmethod
|
|
7
6
|
from enum import Enum, auto
|
|
8
|
-
from pathlib import Path
|
|
9
|
-
from time import sleep
|
|
10
7
|
|
|
11
8
|
import numpy as np
|
|
12
|
-
from serial import serialutil
|
|
13
9
|
|
|
14
|
-
from pyglaze.device.configuration import
|
|
15
|
-
DeviceConfiguration,
|
|
16
|
-
ForceDeviceConfiguration,
|
|
17
|
-
LeDeviceConfiguration,
|
|
18
|
-
)
|
|
10
|
+
from pyglaze.device.configuration import DeviceConfiguration, LeDeviceConfiguration
|
|
19
11
|
|
|
20
12
|
|
|
21
13
|
class MockDevice(ABC):
|
|
@@ -33,143 +25,6 @@ class MockDevice(ABC):
|
|
|
33
25
|
pass
|
|
34
26
|
|
|
35
27
|
|
|
36
|
-
class ForceMockDevice(MockDevice):
|
|
37
|
-
"""Mock device for devices using a FORCE lockin for testing purposes."""
|
|
38
|
-
|
|
39
|
-
CONFIG_PATH = Path(__file__).parents[1] / "devtools" / "mockup_config.json"
|
|
40
|
-
ENCODING: str = "utf-8"
|
|
41
|
-
|
|
42
|
-
def __init__(
|
|
43
|
-
self: ForceMockDevice,
|
|
44
|
-
fail_after: float = np.inf,
|
|
45
|
-
n_fails: float = np.inf,
|
|
46
|
-
*,
|
|
47
|
-
instant_response: bool = False,
|
|
48
|
-
) -> None:
|
|
49
|
-
self.fail_after = fail_after
|
|
50
|
-
self.fails_wanted = n_fails
|
|
51
|
-
self.n_failures = 0
|
|
52
|
-
self.n_scans = 0
|
|
53
|
-
self.rng = np.random.default_rng()
|
|
54
|
-
self.valid_input = True
|
|
55
|
-
self.experiment_running = False
|
|
56
|
-
self.instant_response = instant_response
|
|
57
|
-
|
|
58
|
-
self._periods = None
|
|
59
|
-
self._frequency = None
|
|
60
|
-
self._sweep_length = None
|
|
61
|
-
if not self.CONFIG_PATH.exists():
|
|
62
|
-
conf = {"periods": None, "frequency": None, "sweep_length_ms": None}
|
|
63
|
-
self.__save(conf)
|
|
64
|
-
|
|
65
|
-
@property
|
|
66
|
-
def periods(self: ForceMockDevice) -> int:
|
|
67
|
-
"""Number of integration periods."""
|
|
68
|
-
return int(self.__get_val("periods"))
|
|
69
|
-
|
|
70
|
-
@periods.setter
|
|
71
|
-
def periods(self: ForceMockDevice, value: int) -> None:
|
|
72
|
-
self.__set_val("periods", value)
|
|
73
|
-
|
|
74
|
-
@property
|
|
75
|
-
def frequency(self: ForceMockDevice) -> int:
|
|
76
|
-
"""Modulation frequency in Hz."""
|
|
77
|
-
return int(self.__get_val("frequency"))
|
|
78
|
-
|
|
79
|
-
@frequency.setter
|
|
80
|
-
def frequency(self: ForceMockDevice, value: int) -> None:
|
|
81
|
-
self.__set_val("frequency", value)
|
|
82
|
-
|
|
83
|
-
@property
|
|
84
|
-
def sweep_length(self: ForceMockDevice) -> int:
|
|
85
|
-
"""Total sweep length for one pulse."""
|
|
86
|
-
return int(self.__get_val("sweep_length"))
|
|
87
|
-
|
|
88
|
-
@sweep_length.setter
|
|
89
|
-
def sweep_length(self: ForceMockDevice, value: int) -> None:
|
|
90
|
-
self.__set_val("sweep_length", value)
|
|
91
|
-
|
|
92
|
-
def close(self: ForceMockDevice) -> None:
|
|
93
|
-
"""Mock-close the serial connection."""
|
|
94
|
-
|
|
95
|
-
def write(self: ForceMockDevice, input_string: bytes) -> None:
|
|
96
|
-
"""Mock-write to the serial connection."""
|
|
97
|
-
decoded_input_string = input_string.decode("utf-8")
|
|
98
|
-
split_input_string = decoded_input_string.split(",")
|
|
99
|
-
cmd = split_input_string[0]
|
|
100
|
-
if cmd == "!set timing":
|
|
101
|
-
self.periods = int(split_input_string[1])
|
|
102
|
-
self.frequency = int(split_input_string[2])
|
|
103
|
-
elif cmd == "!set sweep length":
|
|
104
|
-
self.sweep_length = int(split_input_string[1])
|
|
105
|
-
elif cmd == "!s":
|
|
106
|
-
time_pr_point = self.periods / self.frequency
|
|
107
|
-
self.in_waiting = int(self.sweep_length * 1e-3 / time_pr_point)
|
|
108
|
-
self.experiment_running = True
|
|
109
|
-
elif cmd in ["!lut", "!set wave", "!set generator"]:
|
|
110
|
-
pass
|
|
111
|
-
elif cmd != "!dat":
|
|
112
|
-
self.valid_input = False
|
|
113
|
-
|
|
114
|
-
def readline(self: ForceMockDevice) -> bytes:
|
|
115
|
-
"""Mock-readline from the serial connection."""
|
|
116
|
-
if self.valid_input:
|
|
117
|
-
return self.__run_experiment() if self.experiment_running else b"!A,OK\r"
|
|
118
|
-
|
|
119
|
-
return b"A,FAULT\r"
|
|
120
|
-
|
|
121
|
-
def read_until(self: ForceMockDevice, expected: bytes = b"\r") -> bytes: # noqa: ARG002
|
|
122
|
-
"""Mock-read_until from the serial connection."""
|
|
123
|
-
random_datapoint = self.__create_random_datapoint
|
|
124
|
-
return random_datapoint.encode(self.ENCODING)
|
|
125
|
-
|
|
126
|
-
def __run_experiment(self: ForceMockDevice) -> bytes:
|
|
127
|
-
return_string = "!A,OK\\r"
|
|
128
|
-
return_string += (
|
|
129
|
-
f"!S,ip: {self.periods}, "
|
|
130
|
-
f"Freq: {self.frequency}, "
|
|
131
|
-
f"sl: {self.sweep_length}, "
|
|
132
|
-
f"from: 0.0000, "
|
|
133
|
-
f"to:1.0000\r"
|
|
134
|
-
)
|
|
135
|
-
for _ in range(self.in_waiting):
|
|
136
|
-
return_string += self.__create_random_datapoint
|
|
137
|
-
return_string += "!D,DONE\\r"
|
|
138
|
-
if not self.instant_response:
|
|
139
|
-
sleep(self.sweep_length * 1e-3)
|
|
140
|
-
self.n_scans += 1
|
|
141
|
-
if self.n_scans > self.fail_after and self.n_failures < self.fails_wanted:
|
|
142
|
-
self.n_failures += 1
|
|
143
|
-
msg = "MOCK_DEVICE: scan failed"
|
|
144
|
-
raise serialutil.SerialException(msg)
|
|
145
|
-
|
|
146
|
-
return return_string.encode("utf-8")
|
|
147
|
-
|
|
148
|
-
@property
|
|
149
|
-
def __create_random_datapoint(self: ForceMockDevice) -> str:
|
|
150
|
-
radius = self.rng.random() * 10
|
|
151
|
-
theta = self.rng.random() * 360
|
|
152
|
-
return f"!R,{radius},{theta}\r"
|
|
153
|
-
|
|
154
|
-
def __save(self: ForceMockDevice, conf: dict) -> None:
|
|
155
|
-
with self.CONFIG_PATH.open("w") as f:
|
|
156
|
-
json.dump(conf, f)
|
|
157
|
-
|
|
158
|
-
def __get_val(self: ForceMockDevice, key: str) -> int:
|
|
159
|
-
val: int = getattr(self, f"_{key}")
|
|
160
|
-
if not val:
|
|
161
|
-
with self.CONFIG_PATH.open() as f:
|
|
162
|
-
val = json.load(f)[key]
|
|
163
|
-
return val
|
|
164
|
-
|
|
165
|
-
def __set_val(self: ForceMockDevice, key: str, val: str | float) -> None:
|
|
166
|
-
with self.CONFIG_PATH.open() as f:
|
|
167
|
-
config = json.load(f)
|
|
168
|
-
config[key] = val
|
|
169
|
-
setattr(self, f"_{key}", val)
|
|
170
|
-
self.__save(config)
|
|
171
|
-
|
|
172
|
-
|
|
173
28
|
class _LeMockState(Enum):
|
|
174
29
|
IDLE = auto()
|
|
175
30
|
WAITING_FOR_SETTINGS = auto()
|
|
@@ -177,6 +32,8 @@ class _LeMockState(Enum):
|
|
|
177
32
|
RECEIVED_SETTINGS = auto()
|
|
178
33
|
RECEIVED_LIST = auto()
|
|
179
34
|
RECEIVED_STATUS_REQUEST = auto()
|
|
35
|
+
RECEIVED_SERIAL_NUMBER_REQUEST = auto()
|
|
36
|
+
RECEIVED_FIRMWARE_VERSION_REQUEST = auto()
|
|
180
37
|
STARTING_SCAN = auto()
|
|
181
38
|
SCANNING = auto()
|
|
182
39
|
|
|
@@ -235,6 +92,9 @@ class LeMockDevice(MockDevice):
|
|
|
235
92
|
return self._create_scan_bytes(n_bytes=0)
|
|
236
93
|
if self.state == _LeMockState.IDLE:
|
|
237
94
|
return self._create_scan_bytes(n_bytes=size)
|
|
95
|
+
if self.state == _LeMockState.RECEIVED_SERIAL_NUMBER_REQUEST:
|
|
96
|
+
self.state = _LeMockState.IDLE
|
|
97
|
+
return "M-9999".encode(self.ENCODING)
|
|
238
98
|
raise NotImplementedError
|
|
239
99
|
|
|
240
100
|
def read_until(self: LeMockDevice, _: bytes = b"\r") -> bytes: # noqa: PLR0911
|
|
@@ -255,6 +115,9 @@ class LeMockDevice(MockDevice):
|
|
|
255
115
|
self.state = _LeMockState.SCANNING
|
|
256
116
|
self.is_scanning = True
|
|
257
117
|
return "ACK: Scan started.".encode(self.ENCODING)
|
|
118
|
+
if self.state == _LeMockState.RECEIVED_FIRMWARE_VERSION_REQUEST:
|
|
119
|
+
self.state = _LeMockState.IDLE
|
|
120
|
+
return "v0.1.0".encode(self.ENCODING)
|
|
258
121
|
if self.state == _LeMockState.RECEIVED_STATUS_REQUEST:
|
|
259
122
|
if self._scan_has_finished():
|
|
260
123
|
self.state = _LeMockState.IDLE
|
|
@@ -291,6 +154,10 @@ class LeMockDevice(MockDevice):
|
|
|
291
154
|
self._scan_start_time = time.time()
|
|
292
155
|
elif msg == "R":
|
|
293
156
|
self._scan_has_finished()
|
|
157
|
+
elif msg == "s":
|
|
158
|
+
self.state = _LeMockState.RECEIVED_SERIAL_NUMBER_REQUEST
|
|
159
|
+
elif msg == "v":
|
|
160
|
+
self.state = _LeMockState.RECEIVED_FIRMWARE_VERSION_REQUEST
|
|
294
161
|
else:
|
|
295
162
|
msg = f"Unknown message: {msg}"
|
|
296
163
|
raise NotImplementedError(msg)
|
|
@@ -376,7 +243,7 @@ def list_mock_devices() -> list[str]:
|
|
|
376
243
|
]
|
|
377
244
|
|
|
378
245
|
|
|
379
|
-
def _mock_device_factory(config: DeviceConfiguration) ->
|
|
246
|
+
def _mock_device_factory(config: DeviceConfiguration) -> LeMockDevice:
|
|
380
247
|
mock_class = _get_mock_class(config)
|
|
381
248
|
if config.amp_port == "mock_device_scan_should_fail":
|
|
382
249
|
return mock_class(fail_after=0)
|
|
@@ -393,9 +260,7 @@ def _mock_device_factory(config: DeviceConfiguration) -> MockDevice:
|
|
|
393
260
|
raise ValueError(msg)
|
|
394
261
|
|
|
395
262
|
|
|
396
|
-
def _get_mock_class(config: DeviceConfiguration) -> type[
|
|
397
|
-
if isinstance(config, ForceDeviceConfiguration):
|
|
398
|
-
return ForceMockDevice
|
|
263
|
+
def _get_mock_class(config: DeviceConfiguration) -> type[LeMockDevice]:
|
|
399
264
|
if isinstance(config, LeDeviceConfiguration):
|
|
400
265
|
return LeMockDevice
|
|
401
266
|
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from typing import cast
|
|
3
|
+
from typing import TYPE_CHECKING, cast
|
|
4
4
|
|
|
5
5
|
import numpy as np
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from pyglaze.helpers._types import FloatArray
|
|
8
9
|
|
|
9
10
|
|
|
10
11
|
def gaussian_derivative_pulse(
|
|
@@ -32,4 +33,4 @@ def gaussian_derivative_pulse(
|
|
|
32
33
|
scale=1.0 / signal_to_noise, size=len(signal)
|
|
33
34
|
)
|
|
34
35
|
)
|
|
35
|
-
return cast(FloatArray, signal / np.max(signal) + noise)
|
|
36
|
+
return cast("FloatArray", signal / np.max(signal) + noise)
|
|
@@ -8,11 +8,11 @@ from typing import TYPE_CHECKING, Callable, cast
|
|
|
8
8
|
import serial
|
|
9
9
|
import serial.tools.list_ports
|
|
10
10
|
|
|
11
|
-
from pyglaze.helpers._types import P, T
|
|
12
|
-
|
|
13
11
|
if TYPE_CHECKING:
|
|
14
12
|
import logging
|
|
15
13
|
|
|
14
|
+
from pyglaze.helpers._types import P, T
|
|
15
|
+
|
|
16
16
|
APP_NAME = "Glaze"
|
|
17
17
|
LOGGER_NAME = "glaze-logger"
|
|
18
18
|
|
|
@@ -59,7 +59,7 @@ class _BackoffRetry:
|
|
|
59
59
|
def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
|
|
60
60
|
for tries in range(self.max_tries - 1):
|
|
61
61
|
try:
|
|
62
|
-
return cast(T, func(*args, **kwargs))
|
|
62
|
+
return cast("T", func(*args, **kwargs))
|
|
63
63
|
except (KeyboardInterrupt, SystemExit):
|
|
64
64
|
raise
|
|
65
65
|
except Exception as e: # noqa: BLE001
|
|
@@ -69,7 +69,7 @@ class _BackoffRetry:
|
|
|
69
69
|
backoff = min(self.backoff_base * 2**tries, self.max_backoff)
|
|
70
70
|
time.sleep(backoff)
|
|
71
71
|
self._log(f"{func.__name__}: Last try ({tries + 2}).")
|
|
72
|
-
return cast(T, func(*args, **kwargs))
|
|
72
|
+
return cast("T", func(*args, **kwargs))
|
|
73
73
|
|
|
74
74
|
return wrapper
|
|
75
75
|
|
|
@@ -24,7 +24,7 @@ def ws_interpolate(
|
|
|
24
24
|
# times must be zero-centered for formula to work
|
|
25
25
|
sinc = np.sinc((interp_times[:, np.newaxis] - times[0] - dt * _range) / dt)
|
|
26
26
|
|
|
27
|
-
return cast(FloatArray, np.sum(pulse * sinc, axis=1))
|
|
27
|
+
return cast("FloatArray", np.sum(pulse * sinc, axis=1))
|
|
28
28
|
|
|
29
29
|
|
|
30
30
|
def cubic_spline_interpolate(
|
|
@@ -41,4 +41,4 @@ def cubic_spline_interpolate(
|
|
|
41
41
|
FloatArray: Interpolated values
|
|
42
42
|
"""
|
|
43
43
|
spline = CubicSpline(times, pulse, bc_type="natural")
|
|
44
|
-
return cast(FloatArray, spline(interp_times))
|
|
44
|
+
return cast("FloatArray", spline(interp_times))
|
|
@@ -6,7 +6,7 @@ from multiprocessing import Event, Pipe, Process, Queue, synchronize
|
|
|
6
6
|
from queue import Empty, Full
|
|
7
7
|
from typing import TYPE_CHECKING
|
|
8
8
|
|
|
9
|
-
from serial import serialutil
|
|
9
|
+
from serial import SerialException, serialutil
|
|
10
10
|
|
|
11
11
|
from pyglaze.datamodels.waveform import UnprocessedWaveform, _TimestampedWaveform
|
|
12
12
|
from pyglaze.scanning.scanner import Scanner
|
|
@@ -25,6 +25,12 @@ class _ScannerHealth:
|
|
|
25
25
|
error: Exception | None
|
|
26
26
|
|
|
27
27
|
|
|
28
|
+
@dataclass
|
|
29
|
+
class _ScannerMetadata:
|
|
30
|
+
serial_number: str
|
|
31
|
+
firmware_version: str
|
|
32
|
+
|
|
33
|
+
|
|
28
34
|
@dataclass
|
|
29
35
|
class _AsyncScanner:
|
|
30
36
|
"""Used by GlazeClient to starts a scanner in a new process and read scans from shared memory."""
|
|
@@ -34,6 +40,7 @@ class _AsyncScanner:
|
|
|
34
40
|
logger: logging.Logger | None = None
|
|
35
41
|
is_scanning: bool = False
|
|
36
42
|
_child_process: Process = field(init=False)
|
|
43
|
+
_metadata: _ScannerMetadata = field(init=False)
|
|
37
44
|
_shared_mem: Queue[_TimestampedWaveform] = field(init=False)
|
|
38
45
|
_SCAN_TIMEOUT: float = field(init=False)
|
|
39
46
|
_stop_signal: synchronize.Event = field(init=False)
|
|
@@ -72,6 +79,10 @@ class _AsyncScanner:
|
|
|
72
79
|
self.logger.error(str(msg.error))
|
|
73
80
|
raise msg.error
|
|
74
81
|
|
|
82
|
+
# As part of startup, metadata is sent from scanner
|
|
83
|
+
metadata: _ScannerMetadata = self._scanner_conn.recv()
|
|
84
|
+
self._metadata = metadata
|
|
85
|
+
|
|
75
86
|
def stop_scan(self: _AsyncScanner) -> None:
|
|
76
87
|
self._stop_signal.set()
|
|
77
88
|
self._child_process.join()
|
|
@@ -90,6 +101,19 @@ class _AsyncScanner:
|
|
|
90
101
|
def get_next(self: _AsyncScanner) -> UnprocessedWaveform:
|
|
91
102
|
return self._get_scan().waveform
|
|
92
103
|
|
|
104
|
+
def get_serial_number(self: _AsyncScanner) -> str:
|
|
105
|
+
if not self.is_scanning:
|
|
106
|
+
msg = "Scanner not connected"
|
|
107
|
+
raise SerialException(msg)
|
|
108
|
+
return self._metadata.serial_number
|
|
109
|
+
|
|
110
|
+
def get_firmware_version(self: _AsyncScanner) -> str:
|
|
111
|
+
if not self.is_scanning:
|
|
112
|
+
msg = "Scanner not connected"
|
|
113
|
+
raise SerialException(msg)
|
|
114
|
+
|
|
115
|
+
return self._metadata.firmware_version
|
|
116
|
+
|
|
93
117
|
def _get_scan(self: _AsyncScanner) -> _TimestampedWaveform:
|
|
94
118
|
try:
|
|
95
119
|
return self._shared_mem.get(timeout=self._SCAN_TIMEOUT)
|
|
@@ -113,7 +137,12 @@ class _AsyncScanner:
|
|
|
113
137
|
) -> None:
|
|
114
138
|
try:
|
|
115
139
|
scanner = Scanner(config=config)
|
|
140
|
+
device_metadata = _ScannerMetadata(
|
|
141
|
+
serial_number=scanner.get_serial_number(),
|
|
142
|
+
firmware_version=scanner.get_firmware_version(),
|
|
143
|
+
)
|
|
116
144
|
parent_conn.send(_ScannerHealth(is_alive=True, is_healthy=True, error=None))
|
|
145
|
+
parent_conn.send(device_metadata)
|
|
117
146
|
except (serialutil.SerialException, TimeoutError) as e:
|
|
118
147
|
parent_conn.send(_ScannerHealth(is_alive=False, is_healthy=False, error=e))
|
|
119
148
|
return
|
|
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
from dataclasses import dataclass, field
|
|
4
4
|
from typing import TYPE_CHECKING
|
|
5
5
|
|
|
6
|
-
from serial import serialutil
|
|
6
|
+
from serial import SerialException, serialutil
|
|
7
7
|
from typing_extensions import Self
|
|
8
8
|
|
|
9
9
|
from ._asyncscanner import _AsyncScanner
|
|
@@ -57,3 +57,19 @@ class GlazeClient:
|
|
|
57
57
|
n_pulses: The number of terahertz pulses to read from the CCS server.
|
|
58
58
|
"""
|
|
59
59
|
return self._scanner.get_scans(n_pulses)
|
|
60
|
+
|
|
61
|
+
def get_serial_number(self: GlazeClient) -> str:
|
|
62
|
+
"""Get the serial number of the connected device."""
|
|
63
|
+
try:
|
|
64
|
+
return self._scanner.get_serial_number()
|
|
65
|
+
except AttributeError as e:
|
|
66
|
+
msg = "No connection to device."
|
|
67
|
+
raise SerialException(msg) from e
|
|
68
|
+
|
|
69
|
+
def get_firmware_version(self: GlazeClient) -> str:
|
|
70
|
+
"""Get the firmware version of the connected device."""
|
|
71
|
+
try:
|
|
72
|
+
return self._scanner.get_firmware_version()
|
|
73
|
+
except AttributeError as e:
|
|
74
|
+
msg = "No connection to device."
|
|
75
|
+
raise SerialException(msg) from e
|
|
@@ -4,15 +4,10 @@ from abc import ABC, abstractmethod
|
|
|
4
4
|
from typing import TYPE_CHECKING, Generic, TypeVar
|
|
5
5
|
|
|
6
6
|
import numpy as np
|
|
7
|
-
from serial import SerialException
|
|
8
7
|
|
|
9
8
|
from pyglaze.datamodels import UnprocessedWaveform
|
|
10
|
-
from pyglaze.device.ampcom import
|
|
11
|
-
from pyglaze.device.configuration import
|
|
12
|
-
DeviceConfiguration,
|
|
13
|
-
ForceDeviceConfiguration,
|
|
14
|
-
LeDeviceConfiguration,
|
|
15
|
-
)
|
|
9
|
+
from pyglaze.device.ampcom import _LeAmpCom
|
|
10
|
+
from pyglaze.device.configuration import DeviceConfiguration, LeDeviceConfiguration
|
|
16
11
|
from pyglaze.scanning._exceptions import ScanError
|
|
17
12
|
|
|
18
13
|
if TYPE_CHECKING:
|
|
@@ -48,6 +43,14 @@ class _ScannerImplementation(ABC, Generic[TConfig]):
|
|
|
48
43
|
def disconnect(self: _ScannerImplementation) -> None:
|
|
49
44
|
pass
|
|
50
45
|
|
|
46
|
+
@abstractmethod
|
|
47
|
+
def get_serial_number(self: _ScannerImplementation) -> str:
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
@abstractmethod
|
|
51
|
+
def get_firmware_version(self: _ScannerImplementation) -> str:
|
|
52
|
+
pass
|
|
53
|
+
|
|
51
54
|
|
|
52
55
|
class Scanner:
|
|
53
56
|
"""A synchronous scanner for Glaze terahertz devices."""
|
|
@@ -86,92 +89,21 @@ class Scanner:
|
|
|
86
89
|
"""Close serial connection."""
|
|
87
90
|
self._scanner_impl.disconnect()
|
|
88
91
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
"""Perform synchronous terahertz scanning using a given DeviceConfiguration.
|
|
92
|
-
|
|
93
|
-
Args:
|
|
94
|
-
config: A DeviceConfiguration to use for the scan.
|
|
95
|
-
|
|
96
|
-
"""
|
|
97
|
-
|
|
98
|
-
def __init__(self: ForceScanner, config: ForceDeviceConfiguration) -> None:
|
|
99
|
-
self._config: ForceDeviceConfiguration
|
|
100
|
-
self._ampcom: _ForceAmpCom | None = None
|
|
101
|
-
self.config = config
|
|
102
|
-
self._phase_estimator = _LockinPhaseEstimator()
|
|
103
|
-
|
|
104
|
-
@property
|
|
105
|
-
def config(self: ForceScanner) -> ForceDeviceConfiguration:
|
|
106
|
-
"""The device configuration to use for the scan.
|
|
92
|
+
def get_serial_number(self: Scanner) -> str:
|
|
93
|
+
"""Get the serial number of the connected device.
|
|
107
94
|
|
|
108
95
|
Returns:
|
|
109
|
-
|
|
96
|
+
str: The serial number of the connected device.
|
|
110
97
|
"""
|
|
111
|
-
return self.
|
|
112
|
-
|
|
113
|
-
@config.setter
|
|
114
|
-
def config(self: ForceScanner, new_config: ForceDeviceConfiguration) -> None:
|
|
115
|
-
amp = _ForceAmpCom(new_config)
|
|
116
|
-
if getattr(self, "_config", None):
|
|
117
|
-
if (
|
|
118
|
-
self._config.integration_periods != new_config.integration_periods
|
|
119
|
-
or self._config.modulation_frequency != new_config.modulation_frequency
|
|
120
|
-
):
|
|
121
|
-
amp.write_period_and_frequency()
|
|
122
|
-
if self._config.sweep_length_ms != new_config.sweep_length_ms:
|
|
123
|
-
amp.write_sweep_length()
|
|
124
|
-
if self._config.modulation_waveform != new_config.modulation_waveform:
|
|
125
|
-
amp.write_waveform()
|
|
126
|
-
if (
|
|
127
|
-
self._config.min_modulation_voltage != new_config.min_modulation_voltage
|
|
128
|
-
or self._config.max_modulation_voltage
|
|
129
|
-
!= new_config.max_modulation_voltage
|
|
130
|
-
):
|
|
131
|
-
amp.write_modulation_voltage()
|
|
132
|
-
if self._config.scan_intervals != new_config.scan_intervals:
|
|
133
|
-
amp.write_list()
|
|
134
|
-
else:
|
|
135
|
-
amp.write_all()
|
|
98
|
+
return self._scanner_impl.get_serial_number()
|
|
136
99
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
def scan(self: ForceScanner) -> UnprocessedWaveform:
|
|
141
|
-
"""Perform a scan.
|
|
100
|
+
def get_firmware_version(self: Scanner) -> str:
|
|
101
|
+
"""Get the firmware version of the connected device.
|
|
142
102
|
|
|
143
103
|
Returns:
|
|
144
|
-
|
|
104
|
+
str: The firmware version of the connected device.
|
|
145
105
|
"""
|
|
146
|
-
|
|
147
|
-
msg = "Scanner not configured"
|
|
148
|
-
raise ScanError(msg)
|
|
149
|
-
_, responses = self._ampcom.start_scan()
|
|
150
|
-
|
|
151
|
-
time = responses[:, 0]
|
|
152
|
-
radius = responses[:, 1]
|
|
153
|
-
theta = responses[:, 2]
|
|
154
|
-
self._phase_estimator.update_estimate(radius=radius, theta=theta)
|
|
155
|
-
|
|
156
|
-
return UnprocessedWaveform.from_polar_coords(
|
|
157
|
-
time, radius, theta, self._phase_estimator.phase_estimate
|
|
158
|
-
)
|
|
159
|
-
|
|
160
|
-
def update_config(self: ForceScanner, new_config: ForceDeviceConfiguration) -> None:
|
|
161
|
-
"""Update the DeviceConfiguration used in the scan.
|
|
162
|
-
|
|
163
|
-
Args:
|
|
164
|
-
new_config: A DeviceConfiguration to use for the scan.
|
|
165
|
-
"""
|
|
166
|
-
self.config = new_config
|
|
167
|
-
|
|
168
|
-
def disconnect(self: ForceScanner) -> None:
|
|
169
|
-
"""Close serial connection."""
|
|
170
|
-
if self._ampcom is None:
|
|
171
|
-
msg = "Scanner not connected"
|
|
172
|
-
raise SerialException(msg)
|
|
173
|
-
self._ampcom.disconnect()
|
|
174
|
-
self._ampcom = None
|
|
106
|
+
return self._scanner_impl.get_firmware_version()
|
|
175
107
|
|
|
176
108
|
|
|
177
109
|
class LeScanner(_ScannerImplementation[LeDeviceConfiguration]):
|
|
@@ -245,10 +177,30 @@ class LeScanner(_ScannerImplementation[LeDeviceConfiguration]):
|
|
|
245
177
|
self._ampcom.disconnect()
|
|
246
178
|
self._ampcom = None
|
|
247
179
|
|
|
180
|
+
def get_serial_number(self: LeScanner) -> str:
|
|
181
|
+
"""Get the serial number of the connected device.
|
|
182
|
+
|
|
183
|
+
Returns:
|
|
184
|
+
str: The serial number of the connected device.
|
|
185
|
+
"""
|
|
186
|
+
if self._ampcom is None:
|
|
187
|
+
msg = "Scanner not connected"
|
|
188
|
+
raise ScanError(msg)
|
|
189
|
+
return self._ampcom.get_serial_number()
|
|
190
|
+
|
|
191
|
+
def get_firmware_version(self: LeScanner) -> str:
|
|
192
|
+
"""Get the firmware version of the connected device.
|
|
193
|
+
|
|
194
|
+
Returns:
|
|
195
|
+
str: The firmware version of the connected device.
|
|
196
|
+
"""
|
|
197
|
+
if self._ampcom is None:
|
|
198
|
+
msg = "Scanner not connected"
|
|
199
|
+
raise ScanError(msg)
|
|
200
|
+
return self._ampcom.get_firmware_version()
|
|
201
|
+
|
|
248
202
|
|
|
249
203
|
def _scanner_factory(config: DeviceConfiguration) -> _ScannerImplementation:
|
|
250
|
-
if isinstance(config, ForceDeviceConfiguration):
|
|
251
|
-
return ForceScanner(config)
|
|
252
204
|
if isinstance(config, LeDeviceConfiguration):
|
|
253
205
|
return LeScanner(config)
|
|
254
206
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: pyglaze
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.1
|
|
4
4
|
Summary: Pyglaze is a library used to operate the devices of Glaze Technologies
|
|
5
5
|
Author: GLAZE Technologies ApS
|
|
6
6
|
License: BSD 3-Clause License
|
|
@@ -35,14 +35,15 @@ Project-URL: Homepage, https://www.glazetech.dk/
|
|
|
35
35
|
Project-URL: Documentation, https://glazetech.github.io/pyglaze/latest
|
|
36
36
|
Project-URL: Repository, https://github.com/GlazeTech/pyglaze
|
|
37
37
|
Project-URL: Issues, https://github.com/GlazeTech/pyglaze/issues
|
|
38
|
-
Requires-Python: <3.
|
|
38
|
+
Requires-Python: <3.14,>=3.9
|
|
39
39
|
Description-Content-Type: text/markdown
|
|
40
40
|
License-File: LICENSE
|
|
41
|
-
Requires-Dist: numpy
|
|
41
|
+
Requires-Dist: numpy>=1.26.4
|
|
42
42
|
Requires-Dist: pyserial>=3.5
|
|
43
43
|
Requires-Dist: scipy>=1.7.3
|
|
44
44
|
Requires-Dist: bitstring>=4.1.2
|
|
45
45
|
Requires-Dist: typing_extensions>=4.12.2
|
|
46
|
+
Dynamic: license-file
|
|
46
47
|
|
|
47
48
|
# Pyglaze
|
|
48
49
|
Pyglaze is a python library used to operate the devices of [Glaze Technologies](https://www.glazetech.dk/).
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.3.0"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|