pyglaze 0.4.6__tar.gz → 0.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. {pyglaze-0.4.6/src/pyglaze.egg-info → pyglaze-0.5.0}/PKG-INFO +25 -2
  2. {pyglaze-0.4.6 → pyglaze-0.5.0}/pyproject.toml +38 -3
  3. pyglaze-0.5.0/src/pyglaze/__init__.py +1 -0
  4. {pyglaze-0.4.6 → pyglaze-0.5.0}/src/pyglaze/datamodels/pulse.py +2 -2
  5. {pyglaze-0.4.6 → pyglaze-0.5.0}/src/pyglaze/datamodels/waveform.py +32 -7
  6. {pyglaze-0.4.6 → pyglaze-0.5.0}/src/pyglaze/device/ampcom.py +1 -10
  7. {pyglaze-0.4.6 → pyglaze-0.5.0}/src/pyglaze/devtools/mock_device.py +1 -1
  8. pyglaze-0.5.0/src/pyglaze/helpers/_lockin.py +161 -0
  9. {pyglaze-0.4.6 → pyglaze-0.5.0}/src/pyglaze/helpers/utilities.py +6 -5
  10. {pyglaze-0.4.6 → pyglaze-0.5.0}/src/pyglaze/scanning/_asyncscanner.py +51 -8
  11. {pyglaze-0.4.6 → pyglaze-0.5.0}/src/pyglaze/scanning/client.py +22 -1
  12. {pyglaze-0.4.6 → pyglaze-0.5.0}/src/pyglaze/scanning/scanner.py +53 -53
  13. {pyglaze-0.4.6 → pyglaze-0.5.0/src/pyglaze.egg-info}/PKG-INFO +25 -2
  14. {pyglaze-0.4.6 → pyglaze-0.5.0}/src/pyglaze.egg-info/SOURCES.txt +1 -0
  15. pyglaze-0.5.0/src/pyglaze.egg-info/requires.txt +32 -0
  16. pyglaze-0.4.6/src/pyglaze/__init__.py +0 -1
  17. pyglaze-0.4.6/src/pyglaze.egg-info/requires.txt +0 -5
  18. {pyglaze-0.4.6 → pyglaze-0.5.0}/LICENSE +0 -0
  19. {pyglaze-0.4.6 → pyglaze-0.5.0}/MANIFEST.in +0 -0
  20. {pyglaze-0.4.6 → pyglaze-0.5.0}/README.md +0 -0
  21. {pyglaze-0.4.6 → pyglaze-0.5.0}/setup.cfg +0 -0
  22. {pyglaze-0.4.6 → pyglaze-0.5.0}/src/pyglaze/datamodels/__init__.py +0 -0
  23. {pyglaze-0.4.6 → pyglaze-0.5.0}/src/pyglaze/device/__init__.py +0 -0
  24. {pyglaze-0.4.6 → pyglaze-0.5.0}/src/pyglaze/device/configuration.py +0 -0
  25. {pyglaze-0.4.6 → pyglaze-0.5.0}/src/pyglaze/devtools/__init__.py +0 -0
  26. {pyglaze-0.4.6 → pyglaze-0.5.0}/src/pyglaze/devtools/thz_pulse.py +0 -0
  27. {pyglaze-0.4.6 → pyglaze-0.5.0}/src/pyglaze/helpers/__init__.py +0 -0
  28. {pyglaze-0.4.6 → pyglaze-0.5.0}/src/pyglaze/helpers/_types.py +0 -0
  29. {pyglaze-0.4.6 → pyglaze-0.5.0}/src/pyglaze/interpolation/__init__.py +0 -0
  30. {pyglaze-0.4.6 → pyglaze-0.5.0}/src/pyglaze/interpolation/interpolation.py +0 -0
  31. {pyglaze-0.4.6 → pyglaze-0.5.0}/src/pyglaze/py.typed +0 -0
  32. {pyglaze-0.4.6 → pyglaze-0.5.0}/src/pyglaze/scanning/__init__.py +0 -0
  33. {pyglaze-0.4.6 → pyglaze-0.5.0}/src/pyglaze/scanning/_exceptions.py +0 -0
  34. {pyglaze-0.4.6 → pyglaze-0.5.0}/src/pyglaze.egg-info/dependency_links.txt +0 -0
  35. {pyglaze-0.4.6 → pyglaze-0.5.0}/src/pyglaze.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyglaze
3
- Version: 0.4.6
3
+ Version: 0.5.0
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
@@ -38,11 +38,34 @@ Project-URL: Issues, https://github.com/GlazeTech/pyglaze/issues
38
38
  Requires-Python: <3.14,>=3.9
39
39
  Description-Content-Type: text/markdown
40
40
  License-File: LICENSE
