diff --git a/install/linux/usr/share/odemis/sim/sparc2-smaract-mirror-sim.odm.yaml b/install/linux/usr/share/odemis/sim/sparc2-smaract-mirror-sim.odm.yaml new file mode 100644 index 0000000000..6119626e7e --- /dev/null +++ b/install/linux/usr/share/odemis/sim/sparc2-smaract-mirror-sim.odm.yaml @@ -0,0 +1,411 @@ +# SPARC v2 SmarAct 3-Axis Mirror Configuration (multi-phase referencing) + +# Configuration for the SPARC v2: +# * Bay1 module: VISNIR +# * Bay2 module: none +SPARC2-VIS: { # Placeholder names, change to module name(s) + class: Microscope, + role: sparc2, +} + +# Light (lamp with known spectrum) +"Calibration Light": { + class: light.Light, + role: "brightlight", + power_supplier: "Power Control Unit", + affects: ["Camera", "Spectrometer"], +} + +"Power Control Unit": { + class: powerctrl.PowerControlUnit, + role: "power-control", + init: { + port: "/dev/fake", + pin_map: { + "Camera": 0, # change this to the right camera name + "Spectrograph": 2, # no spectograph in SPARC Compact + "Optical Actuators": 4, + "CL PMT control unit": 5, + "Calibration Light": 6, + }, + delay: { # Time it takes before a component is accessible + "Camera": 2, + "Spectrograph": 3, + "Optical Actuators": 1, + "CL PMT control unit": 0, + "Calibration Light": 1, # To turn on/off the light, it's immediate + }, + init: { # Which component to power on from the very beginning (to save time) + "Camera": True, + "Spectrograph": True, + "Optical Actuators": True, + "CL PMT control unit": False, + "Calibration Light": True, # turn if on initially, to warm up. It will automatically be off when the component starts + }, + termination: { # Which component to power off when closing Odemis (otherwise they stay in the current state) + "Calibration Light": False, + # "Camera": False, + "Spectrograph": False, # To force the spectrograph read the turret again if it's changed + # "Optical Actuators": False, + }, + # List of EEPROM IDs (of the plates in bay1 and bay2) which are required to start properly + ids: [], + } +} + +# Control the E-beam through analog external X/Y connection, using a DAQ board +# Control the settings of the E-beam, stage movement, mag/focus through the API + +"SEM Scan Interface": { + class: semcomedi.SEMComedi, + role: null, + init: { + device: "/dev/comedi0" + }, + # more detectors can be added, if necessary + children: { + scanner: "SEM E-beam", + detector0: "SEM Detector Analog", + detector1: "CL PMT Analog", + #counter0: "Monochromator Analog", + }, +} + +# Connect: +# X -> AO 0 +# Y -> AO 1 +# Ground -> AO GND +"SEM E-beam": { + role: null, + init: { + channels: [1, 0], + max_res : [4096, 2732], + # On Delmic scanning box v2, the voltage is x2, so need to specify twice smaller values than needed. + limits: [[-2.67, 2.67], [1.79, -1.79]], # V + park: [-2, -2], # V + # Digital output port mapping on the Delmic scanning box v2: + # 0 = Relay + # 1 = Open drain output (Y0.0) + # 2 = Digital Out 1 + # 3 = Digital Out 0 + # 4 = Status led + scanning_ttl: {4: True}, # output ports -> True (indicate scanning) or False (indicate parked) + settle_time: 10.e-6, # s + hfw_nomag: 0.400 #0.114, # m, must be the same as in "XT E-Beam Control" + }, +} + +# Must be connected on AI1/AI9 (differential) +"SEM Detector Analog": { # aka ETD + # Internal child of SEM Scan Interface, so no class + role: null, + init: { + channel: 1, + limits: [-5, 5], # V + }, +} + +# Must be connected on AI0/AI8 (differential) +"CL PMT Analog": { # the raw detector of the PMT + role: null, + init: { + channel: 0, + limits: [-2.5, 2.5], # V + }, +} + +"SimSEM": { + class: simsem.SimSEM, + role: null, + init: { + image: "simsem-fake-output.h5", # any large 16 bit image is fine + }, + children: { + scanner: "XT E-beam Control", + detector0: "Sim SE Detector", + focus: "XT E-beam Focus", + }, +} + +"XT E-beam Control": { + role: null, + init: {}, +} + +"XT E-beam Focus": { + role: ebeam-focus, + init: {}, + affects: ["SEM E-beam Full"], +} + +# Unused, but needed to make the SimSEM happy +"Sim SE Detector": { + role: null, + init: {}, +} + +# Merges the scan interface e-beam and the e-beam control (via API) +"SEM E-beam Full": { + class: scanner.CompositedScanner, + role: e-beam, + init: { + }, + children: { + detector0: "SEM Detector", + detector1: "CL PMT", + }, + dependencies: { + external: "SEM E-beam", + internal: "XT E-beam Control" + }, + properties: { + scale: [8, 8], # (ratio) : start with a pretty fast scan + dwellTime: 10.e-6, # s + }, + # affects the CCD in case of cathodoluminescence + affects: ["SEM Detector", "Camera", "Spectrometer"], +} + +# Wraps the detectors, so that the "external" mode is set automatically when acquiring +"SEM Detector": { + role: se-detector, + dependencies: { + external: "SEM Detector Analog", + }, + init: {}, +} + +"CL PMT": { + role: null, + dependencies: { + external: "CL PMT Analog", + }, + init: {}, +} + +# The first lens of Plate 1, able to move along the whole range +"Lens1 Mover": { + class: actuator.MultiplexActuator, + role: "lens-mover", + dependencies: {"x": "Optical Actuators"}, + init: { + axes_map: {"x": "l1"}, + ref_on_init: ["x"], + }, + metadata: { + # Default position of the lens (can be improved by user) + FAV_POS_ACTIVE: {"x": 0.0290955686} # m + }, + affects: ["Lens2 Switch"], +} + +# The second lens of Plate 1, either to working or parking position +"Lens2 Switch": { + class: actuator.MultiplexActuator, + role: "lens-switch", + dependencies: {"x": "Optical Actuators"}, + init: { + axes_map: {"x": "l2"}, + ref_on_init: ["x"], + }, + metadata: { + # Position away + FAV_POS_DEACTIVE: {"x": 0.0}, # m + # Default position of the lens (can be improved by user) + FAV_POS_ACTIVE: {"x": -0.0499320073}, # m + POS_ACTIVE_RANGE: {"x": [-1.e-3, 1.e-3]}, # relative min/max from the active position when doing EK-scanning + }, + affects: ["Camera", "Spectrometer"], +} + +# Controller for the motors moving the various parts of the optical box +"Optical Actuators": { + class: tmcm.TMCLController, + role: null, + power_supplier: "Power Control Unit", + init: { + port: "/dev/fake6", + address: null, + # param_file: "/usr/share/odemis/sparc2-atm-tmcm6110-pmt.tmcm.tsv", + axes: ["l1", "l2", "cl-sel", "fw", "slit"], + # These values only need to be roughly correct + ustepsize: [25.1e-7, 25.1e-7, 26.1e-7, 3.392e-5, 5.e-7], # m/µstep # Special value for the simulator to go faster + rng: [[-1.e-3, 100.e-3], [-1.e-3, 100.e-3], [-1.e-3, 100.e-3], [-14, 14], [-1.e-3, 2.e-3]], # very rough ranges, which should be correct for any module + unit: ["m", "m", "m", "rad", "m"], + refproc: "Standard", + refswitch: {"l1": 0, "l2": 0, "cl-sel": 4, "fw": 4}, + inverted: ["l2"], + }, +} + +"Camera": { + class: simcam.Camera, + role: ccd, + power_supplier: "Power Control Unit", + init: { + image: "sparc-ar-mirror-aligned-pos.h5", # only for simulator + }, + dependencies: { + focus: "Spectrograph focus", + mirror: "Mirror Actuators", + }, +} + +# Spectrometer = Newton + Shamrock SR193 (connected via USB) +"Spectrometer": { + class: spectrometer.CompositedSpectrometer, + role: spectrometer0, + dependencies: {detector: "Camera", spectrograph: "Spectrograph"}, + init: { + transp: [1, 2], + }, + properties: { + #binning: [4, 256], + }, +} + +"Optical Path Properties": { + class: static.OpticalLens, + role: lens, + # high NA working distance mirror config + init: { + mag: 0.338, # ratio + na: 0.2, # ratio, numerical aperture + ri: 1.0, # ratio, refractive index + # Add to have support for AR, disable in case of SPARC Compact + pole_pos: [512, 128], # (px, px), position of the pole (aka the hole in the mirror) + # Add to have support for EK, disable in case of SPARC Compact + mirror_pos_top: [220, 0], # (px, px/m), position of the top of the mirror depending on the wavelength + mirror_pos_bottom: [30, 0], # (px, px/m), position of the bottom of the mirror depending on the wavelength + x_max: 13.25e-3, # m, the distance between the parabola origin and the cutoff position + hole_diam: 0.6e-3, # m, diameter the hole in the mirror + focus_dist: 0.5e-3, # m, the vertical mirror cutoff, iow the min distance between the mirror and the sample + parabola_f: 2.5e-3, # m, parabola_parameter=1/4f + rotation: 0, # rad, -1.570796326795: 90° rotation between optical axis and SEM Y axis (declared in the interface kit) + }, + # enable persistent if there is a single mirror used +# persistent: { +# properties: [polePosition, mirrorPositionTop, mirrorPositionBottom], +# }, + affects: ["Camera", "Spectrometer"], +} + +# Controller for moving the smaract mirror actuators +"Mirror Actuators": { + class: smaract.MCS2, + role: mirror, + init: { + locator: "fake", + # multi-phase executes a metadata-driven referencing procedure intended + # for mechanically constrained systems (e.g. L-slot geometries). + refproc: "multi-phase", + ref_on_init: False, + speed: 0.002, # m/s + accel: 0.002, # m/s² + hold_time: 1, # s, default = infinite (.inf) + pos_deactive_after_ref: False, + axes: { + 'x': { + range: [-15.5e-3, 15.0e-3], + unit: 'm', + channel: 0, + }, + 'y': { + range: [-3.0e-3, 1.5e-3], + unit: 'm', + channel: 1, + }, + 'z': { + range: [-11.5e-3, 9.5e-3], + unit: 'm', + channel: 2, + }, + }, + }, + metadata: { + # The order of referencing is defined by the `AXES_ORDER_REF` metadata. Axes listed in + # `FAV_POS_ALIGN` are referenced first and moved to their corresponding alignment/good + # positions. They are then moved to their engaged position specified by `FAV_POS_ACTIVE` + # before the remaining axes are referenced. + AXES_ORDER_REF: ["x", "z", "y"], + FAV_POS_ALIGN: {'x': -15.0e-3, 'z': -10.0e-3}, # m, m + FAV_POS_ACTIVE: {'x': 13.5e-3, 'y': 0.0, 'z': -10.0e-3}, # m, m, m + FAV_POS_DEACTIVE: {'x': -15.0e-3, 'y': 0.0, 'z': 8.0e-3}, # m, m, m + # Flag for sparc2 chamber tab to know that all axes will be referenced together and the + # referencing procedure will be handled by the driver. + CALIB: {"reference_axes_together": True}, + }, +} + +"Spectrograph": { + class: andorshrk.Shamrock, + role: spectrograph, + power_supplier: "Power Control Unit", + init: { + device: "fake", + slits: {1: "slit-in", # INPUT_SIDE + }, + bands: { # The filters in the filter-wheel + 1: "pass-through", # pos -> name or min,max (m) + 2: [475.e-9, 525.e-9], + 3: [525.e-9, 575.e-9], + 4: [575.e-9, 625.e-9], + 5: [625.e-9, 675.e-9], + 6: [675.e-9, 725.e-9], + }, + fstepsize: 30.e-6, # m, size of one focus step + drives_shutter: [0] # flipper positions: the Newton Camera drives the shutter + }, + affects: ["Camera", "Spectrometer"], +} + +"Spectrograph focus": { + class: actuator.MultiplexActuator, + role: "focus", + dependencies: {"z": "Spectrograph"}, + init: { + axes_map: {"z": "focus"}, + }, + affects: ["Camera", "Spectrometer"], +} + +# Provide the mirror (aka port selector) of the spectrograph as a separate component +"Spec CCD Flipper": { + class: actuator.FixedPositionsActuator, + role: "spec-det-selector", + dependencies: {"rx": "Spectrograph"}, + init: { + axis_name: "flip-out", + positions: { + 0: ["Camera", "Spectrometer"], + 1.5707963267948966: [], + }, + }, + affects: ["Camera", "Spectrometer"], +} + +# Provide the filter wheel of the spectrograph as a separate component +"Spec Filter Wheel": { + class: actuator.MultiplexActuator, + role: filter, + dependencies: {"band": "Spectrograph"}, + init: { + axes_map: {"band": "band"}, + }, + affects: ["Camera", "Spectrometer"], +} + +# Control the slit position to either fully-open or small (dependent on the spectrometer slit-in) +"Slit": { + class: actuator.FixedPositionsActuator, + role: "slit-in-big", + dependencies: {"x": "Optical Actuators"}, + init: { + axis_name: "slit", + positions: { + -0.00013: "on", # fully opened + 0.00115: "off", # opening based on the small slit + }, + }, + affects: ["Camera", "Spectrometer"], +} diff --git a/src/odemis/driver/smaract.py b/src/odemis/driver/smaract.py index 967abb2d55..d5a9f47f65 100644 --- a/src/odemis/driver/smaract.py +++ b/src/odemis/driver/smaract.py @@ -33,7 +33,7 @@ import re from ctypes import * -from typing import Optional, Dict +from typing import Optional, Dict, Set from odemis import model from odemis import util from odemis.model import CancellableFuture, CancellableThreadPoolExecutor, isasync, VigilantAttribute, roattribute @@ -49,6 +49,9 @@ logging.info("Smaract Python SDK modules for Picoscale driver not found with error: {}".format(err)) smaract_python_sdk = False +REFPROC_STD = "standard" +REFPROC_MULTI_PHASE = "multi-phase" + def add_coord(pos1, pos2): """ @@ -2912,7 +2915,7 @@ def __str__(self): class MCS2(model.Actuator): - def __init__(self, name, role, locator, ref_on_init=False, axes=None, speed=1e-3, accel=1e-3, + def __init__(self, name, role, locator, ref_on_init=False, refproc=REFPROC_STD, axes=None, speed=1e-3, accel=1e-3, hold_time=float('inf'), pos_deactive_after_ref=False, param_file=None, **kwargs): """ A driver for a SmarAct MCS2 Actuator. @@ -2931,6 +2934,15 @@ def __init__(self, name, role, locator, ref_on_init=False, axes=None, speed=1e-3 network:sn: ref_on_init: (bool) determines if the controller should automatically reference on initialization + refproc (str): referencing (aka homing) procedure name. For now only "multi-phase" or "standard" is accepted. + Default is "standard". "standard" procedure calls the device referencing procedure. + "multi-phase" executes a metadata-driven referencing procedure intended for mechanically constrained systems + (e.g. L-slot geometries). The order of referencing is defined by the 'AXES_ORDER_REF' metadata. Axes listed + in 'FAV_POS_ALIGN' are referenced first and moved to their corresponding alignment/good positions. They are + then moved to their engaged position specified by 'FAV_POS_ACTIVE' before the remaining axes are referenced. + If 'pos_deactive_after_ref' is True, all referenced axes are finally moved to the parking positions defined + by 'FAV_POS_DEACTIVE'. Failed references are retried automatically until all axes are referenced or the + retry limit is reached. hold_time (float): the hold time, in seconds, for the actuator after the target position is reached. Default is float('inf') or infinite. Can be also set to 0 to disable hold. Is set to the same value for all channels. @@ -3053,6 +3065,15 @@ def __init__(self, name, role, locator, ref_on_init=False, axes=None, speed=1e-3 # VA dict str(axis) -> bool self.referenced = model.VigilantAttribute(axes_ref, readonly=True) + refproc = refproc.lower() + if refproc == REFPROC_MULTI_PHASE: + self.reference = self._reference_multi_phase + logging.debug("Using multi-phase referencing procedure") + elif refproc == REFPROC_STD: + logging.debug("Using standard referencing procedure") + else: + raise ValueError(f"Reference procedure {refproc} unknown") + # If ref_on_init, referenced immediately. if all(referenced for _, referenced in axes_ref.items()): logging.debug("SA_CTL is referenced") @@ -3633,6 +3654,24 @@ def reference(self, axes): f = self._executor.submitf(f, self._doReference, f, axes) return f + @isasync + def _reference_multi_phase(self, _=None) -> model.CancellableFuture: + """ + Asynchronous entry point to execute the multi-phase referencing sequence. + + This method automatically collects all mapped axes, verifies their states, + and submits the '_do_reference_multi_phase' operation to the background executor. + No arguments are required, as axis handling is derived entirely from the + device metadata properties ('AXES_ORDER_REF', etc.). + + :return: A CancellableFuture object tracking the state of the background operation. + """ + axes = set(self.axes.keys()) + + f = self._createMoveFuture() + f = self._executor.submitf(f, self._do_reference_multi_phase, f, axes) + return f + def _doReference(self, future, axes): """ Actually runs the referencing code @@ -3676,8 +3715,6 @@ def _doReference(self, future, axes): self._checkMoveAbs(deactive_pos) self._doMoveAbs(future, self._applyInversion(deactive_pos)) - self._waitEndMove(future, moving_channels, time.time() + 100) - except CancelledError: # FIXME: if the referencing is stopped, the device refuses to # move until referencing is run (and successful). @@ -3697,6 +3734,179 @@ def _doReference(self, future, axes): # read-only so manually notify self.referenced.notify(self.referenced.value) + def _reference_with_retry(self, future: model.CancellableFuture, axes_to_ref: list, + post_ref_positions: Optional[dict] = None, reference_lbl: str = ""): + """ + Executes a sequential, retry-based referencing routine for a subset of axes. + + This mechanism is designed to resolve kinematic deadlocks in confined + mechanical setups (such as an L-shaped slot). + + By retrying in a loop, axes that fail to reference initially are skipped until + other axes succeed. Moving the successful axes out of the way (via + 'post_ref_positions') clears the physical path for the previously blocked + axes to succeed on subsequent attempts. + + :param future: The future object used to monitor cancellation requests. + :param axes_to_ref: A list of axis string identifiers to reference, in preferred order. + :param post_ref_positions: Optional mapping of 'axis -> position'. If provided, an + axis will immediately move to this absolute position + upon successful referencing to clear physical space. + :param reference_lbl: A string label (e.g., "Phase 1") used for log message prefixing. + + :raises HwError: If the retry loop exhausts and one or more axes remain unreferenced. + :raises CancelledError: If a stop/cancel flag is set on the future during execution. + """ + unreferenced = set(axes_to_ref) + max_retries = max(len(axes_to_ref) * 2, 1) + + for attempt in range(max_retries): + if not unreferenced: + break + for a in axes_to_ref: + if a not in unreferenced: + continue + if future._must_stop.is_set(): + raise CancelledError() + + channel = self._axis_map[a] + self.referenced._value[a] = False + logging.info("Referencing %s axis %s (Attempt %d)", reference_lbl, a, attempt + 1) + self.Reference(channel) + + try: + self._waitEndMove(future, {channel}, time.time() + 100) + except CancelledError: + raise + except Exception as e: + logging.debug("Axis %s failed to reference: %s", a, e) + + is_referenced = self._is_channel_referenced(channel) + self.referenced._value[a] = is_referenced + + if is_referenced: + self._updatePosition() + if post_ref_positions and a in post_ref_positions: + target = {a: post_ref_positions[a]} + logging.info("Moving axis %s to position %s", a, target) + self._checkMoveAbs(target) + if future._must_stop.is_set(): + raise CancelledError() + self._doMoveAbs(future, self._applyInversion(target)) + unreferenced.remove(a) + logging.info("Axis %s successfully referenced.", a) + else: + logging.warning("Axis %s failed to reference. Will retry.", a) + + if unreferenced: + logging.error("%s axes %s not referenced after retries", reference_lbl, unreferenced) + raise model.HwError(f"Hardware referencing failed for axes: {unreferenced}") + + def _do_reference_multi_phase(self, future: model.CancellableFuture, axes: Set[str]): + """ + A multi-phase referencing sequence for complex geometries (e.g., L-slot mechanics) + to prevent hardware collisions. + + The sequence is strictly controlled by device metadata and executes as follows: + + - Phase 1 (Corner Alignment): Identifies axes designated in 'FAV_POS_ALIGN'. + References them safely using a retry loop, parking them in the "corner" of + the L-slot to maximize physical clearance. + + - Phase 2 (Engaged Referencing): Sequentially moves the already-referenced + Phase 1 axes to their active working positions ('FAV_POS_ACTIVE'), + then references any remaining axes. + + - Phase 3 (Deactivation): If configured, sequentially retracts all axes + to their safe parking positions ('FAV_POS_DEACTIVE'). + + Note: All multi-axis movements in this function are explicitly executed + sequentially (one-by-one). Concurrent movements are avoided to prevent + diagonal motion that could cause mechanical collisions in confined spaces. + + :param future: The future object used to monitor cancellation requests. + :param axes: A set of axis string identifiers to be referenced. 'AXES_ORDER_REF' + metadata should contain the same axes and define the order of procedure. + + :raises ValueError: If required metadata ('AXES_ORDER_REF', 'FAV_POS_ACTIVE') is missing. + :raises HwError: If the hardware fails to reference after all retries are exhausted. + :raises CancelledError: If the operation is interrupted by the user. + """ + with future._moving_lock: + try: + # Gather configuration and logical groups + ordered_axes = self._metadata.get(model.MD_AXES_ORDER_REF, None) + if ordered_axes is None: + raise ValueError("Missing AXES_ORDER_REF metadata for referencing.") + if set(ordered_axes) != axes: + raise ValueError("AXES_ORDER_REF metadata does not match the provided " + "axes. Order: %s, Axes: %s" % (ordered_axes, axes)) + + align_pos = self._metadata.get(model.MD_FAV_POS_ALIGN, {}) + active_pos = self._metadata.get(model.MD_FAV_POS_ACTIVE, None) + + if active_pos is None: + raise ValueError("Missing FAV_POS_ACTIVE metadata for referencing.") + + # Split axes into Phase 1 (L-slot axes needing alignment/good position) and + # Phase 2 (post-alignment axes) + phase1_axes = [a for a in ordered_axes if a in align_pos] + phase2_axes = [a for a in ordered_axes if a not in align_pos] + + # PHASE 1: Reference L-slot axes and move to alignment/good positions + self._reference_with_retry(future, phase1_axes, post_ref_positions=align_pos, reference_lbl="Phase 1") + + # PHASE 2: Move to Active/Engaged position and reference remaining + if phase2_axes: + # Move already-referenced axes to their active (engaged) position to clear the way + pre_phase2_target = {} + for a in self.axes: + # Find axes not in phase 2, perfectly referenced, and possessing an active position + if a not in phase2_axes and self.referenced._value[a] and a in active_pos: + pre_phase2_target[a] = active_pos[a] + + if pre_phase2_target: + logging.info("Moving referenced axes to engaged position %s sequentially before Phase 2", pre_phase2_target) + # Move sequentially (one by one) to prevent diagonal movement from crashing in the L-slot + for a in ordered_axes: + if a in pre_phase2_target: + single_target = {a: pre_phase2_target[a]} + self._checkMoveAbs(single_target) + if future._must_stop.is_set(): + raise CancelledError() + self._doMoveAbs(future, self._applyInversion(single_target)) + + self._reference_with_retry(future, phase2_axes, reference_lbl="Phase 2") + + # PHASE 3: Move to Safe/Deactive position (if requested) + all_axes_referenced = all(self.referenced._value[a] for a in self.axes) + + if self._pos_deactive_after_ref and all_axes_referenced: + try: + deactive_pos = self._metadata[model.MD_FAV_POS_DEACTIVE] + except KeyError: + logging.warning("Cannot move to deactive position. Missing FAV_POS_DEACTIVE") + else: + logging.info("Moving axes sequentially to deactivated position %s after referencing", deactive_pos) + # Ensure we step through the sequence one at a time. + for a in ordered_axes: + if a in deactive_pos: + single_target = {a: deactive_pos[a]} + self._checkMoveAbs(single_target) + if future._must_stop.is_set(): + raise CancelledError() + self._doMoveAbs(future, self._applyInversion(single_target)) + except CancelledError: + logging.warning("Referencing cancelled, device will not move until another referencing") + future._was_stopped = True + raise + except Exception as ex: + self.state._set_value(ex, force_write=True) + logging.exception("Referencing failure") + raise + finally: + self._updatePosition() + self.referenced.notify(self.referenced.value) class FakeMCS2_DLL(object): """ diff --git a/src/odemis/driver/test/smaract_test.py b/src/odemis/driver/test/smaract_test.py index 083678268f..8ba6079fab 100644 --- a/src/odemis/driver/test/smaract_test.py +++ b/src/odemis/driver/test/smaract_test.py @@ -20,7 +20,6 @@ Odemis. If not, see http://www.gnu.org/licenses/. ''' import logging -import math import tempfile from odemis.driver import smaract @@ -816,5 +815,133 @@ def test_reference_cancel(self): self.assertFalse(i) +CONFIG_MULTI_PHASE = { + "name": "MultiPhase Stage", + "role": "stage", + "ref_on_init": False, + "locator": "fake", + "refproc": smaract.REFPROC_MULTI_PHASE, + "speed": 0.002, + "accel": 0.002, + "hold_time": 1.0, + "pos_deactive_after_ref": False, + "param_file": None, + "axes": { + "x": {"range": [-3e-3, 3e-3], "unit": "m", "channel": 0}, + "z": {"range": [-3e-3, 3e-3], "unit": "m", "channel": 2}, + "y": {"range": [-3e-3, 3e-3], "unit": "m", "channel": 1}, + }, +} + +MULTI_PHASE_METADATA = { + model.MD_AXES_ORDER_REF: ["x", "z", "y"], + model.MD_FAV_POS_ALIGN: {"x": -2e-3, "z": -2e-3}, + model.MD_FAV_POS_ACTIVE: {"x": 1e-3, "y": 0.0, "z": -1e-3}, + model.MD_FAV_POS_DEACTIVE: {"x": -2e-3, "y": 0.0, "z": 2e-3}, +} + +class TestMCS2MultiPhase(unittest.TestCase): + """ + Tests for the MCS2 multi-phase referencing procedure (_doReferenceMultiPhase). + Uses the simulator only. + """ + + def setUp(self): + self.dev = smaract.MCS2(**CONFIG_MULTI_PHASE) + # Pass a copy to avoid mutating the global constant if tests modify it + self.dev.updateMetadata(MULTI_PHASE_METADATA.copy()) + + def tearDown(self): + """Cleans up the device after each test.""" + self.dev.terminate() + + def test_reference_all_axes(self): + """All axes are referenced and end up in the active/zero positions.""" + f = self.dev.reference() + f.result(timeout=60) + + # 1. Assert all axes are marked as referenced + for a, referenced in self.dev.referenced.value.items(): + self.assertTrue(referenced, f"Axis {a} not referenced") + + # 2. Assert final position. Because pos_deactive_after_ref=False, + # X and Z should be at FAV_POS_ACTIVE. Y should be at 0.0 (auto-zeroed). + expected_pos = { + "x": MULTI_PHASE_METADATA[model.MD_FAV_POS_ACTIVE]["x"], + "z": MULTI_PHASE_METADATA[model.MD_FAV_POS_ACTIVE]["z"], + "y": 0.0 + } + + testing.assert_pos_almost_equal( + self.dev.position.value, expected_pos, + atol=1e-6, match_all=False + ) + + def test_reference_missing_axes_order_metadata(self): + """Missing AXES_ORDER_REF metadata must raise ValueError.""" + self.dev._metadata.pop(model.MD_AXES_ORDER_REF) + + f = self.dev.reference() + with self.assertRaises(ValueError) as context: + f.result(timeout=10) + + self.assertIn("Missing AXES_ORDER_REF", str(context.exception)) + + def test_reference_axis_not_in_order(self): + """If AXES_ORDER_REF does not exactly match the requested axes, it must raise ValueError.""" + # Modify the metadata to cause a mismatch ('y' is missing) + self.dev._metadata[model.MD_AXES_ORDER_REF] = ["x", "z"] + + f = self.dev.reference() + + with self.assertRaises(ValueError) as context: + f.result(timeout=10) + + self.assertIn( + "AXES_ORDER_REF metadata does not match the provided axes.", + str(context.exception) + ) + + def test_reference_missing_active_pos_metadata(self): + """Missing FAV_POS_ACTIVE metadata (needed for phase 2) must raise ValueError.""" + self.dev._metadata.pop(model.MD_FAV_POS_ACTIVE) + + f = self.dev.reference() + with self.assertRaises(ValueError) as context: + f.result(timeout=10) + + self.assertIn("Missing FAV_POS_ACTIVE", str(context.exception)) + + def test_reference_with_deactive_position(self): + """With pos_deactive_after_ref=True, stage moves sequentially to FAV_POS_DEACTIVE.""" + self.dev._pos_deactive_after_ref = True + + f = self.dev.reference() + f.result(timeout=60) + + # 1. Assert all axes are referenced + for a, referenced in self.dev.referenced.value.items(): + self.assertTrue(referenced, f"Axis {a} not referenced") + + # 2. Assert final position is the deactive parking position + deactive_pos = MULTI_PHASE_METADATA[model.MD_FAV_POS_DEACTIVE] + testing.assert_pos_almost_equal( + self.dev.position.value, deactive_pos, + atol=1e-6, match_all=False + ) + + def test_reference_missing_deactive_pos_metadata(self): + """If pos_deactive_after_ref=True but FAV_POS_DEACTIVE is missing, it should log a warning but NOT crash.""" + self.dev._pos_deactive_after_ref = True + self.dev._metadata.pop(model.MD_FAV_POS_DEACTIVE) + + # It should succeed without throwing an exception + f = self.dev.reference() + f.result(timeout=60) + + for a, referenced in self.dev.referenced.value.items(): + self.assertTrue(referenced) + + if __name__ == '__main__': unittest.main()