pyglaze 0.4.5__tar.gz → 0.4.7__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.4.5/src/pyglaze.egg-info → pyglaze-0.4.7}/PKG-INFO +25 -2
- {pyglaze-0.4.5 → pyglaze-0.4.7}/pyproject.toml +38 -3
- pyglaze-0.4.7/src/pyglaze/__init__.py +1 -0
- {pyglaze-0.4.5 → pyglaze-0.4.7}/src/pyglaze/datamodels/pulse.py +3 -3
- {pyglaze-0.4.5 → pyglaze-0.4.7}/src/pyglaze/datamodels/waveform.py +30 -7
- {pyglaze-0.4.5 → pyglaze-0.4.7}/src/pyglaze/device/ampcom.py +1 -10
- pyglaze-0.4.7/src/pyglaze/helpers/_lockin.py +150 -0
- {pyglaze-0.4.5 → pyglaze-0.4.7}/src/pyglaze/helpers/utilities.py +6 -5
- {pyglaze-0.4.5 → pyglaze-0.4.7}/src/pyglaze/scanning/scanner.py +6 -46
- {pyglaze-0.4.5 → pyglaze-0.4.7/src/pyglaze.egg-info}/PKG-INFO +25 -2
- {pyglaze-0.4.5 → pyglaze-0.4.7}/src/pyglaze.egg-info/SOURCES.txt +1 -0
- pyglaze-0.4.7/src/pyglaze.egg-info/requires.txt +32 -0
- pyglaze-0.4.5/src/pyglaze/__init__.py +0 -1
- pyglaze-0.4.5/src/pyglaze.egg-info/requires.txt +0 -5
- {pyglaze-0.4.5 → pyglaze-0.4.7}/LICENSE +0 -0
- {pyglaze-0.4.5 → pyglaze-0.4.7}/MANIFEST.in +0 -0
- {pyglaze-0.4.5 → pyglaze-0.4.7}/README.md +0 -0
- {pyglaze-0.4.5 → pyglaze-0.4.7}/setup.cfg +0 -0
- {pyglaze-0.4.5 → pyglaze-0.4.7}/src/pyglaze/datamodels/__init__.py +0 -0
- {pyglaze-0.4.5 → pyglaze-0.4.7}/src/pyglaze/device/__init__.py +0 -0
- {pyglaze-0.4.5 → pyglaze-0.4.7}/src/pyglaze/device/configuration.py +0 -0
- {pyglaze-0.4.5 → pyglaze-0.4.7}/src/pyglaze/devtools/__init__.py +0 -0
- {pyglaze-0.4.5 → pyglaze-0.4.7}/src/pyglaze/devtools/mock_device.py +0 -0
- {pyglaze-0.4.5 → pyglaze-0.4.7}/src/pyglaze/devtools/thz_pulse.py +0 -0
- {pyglaze-0.4.5 → pyglaze-0.4.7}/src/pyglaze/helpers/__init__.py +0 -0
- {pyglaze-0.4.5 → pyglaze-0.4.7}/src/pyglaze/helpers/_types.py +0 -0
- {pyglaze-0.4.5 → pyglaze-0.4.7}/src/pyglaze/interpolation/__init__.py +0 -0
- {pyglaze-0.4.5 → pyglaze-0.4.7}/src/pyglaze/interpolation/interpolation.py +0 -0
- {pyglaze-0.4.5 → pyglaze-0.4.7}/src/pyglaze/py.typed +0 -0
- {pyglaze-0.4.5 → pyglaze-0.4.7}/src/pyglaze/scanning/__init__.py +0 -0
- {pyglaze-0.4.5 → pyglaze-0.4.7}/src/pyglaze/scanning/_asyncscanner.py +0 -0
- {pyglaze-0.4.5 → pyglaze-0.4.7}/src/pyglaze/scanning/_exceptions.py +0 -0
- {pyglaze-0.4.5 → pyglaze-0.4.7}/src/pyglaze/scanning/client.py +0 -0
- {pyglaze-0.4.5 → pyglaze-0.4.7}/src/pyglaze.egg-info/dependency_links.txt +0 -0
- {pyglaze-0.4.5 → pyglaze-0.4.7}/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.
|
|
3
|
+
Version: 0.4.7
|
|
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
|
|
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.
|
|
3
|
+
version = "0.4.7"
|
|
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.
|
|
106
|
+
current_version = "0.4.7"
|
|
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.4.7"
|
|
@@ -62,7 +62,7 @@ class Pulse:
|
|
|
62
62
|
@property
|
|
63
63
|
def fft(self: Pulse) -> ComplexArray:
|
|
64
64
|
"""Return the Fourier Transform of a signal."""
|
|
65
|
-
return np.fft.rfft(self.signal, norm="forward")
|
|
65
|
+
return np.fft.rfft(self.signal, norm="forward", axis=0)
|
|
66
66
|
|
|
67
67
|
@property
|
|
68
68
|
def frequency(self: Pulse) -> FloatArray:
|
|
@@ -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.
|
|
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))
|
|
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
|
|
47
|
-
rotation_angle: The angle to rotate lockin signal to align along x-axis. If not given, will
|
|
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
|
-
|
|
50
|
-
|
|
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 -
|
|
55
|
-
signal = radius * np.cos(new_theta
|
|
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
|
|
@@ -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:
|
|
@@ -0,0 +1,150 @@
|
|
|
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
|
+
) -> None:
|
|
119
|
+
self.confidence_threshold = confidence_threshold
|
|
120
|
+
self.phase_estimate: float | None = None
|
|
121
|
+
|
|
122
|
+
def update_estimate(
|
|
123
|
+
self: _LockinPhaseEstimator, Xs: FloatArray, Ys: FloatArray
|
|
124
|
+
) -> None:
|
|
125
|
+
"""Update the phase estimate based on new in-phase (X) and quadrature (Y) data.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
Xs: Array of in-phase (X) values from the lock-in amplifier.
|
|
129
|
+
Ys: Array of quadrature (Y) values from the lock-in amplifier.
|
|
130
|
+
"""
|
|
131
|
+
r_squared = Xs * Xs + Ys * Ys
|
|
132
|
+
phase_estimate, confidence = _estimate_IQ_phase(Xs, Ys, r_squared)
|
|
133
|
+
|
|
134
|
+
# Don't update if confidence is too low
|
|
135
|
+
if confidence < self.confidence_threshold:
|
|
136
|
+
return
|
|
137
|
+
|
|
138
|
+
# First estimate
|
|
139
|
+
if self.phase_estimate is None:
|
|
140
|
+
self._set_estimate(
|
|
141
|
+
_choose_branch_by_strongest_point(phase_estimate, Xs, Ys, r_squared)
|
|
142
|
+
)
|
|
143
|
+
return
|
|
144
|
+
|
|
145
|
+
# Resolve the pi ambiguity using previous estimate
|
|
146
|
+
branched_phase_estimate = _choose_pi_branch(phase_estimate, self.phase_estimate)
|
|
147
|
+
self._set_estimate(branched_phase_estimate)
|
|
148
|
+
|
|
149
|
+
def _set_estimate(self: _LockinPhaseEstimator, phase: float) -> None:
|
|
150
|
+
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
|
|
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
|
|
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"{
|
|
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"{
|
|
72
|
-
return
|
|
72
|
+
self._log(f"{func_name}: Last try ({tries + 2}).")
|
|
73
|
+
return func(*args, **kwargs)
|
|
73
74
|
|
|
74
75
|
return wrapper
|
|
75
76
|
|
|
@@ -1,18 +1,14 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
from abc import ABC, abstractmethod
|
|
4
|
-
from typing import
|
|
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
|
|
|
@@ -154,11 +150,10 @@ class LeScanner(_ScannerImplementation[LeDeviceConfiguration]):
|
|
|
154
150
|
if self._ampcom is None:
|
|
155
151
|
msg = "Scanner not configured"
|
|
156
152
|
raise ScanError(msg)
|
|
157
|
-
_, time,
|
|
158
|
-
self._phase_estimator.update_estimate(
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
time, radius, theta, self._phase_estimator.phase_estimate
|
|
153
|
+
_, time, Xs, Ys = self._ampcom.start_scan()
|
|
154
|
+
self._phase_estimator.update_estimate(Xs=Xs, Ys=Ys)
|
|
155
|
+
return UnprocessedWaveform.from_inphase_quadrature(
|
|
156
|
+
time, Xs, Ys, self._phase_estimator.phase_estimate
|
|
162
157
|
)
|
|
163
158
|
|
|
164
159
|
def update_config(self: LeScanner, new_config: LeDeviceConfiguration) -> None:
|
|
@@ -206,38 +201,3 @@ def _scanner_factory(config: DeviceConfiguration) -> _ScannerImplementation:
|
|
|
206
201
|
|
|
207
202
|
msg = f"Unsupported configuration type: {type(config).__name__}"
|
|
208
203
|
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.
|
|
3
|
+
Version: 0.4.7
|
|
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
|
|
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.5"
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|