41
- Requires-Dist: numpy>=1.26.4
41
+ Requires-Dist: numpy<2.0.0,>=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
+ Provides-Extra: build
47
+ Requires-Dist: build>=1.2.1; extra == "build"
48
+ Requires-Dist: twine>=5.1.1; extra == "build"
49
+ Requires-Dist: bumpver>=2023.1129; extra == "build"
50
+ Provides-Extra: test
51
+ Requires-Dist: pytest>=7.0.1; extra == "test"
52
+ Requires-Dist: pytest-cov>=4.0.0; extra == "test"
53
+ Provides-Extra: docs
54
+ Requires-Dist: mkdocs>=1.2.3; extra == "docs"
55
+ Requires-Dist: mkdocs-material>=8.2.5; extra == "docs"
56
+ Requires-Dist: mkdocstrings[python]>=0.18.1; extra == "docs"
57
+ Requires-Dist: mike>=1.1.2; extra == "docs"
58
+ Requires-Dist: Pygments>=2.12.0; extra == "docs"
59
+ Requires-Dist: matplotlib>=3.7.0; extra == "docs"
60
+ Requires-Dist: bumpver>=2023.1129; extra == "docs"
61
+ Provides-Extra: dev
62
+ Requires-Dist: ruff>=0.14.10; extra == "dev"
63
+ Requires-Dist: ty>=0.0.13; extra == "dev"
64
+ Requires-Dist: pytest>=7.0.1; extra == "dev"
65
+ Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
66
+ Requires-Dist: semver>=2.13.0; extra == "dev"
67
+ Requires-Dist: types-pyserial>=3.5.0.8; extra == "dev"
68
+ Requires-Dist: bumpver>=2023.1129; extra == "dev"
46
69
  Dynamic: license-file
47
70
 
48
71
  # Pyglaze
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pyglaze"
3
- version = "0.4.6"
3
+ version = "0.5.0"
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" }
@@ -10,13 +10,42 @@ authors = [
10
10
  requires-python = ">=3.9,<3.14"
11
11
 
12
12
  dependencies = [
13
- "numpy>=1.26.4",
13
+ "numpy>=1.26.4,<2.0.0",
14
14
  "pyserial>=3.5",
15
15
  "scipy>=1.7.3",
16
16
  "bitstring>=4.1.2",
17
17
  "typing_extensions>=4.12.2"
18
18
  ]
19
19
 
20
+ [project.optional-dependencies]
21
+ build = [
22
+ "build>=1.2.1",
23
+ "twine>=5.1.1",
24
+ "bumpver>=2023.1129",
25
+ ]
26
+ test = [
27
+ "pytest>=7.0.1",
28
+ "pytest-cov>=4.0.0",
29
+ ]
30
+ docs = [
31
+ "mkdocs>=1.2.3",
32
+ "mkdocs-material>=8.2.5",
33
+ "mkdocstrings[python]>=0.18.1",
34
+ "mike>=1.1.2",
35
+ "Pygments>=2.12.0",
36
+ "matplotlib>=3.7.0",
37
+ "bumpver>=2023.1129",
38
+ ]
39
+ dev = [
40
+ "ruff>=0.14.10",
41
+ "ty>=0.0.13",
42
+ "pytest>=7.0.1",
43
+ "pytest-cov>=4.0.0",
44
+ "semver>=2.13.0",
45
+ "types-pyserial>=3.5.0.8",
46
+ "bumpver>=2023.1129",
47
+ ]
48
+
20
49
  [project.urls]
21
50
  Homepage = "https://www.glazetech.dk/"
22
51
  Documentation = "https://glazetech.github.io/pyglaze/latest"
@@ -74,7 +103,7 @@ convention = "google"
74
103
  ]
75
104
 
76
105
  [tool.bumpver]
77
- current_version = "0.4.6"
106
+ current_version = "0.5.0"
78
107
  version_pattern = "MAJOR.MINOR.PATCH[-TAG]"
79
108
  commit_message = "BUMP VERSION {old_version} -> {new_version}"
80
109
  tag_message = "v{new_version}"
@@ -97,3 +126,9 @@ push = false
97
126
  '**Documentation Version**: {version}'
98
127
  ]
99
128
 
129
+ [tool.ty.rules]
130
+ unused-ignore-comment = "error"
131
+
132
+ [tool.ty.terminal]
133
+ error-on-warning = true
134
+
@@ -0,0 +1 @@
1
+ __version__ = "0.5.0"
@@ -115,7 +115,7 @@ class Pulse:
115
115
 
116
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.
117
117
  """
118
- return cast("float", np.trapezoid(self.signal * self.signal, x=self.time)) # type: ignore[attr-defined, unused-ignore]
118
+ return cast("float", np.trapz(self.signal * self.signal, x=self.time)) # noqa: NPY201
119
119
 
120
120
  @classmethod
121
121
  def from_dict(
@@ -585,7 +585,7 @@ def _estimate_bw_idx(x: FloatArray, y: FloatArray, segments: int) -> int:
585
585
  target = np.log(y)
586
586
 
587
587
  def L1(x: FloatArray, y: FloatArray) -> FloatArray:
588
- return np.sum(np.abs(y - x)) # type: ignore[no-any-return]
588
+ return np.sum(np.abs(y - x))
589
589
 
590
590
  def model(pars: list[float]) -> FloatArray:
591
591
  idx = np.searchsorted(x, pars[0])
@@ -6,6 +6,8 @@ from typing import TYPE_CHECKING, Literal
6
6
  import numpy as np
7
7
  from scipy.interpolate import CubicSpline
8
8
 
9
+ from pyglaze.helpers._lockin import _estimate_IQ_phase, _polar_to_IQ, _rotate_inphase
10
+
9
11
  from .pulse import Pulse
10
12
 
11
13
  if TYPE_CHECKING:
@@ -43,16 +45,37 @@ class UnprocessedWaveform:
43
45
  Args:
44
46
  time: The time values recorded by the lock-in amp during the scan.
45
47
  radius: The radius values recorded by the lock-in amp during the scan.
46
- theta: The theta values recorded by the lock-in amp during the scan (in degrees).
47
- rotation_angle: The angle to rotate lockin signal to align along x-axis. If not given, will use the angle at the maximum value of R.
48
+ theta: The theta values recorded by the lock-in amp during the scan (in radians).
49
+ rotation_angle: The angle to rotate lockin signal to align along x-axis. If not given, will estimate phase from data.
48
50
  """
