Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion plugins/blanker_spectrum.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
2 changes: 1 addition & 1 deletion plugins/la_spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
99 changes: 99 additions & 0 deletions plugins/photo_det_live.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# -*- coding: utf-8 -*-
"""
Created on 5 Dev 2025
Copy link

Copilot AI Feb 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Typo in date: "Dev" should be "Dec" (December).

Suggested change
Created on 5 Dev 2025
Created on 5 Dec 2025

Copilot uses AI. Check for mistakes.

@author: Éric Piel

Gives ability to acquire a spectrum data, while keeping the raw CCD image (ie, without vertical binning)
Copy link

Copilot AI Feb 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The description states "Gives ability to acquire a spectrum data, while keeping the raw CCD image (ie, without vertical binning)" but this plugin is for photo-detectors, not CCD cameras. Photo-detectors typically don't have binning options and don't produce images. The description appears to be copied from another plugin (possibly spectrum_raw.py) without being updated for this plugin's actual purpose.

Suggested change
Gives ability to acquire a spectrum data, while keeping the raw CCD image (ie, without vertical binning)
Provides live display and control of photo-detector signals for SPARC systems using monochromator settings.

Copilot uses AI. Check for mistakes.

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"""
Copy link

Copilot AI Feb 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Typo in docstring: "add to to all compatible viewports" should be "add to all compatible viewports" (remove duplicate "to").

Suggested change
""" Create a Monochromator stream, using a photo-detector and add to to all compatible viewports"""
""" Create a Monochromator stream, using a photo-detector and add to all compatible viewports"""

Copilot uses AI. Check for mistakes.
main_data = self.main_app.main_data
Comment on lines +33 to +59
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# First, locate and examine the file
find . -name "photo_det_live.py" -type f

Repository: delmic/odemis

Length of output: 84


🏁 Script executed:

#!/bin/bash
# Get file size to determine how to read it
wc -l plugins/photo_det_live.py

Repository: delmic/odemis

Length of output: 85


🏁 Script executed:

#!/bin/bash
# Read the relevant lines (33-80 to see full context)
sed -n '33,80p' plugins/photo_det_live.py

Repository: delmic/odemis

Length of output: 2272


🏁 Script executed:

#!/bin/bash
# Search for StreamController class definition and usage
rg "class StreamController" --type py -A 5

Repository: delmic/odemis

Length of output: 462


🏁 Script executed:

#!/bin/bash
# Search for add_photo_det_stream method to see what it returns
rg "def add_photo_det_stream" --type py -A 20

Repository: delmic/odemis

Length of output: 1618


🏁 Script executed:

#!/bin/bash
# Read the complete add_photo_det_stream method (lines 59-99)
sed -n '59,99p' plugins/photo_det_live.py

Repository: delmic/odemis

Length of output: 1670


Add class and method docstrings in reST format and return type hints.

The class and both methods lack proper documentation and return type annotations per Python guidelines:

  • Class PhotoDetectorLivePlugin has no docstring
  • __init__ is missing both docstring and return type hint (-> None)
  • add_photo_det_stream has an improperly formatted docstring (single-line, non-reST) and is missing a return type hint; the method returns stream_cont on line 97
✏️ Suggested update
 class PhotoDetectorLivePlugin(Plugin):
+    """
+    Live-display plugin for photo-detector monochromator streams.
+    """
     name = "Photo-detector live display"
