diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml index 6e4819d..497cb51 100644 --- a/.github/workflows/documentation.yml +++ b/.github/workflows/documentation.yml @@ -53,7 +53,7 @@ jobs: python-version: ${{ env.PYTHON_VERSION }} - name: Cache Sphinx cache - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: docs/_build/cache key: ${{ runner.os }}-sphinx-${{ hashFiles('docs/**/*') }} @@ -69,7 +69,7 @@ jobs: run: sphinx-build -b html -j auto -d docs/_build/cache -q docs docs/_build/html - name: Save build doc as artifact - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@v6 with: name: documentation path: docs/_build/html/* diff --git a/.github/workflows/package_and_release.yml b/.github/workflows/package_and_release.yml index f9f483a..2853243 100644 --- a/.github/workflows/package_and_release.yml +++ b/.github/workflows/package_and_release.yml @@ -49,7 +49,7 @@ jobs: - name: Compile translations run: lrelease ${{ env.PROJECT_FOLDER }}/resources/i18n/*.ts - - uses: actions/upload-artifact@v5 + - uses: actions/upload-artifact@v6 with: name: translations-build path: ${{ env.PROJECT_FOLDER }}/**/*.qm @@ -81,7 +81,7 @@ jobs: python -m pip install -U -r requirements/packaging.txt - name: Download translations - uses: actions/download-artifact@v6 + uses: actions/download-artifact@v7 with: name: translations-build path: ${{ env.PROJECT_FOLDER }} @@ -102,7 +102,7 @@ jobs: --allow-uncommitted-changes \ --plugin-repo-url $(gh api "repos/$GITHUB_REPOSITORY/pages" --jq '.html_url') - - uses: actions/upload-artifact@v5 + - uses: actions/upload-artifact@v6 with: name: ${{ env.PROJECT_FOLDER }}-latest path: | @@ -138,7 +138,7 @@ jobs: python -m pip install -U -r requirements/packaging.txt - name: Download translations - uses: actions/download-artifact@v6 + uses: actions/download-artifact@v7 with: name: translations-build path: ${{ env.PROJECT_FOLDER }} diff --git a/.github/workflows/packager.yml b/.github/workflows/packager.yml index aa1a0cf..152d355 100644 --- a/.github/workflows/packager.yml +++ b/.github/workflows/packager.yml @@ -45,7 +45,7 @@ jobs: - name: Package the latest version run: qgis-plugin-ci package latest --allow-uncommitted-changes - - uses: actions/upload-artifact@v5 + - uses: actions/upload-artifact@v6 with: name: ${{ env.PROJECT_FOLDER }}-latest path: ${{ env.PROJECT_FOLDER }}.*.zip diff --git a/docs/usage/interface.md b/docs/usage/interface.md index dc09047..e9c1011 100644 --- a/docs/usage/interface.md +++ b/docs/usage/interface.md @@ -44,6 +44,31 @@ The fault-fault relationship table defines the interaction between faults in the ![Fault Topology](../static/fault_topology_hamersley.png) +## Processing Tools + +The plugin provides several QGIS Processing algorithms for working with geological data. These can be accessed through the QGIS Processing Toolbox. + +### Paint Stratigraphic Order + +The **Paint Stratigraphic Order** algorithm allows you to visualize the stratigraphic order on geology polygons. This tool is useful for: +- Visually debugging the stratigraphic column +- Quality checking unit order +- Creating visualizations of stratigraphic relationships + +The algorithm takes: +- **Input Polygons**: A polygon layer containing geological units (e.g., your geology map) +- **Unit Name Field**: The field in your polygon layer that contains unit names +- **Stratigraphic Column**: A table or layer with the stratigraphic column (ordered from youngest to oldest) +- **Paint Mode**: Choose between: + - **Stratigraphic Order** (0 = youngest, N = oldest): Paints a numeric order onto each polygon + - **Cumulative Thickness**: Paints the cumulative thickness from the bottom (oldest) unit + +The algorithm adds a new field to your polygon layer: +- `strat_order`: The stratigraphic order (when using Stratigraphic Order mode) +- `cum_thickness`: The cumulative thickness in the stratigraphic column (when using Cumulative Thickness mode) + +Units that don't match the stratigraphic column will have null values, helping you identify data quality issues. + ## Model parameters Once the layers have been selected, stratigraphic column defined and the fault topology relationships set, the LoopStructural model can be initialised. diff --git a/loopstructural/gui/map2loop_tools/__init__.py b/loopstructural/gui/map2loop_tools/__init__.py index ab68076..0086ead 100644 --- a/loopstructural/gui/map2loop_tools/__init__.py +++ b/loopstructural/gui/map2loop_tools/__init__.py @@ -6,6 +6,7 @@ from .dialogs import ( BasalContactsDialog, + PaintStratigraphicOrderDialog, SamplerDialog, SorterDialog, ThicknessCalculatorDialog, @@ -14,6 +15,7 @@ __all__ = [ 'BasalContactsDialog', + 'PaintStratigraphicOrderDialog', 'SamplerDialog', 'SorterDialog', 'ThicknessCalculatorDialog', diff --git a/loopstructural/gui/map2loop_tools/basal_contacts_widget.py b/loopstructural/gui/map2loop_tools/basal_contacts_widget.py index ec33e1f..d792222 100644 --- a/loopstructural/gui/map2loop_tools/basal_contacts_widget.py +++ b/loopstructural/gui/map2loop_tools/basal_contacts_widget.py @@ -288,7 +288,7 @@ def _is_null_like(v): continue if val not in values: values.append(val) - stratigraphic_order = values + stratigraphic_order = values print(f"Extracting all contacts for units: {stratigraphic_order}") self.data_manager.logger(f"Extracting all contacts for units: {stratigraphic_order}") @@ -310,4 +310,10 @@ def _is_null_like(v): contact_type = "all contacts and basal contacts" elif not all_contacts and result['basal_contacts'].empty is False: addGeoDataFrameToproject(result['basal_contacts'], "Basal contacts") + else: + QMessageBox.information( + self, + "No Contacts Found", + "No contacts were found with the given parameters.", + ) return result, contact_type diff --git a/loopstructural/gui/map2loop_tools/dialogs.py b/loopstructural/gui/map2loop_tools/dialogs.py index 0d37800..8837880 100644 --- a/loopstructural/gui/map2loop_tools/dialogs.py +++ b/loopstructural/gui/map2loop_tools/dialogs.py @@ -183,3 +183,40 @@ def setup_ui(self): def _run_and_accept(self): """Run the calculator and accept dialog if successful.""" self.widget._run_calculator() + + +class PaintStratigraphicOrderDialog(QDialog): + """Dialog for painting stratigraphic order onto geology polygons.""" + + def __init__(self, parent=None, data_manager=None, debug_manager=None): + """Initialize the paint stratigraphic order dialog.""" + super().__init__(parent) + self.setWindowTitle("Paint Stratigraphic Order") + self.data_manager = data_manager + self.debug_manager = debug_manager + self.setup_ui() + + def setup_ui(self): + """Set up the dialog UI.""" + from .paint_stratigraphic_order_widget import PaintStratigraphicOrderWidget + + layout = QVBoxLayout(self) + self.widget = PaintStratigraphicOrderWidget( + 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 painter and accept dialog if successful.""" + self.widget._run_painter() + diff --git a/loopstructural/gui/map2loop_tools/paint_stratigraphic_order_widget.py b/loopstructural/gui/map2loop_tools/paint_stratigraphic_order_widget.py new file mode 100644 index 0000000..f01a106 --- /dev/null +++ b/loopstructural/gui/map2loop_tools/paint_stratigraphic_order_widget.py @@ -0,0 +1,337 @@ +"""Widget for painting stratigraphic order onto geology polygons.""" + +import os + +from PyQt5.QtWidgets import QMessageBox, QWidget +from qgis.core import QgsMapLayerProxyModel, QgsProject +from qgis.PyQt import uic + +from loopstructural.toolbelt.preferences import PlgOptionsManager + +from ...main.m2l_api import paint_stratigraphic_order + + +class PaintStratigraphicOrderWidget(QWidget): + """Widget for painting stratigraphic order or cumulative thickness onto polygons. + + This widget provides a GUI interface for the paint stratigraphic order tool, + allowing users to visualize stratigraphic relationships on geology polygons. + """ + + def __init__(self, parent=None, data_manager=None, debug_manager=None): + """Initialize the paint stratigraphic order widget. + + Parameters + ---------- + parent : QWidget, optional + Parent widget. + data_manager : object, optional + Data manager for accessing shared data. + debug_manager : object, optional + Debug manager for logging and debugging. + """ + 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__), "paint_stratigraphic_order_widget.ui") + uic.loadUi(ui_path, self) + + # Configure layer filters programmatically + try: + self.geologyLayerComboBox.setFilters(QgsMapLayerProxyModel.PolygonLayer) + # stratigraphic column layer removed from UI + pass + except Exception: + # If QGIS isn't available, skip filter setup + pass + + # Initialize paint modes + self.paint_modes = ["Stratigraphic Order (0=youngest)", "Cumulative Thickness"] + self.paintModeComboBox.addItems(self.paint_modes) + + # New UI: duplicate layer and color ramp + try: + # Populate colour ramps from default style + from qgis.core import QgsStyle + + ramps = QgsStyle().defaultStyle().colorRampNames() + self.colorRampComboBox.addItems(sorted(ramps)) + except Exception as e: + if self._debug.is_debug(): + raise e + # if QGIS unavailable, leave empty + pass + + # Default: no duplication + try: + self.duplicateLayerCheckBox.setChecked(False) + except Exception: + pass + + # Connect signals + self.geologyLayerComboBox.layerChanged.connect(self._on_geology_layer_changed) + self.runButton.clicked.connect(self._run_painter) + + # 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): + """Export layer for debugging purposes.""" + 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): + """Serialize layer for logging.""" + 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): + """Serialize parameters for logging.""" + 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): + """Log parameters for debugging.""" + 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 _setup_field_combo_boxes(self): + """Set up field combo boxes based on current layers.""" + self._on_geology_layer_changed() + # stratigraphic column layer removed from UI + pass + + def _on_geology_layer_changed(self): + """Update unit name field combo box when geology layer changes.""" + geology_layer = self.geologyLayerComboBox.currentLayer() + self.unitNameFieldComboBox.setLayer(geology_layer) + + # Try to auto-select common field names + if geology_layer: + field_names = [field.name() for field in geology_layer.fields()] + for common_name in ['UNITNAME', 'unitname', 'unit_name', 'UNIT', 'unit']: + if common_name in field_names: + self.unitNameFieldComboBox.setField(common_name) + break + + def _run_painter(self): + """Run the paint stratigraphic order algorithm.""" + + geology_layer = self.geologyLayerComboBox.currentLayer() + unit_name_field = self.unitNameFieldComboBox.currentField() + stratigraphic_order = self.data_manager.stratigraphic_order = ( + self.data_manager.get_stratigraphic_unit_names() if self.data_manager else [] + ) + paint_stratigraphic_order( + geology_layer, stratigraphic_order, unit_name_field, debug_manager=self._debug + ) + + # If requested, duplicate layer and apply style using selected colour ramp + try: + duplicate = ( + getattr(self, 'duplicateLayerCheckBox', None) + and self.duplicateLayerCheckBox.isChecked() + ) + except Exception: + duplicate = False + + if duplicate: + # Get chosen ramp name + try: + ramp_name = self.colorRampComboBox.currentText() + except Exception: + ramp_name = None + + # Step 1: create a memory copy of the geology layer and copy attributes/geometry + try: + from PyQt5.QtCore import QVariant + from qgis.core import ( + QgsFeature, + QgsField, + QgsGraduatedSymbolRenderer, + QgsProject, + QgsRendererRange, + QgsStyle, + QgsSymbol, + QgsVectorLayer, + QgsWkbTypes, + ) + + geom_type = QgsWkbTypes.displayString(geology_layer.wkbType()) + crs_auth = geology_layer.crs().authid() if hasattr(geology_layer, 'crs') else None + uri = f"{geom_type}?crs={crs_auth}" if crs_auth else f"{geom_type}" + mem_layer = QgsVectorLayer(uri, f"{geology_layer.name()}_strat", "memory") + + mem_dp = mem_layer.dataProvider() + mem_dp.addAttributes(list(geology_layer.fields())) + mem_layer.updateFields() + + # copy each feature and its attributes explicitly + src_field_names = [f.name() for f in geology_layer.fields()] + new_feats = [] + for src_feat in geology_layer.getFeatures(): + nf = QgsFeature() + nf.setGeometry(src_feat.geometry()) + nf.setFields(mem_layer.fields()) + attrs = [] + for f in mem_layer.fields(): + fname = f.name() + if fname in src_field_names: + try: + attrs.append(src_feat[fname]) + except Exception: + attrs.append(None) + else: + attrs.append(None) + nf.setAttributes(attrs) + new_feats.append(nf) + mem_dp.addFeatures(new_feats) + mem_layer.updateExtents() + QgsProject.instance().addMapLayer(mem_layer) + except Exception as e: + QMessageBox.warning(self, 'Duplicate Layer', f'Failed to create copy: {e}') + return + + # Step 2: ensure 'strat_order' exists on memory layer and populate numeric values by matching geometries + field_name = 'strat_order' + try: + if field_name not in [f.name() for f in mem_layer.fields()]: + mem_layer.startEditing() + mem_dp.addAttributes([QgsField(field_name, QVariant.Int)]) + mem_layer.updateFields() + mem_layer.commitChanges() + + # build mapping from geometry WKB -> numeric strat value from original layer + geom_to_val = {} + for of in geology_layer.getFeatures(): + try: + raw = of[field_name] + except Exception: + raw = None + if raw is None: + continue + try: + val = int(raw) + except Exception: + try: + val = int(float(raw)) + except Exception: + continue + try: + geom_to_val[of.geometry().asWkb()] = val + except Exception: + # fallback to WKT if asWkb unavailable + try: + geom_to_val[of.geometry().asWkt()] = val + except Exception: + continue + + if geom_to_val: + mem_layer.startEditing() + strat_idx = mem_layer.fields().indexFromName(field_name) + for mf in mem_layer.getFeatures(): + try: + key = mf.geometry().asWkb() + except Exception: + try: + key = mf.geometry().asWkt() + except Exception: + key = None + if key is None: + continue + val = geom_to_val.get(key, None) + if val is not None: + mem_layer.changeAttributeValue(mf.id(), strat_idx, int(val)) + mem_layer.commitChanges() + except Exception: + # continue; styling will fallback if numeric data missing + pass + + # Step 3: build graduated renderer using explicit ranges per unique strat value + try: + vals = set() + for f in mem_layer.getFeatures(): + try: + v = f[field_name] + except Exception: + v = None + if v is None: + continue + try: + vals.add(float(v)) + except Exception: + continue + unique_vals = sorted(vals) + + if not unique_vals: + QMessageBox.information( + self, + 'Styling', + "No 'strat_order' values found on duplicated layer; leaving default styling.", + ) + else: + from qgis.core import QgsRendererRange + + ramp = QgsStyle().defaultStyle().colorRamp(ramp_name) if ramp_name else None + ranges = [] + n = len(unique_vals) + for i, v in enumerate(unique_vals): + lower = v - 0.5 + upper = v + 0.5 + symbol = QgsSymbol.defaultSymbol(mem_layer.geometryType()) + if ramp: + try: + color = ramp.color(i / (n - 1) if n > 1 else 0) + symbol.setColor(color) + except Exception: + pass + label = str(int(v)) if float(v).is_integer() else str(v) + ranges.append(QgsRendererRange(lower, upper, symbol, label)) + renderer = QgsGraduatedSymbolRenderer(field_name, ranges) + mem_layer.setRenderer(renderer) + mem_layer.triggerRepaint() + except Exception as e: + QMessageBox.warning( + self, 'Duplicate Layer', f'Failed to apply graduated styling: {e}' + ) + + # QMessageBox.information( + # self, + # "Paint Stratigraphic Order", + # "Stratigraphic order has been painted onto the geology layer.", + # ) diff --git a/loopstructural/gui/map2loop_tools/paint_stratigraphic_order_widget.ui b/loopstructural/gui/map2loop_tools/paint_stratigraphic_order_widget.ui new file mode 100644 index 0000000..08d3ab9 --- /dev/null +++ b/loopstructural/gui/map2loop_tools/paint_stratigraphic_order_widget.ui @@ -0,0 +1,121 @@ + + + PaintStratigraphicOrderWidget + + + + 0 + 0 + 600 + 400 + + + + Paint Stratigraphic Order + + + + + + Paint stratigraphic order or cumulative thickness onto geology polygons + + + true + + + + + + + + + Geology Polygon Layer: + + + + + + + false + + + + + + + Unit Name Field: + + + + + + + + + + Paint Mode: + + + + + + + + + + Duplicate Layer and Style: + + + + + + + + + + Colour Ramp: + + + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + Run + + + + + + + + QgsMapLayerComboBox + QComboBox +
qgis.gui
+
+ + QgsFieldComboBox + QComboBox +
qgis.gui
+
+
+ + +
diff --git a/loopstructural/main/m2l_api.py b/loopstructural/main/m2l_api.py index 1f92aec..7016b74 100644 --- a/loopstructural/main/m2l_api.py +++ b/loopstructural/main/m2l_api.py @@ -1,3 +1,5 @@ +from typing import final + import pandas as pd from map2loop.contact_extractor import ContactExtractor from map2loop.sampler import SamplerDecimator, SamplerSpacing @@ -10,6 +12,7 @@ ) from map2loop.thickness_calculator import InterpolatedStructure, StructuralPoint from osgeo import gdal +from qgis.core import QgsVectorLayer from ..main.vectorLayerWrapper import qgsLayerToDataFrame, qgsLayerToGeoDataFrame from .debug.export import export_debug_package @@ -114,12 +117,17 @@ def extract_basal_contacts( print(e) try: + print('before all') all_contacts_result = contact_extractor.extract_all_contacts() + print('after all') basal_contacts = contact_extractor.extract_basal_contacts(stratigraphic_order) + print('after basal') + print(all_contacts_result.shape, basal_contacts.shape) except Exception as e: print(f"Error during contact extraction: {e}") basal_contacts = pd.DataFrame() all_contacts_result = pd.DataFrame() + if ignore_units and basal_contacts.empty is False: basal_contacts = basal_contacts[ ~basal_contacts['basal_unit'].astype(str).str.strip().isin(ignore_units) @@ -560,3 +568,102 @@ def calculate_thickness( # Ensure result object exists for return and for any debug export res = {'thicknesses': thickness} return res + + +def paint_stratigraphic_order( + geology_layer: 'QgsVectorLayer', + stratigraphic_order: list, + unit_name_field: str = "UNITNAME", + debug_manager: 'DebugManager' = None, + updater: 'Updater' = None, +): + """Paint stratigraphic order onto geology polygons. + Parameters + ---------- + geology_layer : QgsVectorLayer + Geology polygon layer. + stratigraphic_order : list + List of unit names in stratigraphic order. + unit_name_field : str + Name of the field containing unit names. + debug_manager : DebugManager + Debug manager instance for handling debug information. + updater : Updater + Updater instance for handling updates. + Returns + ------- + None + """ + if updater: + updater(f"Painting stratigraphic order...") + # check unit_name_field exists in geology_layer + # Use the QGIS layer directly (if provided), otherwise accept a GeoDataFrame + if geology_layer is None: + msg = "No geology layer provided" + if debug_manager: + try: + debug_manager.log_params("paint_stratigraphic_order", {"error": msg}) + except Exception: + pass + if updater: + updater(msg) + raise ValueError(msg) + + geology_fields = None + # Try to treat as a QgsVectorLayer + if issubclass(type(geology_layer), QgsVectorLayer): + fields = geology_layer.fields() + if fields is not None: + try: + # QgsFields has .names() in many QGIS versions + geology_fields = list(fields.names()) + except Exception: + # Fallback: iterate field objects + geology_fields = [f.name() for f in fields] + finally: + pass + if unit_name_field not in geology_fields: + msg = f"Unit name field '{unit_name_field}' not found in geology layer" + if debug_manager: + try: + debug_manager.log_params( + "paint_stratigraphic_order", + {"error": msg, "geology_fields": geology_fields}, + ) + except Exception: + pass + if updater: + updater(msg) + raise ValueError(msg) + if updater: + updater(f"Found unit name field: {unit_name_field}") + + stratigraphic_order_dict = {unit: index for index, unit in enumerate(stratigraphic_order)} + # Start editing the layer + geology_layer.startEditing() + # Add new field for stratigraphic order if it doesn't exist + strat_order_field = "strat_order" + if strat_order_field not in geology_fields: + from qgis.core import QgsField + from qgis.PyQt.QtCore import QVariant + + new_field = QgsField(strat_order_field, QVariant.Int) + geology_layer.dataProvider().addAttributes([new_field]) + geology_layer.updateFields() + if updater: + updater(f"Added new field for stratigraphic order: {strat_order_field}") + # Update stratigraphic order values + for feature in geology_layer.getFeatures(): + unit_name = feature[unit_name_field] + strat_order_value = stratigraphic_order_dict.get(unit_name, None) + if strat_order_value is not None: + geology_layer.changeAttributeValue( + feature.id(), + geology_layer.fields().indexFromName(strat_order_field), + strat_order_value, + ) + # Commit changes + geology_layer.commitChanges() + if updater: + updater(f"Stratigraphic order painted successfully.") + return diff --git a/loopstructural/plugin_main.py b/loopstructural/plugin_main.py index 60464fb..0e4ac5f 100644 --- a/loopstructural/plugin_main.py +++ b/loopstructural/plugin_main.py @@ -196,12 +196,19 @@ def initGui(self): ) self.action_thickness.triggered.connect(self.show_thickness_dialog) + self.action_paint_strat_order = QAction( + "Paint Stratigraphic Order", + self.iface.mainWindow(), + ) + self.action_paint_strat_order.triggered.connect(self.show_paint_strat_order_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_paint_strat_order) self.toolbar.addAction(self.action_fault_topology) self.iface.addPluginToMenu(__title__, self.action_sampler) @@ -209,27 +216,8 @@ def initGui(self): 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_paint_strat_order) 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() @@ -401,6 +389,17 @@ def show_thickness_dialog(self): ) dialog.exec_() + def show_paint_strat_order_dialog(self): + """Show the paint stratigraphic order dialog.""" + from loopstructural.gui.map2loop_tools import PaintStratigraphicOrderDialog + + dialog = PaintStratigraphicOrderDialog( + 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. @@ -451,6 +450,7 @@ def unload(self): "action_user_sorter", "action_basal_contacts", "action_thickness", + "action_paint_strat_order", "action_fault_topology", "action_modelling", "action_visualisation", diff --git a/loopstructural/processing/algorithms/__init__.py b/loopstructural/processing/algorithms/__init__.py index 08d76a0..bbb5b18 100644 --- a/loopstructural/processing/algorithms/__init__.py +++ b/loopstructural/processing/algorithms/__init__.py @@ -1,5 +1,5 @@ from .extract_basal_contacts import BasalContactsAlgorithm +from .sampler import SamplerAlgorithm from .sorter import StratigraphySorterAlgorithm -from .user_defined_sorter import UserDefinedStratigraphyAlgorithm from .thickness_calculator import ThicknessCalculatorAlgorithm -from .sampler import SamplerAlgorithm +from .user_defined_sorter import UserDefinedStratigraphyAlgorithm diff --git a/loopstructural/processing/provider.py b/loopstructural/processing/provider.py index 2e1fef0..93550a3 100644 --- a/loopstructural/processing/provider.py +++ b/loopstructural/processing/provider.py @@ -16,6 +16,7 @@ from .algorithms import ( BasalContactsAlgorithm, + PaintStratigraphicOrderAlgorithm, SamplerAlgorithm, StratigraphySorterAlgorithm, ThicknessCalculatorAlgorithm, @@ -67,6 +68,7 @@ def loadAlgorithms(self): self.addAlgorithm(UserDefinedStratigraphyAlgorithm()) self.addAlgorithm(ThicknessCalculatorAlgorithm()) self.addAlgorithm(SamplerAlgorithm()) + self.addAlgorithm(PaintStratigraphicOrderAlgorithm()) def id(self) -> str: """Unique provider id, used for identifying it. This string should be unique, \ diff --git a/tests/qgis/test_paint_stratigraphic_order.py b/tests/qgis/test_paint_stratigraphic_order.py new file mode 100644 index 0000000..4db95fd --- /dev/null +++ b/tests/qgis/test_paint_stratigraphic_order.py @@ -0,0 +1,327 @@ +"""Test paint stratigraphic order algorithm.""" + +import unittest +from pathlib import Path + +from qgis.core import ( + Qgis, + QgsApplication, + QgsFeature, + QgsField, + QgsFields, + QgsGeometry, + QgsMessageLog, + QgsPointXY, + QgsProcessingContext, + QgsProcessingFeedback, + QgsVectorLayer, + QgsWkbTypes, +) +from qgis.PyQt.QtCore import QVariant +from qgis.testing import start_app + +from loopstructural.processing.algorithms.paint_stratigraphic_order import ( + PaintStratigraphicOrderAlgorithm, +) +from loopstructural.processing.provider import Map2LoopProvider + + +class TestPaintStratigraphicOrder(unittest.TestCase): + """Tests for the Paint Stratigraphic Order algorithm.""" + + @classmethod + def setUpClass(cls): + """Set up test class.""" + cls.qgs = start_app() + + cls.provider = Map2LoopProvider() + QgsApplication.processingRegistry().addProvider(cls.provider) + + def setUp(self): + """Set up test data.""" + self.test_dir = Path(__file__).parent + self.input_dir = self.test_dir / "input" + + # Check if test data exists + self.geology_file = self.input_dir / "geol_clip_no_gaps.shp" + self.strati_file = self.input_dir / "stratigraphic_column_testing.gpkg" + + def test_paint_stratigraphic_order_with_test_data(self): + """Test the algorithm with actual test data if available.""" + if not self.geology_file.exists() or not self.strati_file.exists(): + self.skipTest("Test data files not available") + + # Load geology layer + 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") + + # Load stratigraphic column + strati_layer = QgsVectorLayer(str(self.strati_file), "strati", "ogr") + self.assertTrue(strati_layer.isValid(), "strati layer should be valid") + self.assertGreater(strati_layer.featureCount(), 0, "strati layer should have features") + + # Initialize algorithm + algorithm = PaintStratigraphicOrderAlgorithm() + algorithm.initAlgorithm() + + # Set up parameters for stratigraphic order mode + parameters = { + 'INPUT_POLYGONS': geology_layer, + 'UNIT_NAME_FIELD': 'unitname', + 'INPUT_STRAT_COLUMN': strati_layer, + 'STRAT_UNIT_NAME_FIELD': 'unit_name', + 'STRAT_THICKNESS_FIELD': '', + 'PAINT_MODE': 0, # Stratigraphic Order + 'OUTPUT': 'memory:painted_order', + } + + context = QgsProcessingContext() + feedback = QgsProcessingFeedback() + + try: + # Run algorithm + result = algorithm.processAlgorithm(parameters, context, feedback) + + self.assertIsNotNone(result, "result should not be None") + self.assertIn('OUTPUT', result, "Result should contain OUTPUT key") + + # Get output layer + output_layer = context.takeResultLayer(result['OUTPUT']) + self.assertIsNotNone(output_layer, "output layer should not be None") + self.assertTrue(output_layer.isValid(), "output layer should be valid") + self.assertGreater(output_layer.featureCount(), 0, "output layer should have features") + + # Check that the strat_order field was added + field_names = [field.name() for field in output_layer.fields()] + self.assertIn('strat_order', field_names, "output should have strat_order field") + + QgsMessageLog.logMessage( + f"Generated {output_layer.featureCount()} features with stratigraphic order", + "TestPaintStratigraphicOrder", + Qgis.Critical, + ) + + except Exception as e: + QgsMessageLog.logMessage( + f"Test error: {str(e)}", "TestPaintStratigraphicOrder", Qgis.Critical + ) + import traceback + + QgsMessageLog.logMessage( + f"Full traceback:\n{traceback.format_exc()}", + "TestPaintStratigraphicOrder", + Qgis.Critical, + ) + raise + + def test_paint_stratigraphic_order_synthetic(self): + """Test the algorithm with synthetic data.""" + # Create a synthetic stratigraphic column layer + strat_fields = QgsFields() + strat_fields.append(QgsField("unit_name", QVariant.String)) + strat_fields.append(QgsField("thickness", QVariant.Double)) + + strat_layer = QgsVectorLayer("None", "strat_column", "memory") + strat_layer.dataProvider().addAttributes(strat_fields) + strat_layer.updateFields() + + # Add stratigraphic units (youngest to oldest) + units = [ + ("Unit_A", 100.0), + ("Unit_B", 200.0), + ("Unit_C", 150.0), + ] + + for unit_name, thickness in units: + feat = QgsFeature(strat_fields) + feat.setAttributes([unit_name, thickness]) + strat_layer.dataProvider().addFeature(feat) + + strat_layer.updateExtents() + self.assertEqual(strat_layer.featureCount(), 3, "strat layer should have 3 features") + + # Create a synthetic geology polygon layer + geol_fields = QgsFields() + geol_fields.append(QgsField("UNITNAME", QVariant.String)) + + geol_layer = QgsVectorLayer("Polygon?crs=EPSG:4326", "geology", "memory") + geol_layer.dataProvider().addAttributes(geol_fields) + geol_layer.updateFields() + + # Add polygons for each unit + polygon_units = ["Unit_A", "Unit_B", "Unit_C", "Unknown_Unit"] + for i, unit_name in enumerate(polygon_units): + feat = QgsFeature(geol_fields) + # Create a simple square polygon + x = i * 2 + points = [ + QgsPointXY(x, 0), + QgsPointXY(x + 1, 0), + QgsPointXY(x + 1, 1), + QgsPointXY(x, 1), + QgsPointXY(x, 0), + ] + feat.setGeometry(QgsGeometry.fromPolygonXY([points])) + feat.setAttributes([unit_name]) + geol_layer.dataProvider().addFeature(feat) + + geol_layer.updateExtents() + self.assertEqual(geol_layer.featureCount(), 4, "geol layer should have 4 features") + + # Test stratigraphic order mode + algorithm = PaintStratigraphicOrderAlgorithm() + algorithm.initAlgorithm() + + parameters = { + 'INPUT_POLYGONS': geol_layer, + 'UNIT_NAME_FIELD': 'UNITNAME', + 'INPUT_STRAT_COLUMN': strat_layer, + 'STRAT_UNIT_NAME_FIELD': 'unit_name', + 'STRAT_THICKNESS_FIELD': 'thickness', + 'PAINT_MODE': 0, # Stratigraphic Order + 'OUTPUT': 'memory:painted_order', + } + + context = QgsProcessingContext() + feedback = QgsProcessingFeedback() + + result = algorithm.processAlgorithm(parameters, context, feedback) + + # Get output layer + output_layer = context.takeResultLayer(result['OUTPUT']) + self.assertIsNotNone(output_layer, "output layer should not be None") + self.assertTrue(output_layer.isValid(), "output layer should be valid") + self.assertEqual(output_layer.featureCount(), 4, "output should have 4 features") + + # Check strat_order values + features = list(output_layer.getFeatures()) + + # Unit_A should have order 0 (youngest) + unit_a_feat = next((f for f in features if f['UNITNAME'] == 'Unit_A'), None) + self.assertIsNotNone(unit_a_feat, "Unit_A feature should exist") + self.assertEqual(unit_a_feat['strat_order'], 0, "Unit_A should have order 0") + + # Unit_B should have order 1 + unit_b_feat = next((f for f in features if f['UNITNAME'] == 'Unit_B'), None) + self.assertIsNotNone(unit_b_feat, "Unit_B feature should exist") + self.assertEqual(unit_b_feat['strat_order'], 1, "Unit_B should have order 1") + + # Unit_C should have order 2 (oldest) + unit_c_feat = next((f for f in features if f['UNITNAME'] == 'Unit_C'), None) + self.assertIsNotNone(unit_c_feat, "Unit_C feature should exist") + self.assertEqual(unit_c_feat['strat_order'], 2, "Unit_C should have order 2") + + # Unknown_Unit should have None + unknown_feat = next((f for f in features if f['UNITNAME'] == 'Unknown_Unit'), None) + self.assertIsNotNone(unknown_feat, "Unknown_Unit feature should exist") + self.assertIsNone(unknown_feat['strat_order'], "Unknown_Unit should have None order") + + def test_paint_cumulative_thickness(self): + """Test the algorithm with cumulative thickness mode.""" + # Create a synthetic stratigraphic column layer + strat_fields = QgsFields() + strat_fields.append(QgsField("unit_name", QVariant.String)) + strat_fields.append(QgsField("thickness", QVariant.Double)) + + strat_layer = QgsVectorLayer("None", "strat_column", "memory") + strat_layer.dataProvider().addAttributes(strat_fields) + strat_layer.updateFields() + + # Add stratigraphic units (youngest to oldest) + units = [ + ("Unit_A", 100.0), + ("Unit_B", 200.0), + ("Unit_C", 150.0), + ] + + for unit_name, thickness in units: + feat = QgsFeature(strat_fields) + feat.setAttributes([unit_name, thickness]) + strat_layer.dataProvider().addFeature(feat) + + strat_layer.updateExtents() + + # Create a synthetic geology polygon layer + geol_fields = QgsFields() + geol_fields.append(QgsField("UNITNAME", QVariant.String)) + + geol_layer = QgsVectorLayer("Polygon?crs=EPSG:4326", "geology", "memory") + geol_layer.dataProvider().addAttributes(geol_fields) + geol_layer.updateFields() + + # Add polygons for each unit + polygon_units = ["Unit_A", "Unit_B", "Unit_C"] + for i, unit_name in enumerate(polygon_units): + feat = QgsFeature(geol_fields) + # Create a simple square polygon + x = i * 2 + points = [ + QgsPointXY(x, 0), + QgsPointXY(x + 1, 0), + QgsPointXY(x + 1, 1), + QgsPointXY(x, 1), + QgsPointXY(x, 0), + ] + feat.setGeometry(QgsGeometry.fromPolygonXY([points])) + feat.setAttributes([unit_name]) + geol_layer.dataProvider().addFeature(feat) + + geol_layer.updateExtents() + + # Test cumulative thickness mode + algorithm = PaintStratigraphicOrderAlgorithm() + algorithm.initAlgorithm() + + parameters = { + 'INPUT_POLYGONS': geol_layer, + 'UNIT_NAME_FIELD': 'UNITNAME', + 'INPUT_STRAT_COLUMN': strat_layer, + 'STRAT_UNIT_NAME_FIELD': 'unit_name', + 'STRAT_THICKNESS_FIELD': 'thickness', + 'PAINT_MODE': 1, # Cumulative Thickness + 'OUTPUT': 'memory:painted_thickness', + } + + context = QgsProcessingContext() + feedback = QgsProcessingFeedback() + + result = algorithm.processAlgorithm(parameters, context, feedback) + + # Get output layer + output_layer = context.takeResultLayer(result['OUTPUT']) + self.assertIsNotNone(output_layer, "output layer should not be None") + self.assertTrue(output_layer.isValid(), "output layer should be valid") + + # Check cum_thickness values + features = list(output_layer.getFeatures()) + + # Unit_C (oldest) should have cumulative thickness 0 + unit_c_feat = next((f for f in features if f['UNITNAME'] == 'Unit_C'), None) + self.assertIsNotNone(unit_c_feat, "Unit_C feature should exist") + self.assertEqual(unit_c_feat['cum_thickness'], 0.0, "Unit_C should have cum_thickness 0") + + # Unit_B should have cumulative thickness 150 (thickness of Unit_C) + unit_b_feat = next((f for f in features if f['UNITNAME'] == 'Unit_B'), None) + self.assertIsNotNone(unit_b_feat, "Unit_B feature should exist") + self.assertEqual(unit_b_feat['cum_thickness'], 150.0, "Unit_B should have cum_thickness 150") + + # Unit_A (youngest) should have cumulative thickness 350 (150 + 200) + unit_a_feat = next((f for f in features if f['UNITNAME'] == 'Unit_A'), None) + self.assertIsNotNone(unit_a_feat, "Unit_A feature should exist") + self.assertEqual( + unit_a_feat['cum_thickness'], 350.0, "Unit_A should have cum_thickness 350" + ) + + @classmethod + def tearDownClass(cls): + """Clean up after tests.""" + try: + registry = QgsApplication.processingRegistry() + registry.removeProvider(cls.provider) + except Exception: + pass + + +if __name__ == '__main__': + unittest.main()