From ae425a0832516f84490847b4bd10151036e93eac Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 16 Dec 2025 22:46:12 +0000 Subject: [PATCH 01/23] Initial plan From fc7b8ebca9025769bffc75016e1366c5866a96d7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 16 Dec 2025 22:59:06 +0000 Subject: [PATCH 02/23] feat: add debug manager and directory setting Co-authored-by: lachlangrose <7371904+lachlangrose@users.noreply.github.com> --- loopstructural/debug_manager.py | 178 ++++++++++++++++++ loopstructural/gui/dlg_settings.py | 27 +++ loopstructural/gui/dlg_settings.ui | 42 ++++- .../map2loop_tools/basal_contacts_widget.py | 26 ++- loopstructural/gui/map2loop_tools/dialogs.py | 51 ++++- .../gui/map2loop_tools/sampler_widget.py | 32 +++- .../gui/map2loop_tools/sorter_widget.py | 30 ++- .../thickness_calculator_widget.py | 31 ++- .../user_defined_sorter_widget.py | 30 ++- loopstructural/plugin_main.py | 32 +++- loopstructural/toolbelt/preferences.py | 11 ++ tests/qgis/test_plg_preferences.py | 3 + 12 files changed, 466 insertions(+), 27 deletions(-) create mode 100644 loopstructural/debug_manager.py diff --git a/loopstructural/debug_manager.py b/loopstructural/debug_manager.py new file mode 100644 index 0000000..dba387d --- /dev/null +++ b/loopstructural/debug_manager.py @@ -0,0 +1,178 @@ +#! python3 + +"""Debug manager handling logging and debug directory management.""" + +# standard +import datetime +import json +import os +import tempfile +import uuid +from pathlib import Path +from typing import Any + +# PyQGIS +from qgis.core import QgsProject + +# project +import loopstructural.toolbelt.preferences as plg_prefs_hdlr + + +class DebugManager: + """Manage debug mode state, logging and debug file storage.""" + + def __init__(self, plugin): + self.plugin = plugin + self._session_dir = None + self._session_id = uuid.uuid4().hex + self._project_name = self._get_project_name() + self._debug_state_logged = False + + def _get_settings(self): + return plg_prefs_hdlr.PlgOptionsManager.get_plg_settings() + + def _get_project_name(self) -> str: + try: + proj = QgsProject.instance() + title = proj.title() + if title: + return title + stem = Path(proj.fileName() or "").stem + return stem or "untitled_project" + except Exception as err: + self.plugin.log( + message=f"[map2loop] Failed to resolve project name: {err}", + log_level=1, + ) + return "unknown_project" + + def is_debug(self) -> bool: + """Return whether debug mode is enabled.""" + try: + state = bool(self._get_settings().debug_mode) + if not self._debug_state_logged: + self.plugin.log( + message=f"[map2loop] Debug mode: {'ON' if state else 'OFF'}", + log_level=0, + ) + self._debug_state_logged = True + return state + except Exception as err: + self.plugin.log( + message=f"[map2loop] Error checking debug mode: {err}", + log_level=2, + ) + return False + + def get_effective_debug_dir(self) -> Path: + """Return the session debug directory, creating it if needed.""" + if self._session_dir is not None: + return self._session_dir + + try: + debug_dir_pref = plg_prefs_hdlr.PlgOptionsManager.get_debug_directory() + except Exception as err: + self.plugin.log( + message=f"[map2loop] Reading debug_directory failed: {err}", + log_level=1, + ) + debug_dir_pref = "" + + base_dir = ( + Path(debug_dir_pref).expanduser() + if str(debug_dir_pref).strip() + else Path(tempfile.gettempdir()) / "map2loop_debug" + ) + + ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") + session_dir = base_dir / self._project_name / f"session_{self._session_id}_{ts}" + + try: + session_dir.mkdir(parents=True, exist_ok=True) + except Exception as err: + self.plugin.log( + message=( + f"[map2loop] Failed to create session dir '{session_dir}': {err}. " + "Falling back to system temp." + ), + log_level=1, + ) + fallback = ( + Path(tempfile.gettempdir()) + / "map2loop_debug" + / self._project_name + / f"session_{self._session_id}_{ts}" + ) + try: + fallback.mkdir(parents=True, exist_ok=True) + except Exception as err_fallback: + self.plugin.log( + message=( + f"[map2loop] Failed to create fallback debug dir '{fallback}': " + f"{err_fallback}" + ), + log_level=2, + ) + fallback = Path(tempfile.gettempdir()) + session_dir = fallback + + self._session_dir = session_dir + self.plugin.log( + message=f"[map2loop] Debug directory resolved: {session_dir}", + log_level=0, + ) + return self._session_dir + + def log_params(self, context_label: str, params: Any): + """Log parameters and persist them when debug mode is enabled.""" + try: + self.plugin.log( + message=f"[map2loop] {context_label} parameters: {params}", + log_level=0, + ) + except Exception as err: + self.plugin.log( + message=( + f"[map2loop] {context_label} parameters (stringified due to {err}): {params}" + ), + log_level=0, + ) + + if self.is_debug(): + try: + debug_dir = self.get_effective_debug_dir() + safe_label = context_label.replace(" ", "_").lower() + file_path = debug_dir / f"{safe_label}_params.json" + payload = params if isinstance(params, dict) else {"_payload": params} + with open(file_path, "w", encoding="utf-8") as file_handle: + json.dump(payload, file_handle, ensure_ascii=False, indent=2, default=str) + self.plugin.log( + message=f"[map2loop] Params saved to: {file_path}", + log_level=0, + ) + except Exception as err: + self.plugin.log( + message=f"[map2loop] Failed to save params for {context_label}: {err}", + log_level=2, + ) + + def save_debug_file(self, filename: str, content_bytes: bytes): + """Persist a debug file atomically and log its location.""" + try: + debug_dir = self.get_effective_debug_dir() + out_path = debug_dir / filename + tmp_path = debug_dir / (filename + ".tmp") + with open(tmp_path, "wb") as file_handle: + file_handle.write(content_bytes) + os.replace(tmp_path, out_path) + self.plugin.log( + message=f"[map2loop] Debug file saved: {out_path}", + log_level=0, + ) + return out_path + except Exception as err: + self.plugin.log( + message=f"[map2loop] Failed to save debug file '{filename}': {err}", + log_level=2, + ) + return None diff --git a/loopstructural/gui/dlg_settings.py b/loopstructural/gui/dlg_settings.py index c4caaaf..d9feeea 100644 --- a/loopstructural/gui/dlg_settings.py +++ b/loopstructural/gui/dlg_settings.py @@ -76,6 +76,11 @@ def __init__(self, parent): self.btn_reset.setIcon(QIcon(QgsApplication.iconPath("mActionUndo.svg"))) self.btn_reset.pressed.connect(self.reset_settings) + if hasattr(self, "btn_browse_debug_directory"): + self.btn_browse_debug_directory.pressed.connect(self._browse_debug_directory) + if hasattr(self, "btn_open_debug_directory"): + self.btn_open_debug_directory.pressed.connect(self._open_debug_directory) + # load previously saved settings self.load_settings() @@ -91,6 +96,9 @@ def apply(self): settings.interpolator_cpw = self.cpw_spin_box.value() settings.interpolator_regularisation = self.regularisation_spin_box.value() settings.version = __version__ + debug_dir_text = (self.le_debug_directory.text() if hasattr(self, "le_debug_directory") else "") or "" + self.plg_settings.set_debug_directory(debug_dir_text) + settings.debug_directory = debug_dir_text # dump new settings into QgsSettings self.plg_settings.save_from_object(settings) @@ -114,6 +122,8 @@ def load_settings(self): self.regularisation_spin_box.setValue(settings.interpolator_regularisation) self.cpw_spin_box.setValue(settings.interpolator_cpw) self.npw_spin_box.setValue(settings.interpolator_npw) + if hasattr(self, "le_debug_directory"): + self.le_debug_directory.setText(settings.debug_directory or "") def reset_settings(self): """Reset settings in the UI and persisted settings to plugin defaults.""" @@ -125,6 +135,23 @@ def reset_settings(self): # update the form self.load_settings() + def _browse_debug_directory(self): + """Open a directory selector for debug directory.""" + from qgis.PyQt.QtWidgets import QFileDialog + + start_dir = (self.le_debug_directory.text() if hasattr(self, "le_debug_directory") else "") or "" + chosen = QFileDialog.getExistingDirectory(self, "Select Debug Files Directory", start_dir) + if chosen and hasattr(self, "le_debug_directory"): + self.le_debug_directory.setText(chosen) + + def _open_debug_directory(self): + """Open configured debug directory in the system file manager.""" + target = self.plg_settings.get_debug_directory() or "" + if target: + QDesktopServices.openUrl(QUrl.fromLocalFile(target)) + else: + self.log(message="[map2loop] No debug directory configured.", log_level=1) + class PlgOptionsFactory(QgsOptionsWidgetFactory): """Factory for options widget.""" diff --git a/loopstructural/gui/dlg_settings.ui b/loopstructural/gui/dlg_settings.ui index edba4d3..9dae23a 100644 --- a/loopstructural/gui/dlg_settings.ui +++ b/loopstructural/gui/dlg_settings.ui @@ -78,9 +78,9 @@ false - - - + + + 200 25 @@ -100,8 +100,8 @@ - - + + 0 @@ -147,8 +147,36 @@ - - + + + + Debug directory + + + + + + + + + + + + Browse... + + + + + + + Open Folder + + + + + + + 200 diff --git a/loopstructural/gui/map2loop_tools/basal_contacts_widget.py b/loopstructural/gui/map2loop_tools/basal_contacts_widget.py index 0141acd..56f3a42 100644 --- a/loopstructural/gui/map2loop_tools/basal_contacts_widget.py +++ b/loopstructural/gui/map2loop_tools/basal_contacts_widget.py @@ -17,7 +17,7 @@ class BasalContactsWidget(QWidget): from geology layers. """ - def __init__(self, parent=None, data_manager=None): + def __init__(self, parent=None, data_manager=None, debug_manager=None): """Initialize the basal contacts widget. Parameters @@ -29,6 +29,7 @@ def __init__(self, parent=None, data_manager=None): """ super().__init__(parent) self.data_manager = data_manager + self._debug = debug_manager # Load the UI file ui_path = os.path.join(os.path.dirname(__file__), "basal_contacts_widget.ui") @@ -62,6 +63,17 @@ def __init__(self, parent=None, data_manager=None): # Set up field combo boxes self._setup_field_combo_boxes() + def set_debug_manager(self, debug_manager): + """Attach a debug manager instance.""" + self._debug = debug_manager + + def _log_params(self, context_label: str): + if getattr(self, "_debug", None): + try: + self._debug.log_params(context_label=context_label, params=self.get_parameters()) + except Exception: + pass + def _guess_layers(self): """Attempt to auto-select layers based on common naming conventions.""" if not self.data_manager: @@ -113,6 +125,8 @@ def _on_geology_layer_changed(self): def _run_extractor(self): """Run the basal contacts extraction algorithm.""" + self._log_params("basal_contacts_widget_run") + # Validate inputs if not self.geologyLayerComboBox.currentLayer(): QMessageBox.warning(self, "Missing Input", "Please select a geology layer.") @@ -160,6 +174,16 @@ def _run_extractor(self): "Success", f"Successfully extracted {contact_type}!", ) + if self._debug and self._debug.is_debug(): + try: + self._debug.save_debug_file( + "basal_contacts_result.txt", str(result).encode("utf-8") + ) + except Exception as err: + self._debug.plugin.log( + message=f"[map2loop] Failed to save basal contacts debug output: {err}", + log_level=2, + ) def get_parameters(self): """Get current widget parameters. diff --git a/loopstructural/gui/map2loop_tools/dialogs.py b/loopstructural/gui/map2loop_tools/dialogs.py index fc4be07..31eaca1 100644 --- a/loopstructural/gui/map2loop_tools/dialogs.py +++ b/loopstructural/gui/map2loop_tools/dialogs.py @@ -10,11 +10,12 @@ class SamplerDialog(QDialog): """Dialog for running samplers using map2loop classes directly.""" - def __init__(self, parent=None, data_manager=None): + def __init__(self, parent=None, data_manager=None, debug_manager=None): """Initialize the sampler dialog.""" super().__init__(parent) self.setWindowTitle("Map2Loop Sampler") self.data_manager = data_manager + self.debug_manager = debug_manager self.setup_ui() def setup_ui(self): @@ -22,7 +23,9 @@ def setup_ui(self): from .sampler_widget import SamplerWidget layout = QVBoxLayout(self) - self.widget = SamplerWidget(self) + self.widget = SamplerWidget(self, data_manager=self.data_manager, debug_manager=self.debug_manager) + if hasattr(self.widget, "set_debug_manager"): + self.widget.set_debug_manager(self.debug_manager) layout.addWidget(self.widget) # Replace the run button with dialog buttons @@ -42,11 +45,12 @@ def _run_and_accept(self): class SorterDialog(QDialog): """Dialog for running stratigraphic sorter using map2loop classes directly.""" - def __init__(self, parent=None, data_manager=None): + def __init__(self, parent=None, data_manager=None, debug_manager=None): """Initialize the sorter dialog.""" super().__init__(parent) self.setWindowTitle("Map2Loop Automatic Stratigraphic Sorter") self.data_manager = data_manager + self.debug_manager = debug_manager self.setup_ui() def setup_ui(self): @@ -54,7 +58,13 @@ def setup_ui(self): from .sorter_widget import SorterWidget layout = QVBoxLayout(self) - self.widget = SorterWidget(self, data_manager=self.data_manager) + self.widget = SorterWidget( + self, + data_manager=self.data_manager, + debug_manager=self.debug_manager, + ) + if hasattr(self.widget, "set_debug_manager"): + self.widget.set_debug_manager(self.debug_manager) layout.addWidget(self.widget) # Replace the run button with dialog buttons @@ -73,11 +83,12 @@ def _run_and_accept(self): class UserDefinedSorterDialog(QDialog): """Dialog for user-defined stratigraphic column using map2loop classes directly.""" - def __init__(self, parent=None, data_manager=None): + def __init__(self, parent=None, data_manager=None, debug_manager=None): """Initialize the user-defined sorter dialog.""" super().__init__(parent) self.setWindowTitle("Map2Loop User-Defined Stratigraphic Column") self.data_manager = data_manager + self.debug_manager = debug_manager self.setup_ui() @@ -86,7 +97,13 @@ def setup_ui(self): from .user_defined_sorter_widget import UserDefinedSorterWidget layout = QVBoxLayout(self) - self.widget = UserDefinedSorterWidget(self, data_manager=self.data_manager) + self.widget = UserDefinedSorterWidget( + self, + data_manager=self.data_manager, + debug_manager=self.debug_manager, + ) + if hasattr(self.widget, "set_debug_manager"): + self.widget.set_debug_manager(self.debug_manager) layout.addWidget(self.widget) # Replace the run button with dialog buttons @@ -105,11 +122,12 @@ def _run_and_accept(self): class BasalContactsDialog(QDialog): """Dialog for extracting basal contacts using map2loop classes directly.""" - def __init__(self, parent=None, data_manager=None): + def __init__(self, parent=None, data_manager=None, debug_manager=None): """Initialize the basal contacts dialog.""" super().__init__(parent) self.setWindowTitle("Map2Loop Basal Contacts Extractor") self.data_manager = data_manager + self.debug_manager = debug_manager self.setup_ui() def setup_ui(self): @@ -117,7 +135,13 @@ def setup_ui(self): from .basal_contacts_widget import BasalContactsWidget layout = QVBoxLayout(self) - self.widget = BasalContactsWidget(self, data_manager=self.data_manager) + self.widget = BasalContactsWidget( + self, + data_manager=self.data_manager, + debug_manager=self.debug_manager, + ) + if hasattr(self.widget, "set_debug_manager"): + self.widget.set_debug_manager(self.debug_manager) layout.addWidget(self.widget) # Replace the run button with dialog buttons @@ -136,11 +160,12 @@ def _run_and_accept(self): class ThicknessCalculatorDialog(QDialog): """Dialog for calculating thickness using map2loop classes directly.""" - def __init__(self, parent=None, data_manager=None): + def __init__(self, parent=None, data_manager=None, debug_manager=None): """Initialize the thickness calculator dialog.""" super().__init__(parent) self.setWindowTitle("Map2Loop Thickness Calculator") self.data_manager = data_manager + self.debug_manager = debug_manager self.setup_ui() def setup_ui(self): @@ -148,7 +173,13 @@ def setup_ui(self): from .thickness_calculator_widget import ThicknessCalculatorWidget layout = QVBoxLayout(self) - self.widget = ThicknessCalculatorWidget(self,data_manager=self.data_manager) + self.widget = ThicknessCalculatorWidget( + self, + data_manager=self.data_manager, + debug_manager=self.debug_manager, + ) + if hasattr(self.widget, "set_debug_manager"): + self.widget.set_debug_manager(self.debug_manager) layout.addWidget(self.widget) # Replace the run button with dialog buttons diff --git a/loopstructural/gui/map2loop_tools/sampler_widget.py b/loopstructural/gui/map2loop_tools/sampler_widget.py index dfdb9c4..fd942b3 100644 --- a/loopstructural/gui/map2loop_tools/sampler_widget.py +++ b/loopstructural/gui/map2loop_tools/sampler_widget.py @@ -15,7 +15,7 @@ class SamplerWidget(QWidget): (Decimator and Spacing). """ - def __init__(self, parent=None, data_manager=None): + def __init__(self, parent=None, data_manager=None, debug_manager=None): """Initialize the sampler widget. Parameters @@ -27,6 +27,7 @@ def __init__(self, parent=None, data_manager=None): """ super().__init__(parent) self.data_manager = data_manager + self._debug = debug_manager # Load the UI file ui_path = os.path.join(os.path.dirname(__file__), "sampler_widget.ui") @@ -55,6 +56,17 @@ def __init__(self, parent=None, data_manager=None): # Initial state update self._on_sampler_type_changed() + def set_debug_manager(self, debug_manager): + """Attach a debug manager instance.""" + self._debug = debug_manager + + def _log_params(self, context_label: str): + if getattr(self, "_debug", None): + try: + self._debug.log_params(context_label=context_label, params=self.get_parameters()) + except Exception: + pass + def _on_sampler_type_changed(self): """Update UI based on selected sampler type.""" sampler_type = self.samplerTypeComboBox.currentText() @@ -92,6 +104,8 @@ def _run_sampler(self): from ...main.m2l_api import sample_contacts + self._log_params("sampler_widget_run") + # Validate inputs if not self.spatialDataLayerComboBox.currentLayer(): QMessageBox.warning(self, "Missing Input", "Please select a spatial data layer.") @@ -130,6 +144,17 @@ def _run_sampler(self): samples = sample_contacts(**kwargs) + if self._debug and self._debug.is_debug(): + try: + if samples is not None: + csv_bytes = samples.to_csv(index=False).encode("utf-8") + self._debug.save_debug_file("sampler_contacts.csv", csv_bytes) + except Exception as err: + self._debug.plugin.log( + message=f"[map2loop] Failed to save sampler debug output: {err}", + log_level=2, + ) + # Convert result back to QGIS layer and add to project if samples is not None and not samples.empty: layer_name = f"Sampled Contacts ({sampler_type})" @@ -211,6 +236,11 @@ def _run_sampler(self): QMessageBox.warning(self, "Warning", "No samples were generated.") except Exception as e: + if self._debug: + self._debug.plugin.log( + message=f"[map2loop] Sampler run failed: {e}", + log_level=2, + ) if PlgOptionsManager.get_debug_mode(): raise e QMessageBox.critical(self, "Error", f"An error occurred: {str(e)}") diff --git a/loopstructural/gui/map2loop_tools/sorter_widget.py b/loopstructural/gui/map2loop_tools/sorter_widget.py index 22926db..2103b0e 100644 --- a/loopstructural/gui/map2loop_tools/sorter_widget.py +++ b/loopstructural/gui/map2loop_tools/sorter_widget.py @@ -18,7 +18,7 @@ class SorterWidget(QWidget): sorting algorithms. """ - def __init__(self, parent=None, data_manager=None): + def __init__(self, parent=None, data_manager=None, debug_manager=None): """Initialize the sorter widget. Parameters @@ -32,6 +32,7 @@ def __init__(self, parent=None, data_manager=None): if data_manager is None: raise ValueError("data_manager must be provided") self.data_manager = data_manager + self._debug = debug_manager # Load the UI file ui_path = os.path.join(os.path.dirname(__file__), "sorter_widget.ui") @@ -76,6 +77,17 @@ def __init__(self, parent=None, data_manager=None): # Initial state update self._on_algorithm_changed() + def set_debug_manager(self, debug_manager): + """Attach a debug manager instance.""" + self._debug = debug_manager + + def _log_params(self, context_label: str): + if getattr(self, "_debug", None): + try: + self._debug.log_params(context_label=context_label, params=self.get_parameters()) + except Exception: + pass + def _guess_layers(self): """Automatically detect and set appropriate field names using ColumnMatcher.""" from ...main.helpers import ColumnMatcher @@ -239,6 +251,8 @@ def _run_sorter(self): """Run the stratigraphic sorter algorithm.""" from ...main.m2l_api import sort_stratigraphic_column + self._log_params("sorter_widget_run") + # Validate inputs if not self.geologyLayerComboBox.currentLayer(): QMessageBox.warning(self, "Missing Input", "Please select a geology layer.") @@ -295,6 +309,15 @@ def _run_sorter(self): kwargs['dtm'] = self.dtmLayerComboBox.currentLayer() result = sort_stratigraphic_column(**kwargs) + if self._debug and self._debug.is_debug(): + try: + payload = "\n".join(result) if result else "" + self._debug.save_debug_file("sorter_result.txt", payload.encode("utf-8")) + except Exception as err: + self._debug.plugin.log( + message=f"[map2loop] Failed to save sorter debug output: {err}", + log_level=2, + ) if result and len(result) > 0: # Clear and update stratigraphic column in data_manager self.data_manager.clear_stratigraphic_column() @@ -310,6 +333,11 @@ def _run_sorter(self): QMessageBox.warning(self, "Error", "Failed to create stratigraphic column.") except Exception as e: + if self._debug: + self._debug.plugin.log( + message=f"[map2loop] Sorter run failed: {e}", + log_level=2, + ) if PlgOptionsManager.get_debug_mode(): raise e QMessageBox.critical(self, "Error", f"An error occurred: {str(e)}") diff --git a/loopstructural/gui/map2loop_tools/thickness_calculator_widget.py b/loopstructural/gui/map2loop_tools/thickness_calculator_widget.py index 24e596f..ed1c5d0 100644 --- a/loopstructural/gui/map2loop_tools/thickness_calculator_widget.py +++ b/loopstructural/gui/map2loop_tools/thickness_calculator_widget.py @@ -18,7 +18,7 @@ class ThicknessCalculatorWidget(QWidget): calculation algorithms. """ - def __init__(self, parent=None, data_manager=None): + def __init__(self, parent=None, data_manager=None, debug_manager=None): """Initialize the thickness calculator widget. Parameters @@ -30,6 +30,7 @@ def __init__(self, parent=None, data_manager=None): """ super().__init__(parent) self.data_manager = data_manager + self._debug = debug_manager # Load the UI file ui_path = os.path.join(os.path.dirname(__file__), "thickness_calculator_widget.ui") @@ -68,6 +69,17 @@ def __init__(self, parent=None, data_manager=None): # Initial state update self._on_calculator_type_changed() + def set_debug_manager(self, debug_manager): + """Attach a debug manager instance.""" + self._debug = debug_manager + + def _log_params(self, context_label: str): + if getattr(self, "_debug", None): + try: + self._debug.log_params(context_label=context_label, params=self.get_parameters()) + except Exception: + pass + def _guess_layers(self): """Attempt to auto-select layers based on common naming conventions.""" if not self.data_manager: @@ -172,6 +184,8 @@ def _run_calculator(self): """Run the thickness calculator algorithm using the map2loop API.""" from ...main.m2l_api import calculate_thickness + self._log_params("thickness_calculator_widget_run") + # Validate inputs if not self.geologyLayerComboBox.currentLayer(): QMessageBox.warning(self, "Missing Input", "Please select a geology layer.") @@ -226,6 +240,16 @@ def _run_calculator(self): kwargs['stratigraphic_order'] = strati_order result = calculate_thickness(**kwargs) + if self._debug and self._debug.is_debug(): + try: + self._debug.save_debug_file( + "thickness_result.txt", str(result).encode("utf-8") + ) + except Exception as err: + self._debug.plugin.log( + message=f"[map2loop] Failed to save thickness debug output: {err}", + log_level=2, + ) for idx in result['thicknesses'].index: u = result['thicknesses'].loc[idx, 'name'] @@ -256,6 +280,11 @@ def _run_calculator(self): QMessageBox.warning(self, "Error", "No thickness data was calculated.") except Exception as e: + if self._debug: + self._debug.plugin.log( + message=f"[map2loop] Thickness calculation failed: {e}", + log_level=2, + ) if PlgOptionsManager.get_debug_mode(): raise e QMessageBox.critical(self, "Error", f"An error occurred: {str(e)}") diff --git a/loopstructural/gui/map2loop_tools/user_defined_sorter_widget.py b/loopstructural/gui/map2loop_tools/user_defined_sorter_widget.py index e840633..a82d749 100644 --- a/loopstructural/gui/map2loop_tools/user_defined_sorter_widget.py +++ b/loopstructural/gui/map2loop_tools/user_defined_sorter_widget.py @@ -13,7 +13,7 @@ class UserDefinedSorterWidget(QWidget): and links it to the data manager for integration with the model. """ - def __init__(self, parent=None, data_manager=None): + def __init__(self, parent=None, data_manager=None, debug_manager=None): """Initialize the user-defined sorter widget. Parameters @@ -29,6 +29,7 @@ def __init__(self, parent=None, data_manager=None): raise ValueError("data_manager must be provided") self.data_manager = data_manager + self._debug = debug_manager # Load the UI file @@ -50,6 +51,17 @@ def __init__(self, parent=None, data_manager=None): main_layout = QVBoxLayout(self) main_layout.addWidget(self.strat_column_widget) + def set_debug_manager(self, debug_manager): + """Attach a debug manager instance.""" + self._debug = debug_manager + + def _log_params(self, context_label: str, params): + if getattr(self, "_debug", None): + try: + self._debug.log_params(context_label=context_label, params=params) + except Exception: + pass + def _run_sorter(self): """Run the user-defined stratigraphic sorter algorithm. @@ -61,6 +73,7 @@ def _run_sorter(self): # Get stratigraphic column data from the data manager strati_column = self.get_stratigraphic_column() + self._log_params("user_defined_sorter_widget_run", {'stratigraphic_column': strati_column}) if not strati_column: QMessageBox.warning( @@ -79,6 +92,16 @@ def _run_sorter(self): feedback = QgsProcessingFeedback() result = processing.run("plugin_map2loop:loop_sorter_2", params, feedback=feedback) + if self._debug and self._debug.is_debug(): + try: + self._debug.save_debug_file( + "user_defined_sorter_result.txt", str(result).encode("utf-8") + ) + except Exception as err: + self._debug.plugin.log( + message=f"[map2loop] Failed to save user-defined sorter debug output: {err}", + log_level=2, + ) if result: QMessageBox.information( self, "Success", "User-defined stratigraphic column created successfully!" @@ -89,6 +112,11 @@ def _run_sorter(self): ) except Exception as e: + if self._debug: + self._debug.plugin.log( + message=f"[map2loop] User-defined sorter run failed: {e}", + log_level=2, + ) QMessageBox.critical(self, "Error", f"An error occurred: {str(e)}") def get_stratigraphic_column(self): diff --git a/loopstructural/plugin_main.py b/loopstructural/plugin_main.py index 25cc63c..41800ee 100644 --- a/loopstructural/plugin_main.py +++ b/loopstructural/plugin_main.py @@ -38,6 +38,7 @@ from loopstructural.processing import ( Map2LoopProvider, ) +from loopstructural.debug_manager import DebugManager from loopstructural.toolbelt import PlgLogger, PlgOptionsManager # ############################################################################ @@ -63,6 +64,7 @@ def __init__(self, iface: QgisInterface): """ self.iface = iface self.log = PlgLogger().log + self.debug_manager = DebugManager(plugin=self) # translation # initialize the locale @@ -329,35 +331,55 @@ def show_sampler_dialog(self): """Show the sampler dialog.""" from loopstructural.gui.map2loop_tools import SamplerDialog - dialog = SamplerDialog(self.iface.mainWindow(), data_manager=self.data_manager) + dialog = SamplerDialog( + self.iface.mainWindow(), + data_manager=self.data_manager, + debug_manager=self.debug_manager, + ) dialog.exec_() def show_sorter_dialog(self): """Show the automatic stratigraphic sorter dialog.""" from loopstructural.gui.map2loop_tools import SorterDialog - dialog = SorterDialog(self.iface.mainWindow(), data_manager=self.data_manager) + dialog = SorterDialog( + self.iface.mainWindow(), + data_manager=self.data_manager, + debug_manager=self.debug_manager, + ) dialog.exec_() def show_user_sorter_dialog(self): """Show the user-defined stratigraphic column dialog.""" from loopstructural.gui.map2loop_tools import UserDefinedSorterDialog - dialog = UserDefinedSorterDialog(self.iface.mainWindow(), data_manager=self.data_manager) + dialog = UserDefinedSorterDialog( + self.iface.mainWindow(), + data_manager=self.data_manager, + debug_manager=self.debug_manager, + ) dialog.exec_() def show_basal_contacts_dialog(self): """Show the basal contacts extractor dialog.""" from loopstructural.gui.map2loop_tools import BasalContactsDialog - dialog = BasalContactsDialog(self.iface.mainWindow(), data_manager=self.data_manager) + dialog = BasalContactsDialog( + self.iface.mainWindow(), + data_manager=self.data_manager, + debug_manager=self.debug_manager, + ) dialog.exec_() def show_thickness_dialog(self): """Show the thickness calculator dialog.""" from loopstructural.gui.map2loop_tools import ThicknessCalculatorDialog - dialog = ThicknessCalculatorDialog(self.iface.mainWindow(), data_manager=self.data_manager) + dialog = ThicknessCalculatorDialog( + self.iface.mainWindow(), + data_manager=self.data_manager, + debug_manager=self.debug_manager, + ) dialog.exec_() def tr(self, message: str) -> str: diff --git a/loopstructural/toolbelt/preferences.py b/loopstructural/toolbelt/preferences.py index 6b24986..13a01a9 100644 --- a/loopstructural/toolbelt/preferences.py +++ b/loopstructural/toolbelt/preferences.py @@ -27,6 +27,7 @@ class PlgSettingsStructure: # global debug_mode: bool = False + debug_directory: str = "" version: str = __version__ interpolator_type: str = 'FDI' interpolator_nelements: int = 10000 @@ -128,6 +129,16 @@ def get_debug_mode(cls) -> bool: """ return cls.get_value_from_key("debug_mode", default=False, exp_type=bool) + @classmethod + def get_debug_directory(cls) -> str: + """Get the configured debug directory path.""" + return cls.get_value_from_key("debug_directory", default="", exp_type=str) or "" + + @classmethod + def set_debug_directory(cls, path: str) -> bool: + """Set the debug directory path.""" + return cls.set_value_from_key("debug_directory", path or "") + @classmethod def set_value_from_key(cls, key: str, value) -> bool: """Set a plugin setting value in QGIS settings. diff --git a/tests/qgis/test_plg_preferences.py b/tests/qgis/test_plg_preferences.py index f951749..94b1611 100644 --- a/tests/qgis/test_plg_preferences.py +++ b/tests/qgis/test_plg_preferences.py @@ -31,6 +31,9 @@ def test_plg_preferences_structure(self): self.assertTrue(hasattr(settings, "debug_mode")) self.assertIsInstance(settings.debug_mode, bool) self.assertEqual(settings.debug_mode, False) + self.assertTrue(hasattr(settings, "debug_directory")) + self.assertIsInstance(settings.debug_directory, str) + self.assertEqual(settings.debug_directory, "") self.assertTrue(hasattr(settings, "version")) self.assertIsInstance(settings.version, str) From dd79214d6cd683d105c4f21a91ec79ee7fa87172 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 16 Dec 2025 23:01:15 +0000 Subject: [PATCH 03/23] chore: refine debug handling and options Co-authored-by: lachlangrose <7371904+lachlangrose@users.noreply.github.com> --- loopstructural/debug_manager.py | 4 ++-- loopstructural/gui/dlg_settings.py | 9 ++++++++- loopstructural/gui/map2loop_tools/dialogs.py | 10 ---------- loopstructural/toolbelt/preferences.py | 3 ++- 4 files changed, 12 insertions(+), 14 deletions(-) diff --git a/loopstructural/debug_manager.py b/loopstructural/debug_manager.py index dba387d..2c9dd34 100644 --- a/loopstructural/debug_manager.py +++ b/loopstructural/debug_manager.py @@ -127,13 +127,13 @@ def log_params(self, context_label: str, params: Any): """Log parameters and persist them when debug mode is enabled.""" try: self.plugin.log( - message=f"[map2loop] {context_label} parameters: {params}", + message=f"[map2loop] {context_label} parameters: {str(params)}", log_level=0, ) except Exception as err: self.plugin.log( message=( - f"[map2loop] {context_label} parameters (stringified due to {err}): {params}" + f"[map2loop] {context_label} parameters (stringified due to {err}): {str(params)}" ), log_level=0, ) diff --git a/loopstructural/gui/dlg_settings.py b/loopstructural/gui/dlg_settings.py index d9feeea..1b35691 100644 --- a/loopstructural/gui/dlg_settings.py +++ b/loopstructural/gui/dlg_settings.py @@ -148,7 +148,14 @@ def _open_debug_directory(self): """Open configured debug directory in the system file manager.""" target = self.plg_settings.get_debug_directory() or "" if target: - QDesktopServices.openUrl(QUrl.fromLocalFile(target)) + target_path = Path(target) + if target_path.exists(): + QDesktopServices.openUrl(QUrl.fromLocalFile(target)) + else: + self.log( + message=f"[map2loop] Debug directory does not exist: {target}", + log_level=1, + ) else: self.log(message="[map2loop] No debug directory configured.", log_level=1) diff --git a/loopstructural/gui/map2loop_tools/dialogs.py b/loopstructural/gui/map2loop_tools/dialogs.py index 31eaca1..0d37800 100644 --- a/loopstructural/gui/map2loop_tools/dialogs.py +++ b/loopstructural/gui/map2loop_tools/dialogs.py @@ -24,8 +24,6 @@ def setup_ui(self): layout = QVBoxLayout(self) self.widget = SamplerWidget(self, data_manager=self.data_manager, debug_manager=self.debug_manager) - if hasattr(self.widget, "set_debug_manager"): - self.widget.set_debug_manager(self.debug_manager) layout.addWidget(self.widget) # Replace the run button with dialog buttons @@ -63,8 +61,6 @@ def setup_ui(self): data_manager=self.data_manager, debug_manager=self.debug_manager, ) - if hasattr(self.widget, "set_debug_manager"): - self.widget.set_debug_manager(self.debug_manager) layout.addWidget(self.widget) # Replace the run button with dialog buttons @@ -102,8 +98,6 @@ def setup_ui(self): data_manager=self.data_manager, debug_manager=self.debug_manager, ) - if hasattr(self.widget, "set_debug_manager"): - self.widget.set_debug_manager(self.debug_manager) layout.addWidget(self.widget) # Replace the run button with dialog buttons @@ -140,8 +134,6 @@ def setup_ui(self): data_manager=self.data_manager, debug_manager=self.debug_manager, ) - if hasattr(self.widget, "set_debug_manager"): - self.widget.set_debug_manager(self.debug_manager) layout.addWidget(self.widget) # Replace the run button with dialog buttons @@ -178,8 +170,6 @@ def setup_ui(self): data_manager=self.data_manager, debug_manager=self.debug_manager, ) - if hasattr(self.widget, "set_debug_manager"): - self.widget.set_debug_manager(self.debug_manager) layout.addWidget(self.widget) # Replace the run button with dialog buttons diff --git a/loopstructural/toolbelt/preferences.py b/loopstructural/toolbelt/preferences.py index 13a01a9..b14c9b2 100644 --- a/loopstructural/toolbelt/preferences.py +++ b/loopstructural/toolbelt/preferences.py @@ -132,7 +132,8 @@ def get_debug_mode(cls) -> bool: @classmethod def get_debug_directory(cls) -> str: """Get the configured debug directory path.""" - return cls.get_value_from_key("debug_directory", default="", exp_type=str) or "" + value = cls.get_value_from_key("debug_directory", default="", exp_type=str) + return value if value is not None else "" @classmethod def set_debug_directory(cls, path: str) -> bool: From ff55f3f51baafd9d9e14909baaf78c68acc0b62a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 16 Dec 2025 23:07:03 +0000 Subject: [PATCH 04/23] chore: address review follow ups Co-authored-by: lachlangrose <7371904+lachlangrose@users.noreply.github.com> --- loopstructural/debug_manager.py | 9 +- loopstructural/gui/dlg_settings.py | 13 +- .../map2loop_tools/basal_contacts_widget.py | 115 ++++++++++-------- .../thickness_calculator_widget.py | 7 +- .../user_defined_sorter_widget.py | 3 +- 5 files changed, 86 insertions(+), 61 deletions(-) diff --git a/loopstructural/debug_manager.py b/loopstructural/debug_manager.py index 2c9dd34..c5f71a1 100644 --- a/loopstructural/debug_manager.py +++ b/loopstructural/debug_manager.py @@ -123,6 +123,13 @@ def get_effective_debug_dir(self) -> Path: ) return self._session_dir + def _sanitize_label(self, context_label: str) -> str: + """Sanitize context label for safe filename usage.""" + return "".join( + c if c.isalnum() or c in ("-", "_") else "_" + for c in context_label.replace(" ", "_").lower() + ) + def log_params(self, context_label: str, params: Any): """Log parameters and persist them when debug mode is enabled.""" try: @@ -141,7 +148,7 @@ def log_params(self, context_label: str, params: Any): if self.is_debug(): try: debug_dir = self.get_effective_debug_dir() - safe_label = context_label.replace(" ", "_").lower() + safe_label = self._sanitize_label(context_label) file_path = debug_dir / f"{safe_label}_params.json" payload = params if isinstance(params, dict) else {"_payload": params} with open(file_path, "w", encoding="utf-8") as file_handle: diff --git a/loopstructural/gui/dlg_settings.py b/loopstructural/gui/dlg_settings.py index 1b35691..8f29be9 100644 --- a/loopstructural/gui/dlg_settings.py +++ b/loopstructural/gui/dlg_settings.py @@ -146,18 +146,23 @@ def _browse_debug_directory(self): def _open_debug_directory(self): """Open configured debug directory in the system file manager.""" - target = self.plg_settings.get_debug_directory() or "" + logger = getattr(self, "log", PlgLogger().log) + target = ( + self.le_debug_directory.text() + if hasattr(self, "le_debug_directory") + else self.plg_settings.get_debug_directory() + ) or "" if target: target_path = Path(target) if target_path.exists(): - QDesktopServices.openUrl(QUrl.fromLocalFile(target)) + QDesktopServices.openUrl(QUrl.fromLocalFile(str(target_path))) else: - self.log( + logger( message=f"[map2loop] Debug directory does not exist: {target}", log_level=1, ) else: - self.log(message="[map2loop] No debug directory configured.", log_level=1) + logger(message="[map2loop] No debug directory configured.", log_level=1) class PlgOptionsFactory(QgsOptionsWidgetFactory): diff --git a/loopstructural/gui/map2loop_tools/basal_contacts_widget.py b/loopstructural/gui/map2loop_tools/basal_contacts_widget.py index 56f3a42..94b0ffd 100644 --- a/loopstructural/gui/map2loop_tools/basal_contacts_widget.py +++ b/loopstructural/gui/map2loop_tools/basal_contacts_widget.py @@ -132,58 +132,31 @@ def _run_extractor(self): QMessageBox.warning(self, "Missing Input", "Please select a geology layer.") return - # Parse ignore units - ignore_units = [] - if self.ignoreUnitsLineEdit.text().strip(): - ignore_units = [ - unit.strip() for unit in self.ignoreUnitsLineEdit.text().split(',') if unit.strip() - ] - geology = self.geologyLayerComboBox.currentLayer() - unit_name_field = self.unitNameFieldComboBox.currentField() - faults = self.faultsLayerComboBox.currentLayer() - stratigraphic_order = ( - self.data_manager.get_stratigraphic_unit_names() if self.data_manager else [] - ) - - # Check if user wants all contacts or just basal contacts - all_contacts = self.allContactsCheckBox.isChecked() - if all_contacts: - stratigraphic_order = list({g[unit_name_field] for g in geology.getFeatures()}) - result = extract_basal_contacts( - geology=geology, - stratigraphic_order=stratigraphic_order, - faults=faults, - ignore_units=ignore_units, - unit_name_field=unit_name_field, - all_contacts=all_contacts, - updater=lambda message: QMessageBox.information(self, "Extraction Progress", message), - ) - - # Show success message based on what was extracted - if all_contacts and result: - addGeoDataFrameToproject(result['all_contacts'], "All contacts") - contact_type = "all contacts and basal contacts" - else: - addGeoDataFrameToproject(result['basal_contacts'], "Basal contacts") - - contact_type = "basal contacts" - - if result: - QMessageBox.information( - self, - "Success", - f"Successfully extracted {contact_type}!", - ) - if self._debug and self._debug.is_debug(): - try: - self._debug.save_debug_file( - "basal_contacts_result.txt", str(result).encode("utf-8") - ) - except Exception as err: - self._debug.plugin.log( - message=f"[map2loop] Failed to save basal contacts debug output: {err}", - log_level=2, - ) + try: + result, contact_type = self._extract_contacts() + if result: + QMessageBox.information( + self, + "Success", + f"Successfully extracted {contact_type}!", + ) + if self._debug and self._debug.is_debug(): + try: + self._debug.save_debug_file( + "basal_contacts_result.txt", str(result).encode("utf-8") + ) + except Exception as err: + self._debug.plugin.log( + message=f"[map2loop] Failed to save basal contacts debug output: {err}", + log_level=2, + ) + except Exception as err: + if self._debug: + self._debug.plugin.log( + message=f"[map2loop] Basal contacts extraction failed: {err}", + log_level=2, + ) + QMessageBox.critical(self, "Error", f"An error occurred: {err}") def get_parameters(self): """Get current widget parameters. @@ -223,3 +196,41 @@ def set_parameters(self, params): self.ignoreUnitsLineEdit.setText(', '.join(params['ignore_units'])) if 'all_contacts' in params: self.allContactsCheckBox.setChecked(params['all_contacts']) + + def _extract_contacts(self): + """Execute basal contacts extraction.""" + # Parse ignore units + ignore_units = [] + if self.ignoreUnitsLineEdit.text().strip(): + ignore_units = [ + unit.strip() for unit in self.ignoreUnitsLineEdit.text().split(',') if unit.strip() + ] + geology = self.geologyLayerComboBox.currentLayer() + unit_name_field = self.unitNameFieldComboBox.currentField() + faults = self.faultsLayerComboBox.currentLayer() + stratigraphic_order = ( + self.data_manager.get_stratigraphic_unit_names() if self.data_manager else [] + ) + + # Check if user wants all contacts or just basal contacts + all_contacts = self.allContactsCheckBox.isChecked() + if all_contacts: + stratigraphic_order = list({g[unit_name_field] for g in geology.getFeatures()}) + result = extract_basal_contacts( + geology=geology, + stratigraphic_order=stratigraphic_order, + faults=faults, + ignore_units=ignore_units, + unit_name_field=unit_name_field, + all_contacts=all_contacts, + updater=lambda message: QMessageBox.information(self, "Extraction Progress", message), + ) + + contact_type = "basal contacts" + if result: + if all_contacts: + addGeoDataFrameToproject(result['all_contacts'], "All contacts") + contact_type = "all contacts and basal contacts" + else: + addGeoDataFrameToproject(result['basal_contacts'], "Basal contacts") + return result, contact_type diff --git a/loopstructural/gui/map2loop_tools/thickness_calculator_widget.py b/loopstructural/gui/map2loop_tools/thickness_calculator_widget.py index ed1c5d0..6d29a8d 100644 --- a/loopstructural/gui/map2loop_tools/thickness_calculator_widget.py +++ b/loopstructural/gui/map2loop_tools/thickness_calculator_widget.py @@ -73,10 +73,11 @@ def set_debug_manager(self, debug_manager): """Attach a debug manager instance.""" self._debug = debug_manager - def _log_params(self, context_label: str): + def _log_params(self, context_label: str, params=None): if getattr(self, "_debug", None): try: - self._debug.log_params(context_label=context_label, params=self.get_parameters()) + payload = params if params is not None else self.get_parameters() + self._debug.log_params(context_label=context_label, params=payload) except Exception: pass @@ -184,7 +185,7 @@ def _run_calculator(self): """Run the thickness calculator algorithm using the map2loop API.""" from ...main.m2l_api import calculate_thickness - self._log_params("thickness_calculator_widget_run") + self._log_params("thickness_calculator_widget_run", self.get_parameters()) # Validate inputs if not self.geologyLayerComboBox.currentLayer(): diff --git a/loopstructural/gui/map2loop_tools/user_defined_sorter_widget.py b/loopstructural/gui/map2loop_tools/user_defined_sorter_widget.py index a82d749..7bcdabb 100644 --- a/loopstructural/gui/map2loop_tools/user_defined_sorter_widget.py +++ b/loopstructural/gui/map2loop_tools/user_defined_sorter_widget.py @@ -1,6 +1,7 @@ """Widget for user-defined stratigraphic column.""" +from typing import Any from PyQt5.QtWidgets import QMessageBox, QVBoxLayout, QWidget from loopstructural.gui.modelling.stratigraphic_column import StratColumnWidget @@ -55,7 +56,7 @@ def set_debug_manager(self, debug_manager): """Attach a debug manager instance.""" self._debug = debug_manager - def _log_params(self, context_label: str, params): + def _log_params(self, context_label: str, params: Any): if getattr(self, "_debug", None): try: self._debug.log_params(context_label=context_label, params=params) From 351a96a6a040d8e996ce1d17d3f40be77177c867 Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Thu, 18 Dec 2025 08:37:20 +1100 Subject: [PATCH 05/23] fix: convert unit_name_field to 'UNITNAME' --- loopstructural/main/m2l_api.py | 32 +++++++++++++++++++------------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/loopstructural/main/m2l_api.py b/loopstructural/main/m2l_api.py index 51fa601..97cb823 100644 --- a/loopstructural/main/m2l_api.py +++ b/loopstructural/main/m2l_api.py @@ -100,7 +100,6 @@ def sort_stratigraphic_column( unitname1_field=None, unitname2_field=None, structure=None, - unit_name_column=None, dip_field="DIP", dipdir_field="DIPDIR", orientation_type="Dip Direction", @@ -154,22 +153,29 @@ def sort_stratigraphic_column( # Convert layers to GeoDataFrames geology_gdf = qgsLayerToGeoDataFrame(geology) contacts_gdf = qgsLayerToGeoDataFrame(contacts) - print(geology_gdf.columns) # Build units DataFrame - units_df = geology_gdf[['UNITNAME']].drop_duplicates().reset_index(drop=True) - if unit_name_field and unit_name_field != 'UNITNAME' and unit_name_field in geology_gdf.columns: + if ( + unit_name_field + and unit_name_field != unit_name_field + and unit_name_field in geology_gdf.columns + ): units_df = geology_gdf[[unit_name_field]].drop_duplicates().reset_index(drop=True) - units_df.columns = ['UNITNAME'] + units_df = units_df.rename(columns={unit_name_field: unit_name_field}) + + elif unit_name_field in geology_gdf.columns: + units_df = geology_gdf[[unit_name_field]].drop_duplicates().reset_index(drop=True) + else: + raise ValueError(f"Unit name field '{unit_name_field}' not found in geology data") if min_age_field and min_age_field in geology_gdf.columns: units_df = units_df.merge( - geology_gdf[['UNITNAME', min_age_field]].drop_duplicates(), - on='UNITNAME', + geology_gdf[[unit_name_field, min_age_field]].drop_duplicates(), + on=unit_name_field, how='left', ) if max_age_field and max_age_field in geology_gdf.columns: units_df = units_df.merge( - geology_gdf[['UNITNAME', max_age_field]].drop_duplicates(), - on='UNITNAME', + geology_gdf[[unit_name_field, max_age_field]].drop_duplicates(), + on=unit_name_field, how='left', ) # Build relationships DataFrame (contacts without geometry) @@ -195,7 +201,7 @@ def sort_stratigraphic_column( 'orientation_type': orientation_type, 'dtm': dtm, 'updater': updater, - 'unit_name_column': unit_name_column, + 'unit_name_column': unit_name_field, } # Only pass required arguments to the sorter @@ -399,10 +405,10 @@ def calculate_thickness( geology_gdf = geology_gdf.rename(columns={unit_name_field: 'UNITNAME'}) units = geology_gdf.copy() - units_unique = units.drop_duplicates(subset=[unit_name_field]).reset_index(drop=True) - units = pd.DataFrame({'name': units_unique[unit_name_field]}) + units_unique = units.drop_duplicates(subset=['UNITNAME']).reset_index(drop=True) + units = pd.DataFrame({'name': units_unique['UNITNAME']}) basal_contacts_gdf['type'] = 'BASAL' # required by calculator - + thickness = calculator.compute( units, stratigraphic_order, From 2ec3fcd1b19fe6e475881e10ddf28eb23d8ae432 Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Thu, 18 Dec 2025 08:38:08 +1100 Subject: [PATCH 06/23] fix: updating unload to prevent error when missing dock widgets --- loopstructural/plugin_main.py | 149 ++++++++++++++++++++++------------ 1 file changed, 98 insertions(+), 51 deletions(-) diff --git a/loopstructural/plugin_main.py b/loopstructural/plugin_main.py index 25cc63c..1c2a4dc 100644 --- a/loopstructural/plugin_main.py +++ b/loopstructural/plugin_main.py @@ -46,6 +46,13 @@ class LoopstructuralPlugin: + def show_fault_topology_dialog(self): + """Show the fault topology calculator dialog.""" + from loopstructural.gui.map2loop_tools.fault_topology_widget import FaultTopologyWidget + + dialog = FaultTopologyWidget(self.iface.mainWindow()) + dialog.exec_() + """QGIS plugin entrypoint for LoopStructural. This class initializes plugin resources, UI elements and data/model managers @@ -111,6 +118,11 @@ def initGui(self): self.iface.registerOptionsWidgetFactory(self.options_factory) # -- Actions + self.action_fault_topology = QAction( + "Fault Topology Calculator", + self.iface.mainWindow(), + ) + self.action_fault_topology.triggered.connect(self.show_fault_topology_dialog) self.action_help = QAction( QgsApplication.getThemeIcon("mActionHelpContents.svg"), self.tr("Help"), @@ -140,6 +152,7 @@ def initGui(self): ) self.toolbar.addAction(self.action_modelling) + self.toolbar.addAction(self.action_fault_topology) # -- Menu self.iface.addPluginToMenu(__title__, self.action_settings) self.iface.addPluginToMenu(__title__, self.action_help) @@ -183,12 +196,14 @@ def initGui(self): self.toolbar.addAction(self.action_user_sorter) self.toolbar.addAction(self.action_basal_contacts) self.toolbar.addAction(self.action_thickness) + self.toolbar.addAction(self.action_fault_topology) self.iface.addPluginToMenu(__title__, self.action_sampler) self.iface.addPluginToMenu(__title__, self.action_sorter) self.iface.addPluginToMenu(__title__, self.action_user_sorter) self.iface.addPluginToMenu(__title__, self.action_basal_contacts) self.iface.addPluginToMenu(__title__, self.action_thickness) + self.iface.addPluginToMenu(__title__, self.action_fault_topology) self.action_basal_contacts.triggered.connect(self.show_basal_contacts_dialog) # Add all map2loop tool actions to the toolbar @@ -381,58 +396,90 @@ def initProcessing(self): QgsApplication.processingRegistry().addProvider(self.provider) def unload(self): - """Clean up when plugin is disabled or uninstalled.""" + """Clean up when plugin is disabled or uninstalled. + + This implementation is defensive: initGui may not have been run when + QGIS asks the plugin to unload (plugin reloader), so attributes may be + missing. Use getattr to check for presence and guard removals/deletions. + """ # -- Clean up dock widgets - if self.loop_dockwidget: - self.iface.removeDockWidget(self.loop_dockwidget) - del self.loop_dockwidget - if self.modelling_dockwidget: - self.iface.removeDockWidget(self.modelling_dockwidget) - del self.modelling_dockwidget - if self.visualisation_dockwidget: - self.iface.removeDockWidget(self.visualisation_dockwidget) - del self.visualisation_dockwidget - - # -- Clean up menu - self.iface.removePluginMenu(__title__, self.action_help) - self.iface.removePluginMenu(__title__, self.action_settings) - self.iface.removePluginMenu(__title__, self.action_sampler) - self.iface.removePluginMenu(__title__, self.action_sorter) - self.iface.removePluginMenu(__title__, self.action_user_sorter) - self.iface.removePluginMenu(__title__, self.action_basal_contacts) - self.iface.removePluginMenu(__title__, self.action_thickness) - # self.iface.removeMenu(self.menu) + for dock_attr in ("loop_dockwidget", "modelling_dockwidget", "visualisation_dockwidget"): + dock = getattr(self, dock_attr, None) + if dock: + try: + self.iface.removeDockWidget(dock) + except Exception: + # ignore errors during unload + pass + try: + delattr(self, dock_attr) + except Exception: + pass + + # -- Clean up menu/actions (only remove if they exist) + for attr in ( + "action_help", + "action_settings", + "action_sampler", + "action_sorter", + "action_user_sorter", + "action_basal_contacts", + "action_thickness", + "action_fault_topology", + "action_modelling", + "action_visualisation", + ): + act = getattr(self, attr, None) + if act: + try: + self.iface.removePluginMenu(__title__, act) + except Exception: + pass + try: + delattr(self, attr) + except Exception: + pass + # -- Clean up preferences panel in QGIS settings - self.iface.unregisterOptionsWidgetFactory(self.options_factory) - # -- Unregister processing - QgsApplication.processingRegistry().removeProvider(self.provider) + options_factory = getattr(self, "options_factory", None) + if options_factory: + try: + self.iface.unregisterOptionsWidgetFactory(options_factory) + except Exception: + pass + try: + delattr(self, "options_factory") + except Exception: + pass + + # -- Unregister processing provider + provider = getattr(self, "provider", None) + if provider: + try: + QgsApplication.processingRegistry().removeProvider(provider) + except Exception: + pass + try: + delattr(self, "provider") + except Exception: + pass # remove from QGIS help/extensions menu - if self.action_help_plugin_menu_documentation: - self.iface.pluginHelpMenu().removeAction(self.action_help_plugin_menu_documentation) - - # remove actions - del self.action_settings - del self.action_help - del self.toolbar - - def run(self): - """Run main process. - - Raises - ------ - Exception - If there is no item in the feed. - """ - try: - self.log( - message=self.tr("Everything ran OK."), - log_level=3, - push=False, - ) - except Exception as err: - self.log( - message=self.tr("Houston, we've got a problem: {}".format(err)), - log_level=2, - push=True, - ) + help_action = getattr(self, "action_help_plugin_menu_documentation", None) + if help_action: + try: + self.iface.pluginHelpMenu().removeAction(help_action) + except Exception: + pass + try: + delattr(self, "action_help_plugin_menu_documentation") + except Exception: + pass + + # remove toolbar if present + if getattr(self, "toolbar", None): + try: + # There's no explicit removeToolbar API; deleting reference is fine. + delattr(self, "toolbar") + except Exception: + pass From 24cc539bbd5a59e09410fea7f3846abe73e71cbf Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Thu, 18 Dec 2025 08:38:47 +1100 Subject: [PATCH 07/23] fix: rename unit_name_column to unit_name_field --- loopstructural/gui/map2loop_tools/sorter_widget.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/loopstructural/gui/map2loop_tools/sorter_widget.py b/loopstructural/gui/map2loop_tools/sorter_widget.py index 22926db..c46d7e4 100644 --- a/loopstructural/gui/map2loop_tools/sorter_widget.py +++ b/loopstructural/gui/map2loop_tools/sorter_widget.py @@ -272,7 +272,7 @@ def _run_sorter(self): 'geology': self.geologyLayerComboBox.currentLayer(), 'contacts': self.contactsLayerComboBox.currentLayer(), 'sorting_algorithm': algorithm_name, - 'unit_name_column': self.unitNameFieldComboBox.currentField(), + 'unit_name_field': self.unitNameFieldComboBox.currentField(), 'updater': lambda msg: QMessageBox.information(self, "Progress", msg), } From 56ceb77a8d5fcd5f2da7d6b7b67f0193da7e0acb Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Thu, 18 Dec 2025 08:39:33 +1100 Subject: [PATCH 08/23] fix: update stratigraphic column with calculated thicknesses --- .../map2loop_tools/thickness_calculator_widget.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/loopstructural/gui/map2loop_tools/thickness_calculator_widget.py b/loopstructural/gui/map2loop_tools/thickness_calculator_widget.py index 24e596f..b323935 100644 --- a/loopstructural/gui/map2loop_tools/thickness_calculator_widget.py +++ b/loopstructural/gui/map2loop_tools/thickness_calculator_widget.py @@ -2,7 +2,7 @@ import os -from PyQt5.QtWidgets import QMessageBox, QWidget +from PyQt5.QtWidgets import QLabel, QMessageBox, QWidget from qgis.PyQt import uic from loopstructural.toolbelt.preferences import PlgOptionsManager @@ -231,8 +231,14 @@ def _run_calculator(self): u = result['thicknesses'].loc[idx, 'name'] thick = result['thicknesses'].loc[idx, 'ThicknessStdDev'] if thick > 0: - - self.data_manager._stratigraphic_column.get_unit_by_name(u).thickness = thick + unit = self.data_manager._stratigraphic_column.get_unit_by_name(u) + if unit: + unit.thickness = thick + else: + self.data_manager.logger( + f"Warning: Unit '{u}' not found in stratigraphic column.", + level=QLabel.Warning, + ) # Save debugging files if checkbox is checked if self.saveDebugCheckBox.isChecked(): if 'lines' in result: From 5fba7c899143f929b1ffc771364a42c3a8c712a9 Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Thu, 18 Dec 2025 08:40:06 +1100 Subject: [PATCH 09/23] fix: update stratigraphic unit to prevent missing widget error --- .../stratigraphic_unit.py | 40 +++++++++++++++---- 1 file changed, 33 insertions(+), 7 deletions(-) diff --git a/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_unit.py b/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_unit.py index c3125bd..0ba69e9 100644 --- a/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_unit.py +++ b/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_unit.py @@ -130,20 +130,46 @@ def setData(self, data: Optional[dict] = None): data : dict or None Dictionary containing 'name' and 'colour' keys. If None, defaults are used. """ + # Safely update internal state first if data: self.name = str(data.get("name", "")) self.colour = data.get("colour", "") - self.lineEditName.setText(self.name) - self.setStyleSheet(f"background-color: {self.colour};" if self.colour else "") - # self.lineEditColour.setText(self.colour) else: self.name = "" self.colour = "" - self.lineEditName.clear() - self.setStyleSheet("") - # self.lineEditColour.clear() - self.validateFields() + # Guard all direct Qt calls since the wrapped C++ objects may have been deleted + try: + if data: + if hasattr(self, 'lineEditName') and self.lineEditName is not None: + try: + self.lineEditName.setText(self.name) + except RuntimeError: + # Widget has been deleted; abort GUI updates + return + try: + self.setStyleSheet(f"background-color: {self.colour};" if self.colour else "") + except RuntimeError: + return + else: + if hasattr(self, 'lineEditName') and self.lineEditName is not None: + try: + self.lineEditName.clear() + except RuntimeError: + return + try: + self.setStyleSheet("") + except RuntimeError: + return + + # Validate fields if widgets still exist + try: + self.validateFields() + except RuntimeError: + return + except RuntimeError: + # Catch any unexpected RuntimeError from underlying Qt objects + return def getData(self) -> dict: """Return the widget data as a dictionary. From 4a0ba19f17631d5f2a5895b920da102964c4e896 Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Thu, 18 Dec 2025 08:46:19 +1100 Subject: [PATCH 10/23] style: formatting --- .../algorithms/thickness_calculator.py | 146 ++++++++++-------- 1 file changed, 85 insertions(+), 61 deletions(-) diff --git a/loopstructural/processing/algorithms/thickness_calculator.py b/loopstructural/processing/algorithms/thickness_calculator.py index 8d67dc5..ddddf31 100644 --- a/loopstructural/processing/algorithms/thickness_calculator.py +++ b/loopstructural/processing/algorithms/thickness_calculator.py @@ -8,9 +8,12 @@ * * *************************************************************************** """ + # Python imports from typing import Any, Optional + import pandas as pd +from map2loop.thickness_calculator import InterpolatedStructure, StructuralPoint # QGIS imports from qgis import processing @@ -21,26 +24,26 @@ QgsProcessingContext, QgsProcessingException, QgsProcessingFeedback, + QgsProcessingParameterEnum, QgsProcessingParameterFeatureSink, QgsProcessingParameterFeatureSource, - QgsProcessingParameterEnum, - QgsProcessingParameterNumber, QgsProcessingParameterField, - QgsProcessingParameterMatrix, - QgsSettings, + QgsProcessingParameterMatrix, + QgsProcessingParameterNumber, QgsProcessingParameterRasterLayer, + QgsSettings, ) + # Internal imports from ...main.vectorLayerWrapper import ( - qgsLayerToGeoDataFrame, - GeoDataFrameToQgsLayer, - qgsLayerToDataFrame, - dataframeToQgsLayer, - qgsRasterToGdalDataset, + GeoDataFrameToQgsLayer, + dataframeToQgsLayer, + dataframeToQgsTable, matrixToDict, - dataframeToQgsTable - ) -from map2loop.thickness_calculator import InterpolatedStructure, StructuralPoint + qgsLayerToDataFrame, + qgsLayerToGeoDataFrame, + qgsRasterToGdalDataset, +) class ThicknessCalculatorAlgorithm(QgsProcessingAlgorithm): @@ -56,6 +59,7 @@ class ThicknessCalculatorAlgorithm(QgsProcessingAlgorithm): INPUT_STRUCTURE_DATA = 'STRUCTURE_DATA' INPUT_DIPDIR_FIELD = 'DIPDIR_FIELD' INPUT_DIP_FIELD = 'DIP_FIELD' + INPUT_STRUCTURE_UNIT_FIELD = 'STRUCTURE_UNIT_FIELD' INPUT_GEOLOGY = 'GEOLOGY' INPUT_ORIENTATION_TYPE = 'ORIENTATION_TYPE' INPUT_UNIT_NAME_FIELD = 'UNIT_NAME_FIELD' @@ -82,14 +86,14 @@ def groupId(self) -> str: def initAlgorithm(self, config: Optional[dict[str, Any]] = None) -> None: """Initialize the algorithm parameters.""" - + self.addParameter( QgsProcessingParameterEnum( self.INPUT_THICKNESS_CALCULATOR_TYPE, "Thickness Calculator Type", - options=['InterpolatedStructure','StructuralPoint'], + options=['InterpolatedStructure', 'StructuralPoint'], allowMultiple=False, - defaultValue='InterpolatedStructure' + defaultValue='InterpolatedStructure', ) ) self.addParameter( @@ -100,38 +104,35 @@ def initAlgorithm(self, config: Optional[dict[str, Any]] = None) -> None: optional=True, ) ) - + self.addParameter( QgsProcessingParameterEnum( self.INPUT_BOUNDING_BOX_TYPE, "Bounding Box Type", options=['Extract from geology layer', 'User defined'], allowMultiple=False, - defaultValue=1 + defaultValue=1, ) ) - + bbox_settings = QgsSettings() last_bbox = bbox_settings.value("m2l/bounding_box", "") self.addParameter( QgsProcessingParameterMatrix( self.INPUT_BOUNDING_BOX, description="Static Bounding Box", - headers=['minx','miny','maxx','maxy'], + headers=['minx', 'miny', 'maxx', 'maxy'], numberRows=1, defaultValue=last_bbox, - optional=True + optional=True, ) ) - + self.addParameter( QgsProcessingParameterNumber( - self.INPUT_MAX_LINE_LENGTH, - "Max Line Length", - minValue=0, - defaultValue=1000 + self.INPUT_MAX_LINE_LENGTH, "Max Line Length", minValue=0, defaultValue=1000 ) - ) + ) self.addParameter( QgsProcessingParameterFeatureSource( self.INPUT_BASAL_CONTACTS, @@ -147,14 +148,14 @@ def initAlgorithm(self, config: Optional[dict[str, Any]] = None) -> None: [QgsProcessing.TypeVectorPolygon], ) ) - + self.addParameter( QgsProcessingParameterField( 'UNIT_NAME_FIELD', 'Unit Name Field e.g. Formation', parentLayerParameterName=self.INPUT_GEOLOGY, type=QgsProcessingParameterField.String, - defaultValue='Formation' + defaultValue='Formation', ) ) @@ -163,10 +164,10 @@ def initAlgorithm(self, config: Optional[dict[str, Any]] = None) -> None: 'STRATIGRAPHIC_COLUMN_LAYER', 'Stratigraphic Column Layer (from sorter)', [QgsProcessing.TypeVector], - optional=True + optional=True, ) ) - + strati_settings = QgsSettings() last_strati_column = strati_settings.value("m2l/strati_column", "") self.addParameter( @@ -176,7 +177,7 @@ def initAlgorithm(self, config: Optional[dict[str, Any]] = None) -> None: headers=["Unit"], numberRows=0, defaultValue=last_strati_column, - optional=True + optional=True, ) ) self.addParameter( @@ -198,7 +199,7 @@ def initAlgorithm(self, config: Optional[dict[str, Any]] = None) -> None: self.INPUT_ORIENTATION_TYPE, 'Orientation Type', options=['Dip Direction', 'Strike'], - defaultValue=0 # Default to Dip Direction + defaultValue=0, # Default to Dip Direction ) ) self.addParameter( @@ -207,7 +208,7 @@ def initAlgorithm(self, config: Optional[dict[str, Any]] = None) -> None: "Dip Direction Column", parentLayerParameterName=self.INPUT_STRUCTURE_DATA, type=QgsProcessingParameterField.Numeric, - defaultValue='DIPDIR' + defaultValue='DIPDIR', ) ) self.addParameter( @@ -216,7 +217,18 @@ def initAlgorithm(self, config: Optional[dict[str, Any]] = None) -> None: "Dip Column", parentLayerParameterName=self.INPUT_STRUCTURE_DATA, type=QgsProcessingParameterField.Numeric, - defaultValue='DIP' + defaultValue='DIP', + ) + ) + # New parameter: choose the field in the structure layer that contains the unit name + self.addParameter( + QgsProcessingParameterField( + self.INPUT_STRUCTURE_UNIT_FIELD, + "Structure Unit Name Field", + parentLayerParameterName=self.INPUT_STRUCTURE_DATA, + type=QgsProcessingParameterField.String, + defaultValue='unit_name', + optional=True, ) ) self.addParameter( @@ -234,7 +246,9 @@ def processAlgorithm( ) -> dict[str, Any]: feedback.pushInfo("Initialising Thickness Calculation Algorithm...") - thickness_type_index = self.parameterAsEnum(parameters, self.INPUT_THICKNESS_CALCULATOR_TYPE, context) + thickness_type_index = self.parameterAsEnum( + parameters, self.INPUT_THICKNESS_CALCULATOR_TYPE, context + ) thickness_type = ['InterpolatedStructure', 'StructuralPoint'][thickness_type_index] dtm_data = self.parameterAsRasterLayer(parameters, self.INPUT_DTM, context) bounding_box_type = self.parameterAsEnum(parameters, self.INPUT_BOUNDING_BOX_TYPE, context) @@ -243,9 +257,12 @@ def processAlgorithm( geology_data = self.parameterAsSource(parameters, self.INPUT_GEOLOGY, context) structure_data = self.parameterAsSource(parameters, self.INPUT_STRUCTURE_DATA, context) orientation_type = self.parameterAsEnum(parameters, self.INPUT_ORIENTATION_TYPE, context) - is_strike = (orientation_type == 1) - structure_dipdir_field = self.parameterAsString(parameters, self.INPUT_DIPDIR_FIELD, context) + is_strike = orientation_type == 1 + structure_dipdir_field = self.parameterAsString( + parameters, self.INPUT_DIPDIR_FIELD, context + ) structure_dip_field = self.parameterAsString(parameters, self.INPUT_DIP_FIELD, context) + sampled_contacts = self.parameterAsSource(parameters, self.INPUT_SAMPLED_CONTACTS, context) unit_name_field = self.parameterAsString(parameters, self.INPUT_UNIT_NAME_FIELD, context) @@ -256,33 +273,41 @@ def processAlgorithm( 'minx': extent.xMinimum(), 'miny': extent.yMinimum(), 'maxx': extent.xMaximum(), - 'maxy': extent.yMaximum() + 'maxy': extent.yMaximum(), } feedback.pushInfo("Using bounding box from geology layer") else: - static_bbox_matrix = self.parameterAsMatrix(parameters, self.INPUT_BOUNDING_BOX, context) + static_bbox_matrix = self.parameterAsMatrix( + parameters, self.INPUT_BOUNDING_BOX, context + ) if not static_bbox_matrix or len(static_bbox_matrix) == 0: raise QgsProcessingException("Bounding box is required") - + bounding_box = matrixToDict(static_bbox_matrix) - + bbox_settings = QgsSettings() bbox_settings.setValue("m2l/bounding_box", static_bbox_matrix) feedback.pushInfo("Using bounding box from user input") - stratigraphic_column_source = self.parameterAsSource(parameters, self.INPUT_STRATIGRAPHIC_COLUMN_LAYER, context) + stratigraphic_column_source = self.parameterAsSource( + parameters, self.INPUT_STRATIGRAPHIC_COLUMN_LAYER, context + ) stratigraphic_order = [] if stratigraphic_column_source is not None: - ordered_pairs =[] + ordered_pairs = [] for feature in stratigraphic_column_source.getFeatures(): order = feature.attribute('order') unit_name = feature.attribute('unit_name') ordered_pairs.append((order, unit_name)) ordered_pairs.sort(key=lambda x: x[0]) stratigraphic_order = [pair[1] for pair in ordered_pairs] - feedback.pushInfo(f"DEBUG: parameterAsVectorLayer Stratigraphic order: {stratigraphic_order}") + feedback.pushInfo( + f"DEBUG: parameterAsVectorLayer Stratigraphic order: {stratigraphic_order}" + ) else: - matrix_stratigraphic_order = self.parameterAsMatrix(parameters, self.INPUT_STRATI_COLUMN, context) + matrix_stratigraphic_order = self.parameterAsMatrix( + parameters, self.INPUT_STRATI_COLUMN, context + ) if matrix_stratigraphic_order: stratigraphic_order = [str(row) for row in matrix_stratigraphic_order if row] else: @@ -313,13 +338,14 @@ def processAlgorithm( rename_map[structure_dip_field] = 'DIP' else: missing_fields.append(structure_dip_field) + if missing_fields: raise QgsProcessingException( f"Orientation data missing required field(s): {', '.join(missing_fields)}" ) if rename_map: structure_data = structure_data.rename(columns=rename_map) - + sampled_contacts = qgsLayerToDataFrame(sampled_contacts) sampled_contacts['X'] = sampled_contacts['X'].astype(float) sampled_contacts['Y'] = sampled_contacts['Y'].astype(float) @@ -327,17 +353,15 @@ def processAlgorithm( dtm_data = qgsRasterToGdalDataset(dtm_data) if thickness_type == "InterpolatedStructure": thickness_calculator = InterpolatedStructure( - dtm_data=dtm_data, - bounding_box=bounding_box, - is_strike=is_strike + dtm_data=dtm_data, bounding_box=bounding_box, is_strike=is_strike ) thicknesses = thickness_calculator.compute( - units, - stratigraphic_order, - basal_contacts, - structure_data, - geology_data, - sampled_contacts + units, + stratigraphic_order, + basal_contacts, + structure_data, + geology_data, + sampled_contacts, ) if thickness_type == "StructuralPoint": @@ -345,21 +369,21 @@ def processAlgorithm( dtm_data=dtm_data, bounding_box=bounding_box, max_line_length=max_line_length, - is_strike=is_strike + is_strike=is_strike, ) - thicknesses =thickness_calculator.compute( + thicknesses = thickness_calculator.compute( units, stratigraphic_order, basal_contacts, structure_data, geology_data, - sampled_contacts + sampled_contacts, ) thicknesses = thicknesses[ - ["name","ThicknessMean","ThicknessMedian", "ThicknessStdDev"] + ["name", "ThicknessMean", "ThicknessMedian", "ThicknessStdDev"] ].copy() - + feedback.pushInfo("Exporting Thickness Table...") thicknesses = dataframeToQgsTable( self, @@ -367,7 +391,7 @@ def processAlgorithm( parameters=parameters, context=context, feedback=feedback, - param_name=self.OUTPUT + param_name=self.OUTPUT, ) return {self.OUTPUT: thicknesses[1]} From 91486405da551cfc5580201d5984a3d1628a1822 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 17 Dec 2025 22:18:23 +0000 Subject: [PATCH 11/23] chore: log layer sources in debug params Co-authored-by: lachlangrose <7371904+lachlangrose@users.noreply.github.com> --- .../map2loop_tools/basal_contacts_widget.py | 25 ++++++++++++++++++- .../gui/map2loop_tools/sampler_widget.py | 25 ++++++++++++++++++- .../gui/map2loop_tools/sorter_widget.py | 25 ++++++++++++++++++- .../thickness_calculator_widget.py | 21 ++++++++++++++++ 4 files changed, 93 insertions(+), 3 deletions(-) diff --git a/loopstructural/gui/map2loop_tools/basal_contacts_widget.py b/loopstructural/gui/map2loop_tools/basal_contacts_widget.py index 94b0ffd..65d4c14 100644 --- a/loopstructural/gui/map2loop_tools/basal_contacts_widget.py +++ b/loopstructural/gui/map2loop_tools/basal_contacts_widget.py @@ -67,10 +67,33 @@ def set_debug_manager(self, debug_manager): """Attach a debug manager instance.""" self._debug = debug_manager + def _serialize_layer(self, layer): + try: + return { + "name": layer.name(), + "id": layer.id(), + "provider": layer.providerType() if hasattr(layer, "providerType") else None, + "source": layer.source() if hasattr(layer, "source") else None, + } + except Exception: + return str(layer) + + def _serialize_params_for_logging(self, params): + serialized = {} + for key, value in params.items(): + if hasattr(value, "source") or hasattr(value, "id"): + serialized[key] = self._serialize_layer(value) + else: + serialized[key] = value + return serialized + def _log_params(self, context_label: str): if getattr(self, "_debug", None): try: - self._debug.log_params(context_label=context_label, params=self.get_parameters()) + self._debug.log_params( + context_label=context_label, + params=self._serialize_params_for_logging(self.get_parameters()), + ) except Exception: pass diff --git a/loopstructural/gui/map2loop_tools/sampler_widget.py b/loopstructural/gui/map2loop_tools/sampler_widget.py index fd942b3..3479986 100644 --- a/loopstructural/gui/map2loop_tools/sampler_widget.py +++ b/loopstructural/gui/map2loop_tools/sampler_widget.py @@ -60,10 +60,33 @@ def set_debug_manager(self, debug_manager): """Attach a debug manager instance.""" self._debug = debug_manager + def _serialize_layer(self, layer): + try: + return { + "name": layer.name(), + "id": layer.id(), + "provider": layer.providerType() if hasattr(layer, "providerType") else None, + "source": layer.source() if hasattr(layer, "source") else None, + } + except Exception: + return str(layer) + + def _serialize_params_for_logging(self, params): + serialized = {} + for key, value in params.items(): + if hasattr(value, "source") or hasattr(value, "id"): + serialized[key] = self._serialize_layer(value) + else: + serialized[key] = value + return serialized + def _log_params(self, context_label: str): if getattr(self, "_debug", None): try: - self._debug.log_params(context_label=context_label, params=self.get_parameters()) + self._debug.log_params( + context_label=context_label, + params=self._serialize_params_for_logging(self.get_parameters()), + ) except Exception: pass diff --git a/loopstructural/gui/map2loop_tools/sorter_widget.py b/loopstructural/gui/map2loop_tools/sorter_widget.py index e978b0f..1848e81 100644 --- a/loopstructural/gui/map2loop_tools/sorter_widget.py +++ b/loopstructural/gui/map2loop_tools/sorter_widget.py @@ -81,10 +81,33 @@ def set_debug_manager(self, debug_manager): """Attach a debug manager instance.""" self._debug = debug_manager + def _serialize_layer(self, layer): + try: + return { + "name": layer.name(), + "id": layer.id(), + "provider": layer.providerType() if hasattr(layer, "providerType") else None, + "source": layer.source() if hasattr(layer, "source") else None, + } + except Exception: + return str(layer) + + def _serialize_params_for_logging(self, params): + serialized = {} + for key, value in params.items(): + if hasattr(value, "source") or hasattr(value, "id"): + serialized[key] = self._serialize_layer(value) + else: + serialized[key] = value + return serialized + def _log_params(self, context_label: str): if getattr(self, "_debug", None): try: - self._debug.log_params(context_label=context_label, params=self.get_parameters()) + self._debug.log_params( + context_label=context_label, + params=self._serialize_params_for_logging(self.get_parameters()), + ) except Exception: pass diff --git a/loopstructural/gui/map2loop_tools/thickness_calculator_widget.py b/loopstructural/gui/map2loop_tools/thickness_calculator_widget.py index 204f961..a836a63 100644 --- a/loopstructural/gui/map2loop_tools/thickness_calculator_widget.py +++ b/loopstructural/gui/map2loop_tools/thickness_calculator_widget.py @@ -73,10 +73,31 @@ def set_debug_manager(self, debug_manager): """Attach a debug manager instance.""" self._debug = debug_manager + def _serialize_layer(self, layer): + try: + return { + "name": layer.name(), + "id": layer.id(), + "provider": layer.providerType() if hasattr(layer, "providerType") else None, + "source": layer.source() if hasattr(layer, "source") else None, + } + except Exception: + return str(layer) + + def _serialize_params_for_logging(self, params): + serialized = {} + for key, value in params.items(): + if hasattr(value, "source") or hasattr(value, "id"): + serialized[key] = self._serialize_layer(value) + else: + serialized[key] = value + return serialized + def _log_params(self, context_label: str, params=None): if getattr(self, "_debug", None): try: payload = params if params is not None else self.get_parameters() + payload = self._serialize_params_for_logging(payload) self._debug.log_params(context_label=context_label, params=payload) except Exception: pass From 91d94e652d3ca14b181fccca5c85092db4949299 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 17 Dec 2025 22:55:24 +0000 Subject: [PATCH 12/23] feat: export layers and add offline runner script Co-authored-by: lachlangrose <7371904+lachlangrose@users.noreply.github.com> --- loopstructural/debug_manager.py | 76 +++++++++++++++++++ .../map2loop_tools/basal_contacts_widget.py | 39 +++++++++- .../gui/map2loop_tools/sampler_widget.py | 39 +++++++++- .../gui/map2loop_tools/sorter_widget.py | 39 +++++++++- .../thickness_calculator_widget.py | 37 ++++++++- 5 files changed, 214 insertions(+), 16 deletions(-) diff --git a/loopstructural/debug_manager.py b/loopstructural/debug_manager.py index c5f71a1..05f2dcf 100644 --- a/loopstructural/debug_manager.py +++ b/loopstructural/debug_manager.py @@ -157,6 +157,7 @@ def log_params(self, context_label: str, params: Any): message=f"[map2loop] Params saved to: {file_path}", log_level=0, ) + self._ensure_runner_script() except Exception as err: self.plugin.log( message=f"[map2loop] Failed to save params for {context_label}: {err}", @@ -183,3 +184,78 @@ def save_debug_file(self, filename: str, content_bytes: bytes): log_level=2, ) return None + + def _ensure_runner_script(self): + """Create a reusable runner script in the debug directory.""" + try: + debug_dir = self.get_effective_debug_dir() + script_path = debug_dir / "run_map2loop.py" + if script_path.exists(): + return + script_content = """#!/usr/bin/env python3 +import argparse +import json +from pathlib import Path + +import geopandas as gpd + +from loopstructural.main import m2l_api + + +def load_layer(layer_info): + if isinstance(layer_info, dict): + export_path = layer_info.get("export_path") + if export_path: + return gpd.read_file(export_path) + return layer_info + + +def load_params(path): + params = json.loads(Path(path).read_text()) + # convert exported layers to GeoDataFrames + for key, value in list(params.items()): + params[key] = load_layer(value) + return params + + +def run(params): + if "sampler_type" in params: + result = m2l_api.sample_contacts(**params) + print("Sampler result:", result) + elif "sorting_algorithm" in params: + result = m2l_api.sort_stratigraphic_column(**params) + print("Sorter result:", result) + elif "calculator_type" in params: + result = m2l_api.calculate_thickness(**params) + print("Thickness result:", result) + elif "geology_layer" in params and "unit_name_field" in params: + result = m2l_api.extract_basal_contacts(**params) + print("Basal contacts result:", result) + else: + print("Unknown params shape; inspect manually:", params.keys()) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument( + "params", + nargs="?", + default=None, + help="Path to params JSON (defaults to first *_params.json in this folder)", + ) + args = parser.parse_args() + base = Path(__file__).parent + params_path = Path(args.params) if args.params else next(base.glob("*_params.json")) + params = load_params(params_path) + run(params) + + +if __name__ == "__main__": + main() +""" + script_path.write_text(script_content, encoding="utf-8") + except Exception as err: + self.plugin.log( + message=f"[map2loop] Failed to create runner script: {err}", + log_level=1, + ) diff --git a/loopstructural/gui/map2loop_tools/basal_contacts_widget.py b/loopstructural/gui/map2loop_tools/basal_contacts_widget.py index 65d4c14..d31160e 100644 --- a/loopstructural/gui/map2loop_tools/basal_contacts_widget.py +++ b/loopstructural/gui/map2loop_tools/basal_contacts_widget.py @@ -4,6 +4,7 @@ from PyQt5.QtWidgets import QMessageBox, QWidget from qgis.PyQt import uic +from qgis.core import QgsProject, QgsVectorFileWriter from ...main.helpers import ColumnMatcher, get_layer_names from ...main.m2l_api import extract_basal_contacts @@ -67,22 +68,50 @@ def set_debug_manager(self, debug_manager): """Attach a debug manager instance.""" self._debug = debug_manager - def _serialize_layer(self, layer): + def _export_layer_for_debug(self, layer, name_prefix: str): + if not (self._debug and self._debug.is_debug()): + return None try: + debug_dir = self._debug.get_effective_debug_dir() + out_path = debug_dir / f"{name_prefix}.gpkg" + options = QgsVectorFileWriter.SaveVectorOptions() + options.driverName = "GPKG" + options.layerName = layer.name() + res = QgsVectorFileWriter.writeAsVectorFormatV3( + layer, + str(out_path), + QgsProject.instance().transformContext(), + options, + ) + if res[0] == QgsVectorFileWriter.NoError: + return str(out_path) + except Exception as err: + self._debug.plugin.log( + message=f"[map2loop] Failed to export layer '{name_prefix}': {err}", + log_level=2, + ) + return None + + def _serialize_layer(self, layer, name_prefix: str): + try: + export_path = self._export_layer_for_debug(layer, name_prefix) return { "name": layer.name(), "id": layer.id(), "provider": layer.providerType() if hasattr(layer, "providerType") else None, "source": layer.source() if hasattr(layer, "source") else None, + "export_path": export_path, } except Exception: return str(layer) - def _serialize_params_for_logging(self, params): + def _serialize_params_for_logging(self, params, context_label: str): serialized = {} for key, value in params.items(): if hasattr(value, "source") or hasattr(value, "id"): - serialized[key] = self._serialize_layer(value) + serialized[key] = self._serialize_layer( + value, f"{context_label}_{key}" + ) else: serialized[key] = value return serialized @@ -92,7 +121,9 @@ def _log_params(self, context_label: str): try: self._debug.log_params( context_label=context_label, - params=self._serialize_params_for_logging(self.get_parameters()), + params=self._serialize_params_for_logging( + self.get_parameters(), context_label + ), ) except Exception: pass diff --git a/loopstructural/gui/map2loop_tools/sampler_widget.py b/loopstructural/gui/map2loop_tools/sampler_widget.py index 3479986..c89fec3 100644 --- a/loopstructural/gui/map2loop_tools/sampler_widget.py +++ b/loopstructural/gui/map2loop_tools/sampler_widget.py @@ -4,6 +4,7 @@ from PyQt5.QtWidgets import QMessageBox, QWidget from qgis.PyQt import uic +from qgis.core import QgsProject, QgsVectorFileWriter from loopstructural.toolbelt.preferences import PlgOptionsManager @@ -60,22 +61,50 @@ def set_debug_manager(self, debug_manager): """Attach a debug manager instance.""" self._debug = debug_manager - def _serialize_layer(self, layer): + def _export_layer_for_debug(self, layer, name_prefix: str): + if not (self._debug and self._debug.is_debug()): + return None try: + debug_dir = self._debug.get_effective_debug_dir() + out_path = debug_dir / f"{name_prefix}.gpkg" + options = QgsVectorFileWriter.SaveVectorOptions() + options.driverName = "GPKG" + options.layerName = layer.name() + res = QgsVectorFileWriter.writeAsVectorFormatV3( + layer, + str(out_path), + QgsProject.instance().transformContext(), + options, + ) + if res[0] == QgsVectorFileWriter.NoError: + return str(out_path) + except Exception as err: + self._debug.plugin.log( + message=f"[map2loop] Failed to export layer '{name_prefix}': {err}", + log_level=2, + ) + return None + + def _serialize_layer(self, layer, name_prefix: str): + try: + export_path = self._export_layer_for_debug(layer, name_prefix) return { "name": layer.name(), "id": layer.id(), "provider": layer.providerType() if hasattr(layer, "providerType") else None, "source": layer.source() if hasattr(layer, "source") else None, + "export_path": export_path, } except Exception: return str(layer) - def _serialize_params_for_logging(self, params): + def _serialize_params_for_logging(self, params, context_label: str): serialized = {} for key, value in params.items(): if hasattr(value, "source") or hasattr(value, "id"): - serialized[key] = self._serialize_layer(value) + serialized[key] = self._serialize_layer( + value, f"{context_label}_{key}" + ) else: serialized[key] = value return serialized @@ -85,7 +114,9 @@ def _log_params(self, context_label: str): try: self._debug.log_params( context_label=context_label, - params=self._serialize_params_for_logging(self.get_parameters()), + params=self._serialize_params_for_logging( + self.get_parameters(), context_label + ), ) except Exception: pass diff --git a/loopstructural/gui/map2loop_tools/sorter_widget.py b/loopstructural/gui/map2loop_tools/sorter_widget.py index 1848e81..7b6b06a 100644 --- a/loopstructural/gui/map2loop_tools/sorter_widget.py +++ b/loopstructural/gui/map2loop_tools/sorter_widget.py @@ -5,6 +5,7 @@ from PyQt5.QtWidgets import QMessageBox, QWidget from qgis.core import QgsRasterLayer from qgis.PyQt import uic +from qgis.core import QgsProject, QgsVectorFileWriter from loopstructural.main.helpers import get_layer_names from loopstructural.main.m2l_api import PARAMETERS_DICTIONARY, SORTER_LIST @@ -81,22 +82,50 @@ def set_debug_manager(self, debug_manager): """Attach a debug manager instance.""" self._debug = debug_manager - def _serialize_layer(self, layer): + def _export_layer_for_debug(self, layer, name_prefix: str): + if not (self._debug and self._debug.is_debug()): + return None try: + debug_dir = self._debug.get_effective_debug_dir() + out_path = debug_dir / f"{name_prefix}.gpkg" + options = QgsVectorFileWriter.SaveVectorOptions() + options.driverName = "GPKG" + options.layerName = layer.name() + res = QgsVectorFileWriter.writeAsVectorFormatV3( + layer, + str(out_path), + QgsProject.instance().transformContext(), + options, + ) + if res[0] == QgsVectorFileWriter.NoError: + return str(out_path) + except Exception as err: + self._debug.plugin.log( + message=f"[map2loop] Failed to export layer '{name_prefix}': {err}", + log_level=2, + ) + return None + + def _serialize_layer(self, layer, name_prefix: str): + try: + export_path = self._export_layer_for_debug(layer, name_prefix) return { "name": layer.name(), "id": layer.id(), "provider": layer.providerType() if hasattr(layer, "providerType") else None, "source": layer.source() if hasattr(layer, "source") else None, + "export_path": export_path, } except Exception: return str(layer) - def _serialize_params_for_logging(self, params): + def _serialize_params_for_logging(self, params, context_label: str): serialized = {} for key, value in params.items(): if hasattr(value, "source") or hasattr(value, "id"): - serialized[key] = self._serialize_layer(value) + serialized[key] = self._serialize_layer( + value, f"{context_label}_{key}" + ) else: serialized[key] = value return serialized @@ -106,7 +135,9 @@ def _log_params(self, context_label: str): try: self._debug.log_params( context_label=context_label, - params=self._serialize_params_for_logging(self.get_parameters()), + params=self._serialize_params_for_logging( + self.get_parameters(), context_label + ), ) except Exception: pass diff --git a/loopstructural/gui/map2loop_tools/thickness_calculator_widget.py b/loopstructural/gui/map2loop_tools/thickness_calculator_widget.py index a836a63..b5e878c 100644 --- a/loopstructural/gui/map2loop_tools/thickness_calculator_widget.py +++ b/loopstructural/gui/map2loop_tools/thickness_calculator_widget.py @@ -4,6 +4,7 @@ from PyQt5.QtWidgets import QLabel, QMessageBox, QWidget from qgis.PyQt import uic +from qgis.core import QgsProject, QgsVectorFileWriter from loopstructural.toolbelt.preferences import PlgOptionsManager @@ -73,22 +74,50 @@ def set_debug_manager(self, debug_manager): """Attach a debug manager instance.""" self._debug = debug_manager - def _serialize_layer(self, layer): + def _export_layer_for_debug(self, layer, name_prefix: str): + if not (self._debug and self._debug.is_debug()): + return None try: + debug_dir = self._debug.get_effective_debug_dir() + out_path = debug_dir / f"{name_prefix}.gpkg" + options = QgsVectorFileWriter.SaveVectorOptions() + options.driverName = "GPKG" + options.layerName = layer.name() + res = QgsVectorFileWriter.writeAsVectorFormatV3( + layer, + str(out_path), + QgsProject.instance().transformContext(), + options, + ) + if res[0] == QgsVectorFileWriter.NoError: + return str(out_path) + except Exception as err: + self._debug.plugin.log( + message=f"[map2loop] Failed to export layer '{name_prefix}': {err}", + log_level=2, + ) + return None + + def _serialize_layer(self, layer, name_prefix: str): + try: + export_path = self._export_layer_for_debug(layer, name_prefix) return { "name": layer.name(), "id": layer.id(), "provider": layer.providerType() if hasattr(layer, "providerType") else None, "source": layer.source() if hasattr(layer, "source") else None, + "export_path": export_path, } except Exception: return str(layer) - def _serialize_params_for_logging(self, params): + def _serialize_params_for_logging(self, params, context_label: str): serialized = {} for key, value in params.items(): if hasattr(value, "source") or hasattr(value, "id"): - serialized[key] = self._serialize_layer(value) + serialized[key] = self._serialize_layer( + value, f"{context_label}_{key}" + ) else: serialized[key] = value return serialized @@ -97,7 +126,7 @@ def _log_params(self, context_label: str, params=None): if getattr(self, "_debug", None): try: payload = params if params is not None else self.get_parameters() - payload = self._serialize_params_for_logging(payload) + payload = self._serialize_params_for_logging(payload, context_label) self._debug.log_params(context_label=context_label, params=payload) except Exception: pass From e590b1936709da380edf05416e797864ec45c745 Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Thu, 18 Dec 2025 17:07:27 +1100 Subject: [PATCH 13/23] fix: give debug manager the logger --- loopstructural/debug_manager.py | 1 + 1 file changed, 1 insertion(+) diff --git a/loopstructural/debug_manager.py b/loopstructural/debug_manager.py index 05f2dcf..c745e14 100644 --- a/loopstructural/debug_manager.py +++ b/loopstructural/debug_manager.py @@ -27,6 +27,7 @@ def __init__(self, plugin): self._session_id = uuid.uuid4().hex self._project_name = self._get_project_name() self._debug_state_logged = False + self.logger = self.plugin.log def _get_settings(self): return plg_prefs_hdlr.PlgOptionsManager.get_plg_settings() From c487dec208cb40af29b85024e1515cf1972bedb1 Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Thu, 18 Dec 2025 17:08:24 +1100 Subject: [PATCH 14/23] fix: add generic exporter for m2l objects --- loopstructural/main/debug/export.py | 41 ++++++++ loopstructural/main/m2l_api.py | 153 ++++++++++++++++++++++++++-- 2 files changed, 184 insertions(+), 10 deletions(-) create mode 100644 loopstructural/main/debug/export.py diff --git a/loopstructural/main/debug/export.py b/loopstructural/main/debug/export.py new file mode 100644 index 0000000..e292b98 --- /dev/null +++ b/loopstructural/main/debug/export.py @@ -0,0 +1,41 @@ +import json +import pickle +from doctest import debug +from pathlib import Path +from typing import Any, Dict, Optional + + +def export_debug_package( + debug_manager, + m2l_object, + runner_script_name: str = "run_debug_model.py", + params: Dict[str, Any] = {}, +): + + exported: Dict[str, str] = {} + if not debug_manager or not getattr(debug_manager, "is_debug", lambda: False)(): + return exported + # store the m2l object (calculator/sampler etc) and the parameters used + # in its main function e.g. compute(), sample() etc + # these will be pickled and saved to the debug directory + # with the prefix of the runner script name to avoid name clashes + # e.g. run_debug_model_m2l_object.pkl, run_debug_model_parameters.pkl + pickles = {'m2l_object': m2l_object, 'params': params} + + if pickles: + for name, obj in pickles.items(): + pkl_name = f"{runner_script_name.replace('.py', '')}_{name}.pkl" + try: + debug_manager.save_debug_file(pkl_name, pickle.dumps(obj)) + exported[name] = pkl_name + except Exception as e: + debug_manager.logger(f"Failed to save debug file '{pkl_name}': {e}") + + script = ( + open(Path(__file__).parent / 'template.txt') + .read() + .format(runner_name=runner_script_name.replace('.py', '')) + ) + debug_manager.save_debug_file(runner_script_name, script.encode("utf-8")) + debug_manager.logger(f"Exported debug package with runner script '{runner_script_name}'") + return exported diff --git a/loopstructural/main/m2l_api.py b/loopstructural/main/m2l_api.py index 97cb823..46a906e 100644 --- a/loopstructural/main/m2l_api.py +++ b/loopstructural/main/m2l_api.py @@ -1,3 +1,5 @@ +from unittest import runner + import pandas as pd from map2loop.contact_extractor import ContactExtractor from map2loop.sampler import SamplerDecimator, SamplerSpacing @@ -10,8 +12,12 @@ ) from map2loop.thickness_calculator import InterpolatedStructure, StructuralPoint from osgeo import gdal +from pkg_resources import run_main + +from loopstructural.main.debug import export from ..main.vectorLayerWrapper import qgsLayerToDataFrame, qgsLayerToGeoDataFrame +from .debug.export import export_debug_package # Mapping of sorter names to sorter classes SORTER_LIST = { @@ -38,6 +44,7 @@ def extract_basal_contacts( unit_name_field=None, all_contacts=False, updater=None, + debug_manager=None, ): """Extract basal contacts from geological data. @@ -76,9 +83,40 @@ def extract_basal_contacts( faults = qgsLayerToGeoDataFrame(faults) if faults else None if unit_name_field and unit_name_field != 'UNITNAME' and unit_name_field in geology.columns: geology = geology.rename(columns={unit_name_field: 'UNITNAME'}) + # Log parameters via DebugManager if provided + if debug_manager: + debug_manager.log_params( + "extract_basal_contacts", + { + "stratigraphic_order": stratigraphic_order, + "ignore_units": ignore_units, + "unit_name_field": unit_name_field, + "all_contacts": all_contacts, + "geology": geology, + "faults": faults, + }, + ) if updater: updater("Extracting Basal Contacts...") contact_extractor = ContactExtractor(geology, faults) + # If debug_manager present and debug mode enabled, export tool, layers and params + try: + if debug_manager and getattr(debug_manager, "is_debug", lambda: False)(): + + layers = {"geology": geology, "faults": faults} + pickles = {"contact_extractor": contact_extractor} + # export layers and pickles first to get the actual filenames used + exported = export_debug_package( + debug_manager, + runner_script_name="run_extract_basal_contacts.py", + m2l_object=contact_extractor, + params={'stratigraphic_order': stratigraphic_order}, + ) + + except Exception as e: + print("Failed to save sampler debug info") + print(e) + all_contacts_result = contact_extractor.extract_all_contacts() basal_contacts = contact_extractor.extract_basal_contacts(stratigraphic_order) @@ -104,6 +142,7 @@ def sort_stratigraphic_column( dipdir_field="DIPDIR", orientation_type="Dip Direction", dtm=None, + debug_manager=None, updater=None, contacts=None, ): @@ -153,6 +192,23 @@ def sort_stratigraphic_column( # Convert layers to GeoDataFrames geology_gdf = qgsLayerToGeoDataFrame(geology) contacts_gdf = qgsLayerToGeoDataFrame(contacts) + + # Log parameters via DebugManager if provided + if debug_manager: + debug_manager.log_params( + "sort_stratigraphic_column", + { + "sorting_algorithm": sorting_algorithm, + "unit_name_field": unit_name_field, + "min_age_field": min_age_field, + "max_age_field": max_age_field, + "orientation_type": orientation_type, + "dtm": dtm, + "geology": geology_gdf, + "contacts": contacts_gdf, + }, + ) + # Build units DataFrame if ( unit_name_field @@ -208,6 +264,21 @@ def sort_stratigraphic_column( sorter_args = {k: v for k, v in all_args.items() if k in required_args} print(f'Calling sorter with args: {sorter_args.keys()}') sorter = sorter_cls(**sorter_args) + # If debugging, pickle sorter and write a small runner script + try: + if debug_manager and getattr(debug_manager, "is_debug", lambda: False)(): + + _exported = export_debug_package( + debug_manager, + m2l_object=sorter, + params={'units_df': units_df}, + runner_script_name="run_sort_stratigraphic_column.py", + ) + + except Exception as e: + print("Failed to save sampler debug info") + print(e) + order = sorter.sort(units_df) if updater: updater(f"Sorting complete: {len(order)} units ordered") @@ -222,6 +293,7 @@ def sample_contacts( spacing=None, dtm=None, geology=None, + debug_manager=None, updater=None, ): """Sample spatial data using map2loop samplers. @@ -267,6 +339,20 @@ def sample_contacts( if geology is not None: geology_gdf = qgsLayerToGeoDataFrame(geology) + # Log parameters via DebugManager if provided + if debug_manager: + debug_manager.log_params( + "sample_contacts", + { + "sampler_type": sampler_type, + "decimation": decimation, + "spacing": spacing, + "dtm": dtm, + "geology": geology_gdf, + "spatial_data": spatial_gdf, + }, + ) + # Run sampler if sampler_type == "Decimator": if decimation is None: @@ -281,8 +367,19 @@ def sample_contacts( samples = sampler.sample(spatial_gdf) - if updater: - updater(f"Sampling complete: {len(samples)} samples generated") + try: + if debug_manager and getattr(debug_manager, "is_debug", lambda: False)(): + _exported = export_debug_package( + debug_manager, + m2l_object=sampler, + params={'spatial_data': spatial_gdf}, + runner_script_name='run_sample_contacts.py', + ) + + except Exception as e: + print("Failed to save sampler debug info") + print(e) + pass return samples @@ -300,6 +397,7 @@ def calculate_thickness( orientation_type="Dip Direction", max_line_length=None, stratigraphic_order=None, + debug_manager=None, updater=None, basal_contacts_unit_name=None, ): @@ -352,6 +450,24 @@ def calculate_thickness( ) sampled_contacts_gdf = qgsLayerToGeoDataFrame(sampled_contacts) structure_gdf = qgsLayerToDataFrame(structure) + + # Log parameters via DebugManager if provided + if debug_manager: + debug_manager.log_params( + "calculate_thickness", + { + "calculator_type": calculator_type, + "unit_name_field": unit_name_field, + "orientation_type": orientation_type, + "max_line_length": max_line_length, + "stratigraphic_order": stratigraphic_order, + "geology": geology_gdf, + "basal_contacts": basal_contacts_gdf, + "sampled_contacts": sampled_contacts_gdf, + "structure": structure_gdf, + }, + ) + bounding_box = { 'maxx': geology_gdf.total_bounds[2], 'minx': geology_gdf.total_bounds[0], @@ -408,7 +524,30 @@ def calculate_thickness( units_unique = units.drop_duplicates(subset=['UNITNAME']).reset_index(drop=True) units = pd.DataFrame({'name': units_unique['UNITNAME']}) basal_contacts_gdf['type'] = 'BASAL' # required by calculator - + + # No local export path placeholders required; export_debug_package handles exports + try: + if debug_manager and getattr(debug_manager, "is_debug", lambda: False)(): + # Export layers and pickled objects first to get their exported filenames + + _exported = export_debug_package( + debug_manager, + runner_script_name="run_calculate_thickness.py", + m2l_object=calculator, + params={ + 'units': units, + 'stratigraphic_order': stratigraphic_order, + 'basal_contacts': basal_contacts_gdf, + 'structure': structure_gdf, + 'geology': geology_gdf, + 'sampled_contacts': sampled_contacts_gdf, + }, + ) + + except Exception as e: + print("Failed to save sampler debug info") + raise e + thickness = calculator.compute( units, stratigraphic_order, @@ -417,12 +556,6 @@ def calculate_thickness( geology_gdf, sampled_contacts_gdf, ) + # Ensure result object exists for return and for any debug export res = {'thicknesses': thickness} - if updater: - updater(f"Thickness calculation complete: {len(thickness)} records") - if hasattr(calculator, 'lines'): - res['lines'] = calculator.lines - if hasattr(calculator, 'location_tracking'): - res['location_tracking'] = calculator.location_tracking - return res From c74aedc01de29889e079c80ae9b5c7ab3c8c0d7f Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Thu, 18 Dec 2025 17:08:52 +1100 Subject: [PATCH 15/23] fix: pass debug manager to api for exporting packages --- .../map2loop_tools/basal_contacts_widget.py | 20 +++++++++---------- .../gui/map2loop_tools/sorter_widget.py | 16 +++++++-------- .../thickness_calculator_widget.py | 15 +++++++------- loopstructural/main/debug/template.txt | 10 ++++++++++ 4 files changed, 34 insertions(+), 27 deletions(-) create mode 100644 loopstructural/main/debug/template.txt diff --git a/loopstructural/gui/map2loop_tools/basal_contacts_widget.py b/loopstructural/gui/map2loop_tools/basal_contacts_widget.py index d31160e..30d31dc 100644 --- a/loopstructural/gui/map2loop_tools/basal_contacts_widget.py +++ b/loopstructural/gui/map2loop_tools/basal_contacts_widget.py @@ -3,8 +3,8 @@ import os from PyQt5.QtWidgets import QMessageBox, QWidget -from qgis.PyQt import uic from qgis.core import QgsProject, QgsVectorFileWriter +from qgis.PyQt import uic from ...main.helpers import ColumnMatcher, get_layer_names from ...main.m2l_api import extract_basal_contacts @@ -109,9 +109,7 @@ def _serialize_params_for_logging(self, params, context_label: str): serialized = {} for key, value in params.items(): if hasattr(value, "source") or hasattr(value, "id"): - serialized[key] = self._serialize_layer( - value, f"{context_label}_{key}" - ) + serialized[key] = self._serialize_layer(value, f"{context_label}_{key}") else: serialized[key] = value return serialized @@ -121,9 +119,7 @@ def _log_params(self, context_label: str): try: self._debug.log_params( context_label=context_label, - params=self._serialize_params_for_logging( - self.get_parameters(), context_label - ), + params=self._serialize_params_for_logging(self.get_parameters(), context_label), ) except Exception: pass @@ -210,6 +206,7 @@ def _run_extractor(self): message=f"[map2loop] Basal contacts extraction failed: {err}", log_level=2, ) + raise err QMessageBox.critical(self, "Error", f"An error occurred: {err}") def get_parameters(self): @@ -270,6 +267,8 @@ def _extract_contacts(self): all_contacts = self.allContactsCheckBox.isChecked() if all_contacts: stratigraphic_order = list({g[unit_name_field] for g in geology.getFeatures()}) + self.data_manager.logger(f"Extracting all contacts for units: {stratigraphic_order}") + result = extract_basal_contacts( geology=geology, stratigraphic_order=stratigraphic_order, @@ -278,13 +277,14 @@ def _extract_contacts(self): unit_name_field=unit_name_field, all_contacts=all_contacts, updater=lambda message: QMessageBox.information(self, "Extraction Progress", message), + debug_manager=self._debug, ) - + self.data_manager.logger(f'All contacts extracted: {all_contacts}') contact_type = "basal contacts" if result: - if all_contacts: + if all_contacts and result['all_contacts'].empty is False: addGeoDataFrameToproject(result['all_contacts'], "All contacts") contact_type = "all contacts and basal contacts" - else: + elif not all_contacts and result['basal_contacts'].empty is False: addGeoDataFrameToproject(result['basal_contacts'], "Basal contacts") return result, contact_type diff --git a/loopstructural/gui/map2loop_tools/sorter_widget.py b/loopstructural/gui/map2loop_tools/sorter_widget.py index 7b6b06a..a393055 100644 --- a/loopstructural/gui/map2loop_tools/sorter_widget.py +++ b/loopstructural/gui/map2loop_tools/sorter_widget.py @@ -3,9 +3,8 @@ import os from PyQt5.QtWidgets import QMessageBox, QWidget -from qgis.core import QgsRasterLayer +from qgis.core import QgsProject, QgsRasterLayer, QgsVectorFileWriter from qgis.PyQt import uic -from qgis.core import QgsProject, QgsVectorFileWriter from loopstructural.main.helpers import get_layer_names from loopstructural.main.m2l_api import PARAMETERS_DICTIONARY, SORTER_LIST @@ -123,9 +122,7 @@ def _serialize_params_for_logging(self, params, context_label: str): serialized = {} for key, value in params.items(): if hasattr(value, "source") or hasattr(value, "id"): - serialized[key] = self._serialize_layer( - value, f"{context_label}_{key}" - ) + serialized[key] = self._serialize_layer(value, f"{context_label}_{key}") else: serialized[key] = value return serialized @@ -135,9 +132,7 @@ def _log_params(self, context_label: str): try: self._debug.log_params( context_label=context_label, - params=self._serialize_params_for_logging( - self.get_parameters(), context_label - ), + params=self._serialize_params_for_logging(self.get_parameters(), context_label), ) except Exception: pass @@ -362,7 +357,10 @@ def _run_sorter(self): ] kwargs['dtm'] = self.dtmLayerComboBox.currentLayer() - result = sort_stratigraphic_column(**kwargs) + result = sort_stratigraphic_column( + **kwargs, + debug_manager=self._debug, + ) if self._debug and self._debug.is_debug(): try: payload = "\n".join(result) if result else "" diff --git a/loopstructural/gui/map2loop_tools/thickness_calculator_widget.py b/loopstructural/gui/map2loop_tools/thickness_calculator_widget.py index b5e878c..8f2d9cc 100644 --- a/loopstructural/gui/map2loop_tools/thickness_calculator_widget.py +++ b/loopstructural/gui/map2loop_tools/thickness_calculator_widget.py @@ -3,8 +3,8 @@ import os from PyQt5.QtWidgets import QLabel, QMessageBox, QWidget -from qgis.PyQt import uic from qgis.core import QgsProject, QgsVectorFileWriter +from qgis.PyQt import uic from loopstructural.toolbelt.preferences import PlgOptionsManager @@ -115,9 +115,7 @@ def _serialize_params_for_logging(self, params, context_label: str): serialized = {} for key, value in params.items(): if hasattr(value, "source") or hasattr(value, "id"): - serialized[key] = self._serialize_layer( - value, f"{context_label}_{key}" - ) + serialized[key] = self._serialize_layer(value, f"{context_label}_{key}") else: serialized[key] = value return serialized @@ -290,12 +288,13 @@ def _run_calculator(self): if strati_order: kwargs['stratigraphic_order'] = strati_order - result = calculate_thickness(**kwargs) + result = calculate_thickness( + **kwargs, + debug_manager=self._debug, + ) if self._debug and self._debug.is_debug(): try: - self._debug.save_debug_file( - "thickness_result.txt", str(result).encode("utf-8") - ) + self._debug.save_debug_file("thickness_result.txt", str(result).encode("utf-8")) except Exception as err: self._debug.plugin.log( message=f"[map2loop] Failed to save thickness debug output: {err}", diff --git a/loopstructural/main/debug/template.txt b/loopstructural/main/debug/template.txt new file mode 100644 index 0000000..1a40f85 --- /dev/null +++ b/loopstructural/main/debug/template.txt @@ -0,0 +1,10 @@ +#!/usr/bin/env python3 +import pickle +from pathlib import Path + +base = Path(__file__).parent +with open(base / '{runner_name}_m2l_object.pkl', 'rb') as fh: + m2l_object = pickle.load(fh) +with open(base / '{runner_name}_parameters.pkl', 'rb') as fh: + params = pickle.load(fh) +m2l_object(**params) From 118aecc412174b6609f23d85cce009e415a1a6c7 Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Wed, 7 Jan 2026 09:39:57 +1100 Subject: [PATCH 16/23] move export to debug manager --- loopstructural/debug_manager.py | 150 +++++++++++++++++- .../gui/map2loop_tools/sampler_widget.py | 42 ++--- .../gui/map2loop_tools/sorter_widget.py | 19 +-- .../thickness_calculator_widget.py | 20 +-- 4 files changed, 172 insertions(+), 59 deletions(-) diff --git a/loopstructural/debug_manager.py b/loopstructural/debug_manager.py index c745e14..3d46a52 100644 --- a/loopstructural/debug_manager.py +++ b/loopstructural/debug_manager.py @@ -131,8 +131,110 @@ def _sanitize_label(self, context_label: str) -> str: for c in context_label.replace(" ", "_").lower() ) + def _export_gdf(self, gdf, out_path: Path) -> bool: + """Export a GeoPandas GeoDataFrame to GeoJSON if geopandas available.""" + try: + import geopandas as gpd + + if isinstance(gdf, gpd.GeoDataFrame): + gdf.to_file(out_path, driver="GeoJSON") + return True + except Exception as err: + # geopandas not available or export failed + self.plugin.log(message=f"[map2loop] GeoDataFrame export failed: {err}", log_level=1) + return False + + def _export_qgis_layer(self, qgs_layer, out_path: Path) -> bool: + """Export a QGIS QgsVectorLayer to GeoJSON if available.""" + try: + from qgis.core import QgsCoordinateTransformContext, QgsVectorFileWriter + + # In QGIS 3, writeAsVectorFormatV2 is preferred, but use writeAsVectorFormat for compatibility + err = QgsVectorFileWriter.writeAsVectorFormat( + qgs_layer, str(out_path), "utf-8", qgs_layer.crs(), "GeoJSON" + ) + # writeAsVectorFormat returns 0 on success in many QGIS versions + if err == 0: + return True + # Some versions return (error, msg) + return False + except Exception as err: + self.plugin.log(message=f"[map2loop] QGIS layer export failed: {err}", log_level=1) + return False + + def _prepare_value_for_export(self, safe_label: str, key: str, value): + """If value is an exportable object, export it and return a reference dict. + + Otherwise return the original value. The reference dict has the form + {"export_path": } so the runner script can re-load exported layers. + """ + debug_dir = self.get_effective_debug_dir() + filename_base = f"{safe_label}_{key}" + + # GeoPandas GeoDataFrame + try: + import geopandas as gpd + + if isinstance(value, gpd.GeoDataFrame): + out_path = debug_dir / f"{filename_base}.geojson" + if self._export_gdf(value, out_path): + return {"export_path": str(out_path)} + except Exception: + pass + + # QGIS vector layer + try: + from qgis.core import QgsVectorLayer + + if isinstance(value, QgsVectorLayer): + out_path = debug_dir / f"{filename_base}.geojson" + if self._export_qgis_layer(value, out_path): + return {"export_path": str(out_path)} + except Exception: + pass + + # Not exportable: return as-is + return value + + def _prepare_params_for_export(self, context_label: str, params: Any): + """Walk params and export embedded spatial layers where possible. + + Returns a payload safe to JSON-serialize where exported objects are + replaced with {"export_path": ...} references. + """ + safe_label = self._sanitize_label(context_label) + + def _recurse(obj, prefix=""): + # dict + if isinstance(obj, dict): + out = {} + for k, v in obj.items(): + out[k] = _recurse(v, f"{prefix}_{k}" if prefix else k) + return out + # list/tuple + if isinstance(obj, (list, tuple)): + return [_recurse(v, f"{prefix}_{i}") for i, v in enumerate(obj)] + # try to export known types + exported = self._prepare_value_for_export(safe_label, prefix or "item", obj) + # If export returns same object, return raw value (may fail JSON later) + return exported + + return _recurse(params) + + def export_file(self, filename: str, content_bytes: bytes): + """Convenience wrapper so callers can ask DebugManager to export a file. + + This centralizes debug file persistence through DebugManager. + """ + return self.save_debug_file(filename, content_bytes) + def log_params(self, context_label: str, params: Any): - """Log parameters and persist them when debug mode is enabled.""" + """Log parameters and persist them when debug mode is enabled. + + Prior to saving params, attempt to export embedded spatial layers and + replace them with file references so the saved JSON can be reloaded by + the runner script. + """ try: self.plugin.log( message=f"[map2loop] {context_label} parameters: {str(params)}", @@ -148,12 +250,15 @@ def log_params(self, context_label: str, params: Any): if self.is_debug(): try: + # Prepare params by exporting embedded layers where applicable + payload = params if isinstance(params, dict) else {"_payload": params} + safe_payload = self._prepare_params_for_export(context_label, payload) + debug_dir = self.get_effective_debug_dir() safe_label = self._sanitize_label(context_label) file_path = debug_dir / f"{safe_label}_params.json" - payload = params if isinstance(params, dict) else {"_payload": params} with open(file_path, "w", encoding="utf-8") as file_handle: - json.dump(payload, file_handle, ensure_ascii=False, indent=2, default=str) + json.dump(safe_payload, file_handle, ensure_ascii=False, indent=2, default=str) self.plugin.log( message=f"[map2loop] Params saved to: {file_path}", log_level=0, @@ -260,3 +365,42 @@ def main(): message=f"[map2loop] Failed to create runner script: {err}", log_level=1, ) + + def export_layer(self, layer, name_prefix: str): + """Public wrapper to export a layer or GeoDataFrame via the DebugManager. + + Returns the path to the exported file (string) on success, or None on failure. + """ + if not self.is_debug(): + return None + safe_prefix = self._sanitize_label(name_prefix) + debug_dir = self.get_effective_debug_dir() + + # Try GeoPandas GeoDataFrame + try: + import geopandas as gpd + + if isinstance(layer, gpd.GeoDataFrame): + out_path = debug_dir / f"{safe_prefix}.geojson" + if self._export_gdf(layer, out_path): + return str(out_path) + except Exception: + pass + + # Try QGIS vector layer + try: + from qgis.core import QgsVectorLayer + + if isinstance(layer, QgsVectorLayer): + out_path = debug_dir / f"{safe_prefix}.geojson" + if self._export_qgis_layer(layer, out_path): + return str(out_path) + except Exception: + pass + + # Unsupported type or export failed + self.plugin.log( + message=f"[map2loop] export_layer: Could not export object of type {type(layer)}", + log_level=1, + ) + return None diff --git a/loopstructural/gui/map2loop_tools/sampler_widget.py b/loopstructural/gui/map2loop_tools/sampler_widget.py index c89fec3..67cb35f 100644 --- a/loopstructural/gui/map2loop_tools/sampler_widget.py +++ b/loopstructural/gui/map2loop_tools/sampler_widget.py @@ -3,8 +3,8 @@ import os from PyQt5.QtWidgets import QMessageBox, QWidget -from qgis.PyQt import uic from qgis.core import QgsProject, QgsVectorFileWriter +from qgis.PyQt import uic from loopstructural.toolbelt.preferences import PlgOptionsManager @@ -62,27 +62,21 @@ def set_debug_manager(self, debug_manager): self._debug = debug_manager def _export_layer_for_debug(self, layer, name_prefix: str): - if not (self._debug and self._debug.is_debug()): - return None + # Prefer DebugManager.export_layer if available try: - debug_dir = self._debug.get_effective_debug_dir() - out_path = debug_dir / f"{name_prefix}.gpkg" - options = QgsVectorFileWriter.SaveVectorOptions() - options.driverName = "GPKG" - options.layerName = layer.name() - res = QgsVectorFileWriter.writeAsVectorFormatV3( - layer, - str(out_path), - QgsProject.instance().transformContext(), - options, - ) - if res[0] == QgsVectorFileWriter.NoError: - return str(out_path) + if getattr(self, '_debug', None) and hasattr(self._debug, 'export_layer'): + exported = self._debug.export_layer(layer, name_prefix) + return exported + except Exception as err: - self._debug.plugin.log( - message=f"[map2loop] Failed to export layer '{name_prefix}': {err}", - log_level=2, - ) + if getattr(self, '_debug', None): + try: + self._debug.plugin.log( + message=f"[map2loop] Failed to export layer '{name_prefix}': {err}", + log_level=2, + ) + except Exception: + pass return None def _serialize_layer(self, layer, name_prefix: str): @@ -102,9 +96,7 @@ def _serialize_params_for_logging(self, params, context_label: str): serialized = {} for key, value in params.items(): if hasattr(value, "source") or hasattr(value, "id"): - serialized[key] = self._serialize_layer( - value, f"{context_label}_{key}" - ) + serialized[key] = self._serialize_layer(value, f"{context_label}_{key}") else: serialized[key] = value return serialized @@ -114,9 +106,7 @@ def _log_params(self, context_label: str): try: self._debug.log_params( context_label=context_label, - params=self._serialize_params_for_logging( - self.get_parameters(), context_label - ), + params=self._serialize_params_for_logging(self.get_parameters(), context_label), ) except Exception: pass diff --git a/loopstructural/gui/map2loop_tools/sorter_widget.py b/loopstructural/gui/map2loop_tools/sorter_widget.py index a393055..4ac6bb7 100644 --- a/loopstructural/gui/map2loop_tools/sorter_widget.py +++ b/loopstructural/gui/map2loop_tools/sorter_widget.py @@ -82,22 +82,11 @@ def set_debug_manager(self, debug_manager): self._debug = debug_manager def _export_layer_for_debug(self, layer, name_prefix: str): - if not (self._debug and self._debug.is_debug()): - return None + # Prefer DebugManager.export_layer try: - debug_dir = self._debug.get_effective_debug_dir() - out_path = debug_dir / f"{name_prefix}.gpkg" - options = QgsVectorFileWriter.SaveVectorOptions() - options.driverName = "GPKG" - options.layerName = layer.name() - res = QgsVectorFileWriter.writeAsVectorFormatV3( - layer, - str(out_path), - QgsProject.instance().transformContext(), - options, - ) - if res[0] == QgsVectorFileWriter.NoError: - return str(out_path) + if getattr(self, '_debug', None) and hasattr(self._debug, 'export_layer'): + return self._debug.export_layer(layer, name_prefix) + except Exception as err: self._debug.plugin.log( message=f"[map2loop] Failed to export layer '{name_prefix}': {err}", diff --git a/loopstructural/gui/map2loop_tools/thickness_calculator_widget.py b/loopstructural/gui/map2loop_tools/thickness_calculator_widget.py index 8f2d9cc..b3c3caf 100644 --- a/loopstructural/gui/map2loop_tools/thickness_calculator_widget.py +++ b/loopstructural/gui/map2loop_tools/thickness_calculator_widget.py @@ -75,22 +75,12 @@ def set_debug_manager(self, debug_manager): self._debug = debug_manager def _export_layer_for_debug(self, layer, name_prefix: str): - if not (self._debug and self._debug.is_debug()): - return None + # Prefer using DebugManager.export_layer if available try: - debug_dir = self._debug.get_effective_debug_dir() - out_path = debug_dir / f"{name_prefix}.gpkg" - options = QgsVectorFileWriter.SaveVectorOptions() - options.driverName = "GPKG" - options.layerName = layer.name() - res = QgsVectorFileWriter.writeAsVectorFormatV3( - layer, - str(out_path), - QgsProject.instance().transformContext(), - options, - ) - if res[0] == QgsVectorFileWriter.NoError: - return str(out_path) + if getattr(self, '_debug', None) and hasattr(self._debug, 'export_layer'): + exported = self._debug.export_layer(layer, name_prefix) + return exported + except Exception as err: self._debug.plugin.log( message=f"[map2loop] Failed to export layer '{name_prefix}': {err}", From e74bcc733590bbe41e43032734aafa461395b773 Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Wed, 7 Jan 2026 17:25:01 +1100 Subject: [PATCH 17/23] adding rebuild with debounce when geometry properties are changed --- .../feature_details_panel.py | 346 ++++++++++++------ 1 file changed, 229 insertions(+), 117 deletions(-) diff --git a/loopstructural/gui/modelling/geological_model_tab/feature_details_panel.py b/loopstructural/gui/modelling/geological_model_tab/feature_details_panel.py index bdc3bfa..8357fe9 100644 --- a/loopstructural/gui/modelling/geological_model_tab/feature_details_panel.py +++ b/loopstructural/gui/modelling/geological_model_tab/feature_details_panel.py @@ -1,4 +1,4 @@ -from PyQt5.QtCore import Qt +from PyQt5.QtCore import Qt, QTimer from PyQt5.QtWidgets import ( QCheckBox, QComboBox, @@ -10,16 +10,24 @@ QVBoxLayout, QWidget, ) -from qgis.gui import QgsMapLayerComboBox, QgsCollapsibleGroupBox - +from qgis.gui import QgsCollapsibleGroupBox, QgsMapLayerComboBox from qgis.utils import plugins + +from LoopStructural import getLogger +from LoopStructural.modelling.features import StructuralFrame +from LoopStructural.utils import ( + normal_vector_to_strike_and_dip, + plungeazimuth2vector, + strikedip2vector, +) + +from .bounding_box_widget import BoundingBoxWidget from .layer_selection_table import LayerSelectionTable from .splot import SPlotDialog -from .bounding_box_widget import BoundingBoxWidget -from LoopStructural.modelling.features import StructuralFrame -from LoopStructural.utils import normal_vector_to_strike_and_dip, plungeazimuth2vector -from LoopStructural import getLogger + logger = getLogger(__name__) + + class BaseFeatureDetailsPanel(QWidget): def __init__(self, parent=None, *, feature=None, model_manager=None, data_manager=None): super().__init__(parent) @@ -47,7 +55,13 @@ def __init__(self, parent=None, *, feature=None, model_manager=None, data_manage # Set the main layout self.setLayout(mainLayout) - + # Debounce timer for rebuilds: schedule a single rebuild after user stops + # interacting for a short interval to avoid repeated expensive builds. + self._rebuild_timer = QTimer(self) + self._rebuild_timer.setSingleShot(True) + self._rebuild_timer.setInterval(500) # milliseconds; adjust as desired + self._rebuild_timer.timeout.connect(self._perform_rebuild) + ## define interpolator parameters # Regularisation spin box self.regularisation_spin_box = QDoubleSpinBox() @@ -55,21 +69,31 @@ def __init__(self, parent=None, *, feature=None, model_manager=None, data_manage self.regularisation_spin_box.setValue( feature.builder.build_arguments.get('regularisation', 1.0) ) + # Update build arguments and schedule a debounced rebuild self.regularisation_spin_box.valueChanged.connect( - lambda value: self.feature.builder.update_build_arguments({'regularisation': value}) + lambda value: ( + self.feature.builder.update_build_arguments({'regularisation': value}), + self.schedule_rebuild(), + ) ) self.cpw_spin_box = QDoubleSpinBox() self.cpw_spin_box.setRange(0, 100) self.cpw_spin_box.setValue(feature.builder.build_arguments.get('cpw', 1.0)) self.cpw_spin_box.valueChanged.connect( - lambda value: self.feature.builder.update_build_arguments({'cpw': value}) + lambda value: ( + self.feature.builder.update_build_arguments({'cpw': value}), + self.schedule_rebuild(), + ) ) self.npw_spin_box = QDoubleSpinBox() self.npw_spin_box.setRange(0, 100) self.npw_spin_box.setValue(feature.builder.build_arguments.get('npw', 1.0)) self.npw_spin_box.valueChanged.connect( - lambda value: self.feature.builder.update_build_arguments({'npw': value}) + lambda value: ( + self.feature.builder.update_build_arguments({'npw': value}), + self.schedule_rebuild(), + ) ) self.interpolator_type_label = QLabel("Interpolator Type:") self.interpolator_type_combo = QComboBox() @@ -104,6 +128,7 @@ def __init__(self, parent=None, *, feature=None, model_manager=None, data_manage self.layout.addWidget(table_group_box) self.addMidBlock() self.addExportBlock() + def addMidBlock(self): """Base mid block is intentionally empty now — bounding-box controls were moved into the export/evaluation section so they appear alongside @@ -120,7 +145,9 @@ def addExportBlock(self): self.export_eval_layout.setSpacing(6) # --- Bounding box controls (moved here into dedicated widget) --- - bb_widget = BoundingBoxWidget(parent=self, model_manager=self.model_manager, data_manager=self.data_manager) + bb_widget = BoundingBoxWidget( + parent=self, model_manager=self.model_manager, data_manager=self.data_manager + ) # keep reference so export handlers can use it self.bounding_box_widget = bb_widget self.export_eval_layout.addWidget(bb_widget) @@ -142,7 +169,9 @@ def addExportBlock(self): # Evaluate target: bounding-box centres or project point layer self.evaluate_target_combo = QComboBox() - self.evaluate_target_combo.addItems(["Bounding box cell centres", "Project point layer","Viewer Object"]) + self.evaluate_target_combo.addItems( + ["Bounding box cell centres", "Project point layer", "Viewer Object"] + ) export_layout.addRow("Evaluate on:", self.evaluate_target_combo) # Project layer selector (populated with point vector layers from project) @@ -160,10 +189,11 @@ def addExportBlock(self): lbl = export_layout.labelForField(self.meshObjectCombo) if lbl is not None: lbl.setVisible(False) + # Connect evaluate target change to enable/disable project layer combo def _on_evaluate_target_changed(index): - use_project = (index == 1) - use_vtk = (index == 2) + use_project = index == 1 + use_vtk = index == 2 self.project_layer_combo.setVisible(use_project) self.project_layer_combo.setEnabled(use_project) self.meshObjectCombo.setVisible(use_vtk) @@ -182,13 +212,8 @@ def _on_evaluate_target_changed(index): viewer = self.plugin.loop_widget.visualisation_widget.plotter mesh_names = list(viewer.meshes.keys()) self.meshObjectCombo.addItems(mesh_names) - self.evaluate_target_combo.currentIndexChanged.connect(_on_evaluate_target_changed) - - - - - + self.evaluate_target_combo.currentIndexChanged.connect(_on_evaluate_target_changed) # Export button self.export_points_button = QPushButton("Export to QGIS points") @@ -204,18 +229,18 @@ def _on_evaluate_target_changed(index): # These blocks are intentionally minimal now (only a disabled label) and # will be populated with export/evaluate controls later. if self.model_manager is not None: - for feat in self.model_manager.features(): + for feat in self.model_manager.features(): block = QWidget() block.setObjectName(f"export_block_{getattr(feat, 'name', 'feature')}") block_layout = QVBoxLayout(block) block_layout.setContentsMargins(0, 0, 0, 0) self.export_eval_layout.addWidget(block) - self.export_blocks[getattr(feat, 'name', f"feature_{len(self.export_blocks)}")] = block + self.export_blocks[getattr(feat, 'name', f"feature_{len(self.export_blocks)}")] = ( + block + ) self.layout.addWidget(self.export_eval_container) - - def _on_bounding_box_updated(self, bounding_box): """Callback to update UI widgets when bounding box object changes externally. @@ -237,21 +262,29 @@ def _on_bounding_box_updated(self, bounding_box): pass try: - if getattr(bounding_box, 'nelements', None) is not None and hasattr(self, 'bb_nelements_spinbox'): + if getattr(bounding_box, 'nelements', None) is not None and hasattr( + self, 'bb_nelements_spinbox' + ): try: self.bb_nelements_spinbox.setValue(int(getattr(bounding_box, 'nelements'))) except Exception: try: self.bb_nelements_spinbox.setValue(getattr(bounding_box, 'nelements')) except Exception: - logger.debug('Could not set nelements spinbox from bounding_box', exc_info=True) + logger.debug( + 'Could not set nelements spinbox from bounding_box', exc_info=True + ) if getattr(bounding_box, 'nsteps', None) is not None: try: nsteps = list(bounding_box.nsteps) except Exception: try: - nsteps = [int(bounding_box.nsteps[0]), int(bounding_box.nsteps[1]), int(bounding_box.nsteps[2])] + nsteps = [ + int(bounding_box.nsteps[0]), + int(bounding_box.nsteps[1]), + int(bounding_box.nsteps[2]), + ] except Exception: nsteps = None if nsteps is not None: @@ -263,7 +296,9 @@ def _on_bounding_box_updated(self, bounding_box): if hasattr(self, 'bb_nsteps_z'): self.bb_nsteps_z.setValue(int(nsteps[2])) except Exception: - logger.debug('Could not set nsteps spinboxes from bounding_box', exc_info=True) + logger.debug( + 'Could not set nsteps spinboxes from bounding_box', exc_info=True + ) finally: # Unblock signals @@ -281,12 +316,14 @@ def updateNelements(self, value): if self.feature[i].interpolator is not None: self.feature[i].interpolator.nelements = value self.feature[i].builder.update_build_arguments({'nelements': value}) - self.feature[i].builder.build() + # schedule a single debounced rebuild after user stops changing value + self.schedule_rebuild() elif self.feature.interpolator is not None: self.feature.interpolator.nelements = value self.feature.builder.update_build_arguments({'nelements': value}) - self.feature.builder.build() + # schedule a debounced rebuild instead of building immediately + self.schedule_rebuild() else: print("Error: Feature is not initialized.") @@ -298,6 +335,7 @@ def getNelements(self, feature): elif feature.interpolator is not None: return feature.interpolator.n_elements return 1000 + def _export_scalar_points(self): """Gather points (bounding-box centres or project point layer), evaluate feature values using the model_manager and add the resulting GeoDataFrame as a memory layer to the @@ -306,7 +344,11 @@ def _export_scalar_points(self): """ # determine scalar type logger.info('Exporting scalar points') - scalar_type = self.scalar_field_combo.currentText() if hasattr(self, 'scalar_field_combo') else 'scalar' + scalar_type = ( + self.scalar_field_combo.currentText() + if hasattr(self, 'scalar_field_combo') + else 'scalar' + ) # gather points pts = None @@ -314,7 +356,7 @@ def _export_scalar_points(self): crs = self.data_manager.project.crs().authid() try: # QGIS imports (guarded) - from qgis.core import QgsProject, QgsVectorLayer, QgsFeature, QgsPoint, QgsField + from qgis.core import QgsFeature, QgsField, QgsPoint, QgsProject, QgsVectorLayer from qgis.PyQt.QtCore import QVariant except Exception as e: # Not running inside QGIS — nothing to do @@ -326,10 +368,7 @@ def _export_scalar_points(self): if self.evaluate_target_combo.currentIndex() == 0: # use bounding-box resolution or custom nsteps logger.info('Using bounding box cell centres for evaluation') - - - pts = self.model_manager.model.bounding_box.cell_centres() # no extra attributes for grid attributes_df = None @@ -364,13 +403,21 @@ def _export_scalar_points(self): try: z = p.z() except Exception: - z = self.model_manager.dem_function(x, y) if hasattr(self.model_manager, 'dem_function') else 0 + z = ( + self.model_manager.dem_function(x, y) + if hasattr(self.model_manager, 'dem_function') + else 0 + ) except Exception: # fallback to centroid try: c = geom.centroid().asPoint() x, y = c.x(), c.y() - z = self.model_manager.dem_function(x, y) if hasattr(self.model_manager, 'dem_function') else 0 + z = ( + self.model_manager.dem_function(x, y) + if hasattr(self.model_manager, 'dem_function') + else 0 + ) except Exception: continue pts_list.append((x, y, z)) @@ -385,6 +432,7 @@ def _export_scalar_points(self): if len(pts_list) == 0: return import pandas as _pd + pts = _pd.DataFrame(pts_list).values try: attributes_df = _pd.DataFrame(attrs) @@ -396,7 +444,7 @@ def _export_scalar_points(self): crs = None elif self.evaluate_target_combo.currentIndex() == 2: # Evaluate on an object from the viewer - # These are all pyvista objects and we want to add + # These are all pyvista objects and we want to add # the scalar as a new field to the objects viewer = self.plugin.loop_widget.visualisation_widget.plotter @@ -407,9 +455,7 @@ def _export_scalar_points(self): return vtk_mesh = viewer.meshes[mesh]['mesh'] self.model_manager.export_feature_values_to_vtk_mesh( - self.feature.name, - vtk_mesh, - scalar_type=scalar_type + self.feature.name, vtk_mesh, scalar_type=scalar_type ) # call model_manager to produce GeoDataFrame try: @@ -494,14 +540,54 @@ def _export_scalar_points(self): mem_layer.updateExtents() QgsProject.instance().addMapLayer(mem_layer) + def schedule_rebuild(self, delay_ms: int = 500): + """Schedule a debounced rebuild of the current feature. + + Multiple calls will reset the timer so only a single rebuild occurs + after user activity has settled. + """ + try: + if self._rebuild_timer is None: + return + self._rebuild_timer.stop() + self._rebuild_timer.setInterval(delay_ms) + self._rebuild_timer.start() + except Exception: + pass + + def _perform_rebuild(self): + """Perform the actual build operation when the debounce timer fires.""" + try: + if not hasattr(self, 'feature') or self.feature is None: + return + # StructuralFrame consists of three sub-features + if issubclass(type(self.feature), StructuralFrame): + for i in range(3): + try: + builder = getattr(self.feature[i], 'builder', None) + if builder is not None: + builder.build() + except Exception: + continue + else: + builder = getattr(self.feature, 'builder', None) + if builder is not None: + builder.build() + except Exception: + logger.debug('Debounced rebuild failed', exc_info=True) + + class FaultFeatureDetailsPanel(BaseFeatureDetailsPanel): def __init__(self, parent=None, *, fault=None, model_manager=None, data_manager=None): - super().__init__(parent, feature=fault, model_manager=model_manager, data_manager=data_manager) + super().__init__( + parent, feature=fault, model_manager=model_manager, data_manager=data_manager + ) if fault is None: raise ValueError("Fault must be provided.") self.fault = fault - dip = normal_vector_to_strike_and_dip(fault.fault_normal_vector)[0, 0] + dip = normal_vector_to_strike_and_dip(fault.fault_normal_vector)[0, 1] + print('fault dip:', dip) pitch = 0 self.fault_parameters = { 'displacement': fault.displacement, @@ -513,76 +599,90 @@ def __init__(self, parent=None, *, fault=None, model_manager=None, data_manager= # 'enabled': fault.fault_enabled } - # # Fault displacement slider - # self.displacement_spinbox = QDoubleSpinBox() - # self.displacement_spinbox.setRange(0, 1000000) # Example range - # self.displacement_spinbox.setValue(self.fault.displacement) - # self.displacement_spinbox.valueChanged.connect( - # lambda value: self.fault_parameters.__setitem__('displacement', value) - # ) + def update_displacement(value): + self.fault.displacement = value + + def update_major_axis(value): + self.fault.fault_major_axis = value + # schedule a debounced rebuild so multiple rapid edits are coalesced + self.schedule_rebuild() + + def update_minor_axis(value): + self.fault.fault_minor_axis = value + self.schedule_rebuild() + + def update_intermediate_axis(value): + self.fault.fault_intermediate_axis = value + self.schedule_rebuild() + + def update_dip(value): + strike = normal_vector_to_strike_and_dip(self.fault.fault_normal_vector)[0, 0] + self.fault.builder.fault_normal_vector = strikedip2vector([strike], [value])[0] + self.schedule_rebuild() + + # Fault displacement slider + self.displacement_spinbox = QDoubleSpinBox() + self.displacement_spinbox.setRange(0, 1000000) # Example range + self.displacement_spinbox.setValue(self.fault.displacement) + self.displacement_spinbox.valueChanged.connect(update_displacement) + + # Fault axis lengths + self.major_axis_spinbox = QDoubleSpinBox() + self.major_axis_spinbox.setRange(0, float('inf')) + self.major_axis_spinbox.setValue(self.fault.fault_major_axis) + # self.major_axis_spinbox.setPrefix("Major Axis Length: ") + self.major_axis_spinbox.valueChanged.connect(update_major_axis) + self.minor_axis_spinbox = QDoubleSpinBox() + self.minor_axis_spinbox.setRange(0, float('inf')) + self.minor_axis_spinbox.setValue(self.fault.fault_minor_axis) + # self.minor_axis_spinbox.setPrefix("Minor Axis Length: ") + self.minor_axis_spinbox.valueChanged.connect(update_minor_axis) + self.intermediate_axis_spinbox = QDoubleSpinBox() + self.intermediate_axis_spinbox.setRange(0, float('inf')) + self.intermediate_axis_spinbox.setValue(fault.fault_intermediate_axis) + self.intermediate_axis_spinbox.valueChanged.connect(update_intermediate_axis) + # self.intermediate_axis_spinbox.setPrefix("Intermediate Axis Length: ") + + # Fault dip field + self.dip_spinbox = QDoubleSpinBox() + self.dip_spinbox.setRange(0, 90) # Dip angle range + self.dip_spinbox.setValue(dip) + # self.dip_spinbox.setPrefix("Fault Dip: ") + self.dip_spinbox.valueChanged.connect(update_dip) + self.pitch_spinbox = QDoubleSpinBox() + self.pitch_spinbox.setRange(0, 180) + self.pitch_spinbox.setValue(self.fault_parameters['pitch']) + self.pitch_spinbox.valueChanged.connect( + lambda value: self.fault_parameters.__setitem__('pitch', value) + ) + # self.dip_spinbox.valueChanged.connect( - # # Fault axis lengths - # self.major_axis_spinbox = QDoubleSpinBox() - # self.major_axis_spinbox.setRange(0, float('inf')) - # self.major_axis_spinbox.setValue(self.fault_parameters['major_axis_length']) - # # self.major_axis_spinbox.setPrefix("Major Axis Length: ") - # self.major_axis_spinbox.valueChanged.connect( - # lambda value: self.fault_parameters.__setitem__('major_axis_length', value) - # ) - # self.minor_axis_spinbox = QDoubleSpinBox() - # self.minor_axis_spinbox.setRange(0, float('inf')) - # self.minor_axis_spinbox.setValue(self.fault_parameters['minor_axis_length']) - # # self.minor_axis_spinbox.setPrefix("Minor Axis Length: ") - # self.minor_axis_spinbox.valueChanged.connect( - # lambda value: self.fault_parameters.__setitem__('minor_axis_length', value) - # ) - # self.intermediate_axis_spinbox = QDoubleSpinBox() - # self.intermediate_axis_spinbox.setRange(0, float('inf')) - # self.intermediate_axis_spinbox.setValue(self.fault_parameters['intermediate_axis_length']) - # self.intermediate_axis_spinbox.valueChanged.connect( - # lambda value: self.fault_parameters.__setitem__('intermediate_axis_length', value) - # ) - # # self.intermediate_axis_spinbox.setPrefix("Intermediate Axis Length: ") + # Enabled field + # self.enabled_checkbox = QCheckBox("Enabled") + # self.enabled_checkbox.setChecked(False) - # # Fault dip field - # self.dip_spinbox = QDoubleSpinBox() - # self.dip_spinbox.setRange(0, 90) # Dip angle range - # self.dip_spinbox.setValue(self.fault_parameters['dip']) - # # self.dip_spinbox.setPrefix("Fault Dip: ") - # self.dip_spinbox.valueChanged.connect( - # lambda value: self.fault_parameters.__setitem__('dip', value) - # ) - # self.pitch_spinbox = QDoubleSpinBox() - # self.pitch_spinbox.setRange(0, 180) - # self.pitch_spinbox.setValue(self.fault_parameters['pitch']) - # self.pitch_spinbox.valueChanged.connect( - # lambda value: self.fault_parameters.__setitem__('pitch', value) - # ) - # # self.dip_spinbox.valueChanged.connect( - - # # Enabled field - # # self.enabled_checkbox = QCheckBox("Enabled") - # # self.enabled_checkbox.setChecked(False) - - # # Form layout for better organization - # form_layout = QFormLayout() - # form_layout.addRow("Fault displacement", self.displacement_spinbox) - # form_layout.addRow("Major Axis Length", self.major_axis_spinbox) - # form_layout.addRow("Minor Axis Length", self.minor_axis_spinbox) - # form_layout.addRow("Intermediate Axis Length", self.intermediate_axis_spinbox) - # form_layout.addRow("Fault Dip", self.dip_spinbox) - # # form_layout.addRow("Enabled:", self.enabled_checkbox) - - # self.layout.addLayout(form_layout) - # self.setLayout(self.layout) + # Form layout for better organization + form_layout = QFormLayout() + form_layout.addRow("Fault displacement", self.displacement_spinbox) + form_layout.addRow("Major Axis Length", self.major_axis_spinbox) + form_layout.addRow("Minor Axis Length", self.minor_axis_spinbox) + form_layout.addRow("Intermediate Axis Length", self.intermediate_axis_spinbox) + form_layout.addRow("Fault Dip", self.dip_spinbox) + # form_layout.addRow("Enabled:", self.enabled_checkbox) + + self.layout.addLayout(form_layout) + self.setLayout(self.layout) class FoliationFeatureDetailsPanel(BaseFeatureDetailsPanel): def __init__(self, parent=None, *, feature=None, model_manager=None, data_manager=None): - super().__init__(parent, feature=feature, model_manager=model_manager, data_manager=data_manager) + super().__init__( + parent, feature=feature, model_manager=model_manager, data_manager=data_manager + ) if feature is None: raise ValueError("Feature must be provided.") self.feature = feature + def addMidBlock(self): form_layout = QFormLayout() fold_frame_combobox = QComboBox() @@ -602,20 +702,24 @@ def addMidBlock(self): # Remove redundant layout setting self.setLayout(self.layout) - def on_fold_frame_changed(self, text): self.model_manager.add_fold_to_feature(self.feature.name, fold_frame_name=text) class StructuralFrameFeatureDetailsPanel(BaseFeatureDetailsPanel): def __init__(self, parent=None, *, feature=None, model_manager=None, data_manager=None): - super().__init__(parent, feature=feature, model_manager=model_manager, data_manager=data_manager) + super().__init__( + parent, feature=feature, model_manager=model_manager, data_manager=data_manager + ) class FoldedFeatureDetailsPanel(BaseFeatureDetailsPanel): def __init__(self, parent=None, *, feature=None, model_manager=None, data_manager=None): - super().__init__(parent, feature=feature, model_manager=model_manager, data_manager=data_manager) - def addMidBlock(self): + super().__init__( + parent, feature=feature, model_manager=model_manager, data_manager=data_manager + ) + + def addMidBlock(self): # Remove redundant layout setting # self.setLayout(self.layout) form_layout = QFormLayout() @@ -636,6 +740,7 @@ def addMidBlock(self): } ) ) + norm_length.valueChanged.connect(lambda value: self.schedule_rebuild()) form_layout.addRow("Normal Length", norm_length) norm_weight = QDoubleSpinBox() @@ -651,6 +756,7 @@ def addMidBlock(self): } ) ) + norm_weight.valueChanged.connect(lambda value: self.schedule_rebuild()) form_layout.addRow("Normal Weight", norm_weight) fold_axis_weight = QDoubleSpinBox() @@ -666,6 +772,7 @@ def addMidBlock(self): } ) ) + fold_axis_weight.valueChanged.connect(lambda value: self.schedule_rebuild()) form_layout.addRow("Fold Axis Weight", fold_axis_weight) fold_orientation_weight = QDoubleSpinBox() @@ -681,6 +788,7 @@ def addMidBlock(self): } ) ) + fold_orientation_weight.valueChanged.connect(lambda value: self.schedule_rebuild()) form_layout.addRow("Fold Orientation Weight", fold_orientation_weight) average_fold_axis_checkbox = QCheckBox("Average Fold Axis") @@ -719,23 +827,27 @@ def addMidBlock(self): self.layout.addWidget(group_box) # Remove redundant layout setting self.setLayout(self.layout) + def open_splot_dialog(self): - dialog = SPlotDialog(self, data_manager=self.data_manager, model_manager=self.model_manager, feature_name=self.feature.name) + dialog = SPlotDialog( + self, + data_manager=self.data_manager, + model_manager=self.model_manager, + feature_name=self.feature.name, + ) if dialog.exec_() == dialog.Accepted: pass + def remove_fold_frame(self): pass def foldAxisFromPlungeAzimuth(self): """Calculate the fold axis from plunge and azimuth.""" if self.feature: - plunge = ( - self.fold_plunge.value() - ) - azimuth = ( - self.fold_azimuth.value()) + plunge = self.fold_plunge.value() + azimuth = self.fold_azimuth.value() vector = plungeazimuth2vector(plunge, azimuth)[0] if plunge is not None and azimuth is not None: self.feature.builder.update_build_arguments({'fold_axis': vector.tolist()}) - - + # schedule rebuild after updating builder arguments + self.schedule_rebuild() From 75ff03ee3d6fb3edd1b248570e5a9422e8182d57 Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Thu, 8 Jan 2026 11:38:10 +1100 Subject: [PATCH 18/23] call update feature when rebuild is required --- .../geological_model_tab/feature_details_panel.py | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/loopstructural/gui/modelling/geological_model_tab/feature_details_panel.py b/loopstructural/gui/modelling/geological_model_tab/feature_details_panel.py index 8357fe9..a2d34d8 100644 --- a/loopstructural/gui/modelling/geological_model_tab/feature_details_panel.py +++ b/loopstructural/gui/modelling/geological_model_tab/feature_details_panel.py @@ -561,18 +561,8 @@ def _perform_rebuild(self): if not hasattr(self, 'feature') or self.feature is None: return # StructuralFrame consists of three sub-features - if issubclass(type(self.feature), StructuralFrame): - for i in range(3): - try: - builder = getattr(self.feature[i], 'builder', None) - if builder is not None: - builder.build() - except Exception: - continue - else: - builder = getattr(self.feature, 'builder', None) - if builder is not None: - builder.build() + self.model_manager.update_feature(self.feature.name) + except Exception: logger.debug('Debounced rebuild failed', exc_info=True) From 7208085955d70473527fd0db11ec49e8f5e323ac Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Thu, 8 Jan 2026 11:38:42 +1100 Subject: [PATCH 19/23] adding progress bar when model update is required --- .../geological_model_tab.py | 214 ++++++++++++++++-- 1 file changed, 199 insertions(+), 15 deletions(-) diff --git a/loopstructural/gui/modelling/geological_model_tab/geological_model_tab.py b/loopstructural/gui/modelling/geological_model_tab/geological_model_tab.py index 8f3832b..533bd8f 100644 --- a/loopstructural/gui/modelling/geological_model_tab/geological_model_tab.py +++ b/loopstructural/gui/modelling/geological_model_tab/geological_model_tab.py @@ -1,6 +1,8 @@ -from PyQt5.QtCore import Qt +from PyQt5.QtCore import QObject, Qt, QThread, pyqtSignal, pyqtSlot from PyQt5.QtWidgets import ( QMenu, + QMessageBox, + QProgressDialog, QPushButton, QSplitter, QTreeWidget, @@ -9,18 +11,18 @@ QWidget, ) -from .feature_details_panel import ( - FaultFeatureDetailsPanel, - FoldedFeatureDetailsPanel, - FoliationFeatureDetailsPanel, - StructuralFrameFeatureDetailsPanel, -) from LoopStructural.modelling.features import FeatureType # Import the AddFaultDialog from .add_fault_dialog import AddFaultDialog from .add_foliation_dialog import AddFoliationDialog from .add_unconformity_dialog import AddUnconformityDialog +from .feature_details_panel import ( + FaultFeatureDetailsPanel, + FoldedFeatureDetailsPanel, + FoliationFeatureDetailsPanel, + StructuralFrameFeatureDetailsPanel, +) class GeologicalModelTab(QWidget): @@ -28,7 +30,28 @@ def __init__(self, parent=None, *, model_manager=None, data_manager=None): super().__init__(parent) self.model_manager = model_manager self.data_manager = data_manager - self.model_manager.observers.append(self.update_feature_list) + # Register update observer using Observable API if available + if self.model_manager is not None: + try: + # listen for model-level updates + self._disp_model = self.model_manager.attach( + self.update_feature_list, 'model_updated' + ) + # show progress when model updates start/finish (covers indirect calls) + self._disp_update_start = self.model_manager.attach( + lambda _obs, _ev, *a, **k: self._on_model_update_started(), + 'model_update_started', + ) + self._disp_update_finish = self.model_manager.attach( + lambda _obs, _ev, *a, **k: self._on_model_update_finished(), + 'model_update_finished', + ) + except Exception: + # fallback to legacy list + try: + self.model_manager.observers.append(self.update_feature_list) + except Exception: + pass # Main layout mainLayout = QVBoxLayout(self) @@ -75,6 +98,10 @@ def __init__(self, parent=None, *, model_manager=None, data_manager=None): # Connect feature selection to update details panel self.featureList.itemClicked.connect(self.on_feature_selected) + # thread handle to keep worker alive while running + self._model_update_thread = None + self._model_update_worker = None + def show_add_feature_menu(self, *args): menu = QMenu(self) add_fault = menu.addAction("Add Fault") @@ -89,6 +116,7 @@ def show_add_feature_menu(self, *args): self.open_add_foliation_dialog() elif action == add_unconformity: self.open_add_unconformity_dialog() + def open_add_fault_dialog(self): dialog = AddFaultDialog(self) if dialog.exec_() == dialog.Accepted: @@ -102,16 +130,95 @@ def open_add_foliation_dialog(self): ) if dialog.exec_() == dialog.Accepted: pass + def open_add_unconformity_dialog(self): dialog = AddUnconformityDialog( self, data_manager=self.data_manager, model_manager=self.model_manager ) if dialog.exec_() == dialog.Accepted: pass + def initialize_model(self): - self.model_manager.update_model() + # Run update_model in a background thread to avoid blocking the UI. + if not self.model_manager: + return + + # create progress dialog (indeterminate) + progress = QProgressDialog("Updating geological model...", "Cancel", 0, 0, self) + progress.setWindowModality(Qt.ApplicationModal) + progress.setWindowTitle("Updating Model") + progress.setCancelButton(None) + progress.setMinimumDuration(0) + progress.show() + + # worker and thread + thread = QThread(self) + worker = _ModelUpdateWorker(self.model_manager) + worker.moveToThread(thread) + + # When thread starts run worker.run + thread.started.connect(worker.run) + + # on worker finished, notify observers on main thread and cleanup + def _on_finished(): + try: + # notify observers now on main thread + try: + self.model_manager.notify('model_updated') + except Exception: + for obs in getattr(self.model_manager, 'observers', []): + try: + obs() + except Exception: + pass + finally: + try: + progress.close() + except Exception: + pass + # cleanup worker/thread + try: + worker.deleteLater() + except Exception: + pass + try: + thread.quit() + thread.wait(2000) + except Exception: + pass + + def _on_error(tb): + try: + progress.close() + except Exception: + pass + try: + QMessageBox.critical( + self, + "Model update failed", + f"An error occurred while updating the model:\n{tb}", + ) + except Exception: + pass + # ensure thread cleanup + try: + worker.deleteLater() + except Exception: + pass + try: + thread.quit() + thread.wait(2000) + except Exception: + pass + + worker.finished.connect(_on_finished) + worker.error.connect(_on_error) + thread.finished.connect(thread.deleteLater) + self._model_update_thread = thread + self._model_update_worker = worker + thread.start() - def update_feature_list(self): + def update_feature_list(self, *args, **kwargs): self.featureList.clear() # Clear the feature list before populating it for feature in self.model_manager.features(): if feature.name.startswith("__"): @@ -153,6 +260,43 @@ def on_feature_selected(self, item): splitter.widget(1).deleteLater() # Remove the existing widget splitter.addWidget(self.featureDetailsPanel) # Add the new widget + def _on_model_update_started(self): + """Show a non-blocking indeterminate progress dialog for model updates. + + This method is invoked via the Observable notifications and ensures the + user sees that a background or foreground update is in progress. + """ + print("Model update started - showing progress dialog") + try: + if getattr(self, '_progress_dialog', None) is None: + self._progress_dialog = QProgressDialog( + "Updating geological model...", None, 0, 0, self + ) + self._progress_dialog.setWindowTitle("Updating Model") + self._progress_dialog.setWindowModality(Qt.ApplicationModal) + self._progress_dialog.setCancelButton(None) + self._progress_dialog.setMinimumDuration(0) + self._progress_dialog.show() + except Exception: + pass + + def _on_model_update_finished(self): + """Close the progress dialog shown for model updates.""" + print("Model update finished - closing progress dialog") + try: + if getattr(self, '_progress_dialog', None) is not None: + try: + self._progress_dialog.close() + except Exception: + pass + try: + self._progress_dialog.deleteLater() + except Exception: + pass + self._progress_dialog = None + except Exception: + pass + def show_feature_context_menu(self, pos): # Show context menu only for items item = self.featureList.itemAt(pos) @@ -197,10 +341,50 @@ def delete_feature(self, item): # Notify observers to refresh UI try: - for obs in getattr(self.model_manager, 'observers', []): - try: - obs() - except Exception: - pass + # Prefer notify API + try: + self.model_manager.notify('model_updated') + except Exception: + # fallback to legacy observers list + for obs in getattr(self.model_manager, 'observers', []): + try: + obs() + except Exception: + pass except Exception: pass + + +class _ModelUpdateWorker(QObject): + """Worker that runs model_manager.update_model in a background thread. + + Emits finished when done and error with a string if an exception occurs. + """ + + finished = pyqtSignal() + error = pyqtSignal(str) + + def __init__(self, model_manager): + super().__init__() + self.model_manager = model_manager + + @pyqtSlot() + def run(self): + try: + # perform the expensive update + # run update without notifying observers from the background thread + try: + self.model_manager.update_model(notify_observers=False) + except TypeError: + # fallback if update_model signature not available + self.model_manager.update_model() + except Exception as e: + try: + import traceback + + tb = traceback.format_exc() + except Exception: + tb = str(e) + self.error.emit(tb) + finally: + self.finished.emit() From 440fd2e4dcf87982b626e671c1bd4d3bda6f6987 Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Thu, 8 Jan 2026 11:39:00 +1100 Subject: [PATCH 20/23] keep track of source feature for meshes --- .../visualisation/loop_pyvistaqt_wrapper.py | 42 +++++++++++++++---- 1 file changed, 35 insertions(+), 7 deletions(-) diff --git a/loopstructural/gui/visualisation/loop_pyvistaqt_wrapper.py b/loopstructural/gui/visualisation/loop_pyvistaqt_wrapper.py index c08d904..9f48c15 100644 --- a/loopstructural/gui/visualisation/loop_pyvistaqt_wrapper.py +++ b/loopstructural/gui/visualisation/loop_pyvistaqt_wrapper.py @@ -1,6 +1,7 @@ +from typing import Any, Dict, Optional, Tuple + from PyQt5.QtCore import pyqtSignal from pyvistaqt import QtInteractor -from typing import Optional, Any, Dict, Tuple class LoopPyVistaQTPlotter(QtInteractor): @@ -27,7 +28,22 @@ def increment_name(self, name): name = '_'.join(parts) return name - def add_mesh_object(self, mesh, name: str, *, scalars: Optional[Any] = None, cmap: Optional[str] = None, clim: Optional[Tuple[float, float]] = None, opacity: Optional[float] = None, show_scalar_bar: bool = False, color: Optional[Tuple[float, float, float]] = None, **kwargs) -> None: + def add_mesh_object( + self, + mesh, + name: str, + *, + scalars: Optional[Any] = None, + cmap: Optional[str] = None, + clim: Optional[Tuple[float, float]] = None, + opacity: Optional[float] = None, + show_scalar_bar: bool = False, + color: Optional[Tuple[float, float, float]] = None, + source_feature: Optional[str] = None, + source_type: Optional[str] = None, + isovalue: Optional[float] = None, + **kwargs, + ) -> None: """Add a mesh to the plotter. This wrapper stores metadata to allow robust re-adding and @@ -51,8 +67,12 @@ def add_mesh_object(self, mesh, name: str, *, scalars: Optional[Any] = None, cma Whether to show a scalar bar for mapped scalars. color : Optional[tuple(float, float, float)] Solid color as (r, g, b) in 0..1; if provided, overrides scalars. - **kwargs : dict - Additional keyword arguments forwarded to the underlying pyvista add_mesh call. + source_feature : Optional[str] + Name of the geological feature (or other source identifier) that + generated this mesh. Stored for later updates. + source_type : Optional[str] + A short tag describing the kind of source (e.g. 'feature_surface', + 'fault_surface', 'bounding_box'). Returns ------- @@ -61,7 +81,7 @@ def add_mesh_object(self, mesh, name: str, *, scalars: Optional[Any] = None, cma # Remove any previous entry with the same name (to keep metadata consistent) # if name in self.meshes: # try: - + # # self.remove_object(name) # except Exception: # # ignore removal errors and proceed to add @@ -73,7 +93,7 @@ def add_mesh_object(self, mesh, name: str, *, scalars: Optional[Any] = None, cma # Build add_mesh kwargs add_kwargs: Dict[str, Any] = {} - + if use_scalar: add_kwargs['scalars'] = scalars add_kwargs['cmap'] = cmap @@ -97,7 +117,15 @@ def add_mesh_object(self, mesh, name: str, *, scalars: Optional[Any] = None, cma actor = self.add_mesh(mesh, name=name, **add_kwargs) # store the mesh, actor and kwargs for future re-adds - self.meshes[name] = {'mesh': mesh, 'actor': actor, 'kwargs': {**add_kwargs}} + # persist source metadata so callers can find meshes created from model features + self.meshes[name] = { + 'mesh': mesh, + 'actor': actor, + 'kwargs': {**add_kwargs}, + 'source_feature': source_feature, + 'source_type': source_type, + 'isovalue': isovalue, + } self.objectAdded.emit(self) def remove_object(self, name: str) -> None: From 3a22d1a28843f4579102772c067704e9f3e97b9d Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Thu, 15 Jan 2026 09:49:19 +1100 Subject: [PATCH 21/23] update feature in viewer when it changes --- .../gui/visualisation/feature_list_widget.py | 281 +++++++++++++++++- loopstructural/main/model_manager.py | 170 +++++++++-- 2 files changed, 410 insertions(+), 41 deletions(-) diff --git a/loopstructural/gui/visualisation/feature_list_widget.py b/loopstructural/gui/visualisation/feature_list_widget.py index 1159eed..ddd6a58 100644 --- a/loopstructural/gui/visualisation/feature_list_widget.py +++ b/loopstructural/gui/visualisation/feature_list_widget.py @@ -33,7 +33,31 @@ def __init__(self, parent=None, *, model_manager=None, viewer=None): # Populate the feature list self.update_feature_list() - self.model_manager.observers.append(self.update_feature_list) + # register observer to refresh list and viewer when model changes + if self.model_manager is not None: + # Attach to specific model events using the Observable framework + try: + # listeners will receive (observable, event, *args) + # attach wrappers that match the Observable callback signature + self._disp_update = self.model_manager.attach( + lambda _obs, _event, *a, **k: self.update_feature_list(), 'model_updated' + ) + # also listen for model and feature updates so visualisation can refresh + # forward event and args into the handler so it can act on specific surfaces + self._disp_feature = self.model_manager.attach( + lambda _obs, _event, *a, **k: self._on_model_update(_event, *a), 'model_updated' + ) + self._disp_feature2 = self.model_manager.attach( + lambda _obs, _event, *a, **k: self._on_model_update(_event, *a), + 'feature_updated', + ) + except Exception: + # Fall back to legacy observers list if available + try: + self.model_manager.observers.append(self.update_feature_list) + self.model_manager.observers.append(self._on_model_update) + except Exception: + pass def update_feature_list(self): if not self.model_manager: @@ -94,17 +118,50 @@ def contextMenuEvent(self, event): def add_scalar_field(self, feature_name): scalar_field = self.model_manager.model[feature_name].scalar_field() - self.viewer.add_mesh_object(scalar_field.vtk(), name=f'{feature_name}_scalar_field') + self.viewer.add_mesh_object( + scalar_field.vtk(), + name=f'{feature_name}_scalar_field', + source_feature=feature_name, + source_type='feature_scalar', + ) def add_surface(self, feature_name): surfaces = self.model_manager.model[feature_name].surfaces() - for surface in surfaces: - self.viewer.add_mesh_object(surface.vtk(), name=f'{feature_name}_surface') + for i, surface in enumerate(surfaces): + # ensure unique names for multiple surfaces per feature + mesh_name = f'{feature_name}_surface' if i == 0 else f'{feature_name}_surface_{i+1}' + # try to determine an isovalue for this surface (may be an attribute or encoded in name) + isovalue = None + try: + isovalue = getattr(surface, 'isovalue', None) + except Exception: + isovalue = None + if isovalue is None: + # attempt to parse trailing numeric suffix in the surface name + try: + parts = str(surface.name).rsplit('_', 1) + if len(parts) == 2: + isovalue = float(parts[1]) + except Exception: + isovalue = None + + self.viewer.add_mesh_object( + surface.vtk(), + name=mesh_name, + source_feature=feature_name, + source_type='feature_surface', + isovalue=isovalue, + ) def add_vector_field(self, feature_name): vector_field = self.model_manager.model[feature_name].vector_field() scale = self._get_vector_scale() - self.viewer.add_mesh_object(vector_field.vtk(scale=scale), name=f'{feature_name}_vector_field') + self.viewer.add_mesh_object( + vector_field.vtk(scale=scale), + name=f'{feature_name}_vector_field', + source_feature=feature_name, + source_type='feature_vector', + ) def add_data(self, feature_name): data = self.model_manager.model[feature_name].get_data() @@ -114,10 +171,18 @@ def add_data(self, feature_name): scale = self._get_vector_scale() # tolerance is None means all points are shown self.viewer.add_mesh_object( - d.vtk(scale=scale, tolerance=None), name=f'{feature_name}_{d.name}_points' + d.vtk(scale=scale, tolerance=None), + name=f'{feature_name}_{d.name}_points', + source_feature=feature_name, + source_type='feature_points', ) else: - self.viewer.add_mesh_object(d.vtk(), name=f'{feature_name}_{d.name}') + self.viewer.add_mesh_object( + d.vtk(), + name=f'{feature_name}_{d.name}', + source_feature=feature_name, + source_type='feature_data', + ) print(f"Adding data to feature: {feature_name}") def add_model_bounding_box(self): @@ -125,7 +190,9 @@ def add_model_bounding_box(self): print("Model manager is not set.") return bb = self.model_manager.model.bounding_box.vtk().outline() - self.viewer.add_mesh_object(bb, name='model_bounding_box') + self.viewer.add_mesh_object( + bb, name='model_bounding_box', source_feature='__model__', source_type='bounding_box' + ) # Logic for adding model bounding box print("Adding model bounding box...") @@ -133,9 +200,16 @@ def add_fault_surfaces(self): if not self.model_manager: print("Model manager is not set.") return + self.model_manager.update_all_features(subset='faults') fault_surfaces = self.model_manager.model.get_fault_surfaces() for surface in fault_surfaces: - self.viewer.add_mesh_object(surface.vtk(), name=f'fault_surface_{surface.name}') + self.viewer.add_mesh_object( + surface.vtk(), + name=f'fault_surface_{surface.name}', + source_feature=surface.name, + source_type='fault_surface', + isovalue=0.0, + ) print("Adding fault surfaces...") def add_stratigraphic_surfaces(self): @@ -144,5 +218,190 @@ def add_stratigraphic_surfaces(self): return stratigraphic_surfaces = self.model_manager.model.get_stratigraphic_surfaces() for surface in stratigraphic_surfaces: - self.viewer.add_mesh_object(surface.vtk(), name=surface.name,color=surface.colour) - print("Adding stratigraphic surfaces...") + self.viewer.add_mesh_object( + surface.vtk(), + name=surface.name, + color=surface.colour, + source_feature=surface.name, + source_type='stratigraphic_surface', + ) + + def _on_model_update(self, event: str, *args): + """Called when the underlying model_manager notifies observers. + + We remove any meshes that were created from model features and re-add + them from the current model so visualisation follows model changes. + + If the notification is for a specific feature (event == 'feature_updated') + and an isovalue is provided (either as second arg or stored in viewer + metadata), only the matching surface will be re-added. For generic + 'model_updated' notifications the previous behaviour (re-add all + affected feature representations) is preserved. + """ + print(f"Model update event received: {event} with args: {args}") + print([f"Mesh: {name}, Meta: {meta}" for name, meta in self.viewer.meshes.items()]) + if not self.model_manager or not self.viewer: + return + if event not in ('model_updated', 'feature_updated'): + return + feature_name = None + if event == 'feature_updated' and len(args) >= 1: + feature_name = args[0] + # Build a set of features that currently have viewer meshes + affected_features = set() + for _, meta in list(self.viewer.meshes.items()): + if feature_name is not None: + if meta.get('source_feature', None) == feature_name: + affected_features.add(feature_name) + print(f"Updating visualisation for feature: {feature_name}") + continue + + sf = meta.get('source_feature', None) + + if sf is not None: + affected_features.add(sf) + print(f"Affected features to update: {affected_features}") + # For each affected feature, only update existing meshes tied to that feature + for feature_name in affected_features: + # collect mesh names that belong to this feature (snapshot to avoid mutation while iterating) + meshes_for_feature = [ + name + for name, meta in list(self.viewer.meshes.items()) + if meta.get('source_feature') == feature_name + ] + print(f"Re-adding meshes for feature: {feature_name}: {meshes_for_feature}") + + for mesh_name in meshes_for_feature: + meta = self.viewer.meshes.get(mesh_name, {}) + source_type = meta.get('source_type') + kwargs = meta.get('kwargs', {}) or {} + isovalue = meta.get('isovalue', None) + + # remove existing actor/entry so add_mesh_object can recreate with same name + try: + self.viewer.remove_object(mesh_name) + print(f"Removed existing mesh: {mesh_name}") + except Exception: + print(f"Failed to remove existing mesh: {mesh_name}") + pass + + try: + # Surfaces associated with individual features + if source_type == 'feature_surface': + surfaces = [] + try: + if isovalue is not None: + surfaces = self.model_manager.model[feature_name].surfaces(isovalue) + else: + surfaces = self.model_manager.model[feature_name].surfaces() + + if surfaces: + add_name = mesh_name + print( + f"Re-adding surface for feature: {feature_name} with isovalue: {isovalue}" + ) + self.viewer.add_mesh_object( + surfaces[0].vtk(), + name=add_name, + source_feature=feature_name, + source_type='feature_surface', + isovalue=isovalue, + **kwargs, + ) + continue + except Exception as e: + print( + f"Failed to find matching surface for feature: {feature_name} with isovalue: {isovalue}, trying all surfaces. Error: {e}" + ) + + # Fault surfaces (added via add_fault_surfaces) + if source_type == 'fault_surface': + try: + fault_surfaces = self.model_manager.model.get_fault_surfaces() + match = next( + (s for s in fault_surfaces if str(s.name) == str(feature_name)), + None, + ) + if match is not None: + print(f"Re-adding fault surface for: {feature_name}") + self.viewer.add_mesh_object( + match.vtk(), + name=mesh_name, + source_feature=feature_name, + source_type='fault_surface', + isovalue=meta.get('isovalue', 0.0), + **kwargs, + ) + continue + except Exception as e: + print(f"Failed to re-add fault surface for {feature_name}: {e}") + + # Stratigraphic surfaces (added via add_stratigraphic_surfaces) + if source_type == 'stratigraphic_surface': + try: + strat_surfaces = self.model_manager.model.get_stratigraphic_surfaces() + match = next( + (s for s in strat_surfaces if str(s.name) == str(feature_name)), + None, + ) + if match is not None: + print(f"Re-adding stratigraphic surface for: {feature_name}") + self.viewer.add_mesh_object( + match.vtk(), + name=mesh_name, + color=getattr(match, 'colour', None), + source_feature=feature_name, + source_type='stratigraphic_surface', + **kwargs, + ) + continue + except Exception as e: + print(f"Failed to re-add stratigraphic surface for {feature_name}: {e}") + + # Vectors, points, scalar fields and other feature related objects + if source_type == 'feature_vector' or source_type == 'feature_vectors': + try: + self.add_vector_field(feature_name) + continue + except Exception as e: + print(f"Failed to re-add vector field for {feature_name}: {e}") + + if source_type in ('feature_points', 'feature_data'): + try: + self.add_data(feature_name) + continue + except Exception as e: + print(f"Failed to re-add data for {feature_name}: {e}") + + if source_type == 'feature_scalar': + try: + self.add_scalar_field(feature_name) + continue + except Exception as e: + print(f"Failed to re-add scalar field for {feature_name}: {e}") + + if source_type == 'bounding_box' or mesh_name == 'model_bounding_box': + try: + self.add_model_bounding_box() + continue + except Exception as e: + print(f"Failed to re-add bounding box: {e}") + + # Fallback: if nothing matched, attempt to re-add by using viewer metadata + # Many viewer entries store the vtk source under meta['vtk'] or similar; try best-effort + try: + vtk_src = meta.get('vtk') + if vtk_src is not None: + print(f"Fallback re-add for mesh {mesh_name}") + self.viewer.add_mesh_object(vtk_src, name=mesh_name, **kwargs) + except Exception: + pass + + except Exception as e: + print(f"Failed to update visualisation for feature: {feature_name}. Error: {e}") + + # Refresh the viewer + try: + self.viewer.update() + except Exception: + pass diff --git a/loopstructural/main/model_manager.py b/loopstructural/main/model_manager.py index f08daa5..9ea9578 100644 --- a/loopstructural/main/model_manager.py +++ b/loopstructural/main/model_manager.py @@ -10,7 +10,8 @@ """ from collections import defaultdict -from typing import Callable, Optional +from contextlib import contextmanager +from typing import Callable, Optional, Union import geopandas as gpd import numpy as np @@ -23,6 +24,7 @@ from LoopStructural.modelling.features import FeatureType, StructuralFrame from LoopStructural.modelling.features.fold import FoldFrame from loopstructural.toolbelt.preferences import PlgSettingsStructure +from LoopStructural.utils.observer import Observable class AllSampler: @@ -72,19 +74,24 @@ def __call__(self, line: gpd.GeoDataFrame, dem: Callable, use_z: bool) -> pd.Dat z = dem(coords[0], coords[1]) else: z = 0 - points.append({'X': coords[0], 'Y': coords[1], 'Z': z, 'feature_id': feature_id, **attributes}) + points.append( + {'X': coords[0], 'Y': coords[1], 'Z': z, 'feature_id': feature_id, **attributes} + ) feature_id += 1 df = pd.DataFrame(points) return df -class GeologicalModelManager: +class GeologicalModelManager(Observable): """This class manages the geological model and assembles it from the data provided by the data manager. It is responsible for updating the model with faults, stratigraphy, and other geological features. """ def __init__(self): """Initialize the geological model manager.""" + # Initialize Observable state + super().__init__() + self.model = GeologicalModel([0, 0, 0], [1, 1, 1]) self.stratigraphy = {} self.groups = [] @@ -92,12 +99,41 @@ def __init__(self): self.stratigraphy = defaultdict(dict) self.stratigraphic_column = None self.fault_topology = None - self.observers = [] + # Observers managed by Observable base class self.dem_function = lambda x, y: 0 + # internal flag to temporarily suppress notifications (used when + # updates are performed in background threads) + self._suppress_notifications = False + + @contextmanager + def suspend_notifications(self): + prev = getattr(self, '_suppress_notifications', False) + self._suppress_notifications = True + try: + yield + finally: + self._suppress_notifications = prev + + def _emit(self, *args, **kwargs): + """Emit an observer notification unless notifications are suppressed. + + This wrapper should be used instead of calling self.notify directly from + the manager so callers can suppress notifications when running updates + on background threads. + """ + if getattr(self, '_suppress_notifications', False): + return + try: + self.notify(*args, **kwargs) + except Exception: + # be tolerant of observer errors + pass def set_stratigraphic_column(self, stratigraphic_column: StratigraphicColumn): """Set the stratigraphic column for the geological model manager.""" self.stratigraphic_column = stratigraphic_column + # changing the stratigraphic column changes model geometry + self._emit('stratigraphic_column_changed') def set_fault_topology(self, fault_topology): """Set the fault topology for the geological model manager.""" @@ -191,6 +227,8 @@ def update_fault_points( for fault_name in existing_faults: self.fault_topology.remove_fault(fault_name) + # signal that fault point input changed + self._emit('data_changed', 'fault_points') def update_contact_traces( self, @@ -235,6 +273,9 @@ def update_contact_traces( unit_points['unit_name'] == unit_name, ['X', 'Y', 'Z'] ] + # signal that input data changed — consumers may choose to rebuild the model + self._emit('data_changed', 'contact_traces') + def update_structural_data( self, structural_orientations: gpd.GeoDataFrame, @@ -277,6 +318,9 @@ def update_structural_data( ] self.stratigraphy[unit_name]['orientations'] = orientations + # signal structural orientation data changed + self._emit('data_changed', 'structural_orientations') + def update_stratigraphic_column(self, stratigraphic_column: StratigraphicColumn): """Update the stratigraphic column with a new stratigraphic column""" self.stratigraphic_column = stratigraphic_column @@ -330,6 +374,8 @@ def update_foliation_features(self): ) self.model.add_unconformity(foliation, 0) self.model.stratigraphic_column = self.stratigraphic_column + # foliation features were rebuilt; let observers know + self._emit('foliation_features_updated') def update_fault_features(self): """Update the fault features in the geological model.""" @@ -348,7 +394,6 @@ def update_fault_features(self): dip = fault_data['data']['dip'].mean() else: dip = 90 - print(f"Fault {fault_name} dip: {dip}") if 'pitch' in fault_data['data']: pitch = fault_data['data']['pitch'].mean() @@ -390,18 +435,76 @@ def valid(self): valid = False return valid - def update_model(self): - """Update the geological model with the current stratigraphy and faults.""" + def update_model(self, notify_observers: bool = True): + """Update the geological model with the current stratigraphy and faults. + + Parameters + ---------- + notify_observers : bool + If True (default) observers will be notified after the model update + completes. If False, the caller is responsible for notifying + observers from the main thread (useful when performing the update + in a background thread). + """ self.model.features = [] self.model.feature_name_index = {} + # Notify start (if requested) so UI can react + if notify_observers: + self._emit('model_update_started') + # Update the model with stratigraphy self.update_fault_features() self.update_foliation_features() - for observer in self.observers: - observer() + # Notify observers using the Observable framework if requested + if notify_observers: + self._emit('model_updated') + self._emit('model_update_finished') + + def update_feature(self, feature_name: str): + """Update a specific feature in the geological model. + + Parameters + ---------- + feature_name : str + Name of the feature to update. + """ + feature = self.model.get_feature_by_name(feature_name) + if feature is None: + raise ValueError(f"Feature '{feature_name}' not found in the model.") + # Allow UI to react to a feature update + self._emit('model_update_started') + feature.builder.update() + # Notify observers and include feature name for interested listeners + self._emit('feature_updated', feature_name) + self._emit('model_update_finished') + + def update_all_features(self, subset: Optional[Union[list, str]] = None): + """Update all features in the geological model.""" + # Allow UI to react to a feature update + self._emit('model_update_started') + if subset is not None: + + if isinstance(subset, str): + if subset == 'faults': + subset = [f.name for f in self.model.features if f.type == FeatureType.FAULT] + elif subset == 'stratigraphy' or subset == 'foliations': + subset = [ + f.name for f in self.model.features if f.type == FeatureType.FOLIATION + ] + else: + subset = [subset] + for feature_name in subset: + feature = self.model.get_feature_by_name(feature_name) + if feature is not None: + feature.builder.update() + else: + self.model.update() + # Notify observers and include feature name for interested listeners + self._emit('all_features_updated') + self._emit('model_update_finished') def features(self): """Return the list of features currently held by the internal model. @@ -449,7 +552,7 @@ def add_foliation( """ # for z dfs = [] - kwargs={} + kwargs = {} for layer_data in data.values(): if layer_data['type'] == 'Orientation': df = sampler(layer_data['df'], self.dem_function, use_z_coordinate) @@ -471,20 +574,16 @@ def add_foliation( df['u'] = df[layer_data['upper_field']] df['feature_name'] = name dfs.append(df[['X', 'Y', 'Z', 'l', 'u', 'feature_name']]) - kwargs['solver']='admm' + kwargs['solver'] = 'admm' else: raise ValueError(f"Unknown layer type: {layer_data['type']}") - self.model.create_and_add_foliation(name, data=pd.concat(dfs, ignore_index=True), **kwargs) - # if folded_feature_name is not None: - # from LoopStructural.modelling.features._feature_converters import add_fold_to_feature - - # folded_feature = self.model.get_feature_by_name(folded_feature_name) - # folded_feature_name = add_fold_to_feature(frame, folded_feature) - # self.model[folded_feature_name] = folded_feature - for observer in self.observers: - observer() + self.model.create_and_add_foliation(name, data=pd.concat(dfs, ignore_index=True), **kwargs) + # inform listeners that a new foliation/feature was added + self._emit('model_updated') - def add_unconformity(self, foliation_name: str, value: float, type: FeatureType = FeatureType.UNCONFORMITY): + def add_unconformity( + self, foliation_name: str, value: float, type: FeatureType = FeatureType.UNCONFORMITY + ): """Add an unconformity (or onlap unconformity) to a named foliation. Parameters @@ -509,6 +608,8 @@ def add_unconformity(self, foliation_name: str, value: float, type: FeatureType self.model.add_unconformity(foliation, value) elif type == FeatureType.ONLAPUNCONFORMITY: self.model.add_onlap_unconformity(foliation, value) + # model geometry changed + self._emit('model_updated') def add_fold_to_feature(self, feature_name: str, fold_frame_name: str, fold_weights={}): """Apply a FoldFrame to an existing feature, producing a folded feature. @@ -533,8 +634,8 @@ def add_fold_to_feature(self, feature_name: str, fold_frame_name: str, fold_weig from LoopStructural.modelling.features._feature_converters import add_fold_to_feature fold_frame = self.model.get_feature_by_name(fold_frame_name) - if isinstance(fold_frame,StructuralFrame): - fold_frame = FoldFrame(fold_frame.name,fold_frame.features, None, fold_frame.model) + if isinstance(fold_frame, StructuralFrame): + fold_frame = FoldFrame(fold_frame.name, fold_frame.features, None, fold_frame.model) if fold_frame is None: raise ValueError(f"Fold frame '{fold_frame_name}' not found in the model.") feature = self.model.get_feature_by_name(feature_name) @@ -542,6 +643,8 @@ def add_fold_to_feature(self, feature_name: str, fold_frame_name: str, fold_weig raise ValueError(f"Feature '{feature_name}' not found in the model.") folded_feature = add_fold_to_feature(feature, fold_frame) self.model[feature_name] = folded_feature + # feature replaced/modified + self._emit('model_updated') def convert_feature_to_structural_frame(self, feature_name: str): """Convert an interpolated feature into a StructuralFrame. @@ -560,13 +663,17 @@ def convert_feature_to_structural_frame(self, feature_name: str): builder = self.model.get_feature_by_name(feature_name).builder new_builder = StructuralFrameBuilder.from_feature_builder(builder) self.model[feature_name] = new_builder.frame + # feature converted + self._emit('model_updated') @property def fold_frames(self): """Return the fold frames in the model.""" return [f for f in self.model.features if f.type == FeatureType.STRUCTURALFRAME] - def evaluate_feature_on_points(self, feature_name: str, points: np.ndarray, scalar_type: str = 'scalar') -> np.ndarray: + def evaluate_feature_on_points( + self, feature_name: str, points: np.ndarray, scalar_type: str = 'scalar' + ) -> np.ndarray: """Evaluate a model feature at the provided points. Parameters @@ -641,14 +748,15 @@ def export_feature_values_to_geodataframe( GeoDataFrame containing point geometries and computed value columns (and any provided attributes). """ - import pandas as _pd import geopandas as _gpd + import pandas as _pd + try: from shapely.geometry import Point as _Point except Exception: - print("Shapely not available; geometry column will be omitted." ) + print("Shapely not available; geometry column will be omitted.") _Point = None - + pts = np.asarray(points) if pts.ndim != 2 or pts.shape[1] < 3: raise ValueError('points must be an Nx3 array') @@ -676,7 +784,9 @@ def export_feature_values_to_geodataframe( if attributes is not None: try: attributes = _pd.DataFrame(attributes).reset_index(drop=True) - df = _pd.concat([df.reset_index(drop=True), attributes.reset_index(drop=True)], axis=1) + df = _pd.concat( + [df.reset_index(drop=True), attributes.reset_index(drop=True)], axis=1 + ) except Exception: # ignore attributes if they cannot be combined pass @@ -692,7 +802,7 @@ def export_feature_values_to_geodataframe( return gdf - def export_feature_values_to_vtk_mesh(self, name, mesh, scalar_type='scalar'): + def export_feature_values_to_vtk_mesh(self, name, mesh, scalar_type='scalar'): """Evaluate a feature on a mesh's points and attach the values as a field. Parameters @@ -713,4 +823,4 @@ def export_feature_values_to_vtk_mesh(self, name, mesh, scalar_type='scalar'): pts = mesh.points values = self.evaluate_feature_on_points(name, pts, scalar_type=scalar_type) mesh[name] = values - return mesh \ No newline at end of file + return mesh From 52cccd18eff35264e78afbc966d8651d76ccdb70 Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Thu, 15 Jan 2026 09:56:42 +1100 Subject: [PATCH 22/23] fix: debug mode changes logging --- .../gui/visualisation/feature_list_widget.py | 57 +++++++++++-------- loopstructural/toolbelt/preferences.py | 26 +++++++++ 2 files changed, 58 insertions(+), 25 deletions(-) diff --git a/loopstructural/gui/visualisation/feature_list_widget.py b/loopstructural/gui/visualisation/feature_list_widget.py index ddd6a58..7173c4e 100644 --- a/loopstructural/gui/visualisation/feature_list_widget.py +++ b/loopstructural/gui/visualisation/feature_list_widget.py @@ -1,9 +1,12 @@ +import logging from typing import Optional, Union from PyQt5.QtWidgets import QMenu, QPushButton, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget from LoopStructural.datatypes import VectorPoints +logger = logging.getLogger(__name__) + class FeatureListWidget(QWidget): def __init__(self, parent=None, *, model_manager=None, viewer=None): @@ -183,22 +186,22 @@ def add_data(self, feature_name): source_feature=feature_name, source_type='feature_data', ) - print(f"Adding data to feature: {feature_name}") + logger.debug(f"Adding data to feature: {feature_name}") def add_model_bounding_box(self): if not self.model_manager: - print("Model manager is not set.") + logger.debug("Model manager is not set.") return bb = self.model_manager.model.bounding_box.vtk().outline() self.viewer.add_mesh_object( bb, name='model_bounding_box', source_feature='__model__', source_type='bounding_box' ) # Logic for adding model bounding box - print("Adding model bounding box...") + logger.debug("Adding model bounding box...") def add_fault_surfaces(self): if not self.model_manager: - print("Model manager is not set.") + logger.debug("Model manager is not set.") return self.model_manager.update_all_features(subset='faults') fault_surfaces = self.model_manager.model.get_fault_surfaces() @@ -210,11 +213,11 @@ def add_fault_surfaces(self): source_type='fault_surface', isovalue=0.0, ) - print("Adding fault surfaces...") + logger.debug("Adding fault surfaces...") def add_stratigraphic_surfaces(self): if not self.model_manager: - print("Model manager is not set.") + logger.debug("Model manager is not set.") return stratigraphic_surfaces = self.model_manager.model.get_stratigraphic_surfaces() for surface in stratigraphic_surfaces: @@ -238,8 +241,8 @@ def _on_model_update(self, event: str, *args): 'model_updated' notifications the previous behaviour (re-add all affected feature representations) is preserved. """ - print(f"Model update event received: {event} with args: {args}") - print([f"Mesh: {name}, Meta: {meta}" for name, meta in self.viewer.meshes.items()]) + logger.debug(f"Model update event received: {event} with args: {args}") + logger.debug([f"Mesh: {name}, Meta: {meta}" for name, meta in self.viewer.meshes.items()]) if not self.model_manager or not self.viewer: return if event not in ('model_updated', 'feature_updated'): @@ -253,14 +256,14 @@ def _on_model_update(self, event: str, *args): if feature_name is not None: if meta.get('source_feature', None) == feature_name: affected_features.add(feature_name) - print(f"Updating visualisation for feature: {feature_name}") + logger.debug(f"Updating visualisation for feature: {feature_name}") continue sf = meta.get('source_feature', None) if sf is not None: affected_features.add(sf) - print(f"Affected features to update: {affected_features}") + logger.debug(f"Affected features to update: {affected_features}") # For each affected feature, only update existing meshes tied to that feature for feature_name in affected_features: # collect mesh names that belong to this feature (snapshot to avoid mutation while iterating) @@ -269,7 +272,7 @@ def _on_model_update(self, event: str, *args): for name, meta in list(self.viewer.meshes.items()) if meta.get('source_feature') == feature_name ] - print(f"Re-adding meshes for feature: {feature_name}: {meshes_for_feature}") + logger.debug(f"Re-adding meshes for feature: {feature_name}: {meshes_for_feature}") for mesh_name in meshes_for_feature: meta = self.viewer.meshes.get(mesh_name, {}) @@ -280,9 +283,9 @@ def _on_model_update(self, event: str, *args): # remove existing actor/entry so add_mesh_object can recreate with same name try: self.viewer.remove_object(mesh_name) - print(f"Removed existing mesh: {mesh_name}") + logger.debug(f"Removed existing mesh: {mesh_name}") except Exception: - print(f"Failed to remove existing mesh: {mesh_name}") + logger.debug(f"Failed to remove existing mesh: {mesh_name}") pass try: @@ -297,7 +300,7 @@ def _on_model_update(self, event: str, *args): if surfaces: add_name = mesh_name - print( + logger.debug( f"Re-adding surface for feature: {feature_name} with isovalue: {isovalue}" ) self.viewer.add_mesh_object( @@ -310,7 +313,7 @@ def _on_model_update(self, event: str, *args): ) continue except Exception as e: - print( + logger.debug( f"Failed to find matching surface for feature: {feature_name} with isovalue: {isovalue}, trying all surfaces. Error: {e}" ) @@ -323,7 +326,7 @@ def _on_model_update(self, event: str, *args): None, ) if match is not None: - print(f"Re-adding fault surface for: {feature_name}") + logger.debug(f"Re-adding fault surface for: {feature_name}") self.viewer.add_mesh_object( match.vtk(), name=mesh_name, @@ -334,7 +337,7 @@ def _on_model_update(self, event: str, *args): ) continue except Exception as e: - print(f"Failed to re-add fault surface for {feature_name}: {e}") + logger.debug(f"Failed to re-add fault surface for {feature_name}: {e}") # Stratigraphic surfaces (added via add_stratigraphic_surfaces) if source_type == 'stratigraphic_surface': @@ -345,7 +348,7 @@ def _on_model_update(self, event: str, *args): None, ) if match is not None: - print(f"Re-adding stratigraphic surface for: {feature_name}") + logger.debug(f"Re-adding stratigraphic surface for: {feature_name}") self.viewer.add_mesh_object( match.vtk(), name=mesh_name, @@ -356,7 +359,9 @@ def _on_model_update(self, event: str, *args): ) continue except Exception as e: - print(f"Failed to re-add stratigraphic surface for {feature_name}: {e}") + logger.debug( + f"Failed to re-add stratigraphic surface for {feature_name}: {e}" + ) # Vectors, points, scalar fields and other feature related objects if source_type == 'feature_vector' or source_type == 'feature_vectors': @@ -364,41 +369,43 @@ def _on_model_update(self, event: str, *args): self.add_vector_field(feature_name) continue except Exception as e: - print(f"Failed to re-add vector field for {feature_name}: {e}") + logger.debug(f"Failed to re-add vector field for {feature_name}: {e}") if source_type in ('feature_points', 'feature_data'): try: self.add_data(feature_name) continue except Exception as e: - print(f"Failed to re-add data for {feature_name}: {e}") + logger.debug(f"Failed to re-add data for {feature_name}: {e}") if source_type == 'feature_scalar': try: self.add_scalar_field(feature_name) continue except Exception as e: - print(f"Failed to re-add scalar field for {feature_name}: {e}") + logger.debug(f"Failed to re-add scalar field for {feature_name}: {e}") if source_type == 'bounding_box' or mesh_name == 'model_bounding_box': try: self.add_model_bounding_box() continue except Exception as e: - print(f"Failed to re-add bounding box: {e}") + logger.debug(f"Failed to re-add bounding box: {e}") # Fallback: if nothing matched, attempt to re-add by using viewer metadata # Many viewer entries store the vtk source under meta['vtk'] or similar; try best-effort try: vtk_src = meta.get('vtk') if vtk_src is not None: - print(f"Fallback re-add for mesh {mesh_name}") + logger.debug(f"Fallback re-add for mesh {mesh_name}") self.viewer.add_mesh_object(vtk_src, name=mesh_name, **kwargs) except Exception: pass except Exception as e: - print(f"Failed to update visualisation for feature: {feature_name}. Error: {e}") + logger.debug( + f"Failed to update visualisation for feature: {feature_name}. Error: {e}" + ) # Refresh the viewer try: diff --git a/loopstructural/toolbelt/preferences.py b/loopstructural/toolbelt/preferences.py index b14c9b2..1214d2e 100644 --- a/loopstructural/toolbelt/preferences.py +++ b/loopstructural/toolbelt/preferences.py @@ -7,6 +7,7 @@ """ # standard +import logging from dataclasses import asdict, dataclass, fields # PyQGIS @@ -44,6 +45,21 @@ class PlgOptionsManager: plugin to persist user preferences such as debug mode and UI options. """ + @staticmethod + def _configure_logging(debug_mode: bool): + """Configure Python logging level according to plugin debug setting. + + When debug_mode is True the root logger level is set to DEBUG so that + any logger.debug(...) calls in the plugin will be emitted. When False + the level is set to INFO to reduce verbosity. + """ + try: + root = logging.getLogger() + root.setLevel(logging.DEBUG if bool(debug_mode) else logging.INFO) + except Exception: + # Best-effort: do not raise from logging configuration issues + pass + @staticmethod def get_plg_settings() -> PlgSettingsStructure: """Load and return plugin settings as a PlgSettingsStructure instance. @@ -74,6 +90,9 @@ def get_plg_settings() -> PlgSettingsStructure: settings.endGroup() + # Ensure logging level matches the loaded debug_mode preference + PlgOptionsManager._configure_logging(options.debug_mode) + return options @staticmethod @@ -171,6 +190,13 @@ def set_value_from_key(cls, key: str, value) -> bool: try: settings.setValue(key, value) out_value = True + + # If debug mode was toggled, immediately apply logging configuration + if key == "debug_mode": + try: + PlgOptionsManager._configure_logging(value) + except Exception: + pass except Exception as err: log_hdlr.PlgLogger.log( message="Error occurred trying to set settings: {}.Trace: {}".format(key, err) From 37e0319db0376596f4140fb09510088076361dd9 Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Thu, 15 Jan 2026 10:49:56 +1100 Subject: [PATCH 23/23] adding copilot fixes --- .../geological_model_tab/feature_details_panel.py | 3 ++- loopstructural/main/debug/export.py | 14 +++++--------- loopstructural/main/m2l_api.py | 5 +---- 3 files changed, 8 insertions(+), 14 deletions(-) diff --git a/loopstructural/gui/modelling/geological_model_tab/feature_details_panel.py b/loopstructural/gui/modelling/geological_model_tab/feature_details_panel.py index a2d34d8..7056445 100644 --- a/loopstructural/gui/modelling/geological_model_tab/feature_details_panel.py +++ b/loopstructural/gui/modelling/geological_model_tab/feature_details_panel.py @@ -553,6 +553,7 @@ def schedule_rebuild(self, delay_ms: int = 500): self._rebuild_timer.setInterval(delay_ms) self._rebuild_timer.start() except Exception: + logger.debug('Failed to schedule debounced rebuild', exc_info=True) pass def _perform_rebuild(self): @@ -577,7 +578,7 @@ def __init__(self, parent=None, *, fault=None, model_manager=None, data_manager= raise ValueError("Fault must be provided.") self.fault = fault dip = normal_vector_to_strike_and_dip(fault.fault_normal_vector)[0, 1] - print('fault dip:', dip) + pitch = 0 self.fault_parameters = { 'displacement': fault.displacement, diff --git a/loopstructural/main/debug/export.py b/loopstructural/main/debug/export.py index e292b98..006fd7e 100644 --- a/loopstructural/main/debug/export.py +++ b/loopstructural/main/debug/export.py @@ -1,8 +1,6 @@ -import json import pickle -from doctest import debug from pathlib import Path -from typing import Any, Dict, Optional +from typing import Any, Dict def export_debug_package( @@ -30,12 +28,10 @@ def export_debug_package( exported[name] = pkl_name except Exception as e: debug_manager.logger(f"Failed to save debug file '{pkl_name}': {e}") + with open(Path(__file__).parent / 'template.txt', 'r') as f: + template = f.read() + template = template.format(runner_name=runner_script_name.replace('.py', '')) - script = ( - open(Path(__file__).parent / 'template.txt') - .read() - .format(runner_name=runner_script_name.replace('.py', '')) - ) - debug_manager.save_debug_file(runner_script_name, script.encode("utf-8")) + debug_manager.save_debug_file(runner_script_name, template.encode("utf-8")) debug_manager.logger(f"Exported debug package with runner script '{runner_script_name}'") return exported diff --git a/loopstructural/main/m2l_api.py b/loopstructural/main/m2l_api.py index 46a906e..1852fd1 100644 --- a/loopstructural/main/m2l_api.py +++ b/loopstructural/main/m2l_api.py @@ -1,5 +1,3 @@ -from unittest import runner - import pandas as pd from map2loop.contact_extractor import ContactExtractor from map2loop.sampler import SamplerDecimator, SamplerSpacing @@ -12,7 +10,6 @@ ) from map2loop.thickness_calculator import InterpolatedStructure, StructuralPoint from osgeo import gdal -from pkg_resources import run_main from loopstructural.main.debug import export @@ -106,7 +103,7 @@ def extract_basal_contacts( layers = {"geology": geology, "faults": faults} pickles = {"contact_extractor": contact_extractor} # export layers and pickles first to get the actual filenames used - exported = export_debug_package( + _exported = export_debug_package( debug_manager, runner_script_name="run_extract_basal_contacts.py", m2l_object=contact_extractor,