# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
from __future__ import annotations
import math
import re
from collections.abc import Iterable
from typing import Any, Callable
import numpy as np
from .utils import (
get_coords,
get_freq,
iter_edifiles,
match_name,
station_name,
)
__all__ = [
"by_names",
"by_index",
"by_chainage",
"by_freq",
"by_bbox",
"by_predicate",
"keep_finite_z",
"mask_large_phase_err",
"drop_empty",
]
[docs]
def by_names(
sites: Any,
patterns: Iterable[Any] | Any,
*,
case: bool = False,
):
r"""
Select sites by matching station names against one or more
patterns.
This is a flexible name-based selector that accepts several
pattern types:
* string with optional glob-like wildcards ``*`` and ``?``
* compiled regular expression (``re.Pattern``)
* callable ``fn(name)->bool``
* iterable of any mix of the above
A site is kept if **any** pattern matches its station name.
Matching is stable: the relative order of kept sites is the
same as in the input.
Parameters
----------
sites : Any
A :class:`~pycsamt.site.base.Sites` instance, an
``EDICollection``, a sequence of ``EDIFile`` objects,
or any iterable yielding EDI-like objects.
patterns : Iterable[Any] or Any
One pattern or an iterable of patterns. See the list
above for supported pattern types.
case : bool, optional
If ``True``, perform case-sensitive matching. If
``False`` (default) names and string patterns are
upper-cased before comparison.
Returns
-------
pycsamt.site.base.Sites
A new ``Sites`` wrapper containing only the matched
EDI items. The original container is not modified.
Notes
-----
String patterns support a minimal glob syntax. ``*`` matches
any sequence (possibly empty) and ``?`` matches any single
character. If you need full regular expressions, pass a
compiled ``re.Pattern``.
When multiple patterns are given, the match is an OR over all
patterns. Matching uses the station name as returned by
``station_name(ed)`` which reflects header normalization.
Examples
--------
>>> from pycsamt.site.base import Sites
>>> from pycsamt.site.selection import by_names
>>> sites = Sites([e1, e2, e3]) # EDIFile objects
>>> out = by_names(sites, "K*") # glob: all names starting K
>>> [s.name for s in out]
['K01', 'K02']
>>> import re
>>> rx = re.compile(r"^S0[1-3]$")
>>> out = by_names(sites, rx)
>>> [s.name for s in out]
['S01', 'S02', 'S03']
>>> out = by_names(sites, lambda n: n.endswith("A"))
>>> [s.name for s in out]
['X1A', 'X2A']
See Also
--------
pycsamt.site.selection.by_index :
Select by numeric positions.
pycsamt.site.selection.by_predicate :
Keep sites for which a boolean predicate returns True.
pycsamt.site.selection.by_freq :
Keep sites that contain data within a frequency window.
References
----------
.. [1] Python re module documentation.
.. [2] Unix shell-style wildcards (glob) convention.
"""
s = _to_sites(sites)
pats = patterns if isinstance(patterns, (list, tuple)) else [patterns]
keep = []
for ed in iter_edifiles(s.edic):
nm = station_name(ed)
ok = any(_name_matches(nm, p, case) for p in pats)
if ok:
keep.append(ed)
return _new_sites(s, keep)
[docs]
def by_index(sites: Any, indices: Iterable[int] | int):
r"""
Select sites by zero-based numeric indices, supporting negative
indices.
Indices are normalized exactly like Python sequence indexing:
``-1`` addresses the last item, ``-2`` the one before last,
and so on. Out-of-range or non-integer indices are ignored.
The resulting subset preserves the original ordering of the
selected items, not the order in which indices are provided.
Parameters
----------
sites : Any
A :class:`~pycsamt.site.base.Sites` instance, an
``EDICollection``, a sequence of ``EDIFile`` objects,
or any iterable yielding EDI-like objects.
indices : Iterable[int] or int
One index or an iterable of indices. Negative indices
are supported and mapped to their Python equivalents.
Returns
-------
pycsamt.site.base.Sites
A new ``Sites`` wrapper containing only items at the
requested positions. If no valid indices remain after
normalization, an empty ``Sites`` is returned.
Notes
-----
Duplicate indices are de-duplicated in the output since the
selection is implemented as a membership test over the set
of normalized indices. The relative order of kept items is
the same as in the original sequence.
Examples
--------
>>> from pycsamt.site.base import Sites
>>> from pycsamt.site.selection import by_index
>>> sites = Sites([e1, e2, e3]) # names: A, B, C
>>> out = by_index(sites, [0, -1]) # first and last
>>> [s.name for s in out]
['A', 'C']
>>> out = by_index(sites, 1) # single integer
>>> [s.name for s in out]
['B']
>>> out = by_index(sites, [10, -10]) # both invalid -> empty
>>> len(out)
0
See Also
--------
pycsamt.site.selection.by_names :
Name-based matching using strings, regex, or callables.
pycsamt.site.selection.by_chainage :
Select by stored chainage range when available.
pycsamt.site.base.Sites.by_index :
Random access to a single site by position.
"""
s = _to_sites(sites)
idxs = indices if isinstance(indices, (list, tuple)) else [indices]
# coerce to valid indices with Python-like negative support
all_items = list(iter_edifiles(s.edic))
n = len(all_items)
norm: list[int] = []
for i in idxs:
if not isinstance(i, int):
continue
j = i if i >= 0 else (n + i)
if 0 <= j < n:
norm.append(j)
if not norm:
return _new_sites(s, [])
keep = [e for k, e in enumerate(all_items) if k in set(norm)]
return _new_sites(s, keep)
[docs]
def by_chainage(sites: Any, smin: float, smax: float):
r"""
Select sites whose stored chainage falls within a closed
interval.
This helper reads the chainage value first from the EDI
``HEAD`` section (``head.chainage``) and, if missing, from a
top-level attribute ``edi.chainage``. Sites for which a
numeric chainage cannot be determined are silently skipped.
Parameters
----------
sites : Any
A :class:`~pycsamt.site.base.Sites` instance, an
``EDICollection``, a sequence of ``EDIFile`` objects, or
any iterable yielding EDI-like items.
smin : float
Minimum chainage (inclusive).
smax : float
Maximum chainage (inclusive).
Returns
-------
pycsamt.site.base.Sites
A new ``Sites`` wrapper containing only the EDI items
whose chainage :math:`c` satisfies
:math:`smin \\le c \\le smax`.
Notes
-----
Chainage is a linear reference commonly used along profiles
or lines, typically measured in meters from a chosen origin.
If chainage is not present on a site, that site is excluded.
The original order of sites is preserved among the kept
items.
Examples
--------
>>> from pycsamt.site.base import Sites
>>> from pycsamt.site.selection import by_chainage
>>> s = Sites([e1, e2, e3]) # EDIFile objects
>>> out = by_chainage(s, smin=100.0, smax=300.0)
>>> [t.name for t in out]
['L02', 'L03']
See Also
--------
pycsamt.site.selection.by_index :
Select by zero-based positions with negative support.
pycsamt.site.selection.by_names :
Select by station names using glob, regex, or callables.
pycsamt.site.selection.by_freq :
Keep sites that contain data in a frequency window.
pycsamt.site.base.Sites.to_profile :
Build a profile or ordered view along a line.
References
----------
.. [1] Linear referencing and chainage in civil engineering.
"""
s = _to_sites(sites)
keep = []
for ed in iter_edifiles(s.edic):
h = getattr(ed, "get_section", lambda *_: None)("head")
ch = getattr(h, "chainage", None) if h else None
if ch is None:
ch = getattr(ed, "chainage", None)
try:
v = float(ch)
except:
continue
if smin <= v <= smax:
keep.append(ed)
return _new_sites(s, keep)
[docs]
def by_freq(sites: Any, fmin: float, fmax: float):
r"""
Select sites that contain at least one data row with
frequency inside a closed interval.
A site is kept if its frequency array ``f`` contains any
finite value satisfying :math:`fmin \le f \le fmax`. Sites
with empty or non-finite frequency arrays are skipped.
Parameters
----------
sites : Any
A :class:`~pycsamt.site.base.Sites` instance, an
``EDICollection``, a sequence of ``EDIFile`` objects, or
any iterable yielding EDI-like items.
fmin : float
Minimum frequency (inclusive).
fmax : float
Maximum frequency (inclusive).
Returns
-------
pycsamt.site.base.Sites
A new ``Sites`` wrapper containing only the EDI items with
at least one finite frequency in the requested window.
Notes
-----
Frequencies are obtained via
``pycsamt.site.utils.get_freq(ed)``. The check is membership
based (any row in range), not a full slicing or resampling.
Use :func:`pycsamt.site.edit.select_freq` to actually subset
rows by frequency.
Examples
--------
>>> from pycsamt.site.base import Sites
>>> from pycsamt.site.selection import by_freq
>>> s = Sites([e1, e2, e3]) # EDIFile objects
>>> out = by_freq(s, fmin=0.5, fmax=2.0)
>>> [t.name for t in out]
['A02', 'A03']
See Also
--------
pycsamt.site.selection.by_names :
Name-based selection using glob, regex, or callables.
pycsamt.site.selection.by_chainage :
Select by stored chainage range when available.
pycsamt.site.edit.select_freq :
Subset frequency rows within sites.
"""
s = _to_sites(sites)
keep = []
for ed in iter_edifiles(s.edic):
f = get_freq(ed)
if f.size == 0:
continue
m = np.isfinite(f) & (f >= fmin) & (f <= fmax)
if bool(m.any()):
keep.append(ed)
return _new_sites(s, keep)
[docs]
def by_bbox(
sites: Any,
minlat: float,
minlon: float,
maxlat: float,
maxlon: float,
):
r"""
Select sites that fall inside an axis-aligned geographic box.
The selection is performed in latitude/longitude degrees and
assumes a geographic CRS (WGS84-like). A site is kept if its
stored coordinates satisfy
.. math::
minlat \le lat \le maxlat \;\;\text{and}\;\;
minlon \le lon \le maxlon .
Parameters
----------
sites : Any
A :class:`~pycsamt.site.base.Sites` instance, an
``EDICollection``, a sequence of ``EDIFile`` objects, or
any iterable yielding EDI-like items.
minlat, minlon, maxlat, maxlon : float
Latitude and longitude bounds in degrees. Bounds are
inclusive.
Returns
-------
pycsamt.site.base.Sites
A new ``Sites`` wrapper with only the items whose coords
are inside the box.
Notes
-----
This is a simple axis-aligned test in lat/lon and does not
handle antimeridian wrapping. If longitudes cross the
antimeridian (for example, from 170 to -170 deg), split the
selection into two boxes and union the results. Coordinates
are retrieved via
:func:`pycsamt.site.utils.get_coords`.
Examples
--------
>>> from pycsamt.site.base import Sites
>>> from pycsamt.site.selection import by_bbox
>>> s = Sites([e1, e2, e3]) # EDIFile objects
>>> out = by_bbox(s, 24.0, 9.0, 27.0, 11.0)
>>> [site.name for site in out]
['S01', 'S03']
See Also
--------
pycsamt.site.selection.by_freq :
Keep sites with at least one frequency inside a window.
pycsamt.site.selection.by_chainage :
Select by stored chainage range.
pycsamt.site.selection.by_predicate :
Arbitrary user-defined filtering.
pycsamt.site.base.Sites.closest :
Find the closest site to a target coordinate.
References
----------
.. [1] Snyder, J. P., "Map Projections: A Working Manual",
USGS Professional Paper 1395.
"""
s = _to_sites(sites)
keep = []
for ed in iter_edifiles(s.edic):
c = get_coords(ed)
if _in_box(c.lat, c.lon, minlat, minlon, maxlat, maxlon):
keep.append(ed)
return _new_sites(s, keep)
[docs]
def by_predicate(sites: Any, pred: Callable[[Any], bool]):
r"""
Select sites using a user-supplied predicate function.
The predicate is called for each EDI-like object and should
return ``True`` to keep the site. Any exception raised by the
predicate is caught and treated as a ``False`` (site is not
kept). This makes bulk filtering robust against occasional
data issues.
Parameters
----------
sites : Any
A :class:`~pycsamt.site.base.Sites` instance, an
``EDICollection``, a sequence of ``EDIFile`` objects, or
any iterable yielding EDI-like items.
pred : Callable[[Any], bool]
Function receiving a single EDI-like object and returning
a boolean.
Returns
-------
pycsamt.site.base.Sites
A new ``Sites`` wrapper containing only the sites for
which ``pred(site)`` returned ``True``.
Notes
-----
The objects passed to ``pred`` are the raw EDI containers, not
the :class:`~pycsamt.site.base.Site` wrapper. If you prefer
the wrapper API, wrap the object inside the predicate:
``lambda ed: Site(ed).has_component("Zxy")``.
Examples
--------
Keep sites that have at least 3 frequency rows:
>>> from pycsamt.site.base import Sites, Site
>>> from pycsamt.site.selection import by_predicate
>>> s = Sites([e1, e2, e3])
>>> out = by_predicate(
... s, lambda ed: (Site(ed).freq is not None and
... len(Site(ed).freq) >= 3)
... )
>>> [t.name for t in out]
['A01', 'A03']
Keep sites whose name matches a rule:
>>> import re
>>> from pycsamt.site.utils import station_name
>>> rule = re.compile(r"^X_")
>>> out = by_predicate(s, lambda ed: bool(rule.search(
... station_name(ed))))
>>> [t.name for t in out]
['X_E01', 'X_E02']
See Also
--------
pycsamt.site.selection.by_names :
Name-based selection with glob or regex patterns.
pycsamt.site.selection.drop_empty :
Remove sites with no usable data arrays.
pycsamt.site.base.Sites.select :
Selection API on the wrapper.
References
----------
.. [1] Gamble, T. D. et al., "Magnetotellurics with a remote
reference", Geophysics, 44(1), 53-68, 1979.
"""
s = _to_sites(sites)
keep = []
for ed in iter_edifiles(s.edic):
try:
if pred(ed):
keep.append(ed)
except Exception:
pass
return _new_sites(s, keep)
[docs]
def keep_finite_z(sites: Any):
r"""
Keep sites that contain at least one finite impedance value.
A site is considered to have finite data if either of the
following is true:
1. The impedance tensor array (``Z.z`` or ``Z._z``) contains
any finite real or imaginary entry.
2. If the tensor is not present, a resistivity array
(``Z._resistivity`` or ``Z.rho``) exists and has at least
one finite value.
Parameters
----------
sites : Any
A :class:`~pycsamt.site.base.Sites` instance, an
``EDICollection``, a sequence of ``EDIFile`` objects, or
any iterable yielding EDI-like items.
Returns
-------
pycsamt.site.base.Sites
A new ``Sites`` wrapper with only the sites that contain
finite impedance (or resistivity) values.
Notes
-----
This function is intended as a coarse pre-filter to remove
empty placeholders and fully invalid sites before more costly
processing. It does not inspect errors or phases, and it does
not modify the data. If a site has a ``Z`` container but all
arrays are missing or fully non-finite, the site is dropped.
Examples
--------
>>> from pycsamt.site.base import Sites
>>> from pycsamt.site.selection import keep_finite_z
>>> s = Sites([e1, e2, e3])
>>> out = keep_finite_z(s)
>>> [t.name for t in out]
['MT01', 'MT03']
See Also
--------
pycsamt.site.selection.drop_empty :
Remove sites with empty frequency or missing Z section.
pycsamt.site.edit.fill_missing :
Allocate arrays and replace invalid entries.
pycsamt.site.compute.res_at_freq :
Compute apparent resistivity at a specific frequency.
"""
s = _to_sites(sites)
keep = [ed for ed in iter_edifiles(s.edic) if _any_finite_z(ed)]
return _new_sites(s, keep)
[docs]
def mask_large_phase_err(sites: Any, thresh: float):
r"""
Filter out sites whose maximum phase-error exceeds a threshold.
For each site, the function inspects the phase-error array
when present (common attribute names are tried, e.g.
``_phase_err`` or ``phase_err``). If no phase-error array is
found, the site is conservatively **kept**. Otherwise, the
site is kept only when the maximum finite phase-error is less
than or equal to ``thresh``.
Parameters
----------
sites : Any
A :class:`~pycsamt.site.base.Sites` object, an
``EDICollection``, a sequence of ``EDIFile`` objects, or
any iterable yielding EDI-like items.
thresh : float
Threshold on phase-error (same units as stored by the
processing pipeline, usually degrees).
Returns
-------
pycsamt.site.base.Sites
New wrapper containing only sites that pass the phase
error test.
Notes
-----
The check uses a "best effort" attribute lookup and ignores
non-finite values during the maximum computation. If the
phase-error array is entirely missing, the site is kept.
This behavior makes the filter robust when some sites did not
store uncertainty products.
Examples
--------
>>> from pycsamt.site.base import Sites
>>> from pycsamt.site.selection import mask_large_phase_err
>>> s = Sites([e1, e2, e3])
>>> out = mask_large_phase_err(s, thresh=10.0)
>>> [t.name for t in out]
['E01', 'E03']
See Also
--------
pycsamt.site.selection.keep_finite_z :
Keep sites that contain at least one finite impedance.
pycsamt.site.selection.drop_empty :
Remove sites with no usable arrays.
pycsamt.site.edit.fill_missing :
Allocate arrays and replace invalid entries.
References
----------
.. [1] Gamble, T. D., Goubau, W. M., Clarke, J., "Magneto-
tellurics with a remote reference", Geophysics,
44(1), 53-68, 1979.
"""
s = _to_sites(sites)
keep = []
for ed in iter_edifiles(s.edic):
m = _max_phase_err(ed)
if not math.isfinite(m):
keep.append(ed)
elif m <= float(thresh):
keep.append(ed)
return _new_sites(s, keep)
[docs]
def drop_empty(sites: Any):
r"""
Drop sites that are effectively empty.
A site is considered empty when either:
* The frequency vector is missing or has zero length.
* The impedance container ``Z`` is missing.
* The ``Z`` container is present but holds no usable arrays
(for example, no ``z`` and no resistivity surrogate).
Parameters
----------
sites : Any
A :class:`~pycsamt.site.base.Sites` object, an
``EDICollection``, a sequence of ``EDIFile`` objects, or
any iterable yielding EDI-like items.
Returns
-------
pycsamt.site.base.Sites
New wrapper that excludes empty sites.
Notes
-----
This is a coarse, fast filter that checks structural
presence and basic array availability. It does **not** test
for NaN-only content; for that, consider
:func:`pycsamt.site.selection.keep_finite_z`.
Examples
--------
>>> from pycsamt.site.base import Sites
>>> from pycsamt.site.selection import drop_empty
>>> s = Sites([e1, e2, e3])
>>> out = drop_empty(s)
>>> [t.name for t in out]
['MT01', 'MT02']
See Also
--------
pycsamt.site.selection.keep_finite_z :
Keep only sites with finite impedance or resistivity.
pycsamt.site.selection.by_freq :
Keep sites that touch a target frequency window.
"""
s = _to_sites(sites)
keep = [ed for ed in iter_edifiles(s.edic) if not _is_empty_site(ed)]
return _new_sites(s, keep)
# ------------- Internal helpers --------------------------
def _to_sites(x: Any):
"""Coerce any edi-like into a Sites wrapper."""
from .base import _to_sites as __to_sites
return __to_sites(x)
def _new_sites(src: Any, items: list[Any]):
"""Build a new Sites preserving wrapper semantics."""
from .base import Sites # lazy import
try:
return Sites(items)
except TypeError:
return Sites(edic=items)
def _in_box(
lat: float,
lon: float,
mnla: float,
mnlo: float,
mxla: float,
mxlo: float,
) -> bool:
if not (math.isfinite(lat) and math.isfinite(lon)):
return False
return (mnla <= lat <= mxla) and (mnlo <= lon <= mxlo)
def _name_matches(nm: str, pat: Any, case: bool) -> bool:
# Prefer utils.match_name for DRY; keep a tiny fallback
try:
if not case and isinstance(pat, str):
# match_name is already case-insensitive
return match_name(pat, nm)
if callable(pat) or isinstance(pat, re.Pattern):
return match_name(pat, nm)
return match_name(str(pat), nm)
except Exception:
# very small fallback
s = str(pat)
if not case:
nm = nm.upper()
s = s.upper()
return nm == s
def _get_attr_any(obj: Any, *names: str):
for nm in names:
v = getattr(obj, nm, None)
if v is not None:
return v
return None
def _any_finite_z(ed: Any) -> bool:
z = getattr(ed, "Z", None)
if z is None:
return False
arr = _get_attr_any(z, "_z", "z")
if arr is None:
r = _get_attr_any(z, "_resistivity", "resistivity", "rho")
if r is None:
return False
a = np.asarray(r, float)
return np.isfinite(a).any()
a = np.asarray(arr, complex)
return np.isfinite(a.real).any() or np.isfinite(a.imag).any()
def _max_phase_err(ed: Any) -> float:
z = getattr(ed, "Z", None)
if z is None:
return float("nan")
pe = _get_attr_any(z, "_phase_err", "phase_err")
if pe is None:
return float("nan")
a = np.asarray(pe, float)
if a.size == 0:
return float("nan")
with np.errstate(all="ignore"):
return float(np.nanmax(a))
def _is_empty_site(ed: Any) -> bool:
f = get_freq(ed)
if f.size == 0:
return True
z = getattr(ed, "Z", None)
if z is None:
return True
# If Z present, consider "empty" when there is no usable
# impedance nor valid resistivity (sentinel-aware).
arr = _get_attr_any(z, "_z", "z")
if arr is not None:
a = np.asarray(arr)
with np.errstate(all="ignore"):
if a.size == 0:
return True
return not np.isfinite(a).any()
r = (
getattr(z, "_resistivity", None)
or getattr(z, "rho", None)
or getattr(z, "res", None)
or getattr(z, "resistivity", None)
)
if r is None:
return True
a = np.asarray(r, float)
if a.size == 0:
return True
with np.errstate(all="ignore"):
fin = np.isfinite(a)
if not fin.any():
return True
# only huge sentinels -> empty
return (np.abs(a[fin]) >= 1.0e30).all()