Source code for pycsamt.format.topo_source

# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
"""Attach real per-station spatial coordinates (and elevation) to a PCSF
adapter's :class:`~pycsamt.format.schema.StationTable` from an external
*topo source*.

Every PCSF adapter (:mod:`pycsamt.format.adapters`) already accepts a
``station_elevations``/``station_lonlat``-style mapping the caller has
to build by hand. This module is the "smart" layer on top of that: a
single ``topo=`` argument that can be

- a ``.bln`` (Golden Software Surfer blanking) file — a bare ``x, y``
  point list, no station identity, matched to stations *positionally*
  (in survey order) since the format itself carries none;
- a ``.stn`` (Zonge station-location) file, parsed by the same
  battle-tested low-level reader :mod:`pycsamt.zonge` already uses
  (:func:`pycsamt.zonge.utils.read_stn`) — never re-implemented here;
- a ``.csv`` (or ``.txt``) file with a header pycsamt can recognise
  (``station``/``lat``/``lon``/``easting``/``northing``/``elevation``
  and common synonyms);
- an already-geo-located ``Sites``/``MapData``/iterable-of-
  ``StationRecord``-like object (duck-typed: anything exposing a
  station id plus ``latitude``/``longitude``/``elevation``) — so a
  survey's own EDI collection can supply topography directly, with no
  intermediate file at all;
- a plain ``{station_name: (lon, lat)}`` / ``{station_name: (lon, lat,
  elevation)}`` mapping — the same shape ``station_lonlat`` already
  accepted, now handled by the same code path;
- for a multiline build, a ``{line_id: <any of the above>}`` mapping,
  or a sequence of one source per line in line order — one ``.bln``/
  ``.stn`` file per surveyed line is a common real-world layout.

Design choices, stated explicitly because they are the crux of "smart"
attribution rather than an implementation detail:

1. **A named source (``.stn``, ``.csv`` with a station column, a
   plain dict, or a Sites/MapData object) matches by station id** —
   exact, then a normalized fallback (:func:`pycsamt.map._core.normalize_station_id`,
   the same one every other cross-source station match in this
   codebase already uses). Unmatched stations on either side are
   reported, never silently dropped or fabricated.
2. **A name-less source (a bare ``.bln``, or a ``.csv`` without a
   station column) is positional** and therefore *requires* an exact
   station-count match — the whole reason to "first detect the number
   of stations passed and compare to the inversion stations" before
   attributing anything. A mismatch raises by default
   (``on_mismatch="raise"``); ``on_mismatch="warn"`` instead issues a
   :class:`UserWarning` and attributes only the overlapping prefix, a
   deliberate best-effort escape hatch rather than the default.
3. **When ``topo`` is given, it takes precedence over any other
   lon/lat source** (``station_lonlat``, a backend's own native
   coordinates) for every station it successfully attributes — with a
   :class:`UserWarning` when both were supplied, so the override is
   never silent. Elevation merges rather than fully overriding: a
   station the topo source has no elevation for keeps whatever
   ``station_elevations`` already gave it.
4. **Passing no ``topo`` behaves exactly as before this module
   existed** — every adapter's existing ``station_elevations``/
   ``station_lonlat`` parameters are unaffected when ``topo`` is
   ``None``.
5. **Projected coordinates (easting/northing) need `epsg` or
   `utm_zone` to become lon/lat** — conversion is delegated entirely
   to :func:`pycsamt.gis.utils.to_ll` (the project's one existing
   UTM/EPSG conversion utility; ``pyproj`` stays an optional
   dependency, imported lazily only when a conversion is actually
   requested, matching PCSF's own "no new mandatory dependency"
   principle in ``SPEC.md`` S2). A source already carrying lat/lon
   columns, or a ``.bln`` explicitly marked ``latlon=True``, needs
   neither.
"""

from __future__ import annotations

import warnings
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any

import numpy as np

__all__ = [
    "TopoTable",
    "TopoAttribution",
    "read_topo_file",
    "topo_from_sites",
    "attribute_topo",
    "resolve_topo",
]

_NAME_ALIASES = ("station", "name", "site", "id", "sta", "dot")
_LAT_ALIASES = ("lat", "latitude")
_LON_ALIASES = ("lon", "long", "longitude")
_EAST_ALIASES = ("easting", "east", "gride", "x", "e")
_NORTH_ALIASES = ("northing", "north", "gridn", "y", "n")
_ELEV_ALIASES = ("elev", "elevation", "alt", "altitude", "z", "h")


