From 2b1dfed679c01ac5390af43eeecb88dadc0edbbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89ric=20Piel?= Date: Thu, 23 Jan 2025 16:04:23 +0100 Subject: [PATCH 1/4] [clean-up] driver semcomedi: remove newPixel event support No code ever used this functionality. The semnidaq has something a little similar, but it's a hardware event. So it actually could even brinkg confusion. Now that we'll add another Event, remove this code. --- src/odemis/driver/semcomedi.py | 103 +---------------------- src/odemis/driver/test/semcomedi_test.py | 43 ---------- 2 files changed, 1 insertion(+), 145 deletions(-) diff --git a/src/odemis/driver/semcomedi.py b/src/odemis/driver/semcomedi.py index 38660d645b..19f0d97f0d 100644 --- a/src/odemis/driver/semcomedi.py +++ b/src/odemis/driver/semcomedi.py @@ -293,9 +293,6 @@ def __init__(self, name, role, children, device, daemon=None, **kwargs): raise ValueError("SEMComedi device '%s' was not given a 'scanner' child" % device) self._scanner = Scanner(parent=self, daemon=daemon, **ckwargs) self.children.value.add(self._scanner) - # for scanner.newPixel - self._new_position_thread = None - self._new_position_thread_pipe = [] # list to communicate with the current thread self._acquisition_thread = None @@ -987,10 +984,7 @@ def write_read_2d_data_raw(self, wchannels, wranges, rchannels, rranges, # We write at the given period, and read "osr" samples for each pixel nrchans = len(rchannels) - if dpr > 1 or (self._scanner.newPixel.hasListeners() and period >= 1e-3): - # if the newPixel event is used, prefer the per pixel write/read - # as it's much more precise (albeit a bit slower). It just needs to - # not be too costly (1 ms should be higher than the setup cost). + if dpr > 1: force_per_pixel = True else: force_per_pixel = False @@ -1033,11 +1027,6 @@ def _write_read_2d_lines(self, wchannels, wranges, rchannels, rranges, Implementation of write_read_2d_data_raw by reading the input data n lines at a time. """ - if self._scanner.newPixel.hasListeners() and margin > 0: - # we don't support margin detection on multiple lines for - # newPixel trigger. - maxlines = 1 - logging.debug(u"Reading %d lines at a time: %d samples/read every %g µs", maxlines, maxlines * data.shape[1] * osr * len(rchannels), period * 1e6) @@ -1243,19 +1232,9 @@ def _fake_write_read_raw_one_cmd(self, wchannels, wranges, rchannels, rranges, stop_arg=nrscans) start = time.time() - np_to_report = nwscans - settling_samples - shift_report = settling_samples - if settling_samples == 0: # indicate a new ebeam position - self._scanner.newPixel.notify() - np_to_report -= 1 - shift_report += 1 - # run the commands self._reader.run() self._writer.run() - self._start_new_position_notifier(np_to_report, - start + shift_report * period, - period) timeout = expected_time * 1.10 + 0.1 # s == expected time + 10% + 0.1s logging.debug("Waiting %g s for the acquisition to finish", timeout) @@ -1357,22 +1336,10 @@ def _write_read_raw_one_cmd(self, wchannels, wranges, rchannels, rranges, comedi.internal_trigger(self._device, self._ao_subdevice, self._ao_trig) comedi.internal_trigger(self._device, self._ai_subdevice, 0) - start = time.time() - - np_to_report = nwscans - settling_samples - shift_report = settling_samples - if settling_samples == 0: - # no margin => indicate a new ebeam position right now - self._scanner.newPixel.notify() - np_to_report -= 1 - shift_report += 1 self._reader.run() if nwscans != 1: self._writer.run() - self._start_new_position_notifier(np_to_report, - start + shift_report * period, - period) timeout = expected_time * 1.10 + 0.1 # s == expected time + 10% + 0.1s logging.debug("Waiting %g s for the acquisition to finish", timeout) @@ -1554,68 +1521,6 @@ def _write_count_raw_one_cmd(self, wchannels, wranges, counter, logging.debug("Counter sync read after %g s", time.time() - start) return rbuf - def _start_new_position_notifier(self, n, start, period): - """ - Notify the newPixel Event n times with the given period. - n (0 <= int): number of event notifications - start (float): time for the first event (should be in the future) - period (float): period between two events - Note: this is used to emulate an actual ebeam change of position when - the hardware is requested to move the ebeam at multiple positions in a - row. Do not expect a precision better than 10us. - Note 2: this method returns immediately (and the emulation is run in a - separate thread). - """ - # no need if no one's listening - if not self._scanner.newPixel.hasListeners(): - return - - if n <= 0: - return - - if period < 10e-6: - # don't even try: that's the time it'd take to have just one loop - # doing nothing - logging.error(u"Cannot generate newPixel events at such a " - u"small period of %s µs", period * 1e6) - return - - self._new_position_thread_pipe = [] - self._new_position_thread = threading.Thread( - target=self._notify_new_position, - args=(n, start, period, self._new_position_thread_pipe), - name="SEM new position notifier") - - self._new_position_thread.start() - - def _notify_new_position(self, n, start, period, pipe): - """ - The thread content - """ - trigger = 0 - failures = 0 - for i in range(n): - now = time.time() - trigger += period # accumulation error should be small - left = start - now + trigger - if left > 0: - if left > 10e-6: # TODO: if left < 1 ms => use usleep or nsleep - time.sleep(left) - else: - failures += 1 - if pipe: # put anything in the pipe and it will mean it has to stop - logging.debug("npnotifier received cancel message") - return - self._scanner.newPixel.notify() - - if failures: - logging.warning(u"Failed to trigger newPixel in time %d times, " - u"last trigger was %g µs late.", failures, -left * 1e6) - - def _cancel_new_position_notifier(self): - logging.debug("cancelling npnotifier") - self._new_position_thread_pipe.append(True) # means it has to stop - def start_acquire(self, detector): """ Start acquiring images on the given detector (i.e., input channel). @@ -1766,7 +1671,6 @@ def _req_stop_acquisition(self): # So it's protected with the init of read/write and set_to_resting_position with self._acquisition_init_lock: self._acquisition_must_stop.set() - self._cancel_new_position_notifier() # Cancelling parts which are not running is a no-op self._writer.cancel() self._reader.cancel() @@ -2837,11 +2741,6 @@ def __init__(self, name, role, parent, channels, limits, settle_time, self.dwellTime = model.FloatContinuous(min_dt, range_dwell, unit="s", setter=self._setDwellTime) - # event to allow another component to synchronize on the beginning of - # a pixel position. Only sent during an actual pixel of a scan, not for - # the beam settling time or when put to rest. - self.newPixel = model.Event() - self._prev_settings = [None, None, None, None] # resolution, scale, translation, margin self._scan_array = None # last scan array computed diff --git a/src/odemis/driver/test/semcomedi_test.py b/src/odemis/driver/test/semcomedi_test.py index bd8aa8912f..2c390b1312 100644 --- a/src/odemis/driver/test/semcomedi_test.py +++ b/src/odemis/driver/test/semcomedi_test.py @@ -531,49 +531,6 @@ def test_sync_flow(self): self.assertEqual(self.left, 0) -# @unittest.skip("simple") - def test_new_position_event(self): - """ - check the new position works at least when the frequency is not too high - """ - self.scanner.dwellTime.value = 1e-3 - self.size = (10, 10) - self.scanner.resolution.value = self.size - numbert = numpy.prod(self.size) - # pixel write/read setup is pretty expensive ~10ms - expected_duration = self.compute_expected_duration() + numbert * 0.01 - - self.left = 1 # unsubscribe just after one - self.events = 0 # reset - - # simulate the synchronizedOn() method of a DataFlow - self.scanner.newPixel.subscribe(self) - - self.sed.data.subscribe(self.receive_image) - for i in range(10): - # * 2 because it can be quite long to setup each pixel. - time.sleep(expected_duration * 2 / 10) - if self.left == 0: - break # just to make it quicker if it's quicker - - self.assertEqual(self.left, 0) - - # Note: there could be slightly more events if the next acquisition starts, - # and that's kind of ok (although it's better to be able to stop the - # acquisition immediately after receiving the right number of images) - self.assertEqual(self.events, numbert) - - self.scanner.newPixel.unsubscribe(self) - self.sed.data.get() - time.sleep(0.1) - self.assertEqual(self.events, numbert) - - def onEvent(self): - """ - Called by the SEM when a new position happens - """ - self.events += 1 - def receive_image(self, dataflow, image): """ callback for df of test_acquire_flow() From 64f46d2e3771ac28db21becd69c6733e2b541fcf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89ric=20Piel?= Date: Thu, 23 Jan 2025 17:38:29 +0100 Subject: [PATCH 2/4] [feat] driver: add startScan Event to e-beam scanners Introduce a new (and almost first) software Event on the e-beam scanners: startScan. It is emitted at the very beginning of the first frame of a scan. If a scan acquires multiple frames, startScan is emitted just once. This will be used for better software synchronization of SPARC acquisitions. --- src/odemis/driver/semcomedi.py | 26 +++++++++++++++++++ src/odemis/driver/semnidaq.py | 22 ++++++++++++++++ src/odemis/driver/simsem.py | 7 ++++++ src/odemis/driver/test/semcomedi_test.py | 28 +++++++++++++++++++++ src/odemis/driver/test/semnidaq_test.py | 32 ++++++++++++++++++++++++ src/odemis/driver/test/simsem_test.py | 27 ++++++++++++++++++++ 6 files changed, 142 insertions(+) diff --git a/src/odemis/driver/semcomedi.py b/src/odemis/driver/semcomedi.py index 19f0d97f0d..2eb5321858 100644 --- a/src/odemis/driver/semcomedi.py +++ b/src/odemis/driver/semcomedi.py @@ -1232,6 +1232,8 @@ def _fake_write_read_raw_one_cmd(self, wchannels, wranges, rchannels, rranges, stop_arg=nrscans) start = time.time() + self._scanner.startScan.notify() # Special event that will only actually notify on the first call + # run the commands self._reader.run() self._writer.run() @@ -1336,6 +1338,7 @@ def _write_read_raw_one_cmd(self, wchannels, wranges, rchannels, rranges, comedi.internal_trigger(self._device, self._ao_subdevice, self._ao_trig) comedi.internal_trigger(self._device, self._ai_subdevice, 0) + self._scanner.startScan.notify() # Special event that will only actually notify on the first call self._reader.run() if nwscans != 1: @@ -1649,6 +1652,8 @@ def _acquisition_run(self): self.set_to_resting_position() # wait until something new comes in self._check_cmd_q(block=True) + + self._scanner.startScan.clear() # Get ready for the next scan except CancelledError: logging.info("Acquisition threading terminated on request") except Exception: @@ -1660,6 +1665,7 @@ def _acquisition_run(self): except comedi.ComediError: # can happen if the driver already terminated pass + self._scanner.startScan.clear() # Get ready for the next scan logging.info("Acquisition thread closed") self._acquisition_thread = None @@ -2515,6 +2521,23 @@ def cancel(self): self._must_stop.set() +class EventOnce(model.Event): + """ + Special Event class which passes the event only once to the listener, until it's reset. + """ + def __init__(self): + super().__init__() + self._notified = False + + def notify(self): + if not self._notified: + self._notified = True + super().notify() + + def clear(self): + self._notified = False + + class Scanner(model.Emitter): """ Represents the e-beam scanner @@ -2665,6 +2688,9 @@ def __init__(self, name, role, parent, channels, limits, settle_time, t.start() self.indicate_scan_state(False) + # Event which is triggered at the beginning of the first frame of a scan + self.startScan = EventOnce() + # In theory the maximum resolution depends on the X/Y ranges, the actual # ranges that can be used and the maxdata. It also depends on the noise # on the scanning cable, and the scanning precision of the SEM. diff --git a/src/odemis/driver/semnidaq.py b/src/odemis/driver/semnidaq.py index b759b3338b..7732eb976a 100644 --- a/src/odemis/driver/semnidaq.py +++ b/src/odemis/driver/semnidaq.py @@ -772,6 +772,7 @@ def _acquire(self): try: self._scanner.active_ttl_mng.indicate_state(True) self._scanner.active_ttl_mng.wait_active() # Blocks until the scan state is set + self._scanner.startScan.clear() # Indicate the next notification should be sent (first frame) while True: # Any more messages to process? Some could have arrived in the meantime @@ -1405,6 +1406,7 @@ def _acquire_frames(self, # Now start! ai_task.start() + self._scanner.startScan.notify() # Special event that will only actually notify on the first frame logging.debug("AI task started (with AO + DO too)") n_analog_det = len(acq_settings.analog_detectors) @@ -2251,6 +2253,23 @@ def _set_state(self, active): time.sleep(self._activation_delay) +class EventOnce(model.Event): + """ + Special Event class which passes the event only once to the listener, until it's reset. + """ + def __init__(self): + super().__init__() + self._notified = False + + def notify(self): + if not self._notified: + self._notified = True + super().notify() + + def clear(self): + self._notified = False + + class Scanner(model.Emitter): """ Represents the e-beam scanner @@ -2372,6 +2391,9 @@ def __init__(self, name: str, role: str, parent: AnalogSEM, self.active_ttl_mng = ActiveTTLManager(parent, self, scanning_ttl or {}, scan_active_delay, self._on_active_state_change) + # Event which is triggered at the beginning of the first frame of a scan + self.startScan = EventOnce() + # Validate fast TTLs fast_do_channels = set() # set of ints, to check all channels are unique if image_ttl is None: diff --git a/src/odemis/driver/simsem.py b/src/odemis/driver/simsem.py index 5efac8f9d2..59b78e2db1 100644 --- a/src/odemis/driver/simsem.py +++ b/src/odemis/driver/simsem.py @@ -132,6 +132,9 @@ def __init__(self, name, role, parent, aperture=100e-6, wd=10e-3, **kwargs): self._aperture = aperture self._working_distance = wd + # Event which is triggered at the beginning of the first frame of a scan + self.startScan = model.Event() + fake_img = self.parent.fake_img if parent._drift_period: # half the size, to keep some margin for the drift @@ -530,6 +533,7 @@ def _acquire_thread(self, callback): the Dataflow. """ try: + first_frame = True while not self._acquisition_must_stop.is_set(): dwelltime = self.parent._scanner.dwellTime.value resolution = self.parent._scanner.resolution.value @@ -540,6 +544,9 @@ def _acquire_thread(self, callback): # as in Odemis the convention for SEM is that the ebeam waits # for _all_ the detectors to be ready before scanning. self.data._waitSync() + if first_frame: + self.parent._scanner.startScan.notify() + first_frame = False callback(self._simulate_image()) except Exception: logging.exception("Unexpected failure during image acquisition") diff --git a/src/odemis/driver/test/semcomedi_test.py b/src/odemis/driver/test/semcomedi_test.py index 2c390b1312..14cd88ebf9 100644 --- a/src/odemis/driver/test/semcomedi_test.py +++ b/src/odemis/driver/test/semcomedi_test.py @@ -162,6 +162,19 @@ def test_generate_scan(self): comp = diffx >= 0 # must be decreasing self.assertTrue(comp.all()) + +class EventReceiver: + """ + Helper class to receive model.Events + """ + def __init__(self): + self.count = 0 + + def onEvent(self): + logging.debug("Received an event") + self.count += 1 + + #@unittest.skip("simple") class TestSEM(unittest.TestCase): """ @@ -425,6 +438,10 @@ def test_acquire_long_short(self): def test_acquire_flow(self): expected_duration = self.compute_expected_duration() + # Also check that the startScan event is properly sent just once after the first acquisition + evt_counter = EventReceiver() + self.scanner.startScan.subscribe(evt_counter) + number = 5 self.left = number self.sed.data.subscribe(self.receive_image) @@ -433,6 +450,9 @@ def test_acquire_flow(self): self.assertEqual(self.left, 0) + self.assertEqual(evt_counter.count, 1) + self.scanner.startScan.unsubscribe(evt_counter) + # @unittest.skip("simple") def test_acquire_with_va(self): """ @@ -493,16 +513,24 @@ def test_df_alternate_sub_unsub(self): number = 5 expected_duration = self.compute_expected_duration() + # Also check that the startScan event is properly sent just once after the first acquisition + evt_counter = EventReceiver() + self.scanner.startScan.subscribe(evt_counter) + self.left = 10000 + number # don't unsubscribe automatically for i in range(number): self.sed.data.subscribe(self.receive_image) time.sleep(expected_duration * 1.2) # make sure we received at least one image self.sed.data.unsubscribe(self.receive_image) + time.sleep(0.01) # if it has acquired a least 5 pictures we are already happy self.assertLessEqual(self.left, 10000) + self.assertEqual(evt_counter.count, number) + self.scanner.startScan.unsubscribe(evt_counter) + def test_sync_flow(self): """ Acquire a dataflow with a softwareTrigger diff --git a/src/odemis/driver/test/semnidaq_test.py b/src/odemis/driver/test/semnidaq_test.py index f3fa7639f7..aca1ad6087 100644 --- a/src/odemis/driver/test/semnidaq_test.py +++ b/src/odemis/driver/test/semnidaq_test.py @@ -131,6 +131,18 @@ } +class EventReceiver: + """ + Helper class to receive model.Events + """ + def __init__(self): + self.count = 0 + + def onEvent(self): + logging.debug("Received an event") + self.count += 1 + + class TestAnalogSEM(unittest.TestCase): @classmethod @@ -735,11 +747,16 @@ def test_acquisition_fast(self): # Default dwell time is the shortest dwell time => that's what we want self.scanner.scale.value = 64, 64 self.scanner.resolution.value = 10, 8 + + evt_counter = EventReceiver() + self.scanner.startScan.subscribe(evt_counter) + exp_shape, exp_pxs, _ = self.compute_expected_metadata() da = self.sed.data.get() self.assertEqual(da.shape, exp_shape) self.assertIn(model.MD_DWELL_TIME, da.metadata) self.assertAlmostEqual(da.metadata[model.MD_PIXEL_SIZE], exp_pxs) + self.assertEqual(evt_counter.count, 1) # Very tiny (single sample) self.scanner.scale.value = 64, 64 @@ -750,6 +767,8 @@ def test_acquisition_fast(self): self.assertEqual(da.shape, exp_shape) self.assertIn(model.MD_DWELL_TIME, da.metadata) self.assertAlmostEqual(da.metadata[model.MD_PIXEL_SIZE], exp_pxs) + self.assertEqual(evt_counter.count, 2) + self.scanner.startScan.unsubscribe(evt_counter) def test_acquisition_big_res(self): """ @@ -800,6 +819,9 @@ def test_acquisition_sparc_slow(self): self.scanner.translation.value = (0, 0) exp_shape, exp_pxs, exp_duration = self.compute_expected_metadata() + evt_counter = EventReceiver() + self.scanner.startScan.subscribe(evt_counter) + spots_dates = [] for i in range(50): # On the SPARC, the dwell time doesn't actually change for every spot, but let's make it @@ -819,7 +841,9 @@ def test_acquisition_sparc_slow(self): spots_dates.append(self.acq_dates[-1]) time.sleep(5e-3) # simulate data processing + self.scanner.startScan.unsubscribe(evt_counter) self.assertEqual(len(spots_dates), 50) + self.assertEqual(evt_counter.count, 50) def test_acquisition_long_dt(self): """ @@ -845,6 +869,10 @@ def test_flow(self): self.scanner.dwellTime.value = 1e-6 # s exp_shape, exp_pxs, exp_duration = self.compute_expected_metadata() + # Also check that the startScan event is properly sent just once after the first acquisition + evt_counter = EventReceiver() + self.scanner.startScan.subscribe(evt_counter) + # Acquire several points/frame in a row, to make sure it can continuously acquire number = 7 self.expected_shape = exp_shape @@ -860,6 +888,10 @@ def test_flow(self): duration = stop_t - start_t self.assertGreaterEqual(duration, exp_tot_dur, f"Acquisition ended too early: {duration}s") + self.assertEqual(evt_counter.count, 1) + self.scanner.startScan.unsubscribe(evt_counter) + + def test_flow_change_settings(self): """ Check continuous acquisition while the settings change diff --git a/src/odemis/driver/test/simsem_test.py b/src/odemis/driver/test/simsem_test.py index 1f21f3e73e..ae04f83851 100644 --- a/src/odemis/driver/test/simsem_test.py +++ b/src/odemis/driver/test/simsem_test.py @@ -95,6 +95,19 @@ def test_pickle(self): sem.terminate() daemon.shutdown() + +class EventReceiver: + """ + Helper class to receive model.Events + """ + def __init__(self): + self.count = 0 + + def onEvent(self): + logging.debug("Received an event") + self.count += 1 + + class TestSEM(unittest.TestCase): """ Tests which can share one SEM device @@ -364,6 +377,10 @@ def test_acquire_long_short(self): def test_acquire_flow(self): expected_duration = self.compute_expected_duration() + # Also check that the startScan event is properly sent just once after the first acquisition + evt_counter = EventReceiver() + self.scanner.startScan.subscribe(evt_counter) + number = 5 self.left = number self.sed.data.subscribe(self.receive_image) @@ -372,6 +389,9 @@ def test_acquire_flow(self): self.assertEqual(self.left, 0) + self.assertEqual(evt_counter.count, 1) + self.scanner.startScan.unsubscribe(evt_counter) + def test_acquire_with_va(self): """ Change some settings before and while acquiring @@ -429,6 +449,10 @@ def test_df_alternate_sub_unsub(self): number = 5 expected_duration = self.compute_expected_duration() + # Also check that the startScan event is properly sent just once after the first acquisition + evt_counter = EventReceiver() + self.scanner.startScan.subscribe(evt_counter) + self.left = 10000 + number # don't unsubscribe automatically for i in range(number): @@ -439,6 +463,9 @@ def test_df_alternate_sub_unsub(self): # if it has acquired a least 5 pictures we are already happy self.assertLessEqual(self.left, 10000) + self.assertEqual(evt_counter.count, number) + self.scanner.startScan.unsubscribe(evt_counter) + def receive_image(self, dataflow, image): """ callback for df of test_acquire_flow() From 5822427cc407f3787703a944b39c12c1564859ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89ric=20Piel?= Date: Thu, 21 Nov 2024 14:29:28 +0100 Subject: [PATCH 3/4] [doc] stream sync improve comments and logs --- src/odemis/acq/stream/_sync.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/odemis/acq/stream/_sync.py b/src/odemis/acq/stream/_sync.py index e062b7e9d2..1a439bdea1 100644 --- a/src/odemis/acq/stream/_sync.py +++ b/src/odemis/acq/stream/_sync.py @@ -175,7 +175,7 @@ def __init__(self, name, streams): self._current_scan_area = None # l,t,r,b (int) # Start threading event for live update overlay - self._live_update_period = 2 + self._live_update_period = 2 # s self._im_needs_recompute = threading.Event() self._init_thread(self._live_update_period) @@ -2513,6 +2513,7 @@ def _runAcquisition(self, future): self._df0.synchronizedOn(self._trigger) for s, sub in zip(self._streams, self._subscribers): s._dataflow.subscribe(sub) + start = time.time() self._acq_min_date = start self._trigger.notify() @@ -2529,8 +2530,8 @@ def _runAcquisition(self, future): for i, s in enumerate(self._streams): timeout = max(0.1, max_end_t - time.time()) if not self._acq_complete[i].wait(timeout): - raise TimeoutError("Acquisition of repetition stream for frame %s timed out after %g s" - % (self._emitter.translation.value, time.time() - max_end_t)) + raise TimeoutError("Acquisition of repetition stream at pos %s timed out after %g s" + % (self._emitter.translation.value, time.time() - start)) if self._acq_state == CANCELLED: raise CancelledError() s._dataflow.unsubscribe(self._subscribers[i]) From 0312f390ef9c9e4e5f81cc26f2f9109f51f0e3cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89ric=20Piel?= Date: Thu, 23 Jan 2025 18:03:37 +0100 Subject: [PATCH 4/4] [feat] acq SEMCCDMDStream use startScan Event to synchronize CCD with e-beam From the momement that the acquisition ask the e-beam scanner to start and the e-beam is actually at the right position, it takes *some* time. It's in the order of a few ms. So far, the only e-beam scanner was semcomedi, and it was (surprisingly) constant in terms of time. It would take between 2 and 5 ms. So it was good enough to just always wait 5ms. We now also have semnidaq, and the NI-DAQ library introduces a lot longer latency. While it can be as "fast" as 10ms, it sometimes takes 40ms to start a scan. There are probably ways to make it a little faster, but it's unlikely it will be possible to always keep all scanners < 5ms. => Use a new Event, startScan, for the e-beam scanner to report the e-beam is ready. Then, we can use it to directly start a CCD acquisition. --- src/odemis/acq/stream/_sync.py | 58 +++++++++++++++++----------------- 1 file changed, 29 insertions(+), 29 deletions(-) diff --git a/src/odemis/acq/stream/_sync.py b/src/odemis/acq/stream/_sync.py index 1a439bdea1..bb58c36a37 100644 --- a/src/odemis/acq/stream/_sync.py +++ b/src/odemis/acq/stream/_sync.py @@ -1083,7 +1083,7 @@ def __init__(self, name, streams): self._sccd = s1 self._ccd = s1._detector self._ccd_df = s1._dataflow - self._trigger = self._ccd.softwareTrigger + self._trigger = self._emitter.startScan # to acquire a CCD image every time the SEM starts a new scan self._ccd_idx = len(self._streams) - 1 # optical detector is always last in streams def _supports_hw_sync(self): @@ -1653,11 +1653,6 @@ def _runAcquisitionEbeam(self, future): # retrigger, or unsynchronise/resynchronise just before the end of # last scan). - # prepare detector - self._ccd_df.synchronizedOn(self._trigger) - # subscribe to last entry in _subscribers (optical detector) - self._ccd_df.subscribe(self._subscribers[self._ccd_idx]) - # Instead of subscribing/unsubscribing to the SEM for each pixel, # we've tried to keep subscribed, but request to be unsynchronised/ # synchronised. However, synchronizing doesn't cancel the current @@ -1699,6 +1694,12 @@ def _runAcquisitionEbeam(self, future): self._emitter.resolution.value, self._emitter.scale.value) + # Prepare CCD: acquire one frame every time the SEM starts scanning. + # The SEM may scan multiple times for each CCD frame. + self._ccd_df.synchronizedOn(self._trigger) + # Get the CCD ready to acquire + self._ccd_df.subscribe(self._subscribers[self._ccd_idx]) + last_ccd_update = 0 start_t = time.time() n = 0 # number of images acquired so far @@ -1887,26 +1888,10 @@ def _acquireImage(self, n, px_idx, img_time, sem_time, raise CancelledError() # subscribe to _subscribers + # As soon as the e-beam start scanning (which can take a couple of ms), the + # startScan event is sent, which triggers the acquisition of one CCD frame. for s, sub in zip(self._streams[:-1], self._subscribers[:-1]): s._dataflow.subscribe(sub) - # TODO: in theory (aka in a perfect world), the ebeam would immediately - # be at the requested position after the subscription starts. However, - # that's not exactly the case due to: - # * physics limits the speed of voltage change in the ebeam column, - # so it takes the "settle time" before the beam is at the right - # place (in the order of 10 µs). - # * the (odemis) driver is asynchronous, and between the moment it - # receives the request to start and the actual moment it asks the - # hardware to change voltages, several ms might have passed. - # One thing that would help is to not park the e-beam between each - # spot. This way, the ebeam would reach the position much quicker, - # and if it's not yet at the right place, it's still not that far. - # In the meantime, waiting a tiny bit ensures the CCD receives the - # right data. - time.sleep(5e-3) # give more chances spot has been already processed - - # send event to detector to acquire one image - self._trigger.notify() # wait for detector to acquire image timedout = self._waitForImage(img_time) @@ -1971,6 +1956,11 @@ def _acquireImage(self, n, px_idx, img_time, sem_time, leech_nimg[li] -= 1 if leech_nimg[li] == 0: try: + # Temporarily switch the CCD to a different event trigger, so that it + # doesn't get triggered while the leech is running (because it could use the + # e-beam, which would send a startScan event) + self._ccd_df.synchronizedOn(self._ccd.softwareTrigger) + nimg = l.next([d[-1] for d in self._acq_data]) logging.debug("Ran leech %s successfully. Will run next leech after %s acquisitions.", l, nimg) except Exception: @@ -1980,6 +1970,9 @@ def _acquireImage(self, n, px_idx, img_time, sem_time, if self._acq_state == CANCELLED: raise CancelledError() + # re-use the real trigger + self._ccd_df.synchronizedOn(self._trigger) + # Since we reached this point means everything went fine, so # no need to retry break @@ -2008,9 +2001,9 @@ def _runAcquisitionScanStage(self, future): # The idea of the acquiring with a scan stage: # (Note we expect the scan stage to be about at the center of its range) # * Move the ebeam to 0, 0 (center), for the best image quality - # * Start CCD acquisition with software synchronisation + # * Start CCD acquisition with synchronisation on e-beam startScan # * Move to next position with the stage and wait for it - # * Start SED acquisition and trigger CCD + # * Start SED acquisition -> startScan event triggers CCD # * Wait for the CCD/SED data # * Repeat until all the points have been scanned # * Move back the stage to center in case of an 'independent' stage @@ -2119,12 +2112,11 @@ def _runAcquisitionScanStage(self, future): if self._acq_state == CANCELLED: raise CancelledError() + # Start e-beam scan. As soon as it really start, a startScan event is sent, which + # triggers the CCD acquisition. for s, sub in zip(self._streams[:-1], self._subscribers[:-1]): s._dataflow.subscribe(sub) - time.sleep(5e-3) # give more chances spot has been already processed - self._trigger.notify() - # wait for detector to acquire image timedout = self._waitForImage(px_time) @@ -2210,6 +2202,11 @@ def _runAcquisitionScanStage(self, future): sstage.moveAbsSync(orig_spos) prev_spos.update(orig_spos) try: + # Temporarily switch the CCD to a different event trigger, so that it + # doesn't get triggered while the leech is running (because it could use the + # e-beam, which would send a startScan event) + self._ccd_df.synchronizedOn(self._ccd.softwareTrigger) + np = l.next([d[-1] for d in self._acq_data]) except Exception: logging.exception("Leech %s failed, will retry next pixel", l) @@ -2218,6 +2215,9 @@ def _runAcquisitionScanStage(self, future): if self._acq_state == CANCELLED: raise CancelledError() + # re-use the real trigger + self._ccd_df.synchronizedOn(self._trigger) + for i, das in enumerate(self._acq_data): self._assembleLiveData(i, das[-1], px_idx, cor_pos, rep, 0)