@@
-    def __init__(self, microscope: model.Microscope, main_app: OdemisGUIApp):
+    def __init__(self, microscope: model.Microscope, main_app: OdemisGUIApp) -> None:
+        """
+        Initialize the plugin and register photo-detector stream actions.
+        
+        :param microscope: Active microscope instance.
+        :param main_app: Main GUI application.
+        """
@@
-    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"""
+    def add_photo_det_stream(self, name: str, detector: model.Detector) -> "StreamController":
+        """
+        Create a monochromator stream using a photo-detector and add it to compatible viewports.
+        
+        :param name: Stream name.
+        :param detector: Photo-detector to bind.
+        :return: The created stream controller.
+        """
🤖 Prompt for AI Agents
In `@plugins/photo_det_live.py` around lines 33 - 59, Add reST docstrings and
return type hints: add a class-level reST docstring to PhotoDetectorLivePlugin
describing purpose and authorship; add an __init__ docstring in reST and
annotate __init__ with -> None; replace the single-line docstring in
add_photo_det_stream with a proper multi-line reST docstring describing
parameters (name, detector), behavior and return value, and add a return type
hint for add_photo_det_stream matching the actual returned object (the variable
stream_cont) — if the concrete type isn't available, import typing.Any or
typing.Optional and annotate accordingly; update imports if needed.

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),
})
Comment on lines +67 to +73
Copy link

Copilot AI Feb 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code unconditionally adds spectrograph axes to the axes dictionary without checking if spg is None. When no spectrograph affects the detector and main_data.spectrograph is also None (which can happen in some microscope configurations), spg will be None, leading to axis map entries with None components. This pattern is inconsistent with addTimeCorrelator (line 2009 in stream_bar.py) which checks if spg is truthy before adding spectrograph axes. Consider wrapping the axes.update() call in an if spg: condition similar to how it's done in addTimeCorrelator.

Suggested change
axes.update({
"wavelength": ("wavelength", spg),
"grating": ("grating", spg),
"iris-in": ("iris-in", spg),
"slit-in": ("slit-in", spg),
"slit-monochromator": ("slit-monochromator", spg),
})
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),
})

Copilot uses AI. Check for mistakes.

# 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
2 changes: 1 addition & 1 deletion plugins/spectrum_arbscor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
2 changes: 1 addition & 1 deletion plugins/spectrum_raw.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
2 changes: 2 additions & 0 deletions src/odemis/acq/path.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')},
Expand Down
104 changes: 71 additions & 33 deletions src/odemis/driver/tmcm.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
import threading
from collections import OrderedDict
from concurrent.futures import CancelledError
from typing import Dict

try:
import canopen
Expand Down Expand Up @@ -443,20 +444,28 @@ 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)
if not 0 <= dur < 1000:
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:
raise ValueError("led_prot_do channel %s is not specified as a do-axis" % channel)
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)
Expand All @@ -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.")
Expand Down Expand Up @@ -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
Comment on lines +1462 to +1510
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add a docstring for _set_protection and drop unused lpos.

_set_protection needs a docstring per guidelines, and lpos is unused here.

🛠️ Proposed fix
                 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]
+                    _, 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)
@@
-    def _set_protection(self, protected: bool) -> bool:
+    def _set_protection(self, protected: bool) -> bool:
+        """
+        Toggle LED protection state.
+
+        :param protected: True to force protection, False to restore expected DO state.
+        :return: The requested protection state.
+        """
         if self.protection.value == protected:
             return protected  # no change
🧰 Tools
🪛 Ruff (0.14.14)

[warning] 1493-1493: Unpacked variable lpos is never used

Prefix it with an underscore or any other dummy variable pattern

(RUF059)

🤖 Prompt for AI Agents
In `@src/odemis/driver/tmcm.py` around lines 1462 - 1510, Add a docstring to the
_set_protection method describing its purpose, parameters, and return value (it
toggles protection state, takes protected: bool, returns the new state) and
remove the unused lpos local variable from the _switch_led_prot method where
_do_axes is unpacked (change "do_an, hpos, lpos, dur = self._do_axes[channel]"
to only unpack the used names or use a throwaway name like _ for the unused
element). Ensure references to _refswitch_lock and calls to _switch_led_prot
remain unchanged.


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)
Expand Down Expand Up @@ -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):
"""
Expand Down Expand Up @@ -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):
Copy link

Copilot AI Feb 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code accesses self.protection.value without checking if the attribute exists. While this should be safe because self.protection is only created when self._led_prot_do is not empty (line 463-467), and this code is inside a condition checking channel in self._led_prot_do, the logic is fragile. For consistency with line 1557, consider using hasattr(self, "protection") before accessing self.protection.value, or document why this is safe.

Suggested change
if channel in self._led_prot_do and (self.protection.value or self._leds_on):
protection_attr = getattr(self, "protection", None)
protection_active = bool(protection_attr.value) if protection_attr is not None else False
if channel in self._led_prot_do and (protection_active or self._leds_on):

Copilot uses AI. Check for mistakes.
# 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)
Expand Down
22 changes: 22 additions & 0 deletions src/odemis/gui/conf/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}),
)),
Expand Down
Loading
Loading