pychilaslasers 1.0.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 (30) hide show
  1. pychilaslasers-1.0.0/PKG-INFO +11 -0
  2. pychilaslasers-1.0.0/README.md +2 -0
  3. pychilaslasers-1.0.0/pyproject.toml +25 -0
  4. pychilaslasers-1.0.0/setup.cfg +4 -0
  5. pychilaslasers-1.0.0/src/pychilaslasers/__init__.py +27 -0
  6. pychilaslasers-1.0.0/src/pychilaslasers/comm.py +312 -0
  7. pychilaslasers-1.0.0/src/pychilaslasers/exceptions/__init__.py +17 -0
  8. pychilaslasers-1.0.0/src/pychilaslasers/exceptions/laser_error.py +22 -0
  9. pychilaslasers-1.0.0/src/pychilaslasers/exceptions/mode_error.py +54 -0
  10. pychilaslasers-1.0.0/src/pychilaslasers/laser.py +427 -0
  11. pychilaslasers-1.0.0/src/pychilaslasers/laser_components/__init__.py +34 -0
  12. pychilaslasers-1.0.0/src/pychilaslasers/laser_components/diode.py +177 -0
  13. pychilaslasers-1.0.0/src/pychilaslasers/laser_components/heaters/__init__.py +27 -0
  14. pychilaslasers-1.0.0/src/pychilaslasers/laser_components/heaters/heater_channels.py +11 -0
  15. pychilaslasers-1.0.0/src/pychilaslasers/laser_components/heaters/heaters.py +224 -0
  16. pychilaslasers-1.0.0/src/pychilaslasers/laser_components/laser_component.py +79 -0
  17. pychilaslasers-1.0.0/src/pychilaslasers/laser_components/tec.py +144 -0
  18. pychilaslasers-1.0.0/src/pychilaslasers/modes/__init__.py +39 -0
  19. pychilaslasers-1.0.0/src/pychilaslasers/modes/calibrated.py +126 -0
  20. pychilaslasers-1.0.0/src/pychilaslasers/modes/manual_mode.py +186 -0
  21. pychilaslasers-1.0.0/src/pychilaslasers/modes/mode.py +88 -0
  22. pychilaslasers-1.0.0/src/pychilaslasers/modes/steady_mode.py +432 -0
  23. pychilaslasers-1.0.0/src/pychilaslasers/modes/sweep_mode.py +462 -0
  24. pychilaslasers-1.0.0/src/pychilaslasers/utils.py +222 -0
  25. pychilaslasers-1.0.0/src/pychilaslasers.egg-info/PKG-INFO +11 -0
  26. pychilaslasers-1.0.0/src/pychilaslasers.egg-info/SOURCES.txt +28 -0
  27. pychilaslasers-1.0.0/src/pychilaslasers.egg-info/dependency_links.txt +1 -0
  28. pychilaslasers-1.0.0/src/pychilaslasers.egg-info/requires.txt +2 -0
  29. pychilaslasers-1.0.0/src/pychilaslasers.egg-info/top_level.txt +1 -0
  30. pychilaslasers-1.0.0/tests/test_calibration_reading.py +116 -0
