Source code for pycsamt.metadata.frequency

# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
"""
pycsamt.metadata.frequency
===========================

Named frequency band registry for electromagnetic geophysics.

Every EM method is characterised by its operational frequency range.
:class:`FrequencyBand` captures that range, together with period bounds
and a rule-of-thumb depth-of-investigation (DOI) estimate based on the
skin-depth formula:

    δ (m) ≈ 503 √( ρ / f )     (ρ in Ω·m, f in Hz)

The pre-defined registry :data:`MT_BANDS` covers the eight principal
EM methods used in applied geophysics.

Quick start
-----------
::

    from pycsamt.metadata.frequency import MT_BANDS, band_for_frequency, REGISTRY

    b = MT_BANDS["AMT"]
    print(b.f_min, b.f_max)         # 10.0  100000.0
    print(b.period_min, b.period_max)
    print(b.doi_range_m(rho=100.0)) # DOI estimate at 100 Ω·m

    # generate log-spaced frequencies inside the band
    freqs = b.logspace(n=30)

    # which bands contain 1 Hz?
    matches = band_for_frequency(1.0)

    # forward/em1d default
    lo, hi = frequency_range("AMT")
"""

from __future__ import annotations

import math
from dataclasses import dataclass
from typing import Any

import numpy as np

__all__ = [
    "FrequencyBand",
    "MT_BANDS",
    "REGISTRY",
    "band_for_frequency",
    "frequency_range",
    "register_band",
]

# Skin-depth reference resistivity used for DOI estimates (Ω·m)
_DOI_REF_RHO = 100.0

# mu_0 / (2 pi) prefactor for skin depth: δ = sqrt(2 ρ / (μ0 ω))
# → δ ≈ 503.3 √(ρ / f) metres
_SKIN_DEPTH_COEFF = 503.3


# ---------------------------------------------------------------------------
# FrequencyBand
# ---------------------------------------------------------------------------


