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)