Source code for sec_interp.core.services.structure_service

from __future__ import annotations

"""Structure Data Processing Service.

This module handles the calculation of apparent dips and projection
of structural measurements (planes, lines) onto the section plane.
"""

# /***************************************************************************
#  SecInterp - StructureService
#                                  A QGIS plugin
#  Service for projecting structural measurements.
#                               -------------------
#         begin                : 2025-12-07
#         copyright            : (C) 2025 by Juan M Bernales
#         email                : juanbernales@gmail.com
#  ***************************************************************************/
#
# /***************************************************************************
#  *                                                                         *
#  *   This program is free software; you can redistribute it and/or modify  *
#  *   it under the terms of the GNU General Public License as published by  *
#  *   the Free Software Foundation; either version 2 of the License, or     *
#  *   (at your option) any later version.                                   *
#  *                                                                         *
#  ***************************************************************************/
from collections.abc import Iterator

from qgis.core import (
    QgsCoordinateReferenceSystem,
    QgsDistanceArea,
    QgsFeature,
    QgsGeometry,
    QgsPointXY,
    QgsRaster,
    QgsRasterLayer,
    QgsVectorLayer,
)

from sec_interp.core import utils as scu
from sec_interp.core.exceptions import DataMissingError, GeometryError, ProcessingError
from sec_interp.core.interfaces.structure_interface import IStructureService
from sec_interp.core.types import StructureData, StructureMeasurement
from sec_interp.logger_config import get_logger

logger = get_logger(__name__)