def _find_col(columns: Sequence[str], aliases: Sequence[str]) -> str | None:
    lower = {str(c).strip().lower(): c for c in columns}
    for alias in aliases:
        if alias in lower:
            return lower[alias]
    # Substring fallback (handles e.g. "Elev(m)") -- only for aliases
    # long enough that a false-positive substring hit is implausible;
    # a single-letter alias like "n"/"e"/"x"/"y" must match the whole
    # column name exactly (already checked above) or not at all, or it
    # would match almost anything ("note" contains "n").
    for col in columns:
        cl = str(col).strip().lower()
        for alias in aliases:
            if len(alias) >= 3 and alias in cl:
                return col
    return None


[docs] @dataclass class TopoTable: """A parsed topo source, before it is matched to any station names. Attributes ---------- names : list of str, optional Station identity carried by the source itself. ``None`` for a positional-only source (e.g. a bare ``.bln``). lon, lat : ndarray, shape (n,) WGS84 decimal degrees — already converted from easting/northing if the source needed that. elevation : ndarray, shape (n,), optional Metres, ``nan`` where genuinely unknown. source : str Human-readable provenance (file path, or a short description for an in-memory source), surfaced in error/warning messages. """ lon: np.ndarray lat: np.ndarray elevation: np.ndarray | None = None names: list[str] | None = None source: str = "<unknown>" def __post_init__(self) -> None: self.lon = np.asarray(self.lon, dtype=float) self.lat = np.asarray(self.lat, dtype=float) if self.elevation is not None: self.elevation = np.asarray(self.elevation, dtype=float) if self.names is not None and len(self.names) != self.lon.shape[0]: raise ValueError( f"{self.source}: {len(self.names)} name(s) but " f"{self.lon.shape[0]} coordinate(s)" )
[docs] @property def n(self) -> int: return int(self.lon.shape[0])
[docs] @dataclass class TopoAttribution: """Per-station real coordinates resolved from a topo source, ready to populate :attr:`~pycsamt.format.schema.StationTable.lon`/``lat`` (and merge into elevation).""" lon: dict[str, float] = field(default_factory=dict) lat: dict[str, float] = field(default_factory=dict) elevation: dict[str, float] = field(default_factory=dict) matched: list[str] = field(default_factory=list) unmatched_stations: list[str] = field(default_factory=list) source: str = "none" def __bool__(self) -> bool: return bool(self.matched)
# --------------------------------------------------------------------- # Coordinate conversion (lazy pyproj/GDAL via pycsamt.gis.utils) # --------------------------------------------------------------------- def _convert_xy( x: np.ndarray, y: np.ndarray, *, epsg: int | None, utm_zone: Any | None, source: str ) -> tuple[np.ndarray, np.ndarray]: if epsg is None and utm_zone is None: raise ValueError( f"{source}: gives projected easting/northing but neither " "epsg nor utm_zone was provided -- pass one so it can be " "converted to lon/lat (see pycsamt.gis.utils.to_ll)." ) from ..gis.utils import to_ll lat, lon = to_ll(np.asarray(x, dtype=float), np.asarray(y, dtype=float), zone=utm_zone, epsg=epsg) return np.asarray(lon, dtype=float), np.asarray(lat, dtype=float) # --------------------------------------------------------------------- # File readers # --------------------------------------------------------------------- def _read_bln( path: Path, *, epsg: int | None, utm_zone: Any | None, latlon: bool ) -> TopoTable: """Golden Software Surfer ``.bln``: a ``n_points[,flag]`` header line, then *n_points* rows of ``x, y`` (an optional 3rd numeric column is read as elevation). The format carries no station identity -- points are positional, in file order.""" raw_lines = path.read_text(encoding="utf-8").splitlines() lines = [ ln.strip() for ln in raw_lines if ln.strip() and not ln.strip().startswith(("#", "//")) ] if not lines: raise ValueError(f"{path}: empty .bln file") header = [p.strip() for p in lines[0].split(",") if p.strip() != ""] try: n_points = int(float(header[0])) except (ValueError, IndexError) as exc: raise ValueError(f"{path}: malformed .bln header {lines[0]!r}") from exc body = lines[1 : 1 + n_points] if len(body) != n_points: raise ValueError( f"{path}: header declares {n_points} point(s), found {len(body)}" ) rows = [ [float(v) for v in ln.replace("\t", ",").split(",") if v.strip() != ""] for ln in body ] arr = np.asarray(rows, dtype=float) x, y = arr[:, 0], arr[:, 1] elevation = arr[:, 2] if arr.shape[1] >= 3 else None if latlon: lon, lat = x, y else: lon, lat = _convert_xy(x, y, epsg=epsg, utm_zone=utm_zone, source=str(path)) return TopoTable(lon=lon, lat=lat, elevation=elevation, names=None, source=str(path)) def _dataframe_to_topotable( df: Any, source: str, *, epsg: int | None, utm_zone: Any | None ) -> TopoTable: columns = list(df.columns) name_col = _find_col(columns, _NAME_ALIASES) lat_col = _find_col(columns, _LAT_ALIASES) lon_col = _find_col(columns, _LON_ALIASES) east_col = _find_col(columns, _EAST_ALIASES) north_col = _find_col(columns, _NORTH_ALIASES) elev_col = _find_col(columns, _ELEV_ALIASES) if lat_col and lon_col: lon = df[lon_col].to_numpy(dtype=float) lat = df[lat_col].to_numpy(dtype=float) elif east_col and north_col: lon, lat = _convert_xy( df[east_col].to_numpy(dtype=float), df[north_col].to_numpy(dtype=float), epsg=epsg, utm_zone=utm_zone, source=str(source), ) else: raise ValueError( f"{source}: could not find lat/lon or easting/northing " f"columns among {columns}" ) elevation = df[elev_col].to_numpy(dtype=float) if elev_col else None names = [str(v) for v in df[name_col].tolist()] if name_col else None return TopoTable(lon=lon, lat=lat, elevation=elevation, names=names, source=str(source))
[docs] def read_topo_file( path: str | Path, *, epsg: int | None = None, utm_zone: Any | None = None, latlon: bool = False, ) -> TopoTable: """Parse a ``.bln``/``.csv``/``.stn`` topo file into a :class:`TopoTable`. Parameters ---------- path : path-like A ``.bln``, ``.csv``/``.txt``, or ``.stn`` file. epsg : int, optional EPSG code of the source's projected CRS, when it stores easting/northing rather than lat/lon. Takes precedence over *utm_zone* when both are given (matches :func:`pycsamt.gis.utils.to_ll`'s own precedence). utm_zone : optional UTM zone designator (e.g. ``"32N"``), an alternative to *epsg*. latlon : bool, default False ``.bln`` only: set ``True`` when the file's own ``x, y`` columns are already ``lon, lat`` (a ``.bln`` carries no CRS metadata to detect this from). Ignored for ``.csv``/``.stn``, which are only treated as already-geographic when their own header says ``lat``/``lon``. Raises ------ ValueError Unrecognised extension, a malformed ``.bln`` header/body, no recognisable coordinate columns in a ``.csv``/``.stn`` file, or projected coordinates with neither *epsg* nor *utm_zone*. """ path = Path(path) suffix = path.suffix.lower() if suffix == ".bln": return _read_bln(path, epsg=epsg, utm_zone=utm_zone, latlon=latlon) if suffix == ".stn": from ..zonge.utils import read_stn df = read_stn(path) return _dataframe_to_topotable(df, str(path), epsg=epsg, utm_zone=utm_zone) if suffix in (".csv", ".txt"): import pandas as pd df = pd.read_csv(path, comment="#", skip_blank_lines=True) return _dataframe_to_topotable(df, str(path), epsg=epsg, utm_zone=utm_zone) raise ValueError( f"{path}: unrecognised topo file extension {suffix!r} -- expected " "one of '.bln', '.csv', '.txt', '.stn'." )
# --------------------------------------------------------------------- # Sites / MapData extraction # ---------------------------------------------------------------------
[docs] def topo_from_sites(source: Any) -> TopoTable: """Extract lon/lat/elevation from an already-geo-located ``Sites``/``MapData``/iterable-of-station-record object. Duck-typed on purpose: works with anything iterable whose items (or whose ``.stations`` attribute's items, for a ``MapData``-like container) expose a station identifier (``id``/``name``/ ``station``) plus ``longitude``/``latitude`` and, optionally, ``elevation`` -- :class:`pycsamt.map._core.StationRecord` and similar objects all qualify without any adapter code. Raises ------ ValueError If no station in *source* carries a usable id + lon/lat pair. """ stations = getattr(source, "stations", source) names: list[str] = [] lons: list[float] = [] lats: list[float] = [] elevs: list[float] = [] for st in stations: name = getattr(st, "id", None) or getattr(st, "name", None) or getattr(st, "station", None) lon = getattr(st, "longitude", None) lat = getattr(st, "latitude", None) if name is None or lon is None or lat is None: continue elev = getattr(st, "elevation", None) names.append(str(name)) lons.append(float(lon)) lats.append(float(lat)) elevs.append(float(elev) if elev is not None else np.nan) if not names: raise ValueError( "topo_from_sites: no geo-located station (id + longitude + " "latitude) found in the given source" ) return TopoTable( lon=np.array(lons), lat=np.array(lats), elevation=np.array(elevs), names=names, source=f"{type(source).__name__} ({len(names)} geo-located station(s))", )
# --------------------------------------------------------------------- # Coercion: any accepted "topo" argument -> TopoTable # --------------------------------------------------------------------- def _mapping_to_topotable(mapping: Mapping[str, Any], source: str) -> TopoTable: names = list(mapping.keys()) values = list(mapping.values()) lon = np.array([float(v[0]) for v in values], dtype=float) lat = np.array([float(v[1]) for v in values], dtype=float) elevation = np.array( [float(v[2]) if len(v) > 2 else np.nan for v in values], dtype=float ) return TopoTable(lon=lon, lat=lat, elevation=elevation, names=names, source=source) def _coerce_topo_table( source: Any, *, epsg: int | None, utm_zone: Any | None, latlon: bool ) -> TopoTable: if isinstance(source, TopoTable): return source if isinstance(source, (str, Path)): return read_topo_file(source, epsg=epsg, utm_zone=utm_zone, latlon=latlon) if isinstance(source, Mapping): return _mapping_to_topotable(source, source="in-memory {name: (lon, lat[, elev])} mapping") return topo_from_sites(source) # --------------------------------------------------------------------- # Attribution # ---------------------------------------------------------------------
[docs] def attribute_topo( topo: TopoTable, station_names: Sequence[str], *, on_mismatch: str = "raise", ) -> TopoAttribution: """Match a parsed :class:`TopoTable` onto *station_names*. Name-based when ``topo.names`` is populated (exact match, then a normalized fallback); positional (in order, requiring an exact count match) otherwise. See the module docstring for the full rationale. Parameters ---------- on_mismatch : {"raise", "warn"}, default "raise" Only consulted for a positional (name-less) source. ``"raise"`` rejects a station-count mismatch outright; ``"warn"`` issues a :class:`UserWarning` and attributes only the overlapping prefix (``min(topo.n, len(station_names))`` points, in order). """ if on_mismatch not in ("raise", "warn"): raise ValueError(f"on_mismatch must be 'raise' or 'warn', got {on_mismatch!r}") station_names = [str(n) for n in station_names] lon_by: dict[str, float] = {} lat_by: dict[str, float] = {} elev_by: dict[str, float] = {} matched: list[str] = [] if topo.names is not None: from ..map._core import normalize_station_id index: dict[str, int] = {} for i, name in enumerate(topo.names): index.setdefault(name, i) index.setdefault(name.strip().lower(), i) index.setdefault(normalize_station_id(name), i) for name in station_names: i = ( index.get(name) if index.get(name) is not None else index.get(name.strip().lower(), index.get(normalize_station_id(name))) ) if i is None: continue lon_by[name] = float(topo.lon[i]) lat_by[name] = float(topo.lat[i]) if topo.elevation is not None and np.isfinite(topo.elevation[i]): elev_by[name] = float(topo.elevation[i]) matched.append(name) else: if topo.n != len(station_names): msg = ( f"{topo.source}: has {topo.n} point(s) but " f"{len(station_names)} station(s) were expected -- a " "name-less topo source must carry exactly one point per " "station, in survey order." ) if on_mismatch == "raise": raise ValueError(msg) warnings.warn( msg + " Attributing only the overlapping prefix (on_mismatch='warn').", UserWarning, stacklevel=2, ) n = min(topo.n, len(station_names)) for i in range(n): name = station_names[i] lon_by[name] = float(topo.lon[i]) lat_by[name] = float(topo.lat[i]) if topo.elevation is not None and np.isfinite(topo.elevation[i]): elev_by[name] = float(topo.elevation[i]) matched.append(name) unmatched = [n for n in station_names if n not in lon_by] return TopoAttribution( lon=lon_by, lat=lat_by, elevation=elev_by, matched=matched, unmatched_stations=unmatched, source=topo.source, )
def _merge_attributions(parts: Sequence[TopoAttribution]) -> TopoAttribution: merged = TopoAttribution(source="; ".join(p.source for p in parts) or "none") for part in parts: merged.lon.update(part.lon) merged.lat.update(part.lat) merged.elevation.update(part.elevation) merged.matched.extend(part.matched) merged.unmatched_stations.extend(part.unmatched_stations) return merged
[docs] def resolve_topo( topo: Any, station_names: Sequence[str] | Mapping[str, Sequence[str]], *, epsg: int | None = None, utm_zone: Any | None = None, latlon: bool = False, on_mismatch: str = "raise", ) -> TopoAttribution: """Resolve any accepted ``topo=`` argument into a :class:`TopoAttribution` against *station_names*. Parameters ---------- topo : None, path-like, TopoTable, Sites/MapData-like, mapping, or sequence - ``None`` -- returns an empty attribution (every adapter's existing ``station_elevations``/``station_lonlat`` behaviour is unaffected). - a ``.bln``/``.csv``/``.stn`` path, or an already-parsed :class:`TopoTable`. - a ``Sites``/``MapData``/iterable-of-station-record object (see :func:`topo_from_sites`). - a plain ``{station_name: (lon, lat[, elevation])}`` mapping. - when *station_names* is a mapping (multiline, ``{line_id: [names, ...]}``): a ``{line_id: <any of the above>}`` mapping, or a sequence with exactly one source per line, in the same order as *station_names*'s own keys. A single non-mapping, non-per-line-sequence source is instead matched by name across *all* lines' stations combined -- the natural choice for one combined ``.stn``/``.csv``/Sites source covering a whole multiline survey. station_names : sequence of str, or mapping of str to sequence of str The inversion's own station names (flat), or ``{line_id: names}`` for a multiline build. epsg, utm_zone, latlon, on_mismatch Forwarded to :func:`read_topo_file`/:func:`attribute_topo` for every file-based source encountered. Returns ------- TopoAttribution Empty (all fields blank, ``source="none"``) when *topo* is ``None``. """ if isinstance(station_names, Mapping): lines = list(station_names.items()) if isinstance(topo, Mapping) and not isinstance(topo, TopoTable): parts = [] for line_id, names in lines: src = topo.get(line_id) if src is None: parts.append( TopoAttribution(unmatched_stations=list(names), source="none") ) continue table = _coerce_topo_table(src, epsg=epsg, utm_zone=utm_zone, latlon=latlon) parts.append(attribute_topo(table, names, on_mismatch=on_mismatch)) return _merge_attributions(parts) if ( isinstance(topo, Sequence) and not isinstance(topo, (str, Path)) and len(lines) > 1 ): if len(topo) != len(lines): raise ValueError( f"got {len(topo)} topo source(s) but {len(lines)} " f"line(s) were expected ({[lid for lid, _ in lines]}); " "pass one topo source per line, in the same order, " "or a single named source (.stn/.csv/dict/Sites) " "matched by station name across every line." ) parts = [ attribute_topo( _coerce_topo_table(src, epsg=epsg, utm_zone=utm_zone, latlon=latlon), names, on_mismatch=on_mismatch, ) for (_, names), src in zip(lines, topo) ] return _merge_attributions(parts) all_names = [n for _, names in lines for n in names] if topo is None: return TopoAttribution(unmatched_stations=all_names, source="none") table = _coerce_topo_table(topo, epsg=epsg, utm_zone=utm_zone, latlon=latlon) return attribute_topo(table, all_names, on_mismatch=on_mismatch) if topo is None: return TopoAttribution(unmatched_stations=[str(n) for n in station_names], source="none") table = _coerce_topo_table(topo, epsg=epsg, utm_zone=utm_zone, latlon=latlon) return attribute_topo(table, station_names, on_mismatch=on_mismatch)