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
17 changes: 14 additions & 3 deletions software/control/core/live_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -487,10 +487,21 @@ def set_trigger_mode(self, mode):
self.microscope.low_level_drivers.microcontroller.set_trigger_mode(0)
self.trigger_mode = mode

def set_trigger_fps(self, fps):
if (self.trigger_mode == TriggerMode.SOFTWARE) or (
def trigger_fps_is_active(self) -> bool:
"""Whether the trigger-fps setting actually paces acquisition.

In CONTINUOUS mode the camera free-runs at its own internal rate and the
host trigger timer is unused, so the fps setting is inert. Software mode
(and hardware mode driven by the internal timer) pace acquisition via the
fps timer. This is the single source of truth for that condition — both
set_trigger_fps and the live widgets' fps-control enabled-state use it.
"""
return (self.trigger_mode == TriggerMode.SOFTWARE) or (
self.trigger_mode == TriggerMode.HARDWARE and self.use_internal_timer_for_hardware_trigger
):
)

def set_trigger_fps(self, fps):
if self.trigger_fps_is_active():
self._set_trigger_fps(fps)

# set microscope mode
Expand Down
17 changes: 17 additions & 0 deletions software/control/widgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -4172,6 +4172,7 @@ def add_components(self, show_trigger_options, show_display_options, show_autole
self.slider_resolutionScaling.valueChanged.connect(self.liveController.set_display_resolution_scaling)
self.dropdown_modeSelection.activated[str].connect(self.select_new_microscope_mode_by_name)
self.dropdown_triggerManu.currentIndexChanged.connect(self.update_trigger_mode)
self._sync_trigger_fps_enabled()
self.btn_live.clicked.connect(self.toggle_live)
self.entry_exposureTime.valueChanged.connect(self.update_config_exposure_time)
self.entry_analogGain.valueChanged.connect(self.update_config_analog_gain)
Expand Down Expand Up @@ -4361,6 +4362,22 @@ def _safe_z_offset_value(raw) -> float:

def update_trigger_mode(self):
self.liveController.set_trigger_mode(self.dropdown_triggerManu.currentText())
self._sync_trigger_fps_enabled()

def _sync_trigger_fps_enabled(self):
# In CONTINUOUS mode the camera free-runs at its own internal rate and the
# fps setting does nothing (LiveController.set_trigger_fps is inert there),
# so keep the control from lying: disable it and explain why.
active = self.liveController.trigger_fps_is_active()
self.entry_triggerFPS.setEnabled(active)
self.entry_triggerFPS.setToolTip(
""
if active
else (
"Frame rate is set by the camera's internal timing in Continuous mode; "
"this control only applies in Software/Hardware trigger modes."
)
)

def update_config_exposure_time(self, new_value):
if self.is_switching_mode == False:
Expand Down
74 changes: 74 additions & 0 deletions software/tests/control/test_live_trigger_fps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
"""Live-view trigger-fps control is inert in CONTINUOUS mode.

In CONTINUOUS mode the camera free-runs at its own internal rate and the host
trigger timer is unused, so the fps setting does nothing. This is pinned at two
layers: LiveController (the single authority, via trigger_fps_is_active /
set_trigger_fps) and the live widget disabling its fps control accordingly.

Uses the method-stealing stub pattern (see test_LiveControlWidget_offset.py) so
the real methods run against minimal state — no QApplication / GUI needed.
"""

from unittest.mock import MagicMock

from control._def import TriggerMode
from control.core.live_controller import LiveController
from control.widgets import LiveControlWidget


class _ControllerStub:
trigger_fps_is_active = LiveController.trigger_fps_is_active
set_trigger_fps = LiveController.set_trigger_fps
_set_trigger_fps = LiveController._set_trigger_fps

def __init__(self, trigger_mode, use_internal_timer=True):
self.trigger_mode = trigger_mode
self.use_internal_timer_for_hardware_trigger = use_internal_timer
self.is_live = False
self.fps_trigger = 10
self.timer_trigger_interval = 100.0
self._log = MagicMock()


def test_software_trigger_is_active_and_applies_fps():
c = _ControllerStub(TriggerMode.SOFTWARE)
assert c.trigger_fps_is_active() is True
c.set_trigger_fps(25)
assert c.fps_trigger == 25 # applied


def test_continuous_is_inert_and_set_trigger_fps_is_noop():
c = _ControllerStub(TriggerMode.CONTINUOUS)
assert c.trigger_fps_is_active() is False
c.set_trigger_fps(25)
assert c.fps_trigger == 10 # unchanged: camera free-runs, fps timer unused


def test_hardware_active_only_with_internal_timer():
assert _ControllerStub(TriggerMode.HARDWARE, use_internal_timer=True).trigger_fps_is_active() is True
assert _ControllerStub(TriggerMode.HARDWARE, use_internal_timer=False).trigger_fps_is_active() is False


class _WidgetStub:
_sync_trigger_fps_enabled = LiveControlWidget._sync_trigger_fps_enabled

def __init__(self, fps_active):
self.liveController = MagicMock()
self.liveController.trigger_fps_is_active.return_value = fps_active
self.entry_triggerFPS = MagicMock()


def test_widget_enables_fps_control_when_active():
w = _WidgetStub(fps_active=True)
w._sync_trigger_fps_enabled()
w.entry_triggerFPS.setEnabled.assert_called_once_with(True)
(tooltip,), _ = w.entry_triggerFPS.setToolTip.call_args
assert tooltip == "" # no warning needed when the control works


def test_widget_disables_fps_control_and_explains_when_inert():
w = _WidgetStub(fps_active=False)
w._sync_trigger_fps_enabled()
w.entry_triggerFPS.setEnabled.assert_called_once_with(False)
(tooltip,), _ = w.entry_triggerFPS.setToolTip.call_args
assert "Continuous" in tooltip # explains why it's disabled
Loading