49
- _rot_ang = (
50
- theta[np.argmax(radius)] if rotation_angle is None else rotation_angle
51
- )
51
+ if rotation_angle is None:
52
+ rotation_angle, _ = _estimate_IQ_phase(*_polar_to_IQ(radius, theta))
52
53
 
53
54
  # rotate such that all signal lies along X
54
- new_theta = theta - _rot_ang
55
- signal = radius * np.cos(new_theta * np.pi / 180.0)
55
+ new_theta = theta - rotation_angle
56
+ signal = radius * np.cos(new_theta)
57
+ return cls(time, signal)
58
+
59
+ @classmethod
60
+ def from_inphase_quadrature(
61
+ cls: type[UnprocessedWaveform],
62
+ time: FloatArray,
63
+ X: FloatArray,
64
+ Y: FloatArray,
65
+ rotation_angle: float | None = None,
66
+ ) -> UnprocessedWaveform:
67
+ """Create an UnprocessedWaveform object from raw lock-in amp output.
68
+
69
+ Args:
70
+ time: The time values recorded by the lock-in amp during the scan.
71
+ X: The in-phase values recorded by the lock-in amp during the scan.
72
+ Y: The quadrature values recorded by the lock-in amp during the scan.
73
+ rotation_angle: The angle to rotate lockin signal to align along x-axis. If not given, will estimate phase from data.
74
+ """
75
+ if rotation_angle is None:
76
+ rotation_angle, _ = _estimate_IQ_phase(X, Y)
77
+
78
+ signal = _rotate_inphase(X, Y, rotation_angle)
56
79
  return cls(time, signal)
57
80
 
58
81
  @classmethod
@@ -159,7 +182,9 @@ class _TimestampedWaveform:
159
182
  Args:
160
183
  timestamp: The timestamp of the pulse given by the Toptica server.
161
184
  waveform: The terahertz pulse received from the Toptica server.
