22import threading
33import time
44from collections .abc import Iterable
5- from concurrent .futures import TimeoutError , CancelledError
5+ from concurrent .futures import CancelledError
66from concurrent .futures ._base import CANCELLED , FINISHED , RUNNING
7- from typing import Any , Dict , List , Optional , Tuple , Union , Callable
7+ from typing import Any , Dict , List , Optional , Tuple , Union
88
99import numpy
1010
1111from odemis import model
12- from odemis .acq .align import light
12+ from odemis .acq .align . autofocus import _mapDetectorToSelector
1313from odemis .model import InstantaneousFuture
14- from odemis .util import executeAsyncTask , almost_equal
15- from odemis .util .driver import guessActuatorMoveDuration
16- from odemis .util .focus import MeasureSEMFocus , Measure1d , MeasureSpotsFocus , AssessFocus
17- from odemis .util .img import Subtract
14+ from odemis .util import executeAsyncTask
1815from scipy .optimize import curve_fit
1916
2017def gaussian (x , amplitude , x0 , width ):
@@ -53,7 +50,6 @@ def find_peak_position(data: numpy.ndarray, window_radius: int = 15) -> float:
5350 else :
5451 spectrum = data
5552
56- x = numpy .arange (len (spectrum ))
5753 peak_idx = numpy .argmax (spectrum ) # find the absolute highest point
5854
5955 # create a window around the peak
@@ -93,7 +89,7 @@ def find_peak_position(data: numpy.ndarray, window_radius: int = 15) -> float:
9389 return weighted_avg
9490
9591
96- def estimate_goffset_scale (spgr : model .Actuator , detector : model .Detector , delta = 5.0 ) -> float :
92+ def estimate_goffset_scale (spgr : model .Actuator , detector : model .Detector , delta = 5.0 , retries = 1 ) -> Tuple [ float , float , float ] :
9793 """
9894 Estimate the scale factor between a change in the grating offset ('goffset')
9995 and the resulting shift of the spectral peak on the detector.
@@ -109,12 +105,14 @@ def estimate_goffset_scale(spgr: model.Actuator, detector: model.Detector, delta
109105 :param detector: detector
110106 :param delta: The relative goffset step size to apply when measuring the scale (default: 5.0).
111107 The actual step may be negated to avoid exceeding hardware limits.
108+ :param retries: number of retries allowed if the estimated scale is unreliable (default: 1).
112109
113110 :return: Tuple (scale, p0, p1)
114111 scale: estimated pixels per unit of goffset
115112 p0: peak position at the initial goffset
116113 p1: peak position after applying the test delta
117114 """
115+
118116 # get initial state
119117 data0 = detector .data .get (asap = False )
120118 p0 = find_peak_position (data0 )
@@ -139,21 +137,28 @@ def estimate_goffset_scale(spgr: model.Actuator, detector: model.Detector, delta
139137 scale = (p1 - p0 ) / actual_delta
140138
141139 logging .info (
142- f"SCALE TRACKING | p0: { p0 :.1f} | p1: { p1 :.1f} | Delta: { actual_delta } | Shift: { (p1 - p0 ):.1f} | Result Scale: { scale :.4f} " )
143-
140+ "SCALE TRACKING | p0: %.1f | p1: %.1f | Delta: %.1f | Shift: %.1f | Result Scale: %.4f" ,
141+ p0 , p1 , actual_delta , (p1 - p0 ), scale ,
142+ )
144143
145144 # If the estimated scale is extremely small, the measurement is likely unreliable
146145 # (e.g., due to noise or a flat spectrum).
147146 # In that case we retry the estimation once by calling the function recursively.
148147 # If the retry still fails (raising a RuntimeError), we fall back to a default
149148 # scale value to ensure the algorithm can continue and avoid infinite recursion.
150149
151- if abs (scale ) < 1e-3 or abs (scale )> 10.0 :
152- try :
153- scale , p0 , p1 = estimate_goffset_scale (spgr , detector )
154- except RuntimeError :
155- logging .warning ("Scale too small, using default 0.5" )
156- scale = 0.5
150+ if abs (scale ) < 1e-3 or abs (scale ) > 10.0 :
151+ logging .warning (
152+ "Unreliable scale estimate (%.4f). Retries left: %d" ,
153+ scale ,
154+ retries
155+ )
156+
157+ if retries > 0 :
158+ return estimate_goffset_scale (spgr , detector , delta , retries - 1 )
159+
160+ logging .warning ("Scale estimation failed after retries, using default 0.5" )
161+ scale = 0.5
157162
158163 return scale , p0 , p1
159164
@@ -221,9 +226,7 @@ def _do_sparc_auto_grating_offset(future: model.ProgressiveFuture,
221226 max_step = 0.1 * (maxv - minv ) # max 10% of range
222227
223228 for i in range (max_it ):
224- with future ._task_lock :
225- if future ._task_state == CANCELLED :
226- raise CancelledError ()
229+ _checkCancelled ()
227230
228231 if i == 0 :
229232 peak_px = p0
@@ -247,8 +250,10 @@ def _do_sparc_auto_grating_offset(future: model.ProgressiveFuture,
247250 delta_goffset = max (- max_step , min (max_step , delta_goffset ))
248251 total_goffset_displacement += delta_goffset
249252
250- logging .debug (f"DEBUG | Iter: { i } | Peak: { peak_px :.1f} | Error: { error_px :.1f} | Move: { delta_goffset :.4f} | Total Change: { total_goffset_displacement :.4f} " )
251- spgr .moveRelSync ({"goffset" : delta_goffset })
253+ logging .debug (
254+ "DEBUG | Iter: %d | Peak: %.1f | Error: %.1f | Move: %.4f | Total Change: %.4f" ,
255+ i , peak_px , error_px , delta_goffset , total_goffset_displacement
256+ ) spgr .moveRelSync ({"goffset" : delta_goffset })
252257
253258 future .set_progress (
254259 end = time .time () + (max_it - i - 1 ) * 0.5 ) # update estimated end time
@@ -260,7 +265,7 @@ def _do_sparc_auto_grating_offset(future: model.ProgressiveFuture,
260265 logging .debug ("SparcAutoGratingOffset cancelled" )
261266 raise
262267 except Exception as e :
263- logging .error (f "Alignment error: { e } " )
268+ logging .error ("Alignment error: %s" , e )
264269 raise
265270
266271def _cancel_sparc_auto_grating_offset (future : model .ProgressiveFuture ):
@@ -269,4 +274,194 @@ def _cancel_sparc_auto_grating_offset(future: model.ProgressiveFuture):
269274 Canceller of _do_sparc_auto_grating_offset task.
270275 """
271276 with future ._task_lock :
272- future ._task_state = CANCELLED
277+ future ._task_state = CANCELLED
278+
279+
280+ def _checkCancelled (future : "model.ProgressiveFuture" ):
281+
282+ """
283+ Check if the future has been cancelled, and if so raise CancelledError.
284+ """
285+
286+ with future ._task_lock :
287+ if future ._task_state == CANCELLED :
288+ raise CancelledError ()
289+
290+ def _total_alignment_time (n_gratings : int ,
291+ n_detectors : int ) -> float :
292+
293+ """
294+ Estimate total time for aligning all grating-detector combinations.
295+
296+ :param n_gratings: number of gratings to align
297+ :param n_detectors: number of detectors to align
298+ :return: estimated total time in seconds
299+ """
300+
301+ runs = n_detectors + max (0 , n_gratings - 1 )
302+ move_time = ((n_gratings - 1 ) * MOVE_TIME_GRATING + (n_detectors - 1 ) * MOVE_TIME_DETECTOR )
303+
304+ # total time = time spent running alignment algorithms + time spent moving hardware
305+ return runs * EST_ALIGN_TIME + move_time
306+
307+
308+ def auto_align_grating_detector_offsets (spectrograph : model .Actuator ,
309+ detectors : Union [model .Detector , List [model .Detector ]],
310+ selector : Optional [model .Actuator ] = None ,
311+ streams : Optional [List ['Stream' ]] = None ) -> model .ProgressiveFuture :
312+
313+ """
314+ Automatically align grating-detector offsets for all combinations of gratings and detectors.
315+ - If a selector is provided, it will be used to switch between detectors for the first grating, then the first detector
316+ will be used for all subsequent gratings.
317+ - For multiple detectors, the grating alignment will only be adjusted for the first detector; subsequent detectors will
318+ be aligned by adjusting the detector offset with the grating alignment fixed.
319+
320+ :param spectrograph: spectrograph
321+ :param detectors: list of detectors
322+ :param selector: optional selector to switch between detectors
323+ :param streams: optional list of streams to update with progress
324+ :return: ProgressiveFuture that will resolve to a dict mapping (grating, detector)
325+ :raises ValueError: if no detectors provided, or if multiple detectors provided without a selector
326+ :raises CancelledError: if the operation is cancelled
327+ """
328+
329+ if not isinstance (detectors , Iterable ):
330+ detectors = [detectors ]
331+ if not detectors :
332+ raise ValueError ("At least one detector must be provided" )
333+ if len (detectors ) > 1 and selector is None :
334+ raise ValueError ("No selector provided, but multiple detectors" )
335+
336+ if streams is None :
337+ streams = []
338+
339+ est_start = time .time () + 0.1
340+ n_gratings = len (spectrograph .axes ["grating" ].choices )
341+ n_detectors = len (detectors )
342+ a_time = _total_alignment_time (n_gratings , n_detectors )
343+ f = model .ProgressiveFuture (start = est_start , end = est_start + a_time )
344+ f .task_canceller = _cancel_auto_align_grating_detector_offsets
345+
346+ f ._task_lock = threading .Lock ()
347+ f ._task_state = RUNNING
348+ f ._subfuture = InstantaneousFuture ()
349+ executeAsyncTask (f , _do_auto_align_grating_detector_offsets , args = (f , spectrograph , detectors , selector , streams ))
350+ return f
351+
352+ MOVE_TIME_GRATING = 20 #s
353+ MOVE_TIME_DETECTOR = 5 #s
354+ EST_ALIGN_TIME = 30 #s
355+
356+ def _do_auto_align_grating_detector_offsets (future : model .ProgressiveFuture ,
357+ spectrograph : model .Actuator ,
358+ detectors : List [model .Detector ],
359+ selector : Optional [model .Actuator ],
360+ streams : List ['Stream' ],
361+ stabilization_time : float = 15.0 ) -> Optional [Dict [Any , Any ]]:
362+
363+ """
364+ Iterate through each grating and detector combination, adjusting the selector if provided, and run the auto-alignment algorithm.
365+ - If a selector is provided, it will be used to switch between detectors for the first grating, then the first detector
366+ will be used for all subsequent gratings.
367+ - For multiple detectors, the grating alignment will only be adjusted for the first detector; subsequent detectors will
368+ be aligned by adjusting the detector offset with the grating alignment fixed.
369+
370+ :param future: ProgressiveFuture to update with progress and results
371+ :param spectrograph: spectrograph
372+ :param detectors: list of detectors
373+ :param selector: optional selector to switch between detectors
374+ :param streams: optional list of streams to update with progress
375+ :param stabilization_time: time to wait after moving hardware before starting alignment (default: 15s)
376+
377+ :return: dict mapping (grating, detector) to alignment success boolean
378+ :raises CancelledError: if the operation is cancelled
379+ """
380+
381+ results : dict [tuple , bool ] = {}
382+ original_pos = {k : v for k , v in spectrograph .position .value .items ()
383+ if k in ("wavelength" , "grating" )}
384+
385+ gratings = sorted (list (spectrograph .axes ["grating" ].choices .keys ()))
386+ logging .info (f"Available gratings: { list (spectrograph .axes ['grating' ].choices .keys ())} " )
387+
388+ first_detector = detectors [0 ]
389+
390+ if selector :
391+ original_selector = selector .position .value
392+ selector_axes , detector_to_selector = _mapDetectorToSelector (selector , detectors )
393+
394+ def is_current_detector (d ):
395+ if selector is None :
396+ return True
397+ return detector_to_selector [d ] == selector .position .value [selector_axes ]
398+
399+ try :
400+ g0 = gratings [0 ]
401+ logging .info ("Starting alignment for initial grating: %s" , g0 )
402+
403+ spectrograph .moveAbsSync ({"grating" : g0 , "wavelength" : 0 })
404+ time .sleep (stabilization_time )
405+
406+ detectors_sorted = sorted (detectors , key = is_current_detector , reverse = True )
407+
408+ # align each detector for the first grating
409+ for d in detectors_sorted :
410+ _checkCancelled (future )
411+ logging .info ("Starting alignment | Detector: %s | Grating: %s" , d .name , g0 )
412+
413+ if selector :
414+ selector .moveAbsSync ({selector_axes : detector_to_selector [d ]})
415+ future ._subfuture = sparc_auto_grating_offset (spectrograph , d )
416+ success = future ._subfuture .result ()
417+ results [(g0 , d .name )] = success
418+
419+ logging .info ("Finished alignment | Detector: %s | Grating: %s | Success: %s" , d .name , g0 )
420+
421+ if selector :
422+ selector .moveAbsSync ({selector_axes : detector_to_selector [first_detector ]})
423+
424+ # align remaining gratings using the first detector
425+ for g in gratings [1 :]:
426+ _checkCancelled (future )
427+ logging .info ("Switching to grating: %s" , g )
428+
429+ spectrograph .moveAbsSync ({"grating" : g , "wavelength" : 0 })
430+ time .sleep (stabilization_time )
431+ logging .info ("Starting alignment | Detector: %s | Grating: %s" , first_detector .name , g )
432+
433+ future ._subfuture = sparc_auto_grating_offset (spectrograph , first_detector )
434+ success = future ._subfuture .result ()
435+ results [(g , first_detector .name )] = success
436+
437+ logging .info ("Finished alignment | Detector: %s | Grating: %s" , first_detector .name , g )
438+
439+ return results
440+
441+ except CancelledError :
442+ logging .info ("Auto-alignment cancelled" )
443+ raise
444+
445+ finally :
446+ spectrograph .moveAbsSync (original_pos )
447+ if selector :
448+ selector .moveAbsSync (original_selector )
449+
450+ with future ._task_lock :
451+ future ._task_state = FINISHED
452+
453+ def _cancel_auto_align_grating_detector_offsets (future : model .ProgressiveFuture ) -> bool :
454+
455+ """
456+ Canceller for _do_auto_align_grating_detector_offsets task.
457+ """
458+ logging .debug ("Cancelling autoalignment..." )
459+
460+ with future ._task_lock :
461+ if future ._task_state == FINISHED :
462+ return False
463+ future ._task_state = CANCELLED
464+ future ._subfuture .cancel ()
465+ logging .debug ("Auto-alignment cancellation requested" )
466+
467+ return True
0 commit comments