BioImager  3.9.1
A .NET microscopy imaging library. Supports various microscopes by using imported libraries & GUI automation. Supported libraries include Prior® & Zeiss® & all devices supported by Micromanager 2.0 and python-microscope.
Loading...
Searching...
No Matches
toptica.py
1#!/usr/bin/env python3
2
3## Copyright (C) 2020 David Miguel Susano Pinto <carandraug@gmail.com>
4##
5## This file is part of Microscope.
6##
7## Microscope is free software: you can redistribute it and/or modify
8## it under the terms of the GNU General Public License as published by
9## the Free Software Foundation, either version 3 of the License, or
10## (at your option) any later version.
11##
12## Microscope is distributed in the hope that it will be useful,
13## but WITHOUT ANY WARRANTY; without even the implied warranty of
14## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15## GNU General Public License for more details.
16##
17## You should have received a copy of the GNU General Public License
18## along with Microscope. If not, see <http://www.gnu.org/licenses/>.
19
20import logging
21import re
22import typing
23
24import serial
25
26import microscope
28import microscope.abc
29
30
31_logger = logging.getLogger(__name__)
32
33
34def _get_table_value(table: bytes, key: bytes) -> bytes:
35 """Get the value for a key in a table/multiline output.
36
37 Some commands return something like a table of key/values. There
38 may be even empty lines on this table. This searches for the
39 first line with a specific key (hopefully there's only one line
40 with such key) and returns the associated value.
41 """
42 # Key might be the first line, hence '(?:^|\r\n)'
43 match = re.search(b"(?:^|\r\n) *" + key + b": (.*)\r\n", table)
44 if match is None:
46 "failed to find key %s on table: %s" % (key, table)
47 )
48 return match.group(1)
49
50
52 """Connection to a specific Toptica iBeam smart laser.
53
54 This class wraps the serial connection to the device, and provides
55 access to some of its commands performing most of the parsing and
56 validation.
57
58 Args:
59 port: port name (Windows) or path to port (everything else) to
60 connect to. For example, `/dev/ttyS1`, `COM1`, or
61 `/dev/cuad1`.
62
63 """
64
65 def __init__(self, port: str):
66 # From the Toptica iBeam SMART manual:
67 # Direct connection via COMx with 115200,8,N,1 and serial
68 # interface handshake "none". That means that no hardware
69 # handshake (DTR, RTS) and no software handshake (XON,XOFF) of
70 # the underlying operating system is supported.
71 serial_conn = serial.Serial(
72 port=port,
73 baudrate=115200,
74 timeout=1.0,
75 bytesize=serial.EIGHTBITS,
76 stopbits=serial.STOPBITS_ONE,
77 parity=serial.PARITY_NONE,
78 xonxoff=False,
79 rtscts=False,
80 dsrdtr=False,
81 )
82 self._serial = microscope._utils.SharedSerial(serial_conn)
83
84 # We don't know what is the current verbosity state and so we
85 # don't know yet what we should be reading back. So blindly
86 # set to the level we want, flush all output, and then check
87 # if indeed this is a Toptica iBeam device.
88 with self._serial.lock:
89 self._serial.write(b"echo off\r\n")
90 self._serial.write(b"prompt off\r\n")
91 # The talk level we want is 'usual'. In theory we should
92 # be able to use 'quiet' which only answers queries but in
93 # practice 'quiet' does not answer some queries like 'show
94 # serial'.
95 self._serial.write(b"talk usual\r\n")
96 self._serial.readlines() # discard all pending lines
97
98 # Empty command does nothing and returns nothing extra so we
99 # use it to ensure this at least behaves like a Toptica iBeam.
100 try:
101 self.command(b"")
102 except microscope.DeviceError as e:
104 "Failed to confirm Toptica iBeam on %s" % (port)
105 ) from e
106
107 answer = self.command(b"show serial")
108 if not answer.startswith(b"SN: "):
110 "Failed to parse serial from %s" % answer
111 )
112 _logger.info("got connection to Toptica iBeam %s", answer.decode())
113
114 def command(self, command: bytes) -> bytes:
115 """Run command and return answer after minimal validation.
116
117 The output of a command has the format::
118
119 \r\nANSWER\r\n[OK]\r\n
120
121 The returned bytes only include `ANSWER` without its own final
122 `\r\n`. This means that the return value might be an empty
123 array of bytes.
124 """
125 # We expect to be on 'talk usual' mode without prompt so each
126 # command will end with [OK] on its own line.
127 with self._serial.lock:
128 self._serial.write(command + b"\r\n")
129 # An answer always starts with \r\n so there will be one
130 # before [OK] even if this command is not a query.
131 answer = self._serial.read_until(b"\r\n[OK]\r\n")
132
133 if not answer.startswith(b"\r\n"):
135 "answer to command %s does not start with CRLF."
136 " This may be leftovers from a previous command:"
137 " %s" % (command, answer)
138 )
139 if not answer.endswith(b"\r\n[OK]\r\n"):
141 "Command %s failed or failed to read answer: %s"
142 % (command, answer)
143 )
144
145 # If an error occurred, the answer still ends in [OK]. We
146 # need to check if the second line (first line is \r\n) is an
147 # error code with the format "%SYS-L-XXX, error description"
148 # where L is the error level (I for Information, W for
149 # Warning, E for Error, and F for Fatal), and XXX is the error
150 # code number.
151 if answer[2:7] == b"%SYS-" and answer[7] != ord(b"I"):
152 # Errors of level I (information) should not raise an
153 # exception since they can be replies to normal commands.
155 "Command %s failed: %s" % (command, answer)
156 )
157
158 # Exclude the first \r\n, the \r\n from a possible answer, and
159 # the final [OK]\r\n
160 return answer[2:-8]
161
162 def laser_on(self) -> None:
163 """Activate LD driver."""
164 self.command(b"laser on")
165
166 def laser_off(self) -> None:
167 """Deactivate LD driver."""
168 self.command(b"laser off")
169
170 def set_normal_channel_power(self, power: float) -> None:
171 """Set power in mW for channel 2 (normal operating level channel).
172
173 We don't have channel number as an argument because we only
174 want to be setting the power via channel 2 (channel 1 is the
175 bias and we haven't seen a laser with a channel 3 yet).
176 """
177 self.command(b"channel 2 power %f" % power)
178
179 def show_power_uW(self) -> float:
180 """Returns actual laser power in µW."""
181 answer = self.command(b"show power")
182 if not answer.startswith(b"PIC = ") and not answer.endswith(b" uW "):
184 "failed to parse power from answer: %s" % answer
185 )
186 return float(answer[7:-5])
187
188 def status_laser(self) -> bytes:
189 """Returns actual status of the LD driver (ON or OFF)."""
190 return self.command(b"status laser")
191
192 def show_max_power(self) -> float:
193 # There should be a cleaner way to get these, right? We can
194 # query the current limits (mA) but how do we go from there to
195 # the power limits (mW)?
196 table = self.command(b"show satellite")
197 value = _get_table_value(table, key=b"Pmax")
198 if not value.endswith(b" mW"):
200 "failed to parse power from %s" % value
201 )
202 return float(value[:-3])
203
204
208):
209 """Toptica iBeam smart laser.
210
211 Control of laser power is performed by setting the power level on
212 the normal channel (#2) only. The bias channel (#1) is left
213 unmodified and so defines the lowest level power.
214 """
215
216 def __init__(self, port: str, **kwargs) -> None:
217 super().__init__(**kwargs)
218 self._conn = _iBeamConnection(port)
219 # The Toptica iBeam has up to five operation modes, named
220 # "channels" on the documentation. Only the first three
221 # channels have any sort of documentation:
222 #
223 # Ch 1: bias level channel
224 # Ch 2: normal operating level channel
225 # Ch 3: only used at high-power models
226 #
227 # We haven't come across a laser with a channel 3 so we are
228 # ignoring it until then. So we just leave the bias channel
229 # (1) alone and control power via the normal channel (2).
230 self._max_power = self._conn.show_max_power()
231
232 def _do_shutdown(self) -> None:
233 pass
234
235 def get_status(self) -> typing.List[str]:
236 status: typing.List[str] = []
237 return status
238
239 def enable(self) -> None:
240 self._conn.laser_on()
241
242 def disable(self) -> None:
243 self._conn.laser_off()
244
245 def get_is_on(self) -> bool:
246 state = self._conn.status_laser()
247 if state == b"ON":
248 return True
249 elif state == b"OFF":
250 return False
251 else:
253 "Unexpected laser status: %s" % state.decode()
254 )
255
256 def _get_max_power_mw(self) -> float:
257 return self._max_power
258
259 def _get_power_mw(self) -> float:
260 return self._conn.show_power_uW() * (10**-3)
261
262 def _set_power_mw(self, mw: float) -> None:
263 self._conn.set_normal_channel_power(mw)
264
265 def _do_set_power(self, power: float) -> None:
266 self._set_power_mw(power * self._get_max_power_mw())
267
268 def _do_get_power(self) -> float:
269 return self._get_power_mw() / self._get_max_power_mw()
None set_normal_channel_power(self, float power)
Definition: toptica.py:170
bytes command(self, bytes command)
Definition: toptica.py:114
typing.List[str] get_status(self)
Definition: toptica.py:235
None _set_power_mw(self, float mw)
Definition: toptica.py:262