pyglaze 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,266 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from abc import ABC, abstractmethod
5
+ from dataclasses import asdict, dataclass, field
6
+ from typing import TYPE_CHECKING, ClassVar, TypeVar
7
+
8
+ from .delayunit import validate_delayunit
9
+
10
+ if TYPE_CHECKING:
11
+ from pathlib import Path
12
+
13
+ T = TypeVar("T", bound="DeviceConfiguration")
14
+
15
+
16
+ @dataclass
17
+ class Interval:
18
+ """An interval with a lower and upper bounds between 0 and 1 to scan."""
19
+
20
+ lower: float
21
+ upper: float
22
+
23
+ @property
24
+ def length(self: Interval) -> float:
25
+ """The length of the interval."""
26
+ return abs(self.upper - self.lower)
27
+
28
+ @classmethod
29
+ def from_dict(cls: type[Interval], d: dict) -> Interval:
30
+ """Create an instance of the Interval class from a dictionary.
31
+
32
+ Args:
33
+ d (dict): The dictionary containing the interval data.
34
+
35
+ Returns:
36
+ Interval: An instance of the Interval class.
37
+ """
38
+ return cls(**d)
39
+
40
+ def __post_init__(self: Interval) -> None: # noqa: D105
41
+ if not 0.0 <= self.lower <= 1.0:
42
+ msg = "Interval: Bounds must be between 0 and 1"
43
+ raise ValueError(msg)
44
+ if not 0.0 <= self.upper <= 1.0:
45
+ msg = "Interval: Bounds must be between 0 and 1"
46
+ raise ValueError(msg)
47
+ if self.upper == self.lower:
48
+ msg = "Interval: Bounds cannot be equal"
49
+ raise ValueError(msg)
50
+
51
+
52
+ class DeviceConfiguration(ABC):
53
+ """Base class for device configurations."""
54
+
55
+ amp_timeout_seconds: float
56
+ amp_port: str
57
+ amp_baudrate: ClassVar[int]
58
+
59
+ @property
60
+ @abstractmethod
61
+ def _sweep_length_ms(self: DeviceConfiguration) -> float:
62
+ """The length of the sweep in milliseconds."""
63
+
64
+ @abstractmethod
65
+ def save(self: DeviceConfiguration, path: Path) -> str:
66
+ """Save a DeviceConfiguration to a file."""
67
+
68
+ @classmethod
69
+ @abstractmethod
70
+ def from_dict(cls: type[T], amp_config: dict) -> T:
71
+ """Create a DeviceConfiguration from a dict."""
72
+
73
+ @classmethod
74
+ @abstractmethod
75
+ def load(cls: type[T], file_path: Path) -> T:
76
+ """Load a DeviceConfiguration from a file."""
77
+
78
+
79
+ @dataclass
80
+ class ForceDeviceConfiguration(DeviceConfiguration):
81
+ """Represents a configuration that can be sent to the lock-in amp for scans.
82
+
83
+ Args:
84
+ amp_port: The name of the serial port the amp is connected to.
85
+ sweep_length_ms: The length of the sweep in milliseconds.
86
+ delayunit: Name of the delay calculator.
87
+ scan_intervals: The intervals to scan.
88
+ integration_periods: The number of integration periods to use.
89
+ modulation_frequency: The frequency of the modulation in Hz.
90
+ dac_lower_bound: The lower bound of the modulation voltage in bits.
91
+ dac_upper_bound: The upper bound of the modulation voltage in bits.
92
+ min_modulation_voltage: The minimum modulation voltage in volts.
93
+ max_modulation_voltage: The maximum modulation voltage in volts.
94
+ modulation_waveform: The waveform to use for modulation.
95
+ amp_timeout_seconds: The timeout for the amp in seconds.
96
+ """
97
+
98
+ amp_port: str
99
+ sweep_length_ms: float
100
+ delayunit: str
101
+ scan_intervals: list[Interval] = field(default_factory=lambda: [Interval(0.0, 1.0)])
102
+ integration_periods: int = 100
103
+ modulation_frequency: int = 10000 # Hz
104
+ dac_lower_bound: int = 6400
105
+ dac_upper_bound: int = 59300
106
+ min_modulation_voltage: float = -1.0 # V
107
+ max_modulation_voltage: float = 0.5 # V
108
+ modulation_waveform: str = "square"
109
+ amp_timeout_seconds: float = 0.05
110
+
111
+ amp_baudrate: ClassVar[int] = 1200000 # bit/s
112
+
113
+ @property
114
+ def _sweep_length_ms(self: ForceDeviceConfiguration) -> float:
115
+ return self.sweep_length_ms
116
+
117
+ def __post_init__(self: ForceDeviceConfiguration) -> None: # noqa: D105
118
+ validate_delayunit(self.delayunit)
119
+
120
+ def save(self: ForceDeviceConfiguration, path: Path) -> str:
121
+ """Save a DeviceConfiguration to a file.
122
+
123
+ Args:
124
+ path: The path to save the configuration to.
125
+
126
+ Returns:
127
+ str: Final path component of the saved file, without the extension.
128
+
129
+ """
130
+ with path.open("w") as f:
131
+ json.dump(asdict(self), f, indent=4, sort_keys=True)
132
+
133
+ return path.stem
134
+
135
+ @classmethod
136
+ def from_dict(
137
+ cls: type[ForceDeviceConfiguration], amp_config: dict
138
+ ) -> ForceDeviceConfiguration:
139
+ """Create a DeviceConfiguration from a dict.
140
+
141
+ Args:
142
+ amp_config: An amp configuration in dict form.
143
+
144
+ Raises:
145
+ ValueError: If the dictionary is empty.
146
+
147
+ Returns:
148
+ DeviceConfiguration: A DeviceConfiguration object.
149
+ """
150
+ return _config_w_intervals_from_dict(cls, amp_config)
151
+
152
+ @classmethod
153
+ def load(
154
+ cls: type[ForceDeviceConfiguration], file_path: Path
155
+ ) -> ForceDeviceConfiguration:
156
+ """Load a DeviceConfiguration from a file.
157
+
158
+ Args:
159
+ file_path: The path to the file to load.
160
+
161
+ Returns:
162
+ DeviceConfiguration: A DeviceConfiguration object.
163
+ """
164
+ with file_path.open() as f:
165
+ configuration_dict = json.load(f)
166
+ return cls.from_dict(configuration_dict)
167
+
168
+
169
+ @dataclass
170
+ class LeDeviceConfiguration(DeviceConfiguration):
171
+ """Represents a configuration that can be sent to a Le-type lock-in amp for scans.
172
+
173
+ Args:
174
+ amp_port: The name of the serial port the amp is connected to.
175
+ delayunit: Name of the delay calculator.
176
+ use_ema: Whether to use en exponentially moving average filter during lockin detection.
177
+ n_points: The number of points to scan.
178
+ scan_intervals: The intervals to scan.
179
+ integration_periods: The number of integration periods per datapoint to use.
180
+ amp_timeout_seconds: The timeout for the connection to the amp in seconds.
181
+ """
182
+
183
+ amp_port: str
184
+ delayunit: str
185
+ use_ema: bool = True
186
+ n_points: int = 1000
187
+ scan_intervals: list[Interval] = field(default_factory=lambda: [Interval(0.0, 1.0)])
188
+ integration_periods: int = 10
189
+ amp_timeout_seconds: float = 0.2
190
+
191
+ modulation_frequency: ClassVar[int] = 10000 # Hz
192
+ fs_dac_lower_bound: ClassVar[int] = 300
193
+ fs_dac_upper_bound: ClassVar[int] = 3700
194
+ amp_baudrate: ClassVar[int] = 1000000 # bit/s
195
+
196
+ def __post_init__(self: LeDeviceConfiguration) -> None: # noqa: D105
197
+ validate_delayunit(self.delayunit)
198
+
199
+ @property
200
+ def _sweep_length_ms(self: LeDeviceConfiguration) -> float:
201
+ return self.n_points * self._time_constant_ms
202
+
203
+ @property
204
+ def _time_constant_ms(self: LeDeviceConfiguration) -> float:
205
+ return 1e3 * self.integration_periods / self.modulation_frequency
206
+
207
+ def save(self: LeDeviceConfiguration, path: Path) -> str:
208
+ """Save a LeDeviceConfiguration to a file.
209
+
210
+ Args:
211
+ path: The path to save the configuration to.
212
+
213
+ Returns:
214
+ str: Final path component of the saved file, without the extension.
215
+
216
+ """
217
+ with path.open("w") as f:
218
+ json.dump(asdict(self), f, indent=4, sort_keys=True)
219
+
220
+ return path.stem
221
+
222
+ @classmethod
223
+ def from_dict(
224
+ cls: type[LeDeviceConfiguration], amp_config: dict
225
+ ) -> LeDeviceConfiguration:
226
+ """Create a LeDeviceConfiguration from a dict.
227
+
228
+ Args:
229
+ amp_config: An amp configuration in dict form.
230
+
231
+ Raises:
232
+ ValueError: If the dictionary is empty.
233
+
234
+ Returns:
235
+ DeviceConfiguration: A DeviceConfiguration object.
236
+ """
237
+ return _config_w_intervals_from_dict(cls, amp_config)
238
+
239
+ @classmethod
240
+ def load(
241
+ cls: type[LeDeviceConfiguration], file_path: Path
242
+ ) -> LeDeviceConfiguration:
243
+ """Load a LeDeviceConfiguration from a file.
244
+
245
+ Args:
246
+ file_path: The path to the file to load.
247
+
248
+ Returns:
249
+ DeviceConfiguration: A DeviceConfiguration object.
250
+ """
251
+ with file_path.open() as f:
252
+ configuration_dict = json.load(f)
253
+ return cls.from_dict(configuration_dict)
254
+
255
+
256
+ C = TypeVar("C", LeDeviceConfiguration, ForceDeviceConfiguration)
257
+
258
+
259
+ def _config_w_intervals_from_dict(cls: type[C], amp_config: dict) -> C:
260
+ if amp_config:
261
+ config = cls(**amp_config)
262
+ config.scan_intervals = [Interval.from_dict(d) for d in config.scan_intervals] # type: ignore[arg-type]
263
+ return config
264
+
265
+ msg = "'amp_config' is empty."
266
+ raise ValueError(msg)
@@ -0,0 +1,151 @@
1
+ from __future__ import annotations
2
+
3
+ import pickle
4
+ from abc import ABC, abstractmethod
5
+ from dataclasses import asdict, dataclass
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+ from typing import TYPE_CHECKING, Callable, cast
9
+ from uuid import UUID, uuid4
10
+
11
+ import numpy as np
12
+
13
+ if TYPE_CHECKING:
14
+ from pyglaze.helpers.types import FloatArray
15
+
16
+ __all__ = ["UniformDelay", "NonuniformDelay", "list_delayunits", "load_delayunit"]
17
+
18
+ _DELAYUNITS_PATH = Path(__file__).parent / "_delayunit_data"
19
+
20
+
21
+ def validate_delayunit(name: str) -> None:
22
+ delayunits = list_delayunits()
23
+ if name not in delayunits:
24
+ msg = f"Unknown delayunit '{name}'. Valid options are: {', '.join(delayunits)}."
25
+ raise ValueError(msg)
26
+
27
+
28
+ def list_delayunits() -> list[str]:
29
+ """List all available delayunits.
30
+
31
+ Returns:
32
+ A list of all available delayunits.
33
+
34
+ """
35
+ return [delayunit.stem for delayunit in _DELAYUNITS_PATH.iterdir()]
36
+
37
+
38
+ def load_delayunit(name: str) -> Delay:
39
+ """Load a delayunit by name.
40
+
41
+ Args:
42
+ name: The name of the delayunit to load.
43
+
44
+ Returns:
45
+ The loaded delayunit.
46
+ """
47
+ try:
48
+ return _load_delayunit_from_path(_DELAYUNITS_PATH / f"{name}.pickle")
49
+ except FileNotFoundError as e:
50
+ msg = f"Unknown delayunit requested ('{name}'). Known units are: {list_delayunits()}"
51
+ raise NameError(msg) from e
52
+
53
+
54
+ def _load_delayunit_from_path(path: Path) -> Delay:
55
+ with Path(path).open("rb") as f:
56
+ _dict: dict = pickle.load(f)
57
+ delay_type = _dict.pop("type")
58
+ return cast(Delay, globals()[delay_type](**_dict))
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class Delay(ABC):
63
+ friendly_name: str
64
+ unique_id: UUID
65
+ creation_time: datetime
66
+
67
+ def __call__(self: Delay, x: FloatArray) -> FloatArray:
68
+ if np.max(x) > 1.0 or np.min(x) < 0.0:
69
+ msg = "All values of 'x' must be between 0 and 1."
70
+ raise ValueError(msg)
71
+
72
+ return self._call(x)
73
+
74
+ @property
75
+ def filename(self: Delay) -> str:
76
+ return f"{self.friendly_name}-{self.creation_time.strftime('%Y-%m-%d')}.pickle"
77
+
78
+ @abstractmethod
79
+ def _call(self: Delay, x: FloatArray) -> FloatArray: ...
80
+
81
+ def save(self: Delay, path: Path) -> None:
82
+ with Path(path).open("wb") as f:
83
+ pickle.dump({"type": self.__class__.__name__, **asdict(self)}, f)
84
+
85
+
86
+ @dataclass(frozen=True)
87
+ class UniformDelay(Delay):
88
+ """A delay calculator that calculates equidisant delays."""
89
+
90
+ time_window: float
91
+
92
+ def _call(self: UniformDelay, x: FloatArray) -> FloatArray:
93
+ return x * self.time_window
94
+
95
+ @classmethod
96
+ def new(cls: type[UniformDelay], time_window: float, friendly_name: str) -> Delay:
97
+ """Create a new Delay object.
98
+
99
+ Args:
100
+ time_window: The time window for the delay.
101
+ friendly_name: The friendly name of the delay.
102
+
103
+ Returns:
104
+ Delay: The newly created Delay object.
105
+ """
106
+ return cls(
107
+ friendly_name=friendly_name,
108
+ unique_id=uuid4(),
109
+ creation_time=datetime.now(), # noqa: DTZ005
110
+ time_window=time_window,
111
+ )
112
+
113
+
114
+ @dataclass(frozen=True)
115
+ class NonuniformDelay(Delay):
116
+ """A delay calculator that calculates non-equidistant delays."""
117
+
118
+ time_window: float
119
+ residual_interpolator: Callable[[FloatArray], FloatArray]
120
+
121
+ def _call(self: NonuniformDelay, x: FloatArray) -> FloatArray:
122
+ return (
123
+ x * self.time_window
124
+ + self.residual_interpolator(x)
125
+ - self.residual_interpolator(np.asarray(0.0))
126
+ )
127
+
128
+ @classmethod
129
+ def new(
130
+ cls: type[NonuniformDelay],
131
+ friendly_name: str,
132
+ time_window: float,
133
+ residual_interpolator: Callable[[FloatArray], FloatArray],
134
+ ) -> Delay:
135
+ """Create a new NonuniformDelay object.
136
+
137
+ Args:
138
+ friendly_name: The friendly name of the NonuniformDelay object.
139
+ time_window: The time window of the pulse.
140
+ residual_interpolator: a residual interpolator for calculating the nonuniform part of the delay.
141
+
142
+ Returns:
143
+ Delay: The newly created Delay object.
144
+ """
145
+ return cls(
146
+ friendly_name=friendly_name,
147
+ unique_id=uuid4(),
148
+ creation_time=datetime.now(), # noqa: DTZ005
149
+ time_window=time_window,
150
+ residual_interpolator=residual_interpolator,
151
+ )
@@ -0,0 +1,41 @@
1
+ from typing import Literal, get_args
2
+ from uuid import UUID
3
+
4
+ from typing_extensions import TypeAlias
5
+
6
+ DeviceName: TypeAlias = Literal["glaze1", "glaze2", "carmen"]
7
+
8
+
9
+ def _device_ids() -> dict[DeviceName, UUID]:
10
+ return {
11
+ "glaze1": UUID("5042dbda-e9bc-4216-a614-ac56d0a32023"),
12
+ "glaze2": UUID("66fa482a-1ef4-4076-a883-72d7bf43e151"),
13
+ "carmen": UUID("6a54db26-fa88-4146-b04f-b84b945bfea8"),
14
+ }
15
+
16
+
17
+ def list_devices() -> list[DeviceName]:
18
+ """List all available devices.
19
+
20
+ Returns:
21
+ A list of all available devices.
22
+ """
23
+ return list(_device_ids().keys())
24
+
25
+
26
+ def get_device_id(name: DeviceName) -> UUID:
27
+ """Get the UUID of a device by its name.
28
+
29
+ Args:
30
+ name: The name of the device.
31
+
32
+ Returns:
33
+ Unique identifier of a device.
34
+ """
35
+ try:
36
+ return _device_ids()[name]
37
+ except KeyError as e:
38
+ msg = (
39
+ f"Device {name} does not exist. Possible values are {get_args(DeviceName)}"
40
+ )
41
+ raise ValueError(msg) from e
@@ -0,0 +1,3 @@
1
+ from .thz_pulse import gaussian_derivative_pulse
2
+
3
+ __all__ = ["gaussian_derivative_pulse"]