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.
Files changed (34) hide show
  1. {pyglaze-0.3.0/src/pyglaze.egg-info → pyglaze-0.4.1}/PKG-INFO +5 -4
  2. {pyglaze-0.3.0 → pyglaze-0.4.1}/pyproject.toml +4 -4
  3. pyglaze-0.4.1/src/pyglaze/__init__.py +1 -0
  4. {pyglaze-0.3.0 → pyglaze-0.4.1}/src/pyglaze/datamodels/pulse.py +35 -13
  5. pyglaze-0.4.1/src/pyglaze/device/__init__.py +6 -0
  6. {pyglaze-0.3.0 → pyglaze-0.4.1}/src/pyglaze/device/ampcom.py +24 -198
  7. {pyglaze-0.3.0 → pyglaze-0.4.1}/src/pyglaze/device/configuration.py +7 -99
  8. {pyglaze-0.3.0 → pyglaze-0.4.1}/src/pyglaze/devtools/mock_device.py +15 -150
  9. {pyglaze-0.3.0 → pyglaze-0.4.1}/src/pyglaze/devtools/thz_pulse.py +4 -3
  10. {pyglaze-0.3.0 → pyglaze-0.4.1}/src/pyglaze/helpers/utilities.py +4 -4
  11. {pyglaze-0.3.0 → pyglaze-0.4.1}/src/pyglaze/interpolation/interpolation.py +2 -2
  12. {pyglaze-0.3.0 → pyglaze-0.4.1}/src/pyglaze/scanning/_asyncscanner.py +30 -1
  13. {pyglaze-0.3.0 → pyglaze-0.4.1}/src/pyglaze/scanning/client.py +17 -1
  14. {pyglaze-0.3.0 → pyglaze-0.4.1}/src/pyglaze/scanning/scanner.py +40 -88
  15. {pyglaze-0.3.0 → pyglaze-0.4.1/src/pyglaze.egg-info}/PKG-INFO +5 -4
  16. {pyglaze-0.3.0 → pyglaze-0.4.1}/src/pyglaze.egg-info/requires.txt +1 -1
  17. pyglaze-0.3.0/src/pyglaze/__init__.py +0 -1
  18. pyglaze-0.3.0/src/pyglaze/device/__init__.py +0 -7
  19. {pyglaze-0.3.0 → pyglaze-0.4.1}/LICENSE +0 -0
  20. {pyglaze-0.3.0 → pyglaze-0.4.1}/MANIFEST.in +0 -0
  21. {pyglaze-0.3.0 → pyglaze-0.4.1}/README.md +0 -0
  22. {pyglaze-0.3.0 → pyglaze-0.4.1}/setup.cfg +0 -0
  23. {pyglaze-0.3.0 → pyglaze-0.4.1}/src/pyglaze/datamodels/__init__.py +0 -0
  24. {pyglaze-0.3.0 → pyglaze-0.4.1}/src/pyglaze/datamodels/waveform.py +0 -0
  25. {pyglaze-0.3.0 → pyglaze-0.4.1}/src/pyglaze/devtools/__init__.py +0 -0
  26. {pyglaze-0.3.0 → pyglaze-0.4.1}/src/pyglaze/helpers/__init__.py +0 -0
  27. {pyglaze-0.3.0 → pyglaze-0.4.1}/src/pyglaze/helpers/_types.py +0 -0
  28. {pyglaze-0.3.0 → pyglaze-0.4.1}/src/pyglaze/interpolation/__init__.py +0 -0
  29. {pyglaze-0.3.0 → pyglaze-0.4.1}/src/pyglaze/py.typed +0 -0
  30. {pyglaze-0.3.0 → pyglaze-0.4.1}/src/pyglaze/scanning/__init__.py +0 -0
  31. {pyglaze-0.3.0 → pyglaze-0.4.1}/src/pyglaze/scanning/_exceptions.py +0 -0
  32. {pyglaze-0.3.0 → pyglaze-0.4.1}/src/pyglaze.egg-info/SOURCES.txt +0 -0
  33. {pyglaze-0.3.0 → pyglaze-0.4.1}/src/pyglaze.egg-info/dependency_links.txt +0 -0
  34. {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.2
1
+ Metadata-Version: 2.4
2
2
  Name: pyglaze
3
- Version: 0.3.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.13,>=3.9
38
+ Requires-Python: <3.14,>=3.9
39
39
  Description-Content-Type: text/markdown
40
40
  License-File: LICENSE
41
- Requires-Dist: numpy<2.0.0,>=1.26.4
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.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.13"
10
+ requires-python = ">=3.9,<3.14"
11
11
 
12
12
  dependencies = [
13
- "numpy>=1.26.4,<2.0.0",
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.3.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.trapz(self.signal * self.signal, x=self.time)) # noqa: NPY201 - trapz removed in numpy 2.0
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
- for scan in roughly_aligned:
165
- scan.time = scan.time - scan.time[0]
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:
@@ -0,0 +1,6 @@
1
+ from .configuration import Interval, LeDeviceConfiguration
2
+
3
+ __all__ = [
4
+ "Interval",
5
+ "LeDeviceConfiguration",
6
+ ]
@@ -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, overload
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
- @overload
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
- return _config_w_intervals_from_dict(cls, amp_config)
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) -> MockDevice:
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[MockDevice]:
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
- from pyglaze.helpers._types import FloatArray
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 _ForceAmpCom, _LeAmpCom
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
- class ForceScanner(_ScannerImplementation[ForceDeviceConfiguration]):
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
- DeviceConfiguration: a DeviceConfiguration.
96
+ str: The serial number of the connected device.
110
97
  """
111
- return self._config
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
- self._config = new_config
138
- self._ampcom = amp
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
- Unprocessed scan.
104
+ str: The firmware version of the connected device.
145
105
  """
146
- if self._ampcom is None:
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.2
1
+ Metadata-Version: 2.4
2
2
  Name: pyglaze
3
- Version: 0.3.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.13,>=3.9
38
+ Requires-Python: <3.14,>=3.9
39
39
  Description-Content-Type: text/markdown
40
40
  License-File: LICENSE
41
- Requires-Dist: numpy<2.0.0,>=1.26.4
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,4 +1,4 @@
1
- numpy<2.0.0,>=1.26.4
1
+ numpy>=1.26.4
2
2
  pyserial>=3.5
3
3
  scipy>=1.7.3
4
4
  bitstring>=4.1.2
@@ -1 +0,0 @@
1
- __version__ = "0.3.0"
@@ -1,7 +0,0 @@
1
- from .configuration import ForceDeviceConfiguration, Interval, LeDeviceConfiguration
2
-
3
- __all__ = [
4
- "ForceDeviceConfiguration",
5
- "Interval",
6
- "LeDeviceConfiguration",
7
- ]
File without changes
File without changes
File without changes
File without changes
File without changes