Source code for pycsamt.map._core

# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
"""Core data extraction helpers for :mod:`pycsamt.map`."""

from __future__ import annotations

import re
from collections.abc import Iterable, Mapping
from dataclasses import dataclass, field
from typing import Any

import numpy as np
import pandas as pd

from pycsamt.emtools._core import ensure_sites

_NON_ALNUM_RE = re.compile(r"[^a-z0-9]")


[docs] def normalize_station_id(name: Any) -> str: """Loosely-normalized station id for fuzzy matching. Lowercases and strips everything but letters/digits, so minor formatting differences between sources — ``23-18-001A`` vs ``23_18_001a`` vs ``23 18 001A`` — still match. Used as a fallback tier (never the only lookup) wherever a station from one source (EDI, ModEM, an uploaded elevation file, ...) needs to be matched against another. """ return _NON_ALNUM_RE.sub("", str(name).strip().lower())
[docs] @dataclass class StationRecord: """A normalized station row used by map renderers.""" id: str latitude: float | None = None longitude: float | None = None elevation: float | None = None line: str | None = None index: int = 0 source: Any = None def __post_init__(self) -> None: """Normalize station metadata.""" self.id = str(self.id) self.latitude = _finite_or_none(self.latitude) self.longitude = _finite_or_none(self.longitude) self.elevation = _finite_or_none(self.elevation) if self.line not in (None, ""): self.line = str(self.line)
[docs] @dataclass class ProfileLine: """A named sequence of stations in one profile.""" name: str stations: tuple[StationRecord, ...] = ()
[docs] @dataclass class MapData: """Normalized survey data shared by map renderers.""" sites: Any stations: tuple[StationRecord, ...] = () profiles: tuple[ProfileLine, ...] = () metadata: dict[str, Any] = field(default_factory=dict) def __post_init__(self) -> None: """Normalize station/profile containers.""" self.stations = tuple(self.stations or ()) if self.profiles: self.profiles = tuple(self.profiles) else: self.profiles = _build_profiles(self.stations) self.metadata.setdefault( "n_stations", len(self.stations), ) self.metadata.setdefault( "n_profiles", len(self.profiles), )
[docs] @property def station_ids(self) -> tuple[str, ...]: """Return normalized station IDs.""" return tuple(station.id for station in self.stations)
[docs] @property def lines(self) -> tuple[str, ...]: """Return available profile/line names.""" return tuple(profile.name for profile in self.profiles)
[docs] @property def has_geo(self) -> bool: """Whether stations have finite map coordinates.""" if not self.stations: return False return all( station.latitude is not None and station.longitude is not None for station in self.stations )
[docs] def iter_edis(self) -> tuple[Any, ...]: """Return EDI-like source objects.""" return _as_edi_tuple(self.sites)
TENSOR_COMPONENTS: dict[str, tuple[int, int]] = { "xx": (0, 0), "xy": (0, 1), "yx": (1, 0), "yy": (1, 1), } COMPONENT_ALIASES: dict[str, str] = { "zxx": "xx", "zxy": "xy", "zyx": "yx", "zyy": "yy", "rho_xy": "xy", "rho_yx": "yx", "phase_xy": "xy", "phase_yx": "yx", "determinant": "det", "det": "det", "avg": "avg", "average": "avg", "mean": "avg", } _LAT_NAMES = ( "latitude", "lat", "Latitude", "LAT", "LATITUDE", ) _LON_NAMES = ( "longitude", "lon", "long", "Longitude", "LON", "LONG", "LONGITUDE", ) _ELEV_NAMES = ( "elevation", "elev", "altitude", "alt", "Elevation", "ELEV", "ELEVATION", ) _ID_NAMES = ("station", "id", "name", "Station", "ID", "Name") _LINE_NAMES = ( "line", "profile", "survey_line", "Line", "Profile", ) _META_NAMES = ( "Head", "Header", "HEAD", "head", "header", "metadata", "meta", "info", "station_info", )
[docs] @dataclass(frozen=True) class ComponentSpec: """Parsed impedance component request.""" name: str indices: tuple[int, int] | None = None mode: str = "tensor"
[docs] @dataclass(frozen=True) class FrequencySelection: """Nearest-frequency selection metadata.""" requested: float | None actual: float index: int delta: float relative_delta: float within_tolerance: bool = True
[docs] @dataclass(frozen=True) class FrequencyValue: """Station value and selected-frequency metadata.""" station: str value: float selection: FrequencySelection
[docs] def ensure_map_data( data: Any, *, recursive: bool = True, line_map: dict[str, Iterable[str]] | None = None, verbose: int = 0, ) -> MapData: """Return normalized map data. Parameters ---------- data : EDI path, directory, iterable, or ``Sites``. recursive : Passed to :func:`pycsamt.emtools._core.ensure_sites`. line_map : Optional ``line -> station names`` mapping used when line metadata is not embedded in EDI objects. verbose : Verbosity passed to :func:`ensure_sites`. """ if isinstance(data, MapData): return data sites = ensure_sites( data, recursive=recursive, verbose=verbose, ) edis = _as_edi_tuple(sites) stations = tuple( _station_record(edi, index=i, line_map=line_map) for i, edi in enumerate(edis) ) profiles = _build_profiles(stations) metadata = { "n_stations": len(stations), "n_profiles": len(profiles), } return MapData( sites=sites, stations=stations, profiles=profiles, metadata=metadata, )
[docs] def station_records( data: Any, **kwargs: Any, ) -> tuple[StationRecord, ...]: """Return normalized station records for *data*.""" return ensure_map_data(data, **kwargs).stations
[docs] def profile_lines( data: Any, **kwargs: Any, ) -> tuple[ProfileLine, ...]: """Return normalized profile lines for *data*.""" return ensure_map_data(data, **kwargs).profiles
[docs] def station_dataframe(data: MapData) -> pd.DataFrame: """Return station records as a DataFrame.""" rows = [ { "ID": s.id, "Latitude": s.latitude, "Longitude": s.longitude, "Elevation": s.elevation, "Line": s.line or "line", "Index": s.index, } for s in data.stations ] return pd.DataFrame(rows)
[docs] def normalize_component(component: str | None) -> str: """Return the canonical map component name.""" raw = str(component or "xy").strip().lower() raw = raw.replace("-", "_") name = COMPONENT_ALIASES.get(raw, raw) if name in TENSOR_COMPONENTS or name in {"det", "avg"}: return name choices = sorted([*TENSOR_COMPONENTS, "avg", "det"]) msg = ( f"Unknown impedance component {component!r}. " f"Expected one of {choices}." ) raise ValueError(msg)
[docs] def component_spec(component: str | None) -> ComponentSpec: """Return a parsed impedance component specification.""" name = normalize_component(component) if name in TENSOR_COMPONENTS: return ComponentSpec( name=name, indices=TENSOR_COMPONENTS[name], ) return ComponentSpec(name=name, mode=name)
[docs] def component_index(component: str) -> tuple[int, int]: """Return tensor indices for an impedance component.""" spec = component_spec(component) if spec.indices is None: msg = ( f"Component {component!r} is derived and has no " "single tensor index." ) raise ValueError(msg) return spec.indices
[docs] def component_values( arr: Any, component: str | None, *, quantity: str = "rho", ) -> np.ndarray: """Extract a 1-D value series for one component.""" values = np.asarray(arr, dtype=float) if values.ndim < 3 or values.shape[-2:] != (2, 2): msg = "Expected impedance values with shape (nfreq, 2, 2)." raise ValueError(msg) spec = component_spec(component) if spec.indices is not None: r, c = spec.indices return values[:, r, c] xy = values[:, 0, 1] yx = values[:, 1, 0] if spec.name == "det": if quantity.lower() in {"phase", "phi"}: return np.nanmean( np.column_stack([xy, yx]), axis=1, ) return np.sqrt(np.abs(xy * yx)) return np.nanmean( np.column_stack([xy, yx]), axis=1, )
[docs] def frequency_axis(data: MapData) -> np.ndarray: """Return the first finite frequency axis found.""" for edi in data.iter_edis(): z_obj = getattr(edi, "Z", None) if z_obj is None: continue freq = np.asarray( getattr(z_obj, "freq", []), dtype=float, ) freq = freq[np.isfinite(freq) & (freq > 0)] if freq.size: return freq return np.array([], dtype=float)
[docs] def select_frequency( frequencies: Any, requested: float | None = None, *, tolerance: float | None = None, ) -> FrequencySelection | None: """Select the closest finite positive frequency.""" freq = np.asarray(frequencies, dtype=float) good = np.isfinite(freq) & (freq > 0) if not good.any(): return None valid_idx = np.flatnonzero(good) valid = freq[good] if requested is None: local_idx = 0 req = None delta = 0.0 rel = 0.0 else: req = float(requested) local_idx = int(np.nanargmin(np.abs(valid - req))) delta = float(abs(valid[local_idx] - req)) rel = delta / abs(req) if req else delta actual = float(valid[local_idx]) within = True if tolerance is not None and requested is not None: within = delta <= float(tolerance) return FrequencySelection( requested=req, actual=actual, index=int(valid_idx[local_idx]), delta=delta, relative_delta=float(rel), within_tolerance=within, )
[docs] def station_distance_km(data: MapData) -> np.ndarray: """Return approximate station distance in km.""" stations = list(data.stations) if not stations: return np.array([], dtype=float) lat = np.array( [s.latitude if s.latitude is not None else np.nan for s in stations], dtype=float, ) lon = np.array( [ s.longitude if s.longitude is not None else np.nan for s in stations ], dtype=float, ) if not np.isfinite(lat).all() or not np.isfinite(lon).all(): return np.arange(len(stations), dtype=float) x = lon * 111.320 * np.cos(np.deg2rad(np.nanmean(lat))) y = lat * 110.574 dist = np.zeros(len(stations), dtype=float) for i in range(1, len(stations)): dist[i] = dist[i - 1] + float( np.hypot( x[i] - x[i - 1], y[i] - y[i - 1], ) ) return dist
[docs] def value_at_frequency( data: MapData, *, frequency: float, quantity: str = "rho", component: str = "xy", tolerance: float | None = None, ) -> dict[str, float]: """Return station values at the closest frequency.""" details = value_at_frequency_details( data, frequency=frequency, quantity=quantity, component=component, tolerance=tolerance, ) return {station: item.value for station, item in details.items()}
[docs] def value_at_frequency_details( data: MapData, *, frequency: float, quantity: str = "rho", component: str = "xy", tolerance: float | None = None, ) -> dict[str, FrequencyValue]: """Return values with selected-frequency metadata.""" out: dict[str, FrequencyValue] = {} for edi in data.iter_edis(): sid = _station_id_from_edi(edi) z_obj = getattr(edi, "Z", None) if z_obj is None: continue freq = np.asarray( getattr(z_obj, "freq", []), dtype=float, ) selection = select_frequency( freq, frequency, tolerance=tolerance, ) if selection is None or not selection.within_tolerance: continue q_name = quantity.lower() if q_name in {"phase", "phi"}: arr = getattr(z_obj, "phase", None) else: arr = getattr(z_obj, "resistivity", None) if arr is None: continue values = component_values( arr, component, quantity=q_name, ) if selection.index >= values.size: continue value = float(values[selection.index]) if np.isfinite(value): out[sid] = FrequencyValue( station=sid, value=value, selection=selection, ) return out
[docs] def skin_depth_at_frequency( data: MapData, *, frequency: float, component: str = "xy", tolerance: float | None = None, ) -> dict[str, float]: """Return skin depth at the closest frequency.""" details = value_at_frequency_details( data, frequency=frequency, quantity="rho", component=component, tolerance=tolerance, ) out: dict[str, float] = {} for sid, item in details.items(): freq = item.selection.actual value = item.value if value > 0 and freq > 0: depth = 503.0 * np.sqrt(value / freq) out[sid] = float(depth) return out
[docs] def pseudosection_table( data: MapData, *, quantity: str = "rho", component: str = "xy", ) -> pd.DataFrame: """Return long-form profile data for pseudosections.""" rows: list[dict[str, float | str]] = [] distances = station_distance_km(data) dist_by_id = { station.id: float(distances[i]) for i, station in enumerate(data.stations) } for edi in data.iter_edis(): sid = _station_id_from_edi(edi) z_obj = getattr(edi, "Z", None) if z_obj is None: continue freq = np.asarray( getattr(z_obj, "freq", []), dtype=float, ) if freq.size == 0: continue arr = getattr( z_obj, "resistivity" if quantity == "rho" else "phase", None, ) if arr is None: continue vals = component_values( arr, component, quantity=quantity, ) periods = np.where(freq > 0, 1.0 / freq, np.nan) for period, value in zip(periods, vals): if not np.isfinite(period) or not np.isfinite(value): continue if quantity == "rho" and value <= 0: continue rows.append( { "station": sid, "distance": dist_by_id.get(sid, np.nan), "period": float(period), "value": float(value), } ) return pd.DataFrame(rows)
def _station_record( edi: Any, *, index: int, line_map: dict[str, Iterable[str]] | None, ) -> StationRecord: station_id = _station_id_from_edi(edi) or f"S{index:03d}" return StationRecord( id=station_id, latitude=_metadata_float(edi, _LAT_NAMES), longitude=_metadata_float(edi, _LON_NAMES), elevation=_metadata_float(edi, _ELEV_NAMES), line=_resolve_line(edi, station_id, line_map), index=index, source=edi, ) def _metadata_float( obj: Any, names: tuple[str, ...], ) -> float | None: value = _first_float(obj, *names) if value is not None: return value coords = _coords_float(obj, names) if coords is not None: return coords for meta in _metadata_sources(obj): value = _first_float(meta, *names) if value is not None: return value coords = _coords_float(meta, names) if coords is not None: return coords return None def _coords_float( obj: Any, names: tuple[str, ...], ) -> float | None: if obj is None: return None try: coords = _get_field(obj, "coords") if coords is None or len(coords) < 2: return None raw = coords[0] if _is_latitude_names(names) else coords[1] except (TypeError, ValueError, IndexError): return None return _finite_or_none(raw) def _is_latitude_names(names: tuple[str, ...]) -> bool: return any(str(name).lower().startswith("lat") for name in names) def _first_float(obj: Any, *names: str) -> float | None: for name in names: value = _get_field(obj, name) value_f = _finite_or_none(value) if value_f is not None: return value_f return None def _get_field(obj: Any, name: str) -> Any: if obj is None: return None if isinstance(obj, Mapping): for key in (name, name.lower(), name.upper()): if key in obj: return obj[key] return None return getattr(obj, name, None) def _metadata_sources(obj: Any) -> tuple[Any, ...]: sources: list[Any] = [] for name in _META_NAMES: value = _get_field(obj, name) if value is not None: sources.append(value) # pyCSAMT EDIFile exposes its header via ``get_section("head")`` # (a method), whose ``.Location`` carries lat/lon/elev. Pull those # section objects — and their Location — into the metadata search. getter = getattr(obj, "get_section", None) if callable(getter): for section in ("head", "Head", "definemeas", "emeassect"): try: sec = getter(section) except Exception: # noqa: BLE001 - optional section sec = None if sec is None: continue sources.append(sec) loc = getattr(sec, "Location", None) or getattr( sec, "location", None ) if loc is not None: sources.append(loc) direct_loc = getattr(obj, "Location", None) or getattr( obj, "location", None ) if direct_loc is not None: sources.append(direct_loc) return tuple(sources) def _finite_or_none(value: Any) -> float | None: if value is None: return None try: value_f = float(value) except (TypeError, ValueError): return None if np.isfinite(value_f): return value_f return None def _station_id_from_edi(edi: Any) -> str: value = _first_text(edi, _ID_NAMES) if value: return value for meta in _metadata_sources(edi): value = _first_text(meta, _ID_NAMES) if value: return value return "" def _first_text(obj: Any, names: tuple[str, ...]) -> str: for name in names: value = _get_field(obj, name) if value not in (None, ""): return str(value) return "" def _as_edi_tuple(sites: Any) -> tuple[Any, ...]: if sites is None: return () if hasattr(sites, "as_list"): return tuple(sites.as_list()) if isinstance(sites, Iterable) and not isinstance( sites, (str, bytes), ): return tuple(sites) return (sites,) def _resolve_line( edi: Any, station_id: str, line_map: dict[str, Iterable[str]] | None, ) -> str | None: value = _first_text(edi, _LINE_NAMES) if value: return value for meta in _metadata_sources(edi): value = _first_text(meta, _LINE_NAMES) if value: return value if line_map: for line, stations in line_map.items(): if station_id in {str(s) for s in stations}: return str(line) return None def _build_profiles( stations: tuple[StationRecord, ...], ) -> tuple[ProfileLine, ...]: grouped: dict[str, list[StationRecord]] = {} for station in stations: line = station.line or "line" grouped.setdefault(line, []).append(station) return tuple( ProfileLine(name=name, stations=tuple(items)) for name, items in grouped.items() )