185
+ phase_estimate: The phase estimate from the lock-in phase estimator at the time of this scan.
162
186
  """
163
187
 
164
188
  timestamp: datetime
165
189
  waveform: UnprocessedWaveform
190
+ phase_estimate: float | None = None
@@ -119,9 +119,7 @@ class _LeAmpCom:
119
119
  self._encode_send_response(self.START_COMMAND)
120
120
  self._await_scan_finished()
121
121
  times, Xs, Ys = self._read_scan()
122
-
123
- radii, angles = self._convert_to_r_angle(Xs, Ys)
124
- return self.START_COMMAND, np.array(times), np.array(radii), np.array(angles)
122
+ return self.START_COMMAND, np.array(times), np.array(Xs), np.array(Ys)
125
123
 
126
124
  def disconnect(self: _LeAmpCom) -> None:
127
125
  """Closes connection when class instance goes out of scope."""
@@ -145,13 +143,6 @@ class _LeAmpCom:
145
143
  """Intervals squished into effective DAC range."""
146
144
  return self.config.scan_intervals or [Interval(lower=0.0, upper=1.0)]
147
145
 
148
- def _convert_to_r_angle(
149
- self: _LeAmpCom, Xs: list, Ys: list
150
- ) -> tuple[FloatArray, FloatArray]:
151
- r = np.sqrt(np.array(Xs) ** 2 + np.array(Ys) ** 2)
152
- angle = np.arctan2(np.array(Ys), np.array(Xs))
153
- return r, np.rad2deg(angle)
154
-
155
146
  def _encode_send_response(
156
147
  self: _LeAmpCom, command: str, *, check_ack: bool = True
157
148
  ) -> str:
@@ -226,7 +226,7 @@ class LeMockDevice(MockDevice):
226
226
  numbers = np.concatenate(
227
227
  (
228
228
  np.array(self.scanning_list) * 100e-12, # mock time values
229
- self.rng.random(2 * len(self.scanning_list)),
229
+ self.rng.random(2 * len(self.scanning_list)) + 1,
230
230
  )
231
231
  )
232
232
 
@@ -0,0 +1,161 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ import numpy as np
6
+
7
+ if TYPE_CHECKING:
8
+ from pyglaze.helpers._types import FloatArray
9
+
10
+
11
+ def _wrap_to_pi(a: float) -> float:
12
+ """Map angle (in radians) to (-pi, pi]."""
13
+ return (a + np.pi) % (2 * np.pi) - np.pi
14
+
15
+
16
+ def _angular_distance(a: float, b: float) -> float:
17
+ """Smallest absolute distance between angles a and b (in radians)."""
18
+ return abs(_wrap_to_pi(a - b))
19
+
20
+
21
+ def _choose_pi_branch(theta: float, ref: float) -> float:
22
+ """Choose between theta and theta+pi (mod 2pi) to be closest to ref.
23
+
24
+ This prevents accidental polarity flips.
25
+ """
26
+ d0 = _angular_distance(theta, ref)
27
+ d1 = _angular_distance(theta + np.pi, ref)
28
+ return theta if d0 <= d1 else (theta + np.pi)
29
+
30
+
31
+ def _rotate_inphase(X: FloatArray, Y: FloatArray, phi: float) -> FloatArray:
32
+ """Compute in-phase after rotation by phi."""
33
+ return X * np.cos(phi) + Y * np.sin(phi)
34
+
35
+
36
+ def _polar_to_IQ(r: FloatArray, theta: FloatArray) -> tuple[FloatArray, FloatArray]:
37
+ """Convert polar coordinates to IQ (X,Y) coordinates."""
38
+ X = r * np.cos(theta)
39
+ Y = r * np.sin(theta)
40
+ return X, Y
41
+
42
+
43
+ def _choose_branch_by_strongest_point(
44
+ theta: float, X: FloatArray, Y: FloatArray, strength: FloatArray | None = None
45
+ ) -> float:
46
+ """Choose between theta and theta+pi so the strongest sample projects positive on the in-phase axis.
47
+
48
+ strength can be r or r² because only ordering matters
49
+ """
50
+ if strength is None:
51
+ strength = X * X + Y * Y
52
+ k = int(np.argmax(strength))
53
+ proj = X[k] * np.cos(theta) + Y[k] * np.sin(theta) # in-phase at strongest point
54
+ return theta if proj >= 0 else (theta + np.pi)
55
+
56
+
57
+ def _eigenvalues_symmetric_2D(
58
+ Sxx: float, Sxy: float, Syy: float
59
+ ) -> tuple[float, float]:
60
+ """Compute the eigenvalues of a symmetric 2D matrix."""
61
+ trace = Sxx + Syy
62
+ D = float(np.sqrt((Sxx - Syy) ** 2 + 4.0 * (Sxy**2)))
63
+ eig1 = 0.5 * (trace + D)
64
+ eig2 = 0.5 * (trace - D)
65
+ # Numerical hygiene: symmetric PSD should be >=0, but float noise can create tiny negatives
66
+ eig1 = float(max(eig1, 0.0))
67
+ eig2 = float(max(eig2, 0.0))
68
+ return eig1, eig2
69
+
70
+
71
+ def _estimate_IQ_phase(
72
+ X: FloatArray, Y: FloatArray, w: FloatArray | None = None
73
+ ) -> tuple[float, float]:
74
+ """Estimate the constant lock-in phase (orientation, modulo π) from many (X, Y) lock-in samples by fitting the dominant axis of the IQ cloud.
75
+
76
+ This treats each sample as a point in the IQ plane: z_i = X_i + j Y_i, and
77
+ finds the rotation angle φ such that rotating all points by -φ makes the
78
+ energy in the quadrature channel minimal (equivalently: maximizes the energy
79
+ along the in-phase axis). Because this estimates an *axis* rather than a
80
+ directed angle, the result is naturally defined modulo π.
81
+
82
+ Estimates phase by calculating a weighted PCA / eigenvector estimate of the first
83
+ principal component direction in 2D. The angle of this direction is the estimated
84
+ lock-in phase.
85
+ """
86
+ # Weighted second moment matrix components - a weighted, scaled covariance matrix without mean subtraction
87
+ if w is None:
88
+ w = X * X + Y * Y
89
+
90
+ Sxx = np.sum(w * X * X)
91
+ Sxy = np.sum(w * X * Y)
92
+ Syy = np.sum(w * Y * Y)
93
+
94
+ # weighted cov matrix defines an ellipse; find its principal axis angle (see: https://en.wikipedia.org/wiki/Ellipse#General_ellipse)
95
+ phi_estimate = 0.5 * np.arctan2(2.0 * Sxy, (Sxx - Syy))
96
+
97
+ # The squareroot of the eigenvalues correspond to the semi-axis lengths of the ellipse defined by the weighted cov matrix
98
+ # When very line-like, we've succesfully mapped all signal into one axis, corresponding to high confidence in phase estimate
99
+ eig1, eig2 = _eigenvalues_symmetric_2D(Sxx, Sxy, Syy)
100
+
101
+ # If the matrix is (near) zero or non-finite, the phase is not meaningful
102
+ if not np.isfinite(eig1) or not np.isfinite(eig2) or eig1 <= 0.0:
103
+ return phi_estimate, 0.0
104
+
105
+ axis_ratio = float(np.sqrt(eig2 / eig1)) # in [0, 1] ideally
106
+ if not np.isfinite(axis_ratio):
107
+ return phi_estimate, 0.0
108
+
109
+ confidence = np.clip(1.0 - axis_ratio, 0, 1) # 0 (circle) to 1 (line)
110
+
111
+ return phi_estimate, confidence
112
+
113
+
114
+ class _LockinPhaseEstimator:
115
+ def __init__(
116
+ self: _LockinPhaseEstimator,
117
+ confidence_threshold: float = 0.8,
118
+ initial_phase_estimate: float | None = None,
119
+ ) -> None:
120
+ """Initialize the lock-in phase estimator.
121
+
122
+ Args:
123
+ confidence_threshold: Minimum confidence (0-1) required to update phase estimate.
124
+ initial_phase_estimate: Optional initial phase in radians. If provided, sets the
125
+ initial phase estimate to this value (wrapped to [-π, π]).
126
+ """
127
+ self.confidence_threshold = confidence_threshold
128
+ if initial_phase_estimate is not None:
129
+ self.phase_estimate = _wrap_to_pi(float(initial_phase_estimate))
130
+ else:
131
+ self.phase_estimate: float | None = None # Phase estimate in radians
132
+
133
+ def update_estimate(
134
+ self: _LockinPhaseEstimator, Xs: FloatArray, Ys: FloatArray
135
+ ) -> None:
136
+ """Update the phase estimate based on new in-phase (X) and quadrature (Y) data.
137
+
138
+ Args:
139
+ Xs: Array of in-phase (X) values from the lock-in amplifier.
140
+ Ys: Array of quadrature (Y) values from the lock-in amplifier.
141
+ """
142
+ r_squared = Xs * Xs + Ys * Ys
143
+ phase_estimate, confidence = _estimate_IQ_phase(Xs, Ys, r_squared)
144
+
145
+ # Don't update if confidence is too low
146
+ if confidence < self.confidence_threshold:
147
+ return
148
+
149
+ # First estimate
150
+ if self.phase_estimate is None:
151
+ self._set_estimate(
152
+ _choose_branch_by_strongest_point(phase_estimate, Xs, Ys, r_squared)
153
+ )
154
+ return
155
+
156
+ # Resolve the pi ambiguity using previous estimate
157
+ branched_phase_estimate = _choose_pi_branch(phase_estimate, self.phase_estimate)
158
+ self._set_estimate(branched_phase_estimate)
159
+
160
+ def _set_estimate(self: _LockinPhaseEstimator, phase: float) -> None:
161
+ self.phase_estimate = _wrap_to_pi(float(phase))
@@ -3,7 +3,7 @@ from __future__ import annotations
3
3
  import time
4
4
  from dataclasses import dataclass
5
5
  from functools import wraps
6
- from typing import TYPE_CHECKING, Callable, cast
6
+ from typing import TYPE_CHECKING, Callable
7
7
 
8
8
  import serial
9
9
  import serial.tools.list_ports
@@ -57,19 +57,20 @@ class _BackoffRetry:
57
57
 
58
58
  @wraps(func)
59
59
  def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
60
+ func_name = getattr(func, "__name__", "function")
60
61
  for tries in range(self.max_tries - 1):
61
62
  try:
62
- return cast("T", func(*args, **kwargs))
63
+ return func(*args, **kwargs)
63
64
  except (KeyboardInterrupt, SystemExit):
64
65
  raise
65
66
  except Exception as e: # noqa: BLE001
66
67
  self._log(
67
- f"{func.__name__} failed {tries + 1} time(s) with: '{e}'. Trying again"
68
+ f"{func_name} failed {tries + 1} time(s) with: '{e}'. Trying again"
68
69
  )
69
70
  backoff = min(self.backoff_base * 2**tries, self.max_backoff)
70
71
  time.sleep(backoff)
71
- self._log(f"{func.__name__}: Last try ({tries + 2}).")
72
- return cast("T", func(*args, **kwargs))
72
+ self._log(f"{func_name}: Last try ({tries + 2}).")
73
+ return func(*args, **kwargs)
73
74
 
74
75
  return wrapper
75
76
 
@@ -1,7 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from dataclasses import dataclass, field
4
- from datetime import datetime
4
+ from datetime import datetime, timezone
5
5
  from multiprocessing import Event, Pipe, Process, Queue, synchronize
6
6
  from queue import Empty, Full
7
7
  from typing import TYPE_CHECKING
@@ -45,20 +45,38 @@ class _AsyncScanner:
45
45
  _SCAN_TIMEOUT: float = field(init=False)
46
46
  _stop_signal: synchronize.Event = field(init=False)
47
47
  _scanner_conn: Connection = field(init=False)
48
+ _initial_phase_estimate: float | None = field(init=False, default=None)
49
+ _cached_phase_estimate: float | None = field(init=False, default=None)
48
50
 
49
- def start_scan(self: _AsyncScanner, config: DeviceConfiguration) -> None:
51
+ def start_scan(
52
+ self: _AsyncScanner,
53
+ config: DeviceConfiguration,
54
+ initial_phase_estimate: float | None = None,
55
+ ) -> None:
50
56
  """Starts continuously scanning in new process.
