class FixedPositionsActuator(model.Actuator): """ A generic actuator component which only allows moving to fixed positions defined by the user upon initialization. It is actually a wrapper to just one axis/actuator and it can also apply cyclic move e.g. in case the actuator moves a filter wheel. """ def __init__(self, name, role, children, axis_name, positions, cycle=None, **kwargs): """ name (string) role (string) children (dict str -> actuator): axis name (in this actuator) -> actuator to be used for this axis axis_name (str): axis name in the child actuator positions (set or dict value -> str): positions where the actuator is allowed to move cycle (float): if not None, it means the actuator does a cyclic move and this value represents a full cycle """ # TODO: forbid inverted if len(children) != 1: raise ValueError("FixedPositionsActuator needs precisely one child") self._cycle = cycle self._move_sum = 0 self._position = {} self._referenced = {} axis, child = children.items()[0] self._axis = axis self._child = child self._caxis = axis_name self._positions = positions # Executor used to reference and move to nearest position self._executor = CancellableThreadPoolExecutor(max_workers=1) # one task at a time if not isinstance(child, model.ComponentBase): raise ValueError("Child %s is not a component." % (child,)) if not hasattr(child, "axes") or not isinstance(child.axes, dict): raise ValueError("Child %s is not an actuator." % child.name) if cycle is not None: # just an offset to reference switch position self._offset = self._cycle / len(self._positions) if not all(0 <= p < cycle for p in positions.keys()): raise ValueError("Positions must be between 0 and %s (non inclusive)" % (cycle,)) ac = child.axes[axis_name] axes = {axis: model.Axis(choices=positions, unit=ac.unit)} # TODO: allow the user to override the unit? model.Actuator.__init__(self, name, role, axes=axes, children=children, **kwargs) self._position = {} self.position = model.VigilantAttribute({}, readonly=True) logging.debug("Subscribing to position of child %s", child.name) child.position.subscribe(self._update_child_position, init=True) if model.hasVA(child, "referenced") and axis_name in child.referenced.value: self._referenced[axis] = child.referenced.value[axis_name] self.referenced = model.VigilantAttribute(self._referenced.copy(), readonly=True) child.referenced.subscribe(self._update_child_ref) # If the axis can be referenced => do it now (and move to a known position) # In case of cyclic move always reference if not self._referenced.get(axis, True) or (self._cycle and axis in self._referenced): # The initialisation will not fail if the referencing fails f = self.reference({axis}) f.add_done_callback(self._on_referenced) else: # If not at a known position => move to the closest known position nearest = util.find_closest(self._child.position.value[self._caxis], self._positions.keys()) self.moveAbs({self._axis: nearest}).result() def _on_referenced(self, future): try: future.result() except Exception as e: self._child.stop({self._caxis}) # prevent any move queued self.state._set_value(e, force_write=True) logging.exception(e) def _update_child_position(self, value): p = value[self._caxis] if self._cycle is not None: p %= self._cycle self._position[self._axis] = p self._updatePosition() def _update_child_ref(self, value): self._referenced[self._axis] = value[self._caxis] self._updateReferenced() def _updatePosition(self): """ update the position VA """ # if it is an unsupported position report the nearest supported one real_pos = self._position[self._axis] nearest = util.find_closest(real_pos, self._positions.keys()) if not util.almost_equal(real_pos, nearest): logging.warning("Reporting axis %s @ %s (known position), while physical axis %s @ %s", self._axis, nearest, self._caxis, real_pos) pos = {self._axis: nearest} logging.debug("reporting position %s", pos) self.position._set_value(pos, force_write=True) def _updateReferenced(self): """ update the referenced VA """ # .referenced is copied to detect changes to it on next update self.referenced._set_value(self._referenced.copy(), force_write=True) @isasync def moveRel(self, shift): if not shift: return model.InstantaneousFuture() self._checkMoveRel(shift) raise NotImplementedError("Relative move on fixed positions axis not supported") @isasync def moveAbs(self, pos): """ Move the actuator to the defined position in m for each axis given. pos dict(string-> float): name of the axis and position in m """ if not pos: return model.InstantaneousFuture() self._checkMoveAbs(pos) pos = self._applyInversion(pos) f = self._executor.submit(self._doMoveAbs, pos) return f def _doMoveAbs(self, pos): axis, distance = pos.items()[0] logging.debug("Moving axis %s (-> %s) to %g", self._axis, self._caxis, distance) if self._cycle is None: move = {self._caxis: distance} self._child.moveAbs(move).result() else: # Optimize by moving through the closest way cur_pos = self._child.position.value[self._caxis] vector1 = distance - cur_pos mod1 = vector1 % self._cycle vector2 = cur_pos - distance mod2 = vector2 % self._cycle if abs(mod1) < abs(mod2): self._move_sum += mod1 if self._move_sum >= self._cycle: # Once we are about to complete a full cycle, reference again # to get rid of accumulated error self._move_sum = 0 # move to the reference switch move_to_ref = (self._cycle - cur_pos) % self._cycle + self._offset self._child.moveRel({self._caxis: move_to_ref}).result() self._child.reference({self._caxis}).result() move = {self._caxis: distance} else: move = {self._caxis: mod1} else: move = {self._caxis:-mod2} self._move_sum -= mod2 self._child.moveRel(move).result() def _doReference(self, axes): logging.debug("Referencing axis %s (-> %s)", self._axis, self._caxis) f = self._child.reference({self._caxis}) f.result() # If we just did homing and ended up to an unsupported position, move to # the nearest supported position cp = self._child.position.value[self._caxis] if (cp not in self._positions): nearest = util.find_closest(cp, self._positions.keys()) self._doMoveAbs({self._axis: nearest}) @isasync def reference(self, axes): if not axes: return model.InstantaneousFuture() self._checkReference(axes) f = self._executor.submit(self._doReference, axes) return f reference.__doc__ = model.Actuator.reference.__doc__ def stop(self, axes=None): """ stops the motion axes (iterable or None): list of axes to stop, or None if all should be stopped """ if axes is not None: axes = set() if self._axis in axes: axes.add(self._caxis) self._child.stop(axes=axes) def terminate(self): if self._executor: self.stop() self._executor.shutdown(wait=True) self._executor = None self._child.position.unsubscribe(self._update_child_position)
class DPSS(model.PowerSupplier): ''' Implements the PowerSupplier class to regulate the power supply of the Cobolt DPSS laser, connected via USB. ''' def __init__(self, name, role, port, light_name, max_power, **kwargs): ''' port (str): port name. Can be a pattern, in which case it will pick the first one which responds well ligth_name (str): the name of the component that is controlled by this power supplier max_power (float): maximum power, in W. Will be set at initialisation. ''' # TODO: allow to pass the serial number, to select the right device model.PowerSupplier.__init__(self, name, role, **kwargs) self._light_name = light_name self._ser_access = threading.Lock() self._port = self._findDevice(port) # sets ._serial logging.info("Found Cobolt DPSS device on port %s", self._port) self._sn = self.GetSerialNumber() driver_name = driver.getSerialDriver(self._port) self._swVersion = "serial driver: %s" % (driver_name,) self._hwVersion = "Cobolt DPSS (s/n: %s)" % (self._sn,) # Reset sequence # TODO: do a proper one. For now it's just everything we can throw, so # that it's a bit easier to debug self._sendCommand("ilk?") self._sendCommand("leds?") self._sendCommand("@cobasky?") self._sendCommand("cf") # Clear fault # self._sendCommand("@cob1") # used to force the laser on after interlock opened error # will take care of executing switch asynchronously self._executor = CancellableThreadPoolExecutor(max_workers=1) # one task at a time # Dict str -> bool: component name -> turn on/off self.supplied = model.VigilantAttribute({light_name: False}, readonly=True) self._updateSupplied() self.SetOutputPower(max_power) # Wrapper for the actual firmware functions def GetSerialNumber(self): return self._sendCommand("sn?") def SetOutputPower(self, p): """ p (0 < float): power in W """ assert 1e-6 < p < 1e6 self._sendCommand("p %.5f" % p) def SetLaser(self, state): """ state (bool): True to turn on """ v = 1 if state else 0 self._sendCommand("l%d" % v) # No space, as they are different commands @isasync def supply(self, sup): """ Change the power supply to the defined state for each component given. This is an asynchronous method. sup dict(string-> boolean): name of the component and new state returns (Future): object to control the supply request """ if not sup: return model.InstantaneousFuture() self._checkSupply(sup) return self._executor.submit(self._doSupply, sup) def _doSupply(self, sup): """ supply power """ for comp, val in sup.items(): self.SetLaser(val) self._updateSupplied() def _updateSupplied(self): """ update the supplied VA """ res = self._sendCommand("l?") pwrd = (res == "1") # it's read-only, so we change it via _value self.supplied._set_value({self._light_name: pwrd}, force_write=True) def terminate(self): if self._executor: self._executor.cancel() self._executor.shutdown() self._executor = None if self._serial: self.SetLaser(False) # TODO: allow to configure with argument to DPSS with self._ser_access: self._serial.close() self._serial = None self._file.close() def _sendCommand(self, cmd): """ cmd (str): command to be sent to device (without the CR) returns (str): answer received from the device (without \n or \r) raises: DPSSError: if an ERROR is returned by the device. """ cmd = cmd + "\r" with self._ser_access: logging.debug("Sending command %s", cmd.encode('string_escape')) self._serial.write(cmd) ans = '' while ans[-2:] != '\r\n': char = self._serial.read() if not char: raise IOError("Timeout after receiving %s" % ans.encode('string_escape')) ans += char logging.debug("Received answer %s", ans.encode('string_escape')) # TODO: check for other error answer? # Normally the device either answers OK, or a value, for commands finishing with a "?" if ans.startswith("Syntax error"): raise DPSSError(ans) return ans.rstrip() @staticmethod def _openSerialPort(port): """ Opens the given serial port the right way for a Power control device. 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, timeout=1 # 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 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 """ # TODO: For debugging purpose # if ports == "/dev/fake": # self._serial = DPSSSimulator(timeout=1) # return ports if os.name == "nt": raise NotImplementedError("Windows not supported") else: names = glob.glob(ports) for n in names: try: # Ensure no one will talk to it simultaneously, and we don't talk to devices already in use self._file = open(n) # Open in RO, just to check for lock try: fcntl.flock(self._file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) # Raises IOError if cannot lock except IOError: logging.info("Port %s is busy, will not use", n) continue self._serial = self._openSerialPort(n) try: sn = self.GetSerialNumber() except DPSSError: # 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") sn = self.GetSerialNumber() return n except (IOError, DPSSError): logging.info("Skipping device on port %s, which didn't seem to be a Cobolt", n) # not possible to use this port? next one! continue else: raise HwError("Failed to find a Cobolt device on ports '%s'. " "Check that the device is turned on and connected to " "the computer." % (ports,))
class Chamber(model.Actuator): """ Simulated chamber component. Just pretends to be able to change pressure """ def __init__(self, name, role, positions, has_pressure=True, **kwargs): """ Initialises the component positions (list of str): each pressure positions supported by the component (among the allowed ones) has_pressure (boolean): if True, has a pressure VA with the current pressure. """ # TODO: or just provide .targetPressure (like .targetTemperature) ? # Or maybe provide .targetPosition: position that would be reached if # all the requested move were instantly applied? chp = {} for p in positions: try: chp[PRESSURES[p]] = p except KeyError: raise ValueError("Pressure position %s is unknown" % (p,)) axes = {"pressure": model.Axis(unit="Pa", choices=chp)} model.Actuator.__init__(self, name, role, axes=axes, **kwargs) # For simulating moves self._position = PRESSURE_VENTED # last official position self._goal = PRESSURE_VENTED 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) if has_pressure: # Almost the same as position, but gives the current position self.pressure = model.VigilantAttribute(self._position, unit="Pa", readonly=True) self._press_timer = util.RepeatingTimer(1, self._updatePressure, "Simulated pressure update") self._press_timer.start() else: self._press_timer = None # Indicates whether the chamber is opened or not # Just pretend it's always closed, and allow the user to change that # for instance via CLI. self.opened = model.BooleanVA(False) # will take care of executing axis move asynchronously self._executor = CancellableThreadPoolExecutor(max_workers=1) # one task at a time def terminate(self): if self._press_timer: self._press_timer.cancel() self._press_timer = None if self._executor: self.stop() self._executor.shutdown() self._executor = None def _updatePressure(self): """ update the pressure VA (called regularly from a thread) """ # 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) def _updatePosition(self): """ update the position VA """ # .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) new_pres = pos["pressure"] est_start = time.time() + 0.1 f = model.ProgressiveFuture(start=est_start, end=est_start + self._getDuration(new_pres)) return self._executor.submitf(f, self._changePressure, f, new_pres) def _getDuration(self, pos): return abs(self._position - pos) / SPEED_PUMP def _changePressure(self, f, p): """ Synchronous change of the pressure p (float): target pressure """ # TODO: allow to cancel during the change now = time.time() duration = self._getDuration(p) # s self._time_start = now self._time_goal = now + duration # s self._goal = p time.sleep(duration / 2) # DEBUG: for testing wrong time estimation # f.set_progress(start=self._time_start, end=self._time_goal + 10) time.sleep(duration / 2) self._position = p self._updatePosition() def stop(self, axes=None): self._executor.cancel() logging.warning("Stopped pressure change")
class MultiplexActuator(model.Actuator): """ An object representing an actuator made of several (real actuators) = a set of axes that can be moved and optionally report their position. """ def __init__(self, name, role, children, axes_map, ref_on_init=None, **kwargs): """ name (string) role (string) children (dict str -> actuator): axis name (in this actuator) -> actuator to be used for this axis axes_map (dict str -> str): axis name in this actuator -> axis name in the child actuator ref_on_init (list): axes to be referenced during initialization """ if not children: raise ValueError("MultiplexActuator needs children") if set(children.keys()) != set(axes_map.keys()): raise ValueError("MultiplexActuator needs the same keys in children and axes_map") ref_on_init = ref_on_init or [] self._axis_to_child = {} # axis name => (Actuator, axis name) self._position = {} self._speed = {} self._referenced = {} axes = {} for axis, child in children.items(): caxis = axes_map[axis] self._axis_to_child[axis] = (child, caxis) # Ducktyping (useful to support also testing with MockComponent) # At least, it has .axes if not isinstance(child, model.ComponentBase): raise ValueError("Child %s is not a component." % (child,)) if not hasattr(child, "axes") or not isinstance(child.axes, dict): raise ValueError("Child %s is not an actuator." % child.name) axes[axis] = copy.deepcopy(child.axes[caxis]) self._position[axis] = child.position.value[axes_map[axis]] if model.hasVA(child, "speed") and caxis in child.speed.value: self._speed[axis] = child.speed.value[caxis] if model.hasVA(child, "referenced") and caxis in child.referenced.value: self._referenced[axis] = child.referenced.value[caxis] # this set ._axes and ._children model.Actuator.__init__(self, name, role, axes=axes, children=children, **kwargs) if len(self.children.value) > 1: # will take care of executing axis move asynchronously self._executor = CancellableThreadPoolExecutor(max_workers=1) # one task at a time # TODO: make use of the 'Cancellable' part (for now cancelling a running future doesn't work) else: # Only one child => optimize by passing all requests directly self._executor = None # keep a reference to the subscribers so that they are not # automatically garbage collected self._subfun = [] children_axes = {} # dict actuator -> set of string (our axes) for axis, (child, ca) in self._axis_to_child.items(): logging.debug("adding axis %s to child %s", axis, child.name) if child in children_axes: children_axes[child].add(axis) else: children_axes[child] = set([axis]) # position & speed: special VAs combining multiple VAs self.position = model.VigilantAttribute(self._position, readonly=True) for c, ax in children_axes.items(): def update_position_per_child(value, ax=ax, c=c): logging.debug("updating position of child %s", c.name) for a in ax: try: self._position[a] = value[axes_map[a]] except KeyError: logging.error("Child %s is not reporting position of axis %s", c.name, a) self._updatePosition() c.position.subscribe(update_position_per_child) self._subfun.append(update_position_per_child) # TODO: change the speed range to a dict of speed ranges self.speed = model.MultiSpeedVA(self._speed, [0., 10.], setter=self._setSpeed) for axis in self._speed.keys(): c, ca = self._axis_to_child[axis] def update_speed_per_child(value, a=axis, ca=ca, cname=c.name): try: self._speed[a] = value[ca] except KeyError: logging.error("Child %s is not reporting speed of axis %s (%s): %s", cname, a, ca, value) self._updateSpeed() c.speed.subscribe(update_speed_per_child) self._subfun.append(update_speed_per_child) # whether the axes are referenced self.referenced = model.VigilantAttribute(self._referenced.copy(), readonly=True) for axis in self._referenced.keys(): c, ca = self._axis_to_child[axis] def update_ref_per_child(value, a=axis, ca=ca, cname=c.name): try: self._referenced[a] = value[ca] except KeyError: logging.error("Child %s is not reporting reference of axis %s (%s)", cname, a, ca) self._updateReferenced() c.referenced.subscribe(update_ref_per_child) self._subfun.append(update_ref_per_child) self._axes_referencing = [] for axis in ref_on_init: # If the axis can be referenced => do it now (and move to a known position) if not self._referenced.get(axis, True): # The initialisation will not fail if the referencing fails f = self.reference({axis}) self._axes_referencing.append(axis) f.add_done_callback(self._on_referenced) def _on_referenced(self, future): try: future.result() except Exception as e: for ax in self._axes_referencing: c, ca = self._axis_to_child[ax] c.stop({ca}) # prevent any move queued self.state._set_value(e, force_write=True) logging.exception(e) def _updatePosition(self): """ update the position VA """ # it's read-only, so we change it via _value pos = self._applyInversion(self._position) logging.debug("reporting position %s", pos) self.position._set_value(pos, force_write=True) def _updateSpeed(self): """ update the speed VA """ # we must not call the setter, so write directly the raw value self.speed._value = self._speed self.speed.notify(self._speed) def _updateReferenced(self): """ update the referenced VA """ # .referenced is copied to detect changes to it on next update self.referenced._set_value(self._referenced.copy(), force_write=True) def _setSpeed(self, value): """ value (dict string-> float): speed for each axis returns (dict string-> float): the new value """ # FIXME the problem with this implementation is that the subscribers # will receive multiple notifications for each set: # * one for each axis (via _updateSpeed from each child) # * the actual one (but it's probably dropped as it's the same value) final_value = value.copy() # copy for axis, v in value.items(): child, ma = self._axis_to_child[axis] new_speed = child.speed.value.copy() # copy new_speed[ma] = v child.speed.value = new_speed final_value[axis] = child.speed.value[ma] return final_value def _moveToChildMove(self, mv): child_to_move = collections.defaultdict(dict) # child -> moveRel argument for axis, distance in mv.items(): child, child_axis = self._axis_to_child[axis] child_to_move[child].update({child_axis: distance}) logging.debug("Moving axis %s (-> %s) by %g", axis, child_axis, distance) return child_to_move def _axesToChildAxes(self, axes): child_to_axes = collections.defaultdict(set) # child -> set(str): axes for axis in axes: child, child_axis = self._axis_to_child[axis] child_to_axes[child].add(child_axis) logging.debug("Interpreting axis %s (-> %s)", axis, child_to_axes) return child_to_axes @isasync def moveRel(self, shift, **kwargs): """ Move the stage the defined values in m for each axis given. shift dict(string-> float): name of the axis and shift in m **kwargs: Mostly there to support "update" argument (but currently works only if there is only one child) """ if not shift: return model.InstantaneousFuture() self._checkMoveRel(shift) shift = self._applyInversion(shift) if self._executor: f = self._executor.submit(self._doMoveRel, shift, **kwargs) else: cmv = self._moveToChildMove(shift) child, move = cmv.popitem() assert not cmv f = child.moveRel(move, **kwargs) return f def _doMoveRel(self, shift, **kwargs): # TODO: updates don't work because we still wait for the end of the # move before we get to the next one => multi-threaded queue? Still need # to ensure the order (ie, X>AB>X can be executed as X/AB>X or X>AB/X but # XA>AB>X must be in the order XA>AB/X futures = [] for child, move in self._moveToChildMove(shift).items(): f = child.moveRel(move, **kwargs) futures.append(f) # just wait for all futures to finish for f in futures: f.result() @isasync def moveAbs(self, pos, **kwargs): if not pos: return model.InstantaneousFuture() self._checkMoveAbs(pos) pos = self._applyInversion(pos) if self._executor: f = self._executor.submit(self._doMoveAbs, pos, **kwargs) else: cmv = self._moveToChildMove(pos) child, move = cmv.popitem() assert not cmv f = child.moveAbs(move, **kwargs) return f def _doMoveAbs(self, pos, **kwargs): futures = [] for child, move in self._moveToChildMove(pos).items(): f = child.moveAbs(move, **kwargs) futures.append(f) # just wait for all futures to finish for f in futures: f.result() @isasync def reference(self, axes): if not axes: return model.InstantaneousFuture() self._checkReference(axes) if self._executor: f = self._executor.submit(self._doReference, axes) else: cmv = self._axesToChildAxes(axes) child, a = cmv.popitem() assert not cmv f = child.reference(a) return f reference.__doc__ = model.Actuator.reference.__doc__ def _doReference(self, axes): child_to_axes = self._axesToChildAxes(axes) futures = [] for child, a in child_to_axes.items(): f = child.reference(a) futures.append(f) # just wait for all futures to finish for f in futures: f.result() def stop(self, axes=None): """ stops the motion axes (iterable or None): list of axes to stop, or None if all should be stopped """ # Empty the queue for the given axes if self._executor: self._executor.cancel() all_axes = set(self.axes.keys()) axes = axes or all_axes unknown_axes = axes - all_axes if unknown_axes: logging.error("Attempting to stop unknown axes: %s", ", ".join(unknown_axes)) axes &= all_axes threads = [] for child, a in self._axesToChildAxes(axes).items(): # it's synchronous, but we want to stop all of them as soon as possible thread = threading.Thread(name="Stopping axis", target=child.stop, args=(a,)) thread.start() threads.append(thread) # wait for completion for thread in threads: thread.join(1) if thread.is_alive(): logging.warning("Stopping child actuator of '%s' is taking more than 1s", self.name) def terminate(self): if self._executor: self.stop() self._executor.shutdown() self._executor = None
class Stage(model.Actuator): """ This is an extension of the model.Actuator class. It provides functions for moving the Zeiss stage and updating the position. """ def __init__(self, name, role, parent, rng=None, **kwargs): """ inverted (set of str): names of the axes which are inverted rng (dict str -> (float,float)): axis name -> min/max of the position on this axis. Note: if the axis is inverted, the range passed will be inverted. Also, if the hardware reports position outside of the range, move might fail, as it is considered outside of the range. """ if rng is None: rng = {} if "x" not in rng: rng["x"] = (5e-3, 152e-3) if "y" not in rng: rng["y"] = (5e-3, 152e-3) if "z" not in rng: rng["z"] = (5e-3, 40e-3) axes_def = { # Ranges are from the documentation "x": model.Axis(unit="m", range=(rng["x"][0], rng["x"][1])), "y": model.Axis(unit="m", range=(rng["y"][0], rng["y"][1])), "z": model.Axis(unit="m", range=(rng["z"][0], rng["z"][1])), } 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({}, unit="m", readonly=True) self._updatePosition() # Refresh regularly the position self._pos_poll = util.RepeatingTimer(5, self._refreshPosition, "Position polling") self._pos_poll.start() def terminate(self): if self._executor: self._executor.cancel() self._executor.shutdown() self._executor = None if self._pos_poll: self._pos_poll.cancel() self._pos_poll = None def _updatePosition(self, raw_pos=None): """ update the position VA raw_pos (dict str -> float): the position in mm (as received from the SEM) """ if raw_pos is None: x, y, z, _ = self.parent.GetStagePosition() else: x, y, z = raw_pos["x"], raw_pos["y"], raw_pos["z"] pos = { "x": x * 1e-3, "y": y * 1e-3, "z": z * 1e-3, } self.position._set_value(self._applyInversion(pos), force_write=True) def _refreshPosition(self): """ Called regularly to update the current position """ # We don't use the VA setters, to avoid sending back to the hardware a # set request logging.debug("Updating SEM stage position") try: self._updatePosition() except Exception: logging.exception("Unexpected failure when updating position") def _doMoveRel(self, future, shift): """ move by the shift shift (float): unit m """ x, y, z, _ = self.parent.GetStagePosition() if "x" in shift: x += shift["x"] * 1e3 if "y" in shift: y += shift["y"] * 1e3 if "z" in shift: z += shift["z"] * 1e3 target_pos = self._applyInversion({ "x": x * 1e-3, "y": y * 1e-3, "z": z * 1e-3 }) # Check range (for the axes we are moving) for an in shift.keys(): rng = self.axes[an].range p = target_pos[an] if not rng[0] <= p <= rng[1]: raise ValueError( "Relative move would cause axis %s out of bound (%g m)" % (an, p)) self._moveTo(future, x, y, z) def _doMoveAbs(self, future, pos): """ move to position pos pos (float): unit m """ # Don't change position for unspecified coordinates x, y, z, _ = self.parent.GetStagePosition() # Convert to mm if "x" in pos: x = pos["x"] * 1e3 if "y" in pos: y = pos["y"] * 1e3 if "z" in pos: z = pos["z"] * 1e3 self._moveTo(future, x, y, z) def _moveTo(self, future, x, y, z, timeout=60): with future._moving_lock: try: if future._must_stop.is_set(): raise CancelledError() logging.debug("Moving to position (%s, %s, %s)", x, y, z) self.parent.MoveStage(x, y, z) # documentation suggests to wait 1s before calling # GetStagePosition() after MoveStage() time.sleep(1) # Wait until the move is over # Don't check for future._must_stop because anyway the stage will # stop moving, and so it's nice to wait until we know the stage is # not moving. moving = True tstart = time.time() while moving: x, y, z, moving = self.parent.GetStagePosition() # Take the opportunity to update .position self._updatePosition({"x": x, "y": y, "z": z}) if time.time() > tstart + timeout: self.parent.Abort() logging.error( "Timeout after submitting stage move. Aborting move." ) break # 50 ms is about the time it takes to read the stage status time.sleep(50e-3) # If it was cancelled, Abort() has stopped the stage before, and # we still have waited until the stage stopped moving. Now let # know the user that the move is not complete. if future._must_stop.is_set(): raise CancelledError() except RemconError: if future._must_stop.is_set(): raise CancelledError() raise finally: future._was_stopped = True # Update the position, even if the move didn't entirely succeed self._updatePosition() def _cancelCurrentMove(self, future): """ Cancels the current move (both absolute or relative). Non-blocking. future (Future): the future to stop. Unused, only one future must be running at a time. return (bool): True if it successfully cancelled (stopped) the move. """ # The difficulty is to synchronise correctly when: # * the task is just starting (not finished requesting axes to move) # * the task is finishing (about to say that it finished successfully) logging.debug("Cancelling current move") future._must_stop.set( ) # tell the thread taking care of the move it's over self.parent.Abort() with future._moving_lock: if not future._was_stopped: logging.debug("Cancelling failed") return future._was_stopped def _createFuture(self): """ Return (CancellableFuture): a future that can be used to manage a move """ f = CancellableFuture() f._moving_lock = threading.Lock() # taken while moving f._must_stop = threading.Event( ) # cancel of the current future requested f._was_stopped = False # if cancel was successful f.task_canceller = self._cancelCurrentMove return f @isasync def moveRel(self, shift): """ shift (dict): shift in m """ if not shift: return model.InstantaneousFuture() self._checkMoveRel(shift) shift = self._applyInversion(shift) f = self._createFuture() f = self._executor.submitf(f, self._doMoveRel, f, shift) return f @isasync def moveAbs(self, pos): """ pos (dict): position in m """ if not pos: return model.InstantaneousFuture() self._checkMoveAbs(pos) pos = self._applyInversion(pos) f = self._createFuture() f = self._executor.submitf(f, self._doMoveAbs, f, pos) return f def stop(self, axes=None): # Empty the queue (and already stop the stage if a future is running) self._executor.cancel() # Try to stop the stage, even if no future is running, for safety logging.warning("Stopping all axes: %s", ", ".join(self.axes)) self.parent.Abort() try: self._updatePosition() except Exception: logging.exception("Unexpected failure when updating position")
class SpectraPro(model.Actuator): def __init__(self, name, role, port, turret=None, calib=None, _noinit=False, dependencies=None, **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. calib (None or list of (int, int and 5 x (float or str))): calibration data, as saved by Winspec. Data can be either in float or as an hexadecimal value "hex:9a,99,99,99,99,79,40,40" blaze in nm, groove gl/mm, center adjust, slope adjust, focal length, inclusion angle, detector angle inverted (None): it is not allowed to invert the axes dependencies (dict str -> Component): "ccd" should be the CCD used to acquire the spectrum. _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 try: self._serial = self.openSerialPort(port) except serial.SerialException: raise HwError( "Failed to find spectrograph %s (on port '%s'). " "Check the device is turned on and connected to the " "computer. You might need to turn it off and on again." % (name, 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 try: self._ccd = dependencies["ccd"] except (TypeError, KeyError): # TODO: only needed if there is calibration info (for the pixel size) # otherwise it's fine without CCD. raise ValueError("Spectrograph needs a dependency 'ccd'") # 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, dependencies=dependencies, **kwargs) # First step of parsing calib parmeter: convert to (int, int) -> ... calib = calib or () if not isinstance(calib, collections.Iterable): raise ValueError("calib parameter must be in the format " "[blz, gl, ca, sa, fl, ia, da], " "but got %s" % (calib, )) dcalib = {} for c in calib: if not isinstance(c, collections.Iterable) or len(c) != 7: raise ValueError("calib parameter must be in the format " "[blz, gl, ca, sa, fl, ia, da], " "but got %s" % (c, )) gt = (c[0], c[1]) if gt in dcalib: raise ValueError( "calib parameter contains twice calibration for " "grating (%d nm, %d gl/mm)" % gt) dcalib[gt] = c[2:] # store calibration for pixel -> wavelength conversion and wavelength offset # int (grating number 1 -> 9) -> center adjust, slope adjust, # focal length, inclusion angle/2, detector angle self._calib = {} # TODO: read the info from MONO-EESTATUS (but it's so # huge that it's not fun to parse). There is also detector angle. dfl = FOCAL_LENGTH_OFFICIAL[model_name] # m dia = math.radians(INCLUSION_ANGLE_OFFICIAL[model_name]) # rad for i in gchoices: # put default values self._calib[i] = (0, 0, dfl, dia, 0) try: blz = self._getBlaze(i) # m gl = self._getGrooveDensity(i) # gl/m except ValueError: logging.warning("Failed to parse info of grating %d" % i, exc_info=True) continue # parse calib info gt = (int(blz * 1e9), int(gl * 1e-3)) if gt in dcalib: calgt = dcalib[gt] ca = self._readCalibVal(calgt[0]) # ratio sa = self._readCalibVal(calgt[1]) # ratio fl = self._readCalibVal(calgt[2]) * 1e-3 # mm -> m ia = math.radians(self._readCalibVal(calgt[3])) # ° -> rad da = math.radians(self._readCalibVal(calgt[4])) # ° -> rad self._calib[i] = ca, sa, fl, ia, da logging.info( "Calibration data for grating %d (%d nm, %d gl/mm) " "-> %s" % (i, gt[0], gt[1], self._calib[i])) else: logging.warning("No calibration data for grating %d " "(%d nm, %d gl/mm)" % (i, gt[0], gt[1])) # 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 # for storing the latest calibrated wavelength value self._wl = (None, None, None ) # grating id, raw center wl, calibrated center wl # RO, as to modify it the client must use .moveRel() or .moveAbs() self.position = model.VigilantAttribute({}, unit="m", readonly=True) self._updatePosition() def _readCalibVal(self, rawv): """ rawv (str or number) return (float) """ if isinstance(rawv, basestring): if rawv.startswith("hex:"): rawv = rawv[4:] return hextof(rawv) elif isinstance(rawv, numbers.Real): return rawv else: raise ValueError("Cannot convert %s to a number" % (rawv, )) # 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 (1 < 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: 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 <= 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 RE_BLZ = re.compile("BLZ=\s+(?P<blz>[0-9.]+)\s*(?P<unit>[NU]M)") def _getBlaze(self, gid): """ Returns the blaze (=optimal center wavelength) of the given grating gid (int): index of the grating returns (float): blaze (in m) 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_BLZ.search(gstring) if not m: raise ValueError("Failed to find blaze in '%s'" % gstring) blaze, unit = float(m.group("blz")), m.group("unit").upper() blaze *= {"UM": 1e-6, "NM": 1e-9}[unit] # m return blaze 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 not 1 <= 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 <= (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 <= 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. def _getCalibratedWavelength(self): """ Read the center wavelength, and adapt it based on the calibration (if it is available for the current grating) return (float): wavelength in m """ gid = self.GetGrating() rawwl = self.GetWavelength() # Do we already now the answer? if (gid, rawwl) == self._wl[0:2]: return self._wl[2] ca, sa, fl, ia, da = self._calib[gid] # It's pretty hard to reverse the formula, so we approximate a8 using # rawwl (instead of wl), which usually doesn't bring error > 0.01 nm gl = self._getGrooveDensity(gid) psz = self._ccd.pixelSize.value[0] # m/px a8 = (rawwl * gl / 2) / math.cos(ia / 2) ga = math.asin(a8) # rad dispersion = math.cos(ia / 2 + ga) / (gl * fl) # m/m pixbw = psz * dispersion wl = (rawwl - ca * pixbw) / (sa + 1) wl = max(0, wl) return wl def _setCalibratedWavelength(self, wl): """ wl (float): center wavelength in m """ gid = self.GetGrating() ca, sa, fl, ia, da = self._calib[gid] # This is approximately what Winspec does, but it seems not exactly, # because the values differ ± 0.1nm gl = self._getGrooveDensity(gid) psz = self._ccd.pixelSize.value[0] # m/px a8 = (wl * gl / 2) / math.cos(ia / 2) ga = math.asin(a8) # rad dispersion = math.cos(ia / 2 + ga) / (gl * fl) # m/m pixbw = psz * dispersion offset = ca * pixbw + sa * wl if abs(offset) > 50e-9: # we normally don't expect offset more than 10 nm logging.warning("Center wavelength offset computed of %g nm", offset * 1e9) else: logging.debug("Center wavelength offset computed of %g nm", offset * 1e9) rawwl = max(0, wl + offset) self.SetWavelength(rawwl) # store the corresponding official wl value as it's hard to inverse the # conversion (for displaying in .position) self._wl = (gid, self.GetWavelength(), wl) # 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._getCalibratedWavelength(), "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.position.value["wavelength"] + 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._setCalibratedWavelength(pos) self._updatePosition() def _doSetWavelengthAbs(self, pos): """ Change the wavelength to a value """ with self._ser_access: self._setCalibratedWavelength(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._setCalibratedWavelength(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 getPixelToWavelength(self, npixels, pxs): """ Return the lookup table pixel number of the CCD -> wavelength observed. npixels (1 <= int): number of pixels on the CCD (horizontally), after binning. pxs (0 < float): pixel size in m (after binning) return (list of floats): pixel number -> wavelength in m """ centerpixel = (npixels - 1) / 2 cw = self.position.value["wavelength"] # m gid = self.position.value["grating"] gl = self._getGrooveDensity(gid) ca, sa, fl, ia, da = self._calib[gid] # Formula based on the Winspec documentation: # "Equations used in WinSpec Wavelength Calibration", p. 257 of the manual # ftp://ftp.piacton.com/Public/Manuals/Princeton%20Instruments/WinSpec%202.6%20Spectroscopy%20Software%20User%20Manual.pdf # Converted to code by Benjamin Brenny (from AMOLF) G = math.asin(cw / (math.cos(ia / 2) * 2 / gl)) wllist = [] for i in range(npixels): pxd = pxs * (i - centerpixel) # distance of pixel to sensor centre E = math.atan((pxd * math.cos(da)) / (fl + pxd * math.sin(da))) wl = (math.sin(G - ia / 2) + math.sin(G + ia / 2 + E)) / gl wllist.append(wl) return wllist # 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 # # TODO: use detector angle # 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: modl = self.GetModel() if not modl.startswith("SP-"): # accept it anyway logging.warning("Device reports unexpected model '%s'", modl) turret = self.GetTurret() if turret not in (1, 2, 3): return False return True except Exception: 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(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, HwError): # 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 CoupledStage(model.Actuator): """ Wrapper stage that takes as children the SEM sample stage and the ConvertStage. For each move to be performed CoupledStage moves, at the same time, both stages. """ def __init__(self, name, role, children, **kwargs): """ children (dict str -> actuator): names to ConvertStage and SEM sample stage """ # SEM stage self._master = None # Optical stage self._slave = None for crole, child in children.items(): # Check if children are actuators if not isinstance(child, model.ComponentBase): raise ValueError("Child %s is not a component." % child) if not hasattr(child, "axes") or not isinstance(child.axes, dict): raise ValueError("Child %s is not an actuator." % child.name) if "x" not in child.axes or "y" not in child.axes: raise ValueError("Child %s doesn't have both x and y axes" % child.name) if crole == "slave": self._slave = child elif crole == "master": self._master = child else: raise ValueError("Child given to CoupledStage must be either 'master' or 'slave', but got %s." % crole) if self._master is None: raise ValueError("CoupledStage needs a master child") if self._slave is None: raise ValueError("CoupledStage needs a slave child") # TODO: limit the range to the minimum of master/slave? axes_def = {} for an in ("x", "y"): axes_def[an] = copy.deepcopy(self._master.axes[an]) axes_def[an].canUpdate = False model.Actuator.__init__(self, name, role, axes=axes_def, children=children, **kwargs) self._metadata[model.MD_HW_NAME] = "CoupledStage" # will take care of executing axis moves asynchronously self._executor = CancellableThreadPoolExecutor(max_workers=1) # one task at a time self._position = {} # RO, as to modify it the client must use .moveRel() or .moveAbs() self.position = model.VigilantAttribute({}, unit="m", readonly=True) self._updatePosition() # TODO: listen to master position to update the position? => but # then it might get updated too early, before the slave has finished # moving. self.referenced = model.VigilantAttribute({}, readonly=True) # listen to changes from children for c in self.children.value: if model.hasVA(c, "referenced"): logging.debug("Subscribing to reference of child %s", c.name) c.referenced.subscribe(self._onChildReferenced) self._updateReferenced() self._stage_conv = None self._createConvertStage() def updateMetadata(self, md): self._metadata.update(md) # Re-initialize ConvertStage with the new transformation values # Called after every sample holder insertion self._createConvertStage() def _createConvertStage(self): """ (Re)create the convert stage, based on the metadata """ self._stage_conv = ConvertStage("converter-xy", "align", children={"aligner": self._slave}, axes=["x", "y"], scale=self._metadata.get(MD_PIXEL_SIZE_COR, (1, 1)), rotation=self._metadata.get(MD_ROTATION_COR, 0), translation=self._metadata.get(MD_POS_COR, (0, 0))) # if set(self._metadata.keys()) & {MD_PIXEL_SIZE_COR, MD_ROTATION_COR, MD_POS_COR}: # # Schedule a null relative move, just to ensure the stages are # # synchronised again (if some metadata is provided) # self._executor.submit(self._doMoveRel, {}) def _updatePosition(self): """ update the position VA """ mode_pos = self._master.position.value self._position["x"] = mode_pos['x'] self._position["y"] = mode_pos['y'] pos = self._applyInversion(self._position) self.position._set_value(pos, force_write=True) def _onChildReferenced(self, ref): # ref can be from any child, so we don't use it self._updateReferenced() def _updateReferenced(self): """ update the referenced VA """ ref = {} # str (axes name) -> boolean (is referenced) # consider an axis referenced iff it's referenced in every referenceable children for c in self.children.value: if not model.hasVA(c, "referenced"): continue cref = c.referenced.value for a in (set(self.axes.keys()) & set(cref.keys())): ref[a] = ref.get(a, True) and cref[a] self.referenced._set_value(ref, force_write=True) def _doMoveAbs(self, pos): """ move to the position """ f = self._master.moveAbs(pos) try: f.result() finally: # synchronise slave position even if move failed # TODO: Move simultaneously based on the expected position, and # only if the final master position is different, move again. mpos = self._master.position.value # Move objective lens f = self._stage_conv.moveAbs({"x": mpos["x"], "y": mpos["y"]}) f.result() self._updatePosition() def _doMoveRel(self, shift): """ move by the shift """ f = self._master.moveRel(shift) try: f.result() finally: mpos = self._master.position.value # Move objective lens f = self._stage_conv.moveAbs({"x": mpos["x"], "y": mpos["y"]}) f.result() self._updatePosition() @isasync def moveRel(self, shift): if not shift: shift = {"x": 0, "y": 0} self._checkMoveRel(shift) shift = self._applyInversion(shift) return self._executor.submit(self._doMoveRel, shift) @isasync def moveAbs(self, pos): if not pos: pos = self.position.value self._checkMoveAbs(pos) pos = self._applyInversion(pos) return self._executor.submit(self._doMoveAbs, pos) def stop(self, axes=None): # Empty the queue for the given axes self._executor.cancel() self._master.stop(axes) self._stage_conv.stop(axes) logging.warning("Stopping all axes: %s", ", ".join(axes or self.axes)) def _doReference(self, axes): fs = [] for c in self.children.value: # only do the referencing for the stages that support it if not model.hasVA(c, "referenced"): continue ax = axes & set(c.referenced.value.keys()) fs.append(c.reference(ax)) # wait for all referencing to be over for f in fs: f.result() # Re-synchronize the 2 stages by moving the slave where the master is mpos = self._master.position.value f = self._stage_conv.moveAbs({"x": mpos["x"], "y": mpos["y"]}) f.result() self._updatePosition() @isasync def reference(self, axes): if not axes: return model.InstantaneousFuture() self._checkReference(axes) return self._executor.submit(self._doReference, axes) def terminate(self): if self._executor: self.stop() self._executor.shutdown() self._executor = None
class AntiBacklashActuator(model.Actuator): """ This is a stage wrapper that takes a stage and ensures that every move always finishes in the same direction. """ def __init__(self, name, role, children, backlash, **kwargs): """ children (dict str -> Stage): dict containing one component, the stage to wrap backlash (dict str -> float): for each axis of the stage, the additional distance to move (and the direction). If an axis of the stage is not present, then it’s the same as having 0 as backlash (=> no antibacklash motion is performed for this axis) """ if len(children) != 1: raise ValueError("AntiBacklashActuator needs 1 child") self._child = children.values()[0] self._backlash = backlash axes_def = self._child.axes # look for axes in backlash not existing in the child missing = set(backlash.keys()) - set(axes_def.keys()) if missing: raise ValueError("Child actuator doesn't have the axes %s", missing) model.Actuator.__init__(self, name, role, axes=axes_def, children=children, **kwargs) # will take care of executing axis moves asynchronously self._executor = CancellableThreadPoolExecutor(max_workers=1) # one task at a time # Duplicate VAs which are just identical # TODO: shall we "hide" the antibacklash move by not updating position # while doing this move? self.position = self._child.position if (hasattr(self._child, "referenced") and isinstance(self._child.referenced, model.VigilantAttributeBase)): self.referenced = self._child.referenced if (hasattr(self._child, "speed") and isinstance(self._child.speed, model.VigilantAttributeBase)): self.speed = self._child.speed def terminate(self): if self._executor: self.stop() self._executor.shutdown() self._executor = None def _doMoveRel(self, shift): # move with the backlash subtracted sub_shift = {} sub_backlash = {} # same as backlash but only contains the axes moved for a, v in shift.items(): if a not in self._backlash: sub_shift[a] = v else: # optimisation: if move goes in the same direction as backlash # correction, then no need to do the correction # TODO: only do this if backlash correction has already been applied once? if v * self._backlash[a] >= 0: sub_shift[a] = v else: sub_shift[a] = v - self._backlash[a] sub_backlash[a] = self._backlash[a] f = self._child.moveRel(sub_shift) f.result() # backlash move f = self._child.moveRel(sub_backlash) f.result() def _doMoveAbs(self, pos): sub_pos = {} fpos = {} # same as pos but only contains the axes moved due to backlash for a, v in pos.items(): if a not in self._backlash: sub_pos[a] = v else: shift = v - self.position.value[a] if shift * self._backlash[a] >= 0: sub_pos[a] = v else: sub_pos[a] = v - self._backlash[a] fpos[a] = pos[a] f = self._child.moveAbs(sub_pos) f.result() # backlash move f = self._child.moveAbs(fpos) return f @isasync def moveRel(self, shift): if not shift: return model.InstantaneousFuture() self._checkMoveRel(shift) return self._executor.submit(self._doMoveRel, shift) @isasync def moveAbs(self, pos): if not pos: return model.InstantaneousFuture() self._checkMoveAbs(pos) return self._executor.submit(self._doMoveAbs, pos) def stop(self, axes=None): self._child.stop() @isasync def reference(self, axes): f = self._child.reference(axes) return f
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=30e-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) powered (list of str or None): set of the HwComponents controlled by the relay 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_VOLT, MAX_VOLT) 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=1 # 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.") ser.timeout = 5 # Sometimes the software-based USB can have some hiccups 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 DPSS(model.PowerSupplier): ''' Implements the PowerSupplier class to regulate the power supply of the Cobolt DPSS laser, connected via USB. ''' def __init__(self, name, role, port, light_name, max_power, **kwargs): ''' port (str): port name. Can be a pattern, in which case it will pick the first one which responds well ligth_name (str): the name of the component that is controlled by this power supplier max_power (float): maximum power, in W. Will be set at initialisation. ''' # TODO: allow to pass the serial number, to select the right device model.PowerSupplier.__init__(self, name, role, **kwargs) self._light_name = light_name self._ser_access = threading.Lock() self._port = self._findDevice(port) # sets ._serial logging.info("Found Cobolt DPSS device on port %s", self._port) self._sn = self.GetSerialNumber() driver_name = driver.getSerialDriver(self._port) self._swVersion = "serial driver: %s" % (driver_name, ) self._hwVersion = "Cobolt DPSS (s/n: %s)" % (self._sn, ) # Reset sequence # TODO: do a proper one. For now it's just everything we can throw, so # that it's a bit easier to debug self._sendCommand("ilk?") self._sendCommand("leds?") self._sendCommand("@cobasky?") self._sendCommand("cf") # Clear fault # self._sendCommand("@cob1") # used to force the laser on after interlock opened error # will take care of executing switch asynchronously self._executor = CancellableThreadPoolExecutor( max_workers=1) # one task at a time # Dict str -> bool: component name -> turn on/off self.supplied = model.VigilantAttribute({light_name: False}, readonly=True) self._updateSupplied() self.SetOutputPower(max_power) # Wrapper for the actual firmware functions def GetSerialNumber(self): return self._sendCommand("sn?") def SetOutputPower(self, p): """ p (0 < float): power in W """ assert 1e-6 < p < 1e6 self._sendCommand("p %.5f" % p) def SetLaser(self, state): """ state (bool): True to turn on """ v = 1 if state else 0 self._sendCommand("l%d" % v) # No space, as they are different commands @isasync def supply(self, sup): """ Change the power supply to the defined state for each component given. This is an asynchronous method. sup dict(string-> boolean): name of the component and new state returns (Future): object to control the supply request """ if not sup: return model.InstantaneousFuture() self._checkSupply(sup) return self._executor.submit(self._doSupply, sup) def _doSupply(self, sup): """ supply power """ for comp, val in sup.items(): self.SetLaser(val) self._updateSupplied() def _updateSupplied(self): """ update the supplied VA """ res = self._sendCommand("l?") pwrd = (res == "1") # it's read-only, so we change it via _value self.supplied._set_value({self._light_name: pwrd}, force_write=True) def terminate(self): if self._executor: self._executor.cancel() self._executor.shutdown() self._executor = None if self._serial: self.SetLaser( False) # TODO: allow to configure with argument to DPSS with self._ser_access: self._serial.close() self._serial = None self._file.close() def _sendCommand(self, cmd): """ cmd (str): command to be sent to device (without the CR) returns (str): answer received from the device (without \n or \r) raises: DPSSError: if an ERROR is returned by the device. """ cmd = cmd + "\r" with self._ser_access: logging.debug("Sending command %s", cmd.encode('string_escape')) self._serial.write(cmd) ans = '' while ans[-2:] != '\r\n': char = self._serial.read() if not char: raise IOError("Timeout after receiving %s" % ans.encode('string_escape')) ans += char logging.debug("Received answer %s", ans.encode('string_escape')) # TODO: check for other error answer? # Normally the device either answers OK, or a value, for commands finishing with a "?" if ans.startswith("Syntax error"): raise DPSSError(ans) return ans.rstrip() @staticmethod def _openSerialPort(port): """ Opens the given serial port the right way for a Power control device. 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, timeout=1 # 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 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 """ # TODO: For debugging purpose # if ports == "/dev/fake": # self._serial = DPSSSimulator(timeout=1) # return ports if os.name == "nt": raise NotImplementedError("Windows not supported") else: names = glob.glob(ports) for n in names: try: # Ensure no one will talk to it simultaneously, and we don't talk to devices already in use self._file = open(n) # Open in RO, just to check for lock try: fcntl.flock( self._file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) # Raises IOError if cannot lock except IOError: logging.info("Port %s is busy, will not use", n) continue self._serial = self._openSerialPort(n) try: sn = self.GetSerialNumber() except DPSSError: # 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") sn = self.GetSerialNumber() return n except (IOError, DPSSError): logging.info( "Skipping device on port %s, which didn't seem to be a Cobolt", n) # not possible to use this port? next one! continue else: raise HwError( "Failed to find a Cobolt device on ports '%s'. " "Check that the device is turned on and connected to " "the computer." % (ports, ))
class TMCM3110(model.Actuator): """ Represents one Trinamic TMCM-3110 controller. Note: it must be set to binary communication mode (that's the default). """ def __init__(self, name, role, port, axes, ustepsize, refproc=None, temp=False, **kwargs): """ port (str): port name (use /dev/fake for a simulator) axes (list of str): names of the axes, from the 1st to the 3rd. ustepsize (list of float): size of a microstep in m (the smaller, the bigger will be a move for a given distance in m) refproc (str or None): referencing (aka homing) procedure type. Use None to indicate it's not possible (no reference/limit switch) or the name of the procedure. For now only "2xFinalForward" is accepted. temp (bool): if True, will read the temperature from the analogue input (10 mV <-> 1 °C) inverted (set of str): names of the axes which are inverted (IOW, either empty or the name of the axis) """ if len(axes) != 3: raise ValueError("Axes must be a list of 3 axis names (got %s)" % (axes,)) self._axes_names = axes # axes names in order if len(axes) != len(ustepsize): raise ValueError("Expecting %d ustepsize (got %s)" % (len(axes), ustepsize)) if refproc not in {REFPROC_2XFF, REFPROC_FAKE, None}: raise ValueError("Reference procedure %s unknown" % (refproc, )) self._refproc = refproc for sz in ustepsize: if sz > 10e-3: # sz is typically ~1µm, so > 1 cm is very fishy raise ValueError("ustepsize should be in meter, but got %g" % (sz,)) self._ustepsize = ustepsize try: self._serial = self._openSerialPort(port) except serial.SerialException: raise HwError("Failed to find device %s on port %s. Ensure it is " "connected to the computer." % (name, port)) self._port = port self._ser_access = threading.Lock() self._target = 1 # Always one, when directly connected via USB self._resynchonise() modl, vmaj, vmin = self.GetVersion() if modl != 3110: logging.warning("Controller TMCM-%d is not supported, will try anyway", modl) if name is None and role is None: # For scan only return if port != "/dev/fake": # TODO: support programs in simulator # Detect if it is "USB bus powered" by using the fact that programs # don't run when USB bus powered addr = 80 # big enough to not overlap with REFPROC_2XFF programs prog = [(9, 50, 2, 1), # Set global param 50 to 1 (28,), # STOP ] self.UploadProgram(prog, addr) if not self._isFullyPowered(): # Only a warning, at the power can be connected afterwards logging.warning("Device %s has no power, the motor will not move", name) # will take care of executing axis move asynchronously self._executor = CancellableThreadPoolExecutor(max_workers=1) # one task at a time axes_def = {} for n, sz in zip(self._axes_names, self._ustepsize): # Mov abs supports ±2³¹ but the actual position is only within ±2²³ rng = [(-2 ** 23) * sz, (2 ** 23 - 1) * sz] # Probably not that much, but there is no info unless the axis has # limit switches and we run a referencing axes_def[n] = model.Axis(range=rng, unit="m") model.Actuator.__init__(self, name, role, axes=axes_def, **kwargs) for i, a in enumerate(self._axes_names): self._init_axis(i) driver_name = driver.getSerialDriver(self._port) self._swVersion = "%s (serial driver: %s)" % (odemis.__version__, driver_name) self._hwVersion = "TMCM-%d (firmware %d.%02d)" % (modl, vmaj, vmin) self.position = model.VigilantAttribute({}, unit="m", readonly=True) self._updatePosition() # TODO: add support for changing speed. cf p.68: axis param 4 + p.81 + TMC 429 p.6 self.speed = model.VigilantAttribute({}, unit="m/s", readonly=True) self._updateSpeed() if refproc is not None: # str -> boolean. Indicates whether an axis has already been referenced axes_ref = dict([(a, False) for a in axes]) self.referenced = model.VigilantAttribute(axes_ref, readonly=True) if temp: # One sensor is at the top, one at the bottom of the sample holder. # The most interesting is the temperature difference, so just # report both. self.temperature = model.FloatVA(0, unit=u"°C", readonly=True) self.temperature1 = model.FloatVA(0, unit=u"°C", readonly=True) self._temp_timer = util.RepeatingTimer(10, self._updateTemperatureVA, "TMCM temperature update") self._updateTemperatureVA() # make sure the temperature is correct self._temp_timer.start() def terminate(self): if self._executor: self.stop() self._executor.shutdown(wait=True) self._executor = None if hasattr(self, "_temp_timer"): self._temp_timer.cancel() del self._temp_timer with self._ser_access: if self._serial: self._serial.close() self._serial = None def _init_axis(self, axis): """ Initialise the given axis with "good" values for our needs (Delphi) axis (int): axis number """ self.SetAxisParam(axis, 4, 1398) # maximum velocity to 1398 == 2 mm/s self.SetAxisParam(axis, 5, 7) # maximum acc to 7 == 20 mm/s2 self.SetAxisParam(axis, 140, 8) # number of usteps ==2^8 =256 per fullstep self.SetAxisParam(axis, 6, 15) # maximum RMS-current to 15 == 15/255 x 2.8 = 165mA self.SetAxisParam(axis, 7, 0) # standby current to 0 self.SetAxisParam(axis, 204, 100) # power off after 100 ms standstill self.SetAxisParam(axis, 154, 0) # step divider to 0 ==2^0 ==1 self.SetAxisParam(axis, 153, 0) # acc divider to 0 ==2^0 ==1 self.SetAxisParam(axis, 163, 0) # chopper mode self.SetAxisParam(axis, 162, 2) # Chopper blank time (1 = for low current applications) self.SetAxisParam(axis, 167, 3) # Chopper off time (2 = minimum) self.MoveRelPos(axis, 0) # activate parameter with dummy move if self._refproc == REFPROC_2XFF: # set up the programs needed for the referencing # Interrupt: stop the referencing # The original idea was to mark the current position as 0 ASAP, and then # later on move back to there. Now, we just stop ASAP, and hope it # takes always the same time to stop. This allows to read how far from # a previous referencing position we were during the testing. prog = [# (6, 1, axis), # GAP 1, Motid # read pos # (35, 60 + axis, 2), # AGP 60, 2 # save pos to 2/60 # (32, 10 + axis, axis), # CCO 10, Motid // Save the current position # doesn't work?? # TODO: see if it's needed to do like in original procedure: set 0 ASAP # (5, 1, axis, 0), # SAP 1, MotId, 0 // Set actual pos 0 (13, 1, axis), # RFS STOP, MotId // Stop the reference search (38,), # RETI ] addr = 50 + 10 * axis # at addr 50/60/70 self.UploadProgram(prog, addr) # Program: start and wait for referencing # It's independent enough that even if the controlling computer # stops during the referencing the motor will always eventually stop. timeout = 20 # s (it can take up to 20 s to reach the home as fast speed) timeout_ticks = int(round(timeout * 100)) # 1 tick = 10 ms gparam = 50 + axis addr = 0 + 15 * axis # Max with 3 axes: ~40 prog = [(9, gparam, 2, 0), # Set global param to 0 (=running) (13, 0, axis), # RFS START, MotId (27, 4, axis, timeout_ticks), # WAIT RFS until timeout (21, 8, 0, addr + 6), # JC ETO, to TIMEOUT (= +6) (9, gparam, 2, 1), # Set global param to 1 (=all went fine) (28,), # STOP (13, 1, axis), # TIMEOUT: RFS STOP, Motid (9, gparam, 2, 2), # Set global param to 2 (=RFS timed-out) (28,), # STOP ] self.UploadProgram(prog, addr) # Communication functions @staticmethod def _instr_to_str(instr): """ instr (buffer of 9 bytes) """ target, n, typ, mot, val, chk = struct.unpack('>BBBBiB', instr) s = "%d, %d, %d, %d, %d (%d)" % (target, n, typ, mot, val, chk) return s @staticmethod def _reply_to_str(rep): """ rep (buffer of 9 bytes) """ ra, rt, status, rn, rval, chk = struct.unpack('>BBBBiB', rep) s = "%d, %d, %d, %d, %d (%d)" % (ra, rt, status, rn, rval, chk) return s def _resynchonise(self): """ Ensures the device communication is "synchronised" """ with self._ser_access: self._serial.flushInput() garbage = self._serial.read(1000) if garbage: logging.debug("Received unexpected bytes '%s'", garbage) if len(garbage) == 1000: # Probably a sign that it's not the device we are expecting logging.warning("Lots of garbage sent from device") # In case the device has received some data before, resynchronise by # sending one byte at a time until we receive a reply. # On Ubuntu, when plugging the device, udev automatically checks # whether this is a real modem, which messes up everything immediately. # As there is no command 0, either we will receive a "wrong command" or # a "wrong checksum", but it's unlikely to ever do anything more. for i in range(9): # a message is 9 bytes self._serial.write(b"\x00") self._serial.flush() res = self._serial.read(9) if len(res) == 9: break # just got synchronised elif len(res) == 0: continue else: logging.error("Device not answering with a 9 bytes reply: %s", res) else: logging.error("Device not answering to a 9 bytes message") # TODO: finish this method and use where possible def SendInstructionRecoverable(self, n, typ=0, mot=0, val=0): try: self.SendInstruction(n, typ, mot, val) except IOError: # TODO: could serial.outWaiting() give a clue on what is going on? # One possible reason is that the device disappeared because the # cable was pulled out, or the power got cut (unlikely, as it's # powered via 2 sources). # TODO: detect that the connection was lost if the port we have # leads to nowhere. => It seems os.path.exists should fail ? # or /proc/pid/fd/n link to a *(deleted) # How to handle the fact it will then probably get a different name # on replug? Use a pattern for the file name? self._resynchonise() def SendInstruction(self, n, typ=0, mot=0, val=0): """ Sends one instruction, and return the reply. n (0<=int<=255): instruction ID typ (0<=int<=255): instruction type mot (0<=int<=255): motor/bank number val (0<=int<2**32): value to send return (0<=int<2**32): value of the reply (if status is good) raises: IOError: if problem with sending/receiving data over the serial port TMCLError: if status if bad """ msg = numpy.empty(9, dtype=numpy.uint8) struct.pack_into('>BBBBiB', msg, 0, self._target, n, typ, mot, val, 0) # compute the checksum (just the sum of all the bytes) msg[-1] = numpy.sum(msg[:-1], dtype=numpy.uint8) with self._ser_access: logging.debug("Sending %s", self._instr_to_str(msg)) self._serial.write(msg) self._serial.flush() while True: res = self._serial.read(9) if len(res) < 9: # TODO: TimeoutError? raise IOError("Received only %d bytes after %s" % (len(res), self._instr_to_str(msg))) logging.debug("Received %s", self._reply_to_str(res)) ra, rt, status, rn, rval, chk = struct.unpack('>BBBBiB', res) # Check it's a valid message npres = numpy.frombuffer(res, dtype=numpy.uint8) good_chk = numpy.sum(npres[:-1], dtype=numpy.uint8) if chk == good_chk: if rt != self._target: logging.warning("Received a message from %d while expected %d", rt, self._target) if rn != n: logging.info("Skipping a message about instruction %d (waiting for %d)", rn, n) continue if not status in TMCL_OK_STATUS: raise TMCLError(status, rval, self._instr_to_str(msg)) else: # TODO: investigate more why once in a while (~1/1000 msg) # the message is garbled logging.warning("Message checksum incorrect (%d), will assume it's all fine", chk) return rval # Low level functions def GetVersion(self): """ return (int, int, int): Controller ID: 3110 for the TMCM-3110 Firmware major version number Firmware minor version number """ val = self.SendInstruction(136, 1) # Ask for binary reply cont = val >> 16 vmaj, vmin = (val & 0xff00) >> 8, (val & 0xff) return cont, vmaj, vmin def GetAxisParam(self, axis, param): """ Read the axis/parameter setting from the RAM axis (0<=int<=2): axis number param (0<=int<=255): parameter number return (0<=int): the value stored for the given axis/parameter """ val = self.SendInstruction(6, param, axis) return val def SetAxisParam(self, axis, param, val): """ Write the axis/parameter setting from the RAM axis (0<=int<=2): axis number param (0<=int<=255): parameter number val (int): the value to store """ self.SendInstruction(5, param, axis, val) def GetGlobalParam(self, bank, param): """ Read the parameter setting from the RAM bank (0<=int<=2): bank number param (0<=int<=255): parameter number return (0<=int): the value stored for the given bank/parameter """ val = self.SendInstruction(10, param, bank) return val def SetGlobalParam(self, bank, param, val): """ Write the parameter setting from the RAM bank (0<=int<=2): bank number param (0<=int<=255): parameter number val (int): the value to store """ self.SendInstruction(9, param, bank, val) def GetIO(self, bank, port): """ Read the input/output value bank (0<=int<=2): bank number port (0<=int<=255): port number return (0<=int): the value read from the given bank/port """ val = self.SendInstruction(15, port, bank) return val def GetCoordinate(self, axis, num): """ Read the axis/parameter setting from the RAM axis (0<=int<=2): axis number num (0<=int<=20): coordinate number return (0<=int): the coordinate stored """ val = self.SendInstruction(30, num, axis) return val def MoveAbsPos(self, axis, pos): """ Requests a move to an absolute position. This is non-blocking. axis (0<=int<=2): axis number pos (-2**31 <= int 2*31-1): position """ self.SendInstruction(4, 0, axis, pos) # 0 = absolute def MoveRelPos(self, axis, offset): """ Requests a move to a relative position. This is non-blocking. axis (0<=int<=2): axis number offset (-2**31 <= int 2*31-1): relative position """ self.SendInstruction(4, 1, axis, offset) # 1 = relative # it returns the expected final absolute position def MotorStop(self, axis): self.SendInstruction(3, mot=axis) def StartRefSearch(self, axis): self.SendInstruction(13, 0, axis) # 0 = start def StopRefSearch(self, axis): self.SendInstruction(13, 1, axis) # 1 = stop def GetStatusRefSearch(self, axis): """ return (bool): False if reference is not active, True if reference is active. """ val = self.SendInstruction(13, 2, axis) # 2 = status return (val != 0) def _isOnTarget(self, axis): """ return (bool): True if the target position is reached """ reached = self.GetAxisParam(axis, 8) return (reached != 0) def UploadProgram(self, prog, addr): """ Upload a program in memory prog (sequence of tuples of 4 ints): list of the arguments for SendInstruction addr (int): starting address of the program """ # cf TMCL reference p. 50 # http://pandrv.com/ttdg/phpBB3/viewtopic.php?f=13&t=992 # To download a TMCL program into a module, the following steps have to be performed: # - Send the "enter download mode command" to the module (command 132 with value as address of the program) # - Send your commands to the module as usual (status byte return 101) # - Send the "exit download mode" command (command 133 with all 0) # Each instruction is numbered +1, starting from 0 self.SendInstruction(132, val=addr) for inst in prog: # TODO: the controller sometimes fails to return the correct response # when uploading a program... not sure why, but for now we hope it # worked anyway. try: self.SendInstruction(*inst) except IOError: logging.warning("Controller returned wrong answer, but will assume it's fine") self.SendInstruction(133) def RunProgram(self, addr): """ Run the progam at the given address addr (int): starting address of the program """ self.SendInstruction(129, typ=1, val=addr) # type 1 = use specified address # To check the program runs (ie, it's not USB bus powered), you can # check the program counter increases: # assert self.GetGlobalParam(0, 130) > addr def StopProgram(self): """ Stop a progam if any is running """ self.SendInstruction(128) def SetInterrupt(self, id, addr): """ Associate an interrupt to run a program at the given address id (int): interrupt number addr (int): starting address of the program """ # Note: interrupts seem to only be executed when a program is running self.SendInstruction(37, typ=id, val=addr) def EnableInterrupt(self, id): """ Enable an interrupt See global parameters to configure the interrupts id (int): interrupt number """ self.SendInstruction(25, typ=id) def DisableInterrupt(self, id): """ Disable an interrupt See global parameters to configure the interrupts id (int): interrupt number """ self.SendInstruction(26, typ=id) def _setInputInterrupt(self, axis): """ Setup the input interrupt handler for stopping the reference search axis (int): axis number """ addr = 50 + 10 * axis # at addr 50/60/70 intid = 40 + axis # axis 0 = IN1 = 40 self.SetInterrupt(intid, addr) self.SetGlobalParam(3, intid, 3) # configure the interrupt: look at both edges self.EnableInterrupt(intid) self.EnableInterrupt(255) # globally switch on interrupt processing def _isFullyPowered(self): """ return (boolean): True if the device is "self-powered" (meaning the motors will be able to move) or False if the device is "USB bus powered" (meaning it does answer to the computer, but nothing more). """ # We use a strange fact that programs will not run if the device is not # self-powered. gparam = 50 self.SetGlobalParam(2, gparam, 0) self.RunProgram(80) # our stupid program address time.sleep(0.01) # 10 ms should be more than enough to run one instruction status = self.GetGlobalParam(2, gparam) return (status == 1) def _doInputReference(self, axis, speed): """ Run synchronously one reference search axis (int): axis number speed (int): speed in (funky) hw units for the move return (bool): True if the search was done in the positive direction, otherwise False raise: TimeoutError: if the search failed within a timeout (20s) """ timeout = 20 # s # Set speed self.SetAxisParam(axis, 194, speed) # maximum home velocity self.SetAxisParam(axis, 195, speed) # maximum switching point velocity (useless for us) # Set direction edge = self.GetIO(0, 1 + axis) # IN1 = bank 0, port 1->3 logging.debug("Going to do reference search in dir %d", edge) if edge == 1: # Edge is high, so we need to go positive dir self.SetAxisParam(axis, 193, 7 + 128) # RFS with positive dir else: # Edge is low => go negative dir self.SetAxisParam(axis, 193, 8) # RFS with negative dir gparam = 50 + axis self.SetGlobalParam(2, gparam, 0) # Run the basic program (we need one, otherwise interrupt handlers are # not processed) addr = 0 + 15 * axis endt = time.time() + timeout + 2 # +2 s to let the program first timeout self.RunProgram(addr) # Wait until referenced status = self.GetGlobalParam(2, gparam) while status == 0: time.sleep(0.01) status = self.GetGlobalParam(2, gparam) if time.time() > endt: self.StopRefSearch(axis) self.StopProgram() self.MotorStop(axis) raise IOError("Timeout during reference search from device") if status == 2: # if timed out raise raise IOError("Timeout during reference search dir %d" % edge) return (edge == 1) # Special methods for referencing def _startReferencing(self, axis): """ Do the referencing (this is synchronous). The current implementation only supports one axis referencing at a time. raise: IOError: if timeout happen """ logging.info("Starting referencing of axis %d", axis) if self._refproc == REFPROC_2XFF: if not self._isFullyPowered(): raise IOError("Device is not powered, so motors cannot move") # Procedure devised by NTS: # It requires the ref signal to be active for half the length. Like: # ___________________ 1 # | # 0 ___________________| # ----------------------------------------> forward # It first checks on which side of the length the actuator is, and # then goes towards the edge. If the movement was backward, then # it does the search a second time forward, to increase the # repeatability. # All this is done twice, once a fast speed finishing with negative # direction, then at slow speed to increase precision, finishing # in positive direction. Note that as the fast speed finishes with # negative direction, normally only one run (in positive direction) # is required on slow speed. # Note also that the reference signal is IN1-3, instead of the # official "left/home switches". It seems the reason is that it was # because when connecting a left switch, a right switch must also # be connected, but that's very probably false. Because of that, # we need to set an interrupt to stop the RFS command when the edge # changes. As interrupts only work when a program is running, we # have a small program that waits for the RFS and report the status. # In conclusion, RFS is used pretty much just to move at a constant # speed. # Note also that it seem "negative/positive" direction of the RFS # are opposite to the move relative negative/positive direction. try: self._setInputInterrupt(axis) # TODO: be able to cancel (=> set a flag + call RFS STOP) pos_dir = self._doInputReference(axis, 350) # fast (~0.5 mm/s) if pos_dir: # always finish first by negative direction self._doInputReference(axis, 350) # fast (~0.5 mm/s) # Go back far enough that the slow referencing always need quite # a bit of move. This is not part of the official NTS procedure # but without that, the final reference position is affected by # the original position. self.MoveRelPos(axis, -20000) # ~ 100µm for i in range(100): time.sleep(0.01) if self._isOnTarget(axis): break else: logging.warning("Relative move failed to finish in time") pos_dir = self._doInputReference(axis, 50) # slow (~0.07 mm/s) if not pos_dir: # if it was done in negative direction (unlikely), redo logging.debug("Doing one last reference move, in positive dir") # As it always wait for the edge to change, the second time # should be positive pos_dir = self._doInputReference(axis, 50) if not pos_dir: logging.warning("Second reference search was again in negative direction") finally: # Disable interrupt intid = 40 + axis # axis 0 = IN1 = 40 self.DisableInterrupt(intid) # TODO: to support multiple axes referencing simultaneously, # only this global interrupt would need to be handle globally # (= only disable iff noone needs interrupt). self.DisableInterrupt(255) # For safety, but also necessary to make sure SetAxisParam() works self.MotorStop(axis) # Reset the absolute 0 (by setting current pos to 0) logging.debug("Changing referencing position by %d", self.GetAxisParam(axis, 1)) self.SetAxisParam(axis, 1, 0) elif self._refproc == REFPROC_FAKE: logging.debug("Simulating referencing") self.MotorStop(axis) self.SetAxisParam(axis, 1, 0) else: raise NotImplementedError("Unknown referencing procedure %s" % self._refproc) # high-level methods (interface) def _updatePosition(self, axes=None): """ update the position VA axes (set of str): names of the axes to update or None if all should be updated """ if axes is None: axes = self._axes_names pos = self.position.value for i, n in enumerate(self._axes_names): if n in axes: # param 1 = current position pos[n] = self.GetAxisParam(i, 1) * self._ustepsize[i] # it's read-only, so we change it via _value self.position._value = pos self.position.notify(self.position.value) def _updateSpeed(self): """ Update the speed VA from the controller settings """ speed = {} # As described in section 3.4.1: # fCLK * velocity # usf = ------------------------ # 2**pulse_div * 2048 * 32 for i, n in enumerate(self._axes_names): velocity = self.GetAxisParam(i, 4) pulse_div = self.GetAxisParam(i, 154) # fCLK = 16 MHz usf = (16e6 * velocity) / (2 ** pulse_div * 2048 * 32) speed[n] = usf * self._ustepsize[i] # m/s # it's read-only, so we change it via _value self.speed._value = speed self.speed.notify(self.speed.value) def _updateTemperatureVA(self): """ Update the temperature VAs, assuming that the 2 analogue inputs are connected to a temperature sensor with mapping 10 mV <-> 1 °C. That's conveniently what is in the Delphi. """ try: # The analogue port return 0..4095 -> 0..10 V val = self.GetIO(1, 0) # 0 = first (analogue) port v = val * 10 / 4095 # V t0 = v / 10e-3 # °C val = self.GetIO(1, 4) # 4 = second (analogue) port v = val * 10 / 4095 # V t1 = v / 10e-3 # °C except Exception: logging.exception("Failed to read the temperature") return logging.info("Temperature 0 = %g °C, temperature 1 = %g °C", t0, t1) self.temperature._value = t0 self.temperature.notify(t0) self.temperature1._value = t1 self.temperature1.notify(t1) def _createFuture(self): """ Return (CancellableFuture): a future that can be used to manage a move """ f = CancellableFuture() f._moving_lock = threading.Lock() # taken while moving f._must_stop = threading.Event() # cancel of the current future requested f._was_stopped = False # if cancel was successful f.task_canceller = self._cancelCurrentMove return f @isasync def moveRel(self, shift): self._checkMoveRel(shift) shift = self._applyInversionRel(shift) # Check if the distance is big enough to make sense for an, v in shift.items(): aid = self._axes_names.index(an) if abs(v) < self._ustepsize[aid]: # TODO: store and accumulate all the small moves instead of dropping them? del shift[an] logging.info("Dropped too small move of %f m", abs(v)) if not shift: return model.InstantaneousFuture() f = self._createFuture() f = self._executor.submitf(f, self._doMoveRel, f, shift) return f @isasync def moveAbs(self, pos): if not pos: return model.InstantaneousFuture() self._checkMoveAbs(pos) pos = self._applyInversionRel(pos) f = self._createFuture() f = self._executor.submitf(f, self._doMoveAbs, f, pos) return f moveAbs.__doc__ = model.Actuator.moveAbs.__doc__ @isasync def reference(self, axes): if not axes: return model.InstantaneousFuture() self._checkReference(axes) f = self._executor.submit(self._doReference, axes) return f reference.__doc__ = model.Actuator.reference.__doc__ def stop(self, axes=None): self._executor.cancel() def _doMoveRel(self, future, pos): """ Blocking and cancellable relative move future (Future): the future it handles pos (dict str -> float): axis name -> relative target position """ with future._moving_lock: end = 0 # expected end moving_axes = set() for an, v in pos.items(): aid = self._axes_names.index(an) moving_axes.add(aid) usteps = int(round(v / self._ustepsize[aid])) self.MoveRelPos(aid, usteps) # compute expected end dur = abs(usteps) * self._ustepsize[aid] / self.speed.value[an] end = max(time.time() + dur, end) self._waitEndMove(future, moving_axes, end) logging.debug("move successfully completed") def _doMoveAbs(self, future, pos): """ Blocking and cancellable absolute move future (Future): the future it handles pos (dict str -> float): axis name -> absolute target position """ with future._moving_lock: end = 0 # expected end old_pos = self.position.value moving_axes = set() for an, v in pos.items(): aid = self._axes_names.index(an) moving_axes.add(aid) usteps = int(round(v / self._ustepsize[aid])) self.MoveAbsPos(aid, usteps) # compute expected end dur = abs(v - old_pos[an]) / self.speed.value[an] end = max(time.time() + dur, end) self._waitEndMove(future, moving_axes, end) logging.debug("move successfully completed") def _waitEndMove(self, future, axes, end=0): """ Wait until all the given axes are finished moving, or a request to stop has been received. future (Future): the future it handles axes (set of int): the axes IDs to check end (float): expected end time raise: CancelledError: if cancelled before the end of the move """ moving_axes = set(axes) last_upd = time.time() last_axes = moving_axes.copy() try: while not future._must_stop.is_set(): for aid in moving_axes.copy(): # need copy to remove during iteration if self._isOnTarget(aid): moving_axes.discard(aid) if not moving_axes: # no more axes to wait for return # Update the position from time to time (10 Hz) if time.time() - last_upd > 0.1 or last_axes != moving_axes: last_names = set(self._axes_names[i] for i in last_axes) self._updatePosition(last_names) last_upd = time.time() last_axes = moving_axes.copy() # Wait half of the time left (maximum 0.1 s) left = end - time.time() sleept = max(0, min(left / 2, 0.1)) future._must_stop.wait(sleept) logging.debug("Move of axes %s cancelled before the end", axes) # stop all axes still moving them for i in moving_axes: self.MotorStop(i) future._was_stopped = True raise CancelledError() finally: self._updatePosition() # update (all axes) with final position def _doReference(self, axes): """ Actually runs the referencing code axes (set of str) """ # do the referencing for each axis for a in axes: aid = self._axes_names.index(a) self._startReferencing(aid) # TODO: handle cancellation # If not cancelled and successful, update .referenced # We only notify after updating the position so that when a listener # receives updates both values are already updated. for a in axes: self.referenced._value[a] = True self._updatePosition(axes) # all the referenced axes should be back to 0 # read-only so manually notify self.referenced.notify(self.referenced.value) def _cancelCurrentMove(self, future): """ Cancels the current move (both absolute or relative). Non-blocking. future (Future): the future to stop. Unused, only one future must be running at a time. return (bool): True if it successfully cancelled (stopped) the move. """ # The difficulty is to synchronise correctly when: # * the task is just starting (not finished requesting axes to move) # * the task is finishing (about to say that it finished successfully) logging.debug("Cancelling current move") future._must_stop.set() # tell the thread taking care of the move it's over with future._moving_lock: if not future._was_stopped: logging.debug("Cancelling failed") return future._was_stopped @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 TMCM3110Simulator(timeout=0.1) ser = serial.Serial( port=port, baudrate=9600, # TODO: can be changed by RS485 setting p.85? bytesize=serial.EIGHTBITS, parity=serial.PARITY_NONE, stopbits=serial.STOPBITS_ONE, timeout=0.1 # s ) return ser @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 """ # TODO: use serial.tools.list_ports.comports() (but only availabe in pySerial 2.6) if os.name == "nt": ports = ["COM" + str(n) for n in range (0, 8)] else: ports = glob.glob('/dev/ttyACM?*') logging.info("Scanning for TMCM controllers in progress...") found = [] # (list of 2-tuple): name, args (port, axes(channel -> CL?) for p in ports: try: logging.debug("Trying port %s", p) dev = cls(None, None, p, axes=["x", "y", "z"], ustepsize=[10e-9, 10e-9, 10e-9]) modl, vmaj, vmin = dev.GetVersion() # TODO: based on the model name (ie, the first number) deduce # the number of axes except (serial.SerialException, IOError): # not possible to use this port? next one! continue except Exception: logging.exception("Error while communicating with port %s", p) continue found.append(("TMCM-%s" % modl, {"port": p, "axes": ["x", "y", "z"], "ustepsize": [10e-9, 10e-9, 10e-9]}) ) return found
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 try: self._serial = self.openSerialPort(port) except serial.SerialException: raise HwError("Failed to find spectrograph %s (on port '%s'). " "Check the device is turned on and connected to the " "computer. You might need to turn it off and on again." % (name, 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 SpectraPro(model.Actuator): def __init__(self, name, role, port, turret=None, calib=None, _noinit=False, children=None, **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. calib (None or list of (int, int and 5 x (float or str))): calibration data, as saved by Winspec. Data can be either in float or as an hexadecimal value "hex:9a,99,99,99,99,79,40,40" blaze in nm, groove gl/mm, center adjust, slope adjust, focal length, inclusion angle, detector angle inverted (None): it is not allowed to invert the axes children (dict str -> Component): "ccd" should be the CCD used to acquire the spectrum. _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 try: self._serial = self.openSerialPort(port) except serial.SerialException: raise HwError("Failed to find spectrograph %s (on port '%s'). " "Check the device is turned on and connected to the " "computer. You might need to turn it off and on again." % (name, 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 try: self._ccd = children["ccd"] except (TypeError, KeyError): # TODO: only needed if there is calibration info (for the pixel size) # otherwise it's fine without CCD. raise ValueError("Spectrograph needs a child 'ccd'") # 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) # First step of parsing calib parmeter: convert to (int, int) -> ... calib = calib or () if not isinstance(calib, collections.Iterable): raise ValueError("calib parameter must be in the format " "[blz, gl, ca, sa, fl, ia, da], " "but got %s" % (calib)) dcalib = {} for c in calib: if not isinstance(c, collections.Iterable) or len(c) != 7: raise ValueError("calib parameter must be in the format " "[blz, gl, ca, sa, fl, ia, da], " "but got %s" % (c,)) gt = (c[0], c[1]) if gt in dcalib: raise ValueError("calib parameter contains twice calibration for " "grating (%d nm, %d gl/mm)" % gt) dcalib[gt] = c[2:] # store calibration for pixel -> wavelength conversion and wavelength offset # int (grating number 1 -> 9) -> center adjust, slope adjust, # focal length, inclusion angle/2, detector angle self._calib = {} # TODO: read the info from MONO-EESTATUS (but it's so # huge that it's not fun to parse). There is also detector angle. dfl = FOCAL_LENGTH_OFFICIAL[model_name] # m dia = math.radians(INCLUSION_ANGLE_OFFICIAL[model_name]) # rad for i in gchoices: # put default values self._calib[i] = (0, 0, dfl, dia, 0) try: blz = self._getBlaze(i) # m gl = self._getGrooveDensity(i) # gl/m except ValueError: logging.warning("Failed to parse info of grating %d" % i, exc_info=True) continue # parse calib info gt = (int(blz * 1e9), int(gl * 1e-3)) if gt in dcalib: calgt = dcalib[gt] ca = self._readCalibVal(calgt[0]) # ratio sa = self._readCalibVal(calgt[1]) # ratio fl = self._readCalibVal(calgt[2]) * 1e-3 # mm -> m ia = math.radians(self._readCalibVal(calgt[3])) # ° -> rad da = math.radians(self._readCalibVal(calgt[4])) # ° -> rad self._calib[i] = ca, sa, fl, ia, da logging.info("Calibration data for grating %d (%d nm, %d gl/mm) " "-> %s" % (i, gt[0], gt[1], self._calib[i])) else: logging.warning("No calibration data for grating %d " "(%d nm, %d gl/mm)" % (i, gt[0], gt[1])) # 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 # for storing the latest calibrated wavelength value self._wl = (None, None, None) # grating id, raw center wl, calibrated center wl # RO, as to modify it the client must use .moveRel() or .moveAbs() self.position = model.VigilantAttribute({}, unit="m", readonly=True) self._updatePosition() def _readCalibVal(self, rawv): """ rawv (str or number) return (float) """ if isinstance(rawv, basestring): if rawv.startswith("hex:"): rawv = rawv[4:] return hextof(rawv) elif isinstance(rawv, numbers.Real): return rawv else: raise ValueError("Cannot convert %s to a number" % (rawv,)) # 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 RE_BLZ = re.compile("BLZ=\s+(?P<blz>[0-9.]+)\s*(?P<unit>[NU]M)") def _getBlaze(self, gid): """ Returns the blaze (=optimal center wavelength) of the given grating gid (int): index of the grating returns (float): blaze (in m) 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_BLZ.search(gstring) if not m: raise ValueError("Failed to find blaze in '%s'" % gstring) blaze, unit = float(m.group("blz")), m.group("unit").upper() blaze *= {"UM": 1e-6, "NM": 1e-9}[unit] # m return blaze 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 not 1 <= 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 <= (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 <= 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. def _getCalibratedWavelength(self): """ Read the center wavelength, and adapt it based on the calibration (if it is available for the current grating) return (float): wavelength in m """ gid = self.GetGrating() rawwl = self.GetWavelength() # Do we already now the answer? if (gid, rawwl) == self._wl[0:2]: return self._wl[2] ca, sa, fl, ia, da = self._calib[gid] # It's pretty hard to reverse the formula, so we approximate a8 using # rawwl (instead of wl), which usually doesn't bring error > 0.01 nm gl = self._getGrooveDensity(gid) psz = self._ccd.pixelSize.value[0] # m/px a8 = (rawwl * gl / 2) / math.cos(ia / 2) ga = math.asin(a8) # rad dispersion = math.cos(ia / 2 + ga) / (gl * fl) # m/m pixbw = psz * dispersion wl = (rawwl - ca * pixbw) / (sa + 1) wl = max(0, wl) return wl def _setCalibratedWavelength(self, wl): """ wl (float): center wavelength in m """ gid = self.GetGrating() ca, sa, fl, ia, da = self._calib[gid] # This is approximately what Winspec does, but it seems not exactly, # because the values differ ± 0.1nm gl = self._getGrooveDensity(gid) psz = self._ccd.pixelSize.value[0] # m/px a8 = (wl * gl / 2) / math.cos(ia / 2) ga = math.asin(a8) # rad dispersion = math.cos(ia / 2 + ga) / (gl * fl) # m/m pixbw = psz * dispersion offset = ca * pixbw + sa * wl if abs(offset) > 50e-9: # we normally don't expect offset more than 10 nm logging.warning("Center wavelength offset computed of %g nm", offset * 1e9) else: logging.debug("Center wavelength offset computed of %g nm", offset * 1e9) rawwl = max(0, wl + offset) self.SetWavelength(rawwl) # store the corresponding official wl value as it's hard to inverse the # conversion (for displaying in .position) self._wl = (gid, self.GetWavelength(), wl) # 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._getCalibratedWavelength(), "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.position.value["wavelength"] + 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._setCalibratedWavelength(pos) self._updatePosition() def _doSetWavelengthAbs(self, pos): """ Change the wavelength to a value """ with self._ser_access: self._setCalibratedWavelength(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._setCalibratedWavelength(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 getPixelToWavelength(self, npixels, pxs): """ Return the lookup table pixel number of the CCD -> wavelength observed. npixels (1 <= int): number of pixels on the CCD (horizontally), after binning. pxs (0 < float): pixel size in m (after binning) return (list of floats): pixel number -> wavelength in m """ centerpixel = (npixels - 1) / 2 cw = self.position.value["wavelength"] # m gid = self.position.value["grating"] gl = self._getGrooveDensity(gid) ca, sa, fl, ia, da = self._calib[gid] # Formula based on the Winspec documentation: # "Equations used in WinSpec Wavelength Calibration", p. 257 of the manual # ftp://ftp.piacton.com/Public/Manuals/Princeton%20Instruments/WinSpec%202.6%20Spectroscopy%20Software%20User%20Manual.pdf # Converted to code by Benjamin Brenny (from AMOLF) G = math.asin(cw / (math.cos(ia / 2) * 2 / gl)) wllist = [] for i in range(npixels): pxd = pxs * (i - centerpixel) # distance of pixel to sensor centre E = math.atan((pxd * math.cos(da)) / (fl + pxd * math.sin(da))) wl = (math.sin(G - ia / 2) + math.sin(G + ia / 2 + E)) / gl wllist.append(wl) return wllist # 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 # # TODO: use detector angle # 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: modl = self.GetModel() if not modl.startswith("SP-"): # accept it anyway logging.warning("Device reports unexpected model '%s'", modl) turret = self.GetTurret() if turret not in (1, 2, 3): return False return True except Exception: 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(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, HwError): # 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 MultiplexActuator(model.Actuator): """ An object representing an actuator made of several (real actuators) = a set of axes that can be moved and optionally report their position. """ def __init__(self, name, role, children, axes_map, ref_on_init=None, **kwargs): """ name (string) role (string) children (dict str -> actuator): axis name (in this actuator) -> actuator to be used for this axis axes_map (dict str -> str): axis name in this actuator -> axis name in the child actuator ref_on_init (None, list or dict (str -> float or None)): axes to be referenced during initialization. If it's a dict, it will go the indicated position after referencing, otherwise, it'll stay where it is. """ if not children: raise ValueError("MultiplexActuator needs children") if set(children.keys()) != set(axes_map.keys()): raise ValueError("MultiplexActuator needs the same keys in children and axes_map") # Convert ref_on_init list to dict with no explicit move after if isinstance(ref_on_init, list): ref_on_init = {a: None for a in ref_on_init} self._ref_on_init = ref_on_init or {} self._axis_to_child = {} # axis name => (Actuator, axis name) self._position = {} self._speed = {} self._referenced = {} axes = {} for axis, child in children.items(): caxis = axes_map[axis] self._axis_to_child[axis] = (child, caxis) # Ducktyping (useful to support also testing with MockComponent) # At least, it has .axes if not isinstance(child, model.ComponentBase): raise ValueError("Child %s is not a component." % (child,)) if not hasattr(child, "axes") or not isinstance(child.axes, dict): raise ValueError("Child %s is not an actuator." % child.name) axes[axis] = copy.deepcopy(child.axes[caxis]) self._position[axis] = child.position.value[axes_map[axis]] if model.hasVA(child, "speed") and caxis in child.speed.value: self._speed[axis] = child.speed.value[caxis] if model.hasVA(child, "referenced") and caxis in child.referenced.value: self._referenced[axis] = child.referenced.value[caxis] # this set ._axes and ._children model.Actuator.__init__(self, name, role, axes=axes, children=children, **kwargs) if len(self.children.value) > 1: # will take care of executing axis move asynchronously self._executor = CancellableThreadPoolExecutor(max_workers=1) # one task at a time # TODO: make use of the 'Cancellable' part (for now cancelling a running future doesn't work) else: # Only one child => optimize by passing all requests directly self._executor = None # keep a reference to the subscribers so that they are not # automatically garbage collected self._subfun = [] children_axes = {} # dict actuator -> set of string (our axes) for axis, (child, ca) in self._axis_to_child.items(): logging.debug("adding axis %s to child %s", axis, child.name) if child in children_axes: children_axes[child].add(axis) else: children_axes[child] = {axis} # position & speed: special VAs combining multiple VAs self.position = model.VigilantAttribute(self._position, readonly=True) for c, ax in children_axes.items(): def update_position_per_child(value, ax=ax, c=c): logging.debug("updating position of child %s", c.name) for a in ax: try: self._position[a] = value[axes_map[a]] except KeyError: logging.error("Child %s is not reporting position of axis %s", c.name, a) self._updatePosition() c.position.subscribe(update_position_per_child) self._subfun.append(update_position_per_child) # TODO: change the speed range to a dict of speed ranges self.speed = model.MultiSpeedVA(self._speed, [0., 10.], setter=self._setSpeed) for axis in self._speed.keys(): c, ca = self._axis_to_child[axis] def update_speed_per_child(value, a=axis, ca=ca, cname=c.name): try: self._speed[a] = value[ca] except KeyError: logging.error("Child %s is not reporting speed of axis %s (%s): %s", cname, a, ca, value) self._updateSpeed() c.speed.subscribe(update_speed_per_child) self._subfun.append(update_speed_per_child) # whether the axes are referenced self.referenced = model.VigilantAttribute(self._referenced.copy(), readonly=True) for axis in self._referenced.keys(): c, ca = self._axis_to_child[axis] def update_ref_per_child(value, a=axis, ca=ca, cname=c.name): try: self._referenced[a] = value[ca] except KeyError: logging.error("Child %s is not reporting reference of axis %s (%s)", cname, a, ca) self._updateReferenced() c.referenced.subscribe(update_ref_per_child) self._subfun.append(update_ref_per_child) for axis, pos in self._ref_on_init.items(): # If the axis can be referenced => do it now (and move to a known position) if axis not in self._referenced: raise ValueError("Axis '%s' cannot be referenced, while should be referenced at init" % (axis,)) if not self._referenced[axis]: # The initialisation will not fail if the referencing fails, but # the state of the component will be updated def _on_referenced(future, axis=axis): try: future.result() except Exception as e: c, ca = self._axis_to_child[axis] c.stop({ca}) # prevent any move queued self.state._set_value(e, force_write=True) logging.exception(e) f = self.reference({axis}) f.add_done_callback(_on_referenced) # If already referenced => directly move # otherwise => put move on the queue, so that any move by client will # be _after_ the init position. if pos is not None: self.moveAbs({axis: pos}) def _updatePosition(self): """ update the position VA """ # it's read-only, so we change it via _value pos = self._applyInversion(self._position) logging.debug("reporting position %s", pos) self.position._set_value(pos, force_write=True) def _updateSpeed(self): """ update the speed VA """ # we must not call the setter, so write directly the raw value self.speed._value = self._speed self.speed.notify(self._speed) def _updateReferenced(self): """ update the referenced VA """ # .referenced is copied to detect changes to it on next update self.referenced._set_value(self._referenced.copy(), force_write=True) def _setSpeed(self, value): """ value (dict string-> float): speed for each axis returns (dict string-> float): the new value """ # FIXME the problem with this implementation is that the subscribers # will receive multiple notifications for each set: # * one for each axis (via _updateSpeed from each child) # * the actual one (but it's probably dropped as it's the same value) final_value = value.copy() # copy for axis, v in value.items(): child, ma = self._axis_to_child[axis] new_speed = child.speed.value.copy() # copy new_speed[ma] = v child.speed.value = new_speed final_value[axis] = child.speed.value[ma] return final_value def _moveToChildMove(self, mv): child_to_move = collections.defaultdict(dict) # child -> moveRel argument for axis, distance in mv.items(): child, child_axis = self._axis_to_child[axis] child_to_move[child].update({child_axis: distance}) logging.debug("Moving axis %s (-> %s) by %g", axis, child_axis, distance) return child_to_move def _axesToChildAxes(self, axes): child_to_axes = collections.defaultdict(set) # child -> set(str): axes for axis in axes: child, child_axis = self._axis_to_child[axis] child_to_axes[child].add(child_axis) logging.debug("Interpreting axis %s (-> %s)", axis, child_to_axes) return child_to_axes @isasync def moveRel(self, shift, **kwargs): """ Move the stage the defined values in m for each axis given. shift dict(string-> float): name of the axis and shift in m **kwargs: Mostly there to support "update" argument (but currently works only if there is only one child) """ if not shift: return model.InstantaneousFuture() self._checkMoveRel(shift) shift = self._applyInversion(shift) if self._executor: f = self._executor.submit(self._doMoveRel, shift, **kwargs) else: cmv = self._moveToChildMove(shift) child, move = cmv.popitem() assert not cmv f = child.moveRel(move, **kwargs) return f def _doMoveRel(self, shift, **kwargs): # TODO: updates don't work because we still wait for the end of the # move before we get to the next one => multi-threaded queue? Still need # to ensure the order (ie, X>AB>X can be executed as X/AB>X or X>AB/X but # XA>AB>X must be in the order XA>AB/X futures = [] for child, move in self._moveToChildMove(shift).items(): f = child.moveRel(move, **kwargs) futures.append(f) # just wait for all futures to finish for f in futures: f.result() @isasync def moveAbs(self, pos, **kwargs): if not pos: return model.InstantaneousFuture() self._checkMoveAbs(pos) pos = self._applyInversion(pos) if self._executor: f = self._executor.submit(self._doMoveAbs, pos, **kwargs) else: cmv = self._moveToChildMove(pos) child, move = cmv.popitem() assert not cmv f = child.moveAbs(move, **kwargs) return f def _doMoveAbs(self, pos, **kwargs): futures = [] for child, move in self._moveToChildMove(pos).items(): f = child.moveAbs(move, **kwargs) futures.append(f) # just wait for all futures to finish for f in futures: f.result() @isasync def reference(self, axes): if not axes: return model.InstantaneousFuture() self._checkReference(axes) if self._executor: f = self._executor.submit(self._doReference, axes) else: cmv = self._axesToChildAxes(axes) child, a = cmv.popitem() assert not cmv f = child.reference(a) return f reference.__doc__ = model.Actuator.reference.__doc__ def _doReference(self, axes): child_to_axes = self._axesToChildAxes(axes) futures = [] for child, a in child_to_axes.items(): f = child.reference(a) futures.append(f) # just wait for all futures to finish for f in futures: f.result() def stop(self, axes=None): """ stops the motion axes (iterable or None): list of axes to stop, or None if all should be stopped """ # Empty the queue for the given axes if self._executor: self._executor.cancel() all_axes = set(self.axes.keys()) axes = axes or all_axes unknown_axes = axes - all_axes if unknown_axes: logging.error("Attempting to stop unknown axes: %s", ", ".join(unknown_axes)) axes &= all_axes threads = [] for child, a in self._axesToChildAxes(axes).items(): # it's synchronous, but we want to stop all of them as soon as possible thread = threading.Thread(name="Stopping axis", target=child.stop, args=(a,)) thread.start() threads.append(thread) # wait for completion for thread in threads: thread.join(1) if thread.is_alive(): logging.warning("Stopping child actuator of '%s' is taking more than 1s", self.name) def terminate(self): if self._executor: self.stop() self._executor.shutdown() self._executor = None
class PM8742(model.Actuator): """ Represents one New Focus picomotor controller 8742. """ def __init__(self, name, role, address, axes, stepsize, sn=None, **kwargs): """ address (str): ip address (use "autoip" to automatically scan and find the controller, "fake" for a simulator) axes (list of str): names of the axes, from the 1st to the 4th, if present. if an axis is not connected, put a "". stepsize (list of float): size of a step in m (the smaller, the bigger will be a move for a given distance in m) sn (str or None): serial number of the device (eg, "11500"). If None, the driver will use whichever controller is first found. inverted (set of str): names of the axes which are inverted (IOW, either empty or the name of the axis) """ if not 1 <= len(axes) <= 4: raise ValueError( "Axes must be a list of 1 to 4 axis names (got %s)" % (axes, )) if len(axes) != len(stepsize): raise ValueError("Expecting %d stepsize (got %s)" % (len(axes), stepsize)) self._name_to_axis = {} # str -> int: name -> axis number for i, n in enumerate(axes): if n == "": # skip this non-connected axis continue self._name_to_axis[n] = i + 1 for sz in stepsize: if sz > 10e-3: # sz is typically ~1µm, so > 1 cm is very fishy raise ValueError("stepsize should be in meter, but got %g" % (sz, )) self._stepsize = stepsize self._address = address self._sn = sn self._accesser = self._openConnection(address, sn) self._recover = False self._resynchonise() if name is None and role is None: # For scan only return # Seems to really be the device, so handle connection errors fully self._recover = True modl, fw, sn = self.GetIdentification() if modl != "8742": logging.warning("Controller %s is not supported, will try anyway", modl) # will take care of executing axis move asynchronously self._executor = CancellableThreadPoolExecutor( max_workers=1) # one task at a time # Let the controller check the actuators are connected self.MotorCheck() axes_def = {} speed = {} for n, i in self._name_to_axis.items(): sz = self._stepsize[i - 1] # TODO: allow to pass the range in m in the arguments # Position supports ±2³¹, probably not that much in reality, but # there is no info. rng = [(-2**31) * sz, (2**31 - 1) * sz] # Check the actuator is connected mt = self.GetMotorType(i) if mt in {MT_NONE, MT_UNKNOWN}: raise HwError( "Controller failed to detect motor %d, check the " "actuator is connected to the controller" % (i, )) max_stp_s = {MT_STANDARD: 2000, MT_TINY: 1750}[mt] srng = (0, self._speedToMS(i, max_stp_s)) speed[n] = self._speedToMS(i, self.GetVelocity(i)) axes_def[n] = model.Axis(range=rng, speed=srng, unit="m") model.Actuator.__init__(self, name, role, axes=axes_def, **kwargs) self._swVersion = "%s (IP connection)" % (odemis.__version__, ) self._hwVersion = "New Focus %s (firmware %s, S/N %s)" % (modl, fw, sn) # Note that the "0" position is just the position at which the # controller turned on self.position = model.VigilantAttribute({}, unit="m", readonly=True) self._updatePosition() max_speed = max(a.speed[1] for a in axes_def.values()) self.speed = model.MultiSpeedVA(speed, range=(0, max_speed), unit="m/s", setter=self._setSpeed) def terminate(self): if self._executor: self.stop() self._executor.shutdown(wait=True) self._executor = None if self._accesser: self._accesser.terminate() self._accesser = None def _sendOrderCommand(self, cmd, val="", axis=None): return self._accesser.sendOrderCommand(cmd, val, axis) def _sendQueryCommand(self, cmd, val="", axis=None): """ Same as accesser's sendQueryCommand, but with error recovery """ trials = 0 while True: try: return self._accesser.sendQueryCommand(cmd, val, axis) except IOError: # Typically due to timeout trials += 1 if not self._recover and trials < 5: raise self._recover = False try: # can also happen just due to error # => first read error and see if that explains anything self._checkError() except IOError: # Sometimes the hardware seems to lose connection # => try to reconnect logging.warning( "Device seems disconnected, will try to reconnect") # Sometimes the device gets confused and answers are shifted. # Reset helps, but it also reset the current position, which # is not handy. # self._accesser.sendOrderCommand("RS") self._accesser.terminate() time.sleep(0.5) self._accesser = self._openConnection( self._address, self._sn) self._checkError() logging.info("Recovered lost connection to device %s", self.name) finally: self._recover = True # Low level functions def GetIdentification(self): """ return (str, str, str): Model name Firmware version (and date) serial number """ resp = self._sendQueryCommand("*IDN") # expects something like this: # New_Focus 8742 v2.2 08/01/13 11511 try: m = re.match("\w+ (?P<model>\w+) (?P<fw>v\S+ \S+) (?P<sn>\d+)", resp) modl, fw, sn = m.groups() except Exception: raise IOError("Failed to decode firmware answer '%s'" % resp.encode('string_escape')) return modl, fw, sn def GetMotorType(self, axis): """ Read the motor type. The motor check action must have been performed before to get correct values. axis (1<=int<=4): axis number return (0<=int<=3): the motor type """ resp = self._sendQueryCommand("QM", axis=axis) return int(resp) def GetVelocity(self, axis): """ Read the max speed axis (1<=int<=4): axis number return (0<=int<=2000): the speed in step/s """ resp = self._sendQueryCommand("VA", axis=axis) return int(resp) def SetVelocity(self, axis, val): """ Write the max speed axis (1<=int<=4): axis number val (1<=int<=2000): the speed in step/s """ if not 1 <= val <= 2000: raise ValueError("Velocity outside of the range 0->2000") self._sendOrderCommand("VA", "%d" % (val, ), axis) def GetAccel(self, axis): """ Read the acceleration axis (1<=int<=4): axis number return (0<=int): the acceleration in step/s² """ resp = self._sendQueryCommand("AC", axis=axis) return int(resp) def SetAccel(self, axis, val): """ Write the acceleration axis (1<=int<=4): axis number val (1<=int<=200000): the acceleration in step/s² """ if not 1 <= val <= 200000: raise ValueError("Acceleration outside of the range 0->200000") self._sendOrderCommand("AC", "%d" % (val, ), axis) def MotorCheck(self): """ Run the motor check command, that automatically configure the right values based on the type of motors connected. """ self._sendOrderCommand("MC") def MoveAbs(self, axis, pos): """ Requests a move to an absolute position. This is non-blocking. axis (1<=int<=4): axis number pos (-2**31 <= int 2*31-1): position in step """ self._sendOrderCommand("PA", "%d" % (pos, ), axis) def GetTarget(self, axis): """ Read the target position for the given axis axis (1<=int<=4): axis number return (int): the position in steps """ # Note, it's not clear what's the difference with PR? resp = self._sendQueryCommand("PA", axis=axis) return int(resp) def MoveRel(self, axis, offset): """ Requests a move to a relative position. This is non-blocking. axis (1<=int<=4): axis number offset (-2**31 <= int 2*31-1): offset in step """ self._sendOrderCommand("PR", "%d" % (offset, ), axis) def GetPosition(self, axis): """ Read the actual position for the given axis axis (1<=int<=4): axis number return (int): the position in steps """ resp = self._sendQueryCommand("TP", axis=axis) return int(resp) def IsMotionDone(self, axis): """ Check whether the axis is in motion axis (1<=int<=4): axis number return (bool): False if in motion, True if motion is finished """ resp = self._sendQueryCommand("MD", axis=axis) if resp == "0": # motion in progress return False elif resp == "1": # no motion return True else: raise IOError("Failed to decode answer about motion '%s'" % resp.encode('string_escape')) def AbortMotion(self): """ Stop immediately the motion on all the axes """ self._sendOrderCommand("AB") def StopMotion(self, axis): """ Stop nicely the motion (using accel/decel values) axis (1<=int<=4): axis number """ self._sendOrderCommand("ST", axis=axis) def GetError(self): """ Read the oldest error in memory. The error buffer is FIFO with 10 elements, so it might not be the latest error if multiple errors have happened since the last time this function was called. return (None or (int, str)): the error number and message """ # Note: there is another one "TE" which only returns the number, and so # is faster, but then there is no way to get the message resp = self._sendQueryCommand("TB") # returns something like "108, MOTOR NOT CONNECTED" try: m = re.match("(?P<no>\d+), (?P<msg>.+)", resp) no, msg = int(m.group("no")), m.group("msg") except Exception: raise IOError("Failed to decode error info '%s'" % resp.encode('string_escape')) if no == 0: return None else: return no, msg def _checkError(self): """ Check if an error happened and convert to a python exception return None raise NewFocusError if an error happened """ err = self.GetError() if err: errno, msg = err raise NewFocusError(errno, msg) def _resynchonise(self): """ Ensures the device communication is "synchronised" """ self._accesser.flushInput() # drop all the errors while self.GetError(): pass # high-level methods (interface) def _updatePosition(self, axes=None): """ update the position VA axes (set of str): names of the axes to update or None if all should be updated """ # uses the current values (converted to internal representation) pos = self._applyInversion(self.position.value) for n, i in self._name_to_axis.items(): if axes is None or n in axes: pos[n] = self.GetPosition(i) * self._stepsize[i - 1] pos = self._applyInversion(pos) # it's read-only, so we change it via _value self.position._value = pos self.position.notify(self.position.value) def _speedToMS(self, axis, sps): """ Convert speed in step/s to m/s axis (1<=int<=4): axis number sps (int): steps/s return (float): m/s """ return sps * self._stepsize[axis - 1] def _setSpeed(self, value): """ value (dict string-> float): speed for each axis returns (dict string-> float): the new value """ if set(value.keys()) != set(self._axes.keys()): raise ValueError("Requested speed %s doesn't specify all axes %s" % (value, self._axes.keys())) for axis, v in value.items(): rng = self._axes[axis].speed if not rng[0] < v <= rng[1]: raise ValueError( "Requested speed of %f for axis %s not within %f->%f" % (v, axis, rng[0], rng[1])) i = self._name_to_axis[axis] sps = max(1, int(round(v / self._stepsize[i - 1]))) self.SetVelocity(i, sps) return value def _createFuture(self): """ Return (CancellableFuture): a future that can be used to manage a move """ f = CancellableFuture() f._moving_lock = threading.Lock() # taken while moving f._must_stop = threading.Event( ) # cancel of the current future requested f._was_stopped = False # if cancel was successful f.task_canceller = self._cancelCurrentMove return f @isasync def moveRel(self, shift): self._checkMoveRel(shift) shift = self._applyInversion(shift) # Check if the distance is big enough to make sense for an, v in shift.items(): aid = self._name_to_axis[an] if abs(v) < self._stepsize[aid - 1]: # TODO: store and accumulate all the small moves instead of dropping them? del shift[an] logging.info("Dropped too small move of %g m < %g m", abs(v), self._stepsize[aid - 1]) if not shift: return model.InstantaneousFuture() f = self._createFuture() f = self._executor.submitf(f, self._doMoveRel, f, shift) return f moveRel.__doc__ = model.Actuator.moveRel.__doc__ @isasync def moveAbs(self, pos): if not pos: return model.InstantaneousFuture() self._checkMoveAbs(pos) pos = self._applyInversion(pos) f = self._createFuture() f = self._executor.submitf(f, self._doMoveAbs, f, pos) return f moveAbs.__doc__ = model.Actuator.moveAbs.__doc__ def stop(self, axes=None): self._executor.cancel() def _doMoveRel(self, future, pos): """ Blocking and cancellable relative move future (Future): the future it handles pos (dict str -> float): axis name -> relative target position """ with future._moving_lock: end = 0 # expected end moving_axes = set() for an, v in pos.items(): aid = self._name_to_axis[an] moving_axes.add(aid) steps = int(round(v / self._stepsize[aid - 1])) self.MoveRel(aid, steps) # compute expected end dur = abs(steps) * self._stepsize[aid - 1] / self.speed.value[an] end = max(time.time() + dur, end) self._waitEndMove(future, moving_axes, end) logging.debug("move successfully completed") def _doMoveAbs(self, future, pos): """ Blocking and cancellable absolute move future (Future): the future it handles pos (dict str -> float): axis name -> absolute target position """ with future._moving_lock: end = 0 # expected end old_pos = self._applyInversion(self.position.value) moving_axes = set() for an, v in pos.items(): aid = self._name_to_axis[an] moving_axes.add(aid) steps = int(round(v / self._stepsize[aid - 1])) self.MoveAbs(aid, steps) # compute expected end dur = abs(v - old_pos[an]) / self.speed.value[an] end = max(time.time() + dur, end) self._waitEndMove(future, moving_axes, end) logging.debug("move successfully completed") def _waitEndMove(self, future, axes, end=0): """ Wait until all the given axes are finished moving, or a request to stop has been received. future (Future): the future it handles axes (set of int): the axes IDs to check end (float): expected end time raise: CancelledError: if cancelled before the end of the move """ moving_axes = set(axes) last_upd = time.time() last_axes = moving_axes.copy() try: while not future._must_stop.is_set(): for aid in moving_axes.copy( ): # need copy to remove during iteration if self.IsMotionDone(aid): moving_axes.discard(aid) if not moving_axes: # no more axes to wait for break # Update the position from time to time (10 Hz) if time.time() - last_upd > 0.1 or last_axes != moving_axes: last_names = set(n for n, i in self._name_to_axis.items() if i in last_axes) self._updatePosition(last_names) last_upd = time.time() last_axes = moving_axes.copy() # Wait half of the time left (maximum 0.1 s) left = end - time.time() sleept = max(0.001, min(left / 2, 0.1)) future._must_stop.wait(sleept) # TODO: timeout if really too long else: logging.debug("Move of axes %s cancelled before the end", axes) # stop all axes still moving them for i in moving_axes: self.StopMotion(i) future._was_stopped = True raise CancelledError() except Exception: raise else: # Did everything really finished fine? self._checkError() finally: self._updatePosition() # update (all axes) with final position def _cancelCurrentMove(self, future): """ Cancels the current move (both absolute or relative). Non-blocking. future (Future): the future to stop. Unused, only one future must be running at a time. return (bool): True if it successfully cancelled (stopped) the move. """ # The difficulty is to synchronise correctly when: # * the task is just starting (not finished requesting axes to move) # * the task is finishing (about to say that it finished successfully) logging.debug("Cancelling current move") future._must_stop.set( ) # tell the thread taking care of the move it's over with future._moving_lock: if not future._was_stopped: logging.debug("Cancelling failed") return future._was_stopped @classmethod def scan(cls): """ returns (list of (str, dict)): name, kwargs Note: it's obviously not advised to call this function if a device is already under use """ logging.info("Scanning for New Focus controllers in progress...") found = [] # (list of 2-tuple): name, kwargs try: conts = cls._scanOverIP() except IOError as exp: logging.exception("Failed to scan for New Focus controllers: %s", exp) for hn, host, port in conts: try: logging.debug("Trying controller at %s", host) dev = cls(None, None, address=host, axes=["a"], stepsize=[1e-6]) modl, fw, sn = dev.GetIdentification() # find out about the axes dev.MotorCheck() axes = [] stepsize = [] for i in range(1, 5): mt = dev.GetMotorType(i) n = chr(ord('a') + i - 1) # No idea about the stepsize, but make it different to allow # distinguishing between motor types if mt == MT_STANDARD: ss = 1e-6 elif mt == MT_TINY: ss = 0.1e-6 else: n = "" ss = 0 axes.append(n) stepsize.append(ss) except IOError: # not possible to use this port? next one! continue except Exception: logging.exception( "Error while communicating with controller %s @ %s:%s", hn, host, port) continue found.append(("NF-%s-%s" % (modl, sn), { "address": host, "axes": axes, "stepsize": stepsize, "sn": sn })) return found @classmethod def _openConnection(cls, address, sn=None): """ return (Accesser) """ if address == "fake": host, port = "fake", 23 elif address == "autoip": conts = cls._scanOverIP() if sn is not None: for hn, host, port in conts: # Open connection to each controller and ask for their SN dev = cls(None, None, address=host, axes=["a"], stepsize=[1e-6]) _, _, devsn = dev.GetIdentification() if sn == devsn: break else: raise HwError( "Failed to find New Focus controller %s over the " "network. Ensure it is turned on and connected to " "the network." % (sn, )) else: # just pick the first one # TODO: only pick the ones of model 8742 try: hn, host, port = conts[0] logging.info("Connecting to New Focus %s", hn) except IndexError: raise HwError( "Failed to find New Focus controller over the " "network. Ensure it is turned on and connected to " "the network.") else: # split the (IP) port, separated by a : if ":" in address: host, ipport_str = address.split(":") port = int(ipport_str) else: host = address port = 23 # default return IPAccesser(host, port) @staticmethod def _scanOverIP(): """ Scan the network for all the responding new focus controllers Note: it actually calls a separate executable because it relies on opening a network port which needs special privileges. return (list of (str, str, int)): hostname, ip address, and port number """ # Run the separate program via authbind try: exc = os.path.join(os.path.dirname(__file__), "nfpm_netscan.py") out = subprocess.check_output(["authbind", "python", exc]) except CalledProcessError as exp: # and handle all the possible errors: # - no authbind (127) # - cannot find the separate program (2) # - no authorisation (13) ret = exp.returncode if ret == 127: raise IOError("Failed to find authbind") elif ret == 2: raise IOError("Failed to find %s" % exc) elif ret == 13: raise IOError("No permission to open network port 23") # or decode the output # hostname \t host \t port ret = [] for l in out.split("\n"): if not l: continue try: hn, host, port = l.split("\t") except Exception: logging.exception("Failed to decode scanner line '%s'", l) ret.append((hn, host, port)) return ret
class AntiBacklashActuator(model.Actuator): """ This is a stage wrapper that takes a stage and ensures that every move always finishes in the same direction. """ def __init__(self, name, role, children, backlash, **kwargs): """ children (dict str -> Stage): dict containing one component, the stage to wrap backlash (dict str -> float): for each axis of the stage, the additional distance to move (and the direction). If an axis of the stage is not present, then it’s the same as having 0 as backlash (=> no antibacklash motion is performed for this axis) """ if len(children) != 1: raise ValueError("AntiBacklashActuator needs 1 child") for a, v in backlash.items(): if not isinstance(a, basestring): raise ValueError("Backlash key must be a string but got '%s'" % (a,)) if not isinstance(v, numbers.Real): raise ValueError("Backlash value of %s must be a number but got '%s'" % (a, v)) self._child = children.values()[0] self._backlash = backlash axes_def = {} for an, ax in self._child.axes.items(): axes_def[an] = copy.deepcopy(ax) axes_def[an].canUpdate = True if an in backlash and hasattr(ax, "range"): # Restrict the range to have some margin for the anti-backlash move rng = ax.range if rng[1] - rng[0] < abs(backlash[an]): raise ValueError("Backlash of %g m is bigger than range %s" % (backlash[an], rng)) if backlash[an] > 0: axes_def[an].range = (rng[0] + backlash[an], rng[1]) else: axes_def[an].range = (rng[0], rng[1] + backlash[an]) # Whether currently a backlash shift is applied on an axis # If True, moving the axis by the backlash value would restore its expected position # _shifted_lock must be taken before modifying this attribute self._shifted = {a: False for a in axes_def.keys()} self._shifted_lock = threading.Lock() # look for axes in backlash not existing in the child missing = set(backlash.keys()) - set(axes_def.keys()) if missing: raise ValueError("Child actuator doesn't have the axes %s" % (missing,)) model.Actuator.__init__(self, name, role, axes=axes_def, children=children, **kwargs) # will take care of executing axis moves asynchronously self._executor = CancellableThreadPoolExecutor(max_workers=1) # one task at a time # Duplicate VAs which are just identical # TODO: shall we "hide" the antibacklash move by not updating position # while doing this move? self.position = self._child.position if model.hasVA(self._child, "referenced"): self.referenced = self._child.referenced if model.hasVA(self._child, "speed"): self.speed = self._child.speed def terminate(self): if self._executor: self.stop() self._executor.shutdown() self._executor = None def _antiBacklashMove(self, axes): """ Moves back the axes to their official position by reverting the anti-backlash shift axes (list of str): the axes to revert """ sub_backlash = {} # same as backlash but only contains the axes moved with self._shifted_lock: for a in axes: if self._shifted[a]: if a in self._backlash: sub_backlash[a] = self._backlash[a] self._shifted[a] = False if sub_backlash: logging.debug("Running anti-backlash move %s", sub_backlash) self._child.moveRelSync(sub_backlash) def _doMoveRel(self, future, shift): # move with the backlash subtracted sub_shift = {} for a, v in shift.items(): if a not in self._backlash: sub_shift[a] = v else: # optimisation: if move goes in the same direction as backlash # correction, then no need to do the correction # TODO: only do this if backlash correction has already been applied once? if v * self._backlash[a] >= 0: sub_shift[a] = v else: with self._shifted_lock: if self._shifted[a]: sub_shift[a] = v else: sub_shift[a] = v - self._backlash[a] self._shifted[a] = True # Do the backlash + move axes = set(shift.keys()) if not any(self._shifted): # a tiny bit faster as we don't sleep self._child.moveRelSync(sub_shift) else: # some antibacklash move needed afterwards => update might be worthy f = self._child.moveRel(sub_shift) done = False while not done: try: f.result(timeout=0.01) except futures.TimeoutError: pass # Keep waiting for end of move else: done = True # Check if there is already a new move to do nf = self._executor.get_next_future(future) if nf is not None and axes <= nf._update_axes: logging.debug("Ending move control early as next move is an update containing %s", axes) return # backlash move self._antiBacklashMove(shift.keys()) def _doMoveAbs(self, future, pos): sub_pos = {} for a, v in pos.items(): if a not in self._backlash: sub_pos[a] = v else: shift = v - self.position.value[a] with self._shifted_lock: if shift * self._backlash[a] >= 0: sub_pos[a] = v self._shifted[a] = False else: sub_pos[a] = v - self._backlash[a] self._shifted[a] = True # Do the backlash + move axes = set(pos.keys()) if not any(self._shifted): # a tiny bit faster as we don't sleep self._child.moveAbsSync(sub_pos) else: # some antibacklash move needed afterwards => update might be worthy f = self._child.moveAbs(sub_pos) done = False while not done: try: f.result(timeout=0.01) except futures.TimeoutError: pass # Keep waiting for end of move else: done = True # Check if there is already a new move to do nf = self._executor.get_next_future(future) if nf is not None and axes <= nf._update_axes: logging.debug("Ending move control early as next move is an update containing %s", axes) return # anti-backlash move self._antiBacklashMove(axes) def _createFuture(self, axes, update): """ Return (CancellableFuture): a future that can be used to manage a move axes (set of str): the axes that are moved update (bool): if it's an update move """ # TODO: do this via the __init__ of subclass of Future? f = CancellableFuture() # TODO: make it cancellable too f._update_axes = set() # axes handled by the move, if update if update: # Check if all the axes support it if all(self.axes[a].canUpdate for a in axes): f._update_axes = axes else: logging.warning("Trying to do a update move on axes %s not supporting update", axes) return f @isasync def moveRel(self, shift, update=False): if not shift: return model.InstantaneousFuture() self._checkMoveRel(shift) f = self._createFuture(set(shift.keys()), update) return self._executor.submitf(f, self._doMoveRel, f, shift) @isasync def moveAbs(self, pos, update=False): if not pos: return model.InstantaneousFuture() self._checkMoveAbs(pos) f = self._createFuture(set(pos.keys()), update) return self._executor.submitf(f, self._doMoveAbs, f, pos) def stop(self, axes=None): self._child.stop(axes=axes) @isasync def reference(self, axes): f = self._child.reference(axes) return f
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 Focus(model.Actuator): """ This is an extension of the model.Actuator class. It provides functions for moving the SEM focus (as it's considered an axis in Odemis) """ def __init__(self, name, role, parent, **kwargs): """ axes (set of string): names of the axes """ self.parent = parent axes_def = { # Ranges are from the documentation "z": model.Axis(unit="m", range=(FOCUS_RANGE[0] * 1e-3, FOCUS_RANGE[1] * 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({}, unit="m", readonly=True) self._updatePosition() # Refresh regularly the position self._pos_poll = util.RepeatingTimer(5, self._refreshPosition, "Focus position polling") self._pos_poll.start() def terminate(self): if self._executor: self._executor.cancel() self._executor.shutdown() self._executor = None if self._pos_poll: self._pos_poll.cancel() self._pos_poll = None def _updatePosition(self): """ update the position VA """ z = self.parent.GetFocus() * 1e-3 self.position._set_value({"z": z}, force_write=True) def _refreshPosition(self): """ Called regularly to update the current position """ # We don't use the VA setters, to avoid sending back to the hardware a # set request logging.debug("Updating SEM focus position") try: self._updatePosition() except Exception: logging.exception( "Unexpected failure when updating focus position") def _doMoveRel(self, foc): """ move by foc foc (float): relative change in mm """ try: foc += self.parent.GetFocus() # mm self.parent.SetFocus(foc) finally: # Update the position, even if the move didn't entirely succeed self._updatePosition() def _doMoveAbs(self, foc): """ move to pos foc (float): unit mm """ try: self.parent.SetFocus(foc) finally: # Update the position, even if the move didn't entirely succeed self._updatePosition() @isasync def moveRel(self, shift): """ shift (dict): shift in m """ if not shift: return model.InstantaneousFuture() self._checkMoveRel(shift) foc = shift["z"] * 1e3 f = self._executor.submit(self._doMoveRel, foc) return f @isasync def moveAbs(self, pos): """ pos (dict): pos in m """ if not pos: return model.InstantaneousFuture() self._checkMoveAbs(pos) foc = pos["z"] * 1e3 f = self._executor.submit(self._doMoveAbs, foc) return f def stop(self, axes=None): """ Stop the last command """ # Empty the queue (and already stop the stage if a future is running) self._executor.cancel() logging.debug("Stopping all axes: %s", ", ".join(self.axes)) try: self._updatePosition() except Exception: logging.exception("Unexpected failure when updating position")
class PowerControlUnit(model.PowerSupplier): ''' Implements the PowerSupplier class to regulate the power supply of the components connected to the Power Control Unit board. It also takes care of communication with the PCU firmware. ''' def __init__(self, name, role, port, pin_map=None, delay=None, init=None, ids=None, **kwargs): ''' port (str): port name pin_map (dict of str -> int): names of the components and the pin where the component is connected. delay (dict str -> float): time to wait for each component after it is turned on. init (dict str -> boolean): turn on/off the corresponding component upon initialization. ids (list str): EEPROM ids expected to be detected during initialization. Raise an exception if the device cannot be opened ''' if pin_map: self.powered = pin_map.keys() else: self.powered = [] model.PowerSupplier.__init__(self, name, role, **kwargs) # TODO: catch errors and convert to HwError self._ser_access = threading.Lock() self._file = None self._port = self._findDevice(port) # sets ._serial and ._file logging.info("Found Power Control device on port %s", self._port) # Get identification of the Power control device self._idn = self._getIdentification() driver_name = driver.getSerialDriver(self._port) self._swVersion = "serial driver: %s" % (driver_name,) self._hwVersion = "%s" % (self._idn,) pin_map = pin_map or {} self._pin_map = pin_map delay = delay or {} # fill the missing pairs with 0 values self._delay = dict.fromkeys(pin_map, 0) self._delay.update(delay) self._last_start = dict.fromkeys(self._delay, time.time()) # will take care of executing switch asynchronously self._executor = CancellableThreadPoolExecutor(max_workers=1) # one task at a time self._supplied = {} self.supplied = model.VigilantAttribute(self._supplied, readonly=True) self._updateSupplied() init = init or {} # Remove all None's from the dict, so it can be passed as-is to _doSupply() for k, v in init.items(): if v is None: del init[k] try: self._doSupply(init, apply_delay=False) except IOError as ex: # This is in particular to handle some cases where the device resets # when turning on the power. One or more trials and the logging.exception("Failure during turning on initial power.") raise HwError("Device error when initialising power: %s. " "Try again or contact support if the problem persists." % (ex,)) self.memoryIDs = model.VigilantAttribute(None, readonly=True, getter=self._getIdentities) if ids: mem_ids = self.memoryIDs.value for eid in ids: if eid not in mem_ids: raise HwError("EEPROM id %s was not detected. Make sure " "all EEPROM components are connected." % (eid,)) @isasync def supply(self, sup): """ Change the power supply to the defined state for each component given. This is an asynchronous method. sup dict(string-> boolean): name of the component and new state returns (Future): object to control the supply request """ if not sup: return model.InstantaneousFuture() self._checkSupply(sup) return self._executor.submit(self._doSupply, sup) def _doSupply(self, sup, apply_delay=True): """ supply power apply_delay (bool): If true, wait the amount of time requested in delay after turning on the power """ for comp, val in sup.items(): # find pin and values corresponding to component pin = self._pin_map[comp] # should always be able to get the value, default values just to be # on the safe side if apply_delay: delay = self._delay.get(comp, 0) else: # We still wait a little, to avoid starting all components # _exactly_ at the same time, which could cause a power peak. delay = 1 if val: self._sendCommand("PWR " + str(pin) + " 1") state = self.supplied.value[comp] if state: # Already on, wait the time remaining remaining = (self._last_start[comp] + delay) - time.time() time.sleep(max(0, remaining)) else: # wait full time self._last_start[comp] = time.time() time.sleep(delay) # Check it really worked ans = self._sendCommand("PWR? " + str(pin)) if ans != "1": logging.warning("Failed to turn on component %s", comp) else: self._sendCommand("PWR " + str(pin) + " 0") self._updateSupplied() def _updateSupplied(self): """ update the supplied VA """ pins_updated = set(self._pin_map.values()) # to avoid asking for the same pin multiple times for pin in pins_updated: ans = self._sendCommand("PWR? " + str(pin)) # Update all components that are connected to the same pin to_update = [c for c in self.powered if pin == self._pin_map[c]] for c_update in to_update: self._supplied[c_update] = (ans == "1") # it's read-only, so we change it via _value self.supplied._value = self._supplied self.supplied.notify(self.supplied.value) def terminate(self): if self._executor: self._executor.cancel() self._executor.shutdown() self._executor = None if self._serial: with self._ser_access: self._serial.close() self._serial = None if self._file: self._file.close() self._file = None def _getIdentification(self): return self._sendCommand("*IDN?") def writeMemory(self, id, address, data): """ Write data to EEPROM. id (str): EEPROM registration number #hex (little-endian format) address (str): starting address #hex data (str): data to be written #hex (little-endian format) """ self._sendCommand("WMEM %s %s %s" % (id, address, data)) def readMemory(self, id, address, length): """ Read data from EEPROM. id (str): EEPROM registration number #hex (little-endian format) address (str): starting address #hex length (int): number of bytes to be read returns (str): data read back #hex (little-endian format) """ ans = self._sendCommand("RMEM %s %s %s" % (id, address, length)) return ans def readEEPROM(self, id): """ We use this method to get a dict that contains all the data written in the EEPROM with the given id. id (str): EEPROM registration number #hex (little-endian format) """ if id not in self.memoryIDs.value: raise KeyError("There was no EEPROM with the given id found") mem_cont = self.readMemory(id, "00", EEPROM_CAPACITY) mem_yaml = "" while mem_cont != "": if mem_cont[:2] != "00": mem_yaml += chr(int(mem_cont[:2], 16)) mem_cont = mem_cont[2:] dct = yaml.load(mem_yaml) return dct def _getIdentities(self): """ Return the ids of connected EEPROMs """ try: ans = self._sendCommand("SID") except PowerControlError as e: # means there is no power provided raise HwError("There is no power provided to the Power Control Unit. " "Please make sure the board is turned on.") x = ans.split(',') return filter(lambda a: a != '', x) def _sendCommand(self, cmd): """ cmd (str): command to be sent to Power Control unit. returns (str): answer received from the Power Control unit raises: IOError: if an ERROR is returned by the Power 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("Power Control Unit connection timeout. " "Please turn off and on the power to the box.") # Handle ERROR coming from Power control unit firmware ans += char logging.debug("Received answer %s", ans.encode('string_escape')) if ans.startswith("ERROR"): raise PowerControlError(ans.split(' ', 1)[1]) return ans.rstrip() @staticmethod def _openSerialPort(port): """ Opens the given serial port the right way for a Power 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=1 # 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, Power Control Unit can safely initialize.") ser.timeout = 5 # Sometimes the software-based USB can have some hiccups 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 = PowerControlSimulator(timeout=1) return ports if os.name == "nt": raise NotImplementedError("Windows not supported") else: names = glob.glob(ports) for n in names: try: self._file = open(n) # Open in RO, just to check for lock try: fcntl.flock(self._file.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._file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) self._serial = self._openSerialPort(n) try: idn = self._getIdentification() except PowerControlError: # 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 Power"): logging.info("Connected to wrong device on %s, skipping.", n) continue return n except (IOError, PowerControlError): # not possible to use this port? next one! logging.debug("Skipping port %s which doesn't seem the right device", n) continue else: raise HwError("Failed to find a Power 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 Power 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 Power"): found.append({"port": port}) except Exception: pass else: # TODO: Windows version raise NotImplementedError("OS not yet supported") return found
class PM8742(model.Actuator): """ Represents one New Focus picomotor controller 8742. """ def __init__(self, name, role, address, axes, stepsize, sn=None, **kwargs): """ address (str): ip address (use "autoip" to automatically scan and find the controller, "fake" for a simulator) axes (list of str): names of the axes, from the 1st to the 4th, if present. if an axis is not connected, put a "". stepsize (list of float): size of a step in m (the smaller, the bigger will be a move for a given distance in m) sn (str or None): serial number of the device (eg, "11500"). If None, the driver will use whichever controller is first found. inverted (set of str): names of the axes which are inverted (IOW, either empty or the name of the axis) """ if not 1 <= len(axes) <= 4: raise ValueError("Axes must be a list of 1 to 4 axis names (got %s)" % (axes,)) if len(axes) != len(stepsize): raise ValueError("Expecting %d stepsize (got %s)" % (len(axes), stepsize)) self._name_to_axis = {} # str -> int: name -> axis number for i, n in enumerate(axes): if n == "": # skip this non-connected axis continue self._name_to_axis[n] = i + 1 for sz in stepsize: if sz > 10e-3: # sz is typically ~1µm, so > 1 cm is very fishy raise ValueError("stepsize should be in meter, but got %g" % (sz,)) self._stepsize = stepsize self._address = address self._sn = sn self._accesser = self._openConnection(address, sn) self._recover = False self._resynchonise() if name is None and role is None: # For scan only return # Seems to really be the device, so handle connection errors fully self._recover = True modl, fw, sn = self.GetIdentification() if modl != "8742": logging.warning("Controller %s is not supported, will try anyway", modl) # will take care of executing axis move asynchronously self._executor = CancellableThreadPoolExecutor(max_workers=1) # one task at a time # Let the controller check the actuators are connected self.MotorCheck() axes_def = {} speed = {} for n, i in self._name_to_axis.items(): sz = self._stepsize[i - 1] # TODO: allow to pass the range in m in the arguments # Position supports ±2³¹, probably not that much in reality, but # there is no info. rng = [(-2 ** 31) * sz, (2 ** 31 - 1) * sz] # Check the actuator is connected mt = self.GetMotorType(i) if mt in {MT_NONE, MT_UNKNOWN}: raise HwError("Controller failed to detect motor %d, check the " "actuator is connected to the controller" % (i,)) max_stp_s = {MT_STANDARD: 2000, MT_TINY: 1750}[mt] srng = (0, self._speedToMS(i, max_stp_s)) speed[n] = self._speedToMS(i, self.GetVelocity(i)) axes_def[n] = model.Axis(range=rng, speed=srng, unit="m") model.Actuator.__init__(self, name, role, axes=axes_def, **kwargs) self._swVersion = "%s (IP connection)" % (odemis.__version__,) self._hwVersion = "New Focus %s (firmware %s, S/N %s)" % (modl, fw, sn) # Note that the "0" position is just the position at which the # controller turned on self.position = model.VigilantAttribute({}, unit="m", readonly=True) self._updatePosition() max_speed = max(a.speed[1] for a in axes_def.values()) self.speed = model.MultiSpeedVA(speed, range=(0, max_speed), unit="m/s", setter=self._setSpeed) def terminate(self): if self._executor: self.stop() self._executor.shutdown(wait=True) self._executor = None if self._accesser: self._accesser.terminate() self._accesser = None def _sendOrderCommand(self, cmd, val="", axis=None): return self._accesser.sendOrderCommand(cmd, val, axis) def _sendQueryCommand(self, cmd, val="", axis=None): """ Same as accesser's sendQueryCommand, but with error recovery """ trials = 0 while True: try: return self._accesser.sendQueryCommand(cmd, val, axis) except IOError: # Typically due to timeout trials += 1 if not self._recover and trials < 5: raise self._recover = False try: # can also happen just due to error # => first read error and see if that explains anything self._checkError() except IOError: # Sometimes the hardware seems to lose connection # => try to reconnect logging.warning("Device seems disconnected, will try to reconnect") # Sometimes the device gets confused and answers are shifted. # Reset helps, but it also reset the current position, which # is not handy. # self._accesser.sendOrderCommand("RS") self._accesser.terminate() time.sleep(0.5) self._accesser = self._openConnection(self._address, self._sn) self._checkError() logging.info("Recovered lost connection to device %s", self.name) finally: self._recover = True # Low level functions def GetIdentification(self): """ return (str, str, str): Model name Firmware version (and date) serial number """ resp = self._sendQueryCommand("*IDN") # expects something like this: # New_Focus 8742 v2.2 08/01/13 11511 try: m = re.match("\w+ (?P<model>\w+) (?P<fw>v\S+ \S+) (?P<sn>\d+)", resp) modl, fw, sn = m.groups() except Exception: raise IOError("Failed to decode firmware answer '%s'" % resp.encode('string_escape')) return modl, fw, sn def GetMotorType(self, axis): """ Read the motor type. The motor check action must have been performed before to get correct values. axis (1<=int<=4): axis number return (0<=int<=3): the motor type """ resp = self._sendQueryCommand("QM", axis=axis) return int(resp) def GetVelocity(self, axis): """ Read the max speed axis (1<=int<=4): axis number return (0<=int<=2000): the speed in step/s """ resp = self._sendQueryCommand("VA", axis=axis) return int(resp) def SetVelocity(self, axis, val): """ Write the max speed axis (1<=int<=4): axis number val (1<=int<=2000): the speed in step/s """ if not 1 <= val <= 2000: raise ValueError("Velocity outside of the range 0->2000") self._sendOrderCommand("VA", "%d" % (val,), axis) def GetAccel(self, axis): """ Read the acceleration axis (1<=int<=4): axis number return (0<=int): the acceleration in step/s² """ resp = self._sendQueryCommand("AC", axis=axis) return int(resp) def SetAccel(self, axis, val): """ Write the acceleration axis (1<=int<=4): axis number val (1<=int<=200000): the acceleration in step/s² """ if not 1 <= val <= 200000: raise ValueError("Acceleration outside of the range 0->200000") self._sendOrderCommand("AC", "%d" % (val,), axis) def MotorCheck(self): """ Run the motor check command, that automatically configure the right values based on the type of motors connected. """ self._sendOrderCommand("MC") def MoveAbs(self, axis, pos): """ Requests a move to an absolute position. This is non-blocking. axis (1<=int<=4): axis number pos (-2**31 <= int 2*31-1): position in step """ self._sendOrderCommand("PA", "%d" % (pos,), axis) def GetTarget(self, axis): """ Read the target position for the given axis axis (1<=int<=4): axis number return (int): the position in steps """ # Note, it's not clear what's the difference with PR? resp = self._sendQueryCommand("PA", axis=axis) return int(resp) def MoveRel(self, axis, offset): """ Requests a move to a relative position. This is non-blocking. axis (1<=int<=4): axis number offset (-2**31 <= int 2*31-1): offset in step """ self._sendOrderCommand("PR", "%d" % (offset,), axis) def GetPosition(self, axis): """ Read the actual position for the given axis axis (1<=int<=4): axis number return (int): the position in steps """ resp = self._sendQueryCommand("TP", axis=axis) return int(resp) def IsMotionDone(self, axis): """ Check whether the axis is in motion axis (1<=int<=4): axis number return (bool): False if in motion, True if motion is finished """ resp = self._sendQueryCommand("MD", axis=axis) if resp == "0": # motion in progress return False elif resp == "1": # no motion return True else: raise IOError("Failed to decode answer about motion '%s'" % resp.encode('string_escape')) def AbortMotion(self): """ Stop immediately the motion on all the axes """ self._sendOrderCommand("AB") def StopMotion(self, axis): """ Stop nicely the motion (using accel/decel values) axis (1<=int<=4): axis number """ self._sendOrderCommand("ST", axis=axis) def GetError(self): """ Read the oldest error in memory. The error buffer is FIFO with 10 elements, so it might not be the latest error if multiple errors have happened since the last time this function was called. return (None or (int, str)): the error number and message """ # Note: there is another one "TE" which only returns the number, and so # is faster, but then there is no way to get the message resp = self._sendQueryCommand("TB") # returns something like "108, MOTOR NOT CONNECTED" try: m = re.match("(?P<no>\d+), (?P<msg>.+)", resp) no, msg = int(m.group("no")), m.group("msg") except Exception: raise IOError("Failed to decode error info '%s'" % resp.encode('string_escape')) if no == 0: return None else: return no, msg def _checkError(self): """ Check if an error happened and convert to a python exception return None raise NewFocusError if an error happened """ err = self.GetError() if err: errno, msg = err raise NewFocusError(errno, msg) def _resynchonise(self): """ Ensures the device communication is "synchronised" """ self._accesser.flushInput() # drop all the errors while self.GetError(): pass # high-level methods (interface) def _updatePosition(self, axes=None): """ update the position VA axes (set of str): names of the axes to update or None if all should be updated """ # uses the current values (converted to internal representation) pos = self._applyInversion(self.position.value) for n, i in self._name_to_axis.items(): if axes is None or n in axes: pos[n] = self.GetPosition(i) * self._stepsize[i - 1] pos = self._applyInversion(pos) # it's read-only, so we change it via _value self.position._value = pos self.position.notify(self.position.value) def _speedToMS(self, axis, sps): """ Convert speed in step/s to m/s axis (1<=int<=4): axis number sps (int): steps/s return (float): m/s """ return sps * self._stepsize[axis - 1] def _setSpeed(self, value): """ value (dict string-> float): speed for each axis returns (dict string-> float): the new value """ if set(value.keys()) != set(self._axes.keys()): raise ValueError("Requested speed %s doesn't specify all axes %s" % (value, self._axes.keys())) for axis, v in value.items(): rng = self._axes[axis].speed if not rng[0] < v <= rng[1]: raise ValueError("Requested speed of %f for axis %s not within %f->%f" % (v, axis, rng[0], rng[1])) i = self._name_to_axis[axis] sps = max(1, int(round(v / self._stepsize[i - 1]))) self.SetVelocity(i, sps) return value def _createFuture(self): """ Return (CancellableFuture): a future that can be used to manage a move """ f = CancellableFuture() f._moving_lock = threading.Lock() # taken while moving f._must_stop = threading.Event() # cancel of the current future requested f._was_stopped = False # if cancel was successful f.task_canceller = self._cancelCurrentMove return f @isasync def moveRel(self, shift): self._checkMoveRel(shift) shift = self._applyInversion(shift) # Check if the distance is big enough to make sense for an, v in shift.items(): aid = self._name_to_axis[an] if abs(v) < self._stepsize[aid - 1]: # TODO: store and accumulate all the small moves instead of dropping them? del shift[an] logging.info("Dropped too small move of %g m < %g m", abs(v), self._stepsize[aid - 1]) if not shift: return model.InstantaneousFuture() f = self._createFuture() f = self._executor.submitf(f, self._doMoveRel, f, shift) return f @isasync def moveAbs(self, pos): if not pos: return model.InstantaneousFuture() self._checkMoveAbs(pos) pos = self._applyInversion(pos) f = self._createFuture() f = self._executor.submitf(f, self._doMoveAbs, f, pos) return f moveAbs.__doc__ = model.Actuator.moveAbs.__doc__ def stop(self, axes=None): self._executor.cancel() def _doMoveRel(self, future, pos): """ Blocking and cancellable relative move future (Future): the future it handles pos (dict str -> float): axis name -> relative target position """ with future._moving_lock: end = 0 # expected end moving_axes = set() for an, v in pos.items(): aid = self._name_to_axis[an] moving_axes.add(aid) steps = int(round(v / self._stepsize[aid - 1])) self.MoveRel(aid, steps) # compute expected end dur = abs(steps) * self._stepsize[aid - 1] / self.speed.value[an] end = max(time.time() + dur, end) self._waitEndMove(future, moving_axes, end) logging.debug("move successfully completed") def _doMoveAbs(self, future, pos): """ Blocking and cancellable absolute move future (Future): the future it handles pos (dict str -> float): axis name -> absolute target position """ with future._moving_lock: end = 0 # expected end old_pos = self._applyInversion(self.position.value) moving_axes = set() for an, v in pos.items(): aid = self._name_to_axis[an] moving_axes.add(aid) steps = int(round(v / self._stepsize[aid - 1])) self.MoveAbs(aid, steps) # compute expected end dur = abs(v - old_pos[an]) / self.speed.value[an] end = max(time.time() + dur, end) self._waitEndMove(future, moving_axes, end) logging.debug("move successfully completed") def _waitEndMove(self, future, axes, end=0): """ Wait until all the given axes are finished moving, or a request to stop has been received. future (Future): the future it handles axes (set of int): the axes IDs to check end (float): expected end time raise: CancelledError: if cancelled before the end of the move """ moving_axes = set(axes) last_upd = time.time() last_axes = moving_axes.copy() try: while not future._must_stop.is_set(): for aid in moving_axes.copy(): # need copy to remove during iteration if self.IsMotionDone(aid): moving_axes.discard(aid) if not moving_axes: # no more axes to wait for break # Update the position from time to time (10 Hz) if time.time() - last_upd > 0.1 or last_axes != moving_axes: last_names = set(n for n, i in self._name_to_axis.items() if i in last_axes) self._updatePosition(last_names) last_upd = time.time() last_axes = moving_axes.copy() # Wait half of the time left (maximum 0.1 s) left = end - time.time() sleept = max(0.001, min(left / 2, 0.1)) future._must_stop.wait(sleept) # TODO: timeout if really too long else: logging.debug("Move of axes %s cancelled before the end", axes) # stop all axes still moving them for i in moving_axes: self.StopMotion(i) future._was_stopped = True raise CancelledError() except Exception: raise else: # Did everything really finished fine? self._checkError() finally: self._updatePosition() # update (all axes) with final position def _cancelCurrentMove(self, future): """ Cancels the current move (both absolute or relative). Non-blocking. future (Future): the future to stop. Unused, only one future must be running at a time. return (bool): True if it successfully cancelled (stopped) the move. """ # The difficulty is to synchronise correctly when: # * the task is just starting (not finished requesting axes to move) # * the task is finishing (about to say that it finished successfully) logging.debug("Cancelling current move") future._must_stop.set() # tell the thread taking care of the move it's over with future._moving_lock: if not future._was_stopped: logging.debug("Cancelling failed") return future._was_stopped @classmethod def scan(cls): """ returns (list of (str, dict)): name, kwargs Note: it's obviously not advised to call this function if a device is already under use """ logging.info("Scanning for New Focus controllers in progress...") found = [] # (list of 2-tuple): name, kwargs try: conts = cls._scanOverIP() except IOError as exp: logging.exception("Failed to scan for New Focus controllers: %s", exp) for hn, host, port in conts: try: logging.debug("Trying controller at %s", host) dev = cls(None, None, address=host, axes=["a"], stepsize=[1e-6]) modl, fw, sn = dev.GetIdentification() # find out about the axes dev.MotorCheck() axes = [] stepsize = [] for i in range(1, 5): mt = dev.GetMotorType(i) n = chr(ord('a') + i - 1) # No idea about the stepsize, but make it different to allow # distinguishing between motor types if mt == MT_STANDARD: ss = 1e-6 elif mt == MT_TINY: ss = 0.1e-6 else: n = "" ss = 0 axes.append(n) stepsize.append(ss) except IOError: # not possible to use this port? next one! continue except Exception: logging.exception("Error while communicating with controller %s @ %s:%s", hn, host, port) continue found.append(("NF-%s-%s" % (modl, sn), {"address": host, "axes": axes, "stepsize": stepsize, "sn": sn}) ) return found @classmethod def _openConnection(cls, address, sn=None): """ return (Accesser) """ if address == "fake": host, port = "fake", 23 elif address == "autoip": conts = cls._scanOverIP() if sn is not None: for hn, host, port in conts: # Open connection to each controller and ask for their SN dev = cls(None, None, address=host, axes=["a"], stepsize=[1e-6]) _, _, devsn = dev.GetIdentification() if sn == devsn: break else: raise HwError("Failed to find New Focus controller %s over the " "network. Ensure it is turned on and connected to " "the network." % (sn)) else: # just pick the first one # TODO: only pick the ones of model 8742 try: hn, host, port = conts[0] logging.info("Connecting to New Focus %s", hn) except IndexError: raise HwError("Failed to find New Focus controller over the " "network. Ensure it is turned on and connected to " "the network.") else: # split the (IP) port, separated by a : if ":" in address: host, ipport_str = port.split(":") port = int(ipport_str) else: host = address port = 23 # default return IPAccesser(host, port) @staticmethod def _scanOverIP(): """ Scan the network for all the responding new focus controllers Note: it actually calls a separate executable because it relies on opening a network port which needs special privileges. return (list of (str, str, int)): hostname, ip address, and port number """ # Run the separate program via authbind try: exc = os.path.join(os.path.dirname(__file__), "nfpm_netscan.py") out = subprocess.check_output(["authbind", "python", exc]) except CalledProcessError as exp: # and handle all the possible errors: # - no authbind (127) # - cannot find the separate program (2) # - no authorisation (13) ret = exp.returncode if ret == 127: raise IOError("Failed to find authbind") elif ret == 2: raise IOError("Failed to find %s" % exc) elif ret == 13: raise IOError("No permission to open network port 23") # or decode the output # hostname \t host \t port ret = [] for l in out.split("\n"): if not l: continue try: hn, host, port = l.split("\t") except Exception: logging.exception("Failed to decode scanner line '%s'", l) ret.append((hn, host, port)) return ret
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 AntiBacklashActuator(model.Actuator): """ This is a stage wrapper that takes a stage and ensures that every move always finishes in the same direction. """ def __init__(self, name, role, children, backlash, **kwargs): """ children (dict str -> Stage): dict containing one component, the stage to wrap backlash (dict str -> float): for each axis of the stage, the additional distance to move (and the direction). If an axis of the stage is not present, then it’s the same as having 0 as backlash (=> no antibacklash motion is performed for this axis) """ if len(children) != 1: raise ValueError("AntiBacklashActuator needs 1 child") for a, v in backlash.items(): if not isinstance(a, basestring): raise ValueError("Backlash key must be a string but got '%s'" % (a,)) if not isinstance(v, numbers.Real): raise ValueError("Backlash value of %s must be a number but got '%s'" % (a, v)) self._child = children.values()[0] self._backlash = backlash axes_def = {} for an, ax in self._child.axes.items(): axes_def[an] = copy.deepcopy(ax) axes_def[an].canUpdate = True # Whether currently a backlash shift is applied on an axis # If True, moving the axis by the backlash value would restore its expected position # _shifted_lock must be taken before modifying this attribute self._shifted = dict((a, False) for a in axes_def.keys()) self._shifted_lock = threading.Lock() # look for axes in backlash not existing in the child missing = set(backlash.keys()) - set(axes_def.keys()) if missing: raise ValueError("Child actuator doesn't have the axes %s" % (missing,)) model.Actuator.__init__(self, name, role, axes=axes_def, children=children, **kwargs) # will take care of executing axis moves asynchronously self._executor = CancellableThreadPoolExecutor(max_workers=1) # one task at a time # Duplicate VAs which are just identical # TODO: shall we "hide" the antibacklash move by not updating position # while doing this move? self.position = self._child.position if model.hasVA(self._child, "referenced"): self.referenced = self._child.referenced if model.hasVA(self._child, "speed"): self.speed = self._child.speed def terminate(self): if self._executor: self.stop() self._executor.shutdown() self._executor = None def _antiBacklashMove(self, axes): """ Moves back the axes to their official position by reverting the anti-backlash shift axes (list of str): the axes to revert """ sub_backlash = {} # same as backlash but only contains the axes moved with self._shifted_lock: for a in axes: if self._shifted[a]: if a in self._backlash: sub_backlash[a] = self._backlash[a] self._shifted[a] = False if sub_backlash: logging.debug("Running anti-backlash move %s", sub_backlash) self._child.moveRelSync(sub_backlash) def _doMoveRel(self, future, shift): # move with the backlash subtracted sub_shift = {} for a, v in shift.items(): if a not in self._backlash: sub_shift[a] = v else: # optimisation: if move goes in the same direction as backlash # correction, then no need to do the correction # TODO: only do this if backlash correction has already been applied once? if v * self._backlash[a] >= 0: sub_shift[a] = v else: with self._shifted_lock: if self._shifted[a]: sub_shift[a] = v else: sub_shift[a] = v - self._backlash[a] self._shifted[a] = True # Do the backlash + move axes = set(shift.keys()) if not any(self._shifted): # a tiny bit faster as we don't sleep self._child.moveRelSync(sub_shift) else: # some antibacklash move needed afterwards => update might be worthy f = self._child.moveRel(sub_shift) done = False while not done: try: f.result(timeout=0.01) except futures.TimeoutError: pass # Keep waiting for end of move else: done = True # Check if there is already a new move to do nf = self._executor.get_next_future(future) if nf is not None and axes <= nf._update_axes: logging.debug("Ending move control early as next move is an update containing %s", axes) return # backlash move self._antiBacklashMove(shift.keys()) def _doMoveAbs(self, future, pos): sub_pos = {} for a, v in pos.items(): if a not in self._backlash: sub_pos[a] = v else: shift = v - self.position.value[a] with self._shifted_lock: if shift * self._backlash[a] >= 0: sub_pos[a] = v self._shifted[a] = False else: sub_pos[a] = v - self._backlash[a] self._shifted[a] = True # Do the backlash + move axes = set(pos.keys()) if not any(self._shifted): # a tiny bit faster as we don't sleep self._child.moveAbsSync(sub_pos) else: # some antibacklash move needed afterwards => update might be worthy f = self._child.moveAbs(sub_pos) done = False while not done: try: f.result(timeout=0.01) except futures.TimeoutError: pass # Keep waiting for end of move else: done = True # Check if there is already a new move to do nf = self._executor.get_next_future(future) if nf is not None and axes <= nf._update_axes: logging.debug("Ending move control early as next move is an update containing %s", axes) return # anti-backlash move self._antiBacklashMove(axes) def _createFuture(self, axes, update): """ Return (CancellableFuture): a future that can be used to manage a move axes (set of str): the axes that are moved update (bool): if it's an update move """ # TODO: do this via the __init__ of subclass of Future? f = CancellableFuture() # TODO: make it cancellable too f._update_axes = set() # axes handled by the move, if update if update: # Check if all the axes support it if all(self.axes[a].canUpdate for a in axes): f._update_axes = axes else: logging.warning("Trying to do a update move on axes %s not supporting update", axes) return f @isasync def moveRel(self, shift, update=False): if not shift: return model.InstantaneousFuture() self._checkMoveRel(shift) f = self._createFuture(set(shift.keys()), update) return self._executor.submitf(f, self._doMoveRel, f, shift) @isasync def moveAbs(self, pos, update=False): if not pos: return model.InstantaneousFuture() self._checkMoveAbs(pos) f = self._createFuture(set(pos.keys()), update) return self._executor.submitf(f, self._doMoveAbs, f, pos) def stop(self, axes=None): self._child.stop(axes=axes) @isasync def reference(self, axes): f = self._child.reference(axes) return f
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 MFF(model.Actuator): """ Represents one Thorlabs Motorized Filter Flipper (ie: MFF101 or MFF102) """ def __init__(self, name, role, children=None, sn=None, port=None, axis="rz", inverted=None, positions=None, **kwargs): """ children (dict string->model.HwComponent): they are not actually used. This is currently in place just to enforce PMT control to be initialized before the Fiber Flipper since we need the relay reset to happen before the flipper is turned on. 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) positions (None, or list of 2 tuples (value, str)): positions values and their corresponding name. If None: 0 and Pi/2 are used, without names. """ 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) self._sn = sn else: self._port = port # The MFF returns no serial number from GetInfo(), so find via USB try: self._sn = self._getSerialNumber(port) logging.info("Found serial number %s for device %s", self._sn, name) except LookupError: self._sn = None self._serial = self._openSerialPort(self._port) self._ser_access = threading.RLock() # reentrant, so that recovery can keep sending messages self._recover = False self._initHw() # will take care of executing axis move asynchronously self._executor = CancellableThreadPoolExecutor(max_workers=1) # one task at a time if positions is None: positions = ((0, None), (math.radians(90), None)) else: if len(positions) != 2 or any(len(p) != 2 for p in positions): raise ValueError("Positions must be exactly 2 tuples of 2 values") # TODO: have the standard inverted Actuator functions work on enumerated axis if inverted and axis in inverted: positions = (positions[1], positions[0]) self._pos_to_jog = {positions[0][0]: 1, positions[1][0]: 2} self._status_to_pos = {STA_FWD_HLS: positions[0][0], STA_RVS_HLS: positions[1][0]} if positions[0][1] is None: choices = set(p[0] for p in positions) else: choices = dict(positions) # TODO: add support for speed axes = {axis: model.Axis(unit="rad", choices=choices)} 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) try: snd, modl, typ, fmv, notes, hwv, state, nc = self.GetInfo() except IOError: # This is the first communication with the hardware, if it fails # it can be a sign the device is in a bad state. (it is known to # fail when turned on and plugged in before the host computer is # turned on) logging.exception("GetInfo() failed.") raise HwError("USB device with S/N %s seems in bad state. " "Check that the Thorlabs filter flipper was " "turned on *after* the host computer." % sn) self._hwVersion = "%s v%d (firmware %s)" % (modl, hwv, fmv) # It has worked at least once, so if it fails, there are hopes self._recover = True self.position = model.VigilantAttribute({}, readonly=True) self._updatePosition() # It'd be nice to know when a move is over, but it the MFF10x doesn't # 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 # off by default until explicitly asking for it (cf MOD_IDENTIFY) def terminate(self): self._recover = False # to stop recovering if it's ongoing 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 _initHw(self): # 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) def _recoverHwError(self): """ Returns when the device is back online """ if self._serial: self._serial.close() self._serial = None # keep looking for a serial port with the right serial number while self._recover: time.sleep(1) try: self._port = self._getSerialPort(self._sn) except HwError: logging.debug("Waiting more for the device %s to come back", self._sn) except Exception: raise else: break else: raise IOError("Device disappeared, and driver terminated") # TODO: if it failed again, try again? logging.info("Found again device %s, on port %s", self._sn, self._port) self._serial = self._openSerialPort(self._port) # Reinit Hw self._initHw() # TODO: put back to last known position? Or at least force to a known position? self._updatePosition() self.state._set_value(model.ST_RUNNING, force_write=True) 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 trials = 0 with self._ser_access: while True: trials += 1 try: logging.debug("Sending: '%s'", ", ".join("%02X" % ord(c) for c in com)) 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) else: return except IOError as ex: if not self._recover or trials >= 5: raise logging.warning("Failed to send message, trying to recover", exc_info=True) self.state._set_value(ex, force_write=True) self._recoverHwError() # 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 timed out, 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 timed out, 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: # This can happen if the mount is half-way 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 = self._applyInversion(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) shift = self._applyInversion(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) pos = self._applyInversion(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: # There is a known problem with the APT devices that prevent # them from connecting to USB if they are connected via a hub # and powered on before the host PC. raise HwError("No USB device with S/N %s. " "Check that the Thorlabs filter flipper was " "turned on *after* the host computer." % 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") def _getSerialNumber(self, port): """ Get the serial number of the device (via USB info) port (str): port name of the device (eg: "/dev/ttyUSB0" on Linux) return (str): serial number """ if sys.platform.startswith('linux'): # Go reverse from getSerialPort(): # /sys/bus/usb-serial/devices/ttyUSB0 # -> read the link and remove the last two levels try: tty = os.path.basename(port) sys_path = "/sys/bus/usb-serial/devices/" + tty usb_path = os.path.join(os.path.dirname(sys_path), os.readlink(sys_path)) serial_path = usb_path + "/../../serial" f = open(serial_path) snp = f.read().strip() return snp except (IOError, OSError): raise LookupError("Failed to find serial number of %s" % (port,)) 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: driver.checkLightBand(band) except Exception: logging.exception("Failed to parse bands %s", bands) raise curpos = self.GetPosition() if curpos not in bands: logging.info("Current position %d is not configured, will add it", curpos) bands[curpos] = "unknown" 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() self.position = model.VigilantAttribute({"band": curpos}, readonly=True) 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 super(FW102c, self).terminate() 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(b"\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 as ex: logging.debug("Port %s doesn't seem to have a FW102C device connected. " + "Identification failed with exception: %s", n, ex) else: raise HwError("Failed to find a filter wheel FW102C on ports '%s'. " "Check that the device is turned on and connected to " "the computer." % (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=10 # s (can take time when filter changes) ) 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", to_str_escape(skipped)) re_err = br"Command error (.*)" def _sendQuery(self, com): """ Send a command which expects an answer com (byte string): command to send (not including the ? and the \r) return (byte string): the answer without newline and suffix ("> ") raises IOError: if there is a timeout TLFWError: if the hardware reports an error """ # TODO: handle IOError and automatically try to reconnect (cf LLE) assert isinstance(com, bytes), 'com argument needs to be a byte string' assert(len(com) <= 50) # commands cannot be long full_com = com + b"\r" with self._ser_access: logging.debug("Sending: '%s'", to_str_escape(full_com)) 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'" % to_str_escape(line)) # normal char line += char if line[-2:] == b"> ": break logging.debug("Received: '%s'", to_str_escape(line)) # remove echo + suffix + new line line = line[len(full_com):-2].rstrip(b"\r") # if it's an error message => raise an error m = re.match(self.re_err, line) if m: err = m.group(1) raise TLFWError("Device rejected command '%s': %s" % (com, err)) return line def _sendCommand(self, com): """ Send a command which does not expect any answer com (byte string): command to send (not including the ? and the \r) return when the command is finished processed raises IOError: if there is a timeout TLFWError: 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(b"*idn?").decode("latin1") def GetMaxPosition(self): """ return (1<int): maximum number of positions available (eg, 6, 12) """ ans = self._sendQuery(b"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(b"pos?") return int(ans) def GetSpeed(self): """ return (0 or 1): current "speed" of the wheel, the bigger the faster """ ans = self._sendQuery(b"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(b"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._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 Exception: 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(15)] 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 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=30e-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) powered (list of str or None): set of the HwComponents controlled by the relay 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_VOLT, MAX_VOLT) 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 = list(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(b"*IDN?").decode('latin1') def _setGain(self, value): self._sendCommand(b"VOLT %f" % (value, )) return self._getGain() def _setProtectionCurrent(self, value): self._sendCommand(b"PCURR %f" % (value * 1e6, )) # in µA def _setProtectionTime(self, value): self._sendCommand(b"PTIME %f" % (value, )) def _getGain(self): ans = self._sendCommand(b"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(b"PWR 1") else: self._sendCommand(b"PWR 0") return value def _getPowerSupply(self): ans = self._sendCommand(b"PWR?") return ans == b"1" def _setProtection(self, value): if value: self._sendCommand(b"SWITCH 0") else: self._sendCommand(b"SWITCH 1") return value def _getProtection(self): ans = self._sendCommand(b"SWITCH?") return ans == b"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(b"RELAY 1") else: self._sendCommand(b"RELAY 0") return value def getRelay(self): ans = self._sendCommand(b"RELAY?") if ans == b"1": status = True else: status = False return status def _sendCommand(self, cmd): """ cmd (byte str): command to be sent to PMT Control unit. returns (byte str): answer received from the PMT Control unit raises: IOError: if an ERROR is returned by the PMT Control firmware. """ cmd = cmd + b"\n" with self._ser_access: logging.debug("Sending command %s", to_str_escape(cmd)) self._serial.write(cmd) ans = b'' char = None while char != b'\n': char = self._serial.read() if not char: logging.error("Timeout after receiving %s", to_str_escape(ans)) # 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", to_str_escape(ans)) if ans.startswith(b"ERROR"): raise PMTControlError(ans.split(b' ', 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=1 # 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 == b'': break logging.debug( "Nothing left to read, PMT Control Unit can safely initialize.") ser.timeout = 5 # Sometimes the software-based USB can have some hiccups 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 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 PowerControlUnit(model.PowerSupplier): ''' Implements the PowerSupplier class to regulate the power supply of the components connected to the Power Control Unit board. It also takes care of communication with the PCU firmware. ''' def __init__(self, name, role, port, pin_map=None, delay=None, init=None, ids=None, termination=None, **kwargs): ''' port (str): port name pin_map (dict of str -> int): names of the components and the pin where the component is connected. delay (dict str -> float): time to wait for each component after it is turned on. init (dict str -> boolean): turn on/off the corresponding component upon initialization. ids (list str): EEPROM ids expected to be detected during initialization. termination (dict str -> bool/None): indicate for every component if it should be turned off on termination (False), turned on (True) or left as-is (None). Raise an exception if the device cannot be opened ''' if pin_map: self.powered = list(pin_map.keys()) else: self.powered = [] model.PowerSupplier.__init__(self, name, role, **kwargs) # TODO: catch errors and convert to HwError self._ser_access = threading.Lock() self._file = None self._port = self._findDevice(port) # sets ._serial and ._file logging.info("Found Power Control device on port %s", self._port) # Get identification of the Power control device self._idn = self._getIdentification() driver_name = driver.getSerialDriver(self._port) self._swVersion = "serial driver: %s" % (driver_name, ) self._hwVersion = "%s" % (self._idn, ) pin_map = pin_map or {} self._pin_map = pin_map delay = delay or {} # fill the missing pairs with 0 values self._delay = dict.fromkeys(pin_map, 0) self._delay.update(delay) self._last_start = dict.fromkeys(self._delay, time.time()) # only keep components that should be changed on termination termination = termination or {} self._termination = { k: v for k, v in termination.items() if v is not None } for comp in self._termination: if comp not in pin_map: raise ValueError( "Component %s in termination not found in pin_map." % comp) # will take care of executing switch asynchronously self._executor = CancellableThreadPoolExecutor( max_workers=1) # one task at a time self._supplied = {} self.supplied = model.VigilantAttribute(self._supplied, readonly=True) self._updateSupplied() init = init or {} # Remove all None's from the dict, so it can be passed as-is to _doSupply() init = {k: v for k, v in init.items() if v is not None} for comp in init: if comp not in pin_map: raise ValueError("Component %s in init not found in pin_map." % comp) try: self._doSupply(init, apply_delay=False) except IOError as ex: # This is in particular to handle some cases where the device resets # when turning on the power. One or more trials and the logging.exception("Failure during turning on initial power.") raise HwError( "Device error when initialising power: %s. " "Try again or contact support if the problem persists." % (ex, )) self.memoryIDs = model.VigilantAttribute(None, readonly=True, getter=self._getIdentities) if ids: mem_ids = self.memoryIDs.value for eid in ids: if eid not in mem_ids: raise HwError("EEPROM id %s was not detected. Make sure " "all EEPROM components are connected." % (eid, )) @isasync def supply(self, sup): """ Change the power supply to the defined state for each component given. This is an asynchronous method. sup dict(string-> boolean): name of the component and new state returns (Future): object to control the supply request """ if not sup: return model.InstantaneousFuture() self._checkSupply(sup) return self._executor.submit(self._doSupply, sup) def _doSupply(self, sup, apply_delay=True): """ supply power apply_delay (bool): If true, wait the amount of time requested in delay after turning on the power """ for comp, val in sup.items(): # find pin and values corresponding to component pin = self._pin_map[comp] # should always be able to get the value, default values just to be # on the safe side if apply_delay: delay = self._delay.get(comp, 0) else: # We still wait a little, to avoid starting all components # _exactly_ at the same time, which could cause a power peak. delay = 1 if val: self._sendCommand("PWR " + str(pin) + " 1") state = self.supplied.value[comp] if state: # Already on, wait the time remaining remaining = (self._last_start[comp] + delay) - time.time() time.sleep(max(0, remaining)) else: # wait full time self._last_start[comp] = time.time() time.sleep(delay) # Check it really worked ans = self._sendCommand("PWR? " + str(pin)) if ans != "1": logging.warning("Failed to turn on component %s", comp) else: self._sendCommand("PWR " + str(pin) + " 0") self._updateSupplied() def _updateSupplied(self): """ update the supplied VA """ pins_updated = set(self._pin_map.values() ) # to avoid asking for the same pin multiple times for pin in pins_updated: ans = self._sendCommand("PWR? " + str(pin)) # Update all components that are connected to the same pin to_update = [c for c in self.powered if pin == self._pin_map[c]] for c_update in to_update: self._supplied[c_update] = (ans == "1") # it's read-only, so we change it via _value self.supplied._value = self._supplied self.supplied.notify(self.supplied.value) def terminate(self): if self._executor: self._executor.cancel() self._executor.shutdown() self._executor = None # Power components on/off according to ._termination # If nothing is specified, leave it as-is. logging.debug("Changing power supply on termination: %s" % self._termination) self._doSupply(self._termination) if self._serial: with self._ser_access: self._serial.close() self._serial = None if self._file: self._file.close() self._file = None def _getIdentification(self): return self._sendCommand("*IDN?") def writeMemory(self, id, address, data): """ Write data to EEPROM. id (str): EEPROM registration number #hex (little-endian format) address (str): starting address #hex data (str): data to be written #hex (little-endian format) """ self._sendCommand("WMEM %s %s %s" % (id, address, data)) def readMemory(self, id, address, length): """ Read data from EEPROM. id (str): EEPROM registration number #hex (little-endian format) address (str): starting address #hex length (int): number of bytes to be read returns (str): data read back #hex (little-endian format) """ ans = self._sendCommand("RMEM %s %s %s" % (id, address, length)) return ans def readEEPROM(self, id): """ We use this method to get a dict that contains all the data written in the EEPROM with the given id. id (str): EEPROM registration number #hex (little-endian format) """ if id not in self.memoryIDs.value: raise KeyError("There was no EEPROM with the given id found") mem_cont = self.readMemory(id, "00", EEPROM_CAPACITY) mem_yaml = "" while mem_cont != "": if mem_cont[:2] != "00": mem_yaml += chr(int(mem_cont[:2], 16)) mem_cont = mem_cont[2:] dct = yaml.load(mem_yaml) return dct def _getIdentities(self): """ Return the ids of connected EEPROMs """ try: ans = self._sendCommand("SID") except PowerControlError as e: # means there is no power provided raise HwError( "There is no power provided to the Power Control Unit. " "Please make sure the board is turned on.") return [x for x in ans.split(',') if x != ''] def _sendCommand(self, cmd): """ cmd (str): command to be sent to Power Control unit. returns (str): answer received from the Power Control unit raises: IOError: if an ERROR is returned by the Power Control firmware. """ cmd = (cmd + "\n").encode('latin1') with self._ser_access: logging.debug("Sending command %s" % to_str_escape(cmd)) self._serial.write(cmd) ans = b'' char = None while char != b'\n': char = self._serial.read() if not char: logging.error("Timeout after receiving %s", to_str_escape(ans)) # TODO: See how you should handle a timeout before you raise # an HWError raise HwError( "Power Control Unit connection timeout. " "Please turn off and on the power to the box.") # Handle ERROR coming from Power control unit firmware ans += char logging.debug("Received answer %s", to_str_escape(ans)) ans = ans.decode('latin1') if ans.startswith("ERROR"): raise PowerControlError(ans.split(' ', 1)[1]) return ans.rstrip() @staticmethod def _openSerialPort(port): """ Opens the given serial port the right way for a Power 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=1 # 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 == b'': break logging.debug( "Nothing left to read, Power Control Unit can safely initialize.") ser.timeout = 5 # Sometimes the software-based USB can have some hiccups 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 = PowerControlSimulator(timeout=1) return ports if os.name == "nt": raise NotImplementedError("Windows not supported") else: names = glob.glob(ports) for n in names: try: self._file = open(n) # Open in RO, just to check for lock try: fcntl.flock(self._file.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._file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) self._serial = self._openSerialPort(n) try: idn = self._getIdentification() except PowerControlError: # 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 Power"): logging.info("Connected to wrong device on %s, skipping.", n) continue return n except (IOError, PowerControlError): # not possible to use this port? next one! logging.debug( "Skipping port %s which doesn't seem the right device", n) continue else: raise HwError( "Failed to find a Power 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 Power 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 Power"): found.append({"port": port}) 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 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: driver.checkLightBand(band) except Exception: logging.exception("Failed to parse bands %s", bands) raise curpos = self.GetPosition() if curpos not in bands: logging.info("Current position %d is not configured, will add it", curpos) bands[curpos] = "unknown" 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() self.position = model.VigilantAttribute({"band": curpos}, readonly=True) 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 _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 HwError("Failed to find a filter wheel FW102C on ports '%s'. " "Check that the device is turned on and connected to " "the computer." % (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=10 # s (can take time when filter changes) ) 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 TLFWError: 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.encode('string_escape')) # 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 TLFWError("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 TLFWError: 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._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 Exception: 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(15)] 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 AntiBacklashActuator(model.Actuator): """ This is a stage wrapper that takes a stage and ensures that every move always finishes in the same direction. """ def __init__(self, name, role, children, backlash, **kwargs): """ children (dict str -> Stage): dict containing one component, the stage to wrap backlash (dict str -> float): for each axis of the stage, the additional distance to move (and the direction). If an axis of the stage is not present, then it’s the same as having 0 as backlash (=> no antibacklash motion is performed for this axis) """ if len(children) != 1: raise ValueError("AntiBacklashActuator needs 1 child") self._child = children.values()[0] self._backlash = backlash axes_def = self._child.axes # look for axes in backlash not existing in the child missing = set(backlash.keys()) - set(axes_def.keys()) if missing: raise ValueError("Child actuator doesn't have the axes %s", missing) model.Actuator.__init__(self, name, role, axes=axes_def, children=children, **kwargs) # will take care of executing axis moves asynchronously self._executor = CancellableThreadPoolExecutor( max_workers=1) # one task at a time # Duplicate VAs which are just identical # TODO: shall we "hide" the antibacklash move by not updating position # while doing this move? self.position = self._child.position if (hasattr(self._child, "referenced") and isinstance( self._child.referenced, model.VigilantAttributeBase)): self.referenced = self._child.referenced if (hasattr(self._child, "speed") and isinstance( self._child.speed, model.VigilantAttributeBase)): self.speed = self._child.speed def terminate(self): if self._executor: self.stop() self._executor.shutdown() self._executor = None def _doMoveRel(self, shift): # move with the backlash subtracted sub_shift = {} sub_backlash = {} # same as backlash but only contains the axes moved for a, v in shift.items(): if a not in self._backlash: sub_shift[a] = v else: # optimisation: if move goes in the same direction as backlash # correction, then no need to do the correction # TODO: only do this if backlash correction has already been applied once? if v * self._backlash[a] >= 0: sub_shift[a] = v else: sub_shift[a] = v - self._backlash[a] sub_backlash[a] = self._backlash[a] f = self._child.moveRel(sub_shift) f.result() # backlash move f = self._child.moveRel(sub_backlash) f.result() def _doMoveAbs(self, pos): sub_pos = {} fpos = { } # same as pos but only contains the axes moved due to backlash for a, v in pos.items(): if a not in self._backlash: sub_pos[a] = v else: shift = v - self.position.value[a] if shift * self._backlash[a] >= 0: sub_pos[a] = v else: sub_pos[a] = v - self._backlash[a] fpos[a] = pos[a] f = self._child.moveAbs(sub_pos) f.result() # backlash move f = self._child.moveAbs(fpos) return f @isasync def moveRel(self, shift): if not shift: return model.InstantaneousFuture() self._checkMoveRel(shift) return self._executor.submit(self._doMoveRel, shift) @isasync def moveAbs(self, pos): if not pos: return model.InstantaneousFuture() self._checkMoveAbs(pos) return self._executor.submit(self._doMoveAbs, pos) def stop(self, axes=None): self._child.stop() @isasync def reference(self, axes): f = self._child.reference(axes) return f