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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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)