Source code for pycsamt.site.edit

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

from __future__ import annotations

import math
from collections.abc import Iterable
from pathlib import Path
from typing import Any, Callable

import numpy as np
import pandas as pd

from .utils import (
    _ensure_head,
    get_freq,
    iter_edifiles,
    maybe_copy,
    set_station_name,
    station_name,
)
from .utils import (
    set_coords as _set_coords,
)

__all__ = [
    "rotate",
    "select_freq",
    "rename",
    "set_coords",
    "fill_missing",
    "recompute_res_phase",
    "rotate_all",
    "select_freq_all",
    "rename_all",
    "set_coords_all",
    "set_coords_from_table",
    "set_coords_from_en",
]


[docs] def rotate(site: Any, angle_deg: float, *, inplace: bool = False) -> Any: r""" Rotate impedance tensor Z (and tipper T, if present) by an azimuthal angle in degrees. The rotation is applied in the horizontal plane using the similarity transform :math:`Z' = R Z R^{-1}`, where :math:`R` is the 2x2 rotation matrix built from ``angle_deg``. When a tipper is available (either on the EDI object as ``T`` / ``TIP`` / ``Tip`` or attached to ``Z``), the 2-component vector is rotated consistently. Parameters ---------- site : Any An EDI-like object (e.g., ``pycsamt.seg.edi.EDIFile``) or wrapper exposing a ``Z`` section compatible with a complex 2x2 impedance array and, optionally, a tipper. angle_deg : float Rotation angle in degrees. Positive values rotate the measurement axes according to the internal convention of this package. If your acquisition system defines the sign oppositely, use the negative of your desired angle. inplace : bool, optional If ``True``, mutate ``site`` in place. Otherwise, work on a shallow copy and return that copy. Default is ``False``. Returns ------- Any The rotated object. If ``inplace`` is ``True``, this is the same object as ``site``; otherwise a new object. Notes ----- - Error arrays (``z_error`` or aliases) are rotated with a magnitude-only scheme (using absolute values of the rotation matrices) as a pragmatic best-effort. This is a common, but approximate, practice. - Only arrays with shapes consistent with MT tensors (``(n, 2, 2)`` for Z, ``(n, 2)`` for T) are rotated. Other shapes are ignored silently. - The function is no-throw by design. If a section is not present or incompatible, it is skipped. Examples -------- >>> from pycsamt.seg.edi import EDIFile >>> from pycsamt.site.edit import rotate >>> ed = EDIFile("path/to/station.edi") >>> ed_rot = rotate(ed, 30.0) # copy by default >>> ed_rot2 = rotate(ed, -45.0, inplace=True) See Also -------- select_freq : Subset rows by frequency range or indices. rename : Rename the station using a policy or explicit name. pycsamt.site.edit.rotate_all : Broadcast rotation over a collection. References ---------- .. [1] Simpson, F. and Bahr, K. (2005). Practical Magnetotellurics. Cambridge Univ. Press. .. [2] SEG EDI format usage notes for MT tensors. """ ed = _to_mutable(site, inplace=inplace) z = getattr(ed, "Z", None) if z is not None: zz = _get_attr_any(z, "z", "impedance", "_z") if zz is not None: r, rinv = _rotm(angle_deg) a = np.asarray(zz, complex) if a.ndim == 3 and a.shape[-2:] == (2, 2): newz = r[None, :, :] @ a @ rinv[None, :, :] _set_attr_first(z, ("z", "impedance", "_z"), newz) ze = _get_attr_any( z, "z_error", "z_err", "impedance_err", "_z_err" ) if ze is not None: ar = np.abs(r) ari = np.abs(rinv) e = np.asarray(ze, float) if e.ndim == 3 and e.shape[-2:] == (2, 2): newe = ar[None, :, :] * e * ari[None, :, :] _set_attr_first( z, ("z_error", "z_err", "impedance_err", "_z_err"), newe, ) # tipper may live in ed.T / ed.TIP or Z.tipper tip_obj = None for cand in ("T", "TIP", "Tip"): tip_obj = getattr(ed, cand, None) if tip_obj is not None: break if tip_obj is not None: t = _get_attr_any(tip_obj, "tipper", "_tipper") if t is not None: _, rinv = _rotm(angle_deg) a = np.asarray(t, complex) if a.ndim == 2 and a.shape[1] == 2: _set_attr_first(tip_obj, ("tipper", "_tipper"), a @ rinv.T) else: if z is not None: t = _get_attr_any(z, "tipper", "tip", "_tipper") if t is not None: _, rinv = _rotm(angle_deg) a = np.asarray(t, complex) if a.ndim == 2 and a.shape[1] == 2: _set_attr_first( z, ("tipper", "tip", "_tipper"), a @ rinv.T, ) return ed
[docs] def select_freq( site: Any, *, fmin: float | None = None, fmax: float | None = None, keep: Iterable[int] | np.ndarray | None = None, inplace: bool = False, ) -> Any: r""" Subset the dataset along frequency by range or explicit indices, keeping all affected arrays aligned. This applies the selection to every frequency-indexed array found on the object, including Z, Z errors, derived resistivity/phase, and any tipper arrays resident on either the root EDI object or under its ``Z`` section. Parameters ---------- site : Any An EDI-like object (e.g., ``pycsamt.seg.edi.EDIFile``) with a discoverable frequency vector. fmin : float, optional Keep rows with ``freq >= fmin``. Ignored if ``None``. fmax : float, optional Keep rows with ``freq <= fmax``. Ignored if ``None``. keep : Iterable[int] or numpy.ndarray, optional Explicit indices or a boolean mask to keep. If provided, ``fmin`` and ``fmax`` are ignored. Use integer indices for exact row picks, or a boolean mask of the same length as the frequency vector. inplace : bool, optional If ``True``, mutate ``site`` in place. Otherwise, operate on a shallow copy. Default is ``False``. Returns ------- Any The object after selection. If ``inplace`` is ``True``, this is the same object as ``site``; otherwise a new object. Notes ----- - All known aliases are sliced consistently (e.g., frequency, Z, Z error, resistivity, phase, tipper, and related per-row arrays) to preserve alignment. - If the frequency vector is empty or missing, the call is a no-op. - The function is no-throw by design; incompatible shapes are skipped silently. Examples -------- Keep only rows with frequency >= 10 Hz: >>> from pycsamt.seg.edi import EDIFile >>> from pycsamt.site.edit import select_freq >>> ed = EDIFile("path/to/station.edi") >>> ed_hi = select_freq(ed, fmin=10.0) Keep the first and last rows explicitly: >>> sel = [0, -1] >>> ed_edge = select_freq(ed, keep=sel) Apply in place: >>> _ = select_freq(ed, fmin=1.0, fmax=100.0, inplace=True) See Also -------- rotate : Rotate Z and tipper by an azimuth angle. rename : Rename the station identifiers. pycsamt.site.edit.select_freq_all : Broadcast selection over a collection. References ---------- .. [1] SEG EDI format usage notes for frequency-indexed MT arrays. """ ed = _to_mutable(site, inplace=inplace) f = get_freq(ed) if keep is not None: sel = np.asarray(list(keep)) if getattr(ed, "Z", None) is not None: _slice_fields(ed.Z, sel) for cand in ("T", "TIP", "Tip"): obj = getattr(ed, cand, None) if obj is not None: _slice_fields(obj, sel) return ed if f.size == 0: return ed m = np.isfinite(f) if fmin is not None: m &= f >= float(fmin) if fmax is not None: m &= f <= float(fmax) if getattr(ed, "Z", None) is not None: _slice_fields(ed.Z, m) for cand in ("T", "TIP", "Tip"): obj = getattr(ed, cand, None) if obj is not None: _slice_fields(obj, m) return ed
[docs] def rename( site: Any, name: str | None = None, policy: Callable[[str], str] | None = None, *, inplace: bool = False, ) -> Any: r""" Rename a station using an explicit name or a policy function. This updates common station identifiers across the EDI header and attempts to keep them in sync so that downstream code resolves the new name consistently. Parameters ---------- site : Any An EDI-like object (e.g., ``pycsamt.seg.edi.EDIFile``) or wrapper with a modifiable HEAD section. name : str, optional Explicit new station name to set. If provided, this takes precedence over ``policy``. policy : Callable[[str], str], optional A function mapping the current station name to a new one. Ignored when ``name`` is provided. inplace : bool, optional If ``True``, mutate ``site`` in place. Otherwise, operate on a shallow copy. Default is ``False``. Returns ------- Any The object with updated identifiers. If ``inplace`` is ``True``, this is the same object as ``site``; otherwise a new object. Notes ----- - The function writes multiple header fields when present (e.g., ``dataid``, ``station``, and other common aliases) and also mirrors the name to ``edi.name``. This increases the chance that name resolution remains stable across different readers. - The rename operation does not touch on-disk filenames. File paths remain unchanged unless you later write out using a template that depends on the station name. - If both ``name`` and ``policy`` are given, ``name`` wins. Examples -------- Policy-based rename: >>> from pycsamt.seg.edi import EDIFile >>> from pycsamt.site.edit import rename >>> ed = EDIFile("path/to/station.edi") >>> ed2 = rename(ed, policy=lambda n: f"X_{n}") Explicit name, in place: >>> _ = rename(ed, name="ST123A", inplace=True) See Also -------- rotate : Rotate Z and tipper by an azimuth angle. select_freq : Subset rows by frequency range or indices. pycsamt.site.edit.rename_all : Broadcast rename over a collection. pycsamt.site.base.Site : Wrapper that resolves a stable site name for indexing. References ---------- .. [1] SEG EDI format field naming and common aliases for station identifiers. """ ed = _to_mutable(site, inplace=inplace) cur = station_name(ed) if name is None and policy is None: return ed new = name if name is not None else cur if policy is not None: try: new = str(policy(cur)) except Exception: new = str(cur) # Best-effort: update all usual identifiers and edi.name. set_station_name(ed, new) try: h = _ensure_head(ed) h.dataid = str(new or "") if not getattr(h, "station", None): h.station = str(new or "") # also mirror common alternates when present for k in ("sitename", "name", "STATION"): try: setattr(h, k, str(new)) except Exception: pass except Exception: pass try: ed.name = str(new) except Exception: pass return ed
[docs] def set_coords( site: Any, *, lat: float | None = None, lon: float | None = None, elev: float | None = None, inplace: bool = False, ) -> Any: r""" Set geographic coordinates on the EDI header. Only the values explicitly provided are updated. The call delegates to the same coordinate writer used by the Site API, so downstream tools see consistent lat, lon, and elev fields. Parameters ---------- site : Any An EDI-like object (e.g., ``pycsamt.seg.edi.EDIFile``) or wrapper exposing a mutable HEAD section. lat : float, optional Latitude in degrees. If ``None``, the field is left unchanged. lon : float, optional Longitude in degrees. If ``None``, the field is left unchanged. elev : float, optional Elevation in meters. If ``None``, the field is left unchanged. inplace : bool, optional If ``True``, mutate ``site`` in place. Otherwise, work on a shallow copy and return that copy. Default is ``False``. Returns ------- Any The updated object. If ``inplace`` is ``True``, this is the same object as ``site``; otherwise a new object. Notes ----- - The function validates numeric types and writes to common HEAD attribute names (``lat``, ``lon``, ``elev``). - If a field is not present in the header, a best-effort attribute is created. Examples -------- >>> from pycsamt.seg.edi import EDIFile >>> from pycsamt.site.edit import set_coords >>> ed = EDIFile("path/to/station.edi") >>> ed2 = set_coords(ed, lat=35.1, lon=12.8, elev=1234.0) >>> _ = set_coords(ed, lat=36.0, inplace=True) See Also -------- pycsamt.site.base.Site.set_coords : Object-oriented wrapper. pycsamt.site.edit.set_coords_all : Broadcast over a collection. References ---------- .. [1] SEG EDI format, HEAD section fields for station coordinates. """ ed = _to_mutable(site, inplace=inplace) _set_coords(ed, lat=lat, lon=lon, elev=elev, inplace=True) return ed
[docs] def fill_missing( site: Any, *, how: str = "zero", components: Iterable[str] = ("Z", "Tip"), inplace: bool = False, ) -> Any: r""" Replace missing or non-finite values in Z and/or tipper arrays with zeros or NaNs. The operation preserves shapes and alignment across arrays. If an array is absent, a new one is allocated with the correct shape inferred from the frequency vector. Parameters ---------- site : Any An EDI-like object with a discoverable frequency vector and optionally Z and tipper sections. how : {"zero", "nan"}, optional Replacement policy. Use ``"zero"`` to fill with numeric zeros. Use ``"nan"`` to fill with NaN for all non-finite entries. Default is ``"zero"``. components : Iterable[str], optional Which components to process. Accepts items like ``"Z"`` or ``"Tip"`` (case-insensitive). Default is ``("Z", "Tip")``. inplace : bool, optional If ``True``, mutate in place. Otherwise, operate on a shallow copy and return that copy. Default is ``False``. Returns ------- Any The object after filling. If ``inplace`` is ``True``, this is the same object as ``site``; otherwise a new object. Notes ----- - Z arrays are expected as shape ``(n, 2, 2)`` and tipper as ``(n, 2)``. Only arrays with the expected shapes are modified or allocated. - When Z exists, the function also fills common aliases for errors and derived quantities, such as ``z_error``, ``rho`` (resistivity), and ``phase`` arrays, if present. - The frequency vector length ``n`` defines the number of rows used for any new arrays. - This function is no-throw by design. Incompatible or absent pieces are skipped silently. Examples -------- Fill Z and tipper with zeros where values are non-finite: >>> from pycsamt.seg.edi import EDIFile >>> from pycsamt.site.edit import fill_missing >>> ed = EDIFile("path/to/station.edi") >>> ed2 = fill_missing(ed, how="zero") Fill only Z with NaNs, in place: >>> _ = fill_missing(ed, how="nan", components=("Z",), ... inplace=True) See Also -------- select_freq : Subset rows by frequency while keeping arrays aligned. rotate : Rotate Z and tipper by an azimuth angle. References ---------- .. [1] Simpson, F. and Bahr, K. (2005). Practical Magnetotellurics. Cambridge Univ. Press. .. [2] SEG EDI format usage notes for MT arrays and tipper. """ how = str(how).lower() if how not in {"zero", "nan"}: raise ValueError("how must be 'zero' or 'nan'") ed = _to_mutable(site, inplace=inplace) f = get_freq(ed) n = int(f.size) if "Z" in set(map(str.upper, components)): z = getattr(ed, "Z", None) if z is not None: # Z 2x2 blocks zcur = _get_attr_any(z, "z", "impedance", "_z") znew = ( np.zeros((n, 2, 2), complex) if zcur is None else _fill_array(zcur, (n, 2, 2), how) ) _set_attr_first(z, ("z", "impedance", "_z"), znew) ze = _get_attr_any( z, "z_error", "z_err", "impedance_err", "_z_err" ) if ze is not None: _set_attr_first( z, ("z_error", "z_err", "impedance_err", "_z_err"), _fill_array(ze, (n, 2, 2), how), ) rr = _get_attr_any(z, "rho", "res", "resistivity", "_resistivity") if rr is not None: _set_attr_first( z, ("rho", "res", "resistivity", "_resistivity"), _fill_array(rr, (n, 2, 2), how), ) ph = _get_attr_any(z, "phase", "phi", "_phase") if ph is not None: _set_attr_first( z, ("phase", "phi", "_phase"), _fill_array(ph, (n, 2, 2), how), ) phe = _get_attr_any(z, "phase_err", "_phase_err") if phe is not None: _set_attr_first( z, ("phase_err", "_phase_err"), _fill_array(phe, (n, 2, 2), how), ) if "TIP" in set(map(str.upper, components)): tp = None for cand in ("T", "TIP", "Tip"): tp = getattr(ed, cand, None) if tp is not None: break if tp is not None: tcur = _get_attr_any(tp, "tipper", "_tipper") tnew = ( np.zeros((n, 2), complex) if tcur is None else _fill_array(tcur, (n, 2), how) ) _set_attr_first(tp, ("tipper", "_tipper"), tnew) te = _get_attr_any(tp, "tipper_err", "_tipper_err") if te is not None: _set_attr_first( tp, ("tipper_err", "_tipper_err"), _fill_array(te, (n, 2), how), ) return ed
[docs] def rotate_all(sites: Any, angle_deg: float, *, inplace: bool = False): r""" Rotate every site in a collection by an azimuthal angle in degrees. This is the broadcast variant of :func:`rotate`. It accepts either a ``Sites`` wrapper or any iterable of EDI-like objects and returns a new ``Sites`` unless ``inplace`` is requested. Parameters ---------- sites : Any A ``pycsamt.site.base.Sites`` instance or any iterable of EDI-like objects. angle_deg : float Rotation angle in degrees, passed through to :func:`rotate`. inplace : bool, optional If ``True``, attempt to apply the rotation in place on the given container. Otherwise, return a new ``Sites``. Default is ``False``. Returns ------- pycsamt.site.base.Sites A sites collection holding the rotated items, or the original container when mutated in place. Notes ----- - The function preserves the input order of items. - If some items lack compatible arrays, they are skipped silently. Examples -------- Using a list of EDI files: >>> from pycsamt.seg.edi import EDIFile >>> from pycsamt.site.edit import rotate_all >>> eds = [EDIFile("A.edi"), EDIFile("B.edi")] >>> ro = rotate_all(eds, 15.0) Using a Sites wrapper: >>> from pycsamt.site.base import Sites >>> sites = Sites(eds) >>> ro2 = rotate_all(sites, -30.0) See Also -------- rotate : Single-site rotation. select_freq_all : Broadcast selection by frequency. """ items = [] for ed, _ in _each_site(sites): items.append(rotate(ed, angle_deg, inplace=False)) return _wrap_output(sites, items, inplace=inplace)
[docs] def select_freq_all( sites: Any, *, fmin: float | None = None, fmax: float | None = None, keep: Iterable[int] | np.ndarray | None = None, inplace: bool = False, ): r""" Subset all sites in a collection along frequency, keeping arrays aligned. This is the broadcast variant of :func:`select_freq`. It accepts a ``Sites`` wrapper or any iterable of EDI-like objects and returns a new ``Sites`` unless ``inplace`` is requested. Parameters ---------- sites : Any A ``pycsamt.site.base.Sites`` instance or any iterable of EDI-like objects. fmin : float, optional Keep rows with ``freq >= fmin``. Ignored if ``None``. fmax : float, optional Keep rows with ``freq <= fmax``. Ignored if ``None``. keep : Iterable[int] or numpy.ndarray, optional Explicit indices or a boolean mask to keep. If provided, ``fmin`` and ``fmax`` are ignored. inplace : bool, optional If ``True``, attempt to modify the given container in place. Otherwise, return a new ``Sites``. Default is ``False``. Returns ------- pycsamt.site.base.Sites A sites collection with selection applied, or the original container when mutated in place. Notes ----- - All known frequency-indexed arrays are sliced in sync for each site (Z, errors, derived quantities, and tipper). - Items missing a frequency vector are left unchanged. Examples -------- >>> from pycsamt.seg.edi import EDIFile >>> from pycsamt.site.base import Sites >>> from pycsamt.site.edit import select_freq_all >>> eds = [EDIFile("A.edi"), EDIFile("B.edi")] >>> sites = Sites(eds) >>> out = select_freq_all(sites, fmin=1.0, fmax=100.0) Use explicit row indices for all sites: >>> out2 = select_freq_all(eds, keep=[0, -1]) See Also -------- select_freq : Single-site selection by frequency. rotate_all : Broadcast rotation over a collection. References ---------- .. [1] SEG EDI format usage notes for frequency-indexed MT arrays. """ items = [] for ed, _ in _each_site(sites): items.append( select_freq( ed, fmin=fmin, fmax=fmax, keep=keep, inplace=False, ) ) return _wrap_output(sites, items, inplace=inplace)
[docs] def rename_all( sites: Any, *, policy: Callable[[str], str] | None = None, name_fn: Callable[[Any], str] | None = None, inplace: bool = False, ): r""" Batch rename a collection of sites. This is the broadcast variant of :func:`rename`. It accepts a ``Sites`` wrapper or any iterable of EDI-like objects and produces a new ``Sites`` (unless ``inplace`` is ``True``). Parameters ---------- sites : Any A :class:`pycsamt.site.base.Sites` instance or any iterable of EDI-like objects. policy : Callable[[str], str], optional Function mapping the *current* station name to a new one, e.g. ``lambda n: f"X_{n}"``. Ignored if ``name_fn`` is provided. name_fn : Callable[[Any], str], optional Function mapping each EDI object to a new name, e.g. from the file stem. This takes precedence over ``policy`` and is useful to guarantee uniqueness across sites. inplace : bool, optional If ``True``, attempt to modify the given container in place. Otherwise, return a new ``Sites``. Default is ``False``. Returns ------- pycsamt.site.base.Sites A collection holding the renamed items, or the original container when mutated in place. Notes ----- - The rename updates common header identifiers and mirrors the name to ``edi.name`` for robust downstream resolution. - If multiple sites share the same original name, a simple ``policy`` like ``lambda n: "X_" + n`` may produce duplicate outputs. Prefer ``name_fn`` that uses a unique attribute (e.g., the file stem) to avoid collisions. - The function preserves input order and is no-throw; items that cannot be renamed are skipped. Examples -------- Policy-based rename for all items: >>> from pycsamt.site.edit import rename_all >>> out = rename_all(eds, policy=lambda n: f"X_{n}") Unique names from file stems: >>> from pathlib import Path >>> out = rename_all( ... eds, ... name_fn=lambda e: f"X_{Path(getattr(e,'path','')).stem}", ... ) See Also -------- rename : Single-site rename helper. set_coords_all : Batch coordinate assignment. pycsamt.site.base.Sites : Collection wrapper used here. References ---------- .. [1] SEG EDI format field naming conventions for station identifiers. """ items = [] for ed, _ in _each_site(sites): cur = station_name(ed) new = name_fn(ed) if name_fn else (policy(cur) if policy else cur) items.append(rename(ed, name=str(new), inplace=False)) return _wrap_output(sites, items, inplace=inplace)
[docs] def set_coords_all( sites: Any, src: Any, *, inplace: bool = False, ): r""" Batch set coordinates for a collection of sites. Coordinates can be provided by a callable, a mapping keyed by station name, or an object exposing a ``.frame`` attribute with tabular data. Parameters ---------- sites : Any A :class:`pycsamt.site.base.Sites` instance or any iterable of EDI-like objects. src : Any One of: * ``callable(edi) -> (lat, lon, elev)`` * ``mapping[name] -> (lat, lon, elev)`` * object with ``.frame`` DataFrame-like with columns: ``station`` plus either ``lat``/``lon`` or ``latitude``/``longitude``, and optionally ``elev``/``elevation``. inplace : bool, optional If ``True``, attempt to modify the given container in place. Otherwise, return a new ``Sites``. Default is ``False``. Returns ------- pycsamt.site.base.Sites A collection with updated coordinates, or the original container when mutated in place. Notes ----- - The lookup order is: ``callable`` then ``mapping`` by station name, then the optional ``.frame`` table. The first source that returns a non-``None`` triple is used. - Latitude and longitude are expected in degrees; elevation in meters. - The function preserves input order and is no-throw; items without a matching entry are left unchanged. Examples -------- From a mapping keyed by names: >>> from pycsamt.site.edit import set_coords_all >>> coords = {"S01": (35.1, 12.8, 1234.0)} >>> out = set_coords_all(eds, coords) From a callable using file stems: >>> from pathlib import Path >>> def pick(edi): ... stem = Path(getattr(edi, "path", "")).stem ... return (10.0, 20.0, 100.0) if stem == "S01" else \ ... (11.0, 21.0, 110.0) >>> out = set_coords_all(eds, pick) From a pandas DataFrame holder: >>> class Holder: ... def __init__(self, frame): ... self.frame = frame >>> out = set_coords_all(eds, Holder(df)) See Also -------- set_coords : Single-site coordinate update. rename_all : Batch rename helper. pycsamt.site.base.Site.set_coords : OOP variant per site. References ---------- .. [1] SEG EDI HEAD section fields for station coordinates. """ def _lookup_by_frame(name: str): fr = getattr(src, "frame", None) if fr is None: return None try: col = "station" if "station" in fr.columns else None if not col: return None row = fr[fr[col] == name] if row is None or len(row) == 0: return None la = float( row["lat"].iloc[0] if "lat" in row else row["latitude"].iloc[0] ) if "lon" in row: lo = float(row["lon"].iloc[0]) else: lo = float(row["longitude"].iloc[0]) ev = float(row.get("elev", row.get("elevation", 0.0)).iloc[0]) return la, lo, ev except Exception: return None items = [] for ed, _ in _each_site(sites): nm = station_name(ed) lat = lon = elv = None if callable(src): try: v = src(ed) if v is not None: lat, lon, elv = v except Exception: pass elif hasattr(src, "get"): try: v = src.get(nm) if v is not None: lat, lon, elv = v except Exception: pass else: v = _lookup_by_frame(nm) if v is not None: lat, lon, elv = v items.append( set_coords( ed, lat=lat, lon=lon, elev=elv, inplace=False, ) ) return _wrap_output(sites, items, inplace=inplace)
[docs] def recompute_res_phase( site: Any, *, inplace: bool = False, ) -> Any: r""" Recompute apparent resistivity and phase from the impedance tensor Z for a single site. The function looks for a ``Z`` section and, if present, calls its ``compute_resistivity_phase()`` method. The operation is best-effort and suppresses exceptions. Parameters ---------- site : Any An EDI-like object (``EDIFile``) or a wrapper exposing a ``Z`` section with the expected API. inplace : bool, optional If ``True``, mutate the given object in place. Otherwise work on a shallow copy and return it. Default is ``False``. Returns ------- Any The mutated object (in place) or a new object (copy). Notes ----- - Derived quantities are typically written under common aliases (e.g., ``rho`` or ``resistivity`` for apparent resistivity in ohm-m, and ``phase`` in degrees). - The method assumes Z has shape ``(n, 2, 2)`` and that a frequency vector is present. If these are missing or incompatible, nothing is changed. Examples -------- Single-site: >>> from pycsamt.seg.edi import EDIFile >>> from pycsamt.site.edit import recompute_res_phase >>> ed = EDIFile("path/to/station.edi") >>> ed2 = recompute_res_phase(ed) In place: >>> _ = recompute_res_phase(ed, inplace=True) See Also -------- select_freq : Keep a subset of rows by frequency. fill_missing : Ensure arrays are allocated and finite before recomputation. pycsamt.site.base.Site.to_dataframe : Inspect derived arrays. References ---------- .. [1] Simpson, F. and Bahr, K. (2005). Practical Magnetotellurics. Cambridge Univ. Press. .. [2] SEG EDI format usage notes for derived MT quantities. """ ed = _to_mutable(site, inplace=inplace) try: z = getattr(ed, "Z", None) if z is None: return ed fn = getattr(z, "compute_resistivity_phase", None) if callable(fn): try: # All args are optional in ResPhase API. fn() except TypeError: # Some forks might require explicit Nones. try: fn(None, None, None) except Exception: pass except: # Best-effort: never raise here pass return ed
[docs] def set_coords_from_table( sites: Any, table: Any, *, columns: dict | None = None, crs_from: str | None = None, to_crs: str = "EPSG:4326", inplace: bool = False, ): r""" Set site coordinates for many EDI files from a table. This high-level helper accepts a wide range of table-like objects (CSV path, pandas DataFrame, numpy structured array, or list of dicts / tuples). It normalizes column names, optionally projects easting/northing to lon/lat, builds a mapping ``{station: (lat, lon, elev)}``, and delegates to :func:`set_coords_all`. Parameters ---------- sites : Any A :class:`~pycsamt.site.base.Sites` instance, an iterable of ``EDIFile`` objects, or anything accepted by :func:`set_coords_all`. table : Any One of: - Path to a CSV or whitespace-delimited text file. - A ``pandas.DataFrame``. - A numpy structured array. - A list of dicts or a list of tuples. columns : dict, optional Explicit column mapping. Keys are canonical names and values are the actual column names present in ``table``. Supported canonical keys are: ``'station'``, ``'lat'``, ``'lon'``, ``'elev'``, ``'easting'``, ``'northing'``. Matching is case-insensitive. crs_from : str, optional Source CRS used when the table provides ``easting`` and ``northing`` instead of ``lat`` and ``lon``. Required in that case. Example: ``'EPSG:32631'`` for UTM 31N. to_crs : str, default "EPSG:4326" Target CRS for output coordinates. The default is WGS84 lon/lat. inplace : bool, default False If ``True``, mutate the given collection and return it. Otherwise return a new :class:`~pycsamt.site.base.Sites` with updated EDI objects. Returns ------- Any The same semantics as :func:`set_coords_all`: either the mutated input (``inplace=True``) or a new :class:`~pycsamt.site.base.Sites` instance. Raises ------ ValueError If the ``station`` column cannot be resolved, or if neither (``lat``, ``lon``) nor (``easting``, ``northing``) can be resolved, or if ``crs_from`` is required but missing. ImportError If projection is needed (``easting``/``northing`` present) but ``pyproj`` is not installed. Notes ----- Column detection is case-insensitive and understands common aliases: - ``station``: ``station``, ``name``, ``site``, ``id``. - ``lat``: ``lat``, ``latitude``. - ``lon``: ``lon``, ``long``, ``longitude``. - ``elev``: ``elev``, ``elevation``, ``z``. - ``easting``: ``easting``, ``x``. - ``northing``: ``northing``, ``y``. When both geographic and projected fields are present, the geographic pair (``lat``, ``lon``) is preferred. If only projected fields are present, a valid ``crs_from`` must be provided and :mod:`pyproj` will be used for projection. Examples -------- Load from a CSV path with standard columns:: >>> from pycsamt.site.edit import set_coords_from_table >>> from pycsamt.site.base import Sites >>> edis = Sites([...]) # your EDI files >>> out = set_coords_from_table( ... edis, "coords.csv", inplace=False ... ) >>> isinstance(out, Sites) True Pass a DataFrame with aliases and an explicit mapping:: >>> import pandas as pd >>> df = pd.DataFrame({ ... "name": ["S01", "S02"], ... "latitude": [35.1, 35.2], ... "long": [12.8, 12.9], ... "elevation": [120.0, 130.0], ... }) >>> out = set_coords_from_table( ... edis, ... df, ... columns={"station": "name", ... "lat": "latitude", ... "lon": "long", ... "elev": "elevation"}, ... inplace=False, ... ) Use easting/northing with a source CRS:: >>> df = pd.DataFrame({ ... "station": ["S10"], ... "easting": [400000.0], ... "northing": [5750000.0], ... }) >>> out = set_coords_from_table( ... edis, ... df, ... crs_from="EPSG:32631", # UTM 31N ... inplace=False, ... ) See Also -------- set_coords_all : Broadcast setting of coordinates using a mapping. set_coords : Single-site coordinate update helper. References ---------- .. [1] EPSG Geodetic Parameter Registry, https://epsg.org/ .. [2] pyproj documentation, https://pyproj4.github.io/pyproj/ """ df, cols = _maybe_df(table, columns=columns) mapping = _frame_to_mapping(df, cols, crs_from=crs_from, to_crs=to_crs) return set_coords_all(sites, mapping, inplace=inplace)
[docs] def set_coords_from_en( site: Any, easting: float, northing: float, *, crs_from: str, elev: float | None = None, to_crs: str = "EPSG:4326", inplace: bool = False, ) -> Any: r""" Project (easting, northing) to lon/lat and set a site's coords. This convenience wraps projection and assignment for a single EDI site. It projects the provided easting/northing from ``crs_from`` to ``to_crs`` (default WGS84 lon/lat), then calls :func:`set_coords`. Parameters ---------- site : Any A single EDI-like object compatible with :func:`set_coords`. easting : float Easting value in meters for the source CRS. northing : float Northing value in meters for the source CRS. crs_from : str The EPSG or PROJ string that identifies the source CRS, for example ``'EPSG:32631'`` for UTM 31N. elev : float, optional Elevation in meters to store in the EDI header. If not provided, the previous elevation is kept (if any). to_crs : str, default "EPSG:4326" Target CRS for output coordinates. The default is WGS84 lon/lat. inplace : bool, default False If ``True``, mutate the given object and return it. Otherwise work on a copy and return the copy. Returns ------- Any The mutated site (``inplace=True``) or a new site object with updated coordinates. Raises ------ ImportError If :mod:`pyproj` is not installed. Exception Any errors raised by the underlying projection engine or by :func:`set_coords` may propagate. Notes ----- Projection uses :mod:`pyproj` with ``always_xy=True`` so that the axis order is interpreted as (lon, lat). Units are assumed to be meters for the easting and northing values. The returned object follows the same semantics as :func:`set_coords`. If you need to update many sites, prefer :func:`set_coords_from_table` or :func:`set_coords_all`. Examples -------- Update a single site from UTM 31N (EPSG:32631) coordinates:: >>> from pycsamt.site.edit import set_coords_from_en >>> site = ... # an EDIFile >>> site2 = set_coords_from_en( ... site, ... easting=400000.0, ... northing=5750000.0, ... crs_from="EPSG:32631", ... elev=250.0, ... inplace=False, ... ) Do the update in place and keep the existing elevation:: >>> _ = set_coords_from_en( ... site, ... easting=500000.0, ... northing=4600000.0, ... crs_from="EPSG:32630", ... inplace=True, ... ) See Also -------- set_coords : Assign lat, lon, elev on a single site. set_coords_from_table : Batch update from tabular input. set_coords_all : Broadcast update using a mapping. References ---------- .. [1] EPSG Geodetic Parameter Registry, https://epsg.org/ .. [2] pyproj documentation, https://pyproj4.github.io/pyproj/ """ lo, la = _project_en_to_lonlat( np.asarray([easting], float), np.asarray([northing], float), crs_from, to_crs=to_crs, ) return set_coords( site, lat=float(la[0]), lon=float(lo[0]), elev=(None if elev is None else float(elev)), inplace=inplace, )
def _to_mutable(ed: Any, *, inplace: bool) -> Any: return ed if inplace else maybe_copy(ed) def _rotm(angle_deg: float) -> tuple[np.ndarray, np.ndarray]: th = float(angle_deg) * (math.pi / 180.0) c = math.cos(th) s = math.sin(th) r = np.array([[c, s], [-s, c]], dtype=float) rinv = np.array([[c, -s], [s, c]], dtype=float) return r, rinv def _slice_fields(obj: Any, sl: Any) -> None: groups = [ ("freq", ("freq", "frequency", "_freq")), ("z", ("z", "impedance", "_z")), ("z_error", ("z_error", "z_err", "impedance_err", "_z_err")), ("rho", ("rho", "res", "resistivity", "_resistivity")), ("phase", ("phase", "phi", "_phase")), ("tipper", ("tipper", "tip", "_tipper")), ("tipper_err", ("tipper_err", "_tipper_err")), ("amp", ("amp", "_amp")), ("azimuth", ("azimuth", "_azimuth")), ] for _, names in groups: for nm in names: a = getattr(obj, nm, None) if a is None: continue try: arr = np.asarray(a) setattr(obj, nm, arr[sl]) except Exception: # keep going to other fields pass def _maybe_df(table: Any, columns: dict | None = None): """ Coerce many table-like inputs to a small DataFrame with columns for station and coordinates. Returns (df, cols) where cols is a dict with resolved canonical names: {'station','lat','lon','elev'} or {'station','easting', 'northing','elev'} when lat/lon are absent. """ cols = { "station": None, "lat": None, "lon": None, "elev": None, "easting": None, "northing": None, } def _pick(name: str, choices: list[str]) -> str | None: for c in choices: if c in df.columns: return c return None # 1) import pandas lazily # 2) Build a DataFrame if isinstance(table, (str, Path)): p = Path(table) try: df = pd.read_csv(p) except Exception: # whitespace-delimited fallback df = pd.read_csv(p, delim_whitespace=True) elif pd is not None and isinstance(table, pd.DataFrame): df = table.copy() else: # try numpy structured or list[dict]/list[tuple] try: df = pd.DataFrame(table) except Exception as exc: raise TypeError( "Unsupported table-like input for coordinates." ) from exc # normalize column names to lower for matching df.columns = [str(c).strip() for c in df.columns] lowmap = {c.lower(): c for c in df.columns} df.columns = list(lowmap.keys()) # 3) If explicit mapping provided, honor it if columns: for k, v in columns.items(): k0 = str(k).lower() v0 = str(v).lower() if v0 in df.columns and k0 in cols: cols[k0] = v0 # 4) Auto-detect columns when not provided if cols["station"] is None: cols["station"] = _pick("station", ["station", "name", "site", "id"]) if cols["lat"] is None and cols["easting"] is None: cols["lat"] = _pick("lat", ["lat", "latitude"]) if cols["lon"] is None and cols["easting"] is None: cols["lon"] = _pick("lon", ["lon", "long", "longitude"]) if cols["easting"] is None and cols["lat"] is None: cols["easting"] = _pick("easting", ["easting", "x"]) if cols["northing"] is None and cols["lat"] is None: cols["northing"] = _pick("northing", ["northing", "y"]) if cols["elev"] is None: cols["elev"] = _pick("elev", ["elev", "elevation", "z"]) if cols["station"] is None: raise ValueError( "Could not resolve 'station' column. Provide " "`columns={'station':'...'} or standard name." ) # keep only the relevant columns keep = [v for v in cols.values() if v is not None] df = df.loc[:, sorted(set(keep))].copy() return df, cols def _project_en_to_lonlat( e: np.ndarray, n: np.ndarray, crs_from: str, to_crs: str = "EPSG:4326", ) -> tuple[np.ndarray, np.ndarray]: """ Project easting/northing to lon/lat using pyproj if present. """ try: from pyproj import Transformer # type: ignore except Exception as exc: raise ImportError( "pyproj is required for easting/northing projection. " "Install pyproj or pre-convert to lon/lat." ) from exc tr = Transformer.from_crs(crs_from, to_crs, always_xy=True) lon, lat = tr.transform(e, n) return np.asarray(lon, float), np.asarray(lat, float) def _frame_to_mapping( df: Any, cols: dict, *, crs_from: str | None = None, to_crs: str = "EPSG:4326", ) -> dict[str, tuple[float, float, float]]: """ Return mapping: name -> (lat, lon, elev) from a frame. """ namec = cols["station"] elevc = cols["elev"] if cols["lat"] and cols["lon"]: latv = np.asarray(df[cols["lat"]], float) lonv = np.asarray(df[cols["lon"]], float) elif cols["easting"] and cols["northing"]: if not crs_from: raise ValueError( "Found easting/northing but `crs_from` not " "provided. Set crs_from='EPSG:32632' (example) or " "pre-convert to lon/lat." ) ev = np.asarray(df[cols["easting"]], float) nv = np.asarray(df[cols["northing"]], float) lonv, latv = _project_en_to_lonlat(ev, nv, crs_from, to_crs=to_crs) else: raise ValueError("Could not resolve lat/lon nor easting/northing.") if elevc and elevc in df.columns: elv = np.asarray(df[elevc], float) else: elv = np.full(latv.size, np.nan, float) names = [str(x) for x in df[namec].astype(str).tolist()] out: dict[str, tuple[float, float, float]] = {} for nm, la, lo, ev in zip(names, latv, lonv, elv): out[nm] = (float(la), float(lo), float(ev)) return out def _get_attr_any(obj: Any, *names: str) -> Any: for n in names: v = getattr(obj, n, None) if v is not None: return v return None def _set_attr_first(obj: Any, names: Iterable[str], val: Any): for n in names: try: setattr(obj, n, val) return except Exception: pass # fallback: attach anyway on the first name names = list(names) if names: try: setattr(obj, names[0], val) except Exception: pass def _each_site(container: Any): try: from .base import Sites # lazy import if isinstance(container, Sites): for i, s in enumerate(getattr(container, "_items", [])): yield getattr(s, "edi", s), i return except Exception: pass for i, ed in enumerate(iter_edifiles(container)): yield ed, i def _wrap_output(src: Any, items: list[Any], *, inplace: bool) -> Any: if inplace: try: from .base import Sites if isinstance(src, Sites): for (s, _), ne in zip(_each_site(src), items): # preserve Site wrappers, swap underlying EDI s.edi = ne return src except Exception: pass try: from .base import Sites return Sites(items) except Exception: return items def _fill_array(a: Any, shape: tuple[int, ...], how: str) -> np.ndarray: if a is None: if how == "zero": return np.zeros(shape, float) return np.full(shape, np.nan, float) out = np.array(a, copy=True) if how == "zero": out = np.nan_to_num(out, nan=0.0, posinf=0.0, neginf=0.0) else: out[~np.isfinite(out)] = np.nan return out