From cf78fb4bf6e928cbd2ce6e29b4fe71e3a79f0381 Mon Sep 17 00:00:00 2001 From: Noelle Cheng Date: Wed, 8 Oct 2025 09:51:49 +0800 Subject: [PATCH 1/4] fix replace contact layer input with ContactExtractor and remove duplicated units in Sorter --- m2l/processing/algorithms/sorter.py | 78 +++++++++++++++++++---------- 1 file changed, 52 insertions(+), 26 deletions(-) diff --git a/m2l/processing/algorithms/sorter.py b/m2l/processing/algorithms/sorter.py index 55a179c..c999b66 100644 --- a/m2l/processing/algorithms/sorter.py +++ b/m2l/processing/algorithms/sorter.py @@ -41,6 +41,7 @@ SorterUseNetworkX, SorterUseHint, # kept for backwards compatibility ) +from map2loop.contact_extractor import ContactExtractor from ...main.vectorLayerWrapper import qgsLayerToGeoDataFrame, qvariantToFloat # a lookup so we don’t need a giant if/else block @@ -65,7 +66,7 @@ class StratigraphySorterAlgorithm(QgsProcessingAlgorithm): INPUT_STRATI_COLUMN = "INPUT_STRATI_COLUMN" SORTING_ALGORITHM = "SORTING_ALGORITHM" OUTPUT = "OUTPUT" - CONTACTS_LAYER = "CONTACTS_LAYER" + FAULTS_LAYER = "FAULTS_LAYER" # ---------------------------------------------------------- # Metadata @@ -125,7 +126,26 @@ def initAlgorithm(self, config: Optional[dict[str, Any]] = None) -> None: self.INPUT_GEOLOGY, "Geology polygons", [QgsProcessing.TypeVectorPolygon], - optional=True + optional=False + ) + ) + + self.addParameter( + QgsProcessingParameterField( + 'UNIT_NAME_FIELD', + 'Unit Name Field', + parentLayerParameterName=self.INPUT_GEOLOGY, + type=QgsProcessingParameterField.String, + defaultValue='unitname' + ) + ) + + self.addParameter( + QgsProcessingParameterFeatureSource( + "FAULTS_LAYER", + "Faults Layer", + [QgsProcessing.TypeVectorLine], + optional=True, ) ) @@ -219,15 +239,6 @@ def initAlgorithm(self, config: Optional[dict[str, Any]] = None) -> None: optional=True, ) ) - - self.addParameter( - QgsProcessingParameterFeatureSource( - "CONTACTS_LAYER", - "Contacts Layer", - [QgsProcessing.TypeVectorLine], - optional=False, - ) - ) self.addParameter( QgsProcessingParameterFeatureSink( @@ -256,11 +267,24 @@ def processAlgorithm( algo_index: int = self.parameterAsEnum(parameters, self.SORTING_ALGORITHM, context) sorter_cls = list(SORTER_LIST.values())[algo_index] - contacts_layer = self.parameterAsVectorLayer(parameters, self.CONTACTS_LAYER, context) + faults_layer = self.parameterAsVectorLayer(parameters, self.FAULTS_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) + geology_gdf = qgsLayerToGeoDataFrame(in_layer) + faults_gdf = qgsLayerToGeoDataFrame(faults_layer) if faults_layer else None + + unit_name_field = self.parameterAsString(parameters, 'UNIT_NAME_FIELD', context) + if unit_name_field in geology_gdf.columns: + geology_gdf = geology_gdf.rename(columns={unit_name_field: 'UNITNAME'}) + + feedback.pushInfo("Extracting contacts from geology...") + contact_extractor = ContactExtractor(geology_gdf, faults_gdf) + all_contacts = contact_extractor.extract_all_contacts() + else: + raise QgsProcessingException("Unit Name Field for geology layer is required for contacts extraction") + + units_df, relationships_df, contacts_df = build_input_frames(in_layer, all_contacts, feedback, parameters) if sorter_cls == SorterObservationProjections: geology_gdf = qgsLayerToGeoDataFrame(in_layer) @@ -344,7 +368,7 @@ def createInstance(self) -> QgsProcessingAlgorithm: # ------------------------------------------------------------------------- # Helper stub – you must replace with *your* conversion logic # ------------------------------------------------------------------------- -def build_input_frames(layer: QgsVectorLayer,contacts_layer: QgsVectorLayer, feedback, parameters, user_defined_units=None) -> tuple: +def build_input_frames(layer: QgsVectorLayer, contacts_gdf, feedback, parameters, user_defined_units=None) -> tuple: """ Placeholder that turns the geology layer (and any other project layers) into the four objects required by the sorter. @@ -384,26 +408,27 @@ def build_input_frames(layer: QgsVectorLayer,contacts_layer: QgsVectorLayer, fee if not group_field: raise QgsProcessingException("Group Field is required") + unique_units = {} units_records = [] for f in layer.getFeatures(): - units_records.append( - dict( - layerId=f.id(), - name=f[unit_name_field], - minAge=qvariantToFloat(f, min_age_field), - maxAge=qvariantToFloat(f, max_age_field), - group=f[group_field], + unit_name = f[unit_name_field] + if unit_name not in unique_units: + unique_units[unit_name] = len(unique_units) + units_records.append( + dict( + layerId=len(units_records), + name=f[unit_name_field], + minAge=qvariantToFloat(f, min_age_field), + maxAge=qvariantToFloat(f, max_age_field), + group=f[group_field], + ) ) - ) units_df = pd.DataFrame.from_records(units_records) feedback.pushInfo(f"Units → {len(units_df)} records") # map_data can be mocked if you only use Age-based sorter - if not contacts_layer or not contacts_layer.isValid(): - raise QgsProcessingException("No contacts layer provided") - - contacts_df = qgsLayerToGeoDataFrame(contacts_layer) if contacts_layer else pd.DataFrame() + contacts_df = contacts_gdf if contacts_gdf is not None else pd.DataFrame() if not contacts_df.empty: relationships_df = contacts_df.copy() if 'length' in contacts_df.columns: @@ -414,5 +439,6 @@ def build_input_frames(layer: QgsVectorLayer,contacts_layer: QgsVectorLayer, fee feedback.pushInfo(f"Relationships → {len(relationships_df)} records") else: relationships_df = pd.DataFrame() + feedback.pushInfo("No contacts extracted") return units_df, relationships_df, contacts_df From cdc3453a01cc5e477e38aa83ca37a0cb176e78ac Mon Sep 17 00:00:00 2001 From: Noelle Cheng Date: Fri, 10 Oct 2025 10:40:37 +0800 Subject: [PATCH 2/4] revert to using contacts layer instead of contact extractor class --- m2l/processing/algorithms/sorter.py | 58 +++++++++-------------------- 1 file changed, 18 insertions(+), 40 deletions(-) diff --git a/m2l/processing/algorithms/sorter.py b/m2l/processing/algorithms/sorter.py index c999b66..e574933 100644 --- a/m2l/processing/algorithms/sorter.py +++ b/m2l/processing/algorithms/sorter.py @@ -41,7 +41,6 @@ SorterUseNetworkX, SorterUseHint, # kept for backwards compatibility ) -from map2loop.contact_extractor import ContactExtractor from ...main.vectorLayerWrapper import qgsLayerToGeoDataFrame, qvariantToFloat # a lookup so we don’t need a giant if/else block @@ -66,7 +65,7 @@ class StratigraphySorterAlgorithm(QgsProcessingAlgorithm): INPUT_STRATI_COLUMN = "INPUT_STRATI_COLUMN" SORTING_ALGORITHM = "SORTING_ALGORITHM" OUTPUT = "OUTPUT" - FAULTS_LAYER = "FAULTS_LAYER" + CONTACTS_LAYER = "CONTACTS_LAYER" # ---------------------------------------------------------- # Metadata @@ -130,25 +129,6 @@ def initAlgorithm(self, config: Optional[dict[str, Any]] = None) -> None: ) ) - self.addParameter( - QgsProcessingParameterField( - 'UNIT_NAME_FIELD', - 'Unit Name Field', - parentLayerParameterName=self.INPUT_GEOLOGY, - type=QgsProcessingParameterField.String, - defaultValue='unitname' - ) - ) - - self.addParameter( - QgsProcessingParameterFeatureSource( - "FAULTS_LAYER", - "Faults Layer", - [QgsProcessing.TypeVectorLine], - optional=True, - ) - ) - self.addParameter( QgsProcessingParameterField( 'UNIT_NAME_FIELD', @@ -181,7 +161,7 @@ def initAlgorithm(self, config: Optional[dict[str, Any]] = None) -> None: optional=True ) ) - + self.addParameter( QgsProcessingParameterField( 'GROUP_FIELD', @@ -239,6 +219,15 @@ def initAlgorithm(self, config: Optional[dict[str, Any]] = None) -> None: optional=True, ) ) + + self.addParameter( + QgsProcessingParameterFeatureSource( + "CONTACTS_LAYER", + "Contacts Layer", + [QgsProcessing.TypeVectorLine], + optional=False, + ) + ) self.addParameter( QgsProcessingParameterFeatureSink( @@ -267,24 +256,11 @@ def processAlgorithm( algo_index: int = self.parameterAsEnum(parameters, self.SORTING_ALGORITHM, context) sorter_cls = list(SORTER_LIST.values())[algo_index] - faults_layer = self.parameterAsVectorLayer(parameters, self.FAULTS_LAYER, context) + 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) - geology_gdf = qgsLayerToGeoDataFrame(in_layer) - faults_gdf = qgsLayerToGeoDataFrame(faults_layer) if faults_layer else None - - unit_name_field = self.parameterAsString(parameters, 'UNIT_NAME_FIELD', context) - if unit_name_field in geology_gdf.columns: - geology_gdf = geology_gdf.rename(columns={unit_name_field: 'UNITNAME'}) - - feedback.pushInfo("Extracting contacts from geology...") - contact_extractor = ContactExtractor(geology_gdf, faults_gdf) - all_contacts = contact_extractor.extract_all_contacts() - else: - raise QgsProcessingException("Unit Name Field for geology layer is required for contacts extraction") - - units_df, relationships_df, contacts_df = build_input_frames(in_layer, all_contacts, feedback, parameters) + units_df, relationships_df, contacts_df= build_input_frames(in_layer,contacts_layer, feedback,parameters) if sorter_cls == SorterObservationProjections: geology_gdf = qgsLayerToGeoDataFrame(in_layer) @@ -368,7 +344,7 @@ def createInstance(self) -> QgsProcessingAlgorithm: # ------------------------------------------------------------------------- # Helper stub – you must replace with *your* conversion logic # ------------------------------------------------------------------------- -def build_input_frames(layer: QgsVectorLayer, contacts_gdf, feedback, parameters, user_defined_units=None) -> tuple: +def build_input_frames(layer: QgsVectorLayer,contacts_layer: QgsVectorLayer, feedback, parameters, user_defined_units=None) -> tuple: """ Placeholder that turns the geology layer (and any other project layers) into the four objects required by the sorter. @@ -428,7 +404,10 @@ def build_input_frames(layer: QgsVectorLayer, contacts_gdf, feedback, parameters feedback.pushInfo(f"Units → {len(units_df)} records") # map_data can be mocked if you only use Age-based sorter - contacts_df = contacts_gdf if contacts_gdf is not None else pd.DataFrame() + if not contacts_layer or not contacts_layer.isValid(): + raise QgsProcessingException("No contacts layer provided") + contacts_df = qgsLayerToGeoDataFrame(contacts_layer) if contacts_layer else pd.DataFrame() + if not contacts_df.empty: relationships_df = contacts_df.copy() if 'length' in contacts_df.columns: @@ -439,6 +418,5 @@ def build_input_frames(layer: QgsVectorLayer, contacts_gdf, feedback, parameters feedback.pushInfo(f"Relationships → {len(relationships_df)} records") else: relationships_df = pd.DataFrame() - feedback.pushInfo("No contacts extracted") return units_df, relationships_df, contacts_df From b8bfa7e762b8d4dd0b5acad45782b863951e245f Mon Sep 17 00:00:00 2001 From: Noelle Cheng Date: Fri, 10 Oct 2025 12:19:00 +0800 Subject: [PATCH 3/4] add validation for contacts layer based on sorter --- m2l/processing/algorithms/sorter.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/m2l/processing/algorithms/sorter.py b/m2l/processing/algorithms/sorter.py index e574933..ec7d086 100644 --- a/m2l/processing/algorithms/sorter.py +++ b/m2l/processing/algorithms/sorter.py @@ -225,7 +225,7 @@ def initAlgorithm(self, config: Optional[dict[str, Any]] = None) -> None: "CONTACTS_LAYER", "Contacts Layer", [QgsProcessing.TypeVectorLine], - optional=False, + optional=True, ) ) @@ -256,9 +256,14 @@ def processAlgorithm( algo_index: int = self.parameterAsEnum(parameters, self.SORTING_ALGORITHM, context) sorter_cls = list(SORTER_LIST.values())[algo_index] + sorter_name = list(SORTER_LIST.keys())[algo_index] + requires_contacts = sorter_cls in [SorterAlpha, SorterMaximiseContacts, SorterObservationProjections] 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) + + if requires_contacts and not contacts_layer or not contacts_layer.isValid(): + raise QgsProcessingException(f"{sorter_name} requires a contacts layer") units_df, relationships_df, contacts_df= build_input_frames(in_layer,contacts_layer, feedback,parameters) From 9b29cc3255366887a4dbb6e7e5e71fcb9ee4bf3b Mon Sep 17 00:00:00 2001 From: Noelle Cheng Date: Thu, 16 Oct 2025 08:37:42 +0800 Subject: [PATCH 4/4] merge with noelle/thickness_calculator --- m2l/processing/algorithms/sampler.py | 8 +-- .../algorithms/thickness_calculator.py | 64 +++++++++++++++---- 2 files changed, 54 insertions(+), 18 deletions(-) diff --git a/m2l/processing/algorithms/sampler.py b/m2l/processing/algorithms/sampler.py index cafd4b2..a3f384e 100644 --- a/m2l/processing/algorithms/sampler.py +++ b/m2l/processing/algorithms/sampler.py @@ -10,7 +10,7 @@ """ # Python imports from typing import Any, Optional -from qgis.PyQt.QtCore import QMetaType +from qgis.PyQt.QtCore import QVariant from osgeo import gdal import pandas as pd @@ -182,11 +182,11 @@ def processAlgorithm( dtype_str = str(dtype) if dtype_str in ['float16', 'float32', 'float64']: - field_type = QMetaType.Type.Double + field_type = QVariant.Double elif dtype_str in ['int8', 'int16', 'int32', 'int64']: - field_type = QMetaType.Type.Int + field_type = QVariant.Int else: - field_type = QMetaType.Type.QString + field_type = QVariant.String fields.append(QgsField(column_name, field_type)) diff --git a/m2l/processing/algorithms/thickness_calculator.py b/m2l/processing/algorithms/thickness_calculator.py index 51d6f91..824fd34 100644 --- a/m2l/processing/algorithms/thickness_calculator.py +++ b/m2l/processing/algorithms/thickness_calculator.py @@ -48,6 +48,7 @@ class ThicknessCalculatorAlgorithm(QgsProcessingAlgorithm): INPUT_THICKNESS_CALCULATOR_TYPE = 'THICKNESS_CALCULATOR_TYPE' INPUT_DTM = 'DTM' + INPUT_BOUNDING_BOX_TYPE = 'BOUNDING_BOX_TYPE' INPUT_BOUNDING_BOX = 'BOUNDING_BOX' INPUT_MAX_LINE_LENGTH = 'MAX_LINE_LENGTH' INPUT_STRATI_COLUMN = 'STRATIGRAPHIC_COLUMN' @@ -56,7 +57,7 @@ class ThicknessCalculatorAlgorithm(QgsProcessingAlgorithm): INPUT_DIPDIR_FIELD = 'DIPDIR_FIELD' INPUT_DIP_FIELD = 'DIP_FIELD' INPUT_GEOLOGY = 'GEOLOGY' - INPUT_THICKNESS_ORIENTATION_TYPE = 'THICKNESS_ORIENTATION_TYPE' + INPUT_ORIENTATION_TYPE = 'ORIENTATION_TYPE' INPUT_UNIT_NAME_FIELD = 'UNIT_NAME_FIELD' INPUT_SAMPLED_CONTACTS = 'SAMPLED_CONTACTS' INPUT_STRATIGRAPHIC_COLUMN_LAYER = 'STRATIGRAPHIC_COLUMN_LAYER' @@ -100,6 +101,29 @@ def initAlgorithm(self, config: Optional[dict[str, Any]] = None) -> None: ) ) + self.addParameter( + QgsProcessingParameterEnum( + self.INPUT_BOUNDING_BOX_TYPE, + "Bounding Box Type", + options=['Extract from geology layer', 'User defined'], + allowMultiple=False, + 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'], + numberRows=1, + defaultValue=last_bbox, + optional=True + ) + ) + self.addParameter( QgsProcessingParameterNumber( self.INPUT_MAX_LINE_LENGTH, @@ -171,8 +195,8 @@ def initAlgorithm(self, config: Optional[dict[str, Any]] = None) -> None: ) self.addParameter( QgsProcessingParameterEnum( - 'THICKNESS_ORIENTATION_TYPE', - 'Thickness Orientation Type', + self.INPUT_ORIENTATION_TYPE, + 'Orientation Type', options=['Dip Direction', 'Strike'], defaultValue=0 # Default to Dip Direction ) @@ -213,26 +237,38 @@ def processAlgorithm( 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 = self.parameterAsMatrix(parameters, self.INPUT_BOUNDING_BOX, context) + bounding_box_type = self.parameterAsEnum(parameters, self.INPUT_BOUNDING_BOX_TYPE, context) max_line_length = self.parameterAsSource(parameters, self.INPUT_MAX_LINE_LENGTH, context) basal_contacts = self.parameterAsSource(parameters, self.INPUT_BASAL_CONTACTS, context) geology_data = self.parameterAsSource(parameters, self.INPUT_GEOLOGY, context) structure_data = self.parameterAsSource(parameters, self.INPUT_STRUCTURE_DATA, context) - thickness_orientation_type = self.parameterAsEnum(parameters, self.INPUT_THICKNESS_ORIENTATION_TYPE, context) - is_strike = (thickness_orientation_type == 1) + 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) 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) - geology_layer = self.parameterAsVectorLayer(parameters, self.INPUT_GEOLOGY, context) - extent = geology_layer.extent() - bounding_box = { - 'minx': extent.xMinimum(), - 'miny': extent.yMinimum(), - 'maxx': extent.xMaximum(), - 'maxy': extent.yMaximum() - } + if bounding_box_type == 0: + geology_layer = self.parameterAsVectorLayer(parameters, self.INPUT_GEOLOGY, context) + extent = geology_layer.extent() + bounding_box = { + 'minx': extent.xMinimum(), + 'miny': extent.yMinimum(), + 'maxx': extent.xMaximum(), + 'maxy': extent.yMaximum() + } + feedback.pushInfo("Using bounding box from geology layer") + else: + 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_order = []