@@ -0,0 +1,11 @@
1
+ Metadata-Version: 2.4
2
+ Name: pychilaslasers
3
+ Version: 1.0.0
4
+ Summary: Python library for controlling Chilas lasers
5
+ Requires-Python: >=3.13
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: packaging>=25.0
8
+ Requires-Dist: pyserial>=3.5
9
+
10
+ # PyChilasLasers
11
+ Python library for Chilas Comet and Atlas lasers
@@ -0,0 +1,2 @@
1
+ # PyChilasLasers
2
+ Python library for Chilas Comet and Atlas lasers
@@ -0,0 +1,25 @@
1
+ [project]
2
+ name = "pychilaslasers"
3
+ version = "1.0.0"
4
+ description = "Python library for controlling Chilas lasers"
5
+ readme = "README.md"
6
+ requires-python = ">=3.13"
7
+ dependencies = [
8
+ "packaging>=25.0",
9
+ "pyserial>=3.5",
10
+ ]
11
+
12
+ [dependency-groups]
13
+ dev = [
14
+ "pychilaslasers",
15
+ "pytest>=8.4.1",
16
+ ]
17
+
18
+ [tool.uv.sources]
19
+ pychilaslasers = { workspace = true }
20
+
21
+
22
+ [tool.pyright]
23
+
24
+ reportOptionalMemberAccess = "warning"
25
+ reportAttributeAccessIssue = "warning"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,27 @@
1
+ """
2
+ PyChilasLasers Module
3
+ <p>
4
+ This module provides functionality for controlling and interfacing with laser systems.
5
+ <p>
6
+ The package has the following structure:
7
+ - `laser.py`: Contains the main `Laser` class for laser control.
8
+ - `utils.py`: Contains utility functions and data structures for calibration and other operations.
9
+ - `modes/`: Contains laser modes which encompass specific laser behaviors as well as enums used interacting with these modes.
10
+ - `laser_components/`: Contains classes for various laser components such as TEC, diode, and drivers.
11
+ These classes are used to encapsulate the behavior, properties and state of these components.
12
+ <p>
13
+ Interaction with the laser should be done through the `Laser` class.
14
+ """
15
+
16
+ from .laser import Laser
17
+
18
+ __all__: list[str] = [
19
+ # Main laser class
20
+ "Laser",
21
+ "__version__"
22
+ ]
23
+
24
+ # Package metadata
25
+ __version__ = "1.0.0"
26
+ __author__ = "Chilas B.V."
27
+ __email__ = "info@chilasbv.com"
@@ -0,0 +1,312 @@
1
+ """
2
+ This module contains the `Communication` class for handling communication with the laser
3
+ driver over serial.
4
+ <p>
5
+ It contains the methods for sending commands to the laser, receiving responses, and managing
6
+ the serial connection.
7
+ <p>
8
+ **Authors:** RLK, AVR, SDU
9
+ **Last Revision:** Aug 5, 2025 - Created deconstructor
10
+ """
11
+
12
+ # ✅ Standard library imports
13
+ import atexit
14
+ import logging
15
+ import signal
16
+
17
+ # ✅ Third-party imports
18
+ import serial
19
+
20
+ # ✅ Local imports
21
+ from pychilaslasers.exceptions.laser_error import LaserError
22
+ from pychilaslasers.utils import Constants
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ class Communication:
28
+ """
29
+ Communication class for handling communication with the laser driver over serial.
30
+ <p>
31
+ This class provides methods for sending commands to the laser, receiving responses,
32
+ and managing the serial connection. It also handles the prefix mode for the laser driver,
33
+ """
34
+
35
+ def __init__(self, com_port: str) -> None:
36
+ """Initialize the Communication class with the specified serial port.
37
+ <p>
38
+ This method sets up the serial connection to the laser driver and initializes
39
+ the communication parameters. It also registers cleanup functions to ensure
40
+ the serial connection is properly closed on exit or signal termination. And
41
+ sets the initial baudrate to the default value. When a connection fails, it will
42
+ attempt to reconnect using the next supported baudrate until a connection is established
43
+ as this is one of the most common issues when connecting to the laser driver.
44
+ <p>
45
+ Args:
46
+ com_port: The serial port to connect to the laser driver.
47
+ this can be found by using the `pychilaslasers.utils.get_serial_ports()` function.
48
+ """
49
+ # Validate the com_port input
50
+ if not isinstance(com_port, str):
51
+ raise ValueError("The com_port must be a string representing the serial port.")
52
+
53
+ # Initialize serial connection to the laser
54
+ self._serial: serial.Serial = serial.Serial(
55
+ port=com_port,
56
+ baudrate=Constants.TLM_INITIAL_BAUDRATE, # Use the first supported baudrate
57
+ bytesize=serial.EIGHTBITS,
58
+ parity=serial.PARITY_NONE,
59
+ stopbits=serial.STOPBITS_ONE,
60
+ timeout=1.0,
61
+ )
62
+ self._previous_command: str = "None"
63
+
64
+ self._prefix_mode: bool = True
65
+ # Attempt to open the serial connection by trying different baudrates
66
+ baudrates: set[int] = Constants.SUPPORTED_BAUDRATES.copy() # Copy to avoid modifying the original set
67
+ rate = Constants.TLM_INITIAL_BAUDRATE
68
+ while True:
69
+ try:
70
+ self.prefix_mode = True
71
+ break
72
+ except Exception:
73
+ try:
74
+ logger.error(f"Serial connection failed at {rate} baud.Attempting new connection with baudrate {(rate:=baudrates.pop())}.")
75
+ self.baudrate = rate # Try next baudrate if the current one fails
76
+ except KeyError:
77
+ logger.critical("No more supported baudrates available. Cannot establish serial connection.")
78
+ raise RuntimeError("Failed to establish serial connection with the laser driver. " +
79
+ "Please check the connection and supported baudrates.") from None
80
+ self.baudrate = Constants.DEFAULT_BAUDRATE
81
+
82
+ # Ensure proper closing of the serial connection on exit or signal
83
+ atexit.register(self.close_connection)
84
+ signal.signal(signal.SIGINT,self.close_connection)
85
+ signal.signal(signal.SIGTERM, self.close_connection)
86
+
87
+ def __del__(self) -> None:
88
+ """Destructor that ensures the serial connection is closed when the object is deleted.
89
+ <p>
90
+ This method is called when the object is garbage collected, providing an additional
91
+ safety mechanism to ensure the serial connection is properly closed even if the
92
+ user forgets to call close explicitly or if the program terminates unexpectedly.
93
+ """
94
+ self.close_connection()
95
+
96
+ ########## Main Methods ##########
97
+
98
+ def query(self, data: str) -> str:
99
+ """Main method for communication with the laser.
100
+ <p>
101
+ This method sends a command to the laser over the serial connection and returns
102
+ the response. It also handles the logging of the command and response. The
103
+ response code of the reply is checked and an error is raised if the response
104
+ code is not 0. Commands that are sent multiple times may be replaced with a
105
+ semicolon to speed up communication.
106
+
107
+ Args:
108
+ data: The serial command to be sent to the laser.
109
+
110
+ Returns:
111
+ The response from the laser. The response is stripped of any leading
112
+ or trailing whitespace as well as the return code. Response may be
113
+ empty if the command does not return a value.
114
+
115
+ Raises:
116
+ serial.SerialException: If there is an error in the serial communication,
117
+ such as a decoding error or an empty reply.
118
+ LaserError: If the response code from the laser is not 0, indicating an error.
119
+ """
120
+
121
+ # Write the command to the serial port
122
+ logger.debug(msg=f"W {data}") # Logs the command being sent
123
+ self._serial.write(
124
+ f"{self._semicolon_replace(data)}\r\n"
125
+ .encode("ascii")
126
+ )
127
+ self._serial.flush()
128
+
129
+ if not self.prefix_mode:
130
+ return "" # If prefix mode is off, return empty string immediately
131
+
132
+ # Read the response from the laser
133
+ try:
134
+ reply: str = self._serial.readline().decode("ascii").rstrip()
135
+ except UnicodeDecodeError as e:
136
+ logger.error(f"Failed to decode reply from device: {e}")
137
+ raise serial.SerialException(f"Failed to decode reply from device: {e}. " +
138
+ "Please check the connection and baudrate settings.")
139
+
140
+ # Error handling
141
+ if not reply or reply == "":
142
+ logger.error("Empty reply from device")
143
+ raise serial.SerialException("Empty reply from device. " \
144
+ "Please check the connection and prefix mode.")
145
+
146
+ if reply[0] != "0":
147
+ logger.error(f"Nonzero return code: {reply[2:]}")
148
+ raise LaserError(code=reply[0],message=reply[2:]) # Raise a custom error with the reply message
149
+ else:
150
+ logger.debug(f"R {reply}")
151
+
152
+ return reply[2:]
153
+
154
+ def close_connection(self, signum = None, fname = None) -> None:
155
+ """Closes the serial connection to the laser driver safely.
156
+ Attempts to reset the baudrate to the initial value before closing the connection.
157
+ <p>
158
+ This method is registered to be called on exit or when a signal is received.
159
+ """
160
+ if self._serial and self._serial.is_open:
161
+ if signum is not None:
162
+ logger.error(f"Received signal {signal.Signals(signum).name} ({signum}): closing connection")
163
+ else:
164
+ logger.debug("Closing connection")
165
+ self.query("SYST:STAT 0")
166
+ self._serial.write(f"SYST:SER:BAUD {Constants.TLM_INITIAL_BAUDRATE}\r\n".encode("ascii"))
167
+ logger.debug("Resetting serial baudrate to initial value")
168
+ self._serial.close()
169
+
170
+
171
+ ########## Private Methods ##########
172
+
173
+ def _semicolon_replace(self, cmd: str) -> str:
174
+ """To speed up communication, a repeating command can be replaced with a semicolon.
175
+ <p>
176
+ Check if the command was previously sent to the device. In that case, replace
177
+ it with a semicolon.
178
+
179
+ Args:
180
+ cmd: The command to be replaced with semicolon.
181
+
182
+ Returns:
183
+ The command with semicolon inserted
184
+ """
185
+ if cmd.split(" ")[0] == self._previous_command and self._previous_command in Constants.SEMICOLON_COMMANDS:
186
+ cmd = cmd.replace(cmd.split(" ")[0], ";")
187
+ else:
188
+ self._previous_command = cmd.split(" ")[0]
189
+ return cmd
190
+
191
+ def _initialize_variables(self) -> None:
192
+ """Initialize private variables."""
193
+ self._previous_command: str = "None"
194
+
195
+
196
+ ########## Properties (Getters/Setters) ##########
197
+
198
+ @property
199
+ def prefix_mode(self) -> bool:
200
+ """Gets prefix mode for the laser driver.
201
+ <p>
202
+ The laser driver can be operated in two different communication modes:
203
+ 1. Prefix mode on
204
+ 2. Prefix mode off
205
+ <p>
206
+ When prefix mode is on, every message over the serial connection will be
207
+ replied to by the driver with a response, and every response will be
208
+ prefixed with a return code (rc), either `0` or `1` for an OK or ERROR
209
+ respectively.
210
+ <p>
211
+ With prefix mode is off, responses from the laser driver are not prefixed
212
+ with a return code. This means that in the case for a serial write command
213
+ without an expected return value, the driver will not send back a reply.
214
+
215
+ Returns:
216
+ whether prefix mode is enabled (True) or disabled (False)
217
+ """
218
+ return self._prefix_mode
219
+
220
+ @prefix_mode.setter
221
+ def prefix_mode(self, mode: bool) -> None:
222
+ """Sets prefix mode for the laser driver.
223
+ <p>
224
+ The laser driver can be operated in two different communication modes:
225
+ 1. Prefix mode on
226
+ 2. Prefix mode off
227
+ <p>
228
+ When prefix mode is on, every message over the serial connection will be
229
+ replied to by the driver with a response, and every response will be
230
+ prefixed with a return code (rc), either `0` or `1` for an OK or ERROR
231
+ respectively.
232
+ <p>
233
+ With prefix mode is off, responses from the laser driver are not prefixed
234
+ with a return code. This means that in the case for a serial write command
235
+ without an expected return value, the driver will not send back a reply.
236
+
237
+ Args:
238
+ mode: whether to enable prefix mode (True) or disable it (False)
239
+ """
240
+ self.query(f"SYST:COMM:PFX {mode:d}")
241
+ self._prefix_mode = mode
242
+ logger.info(f"Changed prefix mode to {mode}")
243
+
244
+
245
+ @property
246
+ def baudrate(self) -> int:
247
+ """Gets the baudrate of the serial connection to the driver
248
+
249
+ The baudrate can be changed, but does require a serial reconnect
250
+
251
+ Returns:
252
+ (int): baudrate currently in use
253
+ """
254
+ driver_baudrate = int(self.query("SYST:SER:BAUD?"))
255
+ if driver_baudrate != self._serial.baudrate:
256
+ logger.error("There seems to be a baudrate mismatch between driver and connection baudrate settings")
257
+ return driver_baudrate
258
+
259
+ @baudrate.setter
260
+ def baudrate(self, new_baudrate: int) -> None:
261
+ """Sets the baudrate of the serial connection to the driver
262
+
263
+ The baudrate can be changed, but this requires a serial reconnect.
264
+
265
+ Currently supported baudrates are:
266
+ - 9600
267
+ - 14400
268
+ - 19200
269
+ - 28800
270
+ - 38400
271
+ - 57600, default
272
+ - 115200
273
+ - 230400
274
+ - 460800
275
+ - 912600
276
+
277
+ This method will first check if there is already a serial connection open.
278
+ If not, it will do nothing and return immediately (None). If a serial connection
279
+ is open, it will first check if new baudrate requested, is supported.
280
+ If not, it will return None. Otherwise, continue to check if the new baudrate
281
+ needs to be set, by comparing with the current baudrate in use. If the new requested
282
+ baudrate is different then it will set the new baudrate as follows:
283
+ 1. Instruct the driver to use a new baudrate
284
+ 2. Close the serial connection
285
+ 3. Change the serial connection attribute to use the new baudrate as well
286
+ 4. Reopen the serial connection
287
+
288
+ Args:
289
+ new_baudrate (int): new baudrate to use
290
+ """
291
+
292
+ # Input validation
293
+ if not self._serial.is_open:
294
+ return
295
+ if new_baudrate == self._serial.baudrate:
296
+ return
297
+ if new_baudrate not in Constants.SUPPORTED_BAUDRATES:
298
+ raise ValueError(f"The given baudrate {new_baudrate} is not supported.")
299
+
300
+
301
+ # 1. Instruct driver to use new baudrate
302
+ logger.info(f"Switching baudrates from {self._serial.baudrate} to {new_baudrate}.")
303
+ self._serial.write(f"SYST:SER:BAUD {new_baudrate:d}\r\n".encode("ascii"))
304
+ logger.debug(f"[baudrate_switch] Writing to serial: SYST:SER:BAUD {new_baudrate:d}")
305
+ # 2. Close serial connection
306
+ logger.debug("[baudrate_switch] Closing serial connection")
307
+ self._serial.close()
308
+ # 3. Change serial connection baudrate attribute
309
+ self._serial.baudrate = new_baudrate
310
+ # 4. Reopen serial connection
311
+ logger.debug("[baudrate_switch] Reopening serial connection with new baudrate")
312
+ self._serial.open()
@@ -0,0 +1,17 @@
1
+ """PyChilasLasers exceptions module.
2
+
3
+ This module contains all custom exceptions for the PyChilasLasers library.
4
+ These exceptions provide specific error handling for laser operations and modes.
5
+ <p>
6
+ Authors: SDU
7
+ Last Revision: Aug 4, 2025 - Created
8
+ """
9
+
10
+ # ✅ Local imports
11
+ from .laser_error import LaserError
12
+ from .mode_error import ModeError
13
+
14
+ __all__ = [
15
+ "LaserError",
16
+ "ModeError",
17
+ ]
@@ -0,0 +1,22 @@
1
+ """
2
+ Class representing errors received from the laser
3
+ <p>
4
+ Authors: SDU
5
+ Last Revision: Aug 4, 2025 - Created the LaserError class
6
+ """
7
+
8
+
9
+ class LaserError(Exception):
10
+ def __init__(self, code: str, message: str) -> None:
11
+ """Class representing errors received from the laser.
12
+
13
+ Args:
14
+ code (str): The error code sent by the laser. Typically a 1 but kept
15
+ abstract to allow for future expansion.
16
+ message (str): The error message.
17
+ """
18
+ self.code: str = code
19
+ self.message: str = message
20
+
21
+ def __str__(self) -> str:
22
+ return f"LaserError {self.code}: The laser has responded with an error {self.message}"
@@ -0,0 +1,54 @@
1
+ """
2
+ Exception class for laser mode-related errors.
3
+ <p>
4
+ This module defines the ModeError exception which is raised when operations
5
+ are attempted in or for incompatible laser modes. It provides detailed information
6
+ about the current mode and suggests the correct mode for the operation.
7
+ <p>
8
+ **Authors:** SDU
9
+ **Last Revision:** August 4, 2025 - Created
10
+ """
11
+
12
+ # ⚛️ Type checking
13
+ from __future__ import annotations
14
+ from typing import TYPE_CHECKING
15
+
16
+ if TYPE_CHECKING:
17
+ from pychilaslasers.modes.mode import Mode
18
+
19
+ # ✅ Local imports
20
+ from pychilaslasers.modes.mode import LaserMode
21
+
22
+ class ModeError(Exception):
23
+ """Exception raised for errors related to the laser mode."""
24
+
25
+ def __init__(self, message: str, current_mode: LaserMode | Mode, desired_mode: LaserMode | Mode | None = None) -> None:
26
+ """Exception raised in case of an error related to the mode the laser is in.
27
+ <p>
28
+ This exception is used to indicate that an operation cannot be performed in the current mode of the laser.
29
+ It provides information about the current mode and the desired mode that would allow the operation to succeed
30
+ <p>
31
+ Args:
32
+ message (str): The error message.
33
+ current_mode (LaserMode): The current mode of the laser.
34
+ desired_mode (LaserMode | None, optional): The laser mode that would allow
35
+ for the operation to succeed. Defaults to None.
36
+ """
37
+ super().__init__(message)
38
+ self.message: str = message
39
+
40
+ # Checking to allow for the use of both LaserMode and Mode types
41
+ self.current_mode: LaserMode = current_mode if isinstance(current_mode, LaserMode) else current_mode.mode
42
+ self.desired_mode: LaserMode | None = (
43
+ desired_mode if isinstance(desired_mode, LaserMode)
44
+ else ( desired_mode.mode if desired_mode is not None else None)
45
+ )
46
+ # Constructing the error message
47
+ if self.desired_mode:
48
+ self.message += f" (current mode: {self.current_mode.name}, mode this" + \
49
+ f" operation is possible in: {self.desired_mode.name})"
50
+ else:
51
+ self.message += f" (current mode: {self.current_mode.name})"
52
+
53
+ def __str__(self) -> str:
54
+ return f"ModeError: {self.message}"