From 601253cc0d1e115f4467b69ac478d905ed724cec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89ric=20Piel?= Date: Fri, 31 Oct 2025 14:35:08 +0100 Subject: [PATCH 1/6] [feat] SPARC GUI: support for "Time correlator" stream with LabCube after spectrograph --- plugins/blanker_spectrum.py | 2 +- plugins/la_spec.py | 2 +- plugins/spectrum_arbscor.py | 2 +- plugins/spectrum_raw.py | 2 +- src/odemis/gui/conf/data.py | 22 ++++++++++++++ src/odemis/gui/cont/stream_bar.py | 48 ++++++++++++++++++++++--------- 6 files changed, 60 insertions(+), 18 deletions(-) diff --git a/plugins/blanker_spectrum.py b/plugins/blanker_spectrum.py index 6e6a2c4530..e81f6a4d9a 100644 --- a/plugins/blanker_spectrum.py +++ b/plugins/blanker_spectrum.py @@ -228,7 +228,7 @@ def addSpectrum(self, name, detector): main_data = self.main_app.main_data stctrl = self._tab.streambar_controller - spg = stctrl._getAffectingSpectrograph(detector) + spg = stctrl._getAffectingSpectrograph(detector, default=main_data.spectrograph) axes = {"wavelength": ("wavelength", spg), "grating": ("grating", spg), diff --git a/plugins/la_spec.py b/plugins/la_spec.py index 1a9e1d5fe8..2a033b69f9 100644 --- a/plugins/la_spec.py +++ b/plugins/la_spec.py @@ -276,7 +276,7 @@ def addSpectrum(self, name, detector): main_data = self.main_app.main_data stctrl = self._tab.streambar_controller - spg = stctrl._getAffectingSpectrograph(detector) + spg = stctrl._getAffectingSpectrograph(detector, default=main_data.spectrograph) axes = {"wavelength": ("wavelength", spg), "grating": ("grating", spg), diff --git a/plugins/spectrum_arbscor.py b/plugins/spectrum_arbscor.py index 90c6202262..cbd9971567 100644 --- a/plugins/spectrum_arbscor.py +++ b/plugins/spectrum_arbscor.py @@ -245,7 +245,7 @@ def add_stream(self, name: str, detector: "Detector"): logging.debug("Adding spectrum arbitrary order stream for %s", detector.name) - spectrograph = stctrl._getAffectingSpectrograph(detector) + spectrograph = stctrl._getAffectingSpectrograph(detector, default=main_data.spectrograph) axes = {"wavelength": ("wavelength", spectrograph), "grating": ("grating", spectrograph), diff --git a/plugins/spectrum_raw.py b/plugins/spectrum_raw.py index b998550b54..78a9e81123 100644 --- a/plugins/spectrum_raw.py +++ b/plugins/spectrum_raw.py @@ -384,7 +384,7 @@ def addst(self): # Removes exposureTime from local (GUI) VAs to use a new one, which allows to integrate images detvas.remove("exposureTime") - spectrograph = stctrl._getAffectingSpectrograph(main_data.ccd) + spectrograph = stctrl._getAffectingSpectrograph(main_data.ccd, default=main_data.spectrograph) spectrometer = stctrl._find_spectrometer(main_data.ccd) axes = {"wavelength": ("wavelength", spectrograph), diff --git a/src/odemis/gui/conf/data.py b/src/odemis/gui/conf/data.py index 41a911f183..ecbaefa7c9 100644 --- a/src/odemis/gui/conf/data.py +++ b/src/odemis/gui/conf/data.py @@ -1161,10 +1161,32 @@ )), stream.ScannedTemporalSettingsStream: OrderedDict(( + # From spectrograph, if the time-correlator is coupled after the spectrograph + ("wavelength", { + "tooltip": "Center wavelength of the spectrograph", + "control_type": odemis.gui.CONTROL_FLT, + "range": (0.0, 1900e-9), + "key_step_min": 1e-9, + }), + ("grating", {}), + ("slit-in", { + "label": "Input slit", + "tooltip": "Opening size of the spectrograph input slit.\nA wide opening is usually fine.", + }), + ("filter-in", { # filter.band axis + "label": "Input filter", + "tooltip": "Filter before the spectrograph", + "choices": util.format_band_choices, + }), + ("slit-monochromator", { + "label": "Output slit", + "tooltip": "Opening size of the spectrograph detector slit.\nThe wider, the larger the wavelength bandwidth.", + }), ("density", { # from tc-od-filter "tooltip": "Optical density", }), ("filter", { # from tc-filter + "label": "LAB Cube filter", "choices": util.format_band_choices, }), )), diff --git a/src/odemis/gui/cont/stream_bar.py b/src/odemis/gui/cont/stream_bar.py index ee2a66b37a..7ce1ba7264 100644 --- a/src/odemis/gui/cont/stream_bar.py +++ b/src/odemis/gui/cont/stream_bar.py @@ -1531,22 +1531,26 @@ def _on_streams(self, streams): self._tab_data_model.acquisitionStreams.discard(acqs) break - def _getAffectingSpectrograph(self, comp): + def _getAffectingSpectrograph(self, comp: model.HwComponent, + default: Optional[model.Actuator] = None, + ) -> Optional[model.Actuator]: """ Find which spectrograph matters for the given component (ex, spectrometer) - comp (Component): the hardware which is affected by a spectrograph - return (None or Component): the spectrograph affecting the component + :param comp: the hardware which is affected by a spectrograph + :param default: component to return if none found + :return: the spectrograph affecting the component (or default, if none is found affecting the component) """ cname = comp.name main_data = self._main_data_model for spg in (main_data.spectrograph, main_data.spectrograph_ded): if spg is not None and cname in spg.affects.value: return spg - else: - logging.warning("No spectrograph found affecting component %s", cname) - # spg should be None, but in case it's an error in the microscope file - # and actually, there is a spectrograph, then use that one - return main_data.spectrograph + + # No spectrograph found + if default is not None: + logging.warning("No spectrograph found affecting component %s, assuming it's %s", + cname, default.name) + return default def _find_spectrometer(self, detector): """ @@ -1806,7 +1810,7 @@ def addSpectrum(self, name=None, detector=None): detector = main_data.spectrometer logging.debug("Adding spectrum stream for %s", detector.name) - spg = self._getAffectingSpectrograph(detector) + spg = self._getAffectingSpectrograph(detector, default=main_data.spectrograph) axes = {"wavelength": ("wavelength", spg), "grating": ("grating", spg), @@ -1862,7 +1866,7 @@ def addAngularSpectrum(self): # Removes exposureTime from local (GUI) VAs to use a new one, which allows to integrate images detvas.remove("exposureTime") - spectrograph = self._getAffectingSpectrograph(main_data.ccd) + spectrograph = self._getAffectingSpectrograph(main_data.ccd, default=main_data.spectrograph) spectrometer = self._find_spectrometer(main_data.ccd) axes = {"wavelength": ("wavelength", spectrograph), @@ -1907,7 +1911,7 @@ def addTemporalSpectrum(self): # remove exposureTime from local (GUI) VAs to use a new one, which allows to integrate images detvas.remove("exposureTime") - spg = self._getAffectingSpectrograph(main_data.streak_ccd) + spg = self._getAffectingSpectrograph(main_data.streak_ccd, default=main_data.spectrograph) axes = {"wavelength": ("wavelength", spg), "grating": ("grating", spg), @@ -1951,7 +1955,7 @@ def addMonochromator(self): """ Create a Monochromator stream and add to to all compatible viewports """ main_data = self._main_data_model - spg = self._getAffectingSpectrograph(main_data.spectrometer) + spg = self._getAffectingSpectrograph(main_data.monochromator, default=main_data.spectrograph) axes = {"wavelength": ("wavelength", spg), "grating": ("grating", spg), @@ -1960,8 +1964,6 @@ def addMonochromator(self): "slit-monochromator": ("slit-monochromator", spg), } - axes = self._filter_axes(axes) - # Also add light filter if it affects the detector for fw in (main_data.cl_filter, main_data.light_filter): if fw is None: @@ -1970,6 +1972,8 @@ def addMonochromator(self): axes["filter"] = ("band", fw) break + axes = self._filter_axes(axes) + monoch_stream = acqstream.MonochromatorSettingsStream( "Monochromator", main_data.monochromator, @@ -1997,9 +2001,25 @@ def addTimeCorrelator(self): main_data = self._main_data_model + # Axes on the "LabCube", which are always affecting the time-correlator axes = {"density": ("density", main_data.tc_od_filter), "filter": ("band", main_data.tc_filter)} + spg = self._getAffectingSpectrograph(main_data.time_correlator) + if spg: + axes.update({ + "wavelength": ("wavelength", spg), + "grating": ("grating", spg), + "iris-in": ("iris-in", spg), + "slit-in": ("slit-in", spg), + "slit-monochromator": ("slit-monochromator", spg), + }) + + # Also add the spectrograph filter if it affects the detector + filter_in = main_data.light_filter + if filter_in and main_data.time_correlator.name in filter_in.affects.value: + axes["filter-in"] = ("band", filter_in) + axes = self._filter_axes(axes) tc_stream = acqstream.ScannedTemporalSettingsStream( From f5a055b8cf76ba21f74c341f7515672030e4c310 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89ric=20Piel?= Date: Fri, 31 Oct 2025 18:08:23 +0100 Subject: [PATCH 2/6] [feat] driver tmcm: add a .protection VA to force the LED protection Allow to force the protection from a separate component. This can be useful in case another component can also cause some light in. --- src/odemis/driver/tmcm.py | 104 ++++++++++++++++++++++++++------------ 1 file changed, 71 insertions(+), 33 deletions(-) diff --git a/src/odemis/driver/tmcm.py b/src/odemis/driver/tmcm.py index 852775617a..e2e1821875 100644 --- a/src/odemis/driver/tmcm.py +++ b/src/odemis/driver/tmcm.py @@ -38,6 +38,7 @@ import threading from collections import OrderedDict from concurrent.futures import CancelledError +from typing import Dict try: import canopen @@ -443,6 +444,7 @@ def __init__(self, name, role, port, axes, ustepsize, address=None, # Add digital output axes self._do_axes = do_axes or {} self._led_prot_do = led_prot_do or {} + self._expected_do_pos: Dict[str, float] = {} # DO positions, as requested by the user for channel, (an, hpos, lpos, dur) in self._do_axes.items(): if an in self._name_to_axis or an in self._name_to_do_axis: raise ValueError("Axis %s specified multiple times" % an) @@ -450,6 +452,7 @@ def __init__(self, name, role, port, axes, ustepsize, address=None, raise ValueError("Axis %s duration %s should be in seconds" % (an, dur)) axes_def[an] = model.Axis(choices={lpos, hpos}) self._name_to_do_axis[an] = channel + self._expected_do_pos[an] = lpos # Always set to low at init via call to _releaseRefSwitch() just after for channel, pos in self._led_prot_do.items(): if channel not in self._do_axes: @@ -457,6 +460,12 @@ def __init__(self, name, role, port, axes, ustepsize, address=None, if pos not in self._do_axes[channel][1:3]: raise ValueError("led_prot_do of channel %d has position %s, not in do_axes" % (channel, pos)) + if self._led_prot_do: + # Add a VA to allow forcing the LED protection on + # If it's False: normal operation, the protection is activated during referencing + # If it's True: the protection is always active + self.protection = model.BooleanVA(False, setter=self._set_protection) + model.Actuator.__init__(self, name, role, axes=axes_def, **kwargs) driver_name = driver.getSerialDriver(self._portpattern) @@ -479,7 +488,7 @@ def __init__(self, name, role, port, axes, ustepsize, address=None, logging.warning("Acceleration of axis %s is null, most probably due to a bad hardware configuration", n) # Check state of refswitch on startup - self._expected_do_pos = {} # do positions before referencing, will be reset after refswitch is released + # Use _refswitch_lock to access this attribute self._leds_on = any(self.GetIO(2, rs) for rs in self._refswitch.values()) if self._leds_on: logging.debug("Refswitch is on during initialization, releasing refswitch for all axes.") @@ -1450,30 +1459,65 @@ def _cancelReferencing2xFF(self, axis): gparam = 128 + axis self.SetGlobalParam(2, gparam, 3) # 3 => indicate cancelled + def _switch_led_prot(self, protected: bool) -> None: + """ + Blocks until the move duration is passed, and will update .position based on the actual + state of the DO axes. + Must be called with _refswitch_lock held. + :param protected: If True, force the shutters closed. If False, set them + to the expected position (if _leds_on is False). + """ + tsleep = 0 # max transition period for all shutters + if protected: + logging.debug("Forcing the protection active") + for channel, val in self._led_prot_do.items(): + do_an, hpos, lpos, dur = self._do_axes[channel] + # If the shutter is already in the right position, no need to wait for it to move + if self.position.value[do_an] == val: + logging.debug("Shutter on axis %s already in protected position", do_an) + else: + tsleep = max(tsleep, dur) + # Set the DO to the "protected" position even if the position reports it's already + # there to be really sure. It's very fast anyway. + self.SetIO(2, channel, val == hpos) + else: + # Set the shutter in the "right" position: + # if _leds_on is False, set to the expected position + # if _leds_on is True, leave them closed (ie, do nothing) + if not self._leds_on: + # Set digital axis outputs to latest requested value + for an, val in self._expected_do_pos.items(): + channel = self._name_to_do_axis[an] + if channel not in self._led_prot_do: + continue + _, hpos, lpos, dur = self._do_axes[channel] + if self.position.value[an] == val: + logging.debug("Shutter on axis %s already in protected position", an) + else: + tsleep = max(tsleep, dur) + self.SetIO(2, channel, val == hpos) + + time.sleep(tsleep) + self._updatePosition(axes={}) + + def _set_protection(self, protected: bool) -> bool: + if self.protection.value == protected: + return protected # no change + + with self._refswitch_lock: + self._switch_led_prot(protected) + + return protected + def _requestRefSwitch(self, axis): refswitch = self._refswitch.get(axis) if refswitch is None: return with self._refswitch_lock: - # Set _leds_on attribute before closing shutters to make sure they are not - # opened again in a concurrent thread - leds_were_on = self._leds_on - self._leds_on = True # do this before closing shutters - # Close shutters - tsleep = 0 # max transition period for all shutters - for channel, val in self._led_prot_do.items(): - do_an, hpos, lpos, dur = self._do_axes[channel] - if not leds_were_on: - self._expected_do_pos[do_an] = self.position.value[do_an] - # TODO: ideally, for each DO, we should know when was the last time it - # was set, and if it's been set to the requested value for long - # enough, we don't need to do the extra sleep - self.SetIO(2, channel, val == hpos) - tsleep = max(tsleep, dur) - - time.sleep(tsleep) - self._updatePosition() + self._leds_on = True + # Activate protection (ie, force the shutters closed) + self._switch_led_prot(protected=True) self._active_refswitchs.add(axis) logging.debug("Activating ref switch power line %d (for axis %d)", refswitch, axis) @@ -1510,15 +1554,8 @@ def _releaseRefSwitch(self, axis): logging.debug("Leaving ref switch power line %d active", refswitch) # Set digital axis outputs to latest requested value - if not self._leds_on: - tsleep = 0 # max transition period for all shutters - for an, val in self._expected_do_pos.items(): - channel = self._name_to_do_axis[an] - _, hpos, lpos, dur = self._do_axes[channel] - self.SetIO(2, channel, val == hpos) - tsleep = max(tsleep, dur) - time.sleep(tsleep) - self._updatePosition() + if not hasattr(self, "protection") or not self.protection.value: + self._switch_led_prot(protected=False) def _startReferencingStd(self, axis): """ @@ -1951,15 +1988,16 @@ def _doMoveAbs(self, future, pos): # Check if it's a digital output if an in self._name_to_do_axis: channel = self._name_to_do_axis[an] - _, hpos, lpos, dur = self._do_axes[channel] - with self._refswitch_lock: # don't start do move at the same time as referencing - if self._leds_on and channel in self._led_prot_do: - # don't move protected do axis now if leds are on, schedule for later - self._expected_do_pos[an] = v + with self._refswitch_lock: + self._expected_do_pos[an] = v # Update user-requested position + if channel in self._led_prot_do and (self.protection.value or self._leds_on): + # Don't move a protecting DO axes if leds are on, they will be moved + # once the protection is turned off. if v != self._led_prot_do[channel]: logging.info("Referencing LEDs are on, move on axis %s to %s will be delayed.", an, v) else: # otherwise allow change + _, hpos, lpos, dur = self._do_axes[channel] logging.info("Setting digital output on channel %s to %s." % (channel, v == hpos)) self.SetIO(2, channel, v == hpos) moving_do_axes.add(channel) From 9a246e9f10b7629fd837af7a7019db93b6efd489 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89ric=20Piel?= Date: Fri, 5 Dec 2025 15:29:55 +0100 Subject: [PATCH 3/6] [feat] mdupdater: also support time-correlator after the spectrograph If a (optical fiber coupled to a) time-correlator is connected to the spectrograph output, it should also have the MD_OUT_WL metadata stored, similarly to the monochromator. --- src/odemis/odemisd/mdupdater.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/odemis/odemisd/mdupdater.py b/src/odemis/odemisd/mdupdater.py index 81a47429f1..c2f6e023d3 100644 --- a/src/odemis/odemisd/mdupdater.py +++ b/src/odemis/odemisd/mdupdater.py @@ -392,14 +392,14 @@ def updateOutWavelength(self, comp_affected: model.HwComponent, Its metadata will be updated. :param filter: a (light) filter-(wheel) component (should have a "band" axis) :param spectrograph: a spectrograph component (should have a "wavelength" axis) - Only used if the detector has the role "monochromator". + Only used if the detector has the role "monochromator", "time-correlator" or "photo-detector". """ filter_pos = self.get_filter_pos(filter) # We only need to care about the spectrograph in the case of the monochromator, because for # the other types of components (eg, spectrometer), MD_OUT_WL is used exclusively for the # filter info, and the MD_WL_LIST is used to store the wavelength info (handled separately). - if comp_affected.role == "monochromator": + if any(comp_affected.role.startswith(r) for r in ("monochromator", "time-correlator", "photo-detector")): spec_bandwidth = self.getMonochromatorBandwidth(spectrograph) # None if wavelength == 0 else: spec_bandwidth = None @@ -424,11 +424,12 @@ def updateOutWavelength(self, comp_affected: model.HwComponent, logging.debug("Updating %s with intersection of filter %s and spectrograph %s -> %s", comp_affected.name, filter_bandwidth, spec_bandwidth, bandwidth) + logging.debug("Updating output wavelength for component %s to %s", comp_affected.name, bandwidth) comp_affected.updateMetadata({model.MD_OUT_WL: bandwidth}) def observeSpectrograph(self, spectrograph, comp_affected): - if comp_affected.role == "monochromator": + if any(comp_affected.role.startswith(r) for r in ("monochromator", "time-correlator", "photo-detector")): def updateOutWLRange(pos, sp=spectrograph, comp_affected=comp_affected): self.updateOutWavelength(comp_affected, self._det_to_filter.get(comp_affected.name), From 12498ce4209589b64e99fa572a2b4d985e542b4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89ric=20Piel?= Date: Fri, 5 Dec 2025 15:31:22 +0100 Subject: [PATCH 4/6] [feat] OPM: support time-correlator after the spectrograph If a (optical fiber coupled to a) time-correlator is connected to the spectrograph output, then the position of the spectograph input slit matters. Note: this actuator position will only be changed if it "affects" (directly or indirectly) the time-correlator. So, on standard SPARCs where the time-correlator is coupled before the spectrograph, this change has no effect. --- src/odemis/acq/path.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/odemis/acq/path.py b/src/odemis/acq/path.py index cfb211070d..6e4766225a 100644 --- a/src/odemis/acq/path.py +++ b/src/odemis/acq/path.py @@ -192,6 +192,8 @@ 'pol-analyzer': {'pol': MD_POL_NONE}, 'light-aligner': {'x': "MD:" + model.MD_FAV_POS_ACTIVE, 'z': "MD:" + model.MD_FAV_POS_ACTIVE}, + # Can affect in case the time-correlator is placed as output of the spectrograph + 'slit-in-big': {'x': 'off'}, # closed }), 'mirror-align': (r"ccd.*", # Also used for lens alignment {'lens-switch': {'x': ("MD:" + model.MD_FAV_POS_DEACTIVE, 'off')}, From c441dba73b823aef347615592ca4c8de840e4102 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89ric=20Piel?= Date: Fri, 5 Dec 2025 15:33:39 +0100 Subject: [PATCH 5/6] [refactor] SparcStreamsController: use directly main_data when available Many functions have "main_data = self._main_data_model". For these function, make sure to always use directly "main_data", instead of refering to "self._main_data_model". That mostly makes the code more readable... and should be a tiny bit faster. --- src/odemis/gui/cont/stream_bar.py | 42 +++++++++++++++---------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/src/odemis/gui/cont/stream_bar.py b/src/odemis/gui/cont/stream_bar.py index 7ce1ba7264..c440cecc4e 100644 --- a/src/odemis/gui/cont/stream_bar.py +++ b/src/odemis/gui/cont/stream_bar.py @@ -1675,7 +1675,7 @@ def addAR(self): main_data = self._main_data_model - detvas = get_local_vas(main_data.ccd, self._main_data_model.hw_settings_config) + detvas = get_local_vas(main_data.ccd, main_data.hw_settings_config) if main_data.ccd.exposureTime.range[1] < 3600: # 1h # remove exposureTime from local (GUI) VAs to use a new one, which allows to integrate images @@ -1690,7 +1690,7 @@ def addAR(self): main_data.ebeam, analyzer=main_data.pol_analyzer, sstage=main_data.scan_stage, - opm=self._main_data_model.opm, + opm=main_data.opm, axis_map=axes, # TODO: add a focuser for the SPARCv2? detvas=detvas, @@ -1719,9 +1719,9 @@ def addEBIC(self, **kwargs): main_data.ebic.data, main_data.ebeam, main_data.sed.data, - focuser=self._main_data_model.ebeam_focus, + focuser=main_data.ebeam_focus, emtvas={"dwellTime"}, - detvas=get_local_vas(main_data.ebic, self._main_data_model.hw_settings_config), + detvas=get_local_vas(main_data.ebic, main_data.hw_settings_config), ) else: ebic_stream = acqstream.EBICSettingsStream( @@ -1730,9 +1730,9 @@ def addEBIC(self, **kwargs): main_data.ebic.data, main_data.ebeam, sstage=main_data.scan_stage, - focuser=self._main_data_model.ebeam_focus, + focuser=main_data.ebeam_focus, emtvas={"dwellTime"}, - detvas=get_local_vas(main_data.ebic, self._main_data_model.hw_settings_config), + detvas=get_local_vas(main_data.ebic, main_data.hw_settings_config), ) # Create the equivalent MDStream @@ -1767,11 +1767,11 @@ def addCLIntensity(self): main_data.cld.data, main_data.ebeam, sstage=main_data.scan_stage, - focuser=self._main_data_model.ebeam_focus, - opm=self._main_data_model.opm, + focuser=main_data.ebeam_focus, + opm=main_data.opm, axis_map=axes, emtvas={"dwellTime"}, - detvas=get_local_vas(main_data.cld, self._main_data_model.hw_settings_config), + detvas=get_local_vas(main_data.cld, main_data.hw_settings_config), ) # Special "safety" feature to avoid having a too high gain at start @@ -1835,10 +1835,10 @@ def addSpectrum(self, name=None, detector=None): main_data.ebeam, main_data.light, sstage=main_data.scan_stage, - opm=self._main_data_model.opm, + opm=main_data.opm, axis_map=axes, - # emtvas=get_local_vas(main_data.ebeam, self._main_data_model.hw_settings_config), # no need - detvas=get_local_vas(detector, self._main_data_model.hw_settings_config), + # emtvas=get_local_vas(main_data.ebeam, main_data.hw_settings_config), # no need + detvas=get_local_vas(detector, main_data.hw_settings_config), ) self._set_default_spectrum_axes(spec_stream) @@ -1856,7 +1856,7 @@ def addAngularSpectrum(self): """ main_data = self._main_data_model - detvas = get_local_vas(main_data.ccd, self._main_data_model.hw_settings_config) + detvas = get_local_vas(main_data.ccd, main_data.hw_settings_config) # For ek acquisition we use a horizontal and a vertical binning # which are instantiated in the AngularSpectrumSettingsStream. # Removes binning from local (GUI) VAs to use a vertical and horizontal binning @@ -1886,7 +1886,7 @@ def addAngularSpectrum(self): spectrograph, analyzer=main_data.pol_analyzer, sstage=main_data.scan_stage, - opm=self._main_data_model.opm, + opm=main_data.opm, axis_map=axes, detvas=detvas, ) @@ -1905,7 +1905,7 @@ def addTemporalSpectrum(self): main_data = self._main_data_model - detvas = get_local_vas(main_data.streak_ccd, self._main_data_model.hw_settings_config) + detvas = get_local_vas(main_data.streak_ccd, main_data.hw_settings_config) if main_data.streak_ccd.exposureTime.range[1] < 86400: # 24h # remove exposureTime from local (GUI) VAs to use a new one, which allows to integrate images @@ -1936,10 +1936,10 @@ def addTemporalSpectrum(self): main_data.streak_unit, main_data.streak_delay, sstage=main_data.scan_stage, - opm=self._main_data_model.opm, + opm=main_data.opm, axis_map=axes, detvas=detvas, - streak_unit_vas=get_local_vas(main_data.streak_unit, self._main_data_model.hw_settings_config)) + streak_unit_vas=get_local_vas(main_data.streak_unit, main_data.hw_settings_config)) self._set_default_spectrum_axes(ts_stream) # For safety, always start with the shutter closed. if model.hasVA(ts_stream, "detShutter"): @@ -1980,10 +1980,10 @@ def addMonochromator(self): main_data.monochromator.data, main_data.ebeam, sstage=main_data.scan_stage, - opm=self._main_data_model.opm, + opm=main_data.opm, axis_map=axes, emtvas={"dwellTime"}, - detvas=get_local_vas(main_data.monochromator, self._main_data_model.hw_settings_config), + detvas=get_local_vas(main_data.monochromator, main_data.hw_settings_config), ) self._set_default_spectrum_axes(monoch_stream) @@ -2027,9 +2027,9 @@ def addTimeCorrelator(self): main_data.time_correlator, main_data.time_correlator.data, main_data.ebeam, - opm=self._main_data_model.opm, + opm=main_data.opm, axis_map=axes, - detvas=get_local_vas(main_data.time_correlator, self._main_data_model.hw_settings_config) + detvas=get_local_vas(main_data.time_correlator, main_data.hw_settings_config) ) # Create the equivalent MDStream From 3c5bae0adcd2ab5c901a6c08a66b0b907f5627d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89ric=20Piel?= Date: Fri, 5 Dec 2025 16:33:00 +0100 Subject: [PATCH 6/6] [feat] plugin photo_det_live: new plugin to add a "live" view of the photo-detector count When adjusting the alignment of the fiber when a labcube is coupled after the spectrograph, seeing the detector count over time can be useful. Otherwise, the only view possible is the immediate count (via odemis live). As this alignment is normally done only once per installation, and there is only one installation, it's currently just a plugin. If it needs to be done more frequently, we could add a new alignment mode to the alignment tab. --- plugins/photo_det_live.py | 99 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 plugins/photo_det_live.py diff --git a/plugins/photo_det_live.py b/plugins/photo_det_live.py new file mode 100644 index 0000000000..8393358dd1 --- /dev/null +++ b/plugins/photo_det_live.py @@ -0,0 +1,99 @@ +# -*- coding: utf-8 -*- +""" +Created on 5 Dev 2025 + +@author: Éric Piel + +Gives ability to acquire a spectrum data, while keeping the raw CCD image (ie, without vertical binning) + +Copyright © 2025 Éric Piel, Delmic + +This file is part of Odemis. + +Odemis is free software: you can redistribute it and/or modify it under the terms of the GNU +General Public License version 2 as published by the Free Software Foundation. + +Odemis is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even +the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General +Public License for more details. + +You should have received a copy of the GNU General Public License along with Odemis. If not, +see http://www.gnu.org/licenses/. +""" +import functools +import logging + +from odemis import model +from odemis.acq.stream import MonochromatorSettingsStream +from odemis.gui.conf.data import get_local_vas +from odemis.gui.main import OdemisGUIApp +from odemis.gui.plugin import Plugin + + +class PhotoDetectorLivePlugin(Plugin): + name = "Photo-detector live display" + __version__ = "1.0" + __author__ = "Éric Piel" + __license__ = "GPLv2" + + def __init__(self, microscope: model.Microscope, main_app: OdemisGUIApp): + super().__init__(microscope, main_app) + main_data = self.main_app.main_data + if not (microscope and main_data.photo_ds and main_data.role.startswith("sparc")): + logging.info("%s plugin cannot load as the microscope is not a SPARC with a photo detector.", + self.name) + return + + self._tab = self.main_app.main_data.getTabByName("sparc_acqui") + stctrl = self._tab.streambar_controller + for det in main_data.photo_ds: + name = f"{det.name} alignment" + act = functools.partial(self.add_photo_det_stream, name=name, detector=det) + stctrl.add_action(name, act) + + # Note: no need to explicitly add the "Temporal Intensity" viewport, because it's normally + # always created when a time-correlator is available, which is the assumption here. + + def add_photo_det_stream(self, name: str, detector: model.Detector): + """ Create a Monochromator stream, using a photo-detector and add to to all compatible viewports""" + main_data = self.main_app.main_data + stctrl = self._tab.streambar_controller + + # Axes on the "LabCube", which are always affecting the time-correlator photo-detectors + axes = {"density": ("density", main_data.tc_od_filter), + "filter": ("band", main_data.tc_filter)} + + spg = stctrl._getAffectingSpectrograph(detector, default=main_data.spectrograph) + axes.update({ + "wavelength": ("wavelength", spg), + "grating": ("grating", spg), + "iris-in": ("iris-in", spg), + "slit-in": ("slit-in", spg), + "slit-monochromator": ("slit-monochromator", spg), + }) + + # Also add light filter if it affects the detector + filter_in = main_data.light_filter + if filter_in and detector.name in filter_in.affects.value: + axes["filter-in"] = ("band", filter_in) + + axes = stctrl._filter_axes(axes) + + photodet_stream = MonochromatorSettingsStream( + name, + detector, + detector.data, + main_data.ebeam, + sstage=main_data.scan_stage, + opm=main_data.opm, + axis_map=axes, + emtvas={"dwellTime"}, + detvas=get_local_vas(detector, main_data.hw_settings_config), + ) + stctrl._set_default_spectrum_axes(photodet_stream) + + # Don't call _addRepStream(), because we only add a live stream, no acquisition stream + + stream_cont = stctrl._add_stream(photodet_stream, add_to_view=True) + stream_cont.stream_panel.show_visible_btn(False) + return stream_cont