From 3a0eaef30db13b25b3f381efd93c2308d91a7e4b Mon Sep 17 00:00:00 2001 From: tyler Date: Tue, 24 Mar 2026 11:04:14 -0700 Subject: [PATCH] new saving format again --- changelog.md | 4 +- main.py | 271 ++++++++++++++++++++++++++++++++++----------------- 2 files changed, 182 insertions(+), 93 deletions(-) diff --git a/changelog.md b/changelog.md index 9f1d852..26ad3f7 100644 --- a/changelog.md +++ b/changelog.md @@ -1,7 +1,9 @@ # Version 1.4.0 -- This is potentially a save-changing release due to changes in how file paths are stored. Please update your project files to ensure compatibility +- This is potentially a save-changing release due to changes in how file paths and parameters are stored. Please update your project files to ensure compatibility - It is still possible to load older saves by enabling 'Incompatible Save Bypass' from the Preferences menu, but your mileage may vary +- AGE, GENDER, GROUP, loaded files, and all the parameters on the right side of the screen can now be saved before any data has been processed +- If the values fail to load, they will fallback to the previous logic of retreiving these values after processing has occured. Fixes [Issue 66](https://git.research.dezeeuw.ca/tyler/flares/issues/66) - Added new parameters to the right side of the screen: MAX_SHIFT, T_MIN, T_MAX. Fixes [Issue 69](https://git.research.dezeeuw.ca/tyler/flares/issues/69) - Added feedback when clicking an analysis option that opens up a new window. Fixes [Issue 20](https://git.research.dezeeuw.ca/tyler/flares/issues/20) - Fixed an issue where projects can not be saved to a different drive letter on windows. Fixes [Issue 71](https://git.research.dezeeuw.ca/tyler/flares/issues/71) diff --git a/main.py b/main.py index 18e8de2..f1a5bb1 100644 --- a/main.py +++ b/main.py @@ -1812,6 +1812,19 @@ class ParamSection(QWidget): self.update_dependencies() + + def has_any_changes(self): + """Returns True if any parameter in this section differs from its default.""" + for name, info in self.widgets.items(): + default = info["default"] + + current_val = self.get_param_values().get(name) + + if str(current_val) != str(default): + return True + return False + + def check_if_changed(self, param_name, current_value): """Toggles bold font on the label if the value differs from default.""" info = self.widgets.get(param_name) @@ -4581,7 +4594,22 @@ class MainApplication(QMainWindow): self.missing_events_bypass = False self.analysis_clearing_bypass = False - self.metadata_cache = {} # { "file_path": {metadata_dict} } + + # Initialization to ensure that saving can occur + self.raw_haemo_dict = {} # Processed Hemodynamic data + self.config_dict = {} # Analysis parameters/settings + self.epochs_dict = {} # Timing/Event data + self.cha_dict = {} # Channel configurations + self.contrast_results_dict = {} # Statistical results + self.df_ind_dict = {} # Individual dataframes + self.design_matrix_dict = {} # GLM Design matrices + self.valid_dict = {} # Quality control/Validity flags + self.fig_bytes_dict = {} # Cached plot images (serialized) + self.file_metadata = {} # AGE, GENDER, GROUP + self.metadata_cache = {} # Internal file/path information metadata cache + self.bubble_widgets = {} # References to the UI "Bubble" objects + self.current_file = None # Tracks the currently selected absolute path + self.metadata_processed.connect(self._safe_ui_update) self.files_total = 0 # total number of files to process @@ -4609,9 +4637,6 @@ class MainApplication(QMainWindow): self.last_clicked_bubble = None self.installEventFilter(self) - self.file_metadata = {} - self.current_file = None - # Start local pending update check thread self.local_check_thread = LocalPendingUpdateCheckThread(CURRENT_VERSION, self.platform_suffix, PLATFORM_NAME, APP_NAME) self.local_check_thread.pending_update_found.connect(self.updater.on_pending_update_found) @@ -5088,48 +5113,62 @@ class MainApplication(QMainWindow): # TODO: Is this needed? - def open_multiple_folders_dialog(self): - while True: - folder_path = QFileDialog.getExistingDirectory(self, "Select Folder") - if not folder_path: - break + # def open_multiple_folders_dialog(self): + # while True: + # folder_path = QFileDialog.getExistingDirectory(self, "Select Folder") + # if not folder_path: + # break - snirf_files = [str(f) for f in Path(folder_path).glob("*.snirf")] + # snirf_files = [str(f) for f in Path(folder_path).glob("*.snirf")] - if not hasattr(self, 'selected_paths'): - self.selected_paths = [] + # if not hasattr(self, 'selected_paths'): + # self.selected_paths = [] - for file_path in snirf_files: - if file_path not in self.selected_paths: - self.selected_paths.append(file_path) + # for file_path in snirf_files: + # if file_path not in self.selected_paths: + # self.selected_paths.append(file_path) - self.show_files_as_bubbles(folder_path) + # self.show_files_as_bubbles(folder_path) - # for section_widget in self.param_sections: - # if hasattr(section_widget, 'update_annotation_dropdown_from_loaded_files'): - # if "REMOVE_EVENTS" in section_widget.widgets: - # section_widget.update_annotation_dropdown_from_loaded_files(self.bubble_widgets, self.button1) - # break - # else: - # print("[MainWindow] Could not find ParamSection with 'REMOVE_EVENTS' widget") + # # for section_widget in self.param_sections: + # # if hasattr(section_widget, 'update_annotation_dropdown_from_loaded_files'): + # # if "REMOVE_EVENTS" in section_widget.widgets: + # # section_widget.update_annotation_dropdown_from_loaded_files(self.bubble_widgets, self.button1) + # # break + # # else: + # # print("[MainWindow] Could not find ParamSection with 'REMOVE_EVENTS' widget") - # Ask if the user wants to add another - more = QMessageBox.question( - self, - "Add Another?", - "Do you want to select another folder?", - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, - ) - if more == QMessageBox.StandardButton.No: - break + # # Ask if the user wants to add another + # more = QMessageBox.question( + # self, + # "Add Another?", + # "Do you want to select another folder?", + # QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + # ) + # if more == QMessageBox.StandardButton.No: + # break - self.button1.setVisible(True) + # self.button1.setVisible(True) def save_project(self, onCrash=False): - if not getattr(self, 'raw_haemo_dict', None): + if hasattr(self, 'current_file') and self.current_file: + self.file_metadata[self.current_file] = { + key: field.text().strip() for key, field in self.meta_fields.items() + } + + has_metadata = any( + any(val for val in meta.values()) + for meta in self.file_metadata.values() + ) + has_param_changes = any(section.has_any_changes() for section in self.param_sections) + + # Check if there is processed data + has_processed_data = bool(getattr(self, 'raw_haemo_dict', None)) + + if not (has_processed_data or has_metadata or has_param_changes): if not onCrash: # Don't show popups during a crash/autosave QMessageBox.warning( self, @@ -5137,6 +5176,11 @@ class MainApplication(QMainWindow): "There is no processed data to save. Please process some data before saving." ) return + + if hasattr(self, 'current_file') and self.current_file: + self.file_metadata[self.current_file] = { + key: field.text() for key, field in self.meta_fields.items() + } if not onCrash: filename, _ = QFileDialog.getSaveFileName( @@ -5179,6 +5223,19 @@ class MainApplication(QMainWindow): print(rel_metadata) + rel_file_params = { + self._get_safe_path(f_path, project_dir): meta + for f_path, meta in self.file_metadata.items() + } + + + current_params = self.get_all_current_ui_params() + + # fallback - if UI reading fails, try the first processed file's config + if not current_params and self.config_dict: + first_file = next(iter(self.config_dict.keys())) + current_params = self.config_dict[first_file] + version = CURRENT_VERSION project_data = { "version": version, @@ -5186,10 +5243,12 @@ class MainApplication(QMainWindow): "progress_states": progress_states, "raw_haemo_dict": self.raw_haemo_dict, "file_metadata": rel_metadata, + "file_parameters": rel_file_params, "config_dict": self.config_dict, "epochs_dict": self.epochs_dict, "fig_bytes_dict": self.fig_bytes_dict, "cha_dict": self.cha_dict, + "current_ui_params": current_params, "contrast_results_dict": self.contrast_results_dict, "df_ind_dict": self.df_ind_dict, "design_matrix_dict": self.design_matrix_dict, @@ -5257,7 +5316,8 @@ class MainApplication(QMainWindow): # Check for potentially broken saves checks = [ ("version", "<=1.1.7"), - ("file_metadata", "<=1.2.2") + ("file_metadata", "<=1.2.2"), + ("file_params", "<=1.3.0") ] for key, ver_str in checks: @@ -5289,7 +5349,9 @@ class MainApplication(QMainWindow): project_dir = Path(filename).parent saved_cache = data.get("file_metadata", {}) + raw_params = data.get("file_parameters", {}) self.metadata_cache = {} + self.file_metadata = {} for rel_path, meta_content in saved_cache.items(): abs_path = str((project_dir / Path(rel_path)).resolve()) @@ -5307,29 +5369,43 @@ class MainApplication(QMainWindow): self.show_files_as_bubbles_from_list(file_list, progress_states, filename) - for file_path, config in self.config_dict.items(): - # Only store AGE, GENDER, GROUP - self.file_metadata[file_path] = { - key: str(config.get(key, "")) # convert to str for QLineEdit - for key in ["AGE", "GENDER", "GROUP"] - } + for rel_path in data["file_list"]: + abs_path = str((project_dir / Path(rel_path)).resolve()) + + if rel_path in raw_params: + # Scenario A: New format found + self.file_metadata[abs_path] = raw_params[rel_path] + elif abs_path in self.config_dict: + # Scenario B: Fallback to old config_dict + old_cfg = self.config_dict[abs_path] + self.file_metadata[abs_path] = { + "AGE": str(old_cfg.get("AGE", "")), + "GENDER": str(old_cfg.get("GENDER", "")), + "GROUP": str(old_cfg.get("GROUP", "")) + } + else: + # Scenario C: Empty default + self.file_metadata[abs_path] = {"AGE": "", "GENDER": "", "GROUP": ""} - if self.config_dict: - first_file = next(iter(self.config_dict.keys())) - self.current_file = first_file - - # Update meta fields (AGE/GENDER/GROUP) - for key, field in self.meta_fields.items(): - field.setText(self.file_metadata[first_file][key]) + if file_list: + self.current_file = file_list[0] self.right_column_widget.show() + + # Update Metadata fields (Age/Gender/Group) for the selected file + curr_meta = self.file_metadata.get(self.current_file, {"AGE": "", "GENDER": "", "GROUP": ""}) + for key, field in self.meta_fields.items(): + field.setText(curr_meta.get(key, "")) - # Restore all other constants to the parameter sections - first_config = self.config_dict[first_file] - self.restore_sections_from_config(first_config) + if "current_ui_params" in data: + self.restore_sections_from_config(data["current_ui_params"]) + + elif self.config_dict: + first_file = next(iter(self.config_dict.keys())) + self.restore_sections_from_config(self.config_dict[first_file]) - # Re-enable buttons - # self.button1.setVisible(True) - self.button3.setVisible(True) + has_data = bool(self.raw_haemo_dict) + self.button1.setVisible(not has_data) + self.button3.setVisible(has_data) QMessageBox.information(self, "Loaded", f"Project loaded from:\n{filename}") @@ -5384,58 +5460,69 @@ class MainApplication(QMainWindow): section_widget.update_dependencies() + # def show_files_as_bubbles(self, folder_paths): - def show_files_as_bubbles(self, folder_paths): + # if isinstance(folder_paths, str): + # folder_paths = [folder_paths] - if isinstance(folder_paths, str): - folder_paths = [folder_paths] + # # Clear previous bubbles + # # while self.bubble_layout.count(): + # # item = self.bubble_layout.takeAt(0) + # # widget = item.widget() + # # if widget: + # # widget.deleteLater() - # Clear previous bubbles - # while self.bubble_layout.count(): - # item = self.bubble_layout.takeAt(0) - # widget = item.widget() - # if widget: - # widget.deleteLater() + # temp_bubble = ProgressBubble("Test Bubble", "") # A dummy bubble for measurement + # temp_bubble.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy. Preferred) + # # temp_bubble.setAttribute(Qt.WA_OpaquePaintEvent) # Improve rendering? + # temp_bubble.adjustSize() # Adjust size after the widget is created + # bubble_width = temp_bubble.width() # Get the actual width of a bubble + # available_width = self.bubble_container.width() - temp_bubble = ProgressBubble("Test Bubble", "") # A dummy bubble for measurement - temp_bubble.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy. Preferred) - # temp_bubble.setAttribute(Qt.WA_OpaquePaintEvent) # Improve rendering? - temp_bubble.adjustSize() # Adjust size after the widget is created - bubble_width = temp_bubble.width() # Get the actual width of a bubble - available_width = self.bubble_container.width() + # cols = max(1, available_width // bubble_width) # Ensure at least 1 column - cols = max(1, available_width // bubble_width) # Ensure at least 1 column - - index = 0 - if not hasattr(self, 'selected_paths'): - self.selected_paths = [] + # index = 0 + # if not hasattr(self, 'selected_paths'): + # self.selected_paths = [] - for folder_path in folder_paths: - if not os.path.isdir(folder_path): - continue + # for folder_path in folder_paths: + # if not os.path.isdir(folder_path): + # continue - snirf_files = [str(f) for f in Path(folder_path).glob("*.snirf")] + # snirf_files = [str(f) for f in Path(folder_path).glob("*.snirf")] - for full_path in snirf_files: + # for full_path in snirf_files: - display_name = f"{os.path.basename(folder_path)} / {os.path.basename(full_path)}" - bubble = ProgressBubble(display_name, full_path) - bubble.set_loading_state(True) - bubble.setCursor(Qt.CursorShape.WaitCursor) + # display_name = f"{os.path.basename(folder_path)} / {os.path.basename(full_path)}" + # bubble = ProgressBubble(display_name, full_path) + # bubble.set_loading_state(True) + # bubble.setCursor(Qt.CursorShape.WaitCursor) - self.bubble_widgets[full_path] = bubble + # self.bubble_widgets[full_path] = bubble - if full_path not in self.selected_paths: - self.selected_paths.append(full_path) + # if full_path not in self.selected_paths: + # self.selected_paths.append(full_path) - row = index // cols - col = index % cols - self.bubble_layout.addWidget(bubble, row, col) - index += 1 - - self.statusBar().showMessage(f"{index} file(s) loaded from: {', '.join(folder_paths)}") + # row = index // cols + # col = index % cols + # self.bubble_layout.addWidget(bubble, row, col) + # index += 1 + # self.statusBar().showMessage(f"{index} file(s) loaded from: {', '.join(folder_paths)}") + def get_all_current_ui_params(self): + """Gathers current values from all UI widgets across all sections.""" + current_ui_config = {} + try: + for section in self.param_sections: + # This calls the get_param_values() method you shared earlier + section_values = section.get_param_values() + current_ui_config.update(section_values) + return current_ui_config + except Exception as e: + print(f"Error reading UI parameters: {e}") + return None + def show_files_as_bubbles_from_list(self, file_list, progress_states=None, filenames=None): if not hasattr(self, 'file_executor') or self.file_executor is None: self.file_executor = concurrent.futures.ProcessPoolExecutor(max_workers=1)