From e04d5edb59537a0943d41c5dc7f046e8b4d237e4 Mon Sep 17 00:00:00 2001 From: "Kevin M. Dean" Date: Wed, 25 Feb 2026 14:50:36 -0600 Subject: [PATCH 1/7] Handle synthetic filter wheels in channels UI Expose filter wheel hardware types via ConfigurationController.filter_wheel_types and pass them into the channels view. Add ChannelCreator.is_synthetic_filter_wheel and use it to skip creating labels and pulldown widgets for synthetic filter wheels (types 'synthetic' or 'syntheticfilterwheel'). Update view.populate_frame/create_labels signatures to accept filter_wheel_types. Fix filterwheel pulldown validation in ChannelSettingController to check each filter wheel per channel (correct indexing for multiple wheels). These changes hide synthetic filter wheels from the channels UI and ensure selections are validated correctly. --- .../controller/configuration_controller.py | 18 ++++++++++++ .../sub_controllers/channels_settings.py | 20 ++++++++----- .../view/main_window_content/channels_tab.py | 29 +++++++++++++++++-- 3 files changed, 57 insertions(+), 10 deletions(-) diff --git a/src/navigate/controller/configuration_controller.py b/src/navigate/controller/configuration_controller.py index 420af74e3..0a8afbfd5 100644 --- a/src/navigate/controller/configuration_controller.py +++ b/src/navigate/controller/configuration_controller.py @@ -535,6 +535,24 @@ def number_of_filter_wheels(self) -> int: return len(self.microscope_config["filter_wheel"]) return 1 + @property + def filter_wheel_types(self) -> list[str]: + """Return a list of filter wheel hardware types. + + Returns + ------- + filter_wheel_types : list + List of filter wheel hardware types. + """ + filter_wheel_types = [] + if self.microscope_config is not None: + for i in range(self.number_of_filter_wheels): + hardware_config = self.microscope_config["filter_wheel"][i].get( + "hardware", {} + ) + filter_wheel_types.append(hardware_config.get("type", "")) + return filter_wheel_types + @property def filter_wheel_names(self) -> list[str]: """Return a list of filter wheel names diff --git a/src/navigate/controller/sub_controllers/channels_settings.py b/src/navigate/controller/sub_controllers/channels_settings.py index 23ce99f72..88362132b 100644 --- a/src/navigate/controller/sub_controllers/channels_settings.py +++ b/src/navigate/controller/sub_controllers/channels_settings.py @@ -69,10 +69,14 @@ def __init__(self, view, parent_controller=None, configuration_controller=None): self.number_of_filter_wheels = ( self.configuration_controller.number_of_filter_wheels ) + filter_wheel_types = getattr( + self.configuration_controller, "filter_wheel_types", [] + ) self.view.populate_frame( channels=self.num, filter_wheels=self.number_of_filter_wheels, filter_wheel_names=self.configuration_controller.filter_wheel_names, + filter_wheel_types=filter_wheel_types, ) #: str: The mode of the channel setting controller. Either 'live' or 'stop'. @@ -189,13 +193,15 @@ def populate_empty_values(self): self.view.laser_pulldowns[i]["values"][0] ) - if ( - self.view.filterwheel_pulldowns[i].get() - not in self.view.filterwheel_pulldowns[i]["values"] - ): - self.view.filterwheel_pulldowns[i].set( - self.view.filterwheel_pulldowns[i]["values"][0] - ) + for j in range(self.number_of_filter_wheels): + idx = i * self.number_of_filter_wheels + j + if ( + self.view.filterwheel_pulldowns[idx].get() + not in self.view.filterwheel_pulldowns[idx]["values"] + ): + self.view.filterwheel_pulldowns[idx].set( + self.view.filterwheel_pulldowns[idx]["values"][0] + ) if self.view.exptime_pulldowns[i].get() == "": self.view.exptime_pulldowns[i].set(100.0) diff --git a/src/navigate/view/main_window_content/channels_tab.py b/src/navigate/view/main_window_content/channels_tab.py index 8b74bffb4..4a1b13b0f 100644 --- a/src/navigate/view/main_window_content/channels_tab.py +++ b/src/navigate/view/main_window_content/channels_tab.py @@ -205,7 +205,11 @@ def __init__( self.frame_columns = [] def populate_frame( - self, channels: int, filter_wheels: int, filter_wheel_names: list + self, + channels: int, + filter_wheels: int, + filter_wheel_names: list, + filter_wheel_types: list = None, ) -> None: """Populates the frame with the widgets. @@ -222,9 +226,11 @@ def populate_frame( The number of filter wheels filter_wheel_names : list The names of the filter wheels + filter_wheel_types : list + The types of filter wheels """ - self.create_labels(filter_wheel_names, filter_wheels) + self.create_labels(filter_wheel_names, filter_wheels, filter_wheel_types) # Configure the columns for consistent spacing for i in range(len(self.label_text)): @@ -286,6 +292,8 @@ def populate_frame( ) ) self.filterwheel_pulldowns[-1].config(state="readonly") + if self.is_synthetic_filter_wheel(i, filter_wheel_types): + continue self.filterwheel_pulldowns[-1].grid( row=num + 1, column=(column_id := column_id + 1), @@ -344,7 +352,9 @@ def populate_frame( pady=self.pad_y, ) - def create_labels(self, filter_wheel_names: list, filter_wheels: int) -> None: + def create_labels( + self, filter_wheel_names: list, filter_wheels: int, filter_wheel_types: list = None + ) -> None: """Create the labels for the columns. Function to create the labels for the columns of the Channel Creator frame. @@ -355,6 +365,8 @@ def create_labels(self, filter_wheel_names: list, filter_wheels: int) -> None: A list of the names of the filter wheels filter_wheels : int Number of filter wheels + filter_wheel_types : list + The types of filter wheels """ # Create the labels for the columns. self.label_text = [ @@ -363,6 +375,8 @@ def create_labels(self, filter_wheel_names: list, filter_wheels: int) -> None: "Power", ] for i in range(filter_wheels): + if self.is_synthetic_filter_wheel(i, filter_wheel_types): + continue self.label_text.append(filter_wheel_names[i]) self.label_text += ["Exp. Time (ms)", "Interval", "Defocus"] @@ -379,6 +393,15 @@ def create_labels(self, filter_wheel_names: list, filter_wheels: int) -> None: row=0, column=0, sticky=tk.N, pady=self.pad_y, padx=self.pad_x ) + @staticmethod + def is_synthetic_filter_wheel(filter_wheel_idx: int, filter_wheel_types: list) -> bool: + """Return whether a filter wheel type should be hidden in the GUI.""" + if filter_wheel_types is None or filter_wheel_idx >= len(filter_wheel_types): + return False + + filter_wheel_type = str(filter_wheel_types[filter_wheel_idx]).strip().lower() + return filter_wheel_type in ("synthetic", "syntheticfilterwheel") + class StackAcquisitionFrame(ttk.Labelframe): """This class is the frame that holds the stack acquisition settings.""" From 9dc43e1bd58b498ea26f4266aed1eb8ccd0aa990 Mon Sep 17 00:00:00 2001 From: "Kevin M. Dean" Date: Wed, 25 Feb 2026 15:22:20 -0600 Subject: [PATCH 2/7] Rebuild and reset channel widgets on refresh Initialize ChannelSettingController state earlier (mode, in_initialization, event_id, channel_setting_dict) so these attributes exist before calling rebuild_view. Trigger rebuild_view() from ChannelsTabController when showing the main window to ensure the channel view matches the active configuration. Add ChannelCreator.reset_frame() and call it at construction to destroy existing child widgets and clear internal widget/variable lists before recreating them. Also apply minor signature/formatting tweaks. These changes prevent stale widgets/attributes and keep the UI in sync with configuration updates. --- .../sub_controllers/channels_settings.py | 28 +++++++++------- .../sub_controllers/channels_tab.py | 1 + .../view/main_window_content/channels_tab.py | 32 +++++++++++++++++-- 3 files changed, 47 insertions(+), 14 deletions(-) diff --git a/src/navigate/controller/sub_controllers/channels_settings.py b/src/navigate/controller/sub_controllers/channels_settings.py index 88362132b..af9c9ba71 100644 --- a/src/navigate/controller/sub_controllers/channels_settings.py +++ b/src/navigate/controller/sub_controllers/channels_settings.py @@ -64,6 +64,22 @@ def __init__(self, view, parent_controller=None, configuration_controller=None): #: ConfigurationController: The configuration controller. self.configuration_controller = configuration_controller + #: str: The mode of the channel setting controller. Either 'live' or 'stop'. + self.mode = "stop" + + #: bool: Whether the channel setting controller is in initialization. + self.in_initialization = True + + #: int: The event id. + self.event_id = None + + #: dict: The channel setting dictionary. + self.channel_setting_dict = None + + self.rebuild_view() + + def rebuild_view(self) -> None: + """Rebuild channel widgets from the active microscope configuration.""" # num: numbers of channels self.num = self.configuration_controller.number_of_channels self.number_of_filter_wheels = ( @@ -79,18 +95,6 @@ def __init__(self, view, parent_controller=None, configuration_controller=None): filter_wheel_types=filter_wheel_types, ) - #: str: The mode of the channel setting controller. Either 'live' or 'stop'. - self.mode = "stop" - - #: bool: Whether the channel setting controller is in initialization. - self.in_initialization = True - - #: int: The event id. - self.event_id = None - - #: dict: The channel setting dictionary. - self.channel_setting_dict = None - # widget command binds for i in range(self.num): channel_vals = self.get_vals_by_channel(i) diff --git a/src/navigate/controller/sub_controllers/channels_tab.py b/src/navigate/controller/sub_controllers/channels_tab.py index 3e1a46a2f..c36d7bb8a 100644 --- a/src/navigate/controller/sub_controllers/channels_tab.py +++ b/src/navigate/controller/sub_controllers/channels_tab.py @@ -190,6 +190,7 @@ def initialize(self) -> None: main window. """ config = self.parent_controller.configuration_controller + self.channel_setting_controller.rebuild_view() self.stack_acq_widgets["cycling"].widget["values"] = ["Per Z", "Per Stack"] # Set the default stage for acquiring a z-stack. diff --git a/src/navigate/view/main_window_content/channels_tab.py b/src/navigate/view/main_window_content/channels_tab.py index 4a1b13b0f..8183c1950 100644 --- a/src/navigate/view/main_window_content/channels_tab.py +++ b/src/navigate/view/main_window_content/channels_tab.py @@ -229,6 +229,7 @@ def populate_frame( filter_wheel_types : list The types of filter wheels """ + self.reset_frame() self.create_labels(filter_wheel_names, filter_wheels, filter_wheel_types) @@ -353,7 +354,10 @@ def populate_frame( ) def create_labels( - self, filter_wheel_names: list, filter_wheels: int, filter_wheel_types: list = None + self, + filter_wheel_names: list, + filter_wheels: int, + filter_wheel_types: list = None, ) -> None: """Create the labels for the columns. @@ -393,8 +397,32 @@ def create_labels( row=0, column=0, sticky=tk.N, pady=self.pad_y, padx=self.pad_x ) + def reset_frame(self) -> None: + """Destroy existing channel widgets and clear references.""" + for child in self.winfo_children(): + child.destroy() + + self.channel_variables = [] + self.channel_checks = [] + self.laser_variables = [] + self.laser_pulldowns = [] + self.laserpower_variables = [] + self.laserpower_pulldowns = [] + self.filterwheel_variables = [] + self.filterwheel_pulldowns = [] + self.exptime_variables = [] + self.exptime_pulldowns = [] + self.interval_variables = [] + self.interval_spins = [] + self.defocus_variables = [] + self.defocus_spins = [] + self.labels = [] + self.frame_columns = [] + @staticmethod - def is_synthetic_filter_wheel(filter_wheel_idx: int, filter_wheel_types: list) -> bool: + def is_synthetic_filter_wheel( + filter_wheel_idx: int, filter_wheel_types: list + ) -> bool: """Return whether a filter wheel type should be hidden in the GUI.""" if filter_wheel_types is None or filter_wheel_idx >= len(filter_wheel_types): return False From 3808dcdf60a15b8b7c5c94c2248d72363443754b Mon Sep 17 00:00:00 2001 From: "Kevin M. Dean" Date: Wed, 25 Feb 2026 15:34:50 -0600 Subject: [PATCH 3/7] Support per-microscope filter wheel visibility Record which filter wheel entries belong to each microscope during configuration verification and save a filter_wheel_visibility list in each microscope config. Add ConfigurationController.filter_wheel_visibility to expose a safe boolean list with sensible fallbacks. Pass visibility through ChannelSettingController to the ChannelCreator view and add should_hide_filter_wheel logic so non-native (or synthetic) wheels are omitted from the UI. This keeps shared/placeholder sequence alignment while allowing the GUI to hide wheels that don't belong to a given microscope. --- src/navigate/config/config.py | 18 +++++++++ .../controller/configuration_controller.py | 24 ++++++++++++ .../sub_controllers/channels_settings.py | 4 ++ .../view/main_window_content/channels_tab.py | 38 +++++++++++++++++-- 4 files changed, 81 insertions(+), 3 deletions(-) diff --git a/src/navigate/config/config.py b/src/navigate/config/config.py index 21e6d816f..6c44b8380 100644 --- a/src/navigate/config/config.py +++ b/src/navigate/config/config.py @@ -969,6 +969,7 @@ def verify_configuration(manager, configuration): ref_list = { "filter_wheel": [], } + filter_wheel_ids_by_microscope = {} required_devices = [ "camera", "filter_wheel", @@ -1040,15 +1041,32 @@ def verify_configuration(manager, configuration): ) temp_config = device_config[microscope_name]["filter_wheel"] + current_filter_wheel_ids = [] for _, filter_wheel_config in enumerate(temp_config): filter_wheel_idx = build_ref_name( "-", filter_wheel_config["hardware"]["type"], filter_wheel_config["hardware"]["wheel_number"], ) + current_filter_wheel_ids.append(filter_wheel_idx) if filter_wheel_idx not in ref_list["filter_wheel"]: ref_list["filter_wheel"].append(filter_wheel_idx) filter_wheel_seq.append(filter_wheel_config) + filter_wheel_ids_by_microscope[microscope_name] = current_filter_wheel_ids + + # Record which filter wheel entries belong to each microscope before + # sequence alignment inserts shared placeholders. + for microscope_name in device_config.keys(): + present_ids = set(filter_wheel_ids_by_microscope.get(microscope_name, [])) + filter_wheel_visibility = [ + ref_name in present_ids for ref_name in ref_list["filter_wheel"] + ] + update_config_dict( + manager, + device_config[microscope_name], + "filter_wheel_visibility", + filter_wheel_visibility, + ) # make sure all microscopes have the same filter wheel sequence if len(device_config.keys()) > 1: diff --git a/src/navigate/controller/configuration_controller.py b/src/navigate/controller/configuration_controller.py index 0a8afbfd5..360eed86a 100644 --- a/src/navigate/controller/configuration_controller.py +++ b/src/navigate/controller/configuration_controller.py @@ -553,6 +553,30 @@ def filter_wheel_types(self) -> list[str]: filter_wheel_types.append(hardware_config.get("type", "")) return filter_wheel_types + @property + def filter_wheel_visibility(self) -> list[bool]: + """Return a list indicating which filter wheels are native to microscope. + + Returns + ------- + filter_wheel_visibility : list + ``True`` for wheels that are defined for this microscope. + """ + if self.microscope_config is None: + return [] + + visibility = self.microscope_config.get("filter_wheel_visibility") + if isinstance(visibility, ListProxy): + visibility = list(visibility) + + if not isinstance(visibility, list): + return [True] * self.number_of_filter_wheels + + if len(visibility) != self.number_of_filter_wheels: + return [True] * self.number_of_filter_wheels + + return [bool(value) for value in visibility] + @property def filter_wheel_names(self) -> list[str]: """Return a list of filter wheel names diff --git a/src/navigate/controller/sub_controllers/channels_settings.py b/src/navigate/controller/sub_controllers/channels_settings.py index af9c9ba71..336f7187f 100644 --- a/src/navigate/controller/sub_controllers/channels_settings.py +++ b/src/navigate/controller/sub_controllers/channels_settings.py @@ -88,11 +88,15 @@ def rebuild_view(self) -> None: filter_wheel_types = getattr( self.configuration_controller, "filter_wheel_types", [] ) + filter_wheel_visibility = getattr( + self.configuration_controller, "filter_wheel_visibility", [] + ) self.view.populate_frame( channels=self.num, filter_wheels=self.number_of_filter_wheels, filter_wheel_names=self.configuration_controller.filter_wheel_names, filter_wheel_types=filter_wheel_types, + filter_wheel_visibility=filter_wheel_visibility, ) # widget command binds diff --git a/src/navigate/view/main_window_content/channels_tab.py b/src/navigate/view/main_window_content/channels_tab.py index 8183c1950..7d1e547e2 100644 --- a/src/navigate/view/main_window_content/channels_tab.py +++ b/src/navigate/view/main_window_content/channels_tab.py @@ -210,6 +210,7 @@ def populate_frame( filter_wheels: int, filter_wheel_names: list, filter_wheel_types: list = None, + filter_wheel_visibility: list = None, ) -> None: """Populates the frame with the widgets. @@ -228,10 +229,17 @@ def populate_frame( The names of the filter wheels filter_wheel_types : list The types of filter wheels + filter_wheel_visibility : list + Indicates whether each filter wheel belongs to this microscope """ self.reset_frame() - self.create_labels(filter_wheel_names, filter_wheels, filter_wheel_types) + self.create_labels( + filter_wheel_names, + filter_wheels, + filter_wheel_types, + filter_wheel_visibility, + ) # Configure the columns for consistent spacing for i in range(len(self.label_text)): @@ -293,7 +301,9 @@ def populate_frame( ) ) self.filterwheel_pulldowns[-1].config(state="readonly") - if self.is_synthetic_filter_wheel(i, filter_wheel_types): + if self.should_hide_filter_wheel( + i, filter_wheel_types, filter_wheel_visibility + ): continue self.filterwheel_pulldowns[-1].grid( row=num + 1, @@ -358,6 +368,7 @@ def create_labels( filter_wheel_names: list, filter_wheels: int, filter_wheel_types: list = None, + filter_wheel_visibility: list = None, ) -> None: """Create the labels for the columns. @@ -371,6 +382,8 @@ def create_labels( Number of filter wheels filter_wheel_types : list The types of filter wheels + filter_wheel_visibility : list + Indicates whether each filter wheel belongs to this microscope """ # Create the labels for the columns. self.label_text = [ @@ -379,7 +392,9 @@ def create_labels( "Power", ] for i in range(filter_wheels): - if self.is_synthetic_filter_wheel(i, filter_wheel_types): + if self.should_hide_filter_wheel( + i, filter_wheel_types, filter_wheel_visibility + ): continue self.label_text.append(filter_wheel_names[i]) @@ -430,6 +445,23 @@ def is_synthetic_filter_wheel( filter_wheel_type = str(filter_wheel_types[filter_wheel_idx]).strip().lower() return filter_wheel_type in ("synthetic", "syntheticfilterwheel") + @classmethod + def should_hide_filter_wheel( + cls, + filter_wheel_idx: int, + filter_wheel_types: list, + filter_wheel_visibility: list, + ) -> bool: + """Return whether a filter wheel should be hidden in the GUI.""" + if ( + filter_wheel_visibility is not None + and filter_wheel_idx < len(filter_wheel_visibility) + and not filter_wheel_visibility[filter_wheel_idx] + ): + return True + + return cls.is_synthetic_filter_wheel(filter_wheel_idx, filter_wheel_types) + class StackAcquisitionFrame(ttk.Labelframe): """This class is the frame that holds the stack acquisition settings.""" From a3607347b3d105493c0c42eb4117d859ccddf893 Mon Sep 17 00:00:00 2001 From: "Kevin M. Dean" Date: Wed, 25 Feb 2026 17:00:24 -0600 Subject: [PATCH 4/7] Support combobox value types; add tests Normalize combobox "values" handling in ChannelSettingController and add related tests. - src/navigate/controller/sub_controllers/channels_settings.py: call initialize() in constructor; add static _get_dropdown_values to normalize dropdown values (handles tuple, list, string, empty, etc.); use it in populate_empty_values and dropdown index lookups to avoid errors when values are empty or provided as different types. - Tests: add/extend tests to cover the new behavior: - test/controller/sub_controllers/test_channels_settings.py: assert dropdown normalization and behavior when dropdowns are empty. - test/config/test_config.py: add test to verify filter_wheel_visibility is set correctly by verify_configuration. - test/controller/test_configuration_controller.py: new tests for ConfigurationController filter wheel types/visibility handling. - test/view/main_window_content/tabs/channels/test_channel_creator.py: new tests for ChannelCreator helper methods. These changes fix brittle assumptions about combobox "values" formats and add tests to prevent regressions. --- .../sub_controllers/channels_settings.py | 42 ++++--- test/config/test_config.py | 51 ++++++++ .../sub_controllers/test_channels_settings.py | 26 ++++ .../test_configuration_controller.py | 118 ++++++++++++++++++ .../tabs/channels/test_channel_creator.py | 49 ++++++++ 5 files changed, 272 insertions(+), 14 deletions(-) create mode 100644 test/controller/test_configuration_controller.py create mode 100644 test/view/main_window_content/tabs/channels/test_channel_creator.py diff --git a/src/navigate/controller/sub_controllers/channels_settings.py b/src/navigate/controller/sub_controllers/channels_settings.py index 336f7187f..5ef19975d 100644 --- a/src/navigate/controller/sub_controllers/channels_settings.py +++ b/src/navigate/controller/sub_controllers/channels_settings.py @@ -77,6 +77,21 @@ def __init__(self, view, parent_controller=None, configuration_controller=None): self.channel_setting_dict = None self.rebuild_view() + self.initialize() + + @staticmethod + def _get_dropdown_values(dropdown): + """Return combobox values as a tuple.""" + values = dropdown["values"] + if not values: + return () + if isinstance(values, tuple): + return values + if isinstance(values, list): + return tuple(values) + if isinstance(values, str): + return tuple(dropdown.tk.splitlist(values)) + return tuple(values) def rebuild_view(self) -> None: """Rebuild channel widgets from the active microscope configuration.""" @@ -193,23 +208,20 @@ def populate_empty_values(self): to be populated with a default value. """ for i in range(self.num): - if ( - self.view.laser_pulldowns[i].get() - not in self.view.laser_pulldowns[i]["values"] - ): - self.view.laser_pulldowns[i].set( - self.view.laser_pulldowns[i]["values"][0] - ) + laser_values = self._get_dropdown_values(self.view.laser_pulldowns[i]) + if laser_values and self.view.laser_pulldowns[i].get() not in laser_values: + self.view.laser_pulldowns[i].set(laser_values[0]) for j in range(self.number_of_filter_wheels): idx = i * self.number_of_filter_wheels + j + filter_values = self._get_dropdown_values( + self.view.filterwheel_pulldowns[idx] + ) if ( - self.view.filterwheel_pulldowns[idx].get() - not in self.view.filterwheel_pulldowns[idx]["values"] + filter_values + and self.view.filterwheel_pulldowns[idx].get() not in filter_values ): - self.view.filterwheel_pulldowns[idx].set( - self.view.filterwheel_pulldowns[idx]["values"][0] - ) + self.view.filterwheel_pulldowns[idx].set(filter_values[0]) if self.view.exptime_pulldowns[i].get() == "": self.view.exptime_pulldowns[i].set(100.0) @@ -475,14 +487,16 @@ def get_index(self, dropdown_name, value): if not value: return -1 if dropdown_name == "laser": + values = self._get_dropdown_values(self.view.laser_pulldowns[0]) try: - laser_id = self.view.laser_pulldowns[0]["values"].index(value) + laser_id = values.index(value) except ValueError: return 0 return laser_id elif dropdown_name.startswith("filter"): idx = int(dropdown_name[dropdown_name.rfind("_") + 1 :]) - return self.view.filterwheel_pulldowns[idx]["values"].index(value) + values = self._get_dropdown_values(self.view.filterwheel_pulldowns[idx]) + return values.index(value) return -1 def verify_experiment_values(self): diff --git a/test/config/test_config.py b/test/config/test_config.py index 65567b3f9..ab8b0d22c 100644 --- a/test/config/test_config.py +++ b/test/config/test_config.py @@ -144,6 +144,57 @@ def test_get_configuration_paths_create_dir(monkeypatch): delete_folder("TESTPATH") +def test_verify_configuration_sets_filter_wheel_visibility(): + manager = Manager() + try: + current_path = os.path.abspath(os.path.dirname(__file__)) + root_path = os.path.dirname(os.path.dirname(current_path)) + config_path = os.path.join(root_path, "src", "navigate", "config") + configuration_file = os.path.join(config_path, "configuration.yaml") + + raw_configuration = load_yaml_file(configuration_file) + microscopes_raw = raw_configuration["microscopes"] + + ref_filter_wheels = [] + filter_wheel_ids_by_microscope = {} + for microscope_name, microscope_config in microscopes_raw.items(): + filter_wheel_config = microscope_config["filter_wheel"] + if isinstance(filter_wheel_config, dict): + filter_wheel_config = [filter_wheel_config] + + filter_wheel_ids = [] + for wheel_config in filter_wheel_config: + filter_wheel_id = config.build_ref_name( + "-", + wheel_config["hardware"]["type"], + wheel_config["hardware"]["wheel_number"], + ) + filter_wheel_ids.append(filter_wheel_id) + if filter_wheel_id not in ref_filter_wheels: + ref_filter_wheels.append(filter_wheel_id) + + filter_wheel_ids_by_microscope[microscope_name] = set(filter_wheel_ids) + + expected_visibility = { + microscope_name: [ + ref_name in filter_wheel_ids_by_microscope[microscope_name] + for ref_name in ref_filter_wheels + ] + for microscope_name in microscopes_raw.keys() + } + + configuration = config.load_configs(manager, configuration=configuration_file) + config.verify_configuration(manager, configuration) + microscopes = configuration["configuration"]["microscopes"] + + for microscope_name, expected in expected_visibility.items(): + visibility = microscopes[microscope_name]["filter_wheel_visibility"] + assert isinstance(visibility, ListProxy) + assert list(visibility) == expected + finally: + manager.shutdown() + + # test that the system is exited if no file is provided to load_yaml_config def test_load_yaml_config_no_file(): """Test that the system exits if no file is provided.""" diff --git a/test/controller/sub_controllers/test_channels_settings.py b/test/controller/sub_controllers/test_channels_settings.py index 81cd790a3..f47afcb01 100644 --- a/test/controller/sub_controllers/test_channels_settings.py +++ b/test/controller/sub_controllers/test_channels_settings.py @@ -159,6 +159,32 @@ def test_channel_callback(self): ) self.channel_setting.parent_controller.commands = [] # reset + def test_dropdown_values_initialized_in_constructor(self): + laser_values = self.channel_setting._get_dropdown_values( + self.channel_setting.view.laser_pulldowns[0] + ) + assert len(laser_values) > 0 + + for i in range(self.channel_setting.number_of_filter_wheels): + filter_values = self.channel_setting._get_dropdown_values( + self.channel_setting.view.filterwheel_pulldowns[i] + ) + assert len(filter_values) > 0 + + def test_populate_empty_values_with_empty_dropdowns(self): + self.channel_setting.view.laser_pulldowns[0]["values"] = () + self.channel_setting.view.filterwheel_pulldowns[0]["values"] = () + self.channel_setting.view.laser_variables[0].set("invalid_laser_value") + self.channel_setting.view.filterwheel_variables[0].set("invalid_filter_value") + + self.channel_setting.populate_empty_values() + + assert self.channel_setting.view.laser_variables[0].get() == "invalid_laser_value" + assert ( + self.channel_setting.view.filterwheel_variables[0].get() + == "invalid_filter_value" + ) + def test_get_vals_by_channel(self): # Not needed to test IMO pass diff --git a/test/controller/test_configuration_controller.py b/test/controller/test_configuration_controller.py new file mode 100644 index 000000000..37e8c3db3 --- /dev/null +++ b/test/controller/test_configuration_controller.py @@ -0,0 +1,118 @@ +# Copyright (c) 2021-2025 The University of Texas Southwestern Medical Center. +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted for academic and research use only (subject to the +# limitations in the disclaimer below) provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# +# * Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# +# * Neither the name of the copyright holders nor the names of its +# contributors may be used to endorse or promote products derived from this +# software without specific prior written permission. +# +# NO EXPRESS OR IMPLIED LICENSES TO ANY PARTY'S PATENT RIGHTS ARE GRANTED BY +# THIS LICENSE. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND +# CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A +# PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR +# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR +# BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER +# IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. + +from multiprocessing import Manager + +import pytest + +from navigate.controller.configuration_controller import ConfigurationController + + +@pytest.fixture +def configuration(): + return { + "experiment": {"MicroscopeState": {"microscope_name": "scope_a"}}, + "configuration": { + "microscopes": { + "scope_a": { + "galvo": [], + "filter_wheel": [ + { + "hardware": { + "type": "Sutter", + "name": "Wheel A", + "wheel_number": 0, + } + }, + { + "hardware": { + "type": "SyntheticFilterWheel", + "name": "Wheel B", + "wheel_number": 1, + } + }, + ], + }, + "scope_b": { + "galvo": [], + "filter_wheel": [ + { + "hardware": { + "type": "ASI", + "name": "Wheel C", + "wheel_number": 0, + } + } + ], + }, + } + }, + "gui": {"channel_settings": {"count": 5}}, + } + + +def test_filter_wheel_types(configuration): + controller = ConfigurationController(configuration) + + assert controller.filter_wheel_types == ["Sutter", "SyntheticFilterWheel"] + + +@pytest.mark.parametrize("visibility", [None, "invalid", [True]]) +def test_filter_wheel_visibility_defaults_to_all_true(configuration, visibility): + if visibility is not None: + configuration["configuration"]["microscopes"]["scope_a"][ + "filter_wheel_visibility" + ] = visibility + + controller = ConfigurationController(configuration) + + assert controller.filter_wheel_visibility == [True, True] + + +def test_filter_wheel_visibility_boolean_cast(configuration): + configuration["configuration"]["microscopes"]["scope_a"][ + "filter_wheel_visibility" + ] = [1, 0] + + controller = ConfigurationController(configuration) + + assert controller.filter_wheel_visibility == [True, False] + + +def test_filter_wheel_visibility_listproxy(configuration): + with Manager() as manager: + configuration["configuration"]["microscopes"]["scope_a"][ + "filter_wheel_visibility" + ] = manager.list([1, 0]) + + controller = ConfigurationController(configuration) + + assert controller.filter_wheel_visibility == [True, False] diff --git a/test/view/main_window_content/tabs/channels/test_channel_creator.py b/test/view/main_window_content/tabs/channels/test_channel_creator.py new file mode 100644 index 000000000..1e0a1670b --- /dev/null +++ b/test/view/main_window_content/tabs/channels/test_channel_creator.py @@ -0,0 +1,49 @@ +# Copyright (c) 2021-2025 The University of Texas Southwestern Medical Center. +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted for academic and research use only (subject to the +# limitations in the disclaimer below) provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# +# * Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# +# * Neither the name of the copyright holders nor the names of its +# contributors may be used to endorse or promote products derived from this +# software without specific prior written permission. +# +# NO EXPRESS OR IMPLIED LICENSES TO ANY PARTY'S PATENT RIGHTS ARE GRANTED BY +# THIS LICENSE. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND +# CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A +# PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR +# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR +# BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER +# IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. + +from navigate.view.main_window_content.channels_tab import ChannelCreator + + +def test_is_synthetic_filter_wheel_variants(): + assert ChannelCreator.is_synthetic_filter_wheel(0, ["synthetic"]) + assert ChannelCreator.is_synthetic_filter_wheel(0, [" SyntheticFilterWheel "]) + assert not ChannelCreator.is_synthetic_filter_wheel(0, ["sutter"]) + assert not ChannelCreator.is_synthetic_filter_wheel(1, ["sutter"]) + assert not ChannelCreator.is_synthetic_filter_wheel(0, None) + + +def test_should_hide_filter_wheel(): + assert ChannelCreator.should_hide_filter_wheel(0, ["sutter"], [False]) + assert ChannelCreator.should_hide_filter_wheel(0, ["synthetic"], [True]) + assert not ChannelCreator.should_hide_filter_wheel(0, ["sutter"], [True]) + assert ChannelCreator.should_hide_filter_wheel( + 1, ["sutter", "syntheticfilterwheel"], [True] + ) From 78e727e3eade8a553dd9b4972240581ccd7328ca Mon Sep 17 00:00:00 2001 From: Annie Wang Date: Wed, 11 Mar 2026 21:56:23 -0500 Subject: [PATCH 5/7] Clean up undefined filter wheels and enforce unique reference names --- src/navigate/config/config.py | 87 ++++++++++--------- .../controller/configuration_controller.py | 5 +- .../sub_controllers/channels_settings.py | 36 ++++---- src/navigate/model/microscope.py | 2 +- 4 files changed, 73 insertions(+), 57 deletions(-) diff --git a/src/navigate/config/config.py b/src/navigate/config/config.py index 6c44b8380..5fb187378 100644 --- a/src/navigate/config/config.py +++ b/src/navigate/config/config.py @@ -318,9 +318,9 @@ def verify_experiment_config(manager, configuration): device_ref ].keys() ): - autofocus_setting_dict[microscope_name][device][ - device_ref - ][k] = autofocus_sample_setting[k] + autofocus_setting_dict[microscope_name][device][device_ref][ + k + ] = autofocus_sample_setting[k] # remove non-consistent autofocus parameter for microscope_name in autofocus_setting_dict.keys(): @@ -639,19 +639,16 @@ def verify_experiment_config(manager, configuration): channel_value["laser_index"] = laser_list.index(channel_value["laser"]) # filter wheel for i in range(number_of_filter_wheels): - ref_name = f"filter_wheel_{i}" + ref_name = configuration["configuration"]["microscopes"][microscope_name][ + "filter_wheel" + ][i].get("name", f"FilterWheel-{i}") if ( ref_name not in channel_value or channel_value[ref_name] not in filterwheel_list[i] ): channel_value[ref_name] = filterwheel_list[i][0] - channel_value[f"filter_position_{i}"] = filterwheel_list[i].index( - channel_value[ref_name] - ) if "filter" in channel_value: channel_value.pop("filter") - if "filter_position" in channel_value: - channel_value.pop("filter_position") # is_selected if ( "is_selected" not in channel_value.keys() @@ -965,14 +962,11 @@ def verify_configuration(manager, configuration): channel_count = 5 # generate hardware header section - hardware_dict = {} ref_list = { "filter_wheel": [], } - filter_wheel_ids_by_microscope = {} required_devices = [ "camera", - "filter_wheel", "shutter", "remote_focus", "galvo", @@ -1029,7 +1023,10 @@ def verify_configuration(manager, configuration): elif "type" not in zoom_config["hardware"]: zoom_config["hardware"]["type"] = "Synthetic" - filter_wheel_config = device_config[microscope_name]["filter_wheel"] + filter_wheel_config = device_config[microscope_name].get("filter_wheel", None) + if filter_wheel_config is None: + continue + if type(filter_wheel_config) == DictProxy: # support older version of configuration.yaml # filter_wheel_delay and available filters @@ -1041,49 +1038,61 @@ def verify_configuration(manager, configuration): ) temp_config = device_config[microscope_name]["filter_wheel"] - current_filter_wheel_ids = [] + filter_wheel_names = set() for _, filter_wheel_config in enumerate(temp_config): filter_wheel_idx = build_ref_name( "-", filter_wheel_config["hardware"]["type"], filter_wheel_config["hardware"]["wheel_number"], ) - current_filter_wheel_ids.append(filter_wheel_idx) if filter_wheel_idx not in ref_list["filter_wheel"]: ref_list["filter_wheel"].append(filter_wheel_idx) filter_wheel_seq.append(filter_wheel_config) - filter_wheel_ids_by_microscope[microscope_name] = current_filter_wheel_ids - - # Record which filter wheel entries belong to each microscope before - # sequence alignment inserts shared placeholders. - for microscope_name in device_config.keys(): - present_ids = set(filter_wheel_ids_by_microscope.get(microscope_name, [])) - filter_wheel_visibility = [ - ref_name in present_ids for ref_name in ref_list["filter_wheel"] - ] - update_config_dict( - manager, - device_config[microscope_name], - "filter_wheel_visibility", - filter_wheel_visibility, - ) + if ( + filter_wheel_config.get("name", None) is None + and filter_wheel_config.get("hardware", {}).get("name", None) + is not None + ): + filter_wheel_config["name"] = filter_wheel_config["hardware"][ + "name" + ] + idx = ref_list["filter_wheel"].index(filter_wheel_idx) + if filter_wheel_seq[idx].get("name", None): + filter_wheel_config["name"] = filter_wheel_seq[idx]["name"] + elif filter_wheel_config.get("name", None): + filter_wheel_seq[idx]["name"] = filter_wheel_config["name"] + elif filter_wheel_config.get("hardware", {}).get("name", None): + filter_wheel_seq[idx]["name"] = filter_wheel_config["hardware"]["name"] + if filter_wheel_seq[idx].get("name", None): + if filter_wheel_seq[idx]["name"] not in filter_wheel_names: + filter_wheel_names.add(filter_wheel_seq[idx]["name"]) + else: + filter_wheel_seq[idx]["name"] = None + + # make sure all filter wheel entries have hardware name + for i, filter_wheel_config in enumerate(filter_wheel_seq): + if filter_wheel_config.get("name", None) is None: + for j in range(len(filter_wheel_seq)): + temp_name = f"FilterWheel-{j}" + if temp_name not in filter_wheel_names: + filter_wheel_seq[i]["name"] = temp_name + filter_wheel_names.add(temp_name) + break # make sure all microscopes have the same filter wheel sequence - if len(device_config.keys()) > 1: + if len(filter_wheel_seq) > 0: for microscope_name in device_config.keys(): - temp_config = device_config[microscope_name]["filter_wheel"] - filter_wheel_ids = list(range(len(ref_list["filter_wheel"]))) - for _, filter_wheel_config in enumerate(temp_config): + temp_config = device_config[microscope_name].get("filter_wheel", None) + if temp_config is None: + continue + for i, filter_wheel_config in enumerate(temp_config): filter_wheel_idx = build_ref_name( "-", filter_wheel_config["hardware"]["type"], filter_wheel_config["hardware"]["wheel_number"], ) - filter_wheel_ids.remove( - ref_list["filter_wheel"].index(filter_wheel_idx) - ) - for i in filter_wheel_ids: - temp_config.insert(i, filter_wheel_seq[i]) + idx = ref_list["filter_wheel"].index(filter_wheel_idx) + temp_config[i]["name"] = filter_wheel_seq[idx]["name"] update_config_dict( manager, diff --git a/src/navigate/controller/configuration_controller.py b/src/navigate/controller/configuration_controller.py index 360eed86a..9a732b96a 100644 --- a/src/navigate/controller/configuration_controller.py +++ b/src/navigate/controller/configuration_controller.py @@ -143,7 +143,8 @@ def channels_info(self) -> dict: "laser": self.lasers_info, } for i, filter_wheel_config in enumerate(self.microscope_config["filter_wheel"]): - setting[f"filter_wheel_{i}"] = list( + filter_wheel_name = filter_wheel_config.get("name", f"FilterWheel-{i}") + setting[filter_wheel_name] = list( filter_wheel_config["available_filters"].keys() ) return setting @@ -589,7 +590,7 @@ def filter_wheel_names(self) -> list[str]: filter_wheel_names = [] if self.microscope_config is not None: for i in range(self.number_of_filter_wheels): - name = self.microscope_config["filter_wheel"][i]["hardware"].get( + name = self.microscope_config["filter_wheel"][i].get( "name", f"Filter Wheel {i}" ) filter_wheel_names.append(name) diff --git a/src/navigate/controller/sub_controllers/channels_settings.py b/src/navigate/controller/sub_controllers/channels_settings.py index 5ef19975d..dc789a265 100644 --- a/src/navigate/controller/sub_controllers/channels_settings.py +++ b/src/navigate/controller/sub_controllers/channels_settings.py @@ -96,6 +96,8 @@ def _get_dropdown_values(dropdown): def rebuild_view(self) -> None: """Rebuild channel widgets from the active microscope configuration.""" # num: numbers of channels + # add a flag to avoid triggering callback when initializing the view + self.in_initialization = True self.num = self.configuration_controller.number_of_channels self.number_of_filter_wheels = ( self.configuration_controller.number_of_filter_wheels @@ -103,15 +105,11 @@ def rebuild_view(self) -> None: filter_wheel_types = getattr( self.configuration_controller, "filter_wheel_types", [] ) - filter_wheel_visibility = getattr( - self.configuration_controller, "filter_wheel_visibility", [] - ) self.view.populate_frame( channels=self.num, filter_wheels=self.number_of_filter_wheels, filter_wheel_names=self.configuration_controller.filter_wheel_names, filter_wheel_types=filter_wheel_types, - filter_wheel_visibility=filter_wheel_visibility, ) # widget command binds @@ -154,13 +152,14 @@ def set_mode(self, mode="stop"): def initialize(self): """Populates the laser and filter wheel options in the View.""" setting_dict = self.configuration_controller.channels_info + filter_wheel_names = self.configuration_controller.filter_wheel_names for i in range(self.num): self.view.laser_pulldowns[i]["values"] = setting_dict["laser"] for j in range(self.number_of_filter_wheels): - ref_name = f"filter_wheel_{j}" + ref_name = filter_wheel_names[j] self.view.filterwheel_pulldowns[i * self.number_of_filter_wheels + j][ "values" - ] = setting_dict[ref_name] + ] = setting_dict.get(ref_name, "") self.show_verbose_info("channel has been initialized") def populate_experiment_values(self, setting_dict): @@ -185,6 +184,7 @@ def populate_experiment_values(self, setting_dict): self.populate_empty_values() self.channel_setting_dict = setting_dict prefix = "channel_" + filter_wheel_names = self.configuration_controller.filter_wheel_names for channel in setting_dict.keys(): channel_id = int(channel[len(prefix) :]) - 1 channel_vals = self.get_vals_by_channel(channel_id) @@ -192,7 +192,19 @@ def populate_experiment_values(self, setting_dict): return channel_value = setting_dict[channel] for name in channel_vals: - channel_vals[name].set(channel_value[name]) + if channel_value.get(name, None): + # don't set the value if the filter wheel value not in the dropdown options + if name in filter_wheel_names and channel_value[ + name + ] not in self._get_dropdown_values( + self.view.filterwheel_pulldowns[ + channel_id * self.number_of_filter_wheels + + filter_wheel_names.index(name) + ] + ): + continue + + channel_vals[name].set(channel_value[name]) # validate exposure_time, interval, laser_power self.view.exptime_pulldowns[channel_id].trigger_focusout_validation() @@ -347,13 +359,6 @@ def update_setting_dict(setting_dict, widget_name): setting_dict["laser_index"] = self.get_index( "laser", channel_vals["laser"].get() ) - elif widget_name.startswith("filter"): - for i in range(self.number_of_filter_wheels): - ref_name = f"filter_wheel_{i}" - setting_dict[ref_name] = channel_vals[ref_name].get() - setting_dict[f"filter_position_{i}"] = self.get_index( - ref_name, channel_vals[ref_name].get() - ) elif widget_name in [ "laser_power", "camera_exposure_time", @@ -462,8 +467,9 @@ def get_vals_by_channel(self, index): "interval_time": self.view.interval_variables[index], "defocus": self.view.defocus_variables[index], } + filter_wheel_names = self.configuration_controller.filter_wheel_names for i in range(self.number_of_filter_wheels): - ref_name = f"filter_wheel_{i}" + ref_name = filter_wheel_names[i] result[ref_name] = self.view.filterwheel_variables[ index * self.number_of_filter_wheels + i ] diff --git a/src/navigate/model/microscope.py b/src/navigate/model/microscope.py index 75dc6ff06..2a31c1c0b 100644 --- a/src/navigate/model/microscope.py +++ b/src/navigate/model/microscope.py @@ -170,7 +170,7 @@ def __init__( "mirror": ["type"], } - device_name_dict = {"laser": "wavelength"} + device_name_dict = {"laser": "wavelength", "filter_wheel": "name"} laser_list = self.configuration["configuration"]["microscopes"][ self.microscope_name From eef2b25ae28367b0ad79d4a47c7cede47d56cbf4 Mon Sep 17 00:00:00 2001 From: Annie Wang Date: Wed, 11 Mar 2026 21:56:47 -0500 Subject: [PATCH 6/7] update tests --- test/config/test_config.py | 200 ++++++++++++------ .../sub_controllers/test_channels_settings.py | 11 +- 2 files changed, 146 insertions(+), 65 deletions(-) diff --git a/test/config/test_config.py b/test/config/test_config.py index ab8b0d22c..6d3116163 100644 --- a/test/config/test_config.py +++ b/test/config/test_config.py @@ -144,57 +144,6 @@ def test_get_configuration_paths_create_dir(monkeypatch): delete_folder("TESTPATH") -def test_verify_configuration_sets_filter_wheel_visibility(): - manager = Manager() - try: - current_path = os.path.abspath(os.path.dirname(__file__)) - root_path = os.path.dirname(os.path.dirname(current_path)) - config_path = os.path.join(root_path, "src", "navigate", "config") - configuration_file = os.path.join(config_path, "configuration.yaml") - - raw_configuration = load_yaml_file(configuration_file) - microscopes_raw = raw_configuration["microscopes"] - - ref_filter_wheels = [] - filter_wheel_ids_by_microscope = {} - for microscope_name, microscope_config in microscopes_raw.items(): - filter_wheel_config = microscope_config["filter_wheel"] - if isinstance(filter_wheel_config, dict): - filter_wheel_config = [filter_wheel_config] - - filter_wheel_ids = [] - for wheel_config in filter_wheel_config: - filter_wheel_id = config.build_ref_name( - "-", - wheel_config["hardware"]["type"], - wheel_config["hardware"]["wheel_number"], - ) - filter_wheel_ids.append(filter_wheel_id) - if filter_wheel_id not in ref_filter_wheels: - ref_filter_wheels.append(filter_wheel_id) - - filter_wheel_ids_by_microscope[microscope_name] = set(filter_wheel_ids) - - expected_visibility = { - microscope_name: [ - ref_name in filter_wheel_ids_by_microscope[microscope_name] - for ref_name in ref_filter_wheels - ] - for microscope_name in microscopes_raw.keys() - } - - configuration = config.load_configs(manager, configuration=configuration_file) - config.verify_configuration(manager, configuration) - microscopes = configuration["configuration"]["microscopes"] - - for microscope_name, expected in expected_visibility.items(): - visibility = microscopes[microscope_name]["filter_wheel_visibility"] - assert isinstance(visibility, ListProxy) - assert list(visibility) == expected - finally: - manager.shutdown() - - # test that the system is exited if no file is provided to load_yaml_config def test_load_yaml_config_no_file(): """Test that the system exits if no file is provided.""" @@ -317,6 +266,143 @@ def test_update_config_dict_with_file_name(self): os.remove(test_entry) +class TestVerifyConfiguration(unittest.TestCase): + def setUp(self): + self.manager = Manager() + current_path = os.path.abspath(os.path.dirname(__file__)) + root_path = os.path.dirname(os.path.dirname(current_path)) + self.config_path = os.path.join(root_path, "src", "navigate", "config") + + def tearDown(self): + self.manager.shutdown() + + def test_verify_configuration_with_valid_config(self): + configuration = config.load_configs( + self.manager, + configuration=os.path.join(self.config_path, "configuration.yaml"), + ) + + configuration["configuration"]["microscopes"]["Mesoscale"]["filter_wheel"][ + "hardware" + ]["wheel_number"] = 2 + try: + config.verify_configuration(self.manager, configuration) + except Exception as e: + self.fail(f"verify_configuration raised an exception: {e}") + + # assert same filter wheel name + microscope_names = list(configuration["configuration"]["microscopes"].keys()) + filter_wheel_name = None + for microscope_name in microscope_names: + temp = configuration["configuration"]["microscopes"][microscope_name][ + "filter_wheel" + ][0]["name"] + if filter_wheel_name is None: + filter_wheel_name = temp + else: + assert ( + filter_wheel_name == temp + ), "filter wheel names should be the same for all microscopes" + + def test_verify_configuration_with_no_filterwheel(self): + configuration = config.load_configs( + self.manager, + configuration=os.path.join(self.config_path, "configuration.yaml"), + ) + for microscope_name in configuration["configuration"]["microscopes"].keys(): + del configuration["configuration"]["microscopes"][microscope_name][ + "filter_wheel" + ] + + config.verify_configuration(self.manager, configuration) + # assert no filter wheel is added to configuration + for microscope_name in configuration["configuration"]["microscopes"].keys(): + assert ( + "filter_wheel" + not in configuration["configuration"]["microscopes"][ + microscope_name + ].keys() + ) + + def test_verify_configuration_with_one_microscope_has_filterwheel_and_another_microscope_has_no_filterwheel( + self, + ): + configuration = config.load_configs( + self.manager, + configuration=os.path.join(self.config_path, "configuration.yaml"), + ) + microscope_names = list(configuration["configuration"]["microscopes"].keys()) + # delete filter wheel of the first microscope + del configuration["configuration"]["microscopes"][microscope_names[0]][ + "filter_wheel" + ] + + config.verify_configuration(self.manager, configuration) + # assert no filter wheel is added to configuration + assert ( + "filter_wheel" + not in configuration["configuration"]["microscopes"][ + microscope_names[0] + ].keys() + ) + + for i in range(1, len(microscope_names)): + assert ( + "filter_wheel" + in configuration["configuration"]["microscopes"][ + microscope_names[i] + ].keys() + ) + + def test_verify_configuration_with_different_filterwheel_for_different_microscopes( + self, + ): + configuration = config.load_configs( + self.manager, + configuration=os.path.join(self.config_path, "configuration.yaml"), + ) + microscope_names = list(configuration["configuration"]["microscopes"].keys()) + # change filter wheel of the first microscope to have different number of filter wheels and different filter wheel types + configuration["configuration"]["microscopes"][microscope_names[0]][ + "filter_wheel" + ]["hardware"]["type"] = "ASI" + + config.verify_configuration(self.manager, configuration) + # assert each microscope has only one filter wheel and the filter wheel type is correct + assert ( + len( + configuration["configuration"]["microscopes"][microscope_names[0]][ + "filter_wheel" + ] + ) + == 1 + ) + assert configuration["configuration"]["microscopes"][microscope_names[0]][ + "filter_wheel" + ][0]["hardware"]["type"].startswith("ASI") + assert ( + len( + configuration["configuration"]["microscopes"][microscope_names[1]][ + "filter_wheel" + ] + ) + == 1 + ) + assert configuration["configuration"]["microscopes"][microscope_names[1]][ + "filter_wheel" + ][0]["hardware"]["type"].startswith("Sutter") + # assert filter wheel name is unique + filter_wheel_names = [] + for microscope_name in microscope_names: + filter_wheel_name = configuration["configuration"]["microscopes"][ + microscope_name + ]["filter_wheel"][0]["name"] + assert ( + filter_wheel_name not in filter_wheel_names + ), f"filter wheel name {filter_wheel_name} is not unique" + filter_wheel_names.append(filter_wheel_name) + + class TestVerifyExperimentConfig(unittest.TestCase): def setUp(self): self.manager = Manager() @@ -774,8 +860,7 @@ def test_load_experiment_file_with_wrong_parameter_values(self): "is_selected": 1, "laser": "48nm", "laser_index": -1, - "filter_wheel_0": "nonexsit_filter_***", - "filter_position_0": 1, + "FilterWheel-0": "nonexsit_filter_***", "camera_exposure_time": -200.0, "laser_power": "a", "interval_time": -3, @@ -786,8 +871,7 @@ def test_load_experiment_file_with_wrong_parameter_values(self): "is_selected": False, "laser": lasers[0], "laser_index": 0, - "filter_wheel_0": filterwheels[0], - "filter_position_0": 0, + "FilterWheel-0": filterwheels[0], "camera_exposure_time": 200.0, "laser_power": 20.0, "interval_time": 0.0, @@ -810,8 +894,7 @@ def test_load_experiment_file_with_wrong_parameter_values(self): "is_selected": 1, "laser": lasers[1], "laser_index": 3, - "filter_wheel_0": filterwheels[2], - "filter_position_0": 1, + "FilterWheel-0": filterwheels[2], "camera_exposure_time": -200.0, "laser_power": "a", "interval_time": -3, @@ -822,8 +905,7 @@ def test_load_experiment_file_with_wrong_parameter_values(self): "is_selected": False, "laser": lasers[1], "laser_index": 1, - "filter_wheel_0": filterwheels[2], - "filter_position_0": 2, + "FilterWheel-0": filterwheels[2], "camera_exposure_time": 200.0, "laser_power": 20.0, "interval_time": 0.0, diff --git a/test/controller/sub_controllers/test_channels_settings.py b/test/controller/sub_controllers/test_channels_settings.py index f47afcb01..2206d4c3f 100644 --- a/test/controller/sub_controllers/test_channels_settings.py +++ b/test/controller/sub_controllers/test_channels_settings.py @@ -97,6 +97,8 @@ def test_set_mode(self, mode, state, state_readonly): assert ( str(self.channel_setting.view.filterwheel_pulldowns[i]["state"]) == state_readonly + if mode == "stop" + else "disabled" ) assert str(self.channel_setting.view.defocus_spins[i]["state"]) == state @@ -147,11 +149,6 @@ def test_channel_callback(self): assert ( setting_dict["laser_index"] == new_setting_dict["laser_index"] ) - elif k == "filter": - assert ( - setting_dict["filter_position"] - == new_setting_dict["filter_position"] - ) elif k == "camera_exposure_time" or k == "is_selected": assert ( self.channel_setting.parent_controller.commands.pop() @@ -179,7 +176,9 @@ def test_populate_empty_values_with_empty_dropdowns(self): self.channel_setting.populate_empty_values() - assert self.channel_setting.view.laser_variables[0].get() == "invalid_laser_value" + assert ( + self.channel_setting.view.laser_variables[0].get() == "invalid_laser_value" + ) assert ( self.channel_setting.view.filterwheel_variables[0].get() == "invalid_filter_value" From 1f4143d92e6c2f255c4f31b431d4dace559da96e Mon Sep 17 00:00:00 2001 From: Kevin Dean <> Date: Fri, 13 Mar 2026 14:49:11 -0500 Subject: [PATCH 7/7] support no filter wheel device Co-Authored-By: Annie Wang <6161065+annie-xd-wang@users.noreply.github.com> --- src/navigate/config/config.py | 4 ++-- src/navigate/controller/configuration_controller.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/navigate/config/config.py b/src/navigate/config/config.py index 5fb187378..db991944a 100644 --- a/src/navigate/config/config.py +++ b/src/navigate/config/config.py @@ -611,13 +611,13 @@ def verify_experiment_config(manager, configuration): ] ] number_of_filter_wheels = len( - configuration["configuration"]["microscopes"][microscope_name]["filter_wheel"] + configuration["configuration"]["microscopes"][microscope_name].get("filter_wheel", []) ) filterwheel_list = [ list(filter_wheel_config["available_filters"].keys()) for filter_wheel_config in configuration["configuration"]["microscopes"][ microscope_name - ]["filter_wheel"] + ].get("filter_wheel", []) ] prefix = "channel_" channel_nums = configuration["configuration"]["gui"]["channels"]["count"] diff --git a/src/navigate/controller/configuration_controller.py b/src/navigate/controller/configuration_controller.py index 9a732b96a..9d21f50d8 100644 --- a/src/navigate/controller/configuration_controller.py +++ b/src/navigate/controller/configuration_controller.py @@ -142,7 +142,7 @@ def channels_info(self) -> dict: setting = { "laser": self.lasers_info, } - for i, filter_wheel_config in enumerate(self.microscope_config["filter_wheel"]): + for i, filter_wheel_config in enumerate(self.microscope_config.get("filter_wheel", [])): filter_wheel_name = filter_wheel_config.get("name", f"FilterWheel-{i}") setting[filter_wheel_name] = list( filter_wheel_config["available_filters"].keys() @@ -533,7 +533,7 @@ def number_of_filter_wheels(self) -> int: """ if self.microscope_config is not None: - return len(self.microscope_config["filter_wheel"]) + return len(self.microscope_config.get("filter_wheel", [])) return 1 @property