class EbeamFocus(model.Actuator): """ This is an extension of the model.Actuator class. It provides functions for adjusting the ebeam focus by changing the working distance i.e. the distance between the end of the objective and the surface of the observed specimen """ def __init__(self, name, role, parent, axes, ranges=None, **kwargs): assert len(axes) > 0 if ranges is None: ranges = {} axes_def = {} self._position = {} # Just z axis a = axes[0] # The maximum, obviously, is not 1 meter. We do not actually care # about the range since Tescan API will adjust the value set if the # required one is out of limits. rng = [0, 1] axes_def[a] = model.Axis(unit="m", range=rng) # start at the centre self._position[a] = parent._device.GetWD() * 1e-3 model.Actuator.__init__(self, name, role, parent=parent, axes=axes_def, **kwargs) # RO, as to modify it the client must use .moveRel() or .moveAbs() self.position = model.VigilantAttribute( self._applyInversionAbs(self._position), unit="m", readonly=True) # will take care of executing axis move asynchronously self._executor = CancellableThreadPoolExecutor(max_workers=1) # one task at a time def _updatePosition(self): """ update the position VA """ # it's read-only, so we change it via _value self.position._value = self._applyInversionAbs(self._position) self.position.notify(self.position.value) def _doMove(self, pos): """ move to the position """ # Perform move through Tescan API # Position from m to mm and inverted self.parent._device.SetWD(self._position["z"] * 1e03) # Obtain the finally reached position after move is performed. with self.parent._acquisition_init_lock: wd = self.parent._device.GetWD() self._position["z"] = wd * 1e-3 # Changing WD results to change in fov self.parent._scanner.updateHorizontalFOV() self._updatePosition() @isasync def moveRel(self, shift): if not shift: return model.InstantaneousFuture() self._checkMoveRel(shift) shift = self._applyInversionRel(shift) for axis, change in shift.items(): self._position[axis] += change pos = self._position return self._executor.submit(self._doMove, pos) @isasync def moveAbs(self, pos): if not pos: return model.InstantaneousFuture() self._checkMoveAbs(pos) pos = self._applyInversionAbs(pos) for axis, new_pos in pos.items(): self._position[axis] = new_pos pos = self._position return self._executor.submit(self._doMove, pos) def stop(self, axes=None): # Empty the queue for the given axes self._executor.cancel() logging.warning("Stopping all axes: %s", ", ".join(self.axes)) def terminate(self): if self._executor: self.stop() self._executor.shutdown() self._executor = None
class ChamberPressure(model.Actuator): """ This is an extension of the model.Actuator class. It provides functions for adjusting the chamber pressure. It actually allows the user to evacuate or vent the chamber and get the current pressure of it. """ def __init__(self, name, role, parent, ranges=None, **kwargs): axes = {"pressure": model.Axis(unit="Pa", choices={PRESSURE_VENTED: "vented", PRESSURE_PUMPED: "vacuum"})} model.Actuator.__init__(self, name, role, parent=parent, axes=axes, **kwargs) # last official position if self.GetStatus() == 0: self._position = PRESSURE_PUMPED else: self._position = PRESSURE_VENTED # RO, as to modify it the client must use .moveRel() or .moveAbs() self.position = model.VigilantAttribute( {"pressure": self._position}, unit="Pa", readonly=True) # Almost the same as position, but gives the current position self.pressure = model.VigilantAttribute(self._position, unit="Pa", readonly=True) # will take care of executing axis move asynchronously self._executor = CancellableThreadPoolExecutor(max_workers=1) # one task at a time def GetStatus(self): """ return int: vacuum status, -1 error 0 ready for operation 1 pumping in progress 2 venting in progress 3 vacuum off (pumps are switched off, valves are closed) 4 chamber open """ with self.parent._acquisition_init_lock: status = self.parent._device.VacGetStatus() # channel 0, reserved return status def terminate(self): if self._executor: self.stop() self._executor.shutdown() self._executor = None def _updatePosition(self): """ update the position VA and .pressure VA """ # it's read-only, so we change it via _value pos = self.parent._device.VacGetPressure(0) self.pressure._value = pos self.pressure.notify(pos) # .position contains the last known/valid position # it's read-only, so we change it via _value self.position._value = {"pressure": self._position} self.position.notify(self.position.value) @isasync def moveRel(self, shift): self._checkMoveRel(shift) # convert into an absolute move pos = {} for a, v in shift.items: pos[a] = self.position.value[a] + v return self.moveAbs(pos) @isasync def moveAbs(self, pos): if not pos: return model.InstantaneousFuture() self._checkMoveAbs(pos) return self._executor.submit(self._changePressure, pos["pressure"]) def _changePressure(self, p): """ Synchronous change of the pressure p (float): target pressure """ if p["pressure"] == PRESSURE_VENTED: self.parent._device.VacVent() else: self.parent._device.VacPump() start = time.time() while not self.GetStatus() == 0: if (time.time() - start) >= VACUUM_TIMEOUT: raise TimeoutError("Vacuum action timed out") # Update chamber pressure until pumping/venting process is done self._updatePosition() self._position = p self._updatePosition() def stop(self, axes=None): self._executor.cancel() logging.warning("Stopped pressure change")
class FW102c(model.Actuator): """ Represents a Thorlabs filter wheel FW102C as an actuator. It provides one enumerated axis, whose actual band values are provided by the user at init. """ # Regex matching the compatible identification strings re_idn = "THORLABS.*FW102C.*" def __init__(self, name, role, port, bands, _scan=False, **kwargs): """ port (string): name of the serial port to connect to. Can be a pattern, in which case, all the ports fitting the pattern will be tried, and the first one which looks like an FW102C will be used. bands (dict 1<=int<=12 -> 2-tuple of floats > 0 or str): filter position -> lower and higher bound of the wavelength (m) of the light which goes _through_. If it's a list, it implies that the filter is multi-band. _scan (bool): only for internal usage raise IOError if no device answering or not a compatible device """ self._ser_access = threading.Lock() self._port = self._findDevice(port) logging.info("Found FW102C device on port %s", self._port) if _scan: return # check bands contains correct data self._maxpos = self.GetMaxPosition() if not bands: raise ValueError("Argument bands must contain at least one band") try: for pos, band in bands.items(): if not 1 <= pos <= self._maxpos: raise ValueError("Filter position should be between 1 and " "%d, but got %d." % (self._maxpos, pos)) # To support "weird" filter, we accept strings if isinstance(band, basestring): if not band.strip(): raise ValueError("Name of filter %d is empty" % pos) else: self._checkBand(band) except Exception: logging.exception("Failed to parse bands %s", bands) raise axes = {"band": model.Axis(choices=bands)} model.Actuator.__init__(self, name, role, axes=axes, **kwargs) driver_name = driver.getSerialDriver(self._port) self._swVersion = "%s (serial driver: %s)" % (odemis.__version__, driver_name) self._hwVersion = self._idn # will take care of executing axis move asynchronously self._executor = CancellableThreadPoolExecutor(max_workers=1) # one task at a time self._speed = self.GetSpeed() curpos = self.GetPosition() self.position = model.VigilantAttribute({"band": curpos}, readonly=True) # TODO: MD_OUT_WL or MD_IN_WL depending on affect self._metadata = {model.MD_FILTER_NAME: name, model.MD_OUT_WL: self._axes["band"].choices[curpos]} def getMetadata(self): return self._metadata def terminate(self): if self._executor: self.stop() self._executor.shutdown() self._executor = None with self._ser_access: if self._serial: self._serial.close() self._serial = None def _checkBand(self, band): """ band (object): should be tuple of floats or list of tuple of floats raise ValueError: if the band doesn't follow the convention """ if not isinstance(band, collections.Iterable) or len(band) == 0: raise ValueError("band must be a (list of a) list of 2 floats") # is it a list of list? if isinstance(band[0], collections.Iterable): # => set of 2-tuples for sb in band: if len(sb) != 2: raise ValueError("Expected only 2 floats in band, found %d" % len(sb)) band = tuple(band) else: # 2-tuple if len(band) != 2: raise ValueError("Expected only 2 floats in band, found %d" % len(band)) band = (tuple(band),) # Check the values are min/max and in m: typically within nm (< µm!) max_val = 10e-6 # m for low, high in band: if low > high: raise ValueError("Min of band must be first in list") if low < 0: raise ValueError("Band must be 2 positive value in meters") if low > max_val or high > max_val: raise ValueError("Band contains very high values for light " "wavelength, ensure the value is in meters: %r." % band) # no error found def _findDevice(self, ports): """ Look for a compatible device ports (str): pattern for the port name return (str): the name of the port used It also sets ._serial and ._idn to contain the opened serial port, and the identification string. raises: IOError: if no device are found """ if os.name == "nt": # TODO #ports = ["COM" + str(n) for n in range (15)] raise NotImplementedError("Windows not supported") else: names = glob.glob(ports) for n in names: try: self._serial = self._openSerialPort(n) except serial.SerialException: # not possible to use this port? next one! continue # check whether it looks like a FW102C try: # If any garbage was previously received, make it discarded. self._serial.write("\r") # can have some \x00 bytes at the beginning + "CMD_NOT_DEFINED" self._flushInput() idn = self.GetIdentification() if re.match(self.re_idn, idn): self._idn = idn return n # found it! except Exception: logging.debug("Port %s doesn't seem to have a FW102C device connected", n) else: raise IOError("No device seems to be an FW102C for ports '%s'" % (ports,)) @staticmethod def _openSerialPort(port): """ Opens the given serial port the right way for the FW102C. port (string): the name of the serial port (e.g., /dev/ttyUSB0) return (serial): the opened serial port """ ser = serial.Serial( port=port, baudrate=115200, # only correct if setting was not changed bytesize=serial.EIGHTBITS, parity=serial.PARITY_NONE, stopbits=serial.STOPBITS_ONE, timeout=1 #s ) return ser def _flushInput(self): """ Ensure there is no more data queued to be read on the bus (=serial port) """ with self._ser_access: self._serial.flush() self._serial.flushInput() # Shouldn't be necessary, but just in case skipped = self._serial.read(1000) # More than 1000 chars => give up logging.debug("Skipping input %s", skipped.encode('string_escape')) re_err = r"Command error (.*)" def _sendQuery(self, com): """ Send a command which expects an answer com (string): command to send (not including the ? and the \r) return (string): the answer without newline and suffix ("> ") raises IOError: if there is a timeout HwError: if the hardware reports an error """ # TODO: handle IOError and automatically try to reconnect (cf LLE) assert(len(com) <= 50) # commands cannot be long full_com = com + "\r" with self._ser_access: logging.debug("Sending: '%s'", full_com.encode('string_escape')) self._serial.write(full_com) # ensure everything is received, before expecting an answer self._serial.flush() # Read until end of answer line = b"" while True: char = self._serial.read() # empty if timeout if not char: # should always finish by a "> " raise IOError("Controller timeout, after receiving %s" % line) # normal char line += char if line[-2:] == "> ": break logging.debug("Received: '%s'", line.encode('string_escape')) # remove echo + suffix + new line line = line[len(full_com):-2].rstrip("\r") # if it's an error message => raise an error m = re.match(self.re_err, line) if m: err = m.group(1) raise HwError("Device rejected command '%s': %s" % (com, err)) return line def _sendCommand(self, com): """ Send a command which does not expect any answer com (string): command to send (not including the ? and the \r) return when the command is finished processed raises IOError: if there is a timeout HwError: if the hardware reports an error """ self._sendQuery(com) # don't return anything def GetIdentification(self): """ return (str): model name as reported by the device """ # answer is like "THORLABS FW102C/FW212C Filter Wheel version 1.04" return self._sendQuery("*idn?") def GetMaxPosition(self): """ return (1<int): maximum number of positions available (eg, 6, 12) """ ans = self._sendQuery("pcount?") return int(ans) def GetPosition(self): """ return (1<=int<=maxpos): current position Note: might be different from the last position set if the user has manually changed it. """ ans = self._sendQuery("pos?") return int(ans) def GetSpeed(self): """ return (0 or 1): current "speed" of the wheel, the bigger the faster """ ans = self._sendQuery("speed?") return int(ans) def SetPosition(self, pos): """ pos (1<=int<=maxpos): current position returns when the new position is set raise Exception in case of error """ assert(1 <= pos <= self._maxpos) # Estimate how long it'll take cur_pos = self.position.value["band"] p1, p2 = sorted([pos, cur_pos]) dist = min(p2 - p1, (6 + p1) - p2) if self._speed == 0: dur_one = 2 #s else: dur_one = 1 #s maxdur = 1 + dist * dur_one * 2 # x 2 as a safe bet prev_timeout = self._serial.timeout try: self._serial.timeout = maxdur self._sendCommand("pos=%d" % pos) finally: self._serial.timeout = prev_timeout logging.debug("Move to pos %d finished", pos) # What we don't need: # speed?\r1\r> # trig?\r0\r> # sensors?\r0\r> def _doMoveBand(self, pos): """ move to the position and updates the metadata and position once it's over """ self.SetPosition(pos) self._metadata[model.MD_OUT_WL] = self._axes["band"].choices[pos] self._updatePosition() # high-level methods (interface) def _updatePosition(self): """ update the position VA Note: it should not be called while holding _ser_access """ pos = {"band": self.GetPosition()} # it's read-only, so we change it via _value self.position._value = pos self.position.notify(self.position.value) @isasync def moveRel(self, shift): if not shift: return model.InstantaneousFuture() self._checkMoveRel(shift) # TODO move to the +N next position? (and modulo number of axes) raise NotImplementedError("Relative move on enumerated axis not supported") @isasync def moveAbs(self, pos): if not pos: return model.InstantaneousFuture() self._checkMoveAbs(pos) return self._executor.submit(self._doMoveBand, pos["band"]) def stop(self, axes=None): self._executor.cancel() def selfTest(self): """ check as much as possible that it works without actually moving the motor return (boolean): False if it detects any problem """ try: pos = self.GetPosition() maxpos = self.GetMaxPosition() if 1 <= pos <= maxpos: return True except: logging.exception("Selftest failed") return False @classmethod def scan(cls, port=None): """ port (string): name of the serial port. If None, all the serial ports are tried returns (list of 2-tuple): name, args (port) Note: it's obviously not advised to call this function if a device is already under use """ if port: ports = [port] else: if os.name == "nt": ports = ["COM" + str(n) for n in range (0,8)] else: ports = glob.glob('/dev/ttyS?*') + glob.glob('/dev/ttyUSB?*') logging.info("Serial ports scanning for Thorlabs filter wheel in progress...") found = [] # (list of 2-tuple): name, kwargs for p in ports: try: logging.debug("Trying port %s", p) dev = cls(None, None, p, bands=None, _scan=True) except (serial.SerialException, IOError): # not possible to use this port? next one! continue # Get some more info try: maxpos = dev.GetMaxPosition() except Exception: continue else: # create fake band argument bands = {} for i in range(1, maxpos + 1): bands[i] = (i * 100e-9, (i + 1) * 100e-9) found.append((dev._idn, {"port": p, "bands": bands})) return found
class Stage(model.Actuator): """ This is an extension of the model.Actuator class. It provides functions for moving the Tescan stage and updating the position. """ def __init__(self, name, role, parent, **kwargs): """ axes (set of string): names of the axes """ axes_def = {} self._position = {} rng = [-0.5, 0.5] axes_def["x"] = model.Axis(unit="m", range=rng) axes_def["y"] = model.Axis(unit="m", range=rng) axes_def["z"] = model.Axis(unit="m", range=rng) # Demand calibrated stage if parent._device.StgIsCalibrated() !=1: logging.warning("Stage was not calibrated. We are performing calibration now.") parent._device.StgCalibrate() #Wait for stage to be stable after calibration while parent._device.StgIsBusy() != 0: # If the stage is busy (movement is in progress), current position is # updated approximately every 500 ms time.sleep(0.5) x, y, z, rot, tilt = parent._device.StgGetPosition() self._position["x"] = -x * 1e-3 self._position["y"] = -y * 1e-3 self._position["z"] = -z * 1e-3 model.Actuator.__init__(self, name, role, parent=parent, axes=axes_def, **kwargs) # will take care of executing axis move asynchronously self._executor = CancellableThreadPoolExecutor(max_workers=1) # one task at a time # RO, as to modify it the client must use .moveRel() or .moveAbs() self.position = model.VigilantAttribute( self._applyInversionAbs(self._position), unit="m", readonly=True) def _updatePosition(self): """ update the position VA """ # it's read-only, so we change it via _value self.position._value = self._applyInversionAbs(self._position) self.position.notify(self.position.value) def _doMove(self, pos): """ move to the position """ # Perform move through Tescan API # Position from m to mm and inverted self.parent._device.StgMoveTo(-pos["x"] * 1e3, - pos["y"] * 1e3, - pos["z"] * 1e3) # Obtain the finally reached position after move is performed. # This is mainly in order to keep the correct position in case the # move we tried to perform was greater than the maximum possible # one. with self.parent._acquisition_init_lock: x, y, z, rot, tilt = self.parent._device.StgGetPosition() self._position["x"] = -x * 1e-3 self._position["y"] = -y * 1e-3 self._position["z"] = -z * 1e-3 self._updatePosition() @isasync def moveRel(self, shift): if not shift: return model.InstantaneousFuture() self._checkMoveRel(shift) shift = self._applyInversionRel(shift) for axis, change in shift.items(): self._position[axis] += change pos = self._position return self._executor.submit(self._doMove, pos) @isasync def moveAbs(self, pos): if not pos: return model.InstantaneousFuture() self._checkMoveAbs(pos) pos = self._applyInversionAbs(pos) for axis, new_pos in pos.items(): self._position[axis] = new_pos pos = self._position return self._executor.submit(self._doMove, pos) def stop(self, axes=None): # Empty the queue for the given axes self._executor.cancel() logging.warning("Stopping all axes: %s", ", ".join(self.axes)) def terminate(self): if self._executor: self.stop() self._executor.shutdown() self._executor = None
class Chamber(model.Actuator): """ Simulated chamber component. Just pretends to be able to change pressure """ def __init__(self, name, role, **kwargs): """ Initialises the component """ # TODO: or just provide .targetPressure (like .targetTemperature) ? # Or maybe provide .targetPosition: position that would be reached if # all the requested move were instantly applied? # TODO: support multiple pressures (low vacuum, high vacuum) axes = {"pressure": model.Axis(unit="Pa", choices={PRESSURE_VENTED: "vented", PRESSURE_PUMPED: "vacuum"})} model.Actuator.__init__(self, name, role, axes=axes, **kwargs) # For simulating moves self._position = PRESSURE_PUMPED # last official position self._goal = PRESSURE_PUMPED self._time_goal = 0 # time the goal was/will be reached self._time_start = 0 # time the move started # RO, as to modify it the client must use .moveRel() or .moveAbs() self.position = model.VigilantAttribute( {"pressure": self._position}, unit="Pa", readonly=True) # Almost the same as position, but gives the current position self.pressure = model.VigilantAttribute(self._position, unit="Pa", readonly=True) # will take care of executing axis move asynchronously self._executor = CancellableThreadPoolExecutor(max_workers=1) # one task at a time def terminate(self): if self._executor: self.stop() self._executor.shutdown() self._executor = None def _updatePosition(self): """ update the position VA and .pressure VA """ # Compute the current pressure now = time.time() if self._time_goal < now: # done # goal ±5% pos = self._goal * random.uniform(0.95, 1.05) else: # TODO make it logarithmic ratio = (now - self._time_start) / (self._time_goal - self._time_start) pos = self._position + (self._goal - self._position) * ratio # it's read-only, so we change it via _value self.pressure._value = pos self.pressure.notify(pos) # .position contains the last known/valid position # it's read-only, so we change it via _value self.position._value = {"pressure": self._position} self.position.notify(self.position.value) @isasync def moveRel(self, shift): self._checkMoveRel(shift) # convert into an absolute move pos = {} for a, v in shift.items: pos[a] = self.position.value[a] + v return self.moveAbs(pos) @isasync def moveAbs(self, pos): if not pos: return model.InstantaneousFuture() self._checkMoveAbs(pos) return self._executor.submit(self._changePressure, pos["pressure"]) def _changePressure(self, p): """ Synchronous change of the pressure p (float): target pressure """ # TODO: allow to cancel during the change now = time.time() duration = 5 # s self._time_start = now self._time_goal = now + duration # s self._goal = p time.sleep(duration) self._position = p self._updatePosition() def stop(self, axes=None): self._executor.cancel() logging.warning("Stopped pressure change")
class PMTControl(model.PowerSupplier): ''' This represents the PMT control unit. At start up the following is set: * protection is on (=> gain is forced to 0) * gain = 0 * power up ''' def __init__(self, name, role, port, prot_time=1e-3, prot_curr=50e-6, relay_cycle=None, powered=None, **kwargs): ''' port (str): port name prot_time (float): protection trip time (in s) prot_curr (float): protection current threshold (in Amperes) relay_cycle (None or 0<float): if not None, will power cycle the relay with the given delay (in s) Raise an exception if the device cannot be opened ''' if powered is None: powered = [] self.powered = powered model.PowerSupplier.__init__(self, name, role, **kwargs) # get protection time (s) and current (A) properties if not 0 <= prot_time < 1e3: raise ValueError("prot_time should be a time (in s) but got %s" % (prot_time,)) self._prot_time = prot_time if not 0 <= prot_curr <= 100e-6: raise ValueError("prot_curr (%s A) is not between 0 and 100.e-6" % (prot_curr,)) self._prot_curr = prot_curr # TODO: catch errors and convert to HwError self._ser_access = threading.Lock() self._port = self._findDevice(port) # sets ._serial logging.info("Found PMT Control device on port %s", self._port) # Get identification of the PMT control device self._idn = self._getIdentification() driver_name = driver.getSerialDriver(self._port) self._swVersion = "serial driver: %s" % (driver_name,) self._hwVersion = "%s" % (self._idn,) # Set protection current and time self._setProtectionCurrent(self._prot_curr) self._setProtectionTime(self._prot_time) # gain, powerSupply and protection VAs self.protection = model.BooleanVA(True, setter=self._setProtection, getter=self._getProtection) self._setProtection(True) gain_rng = (MIN_GAIN, MAX_GAIN) gain = self._getGain() self.gain = model.FloatContinuous(gain, gain_rng, unit="V", setter=self._setGain) self.powerSupply = model.BooleanVA(True, setter=self._setPowerSupply) self._setPowerSupply(True) # will take care of executing supply asynchronously self._executor = CancellableThreadPoolExecutor(max_workers=1) # one task at a time # relay initialization if relay_cycle is not None: logging.info("Power cycling the relay for %f s", relay_cycle) self.setRelay(False) time.sleep(relay_cycle) # Reset if no powered provided if not powered: self.setRelay(True) else: self._supplied = {} self.supplied = model.VigilantAttribute(self._supplied, readonly=True) self._updateSupplied() def terminate(self): if self._executor: self._executor.cancel() self._executor.shutdown() self._executor = None with self._ser_access: if self._serial: self._serial.close() self._serial = None @isasync def supply(self, sup): if not sup: return model.InstantaneousFuture() self._checkSupply(sup) return self._executor.submit(self._doSupply, sup) def _doSupply(self, sup): """ supply power """ value = sup.values()[0] # only care about the value self.setRelay(value) self._updateSupplied() def _updateSupplied(self): """ update the supplied VA """ # update all components since they are all connected to the same switch value = self.getRelay() for comp in self.powered: self._supplied[comp] = value # it's read-only, so we change it via _value self.supplied._value = self._supplied self.supplied.notify(self.supplied.value) def _getIdentification(self): return self._sendCommand("*IDN?") def _setGain(self, value): self._sendCommand("VOLT %f" % (value,)) return self._getGain() def _setProtectionCurrent(self, value): self._sendCommand("PCURR %f" % (value * 1e6,)) # in µA def _setProtectionTime(self, value): self._sendCommand("PTIME %f" % (value,)) def _getGain(self): ans = self._sendCommand("VOLT?") try: value = float(ans) except ValueError: raise IOError("Gain value cannot be converted to float.") return value def _setPowerSupply(self, value): if value: self._sendCommand("PWR 1") else: self._sendCommand("PWR 0") return value def _getPowerSupply(self): ans = self._sendCommand("PWR?") return (ans == "1") def _setProtection(self, value): if value: self._sendCommand("SWITCH 0") else: self._sendCommand("SWITCH 1") return value def _getProtection(self): ans = self._sendCommand("SWITCH?") return (ans == "0") # These two methods are strictly used for the SPARC system in Monash. Use # them to send a high/low signal via the PMT Control Unit to the relay, thus # to pull/push the relay contact and control the power supply from the power # board to the flippers and filter wheel. def setRelay(self, value): # When True, the relay contact is connected if value: self._sendCommand("RELAY 1") else: self._sendCommand("RELAY 0") return value def getRelay(self): ans = self._sendCommand("RELAY?") if ans == "1": status = True else: status = False return status def _sendCommand(self, cmd): """ cmd (str): command to be sent to PMT Control unit. returns (str): answer received from the PMT Control unit raises: IOError: if an ERROR is returned by the PMT Control firmware. """ cmd = cmd + "\n" with self._ser_access: logging.debug("Sending command %s", cmd.encode('string_escape')) self._serial.write(cmd) ans = '' char = None while char != '\n': char = self._serial.read() if not char: logging.error("Timeout after receiving %s", ans.encode('string_escape')) # TODO: See how you should handle a timeout before you raise # an HWError raise HwError("PMT Control Unit connection timeout. " "Please turn off and on the power to the box.") # Handle ERROR coming from PMT control unit firmware ans += char logging.debug("Received answer %s", ans.encode('string_escape')) if ans.startswith("ERROR"): raise PMTControlError(ans.split(' ', 1)[1]) return ans.rstrip() @staticmethod def _openSerialPort(port): """ Opens the given serial port the right way for a PMT control device. port (string): the name of the serial port (e.g., /dev/ttyACM0) return (serial): the opened serial port """ ser = serial.Serial( port=port, timeout=5 # s ) # Purge ser.flush() ser.flushInput() # Try to read until timeout to be extra safe that we properly flushed while True: char = ser.read() if char == '': break logging.debug("Nothing left to read, PMT Control Unit can safely initialize.") return ser def _findDevice(self, ports): """ Look for a compatible device ports (str): pattern for the port name return (str): the name of the port used It also sets ._serial and ._idn to contain the opened serial port, and the identification string. raises: IOError: if no device are found """ # For debugging purpose if ports == "/dev/fake": self._serial = PMTControlSimulator(timeout=1) return ports if os.name == "nt": raise NotImplementedError("Windows not supported") else: names = glob.glob(ports) for n in names: try: self._serial = self._openSerialPort(n) # If the device has just been inserted, odemis-relay will block # it for 10s while reseting the relay, so be patient try: fcntl.flock(self._serial.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) except IOError: logging.info("Port %s is busy, will wait and retry", n) time.sleep(11) fcntl.flock(self._serial.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) try: idn = self._getIdentification() except PMTControlError: # Can happen if the device has received some weird characters # => try again (now that it's flushed) logging.info("Device answered by an error, will try again") idn = self._getIdentification() # Check that we connect to the right device if not idn.startswith("Delmic Analog PMT"): logging.info("Connected to wrong device on %s, skipping.", n) continue return n except (IOError, PMTControlError): # not possible to use this port? next one! continue else: raise HwError("Failed to find a PMT Control device on ports '%s'. " "Check that the device is turned on and connected to " "the computer." % (ports,)) @classmethod def scan(cls): """ returns (list of 2-tuple): name, args (sn) Note: it's obviously not advised to call this function if a device is already under use """ logging.info("Serial ports scanning for PMT control device in progress...") found = [] # (list of 2-tuple): name, kwargs if sys.platform.startswith('linux'): # Look for each ACM device, if the IDN is the expected one acm_paths = glob.glob('/dev/ttyACM?') for port in acm_paths: # open and try to communicate try: dev = cls(name="test", role="test", port=port) idn = dev._getIdentification() if idn.startswith("Delmic Analog PMT"): found.append({"port": port}) except Exception: pass else: # TODO: Windows version raise NotImplementedError("OS not yet supported") return found
class TestExecutor(unittest.TestCase): def setUp(self): self.executor = None def tearDown(self): if self.executor: self.executor.shutdown(wait=True) def test_one_cancellable(self): """ Test cancelling multiple cancellable futures running one at a time """ self.executor = CancellableThreadPoolExecutor(max_workers=1) self.called = 0 # Put several long task, and cancel all of them fs = [] for i in range(20): f = CancellableFuture() f.task_canceller = self._canceller f._must_stop = threading.Event() f = self.executor.submitf(f, self._cancellable_task, f, 3 + i) f.add_done_callback(self._on_end_task) fs.append(f) time.sleep(0.1) self.executor.cancel() self.assertEquals(self.called, 20) for f in fs: self.assertTrue(f.cancelled()) self.assertRaises(CancelledError, f.result) def test_multiple_simple(self): """ Try to cancel multiple running simple futures """ self.executor = CancellableThreadPoolExecutor(max_workers=10) # Put several long task, and cancel all of them fs = [] for i in range(20): f = self.executor.submit(self._task, 3 + i) fs.append(f) time.sleep(0.1) self.executor.cancel() cancelled = 0 for f in fs: if f.cancelled(): cancelled += 1 self.assertRaises(CancelledError, f.result) else: self.assertGreaterEqual(f.result(), 1) # should be a number self.assertGreaterEqual(cancelled, 10) def _task(self, dur): time.sleep(dur) return dur def test_multiple_cancellable(self): """ Try to cancel multiple running cancellable futures """ self.executor = CancellableThreadPoolExecutor(max_workers=10) # Put several long task, and cancel all of them fs = [] for i in range(20): f = CancellableFuture() f.task_canceller = self._canceller f._must_stop = threading.Event() f = self.executor.submitf(f, self._cancellable_task, f, 3 + i) fs.append(f) time.sleep(0.1) self.executor.cancel() for f in fs: self.assertTrue(f.cancelled()) self.assertRaises(CancelledError, f.result) def _cancellable_task(self, future, dur=0): """ Fake task future dur (float): time to wait return (float): dur """ now = time.time() end = now + dur while now < end: left = end - now ms = future._must_stop.wait(max(0, left)) if ms: raise CancelledError() now = time.time() return dur def _canceller(self, future): future._must_stop.set() # for now we assume cancel is always successful return True def _on_end_task(self, future): self.called += 1
class MFF(model.Actuator): """ Represents one Thorlabs Motorized Filter Flipper (ie: MFF101 or MFF102) """ def __init__(self, name, role, sn=None, port=None, axis="rz", inverted=None, **kwargs): """ sn (str): serial number (recommended) port (str): port name (only if sn is not specified) axis (str): name of the axis inverted (set of str): names of the axes which are inverted (IOW, either empty or the name of the axis) """ if (sn is None and port is None) or (sn is not None and port is not None): raise ValueError( "sn or port argument must be specified (but not both)") if sn is not None: if not sn.startswith(SN_PREFIX_MFF) or len(sn) != 8: logging.warning( "Serial number '%s' is unexpected for a MFF " "device (should be 8 digits starting with %s).", sn, SN_PREFIX_MFF) self._port = self._getSerialPort(sn) else: self._port = port self._serial = self._openSerialPort(self._port) self._ser_access = threading.Lock() # Ensure we don't receive anything self.SendMessage(HW_STOP_UPDATEMSGS) self._serial.flushInput() # Documentation says it should be done first, though it doesn't seem # required self.SendMessage(HW_NO_FLASH_PROGRAMMING) # will take care of executing axis move asynchronously self._executor = CancellableThreadPoolExecutor( max_workers=1) # one task at a time # TODO: have the standard inverted Actuator functions work on enumerated # use a different format than the standard Actuator if inverted and axis in inverted: self._pos_to_jog = {POS_UP: 2, POS_DOWN: 1} self._status_to_pos = {STA_RVS_HLS: POS_UP, STA_FWD_HLS: POS_DOWN} else: self._pos_to_jog = {POS_UP: 1, POS_DOWN: 2} self._status_to_pos = {STA_FWD_HLS: POS_UP, STA_RVS_HLS: POS_DOWN} # TODO: add support for speed axes = { axis: model.Axis(unit="rad", choices=set(self._pos_to_jog.keys())) } model.Actuator.__init__(self, name, role, axes=axes, **kwargs) driver_name = driver.getSerialDriver(self._port) self._swVersion = "%s (serial driver: %s)" % (odemis.__version__, driver_name) snd, modl, typ, fmv, notes, hwv, state, nc = self.GetInfo() self._hwVersion = "%s v%d (firmware %s)" % (modl, hwv, fmv) self.position = model.VigilantAttribute({}, readonly=True) self._updatePosition() # It'd be nice to know when a move is over, but it seems the MFF10x # never report ends of move. # self.SendMessage(MOT_RESUME_ENDOFMOVEMSGS) # If we need constant status updates, then, we'll need to answer them # with MOT_ACK_DCSTATUSUPDATE at least once per second. # For now we don't track the current device status, so it's easy. # When requesting update messages, messages are sent at ~10Hz, even if # no change has happened. # self.SendMessage(HW_START_UPDATEMSGS) # Causes a lot of messages # We should make sure that the led is always off, but apparently, it's # always off without doing anything (cf MOT_SET_AVMODES) def terminate(self): if self._executor: self.stop() self._executor.shutdown() self._executor = None with self._ser_access: if self._serial: self._serial.close() self._serial = None def SendMessage(self, msg, dest=0x50, src=1, p1=None, p2=None, data=None): """ Send a message to a device and possibility wait for its response msg (APTSet or APTReq): the message definition dest (0<int): the destination ID (always 0x50 if directly over USB) p1 (None or 0<=int<=255): param1 (passed as byte2) p2 (None or 0<=int<=255): param2 (passed as byte3) data (None or bytes): data to be send further. Cannot be mixed with p1 and p2 return (None or bytes): the content of the response or None if it was an APTSet message raise: IOError: if failed to send or receive message """ assert 0 <= dest < 0x80 # create the message if data is None: # short message p1 = p1 or 0 p2 = p2 or 0 com = struct.pack("<HBBBB", msg.id, p1, p2, dest, src) else: # long message com = struct.pack("<HHBB", msg.id, len(data), dest | 0x80, src) + data logging.debug("Sending: '%s'", ", ".join("%02X" % ord(c) for c in com)) with self._ser_access: self._serial.write(com) if isinstance(msg, APTReq): # read the response # ensure everything is sent, before expecting an answer self._serial.flush() # Read until end of answer while True: rid, res = self._ReadMessage() if rid == msg.rid: return res logging.debug("Skipping unexpected message %X", rid) # Note: unused def WaitMessage(self, msg, timeout=None): """ Wait until a specified message is received msg (APTMessage) timeout (float or None): maximum amount of time to wait return (bytes): the 2 params or the data contained in the message raise: IOError: if timeout happened """ start = time.time() # Read until end of answer with self._ser_access: while True: if timeout is not None: left = time.time() - start + timeout if left <= 0: raise IOError("No message %d received in time" % msg.id) else: left = None mid, res = self._ReadMessage(timeout=left) if mid == msg.id: return res # TODO: instead of discarding the message, it could go into a # queue, to be handled later logging.debug("Skipping unexpected message %X", mid) def _ReadMessage(self, timeout=None): """ Reads the next message timeout (0 < float): maximum time to wait for the message return: mid (int): message ID data (bytes): bytes 3&4 or the data of the message raise: IOError: if failed to send or receive message """ old_timeout = self._serial.timeout if timeout is not None: # Should be only for the first byte, but doing it for the first 6 # should rarely matter self._serial.timeout = timeout try: # read the first (required) 6 bytes msg = b"" for i in range(6): char = self._serial.read() # empty if timeout if not char: raise IOError("Controller timeout, after receiving %s" % msg) msg += char finally: self._serial.timeout = old_timeout mid = struct.unpack("<H", msg[0:2])[0] if not (ord(msg[4]) & 0x80): # short message logging.debug("Received: '%s'", ", ".join("%02X" % ord(c) for c in msg)) return mid, msg[2:4] # long message length = struct.unpack("<H", msg[2:4])[0] for i in range(length): char = self._serial.read() # empty if timeout if not char: raise IOError("Controller timeout, after receiving %s" % msg) msg += char logging.debug("Received: '%s'", ", ".join("%02X" % ord(c) for c in msg)) return mid, msg[6:] # Low level functions def GetInfo(self): """ returns: serial number (int) model number (str) type (int) firmware version (str) notes (str) hardware version (int) hardware state (int) number of channels (int) """ res = self.SendMessage(HW_REQ_INFO) # Expects 0x54 bytes values = struct.unpack('<I8sHI48s12xHHH', res) sn, modl, typ, fmv, notes, hwv, state, nc = values # remove trailing 0's modl = modl.rstrip("\x00") notes = notes.rstrip("\x00") # Convert firmware version to a string fmvs = "%d.%d.%d" % ((fmv & 0xff0000) >> 16, (fmv & 0xff00) >> 8, fmv & 0xff) return sn, modl, typ, fmvs, notes, hwv, state, nc def MoveJog(self, pos): """ Move the position. Note: this is asynchronous. pos (int): 1 or 2 """ assert pos in [1, 2] # p1 is chan ident, always 1 self.SendMessage(MOT_MOVE_JOG, p1=1, p2=pos) def GetStatus(self): """ return: pos (int): position count status (int): status, as a flag of STA_* """ res = self.SendMessage(MOT_REQ_STATUSUPDATE) # expect 14 bytes c, pos, enccount, status = struct.unpack('<HiiI', res) return pos, status # high-level methods (interface) def _updatePosition(self): """ update the position VA """ _, status = self.GetStatus() pos = {} for axis in self.axes: # axes contains precisely one axis # status' flags should never be present simultaneously for f, p in self._status_to_pos.items(): if f & status: pos[axis] = p break else: logging.warning( "Status %X doesn't contain position information", status) return # don't change position # it's read-only, so we change it via _value self.position._value = pos self.position.notify(self.position.value) def _waitNoMotion(self, timeout=None): """ Block as long as the controller reports motion timeout (0 < float): maximum time to wait for the end of the motion """ start = time.time() # Read until end of motion while True: _, status = self.GetStatus() if not (status & STA_IN_MOTION): return if timeout is not None and (time.time() > start + timeout): raise IOError("Device still in motion after %g s" % (timeout, )) # Give it a small break time.sleep(0.05) # 20Hz @isasync def moveRel(self, shift): if not shift: return model.InstantaneousFuture() self._checkMoveRel(shift) # TODO move to the +N next position? (and modulo number of axes) raise NotImplementedError( "Relative move on enumerated axis not supported") @isasync def moveAbs(self, pos): if not pos: return model.InstantaneousFuture() self._checkMoveAbs(pos) return self._executor.submit(self._doMovePos, pos.values()[0]) def stop(self, axes=None): self._executor.cancel() def _doMovePos(self, pos): jogp = self._pos_to_jog[pos] self.MoveJog(jogp) self._waitNoMotion(10) # by default, a move lasts ~0.5 s self._updatePosition() @staticmethod def _openSerialPort(port): """ Opens the given serial port the right way for a Thorlabs APT device. port (string): the name of the serial port (e.g., /dev/ttyUSB0) return (serial): the opened serial port """ # For debugging purpose if port == "/dev/fake": return MFF102Simulator(timeout=1) ser = serial.Serial( port=port, baudrate=115200, bytesize=serial.EIGHTBITS, parity=serial.PARITY_NONE, stopbits=serial.STOPBITS_ONE, rtscts=True, timeout=1 #s ) # Purge (as recommended in the documentation) time.sleep(0.05) # 50 ms ser.flush() ser.flushInput() time.sleep(0.05) # 50 ms # Prepare the port ser.setRTS() return ser def _getSerialPort(self, sn): """ sn (str): serial number of the device return (str): serial port name (eg: "/dev/ttyUSB0" on Linux) """ if sys.platform.startswith('linux'): # Look for each USB device, if the serial number is good sn_paths = glob.glob('/sys/bus/usb/devices/*/serial') for p in sn_paths: try: f = open(p) snp = f.read().strip() except IOError: logging.debug("Failed to read %s, skipping device", p) if snp == sn: break else: raise ValueError("No USB device with S/N %s" % sn) # Deduce the tty: # .../3-1.2/serial => .../3-1.2/3-1.2:1.0/ttyUSB1 sys_path = os.path.dirname(p) usb_num = os.path.basename(sys_path) tty_paths = glob.glob("%s/%s/ttyUSB?*" % (sys_path, usb_num + ":1.0")) if not tty_paths: raise ValueError("Failed to find tty for device with S/N %s" % sn) tty = os.path.basename(tty_paths[0]) # Convert to /dev # Note: that works because udev rules create a dev with the same name # otherwise, we would need to check the char numbers return "/dev/%s" % (tty, ) else: # TODO: Windows version raise NotImplementedError("OS not yet supported") @classmethod def scan(cls): """ returns (list of 2-tuple): name, args (sn) Note: it's obviously not advised to call this function if a device is already under use """ logging.info( "Serial ports scanning for Thorlabs MFFxxx in progress...") found = [] # (list of 2-tuple): name, kwargs if sys.platform.startswith('linux'): # Look for each USB device, if the serial number is potentially good sn_paths = glob.glob('/sys/bus/usb/devices/*/serial') for p in sn_paths: try: f = open(p) snp = f.read().strip() except IOError: logging.debug("Failed to read %s, skipping device", p) if not (snp.startswith(SN_PREFIX_MFF) and len(snp) == 8): continue # Deduce the tty: # .../3-1.2/serial => .../3-1.2/3-1.2:1.0/ttyUSB1 sys_path = os.path.dirname(p) usb_num = os.path.basename(sys_path) logging.info("Looking at device %s with S/N=%s", usb_num, snp) tty_paths = glob.glob("%s/%s/ttyUSB?*" % (sys_path, usb_num + ":1.0")) if not tty_paths: # 0 or 1 paths continue tty = os.path.basename(tty_paths[0]) # Convert to /dev # Note: that works because udev rules create a dev with the same name # otherwise, we would need to check the char numbers port = "/dev/%s" % (tty, ) # open and try to communicate try: dev = cls(name="test", role="test", port=port) _, modl, typ, fmv, notes, hwv, state, nc = dev.GetInfo() found.append((modl, {"sn": snp, "axis": "rz"})) except Exception: pass else: # TODO: Windows version raise NotImplementedError("OS not yet supported") return found
class Stage(model.Actuator): """ This is an extension of the model.Actuator class. It provides functions for moving the Tescan stage and updating the position. """ def __init__(self, name, role, parent, **kwargs): """ axes (set of string): names of the axes """ axes_def = {} self._position = {} rng = [-0.5, 0.5] axes_def["x"] = model.Axis(unit="m", range=rng) axes_def["y"] = model.Axis(unit="m", range=rng) axes_def["z"] = model.Axis(unit="m", range=rng) # Demand calibrated stage if parent._device.StgIsCalibrated() != 1: logging.warning( "Stage was not calibrated. We are performing calibration now.") parent._device.StgCalibrate() #Wait for stage to be stable after calibration while parent._device.StgIsBusy() != 0: # If the stage is busy (movement is in progress), current position is # updated approximately every 500 ms time.sleep(0.5) x, y, z, rot, tilt = parent._device.StgGetPosition() self._position["x"] = -x * 1e-3 self._position["y"] = -y * 1e-3 self._position["z"] = -z * 1e-3 model.Actuator.__init__(self, name, role, parent=parent, axes=axes_def, **kwargs) # will take care of executing axis move asynchronously self._executor = CancellableThreadPoolExecutor( max_workers=1) # one task at a time # RO, as to modify it the client must use .moveRel() or .moveAbs() self.position = model.VigilantAttribute(self._applyInversionAbs( self._position), unit="m", readonly=True) def _updatePosition(self): """ update the position VA """ # it's read-only, so we change it via _value self.position._value = self._applyInversionAbs(self._position) self.position.notify(self.position.value) def _doMove(self, pos): """ move to the position """ # Perform move through Tescan API # Position from m to mm and inverted self.parent._device.StgMoveTo(-pos["x"] * 1e3, -pos["y"] * 1e3, -pos["z"] * 1e3) # Obtain the finally reached position after move is performed. # This is mainly in order to keep the correct position in case the # move we tried to perform was greater than the maximum possible # one. with self.parent._acquisition_init_lock: x, y, z, rot, tilt = self.parent._device.StgGetPosition() self._position["x"] = -x * 1e-3 self._position["y"] = -y * 1e-3 self._position["z"] = -z * 1e-3 self._updatePosition() @isasync def moveRel(self, shift): if not shift: return model.InstantaneousFuture() self._checkMoveRel(shift) shift = self._applyInversionRel(shift) for axis, change in shift.items(): self._position[axis] += change pos = self._position return self._executor.submit(self._doMove, pos) @isasync def moveAbs(self, pos): if not pos: return model.InstantaneousFuture() self._checkMoveAbs(pos) pos = self._applyInversionAbs(pos) for axis, new_pos in pos.items(): self._position[axis] = new_pos pos = self._position return self._executor.submit(self._doMove, pos) def stop(self, axes=None): # Empty the queue for the given axes self._executor.cancel() logging.warning("Stopping all axes: %s", ", ".join(self.axes)) def terminate(self): if self._executor: self.stop() self._executor.shutdown() self._executor = None
class EbeamFocus(model.Actuator): """ This is an extension of the model.Actuator class. It provides functions for adjusting the ebeam focus by changing the working distance i.e. the distance between the end of the objective and the surface of the observed specimen """ def __init__(self, name, role, parent, axes, ranges=None, **kwargs): assert len(axes) > 0 if ranges is None: ranges = {} axes_def = {} self._position = {} # Just z axis a = axes[0] # The maximum, obviously, is not 1 meter. We do not actually care # about the range since Tescan API will adjust the value set if the # required one is out of limits. rng = [0, 1] axes_def[a] = model.Axis(unit="m", range=rng) # start at the centre self._position[a] = parent._device.GetWD() * 1e-3 model.Actuator.__init__(self, name, role, parent=parent, axes=axes_def, **kwargs) # RO, as to modify it the client must use .moveRel() or .moveAbs() self.position = model.VigilantAttribute(self._applyInversionAbs( self._position), unit="m", readonly=True) # will take care of executing axis move asynchronously self._executor = CancellableThreadPoolExecutor( max_workers=1) # one task at a time def _updatePosition(self): """ update the position VA """ # it's read-only, so we change it via _value self.position._value = self._applyInversionAbs(self._position) self.position.notify(self.position.value) def _doMove(self, pos): """ move to the position """ # Perform move through Tescan API # Position from m to mm and inverted self.parent._device.SetWD(self._position["z"] * 1e03) # Obtain the finally reached position after move is performed. with self.parent._acquisition_init_lock: wd = self.parent._device.GetWD() self._position["z"] = wd * 1e-3 # Changing WD results to change in fov self.parent._scanner.updateHorizontalFOV() self._updatePosition() @isasync def moveRel(self, shift): if not shift: return model.InstantaneousFuture() self._checkMoveRel(shift) shift = self._applyInversionRel(shift) for axis, change in shift.items(): self._position[axis] += change pos = self._position return self._executor.submit(self._doMove, pos) @isasync def moveAbs(self, pos): if not pos: return model.InstantaneousFuture() self._checkMoveAbs(pos) pos = self._applyInversionAbs(pos) for axis, new_pos in pos.items(): self._position[axis] = new_pos pos = self._position return self._executor.submit(self._doMove, pos) def stop(self, axes=None): # Empty the queue for the given axes self._executor.cancel() logging.warning("Stopping all axes: %s", ", ".join(self.axes)) def terminate(self): if self._executor: self.stop() self._executor.shutdown() self._executor = None
class ChamberPressure(model.Actuator): """ This is an extension of the model.Actuator class. It provides functions for adjusting the chamber pressure. It actually allows the user to evacuate or vent the chamber and get the current pressure of it. """ def __init__(self, name, role, parent, ranges=None, **kwargs): axes = { "pressure": model.Axis(unit="Pa", choices={ PRESSURE_VENTED: "vented", PRESSURE_PUMPED: "vacuum" }) } model.Actuator.__init__(self, name, role, parent=parent, axes=axes, **kwargs) # last official position if self.GetStatus() == 0: self._position = PRESSURE_PUMPED else: self._position = PRESSURE_VENTED # RO, as to modify it the client must use .moveRel() or .moveAbs() self.position = model.VigilantAttribute({"pressure": self._position}, unit="Pa", readonly=True) # Almost the same as position, but gives the current position self.pressure = model.VigilantAttribute(self._position, unit="Pa", readonly=True) # will take care of executing axis move asynchronously self._executor = CancellableThreadPoolExecutor( max_workers=1) # one task at a time def GetStatus(self): """ return int: vacuum status, -1 error 0 ready for operation 1 pumping in progress 2 venting in progress 3 vacuum off (pumps are switched off, valves are closed) 4 chamber open """ with self.parent._acquisition_init_lock: status = self.parent._device.VacGetStatus() # channel 0, reserved return status def terminate(self): if self._executor: self.stop() self._executor.shutdown() self._executor = None def _updatePosition(self): """ update the position VA and .pressure VA """ # it's read-only, so we change it via _value pos = self.parent._device.VacGetPressure(0) self.pressure._value = pos self.pressure.notify(pos) # .position contains the last known/valid position # it's read-only, so we change it via _value self.position._value = {"pressure": self._position} self.position.notify(self.position.value) @isasync def moveRel(self, shift): self._checkMoveRel(shift) # convert into an absolute move pos = {} for a, v in shift.items: pos[a] = self.position.value[a] + v return self.moveAbs(pos) @isasync def moveAbs(self, pos): if not pos: return model.InstantaneousFuture() self._checkMoveAbs(pos) return self._executor.submit(self._changePressure, pos["pressure"]) def _changePressure(self, p): """ Synchronous change of the pressure p (float): target pressure """ if p["pressure"] == PRESSURE_VENTED: self.parent._device.VacVent() else: self.parent._device.VacPump() start = time.time() while not self.GetStatus() == 0: if (time.time() - start) >= VACUUM_TIMEOUT: raise TimeoutError("Vacuum action timed out") # Update chamber pressure until pumping/venting process is done self._updatePosition() self._position = p self._updatePosition() def stop(self, axes=None): self._executor.cancel() logging.warning("Stopped pressure change")
class SpectraPro(model.Actuator): def __init__(self, name, role, port, turret=None, _noinit=False, **kwargs): """ port (string): name of the serial port to connect to. turret (None or 1<=int<=3): turret number set-up. If None, consider that the current turret known by the device is correct. inverted (None): it is not allowed to invert the axes _noinit (boolean): for internal use only, don't try to initialise the device """ if kwargs.get("inverted", None): raise ValueError("Axis of spectrograph cannot be inverted") # start with this opening the port: if it fails, we are done self._serial = self.openSerialPort(port) self._port = port # to acquire before sending anything on the serial port self._ser_access = threading.Lock() self._try_recover = False if _noinit: return self._initDevice() self._try_recover = True # according to the model determine how many gratings per turret model_name = self.GetModel() self.max_gratings = MAX_GRATINGS_NUM.get(model_name, 3) if turret is not None: if turret < 1 or turret > self.max_gratings: raise ValueError("Turret number given is %s, while expected a value between 1 and %d" % (turret, self.max_gratings)) self.SetTurret(turret) self._turret = turret else: self._turret = self.GetTurret() # for now, it's fixed (and it's unlikely to be useful to allow less than the max) max_speed = 1000e-9 / 10 # about 1000 nm takes 10s => max speed in m/s self.speed = model.MultiSpeedVA(max_speed, range=[max_speed, max_speed], unit="m/s", readonly=True) gchoices = self.GetGratingChoices() # remove the choices which are not valid for the current turret for c in gchoices: t = 1 + (c - 1) // self.max_gratings if t != self._turret: del gchoices[c] # TODO: report the grating with its wavelength range (possible to compute from groove density + blaze wl?) # range also depends on the max grating angle (40°, CCD pixel size, CCD horizontal size, focal length,+ efficienty curve?) # cf http://www.roperscientific.de/gratingcalcmaster.html # TODO: a more precise way to find the maximum wavelength (looking at the available gratings?) # TODO: what's the min? 200nm seems the actual min working, although wavelength is set to 0 by default !? axes = {"wavelength": model.Axis(unit="m", range=(0, 2400e-9), speed=(max_speed, max_speed)), "grating": model.Axis(choices=gchoices) } # provides a ._axes model.Actuator.__init__(self, name, role, axes=axes, **kwargs) # set HW and SW version self._swVersion = "%s (serial driver: %s)" % (odemis.__version__, driver.getSerialDriver(port)) self._hwVersion = "%s (s/n: %s)" % (model_name, (self.GetSerialNumber() or "Unknown")) # will take care of executing axis move asynchronously self._executor = CancellableThreadPoolExecutor(max_workers=1) # one task at a time pos = {"wavelength": self.GetWavelength(), "grating": self.GetGrating()} # RO, as to modify it the client must use .moveRel() or .moveAbs() self.position = model.VigilantAttribute(pos, unit="m", readonly=True) # store focal length and inclusion angle for the polynomial computation try: self._focal_length = FOCAL_LENGTH_OFFICIAL[model_name] self._inclusion_angle = math.radians(INCLUSION_ANGLE_OFFICIAL[model_name]) except KeyError: self._focal_length = None self._inclusion_angle = None # Low-level methods: to access the hardware (should be called with the lock acquired) def _sendOrder(self, *args, **kwargs): """ Send a command which does not expect any report back (just OK) com (str): command to send (non including the \r) raise SPError: if the command doesn't answer the expected OK. IOError: in case of timeout """ # same as a query but nothing to do with the response self._sendQuery(*args, **kwargs) def _sendQuery(self, com, timeout=1): """ Send a command which expects a report back (in addition to the OK) com (str): command to send (non including the \r) timeout (0<float): maximum read timeout for the response return (str): the response received (without the ok) raises: SPError: if the command doesn't answer the expected OK. IOError: in case of timeout """ # All commands or strings of commands must be terminated with a carriage # return (0D hex). The monochromator responds to a command when the # command has been completed by returning the characters " ok" followed by # carriage return and line feed (hex ASCII sequence 20 6F 6B 0D 0A). # Examples of error answers: #MODEL\r # \x00X\xf0~\x00X\xf0~MODEL ? \r\n #?\r # \r\nAddress Error \r\nA=3F4F4445 PC=81444 assert(len(com) > 1 and len(com) <= 100) # commands cannot be long com += "\r" logging.debug("Sending: %s", com.encode('string_escape')) # send command until it succeeds while True: try: self._serial.write(com) break except IOError: if self._try_recover: self._tryRecover() else: raise # read response until timeout or known end of response response = "" timeend = time.time() + timeout while ((time.time() <= timeend) and not (response.endswith(" ok\r\n") or response.endswith("? \r\n"))): self._serial.timeout = max(0.1, timeend - time.time()) char = self._serial.read() if not char: # timeout break response += char logging.debug("Received: %s", response.encode('string_escape')) if response.endswith(" ok\r\n"): return response[:-5] else: # if the device hasn't answered anything, it might have been disconnected if len(response) == 0: if self._try_recover: self._tryRecover() else: raise IOError("Device timeout after receiving '%s'." % response.encode('string_escape')) else: # just non understood command # empty the serial port self._serial.timeout = 0.1 garbage = self._serial.read(100) if len(garbage) == 100: raise IOError("Device keeps sending data") response += garbage raise SPError("Sent '%s' and received error: '%s'" % (com.encode('string_escape'), response.encode('string_escape'))) def _tryRecover(self): # no other access to the serial port should be done # so _ser_access should already be acquired # Retry to open the serial port (in case it was unplugged) while True: try: self._serial.close() self._serial = None except: pass try: logging.debug("retrying to open port %s", self._port) self._serial = self.openSerialPort(self._port) except IOError: time.sleep(2) except Exception: logging.exception("Unexpected error while trying to recover device") raise else: break self._try_recover = False # to avoid recursion self._initDevice() self._try_recover = True def _initDevice(self): # If no echo is desired, the command NO-ECHO will suppress the echo. The # command ECHO will return the SP-2150i to the default echo state. # # If is connected via the real serial port (not USB), it is in echo # mode, so we first need to disable it, while allowing echo of the # command we've just sent. try: r = self._sendOrder("no-echo") except SPError: logging.info("Failed to disable echo, hopping the device has not echo anyway") # empty the serial port self._serial.timeout = 0.1 garbage = self._serial.read(100) if len(garbage) == 100: raise IOError("Device keeps sending data") def GetTurret(self): """ returns (1 <= int <= 3): the current turret number """ # ?TURRET Returns the correctly installed turret numbered 1 - 3 res = self._sendQuery("?turret") val = int(res) if val < 1 or val > 3: raise SPError("Unexpected turret number '%s'" % res) return val def SetTurret(self, t): """ Set the number of the current turret (for correct settings by the hardware) t (1 <= int <= 3): the turret number Raise: ValueError if the turret has no grating configured """ # TURRET Specifies the presently installed turret or the turret to be installed. # Doesn't change the hardware, just which gratings are available assert(1 <= t and t <= 3) # TODO check that there is grating configured for this turret (using GetGratingChoices) self._sendOrder("%d turret" % t) # regex to read the gratings RE_NOTINSTALLED = re.compile("\D*(\d+)\s+Not Installed") RE_INSTALLED = re.compile("\D*(\d+)\s+(\d+)\s*g/mm BLZ=\s*([0-9][.0-9]*)\s*(nm|NM|um|UM)") RE_GRATING = re.compile("\D*(\d+)\s+(.+\S)\s*\r") def GetGratingChoices(self): """ return (dict int -> string): grating number to description """ # ?GRATINGS Returns the list of installed gratings with position groove density and blaze. The # present grating is specified with an arrow. # Example output: # \r\n 1 300 g/mm BLZ= 500NM \r\n\x1a2 300 g/mm BLZ= 750NM \r\n 3 Not Installed \r\n 4 Not Installed \r\n 5 Not Installed \r\n 6 Not Installed \r\n 7 Not Installed \r\n 8 Not Installed \r\n ok\r\n # \r\n\x1a1 600 g/mm BLZ= 1.6UM \r\n 2 150 g/mm BLZ= 2UM \r\n 3 Not Installed \r\n 4 Not Installed \r\n 5 Not Installed \r\n 6 Not Installed \r\n 7 Not Installed \r\n 8 Not Installed \r\n 9 Not Installed \r\n ok\r\n # From the spectrapro_300i_ll.c of fsc2, it seems the format is: # non-digit*,digits=grating number,spaces,"Not Installed"\r\n # non-digit*,digits=grating number,space+,digit+:g/mm,space*,"g/mm BLZ=", space*,digit+:blaze wl in nm,space*,"nm"\r\n res = self._sendQuery("?gratings") gratings = {} for line in res[:-1].split("\n"): # avoid the last \n to not make an empty last line m = self.RE_NOTINSTALLED.search(line) if m: logging.debug("Decoded grating %s as not installed, skipping.", m.group(1)) continue m = self.RE_GRATING.search(line) if not m: logging.debug("Failed to decode grating description '%s'", line) continue num = int(m.group(1)) desc = m.group(2) # TODO: provide a nicer description, using RE_INSTALLED? gratings[num] = desc return gratings RE_GDENSITY = re.compile("(\d+)\s*g/mm") def _getGrooveDensity(self, gid): """ Returns the groove density of the given grating gid (int): index of the grating returns (float): groove density in lines/meter raise LookupError if the grating is not installed ValueError: if the groove density cannot be found out """ gstring = self.axes["grating"].choices[gid] m = self.RE_GDENSITY.search(gstring) if not m: raise ValueError("Failed to find groove density in '%s'" % gstring) density = float(m.group(1)) * 1e3 # l/m return density def GetGrating(self): """ Retuns the current grating in use returns (1<=int<=9) the grating in use """ # ?GRATING Returns the number of gratings presently being used numbered 1 - 9. # On the SP-2150i, it's only up to 6 res = self._sendQuery("?grating") val = int(res) if val < 1 or val > 9: raise SPError("Unexpected grating number '%s'" % res) return val def SetGrating(self, g): """ Change the current grating (the turret turns). g (1<=int<=9): the grating number to change to The method is synchronous, it returns once the grating is selected. It might take up to 20 s. Note: the grating is dependent on turret number (and the self.max_gratting)! Note: after changing the grating, the wavelength, might have changed """ #GRATING Places specified grating in position to the [current] wavelength # Note: it always reports ok, and doesn't change the grating if not # installed or wrong value assert(1 <= g and g <= (3 * self.max_gratings)) # TODO check that the grating is configured self._sendOrder("%d grating" % g, timeout=20) def GetWavelength(self): """ Return (0<=float): the current wavelength at the center (in m) """ # ?NM Returns present wavelength in nm to 0.01nm resolution with units # nm appended. # Note: For the SP-2150i, it seems there is no unit appended # ?NM 300.00 nm res = self._sendQuery("?nm") m = re.search("\s*(\d+.\d+)( nm)?", res) wl = float(m.group(1)) * 1e-9 if wl > 1e-3: raise SPError("Unexpected wavelength of '%s'" % res) return wl def SetWavelength(self, wl): """ Change the wavelength at the center wl (0<=float<=10e-6): wavelength in meter returns when the move is complete The method is synchronous, it returns once the grating is selected. It might take up to 20 s. """ # GOTO: Goes to a destination wavelength at maximum motor speed. Accepts # destination wavelength in nm as a floating point number with up to 3 # digits after the decimal point or whole number wavelength with no # decimal point. # 345.65 GOTO # Note: NM goes to the wavelength slowly (in order to perform a scan). # It shouldn't be needed for spectrometer # Out of bound values are silently ignored by going to the min or max. assert(0 <= wl and wl <= 10e-6) # TODO: check that the value fit the grating configuration? self._sendOrder("%.3f goto" % (wl * 1e9), timeout=20) def GetModel(self): """ Return (str): the model name """ # MODEL Returns model number of the Acton SP series monochromator. # returns something like ' SP-2-150i ' res = self._sendQuery("model") return res.strip() def GetSerialNumber(self): """ Return the serial number or None if it cannot be determined """ try: res = self._sendQuery("serial") except SPError: logging.exception("Device doesn't support serial number query") return None return res.strip() # TODO diverter (mirror) functions: no diverter on SP-2??0i anyway. # high-level methods (interface) def _updatePosition(self): """ update the position VA Note: it should not be called while holding _ser_access """ with self._ser_access: pos = {"wavelength": self.GetWavelength(), "grating": self.GetGrating() } # it's read-only, so we change it via _value self.position._value = pos self.position.notify(self.position.value) @isasync def moveRel(self, shift): """ Move the stage the defined values in m for each axis given. shift dict(string-> float): name of the axis and shift in m returns (Future): future that control the asynchronous move """ self._checkMoveRel(shift) for axis in shift: if axis == "wavelength": # cannot convert it directly to an absolute move, because # several in a row must mean they accumulate. So we queue a # special task. That also means the range check is delayed until # the actual position is known. return self._executor.submit(self._doSetWavelengthRel, shift[axis]) @isasync def moveAbs(self, pos): """ Move the stage the defined values in m for each axis given. pos dict(string-> float): name of the axis and new position in m returns (Future): future that control the asynchronous move """ self._checkMoveAbs(pos) # If grating needs to be changed, change it first, then the wavelength if "grating" in pos: g = pos["grating"] wl = pos.get("wavelength") return self._executor.submit(self._doSetGrating, g, wl) elif "wavelength" in pos: wl = pos["wavelength"] return self._executor.submit(self._doSetWavelengthAbs, wl) else: # nothing to do return model.InstantaneousFuture() def _doSetWavelengthRel(self, shift): """ Change the wavelength by a value """ with self._ser_access: pos = self.GetWavelength() + shift # it's only now that we can check the absolute position is wrong minp, maxp = self.axes["wavelength"].range if not minp <= pos <= maxp: raise ValueError("Position %f of axis '%s' not within range %f→%f" % (pos, "wavelength", minp, maxp)) self.SetWavelength(pos) self._updatePosition() def _doSetWavelengthAbs(self, pos): """ Change the wavelength to a value """ with self._ser_access: self.SetWavelength(pos) self._updatePosition() def _doSetGrating(self, g, wl=None): """ Setter for the grating VA. g (1<=int<=3): the new grating wl (None or float): wavelength to set afterwards. If None, will put the same wavelength as before the change of grating. returns the actual new grating Warning: synchronous until the grating is finished (up to 20s) """ try: with self._ser_access: if wl is None: wl = self.position.value["wavelength"] self.SetGrating(g) self.SetWavelength(wl) except Exception: logging.exception("Failed to change grating to %d", g) raise self._updatePosition() def stop(self, axes=None): """ stops the motion Warning: Only not yet-executed moves can be cancelled, this hardware doesn't support stopping while a move is going on. """ self._executor.cancel() def terminate(self): if self._executor: self.stop() self._executor.shutdown() self._executor = None if self._serial: self._serial.close() self._serial = None def getPolyToWavelength(self): """ Compute the right polynomial to convert from a position on the sensor to the wavelength detected. It depends on the current grating, center wavelength (and focal length of the spectrometer). Note: It will always return some not-too-stupid values, but the only way to get precise values is to have provided a calibration data file. Without it, it will just base the calculations on the theoretical perfect spectrometer. returns (list of float): polynomial coefficients to apply to get the current wavelength corresponding to a given distance from the center: w = p[0] + p[1] * x + p[2] * x²... where w is the wavelength (in m), x is the position from the center (in m, negative are to the left), and p is the polynomial (in m, m^0, m^-1...). """ # FIXME: shall we report the error on the polynomial? At least say if it's # using calibration or not. # TODO: have a calibration procedure, a file format, and load it at init # See fsc2, their calibration is like this for each grating: # INCLUSION_ANGLE_1 = 30.3 # FOCAL_LENGTH_1 = 301.2 mm # DETECTOR_ANGLE_1 = 0.324871 fl = self._focal_length # m ia = self._inclusion_angle # rad cw = self.position.value["wavelength"] # m if not fl: # "very very bad" calibration return [cw] # When no calibration available, fallback to theoretical computation # based on http://www.roperscientific.de/gratingcalcmaster.html gl = self._getGrooveDensity(self.position.value["grating"]) # g/m # fL = focal length (mm) # wE = inclusion angle (°) = the angle between the incident and the reflected beam for the center wavelength of the grating # gL = grating lines (l/mm) # cW = center wavelength (nm) # Grating angle #A8 = (cW/1000*gL/2000)/Math.cos(wE* Math.PI/180); # E8 = Math.asin(A8)*180/Math.PI; try: a8 = (cw * gl/2) / math.cos(ia) ga = math.asin(a8) # radians except (ValueError, ZeroDivisionError): logging.exception("Failed to compute polynomial for wavelength conversion") return [cw] # if (document.forms[0].E8.value == "NaN deg." || E8 > 40){document.forms[0].E8.value = "> 40 deg."; document.forms[0].E8.style.colour="red"; if 0.5 > math.degrees(ga) or math.degrees(ga) > 40: logging.warning("Failed to compute polynomial for wavelength " "conversion, got grating angle = %g°", math.degrees(ga)) return [cw] # dispersion: wavelength(m)/distance(m) # F8a = Math.cos(Math.PI/180*(wE*1 + E8))*(1000000)/(gL*fL); // nm/mm # to convert from nm/mm -> m/m : *1e-6 dispersion = math.cos(ia + ga) / (gl*fl) # m/m if 0 > dispersion or dispersion > 0.5e-3: # < 500 nm/mm logging.warning("Computed dispersion is not within expected bounds: %f nm/mm", dispersion * 1e6) return [cw] # polynomial is cw + dispersion * x return [cw, dispersion] def selfTest(self): """ check as much as possible that it works without actually moving the motor return (boolean): False if it detects any problem """ try: with self._ser_access: model = self.GetModel() if not model.startswith("SP-"): # accept it anyway logging.warning("Device reports unexpected model '%s'", model) turret = self.GetTurret() if not turret in (1,2,3): return False return True except: logging.exception("Selftest failed") return False @staticmethod def scan(port=None): """ port (string): name of the serial port. If None, all the serial ports are tried returns (list of 2-tuple): name, args (port) Note: it's obviously not advised to call this function if a device is already under use """ if port: ports = [port] else: if os.name == "nt": ports = ["COM" + str(n) for n in range (0,8)] else: ports = glob.glob('/dev/ttyS?*') + glob.glob('/dev/ttyUSB?*') logging.info("Serial ports scanning for Acton SpectraPro spectrograph in progress...") found = [] # (list of 2-tuple): name, kwargs for p in ports: try: logging.debug("Trying port %s", p) dev = SpectraPro(None, None, p, _noinit=True) except serial.SerialException: # not possible to use this port? next one! continue # Try to connect and get back some answer. try: model = dev.GetModel() if model.startswith("SP-"): found.append((model, {"port": p})) else: logging.info("Device on port '%s' responded correctly, but with unexpected model name '%s'.", p, model) except: continue return found @staticmethod def openSerialPort(port): """ Opens the given serial port the right way for the SpectraPro. port (string): the name of the serial port (e.g., /dev/ttyUSB0) return (serial): the opened serial port """ # according to doc: # "port set-up is 9600 baud, 8 data bits, 1 stop bit and no parity" ser = serial.Serial( port = port, baudrate = 9600, bytesize = serial.EIGHTBITS, parity = serial.PARITY_NONE, stopbits = serial.STOPBITS_ONE, timeout = 2 #s ) return ser
class MFF(model.Actuator): """ Represents one Thorlabs Motorized Filter Flipper (ie: MFF101 or MFF102) """ def __init__(self, name, role, sn=None, port=None, axis="rz", inverted=None, **kwargs): """ sn (str): serial number (recommended) port (str): port name (only if sn is not specified) axis (str): name of the axis inverted (set of str): names of the axes which are inverted (IOW, either empty or the name of the axis) """ if (sn is None and port is None) or (sn is not None and port is not None): raise ValueError("sn or port argument must be specified (but not both)") if sn is not None: if not sn.startswith(SN_PREFIX_MFF) or len(sn) != 8: logging.warning("Serial number '%s' is unexpected for a MFF " "device (should be 8 digits starting with %s).", sn, SN_PREFIX_MFF) self._port = self._getSerialPort(sn) else: self._port = port self._serial = self._openSerialPort(self._port) self._ser_access = threading.Lock() # Ensure we don't receive anything self.SendMessage(HW_STOP_UPDATEMSGS) self._serial.flushInput() # Documentation says it should be done first, though it doesn't seem # required self.SendMessage(HW_NO_FLASH_PROGRAMMING) # will take care of executing axis move asynchronously self._executor = CancellableThreadPoolExecutor(max_workers=1) # one task at a time # TODO: have the standard inverted Actuator functions work on enumerated # use a different format than the standard Actuator if inverted and axis in inverted: self._pos_to_jog = {POS_UP: 2, POS_DOWN: 1} self._status_to_pos = {STA_RVS_HLS: POS_UP, STA_FWD_HLS: POS_DOWN} else: self._pos_to_jog = {POS_UP: 1, POS_DOWN: 2} self._status_to_pos = {STA_FWD_HLS: POS_UP, STA_RVS_HLS: POS_DOWN} # TODO: add support for speed axes = {axis: model.Axis(unit="rad", choices=set(self._pos_to_jog.keys())) } model.Actuator.__init__(self, name, role, axes=axes, **kwargs) driver_name = driver.getSerialDriver(self._port) self._swVersion = "%s (serial driver: %s)" % (odemis.__version__, driver_name) snd, modl, typ, fmv, notes, hwv, state, nc = self.GetInfo() self._hwVersion = "%s v%d (firmware %s)" % (modl, hwv, fmv) self.position = model.VigilantAttribute({}, readonly=True) self._updatePosition() # It'd be nice to know when a move is over, but it seems the MFF10x # never report ends of move. # self.SendMessage(MOT_RESUME_ENDOFMOVEMSGS) # If we need constant status updates, then, we'll need to answer them # with MOT_ACK_DCSTATUSUPDATE at least once per second. # For now we don't track the current device status, so it's easy. # When requesting update messages, messages are sent at ~10Hz, even if # no change has happened. # self.SendMessage(HW_START_UPDATEMSGS) # Causes a lot of messages # We should make sure that the led is always off, but apparently, it's # always off without doing anything (cf MOT_SET_AVMODES) def terminate(self): if self._executor: self.stop() self._executor.shutdown() self._executor = None with self._ser_access: if self._serial: self._serial.close() self._serial = None def SendMessage(self, msg, dest=0x50, src=1, p1=None, p2=None, data=None): """ Send a message to a device and possibility wait for its response msg (APTSet or APTReq): the message definition dest (0<int): the destination ID (always 0x50 if directly over USB) p1 (None or 0<=int<=255): param1 (passed as byte2) p2 (None or 0<=int<=255): param2 (passed as byte3) data (None or bytes): data to be send further. Cannot be mixed with p1 and p2 return (None or bytes): the content of the response or None if it was an APTSet message raise: IOError: if failed to send or receive message """ assert 0 <= dest < 0x80 # create the message if data is None: # short message p1 = p1 or 0 p2 = p2 or 0 com = struct.pack("<HBBBB", msg.id, p1, p2, dest, src) else: # long message com = struct.pack("<HHBB", msg.id, len(data), dest | 0x80, src) + data logging.debug("Sending: '%s'", ", ".join("%02X" % ord(c) for c in com)) with self._ser_access: self._serial.write(com) if isinstance(msg, APTReq): # read the response # ensure everything is sent, before expecting an answer self._serial.flush() # Read until end of answer while True: rid, res = self._ReadMessage() if rid == msg.rid: return res logging.debug("Skipping unexpected message %X", rid) # Note: unused def WaitMessage(self, msg, timeout=None): """ Wait until a specified message is received msg (APTMessage) timeout (float or None): maximum amount of time to wait return (bytes): the 2 params or the data contained in the message raise: IOError: if timeout happened """ start = time.time() # Read until end of answer with self._ser_access: while True: if timeout is not None: left = time.time() - start + timeout if left <= 0: raise IOError("No message %d received in time" % msg.id) else: left = None mid, res = self._ReadMessage(timeout=left) if mid == msg.id: return res # TODO: instead of discarding the message, it could go into a # queue, to be handled later logging.debug("Skipping unexpected message %X", mid) def _ReadMessage(self, timeout=None): """ Reads the next message timeout (0 < float): maximum time to wait for the message return: mid (int): message ID data (bytes): bytes 3&4 or the data of the message raise: IOError: if failed to send or receive message """ old_timeout = self._serial.timeout if timeout is not None: # Should be only for the first byte, but doing it for the first 6 # should rarely matter self._serial.timeout = timeout try: # read the first (required) 6 bytes msg = b"" for i in range(6): char = self._serial.read() # empty if timeout if not char: raise IOError("Controller timeout, after receiving %s" % msg) msg += char finally: self._serial.timeout = old_timeout mid = struct.unpack("<H", msg[0:2])[0] if not (ord(msg[4]) & 0x80): # short message logging.debug("Received: '%s'", ", ".join("%02X" % ord(c) for c in msg)) return mid, msg[2:4] # long message length = struct.unpack("<H", msg[2:4])[0] for i in range(length): char = self._serial.read() # empty if timeout if not char: raise IOError("Controller timeout, after receiving %s" % msg) msg += char logging.debug("Received: '%s'", ", ".join("%02X" % ord(c) for c in msg)) return mid, msg[6:] # Low level functions def GetInfo(self): """ returns: serial number (int) model number (str) type (int) firmware version (str) notes (str) hardware version (int) hardware state (int) number of channels (int) """ res = self.SendMessage(HW_REQ_INFO) # Expects 0x54 bytes values = struct.unpack('<I8sHI48s12xHHH', res) sn, modl, typ, fmv, notes, hwv, state, nc = values # remove trailing 0's modl = modl.rstrip("\x00") notes = notes.rstrip("\x00") # Convert firmware version to a string fmvs = "%d.%d.%d" % ((fmv & 0xff0000) >> 16, (fmv & 0xff00) >> 8, fmv & 0xff) return sn, modl, typ, fmvs, notes, hwv, state, nc def MoveJog(self, pos): """ Move the position. Note: this is asynchronous. pos (int): 1 or 2 """ assert pos in [1, 2] # p1 is chan ident, always 1 self.SendMessage(MOT_MOVE_JOG, p1=1, p2=pos) def GetStatus(self): """ return: pos (int): position count status (int): status, as a flag of STA_* """ res = self.SendMessage(MOT_REQ_STATUSUPDATE) # expect 14 bytes c, pos, enccount, status = struct.unpack('<HiiI', res) return pos, status # high-level methods (interface) def _updatePosition(self): """ update the position VA """ _, status = self.GetStatus() pos = {} for axis in self.axes: # axes contains precisely one axis # status' flags should never be present simultaneously for f, p in self._status_to_pos.items(): if f & status: pos[axis] = p break else: logging.warning("Status %X doesn't contain position information", status) return # don't change position # it's read-only, so we change it via _value self.position._value = pos self.position.notify(self.position.value) def _waitNoMotion(self, timeout=None): """ Block as long as the controller reports motion timeout (0 < float): maximum time to wait for the end of the motion """ start = time.time() # Read until end of motion while True: _, status = self.GetStatus() if not (status & STA_IN_MOTION): return if timeout is not None and (time.time() > start + timeout): raise IOError("Device still in motion after %g s" % (timeout,)) # Give it a small break time.sleep(0.05) # 20Hz @isasync def moveRel(self, shift): if not shift: return model.InstantaneousFuture() self._checkMoveRel(shift) # TODO move to the +N next position? (and modulo number of axes) raise NotImplementedError("Relative move on enumerated axis not supported") @isasync def moveAbs(self, pos): if not pos: return model.InstantaneousFuture() self._checkMoveAbs(pos) return self._executor.submit(self._doMovePos, pos.values()[0]) def stop(self, axes=None): self._executor.cancel() def _doMovePos(self, pos): jogp = self._pos_to_jog[pos] self.MoveJog(jogp) self._waitNoMotion(10) # by default, a move lasts ~0.5 s self._updatePosition() @staticmethod def _openSerialPort(port): """ Opens the given serial port the right way for a Thorlabs APT device. port (string): the name of the serial port (e.g., /dev/ttyUSB0) return (serial): the opened serial port """ # For debugging purpose if port == "/dev/fake": return MFF102Simulator(timeout=1) ser = serial.Serial( port=port, baudrate=115200, bytesize=serial.EIGHTBITS, parity=serial.PARITY_NONE, stopbits=serial.STOPBITS_ONE, rtscts=True, timeout=1 #s ) # Purge (as recommended in the documentation) time.sleep(0.05) # 50 ms ser.flush() ser.flushInput() time.sleep(0.05) # 50 ms # Prepare the port ser.setRTS() return ser def _getSerialPort(self, sn): """ sn (str): serial number of the device return (str): serial port name (eg: "/dev/ttyUSB0" on Linux) """ if sys.platform.startswith('linux'): # Look for each USB device, if the serial number is good sn_paths = glob.glob('/sys/bus/usb/devices/*/serial') for p in sn_paths: try: f = open(p) snp = f.read().strip() except IOError: logging.debug("Failed to read %s, skipping device", p) if snp == sn: break else: raise ValueError("No USB device with S/N %s" % sn) # Deduce the tty: # .../3-1.2/serial => .../3-1.2/3-1.2:1.0/ttyUSB1 sys_path = os.path.dirname(p) usb_num = os.path.basename(sys_path) tty_paths = glob.glob("%s/%s/ttyUSB?*" % (sys_path, usb_num + ":1.0")) if not tty_paths: raise ValueError("Failed to find tty for device with S/N %s" % sn) tty = os.path.basename(tty_paths[0]) # Convert to /dev # Note: that works because udev rules create a dev with the same name # otherwise, we would need to check the char numbers return "/dev/%s" % (tty,) else: # TODO: Windows version raise NotImplementedError("OS not yet supported") @classmethod def scan(cls): """ returns (list of 2-tuple): name, args (sn) Note: it's obviously not advised to call this function if a device is already under use """ logging.info("Serial ports scanning for Thorlabs MFFxxx in progress...") found = [] # (list of 2-tuple): name, kwargs if sys.platform.startswith('linux'): # Look for each USB device, if the serial number is potentially good sn_paths = glob.glob('/sys/bus/usb/devices/*/serial') for p in sn_paths: try: f = open(p) snp = f.read().strip() except IOError: logging.debug("Failed to read %s, skipping device", p) if not (snp.startswith(SN_PREFIX_MFF) and len(snp) == 8): continue # Deduce the tty: # .../3-1.2/serial => .../3-1.2/3-1.2:1.0/ttyUSB1 sys_path = os.path.dirname(p) usb_num = os.path.basename(sys_path) logging.info("Looking at device %s with S/N=%s", usb_num, snp) tty_paths = glob.glob("%s/%s/ttyUSB?*" % (sys_path, usb_num + ":1.0")) if not tty_paths: # 0 or 1 paths continue tty = os.path.basename(tty_paths[0]) # Convert to /dev # Note: that works because udev rules create a dev with the same name # otherwise, we would need to check the char numbers port = "/dev/%s" % (tty,) # open and try to communicate try: dev = cls(name="test", role="test", port=port) _, modl, typ, fmv, notes, hwv, state, nc = dev.GetInfo() found.append((modl, {"sn": snp, "axis": "rz"})) except Exception: pass else: # TODO: Windows version raise NotImplementedError("OS not yet supported") return found
class FW102c(model.Actuator): """ Represents a Thorlabs filter wheel FW102C as an actuator. It provides one enumerated axis, whose actual band values are provided by the user at init. """ # Regex matching the compatible identification strings re_idn = "THORLABS.*FW102C.*" def __init__(self, name, role, port, bands, _scan=False, **kwargs): """ port (string): name of the serial port to connect to. Can be a pattern, in which case, all the ports fitting the pattern will be tried, and the first one which looks like an FW102C will be used. bands (dict 1<=int<=12 -> 2-tuple of floats > 0 or str): filter position -> lower and higher bound of the wavelength (m) of the light which goes _through_. If it's a list, it implies that the filter is multi-band. _scan (bool): only for internal usage raise IOError if no device answering or not a compatible device """ self._ser_access = threading.Lock() self._port = self._findDevice(port) logging.info("Found FW102C device on port %s", self._port) if _scan: return # check bands contains correct data self._maxpos = self.GetMaxPosition() if not bands: raise ValueError("Argument bands must contain at least one band") try: for pos, band in bands.items(): if not 1 <= pos <= self._maxpos: raise ValueError("Filter position should be between 1 and " "%d, but got %d." % (self._maxpos, pos)) # To support "weird" filter, we accept strings if isinstance(band, basestring): if not band.strip(): raise ValueError("Name of filter %d is empty" % pos) else: self._checkBand(band) except Exception: logging.exception("Failed to parse bands %s", bands) raise axes = {"band": model.Axis(choices=bands)} model.Actuator.__init__(self, name, role, axes=axes, **kwargs) driver_name = driver.getSerialDriver(self._port) self._swVersion = "%s (serial driver: %s)" % (odemis.__version__, driver_name) self._hwVersion = self._idn # will take care of executing axis move asynchronously self._executor = CancellableThreadPoolExecutor( max_workers=1) # one task at a time self._speed = self.GetSpeed() curpos = self.GetPosition() self.position = model.VigilantAttribute({"band": curpos}, readonly=True) # TODO: MD_OUT_WL or MD_IN_WL depending on affect self._metadata = { model.MD_FILTER_NAME: name, model.MD_OUT_WL: self._axes["band"].choices[curpos] } def getMetadata(self): return self._metadata def terminate(self): if self._executor: self.stop() self._executor.shutdown() self._executor = None with self._ser_access: if self._serial: self._serial.close() self._serial = None def _checkBand(self, band): """ band (object): should be tuple of floats or list of tuple of floats raise ValueError: if the band doesn't follow the convention """ if not isinstance(band, collections.Iterable) or len(band) == 0: raise ValueError("band must be a (list of a) list of 2 floats") # is it a list of list? if isinstance(band[0], collections.Iterable): # => set of 2-tuples for sb in band: if len(sb) != 2: raise ValueError( "Expected only 2 floats in band, found %d" % len(sb)) band = tuple(band) else: # 2-tuple if len(band) != 2: raise ValueError("Expected only 2 floats in band, found %d" % len(band)) band = (tuple(band), ) # Check the values are min/max and in m: typically within nm (< µm!) max_val = 10e-6 # m for low, high in band: if low > high: raise ValueError("Min of band must be first in list") if low < 0: raise ValueError("Band must be 2 positive value in meters") if low > max_val or high > max_val: raise ValueError( "Band contains very high values for light " "wavelength, ensure the value is in meters: %r." % band) # no error found def _findDevice(self, ports): """ Look for a compatible device ports (str): pattern for the port name return (str): the name of the port used It also sets ._serial and ._idn to contain the opened serial port, and the identification string. raises: IOError: if no device are found """ if os.name == "nt": # TODO #ports = ["COM" + str(n) for n in range (15)] raise NotImplementedError("Windows not supported") else: names = glob.glob(ports) for n in names: try: self._serial = self._openSerialPort(n) except serial.SerialException: # not possible to use this port? next one! continue # check whether it looks like a FW102C try: # If any garbage was previously received, make it discarded. self._serial.write("\r") # can have some \x00 bytes at the beginning + "CMD_NOT_DEFINED" self._flushInput() idn = self.GetIdentification() if re.match(self.re_idn, idn): self._idn = idn return n # found it! except Exception: logging.debug( "Port %s doesn't seem to have a FW102C device connected", n) else: raise IOError("No device seems to be an FW102C for ports '%s'" % (ports, )) @staticmethod def _openSerialPort(port): """ Opens the given serial port the right way for the FW102C. port (string): the name of the serial port (e.g., /dev/ttyUSB0) return (serial): the opened serial port """ ser = serial.Serial( port=port, baudrate=115200, # only correct if setting was not changed bytesize=serial.EIGHTBITS, parity=serial.PARITY_NONE, stopbits=serial.STOPBITS_ONE, timeout=1 #s ) return ser def _flushInput(self): """ Ensure there is no more data queued to be read on the bus (=serial port) """ with self._ser_access: self._serial.flush() self._serial.flushInput() # Shouldn't be necessary, but just in case skipped = self._serial.read( 1000) # More than 1000 chars => give up logging.debug("Skipping input %s", skipped.encode('string_escape')) re_err = r"Command error (.*)" def _sendQuery(self, com): """ Send a command which expects an answer com (string): command to send (not including the ? and the \r) return (string): the answer without newline and suffix ("> ") raises IOError: if there is a timeout HwError: if the hardware reports an error """ # TODO: handle IOError and automatically try to reconnect (cf LLE) assert (len(com) <= 50) # commands cannot be long full_com = com + "\r" with self._ser_access: logging.debug("Sending: '%s'", full_com.encode('string_escape')) self._serial.write(full_com) # ensure everything is received, before expecting an answer self._serial.flush() # Read until end of answer line = b"" while True: char = self._serial.read() # empty if timeout if not char: # should always finish by a "> " raise IOError("Controller timeout, after receiving %s" % line) # normal char line += char if line[-2:] == "> ": break logging.debug("Received: '%s'", line.encode('string_escape')) # remove echo + suffix + new line line = line[len(full_com):-2].rstrip("\r") # if it's an error message => raise an error m = re.match(self.re_err, line) if m: err = m.group(1) raise HwError("Device rejected command '%s': %s" % (com, err)) return line def _sendCommand(self, com): """ Send a command which does not expect any answer com (string): command to send (not including the ? and the \r) return when the command is finished processed raises IOError: if there is a timeout HwError: if the hardware reports an error """ self._sendQuery(com) # don't return anything def GetIdentification(self): """ return (str): model name as reported by the device """ # answer is like "THORLABS FW102C/FW212C Filter Wheel version 1.04" return self._sendQuery("*idn?") def GetMaxPosition(self): """ return (1<int): maximum number of positions available (eg, 6, 12) """ ans = self._sendQuery("pcount?") return int(ans) def GetPosition(self): """ return (1<=int<=maxpos): current position Note: might be different from the last position set if the user has manually changed it. """ ans = self._sendQuery("pos?") return int(ans) def GetSpeed(self): """ return (0 or 1): current "speed" of the wheel, the bigger the faster """ ans = self._sendQuery("speed?") return int(ans) def SetPosition(self, pos): """ pos (1<=int<=maxpos): current position returns when the new position is set raise Exception in case of error """ assert (1 <= pos <= self._maxpos) # Estimate how long it'll take cur_pos = self.position.value["band"] p1, p2 = sorted([pos, cur_pos]) dist = min(p2 - p1, (6 + p1) - p2) if self._speed == 0: dur_one = 2 #s else: dur_one = 1 #s maxdur = 1 + dist * dur_one * 2 # x 2 as a safe bet prev_timeout = self._serial.timeout try: self._serial.timeout = maxdur self._sendCommand("pos=%d" % pos) finally: self._serial.timeout = prev_timeout logging.debug("Move to pos %d finished", pos) # What we don't need: # speed?\r1\r> # trig?\r0\r> # sensors?\r0\r> def _doMoveBand(self, pos): """ move to the position and updates the metadata and position once it's over """ self.SetPosition(pos) self._metadata[model.MD_OUT_WL] = self._axes["band"].choices[pos] self._updatePosition() # high-level methods (interface) def _updatePosition(self): """ update the position VA Note: it should not be called while holding _ser_access """ pos = {"band": self.GetPosition()} # it's read-only, so we change it via _value self.position._value = pos self.position.notify(self.position.value) @isasync def moveRel(self, shift): if not shift: return model.InstantaneousFuture() self._checkMoveRel(shift) # TODO move to the +N next position? (and modulo number of axes) raise NotImplementedError( "Relative move on enumerated axis not supported") @isasync def moveAbs(self, pos): if not pos: return model.InstantaneousFuture() self._checkMoveAbs(pos) return self._executor.submit(self._doMoveBand, pos["band"]) def stop(self, axes=None): self._executor.cancel() def selfTest(self): """ check as much as possible that it works without actually moving the motor return (boolean): False if it detects any problem """ try: pos = self.GetPosition() maxpos = self.GetMaxPosition() if 1 <= pos <= maxpos: return True except: logging.exception("Selftest failed") return False @classmethod def scan(cls, port=None): """ port (string): name of the serial port. If None, all the serial ports are tried returns (list of 2-tuple): name, args (port) Note: it's obviously not advised to call this function if a device is already under use """ if port: ports = [port] else: if os.name == "nt": ports = ["COM" + str(n) for n in range(0, 8)] else: ports = glob.glob('/dev/ttyS?*') + glob.glob('/dev/ttyUSB?*') logging.info( "Serial ports scanning for Thorlabs filter wheel in progress...") found = [] # (list of 2-tuple): name, kwargs for p in ports: try: logging.debug("Trying port %s", p) dev = cls(None, None, p, bands=None, _scan=True) except (serial.SerialException, IOError): # not possible to use this port? next one! continue # Get some more info try: maxpos = dev.GetMaxPosition() except Exception: continue else: # create fake band argument bands = {} for i in range(1, maxpos + 1): bands[i] = (i * 100e-9, (i + 1) * 100e-9) found.append((dev._idn, {"port": p, "bands": bands})) return found