Skip to content

Commit 1d430b9

Browse files
committed
[feat] merged goffset + goffset_alignment
1 parent 2f65bf7 commit 1d430b9

2 files changed

Lines changed: 264 additions & 40 deletions

File tree

src/odemis/acq/align/goffset.py

Lines changed: 219 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,16 @@
22
import threading
33
import time
44
from collections.abc import Iterable
5-
from concurrent.futures import TimeoutError, CancelledError
5+
from concurrent.futures import CancelledError
66
from 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

99
import numpy
1010

1111
from odemis import model
12-
from odemis.acq.align import light
12+
from odemis.acq.align.autofocus import _mapDetectorToSelector
1313
from 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
1815
from scipy.optimize import curve_fit
1916

2017
def 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

266271
def _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

Comments
 (0)