diff --git a/.github/workflows/linter.yml b/.github/workflows/linter.yml index 7d26925..6b34b0e 100644 --- a/.github/workflows/linter.yml +++ b/.github/workflows/linter.yml @@ -48,4 +48,3 @@ jobs: # - uses: stefanzweifel/git-auto-commit-action@v6 # with: # commit_message: "style: style fixes by ruff and autoformatting by black" - diff --git a/.github/workflows/package_and_release.yml b/.github/workflows/package_and_release.yml index 22a5527..f9f483a 100644 --- a/.github/workflows/package_and_release.yml +++ b/.github/workflows/package_and_release.yml @@ -165,4 +165,4 @@ jobs: --create-plugin-repo --github-token ${{ secrets.GITHUB_TOKEN }} --osgeo-username ${{ secrets.OSGEO_USER }} - --osgeo-password ${{ secrets.OSGEO_PASSWORD }} \ No newline at end of file + --osgeo-password ${{ secrets.OSGEO_PASSWORD }} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 665aaf8..cc3f939 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -27,6 +27,19 @@ repos: - --target-version=py39 +# Local hook to forbid print statements in Python files (excluding tests) + - repo: local + hooks: + - id: forbid-print-statements + name: Forbid print statements + entry: grep -nH -E '\bprint\s*\(' + language: system + types: [python] + exclude: 'tests/' + pass_filenames: true + description: 'Fail if print statements are found in Python files.' + + ci: autoupdate_schedule: quarterly diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 3f6ead5..d28b6e6 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,4 +1,4 @@ { ".": "0.0.1", "loopstructural": "0.1.11" -} \ No newline at end of file +} diff --git a/docs/usage/3d-modeling.md b/docs/usage/3d-modeling.md index 2dda489..f3f2190 100644 --- a/docs/usage/3d-modeling.md +++ b/docs/usage/3d-modeling.md @@ -25,6 +25,3 @@ In LoopStructural stratigraphic surfaces can be modelled using implicit function ### Fault modelling Faults are modelled in LoopStructural by building three implicit functions defining the fault surface, fault slip vector and the fault extent. Combined with a parametric representation of the fault displacement within these coordinates a kinematic model. - - - diff --git a/docs/usage/install/linux.md b/docs/usage/install/linux.md index cdca991..f2d039e 100644 --- a/docs/usage/install/linux.md +++ b/docs/usage/install/linux.md @@ -40,4 +40,4 @@ 1. Restart QGIS if necessary. 2. Confirm that the plugin is available under the **Plugins** menu. -You are now ready to use the plugin on your Linux system! \ No newline at end of file +You are now ready to use the plugin on your Linux system! diff --git a/docs/usage/install/macosx.md b/docs/usage/install/macosx.md index ae36307..102c1bf 100644 --- a/docs/usage/install/macosx.md +++ b/docs/usage/install/macosx.md @@ -12,7 +12,7 @@ ```bash brew install qgis ``` - + ### Step 2: Install Dependencies Using pip 1. Ensure you have `pip` installed. If not, install it using: ```bash @@ -33,4 +33,4 @@ 1. Restart QGIS if necessary. 2. Confirm that the plugin is available under the **Plugins** menu. -You are now ready to use the plugin on your MacOS system! \ No newline at end of file +You are now ready to use the plugin on your MacOS system! diff --git a/docs/usage/installation.md b/docs/usage/installation.md index 6bb5882..17fa2c4 100644 --- a/docs/usage/installation.md +++ b/docs/usage/installation.md @@ -4,12 +4,12 @@ This plugin is published on the official QGIS plugins repository: . -LoopStructural plugin requires the installation of `LoopStructural, loopsolver, pyvista, pyvistaqt and pyqtgraph`. Optionally meshio and geoh5py can also be installed for exporting surfaces/models into different formats. +LoopStructural plugin requires the installation of `LoopStructural, loopsolver, pyvista, pyvistaqt and pyqtgraph`. Optionally meshio and geoh5py can also be installed for exporting surfaces/models into different formats. To install these dependencies you can follow the instructions below for your operating system. ### Using QPIP -You can also use the experimental QGIS plugin QPIP which is developed by OPENGIS.ch that manages the Python dependencies for your QGIS environment and keeps the dependencies up to date. +You can also use the experimental QGIS plugin QPIP which is developed by OPENGIS.ch that manages the Python dependencies for your QGIS environment and keeps the dependencies up to date. ---- @@ -23,5 +23,3 @@ install/linux install/macosx ``` - - diff --git a/docs/usage/interface.md b/docs/usage/interface.md index 88bbd81..dc09047 100644 --- a/docs/usage/interface.md +++ b/docs/usage/interface.md @@ -4,7 +4,7 @@ The LoopStructural plugin interfaces with QGIS to define the model input data and parameters. ### Bounding box -The bounding box defines the spatial extent of the model and can be either specified manually or automatically by calculating the extent from a selected layer or the current view. Not that the bounding box currently has to be axis aligned, meaning that the bounding box is defined by the minimum and maximum x, y and z coordinates. +The bounding box defines the spatial extent of the model and can be either specified manually or automatically by calculating the extent from a selected layer or the current view. Not that the bounding box currently has to be axis aligned, meaning that the bounding box is defined by the minimum and maximum x, y and z coordinates. ![Bounding Box](../static/bounding_box_widget.png) @@ -17,22 +17,22 @@ If the points being modelled contain a Z coordinate, this can be used instead of ### Fault layers The faults trace layer is usually a line layer that contains the trace of the fault. The fault trace is used to define the location of the fault in the model. Optional attributes can be used to further constrain the model: - **fault name** the name of the fault to be used in the model, if this is left blank the feature ID will be used instead. -- **Dip** the dip of the fault, if this is left blank the fault will be assumed to be vertical. +- **Dip** the dip of the fault, if this is left blank the fault will be assumed to be vertical. - **Displacement** - The maximum displacement magnitude of the fault. If this is not specified, a default value will be used. -- **Pitch** - defines the pitch of the fault slip vector in the fault surface. If this is left blank a vertical slip vector is assumed and projected onto the fault surface. +- **Pitch** - defines the pitch of the fault slip vector in the fault surface. If this is left blank a vertical slip vector is assumed and projected onto the fault surface. ![Fault Layer](../static/fault_layers.png) ### Stratigraphy Two layers can be used to constrain the stratigraphy of the model: 1. Basal contacts - this layer defines the basal contacts of the stratigraphy. The layer should contain a line layer with the contact traces. The attributes can be used to define the name of the contact. -2. Structural data - this layer defines the structural data that is used to constrain the model. The layer should contain a point layer with the structural data. The attributes can be used to define the orientation of the data, such as dip and dip direction. +2. Structural data - this layer defines the structural data that is used to constrain the model. The layer should contain a point layer with the structural data. The attributes can be used to define the orientation of the data, such as dip and dip direction. ![Stratigraphic Layer](../static/stratigraphic_layer.png) ## Stratigraphic Column The stratigraphic column defines the order of the contacts and any unconformable relationships between them. The column is defined by a list of units - these units are ordered from oldest at the bottom to youngest at the top. Unconformities can be inserted between units to define an unconformable relationship. The thicknesses define the true thickness of each unit and are used to parameterise the interpolation. The unit names should match the names of the contacts in the basal contacts layer. Units without basal contacts can be included in the stratigraphic column but will not be constrained by any data. -The stratigraphic column can be initialised from the basal contacts layer by clicking the "Initialise from Layer" button. This will create a column with the contacts in the order they are found in the layer. The column can then be edited to add unconformities or change the order of the units. To change the order of units simply drag the units in the list. To add an unconformity, click the "Add Unconformity" button and drag the unconformity the location in the column. +The stratigraphic column can be initialised from the basal contacts layer by clicking the "Initialise from Layer" button. This will create a column with the contacts in the order they are found in the layer. The column can then be edited to add unconformities or change the order of the units. To change the order of units simply drag the units in the list. To add an unconformity, click the "Add Unconformity" button and drag the unconformity the location in the column. ![Stratigraphic Column](../static/stratigraphic_column_04.png) @@ -45,7 +45,7 @@ The fault-fault relationship table defines the interaction between faults in the ![Fault Topology](../static/fault_topology_hamersley.png) ## Model parameters -Once the layers have been selected, stratigraphic column defined and the fault topology relationships set, the LoopStructural model can be initialised. +Once the layers have been selected, stratigraphic column defined and the fault topology relationships set, the LoopStructural model can be initialised. Initialise model will create a LoopStructural model with all of the geological features in the model. For each feature in the model the number of interpolation elements (degrees of freedom), the weighting of the regularisation, contact points and orientation weight can be changed. ![Model Parameters](../static/model-setup.png) diff --git a/loopstructural/debug_manager.py b/loopstructural/debug_manager.py new file mode 100644 index 0000000..6d2147b --- /dev/null +++ b/loopstructural/debug_manager.py @@ -0,0 +1,406 @@ +#! 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 _export_gdf(self, gdf, out_path: Path) -> bool: + """Export a GeoPandas GeoDataFrame to GeoJSON if geopandas available.""" + try: + import geopandas as gpd + + if isinstance(gdf, gpd.GeoDataFrame): + gdf.to_file(out_path, driver="GeoJSON") + return True + except Exception as err: + # geopandas not available or export failed + self.plugin.log(message=f"[map2loop] GeoDataFrame export failed: {err}", log_level=1) + return False + + def _export_qgis_layer(self, qgs_layer, out_path: Path) -> bool: + """Export a QGIS QgsVectorLayer to GeoJSON if available.""" + try: + from qgis.core import QgsVectorFileWriter + + # In QGIS 3, writeAsVectorFormatV2 is preferred, but use writeAsVectorFormat for compatibility + err = QgsVectorFileWriter.writeAsVectorFormat( + qgs_layer, str(out_path), "utf-8", qgs_layer.crs(), "GeoJSON" + ) + # writeAsVectorFormat returns 0 on success in many QGIS versions + if err == 0: + return True + # Some versions return (error, msg) + return False + except Exception as err: + self.plugin.log(message=f"[map2loop] QGIS layer export failed: {err}", log_level=1) + return False + + def _prepare_value_for_export(self, safe_label: str, key: str, value): + """If value is an exportable object, export it and return a reference dict. + + Otherwise return the original value. The reference dict has the form + {"export_path": } so the runner script can re-load exported layers. + """ + debug_dir = self.get_effective_debug_dir() + filename_base = f"{safe_label}_{key}" + + # GeoPandas GeoDataFrame + try: + import geopandas as gpd + + if isinstance(value, gpd.GeoDataFrame): + out_path = debug_dir / f"{filename_base}.geojson" + if self._export_gdf(value, out_path): + return {"export_path": str(out_path)} + except Exception: + pass + + # QGIS vector layer + try: + from qgis.core import QgsVectorLayer + + if isinstance(value, QgsVectorLayer): + out_path = debug_dir / f"{filename_base}.geojson" + if self._export_qgis_layer(value, out_path): + return {"export_path": str(out_path)} + except Exception: + pass + + # Not exportable: return as-is + return value + + def _prepare_params_for_export(self, context_label: str, params: Any): + """Walk params and export embedded spatial layers where possible. + + Returns a payload safe to JSON-serialize where exported objects are + replaced with {"export_path": ...} references. + """ + safe_label = self._sanitize_label(context_label) + + def _recurse(obj, prefix=""): + # dict + if isinstance(obj, dict): + out = {} + for k, v in obj.items(): + out[k] = _recurse(v, f"{prefix}_{k}" if prefix else k) + return out + # list/tuple + if isinstance(obj, (list, tuple)): + return [_recurse(v, f"{prefix}_{i}") for i, v in enumerate(obj)] + # try to export known types + exported = self._prepare_value_for_export(safe_label, prefix or "item", obj) + # If export returns same object, return raw value (may fail JSON later) + return exported + + return _recurse(params) + + def export_file(self, filename: str, content_bytes: bytes): + """Convenience wrapper so callers can ask DebugManager to export a file. + + This centralizes debug file persistence through DebugManager. + """ + return self.save_debug_file(filename, content_bytes) + + def log_params(self, context_label: str, params: Any): + """Log parameters and persist them when debug mode is enabled. + + Prior to saving params, attempt to export embedded spatial layers and + replace them with file references so the saved JSON can be reloaded by + the runner script. + """ + try: + self.plugin.log( + message=f"[map2loop] {context_label} parameters: {str(params)}", + 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: + # Prepare params by exporting embedded layers where applicable + payload = params if isinstance(params, dict) else {"_payload": params} + safe_payload = self._prepare_params_for_export(context_label, payload) + + debug_dir = self.get_effective_debug_dir() + safe_label = self._sanitize_label(context_label) + file_path = debug_dir / f"{safe_label}_params.json" + with open(file_path, "w", encoding="utf-8") as file_handle: + json.dump(safe_payload, file_handle, ensure_ascii=False, indent=2, default=str) + self.plugin.log( + message=f"[map2loop] Params saved to: {file_path}", + log_level=0, + ) + 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, + ) + + def export_layer(self, layer, name_prefix: str): + """Public wrapper to export a layer or GeoDataFrame via the DebugManager. + + Returns the path to the exported file (string) on success, or None on failure. + """ + if not self.is_debug(): + return None + safe_prefix = self._sanitize_label(name_prefix) + debug_dir = self.get_effective_debug_dir() + + # Try GeoPandas GeoDataFrame + try: + import geopandas as gpd + + if isinstance(layer, gpd.GeoDataFrame): + out_path = debug_dir / f"{safe_prefix}.geojson" + if self._export_gdf(layer, out_path): + return str(out_path) + except Exception: + pass + + # Try QGIS vector layer + try: + from qgis.core import QgsVectorLayer + + if isinstance(layer, QgsVectorLayer): + out_path = debug_dir / f"{safe_prefix}.geojson" + if self._export_qgis_layer(layer, out_path): + return str(out_path) + except Exception: + pass + + # Unsupported type or export failed + self.plugin.log( + message=f"[map2loop] export_layer: Could not export object of type {type(layer)}", + log_level=1, + ) + return None diff --git a/loopstructural/gui/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/__init__.py b/loopstructural/gui/map2loop_tools/__init__.py new file mode 100644 index 0000000..ab68076 --- /dev/null +++ b/loopstructural/gui/map2loop_tools/__init__.py @@ -0,0 +1,21 @@ +"""Map2Loop processing tools dialogs. + +This module contains GUI dialogs for map2loop processing tools that can be +accessed from the plugin menu. +""" + +from .dialogs import ( + BasalContactsDialog, + SamplerDialog, + SorterDialog, + ThicknessCalculatorDialog, + UserDefinedSorterDialog, +) + +__all__ = [ + 'BasalContactsDialog', + 'SamplerDialog', + 'SorterDialog', + 'ThicknessCalculatorDialog', + 'UserDefinedSorterDialog', +] diff --git a/loopstructural/gui/map2loop_tools/basal_contacts_widget.py b/loopstructural/gui/map2loop_tools/basal_contacts_widget.py new file mode 100644 index 0000000..a18dd4b --- /dev/null +++ b/loopstructural/gui/map2loop_tools/basal_contacts_widget.py @@ -0,0 +1,287 @@ +"""Widget for extracting basal contacts.""" + +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 +from ...main.m2l_api import extract_basal_contacts +from ...main.vectorLayerWrapper import addGeoDataFrameToproject + + +class BasalContactsWidget(QWidget): + """Widget for configuring and running the basal contacts extractor. + + This widget provides a GUI interface for extracting basal contacts + from geology layers. + """ + + def __init__(self, parent=None, data_manager=None, debug_manager=None): + """Initialize the basal contacts widget. + + Parameters + ---------- + parent : QWidget, optional + Parent widget. + data_manager : object, optional + Data manager for accessing shared data. + """ + 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") + uic.loadUi(ui_path, self) + + # Move layer filter setup out of the .ui (QgsMapLayerProxyModel values in .ui + # can cause import errors outside QGIS). Set filters programmatically + # and preserve the allowEmptyLayer setting for the faults combobox. + try: + from qgis.core import QgsMapLayerProxyModel + + # geology layer should only show polygon layers + self.geologyLayerComboBox.setFilters(QgsMapLayerProxyModel.PolygonLayer) + + # faults should show line layers and allow empty selection (as set in .ui) + self.faultsLayerComboBox.setFilters(QgsMapLayerProxyModel.LineLayer) + try: + # QgsMapLayerComboBox has setAllowEmptyLayer method in newer QGIS versions + self.faultsLayerComboBox.setAllowEmptyLayer(True) + except Exception: + # Older QGIS bindings may use allowEmptyLayer property; ignore if unavailable + pass + except Exception: + # If QGIS isn't available (e.g. editing the UI outside QGIS), skip setting filters + pass + + # Connect signals + self.geologyLayerComboBox.layerChanged.connect(self._on_geology_layer_changed) + self.runButton.clicked.connect(self._run_extractor) + self._guess_layers() + # 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: + return + + # Attempt to find geology layer + geology_layer_names = get_layer_names(self.geologyLayerComboBox) + geology_matcher = ColumnMatcher(geology_layer_names) + geology_layer_match = geology_matcher.find_match('GEOLOGY') + if geology_layer_match: + geology_layer = self.data_manager.find_layer_by_name(geology_layer_match) + self.geologyLayerComboBox.setLayer(geology_layer) + + # Attempt to find faults layer + fault_layer_names = get_layer_names(self.faultsLayerComboBox) + fault_layer_matcher = ColumnMatcher(fault_layer_names) + fault_layer_match = fault_layer_matcher.find_match('FAULTS') + if fault_layer_match: + faults_layer = self.data_manager.find_layer_by_name(fault_layer_match) + self.faultsLayerComboBox.setLayer(faults_layer) + + def _setup_field_combo_boxes(self): + """Set up field combo boxes to link to their respective layers.""" + geology = self.geologyLayerComboBox.currentLayer() + if geology is not None: + self.unitNameFieldComboBox.setLayer(geology) + else: + # Ensure combo boxes are cleared if no geology layer selected + self.unitNameFieldComboBox.setLayer(None) + + def _on_geology_layer_changed(self): + """Update field combo boxes when geology layer changes.""" + from ...main.helpers import ColumnMatcher + + layer = self.geologyLayerComboBox.currentLayer() + self.unitNameFieldComboBox.setLayer(layer) + + # Auto-detect appropriate fields + if layer: + fields = [field.name() for field in layer.fields()] + matcher = ColumnMatcher(fields) + + # Auto-select UNITNAME field + if unit_match := matcher.find_match('UNITNAME'): + self.unitNameFieldComboBox.setField(unit_match) + + 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 + + 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. + + Returns + ------- + dict + Dictionary of current widget parameters. + """ + ignore_units = [] + if self.ignoreUnitsLineEdit.text().strip(): + ignore_units = [ + unit.strip() for unit in self.ignoreUnitsLineEdit.text().split(',') if unit.strip() + ] + + return { + 'geology_layer': self.geologyLayerComboBox.currentLayer(), + 'unit_name_field': self.unitNameFieldComboBox.currentField(), + 'faults_layer': self.faultsLayerComboBox.currentLayer(), + 'ignore_units': ignore_units, + 'all_contacts': self.allContactsCheckBox.isChecked(), + } + + def set_parameters(self, params): + """Set widget parameters. + + Parameters + ---------- + params : dict + Dictionary of parameters to set. + """ + if 'geology_layer' in params and params['geology_layer']: + self.geologyLayerComboBox.setLayer(params['geology_layer']) + if 'faults_layer' in params and params['faults_layer']: + self.faultsLayerComboBox.setLayer(params['faults_layer']) + if 'ignore_units' in params and params['ignore_units']: + 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/basal_contacts_widget.ui b/loopstructural/gui/map2loop_tools/basal_contacts_widget.ui new file mode 100644 index 0000000..af1b629 --- /dev/null +++ b/loopstructural/gui/map2loop_tools/basal_contacts_widget.ui @@ -0,0 +1,125 @@ + + + BasalContactsWidget + + + + 0 + 0 + 600 + 400 + + + + Basal Contacts Extractor + + + + + + + + Geology Layer: + + + + + + + + + + + + Unit Name Field: + + + + + + + + + + Faults Layer: + + + + + + + + true + + + + + + + Units to Ignore: + + + + + + + Comma-separated list of units to ignore + + + + + + + Contact Type: + + + + + + + Extract All Contacts (not just basal) + + + false + + + + + + + + + Extract Contacts + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + QgsMapLayerComboBox + QComboBox +
qgsmaplayercombobox.h
+
+ + QgsFieldComboBox + QComboBox +
qgsfieldcombobox.h
+
+
+ + +
diff --git a/loopstructural/gui/map2loop_tools/dialogs.py b/loopstructural/gui/map2loop_tools/dialogs.py new file mode 100644 index 0000000..0d37800 --- /dev/null +++ b/loopstructural/gui/map2loop_tools/dialogs.py @@ -0,0 +1,185 @@ +"""Dialog wrappers for map2loop processing tools. + +This module provides QDialog wrappers that use map2loop classes directly +instead of QGIS processing algorithms. +""" + +from PyQt5.QtWidgets import QDialog, QDialogButtonBox, QVBoxLayout + + +class SamplerDialog(QDialog): + """Dialog for running samplers using map2loop classes directly.""" + + 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): + """Set up the dialog UI.""" + from .sampler_widget import SamplerWidget + + layout = QVBoxLayout(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 + self.widget.runButton.hide() + + self.button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel, self) + self.button_box.accepted.connect(self._run_and_accept) + self.button_box.rejected.connect(self.reject) + layout.addWidget(self.button_box) + + def _run_and_accept(self): + """Run the sampler and accept dialog if successful.""" + self.widget._run_sampler() + # Dialog stays open so user can see the result + + +class SorterDialog(QDialog): + """Dialog for running stratigraphic sorter using map2loop classes directly.""" + + 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): + """Set up the dialog UI.""" + from .sorter_widget import SorterWidget + + layout = QVBoxLayout(self) + 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 + self.widget.runButton.hide() + + self.button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel, self) + self.button_box.accepted.connect(self._run_and_accept) + self.button_box.rejected.connect(self.reject) + layout.addWidget(self.button_box) + + def _run_and_accept(self): + """Run the sorter and accept dialog if successful.""" + self.widget._run_sorter() + + +class UserDefinedSorterDialog(QDialog): + """Dialog for user-defined stratigraphic column using map2loop classes directly.""" + + 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() + + def setup_ui(self): + """Set up the dialog UI.""" + from .user_defined_sorter_widget import UserDefinedSorterWidget + + layout = QVBoxLayout(self) + 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 + # self.widget.runButton.hide() + + # self.button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel, self) + # self.button_box.accepted.connect(self._run_and_accept) + # self.button_box.rejected.connect(self.reject) + # layout.addWidget(self.button_box) + + def _run_and_accept(self): + """Run the sorter and accept dialog if successful.""" + self.widget._run_sorter() + + +class BasalContactsDialog(QDialog): + """Dialog for extracting basal contacts using map2loop classes directly.""" + + 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): + """Set up the dialog UI.""" + from .basal_contacts_widget import BasalContactsWidget + + layout = QVBoxLayout(self) + 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 + self.widget.runButton.hide() + + self.button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel, self) + self.button_box.accepted.connect(self._run_and_accept) + self.button_box.rejected.connect(self.reject) + layout.addWidget(self.button_box) + + def _run_and_accept(self): + """Run the extractor and accept dialog if successful.""" + self.widget._run_extractor() + + +class ThicknessCalculatorDialog(QDialog): + """Dialog for calculating thickness using map2loop classes directly.""" + + 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): + """Set up the dialog UI.""" + from .thickness_calculator_widget import ThicknessCalculatorWidget + + layout = QVBoxLayout(self) + 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 + self.widget.runButton.hide() + + self.button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel, self) + self.button_box.accepted.connect(self._run_and_accept) + self.button_box.rejected.connect(self.reject) + layout.addWidget(self.button_box) + + def _run_and_accept(self): + """Run the calculator and accept dialog if successful.""" + self.widget._run_calculator() diff --git a/loopstructural/gui/map2loop_tools/fault_topology_widget.py b/loopstructural/gui/map2loop_tools/fault_topology_widget.py new file mode 100644 index 0000000..ed14caa --- /dev/null +++ b/loopstructural/gui/map2loop_tools/fault_topology_widget.py @@ -0,0 +1,92 @@ +"""Widget for calculating fault topology from a fault layer.""" + +import os + +import geopandas as gpd +from PyQt5.QtWidgets import QDialog, QMessageBox +from qgis.PyQt import uic +from qgis.core import QgsMapLayerProxyModel + + +class FaultTopologyWidget(QDialog): + def _guess_fault_layer_and_field(self): + """Attempt to auto-select the fault layer and ID field based on common names.""" + try: + from ...main.helpers import ColumnMatcher, get_layer_names + except ImportError: + return + # Guess fault layer + fault_layer_names = get_layer_names(self.faultLayerComboBox) + fault_matcher = ColumnMatcher(fault_layer_names) + fault_layer_match = fault_matcher.find_match('FAULTS') + if fault_layer_match and hasattr(self, 'data_manager') and self.data_manager: + fault_layer = self.data_manager.find_layer_by_name(fault_layer_match) + self.faultLayerComboBox.setLayer(fault_layer) + # Guess ID field + layer = self.faultLayerComboBox.currentLayer() + if layer: + fields = [field.name() for field in layer.fields()] + matcher = ColumnMatcher(fields) + for key in ["ID", "NAME", "FNAME", "id", "name", "fname"]: + match = matcher.find_match(key) + if match: + self.faultIdFieldComboBox.setField(match) + break + + """Widget for calculating fault topology from a fault layer.""" + + def __init__(self, parent=None, data_manager=None): + super().__init__(parent) + self.data_manager = data_manager + # Load the UI file + ui_path = os.path.join(os.path.dirname(__file__), "fault_topology_widget.ui") + uic.loadUi(ui_path, self) + # Set filter for fault layer selection + + self.faultLayerComboBox.setFilters(QgsMapLayerProxyModel.LineLayer) + self.faultLayerComboBox.layerChanged.connect(self._on_fault_layer_changed) + self.runButton.clicked.connect(self._run_topology) + self._guess_fault_layer_and_field() + + def _on_fault_layer_changed(self): + layer = self.faultLayerComboBox.currentLayer() + self.faultIdFieldComboBox.setLayer(layer) + # Optionally auto-select a likely ID field + if layer: + fields = [field.name() for field in layer.fields()] + for name in ["id", "ID", "fault_id", "FaultID", "FaultId"]: + if name in fields: + self.faultIdFieldComboBox.setField(name) + break + + def _run_topology(self): + layer = self.faultLayerComboBox.currentLayer() + if not layer: + QMessageBox.warning(self, "Missing Input", "Please select a fault layer.") + return + id_field = self.faultIdFieldComboBox.currentField() + if not id_field: + QMessageBox.warning(self, "Missing Input", "Please select a fault ID field.") + return + # Convert to GeoDataFrame + gdf = gpd.GeoDataFrame.from_features(layer.getFeatures()) + if gdf.empty: + QMessageBox.warning(self, "No Data", "The selected layer has no features.") + return + # Rename the selected ID field to 'ID' for Topology class compatibility + if id_field != "ID": + gdf = gdf.rename(columns={id_field: "ID"}) + # Use map2loop Topology class + try: + from map2loop.topology import Topology + except ImportError: + QMessageBox.critical(self, "Error", "Could not import map2loop Topology class.") + return + topology = Topology(geology_data=None, fault_data=gdf) + df = topology.fault_fault_relationships + # if self.data_manager is not None: + # self.data_manager. + # Show or add to project + # addGeoDataFrameToproject(gdf, "Input Faults") + # addGeoDataFrameToproject(df, "Fault Topology Table") + QMessageBox.information(self, "Success", f"Calculated fault topology for {len(df)} pairs.") diff --git a/loopstructural/gui/map2loop_tools/fault_topology_widget.ui b/loopstructural/gui/map2loop_tools/fault_topology_widget.ui new file mode 100644 index 0000000..7952288 --- /dev/null +++ b/loopstructural/gui/map2loop_tools/fault_topology_widget.ui @@ -0,0 +1,64 @@ + + + FaultTopologyWidget + + + + 0 + 0 + 400 + 120 + + + + Fault Topology Calculator + + + + + + + + Fault Layer: + + + + + + + + + + Fault ID Field: + + + + + + + + + + + + Calculate Topology + + + + + + + + QgsMapLayerComboBox + QComboBox +
qgsmaplayercombobox.h
+
+ + QgsFieldComboBox + QComboBox +
qgsfieldcombobox.h
+
+
+ + +
diff --git a/loopstructural/gui/map2loop_tools/sampler_widget.py b/loopstructural/gui/map2loop_tools/sampler_widget.py new file mode 100644 index 0000000..1b80306 --- /dev/null +++ b/loopstructural/gui/map2loop_tools/sampler_widget.py @@ -0,0 +1,324 @@ +"""Widget for running the sampler.""" + +import os + +from PyQt5.QtWidgets import QMessageBox, QWidget +from qgis.core import QgsProject +from qgis.PyQt import uic + +from loopstructural.toolbelt.preferences import PlgOptionsManager + + +class SamplerWidget(QWidget): + """Widget for configuring and running the sampler. + + This widget provides a GUI interface for the map2loop sampler algorithms + (Decimator and Spacing). + """ + + def __init__(self, parent=None, data_manager=None, debug_manager=None): + """Initialize the sampler widget. + + Parameters + ---------- + parent : QWidget, optional + Parent widget. + data_manager : object, optional + Data manager for accessing shared data. + """ + 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") + uic.loadUi(ui_path, self) + + # Configure layer filters programmatically (avoid QgsMapLayerProxyModel in .ui) + try: + from qgis.core import QgsMapLayerProxyModel + + # DTM should show raster layers, geology polygons + self.dtmLayerComboBox.setFilters(QgsMapLayerProxyModel.RasterLayer) + self.geologyLayerComboBox.setFilters(QgsMapLayerProxyModel.PolygonLayer) + # spatialData can be any type, leave default + except Exception: + # If QGIS isn't available, skip filter setup + pass + + # Initialize sampler types + self.sampler_types = ["Decimator", "Spacing"] + self.samplerTypeComboBox.addItems(self.sampler_types) + + # Connect signals + self.samplerTypeComboBox.currentIndexChanged.connect(self._on_sampler_type_changed) + self.runButton.clicked.connect(self._run_sampler) + + # 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): + # Prefer DebugManager.export_layer if available + try: + if getattr(self, '_debug', None) and hasattr(self._debug, 'export_layer'): + exported = self._debug.export_layer(layer, name_prefix) + return exported + + except Exception as err: + if getattr(self, '_debug', None): + 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() + + if sampler_type == "Decimator": + self.decimationLabel.setVisible(True) + self.decimationSpinBox.setVisible(True) + self.spacingLabel.setVisible(False) + self.spacingSpinBox.setVisible(False) + # Decimator requires DTM and geology + self.dtmLayerComboBox.setAllowEmptyLayer(False) + self.geologyLayerComboBox.setAllowEmptyLayer(False) + else: # Spacing + self.decimationLabel.setVisible(False) + self.decimationSpinBox.setVisible(False) + self.spacingLabel.setVisible(True) + self.spacingSpinBox.setVisible(True) + # Spacing can work with optional DTM and geology + self.dtmLayerComboBox.setAllowEmptyLayer(True) + self.geologyLayerComboBox.setAllowEmptyLayer(True) + + def _run_sampler(self): + """Run the sampler algorithm using the map2loop API.""" + from qgis.core import ( + QgsCoordinateReferenceSystem, + QgsFeature, + QgsField, + QgsFields, + QgsGeometry, + QgsPointXY, + QgsVectorLayer, + ) + from qgis.PyQt.QtCore import QVariant + + 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.") + return + + sampler_type = self.samplerTypeComboBox.currentText() + + if sampler_type == "Decimator": + if not self.geologyLayerComboBox.currentLayer(): + QMessageBox.warning( + self, "Missing Input", "Geology layer is required for Decimator." + ) + return + if not self.dtmLayerComboBox.currentLayer(): + QMessageBox.warning(self, "Missing Input", "DTM layer is required for Decimator.") + return + + # Run the sampler API + try: + kwargs = { + 'spatial_data': self.spatialDataLayerComboBox.currentLayer(), + 'sampler_type': sampler_type, + 'updater': lambda msg: QMessageBox.information(self, "Progress", msg), + } + + if sampler_type == "Decimator": + kwargs['decimation'] = self.decimationSpinBox.value() + kwargs['dtm'] = self.dtmLayerComboBox.currentLayer() + kwargs['geology'] = self.geologyLayerComboBox.currentLayer() + else: # Spacing + kwargs['spacing'] = self.spacingSpinBox.value() + if self.dtmLayerComboBox.currentLayer(): + kwargs['dtm'] = self.dtmLayerComboBox.currentLayer() + if self.geologyLayerComboBox.currentLayer(): + kwargs['geology'] = self.geologyLayerComboBox.currentLayer() + + 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})" + + fields = QgsFields() + for column_name in samples.columns: + if column_name == 'geometry': + continue + dtype = samples[column_name].dtype + dtype_str = str(dtype) + + if dtype_str in ['float16', 'float32', 'float64']: + field_type = QVariant.Double + elif dtype_str in ['int8', 'int16', 'int32', 'int64']: + field_type = QVariant.Int + else: + field_type = QVariant.String + + fields.append(QgsField(column_name, field_type)) + + crs = None + if ( + hasattr(self.spatialDataLayerComboBox.currentLayer(), 'crs') + and self.spatialDataLayerComboBox.currentLayer().crs() is not None + ): + crs = QgsCoordinateReferenceSystem.fromWkt( + self.spatialDataLayerComboBox.currentLayer().crs().toWkt() + ) + # Create layer + geom_type = "PointZ" if 'Z' in samples.columns else "Point" + layer = QgsVectorLayer( + f"{geom_type}?crs={crs.authid() if crs else 'EPSG:4326'}", layer_name, "memory" + ) + provider = layer.dataProvider() + provider.addAttributes(fields) + layer.updateFields() + + # Add features + for _index, row in samples.iterrows(): + feature = QgsFeature(fields) + + # Add geometry + if 'Z' in samples.columns and __import__('pandas').notna(row.get('Z')): + wkt = f"POINT Z ({row['X']} {row['Y']} {row['Z']})" + feature.setGeometry(QgsGeometry.fromWkt(wkt)) + else: + feature.setGeometry(QgsGeometry.fromPointXY(QgsPointXY(row['X'], row['Y']))) + + # Add attributes + attributes = [] + for column_name in samples.columns: + if column_name == 'geometry': + continue + value = row.get(column_name) + dtype = samples[column_name].dtype + pd = __import__('pandas') + + if pd.isna(value): + attributes.append(None) + elif dtype in ['float16', 'float32', 'float64']: + attributes.append(float(value)) + elif dtype in ['int8', 'int16', 'int32', 'int64']: + attributes.append(int(value)) + else: + attributes.append(str(value)) + + feature.setAttributes(attributes) + provider.addFeature(feature) + + layer.updateExtents() + QgsProject.instance().addMapLayer(layer) + + QMessageBox.information( + self, + "Success", + f"Sampling completed! Layer '{layer_name}' added with {len(samples)} features.", + ) + else: + 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)}") + + def get_parameters(self): + """Get current widget parameters. + + Returns + ------- + dict + Dictionary of current widget parameters. + """ + return { + 'sampler_type': self.samplerTypeComboBox.currentIndex(), + 'dtm_layer': self.dtmLayerComboBox.currentLayer(), + 'geology_layer': self.geologyLayerComboBox.currentLayer(), + 'spatial_data_layer': self.spatialDataLayerComboBox.currentLayer(), + 'decimation': self.decimationSpinBox.value(), + 'spacing': self.spacingSpinBox.value(), + } + + def set_parameters(self, params): + """Set widget parameters. + + Parameters + ---------- + params : dict + Dictionary of parameters to set. + """ + if 'sampler_type' in params: + self.samplerTypeComboBox.setCurrentIndex(params['sampler_type']) + if 'dtm_layer' in params and params['dtm_layer']: + self.dtmLayerComboBox.setLayer(params['dtm_layer']) + if 'geology_layer' in params and params['geology_layer']: + self.geologyLayerComboBox.setLayer(params['geology_layer']) + if 'spatial_data_layer' in params and params['spatial_data_layer']: + self.spatialDataLayerComboBox.setLayer(params['spatial_data_layer']) + if 'decimation' in params: + self.decimationSpinBox.setValue(params['decimation']) + if 'spacing' in params: + self.spacingSpinBox.setValue(params['spacing']) diff --git a/loopstructural/gui/map2loop_tools/sampler_widget.ui b/loopstructural/gui/map2loop_tools/sampler_widget.ui new file mode 100644 index 0000000..21f1b6d --- /dev/null +++ b/loopstructural/gui/map2loop_tools/sampler_widget.ui @@ -0,0 +1,145 @@ + + + SamplerWidget + + + + 0 + 0 + 600 + 400 + + + + Sampler + + + + + + + + Sampler Type: + + + + + + + + + + DTM Layer: + + + + + + + true + + + + + + + Geology Layer: + + + + + + + true + + + + + + + Spatial Data Layer: + + + + + + + + + + Decimation: + + + + + + + 1 + + + 1 + + + + + + + Spacing: + + + + + + + 2 + + + 0.010000000000000 + + + 100000.000000000000000 + + + 200.000000000000000 + + + + + + + + + Run Sampler + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + QgsMapLayerComboBox + QComboBox +
qgsmaplayercombobox.h
+
+ + QgsDoubleSpinBox + QDoubleSpinBox +
qgsdoublespinbox.h
+
+
+ + +
diff --git a/loopstructural/gui/map2loop_tools/sorter_widget.py b/loopstructural/gui/map2loop_tools/sorter_widget.py new file mode 100644 index 0000000..71f2845 --- /dev/null +++ b/loopstructural/gui/map2loop_tools/sorter_widget.py @@ -0,0 +1,422 @@ +"""Widget for running the automatic stratigraphic sorter.""" + +import os + +from PyQt5.QtWidgets import QMessageBox, QWidget +from qgis.core import QgsRasterLayer +from qgis.core import QgsMapLayerProxyModel + +from qgis.PyQt import uic + +from loopstructural.main.helpers import get_layer_names +from loopstructural.main.m2l_api import PARAMETERS_DICTIONARY, SORTER_LIST +from loopstructural.toolbelt.preferences import PlgOptionsManager + + +class SorterWidget(QWidget): + """Widget for configuring and running the automatic stratigraphic sorter. + + This widget provides a GUI interface for the map2loop stratigraphic + sorting algorithms. + """ + + def __init__(self, parent=None, data_manager=None, debug_manager=None): + """Initialize the sorter widget. + + Parameters + ---------- + parent : QWidget, optional + Parent widget. + data_manager : object, optional + Data manager for accessing shared data. + """ + super().__init__(parent) + 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") + uic.loadUi(ui_path, self) + + # Configure layer filters programmatically (avoid QGIS enums in UI) + + self.geologyLayerComboBox.setFilters(QgsMapLayerProxyModel.PolygonLayer) + self.contactsLayerComboBox.setFilters(QgsMapLayerProxyModel.LineLayer) + self.structureLayerComboBox.setFilters(QgsMapLayerProxyModel.PointLayer) + self.dtmLayerComboBox.setFilters(QgsMapLayerProxyModel.RasterLayer) + + # Initialize sorting algorithms + self.sorting_algorithms = list(SORTER_LIST.keys()) + self.sortingAlgorithmComboBox.addItems(self.sorting_algorithms) + # Set default to 'Age based' if present, else fallback to first + try: + age_based_index = self.sorting_algorithms.index('Age based') + except ValueError: + age_based_index = 0 + self.sortingAlgorithmComboBox.setCurrentIndex(age_based_index) + + # Initialize orientation types + self.orientation_types = ['', 'Dip Direction', 'Strike'] + self.orientationTypeComboBox.addItems(self.orientation_types) + # Connect signals + self.sortingAlgorithmComboBox.currentIndexChanged.connect(self._on_algorithm_changed) + self.geologyLayerComboBox.layerChanged.connect(self._on_geology_layer_changed) + self.structureLayerComboBox.layerChanged.connect(self._on_structure_layer_changed) + self.runButton.clicked.connect(self._run_sorter) + self.orientationTypeComboBox.setCurrentIndex(1) # Default to Dip Direction + self._guess_layers() + + # Set up field combo boxes + self._setup_field_combo_boxes() + + # 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): + # Prefer DebugManager.export_layer + try: + if getattr(self, '_debug', None) and hasattr(self._debug, 'export_layer'): + return self._debug.export_layer(layer, name_prefix) + + except Exception as err: + self._debug.plugin.log( + message=f"[map2loop] Failed to export layer '{name_prefix}': {err}", + 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): + self._debug.log_params( + context_label=context_label, + params=self._serialize_params_for_logging(self.get_parameters(), context_label), + ) + + def _guess_layers(self): + """Automatically detect and set appropriate field names using ColumnMatcher.""" + from ...main.helpers import ColumnMatcher + + # Auto-detect geology fields + geology_layer_names = get_layer_names(self.geologyLayerComboBox) + + geology_layer_matcher = ColumnMatcher(geology_layer_names) + geology_layer_match = geology_layer_matcher.find_match('GEOLOGY') + geology_layer = self.data_manager.find_layer_by_name(geology_layer_match) + self.geologyLayerComboBox.setLayer(geology_layer) + + # Auto-detect structure fields + structure_layer_names = get_layer_names(self.structureLayerComboBox) + structure_layer_matcher = ColumnMatcher(structure_layer_names) + structure_layer_match = structure_layer_matcher.find_match('STRUCTURE') + structure_layer = self.data_manager.find_layer_by_name(structure_layer_match) + self.structureLayerComboBox.setLayer(structure_layer) + + contact_layer_names = get_layer_names(self.contactsLayerComboBox) + contact_layer_matcher = ColumnMatcher(contact_layer_names) + contact_layer_match = contact_layer_matcher.find_match('CONTACTS') + contact_layer = self.data_manager.find_layer_by_name(contact_layer_match) + self.contactsLayerComboBox.setLayer(contact_layer) + + dem_layer_names = get_layer_names(self.dtmLayerComboBox) + dem_layer_matcher = ColumnMatcher(dem_layer_names) + dem_layer_match = dem_layer_matcher.find_match('DTM') + if not dem_layer_match: + dem_layer_match = dem_layer_matcher.find_match('DEM') + dem_layer = self.data_manager.find_layer_by_name(dem_layer_match, layer_type=QgsRasterLayer) + self.dtmLayerComboBox.setLayer(dem_layer) + + def _setup_field_combo_boxes(self): + """Set up field combo boxes to link to their respective layers.""" + self.unitNameFieldComboBox.setLayer(self.geologyLayerComboBox.currentLayer()) + self.minAgeFieldComboBox.setLayer(self.geologyLayerComboBox.currentLayer()) + self.maxAgeFieldComboBox.setLayer(self.geologyLayerComboBox.currentLayer()) + self.dipFieldComboBox.setLayer(self.structureLayerComboBox.currentLayer()) + self.dipDirFieldComboBox.setLayer(self.structureLayerComboBox.currentLayer()) + + def _on_geology_layer_changed(self): + """Update field combo boxes when geology layer changes.""" + from ...main.helpers import ColumnMatcher + + layer = self.geologyLayerComboBox.currentLayer() + self.unitNameFieldComboBox.setLayer(layer) + self.minAgeFieldComboBox.setLayer(layer) + self.maxAgeFieldComboBox.setLayer(layer) + + # Auto-detect appropriate fields + if layer: + fields = [field.name() for field in layer.fields()] + matcher = ColumnMatcher(fields) + + # Auto-select best matches + if unit_match := matcher.find_match('UNITNAME'): + self.unitNameFieldComboBox.setField(unit_match) + + if min_age_match := matcher.find_match('MIN_AGE'): + self.minAgeFieldComboBox.setField(min_age_match) + + if max_age_match := matcher.find_match('MAX_AGE'): + self.maxAgeFieldComboBox.setField(max_age_match) + + def _on_structure_layer_changed(self): + """Update field combo boxes when structure layer changes.""" + from ...main.helpers import ColumnMatcher + + layer = self.structureLayerComboBox.currentLayer() + self.dipFieldComboBox.setLayer(layer) + self.dipDirFieldComboBox.setLayer(layer) + + # Auto-detect appropriate fields + if layer: + fields = [field.name() for field in layer.fields()] + matcher = ColumnMatcher(fields) + + # Auto-select best matches + if dip_match := matcher.find_match('DIP'): + self.dipFieldComboBox.setField(dip_match) + + if dipdir_match := matcher.find_match('DIPDIR'): + self.dipDirFieldComboBox.setField(dipdir_match) + + def _on_algorithm_changed(self): + """Update UI based on selected sorting algorithm and map2loop requirements.""" + algorithm_index = self.sortingAlgorithmComboBox.currentIndex() + algorithm_name = self.sorting_algorithms[algorithm_index] + # Import map2loop's required arguments for sorters + try: + from map2loop.project import REQUIRED_ARGUMENTS as M2L_REQUIRED_ARGUMENTS + except ImportError: + M2L_REQUIRED_ARGUMENTS = {} + # Fallback to PARAMETERS_DICTIONARY if not found + required_fields = M2L_REQUIRED_ARGUMENTS.get(algorithm_name) or PARAMETERS_DICTIONARY.get( + algorithm_name, [] + ) + + # Hide all relevant widgets by default + self.minAgeFieldLabel.setVisible(False) + self.minAgeFieldComboBox.setVisible(False) + self.maxAgeFieldLabel.setVisible(False) + self.maxAgeFieldComboBox.setVisible(False) + self.unitName1FieldLabel.setVisible(False) + self.unitName1FieldComboBox.setVisible(False) + self.unitName2FieldLabel.setVisible(False) + self.unitName2FieldComboBox.setVisible(False) + + self.contactsLayerLabel.setVisible(False) + self.contactsLayerComboBox.setVisible(False) + self.structureLayerLabel.setVisible(False) + self.structureLayerComboBox.setVisible(False) + self.dipFieldLabel.setVisible(False) + self.dipFieldComboBox.setVisible(False) + self.dipDirFieldLabel.setVisible(False) + self.dipDirFieldComboBox.setVisible(False) + self.orientationTypeLabel.setVisible(False) + self.orientationTypeComboBox.setVisible(False) + self.dtmLayerLabel.setVisible(False) + self.dtmLayerComboBox.setVisible(False) + + # Show widgets based on required fields + geology_layer = self.geologyLayerComboBox.currentLayer() + if 'min_age_column' in required_fields or 'min_age_field' in required_fields: + self.minAgeFieldLabel.setVisible(True) + self.minAgeFieldComboBox.setVisible(True) + self.minAgeFieldComboBox.setLayer(geology_layer) + if 'max_age_column' in required_fields or 'max_age_field' in required_fields: + self.maxAgeFieldLabel.setVisible(True) + self.maxAgeFieldComboBox.setVisible(True) + self.maxAgeFieldComboBox.setLayer(geology_layer) + if 'unitname1_column' in required_fields or 'unitname_1' in required_fields: + self.unitName1FieldLabel.setVisible(True) + self.unitName1FieldComboBox.setVisible(True) + self.unitName1FieldComboBox.setLayer(self.contactsLayerComboBox.currentLayer()) + if 'unitname2_column' in required_fields or 'unitname_2' in required_fields: + self.unitName2FieldLabel.setVisible(True) + self.unitName2FieldComboBox.setVisible(True) + self.unitName2FieldComboBox.setLayer(self.contactsLayerComboBox.currentLayer()) + + if 'contacts' in required_fields or 'contacts_layer' in required_fields: + self.contactsLayerLabel.setVisible(True) + self.contactsLayerComboBox.setVisible(True) + if 'structure' in required_fields or 'structure_layer' in required_fields: + self.structureLayerLabel.setVisible(True) + self.structureLayerComboBox.setVisible(True) + self.dipFieldLabel.setVisible(True) + self.dipFieldComboBox.setVisible(True) + self.dipDirFieldLabel.setVisible(True) + self.dipDirFieldComboBox.setVisible(True) + self.orientationTypeLabel.setVisible(True) + self.orientationTypeComboBox.setVisible(True) + self.dtmLayerLabel.setVisible(True) + self.dtmLayerComboBox.setVisible(True) + + # Optionally, handle any additional custom fields from map2loop + # (Add more widget visibility logic here if new fields are added in map2loop) + + 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.") + return + + if not self.contactsLayerComboBox.currentLayer(): + QMessageBox.warning(self, "Missing Input", "Please select a contacts layer.") + return + + algorithm_index = self.sortingAlgorithmComboBox.currentIndex() + algorithm_name = self.sorting_algorithms[algorithm_index] + is_observation_projections = algorithm_index == 5 + + if is_observation_projections: + if not self.structureLayerComboBox.currentLayer(): + QMessageBox.warning( + self, + "Missing Input", + "Structure layer is required for observation projections.", + ) + return + if not self.dtmLayerComboBox.currentLayer(): + QMessageBox.warning( + self, "Missing Input", "DTM layer is required for observation projections." + ) + return + + # Run the sorter API + try: + kwargs = { + 'geology': self.geologyLayerComboBox.currentLayer(), + 'contacts': self.contactsLayerComboBox.currentLayer(), + 'sorting_algorithm': algorithm_name, + 'unit_name_field': self.unitNameFieldComboBox.currentField(), + 'updater': lambda msg: QMessageBox.information(self, "Progress", msg), + } + + # Add optional fields + min_age_field = self.minAgeFieldComboBox.currentField() + if min_age_field: + kwargs['min_age_field'] = min_age_field + + max_age_field = self.maxAgeFieldComboBox.currentField() + if max_age_field: + kwargs['max_age_field'] = max_age_field + + if is_observation_projections: + kwargs['structure'] = self.structureLayerComboBox.currentLayer() + kwargs['dip_field'] = self.dipFieldComboBox.currentField() + kwargs['dipdir_field'] = self.dipDirFieldComboBox.currentField() + kwargs['orientation_type'] = self.orientation_types[ + self.orientationTypeComboBox.currentIndex() + ] + kwargs['dtm'] = self.dtmLayerComboBox.currentLayer() + + 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() + for unit in result: + self.data_manager.add_to_stratigraphic_column({'name': unit, 'type': 'unit'}) + self.data_manager.stratigraphic_column_callback() + QMessageBox.information( + self, + "Success", + f"Stratigraphic column created successfully! ({len(result)} units)", + ) + else: + 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)}") + + def get_parameters(self): + """Get current widget parameters. + + Returns + ------- + dict + Dictionary of current widget parameters. + """ + algorithm_index = self.sortingAlgorithmComboBox.currentIndex() + is_observation_projections = algorithm_index == 5 + + params = { + 'sorting_algorithm': algorithm_index, + 'geology_layer': self.geologyLayerComboBox.currentLayer(), + 'unit_name_field': self.unitNameFieldComboBox.currentField(), + 'min_age_field': self.minAgeFieldComboBox.currentField(), + 'max_age_field': self.maxAgeFieldComboBox.currentField(), + 'contacts_layer': self.contactsLayerComboBox.currentLayer(), + } + + if is_observation_projections: + params['structure_layer'] = self.structureLayerComboBox.currentLayer() + params['dip_field'] = self.dipFieldComboBox.currentField() + params['dipdir_field'] = self.dipDirFieldComboBox.currentField() + params['orientation_type'] = self.orientationTypeComboBox.currentIndex() + params['dtm_layer'] = self.dtmLayerComboBox.currentLayer() + + return params + + def set_parameters(self, params): + """Set widget parameters. + + Parameters + ---------- + params : dict + Dictionary of parameters to set. + """ + if 'sorting_algorithm' in params: + self.sortingAlgorithmComboBox.setCurrentIndex(params['sorting_algorithm']) + if 'geology_layer' in params and params['geology_layer']: + self.geologyLayerComboBox.setLayer(params['geology_layer']) + if 'contacts_layer' in params and params['contacts_layer']: + self.contactsLayerComboBox.setLayer(params['contacts_layer']) diff --git a/loopstructural/gui/map2loop_tools/sorter_widget.ui b/loopstructural/gui/map2loop_tools/sorter_widget.ui new file mode 100644 index 0000000..2a457a5 --- /dev/null +++ b/loopstructural/gui/map2loop_tools/sorter_widget.ui @@ -0,0 +1,197 @@ + + + SorterWidget + + + + 0 + 0 + 600 + 500 + + + + Automatic Stratigraphic Sorter + + + + + + + + Sorting Algorithm: + + + + + + + + + + Geology Layer: + + + + + + + + + + + Unit Name Field: + + + + + + + + + + Min Age Field: + + + + + + + + + + Max Age Field: + + + + + + + + + + Contacts Layer: + + + + + + + + + + + Structure Layer: + + + + + + + true + + + + + + + Dip Field: + + + + + + + + + + Dip Direction Field: + + + + + + + + + + Orientation Type: + + + + + + + + + + DTM Layer: + + + + + + + true + + + + + + + Unit Name 1 Field: + + + + + + + + + + Unit Name 2 Field: + + + + + + + + + + + + Run Sorter + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + QgsMapLayerComboBox + QComboBox +
qgsmaplayercombobox.h
+
+ + QgsFieldComboBox + QComboBox +
qgsfieldcombobox.h
+
+
+ + +
diff --git a/loopstructural/gui/map2loop_tools/thickness_calculator_widget.py b/loopstructural/gui/map2loop_tools/thickness_calculator_widget.py new file mode 100644 index 0000000..c95e788 --- /dev/null +++ b/loopstructural/gui/map2loop_tools/thickness_calculator_widget.py @@ -0,0 +1,377 @@ +"""Widget for thickness calculator.""" + +import os + +from PyQt5.QtWidgets import QLabel, QMessageBox, QWidget +from qgis.PyQt import uic +from qgis.core import QgsMapLayerProxyModel + + +from loopstructural.toolbelt.preferences import PlgOptionsManager + +from ...main.helpers import ColumnMatcher, get_layer_names +from ...main.vectorLayerWrapper import addGeoDataFrameToproject + + +class ThicknessCalculatorWidget(QWidget): + """Widget for configuring and running the thickness calculator. + + This widget provides a GUI interface for the map2loop thickness + calculation algorithms. + """ + + def __init__(self, parent=None, data_manager=None, debug_manager=None): + """Initialize the thickness calculator widget. + + Parameters + ---------- + parent : QWidget, optional + Parent widget. + data_manager : object, optional + Data manager for accessing shared data. + """ + 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") + uic.loadUi(ui_path, self) + + # Configure layer filters programmatically (avoid enum values in .ui) + + self.dtmLayerComboBox.setFilters(QgsMapLayerProxyModel.RasterLayer) + self.geologyLayerComboBox.setFilters(QgsMapLayerProxyModel.PolygonLayer) + self.basalContactsComboBox.setFilters(QgsMapLayerProxyModel.LineLayer) + self.sampledContactsComboBox.setFilters(QgsMapLayerProxyModel.PointLayer) + self.structureLayerComboBox.setFilters(QgsMapLayerProxyModel.PointLayer) + + # Initialize calculator types + self.calculator_types = ["InterpolatedStructure", "StructuralPoint"] + self.calculatorTypeComboBox.addItems(self.calculator_types) + + # Initialize orientation types + self.orientation_types = ['Dip Direction', 'Strike'] + self.orientationTypeComboBox.addItems(self.orientation_types) + + # Connect signals + self.calculatorTypeComboBox.currentIndexChanged.connect(self._on_calculator_type_changed) + self.geologyLayerComboBox.layerChanged.connect(self._on_geology_layer_changed) + self.structureLayerComboBox.layerChanged.connect(self._on_structure_layer_changed) + self.basalContactsComboBox.layerChanged.connect(self._on_basal_contacts_layer_changed) + self.runButton.clicked.connect(self._run_calculator) + self._guess_layers() + # Set up field combo boxes + self._setup_field_combo_boxes() + + # 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): + # Prefer using DebugManager.export_layer if available + try: + if getattr(self, '_debug', None) and hasattr(self._debug, 'export_layer'): + exported = self._debug.export_layer(layer, name_prefix) + return exported + + except Exception as err: + self._debug.plugin.log( + message=f"[map2loop] Failed to export layer '{name_prefix}': {err}", + log_level=2, + ) + 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): + 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) + + def _guess_layers(self): + """Attempt to auto-select layers based on common naming conventions.""" + if not self.data_manager: + return + + # Attempt to find geology layer + geology_layer_names = get_layer_names(self.geologyLayerComboBox) + geology_matcher = ColumnMatcher(geology_layer_names) + geology_layer_match = geology_matcher.find_match('GEOLOGY') + if geology_layer_match: + geology_layer = self.data_manager.find_layer_by_name(geology_layer_match) + self.geologyLayerComboBox.setLayer(geology_layer) + + # Attempt to find basal contacts layer + basal_contacts_names = get_layer_names(self.basalContactsComboBox) + basal_matcher = ColumnMatcher(basal_contacts_names) + basal_layer_match = basal_matcher.find_match('BASAL_CONTACTS') + if basal_layer_match: + basal_layer = self.data_manager.find_layer_by_name(basal_layer_match) + self.basalContactsComboBox.setLayer(basal_layer) + + # Attempt to find sampled contacts layer + sampled_contacts_names = get_layer_names(self.sampledContactsComboBox) + sampled_matcher = ColumnMatcher(sampled_contacts_names) + sampled_layer_match = sampled_matcher.find_match('SAMPLED_CONTACTS') + if sampled_layer_match: + sampled_layer = self.data_manager.find_layer_by_name(sampled_layer_match) + self.sampledContactsComboBox.setLayer(sampled_layer) + + # Attempt to find structure layer + structure_layer_names = get_layer_names(self.structureLayerComboBox) + structure_matcher = ColumnMatcher(structure_layer_names) + structure_layer_match = structure_matcher.find_match('STRUCTURE') + if structure_layer_match: + structure_layer = self.data_manager.find_layer_by_name(structure_layer_match) + self.structureLayerComboBox.setLayer(structure_layer) + + def _setup_field_combo_boxes(self): + """Set up field combo boxes to link to their respective layers.""" + self.unitNameFieldComboBox.setLayer(self.geologyLayerComboBox.currentLayer()) + self.dipFieldComboBox.setLayer(self.structureLayerComboBox.currentLayer()) + self.dipDirFieldComboBox.setLayer(self.structureLayerComboBox.currentLayer()) + self.basalUnitNameFieldComboBox.setLayer(self.basalContactsComboBox.currentLayer()) + + def _on_basal_contacts_layer_changed(self): + """Update field combo box when basal contacts layer changes.""" + layer = self.basalContactsComboBox.currentLayer() + self.basalUnitNameFieldComboBox.setLayer(layer) + # Optionally auto-select a likely unit name field + if layer: + fields = [field.name() for field in layer.fields()] + matcher = ColumnMatcher(fields) + if unit_match := matcher.find_match('UNITNAME'): + self.basalUnitNameFieldComboBox.setField(unit_match) + + def _on_geology_layer_changed(self): + """Update field combo boxes when geology layer changes.""" + + layer = self.geologyLayerComboBox.currentLayer() + self.unitNameFieldComboBox.setLayer(layer) + + # Auto-detect appropriate fields + if layer: + fields = [field.name() for field in layer.fields()] + matcher = ColumnMatcher(fields) + + # Auto-select UNITNAME field + if unit_match := matcher.find_match('UNITNAME'): + self.unitNameFieldComboBox.setField(unit_match) + + def _on_structure_layer_changed(self): + """Update field combo boxes when structure layer changes.""" + + layer = self.structureLayerComboBox.currentLayer() + self.dipFieldComboBox.setLayer(layer) + self.dipDirFieldComboBox.setLayer(layer) + + # Auto-detect appropriate fields + if layer: + fields = [field.name() for field in layer.fields()] + matcher = ColumnMatcher(fields) + + # Auto-select DIP and DIPDIR fields + if dip_match := matcher.find_match('DIP'): + self.dipFieldComboBox.setField(dip_match) + + if dipdir_match := matcher.find_match('DIPDIR'): + self.dipDirFieldComboBox.setField(dipdir_match) + + def _on_calculator_type_changed(self): + """Update UI based on selected calculator type.""" + calculator_type = self.calculatorTypeComboBox.currentText() + + if calculator_type == "StructuralPoint": + self.maxLineLengthLabel.setVisible(True) + self.maxLineLengthSpinBox.setVisible(True) + else: # InterpolatedStructure + self.maxLineLengthLabel.setVisible(False) + self.maxLineLengthSpinBox.setVisible(False) + + 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.") + return + + if not self.basalContactsComboBox.currentLayer(): + QMessageBox.warning(self, "Missing Input", "Please select a basal contacts layer.") + return + + if not self.sampledContactsComboBox.currentLayer(): + QMessageBox.warning(self, "Missing Input", "Please select a sampled contacts layer.") + return + + if not self.structureLayerComboBox.currentLayer(): + QMessageBox.warning( + self, "Missing Input", "Please select a structure/orientation layer." + ) + return + + calculator_type = self.calculatorTypeComboBox.currentText() + + # Prepare parameters + try: + kwargs = { + 'geology': self.geologyLayerComboBox.currentLayer(), + 'basal_contacts': self.basalContactsComboBox.currentLayer(), + 'sampled_contacts': self.sampledContactsComboBox.currentLayer(), + 'structure': self.structureLayerComboBox.currentLayer(), + 'calculator_type': calculator_type, + 'unit_name_field': self.unitNameFieldComboBox.currentField(), + 'basal_contacts_unit_name': self.basalUnitNameFieldComboBox.currentField(), + 'dip_field': self.dipFieldComboBox.currentField(), + 'dipdir_field': self.dipDirFieldComboBox.currentField(), + 'orientation_type': self.orientationTypeComboBox.currentText(), + 'updater': lambda msg: QMessageBox.information(self, "Progress", msg), + 'stratigraphic_order': ( + self.data_manager.get_stratigraphic_unit_names() if self.data_manager else [] + ), + } + + # Add optional parameters + if self.dtmLayerComboBox.currentLayer(): + kwargs['dtm'] = self.dtmLayerComboBox.currentLayer() + + if calculator_type == "StructuralPoint": + kwargs['max_line_length'] = self.maxLineLengthSpinBox.value() + + # Get stratigraphic order from data_manager + if self.data_manager and hasattr(self.data_manager, 'stratigraphic_column'): + strati_order = [unit['name'] for unit in self.data_manager._stratigraphic_column] + if strati_order: + kwargs['stratigraphic_order'] = strati_order + + 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: + 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: + if result['lines'] is not None and not result['lines'].empty: + addGeoDataFrameToproject(result['lines'], "Lines") + if 'location_tracking' in result: + if ( + result['location_tracking'] is not None + and not result['location_tracking'].empty + ): + addGeoDataFrameToproject( + result['location_tracking'], "Thickness Location Tracking" + ) + if result is not None and not result['thicknesses'].empty: + QMessageBox.information( + self, + "Success", + f"Thickness calculation completed successfully! ({len(result)} records)", + ) + else: + 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)}") + + def get_parameters(self): + """Get current widget parameters. + + Returns + ------- + dict + Dictionary of current widget parameters. + """ + return { + 'calculator_type': self.calculatorTypeComboBox.currentIndex(), + 'dtm_layer': self.dtmLayerComboBox.currentLayer(), + 'geology_layer': self.geologyLayerComboBox.currentLayer(), + 'unit_name_field': self.unitNameFieldComboBox.currentField(), + 'basal_contacts': self.basalContactsComboBox.currentLayer(), + 'sampled_contacts': self.sampledContactsComboBox.currentLayer(), + 'structure_layer': self.structureLayerComboBox.currentLayer(), + 'dip_field': self.dipFieldComboBox.currentField(), + 'dipdir_field': self.dipDirFieldComboBox.currentField(), + 'orientation_type': self.orientationTypeComboBox.currentIndex(), + 'max_line_length': self.maxLineLengthSpinBox.value(), + } + + def set_parameters(self, params): + """Set widget parameters. + + Parameters + ---------- + params : dict + Dictionary of parameters to set. + """ + if 'calculator_type' in params: + self.calculatorTypeComboBox.setCurrentIndex(params['calculator_type']) + if 'dtm_layer' in params and params['dtm_layer']: + self.dtmLayerComboBox.setLayer(params['dtm_layer']) + if 'geology_layer' in params and params['geology_layer']: + self.geologyLayerComboBox.setLayer(params['geology_layer']) + if 'basal_contacts' in params and params['basal_contacts']: + self.basalContactsComboBox.setLayer(params['basal_contacts']) + if 'sampled_contacts' in params and params['sampled_contacts']: + self.sampledContactsComboBox.setLayer(params['sampled_contacts']) + if 'structure_layer' in params and params['structure_layer']: + self.structureLayerComboBox.setLayer(params['structure_layer']) + if 'orientation_type' in params: + self.orientationTypeComboBox.setCurrentIndex(params['orientation_type']) + if 'max_line_length' in params: + self.maxLineLengthSpinBox.setValue(params['max_line_length']) diff --git a/loopstructural/gui/map2loop_tools/thickness_calculator_widget.ui b/loopstructural/gui/map2loop_tools/thickness_calculator_widget.ui new file mode 100644 index 0000000..63d978e --- /dev/null +++ b/loopstructural/gui/map2loop_tools/thickness_calculator_widget.ui @@ -0,0 +1,207 @@ + + + ThicknessCalculatorWidget + + + + 0 + 0 + 600 + 700 + + + + Thickness Calculator + + + + + + + + Calculator Type: + + + + + + + + + + DTM Layer: + + + + + + + true + + + + + + + Geology Layer: + + + + + + + + + + Unit Name Field: + + + + + + + + + + Basal Contacts Layer: + + + + + + + + + + Sampled Contacts Layer: + + + + + + + + + + Structure/Orientation Layer: + + + + + + + + + + Dip Field: + + + + + + + + + + Dip Direction Field: + + + + + + + + + + Orientation Type: + + + + + + + + + + Max Line Length: + + + + + + + 1000000.000000000000000 + + + 1000.000000000000000 + + + + + + + Save Debug Files: + + + + + + + Save debugging files (location tracking, lines) + + + + + + + Basal Contact Unitname: + + + + + + + + + + + + Calculate Thickness + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + QgsDoubleSpinBox + QDoubleSpinBox +
qgsdoublespinbox.h
+
+ + QgsFieldComboBox + QComboBox +
qgsfieldcombobox.h
+
+ + QgsMapLayerComboBox + QComboBox +
qgsmaplayercombobox.h
+
+
+ + +
diff --git a/loopstructural/gui/map2loop_tools/user_defined_sorter_widget.py b/loopstructural/gui/map2loop_tools/user_defined_sorter_widget.py new file mode 100644 index 0000000..8118487 --- /dev/null +++ b/loopstructural/gui/map2loop_tools/user_defined_sorter_widget.py @@ -0,0 +1,160 @@ +"""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 + + +class UserDefinedSorterWidget(QWidget): + """Widget for creating a user-defined stratigraphic column. + + This widget uses the LoopStructural StratigraphicColumn widget + and links it to the data manager for integration with the model. + """ + + def __init__(self, parent=None, data_manager=None, debug_manager=None): + """Initialize the user-defined sorter widget. + + Parameters + ---------- + parent : QWidget, optional + Parent widget. + data_manager : object, optional + Data manager for accessing shared data. + """ + super().__init__(parent) + + 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 + + # Create and add the StratigraphicColumn widget to the UI + self.strat_column_widget = StratColumnWidget(parent=self, data_manager=self.data_manager) + + # Add the stratigraphic column widget to the UI layout + # Assuming the UI has a placeholder widget or layout for this + if hasattr(self, 'stratiColumnWidget'): + # If the UI has a widget called stratiColumnWidget, use its layout + layout = self.stratiColumnWidget.layout() + if layout is None: + layout = QVBoxLayout(self.stratiColumnWidget) + layout.addWidget(self.strat_column_widget) + else: + # Otherwise, add it to the main layout + main_layout = self.layout() + if main_layout is 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): + self._debug.log_params(context_label=context_label, params=params) + + def _run_sorter(self): + """Run the user-defined stratigraphic sorter algorithm. + + This method will use the stratigraphic column from the StratColumnWidget + that is already linked to the data manager, ensuring the model is updated. + """ + from qgis import processing + from qgis.core import QgsProcessingFeedback + + # 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( + self, "Missing Input", "Please define at least one stratigraphic unit." + ) + return + + # Prepare parameters + params = { + 'INPUT_STRATI_COLUMN': strati_column, + 'OUTPUT': 'TEMPORARY_OUTPUT', + } + + # Run the algorithm + try: + 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!" + ) + else: + QMessageBox.warning( + self, "Error", "Failed to create user-defined stratigraphic column." + ) + + 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): + """Get the current stratigraphic column from the data manager. + + Returns + ------- + list + List of unit names from youngest to oldest. + """ + if hasattr(self, 'data_manager') and self.data_manager is not None: + strati_column = self.data_manager.get_stratigraphic_column() + # Extract unit names in order + unit_names = [] + for element in strati_column.order: + if hasattr(element, 'name'): + unit_names.append(element.name) + return unit_names + return [] + + def set_stratigraphic_column(self, units): + """Set the stratigraphic column in the data manager. + + Parameters + ---------- + units : list + List of unit names from youngest to oldest. + """ + if not hasattr(self, 'data_manager') or self.data_manager is None: + raise ValueError("data_manager is not initialized") + + # Clear the existing column + self.data_manager._stratigraphic_column.clear() + + # Add each unit to the stratigraphic column + for unit_name in units: + self.data_manager.add_to_stratigraphic_column( + {'type': 'unit', 'name': unit_name, 'colour': None} + ) + + # The callback is already called by add_to_stratigraphic_column + # But we still update the display to be safe + if hasattr(self, 'strat_column_widget'): + self.strat_column_widget.update_display() diff --git a/loopstructural/gui/map2loop_tools/user_defined_sorter_widget.ui b/loopstructural/gui/map2loop_tools/user_defined_sorter_widget.ui new file mode 100644 index 0000000..f6b9f29 --- /dev/null +++ b/loopstructural/gui/map2loop_tools/user_defined_sorter_widget.ui @@ -0,0 +1,31 @@ + + + UserDefinedSorterWidget + + + + 0 + 0 + 600 + 400 + + + + User-Defined Stratigraphic Column + + + + + + Define the stratigraphic order from youngest (top) to oldest (bottom): + + + true + + + + + + + + diff --git a/loopstructural/gui/modelling/geological_model_tab/add_foliation_dialog.py b/loopstructural/gui/modelling/geological_model_tab/add_foliation_dialog.py index f0fa0ed..504b36c 100644 --- a/loopstructural/gui/modelling/geological_model_tab/add_foliation_dialog.py +++ b/loopstructural/gui/modelling/geological_model_tab/add_foliation_dialog.py @@ -14,21 +14,21 @@ def __init__(self, parent=None, *, data_manager=None, model_manager=None): ui_path = os.path.join(os.path.dirname(__file__), 'add_foliation_dialog.ui') loadUi(ui_path, self) self.setWindowTitle('Add Foliation') - + # Create the layer selection table widget self.layer_table = LayerSelectionTable( data_manager=self.data_manager, feature_name_provider=lambda: self.name, name_validator=lambda: (self.name_valid, self.name_error) ) - + # Replace or integrate with existing UI self._integrate_layer_table() - + self.buttonBox.accepted.connect(self.add_foliation) self.buttonBox.rejected.connect(self.cancel) - + self.name_valid = False self.name_error = "" @@ -37,7 +37,7 @@ def validate_name_field(text): valid = True old_name = self.name new_name = text.strip() - + if not new_name: valid = False self.name_error = "Feature name cannot be empty." @@ -62,12 +62,12 @@ def validate_name_field(text): if old_name != new_name and old_name in self.data_manager.feature_data: # Save current table data old_data = self.layer_table.get_table_data() - + # Remove old key and set new key self.data_manager.feature_data.pop(old_name, None) if new_name and valid: self.data_manager.feature_data[new_name] = old_data - + # Update table to reflect new feature name self.layer_table.initialize_feature_data() self.layer_table.restore_table_state() @@ -82,17 +82,17 @@ def add_foliation(self): if not self.name_valid: self.data_manager.logger(f'Name is invalid: {self.name_error}', log_level=2) return - + # Ensure table state is synchronized with data manager self.layer_table.sync_table_with_data() - + # Check if we have any layers selected if not self.layer_table.has_layers(): self.data_manager.logger("No layers selected for the foliation.", log_level=2) return - + folded_feature_name = None - + self.data_manager.add_foliation_to_model(self.name, folded_feature_name=folded_feature_name) self.accept() # Close the dialog @@ -108,11 +108,11 @@ def _integrate_layer_table(self): # Try to replace existing table widget if it exists if hasattr(self, 'items_table'): table_parent = self.items_table.parent() - + # Get the position of the original table if hasattr(table_parent, 'layout') and table_parent.layout(): layout = table_parent.layout() - + # Find the index of the original table in the layout table_index = -1 for i in range(layout.count()): @@ -120,13 +120,13 @@ def _integrate_layer_table(self): if item and item.widget() == self.items_table: table_index = i break - + # Remove original widgets if hasattr(self, 'items_table'): self.items_table.setParent(None) if hasattr(self, 'add_item_button'): self.add_item_button.setParent(None) - + # Insert new widget at the same position if table_index >= 0: layout.insertWidget(table_index, self.layer_table) @@ -139,7 +139,7 @@ def _integrate_layer_table(self): layout = QVBoxLayout(table_parent) table_parent.setLayout(layout) layout.addWidget(self.layer_table) - + # If no existing table found, try to add to main layout elif hasattr(self, 'layout') and self.layout(): - self.layout().addWidget(self.layer_table) \ No newline at end of file + self.layout().addWidget(self.layer_table) diff --git a/loopstructural/gui/modelling/geological_model_tab/add_unconformity_dialog.py b/loopstructural/gui/modelling/geological_model_tab/add_unconformity_dialog.py index 34aafce..6bd683f 100644 --- a/loopstructural/gui/modelling/geological_model_tab/add_unconformity_dialog.py +++ b/loopstructural/gui/modelling/geological_model_tab/add_unconformity_dialog.py @@ -65,4 +65,4 @@ def accept(self): QMessageBox.critical(self, "Error", str(e)) def reject(self): - super().reject() \ No newline at end of file + super().reject() diff --git a/loopstructural/gui/modelling/geological_model_tab/feature_details_panel.py b/loopstructural/gui/modelling/geological_model_tab/feature_details_panel.py index bdc3bfa..446d6dd 100644 --- a/loopstructural/gui/modelling/geological_model_tab/feature_details_panel.py +++ b/loopstructural/gui/modelling/geological_model_tab/feature_details_panel.py @@ -1,4 +1,4 @@ -from PyQt5.QtCore import Qt +from PyQt5.QtCore import Qt, QTimer from PyQt5.QtWidgets import ( QCheckBox, QComboBox, @@ -10,16 +10,24 @@ QVBoxLayout, QWidget, ) -from qgis.gui import QgsMapLayerComboBox, QgsCollapsibleGroupBox - +from qgis.gui import QgsCollapsibleGroupBox, QgsMapLayerComboBox from qgis.utils import plugins + +from LoopStructural import getLogger +from LoopStructural.modelling.features import StructuralFrame +from LoopStructural.utils import ( + normal_vector_to_strike_and_dip, + plungeazimuth2vector, + strikedip2vector, +) + +from .bounding_box_widget import BoundingBoxWidget from .layer_selection_table import LayerSelectionTable from .splot import SPlotDialog -from .bounding_box_widget import BoundingBoxWidget -from LoopStructural.modelling.features import StructuralFrame -from LoopStructural.utils import normal_vector_to_strike_and_dip, plungeazimuth2vector -from LoopStructural import getLogger + logger = getLogger(__name__) + + class BaseFeatureDetailsPanel(QWidget): def __init__(self, parent=None, *, feature=None, model_manager=None, data_manager=None): super().__init__(parent) @@ -47,7 +55,13 @@ def __init__(self, parent=None, *, feature=None, model_manager=None, data_manage # Set the main layout self.setLayout(mainLayout) - + # Debounce timer for rebuilds: schedule a single rebuild after user stops + # interacting for a short interval to avoid repeated expensive builds. + self._rebuild_timer = QTimer(self) + self._rebuild_timer.setSingleShot(True) + self._rebuild_timer.setInterval(500) # milliseconds; adjust as desired + self._rebuild_timer.timeout.connect(self._perform_rebuild) + ## define interpolator parameters # Regularisation spin box self.regularisation_spin_box = QDoubleSpinBox() @@ -55,21 +69,31 @@ def __init__(self, parent=None, *, feature=None, model_manager=None, data_manage self.regularisation_spin_box.setValue( feature.builder.build_arguments.get('regularisation', 1.0) ) + # Update build arguments and schedule a debounced rebuild self.regularisation_spin_box.valueChanged.connect( - lambda value: self.feature.builder.update_build_arguments({'regularisation': value}) + lambda value: ( + self.feature.builder.update_build_arguments({'regularisation': value}), + self.schedule_rebuild(), + ) ) self.cpw_spin_box = QDoubleSpinBox() self.cpw_spin_box.setRange(0, 100) self.cpw_spin_box.setValue(feature.builder.build_arguments.get('cpw', 1.0)) self.cpw_spin_box.valueChanged.connect( - lambda value: self.feature.builder.update_build_arguments({'cpw': value}) + lambda value: ( + self.feature.builder.update_build_arguments({'cpw': value}), + self.schedule_rebuild(), + ) ) self.npw_spin_box = QDoubleSpinBox() self.npw_spin_box.setRange(0, 100) self.npw_spin_box.setValue(feature.builder.build_arguments.get('npw', 1.0)) self.npw_spin_box.valueChanged.connect( - lambda value: self.feature.builder.update_build_arguments({'npw': value}) + lambda value: ( + self.feature.builder.update_build_arguments({'npw': value}), + self.schedule_rebuild(), + ) ) self.interpolator_type_label = QLabel("Interpolator Type:") self.interpolator_type_combo = QComboBox() @@ -102,8 +126,10 @@ def __init__(self, parent=None, *, feature=None, model_manager=None, data_manage group_box.setLayout(form_layout) self.layout.addWidget(group_box) self.layout.addWidget(table_group_box) + # this will call the addMidBlock and addExportBlock methods self.addMidBlock() self.addExportBlock() + def addMidBlock(self): """Base mid block is intentionally empty now — bounding-box controls were moved into the export/evaluation section so they appear alongside @@ -120,7 +146,9 @@ def addExportBlock(self): self.export_eval_layout.setSpacing(6) # --- Bounding box controls (moved here into dedicated widget) --- - bb_widget = BoundingBoxWidget(parent=self, model_manager=self.model_manager, data_manager=self.data_manager) + bb_widget = BoundingBoxWidget( + parent=self, model_manager=self.model_manager, data_manager=self.data_manager + ) # keep reference so export handlers can use it self.bounding_box_widget = bb_widget self.export_eval_layout.addWidget(bb_widget) @@ -142,7 +170,9 @@ def addExportBlock(self): # Evaluate target: bounding-box centres or project point layer self.evaluate_target_combo = QComboBox() - self.evaluate_target_combo.addItems(["Bounding box cell centres", "Project point layer","Viewer Object"]) + self.evaluate_target_combo.addItems( + ["Bounding box cell centres", "Project point layer", "Viewer Object"] + ) export_layout.addRow("Evaluate on:", self.evaluate_target_combo) # Project layer selector (populated with point vector layers from project) @@ -160,10 +190,11 @@ def addExportBlock(self): lbl = export_layout.labelForField(self.meshObjectCombo) if lbl is not None: lbl.setVisible(False) + # Connect evaluate target change to enable/disable project layer combo def _on_evaluate_target_changed(index): - use_project = (index == 1) - use_vtk = (index == 2) + use_project = index == 1 + use_vtk = index == 2 self.project_layer_combo.setVisible(use_project) self.project_layer_combo.setEnabled(use_project) self.meshObjectCombo.setVisible(use_vtk) @@ -182,13 +213,8 @@ def _on_evaluate_target_changed(index): viewer = self.plugin.loop_widget.visualisation_widget.plotter mesh_names = list(viewer.meshes.keys()) self.meshObjectCombo.addItems(mesh_names) - self.evaluate_target_combo.currentIndexChanged.connect(_on_evaluate_target_changed) - - - - - + self.evaluate_target_combo.currentIndexChanged.connect(_on_evaluate_target_changed) # Export button self.export_points_button = QPushButton("Export to QGIS points") @@ -204,18 +230,18 @@ def _on_evaluate_target_changed(index): # These blocks are intentionally minimal now (only a disabled label) and # will be populated with export/evaluate controls later. if self.model_manager is not None: - for feat in self.model_manager.features(): + for feat in self.model_manager.features(): block = QWidget() block.setObjectName(f"export_block_{getattr(feat, 'name', 'feature')}") block_layout = QVBoxLayout(block) block_layout.setContentsMargins(0, 0, 0, 0) self.export_eval_layout.addWidget(block) - self.export_blocks[getattr(feat, 'name', f"feature_{len(self.export_blocks)}")] = block + self.export_blocks[getattr(feat, 'name', f"feature_{len(self.export_blocks)}")] = ( + block + ) self.layout.addWidget(self.export_eval_container) - - def _on_bounding_box_updated(self, bounding_box): """Callback to update UI widgets when bounding box object changes externally. @@ -237,21 +263,29 @@ def _on_bounding_box_updated(self, bounding_box): pass try: - if getattr(bounding_box, 'nelements', None) is not None and hasattr(self, 'bb_nelements_spinbox'): + if getattr(bounding_box, 'nelements', None) is not None and hasattr( + self, 'bb_nelements_spinbox' + ): try: self.bb_nelements_spinbox.setValue(int(getattr(bounding_box, 'nelements'))) except Exception: try: self.bb_nelements_spinbox.setValue(getattr(bounding_box, 'nelements')) except Exception: - logger.debug('Could not set nelements spinbox from bounding_box', exc_info=True) + logger.debug( + 'Could not set nelements spinbox from bounding_box', exc_info=True + ) if getattr(bounding_box, 'nsteps', None) is not None: try: nsteps = list(bounding_box.nsteps) except Exception: try: - nsteps = [int(bounding_box.nsteps[0]), int(bounding_box.nsteps[1]), int(bounding_box.nsteps[2])] + nsteps = [ + int(bounding_box.nsteps[0]), + int(bounding_box.nsteps[1]), + int(bounding_box.nsteps[2]), + ] except Exception: nsteps = None if nsteps is not None: @@ -263,7 +297,9 @@ def _on_bounding_box_updated(self, bounding_box): if hasattr(self, 'bb_nsteps_z'): self.bb_nsteps_z.setValue(int(nsteps[2])) except Exception: - logger.debug('Could not set nsteps spinboxes from bounding_box', exc_info=True) + logger.debug( + 'Could not set nsteps spinboxes from bounding_box', exc_info=True + ) finally: # Unblock signals @@ -281,12 +317,14 @@ def updateNelements(self, value): if self.feature[i].interpolator is not None: self.feature[i].interpolator.nelements = value self.feature[i].builder.update_build_arguments({'nelements': value}) - self.feature[i].builder.build() + # schedule a single debounced rebuild after user stops changing value + self.schedule_rebuild() elif self.feature.interpolator is not None: self.feature.interpolator.nelements = value self.feature.builder.update_build_arguments({'nelements': value}) - self.feature.builder.build() + # schedule a debounced rebuild instead of building immediately + self.schedule_rebuild() else: print("Error: Feature is not initialized.") @@ -298,6 +336,7 @@ def getNelements(self, feature): elif feature.interpolator is not None: return feature.interpolator.n_elements return 1000 + def _export_scalar_points(self): """Gather points (bounding-box centres or project point layer), evaluate feature values using the model_manager and add the resulting GeoDataFrame as a memory layer to the @@ -306,7 +345,11 @@ def _export_scalar_points(self): """ # determine scalar type logger.info('Exporting scalar points') - scalar_type = self.scalar_field_combo.currentText() if hasattr(self, 'scalar_field_combo') else 'scalar' + scalar_type = ( + self.scalar_field_combo.currentText() + if hasattr(self, 'scalar_field_combo') + else 'scalar' + ) # gather points pts = None @@ -314,7 +357,7 @@ def _export_scalar_points(self): crs = self.data_manager.project.crs().authid() try: # QGIS imports (guarded) - from qgis.core import QgsProject, QgsVectorLayer, QgsFeature, QgsPoint, QgsField + from qgis.core import QgsFeature, QgsField, QgsPoint, QgsProject, QgsVectorLayer from qgis.PyQt.QtCore import QVariant except Exception as e: # Not running inside QGIS — nothing to do @@ -326,10 +369,7 @@ def _export_scalar_points(self): if self.evaluate_target_combo.currentIndex() == 0: # use bounding-box resolution or custom nsteps logger.info('Using bounding box cell centres for evaluation') - - - pts = self.model_manager.model.bounding_box.cell_centres() # no extra attributes for grid attributes_df = None @@ -364,13 +404,21 @@ def _export_scalar_points(self): try: z = p.z() except Exception: - z = self.model_manager.dem_function(x, y) if hasattr(self.model_manager, 'dem_function') else 0 + z = ( + self.model_manager.dem_function(x, y) + if hasattr(self.model_manager, 'dem_function') + else 0 + ) except Exception: # fallback to centroid try: c = geom.centroid().asPoint() x, y = c.x(), c.y() - z = self.model_manager.dem_function(x, y) if hasattr(self.model_manager, 'dem_function') else 0 + z = ( + self.model_manager.dem_function(x, y) + if hasattr(self.model_manager, 'dem_function') + else 0 + ) except Exception: continue pts_list.append((x, y, z)) @@ -385,6 +433,7 @@ def _export_scalar_points(self): if len(pts_list) == 0: return import pandas as _pd + pts = _pd.DataFrame(pts_list).values try: attributes_df = _pd.DataFrame(attrs) @@ -396,7 +445,7 @@ def _export_scalar_points(self): crs = None elif self.evaluate_target_combo.currentIndex() == 2: # Evaluate on an object from the viewer - # These are all pyvista objects and we want to add + # These are all pyvista objects and we want to add # the scalar as a new field to the objects viewer = self.plugin.loop_widget.visualisation_widget.plotter @@ -407,9 +456,7 @@ def _export_scalar_points(self): return vtk_mesh = viewer.meshes[mesh]['mesh'] self.model_manager.export_feature_values_to_vtk_mesh( - self.feature.name, - vtk_mesh, - scalar_type=scalar_type + self.feature.name, vtk_mesh, scalar_type=scalar_type ) # call model_manager to produce GeoDataFrame try: @@ -494,14 +541,45 @@ def _export_scalar_points(self): mem_layer.updateExtents() QgsProject.instance().addMapLayer(mem_layer) + def schedule_rebuild(self, delay_ms: int = 500): + """Schedule a debounced rebuild of the current feature. + + Multiple calls will reset the timer so only a single rebuild occurs + after user activity has settled. + """ + try: + if self._rebuild_timer is None: + return + self._rebuild_timer.stop() + self._rebuild_timer.setInterval(delay_ms) + self._rebuild_timer.start() + except Exception: + logger.debug('Failed to schedule debounced rebuild', exc_info=True) + pass + + def _perform_rebuild(self): + """Perform the actual build operation when the debounce timer fires.""" + try: + if not hasattr(self, 'feature') or self.feature is None: + return + # StructuralFrame consists of three sub-features + self.model_manager.update_feature(self.feature.name) + + except Exception: + logger.debug('Debounced rebuild failed', exc_info=True) + + class FaultFeatureDetailsPanel(BaseFeatureDetailsPanel): def __init__(self, parent=None, *, fault=None, model_manager=None, data_manager=None): - super().__init__(parent, feature=fault, model_manager=model_manager, data_manager=data_manager) + super().__init__( + parent, feature=fault, model_manager=model_manager, data_manager=data_manager + ) if fault is None: raise ValueError("Fault must be provided.") self.fault = fault - dip = normal_vector_to_strike_and_dip(fault.fault_normal_vector)[0, 0] + dip = normal_vector_to_strike_and_dip(fault.fault_normal_vector)[0, 1] + pitch = 0 self.fault_parameters = { 'displacement': fault.displacement, @@ -513,76 +591,90 @@ def __init__(self, parent=None, *, fault=None, model_manager=None, data_manager= # 'enabled': fault.fault_enabled } - # # Fault displacement slider - # self.displacement_spinbox = QDoubleSpinBox() - # self.displacement_spinbox.setRange(0, 1000000) # Example range - # self.displacement_spinbox.setValue(self.fault.displacement) - # self.displacement_spinbox.valueChanged.connect( - # lambda value: self.fault_parameters.__setitem__('displacement', value) - # ) + def update_displacement(value): + self.fault.displacement = value + + def update_major_axis(value): + self.fault.fault_major_axis = value + # schedule a debounced rebuild so multiple rapid edits are coalesced + self.schedule_rebuild() + + def update_minor_axis(value): + self.fault.fault_minor_axis = value + self.schedule_rebuild() + + def update_intermediate_axis(value): + self.fault.fault_intermediate_axis = value + self.schedule_rebuild() + + def update_dip(value): + strike = normal_vector_to_strike_and_dip(self.fault.fault_normal_vector)[0, 0] + self.fault.builder.fault_normal_vector = strikedip2vector([strike], [value])[0] + self.schedule_rebuild() + + # Fault displacement slider + self.displacement_spinbox = QDoubleSpinBox() + self.displacement_spinbox.setRange(0, 1000000) # Example range + self.displacement_spinbox.setValue(self.fault.displacement) + self.displacement_spinbox.valueChanged.connect(update_displacement) + + # Fault axis lengths + self.major_axis_spinbox = QDoubleSpinBox() + self.major_axis_spinbox.setRange(0, float('inf')) + self.major_axis_spinbox.setValue(self.fault.fault_major_axis) + # self.major_axis_spinbox.setPrefix("Major Axis Length: ") + self.major_axis_spinbox.valueChanged.connect(update_major_axis) + self.minor_axis_spinbox = QDoubleSpinBox() + self.minor_axis_spinbox.setRange(0, float('inf')) + self.minor_axis_spinbox.setValue(self.fault.fault_minor_axis) + # self.minor_axis_spinbox.setPrefix("Minor Axis Length: ") + self.minor_axis_spinbox.valueChanged.connect(update_minor_axis) + self.intermediate_axis_spinbox = QDoubleSpinBox() + self.intermediate_axis_spinbox.setRange(0, float('inf')) + self.intermediate_axis_spinbox.setValue(fault.fault_intermediate_axis) + self.intermediate_axis_spinbox.valueChanged.connect(update_intermediate_axis) + # self.intermediate_axis_spinbox.setPrefix("Intermediate Axis Length: ") + + # Fault dip field + self.dip_spinbox = QDoubleSpinBox() + self.dip_spinbox.setRange(0, 90) # Dip angle range + self.dip_spinbox.setValue(dip) + # self.dip_spinbox.setPrefix("Fault Dip: ") + self.dip_spinbox.valueChanged.connect(update_dip) + self.pitch_spinbox = QDoubleSpinBox() + self.pitch_spinbox.setRange(0, 180) + self.pitch_spinbox.setValue(self.fault_parameters['pitch']) + self.pitch_spinbox.valueChanged.connect( + lambda value: self.fault_parameters.__setitem__('pitch', value) + ) + # self.dip_spinbox.valueChanged.connect( - # # Fault axis lengths - # self.major_axis_spinbox = QDoubleSpinBox() - # self.major_axis_spinbox.setRange(0, float('inf')) - # self.major_axis_spinbox.setValue(self.fault_parameters['major_axis_length']) - # # self.major_axis_spinbox.setPrefix("Major Axis Length: ") - # self.major_axis_spinbox.valueChanged.connect( - # lambda value: self.fault_parameters.__setitem__('major_axis_length', value) - # ) - # self.minor_axis_spinbox = QDoubleSpinBox() - # self.minor_axis_spinbox.setRange(0, float('inf')) - # self.minor_axis_spinbox.setValue(self.fault_parameters['minor_axis_length']) - # # self.minor_axis_spinbox.setPrefix("Minor Axis Length: ") - # self.minor_axis_spinbox.valueChanged.connect( - # lambda value: self.fault_parameters.__setitem__('minor_axis_length', value) - # ) - # self.intermediate_axis_spinbox = QDoubleSpinBox() - # self.intermediate_axis_spinbox.setRange(0, float('inf')) - # self.intermediate_axis_spinbox.setValue(self.fault_parameters['intermediate_axis_length']) - # self.intermediate_axis_spinbox.valueChanged.connect( - # lambda value: self.fault_parameters.__setitem__('intermediate_axis_length', value) - # ) - # # self.intermediate_axis_spinbox.setPrefix("Intermediate Axis Length: ") + # Enabled field + # self.enabled_checkbox = QCheckBox("Enabled") + # self.enabled_checkbox.setChecked(False) - # # Fault dip field - # self.dip_spinbox = QDoubleSpinBox() - # self.dip_spinbox.setRange(0, 90) # Dip angle range - # self.dip_spinbox.setValue(self.fault_parameters['dip']) - # # self.dip_spinbox.setPrefix("Fault Dip: ") - # self.dip_spinbox.valueChanged.connect( - # lambda value: self.fault_parameters.__setitem__('dip', value) - # ) - # self.pitch_spinbox = QDoubleSpinBox() - # self.pitch_spinbox.setRange(0, 180) - # self.pitch_spinbox.setValue(self.fault_parameters['pitch']) - # self.pitch_spinbox.valueChanged.connect( - # lambda value: self.fault_parameters.__setitem__('pitch', value) - # ) - # # self.dip_spinbox.valueChanged.connect( - - # # Enabled field - # # self.enabled_checkbox = QCheckBox("Enabled") - # # self.enabled_checkbox.setChecked(False) - - # # Form layout for better organization - # form_layout = QFormLayout() - # form_layout.addRow("Fault displacement", self.displacement_spinbox) - # form_layout.addRow("Major Axis Length", self.major_axis_spinbox) - # form_layout.addRow("Minor Axis Length", self.minor_axis_spinbox) - # form_layout.addRow("Intermediate Axis Length", self.intermediate_axis_spinbox) - # form_layout.addRow("Fault Dip", self.dip_spinbox) - # # form_layout.addRow("Enabled:", self.enabled_checkbox) - - # self.layout.addLayout(form_layout) - # self.setLayout(self.layout) + # Form layout for better organization + form_layout = QFormLayout() + form_layout.addRow("Fault displacement", self.displacement_spinbox) + form_layout.addRow("Major Axis Length", self.major_axis_spinbox) + form_layout.addRow("Minor Axis Length", self.minor_axis_spinbox) + form_layout.addRow("Intermediate Axis Length", self.intermediate_axis_spinbox) + form_layout.addRow("Fault Dip", self.dip_spinbox) + # form_layout.addRow("Enabled:", self.enabled_checkbox) + + self.layout.addLayout(form_layout) + self.setLayout(self.layout) class FoliationFeatureDetailsPanel(BaseFeatureDetailsPanel): def __init__(self, parent=None, *, feature=None, model_manager=None, data_manager=None): - super().__init__(parent, feature=feature, model_manager=model_manager, data_manager=data_manager) + super().__init__( + parent, feature=feature, model_manager=model_manager, data_manager=data_manager + ) if feature is None: raise ValueError("Feature must be provided.") self.feature = feature + def addMidBlock(self): form_layout = QFormLayout() fold_frame_combobox = QComboBox() @@ -602,20 +694,24 @@ def addMidBlock(self): # Remove redundant layout setting self.setLayout(self.layout) - def on_fold_frame_changed(self, text): self.model_manager.add_fold_to_feature(self.feature.name, fold_frame_name=text) class StructuralFrameFeatureDetailsPanel(BaseFeatureDetailsPanel): def __init__(self, parent=None, *, feature=None, model_manager=None, data_manager=None): - super().__init__(parent, feature=feature, model_manager=model_manager, data_manager=data_manager) + super().__init__( + parent, feature=feature, model_manager=model_manager, data_manager=data_manager + ) class FoldedFeatureDetailsPanel(BaseFeatureDetailsPanel): def __init__(self, parent=None, *, feature=None, model_manager=None, data_manager=None): - super().__init__(parent, feature=feature, model_manager=model_manager, data_manager=data_manager) - def addMidBlock(self): + super().__init__( + parent, feature=feature, model_manager=model_manager, data_manager=data_manager + ) + + def addMidBlock(self): # Remove redundant layout setting # self.setLayout(self.layout) form_layout = QFormLayout() @@ -636,6 +732,7 @@ def addMidBlock(self): } ) ) + norm_length.valueChanged.connect(lambda value: self.schedule_rebuild()) form_layout.addRow("Normal Length", norm_length) norm_weight = QDoubleSpinBox() @@ -651,6 +748,7 @@ def addMidBlock(self): } ) ) + norm_weight.valueChanged.connect(lambda value: self.schedule_rebuild()) form_layout.addRow("Normal Weight", norm_weight) fold_axis_weight = QDoubleSpinBox() @@ -666,6 +764,7 @@ def addMidBlock(self): } ) ) + fold_axis_weight.valueChanged.connect(lambda value: self.schedule_rebuild()) form_layout.addRow("Fold Axis Weight", fold_axis_weight) fold_orientation_weight = QDoubleSpinBox() @@ -681,6 +780,7 @@ def addMidBlock(self): } ) ) + fold_orientation_weight.valueChanged.connect(lambda value: self.schedule_rebuild()) form_layout.addRow("Fold Orientation Weight", fold_orientation_weight) average_fold_axis_checkbox = QCheckBox("Average Fold Axis") @@ -719,23 +819,27 @@ def addMidBlock(self): self.layout.addWidget(group_box) # Remove redundant layout setting self.setLayout(self.layout) + def open_splot_dialog(self): - dialog = SPlotDialog(self, data_manager=self.data_manager, model_manager=self.model_manager, feature_name=self.feature.name) + dialog = SPlotDialog( + self, + data_manager=self.data_manager, + model_manager=self.model_manager, + feature_name=self.feature.name, + ) if dialog.exec_() == dialog.Accepted: pass + def remove_fold_frame(self): pass def foldAxisFromPlungeAzimuth(self): """Calculate the fold axis from plunge and azimuth.""" if self.feature: - plunge = ( - self.fold_plunge.value() - ) - azimuth = ( - self.fold_azimuth.value()) + plunge = self.fold_plunge.value() + azimuth = self.fold_azimuth.value() vector = plungeazimuth2vector(plunge, azimuth)[0] if plunge is not None and azimuth is not None: self.feature.builder.update_build_arguments({'fold_axis': vector.tolist()}) - - + # schedule rebuild after updating builder arguments + self.schedule_rebuild() diff --git a/loopstructural/gui/modelling/geological_model_tab/geological_model_tab.py b/loopstructural/gui/modelling/geological_model_tab/geological_model_tab.py index 8f3832b..287c25c 100644 --- a/loopstructural/gui/modelling/geological_model_tab/geological_model_tab.py +++ b/loopstructural/gui/modelling/geological_model_tab/geological_model_tab.py @@ -1,6 +1,8 @@ -from PyQt5.QtCore import Qt +from PyQt5.QtCore import QObject, Qt, QThread, pyqtSignal, pyqtSlot from PyQt5.QtWidgets import ( QMenu, + QMessageBox, + QProgressDialog, QPushButton, QSplitter, QTreeWidget, @@ -9,18 +11,18 @@ QWidget, ) -from .feature_details_panel import ( - FaultFeatureDetailsPanel, - FoldedFeatureDetailsPanel, - FoliationFeatureDetailsPanel, - StructuralFrameFeatureDetailsPanel, -) from LoopStructural.modelling.features import FeatureType # Import the AddFaultDialog from .add_fault_dialog import AddFaultDialog from .add_foliation_dialog import AddFoliationDialog from .add_unconformity_dialog import AddUnconformityDialog +from .feature_details_panel import ( + FaultFeatureDetailsPanel, + FoldedFeatureDetailsPanel, + FoliationFeatureDetailsPanel, + StructuralFrameFeatureDetailsPanel, +) class GeologicalModelTab(QWidget): @@ -28,7 +30,28 @@ def __init__(self, parent=None, *, model_manager=None, data_manager=None): super().__init__(parent) self.model_manager = model_manager self.data_manager = data_manager - self.model_manager.observers.append(self.update_feature_list) + # Register update observer using Observable API if available + if self.model_manager is not None: + try: + # listen for model-level updates + self._disp_model = self.model_manager.attach( + self.update_feature_list, 'model_updated' + ) + # show progress when model updates start/finish (covers indirect calls) + self._disp_update_start = self.model_manager.attach( + lambda _obs, _ev, *a, **k: self._on_model_update_started(), + 'model_update_started', + ) + self._disp_update_finish = self.model_manager.attach( + lambda _obs, _ev, *a, **k: self._on_model_update_finished(), + 'model_update_finished', + ) + except Exception: + # fallback to legacy list + try: + self.model_manager.observers.append(self.update_feature_list) + except Exception: + raise RuntimeError("Failed to register model update observer") # Main layout mainLayout = QVBoxLayout(self) @@ -75,6 +98,10 @@ def __init__(self, parent=None, *, model_manager=None, data_manager=None): # Connect feature selection to update details panel self.featureList.itemClicked.connect(self.on_feature_selected) + # thread handle to keep worker alive while running + self._model_update_thread = None + self._model_update_worker = None + def show_add_feature_menu(self, *args): menu = QMenu(self) add_fault = menu.addAction("Add Fault") @@ -89,6 +116,7 @@ def show_add_feature_menu(self, *args): self.open_add_foliation_dialog() elif action == add_unconformity: self.open_add_unconformity_dialog() + def open_add_fault_dialog(self): dialog = AddFaultDialog(self) if dialog.exec_() == dialog.Accepted: @@ -102,16 +130,95 @@ def open_add_foliation_dialog(self): ) if dialog.exec_() == dialog.Accepted: pass + def open_add_unconformity_dialog(self): dialog = AddUnconformityDialog( self, data_manager=self.data_manager, model_manager=self.model_manager ) if dialog.exec_() == dialog.Accepted: pass + def initialize_model(self): - self.model_manager.update_model() + # Run update_model in a background thread to avoid blocking the UI. + if not self.model_manager: + return - def update_feature_list(self): + # create progress dialog (indeterminate) + progress = QProgressDialog("Updating geological model...", "Cancel", 0, 0, self) + progress.setWindowModality(Qt.ApplicationModal) + progress.setWindowTitle("Updating Model") + progress.setCancelButton(None) + progress.setMinimumDuration(0) + progress.show() + + # worker and thread + thread = QThread(self) + worker = _ModelUpdateWorker(self.model_manager) + worker.moveToThread(thread) + + # When thread starts run worker.run + thread.started.connect(worker.run) + + # on worker finished, notify observers on main thread and cleanup + def _on_finished(): + try: + # notify observers now on main thread + try: + self.model_manager.notify('model_updated') + except Exception: + for obs in getattr(self.model_manager, 'observers', []): + try: + obs() + except Exception as e: + self._debug.log_error("Error notifying observer", e) + finally: + try: + progress.close() + except Exception: + pass + # cleanup worker/thread + try: + worker.deleteLater() + except Exception: + pass + try: + thread.quit() + thread.wait(2000) + except Exception: + pass + + def _on_error(tb): + try: + progress.close() + except Exception: + pass + try: + QMessageBox.critical( + self, + "Model update failed", + f"An error occurred while updating the model:\n{tb}", + ) + except Exception: + pass + # ensure thread cleanup + try: + worker.deleteLater() + except Exception: + pass + try: + thread.quit() + thread.wait(2000) + except Exception: + pass + + worker.finished.connect(_on_finished) + worker.error.connect(_on_error) + thread.finished.connect(thread.deleteLater) + self._model_update_thread = thread + self._model_update_worker = worker + thread.start() + + def update_feature_list(self, *args, **kwargs): self.featureList.clear() # Clear the feature list before populating it for feature in self.model_manager.features(): if feature.name.startswith("__"): @@ -153,6 +260,42 @@ def on_feature_selected(self, item): splitter.widget(1).deleteLater() # Remove the existing widget splitter.addWidget(self.featureDetailsPanel) # Add the new widget + def _on_model_update_started(self): + """Show a non-blocking indeterminate progress dialog for model updates. + + This method is invoked via the Observable notifications and ensures the + user sees that a background or foreground update is in progress. + """ + print("Model update started - showing progress dialog") + try: + if getattr(self, '_progress_dialog', None) is None: + self._progress_dialog = QProgressDialog( + "Updating geological model...", None, 0, 0, self + ) + self._progress_dialog.setWindowTitle("Updating Model") + self._progress_dialog.setWindowModality(Qt.ApplicationModal) + self._progress_dialog.setCancelButton(None) + self._progress_dialog.setMinimumDuration(0) + self._progress_dialog.show() + except Exception: + pass + + def _on_model_update_finished(self): + """Close the progress dialog shown for model updates.""" + try: + if getattr(self, '_progress_dialog', None) is not None: + try: + self._progress_dialog.close() + except Exception: + pass + try: + self._progress_dialog.deleteLater() + except Exception: + pass + self._progress_dialog = None + except Exception: + pass + def show_feature_context_menu(self, pos): # Show context menu only for items item = self.featureList.itemAt(pos) @@ -197,10 +340,50 @@ def delete_feature(self, item): # Notify observers to refresh UI try: - for obs in getattr(self.model_manager, 'observers', []): - try: - obs() - except Exception: - pass + # Prefer notify API + try: + self.model_manager.notify('model_updated') + except Exception: + # fallback to legacy observers list + for obs in getattr(self.model_manager, 'observers', []): + try: + obs() + except Exception: + pass except Exception: pass + + +class _ModelUpdateWorker(QObject): + """Worker that runs model_manager.update_model in a background thread. + + Emits finished when done and error with a string if an exception occurs. + """ + + finished = pyqtSignal() + error = pyqtSignal(str) + + def __init__(self, model_manager): + super().__init__() + self.model_manager = model_manager + + @pyqtSlot() + def run(self): + try: + # perform the expensive update + # run update without notifying observers from the background thread + try: + self.model_manager.update_model(notify_observers=False) + except TypeError: + # fallback if update_model signature not available + self.model_manager.update_model() + except Exception as e: + try: + import traceback + + tb = traceback.format_exc() + except Exception: + tb = str(e) + self.error.emit(tb) + finally: + self.finished.emit() diff --git a/loopstructural/gui/modelling/geological_model_tab/layer_selection_table.py b/loopstructural/gui/modelling/geological_model_tab/layer_selection_table.py index 38e119b..5bec3e6 100644 --- a/loopstructural/gui/modelling/geological_model_tab/layer_selection_table.py +++ b/loopstructural/gui/modelling/geological_model_tab/layer_selection_table.py @@ -16,12 +16,12 @@ class LayerSelectionTable(QWidget): """ Self-contained widget for layer selection table functionality for geological features. - + This widget includes: - A table for displaying selected layers with Type, Layer, and Delete columns - An "Add Data" button at the bottom for adding new layers - Complete data management integration with data_manager.feature_data - + Usage example: # Create the widget layer_table = LayerSelectionTable( @@ -29,19 +29,19 @@ class LayerSelectionTable(QWidget): feature_name_provider=lambda: "my_feature_name", name_validator=lambda: (True, "") # or your validation logic ) - + # Add to your layout layout.addWidget(layer_table) - + # Access data if layer_table.has_layers(): data = layer_table.get_table_data() """ - + def __init__(self, data_manager, feature_name_provider, name_validator, parent=None): """ Initialize the layer selection table widget. - + Args: data_manager: Data manager instance feature_name_provider: Callable that returns the current feature name @@ -52,104 +52,104 @@ def __init__(self, data_manager, feature_name_provider, name_validator, parent=N self.data_manager = data_manager self.get_feature_name = feature_name_provider self.validate_name = name_validator - + self._setup_ui() self.initialize_feature_data() self.restore_table_state() - + def _setup_ui(self): """Setup the widget UI with table and add button.""" layout = QVBoxLayout(self) - + # Create the table widget self.table = QTableWidget() self._setup_table() layout.addWidget(self.table) - + # Create add button self.add_button = QPushButton("Add Data") self.add_button.clicked.connect(self.add_item_row) layout.addWidget(self.add_button) - + def _setup_table(self): """Setup table columns and headers.""" self.table.setColumnCount(3) self.table.setHorizontalHeaderLabels(["Type", "Select Layer", "Delete"]) - + def initialize_feature_data(self): """Initialize feature data in the data manager if it doesn't exist.""" feature_name = self.get_feature_name() if feature_name and feature_name not in self.data_manager.feature_data: self.data_manager.feature_data[feature_name] = {} - + def restore_table_state(self): """Restore table state from data manager.""" feature_name = self.get_feature_name() if not feature_name or feature_name not in self.data_manager.feature_data: return - + # Clear existing table rows self.table.setRowCount(0) - + # Restore rows from data feature_data = self.data_manager.feature_data[feature_name] for _layer_name, layer_data in feature_data.items(): self._add_row_from_data(layer_data) - + def _add_row_from_data(self, layer_data): """Add a row to the table from existing data.""" row = self.table.rowCount() self.table.insertRow(row) - + # Type dropdown type_combo = self._create_type_combo() type_combo.setCurrentText(layer_data.get('type', 'Value')) self.table.setCellWidget(row, 0, type_combo) - + # Select Layer button select_layer_btn = self._create_select_layer_button(row, type_combo) self._update_button_with_selection(select_layer_btn, layer_data) self.table.setCellWidget(row, 1, select_layer_btn) - + # Delete button del_btn = self._create_delete_button(row) self.table.setCellWidget(row, 2, del_btn) - + def add_item_row(self): """Add a new row to the table.""" self.initialize_feature_data() # Ensure feature data exists - + row = self.table.rowCount() self.table.insertRow(row) - + # Type dropdown type_combo = self._create_type_combo() self.table.setCellWidget(row, 0, type_combo) - + # Select Layer button select_layer_btn = self._create_select_layer_button(row, type_combo) self.table.setCellWidget(row, 1, select_layer_btn) - + # Delete button del_btn = self._create_delete_button(row) self.table.setCellWidget(row, 2, del_btn) - + def _create_type_combo(self): """Create type selection combo box.""" combo = QComboBox() combo.addItems(["Value", "Form Line", "Orientation", "Inequality"]) return combo - + def _create_select_layer_button(self, row, type_combo): """Create select layer button.""" btn = QPushButton("Select Layer") - + def open_layer_dialog(): name_valid, name_error = self.validate_name() if not name_valid: self.data_manager.logger(f'Name is invalid: {name_error}', log_level=2) return - + dialog = LayerSelectionDialog( parent=self.table, data_manager=self.data_manager, @@ -157,22 +157,22 @@ def open_layer_dialog(): layer_type=type_combo.currentText(), existing_data=self._get_existing_data_for_button(btn) ) - + if dialog.exec_() == QDialog.Accepted: layer_data = dialog.get_layer_data() if layer_data: self._update_button_with_selection(btn, layer_data) self._add_layer_to_data_manager(layer_data) - + btn.clicked.connect(open_layer_dialog) return btn - + def _create_delete_button(self, row): """Create delete button for a row.""" btn = QPushButton("Delete") btn.clicked.connect(lambda: self._delete_item_row(row)) return btn - + def _delete_item_row(self, row): """Delete a row from the table and update data manager.""" # Find the select layer button in the same row to get layer name @@ -183,13 +183,13 @@ def _delete_item_row(self, row): if feature_name in self.data_manager.feature_data: self.data_manager.feature_data[feature_name].pop(layer_name, None) print(f'Removing layer: {layer_name} for feature: {feature_name}') - + # Remove the row from table self.table.removeRow(row) - + # Update delete button connections for remaining rows self._update_delete_button_connections() - + def _update_delete_button_connections(self): """Update delete button connections after row deletion to maintain correct row indices.""" for row in range(self.table.rowCount()): @@ -199,7 +199,7 @@ def _update_delete_button_connections(self): delete_btn.clicked.disconnect() # Reconnect with correct row index delete_btn.clicked.connect(lambda checked, r=row: self._delete_item_row(r)) - + def _get_existing_data_for_button(self, btn): """Get existing data for a button if it has been configured.""" if btn.text() != "Select Layer" and hasattr(btn, 'selected_layer'): @@ -207,20 +207,20 @@ def _get_existing_data_for_button(self, btn): if feature_name and feature_name in self.data_manager.feature_data: return self.data_manager.feature_data[feature_name].get(btn.selected_layer, {}) return {} - + def _update_button_with_selection(self, btn, layer_data): """Update button text and store selection data.""" layer_name = layer_data.get('layer_name', 'Unknown') btn.setText(layer_name) btn.selected_layer = layer_name - + # Store field information for different types btn.strike_field = layer_data.get('strike_field') btn.dip_field = layer_data.get('dip_field') btn.value_field = layer_data.get('value_field') btn.lower_field = layer_data.get('lower_field') btn.upper_field = layer_data.get('upper_field') - + def _add_layer_to_data_manager(self, layer_data): """Add selected layer data to the data manager.""" if not isinstance(layer_data, dict): @@ -230,112 +230,112 @@ def _add_layer_to_data_manager(self, layer_data): self.data_manager.update_feature_data(feature_name, layer_data) else: raise RuntimeError("Data manager is not set.") - + def clear_table(self): """Clear all rows from the table and reset feature data.""" feature_name = self.get_feature_name() if feature_name and feature_name in self.data_manager.feature_data: self.data_manager.feature_data[feature_name].clear() - + # Clear all table rows self.table.setRowCount(0) - + def get_table_data(self): """Get all table data as a dictionary.""" feature_name = self.get_feature_name() if feature_name and feature_name in self.data_manager.feature_data: return self.data_manager.feature_data[feature_name].copy() return {} - + def set_table_data(self, data): """Set table data and restore table state.""" feature_name = self.get_feature_name() if not feature_name: return - + # Clear existing table self.clear_table() - + # Update data manager self.initialize_feature_data() self.data_manager.feature_data[feature_name] = data.copy() - + # Restore table state self.restore_table_state() - + def validate_table_state(self): """Validate that table state matches data manager state.""" feature_name = self.get_feature_name() if not feature_name or feature_name not in self.data_manager.feature_data: return True - + feature_data = self.data_manager.feature_data[feature_name] table_layers = [] - + # Collect layers from table for row in range(self.table.rowCount()): select_btn = self.table.cellWidget(row, 1) if hasattr(select_btn, 'selected_layer'): table_layers.append(select_btn.selected_layer) - + # Compare with data manager data_layers = list(feature_data.keys()) - + if set(table_layers) != set(data_layers): print(f"Table state inconsistency detected for feature '{feature_name}':") print(f" Table layers: {table_layers}") print(f" Data layers: {data_layers}") return False - + return True - + def sync_table_with_data(self): """Synchronize table state with data manager state.""" if not self.validate_table_state(): print("Syncing table with data manager...") self.restore_table_state() - + def get_layer_count(self): """Get the number of layers currently in the table.""" feature_name = self.get_feature_name() if feature_name and feature_name in self.data_manager.feature_data: return len(self.data_manager.feature_data[feature_name]) return 0 - + def has_layers(self): """Check if there are any layers in the table.""" return self.get_layer_count() > 0 - + def get_layer_names(self): """Get a list of all layer names in the table.""" feature_name = self.get_feature_name() if feature_name and feature_name in self.data_manager.feature_data: return list(self.data_manager.feature_data[feature_name].keys()) return [] - + def get_table_widget(self): """Get the internal table widget for direct access if needed.""" return self.table - + def get_add_button(self): """Get the add button widget for customization if needed.""" return self.add_button - + def set_add_button_text(self, text): """Set the text of the add button.""" self.add_button.setText(text) - + def set_table_headers(self, headers): """Set custom table headers.""" if len(headers) == 3: self.table.setHorizontalHeaderLabels(headers) else: raise ValueError("Headers list must contain exactly 3 items") - + def set_add_button_enabled(self, enabled): """Enable or disable the add button.""" self.add_button.setEnabled(enabled) - + def is_add_button_enabled(self): """Check if the add button is enabled.""" return self.add_button.isEnabled() @@ -343,7 +343,7 @@ def is_add_button_enabled(self): class LayerSelectionDialog(QDialog): """Dialog for selecting layers and configuring their fields.""" - + def __init__(self, parent=None, data_manager=None, feature_name="", layer_type="", existing_data=None): super().__init__(parent) self.data_manager = data_manager @@ -351,179 +351,179 @@ def __init__(self, parent=None, data_manager=None, feature_name="", layer_type=" self.layer_type = layer_type self.existing_data = existing_data or {} self.layer_data = {} - + self.setWindowTitle("Select Layer") self._setup_ui() - + def _setup_ui(self): """Setup the dialog UI.""" layout = QVBoxLayout(self) - + # Layer selection layer_label = QLabel("Layer:") layout.addWidget(layer_label) - + self.layer_combo = QgsMapLayerComboBox() self.layer_combo.setFilters( QgsMapLayerProxyModel.LineLayer | QgsMapLayerProxyModel.PointLayer ) layout.addWidget(self.layer_combo) - + # Set existing layer if available if 'layer' in self.existing_data: self.layer_combo.setLayer(self.existing_data['layer']) - + # Type-specific field selection self._setup_type_specific_fields(layout) - + # Dialog buttons self.button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) layout.addWidget(self.button_box) - + self.button_box.accepted.connect(self._on_accepted) self.button_box.rejected.connect(self.reject) - + # Validation self.layer_combo.layerChanged.connect(self._validate_layer_selection) self._validate_layer_selection() - + def _setup_type_specific_fields(self, layout): """Setup fields specific to the layer type.""" self.field_combos = {} - + if self.layer_type == "Orientation": self._setup_orientation_fields(layout) elif self.layer_type == "Value": self._setup_value_fields(layout) elif self.layer_type == "Inequality": self._setup_inequality_fields(layout) - + def _setup_orientation_fields(self, layout): """Setup fields for orientation data type.""" field_layout = QHBoxLayout() - + # Format selection self.format_combo = QComboBox() self.format_combo.addItems(["Strike", "Dip Direction"]) if 'orientation_format' in self.existing_data: self.format_combo.setCurrentText(self.existing_data['orientation_format']) field_layout.addWidget(self.format_combo) - + # Strike/Dip Direction field self.strike_field_label = QLabel("Strike:") self.strike_field_combo = QgsFieldComboBox() self.strike_field_combo.setLayer(self.layer_combo.currentLayer()) if 'strike_field' in self.existing_data: self.strike_field_combo.setField(self.existing_data['strike_field']) - + field_layout.addWidget(self.strike_field_label) field_layout.addWidget(self.strike_field_combo) - + # Dip field dip_field_label = QLabel("Dip:") self.dip_field_combo = QgsFieldComboBox() self.dip_field_combo.setLayer(self.layer_combo.currentLayer()) if 'dip_field' in self.existing_data: self.dip_field_combo.setField(self.existing_data['dip_field']) - + field_layout.addWidget(dip_field_label) field_layout.addWidget(self.dip_field_combo) - + layout.addLayout(field_layout) - + # Update strike label based on format def update_strike_label(text): if text == "Dip Direction": self.strike_field_label.setText("Dip Direction:") else: self.strike_field_label.setText("Strike:") - + self.format_combo.currentTextChanged.connect(update_strike_label) self.layer_combo.layerChanged.connect(self.strike_field_combo.setLayer) self.layer_combo.layerChanged.connect(self.dip_field_combo.setLayer) - + self.field_combos = { 'strike_field': self.strike_field_combo, 'dip_field': self.dip_field_combo, 'format': self.format_combo } - + def _setup_value_fields(self, layout): """Setup fields for value data type.""" field_layout = QHBoxLayout() - + value_field_label = QLabel("Value Field:") self.value_field_combo = QgsFieldComboBox() self.value_field_combo.setLayer(self.layer_combo.currentLayer()) if 'value_field' in self.existing_data: self.value_field_combo.setField(self.existing_data['value_field']) - + field_layout.addWidget(value_field_label) field_layout.addWidget(self.value_field_combo) layout.addLayout(field_layout) - + self.layer_combo.layerChanged.connect(self.value_field_combo.setLayer) - + self.field_combos = { 'value_field': self.value_field_combo } - + def _setup_inequality_fields(self, layout): """Setup fields for inequality data type.""" field_layout = QHBoxLayout() - + lower_field_label = QLabel("Lower:") self.lower_field_combo = QgsFieldComboBox() self.lower_field_combo.setLayer(self.layer_combo.currentLayer()) if 'lower_field' in self.existing_data: self.lower_field_combo.setField(self.existing_data['lower_field']) - + upper_field_label = QLabel("Upper:") self.upper_field_combo = QgsFieldComboBox() self.upper_field_combo.setLayer(self.layer_combo.currentLayer()) if 'upper_field' in self.existing_data: self.upper_field_combo.setField(self.existing_data['upper_field']) - + field_layout.addWidget(lower_field_label) field_layout.addWidget(self.lower_field_combo) field_layout.addWidget(upper_field_label) field_layout.addWidget(self.upper_field_combo) layout.addLayout(field_layout) - + self.layer_combo.layerChanged.connect(self.lower_field_combo.setLayer) self.layer_combo.layerChanged.connect(self.upper_field_combo.setLayer) - + self.field_combos = { 'lower_field': self.lower_field_combo, 'upper_field': self.upper_field_combo } - + def _validate_layer_selection(self): """Validate the current layer selection.""" if self.layer_combo.currentLayer() is None: self.button_box.button(QDialogButtonBox.Ok).setEnabled(False) return False - + layer_name = self.layer_combo.currentLayer().name() if layer_name in self.data_manager.feature_data.get(self.feature_name, {}): self.data_manager.logger("Layer already selected.", log_level=2) self.button_box.button(QDialogButtonBox.Ok).setEnabled(False) return False - + self.button_box.button(QDialogButtonBox.Ok).setEnabled(True) return True - + def _on_accepted(self): """Handle dialog acceptance.""" if self.layer_combo.currentLayer() is None: return - + self.layer_data = { 'layer': self.layer_combo.currentLayer(), 'layer_name': self.layer_combo.currentLayer().name(), 'type': self.layer_type } - + # Add type-specific data if self.layer_type == "Orientation": if not self.field_combos['strike_field'].currentField() or not self.field_combos['dip_field'].currentField(): @@ -531,20 +531,20 @@ def _on_accepted(self): self.layer_data['strike_field'] = self.field_combos['strike_field'].currentField() self.layer_data['dip_field'] = self.field_combos['dip_field'].currentField() self.layer_data['orientation_format'] = self.field_combos['format'].currentText() - + elif self.layer_type == "Value": if not self.field_combos['value_field'].currentField(): return self.layer_data['value_field'] = self.field_combos['value_field'].currentField() - + elif self.layer_type == "Inequality": if not self.field_combos['lower_field'].currentField() or not self.field_combos['upper_field'].currentField(): return self.layer_data['lower_field'] = self.field_combos['lower_field'].currentField() self.layer_data['upper_field'] = self.field_combos['upper_field'].currentField() - + self.accept() - + def get_layer_data(self): """Get the configured layer data.""" return self.layer_data diff --git a/loopstructural/gui/modelling/geological_model_tab/splot.py b/loopstructural/gui/modelling/geological_model_tab/splot.py index af888a5..5779cd4 100644 --- a/loopstructural/gui/modelling/geological_model_tab/splot.py +++ b/loopstructural/gui/modelling/geological_model_tab/splot.py @@ -18,12 +18,12 @@ def __init__(self, parent=None, *, data_manager=None, model_manager=None, featur feature = self.model_manager.model.get_feature_by_name(self.feature_name) layout = QVBoxLayout() - + fold_frame = feature.fold.fold_limb_rotation.fold_frame_coordinate - rotation = feature.fold.fold_limb_rotation.rotation_angle + rotation = feature.fold.fold_limb_rotation.rotation_angle # Placeholder scatter plot using pyqtgraph self.plot_widget = pg.PlotWidget() - + self.plot_widget.plot( fold_frame, rotation, diff --git a/loopstructural/gui/modelling/model_definition/dem.py b/loopstructural/gui/modelling/model_definition/dem.py index fcdc452..0bf35f7 100644 --- a/loopstructural/gui/modelling/model_definition/dem.py +++ b/loopstructural/gui/modelling/model_definition/dem.py @@ -25,7 +25,7 @@ def set_dem_layer(self, layer): else: self.demLayerQgsMapLayerComboBox.setCurrentIndex(-1) self.useDEMCheckBox.setChecked(False) - + def onUseDEMClicked(self): if self.useDEMCheckBox.isChecked(): diff --git a/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py b/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py index 4e1607e..6fd4b6e 100644 --- a/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py +++ b/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py @@ -1,3 +1,4 @@ +from LoopStructural.modelling.core.stratigraphic_column import StratigraphicColumnElementType from PyQt5.QtWidgets import ( QAbstractItemView, QListWidget, @@ -8,7 +9,6 @@ ) from loopstructural.gui.modelling.stratigraphic_column.unconformity import UnconformityWidget -from LoopStructural.modelling.core.stratigraphic_column import StratigraphicColumnElementType from .stratigraphic_unit import StratigraphicUnitWidget @@ -27,6 +27,8 @@ class StratColumnWidget(QWidget): ----- The widget updates its display based on the data_manager's stratigraphic column and registers a callback via data_manager.set_stratigraphic_column_callback. + + This widget uses efficient incremental updates rather than full rebuilds when possible. """ def __init__(self, parent=None, data_manager=None): @@ -35,6 +37,13 @@ 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 + + # Cache to track current widgets and their UUIDs for efficient updates + self._widget_cache = {} # Maps UUID -> (widget, list_item) + + # Flag to prevent recursive/reentrant callbacks during updates + self._updating = False + # Main list widget self.unitList = QListWidget() self.unitList.setDragDropMode(QAbstractItemView.InternalMove) @@ -66,22 +75,89 @@ def __init__(self, parent=None, data_manager=None): def clearColumn(self): """Clear the stratigraphic column.""" - self.unitList.clear() + # Use the data manager's clear method to ensure callback is triggered + # This will notify all listening widgets (including this one and others) if self.data_manager: self.data_manager._stratigraphic_column.clear() + # Trigger callback to notify all listeners + if self.data_manager.stratigraphic_column_callback: + self.data_manager.stratigraphic_column_callback() else: + # Fallback: clear locally if no data manager + self.unitList.clear() + self._widget_cache.clear() print("Error: Data manager is not initialized.") def update_display(self): - """Update the widget display based on the data manager's stratigraphic column.""" - self.unitList.clear() - if self.data_manager and self.data_manager._stratigraphic_column: - for unit in self.data_manager._stratigraphic_column.order: - if unit.element_type == StratigraphicColumnElementType.UNIT: - self.add_unit(unit_data=unit.to_dict(), create_new=False) - elif unit.element_type == StratigraphicColumnElementType.UNCONFORMITY: + """Update the widget display with efficient incremental updates. + + Instead of rebuilding the entire display, this method: + 1. Identifies items that were added/removed/reordered + 2. Only updates what changed + 3. Falls back to full rebuild only when necessary + + This method is protected against recursive/reentrant calls during active updates. + """ + # Prevent reentrant calls during updates + if self._updating: + return + + self._updating = True + try: + # Check if the list widget is still valid + try: + if not self.data_manager or not self.data_manager._stratigraphic_column: + self.unitList.clear() + self._widget_cache.clear() + return + except RuntimeError: + # Widget was deleted + return + + current_order = self.data_manager._stratigraphic_column.order + current_uuids = [unit.uuid for unit in current_order] + cached_uuids = list(self._widget_cache.keys()) + + # Check if the order and content match + if current_uuids == cached_uuids: + # No changes in order or content, just update data if needed + for unit in current_order: + if unit.uuid in self._widget_cache: + widget, _ = self._widget_cache[unit.uuid] + # Update widget data without rebuilding + if hasattr(widget, 'setData'): + widget.setData(unit.to_dict()) + return - self.add_unconformity(unconformity_data=unit.to_dict(), create_new=False) + # If order/content differs, do a full rebuild + # but only as a last resort + self._full_rebuild_display(current_order) + finally: + self._updating = False + + def _full_rebuild_display(self, current_order): + """Perform a full rebuild of the display (called only when necessary). + + Parameters + ---------- + current_order : list + The current order of elements in the stratigraphic column + """ + # Check if the list widget is still valid (could be deleted in some cases) + try: + # Clear the list and cache + self.unitList.clear() + self._widget_cache.clear() + except RuntimeError: + # Widget was deleted, can't rebuild + return + + # Rebuild from scratch + for unit in current_order: + if unit.element_type == StratigraphicColumnElementType.UNIT: + self.add_unit(unit_data=unit.to_dict(), create_new=False) + elif unit.element_type == StratigraphicColumnElementType.UNCONFORMITY: + self.add_unconformity(unconformity_data=unit.to_dict(), create_new=False) def init_stratigraphic_column_from_basal_contacts(self): if self.data_manager: @@ -102,12 +178,20 @@ def add_unit(self, *, unit_data=None, create_new=True): unit = self.data_manager._stratigraphic_column.get_element_by_uuid( unit_data['uuid'] ) + + # Check if widget already exists in cache (avoid duplicates during rebuild) + if unit_data['uuid'] in self._widget_cache: + widget, _ = self._widget_cache[unit_data['uuid']] + # Just update the data, don't recreate the widget + if hasattr(widget, 'setData'): + widget.setData(unit_data) + return + unit_data.pop('type', None) # Remove type if present unit_data.pop('id', None) for k in list(unit_data.keys()): if unit_data[k] is None: unit_data.pop(k) - print(f"Adding unit with data: {unit_data}") unit_widget = StratigraphicUnitWidget(**unit_data) unit_widget.deleteRequested.connect(self.delete_unit) # Connect delete signal unit_widget.nameChanged.connect( @@ -117,7 +201,7 @@ def add_unit(self, *, unit_data=None, create_new=True): unit_widget.thicknessChanged.connect( lambda: self.update_element(unit_widget) ) # Connect thickness change signal - + unit_widget.set_thickness(unit_data.get('thickness', 0.0)) # Set initial thickness unit_widget.colourChanged.connect( lambda: self.update_element(unit_widget) @@ -127,7 +211,9 @@ def add_unit(self, *, unit_data=None, create_new=True): self.unitList.addItem(item) self.unitList.setItemWidget(item, unit_widget) unit_widget.setData(unit_data) # Set data for the unit widget - # Update data manager + + # Cache the widget for efficient updates + self._widget_cache[unit_data['uuid']] = (unit_widget, item) def add_unconformity(self, *, unconformity_data=None, create_new=True): if unconformity_data is None: @@ -139,6 +225,14 @@ def add_unconformity(self, *, unconformity_data=None, create_new=True): unconformity_data['uuid'] ) + # Check if widget already exists in cache (avoid duplicates during rebuild) + if unconformity.uuid in self._widget_cache: + widget, _ = self._widget_cache[unconformity.uuid] + # Just update the data, don't recreate the widget + if hasattr(widget, 'setData'): + widget.setData(unconformity_data) + return + unconformity_widget = UnconformityWidget(uuid=unconformity.uuid) unconformity_widget.deleteRequested.connect(self.delete_unit) item = QListWidgetItem() @@ -146,7 +240,8 @@ def add_unconformity(self, *, unconformity_data=None, create_new=True): self.unitList.addItem(item) self.unitList.setItemWidget(item, unconformity_widget) - # Update data manager + # Cache the widget for efficient updates + self._widget_cache[unconformity.uuid] = (unconformity_widget, item) def delete_unit(self, unit_widget): for i in range(self.unitList.count()): @@ -155,10 +250,14 @@ def delete_unit(self, unit_widget): self.unitList.takeItem(i) break - # Update data manager + # Update data manager and cache if self.data_manager: self.data_manager.remove_from_stratigraphic_column(unit_widget.uuid) + # Remove from cache + if unit_widget.uuid in self._widget_cache: + del self._widget_cache[unit_widget.uuid] + def update_order(self, parent, start, end, destination, row): """Update the data manager when the order of items changes.""" if self.data_manager: @@ -173,7 +272,14 @@ def update_order(self, parent, start, end, destination, row): self.data_manager.update_stratigraphic_column_order(ordered_uuids) def update_element(self, unit_widget): - """Update the data manager with the changes made in the unit widget.""" + """Update the data manager with the changes made in the unit widget. + + After updating the element, triggers the callback to notify all listeners + (including other widgets) that the stratigraphic column has changed. + """ if self.data_manager: unit_data = unit_widget.getData() self.data_manager._stratigraphic_column.update_element(unit_data) + # Trigger callback to notify all listeners of the change + if self.data_manager.stratigraphic_column_callback: + self.data_manager.stratigraphic_column_callback() 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/gui/modelling/stratigraphic_column/unconformity.py b/loopstructural/gui/modelling/stratigraphic_column/unconformity.py index 0f0260a..17c4566 100644 --- a/loopstructural/gui/modelling/stratigraphic_column/unconformity.py +++ b/loopstructural/gui/modelling/stratigraphic_column/unconformity.py @@ -20,9 +20,9 @@ def __init__( self.buttonDelete.clicked.connect(self.request_delete) self.uuid = uuid self.unconformity_type = 'erode' - self.comboBoxUnconformityType.currentIndexChanged.connect( - lambda: setattr(self, 'unconformity_type', self.comboBoxUnconformityType.currentText()) - ) + # self.comboBoxUnconformityType.currentIndexChanged.connect( + # lambda: setattr(self, 'unconformity_type', self.comboBoxUnconformityType.currentText()) + # ) def request_delete(self): @@ -38,11 +38,11 @@ def setData(self, data: Optional[dict] = None): """ if data: self.unconformity_type = data.get("unconformity_type", "") - self.unconformityTypeComboBox.setCurrentIndex( - self.unconformityTypeComboBox.findText(self.unconformity_type) - ) + # self.unconformityTypeComboBox.setCurrentIndex( + # self.unconformityTypeComboBox.findText(self.unconformity_type) + # ) else: self.unconformity_type = 'erode' - self.unconformityTypeComboBox.setCurrentIndex( - self.unconformityTypeComboBox.findText(self.unconformity_type) - ) + # self.unconformityTypeComboBox.setCurrentIndex( + # self.unconformityTypeComboBox.findText(self.unconformity_type) + # ) diff --git a/loopstructural/gui/visualisation/feature_list_widget.py b/loopstructural/gui/visualisation/feature_list_widget.py index 1159eed..7173c4e 100644 --- a/loopstructural/gui/visualisation/feature_list_widget.py +++ b/loopstructural/gui/visualisation/feature_list_widget.py @@ -1,9 +1,12 @@ +import logging from typing import Optional, Union from PyQt5.QtWidgets import QMenu, QPushButton, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget from LoopStructural.datatypes import VectorPoints +logger = logging.getLogger(__name__) + class FeatureListWidget(QWidget): def __init__(self, parent=None, *, model_manager=None, viewer=None): @@ -33,7 +36,31 @@ def __init__(self, parent=None, *, model_manager=None, viewer=None): # Populate the feature list self.update_feature_list() - self.model_manager.observers.append(self.update_feature_list) + # register observer to refresh list and viewer when model changes + if self.model_manager is not None: + # Attach to specific model events using the Observable framework + try: + # listeners will receive (observable, event, *args) + # attach wrappers that match the Observable callback signature + self._disp_update = self.model_manager.attach( + lambda _obs, _event, *a, **k: self.update_feature_list(), 'model_updated' + ) + # also listen for model and feature updates so visualisation can refresh + # forward event and args into the handler so it can act on specific surfaces + self._disp_feature = self.model_manager.attach( + lambda _obs, _event, *a, **k: self._on_model_update(_event, *a), 'model_updated' + ) + self._disp_feature2 = self.model_manager.attach( + lambda _obs, _event, *a, **k: self._on_model_update(_event, *a), + 'feature_updated', + ) + except Exception: + # Fall back to legacy observers list if available + try: + self.model_manager.observers.append(self.update_feature_list) + self.model_manager.observers.append(self._on_model_update) + except Exception: + pass def update_feature_list(self): if not self.model_manager: @@ -94,17 +121,50 @@ def contextMenuEvent(self, event): def add_scalar_field(self, feature_name): scalar_field = self.model_manager.model[feature_name].scalar_field() - self.viewer.add_mesh_object(scalar_field.vtk(), name=f'{feature_name}_scalar_field') + self.viewer.add_mesh_object( + scalar_field.vtk(), + name=f'{feature_name}_scalar_field', + source_feature=feature_name, + source_type='feature_scalar', + ) def add_surface(self, feature_name): surfaces = self.model_manager.model[feature_name].surfaces() - for surface in surfaces: - self.viewer.add_mesh_object(surface.vtk(), name=f'{feature_name}_surface') + for i, surface in enumerate(surfaces): + # ensure unique names for multiple surfaces per feature + mesh_name = f'{feature_name}_surface' if i == 0 else f'{feature_name}_surface_{i+1}' + # try to determine an isovalue for this surface (may be an attribute or encoded in name) + isovalue = None + try: + isovalue = getattr(surface, 'isovalue', None) + except Exception: + isovalue = None + if isovalue is None: + # attempt to parse trailing numeric suffix in the surface name + try: + parts = str(surface.name).rsplit('_', 1) + if len(parts) == 2: + isovalue = float(parts[1]) + except Exception: + isovalue = None + + self.viewer.add_mesh_object( + surface.vtk(), + name=mesh_name, + source_feature=feature_name, + source_type='feature_surface', + isovalue=isovalue, + ) def add_vector_field(self, feature_name): vector_field = self.model_manager.model[feature_name].vector_field() scale = self._get_vector_scale() - self.viewer.add_mesh_object(vector_field.vtk(scale=scale), name=f'{feature_name}_vector_field') + self.viewer.add_mesh_object( + vector_field.vtk(scale=scale), + name=f'{feature_name}_vector_field', + source_feature=feature_name, + source_type='feature_vector', + ) def add_data(self, feature_name): data = self.model_manager.model[feature_name].get_data() @@ -114,35 +174,241 @@ def add_data(self, feature_name): scale = self._get_vector_scale() # tolerance is None means all points are shown self.viewer.add_mesh_object( - d.vtk(scale=scale, tolerance=None), name=f'{feature_name}_{d.name}_points' + d.vtk(scale=scale, tolerance=None), + name=f'{feature_name}_{d.name}_points', + source_feature=feature_name, + source_type='feature_points', ) else: - self.viewer.add_mesh_object(d.vtk(), name=f'{feature_name}_{d.name}') - print(f"Adding data to feature: {feature_name}") + self.viewer.add_mesh_object( + d.vtk(), + name=f'{feature_name}_{d.name}', + source_feature=feature_name, + source_type='feature_data', + ) + logger.debug(f"Adding data to feature: {feature_name}") def add_model_bounding_box(self): if not self.model_manager: - print("Model manager is not set.") + logger.debug("Model manager is not set.") return bb = self.model_manager.model.bounding_box.vtk().outline() - self.viewer.add_mesh_object(bb, name='model_bounding_box') + self.viewer.add_mesh_object( + bb, name='model_bounding_box', source_feature='__model__', source_type='bounding_box' + ) # Logic for adding model bounding box - print("Adding model bounding box...") + logger.debug("Adding model bounding box...") def add_fault_surfaces(self): if not self.model_manager: - print("Model manager is not set.") + logger.debug("Model manager is not set.") return + self.model_manager.update_all_features(subset='faults') fault_surfaces = self.model_manager.model.get_fault_surfaces() for surface in fault_surfaces: - self.viewer.add_mesh_object(surface.vtk(), name=f'fault_surface_{surface.name}') - print("Adding fault surfaces...") + self.viewer.add_mesh_object( + surface.vtk(), + name=f'fault_surface_{surface.name}', + source_feature=surface.name, + source_type='fault_surface', + isovalue=0.0, + ) + logger.debug("Adding fault surfaces...") def add_stratigraphic_surfaces(self): if not self.model_manager: - print("Model manager is not set.") + logger.debug("Model manager is not set.") return stratigraphic_surfaces = self.model_manager.model.get_stratigraphic_surfaces() for surface in stratigraphic_surfaces: - self.viewer.add_mesh_object(surface.vtk(), name=surface.name,color=surface.colour) - print("Adding stratigraphic surfaces...") + self.viewer.add_mesh_object( + surface.vtk(), + name=surface.name, + color=surface.colour, + source_feature=surface.name, + source_type='stratigraphic_surface', + ) + + def _on_model_update(self, event: str, *args): + """Called when the underlying model_manager notifies observers. + + We remove any meshes that were created from model features and re-add + them from the current model so visualisation follows model changes. + + If the notification is for a specific feature (event == 'feature_updated') + and an isovalue is provided (either as second arg or stored in viewer + metadata), only the matching surface will be re-added. For generic + 'model_updated' notifications the previous behaviour (re-add all + affected feature representations) is preserved. + """ + logger.debug(f"Model update event received: {event} with args: {args}") + logger.debug([f"Mesh: {name}, Meta: {meta}" for name, meta in self.viewer.meshes.items()]) + if not self.model_manager or not self.viewer: + return + if event not in ('model_updated', 'feature_updated'): + return + feature_name = None + if event == 'feature_updated' and len(args) >= 1: + feature_name = args[0] + # Build a set of features that currently have viewer meshes + affected_features = set() + for _, meta in list(self.viewer.meshes.items()): + if feature_name is not None: + if meta.get('source_feature', None) == feature_name: + affected_features.add(feature_name) + logger.debug(f"Updating visualisation for feature: {feature_name}") + continue + + sf = meta.get('source_feature', None) + + if sf is not None: + affected_features.add(sf) + logger.debug(f"Affected features to update: {affected_features}") + # For each affected feature, only update existing meshes tied to that feature + for feature_name in affected_features: + # collect mesh names that belong to this feature (snapshot to avoid mutation while iterating) + meshes_for_feature = [ + name + for name, meta in list(self.viewer.meshes.items()) + if meta.get('source_feature') == feature_name + ] + logger.debug(f"Re-adding meshes for feature: {feature_name}: {meshes_for_feature}") + + for mesh_name in meshes_for_feature: + meta = self.viewer.meshes.get(mesh_name, {}) + source_type = meta.get('source_type') + kwargs = meta.get('kwargs', {}) or {} + isovalue = meta.get('isovalue', None) + + # remove existing actor/entry so add_mesh_object can recreate with same name + try: + self.viewer.remove_object(mesh_name) + logger.debug(f"Removed existing mesh: {mesh_name}") + except Exception: + logger.debug(f"Failed to remove existing mesh: {mesh_name}") + pass + + try: + # Surfaces associated with individual features + if source_type == 'feature_surface': + surfaces = [] + try: + if isovalue is not None: + surfaces = self.model_manager.model[feature_name].surfaces(isovalue) + else: + surfaces = self.model_manager.model[feature_name].surfaces() + + if surfaces: + add_name = mesh_name + logger.debug( + f"Re-adding surface for feature: {feature_name} with isovalue: {isovalue}" + ) + self.viewer.add_mesh_object( + surfaces[0].vtk(), + name=add_name, + source_feature=feature_name, + source_type='feature_surface', + isovalue=isovalue, + **kwargs, + ) + continue + except Exception as e: + logger.debug( + f"Failed to find matching surface for feature: {feature_name} with isovalue: {isovalue}, trying all surfaces. Error: {e}" + ) + + # Fault surfaces (added via add_fault_surfaces) + if source_type == 'fault_surface': + try: + fault_surfaces = self.model_manager.model.get_fault_surfaces() + match = next( + (s for s in fault_surfaces if str(s.name) == str(feature_name)), + None, + ) + if match is not None: + logger.debug(f"Re-adding fault surface for: {feature_name}") + self.viewer.add_mesh_object( + match.vtk(), + name=mesh_name, + source_feature=feature_name, + source_type='fault_surface', + isovalue=meta.get('isovalue', 0.0), + **kwargs, + ) + continue + except Exception as e: + logger.debug(f"Failed to re-add fault surface for {feature_name}: {e}") + + # Stratigraphic surfaces (added via add_stratigraphic_surfaces) + if source_type == 'stratigraphic_surface': + try: + strat_surfaces = self.model_manager.model.get_stratigraphic_surfaces() + match = next( + (s for s in strat_surfaces if str(s.name) == str(feature_name)), + None, + ) + if match is not None: + logger.debug(f"Re-adding stratigraphic surface for: {feature_name}") + self.viewer.add_mesh_object( + match.vtk(), + name=mesh_name, + color=getattr(match, 'colour', None), + source_feature=feature_name, + source_type='stratigraphic_surface', + **kwargs, + ) + continue + except Exception as e: + logger.debug( + f"Failed to re-add stratigraphic surface for {feature_name}: {e}" + ) + + # Vectors, points, scalar fields and other feature related objects + if source_type == 'feature_vector' or source_type == 'feature_vectors': + try: + self.add_vector_field(feature_name) + continue + except Exception as e: + logger.debug(f"Failed to re-add vector field for {feature_name}: {e}") + + if source_type in ('feature_points', 'feature_data'): + try: + self.add_data(feature_name) + continue + except Exception as e: + logger.debug(f"Failed to re-add data for {feature_name}: {e}") + + if source_type == 'feature_scalar': + try: + self.add_scalar_field(feature_name) + continue + except Exception as e: + logger.debug(f"Failed to re-add scalar field for {feature_name}: {e}") + + if source_type == 'bounding_box' or mesh_name == 'model_bounding_box': + try: + self.add_model_bounding_box() + continue + except Exception as e: + logger.debug(f"Failed to re-add bounding box: {e}") + + # Fallback: if nothing matched, attempt to re-add by using viewer metadata + # Many viewer entries store the vtk source under meta['vtk'] or similar; try best-effort + try: + vtk_src = meta.get('vtk') + if vtk_src is not None: + logger.debug(f"Fallback re-add for mesh {mesh_name}") + self.viewer.add_mesh_object(vtk_src, name=mesh_name, **kwargs) + except Exception: + pass + + except Exception as e: + logger.debug( + f"Failed to update visualisation for feature: {feature_name}. Error: {e}" + ) + + # Refresh the viewer + try: + self.viewer.update() + except Exception: + pass diff --git a/loopstructural/gui/visualisation/loop_pyvistaqt_wrapper.py b/loopstructural/gui/visualisation/loop_pyvistaqt_wrapper.py index c08d904..9f48c15 100644 --- a/loopstructural/gui/visualisation/loop_pyvistaqt_wrapper.py +++ b/loopstructural/gui/visualisation/loop_pyvistaqt_wrapper.py @@ -1,6 +1,7 @@ +from typing import Any, Dict, Optional, Tuple + from PyQt5.QtCore import pyqtSignal from pyvistaqt import QtInteractor -from typing import Optional, Any, Dict, Tuple class LoopPyVistaQTPlotter(QtInteractor): @@ -27,7 +28,22 @@ def increment_name(self, name): name = '_'.join(parts) return name - def add_mesh_object(self, mesh, name: str, *, scalars: Optional[Any] = None, cmap: Optional[str] = None, clim: Optional[Tuple[float, float]] = None, opacity: Optional[float] = None, show_scalar_bar: bool = False, color: Optional[Tuple[float, float, float]] = None, **kwargs) -> None: + def add_mesh_object( + self, + mesh, + name: str, + *, + scalars: Optional[Any] = None, + cmap: Optional[str] = None, + clim: Optional[Tuple[float, float]] = None, + opacity: Optional[float] = None, + show_scalar_bar: bool = False, + color: Optional[Tuple[float, float, float]] = None, + source_feature: Optional[str] = None, + source_type: Optional[str] = None, + isovalue: Optional[float] = None, + **kwargs, + ) -> None: """Add a mesh to the plotter. This wrapper stores metadata to allow robust re-adding and @@ -51,8 +67,12 @@ def add_mesh_object(self, mesh, name: str, *, scalars: Optional[Any] = None, cma Whether to show a scalar bar for mapped scalars. color : Optional[tuple(float, float, float)] Solid color as (r, g, b) in 0..1; if provided, overrides scalars. - **kwargs : dict - Additional keyword arguments forwarded to the underlying pyvista add_mesh call. + source_feature : Optional[str] + Name of the geological feature (or other source identifier) that + generated this mesh. Stored for later updates. + source_type : Optional[str] + A short tag describing the kind of source (e.g. 'feature_surface', + 'fault_surface', 'bounding_box'). Returns ------- @@ -61,7 +81,7 @@ def add_mesh_object(self, mesh, name: str, *, scalars: Optional[Any] = None, cma # Remove any previous entry with the same name (to keep metadata consistent) # if name in self.meshes: # try: - + # # self.remove_object(name) # except Exception: # # ignore removal errors and proceed to add @@ -73,7 +93,7 @@ def add_mesh_object(self, mesh, name: str, *, scalars: Optional[Any] = None, cma # Build add_mesh kwargs add_kwargs: Dict[str, Any] = {} - + if use_scalar: add_kwargs['scalars'] = scalars add_kwargs['cmap'] = cmap @@ -97,7 +117,15 @@ def add_mesh_object(self, mesh, name: str, *, scalars: Optional[Any] = None, cma actor = self.add_mesh(mesh, name=name, **add_kwargs) # store the mesh, actor and kwargs for future re-adds - self.meshes[name] = {'mesh': mesh, 'actor': actor, 'kwargs': {**add_kwargs}} + # persist source metadata so callers can find meshes created from model features + self.meshes[name] = { + 'mesh': mesh, + 'actor': actor, + 'kwargs': {**add_kwargs}, + 'source_feature': source_feature, + 'source_type': source_type, + 'isovalue': isovalue, + } self.objectAdded.emit(self) def remove_object(self, name: str) -> None: diff --git a/loopstructural/gui/visualisation/object_list_widget.py b/loopstructural/gui/visualisation/object_list_widget.py index e3f9d99..d6eca25 100644 --- a/loopstructural/gui/visualisation/object_list_widget.py +++ b/loopstructural/gui/visualisation/object_list_widget.py @@ -39,7 +39,7 @@ def on_object_selected(self): if not selected_items: # if nothing selected keep the previous selection. # Need to select a new object to change its properties - return + return # For simplicity, just handle the first selected item item = selected_items[0] @@ -257,7 +257,7 @@ def export_selected_object(self): # Determine available formats based on object type and dependencies formats = [] try: - import geoh5py + import geoh5py has_geoh5py = True except ImportError: has_geoh5py = False diff --git a/loopstructural/gui/visualisation/object_properties_widget.py b/loopstructural/gui/visualisation/object_properties_widget.py index 10bed1b..f329a55 100644 --- a/loopstructural/gui/visualisation/object_properties_widget.py +++ b/loopstructural/gui/visualisation/object_properties_widget.py @@ -296,7 +296,7 @@ def setCurrentObject(self, object_name: str): # populate scalar combo self.scalar_combo.blockSignals(True) self.scalar_combo.clear() - + try: pdata = getattr(self.current_mesh, 'point_data', None) or {} cdata = getattr(self.current_mesh, 'cell_data', None) or {} diff --git a/loopstructural/gui/visualisation/visualisation_widget.py b/loopstructural/gui/visualisation/visualisation_widget.py index 9a6e488..2be5733 100644 --- a/loopstructural/gui/visualisation/visualisation_widget.py +++ b/loopstructural/gui/visualisation/visualisation_widget.py @@ -78,7 +78,7 @@ def show_properties_panel(self, show: bool): # collapse properties to 0 width self._main_splitter.setSizes([sizes[0], sizes[1], 0]) self._previous_splitter_sizes = [sizes[0], sizes[1], 0] - + def is_properties_panel_visible(self) -> bool: if not hasattr(self, '_main_splitter'): return False diff --git a/loopstructural/main/data_manager.py b/loopstructural/main/data_manager.py index 1799d47..56afc17 100644 --- a/loopstructural/main/data_manager.py +++ b/loopstructural/main/data_manager.py @@ -2,10 +2,11 @@ from collections import defaultdict import numpy as np +from LoopStructural.datatypes import BoundingBox from qgis.core import QgsPointXY, QgsProject, QgsVectorLayer from LoopStructural import FaultTopology, StratigraphicColumn -from LoopStructural.datatypes import BoundingBox +from LoopStructural.modelling.core.stratigraphic_column import StratigraphicColumnElementType from .vectorLayerWrapper import qgsLayerToGeoDataFrame @@ -58,7 +59,7 @@ def __init__(self, *, project=None, mapCanvas=None, logger=None): self.basal_contacts_callback = None self.fault_traces_callback = None self.structural_orientations_callback = None - self.stratigraphic_column_callback = None + self._stratigraphic_column_callbacks = [] self.fault_adjacency = None self.fault_stratigraphy_adjacency = None self.elevation = np.nan @@ -84,9 +85,11 @@ def onLoadProject(self): except json.JSONDecodeError as e: self.logger(message=f"Error loading data manager: {e}", log_level=2) + def onNewProject(self): self.logger(message="New project created, clearing data...", log_level=3) self.update_from_dict({}) + def set_model_manager(self, model_manager): """Set the model manager for the data manager.""" if model_manager is None: @@ -139,7 +142,15 @@ def set_basal_contacts_callback(self, callback): def set_stratigraphic_column_callback(self, callback): """Set the callback for when the stratigraphic column is updated.""" - self.stratigraphic_column_callback = callback + self._stratigraphic_column_callbacks.append(callback) + + @property + def stratigraphic_column_callback(self): + def call_all(): + for cb in self._stratigraphic_column_callbacks: + cb() + + return call_all def set_dem_callback(self, callback): """Set the callback for when the DEM layer is updated.""" @@ -188,7 +199,7 @@ def dem_function(x, y): def set_use_dem(self, use_dem): self.use_dem = use_dem self._model_manager.set_dem_function(self.dem_function) - + def set_basal_contacts(self, basal_contacts, unitname_field=None, use_z_coordinate=False): """Set the basal contacts for the model.""" self._basal_contacts = { @@ -227,6 +238,17 @@ def init_stratigraphic_column_from_basal_contacts(self): # Add the unit to the stratigraphic column if it does not already exist self._stratigraphic_column.add_unit(name=unit_name, colour=None) self.update_stratigraphy() + if self.stratigraphic_column_callback: + self.stratigraphic_column_callback() + + def get_stratigraphic_unit_names(self): + """Get the names of the stratigraphic units in the column.""" + units = [] + for u in self._stratigraphic_column.order: + if u.element_type == StratigraphicColumnElementType.UNIT: + units.append(u.name) + print(f"Unit: {u.name}") + return units def add_to_stratigraphic_column(self, unit_data): """Add a unit or unconformity to the stratigraphic column.""" @@ -234,7 +256,7 @@ def add_to_stratigraphic_column(self, unit_data): if isinstance(unit_data, dict): if unit_data.get('type') == 'unit': stratigraphic_element = self._stratigraphic_column.add_unit( - name=unit_data.get('name'), colour=unit_data.get('colour') + name=unit_data.get('name'), colour=unit_data.get('colour', None) ) elif unit_data.get('type') == 'unconformity': stratigraphic_element = self._stratigraphic_column.add_unconformity( @@ -249,12 +271,16 @@ def add_to_stratigraphic_column(self, unit_data): message=f"Added {unit_data.get('type')} '{unit_data.get('name')}' to the stratigraphic column." ) self.update_stratigraphy() + if self.stratigraphic_column_callback: + self.stratigraphic_column_callback() return stratigraphic_element def remove_from_stratigraphic_column(self, unit_uuid): """Remove a unit or unconformity from the stratigraphic column.""" self._stratigraphic_column.remove_unit(uuid=unit_uuid) self.update_stratigraphy() + if self.stratigraphic_column_callback: + self.stratigraphic_column_callback() def update_stratigraphic_column_order(self, new_order): """Update the order of units in the stratigraphic column.""" @@ -262,6 +288,8 @@ def update_stratigraphic_column_order(self, new_order): raise ValueError("new_order must be a list of unit uuids.") self._stratigraphic_column.update_order(new_order) self.update_stratigraphy() + if self.stratigraphic_column_callback: + self.stratigraphic_column_callback() def get_basal_contacts(self): """Get the basal contacts.""" @@ -332,6 +360,9 @@ def get_stratigraphic_column(self): """Get the stratigraphic column.""" return self._stratigraphic_column + def clear_stratigraphic_column(self): + self._stratigraphic_column.clear() + def update_stratigraphy(self): """Update the foliation features in the model manager.""" print("Updating stratigraphy...") @@ -541,8 +572,7 @@ def update_from_dict(self, data): if self.stratigraphic_column_callback: self.stratigraphic_column_callback() - - def find_layer_by_name(self, layer_name): + def find_layer_by_name(self, layer_name, layer_type=QgsVectorLayer): """Find a layer by name in the project.""" if layer_name is None: return None @@ -557,11 +587,11 @@ def find_layer_by_name(self, layer_name): log_level=2, ) i = 0 - while i < len(layers) and not issubclass(type(layers[i]), QgsVectorLayer): + while i < len(layers) and not issubclass(type(layers[i]), layer_type): i += 1 - if issubclass(type(layers[i]), QgsVectorLayer): + if issubclass(type(layers[i]), layer_type): return layers[i] else: self.logger(message=f"Layer '{layer_name}' is not a vector layer.", log_level=2) @@ -585,7 +615,10 @@ def add_foliation_to_model(self, foliation_name: str, *, folded_feature_name=Non ) # Convert QgsVectorLayer to GeoDataFrame if self._model_manager: self._model_manager.add_foliation( - foliation_name, foliation_data, folded_feature_name=folded_feature_name,use_z_coordinate=True + foliation_name, + foliation_data, + folded_feature_name=folded_feature_name, + use_z_coordinate=True, ) self.logger(message=f"Added foliation '{foliation_name}' to the model.") else: diff --git a/loopstructural/main/debug/export.py b/loopstructural/main/debug/export.py new file mode 100644 index 0000000..006fd7e --- /dev/null +++ b/loopstructural/main/debug/export.py @@ -0,0 +1,37 @@ +import pickle +from pathlib import Path +from typing import Any, Dict + + +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}") + with open(Path(__file__).parent / 'template.txt', 'r') as f: + template = f.read() + template = template.format(runner_name=runner_script_name.replace('.py', '')) + + debug_manager.save_debug_file(runner_script_name, template.encode("utf-8")) + debug_manager.logger(f"Exported debug package with runner script '{runner_script_name}'") + return exported diff --git a/loopstructural/main/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/helpers.py b/loopstructural/main/helpers.py new file mode 100644 index 0000000..a4e5cf0 --- /dev/null +++ b/loopstructural/main/helpers.py @@ -0,0 +1,447 @@ +""" +Helper utilities for the LoopStructural plugin. + +Includes utilities for intelligent column name matching and other common tasks. +""" + +import re +from difflib import SequenceMatcher +from typing import Dict, List, Optional, Tuple, Union, overload + +from qgis.gui import QgsMapLayerComboBox + + +class ColumnMatcher: + """ + Intelligent column name matcher that finds the best matching column from a list. + + This class uses multiple matching strategies: + 1. Exact match (case-insensitive) + 2. Common aliases/synonyms + 3. Fuzzy string matching + 4. Pattern-based matching + + Examples + -------- + >>> matcher = ColumnMatcher(['unitname', 'dip_angle', 'dip_direction', 'age_min']) + >>> matcher.find_match('DIP') + 'dip_angle' + >>> matcher.find_match('DIPDIR') + 'dip_direction' + >>> matcher.find_match('UNITNAME') + 'unitname' + + >>> # Batch matching + >>> results = matcher.find_matches(['DIP', 'DIPDIR', 'UNIT']) + >>> print(results) + {'DIP': 'dip_angle', 'DIPDIR': 'dip_direction', 'UNIT': 'unitname'} + """ + + # Common field aliases and synonyms + FIELD_ALIASES = { + 'UNITNAME': [ + 'unit_name', + 'unit', + 'unitname', + 'formation', + 'lithology', + 'rock_type', + 'geology', + 'strat_name', + 'code', + 'unitcode', + ], + 'DIP': ['dip', 'dip_angle', 'dip_value', 'inclination', 'plunge'], + 'DIPDIR': [ + 'dip_dir', + 'dip_direction', + 'dipdir', + 'dipdirection', + 'azimuth', + 'dip_azimuth', + 'strike_dir', + ], + 'STRIKE': ['strike', 'strike_angle', 'strike_direction', 'trend'], + 'MIN_AGE': ['min_age', 'minage', 'age_min', 'younger', 'min_age_ma', 'age_low'], + 'MAX_AGE': ['max_age', 'maxage', 'age_max', 'older', 'max_age_ma', 'age_high'], + 'GROUP': ['group', 'group_name', 'groupname', 'series', 'supergroup'], + 'X': ['x', 'easting', 'longitude', 'lon', 'long', 'x_coord'], + 'Y': ['y', 'northing', 'latitude', 'lat', 'y_coord'], + 'Z': ['z', 'elevation', 'altitude', 'height', 'elev', 'z_coord'], + 'ID': ['id', 'objectid', 'fid', 'gid', 'uid', 'feature_id', 'object_id'], + } + + def __init__(self, available_columns: List[str], case_sensitive: bool = False): + """ + Initialize the column matcher. + + Parameters + ---------- + available_columns : List[str] + List of available column names to match against. + case_sensitive : bool, optional + Whether to use case-sensitive matching, by default False. + """ + self.available_columns = available_columns + self.case_sensitive = case_sensitive + + # Normalize columns for matching if case-insensitive + if not case_sensitive: + self._normalized_columns = {col.lower(): col for col in available_columns if col} + else: + self._normalized_columns = {col: col for col in available_columns if col} + + @overload + def find_match( + self, target: str, threshold: float = 0.6, return_score: bool = False + ) -> Optional[str]: ... + + @overload + def find_match( + self, target: str, threshold: float, return_score: bool = True + ) -> Tuple[Optional[str], float]: ... + + def find_match( + self, target: str, threshold: float = 0.6, return_score: bool = False + ) -> Union[Optional[str], Tuple[Optional[str], float]]: + """ + Find the best matching column name for a target field. + + Parameters + ---------- + target : str + The target field name to find a match for (e.g., 'DIP', 'UNITNAME'). + threshold : float, optional + Minimum similarity score (0-1) required for a match, by default 0.6. + return_score : bool, optional + If True, return (match, score) tuple instead of just match, by default False. + + Returns + ------- + str or None or Tuple[str, float] + The best matching column name, or None if no good match found. + If return_score=True, returns (column_name, score) or (None, 0.0). + """ + if not self.available_columns: + return (None, 0.0) if return_score else None + + # Normalize target + search_target = target if self.case_sensitive else target.lower() + + # Strategy 1: Exact match + if search_target in self._normalized_columns: + match = self._normalized_columns[search_target] + return (match, 1.0) if return_score else match + + # Strategy 2: Check aliases + match, score = self._match_via_aliases(target) + if match and score >= threshold: + return (match, score) if return_score else match + + # Strategy 3: Fuzzy matching + match, score = self._fuzzy_match(target, threshold) + if match: + return (match, score) if return_score else match + + return (None, 0.0) if return_score else None + + def find_matches(self, targets: List[str], threshold: float = 0.6) -> Dict[str, Optional[str]]: + """ + Find matches for multiple target fields at once. + + Parameters + ---------- + targets : List[str] + List of target field names to find matches for. + threshold : float, optional + Minimum similarity score required for a match, by default 0.6. + + Returns + ------- + Dict[str, Optional[str]] + Dictionary mapping each target to its best match (or None). + """ + return { + target: self.find_match(target, threshold, return_score=False) for target in targets + } + + def find_best_matches( + self, targets: List[str], threshold: float = 0.6 + ) -> Dict[str, Tuple[Optional[str], float]]: + """ + Find matches with confidence scores for multiple targets. + + Parameters + ---------- + targets : List[str] + List of target field names to find matches for. + threshold : float, optional + Minimum similarity score required for a match, by default 0.6. + + Returns + ------- + Dict[str, Tuple[Optional[str], float]] + Dictionary mapping each target to (best_match, score). + """ + return {target: self.find_match(target, threshold, return_score=True) for target in targets} + + def _match_via_aliases(self, target: str) -> Tuple[Optional[str], float]: + """ + Try to match using predefined aliases. + + Parameters + ---------- + target : str + Target field name. + + Returns + ------- + Tuple[Optional[str], float] + (matched_column, confidence_score) or (None, 0.0). + """ + target_upper = target.upper() + + # Check if target is a known field type + if target_upper in self.FIELD_ALIASES: + aliases = self.FIELD_ALIASES[target_upper] + + # Try exact alias match + for alias in aliases: + search_alias = alias if self.case_sensitive else alias.lower() + if search_alias in self._normalized_columns: + return self._normalized_columns[search_alias], 0.95 + + # Try fuzzy match within aliases + best_match = None + best_score = 0.0 + + for alias in aliases: + for col_norm, col_orig in self._normalized_columns.items(): + score = self._similarity(alias.lower(), col_norm) + if score > best_score: + best_score = score + best_match = col_orig + + if best_score >= 0.7: + return best_match, best_score + + return None, 0.0 + + def _fuzzy_match(self, target: str, threshold: float) -> Tuple[Optional[str], float]: + """ + Perform fuzzy string matching. + + Parameters + ---------- + target : str + Target field name. + threshold : float + Minimum similarity threshold. + + Returns + ------- + Tuple[Optional[str], float] + (matched_column, confidence_score) or (None, 0.0). + """ + search_target = target if self.case_sensitive else target.lower() + + best_match = None + best_score = 0.0 + + for col_norm, col_orig in self._normalized_columns.items(): + score = self._similarity(search_target, col_norm) + if score > best_score: + best_score = score + best_match = col_orig + + if best_score >= threshold: + return best_match, best_score + + return None, 0.0 + + @staticmethod + def _similarity(a: str, b: str) -> float: + """ + Calculate similarity between two strings. + + Uses multiple metrics and returns the best score: + - SequenceMatcher ratio (overall similarity) + - Substring matching (one contains the other) + - Word-based matching (split by _ or -) + - Keyword matching (target word found in column) + + Parameters + ---------- + a : str + First string (typically the target field to search for). + b : str + Second string (typically the column name to match against). + + Returns + ------- + float + Similarity score between 0 and 1. + """ + # Normalize + a_lower = a.lower() + b_lower = b.lower() + + # Exact match + if a_lower == b_lower: + return 1.0 + + # Sequence matcher (overall similarity) + seq_score = SequenceMatcher(None, a_lower, b_lower).ratio() + + # Substring matching + if a_lower in b_lower or b_lower in a_lower: + # Give higher score if one is substring of other + len_ratio = min(len(a_lower), len(b_lower)) / max(len(a_lower), len(b_lower)) + substring_score = 0.8 * len_ratio + else: + substring_score = 0.0 + + # Word-based matching (split by common separators) + a_words = set(re.split(r'[_\-\s]+', a_lower)) + b_words = set(re.split(r'[_\-\s]+', b_lower)) + + # Remove empty strings from word sets + a_words.discard('') + b_words.discard('') + + if a_words and b_words: + intersection = a_words & b_words + union = a_words | b_words + word_score = len(intersection) / len(union) if union else 0.0 + + # Boost score if target is a single word and it's found in column words + # e.g., searching for "geology" should match "hamersley_geology" + if len(a_words) == 1 and a_words.issubset(b_words): + word_score = max(word_score, 0.75) + else: + word_score = 0.0 + + # Return the best score + return max(seq_score, substring_score, word_score) + + def get_suggestions(self, target: str, top_n: int = 3) -> List[Tuple[str, float]]: + """ + Get top N suggestions for a target field. + + Parameters + ---------- + target : str + Target field name. + top_n : int, optional + Number of suggestions to return, by default 3. + + Returns + ------- + List[Tuple[str, float]] + List of (column_name, score) tuples sorted by score descending. + """ + search_target = target if self.case_sensitive else target.lower() + + scores = [] + for col_norm, col_orig in self._normalized_columns.items(): + score = self._similarity(search_target, col_norm) + scores.append((col_orig, score)) + + # Sort by score descending + scores.sort(key=lambda x: x[1], reverse=True) + + return scores[:top_n] + + +def find_column_match( + available_columns: List[str], target: str, threshold: float = 0.6, case_sensitive: bool = False +) -> Optional[str]: + """ + Convenience function to find a single column match. + + This is a simplified interface to ColumnMatcher for one-off matches. + + Parameters + ---------- + available_columns : List[str] + List of available column names. + target : str + Target field name to match. + threshold : float, optional + Minimum similarity threshold, by default 0.6. + case_sensitive : bool, optional + Whether to use case-sensitive matching, by default False. + + Returns + ------- + str or None + Best matching column name, or None if no match found. + + Examples + -------- + >>> columns = ['unitname', 'dip_angle', 'dip_direction'] + >>> find_column_match(columns, 'DIP') + 'dip_angle' + >>> find_column_match(columns, 'DIPDIR') + 'dip_direction' + """ + matcher = ColumnMatcher(available_columns, case_sensitive) + return matcher.find_match(target, threshold) + + +def find_column_matches( + available_columns: List[str], + targets: List[str], + threshold: float = 0.6, + case_sensitive: bool = False, +) -> Dict[str, Optional[str]]: + """ + Convenience function to find multiple column matches at once. + + Parameters + ---------- + available_columns : List[str] + List of available column names. + targets : List[str] + List of target field names to match. + threshold : float, optional + Minimum similarity threshold, by default 0.6. + case_sensitive : bool, optional + Whether to use case-sensitive matching, by default False. + + Returns + ------- + Dict[str, Optional[str]] + Dictionary mapping each target to its best match. + + Examples + -------- + >>> columns = ['unitname', 'dip_angle', 'dip_direction', 'min_age'] + >>> targets = ['DIP', 'DIPDIR', 'UNITNAME', 'MIN_AGE'] + >>> find_column_matches(columns, targets) + {'DIP': 'dip_angle', 'DIPDIR': 'dip_direction', 'UNITNAME': 'unitname', 'MIN_AGE': 'min_age'} + """ + matcher = ColumnMatcher(available_columns, case_sensitive) + return matcher.find_matches(targets, threshold) + + +def get_layer_names(combo_box: QgsMapLayerComboBox): + """ + Get all layers from a QgsMapLayerComboBox. + + Parameters + ---------- + combo_box : QgsMapLayerComboBox + The combo box to extract layers from. + + Returns + ------- + List[QgsMapLayer] + List of layers in the combo box. + """ + layers = [] + for i in range(combo_box.count()): + layer = combo_box.layer(i) + if layer is not None: + layers.append(layer.name()) + return layers diff --git a/loopstructural/main/m2l_api.py b/loopstructural/main/m2l_api.py new file mode 100644 index 0000000..83503f8 --- /dev/null +++ b/loopstructural/main/m2l_api.py @@ -0,0 +1,555 @@ +import pandas as pd +from map2loop.contact_extractor import ContactExtractor +from map2loop.sampler import SamplerDecimator, SamplerSpacing +from map2loop.sorter import ( + SorterAgeBased, + SorterAlpha, + SorterMaximiseContacts, + SorterObservationProjections, + SorterUseNetworkX, +) +from map2loop.thickness_calculator import InterpolatedStructure, StructuralPoint +from osgeo import gdal + +from ..main.vectorLayerWrapper import qgsLayerToDataFrame, qgsLayerToGeoDataFrame +from .debug.export import export_debug_package + +# Mapping of sorter names to sorter classes +SORTER_LIST = { + "Age based": SorterAgeBased, + "NetworkX topological": SorterUseNetworkX, + "Adjacency α": SorterAlpha, + "Maximise contacts": SorterMaximiseContacts, + "Observation projections": SorterObservationProjections, +} +PARAMETERS_DICTIONARY = { + "Age based": SorterAgeBased.required_arguments, + "NetworkX topological": SorterUseNetworkX.required_arguments, + "Adjacency α": SorterAlpha.required_arguments, + "Maximise contacts": SorterMaximiseContacts.required_arguments, + "Observation projections": SorterObservationProjections.required_arguments, +} + + +def extract_basal_contacts( + geology, + stratigraphic_order, + faults=None, + ignore_units=None, + unit_name_field=None, + all_contacts=False, + updater=None, + debug_manager=None, +): + """Extract basal contacts from geological data. + + Parameters + ---------- + geology : QgsVectorLayer or GeoDataFrame + Geological layer as a GeoDataFrame or QgsVectorLayer. + stratigraphic_order : list + List defining the stratigraphic order of units. + faults : QgsVectorLayer or GeoDataFrame, optional + Faults layer as a GeoDataFrame or QgsVectorLayer, by default None. + ignore_units : list, optional + List of unit names to ignore, by default None. + unit_name_field : str, optional + Name of the field containing unit names, by default None. + all_contacts : bool, optional + Whether to return all contacts in addition to basal contacts, by default False. + updater : callable, optional + Callback function for progress updates, by default None. + + Returns + ------- + dict + Dictionary containing 'basal_contacts' GeoDataFrame and optionally 'all_contacts' GeoDataFrame. + """ + geology = qgsLayerToGeoDataFrame(geology) + if unit_name_field and unit_name_field in geology.columns: + mask = ~geology[unit_name_field].astype(str).str.strip().isin(ignore_units or []) + geology = geology[mask].reset_index(drop=True) + if updater: + updater(f"filtered by unit name field: {unit_name_field}") + else: + if updater: + updater(f"no unit name field found: {unit_name_field}") + + 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) + + if ignore_units: + basal_contacts = basal_contacts[ + ~basal_contacts['UNITNAME'].astype(str).str.strip().isin(ignore_units) + ].reset_index(drop=True) + if all_contacts: + return {'basal_contacts': basal_contacts, 'all_contacts': all_contacts_result} + return {'basal_contacts': basal_contacts} + + +def sort_stratigraphic_column( + geology, + sorting_algorithm="Observation projections", + unit_name_field="UNITNAME", + min_age_field=None, + max_age_field=None, + unitname1_field=None, + unitname2_field=None, + structure=None, + dip_field="DIP", + dipdir_field="DIPDIR", + orientation_type="Dip Direction", + dtm=None, + debug_manager=None, + updater=None, + contacts=None, +): + """Sort stratigraphic units using map2loop sorters. + + Parameters + ---------- + geology : QgsVectorLayer or GeoDataFrame + Geology polygon layer. + contacts : QgsVectorLayer or GeoDataFrame + Contacts line layer. + sorting_algorithm : str, optional + Name of the sorting algorithm, by default "Observation projections". + unit_name_field : str, optional + Name of the unit name field, by default "UNITNAME". + min_age_field : str, optional + Name of the minimum age field, by default None. + max_age_field : str, optional + Name of the maximum age field, by default None. + group_field : str, optional + Name of the group field, by default None. + structure : QgsVectorLayer or GeoDataFrame, optional + Structure point layer, by default None. + dip_field : str, optional + Name of the dip field, by default "DIP". + dipdir_field : str, optional + Name of the dip direction field, by default "DIPDIR". + orientation_type : str, optional + Type of orientation ("Dip Direction" or "Strike"), by default "Dip Direction". + dtm : QgsRasterLayer or GDAL dataset, optional + Digital terrain model, by default None. + updater : callable, optional + Callback function for progress updates, by default None. + + Returns + ------- + list + List of unit names sorted from youngest to oldest. + """ + if updater: + updater(f"Sorting using {sorting_algorithm}...") + + # Get the sorter class + sorter_cls = SORTER_LIST.get(sorting_algorithm, SorterObservationProjections) + required_args = getattr(sorter_cls, 'required_arguments', []) + + # Convert layers to GeoDataFrames + geology_gdf = qgsLayerToGeoDataFrame(geology) + contacts_gdf = qgsLayerToGeoDataFrame(contacts) + + # Log parameters via DebugManager if provided + if debug_manager: + debug_manager.log_params( + "sort_stratigraphic_column", + { + "sorting_algorithm": sorting_algorithm, + "unit_name_field": unit_name_field, + "min_age_field": min_age_field, + "max_age_field": max_age_field, + "orientation_type": orientation_type, + "dtm": dtm, + "geology": geology_gdf, + "contacts": contacts_gdf, + }, + ) + + # Build units DataFrame + if ( + unit_name_field + 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) + 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[[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[[unit_name_field, max_age_field]].drop_duplicates(), + on=unit_name_field, + how='left', + ) + # Build relationships DataFrame (contacts without geometry) + relationships_df = contacts_gdf.copy() + if 'geometry' in relationships_df.columns: + relationships_df = relationships_df.drop(columns=['geometry']) + if 'length' in relationships_df.columns: + relationships_df = relationships_df.drop(columns=['length']) + + # Prepare all possible arguments + all_args = { + 'geology_data': geology_gdf, + 'contacts': contacts_gdf, + 'relationships': relationships_df, + 'unit_name_field': unit_name_field, + 'min_age_column': min_age_field, + 'max_age_column': max_age_field, + 'unitname1_field': unitname1_field, + 'unitname2_field': unitname2_field, + 'structure': qgsLayerToGeoDataFrame(structure) if structure is not None else None, + 'dip_field': dip_field, + 'dipdir_field': dipdir_field, + 'orientation_type': orientation_type, + 'dtm': dtm, + 'updater': updater, + '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") + + return order + + +def sample_contacts( + spatial_data, + sampler_type="Spacing", + decimation=None, + spacing=None, + dtm=None, + geology=None, + debug_manager=None, + updater=None, +): + """Sample spatial data using map2loop samplers. + + Parameters + ---------- + spatial_data : QgsVectorLayer or GeoDataFrame + Spatial data to sample (points or lines). + sampler_type : str, optional + Type of sampler ("Decimator" or "Spacing"), by default "Spacing". + decimation : int, optional + Decimation factor for Decimator, by default None. + spacing : float, optional + Spacing for Spacing sampler, by default None. + dtm : QgsRasterLayer or GDAL dataset, optional + Digital terrain model, by default None. + geology : QgsVectorLayer or GeoDataFrame, optional + Geology polygon layer, by default None. + updater : callable, optional + Callback function for progress updates, by default None. + + Returns + ------- + GeoDataFrame + Sampled data as GeoDataFrame. + """ + if updater: + updater(f"Sampling using {sampler_type}...") + + # Convert spatial data to GeoDataFrame + spatial_gdf = qgsLayerToGeoDataFrame(spatial_data) + + # Convert DTM to GDAL dataset if needed + dtm_gdal = None + if dtm is not None: + if hasattr(dtm, 'source'): # It's a QgsRasterLayer + dtm_gdal = gdal.Open(dtm.source()) + else: + dtm_gdal = dtm + + # Convert geology to GeoDataFrame if provided + geology_gdf = None + 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: + raise ValueError("decimation parameter is required for Decimator sampler") + sampler = SamplerDecimator( + decimation=decimation, dtm_data=dtm_gdal, geology_data=geology_gdf + ) + else: # Spacing + if spacing is None: + raise ValueError("spacing parameter is required for Spacing sampler") + sampler = SamplerSpacing(spacing=spacing, dtm_data=dtm_gdal, geology_data=geology_gdf) + + samples = sampler.sample(spatial_gdf) + + 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) + + return samples + + +def calculate_thickness( + geology, + basal_contacts, + sampled_contacts, + structure, + calculator_type="InterpolatedStructure", + dtm=None, + unit_name_field="UNITNAME", + dip_field="DIP", + dipdir_field="DIPDIR", + orientation_type="Dip Direction", + max_line_length=None, + stratigraphic_order=None, + debug_manager=None, + updater=None, + basal_contacts_unit_name=None, +): + """Calculate thickness using map2loop thickness calculators. + + Parameters + ---------- + geology : QgsVectorLayer or GeoDataFrame + Geology polygon layer. + basal_contacts : QgsVectorLayer or GeoDataFrame + Basal contacts line layer. + sampled_contacts : QgsVectorLayer or GeoDataFrame + Sampled contacts point layer. + structure : QgsVectorLayer or GeoDataFrame + Structure point layer with orientation data. + calculator_type : str, optional + Type of calculator ("InterpolatedStructure" or "StructuralPoint"), by default "InterpolatedStructure". + dtm : QgsRasterLayer or GDAL dataset, optional + Digital terrain model, by default None. + unit_name_field : str, optional + Name of the unit name field, by default "UNITNAME". + dip_field : str, optional + Name of the dip field, by default "DIP". + dipdir_field : str, optional + Name of the dip direction field, by default "DIPDIR". + orientation_type : str, optional + Type of orientation ("Dip Direction" or "Strike"), by default "Dip Direction". + max_line_length : float, optional + Maximum line length for StructuralPoint calculator, by default None. + stratigraphic_order : list, optional + List of unit names in stratigraphic order, by default None. + updater : callable, optional + Callback function for progress updates, by default None. + + Returns + ------- + GeoDataFrame + Calculated thickness data as GeoDataFrame. + """ + if updater: + updater(f"Calculating thickness using {calculator_type}...") + + # Convert layers to GeoDataFrames + geology_gdf = qgsLayerToGeoDataFrame(geology) + basal_contacts_gdf = qgsLayerToGeoDataFrame(basal_contacts) + basal_contacts_gdf = ( + basal_contacts_gdf.rename(columns={basal_contacts_unit_name: 'basal_unit'}) + if basal_contacts_unit_name + else basal_contacts_gdf + ) + 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], + 'maxy': geology_gdf.total_bounds[3], + 'miny': geology_gdf.total_bounds[1], + } + # Rename unit name field if needed + if unit_name_field and unit_name_field != 'UNITNAME': + if unit_name_field in geology_gdf.columns: + geology_gdf = geology_gdf.rename(columns={unit_name_field: 'UNITNAME'}) + + # Handle dip field + if dip_field and dip_field != 'DIP' and dip_field in structure_gdf.columns: + structure_gdf = structure_gdf.rename(columns={dip_field: 'DIP'}) + + # Handle dip direction field based on orientation type + if dipdir_field and dipdir_field in structure_gdf.columns: + if orientation_type == 'Strike': + structure_gdf['DIPDIR'] = structure_gdf[dipdir_field].apply( + lambda val: (val + 90.0) % 360.0 if pd.notna(val) else val + ) + elif orientation_type == 'Dip Direction': + structure_gdf = structure_gdf.rename(columns={dipdir_field: 'DIPDIR'}) + + # Convert DTM to GDAL dataset if needed + dtm_gdal = None + if dtm is not None: + if hasattr(dtm, 'source'): # It's a QgsRasterLayer + dtm_gdal = gdal.Open(dtm.source()) + else: + dtm_gdal = dtm + + # Run thickness calculator + if calculator_type == "InterpolatedStructure": + calculator = InterpolatedStructure( + bounding_box=bounding_box, + dtm_data=dtm_gdal, + is_strike=orientation_type == 'Strike', + max_line_length=max_line_length, + ) + else: # StructuralPoint + if max_line_length is None: + raise ValueError("max_line_length parameter is required for StructuralPoint calculator") + calculator = StructuralPoint( + bounding_box=bounding_box, + dtm_data=dtm_gdal, + is_strike=orientation_type == 'Strike', + max_line_length=max_line_length, + ) + if unit_name_field != 'UNITNAME' and unit_name_field in geology_gdf.columns: + geology_gdf = geology_gdf.rename(columns={unit_name_field: 'UNITNAME'}) + units = geology_gdf.copy() + + 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, + basal_contacts_gdf, + structure_gdf, + geology_gdf, + sampled_contacts_gdf, + ) + # Ensure result object exists for return and for any debug export + res = {'thicknesses': thickness} + return res diff --git a/loopstructural/main/model_manager.py b/loopstructural/main/model_manager.py index f08daa5..9ea9578 100644 --- a/loopstructural/main/model_manager.py +++ b/loopstructural/main/model_manager.py @@ -10,7 +10,8 @@ """ from collections import defaultdict -from typing import Callable, Optional +from contextlib import contextmanager +from typing import Callable, Optional, Union import geopandas as gpd import numpy as np @@ -23,6 +24,7 @@ from LoopStructural.modelling.features import FeatureType, StructuralFrame from LoopStructural.modelling.features.fold import FoldFrame from loopstructural.toolbelt.preferences import PlgSettingsStructure +from LoopStructural.utils.observer import Observable class AllSampler: @@ -72,19 +74,24 @@ def __call__(self, line: gpd.GeoDataFrame, dem: Callable, use_z: bool) -> pd.Dat z = dem(coords[0], coords[1]) else: z = 0 - points.append({'X': coords[0], 'Y': coords[1], 'Z': z, 'feature_id': feature_id, **attributes}) + points.append( + {'X': coords[0], 'Y': coords[1], 'Z': z, 'feature_id': feature_id, **attributes} + ) feature_id += 1 df = pd.DataFrame(points) return df -class GeologicalModelManager: +class GeologicalModelManager(Observable): """This class manages the geological model and assembles it from the data provided by the data manager. It is responsible for updating the model with faults, stratigraphy, and other geological features. """ def __init__(self): """Initialize the geological model manager.""" + # Initialize Observable state + super().__init__() + self.model = GeologicalModel([0, 0, 0], [1, 1, 1]) self.stratigraphy = {} self.groups = [] @@ -92,12 +99,41 @@ def __init__(self): self.stratigraphy = defaultdict(dict) self.stratigraphic_column = None self.fault_topology = None - self.observers = [] + # Observers managed by Observable base class self.dem_function = lambda x, y: 0 + # internal flag to temporarily suppress notifications (used when + # updates are performed in background threads) + self._suppress_notifications = False + + @contextmanager + def suspend_notifications(self): + prev = getattr(self, '_suppress_notifications', False) + self._suppress_notifications = True + try: + yield + finally: + self._suppress_notifications = prev + + def _emit(self, *args, **kwargs): + """Emit an observer notification unless notifications are suppressed. + + This wrapper should be used instead of calling self.notify directly from + the manager so callers can suppress notifications when running updates + on background threads. + """ + if getattr(self, '_suppress_notifications', False): + return + try: + self.notify(*args, **kwargs) + except Exception: + # be tolerant of observer errors + pass def set_stratigraphic_column(self, stratigraphic_column: StratigraphicColumn): """Set the stratigraphic column for the geological model manager.""" self.stratigraphic_column = stratigraphic_column + # changing the stratigraphic column changes model geometry + self._emit('stratigraphic_column_changed') def set_fault_topology(self, fault_topology): """Set the fault topology for the geological model manager.""" @@ -191,6 +227,8 @@ def update_fault_points( for fault_name in existing_faults: self.fault_topology.remove_fault(fault_name) + # signal that fault point input changed + self._emit('data_changed', 'fault_points') def update_contact_traces( self, @@ -235,6 +273,9 @@ def update_contact_traces( unit_points['unit_name'] == unit_name, ['X', 'Y', 'Z'] ] + # signal that input data changed — consumers may choose to rebuild the model + self._emit('data_changed', 'contact_traces') + def update_structural_data( self, structural_orientations: gpd.GeoDataFrame, @@ -277,6 +318,9 @@ def update_structural_data( ] self.stratigraphy[unit_name]['orientations'] = orientations + # signal structural orientation data changed + self._emit('data_changed', 'structural_orientations') + def update_stratigraphic_column(self, stratigraphic_column: StratigraphicColumn): """Update the stratigraphic column with a new stratigraphic column""" self.stratigraphic_column = stratigraphic_column @@ -330,6 +374,8 @@ def update_foliation_features(self): ) self.model.add_unconformity(foliation, 0) self.model.stratigraphic_column = self.stratigraphic_column + # foliation features were rebuilt; let observers know + self._emit('foliation_features_updated') def update_fault_features(self): """Update the fault features in the geological model.""" @@ -348,7 +394,6 @@ def update_fault_features(self): dip = fault_data['data']['dip'].mean() else: dip = 90 - print(f"Fault {fault_name} dip: {dip}") if 'pitch' in fault_data['data']: pitch = fault_data['data']['pitch'].mean() @@ -390,18 +435,76 @@ def valid(self): valid = False return valid - def update_model(self): - """Update the geological model with the current stratigraphy and faults.""" + def update_model(self, notify_observers: bool = True): + """Update the geological model with the current stratigraphy and faults. + + Parameters + ---------- + notify_observers : bool + If True (default) observers will be notified after the model update + completes. If False, the caller is responsible for notifying + observers from the main thread (useful when performing the update + in a background thread). + """ self.model.features = [] self.model.feature_name_index = {} + # Notify start (if requested) so UI can react + if notify_observers: + self._emit('model_update_started') + # Update the model with stratigraphy self.update_fault_features() self.update_foliation_features() - for observer in self.observers: - observer() + # Notify observers using the Observable framework if requested + if notify_observers: + self._emit('model_updated') + self._emit('model_update_finished') + + def update_feature(self, feature_name: str): + """Update a specific feature in the geological model. + + Parameters + ---------- + feature_name : str + Name of the feature to update. + """ + feature = self.model.get_feature_by_name(feature_name) + if feature is None: + raise ValueError(f"Feature '{feature_name}' not found in the model.") + # Allow UI to react to a feature update + self._emit('model_update_started') + feature.builder.update() + # Notify observers and include feature name for interested listeners + self._emit('feature_updated', feature_name) + self._emit('model_update_finished') + + def update_all_features(self, subset: Optional[Union[list, str]] = None): + """Update all features in the geological model.""" + # Allow UI to react to a feature update + self._emit('model_update_started') + if subset is not None: + + if isinstance(subset, str): + if subset == 'faults': + subset = [f.name for f in self.model.features if f.type == FeatureType.FAULT] + elif subset == 'stratigraphy' or subset == 'foliations': + subset = [ + f.name for f in self.model.features if f.type == FeatureType.FOLIATION + ] + else: + subset = [subset] + for feature_name in subset: + feature = self.model.get_feature_by_name(feature_name) + if feature is not None: + feature.builder.update() + else: + self.model.update() + # Notify observers and include feature name for interested listeners + self._emit('all_features_updated') + self._emit('model_update_finished') def features(self): """Return the list of features currently held by the internal model. @@ -449,7 +552,7 @@ def add_foliation( """ # for z dfs = [] - kwargs={} + kwargs = {} for layer_data in data.values(): if layer_data['type'] == 'Orientation': df = sampler(layer_data['df'], self.dem_function, use_z_coordinate) @@ -471,20 +574,16 @@ def add_foliation( df['u'] = df[layer_data['upper_field']] df['feature_name'] = name dfs.append(df[['X', 'Y', 'Z', 'l', 'u', 'feature_name']]) - kwargs['solver']='admm' + kwargs['solver'] = 'admm' else: raise ValueError(f"Unknown layer type: {layer_data['type']}") - self.model.create_and_add_foliation(name, data=pd.concat(dfs, ignore_index=True), **kwargs) - # if folded_feature_name is not None: - # from LoopStructural.modelling.features._feature_converters import add_fold_to_feature - - # folded_feature = self.model.get_feature_by_name(folded_feature_name) - # folded_feature_name = add_fold_to_feature(frame, folded_feature) - # self.model[folded_feature_name] = folded_feature - for observer in self.observers: - observer() + self.model.create_and_add_foliation(name, data=pd.concat(dfs, ignore_index=True), **kwargs) + # inform listeners that a new foliation/feature was added + self._emit('model_updated') - def add_unconformity(self, foliation_name: str, value: float, type: FeatureType = FeatureType.UNCONFORMITY): + def add_unconformity( + self, foliation_name: str, value: float, type: FeatureType = FeatureType.UNCONFORMITY + ): """Add an unconformity (or onlap unconformity) to a named foliation. Parameters @@ -509,6 +608,8 @@ def add_unconformity(self, foliation_name: str, value: float, type: FeatureType self.model.add_unconformity(foliation, value) elif type == FeatureType.ONLAPUNCONFORMITY: self.model.add_onlap_unconformity(foliation, value) + # model geometry changed + self._emit('model_updated') def add_fold_to_feature(self, feature_name: str, fold_frame_name: str, fold_weights={}): """Apply a FoldFrame to an existing feature, producing a folded feature. @@ -533,8 +634,8 @@ def add_fold_to_feature(self, feature_name: str, fold_frame_name: str, fold_weig from LoopStructural.modelling.features._feature_converters import add_fold_to_feature fold_frame = self.model.get_feature_by_name(fold_frame_name) - if isinstance(fold_frame,StructuralFrame): - fold_frame = FoldFrame(fold_frame.name,fold_frame.features, None, fold_frame.model) + if isinstance(fold_frame, StructuralFrame): + fold_frame = FoldFrame(fold_frame.name, fold_frame.features, None, fold_frame.model) if fold_frame is None: raise ValueError(f"Fold frame '{fold_frame_name}' not found in the model.") feature = self.model.get_feature_by_name(feature_name) @@ -542,6 +643,8 @@ def add_fold_to_feature(self, feature_name: str, fold_frame_name: str, fold_weig raise ValueError(f"Feature '{feature_name}' not found in the model.") folded_feature = add_fold_to_feature(feature, fold_frame) self.model[feature_name] = folded_feature + # feature replaced/modified + self._emit('model_updated') def convert_feature_to_structural_frame(self, feature_name: str): """Convert an interpolated feature into a StructuralFrame. @@ -560,13 +663,17 @@ def convert_feature_to_structural_frame(self, feature_name: str): builder = self.model.get_feature_by_name(feature_name).builder new_builder = StructuralFrameBuilder.from_feature_builder(builder) self.model[feature_name] = new_builder.frame + # feature converted + self._emit('model_updated') @property def fold_frames(self): """Return the fold frames in the model.""" return [f for f in self.model.features if f.type == FeatureType.STRUCTURALFRAME] - def evaluate_feature_on_points(self, feature_name: str, points: np.ndarray, scalar_type: str = 'scalar') -> np.ndarray: + def evaluate_feature_on_points( + self, feature_name: str, points: np.ndarray, scalar_type: str = 'scalar' + ) -> np.ndarray: """Evaluate a model feature at the provided points. Parameters @@ -641,14 +748,15 @@ def export_feature_values_to_geodataframe( GeoDataFrame containing point geometries and computed value columns (and any provided attributes). """ - import pandas as _pd import geopandas as _gpd + import pandas as _pd + try: from shapely.geometry import Point as _Point except Exception: - print("Shapely not available; geometry column will be omitted." ) + print("Shapely not available; geometry column will be omitted.") _Point = None - + pts = np.asarray(points) if pts.ndim != 2 or pts.shape[1] < 3: raise ValueError('points must be an Nx3 array') @@ -676,7 +784,9 @@ def export_feature_values_to_geodataframe( if attributes is not None: try: attributes = _pd.DataFrame(attributes).reset_index(drop=True) - df = _pd.concat([df.reset_index(drop=True), attributes.reset_index(drop=True)], axis=1) + df = _pd.concat( + [df.reset_index(drop=True), attributes.reset_index(drop=True)], axis=1 + ) except Exception: # ignore attributes if they cannot be combined pass @@ -692,7 +802,7 @@ def export_feature_values_to_geodataframe( return gdf - def export_feature_values_to_vtk_mesh(self, name, mesh, scalar_type='scalar'): + def export_feature_values_to_vtk_mesh(self, name, mesh, scalar_type='scalar'): """Evaluate a feature on a mesh's points and attach the values as a field. Parameters @@ -713,4 +823,4 @@ def export_feature_values_to_vtk_mesh(self, name, mesh, scalar_type='scalar'): pts = mesh.points values = self.evaluate_feature_on_points(name, pts, scalar_type=scalar_type) mesh[name] = values - return mesh \ No newline at end of file + return mesh diff --git a/loopstructural/main/vectorLayerWrapper.py b/loopstructural/main/vectorLayerWrapper.py index 4ec74d8..b4ed3e9 100644 --- a/loopstructural/main/vectorLayerWrapper.py +++ b/loopstructural/main/vectorLayerWrapper.py @@ -26,7 +26,6 @@ ) from qgis.PyQt.QtCore import QDateTime, QVariant from shapely.geometry import LineString, MultiLineString, MultiPoint, MultiPolygon, Point, Polygon -from shapely.wkb import loads as wkb_loads def qgsRasterToGdalDataset(rlayer: QgsRasterLayer): @@ -254,25 +253,41 @@ def vertices_from_geometry(geom): def GeoDataFrameToQgsLayer( - qgs_algorithm, geodataframe, parameters, context, output_key, feedback=None + qgs_algorithm, geodataframe, parameters=None, context=None, output_key=None, feedback=None ): """ - Write a GeoPandas GeoDataFrame directly to a QGIS Processing FeatureSink. + Write a GeoPandas GeoDataFrame to a QGIS layer (Processing or non-Processing context). + + When used in a Processing algorithm context (parameters, context, output_key provided): + Returns the dest_id for a feature sink. + + When used outside Processing (parameters=None): + Returns a QgsVectorLayer (memory layer). Parameters ---------- - alg : QgsProcessingAlgorithm (self) - gdf : geopandas.GeoDataFrame - parameters : dict (from processAlgorithm) - context : QgsProcessingContext - output_key : str (e.g. self.OUTPUT) + qgs_algorithm : QgsProcessingAlgorithm (self) or None + Processing algorithm instance. Can be None for non-processing usage. + geodataframe : geopandas.GeoDataFrame + The GeoDataFrame to convert + parameters : dict (from processAlgorithm) or None + If None, creates a memory layer instead of a sink + context : QgsProcessingContext or None + output_key : str or None + e.g. self.OUTPUT (only needed for processing context) feedback : QgsProcessingFeedback | None Returns ------- - str : dest_id to return from processAlgorithm, e.g. { output_key: dest_id } + str or QgsVectorLayer + dest_id if in processing context, QgsVectorLayer if non-processing context """ + # Non-processing context: delegate to geodataframeToMemoryLayer + if parameters is None or context is None or output_key is None: + layer_name = getattr(qgs_algorithm, 'name', lambda: 'GeoDataFrame Layer')() + return geodataframeToMemoryLayer(geodataframe, layer_name) + if feedback is None: class _Dummy: @@ -760,3 +775,199 @@ def qvariantToFloat(f, field_name): return float(val) except Exception: return None + + +def geodataframeToMemoryLayer(geodataframe, layer_name: str = "GeoDataFrame Layer"): + """ + Convert a GeoPandas GeoDataFrame to a QGIS memory layer (non-processing context). + + This function works outside of the QGIS Processing framework and can be used + in GUI components, plugins, or standalone scripts. + + Parameters + ---------- + geodataframe : geopandas.GeoDataFrame + The GeoDataFrame to convert + layer_name : str + Name for the created layer + + Returns + ------- + QgsVectorLayer + The created QGIS vector layer with features from the GeoDataFrame + """ + from qgis.core import QgsFeature, QgsGeometry, QgsVectorLayer + + if geodataframe is None or geodataframe.empty: + raise ValueError("GeoDataFrame is None or empty") + + # --- Infer WKB type (family, Multi, Z) + def _infer_wkb(series): + base = None + any_multi = False + has_z = False + for geom in series: + if geom is None: + continue + if getattr(geom, "is_empty", False): + continue + # multi? + if isinstance(geom, (MultiPoint, MultiLineString, MultiPolygon)): + any_multi = True + g0 = next(iter(getattr(geom, "geoms", [])), None) + gt = getattr(g0, "geom_type", None) or None + else: + gt = getattr(geom, "geom_type", None) + + # base family + if gt in ("Point", "LineString", "Polygon"): + base = gt + # z? + try: + has_z = has_z or bool(getattr(geom, "has_z", False)) + except Exception as e: + print("Error checking geometry Z value", e) + if base: + break + + if base is None: + # default safely to LineString if everything is empty + base = "LineString" + + fam = { + "Point": QgsWkbTypes.Point, + "LineString": QgsWkbTypes.LineString, + "Polygon": QgsWkbTypes.Polygon, + }[base] + + if any_multi: + fam = QgsWkbTypes.multiType(fam) + if has_z: + fam = QgsWkbTypes.addZ(fam) + return fam + + wkb_type = _infer_wkb(geodataframe.geometry) + + # --- Build CRS from gdf.crs + crs = QgsCoordinateReferenceSystem() + if geodataframe.crs is not None: + try: + crs = QgsCoordinateReferenceSystem.fromWkt(geodataframe.crs.to_wkt()) + except Exception: + try: + epsg = geodataframe.crs.to_epsg() + if epsg: + crs = QgsCoordinateReferenceSystem.fromEpsgId(int(epsg)) + except Exception as e: + print("Error building CRS from EPSG", e) + pass + + # --- Build QGIS fields from pandas dtypes + fields = QgsFields() + non_geom_cols = [c for c in geodataframe.columns if c != geodataframe.geometry.name] + + def _qvariant_type(dtype) -> QVariant.Type: + if pd.api.types.is_integer_dtype(dtype): + return QVariant.Int + if pd.api.types.is_float_dtype(dtype): + return QVariant.Double + if pd.api.types.is_bool_dtype(dtype): + return QVariant.Bool + if pd.api.types.is_datetime64_any_dtype(dtype): + return QVariant.DateTime + return QVariant.String + + for col in non_geom_cols: + fields.append(QgsField(str(col), _qvariant_type(geodataframe[col].dtype))) + + # --- Create memory layer + geom_type_str = QgsWkbTypes.displayString(wkb_type) + crs_str = crs.authid() if crs.isValid() else "EPSG:4326" + uri = f"{geom_type_str}?crs={crs_str}" + + layer = QgsVectorLayer(uri, layer_name, "memory") + if not layer.isValid(): + raise RuntimeError(f"Failed to create memory layer: {layer_name}") + + # Add fields to the layer + layer.dataProvider().addAttributes(list(fields)) + layer.updateFields() + + # --- Write features + is_multi_wkb = QgsWkbTypes.isMultiType(wkb_type) + features = [] + + for _, row in geodataframe.iterrows(): + geom = row[geodataframe.geometry.name] + if geom is None or getattr(geom, "is_empty", False): + continue + + # Promote single → multi if needed + if is_multi_wkb: + if isinstance(geom, Point): + geom = MultiPoint([geom]) + elif isinstance(geom, LineString): + geom = MultiLineString([geom]) + elif isinstance(geom, Polygon): + geom = MultiPolygon([geom]) + + f = QgsFeature(fields) + + # Attributes in declared order + attrs = [] + for col in non_geom_cols: + val = row[col] + if isinstance(val, np.generic): + try: + val = val.item() + except Exception: + # don't crash UI on conversion failure + pass + if pd.api.types.is_datetime64_any_dtype(geodataframe[col].dtype): + if pd.isna(val): + val = None + else: + val = QDateTime(val.to_pydatetime()) + attrs.append(val) + f.setAttributes(attrs) + + # Geometry (shapely → QGIS) + try: + f.setGeometry(QgsGeometry.fromWkb(geom.wkb)) + except Exception: + f.setGeometry(QgsGeometry.fromWkt(geom.wkt)) + + features.append(f) + + # Add all features at once + layer.dataProvider().addFeatures(features) + layer.updateExtents() + + return layer + + +def addGeoDataFrameToproject(geodataframe, layer_name: str = "GeoDataFrame Layer"): + """ + Add a GeoPandas GeoDataFrame as a temporary layer to the current QGIS project. + + Parameters + ---------- + geodataframe : geopandas.GeoDataFrame + The GeoDataFrame to add to the project + layer_name : str + Name of the layer in QGIS (default: "GeoDataFrame Layer") + + Returns + ------- + QgsVectorLayer + The created and added QGIS vector layer. + """ + from qgis.core import QgsProject + + # Create the memory layer + layer = geodataframeToMemoryLayer(geodataframe, layer_name) + + # Add to project + QgsProject.instance().addMapLayer(layer) + + return layer diff --git a/loopstructural/plugin_main.py b/loopstructural/plugin_main.py index 19d01c8..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 @@ -90,6 +99,8 @@ def injectLogHandler(self): """ import logging + from map2loop.logging import setLogging as setLogging_m2l + import LoopStructural from loopstructural.toolbelt.log_handler import PlgLoggerHandler @@ -97,6 +108,7 @@ def injectLogHandler(self): handler.setFormatter(logging.Formatter('%(name)s - %(levelname)s - %(message)s')) LoopStructural.setLogging(level="warning", handler=handler) + setLogging_m2l(level="warning", handler=handler) def initGui(self): """Set up plugin UI elements.""" @@ -108,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"), @@ -137,11 +154,81 @@ 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) self.initProcessing() + # Map2Loop tool actions + self.action_sampler = QAction( + "Sampler", + self.iface.mainWindow(), + ) + self.action_sampler.triggered.connect(self.show_sampler_dialog) + + self.action_sorter = QAction( + "Automatic Stratigraphic Sorter", + self.iface.mainWindow(), + ) + self.action_sorter.triggered.connect(self.show_sorter_dialog) + + self.action_user_sorter = QAction( + "User-Defined Stratigraphic Column", + self.iface.mainWindow(), + ) + self.action_user_sorter.triggered.connect(self.show_user_sorter_dialog) + + self.action_basal_contacts = QAction( + QIcon(os.path.dirname(__file__) + "/resources/images/basal_contacts.png"), + "Extract Basal Contacts", + self.iface.mainWindow(), + ) + self.action_basal_contacts.triggered.connect(self.show_basal_contacts_dialog) + + self.action_thickness = QAction( + "Thickness Calculator", + self.iface.mainWindow(), + ) + self.action_thickness.triggered.connect(self.show_thickness_dialog) + + # Add all map2loop tool actions to the toolbar + self.toolbar.addAction(self.action_sampler) + self.toolbar.addAction(self.action_sorter) + 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 + self.toolbar.addAction(self.action_sampler) + self.toolbar.addAction(self.action_sorter) + self.toolbar.addAction(self.action_user_sorter) + self.toolbar.addAction(self.action_basal_contacts) + self.toolbar.addAction(self.action_thickness) + + self.action_thickness = QAction( + "Thickness Calculator", + self.iface.mainWindow(), + ) + self.action_thickness.triggered.connect(self.show_thickness_dialog) + + 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.initProcessing() + # -- Help menu # documentation @@ -255,6 +342,61 @@ def initGui(self): self.modelling_dockwidget = None self.visualisation_dockwidget = None + 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, + 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, + 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, + 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, + 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, + debug_manager=self.debug_manager, + ) + dialog.exec_() + def tr(self, message: str) -> str: """Translate a string using Qt translation API. @@ -276,53 +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.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/extract_basal_contacts.py b/loopstructural/processing/algorithms/extract_basal_contacts.py index 6446b13..d07b529 100644 --- a/loopstructural/processing/algorithms/extract_basal_contacts.py +++ b/loopstructural/processing/algorithms/extract_basal_contacts.py @@ -12,9 +12,7 @@ from typing import Any, Optional # QGIS imports -from qgis import processing from qgis.core import ( - QgsFeatureSink, QgsProcessing, QgsProcessingAlgorithm, QgsProcessingContext, @@ -22,22 +20,21 @@ QgsProcessingFeedback, QgsProcessingParameterFeatureSink, QgsProcessingParameterFeatureSource, - QgsProcessingParameterMapLayer, - QgsProcessingParameterString, QgsProcessingParameterField, + QgsProcessingParameterMapLayer, QgsProcessingParameterMatrix, - QgsVectorLayer, - QgsSettings + QgsSettings, ) + +from ...main.m2l_api import extract_basal_contacts + # Internal imports -from ...main.vectorLayerWrapper import qgsLayerToGeoDataFrame, GeoDataFrameToQgsLayer -from map2loop.contact_extractor import ContactExtractor +from ...main.vectorLayerWrapper import GeoDataFrameToQgsLayer class BasalContactsAlgorithm(QgsProcessingAlgorithm): """Processing algorithm to create basal contacts.""" - - + INPUT_GEOLOGY = 'GEOLOGY' INPUT_FAULTS = 'FAULTS' INPUT_STRATI_COLUMN = 'STRATIGRAPHIC_COLUMN' @@ -63,7 +60,7 @@ def groupId(self) -> str: def initAlgorithm(self, config: Optional[dict[str, Any]] = None) -> None: """Initialize the algorithm parameters.""" - + self.addParameter( QgsProcessingParameterFeatureSource( self.INPUT_GEOLOGY, @@ -77,7 +74,7 @@ def initAlgorithm(self, config: Optional[dict[str, Any]] = None) -> None: 'Unit Name Field', parentLayerParameterName=self.INPUT_GEOLOGY, type=QgsProcessingParameterField.String, - defaultValue='unitname' + defaultValue='unitname', ) ) @@ -105,10 +102,10 @@ def initAlgorithm(self, config: Optional[dict[str, Any]] = None) -> None: "Unit(s) to ignore", headers=["Unit"], defaultValue=last_ignore_units, - optional=True + optional=True, ) ) - + self.addParameter( QgsProcessingParameterFeatureSink( self.OUTPUT, @@ -136,57 +133,52 @@ def processAlgorithm( strati_column = self.parameterAsSource(parameters, self.INPUT_STRATI_COLUMN, context) ignore_units = self.parameterAsMatrix(parameters, self.INPUT_IGNORE_UNITS, context) - - if isinstance(strati_column, QgsProcessingParameterMapLayer) : + if isinstance(strati_column, QgsProcessingParameterMapLayer): raise QgsProcessingException("Invalid stratigraphic column layer") - + elif strati_column is not None: # extract unit names from strati_column field_name = "unit_name" strati_order = [f[field_name] for f in strati_column.getFeatures()] - - if not ignore_units or all(isinstance(unit, str) and not unit.strip() for unit in ignore_units): + + if not ignore_units or all( + isinstance(unit, str) and not unit.strip() for unit in ignore_units + ): feedback.pushInfo("no units to ignore specified") ignore_settings = QgsSettings() ignore_settings.setValue("m2l/ignore_units", ignore_units) unit_name_field = self.parameterAsString(parameters, 'UNIT_NAME_FIELD', context) - - geology = qgsLayerToGeoDataFrame(geology) - if unit_name_field and unit_name_field in geology.columns: - mask = ~geology[unit_name_field].astype(str).str.strip().isin(ignore_units) - geology = geology[mask].reset_index(drop=True) - feedback.pushInfo(f"filtered by unit name field: {unit_name_field}") - else: - feedback.pushInfo(f"no unit name field found: {unit_name_field}") - - faults = qgsLayerToGeoDataFrame(faults) if faults else None - if unit_name_field != 'UNITNAME' and unit_name_field in geology.columns: - geology = geology.rename(columns={unit_name_field: 'UNITNAME'}) - - feedback.pushInfo("Extracting Basal Contacts...") - contact_extractor = ContactExtractor(geology, faults) - all_contacts = contact_extractor.extract_all_contacts() - basal_contacts = contact_extractor.extract_basal_contacts(strati_order) - + + result = extract_basal_contacts( + geology=geology, + stratigraphic_order=strati_order, + faults=faults, + ignore_units=ignore_units, + unit_name_field=unit_name_field, + all_contacts=False, + updater=feedback.pushInfo, + ) + basal_contacts = result['basal_contacts'] + all_contacts = result['all_contacts'] feedback.pushInfo("Exporting Basal Contacts Layer...") basal_contacts = GeoDataFrameToQgsLayer( - self, + self, basal_contacts, parameters=parameters, context=context, output_key=self.OUTPUT, feedback=feedback, - ) + ) contacts_layer = GeoDataFrameToQgsLayer( - self, + self, all_contacts, parameters=parameters, context=context, output_key=self.ALL_CONTACTS, feedback=feedback, - ) + ) return {self.OUTPUT: basal_contacts, self.ALL_CONTACTS: contacts_layer} def createInstance(self) -> QgsProcessingAlgorithm: diff --git a/loopstructural/processing/algorithms/sampler.py b/loopstructural/processing/algorithms/sampler.py index 66191c3..d31abc8 100644 --- a/loopstructural/processing/algorithms/sampler.py +++ b/loopstructural/processing/algorithms/sampler.py @@ -10,13 +10,12 @@ """ # Python imports from typing import Any, Optional -from qgis.PyQt.QtCore import QMetaType, QVariant +from qgis.PyQt.QtCore import QVariant from osgeo import gdal import pandas as pd # QGIS imports from qgis.core import ( - QgsFeatureSink, QgsProcessing, QgsProcessingAlgorithm, QgsProcessingContext, @@ -32,7 +31,6 @@ QgsFeature, QgsGeometry, QgsPointXY, - QgsVectorLayer, QgsWkbTypes, QgsCoordinateReferenceSystem ) @@ -71,18 +69,18 @@ def groupId(self) -> str: def initAlgorithm(self, config: Optional[dict[str, Any]] = None) -> None: """Initialize the algorithm parameters.""" - - + + self.addParameter( QgsProcessingParameterEnum( self.INPUT_SAMPLER_TYPE, "SAMPLER_TYPE", - ["Decimator (Point Geometry Data)", + ["Decimator (Point Geometry Data)", "Spacing (Line Geometry Data)"], defaultValue=0 ) ) - + self.addParameter( QgsProcessingParameterRasterLayer( self.INPUT_DTM, @@ -91,7 +89,7 @@ def initAlgorithm(self, config: Optional[dict[str, Any]] = None) -> None: optional=True, ) ) - + self.addParameter( QgsProcessingParameterFeatureSource( self.INPUT_GEOLOGY, @@ -100,7 +98,7 @@ def initAlgorithm(self, config: Optional[dict[str, Any]] = None) -> None: optional=True, ) ) - + self.addParameter( QgsProcessingParameterFeatureSource( self.INPUT_SPATIAL_DATA, @@ -119,7 +117,7 @@ def initAlgorithm(self, config: Optional[dict[str, Any]] = None) -> None: optional=True, ) ) - + self.addParameter( QgsProcessingParameterNumber( self.INPUT_SPACING, @@ -129,7 +127,7 @@ def initAlgorithm(self, config: Optional[dict[str, Any]] = None) -> None: optional=True, ) ) - + self.addParameter( QgsProcessingParameterFeatureSink( self.OUTPUT, @@ -154,28 +152,28 @@ def processAlgorithm( if spatial_data is None: raise QgsProcessingException("Spatial data is required") - + if sampler_type == "Decimator": if geology is None: raise QgsProcessingException("Geology is required") if dtm is None: raise QgsProcessingException("DTM is required") - + # Convert geology layers to GeoDataFrames geology = qgsLayerToGeoDataFrame(geology) spatial_data_gdf = qgsLayerToGeoDataFrame(spatial_data) dtm_gdal = gdal.Open(dtm.source()) if dtm is not None and dtm.isValid() else None - + if sampler_type == "Decimator": feedback.pushInfo("Sampling...") sampler = SamplerDecimator(decimation=decimation, dtm_data=dtm_gdal, geology_data=geology) samples = sampler.sample(spatial_data_gdf) - + if sampler_type == "Spacing": feedback.pushInfo("Sampling...") sampler = SamplerSpacing(spacing=spacing, dtm_data=dtm_gdal, geology_data=geology) samples = sampler.sample(spatial_data_gdf) - + fields = QgsFields() fields.append(QgsField("ID", QVariant.String)) fields.append(QgsField("X", QVariant.Double)) @@ -199,7 +197,7 @@ def processAlgorithm( if samples is not None and not samples.empty: for _index, row in samples.iterrows(): feature = QgsFeature(fields) - + # decimator has z values if 'Z' in samples.columns and pd.notna(row.get('Z')): wkt = f"POINT Z ({row['X']} {row['Y']} {row['Z']})" @@ -207,7 +205,7 @@ def processAlgorithm( else: #spacing has no z values feature.setGeometry(QgsGeometry.fromPointXY(QgsPointXY(row['X'], row['Y']))) - + feature.setAttributes([ str(row.get('ID', '')), float(row.get('X', 0)), @@ -215,11 +213,11 @@ def processAlgorithm( float(row.get('Z', 0)) if pd.notna(row.get('Z')) else 0.0, str(row.get('featureId', '')) ]) - + sink.addFeature(feature) return {self.OUTPUT: dest_id} def createInstance(self) -> QgsProcessingAlgorithm: """Create a new instance of the algorithm.""" - return self.__class__() # SamplerAlgorithm() \ No newline at end of file + return self.__class__() # SamplerAlgorithm() diff --git a/loopstructural/processing/algorithms/sorter.py b/loopstructural/processing/algorithms/sorter.py index 55a179c..bb816fd 100644 --- a/loopstructural/processing/algorithms/sorter.py +++ b/loopstructural/processing/algorithms/sorter.py @@ -4,14 +4,11 @@ import json from PyQt5.QtCore import QVariant -from qgis import processing from qgis.core import ( QgsFeatureSink, - QgsFields, - QgsField, - QgsFeature, - QgsGeometry, - QgsRasterLayer, + QgsFields, + QgsField, + QgsFeature, QgsProcessing, QgsProcessingAlgorithm, QgsProcessingContext, @@ -23,11 +20,8 @@ QgsProcessingParameterFeatureSource, QgsProcessingParameterField, QgsProcessingParameterRasterLayer, - QgsProcessingParameterMatrix, - QgsCoordinateReferenceSystem, QgsVectorLayer, - QgsWkbTypes, - QgsSettings + QgsWkbTypes ) # ──────────────────────────────────────────────── @@ -81,7 +75,7 @@ def group(self) -> str: def groupId(self) -> str: return "Stratigraphy_Column" - + def updateParameters(self, parameters): selected_method = parameters.get(self.METHOD, 0) selected_algorithm = parameters.get(self.SORTING_ALGORITHM, 0) @@ -94,7 +88,7 @@ def updateParameters(self, parameters): self.parameterDefinition(self.INPUT_GEOLOGY).setMetadata({'widget_wrapper': {'visible': True}}) self.parameterDefinition(self.SORTING_ALGORITHM).setMetadata({'widget_wrapper': {'visible': True}}) self.parameterDefinition(self.INPUT_STRATI_COLUMN).setMetadata({'widget_wrapper': {'visible': False}}) - + # observation projects is_observation_projections = selected_algorithm == 5 self.parameterDefinition(self.INPUT_STRUCTURE).setMetadata({'widget_wrapper': {'visible': is_observation_projections}}) @@ -102,7 +96,7 @@ def updateParameters(self, parameters): self.parameterDefinition('DIP_FIELD').setMetadata({'widget_wrapper': {'visible': is_observation_projections}}) self.parameterDefinition('DIPDIR_FIELD').setMetadata({'widget_wrapper': {'visible': is_observation_projections}}) self.parameterDefinition('ORIENTATION_TYPE').setMetadata({'widget_wrapper': {'visible': is_observation_projections}}) - + return super().updateParameters(parameters) # ---------------------------------------------------------- @@ -150,7 +144,7 @@ def initAlgorithm(self, config: Optional[dict[str, Any]] = None) -> None: optional=True ) ) - + self.addParameter( QgsProcessingParameterField( 'MAX_AGE_FIELD', @@ -161,7 +155,7 @@ def initAlgorithm(self, config: Optional[dict[str, Any]] = None) -> None: optional=True ) ) - + self.addParameter( QgsProcessingParameterField( 'GROUP_FIELD', @@ -228,7 +222,7 @@ def initAlgorithm(self, config: Optional[dict[str, Any]] = None) -> None: optional=False, ) ) - + self.addParameter( QgsProcessingParameterFeatureSink( self.OUTPUT, @@ -259,7 +253,7 @@ def processAlgorithm( contacts_layer = self.parameterAsVectorLayer(parameters, self.CONTACTS_LAYER, context) in_layer = self.parameterAsVectorLayer(parameters, self.INPUT_GEOLOGY, context) output_file = self.parameterAsFileOutput(parameters, 'JSON_OUTPUT', context) - + units_df, relationships_df, contacts_df= build_input_frames(in_layer,contacts_layer, feedback,parameters) if sorter_cls == SorterObservationProjections: @@ -331,7 +325,7 @@ def processAlgorithm( try: with open(output_file, 'w') as f: json.dump(order, f) - except Exception as e: + except Exception: with open(output_file, 'w') as f: json.dump([], f) @@ -389,7 +383,7 @@ def build_input_frames(layer: QgsVectorLayer,contacts_layer: QgsVectorLayer, fee units_records.append( dict( layerId=f.id(), - name=f[unit_name_field], + name=f[unit_name_field], minAge=qvariantToFloat(f, min_age_field), maxAge=qvariantToFloat(f, max_age_field), group=f[group_field], diff --git a/loopstructural/processing/algorithms/thickness_calculator.py b/loopstructural/processing/algorithms/thickness_calculator.py index 8d67dc5..de239c8 100644 --- a/loopstructural/processing/algorithms/thickness_calculator.py +++ b/loopstructural/processing/algorithms/thickness_calculator.py @@ -8,39 +8,38 @@ * * *************************************************************************** """ + # Python imports from typing import Any, Optional + import pandas as pd +from map2loop.thickness_calculator import InterpolatedStructure, StructuralPoint # QGIS imports -from qgis import processing from qgis.core import ( - QgsFeatureSink, QgsProcessing, QgsProcessingAlgorithm, 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, + dataframeToQgsTable, matrixToDict, - dataframeToQgsTable - ) -from map2loop.thickness_calculator import InterpolatedStructure, StructuralPoint + qgsLayerToDataFrame, + qgsLayerToGeoDataFrame, + qgsRasterToGdalDataset, +) class ThicknessCalculatorAlgorithm(QgsProcessingAlgorithm): @@ -56,6 +55,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 +82,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 +100,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 +144,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 +160,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 +173,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 +195,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 +204,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 +213,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 +242,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 +253,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 +269,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 +334,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 +349,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 +365,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 +387,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/processing/algorithms/user_defined_sorter.py b/loopstructural/processing/algorithms/user_defined_sorter.py index 23857a3..7b4c96c 100644 --- a/loopstructural/processing/algorithms/user_defined_sorter.py +++ b/loopstructural/processing/algorithms/user_defined_sorter.py @@ -1,44 +1,20 @@ -from typing import Any, Optional -from osgeo import gdal import numpy as np -import json from PyQt5.QtCore import QVariant -from qgis import processing from qgis.core import ( QgsFeatureSink, - QgsFields, - QgsField, - QgsFeature, - QgsGeometry, - QgsRasterLayer, - QgsProcessing, + QgsFields, + QgsField, + QgsFeature, QgsProcessingAlgorithm, - QgsProcessingContext, - QgsProcessingException, - QgsProcessingFeedback, - QgsProcessingParameterEnum, - QgsProcessingParameterFileDestination, QgsProcessingParameterFeatureSink, - QgsProcessingParameterFeatureSource, - QgsProcessingParameterField, - QgsProcessingParameterRasterLayer, QgsProcessingParameterMatrix, QgsCoordinateReferenceSystem, - QgsVectorLayer, QgsWkbTypes, QgsSettings ) -from qgis.core import ( - QgsFields, QgsField, QgsFeature, QgsFeatureSink, QgsWkbTypes, - QgsCoordinateReferenceSystem, QgsProcessingAlgorithm, QgsProcessingContext, - QgsProcessingFeedback, QgsProcessingParameterFeatureSink, QgsProcessingParameterMatrix, - QgsSettings -) -from PyQt5.QtCore import QVariant -import numpy as np class UserDefinedStratigraphyAlgorithm(QgsProcessingAlgorithm): INPUT_STRATI_COLUMN = "INPUT_STRATI_COLUMN" diff --git a/loopstructural/requirements.txt b/loopstructural/requirements.txt index b3904c8..307611d 100644 --- a/loopstructural/requirements.txt +++ b/loopstructural/requirements.txt @@ -1,6 +1,7 @@ pyvistaqt pyvista -LoopStructural==1.6.22 +LoopStructural==1.6.24 pyqtgraph loopsolver -geopandas \ No newline at end of file +geopandas +numpy==1.26.4 diff --git a/loopstructural/resources/images/basal_contacts.png b/loopstructural/resources/images/basal_contacts.png new file mode 100644 index 0000000..77c12fd Binary files /dev/null and b/loopstructural/resources/images/basal_contacts.png differ diff --git a/loopstructural/resources/images/basal_contacts.svg b/loopstructural/resources/images/basal_contacts.svg new file mode 100644 index 0000000..029ec34 --- /dev/null +++ b/loopstructural/resources/images/basal_contacts.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/loopstructural/toolbelt/log_handler.py b/loopstructural/toolbelt/log_handler.py index 0e97360..31001a0 100644 --- a/loopstructural/toolbelt/log_handler.py +++ b/loopstructural/toolbelt/log_handler.py @@ -9,10 +9,10 @@ # standard library import logging from functools import partial -from typing import Callable, Literal, Optional, Union +from typing import Callable # PyQGIS -from qgis.core import Qgis, QgsMessageLog, QgsMessageOutput +from qgis.core import QgsMessageLog, QgsMessageOutput from qgis.gui import QgsMessageBar from qgis.PyQt.QtWidgets import QPushButton, QWidget from qgis.utils import iface diff --git a/loopstructural/toolbelt/preferences.py b/loopstructural/toolbelt/preferences.py index f40cfd4..1214d2e 100644 --- a/loopstructural/toolbelt/preferences.py +++ b/loopstructural/toolbelt/preferences.py @@ -7,6 +7,7 @@ """ # standard +import logging from dataclasses import asdict, dataclass, fields # PyQGIS @@ -27,6 +28,7 @@ class PlgSettingsStructure: # global debug_mode: bool = False + debug_directory: str = "" version: str = __version__ interpolator_type: str = 'FDI' interpolator_nelements: int = 10000 @@ -43,6 +45,21 @@ class PlgOptionsManager: plugin to persist user preferences such as debug mode and UI options. """ + @staticmethod + def _configure_logging(debug_mode: bool): + """Configure Python logging level according to plugin debug setting. + + When debug_mode is True the root logger level is set to DEBUG so that + any logger.debug(...) calls in the plugin will be emitted. When False + the level is set to INFO to reduce verbosity. + """ + try: + root = logging.getLogger() + root.setLevel(logging.DEBUG if bool(debug_mode) else logging.INFO) + except Exception: + # Best-effort: do not raise from logging configuration issues + pass + @staticmethod def get_plg_settings() -> PlgSettingsStructure: """Load and return plugin settings as a PlgSettingsStructure instance. @@ -73,6 +90,9 @@ def get_plg_settings() -> PlgSettingsStructure: settings.endGroup() + # Ensure logging level matches the loaded debug_mode preference + PlgOptionsManager._configure_logging(options.debug_mode) + return options @staticmethod @@ -117,6 +137,28 @@ def get_value_from_key(key: str, default=None, exp_type=None): return out_value + @classmethod + def get_debug_mode(cls) -> bool: + """Get the current debug mode setting. + + Returns + ------- + bool + True if debug mode is enabled, False otherwise. + """ + 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. @@ -148,6 +190,13 @@ def set_value_from_key(cls, key: str, value) -> bool: try: settings.setValue(key, value) out_value = True + + # If debug mode was toggled, immediately apply logging configuration + if key == "debug_mode": + try: + PlgOptionsManager._configure_logging(value) + except Exception: + pass except Exception as err: log_hdlr.PlgLogger.log( message="Error occurred trying to set settings: {}.Trace: {}".format(key, err) diff --git a/tests/qgis/input/faults_clip.cpg b/tests/qgis/input/faults_clip.cpg index cd89cb9..57decb4 100644 --- a/tests/qgis/input/faults_clip.cpg +++ b/tests/qgis/input/faults_clip.cpg @@ -1 +1 @@ -ISO-8859-1 \ No newline at end of file +ISO-8859-1 diff --git a/tests/qgis/input/faults_clip.prj b/tests/qgis/input/faults_clip.prj index 51bb0e5..6fe083f 100644 --- a/tests/qgis/input/faults_clip.prj +++ b/tests/qgis/input/faults_clip.prj @@ -1 +1 @@ -PROJCS["GDA94_MGA_zone_50",GEOGCS["GCS_GDA94",DATUM["D_Geocentric_Datum_of_Australia_1994",SPHEROID["GRS_1980",6378137.0,298.257222101]],PRIMEM["Greenwich",0.0],UNIT["Degree",0.0174532925199433]],PROJECTION["Transverse_Mercator"],PARAMETER["False_Easting",500000.0],PARAMETER["False_Northing",10000000.0],PARAMETER["Central_Meridian",117.0],PARAMETER["Scale_Factor",0.9996],PARAMETER["Latitude_Of_Origin",0.0],UNIT["Meter",1.0]] \ No newline at end of file +PROJCS["GDA94_MGA_zone_50",GEOGCS["GCS_GDA94",DATUM["D_Geocentric_Datum_of_Australia_1994",SPHEROID["GRS_1980",6378137.0,298.257222101]],PRIMEM["Greenwich",0.0],UNIT["Degree",0.0174532925199433]],PROJECTION["Transverse_Mercator"],PARAMETER["False_Easting",500000.0],PARAMETER["False_Northing",10000000.0],PARAMETER["Central_Meridian",117.0],PARAMETER["Scale_Factor",0.9996],PARAMETER["Latitude_Of_Origin",0.0],UNIT["Meter",1.0]] diff --git a/tests/qgis/input/folds_clip.cpg b/tests/qgis/input/folds_clip.cpg index cd89cb9..57decb4 100644 --- a/tests/qgis/input/folds_clip.cpg +++ b/tests/qgis/input/folds_clip.cpg @@ -1 +1 @@ -ISO-8859-1 \ No newline at end of file +ISO-8859-1 diff --git a/tests/qgis/input/folds_clip.prj b/tests/qgis/input/folds_clip.prj index 51bb0e5..6fe083f 100644 --- a/tests/qgis/input/folds_clip.prj +++ b/tests/qgis/input/folds_clip.prj @@ -1 +1 @@ -PROJCS["GDA94_MGA_zone_50",GEOGCS["GCS_GDA94",DATUM["D_Geocentric_Datum_of_Australia_1994",SPHEROID["GRS_1980",6378137.0,298.257222101]],PRIMEM["Greenwich",0.0],UNIT["Degree",0.0174532925199433]],PROJECTION["Transverse_Mercator"],PARAMETER["False_Easting",500000.0],PARAMETER["False_Northing",10000000.0],PARAMETER["Central_Meridian",117.0],PARAMETER["Scale_Factor",0.9996],PARAMETER["Latitude_Of_Origin",0.0],UNIT["Meter",1.0]] \ No newline at end of file +PROJCS["GDA94_MGA_zone_50",GEOGCS["GCS_GDA94",DATUM["D_Geocentric_Datum_of_Australia_1994",SPHEROID["GRS_1980",6378137.0,298.257222101]],PRIMEM["Greenwich",0.0],UNIT["Degree",0.0174532925199433]],PROJECTION["Transverse_Mercator"],PARAMETER["False_Easting",500000.0],PARAMETER["False_Northing",10000000.0],PARAMETER["Central_Meridian",117.0],PARAMETER["Scale_Factor",0.9996],PARAMETER["Latitude_Of_Origin",0.0],UNIT["Meter",1.0]] diff --git a/tests/qgis/input/geol_clip_no_gaps.cpg b/tests/qgis/input/geol_clip_no_gaps.cpg index cd89cb9..57decb4 100644 --- a/tests/qgis/input/geol_clip_no_gaps.cpg +++ b/tests/qgis/input/geol_clip_no_gaps.cpg @@ -1 +1 @@ -ISO-8859-1 \ No newline at end of file +ISO-8859-1 diff --git a/tests/qgis/input/geol_clip_no_gaps.prj b/tests/qgis/input/geol_clip_no_gaps.prj index 51bb0e5..6fe083f 100644 --- a/tests/qgis/input/geol_clip_no_gaps.prj +++ b/tests/qgis/input/geol_clip_no_gaps.prj @@ -1 +1 @@ -PROJCS["GDA94_MGA_zone_50",GEOGCS["GCS_GDA94",DATUM["D_Geocentric_Datum_of_Australia_1994",SPHEROID["GRS_1980",6378137.0,298.257222101]],PRIMEM["Greenwich",0.0],UNIT["Degree",0.0174532925199433]],PROJECTION["Transverse_Mercator"],PARAMETER["False_Easting",500000.0],PARAMETER["False_Northing",10000000.0],PARAMETER["Central_Meridian",117.0],PARAMETER["Scale_Factor",0.9996],PARAMETER["Latitude_Of_Origin",0.0],UNIT["Meter",1.0]] \ No newline at end of file +PROJCS["GDA94_MGA_zone_50",GEOGCS["GCS_GDA94",DATUM["D_Geocentric_Datum_of_Australia_1994",SPHEROID["GRS_1980",6378137.0,298.257222101]],PRIMEM["Greenwich",0.0],UNIT["Degree",0.0174532925199433]],PROJECTION["Transverse_Mercator"],PARAMETER["False_Easting",500000.0],PARAMETER["False_Northing",10000000.0],PARAMETER["Central_Meridian",117.0],PARAMETER["Scale_Factor",0.9996],PARAMETER["Latitude_Of_Origin",0.0],UNIT["Meter",1.0]] diff --git a/tests/qgis/input/structure_clip.cpg b/tests/qgis/input/structure_clip.cpg index cd89cb9..57decb4 100644 --- a/tests/qgis/input/structure_clip.cpg +++ b/tests/qgis/input/structure_clip.cpg @@ -1 +1 @@ -ISO-8859-1 \ No newline at end of file +ISO-8859-1 diff --git a/tests/qgis/input/structure_clip.prj b/tests/qgis/input/structure_clip.prj index 51bb0e5..6fe083f 100644 --- a/tests/qgis/input/structure_clip.prj +++ b/tests/qgis/input/structure_clip.prj @@ -1 +1 @@ -PROJCS["GDA94_MGA_zone_50",GEOGCS["GCS_GDA94",DATUM["D_Geocentric_Datum_of_Australia_1994",SPHEROID["GRS_1980",6378137.0,298.257222101]],PRIMEM["Greenwich",0.0],UNIT["Degree",0.0174532925199433]],PROJECTION["Transverse_Mercator"],PARAMETER["False_Easting",500000.0],PARAMETER["False_Northing",10000000.0],PARAMETER["Central_Meridian",117.0],PARAMETER["Scale_Factor",0.9996],PARAMETER["Latitude_Of_Origin",0.0],UNIT["Meter",1.0]] \ No newline at end of file +PROJCS["GDA94_MGA_zone_50",GEOGCS["GCS_GDA94",DATUM["D_Geocentric_Datum_of_Australia_1994",SPHEROID["GRS_1980",6378137.0,298.257222101]],PRIMEM["Greenwich",0.0],UNIT["Degree",0.0174532925199433]],PROJECTION["Transverse_Mercator"],PARAMETER["False_Easting",500000.0],PARAMETER["False_Northing",10000000.0],PARAMETER["Central_Meridian",117.0],PARAMETER["Scale_Factor",0.9996],PARAMETER["Latitude_Of_Origin",0.0],UNIT["Meter",1.0]] diff --git a/tests/qgis/test_basal_contacts.py b/tests/qgis/test_basal_contacts.py index 20d0276..9cb9a30 100644 --- a/tests/qgis/test_basal_contacts.py +++ b/tests/qgis/test_basal_contacts.py @@ -1,7 +1,6 @@ import unittest from pathlib import Path -from qgis.core import QgsVectorLayer, QgsProcessingContext, QgsProcessingFeedback, QgsMessageLog, Qgis, QgsApplication, QgsFeature, QgsField -from qgis.PyQt.QtCore import QVariant +from qgis.core import QgsVectorLayer, QgsProcessingContext, QgsProcessingFeedback, QgsMessageLog, Qgis, QgsApplication from qgis.testing import start_app from loopstructural.processing.algorithms.extract_basal_contacts import BasalContactsAlgorithm from loopstructural.processing.provider import Map2LoopProvider @@ -11,39 +10,39 @@ class TestBasalContacts(unittest.TestCase): @classmethod def setUpClass(cls): cls.qgs = start_app() - + cls.provider = Map2LoopProvider() QgsApplication.processingRegistry().addProvider(cls.provider) def setUp(self): self.test_dir = Path(__file__).parent self.input_dir = self.test_dir / "input" - + self.geology_file = self.input_dir / "geol_clip_no_gaps.shp" self.faults_file = self.input_dir / "faults_clip.shp" self.strati_file = self.input_dir / "stratigraphic_column_testing.gpkg" - + self.assertTrue(self.geology_file.exists(), f"geology not found: {self.geology_file}") self.assertTrue(self.strati_file.exists(), f"strati not found: {self.strati_file}") if not self.faults_file.exists(): QgsMessageLog.logMessage(f"faults not found: {self.faults_file}, will run test without faults", "TestBasalContacts", Qgis.Warning) def test_basal_contacts_extraction(self): - + geology_layer = QgsVectorLayer(str(self.geology_file), "geology", "ogr") - + self.assertTrue(geology_layer.isValid(), "geology layer should be valid") self.assertGreater(geology_layer.featureCount(), 0, "geology layer should have features") - + faults_layer = None if self.faults_file.exists(): faults_layer = QgsVectorLayer(str(self.faults_file), "faults", "ogr") self.assertTrue(faults_layer.isValid(), "faults layer should be valid") self.assertGreater(faults_layer.featureCount(), 0, "faults layer should have features") QgsMessageLog.logMessage(f"faults layer: {faults_layer.featureCount()} features", "TestBasalContacts", Qgis.Critical) - + QgsMessageLog.logMessage(f"geology layer: {geology_layer.featureCount()} features", "TestBasalContacts", Qgis.Critical) - + strati_table = QgsVectorLayer(str(self.strati_file), "strati", "ogr") algorithm = BasalContactsAlgorithm() algorithm.initAlgorithm() @@ -58,46 +57,46 @@ def test_basal_contacts_extraction(self): 'BASAL_CONTACTS': 'memory:basal_contacts', 'ALL_CONTACTS': 'memory:all_contacts' } - + context = QgsProcessingContext() feedback = QgsProcessingFeedback() try: QgsMessageLog.logMessage("Starting basal contacts algorithm...", "TestBasalContacts", Qgis.Critical) - + result = algorithm.processAlgorithm(parameters, context, feedback) - + QgsMessageLog.logMessage(f"Result: {result}", "TestBasalContacts", Qgis.Critical) - + self.assertIsNotNone(result, "result should not be None") self.assertIn('BASAL_CONTACTS', result, "Result should contain BASAL_CONTACTS key") self.assertIn('ALL_CONTACTS', result, "Result should contain ALL_CONTACTS key") - + basal_contacts_layer = context.takeResultLayer(result['BASAL_CONTACTS']) self.assertIsNotNone(basal_contacts_layer, "basal contacts layer should not be None") self.assertTrue(basal_contacts_layer.isValid(), "basal contacts layer should be valid") self.assertGreater(basal_contacts_layer.featureCount(), 0, "basal contacts layer should have features") - - QgsMessageLog.logMessage(f"Generated {basal_contacts_layer.featureCount()} basal contacts", + + QgsMessageLog.logMessage(f"Generated {basal_contacts_layer.featureCount()} basal contacts", "TestBasalContacts", Qgis.Critical) - + all_contacts_layer = context.takeResultLayer(result['ALL_CONTACTS']) self.assertIsNotNone(all_contacts_layer, "all contacts layer should not be None") self.assertTrue(all_contacts_layer.isValid(), "all contacts layer should be valid") self.assertGreater(all_contacts_layer.featureCount(), 0, "all contacts layer should have features") - QgsMessageLog.logMessage(f"Generated {all_contacts_layer.featureCount()} total contacts", + QgsMessageLog.logMessage(f"Generated {all_contacts_layer.featureCount()} total contacts", "TestBasalContacts", Qgis.Critical) - + QgsMessageLog.logMessage("Basal contacts test completed successfully!", "TestBasalContacts", Qgis.Critical) - + except Exception as e: QgsMessageLog.logMessage(f"Basal contacts test error: {str(e)}", "TestBasalContacts", Qgis.Critical) QgsMessageLog.logMessage(f"Error type: {type(e).__name__}", "TestBasalContacts", Qgis.Critical) import traceback QgsMessageLog.logMessage(f"Full traceback:\n{traceback.format_exc()}", "TestBasalContacts", Qgis.Critical) raise - + finally: QgsMessageLog.logMessage("=" * 50, "TestBasalContacts", Qgis.Critical) @@ -110,4 +109,4 @@ def tearDownClass(cls): pass if __name__ == '__main__': - unittest.main() \ No newline at end of file + unittest.main() diff --git a/tests/qgis/test_column_matching.py b/tests/qgis/test_column_matching.py new file mode 100644 index 0000000..2b4ae75 --- /dev/null +++ b/tests/qgis/test_column_matching.py @@ -0,0 +1,294 @@ +""" +Pytest tests for ColumnMatcher functionality. +Tests column matching with keywords in field names. +""" + +import pytest + +from loopstructural.main.helpers import ColumnMatcher + + +class TestColumnMatcher: + """Test suite for ColumnMatcher class.""" + + @pytest.fixture + def simple_columns(self): + """Fixture providing simple column names.""" + return [ + 'hamersley_geology', + 'geology_type', + 'unit_geology', + 'rock_type', + 'unitname', + 'formation', + ] + + @pytest.fixture + def complex_columns(self): + """Fixture providing complex prefixed column names.""" + return [ + 'hamersley_geology', + 'hamersley_dip', + 'hamersley_dipdir', + 'hamersley_age_min', + 'hamersley_age_max', + 'station_id', + ] + + def test_keyword_in_column_name(self, simple_columns): + """Test that 'geology' matches columns containing 'geology' as a word.""" + matcher = ColumnMatcher(simple_columns) + match, score = matcher.find_match('geology', threshold=0.6, return_score=True) + + assert match is not None, "Should find a match for 'geology'" + assert 'geology' in match.lower(), f"Match '{match}' should contain 'geology'" + assert score >= 0.6, f"Score {score} should be >= 0.6" + + def test_geology_matches_hamersley_geology(self, simple_columns): + """Test specific case: 'geology' should match 'hamersley_geology'.""" + matcher = ColumnMatcher(simple_columns) + match = matcher.find_match('geology', threshold=0.6) + + # Should match one of the geology columns + assert match in ['hamersley_geology', 'geology_type', 'unit_geology'] + + def test_unitname_alias_matching(self, simple_columns): + """Test that UNITNAME matches via geological aliases.""" + matcher = ColumnMatcher(simple_columns) + match, score = matcher.find_match('UNITNAME', threshold=0.6, return_score=True) + + assert match == 'unitname', f"UNITNAME should match 'unitname', got '{match}'" + assert score >= 0.9, f"Exact match should have high score, got {score}" + + def test_get_suggestions_returns_multiple(self, simple_columns): + """Test that get_suggestions returns multiple ranked matches.""" + matcher = ColumnMatcher(simple_columns) + suggestions = matcher.get_suggestions('geology', top_n=3) + + assert len(suggestions) <= 3, "Should return at most 3 suggestions" + assert all( + isinstance(item, tuple) for item in suggestions + ), "Each suggestion should be a tuple" + assert all(len(item) == 2 for item in suggestions), "Each tuple should have (column, score)" + + # Check scores are in descending order + scores = [score for _, score in suggestions] + assert scores == sorted(scores, reverse=True), "Suggestions should be sorted by score" + + def test_complex_prefixed_columns(self, complex_columns): + """Test matching with complex prefixed column names.""" + matcher = ColumnMatcher(complex_columns) + + test_cases = [ + ('geology', 'hamersley_geology'), + ('DIP', 'hamersley_dip'), + ('DIPDIR', 'hamersley_dipdir'), + ] + + for target, expected in test_cases: + match = matcher.find_match(target, threshold=0.6) + assert match == expected, f"'{target}' should match '{expected}', got '{match}'" + + def test_batch_matching(self, complex_columns): + """Test find_best_matches for multiple targets at once.""" + matcher = ColumnMatcher(complex_columns) + targets = ['geology', 'DIP', 'DIPDIR', 'MIN_AGE', 'MAX_AGE'] + + results = matcher.find_best_matches(targets, threshold=0.6) + + assert isinstance(results, dict), "Results should be a dictionary" + assert len(results) == len(targets), "Should return results for all targets" + + # Check specific matches + assert results['geology'][0] == 'hamersley_geology' + assert results['DIP'][0] == 'hamersley_dip' + assert results['DIPDIR'][0] == 'hamersley_dipdir' + assert results['MIN_AGE'][0] == 'hamersley_age_min' + assert results['MAX_AGE'][0] == 'hamersley_age_max' + + def test_no_match_below_threshold(self, simple_columns): + """Test that no match is returned when score is below threshold.""" + matcher = ColumnMatcher(simple_columns) + match = matcher.find_match('completely_unrelated_field', threshold=0.8) + + # With high threshold, unlikely fields shouldn't match + # This might return None or a low-scoring match depending on threshold + if match: + _, score = matcher.find_match( + 'completely_unrelated_field', threshold=0.8, return_score=True + ) + assert score >= 0.8, "If match exists, score should meet threshold" + + def test_case_insensitive_matching(self, simple_columns): + """Test that matching is case-insensitive by default.""" + matcher = ColumnMatcher(simple_columns) + + match1 = matcher.find_match('UNITNAME') + match2 = matcher.find_match('unitname') + match3 = matcher.find_match('UnitName') + + # All should match the same column + assert match1 == match2 == match3, "Case variations should match the same column" + + def test_geological_field_aliases(self): + """Test that common geological field aliases work correctly.""" + columns = ['dip_angle', 'azimuth', 'formation_name', 'x_coord', 'y_coord', 'elevation'] + matcher = ColumnMatcher(columns) + + test_cases = [ + ('DIP', 'dip_angle'), + ('DIPDIR', 'azimuth'), + ('UNITNAME', 'formation_name'), + ('X', 'x_coord'), + ('Y', 'y_coord'), + ('Z', 'elevation'), + ] + + for target, expected in test_cases: + match = matcher.find_match(target, threshold=0.6) + assert match == expected, f"Alias '{target}' should match '{expected}', got '{match}'" + + def test_empty_columns_list(self): + """Test that matcher handles empty column list gracefully.""" + matcher = ColumnMatcher([]) + match = matcher.find_match('geology') + + assert match is None, "Should return None when no columns available" + + def test_single_column(self): + """Test matcher with a single column.""" + matcher = ColumnMatcher(['geology']) + match = matcher.find_match('geology') + + assert match == 'geology', "Should match the only available column" + + @pytest.mark.parametrize("separator", ['_', '-', ' ']) + def test_different_separators(self, separator): + """Test that matching works with different word separators.""" + column_name = f'hamersley{separator}geology' + matcher = ColumnMatcher([column_name]) + match = matcher.find_match('geology', threshold=0.6) + + assert match == column_name, f"Should match with separator '{separator}'" + + def test_word_order_independence(self): + """Test that word order doesn't prevent matching.""" + columns = ['geology_hamersley', 'hamersley_geology'] + matcher = ColumnMatcher(columns) + + # Both should be viable matches for 'geology' + match = matcher.find_match('geology', threshold=0.6) + assert match in columns, "Should match one of the geology columns regardless of word order" + + def test_return_score_flag(self, simple_columns): + """Test that return_score parameter works correctly.""" + matcher = ColumnMatcher(simple_columns) + + # Without return_score + result = matcher.find_match('geology', return_score=False) + assert isinstance( + result, (str, type(None)) + ), "Without return_score should return string or None" + + # With return_score + result = matcher.find_match('geology', return_score=True) + assert isinstance(result, tuple), "With return_score should return tuple" + assert len(result) == 2, "Tuple should have (match, score)" + column, score = result + assert isinstance(score, float), "Score should be a float" + assert 0 <= score <= 1, "Score should be between 0 and 1" + + +class TestColumnMatcherEdgeCases: + """Test edge cases and special scenarios.""" + + def test_special_characters_in_column_names(self): + """Test columns with special characters.""" + columns = ['geology(type)', 'dip [degrees]', 'unit-name', 'id#'] + matcher = ColumnMatcher(columns) + + # Should still match despite special characters + match = matcher.find_match('geology') + assert match is not None, "Should handle special characters in column names" + + def test_numeric_suffixes(self): + """Test columns with numeric suffixes.""" + columns = ['geology1', 'geology2', 'geology_v3', 'dip_2023'] + matcher = ColumnMatcher(columns) + + match = matcher.find_match('geology') + assert match in columns[:3], "Should match geology columns with numeric suffixes" + + def test_very_long_column_names(self): + """Test performance with very long column names.""" + long_name = 'very_long_column_name_with_many_words_including_geology_information' + matcher = ColumnMatcher([long_name]) + + match = matcher.find_match('geology', threshold=0.5) + assert match == long_name, "Should match even with very long column names" + + def test_similar_column_names(self): + """Test disambiguation when multiple similar columns exist.""" + columns = ['dip', 'dip_angle', 'dip_direction', 'apparent_dip'] + matcher = ColumnMatcher(columns) + + match = matcher.find_match('DIP', threshold=0.6) + # Should prefer exact or closest match + assert match in ['dip', 'dip_angle'], f"Should prefer closest match, got '{match}'" + + +class TestColumnMatcherIntegration: + """Integration tests simulating real-world usage.""" + + def test_realistic_geology_dataset(self): + """Test with realistic geological field names.""" + columns = [ + 'objectid', + 'shape', + 'unit_name', + 'map_symbol', + 'dip_amount', + 'dip_azimuth', + 'strike_direction', + 'min_age_ma', + 'max_age_ma', + 'rock_group', + 'x_coordinate', + 'y_coordinate', + 'elevation_m', + ] + + matcher = ColumnMatcher(columns) + + expected_matches = { + 'UNITNAME': 'unit_name', + 'DIP': 'dip_amount', + 'DIPDIR': 'dip_azimuth', + 'STRIKE': 'strike_direction', + 'MIN_AGE': 'min_age_ma', + 'MAX_AGE': 'max_age_ma', + 'GROUP': 'rock_group', + 'X': 'x_coordinate', + 'Y': 'y_coordinate', + 'Z': 'elevation_m', + } + + for target, expected in expected_matches.items(): + match = matcher.find_match(target, threshold=0.6) + assert match == expected, f"Field '{target}' should match '{expected}', got '{match}'" + + def test_mixed_naming_conventions(self): + """Test with mixed camelCase, snake_case, and other conventions.""" + columns = ['UnitName', 'dip_angle', 'DipDirection', 'age-min', 'AGE MAX', 'rockGroup'] + + matcher = ColumnMatcher(columns) + + # Should handle different conventions + assert matcher.find_match('UNITNAME') is not None + assert matcher.find_match('DIP') is not None + assert matcher.find_match('DIPDIR') is not None + assert matcher.find_match('MIN_AGE') is not None + + +if __name__ == '__main__': + pytest.main([__file__, '-v']) 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) diff --git a/tests/qgis/test_sampler_decimator.py b/tests/qgis/test_sampler_decimator.py index c329321..27971ea 100644 --- a/tests/qgis/test_sampler_decimator.py +++ b/tests/qgis/test_sampler_decimator.py @@ -10,46 +10,46 @@ class TestSamplerDecimator(unittest.TestCase): @classmethod def setUpClass(cls): cls.qgs = start_app() - + cls.provider = Map2LoopProvider() QgsApplication.processingRegistry().addProvider(cls.provider) def setUp(self): self.test_dir = Path(__file__).parent self.input_dir = self.test_dir / "input" - + self.geology_file = self.input_dir / "geol_clip_no_gaps.shp" self.structure_file = self.input_dir / "structure_clip.shp" self.dtm_file = self.input_dir / "dtm_rp.tif" - + self.assertTrue(self.geology_file.exists(), f"geology not found: {self.geology_file}") self.assertTrue(self.structure_file.exists(), f"structure not found: {self.structure_file}") self.assertTrue(self.dtm_file.exists(), f"dtm not found: {self.dtm_file}") def test_decimator_1_with_structure(self): - + geology_layer = QgsVectorLayer(str(self.geology_file), "geology", "ogr") structure_layer = QgsVectorLayer(str(self.structure_file), "structure", "ogr") dtm_layer = QgsRasterLayer(str(self.dtm_file), "dtm") - + self.assertTrue(geology_layer.isValid(), "geology layer should be valid") self.assertTrue(structure_layer.isValid(), "structure layer should be valid") self.assertTrue(dtm_layer.isValid(), "dtm layer should be valid") self.assertGreater(geology_layer.featureCount(), 0, "geology layer should have features") self.assertGreater(structure_layer.featureCount(), 0, "structure layer should have features") - + QgsMessageLog.logMessage(f"geology layer valid: {geology_layer.isValid()}", "TestDecimator", Qgis.Critical) QgsMessageLog.logMessage(f"structure layer valid: {structure_layer.isValid()}", "TestDecimator", Qgis.Critical) QgsMessageLog.logMessage(f"dtm layer valid: {dtm_layer.isValid()}", "TestDecimator", Qgis.Critical) QgsMessageLog.logMessage(f"dtm source: {dtm_layer.source()}", "TestDecimator", Qgis.Critical) - + QgsMessageLog.logMessage(f"geology layer: {geology_layer.featureCount()} features", "TestDecimator", Qgis.Critical) QgsMessageLog.logMessage(f"structure layer: {structure_layer.featureCount()} features", "TestDecimator", Qgis.Critical) - QgsMessageLog.logMessage(f"spatial data- structure layer", "TestDecimator", Qgis.Critical) - QgsMessageLog.logMessage(f"sampler type: Decimator", "TestDecimator", Qgis.Critical) - QgsMessageLog.logMessage(f"decimation: 1", "TestDecimator", Qgis.Critical) + QgsMessageLog.logMessage("spatial data- structure layer", "TestDecimator", Qgis.Critical) + QgsMessageLog.logMessage("sampler type: Decimator", "TestDecimator", Qgis.Critical) + QgsMessageLog.logMessage("decimation: 1", "TestDecimator", Qgis.Critical) QgsMessageLog.logMessage(f"dtm: {self.dtm_file.name}", "TestDecimator", Qgis.Critical) - + algorithm = SamplerAlgorithm() algorithm.initAlgorithm() @@ -62,23 +62,23 @@ def test_decimator_1_with_structure(self): 'SPACING': 200.0, 'SAMPLED_CONTACTS': 'memory:decimated_points' } - + context = QgsProcessingContext() feedback = QgsProcessingFeedback() - - + + try: QgsMessageLog.logMessage("Starting decimator sampler algorithm...", "TestDecimator", Qgis.Critical) - + result = algorithm.processAlgorithm(parameters, context, feedback) - + QgsMessageLog.logMessage(f"Result: {result}", "TestDecimator", Qgis.Critical) - + self.assertIsNotNone(result, "result should not be None") self.assertIn('SAMPLED_CONTACTS', result, "Result should contain SAMPLED_CONTACTS key") - + QgsMessageLog.logMessage("Decimator sampler test completed successfully!", "TestDecimator", Qgis.Critical) - + except Exception as e: QgsMessageLog.logMessage(f"Decimator sampler test error: {str(e)}", "TestDecimator", Qgis.Critical) QgsMessageLog.logMessage(f"Error type: {type(e).__name__}", "TestDecimator", Qgis.Critical) @@ -86,10 +86,10 @@ def test_decimator_1_with_structure(self): import traceback QgsMessageLog.logMessage(f"Full traceback:\n{traceback.format_exc()}", "TestDecimator", Qgis.Critical) raise - + finally: QgsMessageLog.logMessage("=" * 50, "TestDecimator", Qgis.Critical) - + @classmethod def tearDownClass(cls): try: diff --git a/tests/qgis/test_sampler_spacing.py b/tests/qgis/test_sampler_spacing.py index e2f285d..d246b83 100644 --- a/tests/qgis/test_sampler_spacing.py +++ b/tests/qgis/test_sampler_spacing.py @@ -10,30 +10,30 @@ class TestSamplerSpacing(unittest.TestCase): @classmethod def setUpClass(cls): cls.qgs = start_app() - + cls.provider = Map2LoopProvider() QgsApplication.processingRegistry().addProvider(cls.provider) def setUp(self): self.test_dir = Path(__file__).parent self.input_dir = self.test_dir / "input" - + self.geology_file = self.input_dir / "geol_clip_no_gaps.shp" - + self.assertTrue(self.geology_file.exists(), f"geology not found: {self.geology_file}") def test_spacing_50_with_geology(self): - + geology_layer = QgsVectorLayer(str(self.geology_file), "geology", "ogr") - + self.assertTrue(geology_layer.isValid(), "geology layer should be valid") self.assertGreater(geology_layer.featureCount(), 0, "geology layer should have features") - + QgsMessageLog.logMessage(f"geology layer: {geology_layer.featureCount()} features", "TestSampler", Qgis.Critical) - QgsMessageLog.logMessage(f"spatial data- geology layer", "TestSampler", Qgis.Critical) - QgsMessageLog.logMessage(f"sampler type: Spacing", "TestSampler", Qgis.Critical) - QgsMessageLog.logMessage(f"spacing: 50", "TestSampler", Qgis.Critical) - + QgsMessageLog.logMessage("spatial data- geology layer", "TestSampler", Qgis.Critical) + QgsMessageLog.logMessage("sampler type: Spacing", "TestSampler", Qgis.Critical) + QgsMessageLog.logMessage("spacing: 50", "TestSampler", Qgis.Critical) + algorithm = SamplerAlgorithm() algorithm.initAlgorithm() @@ -46,29 +46,29 @@ def test_spacing_50_with_geology(self): 'SPACING': 50.0, 'SAMPLED_CONTACTS': 'memory:sampled_points' } - + context = QgsProcessingContext() feedback = QgsProcessingFeedback() try: QgsMessageLog.logMessage("Starting spacing sampler algorithm...", "TestSampler", Qgis.Critical) - + result = algorithm.processAlgorithm(parameters, context, feedback) - + QgsMessageLog.logMessage(f"Result: {result}", "TestSampler", Qgis.Critical) - + self.assertIsNotNone(result, "result should not be None") self.assertIn('SAMPLED_CONTACTS', result, "Result should contain SAMPLED_CONTACTS key") - + QgsMessageLog.logMessage("Spacing sampler test completed successfully!", "TestSampler", Qgis.Critical) - + except Exception as e: QgsMessageLog.logMessage(f"Spacing sampler test error: {str(e)}", "TestSampler", Qgis.Critical) QgsMessageLog.logMessage(f"Error type: {type(e).__name__}", "TestSampler", Qgis.Critical) import traceback QgsMessageLog.logMessage(f"Full traceback:\n{traceback.format_exc()}", "TestSampler", Qgis.Critical) raise - + finally: QgsMessageLog.logMessage("=" * 50, "TestSampler", Qgis.Critical)