Source code for pycsamt.map.geometry

# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
"""Real-geometry line-layout helpers for 3-D survey maps.

Multi-line 3-D views (fence, block, depth-slice) need two things per
survey line: an along-line ("profile distance") axis and a
line-to-line ("offset") position. Placing lines with a synthetic
``index * spacing`` stack ignores where the lines actually sit on the
ground, which staggers panels that should be parallel and evenly
spaced.

This module derives both from real station coordinates instead:
every station is projected onto a shared local (u, v) frame — ``u``
along the survey's dominant strike, ``v`` across it — so parallel
lines stay parallel and the line-to-line spacing reflects their true
geographic separation.

Pure numpy, no Plotly/Dash/pycsamt-app dependencies, so it is safe to
import from both :mod:`pycsamt.map` (the code-first / mapview 3-D
builder) and :mod:`pycsamt.app.web` (the legacy 3-D map callbacks) —
whichever data structure each caller uses (``MapData`` stations, or
plain per-line dicts) is adapted to plain id/lat/lon/line arrays
before calling in here.
"""

from __future__ import annotations

import math
from collections.abc import Sequence
from dataclasses import dataclass

import numpy as np

__all__ = [
    "equirect_xy",
    "fit_strike",
    "survey_uv",
    "SurveyFrame",
    "survey_frame",
    "normalize_offsets",
    "resolve_offset",
]

# Metres per degree, equirectangular approximation — adequate for the
# local extent (tens of km) of a single survey; avoids a hard pyproj
# dependency for what is otherwise a display-layout computation.
_LON_M_PER_DEG = 111_320.0
_LAT_M_PER_DEG = 110_574.0


