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/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..2873dd5 --- /dev/null +++ b/loopstructural/gui/map2loop_tools/paint_stratigraphic_order_widget.py @@ -0,0 +1,273 @@ +"""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 + + +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) + self.stratColumnLayerComboBox.setFilters(QgsMapLayerProxyModel.NoGeometry) + 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) + + # Connect signals + self.geologyLayerComboBox.layerChanged.connect(self._on_geology_layer_changed) + self.stratColumnLayerComboBox.layerChanged.connect(self._on_strat_column_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() + self._on_strat_column_layer_changed() + + 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 _on_strat_column_layer_changed(self): + """Update stratigraphic column field combo boxes when layer changes.""" + strat_layer = self.stratColumnLayerComboBox.currentLayer() + self.stratUnitFieldComboBox.setLayer(strat_layer) + self.stratThicknessFieldComboBox.setLayer(strat_layer) + + # Try to auto-select common field names + if strat_layer: + field_names = [field.name() for field in strat_layer.fields()] + + # Unit name field + for common_name in ['unit_name', 'name', 'UNITNAME', 'unitname']: + if common_name in field_names: + self.stratUnitFieldComboBox.setField(common_name) + break + + # Thickness field + for common_name in ['thickness', 'THICKNESS', 'thick']: + if common_name in field_names: + self.stratThicknessFieldComboBox.setField(common_name) + break + + def _run_painter(self): + """Run the paint stratigraphic order algorithm.""" + from qgis import processing + + self._log_params("paint_strat_order_widget_run") + + # Validate inputs + if not self.geologyLayerComboBox.currentLayer(): + QMessageBox.warning(self, "Missing Input", "Please select a geology polygon layer.") + return + + if not self.stratColumnLayerComboBox.currentLayer(): + QMessageBox.warning(self, "Missing Input", "Please select a stratigraphic column layer.") + return + + if not self.unitNameFieldComboBox.currentField(): + QMessageBox.warning(self, "Missing Input", "Please select the unit name field.") + return + + if not self.stratUnitFieldComboBox.currentField(): + QMessageBox.warning( + self, "Missing Input", "Please select the stratigraphic column unit name field." + ) + return + + # Run the processing algorithm + try: + params = { + 'INPUT_POLYGONS': self.geologyLayerComboBox.currentLayer(), + 'UNIT_NAME_FIELD': self.unitNameFieldComboBox.currentField(), + 'INPUT_STRAT_COLUMN': self.stratColumnLayerComboBox.currentLayer(), + 'STRAT_UNIT_NAME_FIELD': self.stratUnitFieldComboBox.currentField(), + 'STRAT_THICKNESS_FIELD': self.stratThicknessFieldComboBox.currentField() or '', + 'PAINT_MODE': self.paintModeComboBox.currentIndex(), + 'OUTPUT': 'TEMPORARY_OUTPUT', + } + + if self._debug and self._debug.is_debug(): + try: + import json + + params_json = json.dumps( + self._serialize_params_for_logging(params, "paint_strat_order"), + indent=2, + ).encode("utf-8") + self._debug.save_debug_file("paint_strat_order_params.json", params_json) + except Exception as err: + self._debug.plugin.log( + message=f"[map2loop] Failed to save paint strat order params: {err}", + log_level=2, + ) + + result = processing.run("plugin_map2loop:paint_stratigraphic_order", params) + + if result and 'OUTPUT' in result: + output_layer = result['OUTPUT'] + if output_layer: + QgsProject.instance().addMapLayer(output_layer) + + field_name = ( + 'strat_order' if self.paintModeComboBox.currentIndex() == 0 + else 'cum_thickness' + ) + + QMessageBox.information( + self, + "Success", + f"Stratigraphic order painted successfully!\n" + f"Output layer added with '{field_name}' field.", + ) + else: + QMessageBox.warning(self, "Warning", "No output layer was generated.") + else: + QMessageBox.warning(self, "Warning", "Algorithm did not produce expected output.") + + except Exception as e: + if self._debug: + self._debug.plugin.log( + message=f"[map2loop] Paint stratigraphic order 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 { + 'geology_layer': self.geologyLayerComboBox.currentLayer(), + 'unit_name_field': self.unitNameFieldComboBox.currentField(), + 'strat_column_layer': self.stratColumnLayerComboBox.currentLayer(), + 'strat_unit_field': self.stratUnitFieldComboBox.currentField(), + 'strat_thickness_field': self.stratThicknessFieldComboBox.currentField(), + 'paint_mode': self.paintModeComboBox.currentIndex(), + } + + 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 'unit_name_field' in params and params['unit_name_field']: + self.unitNameFieldComboBox.setField(params['unit_name_field']) + if 'strat_column_layer' in params and params['strat_column_layer']: + self.stratColumnLayerComboBox.setLayer(params['strat_column_layer']) + if 'strat_unit_field' in params and params['strat_unit_field']: + self.stratUnitFieldComboBox.setField(params['strat_unit_field']) + if 'strat_thickness_field' in params and params['strat_thickness_field']: + self.stratThicknessFieldComboBox.setField(params['strat_thickness_field']) + if 'paint_mode' in params: + self.paintModeComboBox.setCurrentIndex(params['paint_mode']) 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..16e9de4 --- /dev/null +++ b/loopstructural/gui/map2loop_tools/paint_stratigraphic_order_widget.ui @@ -0,0 +1,139 @@ + + + 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: + + + + + + + + + + Stratigraphic Column Layer: + + + + + + + false + + + + + + + Strat Column Unit Field: + + + + + + + + + + Strat Column Thickness Field: + + + + + + + true + + + + + + + Paint Mode: + + + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + Run + + + + + + + + QgsMapLayerComboBox + QComboBox +
qgis.gui
+
+ + QgsFieldComboBox + QComboBox +
qgis.gui
+
+
+ + +
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..dd1d282 100644 --- a/loopstructural/processing/algorithms/__init__.py +++ b/loopstructural/processing/algorithms/__init__.py @@ -1,5 +1,6 @@ from .extract_basal_contacts import BasalContactsAlgorithm +from .paint_stratigraphic_order import PaintStratigraphicOrderAlgorithm +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/algorithms/paint_stratigraphic_order.py b/loopstructural/processing/algorithms/paint_stratigraphic_order.py new file mode 100644 index 0000000..c6eafce --- /dev/null +++ b/loopstructural/processing/algorithms/paint_stratigraphic_order.py @@ -0,0 +1,289 @@ +"""Paint stratigraphic order onto geology polygons. + +This algorithm allows the user to paint stratigraphic order (0-N where 0 is youngest) +or cumulative thickness onto polygon features based on a stratigraphic column. +""" + +from typing import Any, Optional + +from PyQt5.QtCore import QVariant +from qgis.core import ( + QgsFeature, + QgsFeatureSink, + QgsField, + QgsFields, + QgsProcessing, + QgsProcessingAlgorithm, + QgsProcessingContext, + QgsProcessingException, + QgsProcessingFeedback, + QgsProcessingParameterBoolean, + QgsProcessingParameterEnum, + QgsProcessingParameterFeatureSink, + QgsProcessingParameterFeatureSource, + QgsProcessingParameterField, + QgsWkbTypes, +) + + +class PaintStratigraphicOrderAlgorithm(QgsProcessingAlgorithm): + """Algorithm to paint stratigraphic order or cumulative thickness onto geology polygons. + + This algorithm takes a polygon layer with unit names and a stratigraphic column, + then adds fields for: + - Stratigraphic order (0 = youngest, N = oldest) + - Cumulative thickness (from bottom unit) + - Group index (for handling unconformities) + """ + + # Parameter names + INPUT_POLYGONS = "INPUT_POLYGONS" + UNIT_NAME_FIELD = "UNIT_NAME_FIELD" + INPUT_STRAT_COLUMN = "INPUT_STRAT_COLUMN" + STRAT_UNIT_NAME_FIELD = "STRAT_UNIT_NAME_FIELD" + STRAT_THICKNESS_FIELD = "STRAT_THICKNESS_FIELD" + PAINT_MODE = "PAINT_MODE" + OUTPUT = "OUTPUT" + + def name(self) -> str: + """Algorithm name.""" + return "paint_stratigraphic_order" + + def displayName(self) -> str: + """Display name for the algorithm.""" + return "Paint Stratigraphic Order" + + def group(self) -> str: + """Group name.""" + return "Stratigraphy" + + def groupId(self) -> str: + """Group ID.""" + return "stratigraphy" + + def shortHelpString(self) -> str: + """Short help string.""" + return """ + Paint stratigraphic order or cumulative thickness onto geology polygons. + + This tool matches unit names from a polygon layer with a stratigraphic column + and adds fields for: + - Stratigraphic order (0 = youngest, N = oldest) + - Cumulative thickness (starting from the bottom unit) + - Group index (breaks at unconformities) + + Parameters: + - Input Polygons: Polygon layer with geological units + - Unit Name Field: Field in the polygon layer containing unit names + - Stratigraphic Column: Table/layer with ordered stratigraphic units + - Strat Unit Name Field: Field in the stratigraphic column with unit names + - Strat Thickness Field: Field in the stratigraphic column with thickness values + - Paint Mode: Choose between painting order or cumulative thickness + """ + + def initAlgorithm(self, config: Optional[dict[str, Any]] = None) -> None: + """Initialize algorithm parameters.""" + + # Input polygon layer + self.addParameter( + QgsProcessingParameterFeatureSource( + self.INPUT_POLYGONS, + "Input Polygons (Geology)", + [QgsProcessing.TypeVectorPolygon], + ) + ) + + # Unit name field in polygon layer + self.addParameter( + QgsProcessingParameterField( + self.UNIT_NAME_FIELD, + "Unit Name Field", + parentLayerParameterName=self.INPUT_POLYGONS, + type=QgsProcessingParameterField.String, + defaultValue="UNITNAME", + ) + ) + + # Stratigraphic column table/layer + self.addParameter( + QgsProcessingParameterFeatureSource( + self.INPUT_STRAT_COLUMN, + "Stratigraphic Column", + [QgsProcessing.TypeVector], + ) + ) + + # Unit name field in stratigraphic column + self.addParameter( + QgsProcessingParameterField( + self.STRAT_UNIT_NAME_FIELD, + "Stratigraphic Column Unit Name Field", + parentLayerParameterName=self.INPUT_STRAT_COLUMN, + type=QgsProcessingParameterField.String, + defaultValue="unit_name", + ) + ) + + # Thickness field in stratigraphic column + self.addParameter( + QgsProcessingParameterField( + self.STRAT_THICKNESS_FIELD, + "Stratigraphic Column Thickness Field", + parentLayerParameterName=self.INPUT_STRAT_COLUMN, + type=QgsProcessingParameterField.Numeric, + defaultValue="thickness", + optional=True, + ) + ) + + # Paint mode: order or cumulative thickness + self.addParameter( + QgsProcessingParameterEnum( + self.PAINT_MODE, + "Paint Mode", + options=["Stratigraphic Order (0=youngest)", "Cumulative Thickness"], + defaultValue=0, + ) + ) + + # Output layer + self.addParameter( + QgsProcessingParameterFeatureSink( + self.OUTPUT, + "Output Layer", + ) + ) + + def processAlgorithm( + self, + parameters: dict[str, Any], + context: QgsProcessingContext, + feedback: QgsProcessingFeedback, + ) -> dict[str, Any]: + """Process the algorithm.""" + + # Get parameters + polygon_source = self.parameterAsSource(parameters, self.INPUT_POLYGONS, context) + unit_name_field = self.parameterAsString(parameters, self.UNIT_NAME_FIELD, context) + + strat_column_source = self.parameterAsSource(parameters, self.INPUT_STRAT_COLUMN, context) + strat_unit_field = self.parameterAsString(parameters, self.STRAT_UNIT_NAME_FIELD, context) + strat_thickness_field = self.parameterAsString(parameters, self.STRAT_THICKNESS_FIELD, context) + + paint_mode = self.parameterAsEnum(parameters, self.PAINT_MODE, context) + + if not polygon_source: + raise QgsProcessingException("Invalid input polygon layer") + + if not strat_column_source: + raise QgsProcessingException("Invalid stratigraphic column layer") + + # Read stratigraphic column and build lookup + feedback.pushInfo("Reading stratigraphic column...") + strat_order = [] + strat_thickness_map = {} + + for feature in strat_column_source.getFeatures(): + unit_name = feature[strat_unit_field] + if unit_name: + strat_order.append(unit_name) + if strat_thickness_field: + thickness = feature[strat_thickness_field] + try: + strat_thickness_map[unit_name] = float(thickness) if thickness is not None else 0.0 + except (ValueError, TypeError): + strat_thickness_map[unit_name] = 0.0 + else: + strat_thickness_map[unit_name] = 0.0 + + if not strat_order: + raise QgsProcessingException("Stratigraphic column is empty") + + feedback.pushInfo(f"Found {len(strat_order)} units in stratigraphic column") + + # Build order lookup (0 = youngest, which is at the top of the list) + # In stratigraphic column, youngest is typically first + order_lookup = {name: idx for idx, name in enumerate(strat_order)} + + # Calculate cumulative thickness from bottom (oldest) to top (youngest) + # Reverse the order for thickness calculation + cumulative_thickness = {} + total_thickness = 0.0 + for unit_name in reversed(strat_order): + cumulative_thickness[unit_name] = total_thickness + total_thickness += strat_thickness_map.get(unit_name, 0.0) + + feedback.pushInfo(f"Total stratigraphic thickness: {total_thickness}") + + # Prepare output fields + output_fields = QgsFields(polygon_source.fields()) + + if paint_mode == 0: # Stratigraphic Order + output_fields.append(QgsField("strat_order", QVariant.Int)) + else: # Cumulative Thickness + output_fields.append(QgsField("cum_thickness", QVariant.Double)) + + # Create sink + (sink, dest_id) = self.parameterAsSink( + parameters, + self.OUTPUT, + context, + output_fields, + polygon_source.wkbType(), + polygon_source.sourceCrs(), + ) + + if sink is None: + raise QgsProcessingException("Could not create output layer") + + # Process features + total = 100.0 / polygon_source.featureCount() if polygon_source.featureCount() else 0 + matched_count = 0 + unmatched_count = 0 + + feedback.pushInfo("Processing polygons...") + + for current, feature in enumerate(polygon_source.getFeatures()): + if feedback.isCanceled(): + break + + # Create output feature + out_feature = QgsFeature(output_fields) + out_feature.setGeometry(feature.geometry()) + + # Copy existing attributes + for i, field in enumerate(polygon_source.fields()): + out_feature.setAttribute(field.name(), feature.attribute(field.name())) + + # Get unit name from polygon + unit_name = feature[unit_name_field] + + if unit_name in order_lookup: + matched_count += 1 + if paint_mode == 0: # Paint stratigraphic order + out_feature.setAttribute("strat_order", order_lookup[unit_name]) + else: # Paint cumulative thickness + out_feature.setAttribute("cum_thickness", cumulative_thickness[unit_name]) + else: + unmatched_count += 1 + # Set null/default value for unmatched units + if paint_mode == 0: + out_feature.setAttribute("strat_order", None) + else: + out_feature.setAttribute("cum_thickness", None) + + if unmatched_count <= 10: # Only show first 10 warnings + feedback.pushWarning(f"Unit '{unit_name}' not found in stratigraphic column") + + sink.addFeature(out_feature, QgsFeatureSink.FastInsert) + feedback.setProgress(int(current * total)) + + feedback.pushInfo(f"\nProcessing complete:") + feedback.pushInfo(f" Matched units: {matched_count}") + feedback.pushInfo(f" Unmatched units: {unmatched_count}") + + return {self.OUTPUT: dest_id} + + def createInstance(self) -> QgsProcessingAlgorithm: + """Create a new instance of the algorithm.""" + return PaintStratigraphicOrderAlgorithm() 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()