[docs] class StructureService(IStructureService): """Service for projecting structural measurements onto cross-sections. This service handles the filtering and projection of structural measurements (dip/strike) onto a cross-section plane to calculate apparent dip. """
[docs] def project_structures( self, line_lyr: QgsVectorLayer, raster_lyr: QgsRasterLayer, struct_lyr: QgsVectorLayer, buffer_m: int, line_az: float, dip_field: str, strike_field: str, band_number: int = 1, ) -> StructureData: """Project structural measurements onto the cross-section plane. Filters structures within a buffer, samples elevation, and calculates apparent dip for each measurement. Args: line_lyr: The cross-section line vector layer. raster_lyr: The DEM raster layer for elevation sampling. struct_lyr: Vector layer containing structural measurements. buffer_m: Search buffer distance in meters. line_az: Azimuth of the section line in degrees. dip_field: Name of the field containing dip values. strike_field: Name of the field containing strike values. band_number: Raster band to use for elevation (default: 1). Returns: A list of StructureMeasurement objects sorted by distance along section. Raises: DataMissingError: If line layer has no features. GeometryError: If line geometry is invalid. """ # Logging: Initial setup logger.info(f"Analyzing structures in {struct_lyr.name()} with buffer {buffer_m}m") line_geom, line_start = self._extract_line_info(line_lyr) # 1. Create Buffer buffer_geom = self._create_buffer_zone(line_geom, line_lyr.crs(), buffer_m) # 2. Filter Measures filtered_features = self._filter_structures(struct_lyr, buffer_geom, line_lyr.crs()) # 3. Process Features projected_structs = [] crs = struct_lyr.crs() da = scu.create_distance_area(crs) for f in filtered_features: measurement = self._process_single_structure( f, line_geom, line_start, da, raster_lyr, band_number, line_az, dip_field, strike_field, ) if measurement: projected_structs.append(measurement) # Sort by distance projected_structs.sort(key=lambda x: x.distance) logger.info( f"Processed {len(projected_structs)} structural measurements from {struct_lyr.name()}" ) return projected_structs
def _create_buffer_zone( self, line_geom: QgsGeometry, crs: QgsCoordinateReferenceSystem, buffer_m: float, ) -> QgsGeometry: """Create a buffer geometry around the section line. Args: line_geom: The section line geometry. crs: CRS of the line layer. buffer_m: Buffer distance in meters. Returns: The buffered area as a QgsGeometry. Raises: ProcessingError: If buffer creation fails. """ try: return scu.create_buffer_geometry(line_geom, crs, buffer_m, segments=25) except (ValueError, RuntimeError) as e: logger.exception("Buffer creation failed") raise ProcessingError( "Cannot create buffer zone", {"buffer_m": buffer_m, "crs": crs.authid()} ) from e def _filter_structures( self, struct_lyr: QgsVectorLayer, buffer_geom: QgsGeometry, target_crs: QgsCoordinateReferenceSystem, ) -> Iterator[QgsFeature]: """Select structure features within the buffer using spatial indexing. Args: struct_lyr: The layer containing structural measurements. buffer_geom: The buffer area geometry. target_crs: CRS of the project/section line. Returns: An iterator over the filtered QgsFeature objects. Raises: ProcessingError: If spatial filtering fails. """ try: return scu.filter_features_by_buffer(struct_lyr, buffer_geom, target_crs) except (ValueError, RuntimeError) as e: logger.exception("Spatial filtering failed") raise ProcessingError( "Cannot filter structures by buffer", {"layer": struct_lyr.name()} ) from e def _process_single_structure( self, feature: QgsFeature, line_geom: QgsGeometry, line_start: QgsPointXY, da: QgsDistanceArea, raster_lyr: QgsRasterLayer, band_number: int, line_az: float, dip_field: str, strike_field: str, ) -> StructureMeasurement | None: """Process a single structure feature to calculate its 2D coordinates and apparent dip. Args: feature: The source structural point feature. line_geom: The section line geometry. line_start: The start point of the section line. da: The distance calculation object. raster_lyr: The DEM layer for elevation sampling. band_number: The raster band index. line_az: The azimuth of the section line. dip_field: Field name for original dip. strike_field: Field name for original strike. Returns: The projected measurement object, or None if invalid or cannot be projected. """ struct_geom = feature.geometry() if not struct_geom or struct_geom.isNull(): return None # Project point onto line to get true station distance proj_dist = line_geom.lineLocatePoint(struct_geom) if proj_dist < 0: return None # Interpolate point on line at that distance proj_pt = line_geom.interpolate(proj_dist).asPoint() # Measure geodesic distance from start # Using measureLine ensures correct units (meters) even if CRS is geographic dist = da.measureLine(line_start, proj_pt) elev = self._sample_elevation(raster_lyr, proj_pt, band_number) parsed_data = self._parse_structural_data(feature, strike_field, dip_field, line_az) if not parsed_data: return None strike, dip_angle, app_dip = parsed_data # Create object return StructureMeasurement( distance=round(dist, 1), elevation=round(elev, 1), apparent_dip=round(app_dip, 1), original_dip=dip_angle, original_strike=strike, attributes=dict(zip(feature.fields().names(), feature.attributes(), strict=False)), ) def _extract_line_info(self, line_lyr: QgsVectorLayer) -> tuple[QgsGeometry, QgsPointXY]: """Extract geometry and start point from the line layer. Args: line_lyr: The vector layer containing the section line. Returns: A tuple containing (line_geometry, start_point). Raises: DataMissingError: If layer has no features. GeometryError: If geometry is invalid. """ line_feat = next(line_lyr.getFeatures(), None) if not line_feat: raise DataMissingError("Line layer has no features", {"layer": line_lyr.name()}) line_geom = line_feat.geometry() if not line_geom or line_geom.isNull(): raise GeometryError("Line geometry is not valid", {"layer": line_lyr.name()}) if line_geom.isMultipart(): line_start = line_geom.asMultiPolyline()[0][0] else: line_start = line_geom.asPolyline()[0] return line_geom, line_start def _sample_elevation( self, raster_lyr: QgsRasterLayer, point: QgsPointXY, band_number: int ) -> float: """Sample elevation from a raster at a given point. Args: raster_lyr: The DEM raster layer. point: The point to sample. band_number: The band index. Returns: The sampled elevation value or 0.0 if sampling fails. """ res_val = raster_lyr.dataProvider().identify(point, QgsRaster.IdentifyFormatValue).results() return res_val.get(band_number, 0.0) def _parse_structural_data( self, feature: QgsFeature, strike_field: str, dip_field: str, line_az: float, ) -> tuple[float, float, float] | None: """Parse strike and dip attributes and calculate apparent dip. Args: feature: The feature containing attributes. strike_field: Field name for strike. dip_field: Field name for dip. line_az: Azimuth of the section line. Returns: A tuple of (strike, dip_angle, apparent_dip) or None if validation fails. """ try: strike_raw = feature[strike_field] dip_raw = feature[dip_field] except KeyError: return None strike = scu.parse_strike(strike_raw) dip_angle, _ = scu.parse_dip(dip_raw) if strike is None or dip_angle is None: return None # Validate ranges if not (0 <= strike <= 360) or not (0 <= dip_angle <= 90): return None app_dip = scu.calculate_apparent_dip(strike, dip_angle, line_az) return strike, dip_angle, app_dip