[docs] def equirect_xy( lat: Sequence[float], lon: Sequence[float], ) -> tuple[np.ndarray, np.ndarray]: """Local planar metres for *lat*/*lon*, equirectangular approx. Centred implicitly at the mean latitude of *lat*. """ lat_arr = np.asarray(lat, dtype=float) lon_arr = np.asarray(lon, dtype=float) mlat = np.deg2rad(float(np.nanmean(lat_arr))) x = lon_arr * _LON_M_PER_DEG * np.cos(mlat) y = lat_arr * _LAT_M_PER_DEG return x, y
[docs] def fit_strike( points: np.ndarray, groups: np.ndarray, ) -> np.ndarray | None: """Return the shared along-line unit direction for *points*. Fits each group's (line's) own best-fit axis — SVD on that group's centred points — then averages them, sign-aligned and weighted by point count. This reflects "which way each line actually runs" rather than the aggregate shape of the whole point cloud, which can pick the wrong axis when a survey's footprint is roughly as wide (line-to-line) as it is long (along-line), e.g. a short/dense grid. Returns ``None`` when no direction can be fit (fewer than two points overall, or a degenerate/singular point cloud). """ directions: list[np.ndarray] = [] weights: list[int] = [] for name in np.unique(groups): mask = groups == name if int(mask.sum()) < 2: continue sub = points[mask] sub = sub - sub.mean(axis=0) try: _, singular, vt = np.linalg.svd(sub, full_matrices=False) except np.linalg.LinAlgError: continue if singular[0] <= 0: continue directions.append(vt[0]) weights.append(int(mask.sum())) if not directions: if points.shape[0] < 2: return None try: _, singular, vt = np.linalg.svd(points, full_matrices=False) except np.linalg.LinAlgError: return None return vt[0] if singular[0] > 0 else None ref = directions[0] total = np.zeros(2) for direction, weight in zip(directions, weights): if np.dot(direction, ref) < 0: direction = -direction total += direction * weight norm = float(np.linalg.norm(total)) return total / norm if norm > 0 else ref
[docs] def survey_uv( ids: Sequence[str], lat: Sequence[float], lon: Sequence[float], line: Sequence[str], ) -> dict[str, tuple[float, float]]: """Project every station onto a shared local (u, v) frame, metres. ``u`` is the along-strike distance, ``v`` the cross-strike offset, both measured from the combined centroid of every station passed in. *ids*, *lat*, *lon*, *line* must be the same length and aligned (one entry per station). Returns ``{}`` when there are fewer than two stations or no strike direction could be fit (e.g. all stations coincide). """ ids = list(ids) if len(ids) < 2: return {} x, y = equirect_xy(lat, lon) pts = np.column_stack([x, y]) centered = pts - pts.mean(axis=0) strike = fit_strike(centered, np.asarray(line, dtype=object)) if strike is None: return {} perp = np.array([-strike[1], strike[0]]) u = centered @ strike v = centered @ perp return {sid: (float(ui), float(vi)) for sid, ui, vi in zip(ids, u, v)}
[docs] @dataclass(frozen=True) class SurveyFrame: """A survey's local along/cross-strike frame, reusable for any point. :func:`survey_uv` returns only the station projections; this exposes the frame itself so an *arbitrary* geographic point (a borehole collar, a target) can be placed in the *same* local metres the 3-D map builders use. ``project`` mirrors :func:`survey_uv` exactly: equirectangular metres about the survey's mean latitude, then a shift to the station centroid and a rotation onto the fitted strike. """ mean_lat_deg: float origin_xy: tuple[float, float] strike: tuple[float, float] perp: tuple[float, float]
[docs] def project(self, lat: float, lon: float) -> tuple[float, float]: """Return ``(u, v)`` local metres for one WGS84 lat/lon.""" scale = _LON_M_PER_DEG * math.cos(math.radians(self.mean_lat_deg)) dx = float(lon) * scale - self.origin_xy[0] dy = float(lat) * _LAT_M_PER_DEG - self.origin_xy[1] u = dx * self.strike[0] + dy * self.strike[1] v = dx * self.perp[0] + dy * self.perp[1] return float(u), float(v)
[docs] def survey_frame( lat: Sequence[float], lon: Sequence[float], line: Sequence[str], ) -> SurveyFrame | None: """Fit the shared local frame used by :func:`survey_uv`. Returns ``None`` under the same conditions as :func:`survey_uv` (fewer than two stations, or no strike direction could be fit). """ lat_list = list(lat) if len(lat_list) < 2: return None x, y = equirect_xy(lat_list, lon) pts = np.column_stack([x, y]) origin = pts.mean(axis=0) strike = fit_strike(pts - origin, np.asarray(list(line), dtype=object)) if strike is None: return None perp = np.array([-strike[1], strike[0]]) return SurveyFrame( mean_lat_deg=float(np.nanmean(np.asarray(lat_list, dtype=float))), origin_xy=(float(origin[0]), float(origin[1])), strike=(float(strike[0]), float(strike[1])), perp=(float(perp[0]), float(perp[1])), )
[docs] def normalize_offsets( offsets: dict[str, float | None], ) -> dict[str, float] | None: """Shift *offsets* so the smallest value is zero. Returns ``None`` (signalling "no real geometry, fall back to a synthetic layout") when *offsets* is empty or any value is missing/non-finite — mixing real and synthetic placement in the same plot would look inconsistent, so it is all-or-nothing. """ if not offsets: return None for value in offsets.values(): if value is None or not np.isfinite(value): return None base = min(offsets.values()) return {name: float(value) - base for name, value in offsets.items()}
[docs] def resolve_offset( name: str, idx: int, real_offsets: dict[str, float] | None, fallback_unit: float, spacing: float, ) -> float: """Cross-strike placement for one line, in metres. Uses the survey's true line-to-line spacing (scaled by *spacing*, e.g. a UI "line spacing" multiplier where ``1.0`` means true spacing) when *real_offsets* is available, otherwise falls back to a synthetic ``idx * spacing * fallback_unit`` stack. """ if real_offsets is not None: return real_offsets[name] * spacing return idx * spacing * fallback_unit