# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
"""Place PCBH boreholes in the 3-D map's local scene coordinates.
The fence / block / depth-slice / iso-surface builders in
:mod:`pycsamt.map.volume` do not work in real easting/northing. They
work in a synthetic *profile space*: ``x`` is along-strike distance,
``y`` is cross-strike line offset, and ``z`` is elevation with depth
running downward from a ``z = 0`` (or draped-topography) datum.
A PCBH document, by contrast, carries absolute collar coordinates in its
own CRS. Dropping those numbers straight into the scene puts the hole
kilometres away from the section. This module bridges the two: it
projects each collar into the survey's :class:`~pycsamt.map.geometry.
SurveyFrame`, walks the desurveyed trajectory, and returns per-hole
polylines and coloured interval segments already in scene coordinates,
plus a note on how the hole sits relative to the imaged volume.
Pure numpy + the PCBH render model — no Plotly / Dash / Qt.
"""
from __future__ import annotations
import math
from collections.abc import Callable, Sequence
from dataclasses import dataclass, field
from typing import Any
import numpy as np
from ..format.borehole import (
DisplayRadiusPolicy,
PCBHDocument,
build_render_model,
)
from .geometry import SurveyFrame
__all__ = [
"AlignedSegment",
"AlignedHole",
"SceneAlignment",
"surface_from_sections",
"align_boreholes_to_scene",
]
_ScenePoint = tuple[float, float, float]
[docs]
@dataclass(frozen=True, repr=False)
class AlignedSegment:
"""One logged interval as a scene-space polyline."""
borehole_id: str
family: str
from_md: float
to_md: float
color: str
points: tuple[_ScenePoint, ...]
metadata: dict[str, Any] = field(default_factory=dict)
[docs]
@dataclass(frozen=True, repr=False)
class AlignedHole:
"""One borehole placed in the 3-D map scene."""
borehole_id: str
name: str
collar_scene: _ScenePoint
centerline: tuple[_ScenePoint, ...]
segments: tuple[AlignedSegment, ...]
display_radius: float
relation: str
warnings: tuple[str, ...] = ()
placed: bool = True
[docs]
@dataclass(frozen=True, repr=False)
class SceneAlignment:
"""Result of aligning a whole document to one scene."""
holes: tuple[AlignedHole, ...]
family: str
warnings: tuple[str, ...] = ()
[docs]
@property
def placed(self) -> tuple[AlignedHole, ...]:
return tuple(hole for hole in self.holes if hole.placed)
[docs]
def surface_from_sections(
sections: Sequence[tuple[Sequence[float], Sequence[float]]],
) -> Callable[[float], float] | None:
"""Build a ``u -> elevation`` interpolator from section terrain lines.
``sections`` is a list of ``(u_values, elevation_values)`` pairs, one
per rendered line, in the same local metres as
:class:`~pycsamt.map.geometry.SurveyFrame`. Returns ``None`` when no
finite terrain is available (the caller then falls back to a flat
``z = 0`` datum).
"""
us: list[float] = []
zs: list[float] = []
for u_values, z_values in sections:
for u, z in zip(u_values, z_values):
if np.isfinite(u) and np.isfinite(z):
us.append(float(u))
zs.append(float(z))
if len(us) < 2:
return None
order = np.argsort(us)
grid_u = np.asarray(us, dtype=float)[order]
grid_z = np.asarray(zs, dtype=float)[order]
grid_u, unique = np.unique(grid_u, return_index=True)
grid_z = grid_z[unique]
def surface(u: float) -> float:
return float(np.interp(u, grid_u, grid_z, grid_z[0], grid_z[-1]))
return surface
[docs]
def align_boreholes_to_scene(
document: PCBHDocument,
frame: SurveyFrame,
*,
family: str = "lithology",
azimuth_deg: float = 0.0,
surface: Callable[[float], float] | None = None,
datum: str = "surface",
scene_bounds: (
tuple[float, float, float, float, float, float] | None
) = None,
depth_range: tuple[float, float] | None = None,
radius_policy: DisplayRadiusPolicy | None = None,
offset_shift: float = 0.0,
offset_scale: float = 1.0,
lean_deg: float = 0.0,
lean_azimuth_deg: float = 0.0,
lean_only_vertical: bool = True,
sampling_step_md: float = 10.0,
selected_ids: set[str] | None = None,
) -> SceneAlignment:
"""Align every borehole in *document* to a 3-D map scene.
Parameters
----------
document : PCBHDocument
frame : SurveyFrame
The survey's local frame (see :func:`pycsamt.map.geometry.
survey_frame`), fitted from the same stations the scene uses.
family : str, default='lithology'
Interval-log family to colour the trajectory by.
azimuth_deg : float, default=0
Scene azimuth, matching ``VolumeMapOptions.azimuth`` — the hole's
cross-strike offset is rotated by it exactly as line panels are.
surface : callable, optional
``u -> elevation`` terrain function (see
:func:`surface_from_sections`). Required for ``datum='surface'``.
datum : {'surface', 'collar_z', 'zero'}, default='surface'
Depth reference. ``surface`` drapes the collar onto the terrain
line, ``collar_z`` trusts the PCBH collar elevation, ``zero`` puts
the collar at ``z = 0`` (matching a no-topography scene).
scene_bounds : tuple, optional
``(umin, umax, vmin, vmax, zmin, zmax)`` of the imaged volume, for
the per-hole ``relation`` classification.
depth_range : tuple, optional
``(lo, hi)`` metres-below-datum clip applied to the trajectory,
matching the scene's depth filter.
radius_policy : DisplayRadiusPolicy, optional
View-only tube radius policy (physical diameter is never changed).
offset_shift : float, default=0
Cross-strike origin shift, in local metres, applied to every
borehole ``v`` before ``offset_scale``. The
:mod:`pycsamt.map.volume` builders normalise the line panels so
the front-most line sits at ``v = 0`` (see
:func:`pycsamt.map.geometry.normalize_offsets`); pass that same
shift here or the holes land in front of / behind their line.
offset_scale : float, default=1
Cross-strike exaggeration matching ``VolumeMapOptions.line_spacing``
— the scene stretches the line-to-line axis by this factor, so the
hole's offset must stretch with it.
lean_deg : float, default=0
Apparent lean from vertical, in degrees, applied to holes that
have no surveyed deviation (see ``lean_only_vertical``). ``0``
keeps every hole plumb.
lean_azimuth_deg : float, default=0
Compass bearing (0 = grid north, 90 = east) the leaned hole tips
toward.
lean_only_vertical : bool, default=True
When true, only holes whose desurveyed trajectory stays within
~1 m of the collar are leaned; real deviation surveys pass
through untouched. Set false to force every hole to ``lean_deg``.
sampling_step_md : float, default=10
Trajectory sampling step in measured depth.
selected_ids : set of str, optional
Highlighted borehole ids (passed through to the render model).
Returns
-------
SceneAlignment
"""
if not isinstance(document, PCBHDocument):
raise TypeError("document must be a PCBHDocument")
if not isinstance(frame, SurveyFrame):
raise TypeError("frame must be a SurveyFrame")
if datum not in {"surface", "collar_z", "zero"}:
raise ValueError("datum must be 'surface', 'collar_z', or 'zero'")
model = build_render_model(
document,
family=family,
selected_ids=selected_ids,
radius_policy=radius_policy or DisplayRadiusPolicy(),
sampling_step_md=sampling_step_md,
)
az = math.radians(float(azimuth_deg))
sin_az, cos_az = math.sin(az), math.cos(az)
lean = math.radians(max(0.0, float(lean_deg)))
bearing = math.radians(float(lean_azimuth_deg))
# scene (u, v) components of a unit horizontal step along the bearing
dir_e, dir_n = math.sin(bearing), math.cos(bearing)
lean_du = math.sin(lean) * (
dir_e * frame.strike[0] + dir_n * frame.strike[1]
)
lean_dv = math.sin(lean) * (
dir_e * frame.perp[0] + dir_n * frame.perp[1]
)
lean_dz = math.cos(lean)
lonlat = _collar_lonlat(document)
collars = {hole.id: hole.collar for hole in document.boreholes}
holes: list[AlignedHole] = []
doc_warnings: list[str] = []
for render_hole in model.boreholes:
hid = render_hole.borehole_id
collar = collars[hid]
latlon = lonlat.get(hid)
warnings: list[str] = []
if latlon is None:
holes.append(
AlignedHole(
borehole_id=hid,
name=render_hole.collar.label,
collar_scene=(math.nan, math.nan, math.nan),
centerline=(),
segments=(),
display_radius=render_hole.display_radius,
relation="unplaced",
warnings=(
"no WGS84 collar and CRS could not be reprojected; "
"set collar longitude/latitude or a projected CRS",
),
placed=False,
)
)
continue
lat, lon = latlon
cu, cv = frame.project(lat, lon)
if datum == "surface" and surface is not None:
z_top = surface(cu)
elif datum == "collar_z":
z_top = float(collar.z)
else:
z_top = 0.0
if datum == "surface":
warnings.append("no terrain available; collar placed at z=0")
lean_active = False
if lean > 1e-9:
if lean_only_vertical:
max_h = max(
(
math.hypot(
float(p.x) - float(collar.x),
float(p.y) - float(collar.y),
)
for p in render_hole.centerline.points
),
default=0.0,
)
lean_active = max_h <= 1.0
else:
lean_active = True
if lean_active:
warnings.append(
f"shown leaning {float(lean_deg):.0f}° from vertical "
f"toward {float(lean_azimuth_deg):.0f}° (display only)"
)
place = _Placement(
frame=frame,
collar_east=float(collar.x),
collar_north=float(collar.y),
cu=cu,
cv=cv,
z_top=z_top,
sin_az=sin_az,
cos_az=cos_az,
offset_shift=float(offset_shift),
offset_scale=float(offset_scale),
lean_active=lean_active,
lean_du=lean_du,
lean_dv=lean_dv,
lean_dz=lean_dz,
)
centerline = tuple(
place.to_scene(p)
for p in render_hole.centerline.points
if _within_depth(place.effective_tvd(p), depth_range)
)
segments = tuple(
AlignedSegment(
borehole_id=hid,
family=family,
from_md=seg.from_md,
to_md=seg.to_md,
color=seg.color,
points=tuple(
place.to_scene(p)
for p in seg.points
if _within_depth(place.effective_tvd(p), depth_range)
),
metadata=dict(seg.metadata),
)
for seg in render_hole.interval_segments
)
segments = tuple(seg for seg in segments if len(seg.points) >= 2)
v_scene = (cv - float(offset_shift)) * float(offset_scale)
collar_scene = (cu + v_scene * sin_az, v_scene * cos_az, z_top)
relation = _relation(collar_scene, centerline, scene_bounds)
if relation == "outside":
warnings.append(
"collar and trajectory fall outside the imaged volume"
)
holes.append(
AlignedHole(
borehole_id=hid,
name=render_hole.collar.label,
collar_scene=collar_scene,
centerline=centerline,
segments=segments,
display_radius=render_hole.display_radius,
relation=relation,
warnings=tuple(warnings),
)
)
return SceneAlignment(
holes=tuple(holes), family=family, warnings=tuple(doc_warnings)
)
# ---------------------------------------------------------------------------
# helpers
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class _Placement:
"""Per-hole scene transform for one desurveyed trajectory point."""
frame: SurveyFrame
collar_east: float
collar_north: float
cu: float
cv: float
z_top: float
sin_az: float
cos_az: float
offset_shift: float = 0.0
offset_scale: float = 1.0
lean_active: bool = False
lean_du: float = 0.0
lean_dv: float = 0.0
lean_dz: float = 1.0
def effective_tvd(self, point: Any) -> float:
"""True-vertical depth for the depth-window filter.
A leaned hole's vertical reach is ``md * cos(lean)``, shorter than
the plumb-hole ``tvd`` the render model carries.
"""
if self.lean_active:
return float(point.md) * self.lean_dz
return float(point.tvd)
def to_scene(self, point: Any) -> _ScenePoint:
if self.lean_active:
md = float(point.md)
u = self.cu + md * self.lean_du
v_raw = self.cv + md * self.lean_dv
dz = md * self.lean_dz
else:
d_east = float(point.x) - self.collar_east
d_north = float(point.y) - self.collar_north
du = (
d_east * self.frame.strike[0]
+ d_north * self.frame.strike[1]
)
dv = d_east * self.frame.perp[0] + d_north * self.frame.perp[1]
u = self.cu + du
v_raw = self.cv + dv
dz = float(point.tvd)
v = (v_raw - self.offset_shift) * self.offset_scale
return (
u + v * self.sin_az,
v * self.cos_az,
self.z_top - dz,
)
def _within_depth(tvd: float, depth_range: tuple[float, float] | None) -> bool:
if depth_range is None:
return True
lo, hi = depth_range
return lo - 1e-6 <= float(tvd) <= hi + 1e-6
def _relation(
collar_scene: _ScenePoint,
centerline: Sequence[_ScenePoint],
scene_bounds: tuple[float, float, float, float, float, float] | None,
) -> str:
if scene_bounds is None:
return "unknown"
umin, umax, vmin, vmax, zmin, zmax = scene_bounds
points = [collar_scene, *centerline]
inside = 0
for x, y, z in points:
if not (
np.isfinite(x) and np.isfinite(y) and np.isfinite(z)
):
continue
if umin <= x <= umax and vmin <= y <= vmax and zmin <= z <= zmax:
inside += 1
if inside == 0:
return "outside"
if inside == len([p for p in points if all(np.isfinite(c) for c in p)]):
return "inside"
return "edge"
def _collar_lonlat(
document: PCBHDocument,
) -> dict[str, tuple[float, float]]:
"""Return ``{borehole_id: (lat, lon)}`` for every locatable hole."""
result: dict[str, tuple[float, float]] = {}
transformer = None
need_transform = False
for hole in document.boreholes:
collar = hole.collar
if collar.latitude is not None and collar.longitude is not None:
result[hole.id] = (
float(collar.latitude),
float(collar.longitude),
)
else:
need_transform = True
if not need_transform:
return result
horizontal = str(document.crs.horizontal or "")
if horizontal.upper().startswith("LOCAL:") or not horizontal:
return result
try:
from pyproj import Transformer
transformer = Transformer.from_crs(
horizontal, "EPSG:4326", always_xy=True
)
except Exception: # noqa: BLE001 - pyproj missing / unknown CRS
return result
for hole in document.boreholes:
if hole.id in result:
continue
collar = hole.collar
try:
lon, lat = transformer.transform(
float(collar.x), float(collar.y)
)
except Exception: # noqa: BLE001
continue
if np.isfinite(lon) and np.isfinite(lat):
result[hole.id] = (float(lat), float(lon))
return result