51
57
 
52
58
  Args:
53
- config: Device configurtaion
59
+ config: Device configuration
60
+ initial_phase_estimate: Optional initial phase estimate in radians for lock-in detection.
61
+ Use this to maintain consistent polarity across scanner instances.
54
62
  """
63
+ self._initial_phase_estimate = initial_phase_estimate
64
+ self._cached_phase_estimate = (
65
+ initial_phase_estimate # Initialize cache with initial value
66
+ )
55
67
  self._SCAN_TIMEOUT = config._sweep_length_ms * 2e-3 + 1 # noqa: SLF001, access to private attribute for backwards compatibility
56
68
  self._shared_mem = Queue(maxsize=self.queue_maxsize)
57
69
  self._stop_signal = Event()
58
70
  self._scanner_conn, child_conn = Pipe()
59
71
  self._child_process = Process(
60
72
  target=_AsyncScanner._run_scanner,
61
- args=[config, self._shared_mem, self._stop_signal, child_conn],
73
+ args=[
74
+ config,
75
+ self._shared_mem,
76
+ self._stop_signal,
77
+ child_conn,
78
+ initial_phase_estimate,
79
+ ],
62
80
  )
63
81
  self._child_process.start()
64
82
 
@@ -90,7 +108,7 @@ class _AsyncScanner:
90
108
  self.is_scanning = False
91
109
 
92
110
  def get_scans(self: _AsyncScanner, n_pulses: int) -> list[UnprocessedWaveform]:
93
- call_time = datetime.now() # noqa: DTZ005
111
+ call_time = datetime.now(tz=timezone.utc)
94
112
  stamped_pulse = self._get_scan()
95
113
 
96
114
  while stamped_pulse.timestamp < call_time:
@@ -114,9 +132,23 @@ class _AsyncScanner:
114
132
 
115
133
  return self._metadata.firmware_version
116
134
 
135
+ def get_phase_estimate(self: _AsyncScanner) -> float | None:
136
+ """Get the current phase estimate from the scanner.
137
+
138
+ Returns the cached phase estimate from the most recently received waveform.
139
+ This method returns instantly without blocking and can be called even after
140
+ the scanner has stopped, allowing phase estimates to be extracted and reused.
141
+
142
+ Returns:
143
+ float | None: The current phase estimate in radians, or None if not yet estimated.
144
+ """
145
+ return self._cached_phase_estimate
146
+
117
147
  def _get_scan(self: _AsyncScanner) -> _TimestampedWaveform:
118
148
  try:
119
- return self._shared_mem.get(timeout=self._SCAN_TIMEOUT)
149
+ waveform = self._shared_mem.get(timeout=self._SCAN_TIMEOUT)
150
+ # Cache the phase estimate from this waveform
151
+ self._cached_phase_estimate = waveform.phase_estimate
120
152
  except Exception as err:
121
153
  scanner_err: Exception | None = None
122
154
  if self._scanner_conn.poll(timeout=self.startup_timeout):
@@ -127,6 +159,8 @@ class _AsyncScanner:
127
159
  if scanner_err:
128
160
  raise scanner_err from err
129
161
  raise
162
+ else:
163
+ return waveform
130
164
 
131
165
  @staticmethod
132
166
  def _run_scanner(
@@ -134,9 +168,12 @@ class _AsyncScanner:
134
168
  shared_mem: Queue[_TimestampedWaveform],
135
169
  stop_signal: synchronize.Event,
136
170
  parent_conn: Connection,
171
+ initial_phase_estimate: float | None = None,
137
172
  ) -> None:
138
173
  try:
139
- scanner = Scanner(config=config)
174
+ scanner = Scanner(
175
+ config=config, initial_phase_estimate=initial_phase_estimate
176
+ )
140
177
  device_metadata = _ScannerMetadata(
141
178
  serial_number=scanner.get_serial_number(),
142
179
  firmware_version=scanner.get_firmware_version(),
@@ -149,7 +186,13 @@ class _AsyncScanner:
149
186
 
150
187
  while not stop_signal.is_set():
151
188
  try:
152
- waveform = _TimestampedWaveform(datetime.now(), scanner.scan()) # noqa: DTZ005
189
+ scanned_waveform = scanner.scan()
190
+ phase = scanner.get_phase_estimate()
191
+ waveform = _TimestampedWaveform(
192
+ datetime.now(tz=timezone.utc),
193
+ scanned_waveform,
194
+ phase,
195
+ )
153
196
  except Exception as e: # noqa: BLE001
154
197
  parent_conn.send(
155
198
  _ScannerHealth(is_alive=False, is_healthy=False, error=e)
@@ -28,16 +28,19 @@ class GlazeClient:
28
28
 
29
29
  Args:
30
30
  config: Configuration to use for scans
31
+ initial_phase_estimate: Optional initial phase estimate in radians for lock-in detection.
32
+ Use this to maintain consistent polarity across scanning sessions.
31
33
  """
