From 73b6e2263915fbbb4dfddb285f8f8ab702281069 Mon Sep 17 00:00:00 2001 From: Rabii Chaarani <50892556+rabii-chaarani@users.noreply.github.com> Date: Wed, 17 Sep 2025 11:55:33 +0930 Subject: [PATCH 01/17] feat: thickness calculator tool --- .../algorithms/thickness_calculator.py | 151 +++++++++++++----- 1 file changed, 115 insertions(+), 36 deletions(-) diff --git a/m2l/processing/algorithms/thickness_calculator.py b/m2l/processing/algorithms/thickness_calculator.py index 71f72eb..e10604d 100644 --- a/m2l/processing/algorithms/thickness_calculator.py +++ b/m2l/processing/algorithms/thickness_calculator.py @@ -25,10 +25,20 @@ QgsProcessingParameterEnum, QgsProcessingParameterNumber, QgsProcessingParameterField, - QgsProcessingParameterMatrix + QgsProcessingParameterMatrix, + QgsSettings, + QgsProcessingParameterRasterLayer, ) # Internal imports -from ...main.vectorLayerWrapper import qgsLayerToGeoDataFrame, GeoDataFrameToQgsLayer, qgsLayerToDataFrame, dataframeToQgsLayer +from ...main.vectorLayerWrapper import ( + qgsLayerToGeoDataFrame, + GeoDataFrameToQgsLayer, + qgsLayerToDataFrame, + dataframeToQgsLayer, + qgsRasterToGdalDataset, + matrixToDict, + dataframeToQgsTable + ) from map2loop.thickness_calculator import InterpolatedStructure, StructuralPoint @@ -39,11 +49,13 @@ class ThicknessCalculatorAlgorithm(QgsProcessingAlgorithm): INPUT_DTM = 'DTM' INPUT_BOUNDING_BOX = 'BOUNDING_BOX' INPUT_MAX_LINE_LENGTH = 'MAX_LINE_LENGTH' - INPUT_UNITS = 'UNITS' INPUT_STRATI_COLUMN = 'STRATIGRAPHIC_COLUMN' INPUT_BASAL_CONTACTS = 'BASAL_CONTACTS' INPUT_STRUCTURE_DATA = 'STRUCTURE_DATA' + INPUT_DIPDIR_FIELD = 'DIPDIR_FIELD' + INPUT_DIP_FIELD = 'DIP_FIELD' INPUT_GEOLOGY = 'GEOLOGY' + INPUT_UNIT_NAME_FIELD = 'UNIT_NAME_FIELD' INPUT_SAMPLED_CONTACTS = 'SAMPLED_CONTACTS' OUTPUT = "THICKNESS" @@ -73,21 +85,27 @@ def initAlgorithm(self, config: Optional[dict[str, Any]] = None) -> None: "Thickness Calculator Type", options=['InterpolatedStructure','StructuralPoint'], allowMultiple=False, + defaultValue='InterpolatedStructure' ) ) self.addParameter( - QgsProcessingParameterFeatureSource( + QgsProcessingParameterRasterLayer( self.INPUT_DTM, - "DTM", + "DTM (InterpolatedStructure)", [QgsProcessing.TypeRaster], + optional=True, ) ) + + bbox_settings = QgsSettings() + last_bbox = bbox_settings.value("m2l/bounding_box", "") self.addParameter( - QgsProcessingParameterEnum( + QgsProcessingParameterMatrix( self.INPUT_BOUNDING_BOX, - "Bounding Box", - options=['minx','miny','maxx','maxy'], - allowMultiple=True, + description="Bounding Box", + headers=['minx','miny','maxx','maxy'], + numberRows=1, + defaultValue=last_bbox ) ) self.addParameter( @@ -98,18 +116,12 @@ def initAlgorithm(self, config: Optional[dict[str, Any]] = None) -> None: defaultValue=1000 ) ) - self.addParameter( - QgsProcessingParameterFeatureSource( - self.INPUT_UNITS, - "Units", - [QgsProcessing.TypeVectorLine], - ) - ) self.addParameter( QgsProcessingParameterFeatureSource( self.INPUT_BASAL_CONTACTS, "Basal Contacts", [QgsProcessing.TypeVectorLine], + defaultValue='Basal Contacts', ) ) self.addParameter( @@ -119,29 +131,60 @@ def initAlgorithm(self, config: Optional[dict[str, Any]] = None) -> None: [QgsProcessing.TypeVectorPolygon], ) ) + + self.addParameter( + QgsProcessingParameterField( + 'UNIT_NAME_FIELD', + 'Unit Name Field e.g. Formation', + parentLayerParameterName=self.INPUT_GEOLOGY, + type=QgsProcessingParameterField.String, + defaultValue='Formation' + ) + ) + + strati_settings = QgsSettings() + last_strati_column = strati_settings.value("m2l/strati_column", "") self.addParameter( QgsProcessingParameterMatrix( name=self.INPUT_STRATI_COLUMN, description="Stratigraphic Order", headers=["Unit"], numberRows=0, - defaultValue=[] + defaultValue=last_strati_column ) ) self.addParameter( QgsProcessingParameterFeatureSource( self.INPUT_SAMPLED_CONTACTS, - "SAMPLED_CONTACTS", + "Sampled Contacts", [QgsProcessing.TypeVectorPoint], ) ) self.addParameter( QgsProcessingParameterFeatureSource( self.INPUT_STRUCTURE_DATA, - "STRUCTURE_DATA", + "Orientation Data", [QgsProcessing.TypeVectorPoint], ) ) + self.addParameter( + QgsProcessingParameterField( + self.INPUT_DIPDIR_FIELD, + "Dip Direction Column", + parentLayerParameterName=self.INPUT_STRUCTURE_DATA, + type=QgsProcessingParameterField.Numeric, + defaultValue='DIPDIR' + ) + ) + self.addParameter( + QgsProcessingParameterField( + self.INPUT_DIP_FIELD, + "Dip Column", + parentLayerParameterName=self.INPUT_STRUCTURE_DATA, + type=QgsProcessingParameterField.Numeric, + defaultValue='DIP' + ) + ) self.addParameter( QgsProcessingParameterFeatureSink( self.OUTPUT, @@ -157,32 +200,63 @@ def processAlgorithm( ) -> dict[str, Any]: feedback.pushInfo("Initialising Thickness Calculation Algorithm...") - thickness_type = self.parameterAsEnum(parameters, self.INPUT_THICKNESS_CALCULATOR_TYPE, context) - dtm_data = self.parameterAsSource(parameters, self.INPUT_DTM, context) - bounding_box = self.parameterAsEnum(parameters, self.INPUT_BOUNDING_BOX, context) - max_line_length = self.parameterAsNumber(parameters, self.INPUT_MAX_LINE_LENGTH, context) - units = self.parameterAsSource(parameters, self.INPUT_UNITS, context) + thickness_type_index = self.parameterAsEnum(parameters, self.INPUT_THICKNESS_CALCULATOR_TYPE, context) + thickness_type = ['InterpolatedStructure', 'StructuralPoint'][thickness_type_index] + dtm_data = self.parameterAsRasterLayer(parameters, self.INPUT_DTM, context) + bounding_box = self.parameterAsMatrix(parameters, self.INPUT_BOUNDING_BOX, 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) stratigraphic_order = self.parameterAsMatrix(parameters, self.INPUT_STRATI_COLUMN, context) structure_data = self.parameterAsSource(parameters, self.INPUT_STRUCTURE_DATA, context) + 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) + bbox_settings = QgsSettings() + bbox_settings.setValue("m2l/bounding_box", bounding_box) + strati_column_settings = QgsSettings() + strati_column_settings.setValue('m2l/strati_column', stratigraphic_order) # convert layers to dataframe or geodataframe + units = qgsLayerToDataFrame(geology_data) geology_data = qgsLayerToGeoDataFrame(geology_data) - units = qgsLayerToDataFrame(units) basal_contacts = qgsLayerToGeoDataFrame(basal_contacts) structure_data = qgsLayerToDataFrame(structure_data) + rename_map = {} + missing_fields = [] + if unit_name_field != 'UNITNAME' and unit_name_field in geology_data.columns: + geology_data = geology_data.rename(columns={unit_name_field: 'UNITNAME'}) + units = units.rename(columns={unit_name_field: 'UNITNAME'}) + units = units.drop_duplicates(subset=['UNITNAME']).reset_index(drop=True) + units = units.rename(columns={'UNITNAME': 'name'}) + if structure_data is not None: + if structure_dipdir_field: + if structure_dipdir_field in structure_data.columns: + rename_map[structure_dipdir_field] = 'DIPDIR' + else: + missing_fields.append(structure_dipdir_field) + if structure_dip_field: + if structure_dip_field in structure_data.columns: + rename_map[structure_dip_field] = 'DIP' + else: + missing_fields.append(structure_dip_field) + if missing_fields: + raise QgsProcessingException( + f"Orientation data missing required field(s): {', '.join(missing_fields)}" + ) + if rename_map: + structure_data = structure_data.rename(columns=rename_map) sampled_contacts = qgsLayerToDataFrame(sampled_contacts) - + dtm_data = qgsRasterToGdalDataset(dtm_data) + bounding_box = matrixToDict(bounding_box) feedback.pushInfo("Calculating unit thicknesses...") - if thickness_type == "InterpolatedStructure": thickness_calculator = InterpolatedStructure( dtm_data=dtm_data, bounding_box=bounding_box, ) - thickness_calculator.compute( + thicknesses = thickness_calculator.compute( units, stratigraphic_order, basal_contacts, @@ -197,7 +271,7 @@ def processAlgorithm( bounding_box=bounding_box, max_line_length=max_line_length, ) - thickness_calculator.compute( + thicknesses =thickness_calculator.compute( units, stratigraphic_order, basal_contacts, @@ -206,17 +280,22 @@ def processAlgorithm( sampled_contacts ) - #TODO: convert thicknesses dataframe to qgs layer - thicknesses = dataframeToQgsLayer( - self, - # contact_extractor.basal_contacts, + thicknesses = thicknesses[ + ["name","ThicknessMean","ThicknessMedian", "ThicknessStdDev"] + ].copy() + + feedback.pushInfo("Exporting Thickness Table...") + thicknesses = dataframeToQgsTable( + self, + thicknesses, parameters=parameters, context=context, feedback=feedback, - ) - + param_name=self.OUTPUT + ) + return {self.OUTPUT: thicknesses[1]} def createInstance(self) -> QgsProcessingAlgorithm: """Create a new instance of the algorithm.""" - return self.__class__() # BasalContactsAlgorithm() \ No newline at end of file + return self.__class__() # ThicknessCalculatorAlgorithm() From b483f006e335e735800e971824a4bd53d1aa7069 Mon Sep 17 00:00:00 2001 From: Rabii Chaarani <50892556+rabii-chaarani@users.noreply.github.com> Date: Wed, 17 Sep 2025 11:56:00 +0930 Subject: [PATCH 02/17] feat: raster and dataframe handling --- m2l/main/vectorLayerWrapper.py | 356 +++++++++++++++++++++++++++------ 1 file changed, 298 insertions(+), 58 deletions(-) diff --git a/m2l/main/vectorLayerWrapper.py b/m2l/main/vectorLayerWrapper.py index 72ff281..00c6193 100644 --- a/m2l/main/vectorLayerWrapper.py +++ b/m2l/main/vectorLayerWrapper.py @@ -1,5 +1,5 @@ # PyQGIS / PyQt imports - +from osgeo import gdal from qgis.core import ( QgsRaster, QgsFields, @@ -12,17 +12,79 @@ QgsProcessingException, QgsPoint, QgsPointXY, + QgsProject, + QgsCoordinateTransform, + QgsRasterLayer ) -from qgis.PyQt.QtCore import QVariant, QDateTime, QVariant - +from qgis.PyQt.QtCore import QVariant, QDateTime +from qgis import processing from shapely.geometry import Point, MultiPoint, LineString, MultiLineString, Polygon, MultiPolygon from shapely.wkb import loads as wkb_loads import pandas as pd import geopandas as gpd import numpy as np - +import tempfile +import os + +def qgsRasterToGdalDataset(rlayer: QgsRasterLayer): + """ + Convert a QgsRasterLayer to an osgeo.gdal.Dataset (read-only). + If the raster is non-file-based (e.g. WMS/WCS/virtual), we create a temp GeoTIFF via gdal:translate. + Returns a gdal.Dataset or None. + """ + if rlayer is None or not rlayer.isValid(): + return None + + # Try direct open on file-backed layers + candidates = [] + try: + candidates.append(rlayer.source()) + except Exception: + pass + try: + if rlayer.dataProvider(): + candidates.append(rlayer.dataProvider().dataSourceUri()) + except Exception: + pass + tried = set() + for uri in candidates: + if not uri: + continue + if uri in tried: + continue + tried.add(uri) + + # Strip QGIS pipe options: "path.tif|layername=..." → "path.tif" + base_uri = uri.split("|")[0] + + # Some providers store “SUBDATASET:” URIs; gdal.OpenEx can usually handle them directly. + ds = gdal.OpenEx(base_uri, gdal.OF_RASTER | gdal.OF_READONLY) + if ds is not None: + return ds + + # If we’re here, it’s likely non-file-backed. Export to a temp GeoTIFF. + tmpdir = tempfile.gettempdir() + tmp_path = os.path.join(tmpdir, f"m2l_dtm_{rlayer.id()}.tif") + + # Use GDAL Translate via QGIS processing (avoids CRS pitfalls) + processing.run( + "gdal:translate", + { + "INPUT": rlayer, # QGIS accepts the layer object here + "TARGET_CRS": None, + "NODATA": None, + "COPY_SUBDATASETS": False, + "OPTIONS": "", + "EXTRA": "", + "DATA_TYPE": 0, # Use input data type + "OUTPUT": tmp_path, + } + ) + + ds = gdal.OpenEx(tmp_path, gdal.OF_RASTER | gdal.OF_READONLY) + return ds def qgsLayerToGeoDataFrame(layer) -> gpd.GeoDataFrame: if layer is None: @@ -42,63 +104,147 @@ def qgsLayerToGeoDataFrame(layer) -> gpd.GeoDataFrame: data[f.name()].append(str(feature[f.name()])) else: data[f.name()].append(feature[f.name()]) - return gpd.GeoDataFrame(data, crs=layer.crs().authid()) - -def qgsLayerToDataFrame(layer, dtm) -> pd.DataFrame: - """Convert a vector layer to a pandas DataFrame - samples the geometry using either points or the vertices of the lines - - :param layer: _description_ - :type layer: _type_ - :param dtm: Digital Terrain Model to evaluate Z values - :type dtm: _type_ or None - :return: the dataframe object - :rtype: pd.DataFrame + return gpd.GeoDataFrame(data, crs=layer.sourceCrs().authid()) + +def qgsLayerToDataFrame(src, dtm=None) -> pd.DataFrame: """ - if layer is None: + Convert a vector layer or processing feature source to a pandas DataFrame. + Samples geometry using points or vertices of lines/polygons. + Optionally samples Z from a DTM raster. + + :param src: QgsVectorLayer or QgsProcessingFeatureSource + :param dtm: QgsRasterLayer or None + :return: pd.DataFrame with columns: X, Y, Z, and all layer fields + """ + + if src is None: return None - fields = layer.fields() - data = {} - data['X'] = [] - data['Y'] = [] - data['Z'] = [] - - for field in fields: - data[field.name()] = [] - for feature in layer.getFeatures(): - geom = feature.geometry() - points = [] - if geom.isMultipart(): - if geom.type() == QgsWkbTypes.PointGeometry: - points = geom.asMultiPoint() - elif geom.type() == QgsWkbTypes.LineGeometry: + + # --- Resolve fields and source CRS (works for both layer and feature source) --- + fields = src.fields() if hasattr(src, "fields") else None + if fields is None: + # Fallback: take fields from first feature if needed + feat_iter = src.getFeatures() + try: + first = next(feat_iter) + except StopIteration: + return pd.DataFrame(columns=["X", "Y", "Z"]) + fields = first.fields() + # Rewind iterator by building a new one + feats = [first] + list(src.getFeatures()) + else: + feats = src.getFeatures() + + # Get source CRS + if hasattr(src, "crs"): + src_crs = src.crs() + elif hasattr(src, "sourceCrs"): + src_crs = src.sourceCrs() + else: + src_crs = None + + # --- Prepare optional transform to DTM CRS for sampling --- + to_dtm = None + if dtm is not None and src_crs is not None and dtm.crs().isValid() and src_crs.isValid(): + if src_crs != dtm.crs(): + to_dtm = QgsCoordinateTransform(src_crs, dtm.crs(), QgsProject.instance()) + + # --- Helper: sample Z from DTM (returns float or -9999) --- + def sample_dtm_xy(x, y): + if dtm is None: + return 0.0 + # Transform coordinate if needed + if to_dtm is not None: + try: + from qgis.core import QgsPointXY + x, y = to_dtm.transform(QgsPointXY(x, y)) + except Exception: + return -9999.0 + from qgis.core import QgsPointXY + ident = dtm.dataProvider().identify(QgsPointXY(x, y), QgsRaster.IdentifyFormatValue) + if not ident.isValid(): + return -9999.0 + res = ident.results() + if not res: + return -9999.0 + # take first band value (band keys are 1-based) + try: + # Prefer band 1 if present + return float(res.get(1, next(iter(res.values())))) + except Exception: + return -9999.0 + + # --- Geometry -> list of vertices (QgsPoint or QgsPointXY) --- + def vertices_from_geometry(geom): + if geom is None or geom.isEmpty(): + return [] + gtype = QgsWkbTypes.geometryType(geom.wkbType()) + is_multi = QgsWkbTypes.isMultiType(geom.wkbType()) + + if gtype == QgsWkbTypes.PointGeometry: + if is_multi: + return list(geom.asMultiPoint()) + else: + return [geom.asPoint()] + + elif gtype == QgsWkbTypes.LineGeometry: + pts = [] + if is_multi: for line in geom.asMultiPolyline(): - points.extend(line) - # points = geom.asMultiPolyline()[0] - else: - if geom.type() == QgsWkbTypes.PointGeometry: - points = [geom.asPoint()] - elif geom.type() == QgsWkbTypes.LineGeometry: - points = geom.asPolyline() - - for p in points: - data['X'].append(p.x()) - data['Y'].append(p.y()) - if dtm is not None: - # Replace with your coordinates - - # Extract the value at the point - z_value = dtm.dataProvider().identify(p, QgsRaster.IdentifyFormatValue) - if z_value.isValid(): - z_value = z_value.results()[1] - else: - z_value = -9999 - data['Z'].append(z_value) - if dtm is None: - data['Z'].append(0) - for field in fields: - data[field.name()].append(feature[field.name()]) - return pd.DataFrame(data) + pts.extend(line) + else: + pts.extend(geom.asPolyline()) + return pts + + elif gtype == QgsWkbTypes.PolygonGeometry: + pts = [] + if is_multi: + mpoly = geom.asMultiPolygon() + for poly in mpoly: + for ring in poly: # exterior + interior rings + pts.extend(ring) + else: + poly = geom.asPolygon() + for ring in poly: + pts.extend(ring) + return pts + + # Other geometry types not handled + return [] + + # --- Build rows safely (one dict per sampled point) --- + rows = [] + field_names = [f.name() for f in fields] + + for f in feats: + geom = f.geometry() + pts = vertices_from_geometry(geom) + + if not pts: + # If you want to keep attribute rows even when no vertices: uncomment below + # row = {name: f[name] for name in field_names} + # row.update({"X": None, "Y": None, "Z": None}) + # rows.append(row) + continue + + # Cache attributes once per feature and reuse for each sampled point + base_attrs = {name: f[name] for name in field_names} + + for p in pts: + # QgsPoint vs QgsPointXY both have x()/y() + x, y = float(p.x()), float(p.y()) + z = sample_dtm_xy(x, y) + + row = {"X": x, "Y": y, "Z": z} + row.update(base_attrs) + rows.append(row) + + # Create DataFrame; if empty, return with expected columns + if not rows: + cols = ["X", "Y", "Z"] + field_names + return pd.DataFrame(columns=cols) + + return pd.DataFrame.from_records(rows) def GeoDataFrameToQgsLayer(qgs_algorithm, geodataframe, parameters, context, output_key, feedback=None): """ @@ -454,3 +600,97 @@ def dataframeToQgsLayer( feedback.pushInfo("Done.") feedback.setProgress(100) return sink, sink_id + + +def matrixToDict(matrix, headers=("minx", "miny", "maxx", "maxy")) -> dict: + """ + Convert a QgsProcessingParameterMatrix value to a dict with float values. + Accepts: [[minx,miny,maxx,maxy]] or [minx,miny,maxx,maxy]. + Raises a clear error if an enum index (int) was passed by mistake. + """ + # Guard: common mistake → using parameterAsEnum + if isinstance(matrix, int): + raise QgsProcessingException( + "Bounding Box was read with parameterAsEnum (got an int). " + "Use parameterAsMatrix for QgsProcessingParameterMatrix." + ) + + if matrix is None: + raise QgsProcessingException("Bounding box matrix is None.") + + # Allow empty string from settings/defaults + if isinstance(matrix, str) and not matrix.strip(): + raise QgsProcessingException("Bounding box matrix is empty.") + + # Accept single-row matrix or flat list + if isinstance(matrix, (list, tuple)): + if matrix and isinstance(matrix[0], (list, tuple)): + row = matrix[0] + else: + row = matrix + else: + # last resort: try comma-separated string "minx,miny,maxx,maxy" + if isinstance(matrix, str) and "," in matrix: + row = [v.strip() for v in matrix.split(",")] + else: + raise QgsProcessingException(f"Unrecognized bounding box value: {type(matrix)}") + + if len(row) < 4: + raise QgsProcessingException(f"Bounding box needs 4 numbers, got {len(row)}: {row}") + + def _to_float(v): + if isinstance(v, str): + v = v.strip() + return float(v) + + vals = list(map(_to_float, row[:4])) + bbox = dict(zip(headers, vals)) + + if not (bbox["minx"] < bbox["maxx"] and bbox["miny"] < bbox["maxy"]): + raise QgsProcessingException(f"Invalid bounding box: {bbox} (expect minx Date: Mon, 22 Sep 2025 14:11:23 +0930 Subject: [PATCH 03/17] fix: remove unused QgsSettings --- m2l/processing/algorithms/sorter.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/m2l/processing/algorithms/sorter.py b/m2l/processing/algorithms/sorter.py index 20d3cd7..f80cfe0 100644 --- a/m2l/processing/algorithms/sorter.py +++ b/m2l/processing/algorithms/sorter.py @@ -118,8 +118,6 @@ def initAlgorithm(self, config: Optional[dict[str, Any]] = None) -> None: defaultValue="Observation projections", # Age-based is safest default ) ) - strati_settings = QgsSettings() - last_strati_column = strati_settings.value("m2l/sorter_strati_column", "") self.addParameter( QgsProcessingParameterFeatureSource( From 4ff75469fea8ce5d46db503e2f71e7ef29113ea5 Mon Sep 17 00:00:00 2001 From: Rabii Chaarani <50892556+rabii-chaarani@users.noreply.github.com> Date: Mon, 22 Sep 2025 14:59:22 +0930 Subject: [PATCH 04/17] fix: updated tearDownClass --- tests/qgis/test_sampler_decimator.py | 2 +- tests/qgis/test_sampler_spacing.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/qgis/test_sampler_decimator.py b/tests/qgis/test_sampler_decimator.py index b12f56a..13e7228 100644 --- a/tests/qgis/test_sampler_decimator.py +++ b/tests/qgis/test_sampler_decimator.py @@ -87,7 +87,7 @@ def test_decimator_1_with_structure(self): @classmethod def tearDownClass(cls): - QgsApplication.processingRegistry().removeProvider(cls.provider) + # QgsApplication.processingRegistry().removeProvider(cls.provider) if __name__ == '__main__': unittest.main() diff --git a/tests/qgis/test_sampler_spacing.py b/tests/qgis/test_sampler_spacing.py index a542b85..fc3a523 100644 --- a/tests/qgis/test_sampler_spacing.py +++ b/tests/qgis/test_sampler_spacing.py @@ -74,7 +74,7 @@ def test_spacing_50_with_geology(self): @classmethod def tearDownClass(cls): - QgsApplication.processingRegistry().removeProvider(cls.provider) + # QgsApplication.processingRegistry().removeProvider(cls.provider) if __name__ == '__main__': unittest.main() From 14db8d05abc7bb12983083001f5e52ce0e87bda1 Mon Sep 17 00:00:00 2001 From: Rabii Chaarani <50892556+rabii-chaarani@users.noreply.github.com> Date: Mon, 22 Sep 2025 15:00:52 +0930 Subject: [PATCH 05/17] fix: add pass statement --- tests/qgis/test_sampler_decimator.py | 1 + tests/qgis/test_sampler_spacing.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/qgis/test_sampler_decimator.py b/tests/qgis/test_sampler_decimator.py index 13e7228..15782bc 100644 --- a/tests/qgis/test_sampler_decimator.py +++ b/tests/qgis/test_sampler_decimator.py @@ -88,6 +88,7 @@ def test_decimator_1_with_structure(self): @classmethod def tearDownClass(cls): # QgsApplication.processingRegistry().removeProvider(cls.provider) + pass if __name__ == '__main__': unittest.main() diff --git a/tests/qgis/test_sampler_spacing.py b/tests/qgis/test_sampler_spacing.py index fc3a523..4016d5f 100644 --- a/tests/qgis/test_sampler_spacing.py +++ b/tests/qgis/test_sampler_spacing.py @@ -75,6 +75,6 @@ def test_spacing_50_with_geology(self): @classmethod def tearDownClass(cls): # QgsApplication.processingRegistry().removeProvider(cls.provider) - + pass if __name__ == '__main__': unittest.main() From ec993bb982116e28936bfa9b2f184475c182b952 Mon Sep 17 00:00:00 2001 From: Noelle Cheng Date: Mon, 22 Sep 2025 13:22:54 +0800 Subject: [PATCH 06/17] update sampler tests --- tests/qgis/test_sampler_decimator.py | 11 ++++++++++- tests/qgis/test_sampler_spacing.py | 6 +++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/tests/qgis/test_sampler_decimator.py b/tests/qgis/test_sampler_decimator.py index b12f56a..bb1864c 100644 --- a/tests/qgis/test_sampler_decimator.py +++ b/tests/qgis/test_sampler_decimator.py @@ -34,22 +34,27 @@ def test_decimator_1_with_structure(self): self.assertTrue(geology_layer.isValid(), "geology layer should be valid") self.assertTrue(structure_layer.isValid(), "structure layer should be valid") + self.assertTrue(dtm_layer.isValid(), "dtm layer should be valid") self.assertGreater(geology_layer.featureCount(), 0, "geology layer should have features") self.assertGreater(structure_layer.featureCount(), 0, "structure layer should have features") QgsMessageLog.logMessage(f"geology layer valid: {geology_layer.isValid()}", "TestDecimator", Qgis.Critical) QgsMessageLog.logMessage(f"structure layer valid: {structure_layer.isValid()}", "TestDecimator", Qgis.Critical) + QgsMessageLog.logMessage(f"dtm layer valid: {dtm_layer.isValid()}", "TestDecimator", Qgis.Critical) + QgsMessageLog.logMessage(f"dtm source: {dtm_layer.source()}", "TestDecimator", Qgis.Critical) QgsMessageLog.logMessage(f"geology layer: {geology_layer.featureCount()} features", "TestDecimator", Qgis.Critical) QgsMessageLog.logMessage(f"structure layer: {structure_layer.featureCount()} features", "TestDecimator", Qgis.Critical) QgsMessageLog.logMessage(f"spatial data- structure layer", "TestDecimator", Qgis.Critical) QgsMessageLog.logMessage(f"sampler type: Decimator", "TestDecimator", Qgis.Critical) QgsMessageLog.logMessage(f"decimation: 1", "TestDecimator", Qgis.Critical) + QgsMessageLog.logMessage(f"dtm: {self.dtm_file.name}", "TestDecimator", Qgis.Critical) algorithm = SamplerAlgorithm() algorithm.initAlgorithm() parameters = { + 'DTM': dtm_layer, 'GEOLOGY': geology_layer, 'SPATIAL_DATA': structure_layer, 'SAMPLER_TYPE': 0, @@ -87,7 +92,11 @@ def test_decimator_1_with_structure(self): @classmethod def tearDownClass(cls): - QgsApplication.processingRegistry().removeProvider(cls.provider) + try: + registry = QgsApplication.processingRegistry() + registry.removeProvider(cls.provider) + except Exception: + pass if __name__ == '__main__': unittest.main() diff --git a/tests/qgis/test_sampler_spacing.py b/tests/qgis/test_sampler_spacing.py index a542b85..a49042f 100644 --- a/tests/qgis/test_sampler_spacing.py +++ b/tests/qgis/test_sampler_spacing.py @@ -74,7 +74,11 @@ def test_spacing_50_with_geology(self): @classmethod def tearDownClass(cls): - QgsApplication.processingRegistry().removeProvider(cls.provider) + try: + registry = QgsApplication.processingRegistry() + registry.removeProvider(cls.provider) + except Exception: + pass if __name__ == '__main__': unittest.main() From 83c170376803fef39e6ba36dfc7f15c255b5a44d Mon Sep 17 00:00:00 2001 From: Noelle Cheng Date: Mon, 22 Sep 2025 13:24:13 +0800 Subject: [PATCH 07/17] update sampler --- m2l/processing/algorithms/sampler.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/m2l/processing/algorithms/sampler.py b/m2l/processing/algorithms/sampler.py index 28e5184..726d44a 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 QVariant +from qgis.PyQt.QtCore import QMetaType from osgeo import gdal import pandas as pd @@ -154,8 +154,11 @@ def processAlgorithm( if spatial_data is None: raise QgsProcessingException("Spatial data is required") - if sampler_type == "Decimator" and geology is None: - raise QgsProcessingException("Geology is required") + if sampler_type == "Decimator": + if geology is None: + raise QgsProcessingException("Geology is required") + if dtm is None: + raise QgsProcessingException("DTM is required") # Convert geology layers to GeoDataFrames geology = qgsLayerToGeoDataFrame(geology) @@ -173,11 +176,11 @@ def processAlgorithm( samples = sampler.sample(spatial_data_gdf) fields = QgsFields() - fields.append(QgsField("ID", QVariant.String)) - fields.append(QgsField("X", QVariant.Double)) - fields.append(QgsField("Y", QVariant.Double)) - fields.append(QgsField("Z", QVariant.Double)) - fields.append(QgsField("featureId", QVariant.String)) + fields.append(QgsField("ID", QMetaType.Type.QString)) + fields.append(QgsField("X", QMetaType.Type.Float)) + fields.append(QgsField("Y", QMetaType.Type.Float)) + fields.append(QgsField("Z", QMetaType.Type.Float)) + fields.append(QgsField("featureId", QMetaType.Type.QString)) crs = None if spatial_data_gdf is not None and spatial_data_gdf.crs is not None: From 5205cf562e1e42d4b8f3b1ff607175f21d30c901 Mon Sep 17 00:00:00 2001 From: Noelle Cheng Date: Tue, 23 Sep 2025 18:26:48 +0800 Subject: [PATCH 08/17] update strati column and bounding box --- .../algorithms/thickness_calculator.py | 58 +++++++++++++------ 1 file changed, 39 insertions(+), 19 deletions(-) diff --git a/m2l/processing/algorithms/thickness_calculator.py b/m2l/processing/algorithms/thickness_calculator.py index e10604d..6172a94 100644 --- a/m2l/processing/algorithms/thickness_calculator.py +++ b/m2l/processing/algorithms/thickness_calculator.py @@ -57,6 +57,7 @@ class ThicknessCalculatorAlgorithm(QgsProcessingAlgorithm): INPUT_GEOLOGY = 'GEOLOGY' INPUT_UNIT_NAME_FIELD = 'UNIT_NAME_FIELD' INPUT_SAMPLED_CONTACTS = 'SAMPLED_CONTACTS' + INPUT_STRATIGRAPHIC_COLUMN_LAYER = 'STRATIGRAPHIC_COLUMN_LAYER' OUTPUT = "THICKNESS" @@ -97,17 +98,6 @@ def initAlgorithm(self, config: Optional[dict[str, Any]] = None) -> None: ) ) - bbox_settings = QgsSettings() - last_bbox = bbox_settings.value("m2l/bounding_box", "") - self.addParameter( - QgsProcessingParameterMatrix( - self.INPUT_BOUNDING_BOX, - description="Bounding Box", - headers=['minx','miny','maxx','maxy'], - numberRows=1, - defaultValue=last_bbox - ) - ) self.addParameter( QgsProcessingParameterNumber( self.INPUT_MAX_LINE_LENGTH, @@ -141,6 +131,15 @@ def initAlgorithm(self, config: Optional[dict[str, Any]] = None) -> None: defaultValue='Formation' ) ) + + self.addParameter( + QgsProcessingParameterFeatureSource( + 'STRATIGRAPHIC_COLUMN_LAYER', + 'Stratigraphic Column Layer (from sorter)', + [QgsProcessing.TypeVector], + optional=True + ) + ) strati_settings = QgsSettings() last_strati_column = strati_settings.value("m2l/strati_column", "") @@ -150,7 +149,8 @@ def initAlgorithm(self, config: Optional[dict[str, Any]] = None) -> None: description="Stratigraphic Order", headers=["Unit"], numberRows=0, - defaultValue=last_strati_column + defaultValue=last_strati_column, + optional=True ) ) self.addParameter( @@ -207,17 +207,39 @@ def processAlgorithm( 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) - stratigraphic_order = self.parameterAsMatrix(parameters, self.INPUT_STRATI_COLUMN, context) structure_data = self.parameterAsSource(parameters, self.INPUT_STRUCTURE_DATA, context) 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) - bbox_settings = QgsSettings() - bbox_settings.setValue("m2l/bounding_box", bounding_box) - strati_column_settings = QgsSettings() - strati_column_settings.setValue('m2l/strati_column', stratigraphic_order) + 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() + } + stratigraphic_column_layer = self.parameterAsVectorLayer(parameters, self.INPUT_STRATIGRAPHIC_COLUMN_LAYER, context) + stratigraphic_order = None + if stratigraphic_column_layer is not None and stratigraphic_column_layer.isValid(): + stratigraphic_order=[] + stratigraphic_order_df = qgsLayerToDataFrame(stratigraphic_column_layer) + stratigraphic_order_df = stratigraphic_order_df.sort_values('order') + for _, row in stratigraphic_order_df.iterrows(): + stratigraphic_order.append(row['unit_name']) + + else: + matrix_stratigraphic_order = self.parameterAsMatrix(parameters, self.INPUT_STRATI_COLUMN, context) + if matrix_stratigraphic_order: + stratigraphic_order = [row[0] for row in matrix_stratigraphic_order if row and len(row) > 0] + else: + raise QgsProcessingException("Stratigraphic column layer is required") + if stratigraphic_order: + matrix = [[unit] for unit in stratigraphic_order] + strati_column_settings = QgsSettings() + strati_column_settings.setValue('m2l/strati_column', matrix) # convert layers to dataframe or geodataframe units = qgsLayerToDataFrame(geology_data) geology_data = qgsLayerToGeoDataFrame(geology_data) @@ -249,8 +271,6 @@ def processAlgorithm( structure_data = structure_data.rename(columns=rename_map) sampled_contacts = qgsLayerToDataFrame(sampled_contacts) dtm_data = qgsRasterToGdalDataset(dtm_data) - bounding_box = matrixToDict(bounding_box) - feedback.pushInfo("Calculating unit thicknesses...") if thickness_type == "InterpolatedStructure": thickness_calculator = InterpolatedStructure( dtm_data=dtm_data, From a259caef14bfb67736a65de6e2581ccd33803a7c Mon Sep 17 00:00:00 2001 From: Noelle Cheng Date: Tue, 23 Sep 2025 18:27:29 +0800 Subject: [PATCH 09/17] fix sample contacts data type --- m2l/processing/algorithms/thickness_calculator.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/m2l/processing/algorithms/thickness_calculator.py b/m2l/processing/algorithms/thickness_calculator.py index 6172a94..afba1e8 100644 --- a/m2l/processing/algorithms/thickness_calculator.py +++ b/m2l/processing/algorithms/thickness_calculator.py @@ -270,6 +270,9 @@ def processAlgorithm( if rename_map: structure_data = structure_data.rename(columns=rename_map) sampled_contacts = qgsLayerToDataFrame(sampled_contacts) + sampled_contacts['X'] = sampled_contacts['X'].astype(float) + sampled_contacts['Y'] = sampled_contacts['Y'].astype(float) + sampled_contacts['Z'] = sampled_contacts['Z'].astype(float) dtm_data = qgsRasterToGdalDataset(dtm_data) if thickness_type == "InterpolatedStructure": thickness_calculator = InterpolatedStructure( From 00d4bb82b4b6a9dfdd21264cdafc75dc8d71990b Mon Sep 17 00:00:00 2001 From: Noelle Cheng Date: Tue, 23 Sep 2025 18:47:40 +0800 Subject: [PATCH 10/17] add orientation type for structure data --- m2l/processing/algorithms/thickness_calculator.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/m2l/processing/algorithms/thickness_calculator.py b/m2l/processing/algorithms/thickness_calculator.py index afba1e8..0210f68 100644 --- a/m2l/processing/algorithms/thickness_calculator.py +++ b/m2l/processing/algorithms/thickness_calculator.py @@ -55,6 +55,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_UNIT_NAME_FIELD = 'UNIT_NAME_FIELD' INPUT_SAMPLED_CONTACTS = 'SAMPLED_CONTACTS' INPUT_STRATIGRAPHIC_COLUMN_LAYER = 'STRATIGRAPHIC_COLUMN_LAYER' @@ -167,6 +168,14 @@ def initAlgorithm(self, config: Optional[dict[str, Any]] = None) -> None: [QgsProcessing.TypeVectorPoint], ) ) + self.addParameter( + QgsProcessingParameterEnum( + 'THICKNESS_ORIENTATION_TYPE', + 'Thickness Orientation Type', + options=['Dip Direction', 'Strike'], + defaultValue=0 # Default to Dip Direction + ) + ) self.addParameter( QgsProcessingParameterField( self.INPUT_DIPDIR_FIELD, @@ -208,6 +217,8 @@ def processAlgorithm( 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) 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) @@ -269,6 +280,7 @@ def processAlgorithm( ) if rename_map: structure_data = structure_data.rename(columns=rename_map) + sampled_contacts = qgsLayerToDataFrame(sampled_contacts) sampled_contacts['X'] = sampled_contacts['X'].astype(float) sampled_contacts['Y'] = sampled_contacts['Y'].astype(float) @@ -278,6 +290,7 @@ def processAlgorithm( thickness_calculator = InterpolatedStructure( dtm_data=dtm_data, bounding_box=bounding_box, + is_strike=is_strike ) thicknesses = thickness_calculator.compute( units, @@ -293,6 +306,7 @@ def processAlgorithm( dtm_data=dtm_data, bounding_box=bounding_box, max_line_length=max_line_length, + is_strike=is_strike ) thicknesses =thickness_calculator.compute( units, From f59f4f8d19fadc506de5a6e23de01dbba1813fe1 Mon Sep 17 00:00:00 2001 From: Noelle Cheng Date: Wed, 24 Sep 2025 12:42:28 +0800 Subject: [PATCH 11/17] fix units in ThicknessCalculatorAlgorithm --- m2l/processing/algorithms/thickness_calculator.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/m2l/processing/algorithms/thickness_calculator.py b/m2l/processing/algorithms/thickness_calculator.py index 0210f68..7bab4f4 100644 --- a/m2l/processing/algorithms/thickness_calculator.py +++ b/m2l/processing/algorithms/thickness_calculator.py @@ -10,6 +10,7 @@ """ # Python imports from typing import Any, Optional +import pandas as pd # QGIS imports from qgis import processing @@ -260,9 +261,8 @@ def processAlgorithm( missing_fields = [] if unit_name_field != 'UNITNAME' and unit_name_field in geology_data.columns: geology_data = geology_data.rename(columns={unit_name_field: 'UNITNAME'}) - units = units.rename(columns={unit_name_field: 'UNITNAME'}) - units = units.drop_duplicates(subset=['UNITNAME']).reset_index(drop=True) - units = units.rename(columns={'UNITNAME': 'name'}) + units_unique = units.drop_duplicates(subset=[unit_name_field]).reset_index(drop=True) + units = pd.DataFrame({'name': units_unique[unit_name_field]}) if structure_data is not None: if structure_dipdir_field: if structure_dipdir_field in structure_data.columns: From 2cd4c536f392f8bb6cb988e465ff53f39fa616bc Mon Sep 17 00:00:00 2001 From: Noelle Cheng Date: Mon, 6 Oct 2025 14:06:04 +0800 Subject: [PATCH 12/17] feat dynamic field handling in Sampler --- m2l/processing/algorithms/sampler.py | 40 +++++++++++++++++++--------- 1 file changed, 28 insertions(+), 12 deletions(-) diff --git a/m2l/processing/algorithms/sampler.py b/m2l/processing/algorithms/sampler.py index 726d44a..cafd4b2 100644 --- a/m2l/processing/algorithms/sampler.py +++ b/m2l/processing/algorithms/sampler.py @@ -176,11 +176,19 @@ def processAlgorithm( samples = sampler.sample(spatial_data_gdf) fields = QgsFields() - fields.append(QgsField("ID", QMetaType.Type.QString)) - fields.append(QgsField("X", QMetaType.Type.Float)) - fields.append(QgsField("Y", QMetaType.Type.Float)) - fields.append(QgsField("Z", QMetaType.Type.Float)) - fields.append(QgsField("featureId", QMetaType.Type.QString)) + if samples is not None and not samples.empty: + for column_name in samples.columns: + dtype = samples[column_name].dtype + dtype_str = str(dtype) + + if dtype_str in ['float16', 'float32', 'float64']: + field_type = QMetaType.Type.Double + elif dtype_str in ['int8', 'int16', 'int32', 'int64']: + field_type = QMetaType.Type.Int + else: + field_type = QMetaType.Type.QString + + fields.append(QgsField(column_name, field_type)) crs = None if spatial_data_gdf is not None and spatial_data_gdf.crs is not None: @@ -207,13 +215,21 @@ def processAlgorithm( #spacing has no z values feature.setGeometry(QgsGeometry.fromPointXY(QgsPointXY(row['X'], row['Y']))) - feature.setAttributes([ - str(row.get('ID', '')), - float(row.get('X', 0)), - float(row.get('Y', 0)), - float(row.get('Z', 0)) if pd.notna(row.get('Z')) else 0.0, - str(row.get('featureId', '')) - ]) + attributes = [] + for column_name in samples.columns: + value = row.get(column_name) + dtype = samples[column_name].dtype + + if pd.isna(value): + attributes.append(None) + elif dtype in ['float16', 'float32', 'float64']: + attributes.append(float(value)) + elif dtype in ['int8', 'int16', 'int32', 'int64']: + attributes.append(int(value)) + else: + attributes.append(str(value)) + + feature.setAttributes(attributes) sink.addFeature(feature) From 97c527446c1813599fa7873de4748b93d6eac539 Mon Sep 17 00:00:00 2001 From: Noelle Cheng Date: Mon, 6 Oct 2025 15:17:12 +0800 Subject: [PATCH 13/17] fix strat column data in ThicknessCalculator --- .../algorithms/thickness_calculator.py | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/m2l/processing/algorithms/thickness_calculator.py b/m2l/processing/algorithms/thickness_calculator.py index 7bab4f4..51d6f91 100644 --- a/m2l/processing/algorithms/thickness_calculator.py +++ b/m2l/processing/algorithms/thickness_calculator.py @@ -233,19 +233,22 @@ def processAlgorithm( 'maxx': extent.xMaximum(), 'maxy': extent.yMaximum() } - stratigraphic_column_layer = self.parameterAsVectorLayer(parameters, self.INPUT_STRATIGRAPHIC_COLUMN_LAYER, context) - stratigraphic_order = None - if stratigraphic_column_layer is not None and stratigraphic_column_layer.isValid(): - stratigraphic_order=[] - stratigraphic_order_df = qgsLayerToDataFrame(stratigraphic_column_layer) - stratigraphic_order_df = stratigraphic_order_df.sort_values('order') - for _, row in stratigraphic_order_df.iterrows(): - stratigraphic_order.append(row['unit_name']) + stratigraphic_column_source = self.parameterAsSource(parameters, self.INPUT_STRATIGRAPHIC_COLUMN_LAYER, context) + stratigraphic_order = [] + if stratigraphic_column_source is not None: + ordered_pairs =[] + for feature in stratigraphic_column_source.getFeatures(): + order = feature.attribute('order') + unit_name = feature.attribute('unit_name') + ordered_pairs.append((order, unit_name)) + ordered_pairs.sort(key=lambda x: x[0]) + stratigraphic_order = [pair[1] for pair in ordered_pairs] + feedback.pushInfo(f"DEBUG: parameterAsVectorLayer Stratigraphic order: {stratigraphic_order}") else: matrix_stratigraphic_order = self.parameterAsMatrix(parameters, self.INPUT_STRATI_COLUMN, context) if matrix_stratigraphic_order: - stratigraphic_order = [row[0] for row in matrix_stratigraphic_order if row and len(row) > 0] + stratigraphic_order = [str(row) for row in matrix_stratigraphic_order if row] else: raise QgsProcessingException("Stratigraphic column layer is required") if stratigraphic_order: From fe429df9cde758877301b8308afbf737204feafc Mon Sep 17 00:00:00 2001 From: Noelle Cheng Date: Tue, 7 Oct 2025 11:08:39 +0800 Subject: [PATCH 14/17] merge with processing/processing_tools --- m2l/processing/algorithms/__init__.py | 1 + .../algorithms/extract_basal_contacts.py | 50 ++++---- m2l/processing/algorithms/sorter.py | 10 +- .../algorithms/user_defined_sorter.py | 112 ++++++++++++++++++ m2l/processing/provider.py | 2 + .../input/stratigraphic_column_testing.gpkg | Bin 0 -> 73728 bytes tests/qgis/test_basal_contacts.py | 29 +---- 7 files changed, 150 insertions(+), 54 deletions(-) create mode 100644 m2l/processing/algorithms/user_defined_sorter.py create mode 100644 tests/qgis/input/stratigraphic_column_testing.gpkg diff --git a/m2l/processing/algorithms/__init__.py b/m2l/processing/algorithms/__init__.py index f0aaedb..08d76a0 100644 --- a/m2l/processing/algorithms/__init__.py +++ b/m2l/processing/algorithms/__init__.py @@ -1,4 +1,5 @@ from .extract_basal_contacts import BasalContactsAlgorithm from .sorter import StratigraphySorterAlgorithm +from .user_defined_sorter import UserDefinedStratigraphyAlgorithm from .thickness_calculator import ThicknessCalculatorAlgorithm from .sampler import SamplerAlgorithm diff --git a/m2l/processing/algorithms/extract_basal_contacts.py b/m2l/processing/algorithms/extract_basal_contacts.py index 5903d82..4d3ba5f 100644 --- a/m2l/processing/algorithms/extract_basal_contacts.py +++ b/m2l/processing/algorithms/extract_basal_contacts.py @@ -22,9 +22,11 @@ QgsProcessingFeedback, QgsProcessingParameterFeatureSink, QgsProcessingParameterFeatureSource, + QgsProcessingParameterMapLayer, QgsProcessingParameterString, QgsProcessingParameterField, QgsProcessingParameterMatrix, + QgsVectorLayer, QgsSettings ) # Internal imports @@ -49,15 +51,15 @@ def name(self) -> str: def displayName(self) -> str: """Return the algorithm display name.""" - return "Loop3d: Basal Contacts" + return "Basal Contacts" def group(self) -> str: """Return the algorithm group name.""" - return "Loop3d" + return "Contact Extractors" def groupId(self) -> str: """Return the algorithm group ID.""" - return "Loop3d" + return "Contact_Extractors" def initAlgorithm(self, config: Optional[dict[str, Any]] = None) -> None: """Initialize the algorithm parameters.""" @@ -98,15 +100,12 @@ def initAlgorithm(self, config: Optional[dict[str, Any]] = None) -> None: optional=True, ) ) - strati_settings = QgsSettings() - last_strati_column = strati_settings.value("m2l/strati_column", "") self.addParameter( - QgsProcessingParameterMatrix( - name=self.INPUT_STRATI_COLUMN, - description="Stratigraphic Order", - headers=["Unit"], - numberRows=0, - defaultValue=last_strati_column + QgsProcessingParameterFeatureSource( + self.INPUT_STRATI_COLUMN, + "Stratigraphic Order", + [QgsProcessing.TypeVector], + defaultValue='formation', ) ) ignore_settings = QgsSettings() @@ -145,34 +144,33 @@ def processAlgorithm( feedback.pushInfo("Loading data...") geology = self.parameterAsVectorLayer(parameters, self.INPUT_GEOLOGY, context) faults = self.parameterAsVectorLayer(parameters, self.INPUT_FAULTS, context) - strati_column = self.parameterAsMatrix(parameters, self.INPUT_STRATI_COLUMN, context) + strati_column = self.parameterAsSource(parameters, self.INPUT_STRATI_COLUMN, context) ignore_units = self.parameterAsMatrix(parameters, self.INPUT_IGNORE_UNITS, context) - if not strati_column or all(isinstance(unit, str) and not unit.strip() for unit in strati_column): - raise QgsProcessingException("no stratigraphic column found") + + if isinstance(strati_column, QgsProcessingParameterMapLayer) : + raise QgsProcessingException("Invalid stratigraphic column layer") + + elif strati_column is not None: + # extract unit names from strati_column + field_name = "unit_name" + strati_order = [f[field_name] for f in strati_column.getFeatures()] if not ignore_units or all(isinstance(unit, str) and not unit.strip() for unit in ignore_units): feedback.pushInfo("no units to ignore specified") - - # if strati_column and strati_column.strip(): - # strati_column = [unit.strip() for unit in strati_column.split(',')] - # Save stratigraphic column settings - strati_column_settings = QgsSettings() - strati_column_settings.setValue('m2l/strati_column', strati_column) ignore_settings = QgsSettings() ignore_settings.setValue("m2l/ignore_units", ignore_units) unit_name_field = self.parameterAsString(parameters, 'UNIT_NAME_FIELD', context) - formation_field = self.parameterAsString(parameters, 'FORMATION_FIELD', context) geology = qgsLayerToGeoDataFrame(geology) - if formation_field and formation_field in geology.columns: - mask = ~geology[formation_field].astype(str).str.strip().isin(ignore_units) + if unit_name_field and unit_name_field in geology.columns: + mask = ~geology[unit_name_field].astype(str).str.strip().isin(ignore_units) geology = geology[mask].reset_index(drop=True) - feedback.pushInfo(f"filtered by formation field: {formation_field}") + feedback.pushInfo(f"filtered by unit name field: {unit_name_field}") else: - feedback.pushInfo(f"no formation field found: {formation_field}") + feedback.pushInfo(f"no unit name field found: {unit_name_field}") faults = qgsLayerToGeoDataFrame(faults) if faults else None if unit_name_field != 'UNITNAME' and unit_name_field in geology.columns: @@ -181,7 +179,7 @@ def processAlgorithm( feedback.pushInfo("Extracting Basal Contacts...") contact_extractor = ContactExtractor(geology, faults) all_contacts = contact_extractor.extract_all_contacts() - basal_contacts = contact_extractor.extract_basal_contacts(strati_column) + basal_contacts = contact_extractor.extract_basal_contacts(strati_order) feedback.pushInfo("Exporting Basal Contacts Layer...") basal_contacts = GeoDataFrameToQgsLayer( diff --git a/m2l/processing/algorithms/sorter.py b/m2l/processing/algorithms/sorter.py index f80cfe0..55a179c 100644 --- a/m2l/processing/algorithms/sorter.py +++ b/m2l/processing/algorithms/sorter.py @@ -24,6 +24,7 @@ QgsProcessingParameterField, QgsProcessingParameterRasterLayer, QgsProcessingParameterMatrix, + QgsCoordinateReferenceSystem, QgsVectorLayer, QgsWkbTypes, QgsSettings @@ -73,13 +74,13 @@ def name(self) -> str: return "loop_sorter" def displayName(self) -> str: - return "Loop3d: Stratigraphic sorter" + return "Automatic Stratigraphic Column" def group(self) -> str: - return "Loop3d" + return "Stratigraphy" def groupId(self) -> str: - return "Loop3d" + return "Stratigraphy_Column" def updateParameters(self, parameters): selected_method = parameters.get(self.METHOD, 0) @@ -340,7 +341,6 @@ def processAlgorithm( def createInstance(self) -> QgsProcessingAlgorithm: return StratigraphySorterAlgorithm() - # ------------------------------------------------------------------------- # Helper stub – you must replace with *your* conversion logic # ------------------------------------------------------------------------- @@ -415,4 +415,4 @@ def build_input_frames(layer: QgsVectorLayer,contacts_layer: QgsVectorLayer, fee else: relationships_df = pd.DataFrame() - return units_df, relationships_df, contacts_df \ No newline at end of file + return units_df, relationships_df, contacts_df diff --git a/m2l/processing/algorithms/user_defined_sorter.py b/m2l/processing/algorithms/user_defined_sorter.py new file mode 100644 index 0000000..23857a3 --- /dev/null +++ b/m2l/processing/algorithms/user_defined_sorter.py @@ -0,0 +1,112 @@ +from typing import Any, Optional +from osgeo import gdal +import numpy as np +import json + +from PyQt5.QtCore import QVariant +from qgis import processing +from qgis.core import ( + QgsFeatureSink, + QgsFields, + QgsField, + QgsFeature, + QgsGeometry, + QgsRasterLayer, + QgsProcessing, + QgsProcessingAlgorithm, + QgsProcessingContext, + QgsProcessingException, + QgsProcessingFeedback, + QgsProcessingParameterEnum, + QgsProcessingParameterFileDestination, + QgsProcessingParameterFeatureSink, + QgsProcessingParameterFeatureSource, + QgsProcessingParameterField, + QgsProcessingParameterRasterLayer, + QgsProcessingParameterMatrix, + QgsCoordinateReferenceSystem, + QgsVectorLayer, + QgsWkbTypes, + QgsSettings +) + + +from qgis.core import ( + QgsFields, QgsField, QgsFeature, QgsFeatureSink, QgsWkbTypes, + QgsCoordinateReferenceSystem, QgsProcessingAlgorithm, QgsProcessingContext, + QgsProcessingFeedback, QgsProcessingParameterFeatureSink, QgsProcessingParameterMatrix, + QgsSettings +) +from PyQt5.QtCore import QVariant +import numpy as np + +class UserDefinedStratigraphyAlgorithm(QgsProcessingAlgorithm): + INPUT_STRATI_COLUMN = "INPUT_STRATI_COLUMN" + OUTPUT = "OUTPUT" + + def name(self): return "loop_sorter_2" + def displayName(self): return "User-Defined Stratigraphic Column" + def group(self): return "Stratigraphy" + def groupId(self): return "Stratigraphy_Column" + + def initAlgorithm(self, config=None): + strati_settings = QgsSettings() + last_strati_column = strati_settings.value("m2l/strati_column", "") + self.addParameter( + QgsProcessingParameterMatrix( + name=self.INPUT_STRATI_COLUMN, + description="Stratigraphic Order", + headers=["Unit"], + numberRows=0, + defaultValue=last_strati_column + ) + ) + self.addParameter( + QgsProcessingParameterFeatureSink( + self.OUTPUT, + "Stratigraphic column", + ) + ) + + def processAlgorithm(self, parameters, context, feedback): + # 1) Read the matrix; it may be a list of lists (rows) or a flat list depending on input source. + matrix = self.parameterAsMatrix(parameters, self.INPUT_STRATI_COLUMN, context) + + # Normalize to a list of unit strings (one column: "Unit") + units = [] + for row in matrix: + if isinstance(row, (list, tuple)): + unit = row[0] if row else "" + else: + unit = row + unit = (unit or "").strip() + if unit: # skip empty rows to avoid writing "" into fields + units.append(unit) + + # 2) Build sequential order (1-based), cast to native int + order_vals = [int(i) for i in (np.arange(len(units)) + 1)] + + # 3) Prepare sink + sink_fields = QgsFields() + sink_fields.append(QgsField("order", QVariant.Int)) # or QVariant.LongLong + sink_fields.append(QgsField("unit_name", QVariant.String)) + + crs = context.project().crs() if context and context.project() else QgsCoordinateReferenceSystem() + sink, dest_id = self.parameterAsSink( + parameters, self.OUTPUT, context, + sink_fields, QgsWkbTypes.NoGeometry, crs + ) + + # 4) Insert features + for pos, unit_name in zip(order_vals, units): + f = QgsFeature(sink_fields) + # Ensure correct types: int for "order", str for "unit_name" + f.setAttributes([int(pos), str(unit_name)]) + ok = sink.addFeature(f, QgsFeatureSink.FastInsert) + if not ok: + feedback.reportError(f"Failed to add feature for unit '{unit_name}' (order={pos}).") + + return {self.OUTPUT: dest_id} + + def createInstance(self): + return __class__() diff --git a/m2l/processing/provider.py b/m2l/processing/provider.py index 318e944..9616d9b 100644 --- a/m2l/processing/provider.py +++ b/m2l/processing/provider.py @@ -19,6 +19,7 @@ from .algorithms import ( BasalContactsAlgorithm, StratigraphySorterAlgorithm, + UserDefinedStratigraphyAlgorithm, ThicknessCalculatorAlgorithm, SamplerAlgorithm ) @@ -35,6 +36,7 @@ def loadAlgorithms(self): """Loads all algorithms belonging to this provider.""" self.addAlgorithm(BasalContactsAlgorithm()) self.addAlgorithm(StratigraphySorterAlgorithm()) + self.addAlgorithm(UserDefinedStratigraphyAlgorithm()) self.addAlgorithm(ThicknessCalculatorAlgorithm()) self.addAlgorithm(SamplerAlgorithm()) diff --git a/tests/qgis/input/stratigraphic_column_testing.gpkg b/tests/qgis/input/stratigraphic_column_testing.gpkg new file mode 100644 index 0000000000000000000000000000000000000000..bb782ef5b2f52fba0d364aa506a7e5bdf571a79c GIT binary patch literal 73728 zcmeI*Pi!Ms9S87n{yEtscG7OvrD@q$OTjVg?8Z*AS?_iMC-FLNV>|U9rRh>xv-XQK ztUZ&?jGN60A@&a-aY9I3IDrHQE=XJ`H||_G!U3*GsH#95;ZT0#@t>Kolcvd5yG_1U z{(0V;H}8GkKfigiZSI{7N#|@wQEQ^k7K91G>vl`hJPNTd$YtkAc=3`t1^g z*WFAh?G%lkhgc*6YEBPI?(00Izz00bZa0SG_<0uX=z1R(GQ z33$EZlyd+0uLTeN@a%p;mqD@+fB*y_009U<00Izz00ba#Jb`Zu?%TrD@a0-TF7q8p z=4DplN<|g-cBK-_Wpn9FI$m#uwg0e2Dk+LumSmCkdR4x|RW6q}(+)J9*Y1AWdbw{2 zQ?ASRJ6Bm1Rh?^+D7P;1X*==uAFLDUh>JYA_{*JBrdgE$j zX*m*ESh~ItiWTzfnQSt@eb;ZK><{^u=NIPv@4w$8wS>yKyf2k@ z{h|3D(QicO!$$N%I+?%gFY^jTr~fszfM^*4=Zw|c|0D=<$L9L(adqR74urPnH1~jYfO?^nU|!RSf$@aE;Xje zOyA|KX|L_=a#fMa#`@po{YaoMydVGp2tWV=5P$##AOHafKmY;|c(DW$uF=37f$`f$ zu7)*TrF#Iyl2WbL=njvrO84q?+s1Y{G9Ou*n~%;#7W4DVH|G~_E-qi4zaDw-0wv(` z{!5@QydVGp2tWV=5P$##AOHafKmY;|I2!^J{ecnNeF3ch&ql?f6cB&_1Rwwb2tWV= z5P$##AOHc2K-c=;eE$Cv!TX6N7XE|)1Rwwb2tWV=5P$##AOHafK;X+OFzMVBu4gm;200Izz00bZa0SG_<0uVSvpy&QS{ZIe!f&c^{009U<00Izz00bZa z0SG|g3=0_h|5*Q@;R;64AOHafKmY;|fB*y_009U<00Ja{^*@>c1Rwwb2tWV=5P$## zAOHafK;Y~P82|l0?EjzrdPWH$009U<00Izz00bZa0SG|gd<$Uz|9sa!N)G`DKmY;| zfB*y_009U<00QSj0Q>*vqn=Sp2tWV=5P$##AOHafKmY;|INt);|3BaLkJ3W`0uX=z z1Rwwb2tWV=5P-n>5E%FTNAUK2EO`Dq_IJ-mp6`yi#(qAsJNo<4`|j_JTps+x;Qqjk zzK>nM5>n^G1EoB(0*~MFx~Jcn8-DtRu1b{(S1Wt>D@9$Z@?uS-Ll24%6{S|J@(+2n zSl=s)I(Noc&BkN-ILl{~i9|fh0?zR66as7|ekYTSv%=<@ajLSzI#*$tbaxs7M)6rZ zwz|%;nJqd!kxa8(d?UV^XW3XX7oUx-WU~1X3z&Iiip+HJUX`;zSAGHk_MxOysU#XJ zDYB-kq9p4#tyw6EvaIOr9%ogqX-wZ0Wi}rOvaR(v<$5+9-@0na@SAKtc#Fl;YmeXb zn3#jdz?5W-tGW~G^I)1Ov@w(ETs)gUC7+q*^WppE_>eC*=Mp5j%pYhUR3)7k#k#IE z-WQ!x#jx{;=gI1r+c!7o`eC$D_s($V`|%cbY-JD747HbmX=&UFq3rFOHt!py~a2;dSq=H zF|4Jr*QmA3Ax+haQn|ZwYLZ-h(1mLpJ?K6vKIj#fj&{ejQ&#kv_RK6D)znCvif0U_ zoeDC`+g&-@o~TP=wW#u)qIRIM*=EFGVsyxNcj?&ul;pens!<FkoVl@!v+cM3E!bvmIfK6OzezHF~} zOXOqqOlNB<6;I!mk|RUD_m_`sOYKT&wuoY+XPdZ?9RB=CnRAHq3L}p$4Z3~TuDKrF z?KDiKqEc;{<5eRA)&oP`4bmEHsh1-Lo*gdgb(Paq>T+jtSc~6@6*kP7Cm0(V@)fQf z$z{E6v)@?{xu3k&=k^5xt{?1o%4RE9>rH=mnOZ^ChIT}O=+mNDG`f;KPDm=#pfBx6 zTr~sC)*C8wt)xnOxnc-L~;w*ib+tH;O zpAF1x&&|~4X3F`Q^_w%Pn=?D_1wsK?*$)JRA?rvUV%8BnRI?1+6l58&9rHqWXHbpq zV9XpR8C~0hYjj}9mkJzN!>w9qc7)d3!D#>SPc<8U>(l{S-wYdToX7pH#@zcP*l8S& zd7#nu9mo2*n~EcbIeYZ@EOp3;Y8?|pidqq6=^;%=t(MUqWvxNfaTK*oQ&rFDyUcea zS!yKVXpYWy7aSk031dQ3xbU^%Kf9j}%?|vn|NDK5!e51`_qU#(jH?6{Hnh54k6wGF z(YGF%eal!{=#nwU8#9^p&7=*?ZwvFYS?1r7%Kr985bGzhSRtQDrs?!l+&bGZKNX#; zewK=_B?~E(+#6+swr0vu&PaX{Q&D)lQo(r)sCRG}^Y$ z`98(7wA_B#J-tQaY4or@-@F@OF56GqP`tC;(iBEjNXN6xTc!uoWlJoC~L4?X6>u2dCEg>||OxozHI1T<6M3 zbvC~;8YORe_N*zs`$&Kh?oLUd-(`1k*lo@v4RPw$_+ zzw`dgyYF4|e%(9b`M2jUoN`5hvsmtGyWO3ILFk%FZyv4a{54b!`0kPqf zsH!4MiM4xFVwLvb+xdFMO%WE{Tar%C@zI0AosjSlh43M>fT>be*Vp~d;ezvpW>!+}A|5k&|$ZK}@ zKrx;leZ7wYZugOAwZeJXnf)o(fG~0`PY>O2wn{(ln}Ejp-{t*8pf9{2009U<00Izz b00bZa0SG_<0uVS00+X)(fXnvO#pM41;`-|; literal 0 HcmV?d00001 diff --git a/tests/qgis/test_basal_contacts.py b/tests/qgis/test_basal_contacts.py index c6f9256..bb90de8 100644 --- a/tests/qgis/test_basal_contacts.py +++ b/tests/qgis/test_basal_contacts.py @@ -1,6 +1,7 @@ import unittest from pathlib import Path -from qgis.core import QgsVectorLayer, QgsProcessingContext, QgsProcessingFeedback, QgsMessageLog, Qgis, QgsApplication +from qgis.core import QgsVectorLayer, QgsProcessingContext, QgsProcessingFeedback, QgsMessageLog, Qgis, QgsApplication, QgsFeature, QgsField +from qgis.PyQt.QtCore import QVariant from qgis.testing import start_app from m2l.processing.algorithms.extract_basal_contacts import BasalContactsAlgorithm from m2l.processing.provider import Map2LoopProvider @@ -20,9 +21,10 @@ def setUp(self): self.geology_file = self.input_dir / "geol_clip_no_gaps.shp" self.faults_file = self.input_dir / "faults_clip.shp" + self.strati_file = self.input_dir / "stratigraphic_column_testing.gpkg" self.assertTrue(self.geology_file.exists(), f"geology not found: {self.geology_file}") - + self.assertTrue(self.strati_file.exists(), f"strati not found: {self.strati_file}") if not self.faults_file.exists(): QgsMessageLog.logMessage(f"faults not found: {self.faults_file}, will run test without faults", "TestBasalContacts", Qgis.Warning) @@ -42,26 +44,7 @@ def test_basal_contacts_extraction(self): QgsMessageLog.logMessage(f"geology layer: {geology_layer.featureCount()} features", "TestBasalContacts", Qgis.Critical) - strati_column = [ - "Turee Creek Group", - "Boolgeeda Iron Formation", - "Woongarra Rhyolite", - "Weeli Wolli Formation", - "Brockman Iron Formation", - "Mount McRae Shale and Mount Sylvia Formation", - "Wittenoom Formation", - "Marra Mamba Iron Formation", - "Jeerinah Formation", - "Bunjinah Formation", - "Pyradie Formation", - "Fortescue Group", - "Hardey Formation", - "Boongal Formation", - "Mount Roe Basalt", - "Rocklea Inlier greenstones", - "Rocklea Inlier metagranitic unit" - ] - + strati_table = QgsVectorLayer(str(self.strati_file), "strati", "ogr") algorithm = BasalContactsAlgorithm() algorithm.initAlgorithm() @@ -70,7 +53,7 @@ def test_basal_contacts_extraction(self): 'UNIT_NAME_FIELD': 'unitname', 'FORMATION_FIELD': 'formation', 'FAULTS': faults_layer, - 'STRATIGRAPHIC_COLUMN': strati_column, + 'STRATIGRAPHIC_COLUMN': strati_table, 'IGNORE_UNITS': [], 'BASAL_CONTACTS': 'memory:basal_contacts', 'ALL_CONTACTS': 'memory:all_contacts' From 53917e3a0e47807e5d4d344f12c529d9304b373d Mon Sep 17 00:00:00 2001 From: Noelle Cheng Date: Wed, 15 Oct 2025 13:17:34 +0800 Subject: [PATCH 15/17] add user defined boundingbox in ThicknessCalculatorAlgorithm --- .../algorithms/thickness_calculator.py | 54 +++++++++++++++---- 1 file changed, 45 insertions(+), 9 deletions(-) diff --git a/m2l/processing/algorithms/thickness_calculator.py b/m2l/processing/algorithms/thickness_calculator.py index 51d6f91..a88f387 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' @@ -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, @@ -213,7 +237,7 @@ 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) @@ -225,14 +249,26 @@ def processAlgorithm( 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 = [] From c94b40cb706a5144f032ff8fd4869fcff25dea3e Mon Sep 17 00:00:00 2001 From: Noelle Cheng Date: Wed, 15 Oct 2025 13:48:43 +0800 Subject: [PATCH 16/17] replace QMetaType with QVariant in sampler --- m2l/processing/algorithms/sampler.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 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)) From b32baec3fbd2695729aa22fe827543517a421677 Mon Sep 17 00:00:00 2001 From: Noelle Cheng Date: Wed, 15 Oct 2025 13:56:35 +0800 Subject: [PATCH 17/17] change orientation type variable name in ThicknessCalculatorAlgorithm --- m2l/processing/algorithms/thickness_calculator.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/m2l/processing/algorithms/thickness_calculator.py b/m2l/processing/algorithms/thickness_calculator.py index a88f387..824fd34 100644 --- a/m2l/processing/algorithms/thickness_calculator.py +++ b/m2l/processing/algorithms/thickness_calculator.py @@ -57,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' @@ -195,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 ) @@ -242,8 +242,8 @@ def processAlgorithm( 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)