# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
from __future__ import annotations
import math
from collections.abc import Iterable
from dataclasses import dataclass, field
from typing import Any
import numpy as np
from ..constants import _M_PER_DEG
from .location import Coord, chainage_along
from .utils import get_coords, station_name
__all__ = ["infer_line_orientation", "Profile"]
[docs]
@dataclass
class Profile:
r"""
Profile(origin, azimuth, chainages=None, spacing_stats=None,
gaps=None)
Describe a 1-D survey line (profile) built from site
locations. Stores the origin, line azimuth, per-site
chainages, spacing statistics, and detected large gaps.
The azimuth follows the convention 0 deg north, 90 deg east.
Chainages are in meters and are computed consistently with
:func:`pycsamt.site.location.chainage_along`.
Parameters
----------
origin : pycsamt.site.location.Coord
Origin coordinate used for chainage computations.
azimuth : float
Line azimuth in degrees, 0 deg north, 90 deg east.
chainages : dict, optional
Mapping ``{station_name: chainage_m}``. Usually filled
via :meth:`Profile.from_sites`.
spacing_stats : dict, optional
Precomputed spacing metrics. Filled automatically when
chainages are set or updated.
gaps : list of tuple, optional
Large spacing gaps as ``[(s_left, s_right), ...]`` in
meters.
Attributes
----------
origin : Coord
Profile origin coordinate.
azimuth : float
Profile azimuth (deg).
chainages : dict
Per-site chainages in meters.
spacing_stats : dict
Keys include ``spacing_mean``, ``spacing_med``,
``spacing_min``, ``spacing_max`` (meters).
gaps : list of tuple
Detected large gaps as chainage intervals (meters).
Notes
-----
Chainage for a site with local offsets :math:`(x,y)` relative
to the origin and profile azimuth :math:`A` is
.. math::
s = x * cos(A) + y * sin(A)
Examples
--------
>>> from pycsamt.site.profile import Profile
>>> from pycsamt.site.location import Coord
>>> class Head:
... def __init__(self, lat, lon, name):
... self.lat, self.lon, self.dataid = lat, lon, name
...
>>> class EDI:
... def __init__(self, name, lat, lon):
... self._h = Head(lat, lon, name)
... def get_section(self, key):
... return self._h if key == "head" else None
...
>>> sites = [EDI("A", 0.0, 0.00),
... EDI("B", 0.0, 0.01),
... EDI("C", 0.0, 0.02)]
>>> prof = Profile.from_sites(sites)
>>> round(prof.azimuth) in (89, 90, 91)
True
See Also
--------
pycsamt.site.profile.infer_line_orientation
pycsamt.site.location.Coord
pycsamt.site.location.chainage_along
"""
origin: Coord
azimuth: float
chainages: dict[str, float] = field(default_factory=dict)
spacing_stats: dict[str, float] = field(default_factory=dict)
gaps: list[tuple[float, float]] = field(default_factory=list)
[docs]
@classmethod
def from_sites(
cls,
sites: Iterable[Any],
*,
origin: Coord | None = None,
azimuth: float | None = None,
) -> Profile:
r"""
from_sites(sites, *, origin=None, azimuth=None)
Build a profile from an iterable of sites. If ``origin`` is
omitted, the first site with finite coordinates is used. If
``azimuth`` is omitted, it is inferred with
:func:`infer_line_orientation`.
Parameters
----------
sites : iterable
EDI-like objects or wrappers with an ``.edi`` attribute.
origin : Coord, optional
Origin coordinate. If omitted, inferred from the first
finite site.
azimuth : float, optional
Profile azimuth in degrees. If omitted, inferred from
site positions.
Returns
-------
Profile
Profile with per-site chainages and spacing statistics
computed.
Notes
-----
Coordinates and names are obtained via
:func:`pycsamt.site.utils.get_coords` and
:func:`pycsamt.site.utils.station_name`. Chainages are
computed with
:func:`pycsamt.site.location.chainage_along`.
Examples
--------
>>> from pycsamt.site.profile import Profile
>>> prof = Profile.from_sites([])
>>> isinstance(prof, Profile)
True
"""
items = _iter_sites(sites)
if not items:
o = origin or Coord(0.0, 0.0, 0.0)
return cls(o, float(azimuth or 0.0))
# origin default = first finite coordinate
if origin is None:
for _, la, lo, _ in items:
if _finite(la) and _finite(lo):
origin = Coord(la, lo, 0.0)
break
if origin is None:
origin = Coord(0.0, 0.0, 0.0)
# auto azimuth from points if not given
if azimuth is None:
azimuth = infer_line_orientation([it[3] for it in items])
# chainages along the line
s: dict[str, float] = {}
for name, la, lo, _ in items:
if _finite(la) and _finite(lo):
s[name] = float(
chainage_along(
(origin.lat, origin.lon),
float(azimuth),
(la, lo),
)
)
prof = cls(origin, float(azimuth), s)
prof._update_stats()
return prof
[docs]
def sort_sites(self, sites: Iterable[Any]) -> list[Any]:
r"""
sort_sites(sites)
Return the input sites ordered by chainage along the
profile. Sites without finite chainage are dropped.
Parameters
----------
sites : iterable
Same accepted types as :meth:`Profile.from_sites`.
Returns
-------
list
The subset of input sites sorted by increasing chainage.
Examples
--------
>>> sorted_sites = prof.sort_sites(sites)
>>> isinstance(sorted_sites, list)
True
"""
items = _iter_sites(sites)
order = []
for it in items:
si = self.chainages.get(it[0], float("nan"))
order.append((si, it[3]))
order.sort(key=lambda t: (not _finite(t[0]), t[0]))
return [o[1] for o in order if _finite(o[0])]
[docs]
def slice(self, s_min: float, s_max: float) -> dict[str, float]:
r"""
slice(s_min, s_max)
Return chainages within the window ``s_min <= s <= s_max``
as a dict ordered by chainage.
Parameters
----------
s_min : float
Lower bound in meters.
s_max : float
Upper bound in meters.
Returns
-------
dict
Mapping ``{station_name: chainage_m}``, ordered by
chainage.
Examples
--------
>>> win = prof.slice(500.0, 1500.0)
>>> isinstance(win, dict)
True
"""
out = {
k: v
for k, v in self.chainages.items()
if _finite(v) and (s_min <= v <= s_max)
}
return dict(sorted(out.items(), key=lambda t: t[1]))
[docs]
def resample(self, step: float) -> np.ndarray:
r"""
resample(step)
Build a regular chainage grid between the current minimum
and maximum chainage (inclusive of the minimum). If
``step <= 0``, an empty array is returned.
Parameters
----------
step : float
Grid spacing in meters.
Returns
-------
numpy.ndarray
1-D array of chainage locations in meters.
Examples
--------
>>> grid = prof.resample(250.0)
>>> (grid.ndim, grid.dtype.kind) == (1, 'f')
True
"""
if not self.chainages:
return np.asarray([], float)
s = np.asarray(list(self.chainages.values()), float)
s = s[np.isfinite(s)]
if s.size == 0 or step <= 0:
return np.asarray([], float)
a = float(np.nanmin(s))
b = float(np.nanmax(s))
n = int(max(1, math.floor((b - a) / step) + 1))
return a + step * np.arange(n, dtype=float)
[docs]
def summary(self) -> dict[str, float]:
r"""
summary()
Return a compact summary of the profile and its spacing
statistics.
Returns
-------
dict
Keys include ``n_sites``, ``s_min``, ``s_max``,
``n_gaps``, and entries from ``spacing_stats``
(``spacing_mean``, ``spacing_med``, ``spacing_min``,
``spacing_max``).
Examples
--------
>>> info = prof.summary()
>>> set(["n_sites", "n_gaps"]).issubset(info.keys())
True
"""
d: dict[str, float] = {}
d.update(self.spacing_stats)
d["n_sites"] = float(len(self.chainages))
if self.chainages:
vals = np.asarray(list(self.chainages.values()), float)
m = np.isfinite(vals)
if np.any(m):
d["s_min"] = float(np.min(vals[m]))
d["s_max"] = float(np.max(vals[m]))
d["n_gaps"] = float(len(self.gaps))
return d
def _update_stats(self) -> None:
if not self.chainages:
self.spacing_stats = {}
self.gaps = []
return
s = np.asarray(list(self.chainages.values()), float)
s = s[np.isfinite(s)]
s.sort()
if s.size < 2:
self.spacing_stats = {
"spacing_mean": float("nan"),
"spacing_med": float("nan"),
"spacing_min": float("nan"),
"spacing_max": float("nan"),
}
self.gaps = []
return
d = np.diff(s)
self.spacing_stats = {
"spacing_mean": float(np.mean(d)),
"spacing_med": float(np.median(d)),
"spacing_min": float(np.min(d)),
"spacing_max": float(np.max(d)),
}
med = float(np.median(d))
thr = 1.5 * med if med > 0 else float("inf")
gaps: list[tuple[float, float]] = []
for i in range(d.size):
if d[i] > thr:
gaps.append((float(s[i]), float(s[i + 1])))
self.gaps = gaps
# ------------------------ helpers (minimal) ------------------------
def _finite(x: float) -> bool:
return math.isfinite(float(x))
def _iter_sites(sites: Iterable[Any]) -> list[tuple[str, float, float, Any]]:
"""Return (name, lat, lon, edi_like) for each input item.
Uses utils.station_name / utils.get_coords and supports objects
that wrap an EDI as `.edi`.
"""
out: list[tuple[str, float, float, Any]] = []
for s in sites:
ed = getattr(s, "edi", s)
name = station_name(ed) or station_name(s)
c = get_coords(ed) # has fields: lat, lon, elev
out.append((name, float(c.lat), float(c.lon), ed))
return out
def _xy_local(
lats: np.ndarray, lons: np.ndarray
) -> tuple[np.ndarray, np.ndarray]:
la0 = float(np.nanmean(lats))
lo0 = float(np.nanmean(lons))
dy = (lats - la0) * _M_PER_DEG
dx = (lons - lo0) * _M_PER_DEG * math.cos(math.radians(la0))
return dx, dy
[docs]
def infer_line_orientation(sites: Iterable[Any]) -> float:
r"""
Infer the survey line azimuth from a collection of sites.
This estimates the dominant line axis that best explains the
site distribution. The estimate uses PCA on local Cartesian
offsets derived from geographic coordinates. The result is an
azimuth in degrees where 0 deg is north and 90 deg is east.
Parameters
----------
sites : iterable of objects
Items may be EDI-like objects or wrappers exposing an
``.edi`` attribute. Site names and coordinates are
resolved using :func:`pycsamt.site.utils.station_name`
and :func:`pycsamt.site.utils.get_coords`.
Returns
-------
float
Azimuth in degrees, 0 deg north and 90 deg east. Because
eigenvectors are sign-ambiguous, the orientation is
defined modulo 180 deg (e.g., 45 deg and 225 deg are the
same axis).
Notes
-----
Local offsets :math:`(x, y)` are built with a small-extent
flat-Earth approximation about the mean latitude
:math:`lat_0` and longitude :math:`lon_0`:
.. math::
x = (lon - lon_0) * M\_PER\_DEG * cos(lat\_0)
y = (lat - lat_0) * M\_PER\_DEG
The principal component with the largest variance gives the
line axis in the local frame :math:`(x=east, y=north)`. It is
then converted to an azimuth (0 deg north, 90 deg east).
Examples
--------
>>> from pycsamt.site.profile import infer_line_orientation
>>> class Head:
... def __init__(self, lat, lon, name):
... self.lat, self.lon, self.dataid = lat, lon, name
...
>>> class EDI:
... def __init__(self, name, lat, lon):
... self._h = Head(lat, lon, name)
... def get_section(self, key):
... return self._h if key == "head" else None
...
>>> east = [EDI(f"S{i}", 0.0, i*0.01) for i in range(5)]
>>> az = infer_line_orientation(east)
>>> 80.0 <= az <= 100.0
True
See Also
--------
pycsamt.site.profile.Profile
pycsamt.site.location.Coord
pycsamt.site.location.chainage_along
pycsamt.site.utils.get_coords
pycsamt.site.utils.station_name
References
----------
.. [1] Jolliffe, I. T., "Principal Component Analysis",
Springer, 2nd ed., 2002.
"""
items = _iter_sites(sites)
if not items:
return 0.0
la = np.asarray([i[1] for i in items], float)
lo = np.asarray([i[2] for i in items], float)
m = np.isfinite(la) & np.isfinite(lo)
if not np.any(m):
return 0.0
x, y = _xy_local(la[m], lo[m])
if x.size < 2:
return 0.0
# PCA major axis
X = np.stack([x, y], axis=0)
vals, vecs = np.linalg.eig(np.cov(X))
k = int(np.argmax(vals))
vx, vy = float(vecs[0, k]), float(vecs[1, k])
# Convert to azimuth (0°=north, 90°=east).
# The vector is in (x=east, y=north) => swap in atan2.
ang = math.degrees(math.atan2(vx, vy))
return (ang + 360.0) % 360.0