32
34
 
33
35
  config: DeviceConfiguration
36
+ initial_phase_estimate: float | None = None
34
37
  _scanner: _AsyncScanner = field(init=False)
35
38
 
36
39
  def __enter__(self: Self) -> Self:
37
40
  """Start the scanner and return the client."""
38
41
  self._scanner = _AsyncScanner()
39
42
  try:
40
- self._scanner.start_scan(self.config)
43
+ self._scanner.start_scan(self.config, self.initial_phase_estimate)
41
44
  except (TimeoutError, serialutil.SerialException) as e:
42
45
  self.__exit__(e)
43
46
  return self
@@ -73,3 +76,21 @@ class GlazeClient:
73
76
  except AttributeError as e:
74
77
  msg = "No connection to device."
75
78
  raise SerialException(msg) from e
79
+
80
+ def get_phase_estimate(self: GlazeClient) -> float | None:
81
+ """Get the current phase estimate from the lock-in phase estimator.
82
+
83
+ Can be called even after the client has been stopped, allowing phase estimates
84
+ to be extracted and reused for maintaining consistent polarity across sessions.
85
+
86
+ Returns:
87
+ float | None: The current phase estimate in radians, or None if not yet estimated.
88
+
89
+ Raises:
90
+ SerialException: If the scanner was never started.
91
+ """
92
+ try:
93
+ return self._scanner.get_phase_estimate()
94
+ except AttributeError as e:
95
+ msg = "No connection to device."
96
+ raise SerialException(msg) from e
@@ -1,18 +1,14 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from abc import ABC, abstractmethod
4
- from typing import TYPE_CHECKING, Generic, TypeVar
5
-
6
- import numpy as np
4
+ from typing import Generic, TypeVar
7
5
 
8
6
  from pyglaze.datamodels import UnprocessedWaveform
9
7
  from pyglaze.device.ampcom import _LeAmpCom
10
8
  from pyglaze.device.configuration import DeviceConfiguration, LeDeviceConfiguration
9
+ from pyglaze.helpers._lockin import _LockinPhaseEstimator
11
10
  from pyglaze.scanning._exceptions import ScanError
