from __future__ import annotations
"""Drillhole Data Processing Service.
This module provides services for processing and projecting drillhole data,
including collar projection, trajectory calculation, and interval interpolation.
"""
import contextlib
from typing import Any
from qgis.core import (
QgsCoordinateReferenceSystem,
QgsDistanceArea,
QgsFeature,
QgsFeatureRequest,
QgsGeometry,
QgsPointXY,
QgsRaster,
QgsRasterLayer,
QgsVectorLayer,
)
from sec_interp.core import utils as scu
from sec_interp.core.interfaces.drillhole_interface import IDrillholeService
from sec_interp.core.types import DrillholeTaskInput, GeologySegment
from sec_interp.logger_config import get_logger
logger = get_logger(__name__)
[docs]
class DrillholeService(IDrillholeService):
"""Service for processing drillhole data."""
[docs]
def project_collars(
self,
collar_data: list[dict[str, Any]],
line_data: Any,
distance_area: Any,
buffer_width: float,
collar_id_field: str,
use_geometry: bool,
collar_x_field: str,
collar_y_field: str,
collar_z_field: str,
collar_depth_field: str,
pre_sampled_z: dict[Any, float] | None = None,
) -> list[tuple[Any, float, float, float, float]]:
"""Project collar points onto section line using detached domain data."""
projected_collars = []
# Get line start point
line_start = QgsPointXY(line_data.vertexAt(0).x(), line_data.vertexAt(0).y())
for c_item in collar_data:
result = self._project_single_detached_collar(
c_item,
line_data,
line_start,
distance_area,
buffer_width,
collar_id_field,
use_geometry,
collar_x_field,
collar_y_field,
collar_z_field,
collar_depth_field,
pre_sampled_z,
)
if result:
projected_collars.append(result)
return projected_collars
def _validate_project_collars_params(
self,
fields: Any,
buffer_width: float,
id_field: str,
use_geom: bool,
x_field: str,
y_field: str,
) -> None:
"""Validate parameters for project_collars."""
from sec_interp.core.exceptions import ValidationError
if buffer_width <= 0:
raise ValidationError(f"Buffer width must be positive, got {buffer_width}")
if id_field and fields.indexFromName(id_field) == -1:
raise ValidationError(f"Field '{id_field}' not found in collar data fields.")
if not use_geom:
for f_name in [x_field, y_field]:
if f_name and fields.indexFromName(f_name) == -1:
raise ValidationError(f"Field '{f_name}' not found in collar data fields.")
def _validate_prepare_task_params(
self,
buffer_width: float,
section_azimuth: float,
collar_layer: QgsVectorLayer,
id_field: str,
use_geom: bool,
x_field: str,
y_field: str,
) -> None:
"""Validate parameters for prepare_task_input."""
from sec_interp.core.exceptions import ValidationError
from sec_interp.core.validation.validators import (
validate_positive,
validate_range,
)
validate_positive("Buffer width")(buffer_width)
validate_range(0, 360, "Section azimuth")(section_azimuth)
if id_field and collar_layer.fields().indexFromName(id_field) == -1:
raise ValidationError(f"Field '{id_field}' not found in collar layer.")
if not use_geom:
if x_field and collar_layer.fields().indexFromName(x_field) == -1:
raise ValidationError(f"Field '{x_field}' not found in collar layer.")
if y_field and collar_layer.fields().indexFromName(y_field) == -1:
raise ValidationError(f"Field '{y_field}' not found in collar layer.")
def _detach_collar_features(
self,
layer: QgsVectorLayer,
line_geom: QgsGeometry,
buffer_width: float,
id_field: str,
use_geom: bool,
x_field: str,
y_field: str,
z_field: str,
dem_layer: QgsRasterLayer | None,
) -> tuple[set[Any], list[dict[str, Any]], dict[Any, float]]:
"""Detach collar features and pre-sample Z using WKT for decoupling."""
try:
line_buffer = line_geom.buffer(buffer_width, 8)
except Exception:
line_buffer = None
collar_bbox = line_buffer.boundingBox() if line_buffer else line_geom.boundingBox()
req = QgsFeatureRequest().setFilterRect(collar_bbox)
collar_ids = set()
collar_data = []
pre_sampled_z = {}
for feat in layer.getFeatures(req):
if line_buffer and not feat.geometry().intersects(line_buffer):
continue
hid = feat[id_field]
collar_ids.add(hid)
# Detach geometry as WKT and attributes
wkt = feat.geometry().asWkt() if feat.hasGeometry() else None
attrs = dict(zip(feat.fields().names(), feat.attributes(), strict=False))
collar_data.append({"wkt": wkt, "attributes": attrs, "id": hid})
# Pre-sample Z
if dem_layer:
z = self._pre_sample_z_for_task(
feat,
hydro_id=hid,
dem_layer=dem_layer,
z_field=z_field,
use_geom=use_geom,
x_field=x_field,
y_field=y_field,
)
if z is not None:
pre_sampled_z[hid] = z
return collar_ids, collar_data, pre_sampled_z
def _pre_sample_z_for_task(
self,
feat: QgsFeature,
hydro_id: Any,
dem_layer: QgsRasterLayer,
z_field: str,
use_geom: bool,
x_field: str,
y_field: str,
) -> float | None:
"""Sample Z from DEM if missing in features."""
z_val = 0.0
try:
if z_field:
z_val = float(feat[z_field] or 0.0)
except (ValueError, TypeError):
z_val = 0.0
if z_val == 0.0:
pt = self._extract_point(feat, use_geom, x_field, y_field)
if pt:
return self._sample_dem(dem_layer, pt)
return None
[docs]
def process_task_data(self, task_input: DrillholeTaskInput, feedback: Any | None = None) -> Any:
"""Process drillholes using detached domain data (Thread-Safe)."""
# Reconstruct Objects
line_crs = QgsCoordinateReferenceSystem(task_input.line_crs_authid)
da = scu.create_distance_area(line_crs)
line_geom = QgsGeometry.fromWkt(task_input.line_geometry_wkt)
line_start = QgsPointXY(task_input.line_start_x, task_input.line_start_y)
geol_data_all = []
drillhole_data_all = [] # (hid, trace2d, trace3d, proj3d, geologic_segments)
total = len(task_input.collar_data)
for i, c_item in enumerate(task_input.collar_data):
if feedback and feedback.isCanceled():
return None
result = self._project_single_detached_collar(
c_item,
line_geom,
line_start,
da,
task_input.buffer_width,
task_input.collar_id_field,
task_input.use_geometry,
task_input.collar_x_field,
task_input.collar_y_field,
task_input.collar_z_field,
task_input.collar_depth_field,
task_input.pre_sampled_z,
)
if result:
# result is (hid, dist, z, offset, total_depth)
hid, _, z, _, depth = result
# Full Processing
surveys = task_input.survey_data.get(hid, [])
intervals = task_input.interval_data.get(hid, [])
# We can reuse _process_single_hole as it already takes primitives
# but we need to pass pt (collar_point) which we extracted in result
# but wait, result doesn't have pt.
# Actually _project_single_detached_collar should probably return pt too or extract it here.
# Let's refactor slightly to avoid re-extracting pt.
# Actually, I'll just re-extract it for now for simplicity.
pt = (
QgsGeometry.fromWkt(c_item["wkt"]).asPoint()
if task_input.use_geometry and c_item.get("wkt")
else QgsPointXY(
float(c_item["attributes"].get(task_input.collar_x_field, 0.0)),
float(c_item["attributes"].get(task_input.collar_y_field, 0.0)),
)
)
hole_geol, hole_tuple = self._process_single_hole(
hole_id=hid,
collar_point=pt,
collar_z=z,
given_depth=depth,
survey_data=surveys,
intervals=intervals,
line_geom=line_geom,
line_start=line_start,
distance_area=da,
buffer_width=task_input.buffer_width,
section_azimuth=task_input.section_azimuth,
)
geol_data_all.extend(hole_geol)
drillhole_data_all.append(hole_tuple)
if feedback:
feedback.setProgress((i / total) * 100)
return geol_data_all, drillhole_data_all
def _process_detached_collar_item(
self,
c_item: dict[str, Any],
task_input: DrillholeTaskInput,
da: QgsDistanceArea,
) -> tuple[list[GeologySegment], tuple] | None:
"""Process a single detached collar item."""
hid = c_item["id"]
attrs = c_item["attributes"]
geom = c_item["geometry"]
# 1. Extract Point
pt = None
if task_input.use_geometry and geom:
pt = geom.asPoint()
else:
with contextlib.suppress(ValueError, TypeError):
x = float(attrs.get(task_input.collar_x_field, 0.0))
y = float(attrs.get(task_input.collar_y_field, 0.0))
pt = QgsPointXY(x, y)
if not pt:
return None
# 2. Extract Z/Depth
z = 0.0
with contextlib.suppress(ValueError, TypeError):
z = float(attrs.get(task_input.collar_z_field, 0.0))
if z == 0.0 and hid in task_input.pre_sampled_z:
z = task_input.pre_sampled_z[hid]
depth = 0.0
with contextlib.suppress(ValueError, TypeError):
depth = float(attrs.get(task_input.collar_depth_field, 0.0))
# 3. Project
collar_geom_pt = QgsGeometry.fromPointXY(pt)
nearest_point = task_input.line_geometry.nearestPoint(collar_geom_pt).asPoint()
offset = da.measureLine(pt, nearest_point)
if offset > task_input.buffer_width:
return None
# 4. Full Processing
surveys = task_input.survey_data.get(hid, [])
intervals = task_input.interval_data.get(hid, [])
return self._process_single_hole(
hole_id=hid,
collar_point=pt,
collar_z=z,
given_depth=depth,
survey_data=surveys,
intervals=intervals,
line_geom=task_input.line_geometry,
line_start=task_input.line_start,
distance_area=da,
buffer_width=task_input.buffer_width,
section_azimuth=task_input.section_azimuth,
)
[docs]
def process_intervals(
self,
collar_points: list[tuple],
collar_data: list[dict[str, Any]],
survey_data: dict[Any, list[tuple]],
interval_data: dict[Any, list[tuple]],
collar_id_field: str,
use_geometry: bool,
collar_x_field: str,
collar_y_field: str,
line_data: Any,
distance_area: Any,
buffer_width: float,
section_azimuth: float,
survey_fields: dict[str, str],
interval_fields: dict[str, str],
) -> tuple[list, list]:
"""Process drillhole interval data using detached structures."""
geol_data, drillhole_data = [], []
# 1. Build collar coordinate map from detached data
collar_coords = {}
for item in collar_data:
hid = item["id"]
wkt = item.get("wkt")
attrs = item["attributes"]
pt = None
if use_geometry and wkt:
pt = QgsGeometry.fromWkt(wkt).asPoint()
else:
with contextlib.suppress(ValueError, TypeError, KeyError):
x, y = float(attrs.get(collar_x_field, 0)), float(attrs.get(collar_y_field, 0))
pt = QgsPointXY(x, y)
if pt:
collar_coords[hid] = pt
for hole_id, _dist, collar_z, _off, given_depth in collar_points:
collar_point = collar_coords.get(hole_id)
if not collar_point:
continue
self._safe_process_single_hole(
hole_id,
collar_point,
collar_z,
given_depth,
survey_data,
interval_data,
line_data,
collar_point, # Note: safe_process_single_hole signature might need line_start
distance_area,
buffer_width,
section_azimuth,
geol_data,
drillhole_data,
)
return geol_data, drillhole_data
def _fetch_bulk_data(
self, layer: QgsVectorLayer, hole_ids: set[Any], fields: dict[str, str]
) -> dict[Any, list[tuple[Any, ...]]]:
"""Fetch data for multiple holes in a single pass."""
if not self._validate_bulk_fetch_fields(layer, fields):
return {}
result_map: dict[Any, list[tuple]] = {}
if not hole_ids:
return {}
id_f = fields["id"]
is_survey = "depth" in fields
ids_str = ", ".join([f"'{hid!s}'" for hid in hole_ids])
request = QgsFeatureRequest().setFilterExpression(f'"{id_f}" IN ({ids_str})')
for feat in layer.getFeatures(request):
hole_id = feat[id_f]
data = self._extract_data_tuple(feat, fields, is_survey)
if data:
result_map.setdefault(hole_id, []).append(data)
# Sort surveys by depth
if is_survey:
for h_id in result_map:
result_map[h_id].sort(key=lambda x: x[0])
return result_map
def _validate_bulk_fetch_fields(self, layer: QgsVectorLayer, fields: dict[str, str]) -> bool:
"""Validate fields for bulk fetching."""
if not layer or not layer.isValid():
return False
id_f = fields.get("id")
if not id_f or layer.fields().indexFromName(id_f) == -1:
return False
is_survey = "depth" in fields
required = ["depth", "azim", "incl"] if is_survey else ["from", "to", "lith"]
for field_key in required:
f_name = fields.get(field_key)
if not f_name or layer.fields().indexFromName(f_name) == -1:
return False
return True
def _fetch_bulk_data_detached(
self, layer: QgsVectorLayer, hole_ids: set[Any], fields: dict[str, str]
) -> dict[Any, list[tuple]]:
"""Fetch data into pure python structures (no QgsFeature dependency)."""
# This is essentially the same as _fetch_bulk_data but guarantees primitive types
# in the returned list, which _extract_data_tuple already does.
# So we can reuse the logic but ensures it runs on Main Thread in prepare()
return self._fetch_bulk_data(layer, hole_ids, fields)
def _process_single_hole(
self,
hole_id: Any,
collar_point: QgsPointXY,
collar_z: float,
given_depth: float,
survey_data: list[tuple[float, float, float]],
intervals: list[tuple[float, float, str]],
line_geom: QgsGeometry,
line_start: QgsPointXY,
distance_area: QgsDistanceArea,
buffer_width: float,
section_azimuth: float,
) -> tuple[list[GeologySegment], tuple]:
"""Process a single drillhole's trajectory and intervals."""
# 1. Determine Final Depth
final_depth = self._determine_final_depth(given_depth, survey_data, intervals)
# 2. Trajectory and Projection
trajectory = scu.calculate_drillhole_trajectory(
collar_point,
collar_z,
survey_data,
section_azimuth,
total_depth=final_depth,
)
projected_traj = scu.project_trajectory_to_section(
trajectory, line_geom, line_start, distance_area
)
# 3. Interpolate Intervals
hole_geol_data = self._interpolate_hole_intervals(projected_traj, intervals, buffer_width)
# 4. Generate trace results
hole_tuple = self._create_drillhole_result_tuple(hole_id, projected_traj, hole_geol_data)
return hole_geol_data, hole_tuple
def _determine_final_depth(
self, given_depth: float, survey_data: list[tuple], intervals: list[tuple]
) -> float:
"""Determine final depth from given depth, surveys and intervals."""
max_s_depth = max([s[0] for s in survey_data]) if survey_data else 0.0
max_i_depth = max([i[1] for i in intervals]) if intervals else 0.0
return max(given_depth, max_s_depth, max_i_depth)
def _create_drillhole_result_tuple(
self,
hole_id: Any,
projected_traj: list[tuple],
hole_geol_data: list[GeologySegment],
) -> tuple:
"""Create the final result tuple for a drillhole."""
traj_points_2d = [(p[4], p[3]) for p in projected_traj]
traj_points_3d = [(p[1], p[2], p[3]) for p in projected_traj]
traj_points_3d_proj = [(p[6], p[7], p[3]) for p in projected_traj]
return (
hole_id,
traj_points_2d,
traj_points_3d,
traj_points_3d_proj,
hole_geol_data,
)
def _build_collar_coord_map(
self,
layer: QgsVectorLayer,
id_field: str,
use_geom: bool,
x_field: str,
y_field: str,
) -> dict[Any, QgsPointXY]:
"""Build a lookup map for collar coordinates.
Args:
layer: The collar vector layer.
id_field: Field name for hole ID.
use_geom: Whether to use feature geometry.
x_field: Field name for X.
y_field: Field name for Y.
Returns:
A dictionary mapping hole_id to QgsPointXY.
"""
if not layer or not id_field:
return {}
coords = {}
# Fetch only necessary attributes and geometry
if use_geom:
# Need geometry and id_field only
request = QgsFeatureRequest().setSubsetOfAttributes([id_field], layer.fields())
else:
# Need id_field, x_field, y_field but no geometry
request = QgsFeatureRequest().setSubsetOfAttributes(
[id_field, x_field, y_field], layer.fields()
)
request.setFlags(QgsFeatureRequest.NoGeometry)
for feat in layer.getFeatures(request):
hole_id = feat[id_field]
pt = self._extract_point(feat, use_geom, x_field, y_field)
if pt:
coords[hole_id] = pt
return coords
def _extract_point(
self, feat: QgsFeature, use_geom: bool, x_f: str, y_f: str
) -> QgsPointXY | None:
"""Extract point from feature geometry or fields."""
if use_geom:
geom = feat.geometry()
if geom:
pt = geom.asPoint()
if pt.x() != 0 or pt.y() != 0:
return pt
else:
try:
x, y = float(feat[x_f]), float(feat[y_f])
if x != 0 or y != 0:
return QgsPointXY(x, y)
except (ValueError, TypeError, KeyError):
pass
return None
def _get_survey_data(
self, layer: QgsVectorLayer, hole_id: Any, fields: dict[str, str]
) -> list[tuple[Any, ...]]:
"""Legacy support - redirected to bulk fetch if needed."""
res = self._fetch_bulk_data(layer, {hole_id}, fields)
return res.get(hole_id, [])
def _get_interval_data(
self, layer: QgsVectorLayer, hole_id: Any, fields: dict[str, str]
) -> list[tuple[Any, ...]]:
"""Legacy support - redirected to bulk fetch if needed."""
res = self._fetch_bulk_data(layer, {hole_id}, fields)
return res.get(hole_id, [])
def _interpolate_hole_intervals(
self,
traj: list[tuple[float, float, float, float, float]],
intervals: list[tuple[float, float, str]],
buffer_width: float,
) -> list[GeologySegment]:
"""Interpolate intervals along a trajectory and return GeologySegments.
Args:
traj: The projected trajectory tuples.
intervals: List of (from, to, lith) tuples.
buffer_width: Section buffer width.
Returns:
A list of GeologySegment objects.
"""
if not intervals:
return []
rich_intervals = [
(fd, td, {"unit": lith, "from": fd, "to": td}) for fd, td, lith in intervals
]
# Scu returns (attr, points_2d, points_3d, points_3d_proj)
tuples = scu.interpolate_intervals_on_trajectory(traj, rich_intervals, buffer_width)
segments = []
for attr, points_2d, points_3d, points_3d_proj in tuples:
segments.append(
GeologySegment(
unit_name=str(attr.get("unit", "Unknown")),
geometry_wkt=None,
attributes=attr,
points=points_2d,
points_3d=points_3d,
points_3d_projected=points_3d_proj,
)
)
return segments
def _project_single_detached_collar(
self,
collar_data: dict[str, Any] | QgsFeature,
line_geom: QgsGeometry,
line_start: QgsPointXY,
distance_area: QgsDistanceArea,
buffer_width: float,
collar_id_field: str,
use_geometry: bool,
collar_x_field: str,
collar_y_field: str,
collar_z_field: str,
collar_depth_field: str,
pre_sampled_z: dict[Any, float] | None = None,
dem_layer: QgsRasterLayer | None = None,
) -> tuple[Any, float, float, float, float] | None:
"""Process and project a single collar from either dict or feature.
Args:
collar_data: Dictionary (detached) or QgsFeature.
line_geom: Section line geometry.
line_start: Start point of section line.
distance_area: Distance calculation object.
buffer_width: Buffer distance for exclusion.
collar_id_field: Field for ID.
use_geometry: Use geometry for coordinates.
collar_x_field: Field for X.
collar_y_field: Field for Y.
collar_z_field: Field for Z.
collar_depth_field: Field for depth.
pre_sampled_z: Pre-sampled elevations map.
dem_layer: Optional DEM layer for sampling.
Returns:
Tuple of (hole_id, dist_along, z, offset, total_depth) or None.
"""
# 1. Extract Data agnosticly
is_dict = isinstance(collar_data, dict)
attrs = collar_data.get("attributes", {}) if is_dict else collar_data
# Get hole ID
try:
hole_id = attrs.get(collar_id_field) if is_dict else attrs[collar_id_field]
except (KeyError, AttributeError):
return None
if not hole_id:
return None
# Extract point
collar_pt = None
if use_geometry:
if is_dict:
wkt = collar_data.get("wkt", "")
if wkt:
geom = QgsGeometry.fromWkt(wkt)
if not geom.isNull() and not geom.isEmpty():
pt = geom.asPoint()
collar_pt = QgsPointXY(pt.x(), pt.y())
else:
geom = collar_data.geometry()
if not geom.isNull() and not geom.isEmpty():
pt = geom.asPoint()
collar_pt = QgsPointXY(pt.x(), pt.y())
if not collar_pt:
# Fallback to fields
try:
x = (
float(attrs.get(collar_x_field, 0.0))
if is_dict
else float(attrs[collar_x_field] or 0.0)
)
y = (
float(attrs.get(collar_y_field, 0.0))
if is_dict
else float(attrs[collar_y_field] or 0.0)
)
collar_pt = QgsPointXY(x, y)
except (ValueError, TypeError, KeyError):
return None
# Extract Z
z = 0.0
try:
if collar_z_field:
z = (
float(attrs.get(collar_z_field, 0.0))
if is_dict
else float(attrs[collar_z_field] or 0.0)
)
except (ValueError, TypeError, KeyError):
z = 0.0
if z == 0.0:
if pre_sampled_z and hole_id in pre_sampled_z:
z = pre_sampled_z[hole_id]
elif dem_layer:
z = self._sample_dem(dem_layer, collar_pt)
# Extract depth
total_depth = 0.0
try:
if collar_depth_field:
total_depth = (
float(attrs.get(collar_depth_field, 0.0))
if is_dict
else float(attrs[collar_depth_field] or 0.0)
)
except (ValueError, TypeError, KeyError):
total_depth = 0.0
# 2. Project to section line
collar_geom_pt = QgsGeometry.fromPointXY(collar_pt)
nearest_point_geom = line_geom.nearestPoint(collar_geom_pt)
nearest_point = nearest_point_geom.asPoint()
nearest_point_xy = QgsPointXY(nearest_point.x(), nearest_point.y())
# 3. Calculate distances
dist_along = distance_area.measureLine(line_start, nearest_point_xy)
offset = distance_area.measureLine(collar_pt, nearest_point_xy)
# 4. Check if within buffer
if offset <= buffer_width:
return (hole_id, dist_along, z, offset, total_depth)
return None
def _get_collar_info(
self,
feat: QgsFeature,
id_field: str,
use_geom: bool,
x_field: str,
y_field: str,
z_field: str,
depth_field: str,
dem_layer: QgsRasterLayer | None = None,
) -> tuple[Any, QgsPointXY, float, float] | None:
"""Extract collar ID, coordinate, Z and depth from a feature."""
if not id_field:
return None
hole_id = feat[id_field]
# Point extraction
pt = self._extract_point(feat, use_geom, x_field, y_field)
if not pt:
return None
z = self._extract_collar_z(feat, z_field, pt, dem_layer)
depth = self._extract_collar_depth(feat, depth_field)
return hole_id, pt, z, depth
def _extract_collar_z(
self,
feat: QgsFeature,
z_field: str,
pt: QgsPointXY,
dem_layer: QgsRasterLayer | None,
) -> float:
"""Extract collar Z with DEM fallback."""
z = 0.0
if z_field:
with contextlib.suppress(ValueError, TypeError):
z = float(feat[z_field])
if z == 0.0 and dem_layer:
z = self._sample_dem(dem_layer, pt)
return z
def _extract_collar_depth(self, feat: QgsFeature, depth_field: str) -> float:
"""Extract collar depth."""
depth = 0.0
if depth_field:
with contextlib.suppress(ValueError, TypeError):
depth = float(feat[depth_field])
return depth
def _sample_dem(self, dem_layer: QgsRasterLayer, pt: QgsPointXY) -> float:
"""Sample elevation at point from DEM."""
ident = dem_layer.dataProvider().identify(pt, QgsRaster.IdentifyFormatValue)
if ident.isValid():
val = ident.results().get(1)
if val is not None:
return float(val)
return 0.0
def _safe_process_single_hole(
self,
hole_id: Any,
collar_point: QgsPointXY,
collar_z: float,
given_depth: float,
surveys_map: dict[Any, list[tuple[float, float, float]]],
intervals_map: dict[Any, list[tuple[float, float, str]]],
line_geom: QgsGeometry,
line_start: QgsPointXY,
distance_area: QgsDistanceArea,
buffer_width: float,
section_azimuth: float,
geol_data: list[GeologySegment],
drillhole_data: list[tuple[Any, list[tuple[float, float]], list[GeologySegment]]],
) -> None:
"""Safely process a single hole with error logging."""
try:
hole_geol, hole_drill = self._process_single_hole(
hole_id=hole_id,
collar_point=collar_point,
collar_z=collar_z,
given_depth=given_depth,
survey_data=surveys_map.get(hole_id, []),
intervals=intervals_map.get(hole_id, []),
line_geom=line_geom,
line_start=line_start,
distance_area=distance_area,
buffer_width=buffer_width,
section_azimuth=section_azimuth,
)
if hole_geol:
geol_data.extend(hole_geol)
drillhole_data.append(hole_drill)
except Exception as e:
logger.exception(f"Failed to process hole {hole_id}: {type(e).__name__}: {e}")
import traceback
logger.exception(traceback.format_exc())
raise
def _extract_data_tuple(
self, feat: QgsFeature, fields: dict[str, str], is_survey: bool
) -> tuple[float, float, Any] | None:
"""Extract a data tuple from a feature based on its role."""
try:
if is_survey:
return (
float(feat[fields["depth"]]),
float(feat[fields["azim"]]),
float(feat[fields["incl"]]),
)
else:
return (
float(feat[fields["from"]]),
float(feat[fields["to"]]),
str(feat[fields["lith"]]),
)
except (ValueError, TypeError, KeyError):
return None