[docs] @dataclass class FrequencyBand: """Specification of an EM geophysical frequency band. Parameters ---------- name : str Short identifier, e.g. ``"AMT"``. label : str Human-readable name, e.g. ``"Audio-frequency MT"``. f_min : float Minimum operational frequency in Hz. f_max : float Maximum operational frequency in Hz. method : str EM acquisition method: ``"MT"``, ``"AMT"``, ``"CSAMT"``, ``"TEM"``, ``"CSEM"``, etc. doi_ref_rho : float, default 100.0 Reference resistivity (Ω·m) used to compute DOI estimates via the skin-depth formula. notes : str Optional annotation. Computed attributes ------------------- period_min, period_max : float Period bounds derived from ``f_max`` and ``f_min``. n_decades : float Number of frequency decades spanned by the band. Examples -------- :: b = MT_BANDS["AMT"] print(b.f_min, b.f_max) # 10.0 100000.0 print(b.period_range) # (1e-5, 0.1) s print(b.doi_range_m()) # DOI at 100 Ω·m print(b.doi_range_m(rho=10.0)) # DOI at 10 Ω·m # log-spaced frequencies freqs = b.logspace(30) # test membership assert 1000.0 in b """ name: str label: str f_min: float f_max: float method: str doi_ref_rho: float = _DOI_REF_RHO notes: str = "" def __post_init__(self) -> None: if self.f_min <= 0 or self.f_max <= 0: raise ValueError("f_min and f_max must be > 0") if self.f_min > self.f_max: self.f_min, self.f_max = self.f_max, self.f_min # ------------------------------------------------------------------ # Computed properties # ------------------------------------------------------------------
[docs] @property def period_min(self) -> float: """Minimum period in seconds (= 1 / f_max).""" return 1.0 / self.f_max
[docs] @property def period_max(self) -> float: """Maximum period in seconds (= 1 / f_min).""" return 1.0 / self.f_min
[docs] @property def period_range(self) -> tuple[float, float]: """``(period_min, period_max)`` in seconds.""" return (self.period_min, self.period_max)
[docs] @property def frequency_range(self) -> tuple[float, float]: """``(f_min, f_max)`` in Hz.""" return (self.f_min, self.f_max)
[docs] @property def n_decades(self) -> float: """Number of frequency decades in the band.""" return math.log10(self.f_max / self.f_min)
# ------------------------------------------------------------------ # DOI estimation via skin depth # ------------------------------------------------------------------
[docs] def skin_depth_m(self, f: float, rho: float | None = None) -> float: """Skin depth δ (m) at frequency *f* and resistivity *rho*. Formula: δ ≈ 503.3 √(ρ / f) Parameters ---------- f : float Frequency in Hz. rho : float, optional Resistivity in Ω·m; defaults to :attr:`doi_ref_rho`. """ r = rho if rho is not None else self.doi_ref_rho return _SKIN_DEPTH_COEFF * math.sqrt(max(r, 1e-9) / max(f, 1e-12))
[docs] def doi_range_m( self, rho: float | None = None, ) -> tuple[float, float]: """Return the *(shallow, deep)* depth-of-investigation estimate in metres. Shallow DOI corresponds to ``f_max``; deep DOI to ``f_min``. Parameters ---------- rho : float, optional Resistivity in Ω·m; defaults to :attr:`doi_ref_rho`. """ return ( round(self.skin_depth_m(self.f_max, rho), 1), round(self.skin_depth_m(self.f_min, rho), 1), )
# ------------------------------------------------------------------ # Utilities # ------------------------------------------------------------------
[docs] def logspace(self, n: int = 30) -> np.ndarray: """Return *n* log-spaced frequencies inside the band (Hz). Parameters ---------- n : int, default 30 Number of frequencies. """ return np.logspace( math.log10(self.f_min), math.log10(self.f_max), num=n, )
[docs] def contains(self, f: float) -> bool: """Return True when *f* (Hz) lies within the band.""" return self.f_min <= f <= self.f_max
[docs] def overlaps(self, other: FrequencyBand) -> bool: """Return True when *self* and *other* share any frequency.""" return self.f_min <= other.f_max and other.f_min <= self.f_max
[docs] def intersection( self, other: FrequencyBand ) -> tuple[float, float] | None: """Return the (f_min, f_max) intersection with *other*, or None.""" lo = max(self.f_min, other.f_min) hi = min(self.f_max, other.f_max) return (lo, hi) if lo <= hi else None
[docs] def clip_frequencies(self, freqs: Any) -> np.ndarray: """Return only those elements of *freqs* that lie inside the band.""" f = np.asarray(freqs, dtype=float) return f[(f >= self.f_min) & (f <= self.f_max)]
def __contains__(self, f: Any) -> bool: try: return self.contains(float(f)) except (TypeError, ValueError): return False
[docs] def to_dict(self) -> dict[str, Any]: return { "name": self.name, "label": self.label, "f_min": self.f_min, "f_max": self.f_max, "period_min": self.period_min, "period_max": self.period_max, "method": self.method, "doi_ref_rho": self.doi_ref_rho, "doi_shallow_m": self.doi_range_m()[0], "doi_deep_m": self.doi_range_m()[1], "n_decades": round(self.n_decades, 2), "notes": self.notes, }
def __repr__(self) -> str: doi = self.doi_range_m() return ( f"FrequencyBand({self.name!r} " f"{self.f_min:.2g}{self.f_max:.2g} Hz " f"DOI≈{doi[0]:.0f}{doi[1]:.0f} m " f"[{self.method}])" )
# --------------------------------------------------------------------------- # Pre-defined band registry # --------------------------------------------------------------------------- #: Dictionary of all pre-defined frequency bands. MT_BANDS: dict[str, FrequencyBand] = { # ── Passive MT / broadband ────────────────────────────────────────── "LMT": FrequencyBand( name="LMT", label="Long-period MT", f_min=1e-5, f_max=1e-1, method="MT", notes="Geomagnetic depth sounding; crustal and mantle targets", ), "MT": FrequencyBand( name="MT", label="Magnetotelluric (standard)", f_min=1e-4, f_max=1e3, method="MT", notes="Broadband passive-source MT; crustal to sedimentary targets", ), "BBMT": FrequencyBand( name="BBMT", label="Broadband MT (wide-range)", f_min=1e-4, f_max=1e4, method="MT", notes="Combined MT+AMT coverage; typical modern broadband system", ), "AMT": FrequencyBand( name="AMT", label="Audio-frequency MT", f_min=1e1, f_max=1e5, method="AMT", notes="High-frequency MT; shallow targets (tens of metres)", ), "LAMT": FrequencyBand( name="LAMT", label="Long-period AMT", f_min=1.0, f_max=1e4, method="AMT", notes="Intermediate AMT; 1–10 000 Hz", ), # ── Controlled-source ────────────────────────────────────────────── "CSAMT": FrequencyBand( name="CSAMT", label="Controlled-source AMT", f_min=1e0, f_max=1e4, method="CSAMT", notes=( "Active source; far-field approximation valid above " "a few source–receiver offsets" ), ), "CSEM": FrequencyBand( name="CSEM", label="Marine CSEM", f_min=1e-2, f_max=1e0, method="CSEM", notes="Low-frequency towed-source marine survey; HC reservoir targets", ), # ── Time-domain ──────────────────────────────────────────────────── "TEM": FrequencyBand( name="TEM", label="Time-domain EM (TEM / TDEM)", f_min=1e-4, f_max=1e2, method="TEM", notes=( "Equivalent frequency range for a central-loop TEM system; " "ground/airborne; conductive target detection" ), ), } #: Global mutable registry — start with MT_BANDS; users can add custom bands. REGISTRY: dict[str, FrequencyBand] = dict(MT_BANDS) # --------------------------------------------------------------------------- # Public helpers # ---------------------------------------------------------------------------
[docs] def register_band(band: FrequencyBand) -> None: """Add or replace a :class:`FrequencyBand` in the global registry. Parameters ---------- band : FrequencyBand The band to register under ``band.name``. """ REGISTRY[band.name] = band
[docs] def band_for_frequency( f: float, registry: dict[str, FrequencyBand] | None = None, ) -> list[FrequencyBand]: """Return all bands in the registry that contain *f* Hz. Parameters ---------- f : float Query frequency in Hz. registry : dict, optional Registry to search; defaults to :data:`REGISTRY`. Returns ------- list of FrequencyBand Sorted from narrowest to widest (fewest to most decades). """ reg = registry if registry is not None else REGISTRY matches = [b for b in reg.values() if b.contains(f)] return sorted(matches, key=lambda b: b.n_decades)
[docs] def frequency_range( method: str, registry: dict[str, FrequencyBand] | None = None, ) -> tuple[float, float]: """Return ``(f_min, f_max)`` for a named method or band. Parameters ---------- method : str Band name (e.g. ``"AMT"``) **or** method string (e.g. ``"CSAMT"``). Case-insensitive. When multiple bands share the same method, the union range is returned. registry : dict, optional Registry to search; defaults to :data:`REGISTRY`. Returns ------- tuple[float, float] ``(f_min, f_max)`` in Hz. Raises ------ KeyError When *method* is not found in any band. """ reg = registry if registry is not None else REGISTRY key = method.upper() # Direct name lookup first if key in reg: b = reg[key] return (b.f_min, b.f_max) # Method-level union matched = [b for b in reg.values() if b.method.upper() == key] if not matched: raise KeyError( f"No band found for method/name {method!r}. " f"Available: {sorted(reg.keys())}" ) return ( min(b.f_min for b in matched), max(b.f_max for b in matched), )
[docs] def doi_estimate( f: float, rho: float = _DOI_REF_RHO, ) -> float: """Return the skin-depth DOI estimate in metres at frequency *f*. Parameters ---------- f : float Frequency in Hz. rho : float, default 100.0 Reference resistivity in Ω·m. """ return _SKIN_DEPTH_COEFF * math.sqrt(max(rho, 1e-9) / max(f, 1e-12))