12
11
 
13
- if TYPE_CHECKING:
14
- from pyglaze.helpers._types import FloatArray
15
-
16
12
  TConfig = TypeVar("TConfig", bound=DeviceConfiguration)
17
13
 
18
14
 
@@ -51,13 +47,27 @@ class _ScannerImplementation(ABC, Generic[TConfig]):
51
47
  def get_firmware_version(self: _ScannerImplementation) -> str:
52
48
  pass
53
49
 
50
+ @abstractmethod
51
+ def get_phase_estimate(self: _ScannerImplementation) -> float | None:
52
+ pass
53
+
54
54
 
55
55
  class Scanner:
56
- """A synchronous scanner for Glaze terahertz devices."""
56
+ """A synchronous scanner for Glaze terahertz devices.
57
+
58
+ Args:
59
+ config: Device configuration for the scanner.
60
+ initial_phase_estimate: Optional initial phase estimate in radians for lock-in detection.
61
+ Use this to maintain consistent polarity across scanner instances.
62
+ """
57
63
 
58
- def __init__(self: Scanner, config: TConfig) -> None:
64
+ def __init__(
65
+ self: Scanner,
66
+ config: TConfig,
67
+ initial_phase_estimate: float | None = None,
68
+ ) -> None:
59
69
  self._scanner_impl: _ScannerImplementation[DeviceConfiguration] = (
60
- _scanner_factory(config)
70
+ _scanner_factory(config, initial_phase_estimate)
61
71
  )
62
72
 
63
73
  @property
@@ -105,19 +115,35 @@ class Scanner:
105
115
  """
106
116
  return self._scanner_impl.get_firmware_version()
107
117
 
118
+ def get_phase_estimate(self: Scanner) -> float | None:
119
+ """Get the current phase estimate from the lock-in phase estimator.
120
+
121
+ Returns:
122
+ float | None: The current phase estimate in radians, or None if not yet estimated.
123
+ """
124
+ return self._scanner_impl.get_phase_estimate()
125
+
108
126
 
109
127
  class LeScanner(_ScannerImplementation[LeDeviceConfiguration]):
110
128
  """Perform synchronous terahertz scanning using a given DeviceConfiguration.
111
129
 
112
130
  Args:
113
131
  config: A DeviceConfiguration to use for the scan.
