from __future__ import annotations
"""Drillhole Utilities Module.
Calculations for drillhole geometry and projection.
"""
import math
from typing import Any
from qgis.core import QgsDistanceArea, QgsGeometry, QgsPointXY
[docs]
def calculate_drillhole_trajectory(
collar_point: Any, # Expected tuple[float, float] or QgsPointXY
collar_z: float,
survey_data: list[tuple[float, float, float]],
section_azimuth: float,
densify_step: float = 1.0,
total_depth: float = 0.0,
) -> list[tuple[float, float, float, float, float, float]]:
"""Calculate 3D trajectory of a drillhole using survey data."""
if not survey_data:
if total_depth > 0:
# No survey but depth provided: assume vertical hole
survey_data = [(0.0, 0.0, -90.0)]
else:
return []
trajectory = []
# Start at collar
try:
# Support both QgsPointXY and tuple
x = collar_point.x() if hasattr(collar_point, "x") else collar_point[0]
y = collar_point.y() if hasattr(collar_point, "y") else collar_point[1]
except Exception:
x, y = 0.0, 0.0
z = collar_z
prev_depth = 0.0
# Add collar point
trajectory.append((0.0, x, y, z, 0.0, 0.0))
last_azimuth = 0.0
last_inclination = 0.0
last_survey_depth = 0.0
for depth, azimuth, inclination in survey_data:
# Keep track of last orientation for extrapolation
last_azimuth = azimuth
last_inclination = inclination
last_survey_depth = depth
if depth <= prev_depth:
continue
# Calculate interval
interval = depth - prev_depth
# Convert angles to radians
azim_rad = math.radians(azimuth)
# Inclination convention: -90° = vertical down, 0° = horizontal
# We need to convert to standard convention where 0° = vertical down
# Standard: 0° down, 90° horizontal
standard_incl_rad = math.radians(90 + inclination)
# Vertical component (negative because Z decreases downward)
total_dz = -interval * math.cos(standard_incl_rad)
# Horizontal components (East, North)
total_dx = interval * math.sin(standard_incl_rad) * math.sin(azim_rad)
total_dy = interval * math.sin(standard_incl_rad) * math.cos(azim_rad)
# Densify: generate intermediate points along this segment
num_steps = max(1, int(interval / densify_step))
for i in range(1, num_steps + 1):
# Calculate fraction of segment
fraction = i / num_steps
# Interpolate depth
interp_depth = prev_depth + interval * fraction
# Interpolate position (linear interpolation along segment)
interp_x = x + total_dx * fraction
interp_y = y + total_dy * fraction
interp_z = z + total_dz * fraction
# Add interpolated point
trajectory.append((interp_depth, interp_x, interp_y, interp_z, 0.0, 0.0))
# Update position to end of segment
x += total_dx
y += total_dy
z += total_dz
prev_depth = depth
# Extrapolate if total_depth is provided and greater than last survey
if total_depth > last_survey_depth:
# Calculate interval
interval = total_depth - last_survey_depth
# Use last known orientation
azim_rad = math.radians(last_azimuth)
standard_incl_rad = math.radians(90 + last_inclination)
# Vertical component
total_dz = -interval * math.cos(standard_incl_rad)
# Horizontal components
total_dx = interval * math.sin(standard_incl_rad) * math.sin(azim_rad)
total_dy = interval * math.sin(standard_incl_rad) * math.cos(azim_rad)
# Densify extrapolation
num_steps = max(1, int(interval / densify_step))
for i in range(1, num_steps + 1):
fraction = i / num_steps
interp_depth = last_survey_depth + interval * fraction
interp_x = x + total_dx * fraction
interp_y = y + total_dy * fraction
interp_z = z + total_dz * fraction
trajectory.append((interp_depth, interp_x, interp_y, interp_z, 0.0, 0.0))
return trajectory
[docs]
def project_trajectory_to_section(
trajectory: list[tuple],
line_geom: QgsGeometry,
line_start: Any, # Point2D or QgsPointXY
distance_area: QgsDistanceArea,
) -> list[tuple[float, float, float, float, float, float, float, float]]:
"""Project drillhole trajectory points onto section line."""
projected = []
# Ensure line_start is QgsPointXY
start_pt = line_start if hasattr(line_start, "x") else QgsPointXY(line_start[0], line_start[1])
for depth, x, y, z, _, _ in trajectory:
point = QgsPointXY(x, y)
point_geom = QgsGeometry.fromPointXY(point)
# Find nearest point on line
nearest_point = line_geom.nearestPoint(point_geom)
nearest_pt_xy = nearest_point.asPoint()
# Calculate distance along section
dist_along = distance_area.measureLine(start_pt, nearest_pt_xy)
# Calculate offset from section
offset = distance_area.measureLine(point, nearest_pt_xy)
projected.append((depth, x, y, z, dist_along, offset, nearest_pt_xy.x(), nearest_pt_xy.y()))
return projected
[docs]
def interpolate_intervals_on_trajectory(
trajectory: list[tuple],
intervals: list[tuple[float, float, Any]],
buffer_width: float,
) -> list[tuple[Any, list[tuple[float, float]], list[tuple[float, float, float]]]]:
"""Interpolate interval attributes along drillhole trajectory.
Filters and maps geological intervals onto the 3D trajectory points
that fall within the specified section buffer.
Args:
trajectory: List of (depth, x, y, z, dist_along, offset) tuples.
intervals: List of (from_depth, to_depth, attribute) tuples.
buffer_width: Maximum perpendicular offset to include a point.
Returns:
List of tuples containing:
- attribute: The metadata/geology associated with the interval.
- points_2d: List of (distance, elevation) coordinates for rendering.
- points_3d: List of (x, y, z) original coordinates for 3D export.
"""
geol_segments = []
for from_depth, to_depth, attribute in intervals:
# Find trajectory points within this interval
interval_points_2d = []
interval_points_3d = []
interval_points_3d_proj = []
for depth, x, y, z, dist_along, offset, nx, ny in trajectory:
# Check if point is within interval and buffer
if from_depth <= depth <= to_depth and offset <= buffer_width:
interval_points_2d.append((dist_along, z))
interval_points_3d.append((x, y, z))
interval_points_3d_proj.append((nx, ny, z))
# Add segment if we have points
if interval_points_2d:
geol_segments.append(
(
attribute,
interval_points_2d,
interval_points_3d,
interval_points_3d_proj,
)
)
return geol_segments