diff --git a/loopstructural/debug_manager.py b/loopstructural/debug_manager.py new file mode 100644 index 0000000..c745e14 --- /dev/null +++ b/loopstructural/debug_manager.py @@ -0,0 +1,262 @@ +#! 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 + self.logger = self.plugin.log + + 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 _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: + self.plugin.log( + 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}): {str(params)}" + ), + log_level=0, + ) + + if self.is_debug(): + try: + debug_dir = self.get_effective_debug_dir() + safe_label = self._sanitize_label(context_label) + file_path = debug_dir / f"{safe_label}_params.json" + payload = params if isinstance(params, dict) else {"_payload": params} + with open(file_path, "w", encoding="utf-8") as file_handle: + json.dump(payload, file_handle, ensure_ascii=False, indent=2, default=str) + self.plugin.log( + 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}", + 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 + + 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/dlg_settings.py b/loopstructural/gui/dlg_settings.py index c4caaaf..8f29be9 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,35 @@ 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.""" + 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(str(target_path))) + else: + logger( + message=f"[map2loop] Debug directory does not exist: {target}", + log_level=1, + ) + else: + logger(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..30d31dc 100644 --- a/loopstructural/gui/map2loop_tools/basal_contacts_widget.py +++ b/loopstructural/gui/map2loop_tools/basal_contacts_widget.py @@ -3,6 +3,7 @@ import os from PyQt5.QtWidgets import QMessageBox, QWidget +from qgis.core import QgsProject, QgsVectorFileWriter from qgis.PyQt import uic from ...main.helpers import ColumnMatcher, get_layer_names @@ -17,7 +18,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 +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__), "basal_contacts_widget.ui") @@ -62,6 +64,66 @@ 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 _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, 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}") + 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._serialize_params_for_logging(self.get_parameters(), context_label), + ) + except Exception: + pass + def _guess_layers(self): """Attempt to auto-select layers based on common naming conventions.""" if not self.data_manager: @@ -113,53 +175,39 @@ 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.") 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}!", - ) + 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, + ) + raise err + QMessageBox.critical(self, "Error", f"An error occurred: {err}") def get_parameters(self): """Get current widget parameters. @@ -199,3 +247,44 @@ 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()}) + self.data_manager.logger(f"Extracting all contacts for units: {stratigraphic_order}") + + 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), + debug_manager=self._debug, + ) + self.data_manager.logger(f'All contacts extracted: {all_contacts}') + contact_type = "basal contacts" + if result: + if all_contacts and result['all_contacts'].empty is False: + addGeoDataFrameToproject(result['all_contacts'], "All contacts") + contact_type = "all contacts and basal contacts" + 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/dialogs.py b/loopstructural/gui/map2loop_tools/dialogs.py index fc4be07..0d37800 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,7 @@ 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) layout.addWidget(self.widget) # Replace the run button with dialog buttons @@ -42,11 +43,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 +56,11 @@ 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, + ) layout.addWidget(self.widget) # Replace the run button with dialog buttons @@ -73,11 +79,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 +93,11 @@ 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, + ) layout.addWidget(self.widget) # Replace the run button with dialog buttons @@ -105,11 +116,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 +129,11 @@ 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, + ) layout.addWidget(self.widget) # Replace the run button with dialog buttons @@ -136,11 +152,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 +165,11 @@ 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, + ) 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..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 @@ -15,7 +16,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 +28,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 +57,70 @@ 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 _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, 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}" + ) + 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._serialize_params_for_logging( + self.get_parameters(), context_label + ), + ) + except Exception: + pass + def _on_sampler_type_changed(self): """Update UI based on selected sampler type.""" sampler_type = self.samplerTypeComboBox.currentText() @@ -92,6 +158,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 +198,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 +290,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..a393055 100644 --- a/loopstructural/gui/map2loop_tools/sorter_widget.py +++ b/loopstructural/gui/map2loop_tools/sorter_widget.py @@ -3,7 +3,7 @@ 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 loopstructural.main.helpers import get_layer_names @@ -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,66 @@ 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 _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, 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}") + 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._serialize_params_for_logging(self.get_parameters(), context_label), + ) + except Exception: + pass + def _guess_layers(self): """Automatically detect and set appropriate field names using ColumnMatcher.""" from ...main.helpers import ColumnMatcher @@ -239,6 +300,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.") @@ -272,7 +335,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), } @@ -294,7 +357,19 @@ 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 "" + 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 +385,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..8f2d9cc 100644 --- a/loopstructural/gui/map2loop_tools/thickness_calculator_widget.py +++ b/loopstructural/gui/map2loop_tools/thickness_calculator_widget.py @@ -2,7 +2,8 @@ import os -from PyQt5.QtWidgets import QMessageBox, QWidget +from PyQt5.QtWidgets import QLabel, QMessageBox, QWidget +from qgis.core import QgsProject, QgsVectorFileWriter from qgis.PyQt import uic from loopstructural.toolbelt.preferences import PlgOptionsManager @@ -18,7 +19,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 +31,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 +70,65 @@ 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 _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, 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}") + 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, context_label) + self._debug.log_params(context_label=context_label, params=payload) + except Exception: + pass + def _guess_layers(self): """Attempt to auto-select layers based on common naming conventions.""" if not self.data_manager: @@ -172,6 +233,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", self.get_parameters()) + # Validate inputs if not self.geologyLayerComboBox.currentLayer(): QMessageBox.warning(self, "Missing Input", "Please select a geology layer.") @@ -225,14 +288,31 @@ 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")) + 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'] 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: @@ -256,6 +336,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..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 @@ -13,7 +14,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 +30,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 +52,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: Any): + 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 +74,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 +93,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 +113,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/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. 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/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) diff --git a/loopstructural/main/m2l_api.py b/loopstructural/main/m2l_api.py index 51fa601..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) @@ -100,11 +138,11 @@ 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", dtm=None, + debug_manager=None, updater=None, contacts=None, ): @@ -154,22 +192,46 @@ def sort_stratigraphic_column( # Convert layers to GeoDataFrames geology_gdf = qgsLayerToGeoDataFrame(geology) contacts_gdf = qgsLayerToGeoDataFrame(contacts) - print(geology_gdf.columns) + + # 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 - 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 = 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) - units_df.columns = ['UNITNAME'] + 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,13 +257,28 @@ 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 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") @@ -216,6 +293,7 @@ def sample_contacts( spacing=None, dtm=None, geology=None, + debug_manager=None, updater=None, ): """Sample spatial data using map2loop samplers. @@ -261,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: @@ -275,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 @@ -294,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, ): @@ -346,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], @@ -399,10 +521,33 @@ 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 + # 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, @@ -411,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 diff --git a/loopstructural/plugin_main.py b/loopstructural/plugin_main.py index 25cc63c..4db9794 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 # ############################################################################ @@ -46,6 +47,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 @@ -63,6 +71,7 @@ def __init__(self, iface: QgisInterface): """ self.iface = iface self.log = PlgLogger().log + self.debug_manager = DebugManager(plugin=self) # translation # initialize the locale @@ -111,6 +120,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 +154,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 +198,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 @@ -329,35 +346,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: @@ -381,58 +418,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 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]} diff --git a/loopstructural/toolbelt/preferences.py b/loopstructural/toolbelt/preferences.py index 6b24986..b14c9b2 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,17 @@ 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.""" + 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: + """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)