132
+ initial_phase_estimate: Optional initial phase estimate in radians for lock-in detection.
133
+ Use this to maintain consistent polarity across scanner instances.
114
134
  """
115
135
 
116
- def __init__(self: LeScanner, config: LeDeviceConfiguration) -> None:
136
+ def __init__(
137
+ self: LeScanner,
138
+ config: LeDeviceConfiguration,
139
+ initial_phase_estimate: float | None = None,
140
+ ) -> None:
117
141
  self._config: LeDeviceConfiguration
118
142
  self._ampcom: _LeAmpCom | None = None
119
143
  self.config = config
120
- self._phase_estimator = _LockinPhaseEstimator()
144
+ self._phase_estimator = _LockinPhaseEstimator(
145
+ initial_phase_estimate=initial_phase_estimate
146
+ )
121
147
 
122
148
  @property
123
149
  def config(self: LeScanner) -> LeDeviceConfiguration:
@@ -154,11 +180,10 @@ class LeScanner(_ScannerImplementation[LeDeviceConfiguration]):
154
180
  if self._ampcom is None:
155
181
  msg = "Scanner not configured"
156
182
  raise ScanError(msg)
157
- _, time, radius, theta = self._ampcom.start_scan()
158
- self._phase_estimator.update_estimate(radius=radius, theta=theta)
159
-
160
- return UnprocessedWaveform.from_polar_coords(
161
- time, radius, theta, self._phase_estimator.phase_estimate
183
+ _, time, Xs, Ys = self._ampcom.start_scan()
184
+ self._phase_estimator.update_estimate(Xs=Xs, Ys=Ys)
185
+ return UnprocessedWaveform.from_inphase_quadrature(
186
+ time, Xs, Ys, self._phase_estimator.phase_estimate
162
187
  )
163
188
 
164
189
  def update_config(self: LeScanner, new_config: LeDeviceConfiguration) -> None:
@@ -199,45 +224,20 @@ class LeScanner(_ScannerImplementation[LeDeviceConfiguration]):
199
224
  raise ScanError(msg)
200
225
  return self._ampcom.get_firmware_version()
201
226
 
227
+ def get_phase_estimate(self: LeScanner) -> float | None:
228
+ """Get the current phase estimate from the lock-in phase estimator.
229
+
230
+ Returns:
231
+ float | None: The current phase estimate in radians, or None if not yet estimated.
232
+ """
233
+ return self._phase_estimator.phase_estimate
234
+
202
235
 
203
- def _scanner_factory(config: DeviceConfiguration) -> _ScannerImplementation:
236
+ def _scanner_factory(
237
+ config: DeviceConfiguration, initial_phase_estimate: float | None = None
238
+ ) -> _ScannerImplementation:
204
239
  if isinstance(config, LeDeviceConfiguration):
205
- return LeScanner(config)
240
+ return LeScanner(config, initial_phase_estimate)
206
241
 
207
242
  msg = f"Unsupported configuration type: {type(config).__name__}"
208
243
  raise TypeError(msg)
209
-
210
-
211
- class _LockinPhaseEstimator:
212
- def __init__(
213
- self: _LockinPhaseEstimator,
214
- r_threshold_for_update: float = 2.0,
215
- theta_threshold_for_adjustment: float = 1.0,
216
- ) -> None:
217
- self.r_threshold_for_update = r_threshold_for_update
218
- self.theta_threshold_for_adjustment = theta_threshold_for_adjustment
219
- self.phase_estimate: float | None = None
220
- self._radius_of_est: float | None = None
221
-
222
- def update_estimate(
223
- self: _LockinPhaseEstimator, radius: FloatArray, theta: FloatArray
224
- ) -> None:
225
- r_argmax = np.argmax(radius)
226
- r_max = radius[r_argmax]
227
- theta_at_max = theta[r_argmax]
228
- if self._radius_of_est is None:
229
- self._set_estimates(theta_at_max, r_max)
230
- return
231
-
232
- if r_max > self.r_threshold_for_update * self._radius_of_est or (
233
- r_max > self._radius_of_est
234
- and abs(theta_at_max - self.phase_estimate)
235
- < self.theta_threshold_for_adjustment
236
- ):
237
- self._set_estimates(theta_at_max, r_max)
238
-
239
- def _set_estimates(
240
- self: _LockinPhaseEstimator, phase: float, radius: float
241
- ) -> None:
242
- self.phase_estimate = phase
243
- self._radius_of_est = radius
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyglaze
3
- Version: 0.4.6
3
+ Version: 0.5.0
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
@@ -38,11 +38,34 @@ Project-URL: Issues, https://github.com/GlazeTech/pyglaze/issues
38
38
  Requires-Python: <3.14,>=3.9
39
39
  Description-Content-Type: text/markdown
40
40
  License-File: LICENSE
41
- Requires-Dist: numpy>=1.26.4
41
+ Requires-Dist: numpy<2.0.0,>=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
+ Provides-Extra: build
47
+ Requires-Dist: build>=1.2.1; extra == "build"
48
+ Requires-Dist: twine>=5.1.1; extra == "build"
49
+ Requires-Dist: bumpver>=2023.1129; extra == "build"
50
+ Provides-Extra: test
51
+ Requires-Dist: pytest>=7.0.1; extra == "test"
52
+ Requires-Dist: pytest-cov>=4.0.0; extra == "test"
53
+ Provides-Extra: docs
54
+ Requires-Dist: mkdocs>=1.2.3; extra == "docs"
55
+ Requires-Dist: mkdocs-material>=8.2.5; extra == "docs"
56
+ Requires-Dist: mkdocstrings[python]>=0.18.1; extra == "docs"
57
+ Requires-Dist: mike>=1.1.2; extra == "docs"
58
+ Requires-Dist: Pygments>=2.12.0; extra == "docs"
59
+ Requires-Dist: matplotlib>=3.7.0; extra == "docs"
60
+ Requires-Dist: bumpver>=2023.1129; extra == "docs"
61
+ Provides-Extra: dev
62
+ Requires-Dist: ruff>=0.14.10; extra == "dev"
63
+ Requires-Dist: ty>=0.0.13; extra == "dev"
64
+ Requires-Dist: pytest>=7.0.1; extra == "dev"
65
+ Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
66
+ Requires-Dist: semver>=2.13.0; extra == "dev"
67
+ Requires-Dist: types-pyserial>=3.5.0.8; extra == "dev"
68
+ Requires-Dist: bumpver>=2023.1129; extra == "dev"
46
69
  Dynamic: license-file
47
70
 
48
71
  # Pyglaze
@@ -19,6 +19,7 @@ src/pyglaze/devtools/__init__.py
19
19
  src/pyglaze/devtools/mock_device.py
20
20
  src/pyglaze/devtools/thz_pulse.py
21
21
  src/pyglaze/helpers/__init__.py
22
+ src/pyglaze/helpers/_lockin.py
22
23
  src/pyglaze/helpers/_types.py
23
24
  src/pyglaze/helpers/utilities.py
24
25
  src/pyglaze/interpolation/__init__.py
@@ -0,0 +1,32 @@
1
+ numpy<2.0.0,>=1.26.4
2
+ pyserial>=3.5
3
+ scipy>=1.7.3
4
+ bitstring>=4.1.2
5
+ typing_extensions>=4.12.2
6
+
7
+ [build]
8
+ build>=1.2.1
9
+ twine>=5.1.1
10
+ bumpver>=2023.1129
11
+
12
+ [dev]
13
+ ruff>=0.14.10
14
+ ty>=0.0.13
15
+ pytest>=7.0.1
16
+ pytest-cov>=4.0.0
17
+ semver>=2.13.0
18
+ types-pyserial>=3.5.0.8
19
+ bumpver>=2023.1129
20
+
21
+ [docs]
22
+ mkdocs>=1.2.3
23
+ mkdocs-material>=8.2.5
24
+ mkdocstrings[python]>=0.18.1
25
+ mike>=1.1.2
26
+ Pygments>=2.12.0
27
+ matplotlib>=3.7.0
28
+ bumpver>=2023.1129
29
+
30
+ [test]
31
+ pytest>=7.0.1
32
+ pytest-cov>=4.0.0
@@ -1 +0,0 @@
1
- __version__ = "0.4.6"
@@ -1,5 +0,0 @@
1
- numpy>=1.26.4
2
- pyserial>=3.5
3
- scipy>=1.7.3
4
- bitstring>=4.1.2
5
- typing_extensions>=4.12.2
File without changes
File without changes
File without changes
File without changes
File without changes