Source code for pycsamt.utils.io

# Author: L. Kouadio <etanoyau@gmail.com>
# License: LGPL-3.0

"""
pycsamt.utils.io

I/O utility functions for PyCSAMT v2.0.
"""

import os
import re
from typing import Optional, Union

import numpy as np
from scipy.interpolate import interp1d

from ..exceptions import PycsamtError, ValidationError

__all__ = [
    "stn_separation",
    "parse_stn_profile",
]


[docs] def stn_separation( easting: Union[np.ndarray, list, tuple], northing: Union[np.ndarray, list, tuple], interpolate: bool = False, ) -> tuple[np.ndarray, float]: """ Compute station separations (distance between successive points). Parameters ---------- easting : array_like Easting coordinates (m). northing : array_like Northing coordinates (m). interpolate : bool, optional If True, returns an array of length N matching the number of electrodes by extrapolating the first separation. If False, returns length N-1 (number of dipoles). Default is False. Returns ------- separations : np.ndarray Distances between successive points. mean_sep : float Mean separation value. """ # Convert inputs to numpy arrays of float try: e = np.asarray(easting, dtype=float) n = np.asarray(northing, dtype=float) except Exception as err: raise ValidationError(f"Invalid coordinate arrays: {err}") if e.shape != n.shape: raise ValidationError( f"Easting and northing shapes differ: {e.shape} vs {n.shape}" ) count = e.size if count < 2: return np.array([], dtype=float), 0.0 # Calculate pairwise distances deltas = np.sqrt((np.diff(e)) ** 2 + (np.diff(n)) ** 2) if interpolate: # Extrapolate separations to length = count indices = np.arange(1, count) f = interp1d(indices, deltas, fill_value="extrapolate") separations = f(np.arange(count)) else: separations = deltas mean_sep = float(np.mean(separations)) return separations, mean_sep
[docs] def parse_stn_profile( file_path: str, delimiter: Optional[str] = None ) -> dict[str, np.ndarray]: """ Parse a station profile file (.stn) containing columns such as station position (dot), easting, northing, elevation. Parameters ---------- file_path : str Path to the station profile file. delimiter : str, optional Field delimiter. If None, whitespace splitting is used. Returns ------- result : Dict[str, np.ndarray] Dictionary with keys: - 'position': station position (float) - 'easting': easting coordinate (float) - 'northing': northing coordinate (float) - 'elevation': elevation (float) Raises ------ PycsamtError If file cannot be read or parsed. """ if not os.path.isfile(file_path): raise PycsamtError(f"File not found: {file_path}") with open(file_path, encoding="utf8") as f: lines = [ln.strip() for ln in f if ln.strip()] # Skip comment lines starting with '>' or '!' data_lines = [ln for ln in lines if not re.match(r"^[>!+]", ln)] if not data_lines: raise PycsamtError(f"No data found in {file_path}") # First non-comment line is header header = data_lines[0] if delimiter: cols = header.split(delimiter) else: cols = header.split() # Normalize column names cols = [col.strip().strip('"') for col in cols] # Read data rows data = [] for ln in data_lines[1:]: parts = ln.split(delimiter) if delimiter else ln.split() if len(parts) != len(cols): raise PycsamtError( f"Line has {len(parts)} fields but expected {len(cols)}: {ln}" ) data.append([float(p) for p in parts]) arr = np.array(data) result = {} for idx, name in enumerate(cols): key = name.lower() if key in ("dot", "station", "stn"): result["position"] = arr[:, idx] elif "east" in key or key == "e": result["easting"] = arr[:, idx] elif "north" in key or key == "n": result["northing"] = arr[:, idx] elif "elev" in key or "h" == key: result["elevation"] = arr[:, idx] else: # preserve any other columns under raw names result[key] = arr[:, idx] # Ensure required keys for req in ("position", "easting", "northing", "elevation"): if req not in result: raise PycsamtError( f"Missing required column '{req}' in {file_path}" ) return result